Skip to content

Commit 9fe2d2b

Browse files
Merge pull request #5 from HapticTide/codex/add-recording-feature
新增投屏录制功能
2 parents a6745fd + cc5fa80 commit 9fe2d2b

16 files changed

Lines changed: 968 additions & 6 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,9 @@ Secrets.swift
8888
*.xcuserstate
8989
ScreenPresenter.xcodeproj/xcuserdata
9090
ScreenPresenter.xcodeproj/xcuserdata
91+
92+
# =============================================================================
93+
# Agent workspace
94+
# =============================================================================
95+
.superpowers/
96+
docs/

ScreenPresenter.xcodeproj/project.pbxproj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@
7777
G1000001000000000004 /* ScrcpyVideoStreamParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = G1000002000000000004 /* ScrcpyVideoStreamParser.swift */; };
7878
G1000001000000000005 /* VideoToolboxDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = G1000002000000000005 /* VideoToolboxDecoder.swift */; };
7979
G60942986538400935988421 /* ScrcpyErrorHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = G34952747508024177339802 /* ScrcpyErrorHelper.swift */; };
80+
R0000001000000000001 /* RecordingFileNaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = R0000002000000000001 /* RecordingFileNaming.swift */; };
81+
R0000001000000000002 /* RecordingFrameSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = R0000002000000000002 /* RecordingFrameSnapshot.swift */; };
82+
R0000001000000000003 /* RecordingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = R0000002000000000003 /* RecordingService.swift */; };
8083
/* End PBXBuildFile section */
8184

8285
/* Begin PBXCopyFilesBuildPhase section */
@@ -160,6 +163,9 @@
160163
G1000002000000000004 /* ScrcpyVideoStreamParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrcpyVideoStreamParser.swift; sourceTree = "<group>"; };
161164
G1000002000000000005 /* VideoToolboxDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoToolboxDecoder.swift; sourceTree = "<group>"; };
162165
G34952747508024177339802 /* ScrcpyErrorHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrcpyErrorHelper.swift; sourceTree = "<group>"; };
166+
R0000002000000000001 /* RecordingFileNaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingFileNaming.swift; sourceTree = "<group>"; };
167+
R0000002000000000002 /* RecordingFrameSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingFrameSnapshot.swift; sourceTree = "<group>"; };
168+
R0000002000000000003 /* RecordingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingService.swift; sourceTree = "<group>"; };
163169
/* End PBXFileReference section */
164170

165171
/* Begin PBXFrameworksBuildPhase section */
@@ -290,6 +296,16 @@
290296
path = Rendering;
291297
sourceTree = "<group>";
292298
};
299+
R0000003000000000001 /* Recording */ = {
300+
isa = PBXGroup;
301+
children = (
302+
R0000002000000000001 /* RecordingFileNaming.swift */,
303+
R0000002000000000002 /* RecordingFrameSnapshot.swift */,
304+
R0000002000000000003 /* RecordingService.swift */,
305+
);
306+
path = Recording;
307+
sourceTree = "<group>";
308+
};
293309
A44133202EF93201003DCDD3 /* Utilities */ = {
294310
isa = PBXGroup;
295311
children = (
@@ -315,6 +331,7 @@
315331
A44133092EF93201003DCDD3 /* DeviceSource */,
316332
A441330F2EF93201003DCDD3 /* Preferences */,
317333
A44133122EF93201003DCDD3 /* Process */,
334+
R0000003000000000001 /* Recording */,
318335
A441331E2EF93201003DCDD3 /* Rendering */,
319336
A44133202EF93201003DCDD3 /* Utilities */,
320337
);
@@ -538,6 +555,9 @@
538555
C0025E2AB0327BD165380C0C /* FrameBuffer.swift in Sources */,
539556
A481F7992F0B579F00D9DAB0 /* FramePipeline.swift in Sources */,
540557
2A000001000000000001 /* AudioPlayer.swift in Sources */,
558+
R0000001000000000001 /* RecordingFileNaming.swift in Sources */,
559+
R0000001000000000002 /* RecordingFrameSnapshot.swift in Sources */,
560+
R0000001000000000003 /* RecordingService.swift in Sources */,
541561
);
542562
runOnlyForDeploymentPostprocessing = 0;
543563
};

ScreenPresenter/AppDelegate.swift

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import AppKit
1212
import AVFoundation
13+
import Combine
1314
import MarkdownEditor
1415

1516
// MARK: - 应用程序委托
@@ -28,8 +29,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider {
2829
private var preventSleepToolbarItem: NSToolbarItem?
2930
private var layoutModeToolbarItem: NSToolbarItem?
3031
private var layoutModeSegmentedControl: NSSegmentedControl?
32+
private var recordingToolbarItem: NSToolbarItem?
33+
private var recordingOutputToolbarItem: NSToolbarItem?
34+
private var recordingOutputButton: NSButton?
35+
private var recordingOutputWidthConstraint: NSLayoutConstraint?
3136
private var markdownToggleToolbarItem: NSToolbarItem?
3237
private var isRefreshing: Bool = false
38+
private var cancellables = Set<AnyCancellable>()
3339

3440
// MARK: - 菜单项
3541

@@ -56,6 +62,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider {
5662

5763
// MARK: - 应用生命周期
5864

65+
@MainActor
5966
func applicationDidFinishLaunching(_ notification: Notification) {
6067
AppLogger.app.info("应用启动")
6168

@@ -144,6 +151,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider {
144151
updateLayoutModeToolbarState()
145152
}
146153

154+
@MainActor
147155
@objc private func handleLanguageChange() {
148156
// 重建主菜单
149157
setupMainMenu()
@@ -160,6 +168,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider {
160168
mainViewController?.updateLocalizedTexts()
161169
}
162170

171+
@MainActor
163172
private func rebuildWindowToolbar(for window: NSWindow) {
164173
// 移除旧工具栏
165174
window.toolbar = nil
@@ -168,10 +177,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider {
168177
preventSleepToolbarItem = nil
169178
layoutModeToolbarItem = nil
170179
layoutModeSegmentedControl = nil
180+
recordingToolbarItem = nil
181+
recordingOutputToolbarItem = nil
182+
recordingOutputButton = nil
183+
recordingOutputWidthConstraint = nil
171184
markdownToggleToolbarItem = nil
172185

173186
// 创建新工具栏
174187
setupWindowToolbar(for: window)
188+
updateRecordingUI()
175189
}
176190

177191
/// 请求摄像头权限
@@ -1153,6 +1167,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider {
11531167
}
11541168
// MARK: - 窗口设置
11551169

1170+
@MainActor
11561171
private func setupMainWindow() {
11571172
// 创建主视图控制器
11581173
mainViewController = MainViewController()
@@ -1175,6 +1190,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider {
11751190

11761191
// 设置窗口工具栏
11771192
setupWindowToolbar(for: window)
1193+
setupRecordingObservation()
11781194

11791195
// 设置窗口代理
11801196
window.delegate = self
@@ -1199,6 +1215,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FormatMenuProvider {
11991215
window.toolbar = toolbar
12001216
windowToolbar = toolbar
12011217
}
1218+
1219+
@MainActor
1220+
private func setupRecordingObservation() {
1221+
AppState.shared.recordingService.stateChangedPublisher
1222+
.receive(on: DispatchQueue.main)
1223+
.sink { [weak self] in
1224+
self?.updateRecordingUI()
1225+
}
1226+
.store(in: &cancellables)
1227+
}
12021228
}
12031229

12041230
// MARK: - 窗口代理
@@ -1471,6 +1497,46 @@ extension AppDelegate {
14711497
ToastView.success(L10n.toolbar.refreshComplete, in: mainWindow)
14721498
}
14731499

1500+
// MARK: - 录制操作
1501+
1502+
@IBAction func toggleRecording(_ sender: Any?) {
1503+
let recordingService = AppState.shared.recordingService
1504+
1505+
if recordingService.state.isRecording {
1506+
let directory = recordingService.stopRecording()
1507+
AppState.shared.iosDeviceSource?.stopAudioCaptureForCurrentSession()
1508+
updateRecordingUI()
1509+
if directory != nil {
1510+
ToastView.success(L10n.recording.saved, in: mainWindow)
1511+
}
1512+
return
1513+
}
1514+
1515+
Task {
1516+
do {
1517+
try await recordingService.startRecording()
1518+
await MainActor.run {
1519+
updateRecordingUI()
1520+
ToastView.success(L10n.recording.started, in: mainWindow)
1521+
}
1522+
} catch {
1523+
await MainActor.run {
1524+
updateRecordingUI()
1525+
ToastView.error(error.localizedDescription, in: mainWindow)
1526+
}
1527+
}
1528+
}
1529+
}
1530+
1531+
@MainActor
1532+
@objc private func openRecordingOutputDirectory(_ sender: Any?) {
1533+
guard let directory = AppState.shared.recordingService.lastOutputDirectory else {
1534+
NSSound.beep()
1535+
return
1536+
}
1537+
NSWorkspace.shared.activateFileViewerSelecting([directory])
1538+
}
1539+
14741540
// MARK: - 面板交换操作
14751541

14761542
@IBAction func swapPanels(_ sender: Any?) {
@@ -1757,6 +1823,70 @@ extension AppDelegate {
17571823
item.image = image
17581824
}
17591825

1826+
@MainActor
1827+
private func updateRecordingUI() {
1828+
let service = AppState.shared.recordingService
1829+
let isRecording = service.state.isRecording
1830+
let shouldShowOutput = service.lastOutputDirectory != nil && !isRecording
1831+
1832+
if let item = recordingToolbarItem {
1833+
updateRecordingToolbarItemImage(item)
1834+
}
1835+
1836+
updateRecordingOutputToolbarPresence(shouldShowOutput)
1837+
1838+
if let outputButton = recordingOutputButton {
1839+
if let directory = service.lastOutputDirectory, shouldShowOutput {
1840+
outputButton.title = L10n.recording.savedLocation(
1841+
(directory.path as NSString).abbreviatingWithTildeInPath
1842+
)
1843+
outputButton.toolTip = L10n.recording.openOutputDirectory
1844+
outputButton.isHidden = false
1845+
recordingOutputWidthConstraint?.constant = 320
1846+
} else {
1847+
outputButton.title = ""
1848+
outputButton.toolTip = nil
1849+
outputButton.isHidden = true
1850+
recordingOutputWidthConstraint?.constant = 0
1851+
}
1852+
}
1853+
1854+
mainViewController?.setRecordingIndicatorVisible(isRecording, elapsedSeconds: service.elapsedSeconds)
1855+
}
1856+
1857+
@MainActor
1858+
private func updateRecordingOutputToolbarPresence(_ shouldShow: Bool) {
1859+
guard let toolbar = windowToolbar else { return }
1860+
1861+
let items = toolbar.items
1862+
let outputIndex = items.firstIndex { $0.itemIdentifier == ToolbarItemIdentifier.recordingOutput }
1863+
1864+
if shouldShow {
1865+
guard outputIndex == nil else { return }
1866+
1867+
let recordingIndex = items.firstIndex { $0.itemIdentifier == ToolbarItemIdentifier.recording } ?? 0
1868+
toolbar.insertItem(withItemIdentifier: ToolbarItemIdentifier.recordingOutput, at: recordingIndex)
1869+
} else if let outputIndex {
1870+
toolbar.removeItem(at: outputIndex)
1871+
recordingOutputToolbarItem = nil
1872+
recordingOutputButton = nil
1873+
recordingOutputWidthConstraint = nil
1874+
}
1875+
}
1876+
1877+
@MainActor
1878+
private func updateRecordingToolbarItemImage(_ item: NSToolbarItem) {
1879+
let isRecording = AppState.shared.recordingService.state.isRecording
1880+
let symbolName = isRecording ? "stop.circle.fill" : "record.circle"
1881+
item.label = isRecording ? L10n.recording.stop : L10n.recording.record
1882+
item.paletteLabel = L10n.recording.record
1883+
item.toolTip = isRecording ? L10n.recording.stop : L10n.recording.record
1884+
item.image = NSImage(
1885+
systemSymbolName: symbolName,
1886+
accessibilityDescription: item.toolTip
1887+
)
1888+
}
1889+
17601890
private func updateLayoutModeToolbarState() {
17611891
guard let segmentedControl = layoutModeSegmentedControl else { return }
17621892
let currentMode = UserPreferences.shared.layoutMode
@@ -1797,6 +1927,8 @@ extension AppDelegate: NSMenuDelegate {
17971927

17981928
extension AppDelegate: NSToolbarDelegate {
17991929
private enum ToolbarItemIdentifier {
1930+
static let recording = NSToolbarItem.Identifier("recording")
1931+
static let recordingOutput = NSToolbarItem.Identifier("recordingOutput")
18001932
static let layoutMode = NSToolbarItem.Identifier("layoutMode")
18011933
static let refresh = NSToolbarItem.Identifier("refresh")
18021934
static let toggleBezel = NSToolbarItem.Identifier("toggleBezel")
@@ -1808,6 +1940,7 @@ extension AppDelegate: NSToolbarDelegate {
18081940

18091941
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
18101942
[
1943+
ToolbarItemIdentifier.recording,
18111944
ToolbarItemIdentifier.markdownToggle,
18121945
.space,
18131946
ToolbarItemIdentifier.layoutMode,
@@ -1820,6 +1953,8 @@ extension AppDelegate: NSToolbarDelegate {
18201953

18211954
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
18221955
[
1956+
ToolbarItemIdentifier.recording,
1957+
ToolbarItemIdentifier.recordingOutput,
18231958
ToolbarItemIdentifier.refresh,
18241959
ToolbarItemIdentifier.markdownToggle,
18251960
ToolbarItemIdentifier.layoutMode,
@@ -1835,6 +1970,39 @@ extension AppDelegate: NSToolbarDelegate {
18351970
willBeInsertedIntoToolbar flag: Bool
18361971
) -> NSToolbarItem? {
18371972
switch itemIdentifier {
1973+
case ToolbarItemIdentifier.recording:
1974+
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
1975+
item.target = self
1976+
item.action = #selector(toggleRecording(_:))
1977+
recordingToolbarItem = item
1978+
updateRecordingToolbarItemImage(item)
1979+
return item
1980+
1981+
case ToolbarItemIdentifier.recordingOutput:
1982+
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
1983+
item.label = L10n.recording.saved
1984+
item.paletteLabel = L10n.recording.saved
1985+
1986+
let button = NSButton(title: "", target: self, action: #selector(openRecordingOutputDirectory(_:)))
1987+
button.bezelStyle = .rounded
1988+
button.isBordered = true
1989+
button.font = NSFont.systemFont(ofSize: 12)
1990+
button.lineBreakMode = .byTruncatingMiddle
1991+
button.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
1992+
button.translatesAutoresizingMaskIntoConstraints = false
1993+
button.isHidden = true
1994+
1995+
let widthConstraint = button.widthAnchor.constraint(equalToConstant: 0)
1996+
let heightConstraint = button.heightAnchor.constraint(equalToConstant: 28)
1997+
widthConstraint.isActive = true
1998+
heightConstraint.isActive = true
1999+
2000+
item.view = button
2001+
recordingOutputToolbarItem = item
2002+
recordingOutputButton = button
2003+
recordingOutputWidthConstraint = widthConstraint
2004+
return item
2005+
18382006
case ToolbarItemIdentifier.layoutMode:
18392007
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
18402008
item.label = L10n.toolbar.layoutMode

0 commit comments

Comments
 (0)