From 10c276f9830d5714e9b71ca48a9189f7e709874c Mon Sep 17 00:00:00 2001 From: TheyCallMeTank <448631961@qq.com> Date: Tue, 7 Jul 2026 16:40:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BD=95=E5=88=B6=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=9B=9E=E6=94=BE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ScreenPresenter.xcodeproj/project.pbxproj | 163 ++++++++ ScreenPresenter/AppDelegate.swift | 150 ++++--- .../Core/Recording/RecordingLibrary.swift | 256 ++++++++++++ .../Core/Utilities/Localization.swift | 31 +- .../Resources/en.lproj/Localizable.strings | 15 + .../zh-Hans.lproj/Localizable.strings | 15 + .../RecordingHistoryPopoverView.swift | 373 ++++++++++++++++++ .../RecordingReplayView+DevicePane.swift | 103 +++++ .../RecordingReplayView+Layout.swift | 160 ++++++++ .../RecordingReplayView+Playback.swift | 168 ++++++++ .../Components/RecordingReplayView.swift | 305 ++++++++++++++ .../Views/MainViewController.swift | 26 ++ .../RecordingFileNamingTests.swift | 2 +- .../RecordingLibraryTests.swift | 166 ++++++++ .../ScrcpyVideoStreamParserTests.swift | 32 +- 15 files changed, 1901 insertions(+), 64 deletions(-) create mode 100644 ScreenPresenter/Core/Recording/RecordingLibrary.swift create mode 100644 ScreenPresenter/Views/Components/RecordingHistoryPopoverView.swift create mode 100644 ScreenPresenter/Views/Components/RecordingReplayView+DevicePane.swift create mode 100644 ScreenPresenter/Views/Components/RecordingReplayView+Layout.swift create mode 100644 ScreenPresenter/Views/Components/RecordingReplayView+Playback.swift create mode 100644 ScreenPresenter/Views/Components/RecordingReplayView.swift create mode 100644 ScreenPresenterTests/RecordingLibraryTests.swift diff --git a/ScreenPresenter.xcodeproj/project.pbxproj b/ScreenPresenter.xcodeproj/project.pbxproj index 4d77e0d..50991cc 100644 --- a/ScreenPresenter.xcodeproj/project.pbxproj +++ b/ScreenPresenter.xcodeproj/project.pbxproj @@ -64,6 +64,11 @@ D1000001000000000004 /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000002000000000004 /* ToastView.swift */; }; D1000001000000000005 /* DeviceStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000002000000000005 /* DeviceStatusView.swift */; }; D1000001000000000006 /* DeviceCaptureInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000002000000000006 /* DeviceCaptureInfoView.swift */; }; + D1000001000000000007 /* RecordingReplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000002000000000007 /* RecordingReplayView.swift */; }; + D1000001000000000008 /* RecordingReplayView+Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000002000000000008 /* RecordingReplayView+Layout.swift */; }; + D1000001000000000009 /* RecordingReplayView+Playback.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000002000000000009 /* RecordingReplayView+Playback.swift */; }; + D100000100000000000A /* RecordingReplayView+DevicePane.swift in Sources */ = {isa = PBXBuildFile; fileRef = D100000200000000000A /* RecordingReplayView+DevicePane.swift */; }; + D100000100000000000B /* RecordingHistoryPopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D100000200000000000B /* RecordingHistoryPopoverView.swift */; }; DD604589504C2702EA91DEAD /* IOSDeviceSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C66B6DF8C1E31F829EDE370 /* IOSDeviceSource.swift */; }; E1000001000000000002 /* FBDeviceControlKit in Frameworks */ = {isa = PBXBuildFile; productRef = E1000002000000000002 /* FBDeviceControlKit */; }; E1000001000000000003 /* Opus in Frameworks */ = {isa = PBXBuildFile; productRef = E1000002000000000003 /* Opus */; }; @@ -80,8 +85,22 @@ R0000001000000000001 /* RecordingFileNaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = R0000002000000000001 /* RecordingFileNaming.swift */; }; R0000001000000000002 /* RecordingFrameSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = R0000002000000000002 /* RecordingFrameSnapshot.swift */; }; R0000001000000000003 /* RecordingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = R0000002000000000003 /* RecordingService.swift */; }; + R0000001000000000004 /* RecordingLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = R0000002000000000004 /* RecordingLibrary.swift */; }; + TST000010000000000001 /* RecordingFileNamingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TST000020000000000001 /* RecordingFileNamingTests.swift */; }; + TST000010000000000002 /* ScrcpyVideoStreamParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TST000020000000000002 /* ScrcpyVideoStreamParserTests.swift */; }; + TST000010000000000003 /* RecordingLibraryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TST000020000000000003 /* RecordingLibraryTests.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + TST000070000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A10000012419FFF8000001A1 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A10000012419FFFF000001A1; + remoteInfo = ScreenPresenter; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ PREVIEW000005000000000001 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; @@ -153,6 +172,11 @@ D1000002000000000004 /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; D1000002000000000005 /* DeviceStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusView.swift; sourceTree = ""; }; D1000002000000000006 /* DeviceCaptureInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceCaptureInfoView.swift; sourceTree = ""; }; + D1000002000000000007 /* RecordingReplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingReplayView.swift; sourceTree = ""; }; + D1000002000000000008 /* RecordingReplayView+Layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecordingReplayView+Layout.swift"; sourceTree = ""; }; + D1000002000000000009 /* RecordingReplayView+Playback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecordingReplayView+Playback.swift"; sourceTree = ""; }; + D100000200000000000A /* RecordingReplayView+DevicePane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecordingReplayView+DevicePane.swift"; sourceTree = ""; }; + D100000200000000000B /* RecordingHistoryPopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingHistoryPopoverView.swift; sourceTree = ""; }; DC7BD7E5F57E69F54F103E7C /* IOSDevice.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IOSDevice.swift; sourceTree = ""; }; F1000002000000000001 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; FB000002000000000001 /* IOSDeviceStateMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSDeviceStateMapper.swift; sourceTree = ""; }; @@ -166,6 +190,11 @@ R0000002000000000001 /* RecordingFileNaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingFileNaming.swift; sourceTree = ""; }; R0000002000000000002 /* RecordingFrameSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingFrameSnapshot.swift; sourceTree = ""; }; R0000002000000000003 /* RecordingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingService.swift; sourceTree = ""; }; + R0000002000000000004 /* RecordingLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingLibrary.swift; sourceTree = ""; }; + TST000020000000000000 /* ScreenPresenterTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ScreenPresenterTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + TST000020000000000001 /* RecordingFileNamingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingFileNamingTests.swift; sourceTree = ""; }; + TST000020000000000002 /* ScrcpyVideoStreamParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrcpyVideoStreamParserTests.swift; sourceTree = ""; }; + TST000020000000000003 /* RecordingLibraryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingLibraryTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -183,6 +212,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + TST000040000000000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -213,6 +249,7 @@ isa = PBXGroup; children = ( A1000001241A0001000001A2 /* ScreenPresenter */, + TST000030000000000001 /* ScreenPresenterTests */, A1000001241A0001000001A3 /* Products */, ); sourceTree = ""; @@ -235,10 +272,21 @@ isa = PBXGroup; children = ( A1000001241A0000000001A1 /* ScreenPresenter.app */, + TST000020000000000000 /* ScreenPresenterTests.xctest */, ); name = Products; sourceTree = ""; }; + TST000030000000000001 /* ScreenPresenterTests */ = { + isa = PBXGroup; + children = ( + TST000020000000000001 /* RecordingFileNamingTests.swift */, + TST000020000000000003 /* RecordingLibraryTests.swift */, + TST000020000000000002 /* ScrcpyVideoStreamParserTests.swift */, + ); + path = ScreenPresenterTests; + sourceTree = ""; + }; A44133052EF93201003DCDD3 /* DeviceDiscovery */ = { isa = PBXGroup; children = ( @@ -301,6 +349,7 @@ children = ( R0000002000000000001 /* RecordingFileNaming.swift */, R0000002000000000002 /* RecordingFrameSnapshot.swift */, + R0000002000000000004 /* RecordingLibrary.swift */, R0000002000000000003 /* RecordingService.swift */, ); path = Recording; @@ -376,6 +425,11 @@ D1000002000000000001 /* DevicePanelView.swift */, D1000002000000000005 /* DeviceStatusView.swift */, D1000002000000000004 /* ToastView.swift */, + D1000002000000000007 /* RecordingReplayView.swift */, + D1000002000000000008 /* RecordingReplayView+Layout.swift */, + D1000002000000000009 /* RecordingReplayView+Playback.swift */, + D100000200000000000A /* RecordingReplayView+DevicePane.swift */, + D100000200000000000B /* RecordingHistoryPopoverView.swift */, 3E7E9FE2FF970D867ABAC3C4 /* ColorCompensationPanel.swift */, ); path = Components; @@ -435,6 +489,24 @@ productReference = A1000001241A0000000001A1 /* ScreenPresenter.app */; productType = "com.apple.product-type.application"; }; + TST000060000000000001 /* ScreenPresenterTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = TST000090000000000001 /* Build configuration list for PBXNativeTarget "ScreenPresenterTests" */; + buildPhases = ( + TST000040000000000001 /* Sources */, + TST000040000000000002 /* Frameworks */, + TST000040000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + TST000080000000000001 /* PBXTargetDependency */, + ); + name = ScreenPresenterTests; + productName = ScreenPresenterTests; + productReference = TST000020000000000000 /* ScreenPresenterTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -448,6 +520,10 @@ A10000012419FFFF000001A1 = { CreatedOnToolsVersion = 15.0; }; + TST000060000000000001 = { + CreatedOnToolsVersion = 16.4; + TestTargetID = A10000012419FFFF000001A1; + }; }; }; buildConfigurationList = A10000012419FFFB000001A1 /* Build configuration list for PBXProject "ScreenPresenter" */; @@ -473,6 +549,7 @@ projectRoot = ""; targets = ( A10000012419FFFF000001A1 /* ScreenPresenter */, + TST000060000000000001 /* ScreenPresenterTests */, ); }; /* End PBXProject section */ @@ -489,6 +566,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + TST000040000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -535,6 +619,11 @@ D1000001000000000004 /* ToastView.swift in Sources */, D1000001000000000005 /* DeviceStatusView.swift in Sources */, D1000001000000000006 /* DeviceCaptureInfoView.swift in Sources */, + D1000001000000000007 /* RecordingReplayView.swift in Sources */, + D1000001000000000008 /* RecordingReplayView+Layout.swift in Sources */, + D1000001000000000009 /* RecordingReplayView+Playback.swift in Sources */, + D100000100000000000A /* RecordingReplayView+DevicePane.swift in Sources */, + D100000100000000000B /* RecordingHistoryPopoverView.swift in Sources */, A481F79C2F0B8CF300D9DAB0 /* ScrcpyAudioStreamParser.swift in Sources */, A481F79D2F0B8CF300D9DAB0 /* ScrcpyAudioDecoder.swift in Sources */, A481F79E2F0B8CF400D9DAB0 /* ScrcpyOpusDecoder.swift in Sources */, @@ -557,12 +646,31 @@ 2A000001000000000001 /* AudioPlayer.swift in Sources */, R0000001000000000001 /* RecordingFileNaming.swift in Sources */, R0000001000000000002 /* RecordingFrameSnapshot.swift in Sources */, + R0000001000000000004 /* RecordingLibrary.swift in Sources */, R0000001000000000003 /* RecordingService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + TST000040000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + TST000010000000000001 /* RecordingFileNamingTests.swift in Sources */, + TST000010000000000003 /* RecordingLibraryTests.swift in Sources */, + TST000010000000000002 /* ScrcpyVideoStreamParserTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + TST000080000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A10000012419FFFF000001A1 /* ScreenPresenter */; + targetProxy = TST000070000000000001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ B1000002000000000011 /* Localizable.strings */ = { isa = PBXVariantGroup; @@ -772,6 +880,52 @@ }; name = Release; }; + TST0000A0000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = TNX486YJGV; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = com.haptictide.ScreenPresenterTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ScreenPresenter.app/Contents/MacOS/ScreenPresenter"; + TEST_TARGET_NAME = ScreenPresenter; + WRAPPER_EXTENSION = xctest; + }; + name = Debug; + }; + TST0000A0000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = TNX486YJGV; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = com.haptictide.ScreenPresenterTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ScreenPresenter.app/Contents/MacOS/ScreenPresenter"; + TEST_TARGET_NAME = ScreenPresenter; + WRAPPER_EXTENSION = xctest; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -793,6 +947,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + TST000090000000000001 /* Build configuration list for PBXNativeTarget "ScreenPresenterTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + TST0000A0000000000001 /* Debug */, + TST0000A0000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/ScreenPresenter/AppDelegate.swift b/ScreenPresenter/AppDelegate.swift index 738bfbb..81910ee 100644 --- a/ScreenPresenter/AppDelegate.swift +++ b/ScreenPresenter/AppDelegate.swift @@ -31,8 +31,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { private var layoutModeSegmentedControl: NSSegmentedControl? private var recordingToolbarItem: NSToolbarItem? private var recordingOutputToolbarItem: NSToolbarItem? - private var recordingOutputButton: NSButton? - private var recordingOutputWidthConstraint: NSLayoutConstraint? + private let recordingLibrary = RecordingLibrary() + private var recordingHistoryWindowController: NSWindowController? private var markdownToggleToolbarItem: NSToolbarItem? private var isRefreshing: Bool = false private var cancellables = Set() @@ -179,8 +179,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { layoutModeSegmentedControl = nil recordingToolbarItem = nil recordingOutputToolbarItem = nil - recordingOutputButton = nil - recordingOutputWidthConstraint = nil + recordingHistoryWindowController?.close() + recordingHistoryWindowController = nil markdownToggleToolbarItem = nil // 创建新工具栏 @@ -1231,6 +1231,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider { extension AppDelegate: NSWindowDelegate { func windowShouldClose(_ sender: NSWindow) -> Bool { + if sender == recordingHistoryWindowController?.window { + recordingHistoryWindowController = nil + return true + } + guard sender == mainWindow else { return true } @@ -1528,13 +1533,80 @@ extension AppDelegate { } } - @MainActor - @objc private func openRecordingOutputDirectory(_ sender: Any?) { - guard let directory = AppState.shared.recordingService.lastOutputDirectory else { - NSSound.beep() + @objc private func showRecordingHistory(_ sender: Any?) { + let historyView = RecordingHistoryPopoverView( + frame: NSRect(x: 0, y: 0, width: 560, height: 400) + ) + historyView.configure(sessions: []) + + let contentViewController = NSViewController() + contentViewController.view = historyView + + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: historyView.frame.size), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.title = L10n.recording.historyTitle + window.contentViewController = contentViewController + window.delegate = self + window.isReleasedWhenClosed = false + window.minSize = NSSize(width: 520, height: 320) + + let windowController = NSWindowController(window: window) + + historyView.onRevealDirectory = { [weak self, weak window] session in + self?.dismissRecordingHistoryWindow(window) + NSWorkspace.shared.activateFileViewerSelecting([session.directory]) + } + + historyView.onReplay = { [weak self, weak window] session in + self?.dismissRecordingHistoryWindow(window) + self?.mainViewController?.showRecordingReplay(session: session) + } + + dismissRecordingHistoryWindow(recordingHistoryWindowController?.window) + recordingHistoryWindowController = windowController + + positionRecordingHistoryWindow(window) + windowController.showWindow(nil) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + + do { + let sessions = try recordingLibrary.scanSessions() + historyView.configure(sessions: sessions) + } catch { + AppLogger.capture.error("读取录制历史失败: \(error.localizedDescription)") + historyView.configure(sessions: []) + } + } + + private func positionRecordingHistoryWindow(_ window: NSWindow) { + guard let mainWindow else { + window.center() return } - NSWorkspace.shared.activateFileViewerSelecting([directory]) + + let mainFrame = mainWindow.frame + let windowSize = window.frame.size + let origin = NSPoint( + x: mainFrame.midX - windowSize.width / 2, + y: mainFrame.midY - windowSize.height / 2 + ) + window.setFrameOrigin(origin) + } + + private func dismissRecordingHistoryWindow(_ window: NSWindow?) { + guard let window else { + recordingHistoryWindowController = nil + return + } + + window.close() + recordingHistoryWindowController = nil } // MARK: - 面板交换操作 @@ -1827,28 +1899,15 @@ extension AppDelegate { private func updateRecordingUI() { let service = AppState.shared.recordingService let isRecording = service.state.isRecording - let shouldShowOutput = service.lastOutputDirectory != nil && !isRecording if let item = recordingToolbarItem { updateRecordingToolbarItemImage(item) } - updateRecordingOutputToolbarPresence(shouldShowOutput) + updateRecordingOutputToolbarPresence(true) - if let outputButton = recordingOutputButton { - if let directory = service.lastOutputDirectory, shouldShowOutput { - outputButton.title = L10n.recording.savedLocation( - (directory.path as NSString).abbreviatingWithTildeInPath - ) - outputButton.toolTip = L10n.recording.openOutputDirectory - outputButton.isHidden = false - recordingOutputWidthConstraint?.constant = 320 - } else { - outputButton.title = "" - outputButton.toolTip = nil - outputButton.isHidden = true - recordingOutputWidthConstraint?.constant = 0 - } + if let item = recordingOutputToolbarItem { + updateRecordingHistoryToolbarItemImage(item) } mainViewController?.setRecordingIndicatorVisible(isRecording, elapsedSeconds: service.elapsedSeconds) @@ -1866,11 +1925,9 @@ extension AppDelegate { let recordingIndex = items.firstIndex { $0.itemIdentifier == ToolbarItemIdentifier.recording } ?? 0 toolbar.insertItem(withItemIdentifier: ToolbarItemIdentifier.recordingOutput, at: recordingIndex) - } else if let outputIndex { + } else if let outputIndex, recordingOutputToolbarItem == nil { toolbar.removeItem(at: outputIndex) recordingOutputToolbarItem = nil - recordingOutputButton = nil - recordingOutputWidthConstraint = nil } } @@ -1887,6 +1944,19 @@ extension AppDelegate { ) } + @MainActor + private func updateRecordingHistoryToolbarItemImage(_ item: NSToolbarItem) { + let image = NSImage( + systemSymbolName: "clock.arrow.circlepath", + accessibilityDescription: L10n.recording.openHistory + ) + + item.label = L10n.recording.openHistory + item.paletteLabel = L10n.recording.openHistory + item.toolTip = L10n.recording.openHistory + item.image = image + } + private func updateLayoutModeToolbarState() { guard let segmentedControl = layoutModeSegmentedControl else { return } let currentMode = UserPreferences.shared.layoutMode @@ -1940,6 +2010,7 @@ extension AppDelegate: NSToolbarDelegate { func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [ + ToolbarItemIdentifier.recordingOutput, ToolbarItemIdentifier.recording, ToolbarItemIdentifier.markdownToggle, .space, @@ -1980,27 +2051,10 @@ extension AppDelegate: NSToolbarDelegate { case ToolbarItemIdentifier.recordingOutput: let item = NSToolbarItem(itemIdentifier: itemIdentifier) - item.label = L10n.recording.saved - item.paletteLabel = L10n.recording.saved - - let button = NSButton(title: "", target: self, action: #selector(openRecordingOutputDirectory(_:))) - button.bezelStyle = .rounded - button.isBordered = true - button.font = NSFont.systemFont(ofSize: 12) - button.lineBreakMode = .byTruncatingMiddle - button.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - button.translatesAutoresizingMaskIntoConstraints = false - button.isHidden = true - - let widthConstraint = button.widthAnchor.constraint(equalToConstant: 0) - let heightConstraint = button.heightAnchor.constraint(equalToConstant: 28) - widthConstraint.isActive = true - heightConstraint.isActive = true - - item.view = button + item.target = self + item.action = #selector(showRecordingHistory(_:)) recordingOutputToolbarItem = item - recordingOutputButton = button - recordingOutputWidthConstraint = widthConstraint + updateRecordingHistoryToolbarItemImage(item) return item case ToolbarItemIdentifier.layoutMode: diff --git a/ScreenPresenter/Core/Recording/RecordingLibrary.swift b/ScreenPresenter/Core/Recording/RecordingLibrary.swift new file mode 100644 index 0000000..37e11eb --- /dev/null +++ b/ScreenPresenter/Core/Recording/RecordingLibrary.swift @@ -0,0 +1,256 @@ +// +// RecordingLibrary.swift +// ScreenPresenter +// +// Created by Sun on 2026/07/02. +// +// 录制历史扫描模型 +// 从录制根目录解析会话、设备轨道和截图索引 +// + +import AVFoundation +import Foundation + +// MARK: - 录制平台 + +enum RecordingPlatform: String, Hashable { + case ios = "iOS" + case android = "Android" + + var sortPriority: Int { + switch self { + case .ios: + 0 + case .android: + 1 + } + } + + var displayName: String { + switch self { + case .ios: + "iOS" + case .android: + "Android" + } + } +} + +// MARK: - 录制截图 + +struct RecordingSnapshot: Hashable { + let elapsedSecond: Int + let fileURL: URL +} + +// MARK: - 设备轨道 + +struct RecordingDeviceTrack: Hashable { + let platform: RecordingPlatform + let deviceName: String + let directory: URL + let snapshots: [RecordingSnapshot] + + func snapshot(at elapsedSecond: Int) -> RecordingSnapshot? { + if let snapshot = snapshots.last(where: { $0.elapsedSecond <= elapsedSecond }) { + return snapshot + } + + // 设备可能在录制开始后才接入投屏。回放时间早于第一张截图时, + // 仍显示该设备第一张可用截图,避免回放页出现整块空白。 + return snapshots.first + } +} + +// MARK: - 录制会话 + +struct RecordingSession: Hashable { + let directory: URL + let audioURL: URL + let startedAt: Date + let duration: TimeInterval + let deviceTracks: [RecordingDeviceTrack] +} + +// MARK: - 录制历史扫描 + +struct RecordingLibrary { + typealias AudioDurationProvider = (URL) throws -> TimeInterval + + private let rootDirectory: URL + private let fileManager: FileManager + private let audioDurationProvider: AudioDurationProvider + + private static let sessionDirectoryFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH-mm-ss" + return formatter + }() + + init( + rootDirectory: URL? = nil, + fileManager: FileManager = .default, + audioDurationProvider: @escaping AudioDurationProvider = RecordingLibrary.defaultAudioDuration + ) { + self.fileManager = fileManager + self.rootDirectory = rootDirectory ?? RecordingLibrary.defaultRootDirectory(fileManager: fileManager) + self.audioDurationProvider = audioDurationProvider + } + + func scanSessions() throws -> [RecordingSession] { + guard fileManager.directoryExists(at: rootDirectory) else { + return [] + } + + let sessionDirectories = try fileManager.contentsOfDirectory( + at: rootDirectory, + includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) + + let sessions = sessionDirectories.compactMap { directory -> RecordingSession? in + guard fileManager.directoryExists(at: directory) else { + return nil + } + + let audioURL = directory.appendingPathComponent("audio.m4a", isDirectory: false) + guard fileManager.fileExists(atPath: audioURL.path) else { + return nil + } + + do { + return RecordingSession( + directory: directory, + audioURL: audioURL, + startedAt: startedAt(for: directory), + duration: try audioDurationProvider(audioURL), + deviceTracks: scanDeviceTracks(in: directory) + ) + } catch { + AppLogger.capture.warning("跳过损坏的录制记录: \(directory.path), \(error.localizedDescription)") + return nil + } + } + + return sessions.sorted { lhs, rhs in + lhs.startedAt > rhs.startedAt + } + } + + private func scanDeviceTracks(in sessionDirectory: URL) -> [RecordingDeviceTrack] { + guard let deviceDirectories = try? fileManager.contentsOfDirectory( + at: sessionDirectory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + AppLogger.capture.warning("读取录制设备截图目录失败: \(sessionDirectory.path)") + return [] + } + + let tracks = deviceDirectories.compactMap { directory -> RecordingDeviceTrack? in + guard fileManager.directoryExists(at: directory), + let device = parseDeviceDirectoryName(directory.lastPathComponent) else { + return nil + } + + return RecordingDeviceTrack( + platform: device.platform, + deviceName: device.deviceName, + directory: directory, + snapshots: scanSnapshots(in: directory) + ) + } + + return tracks.sorted { lhs, rhs in + if lhs.platform.sortPriority != rhs.platform.sortPriority { + return lhs.platform.sortPriority < rhs.platform.sortPriority + } + + let order = lhs.directory.lastPathComponent + .localizedStandardCompare(rhs.directory.lastPathComponent) + return order == .orderedAscending + } + } + + private func scanSnapshots(in deviceDirectory: URL) -> [RecordingSnapshot] { + guard let files = try? fileManager.contentsOfDirectory( + at: deviceDirectory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + AppLogger.capture.warning("读取录制截图文件失败: \(deviceDirectory.path)") + return [] + } + + let snapshots = files.compactMap { fileURL -> RecordingSnapshot? in + guard fileURL.pathExtension.lowercased() == "jpg", + let elapsedSecond = parseSnapshotElapsedSecond( + fileURL.deletingPathExtension().lastPathComponent + ) else { + return nil + } + + return RecordingSnapshot(elapsedSecond: elapsedSecond, fileURL: fileURL) + } + + return snapshots.sorted { lhs, rhs in + lhs.elapsedSecond < rhs.elapsedSecond + } + } + + private func parseDeviceDirectoryName(_ name: String) -> (platform: RecordingPlatform, deviceName: String)? { + for platform in [RecordingPlatform.ios, .android] { + for prefix in ["\(platform.rawValue)-", "\(platform.rawValue.lowercased())-"] where name.hasPrefix(prefix) { + return (platform, String(name.dropFirst(prefix.count))) + } + } + return nil + } + + private func parseSnapshotElapsedSecond(_ baseName: String) -> Int? { + guard let separatorIndex = baseName.firstIndex(of: "_") else { + return nil + } + + let firstComponent = baseName[.. Date { + if let date = RecordingLibrary.sessionDirectoryFormatter.date(from: directory.lastPathComponent) { + return date + } + + let values = try? directory.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey]) + return values?.creationDate ?? values?.contentModificationDate ?? .distantPast + } + + private static func defaultRootDirectory(fileManager: FileManager) -> URL { + let moviesDirectory = fileManager.urls(for: .moviesDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Movies", isDirectory: true) + + return moviesDirectory.appendingPathComponent("ScreenPresenter Recordings", isDirectory: true) + } + + private static func defaultAudioDuration(audioURL: URL) throws -> TimeInterval { + let audioFile = try AVAudioFile(forReading: audioURL) + let sampleRate = audioFile.fileFormat.sampleRate + guard sampleRate > 0 else { + return 0 + } + + return Double(audioFile.length) / sampleRate + } +} + +private extension FileManager { + func directoryExists(at url: URL) -> Bool { + var isDirectory: ObjCBool = false + return fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue + } +} diff --git a/ScreenPresenter/Core/Utilities/Localization.swift b/ScreenPresenter/Core/Utilities/Localization.swift index 2ae4bd6..d85b47f 100644 --- a/ScreenPresenter/Core/Utilities/Localization.swift +++ b/ScreenPresenter/Core/Utilities/Localization.swift @@ -542,8 +542,37 @@ enum L10n { static var microphonePermissionDenied: String { "recording.microphonePermissionDenied".localized } static var outputDirectoryUnavailable: String { "recording.outputDirectoryUnavailable".localized } static var openOutputDirectory: String { "recording.openOutputDirectory".localized } + static var openHistory: String { "recording.openHistory".localized } + static var back: String { "recording.back".localized } + static var play: String { "recording.play".localized } + static var pause: String { "recording.pause".localized } + static var revealDirectory: String { "recording.revealDirectory".localized } + static var replay: String { "recording.replay".localized } + static var noHistory: String { "recording.noHistory".localized } + static var historyTitle: String { "recording.historyTitle".localized } + static var rewindFiveSeconds: String { + "recording.rewindFiveSeconds".localized + } + static var forwardFiveSeconds: String { + "recording.forwardFiveSeconds".localized + } + static var noSnapshots: String { "recording.noSnapshots".localized } + static var replayLoadFailed: String { + "recording.replayLoadFailed".localized + } static func savedLocation(_ path: String) -> String { "recording.savedLocation".localized(path) } - static func recordingWithElapsed(_ elapsed: String) -> String { "recording.recordingWithElapsed".localized(elapsed) } + static func recordingWithElapsed(_ elapsed: String) -> String { + "recording.recordingWithElapsed".localized(elapsed) + } + static func replayTitle(_ title: String) -> String { + "recording.replayTitle".localized(title) + } + static func replayDeviceCount(_ count: Int) -> String { + "recording.replayDeviceCount".localized(count) + } + static func historyDetail(_ duration: String, _ deviceCount: Int) -> String { + "recording.historyDetail".localized(duration, deviceCount) + } } // MARK: - Toast diff --git a/ScreenPresenter/Resources/en.lproj/Localizable.strings b/ScreenPresenter/Resources/en.lproj/Localizable.strings index a3e433b..1fa787c 100644 --- a/ScreenPresenter/Resources/en.lproj/Localizable.strings +++ b/ScreenPresenter/Resources/en.lproj/Localizable.strings @@ -446,6 +446,21 @@ "recording.microphonePermissionDenied" = "Microphone permission is disabled. Allow ScreenPresenter to access the microphone in System Settings."; "recording.outputDirectoryUnavailable" = "Unable to create recording output directory"; "recording.openOutputDirectory" = "Open recording folder"; +"recording.openHistory" = "View recording history"; +"recording.back" = "Back"; +"recording.play" = "Play"; +"recording.pause" = "Pause"; +"recording.revealDirectory" = "Show Folder"; +"recording.replay" = "Replay"; +"recording.noHistory" = "No recordings yet"; +"recording.historyTitle" = "Recording History"; +"recording.historyDetail" = "%@ · %d devices"; +"recording.rewindFiveSeconds" = "-5s"; +"recording.forwardFiveSeconds" = "+5s"; +"recording.noSnapshots" = "No snapshots"; +"recording.replayLoadFailed" = "Failed to load recording audio"; +"recording.replayTitle" = "Replay %@"; +"recording.replayDeviceCount" = "%d devices"; "recording.savedLocation" = "Saved: %@"; "recording.recordingWithElapsed" = "Recording %@"; diff --git a/ScreenPresenter/Resources/zh-Hans.lproj/Localizable.strings b/ScreenPresenter/Resources/zh-Hans.lproj/Localizable.strings index 8cab718..92d5b1e 100644 --- a/ScreenPresenter/Resources/zh-Hans.lproj/Localizable.strings +++ b/ScreenPresenter/Resources/zh-Hans.lproj/Localizable.strings @@ -446,6 +446,21 @@ "recording.microphonePermissionDenied" = "麦克风权限未开启,请在系统设置中允许 ScreenPresenter 访问麦克风"; "recording.outputDirectoryUnavailable" = "无法创建录制保存目录"; "recording.openOutputDirectory" = "打开录制目录"; +"recording.openHistory" = "查看录制历史"; +"recording.back" = "返回"; +"recording.play" = "播放"; +"recording.pause" = "暂停"; +"recording.revealDirectory" = "查看目录"; +"recording.replay" = "回放"; +"recording.noHistory" = "暂无录制记录"; +"recording.historyTitle" = "录制历史"; +"recording.historyDetail" = "%@ · %d 台设备"; +"recording.rewindFiveSeconds" = "后退5秒"; +"recording.forwardFiveSeconds" = "前进5秒"; +"recording.noSnapshots" = "暂无截图"; +"recording.replayLoadFailed" = "录制音频加载失败"; +"recording.replayTitle" = "回看 %@"; +"recording.replayDeviceCount" = "%d 台设备"; "recording.savedLocation" = "已保存:%@"; "recording.recordingWithElapsed" = "录制中 %@"; diff --git a/ScreenPresenter/Views/Components/RecordingHistoryPopoverView.swift b/ScreenPresenter/Views/Components/RecordingHistoryPopoverView.swift new file mode 100644 index 0000000..6bad9ce --- /dev/null +++ b/ScreenPresenter/Views/Components/RecordingHistoryPopoverView.swift @@ -0,0 +1,373 @@ +// +// RecordingHistoryPopoverView.swift +// ScreenPresenter +// +// Created by Tank on 2026/07/02. +// + +import AppKit + +// MARK: - 录制历史弹框内容视图 + +final class RecordingHistoryPopoverView: NSView { + // MARK: - 回调 + + var onRevealDirectory: ((RecordingSession) -> Void)? + var onReplay: ((RecordingSession) -> Void)? + + // MARK: - UI 组件 + + private let scrollView = NSScrollView() + private let tableView = NSTableView() + private let emptyLabel = NSTextField(labelWithString: L10n.recording.noHistory) + private var sessions: [RecordingSession] = [] + + // MARK: - 格式化 + + private static let startedAtFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .current + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + // MARK: - 初始化 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + override var intrinsicContentSize: NSSize { + NSSize(width: Metrics.preferredWidth, height: Metrics.preferredHeight) + } + + override func layout() { + super.layout() + updateTableColumnWidth() + } + + // MARK: - 公开方法 + + func configure(sessions: [RecordingSession]) { + self.sessions = sessions + + emptyLabel.isHidden = !sessions.isEmpty + scrollView.isHidden = sessions.isEmpty + tableView.reloadData() + updateTableColumnWidth() + } + + // MARK: - UI 设置 + + private func setupUI() { + wantsLayer = true + layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + + setupScrollView() + setupEmptyState() + } + + private func setupScrollView() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.borderType = .noBorder + addSubview(scrollView) + + tableView.headerView = nil + tableView.rowHeight = Metrics.rowHeight + tableView.intercellSpacing = NSSize(width: 0, height: Metrics.rowSpacing) + tableView.backgroundColor = .clear + tableView.selectionHighlightStyle = .none + tableView.usesAlternatingRowBackgroundColors = false + tableView.dataSource = self + tableView.delegate = self + + let column = NSTableColumn(identifier: Metrics.columnIdentifier) + column.resizingMask = .autoresizingMask + tableView.addTableColumn(column) + scrollView.documentView = tableView + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor, constant: Metrics.contentInset), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Metrics.contentInset), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.contentInset), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Metrics.contentInset) + ]) + } + + private func setupEmptyState() { + emptyLabel.translatesAutoresizingMaskIntoConstraints = false + emptyLabel.font = NSFont.systemFont(ofSize: 13) + emptyLabel.textColor = .secondaryLabelColor + emptyLabel.alignment = .center + addSubview(emptyLabel) + + NSLayoutConstraint.activate([ + emptyLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Metrics.contentInset), + emptyLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.contentInset), + emptyLabel.centerYAnchor.constraint(equalTo: centerYAnchor, constant: Metrics.emptyStateYOffset) + ]) + } + + private func makeCell(for session: RecordingSession) -> NSTableCellView { + let cell = NSTableCellView() + cell.wantsLayer = true + cell.layer?.backgroundColor = NSColor.clear.cgColor + + let row = HistoryRowView() + row.configure( + title: session.directory.lastPathComponent, + subtitle: subtitleText(for: session), + session: session + ) + row.revealButton.target = self + row.revealButton.action = #selector(revealDirectoryTapped(_:)) + row.replayButton.target = self + row.replayButton.action = #selector(replayTapped(_:)) + row.replayButton.isEnabled = session.duration > 0 + row.translatesAutoresizingMaskIntoConstraints = false + cell.addSubview(row) + + NSLayoutConstraint.activate([ + row.leadingAnchor.constraint(equalTo: cell.leadingAnchor), + row.trailingAnchor.constraint(equalTo: cell.trailingAnchor), + row.topAnchor.constraint(equalTo: cell.topAnchor), + row.bottomAnchor.constraint(equalTo: cell.bottomAnchor) + ]) + + return cell + } + + private func updateTableColumnWidth() { + guard let column = tableView.tableColumns.first else { return } + column.width = max(scrollView.contentSize.width, Metrics.minimumContentWidth) + } + + private func subtitleText(for session: RecordingSession) -> String { + let startedAtText = session.startedAt > .distantPast + ? Self.startedAtFormatter.string(from: session.startedAt) + : session.directory.lastPathComponent + let detailText = L10n.recording.historyDetail( + RecordingReplayView.formatTime(session.duration), + session.deviceTracks.count + ) + return "\(startedAtText) · \(detailText)" + } + + // MARK: - Actions + + @objc private func revealDirectoryTapped(_ sender: SessionButton) { + guard let session = sender.session else { + return + } + onRevealDirectory?(session) + } + + @objc private func replayTapped(_ sender: SessionButton) { + guard let session = sender.session else { + return + } + onReplay?(session) + } +} + +// MARK: - 内部类型 + +private class SessionButton: NSButton { + var session: RecordingSession? +} + +private final class HistoryRowView: NSView { + let revealButton = SessionIconButton(style: .secondary) + let replayButton = SessionIconButton(style: .primary) + + private let titleLabel = NSTextField(labelWithString: "") + private let subtitleLabel = NSTextField(labelWithString: "") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + override func layout() { + super.layout() + + let textLeft = Metrics.rowHorizontalInset + let midpoint = bounds.width * Metrics.actionAreaStartRatio + let actionButtonSize = Metrics.iconButtonSize + let actionSpacing = Metrics.iconButtonSpacing + let actionsWidth = actionButtonSize.width * 2 + actionSpacing + let actionsX = max(midpoint, bounds.width - actionsWidth - Metrics.actionRightInset) + let buttonY = (bounds.height - actionButtonSize.height) / 2 + + revealButton.frame = NSRect( + x: actionsX, + y: buttonY, + width: actionButtonSize.width, + height: actionButtonSize.height + ) + replayButton.frame = NSRect( + x: revealButton.frame.maxX + actionSpacing, + y: buttonY, + width: actionButtonSize.width, + height: actionButtonSize.height + ) + + let textWidth = max(0, actionsX - textLeft - Metrics.textToActionSpacing) + titleLabel.frame = NSRect( + x: textLeft, + y: bounds.midY + 3, + width: textWidth, + height: 20 + ) + subtitleLabel.frame = NSRect( + x: textLeft, + y: bounds.midY - 20, + width: textWidth, + height: 17 + ) + } + + func configure(title: String, subtitle: String, session: RecordingSession) { + titleLabel.stringValue = title + subtitleLabel.stringValue = subtitle + revealButton.session = session + replayButton.session = session + } + + private func setupUI() { + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + + titleLabel.font = NSFont.systemFont(ofSize: 14, weight: .semibold) + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.maximumNumberOfLines = 1 + addSubview(titleLabel) + + subtitleLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular) + subtitleLabel.textColor = .secondaryLabelColor + subtitleLabel.lineBreakMode = .byTruncatingTail + subtitleLabel.maximumNumberOfLines = 1 + addSubview(subtitleLabel) + + revealButton.image = NSImage( + systemSymbolName: "folder", + accessibilityDescription: L10n.recording.revealDirectory + ) + revealButton.toolTip = L10n.recording.revealDirectory + addSubview(revealButton) + + replayButton.image = NSImage( + systemSymbolName: "play.fill", + accessibilityDescription: L10n.recording.replay + ) + replayButton.toolTip = L10n.recording.replay + addSubview(replayButton) + } +} + +private final class SessionIconButton: SessionButton { + enum Style { + case primary + case secondary + } + + private let style: Style + + init(style: Style) { + self.style = style + super.init(frame: .zero) + setupUI() + } + + required init?(coder: NSCoder) { + self.style = .secondary + super.init(coder: coder) + setupUI() + } + + override var isEnabled: Bool { + didSet { + updateAppearance() + } + } + + override func layout() { + super.layout() + layer?.cornerRadius = bounds.height / 2 + } + + private func setupUI() { + title = "" + isBordered = false + bezelStyle = .regularSquare + imagePosition = .imageOnly + imageScaling = .scaleProportionallyDown + focusRingType = .none + wantsLayer = true + updateAppearance() + } + + private func updateAppearance() { + switch style { + case .primary: + layer?.backgroundColor = (isEnabled ? NSColor.systemBlue : NSColor.systemGray) + .withAlphaComponent(isEnabled ? 0.95 : 0.35) + .cgColor + contentTintColor = .white + case .secondary: + layer?.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.75).cgColor + contentTintColor = isEnabled ? .labelColor : .disabledControlTextColor + } + } +} + +// MARK: - NSTableViewDataSource / NSTableViewDelegate + +extension RecordingHistoryPopoverView: NSTableViewDataSource, NSTableViewDelegate { + func numberOfRows(in tableView: NSTableView) -> Int { + sessions.count + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + Metrics.rowHeight + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard sessions.indices.contains(row) else { + return nil + } + + return makeCell(for: sessions[row]) + } +} + +private enum Metrics { + static let preferredWidth: CGFloat = 520 + static let preferredHeight: CGFloat = 360 + static let minimumContentWidth: CGFloat = 320 + static let columnIdentifier = NSUserInterfaceItemIdentifier("recordingHistory") + static let contentInset: CGFloat = 16 + static let rowSpacing: CGFloat = 8 + static let rowHeight: CGFloat = 72 + static let emptyStateYOffset: CGFloat = 14 + static let rowHorizontalInset: CGFloat = 8 + static let actionRightInset: CGFloat = 44 + static let actionAreaStartRatio: CGFloat = 0.5 + static let textToActionSpacing: CGFloat = 16 + static let iconButtonSize = NSSize(width: 42, height: 32) + static let iconButtonSpacing: CGFloat = 12 +} diff --git a/ScreenPresenter/Views/Components/RecordingReplayView+DevicePane.swift b/ScreenPresenter/Views/Components/RecordingReplayView+DevicePane.swift new file mode 100644 index 0000000..e211ae0 --- /dev/null +++ b/ScreenPresenter/Views/Components/RecordingReplayView+DevicePane.swift @@ -0,0 +1,103 @@ +// +// RecordingReplayView+DevicePane.swift +// ScreenPresenter +// +// Created by Tank on 2026/07/02. +// + +import AppKit + +// MARK: - 设备截图 Pane + +extension RecordingReplayView { + final class DevicePane { + let track: RecordingDeviceTrack + let container: DevicePaneContainerView + let titleLabel: NSTextField + let imageView: NSImageView + var currentSnapshotURL: URL? + + init(track: RecordingDeviceTrack) { + self.track = track + + container = DevicePaneContainerView() + + titleLabel = NSTextField(labelWithString: "\(track.platform.displayName) · \(track.deviceName)") + titleLabel.font = NSFont.systemFont(ofSize: 12, weight: .medium) + titleLabel.textColor = .secondaryLabelColor + titleLabel.alignment = .center + titleLabel.lineBreakMode = .byTruncatingTail + + imageView = NSImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.imageAlignment = .alignCenter + + container.addSubview(titleLabel) + container.addSubview(imageView) + container.onLayout = { [weak titleLabel, weak imageView] bounds in + titleLabel?.frame = NSRect( + x: 10, + y: bounds.height - 30, + width: max(0, bounds.width - 20), + height: 18 + ) + imageView?.frame = NSRect( + x: 10, + y: 10, + width: max(0, bounds.width - 20), + height: max(0, bounds.height - 48) + ) + } + } + + func clearImage() { + currentSnapshotURL = nil + imageView.image = nil + } + } + + func rebuildDevicePanes(for tracks: [RecordingDeviceTrack]) { + deviceStackView.arrangedSubviews.forEach { view in + deviceStackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + devicePanes.removeAll() + + emptyLabel.isHidden = !tracks.isEmpty + deviceStackView.isHidden = tracks.isEmpty + + for track in tracks { + let pane = DevicePane(track: track) + devicePanes.append(pane) + deviceStackView.addArrangedSubview(pane.container) + } + needsLayout = true + } +} + +// MARK: - 设备截图容器 + +final class DevicePaneContainerView: NSView { + var onLayout: ((NSRect) -> Void)? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + override func layout() { + super.layout() + onLayout?(bounds) + } + + private func setupUI() { + wantsLayer = true + layer?.backgroundColor = NSColor.black.cgColor + layer?.cornerRadius = 8 + } +} diff --git a/ScreenPresenter/Views/Components/RecordingReplayView+Layout.swift b/ScreenPresenter/Views/Components/RecordingReplayView+Layout.swift new file mode 100644 index 0000000..36862d1 --- /dev/null +++ b/ScreenPresenter/Views/Components/RecordingReplayView+Layout.swift @@ -0,0 +1,160 @@ +// +// RecordingReplayView+Layout.swift +// ScreenPresenter +// +// Created by Tank on 2026/07/02. +// + +import AppKit + +// MARK: - 布局 + +extension RecordingReplayView { + struct ControlButtonLayout { + let maxX: CGFloat + let originY: CGFloat + let spacing: CGFloat + } + + func layoutReplayView() { + let viewBounds = bounds + let horizontalInset: CGFloat = 20 + let headerHeight: CGFloat = 58 + let controlsHeight: CGFloat = 58 + + headerContainer.frame = NSRect( + x: 0, + y: viewBounds.height - headerHeight, + width: viewBounds.width, + height: headerHeight + ) + controlsContainer.frame = NSRect(x: 0, y: 0, width: viewBounds.width, height: controlsHeight) + contentContainer.frame = NSRect( + x: 0, + y: controlsHeight, + width: viewBounds.width, + height: max(0, viewBounds.height - headerHeight - controlsHeight) + ) + + layoutHeader(inset: horizontalInset) + layoutContent(inset: horizontalInset) + layoutControls(inset: horizontalInset) + } + + func layoutHeader(inset: CGFloat) { + let buttonWidth: CGFloat = 36 + backButton.frame = NSRect( + x: inset, + y: (headerContainer.bounds.height - 30) / 2, + width: buttonWidth, + height: 30 + ) + + let titleOriginX = backButton.frame.maxX + 14 + let titleWidth = max(0, headerContainer.bounds.width - titleOriginX - inset) + titleLabel.frame = NSRect(x: titleOriginX, y: 30, width: titleWidth, height: 20) + deviceCountLabel.frame = NSRect(x: titleOriginX, y: 12, width: titleWidth, height: 16) + } + + func layoutContent(inset: CGFloat) { + let contentBounds = contentContainer.bounds.insetBy(dx: inset, dy: 16) + emptyLabel.frame = NSRect( + x: inset, + y: (contentContainer.bounds.height - 24) / 2, + width: max(0, contentContainer.bounds.width - inset * 2), + height: 24 + ) + deviceStackView.frame = contentBounds + } + + func layoutControls(inset: CGFloat) { + let controlOriginY = (controlsContainer.bounds.height - 28) / 2 + let timeWidth: CGFloat = 104 + let buttonWidth: CGFloat = 42 + let playWidth: CGFloat = 42 + let speedWidth: CGFloat = 58 + let spacing: CGFloat = 8 + + let maxControlX = layoutTimeLabel( + inset: inset, + originY: controlOriginY, + timeWidth: timeWidth, + spacing: spacing + ) + let buttonLayout = ControlButtonLayout( + maxX: maxControlX, + originY: controlOriginY, + spacing: spacing + ) + var nextControlOriginX = inset + nextControlOriginX = layoutButton( + rewindButton, + originX: nextControlOriginX, + width: buttonWidth, + layout: buttonLayout + ) + nextControlOriginX = layoutButton( + playPauseButton, + originX: nextControlOriginX, + width: playWidth, + layout: buttonLayout + ) + nextControlOriginX = layoutButton( + forwardButton, + originX: nextControlOriginX, + width: buttonWidth, + layout: buttonLayout + ) + nextControlOriginX = layoutButton( + speedButton, + originX: nextControlOriginX, + width: speedWidth, + layout: buttonLayout + ) + + let sliderOriginX = nextControlOriginX + let sliderWidth = max(0, maxControlX - sliderOriginX) + progressSlider.isHidden = sliderWidth == 0 + progressSlider.frame = NSRect(x: sliderOriginX, y: controlOriginY + 3, width: sliderWidth, height: 22) + } + + func layoutTimeLabel( + inset: CGFloat, + originY: CGFloat, + timeWidth: CGFloat, + spacing: CGFloat + ) -> CGFloat { + totalTimeLabel.isHidden = true + totalTimeLabel.frame = .zero + + currentTimeLabel.frame = NSRect( + x: controlsContainer.bounds.width - inset - timeWidth, + y: originY + 5, + width: timeWidth, + height: 18 + ) + + let canShowTime = currentTimeLabel.frame.minX >= inset + spacing + currentTimeLabel.isHidden = !canShowTime + + let rightLimit = canShowTime ? currentTimeLabel.frame.minX : controlsContainer.bounds.width - inset + return rightLimit - spacing + } + + func layoutButton( + _ button: NSButton, + originX: CGFloat, + width: CGFloat, + layout: ControlButtonLayout + ) -> CGFloat { + guard originX + width <= layout.maxX else { + button.isHidden = true + button.frame = .zero + return originX + } + + button.isHidden = false + button.frame = NSRect(x: originX, y: layout.originY, width: width, height: 28) + return button.frame.maxX + layout.spacing + } +} diff --git a/ScreenPresenter/Views/Components/RecordingReplayView+Playback.swift b/ScreenPresenter/Views/Components/RecordingReplayView+Playback.swift new file mode 100644 index 0000000..67527b4 --- /dev/null +++ b/ScreenPresenter/Views/Components/RecordingReplayView+Playback.swift @@ -0,0 +1,168 @@ +// +// RecordingReplayView+Playback.swift +// ScreenPresenter +// +// Created by Tank on 2026/07/02. +// + +import AppKit + +// MARK: - 播放控制 + +extension RecordingReplayView { + func seek(to time: TimeInterval) { + let duration = session?.duration ?? audioPlayer?.duration ?? 0 + let clampedTime = min(max(0, time), duration) + audioPlayer?.currentTime = clampedTime + refreshPlaybackState(at: clampedTime) + } + + func refreshPlaybackState(at time: TimeInterval) { + currentTimeLabel.stringValue = "\(Self.formatTime(time)) / \(Self.formatTime(session?.duration ?? 0))" + progressSlider.doubleValue = min(max(progressSlider.minValue, time), progressSlider.maxValue) + refreshSnapshots(at: Int(floor(time))) + } + + func startPlaybackTimer() { + playbackTimer?.invalidate() + playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in + self?.playbackTimerFired() + } + } + + func updatePlayPauseButton(isPlaying: Bool) { + let title = isPlaying ? L10n.recording.pause : L10n.recording.play + let symbolName = isPlaying ? "pause.fill" : "play.fill" + playPauseButton.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) + playPauseButton.toolTip = title + } + + func startAutoPlayback() { + guard let audioPlayer else { return } + + audioPlayer.rate = playbackRate + if audioPlayer.play() { + startPlaybackTimer() + updatePlayPauseButton(isPlaying: true) + } else { + updatePlayPauseButton(isPlaying: false) + } + } + + func updateSpeedButtonTitle() { + speedButton.title = String(format: "%.1fx", playbackRate) + } + + func updatePlaybackControls(isEnabled: Bool) { + rewindButton.isEnabled = isEnabled + playPauseButton.isEnabled = isEnabled + forwardButton.isEnabled = isEnabled + speedButton.isEnabled = isEnabled + progressSlider.isEnabled = isEnabled + } + + func setErrorMessage(_ message: String?) { + if let message { + emptyLabel.stringValue = message + emptyLabel.isHidden = false + } else { + emptyLabel.stringValue = L10n.recording.noSnapshots + emptyLabel.isHidden = !(session?.deviceTracks.isEmpty ?? true) + } + } + + func refreshSnapshots(at elapsedSecond: Int) { + for pane in devicePanes { + guard let snapshot = pane.track.snapshot(at: elapsedSecond) else { + pane.clearImage() + continue + } + + updateSnapshot(for: pane, fileURL: snapshot.fileURL) + } + } + + func updateSnapshot(for pane: DevicePane, fileURL: URL) { + // 同一秒内定时器会多次触发;URL 没变时直接跳过磁盘读取和 imageView 赋值。 + if pane.currentSnapshotURL == fileURL { + return + } + + pane.currentSnapshotURL = fileURL + if let cachedImage = imageCache[fileURL] { + pane.imageView.image = cachedImage + return + } + + guard let image = NSImage(contentsOf: fileURL) else { + pane.imageView.image = nil + return + } + + imageCache[fileURL] = image + pane.imageView.image = image + } + + @objc func backTapped() { + stopPlayback() + onBack?() + } + + @objc func playPauseTapped() { + guard let audioPlayer else { return } + + if audioPlayer.isPlaying { + audioPlayer.pause() + playbackTimer?.invalidate() + playbackTimer = nil + updatePlayPauseButton(isPlaying: false) + } else { + if audioPlayer.currentTime >= audioPlayer.duration { + seek(to: 0) + } + audioPlayer.rate = playbackRate + audioPlayer.play() + startPlaybackTimer() + updatePlayPauseButton(isPlaying: true) + } + } + + @objc func rewindTapped() { + seek(to: (audioPlayer?.currentTime ?? progressSlider.doubleValue) - 5) + } + + @objc func forwardTapped() { + seek(to: (audioPlayer?.currentTime ?? progressSlider.doubleValue) + 5) + } + + @objc func speedTapped() { + switch playbackRate { + case 1.0: + playbackRate = 1.5 + case 1.5: + playbackRate = 2.0 + case 2.0: + playbackRate = 0.5 + default: + playbackRate = 1.0 + } + + audioPlayer?.rate = playbackRate + updateSpeedButtonTitle() + } + + @objc func progressChanged() { + seek(to: progressSlider.doubleValue) + } + + @objc func playbackTimerFired() { + guard let audioPlayer else { return } + + refreshPlaybackState(at: audioPlayer.currentTime) + if !audioPlayer.isPlaying { + playbackTimer?.invalidate() + playbackTimer = nil + updatePlayPauseButton(isPlaying: false) + } + } +} diff --git a/ScreenPresenter/Views/Components/RecordingReplayView.swift b/ScreenPresenter/Views/Components/RecordingReplayView.swift new file mode 100644 index 0000000..71dbb3a --- /dev/null +++ b/ScreenPresenter/Views/Components/RecordingReplayView.swift @@ -0,0 +1,305 @@ +// +// RecordingReplayView.swift +// ScreenPresenter +// +// Created by Tank on 2026/07/02. +// +// 录制回看视图 +// 同步音频播放进度与设备截图轨道 +// + +import AVFoundation +import AppKit + +// MARK: - 录制回看视图 + +final class RecordingReplayView: NSView { + // MARK: - 回调 + + var onBack: (() -> Void)? + + // MARK: - UI 组件 + + let headerContainer = NSView() + let backButton = NSButton() + let titleLabel = NSTextField(labelWithString: "") + let deviceCountLabel = NSTextField(labelWithString: "") + + let contentContainer = NSView() + let emptyLabel = NSTextField(labelWithString: "") + let deviceStackView = NSStackView() + + let controlsContainer = NSView() + let currentTimeLabel = NSTextField(labelWithString: "00:00") + let rewindButton = NSButton() + let playPauseButton = NSButton() + let forwardButton = NSButton() + let speedButton = NSButton() + let progressSlider = NSSlider(value: 0, minValue: 0, maxValue: 1, target: nil, action: nil) + let totalTimeLabel = NSTextField(labelWithString: "00:00") + + // MARK: - 播放状态 + + var session: RecordingSession? + var audioPlayer: AVAudioPlayer? + var playbackTimer: Timer? + private var keyboardMonitor: Any? + var devicePanes: [DevicePane] = [] + var imageCache: [URL: NSImage] = [:] + var playbackRate: Float = 1.0 + + // MARK: - 初始化 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupUI() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupUI() + } + + deinit { + stopPlayback() + stopKeyboardMonitor() + } + + override func layout() { + super.layout() + layoutReplayView() + } + + override func viewWillMove(toSuperview newSuperview: NSView?) { + super.viewWillMove(toSuperview: newSuperview) + + if newSuperview == nil { + stopPlayback() + stopKeyboardMonitor() + } + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + + if window == nil { + stopKeyboardMonitor() + } else { + startKeyboardMonitor() + } + } + + // MARK: - 公开方法 + + func configure(session: RecordingSession) { + stopPlayback() + + self.session = session + imageCache.removeAll() + playbackRate = 1.0 + + titleLabel.stringValue = L10n.recording.replayTitle(session.directory.lastPathComponent) + deviceCountLabel.stringValue = L10n.recording.replayDeviceCount(session.deviceTracks.count) + currentTimeLabel.stringValue = "\(Self.formatTime(0)) / \(Self.formatTime(session.duration))" + totalTimeLabel.stringValue = "" + progressSlider.minValue = 0 + progressSlider.maxValue = max(session.duration, 1) + progressSlider.doubleValue = 0 + updateSpeedButtonTitle() + updatePlaybackControls(isEnabled: false) + rebuildDevicePanes(for: session.deviceTracks) + + do { + let player = try AVAudioPlayer(contentsOf: session.audioURL) + player.enableRate = true + player.rate = playbackRate + player.prepareToPlay() + audioPlayer = player + setErrorMessage(nil) + updatePlaybackControls(isEnabled: true) + refreshPlaybackState(at: 0) + startAutoPlayback() + } catch { + audioPlayer = nil + setErrorMessage(L10n.recording.replayLoadFailed) + } + } + + func stopPlayback() { + playbackTimer?.invalidate() + playbackTimer = nil + + audioPlayer?.stop() + audioPlayer = nil + + updatePlayPauseButton(isPlaying: false) + updatePlaybackControls(isEnabled: false) + } + + private func startKeyboardMonitor() { + guard keyboardMonitor == nil else { return } + keyboardMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self else { return event } + guard self.window != nil, event.window === self.window else { return event } + + return self.handleReplayKeyDown(event) + } + } + + private func stopKeyboardMonitor() { + guard let keyboardMonitor else { return } + NSEvent.removeMonitor(keyboardMonitor) + self.keyboardMonitor = nil + } + + private func handleReplayKeyDown(_ event: NSEvent) -> NSEvent? { + let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let ignoredModifiers: NSEvent.ModifierFlags = [.capsLock, .numericPad, .function] + guard modifiers.subtracting(ignoredModifiers).isEmpty else { + return event + } + + switch event.keyCode { + case 49: + playPauseTapped() + return nil + case 123: + rewindTapped() + return nil + case 124: + forwardTapped() + return nil + default: + if event.charactersIgnoringModifiers == " " { + playPauseTapped() + return nil + } + return event + } + } + + static func formatTime(_ time: TimeInterval) -> String { + let totalSeconds = max(0, Int(floor(time))) + return String(format: "%02d:%02d", totalSeconds / 60, totalSeconds % 60) + } + + // MARK: - UI 设置 + + func setupUI() { + wantsLayer = true + layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + + setupHeader() + setupContent() + setupControls() + } + + func setupHeader() { + addSubview(headerContainer) + + backButton.title = "" + backButton.image = NSImage( + systemSymbolName: "chevron.left", + accessibilityDescription: L10n.recording.back + ) + backButton.imagePosition = .imageOnly + backButton.imageScaling = .scaleProportionallyDown + backButton.bezelStyle = .rounded + backButton.toolTip = L10n.recording.back + backButton.target = self + backButton.action = #selector(backTapped) + headerContainer.addSubview(backButton) + + titleLabel.font = NSFont.systemFont(ofSize: 17, weight: .semibold) + titleLabel.lineBreakMode = .byTruncatingTail + headerContainer.addSubview(titleLabel) + + deviceCountLabel.font = NSFont.systemFont(ofSize: 12) + deviceCountLabel.textColor = .secondaryLabelColor + headerContainer.addSubview(deviceCountLabel) + } + + func setupContent() { + contentContainer.wantsLayer = true + contentContainer.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.9).cgColor + addSubview(contentContainer) + + emptyLabel.stringValue = L10n.recording.noSnapshots + emptyLabel.font = NSFont.systemFont(ofSize: 15) + emptyLabel.textColor = .secondaryLabelColor + emptyLabel.alignment = .center + emptyLabel.isHidden = true + contentContainer.addSubview(emptyLabel) + + deviceStackView.orientation = .horizontal + deviceStackView.alignment = .top + deviceStackView.distribution = .fillEqually + deviceStackView.spacing = 12 + contentContainer.addSubview(deviceStackView) + } + + func setupControls() { + addSubview(controlsContainer) + + currentTimeLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular) + currentTimeLabel.alignment = .left + controlsContainer.addSubview(currentTimeLabel) + + configureIconButton( + rewindButton, + systemSymbolName: "gobackward.5", + tooltip: L10n.recording.rewindFiveSeconds, + action: #selector(rewindTapped) + ) + configureIconButton( + playPauseButton, + systemSymbolName: "play.fill", + tooltip: L10n.recording.play, + action: #selector(playPauseTapped) + ) + configureIconButton( + forwardButton, + systemSymbolName: "goforward.5", + tooltip: L10n.recording.forwardFiveSeconds, + action: #selector(forwardTapped) + ) + configureButton(speedButton, title: "1.0x", action: #selector(speedTapped)) + + progressSlider.target = self + progressSlider.action = #selector(progressChanged) + controlsContainer.addSubview(progressSlider) + + totalTimeLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular) + totalTimeLabel.textColor = .secondaryLabelColor + controlsContainer.addSubview(totalTimeLabel) + + updatePlayPauseButton(isPlaying: false) + } + + func configureButton(_ button: NSButton, title: String, action: Selector) { + button.title = title + button.bezelStyle = .rounded + button.target = self + button.action = action + button.focusRingType = .none + controlsContainer.addSubview(button) + } + + func configureIconButton( + _ button: NSButton, + systemSymbolName: String, + tooltip: String, + action: Selector + ) { + button.title = "" + button.image = NSImage(systemSymbolName: systemSymbolName, accessibilityDescription: tooltip) + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + button.bezelStyle = .rounded + button.toolTip = tooltip + button.target = self + button.action = action + button.focusRingType = .none + controlsContainer.addSubview(button) + } +} diff --git a/ScreenPresenter/Views/MainViewController.swift b/ScreenPresenter/Views/MainViewController.swift index 0c47b5b..ac107f6 100644 --- a/ScreenPresenter/Views/MainViewController.swift +++ b/ScreenPresenter/Views/MainViewController.swift @@ -19,6 +19,7 @@ final class MainViewController: NSViewController { // MARK: - UI 组件 private let previewContainerView = PreviewContainerView() + private var recordingReplayView: RecordingReplayView? // MARK: - 公开属性 @@ -101,6 +102,9 @@ final class MainViewController: NSViewController { override func viewWillDisappear() { super.viewWillDisappear() + if recordingReplayView?.isHidden == false { + hideRecordingReplay() + } stopRendering() } @@ -246,6 +250,7 @@ final class MainViewController: NSViewController { width: bounds.width, height: max(0, bounds.height - topOffset) ) + recordingReplayView?.frame = previewContainerView.frame } override func mouseEntered(with event: NSEvent) { @@ -761,6 +766,27 @@ final class MainViewController: NSViewController { previewContainerView.setRecordingIndicatorVisible(visible, elapsedSeconds: elapsedSeconds) } + /// 显示录制回看页。 + func showRecordingReplay(session: RecordingSession) { + let replayView = recordingReplayView ?? RecordingReplayView(frame: previewContainerView.frame) + replayView.onBack = { [weak self] in self?.hideRecordingReplay() } + replayView.frame = previewContainerView.frame + replayView.isHidden = false + previewContainerView.isHidden = true + if replayView.superview == nil { + view.addSubview(replayView, positioned: .above, relativeTo: previewContainerView) + } + recordingReplayView = replayView + replayView.configure(session: session) + } + + /// 隐藏录制回看页并恢复主页预览区。 + func hideRecordingReplay() { + recordingReplayView?.stopPlayback() + recordingReplayView?.isHidden = true + previewContainerView.isHidden = false + } + /// 新建 Markdown 文件(清空当前内容) func newMarkdownFile() { previewContainerView.newMarkdownFile() diff --git a/ScreenPresenterTests/RecordingFileNamingTests.swift b/ScreenPresenterTests/RecordingFileNamingTests.swift index c57190b..3811472 100644 --- a/ScreenPresenterTests/RecordingFileNamingTests.swift +++ b/ScreenPresenterTests/RecordingFileNamingTests.swift @@ -22,7 +22,7 @@ final class RecordingFileNamingTests: XCTestCase { XCTAssertEqual(result, "iOS-Tank-iPhone-15-Pro") } - func testSnapshotFileNameIncludesElapsedSecondAndWallClockSecond() { + func testSnapshotFileNameIncludesElapsedSecondAndWallClockSecond() throws { var components = DateComponents() components.calendar = Calendar.current components.year = 2026 diff --git a/ScreenPresenterTests/RecordingLibraryTests.swift b/ScreenPresenterTests/RecordingLibraryTests.swift new file mode 100644 index 0000000..c4fd1a2 --- /dev/null +++ b/ScreenPresenterTests/RecordingLibraryTests.swift @@ -0,0 +1,166 @@ +// +// RecordingLibraryTests.swift +// ScreenPresenterTests +// +// Created by Sun on 2026/07/02. +// + +import Foundation +import XCTest +@testable import ScreenPresenter + +final class RecordingLibraryTests: XCTestCase { + private var temporaryDirectory: URL! + + override func setUpWithError() throws { + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: temporaryDirectory) + temporaryDirectory = nil + } + + func testScanSortsSessionsAndParsesDurationPlatformDeviceNameAndSnapshotSeconds() throws { + let olderSession = try makeSession(named: "2026-06-30 20-24-48", duration: 61) + let newerSession = try makeSession(named: "2026-07-01 09-10-11", duration: 12) + + try makeDeviceDirectory( + in: newerSession, + named: "Android-Pixel 8", + snapshotNames: ["000010_2026-07-01_09-10-21.jpg"] + ) + try makeDeviceDirectory( + in: newerSession, + named: "iOS-iPhone 15 Pro", + snapshotNames: [ + "000032_2026-07-01_09-10-43.jpg", + "000002_2026-07-01_09-10-13.jpg" + ] + ) + try makeDeviceDirectory( + in: olderSession, + named: "iOS-iPad", + snapshotNames: ["000001_2026-06-30_20-24-49.jpg"] + ) + + let library = RecordingLibrary( + rootDirectory: temporaryDirectory, + audioDurationProvider: { url in + url.deletingLastPathComponent().lastPathComponent == "2026-07-01 09-10-11" ? 12 : 61 + } + ) + + let sessions = try library.scanSessions() + + XCTAssertEqual(sessions.map(\.directory.lastPathComponent), [ + "2026-07-01 09-10-11", + "2026-06-30 20-24-48" + ]) + XCTAssertEqual(sessions.map(\.duration), [12, 61]) + XCTAssertEqual( + sessions[0].startedAt, + sessionDate(fromDirectoryName: "2026-07-01 09-10-11") + ) + XCTAssertEqual(sessions[0].deviceTracks.map(\.platform), [.ios, .android]) + XCTAssertEqual(sessions[0].deviceTracks.map(\.deviceName), ["iPhone 15 Pro", "Pixel 8"]) + XCTAssertEqual(sessions[0].deviceTracks[0].snapshots.map(\.elapsedSecond), [2, 32]) + } + + func testSnapshotAtReturnsNearestSnapshotAtOrBeforeTargetSecond() { + let track = RecordingDeviceTrack( + platform: .ios, + deviceName: "iPhone", + directory: temporaryDirectory.appendingPathComponent("iOS-iPhone", isDirectory: true), + snapshots: [ + snapshot(elapsedSecond: 3), + snapshot(elapsedSecond: 8), + snapshot(elapsedSecond: 15) + ] + ) + + XCTAssertEqual(track.snapshot(at: 2)?.elapsedSecond, 3) + XCTAssertEqual(track.snapshot(at: 3)?.elapsedSecond, 3) + XCTAssertEqual(track.snapshot(at: 10)?.elapsedSecond, 8) + XCTAssertEqual(track.snapshot(at: 99)?.elapsedSecond, 15) + } + + func testScanIgnoresInvalidSessionsDeviceDirectoriesAndSnapshots() throws { + _ = try makeSession(named: "2026-07-01 09-10-11", duration: 30) + let validSession = temporaryDirectory.appendingPathComponent("2026-07-01 09-10-11", isDirectory: true) + try makeDeviceDirectory( + in: validSession, + named: "iOS-iPhone", + snapshotNames: [ + "000001_2026-07-01_09-10-12.jpg", + "000002_2026-07-01_09-10-13.png", + "abc003_2026-07-01_09-10-14.jpg", + "00004_2026-07-01_09-10-15.jpg", + "000005.jpg" + ] + ) + try makeDeviceDirectory( + in: validSession, + named: "windows-Surface", + snapshotNames: ["000003_2026-07-01_09-10-14.jpg"] + ) + + let invalidSession = temporaryDirectory.appendingPathComponent( + "2026-07-02 09-10-11", + isDirectory: true + ) + try FileManager.default.createDirectory(at: invalidSession, withIntermediateDirectories: true) + try makeDeviceDirectory( + in: invalidSession, + named: "Android-Pixel", + snapshotNames: ["000001_2026-07-02_09-10-12.jpg"] + ) + + let library = RecordingLibrary( + rootDirectory: temporaryDirectory, + audioDurationProvider: { _ in 30 } + ) + + let sessions = try library.scanSessions() + + XCTAssertEqual(sessions.count, 1) + XCTAssertEqual(sessions[0].deviceTracks.map(\.deviceName), ["iPhone"]) + XCTAssertEqual(sessions[0].deviceTracks[0].snapshots.map(\.elapsedSecond), [1]) + } + + private func makeSession(named name: String, duration: TimeInterval) throws -> URL { + let directory = temporaryDirectory.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("audio-\(duration)".utf8).write(to: directory.appendingPathComponent("audio.m4a")) + return directory + } + + private func makeDeviceDirectory( + in sessionDirectory: URL, + named name: String, + snapshotNames: [String] + ) throws { + let directory = sessionDirectory.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + for snapshotName in snapshotNames { + try Data("snapshot".utf8).write(to: directory.appendingPathComponent(snapshotName)) + } + } + + private func snapshot(elapsedSecond: Int) -> RecordingSnapshot { + RecordingSnapshot( + elapsedSecond: elapsedSecond, + fileURL: temporaryDirectory.appendingPathComponent("\(elapsedSecond).jpg") + ) + } + + private func sessionDate(fromDirectoryName directoryName: String) -> Date { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH-mm-ss" + return formatter.date(from: directoryName)! + } +} diff --git a/ScreenPresenterTests/ScrcpyVideoStreamParserTests.swift b/ScreenPresenterTests/ScrcpyVideoStreamParserTests.swift index 5601f24..e7e6f55 100644 --- a/ScreenPresenterTests/ScrcpyVideoStreamParserTests.swift +++ b/ScreenPresenterTests/ScrcpyVideoStreamParserTests.swift @@ -170,6 +170,8 @@ final class AnnexBToAVCCConverterTests: XCTestCase { data: Data([0x67, 0x42, 0x00]), isParameterSet: true, isKeyFrame: false, + protocolKeyFrame: false, + isVCL: false, codecType: kCMVideoCodecType_H264 ) let nalUnit2 = ParsedNALUnit( @@ -177,6 +179,8 @@ final class AnnexBToAVCCConverterTests: XCTestCase { data: Data([0x68, 0xce]), isParameterSet: true, isKeyFrame: false, + protocolKeyFrame: false, + isVCL: false, codecType: kCMVideoCodecType_H264 ) @@ -247,8 +251,8 @@ final class ScrcpyProtocolParsingTests: XCTestCase { func testFrameHeaderParsing() { // PTS = 1000000 (1秒), packetSize = 4096 var data = Data() - var pts: UInt64 = 1_000_000 - var size: UInt32 = 4096 + let pts: UInt64 = 1_000_000 + let size: UInt32 = 4096 data.append(contentsOf: withUnsafeBytes(of: pts.bigEndian) { Array($0) }) data.append(contentsOf: withUnsafeBytes(of: size.bigEndian) { Array($0) }) @@ -258,14 +262,14 @@ final class ScrcpyProtocolParsingTests: XCTestCase { XCTAssertEqual(header?.pts, 1_000_000) XCTAssertEqual(header?.packetSize, 4096) XCTAssertFalse(header?.isConfigPacket ?? true, "普通帧不应该是配置包") - XCTAssertEqual(header?.cmTime.seconds, 1.0, accuracy: 0.001) + XCTAssertEqual(header?.cmTime.seconds ?? 0, 1.0, accuracy: 0.001) } func testFrameHeaderParsingConfigPacket() { // 配置包:PTS bit62 为 1 var data = Data() - var pts: UInt64 = (1 << 62) | 0 - var size: UInt32 = 128 + let pts: UInt64 = (1 << 62) | 0 + let size: UInt32 = 128 data.append(contentsOf: withUnsafeBytes(of: pts.bigEndian) { Array($0) }) data.append(contentsOf: withUnsafeBytes(of: size.bigEndian) { Array($0) }) @@ -278,9 +282,9 @@ final class ScrcpyProtocolParsingTests: XCTestCase { func testVideoSessionMetaParsing() { var data = Data() - var flags: UInt32 = 0x8000_0000 - var width: UInt32 = 1080 - var height: UInt32 = 1920 + let flags: UInt32 = 0x8000_0000 + let width: UInt32 = 1080 + let height: UInt32 = 1920 data.append(contentsOf: withUnsafeBytes(of: flags.bigEndian) { Array($0) }) data.append(contentsOf: withUnsafeBytes(of: width.bigEndian) { Array($0) }) data.append(contentsOf: withUnsafeBytes(of: height.bigEndian) { Array($0) }) @@ -303,12 +307,12 @@ final class ScrcpyProtocolParsingTests: XCTestCase { deviceNameBytes.append(contentsOf: [UInt8](repeating: 0, count: 64 - deviceNameBytes.count)) data.append(contentsOf: deviceNameBytes) - var codecId: UInt32 = 0x6832_3634 + let codecId: UInt32 = 0x6832_3634 data.append(contentsOf: withUnsafeBytes(of: codecId.bigEndian) { Array($0) }) - var sessionFlags: UInt32 = 0x8000_0000 - var sessionWidth: UInt32 = 1080 - var sessionHeight: UInt32 = 1920 + let sessionFlags: UInt32 = 0x8000_0000 + let sessionWidth: UInt32 = 1080 + let sessionHeight: UInt32 = 1920 data.append(contentsOf: withUnsafeBytes(of: sessionFlags.bigEndian) { Array($0) }) data.append(contentsOf: withUnsafeBytes(of: sessionWidth.bigEndian) { Array($0) }) data.append(contentsOf: withUnsafeBytes(of: sessionHeight.bigEndian) { Array($0) }) @@ -317,8 +321,8 @@ final class ScrcpyProtocolParsingTests: XCTestCase { 0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84, 0x00, ]) - var pts: UInt64 = (1 << 61) | 1000 - var packetSize = UInt32(frameData.count) + let pts: UInt64 = (1 << 61) | 1000 + let packetSize = UInt32(frameData.count) data.append(contentsOf: withUnsafeBytes(of: pts.bigEndian) { Array($0) }) data.append(contentsOf: withUnsafeBytes(of: packetSize.bigEndian) { Array($0) }) data.append(frameData)