From f65f639af4e2c01a5ea73df821531dd84ed7a950 Mon Sep 17 00:00:00 2001 From: Marcin Macierzanka Date: Tue, 3 Nov 2026 02:40:24 +0100 Subject: [PATCH] Split main.swift into App, Capture, Menu, Power and Preferences Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 3 +- README.md | 2 +- Sources/App.swift | 57 ++++++++++ Sources/Capture.swift | 71 +++++++++++++ Sources/Menu.swift | 91 ++++++++++++++++ Sources/Power.swift | 15 +++ Sources/Preferences.swift | 10 ++ Sources/main.swift | 218 -------------------------------------- 8 files changed, 247 insertions(+), 220 deletions(-) create mode 100644 Sources/App.swift create mode 100644 Sources/Capture.swift create mode 100644 Sources/Menu.swift create mode 100644 Sources/Power.swift create mode 100644 Sources/Preferences.swift diff --git a/CLAUDE.md b/CLAUDE.md index 6231297..1ab85b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,8 @@ Aplikacja natywna na macOS, która wyświetla w oknie obraz z urządzenia przech - Poza zakresem (na razie): start przy logowaniu, nagrywanie, zrzuty ekranu, suwak głośności. ## Struktura -- `Sources/main.swift` – cała aplikacja (AppKit + AVFoundation, jedna sesja `AVCaptureSession`, dźwięk przez `AVCaptureAudioPreviewOutput`). +- `Sources/` – kod aplikacji (AppKit + AVFoundation, jedna sesja `AVCaptureSession`, dźwięk przez `AVCaptureAudioPreviewOutput`), klasa `App` rozbita na rozszerzenia: + `main.swift` (start), `App.swift` (stan, okno, cykl życia), `Capture.swift` (urządzenia, formaty, hot-plug), `Menu.swift` (menu i akcje), `Power.swift` (blokada usypiania), `Preferences.swift` (ustawienia z UserDefaults). - `Config/Info.plist`, `Config/SecondDisplay.entitlements` – wersja, uprawnienia (kamera, mikrofon; hardened runtime). - `Resources/` – `AppIcon.icon` (Icon Composer, Liquid Glass; kompilowana przez `actool`), `{en,pl}.lproj` (`Localizable.strings`, `InfoPlist.strings`), `uninstall.sh` (kopiowany do bundla; usuwa aplikację, preferencje, cache, uprawnienia TCC). Bundle ID: `com.mmacierzanka.seconddisplay`. - `scripts/check-strings.sh` – spójność tłumaczeń en/pl. diff --git a/README.md b/README.md index 76389e6..fe3b4de 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Podpis jest ad-hoc (działa na tym Macu). Do dystrybucji: `make SIGN_ID="Develop Menu u góry ekranu: **Obraz** (kamera, format), **Dźwięk** (źródło lub wyłączony), **Opcje** (blokada usypiania, pełny ekran przy starcie, na wierzchu, wyciszenie ⇧⌘M, skalowanie), **Okno** (kafelkowanie, pełny ekran ⌃⌘F). ## Struktura -- `Sources/main.swift` – aplikacja (AppKit + AVFoundation) +- `Sources/` – aplikacja (AppKit + AVFoundation): `App` (stan, okno), `Capture` (urządzenia, formaty), `Menu`, `Power`, `Preferences`, `main` (start) - `Config/` – `Info.plist`, entitlements (kamera, mikrofon) - `Resources/` – ikona (`AppIcon.icon`, Icon Composer), tłumaczenia en/pl, `uninstall.sh` - `scripts/check-strings.sh` – spójność tłumaczeń diff --git a/Sources/App.swift b/Sources/App.swift new file mode 100644 index 0000000..3f29441 --- /dev/null +++ b/Sources/App.swift @@ -0,0 +1,57 @@ +import AppKit +import AVFoundation +import IOKit.pwr_mgt + +final class App: NSObject, NSApplicationDelegate { + let session = AVCaptureSession() + let preview = AVCaptureAudioPreviewOutput() + var videoInput: AVCaptureDeviceInput? + var audioInput: AVCaptureDeviceInput? + var window: NSWindow! + let videoMenu = NSMenu(), audioMenu = NSMenu(), optionsMenu = NSMenu(), windowMenu = NSMenu() + var sleepAssertion: IOPMAssertionID = 0 + var keepAwake = false + var layer: AVCaptureVideoPreviewLayer! + var muted = false + let defaults = UserDefaults.standard + + func applicationDidFinishLaunching(_ n: Notification) { + window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 960, height: 540), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.title = "Second Display" + window.contentView!.wantsLayer = true + layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = gravity + window.level = onTop ? .floating : .normal + layer.frame = window.contentView!.bounds + layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + window.contentView!.layer!.addSublayer(layer) + window.contentView!.layer!.backgroundColor = NSColor.black.cgColor + window.center() + window.makeKeyAndOrderFront(nil) + if startFull { window.toggleFullScreen(nil) } + + + buildMainMenu() + + keepAwake = defaults.object(forKey: "keepAwake") as? Bool ?? true + setKeepAwake(keepAwake) + + preview.volume = 1 + session.beginConfiguration() + if session.canAddOutput(preview) { session.addOutput(preview) } + session.commitConfiguration() + + observeDevices() + + AVCaptureDevice.requestAccess(for: .video) { _ in + AVCaptureDevice.requestAccess(for: .audio) { _ in + DispatchQueue.main.async { self.restore() } + } + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ a: NSApplication) -> Bool { true } + func applicationWillTerminate(_ n: Notification) { setKeepAwake(false) } +} diff --git a/Sources/Capture.swift b/Sources/Capture.swift new file mode 100644 index 0000000..174b6fb --- /dev/null +++ b/Sources/Capture.swift @@ -0,0 +1,71 @@ +import AppKit +import AVFoundation + +extension App { + func observeDevices() { + let nc = NotificationCenter.default + nc.addObserver(forName: AVCaptureDevice.wasConnectedNotification, object: nil, queue: .main) { _ in self.restore() } + nc.addObserver(forName: AVCaptureDevice.wasDisconnectedNotification, object: nil, queue: .main) { n in + guard let d = n.object as? AVCaptureDevice else { return } + if d.uniqueID == self.videoInput?.device.uniqueID { self.swap(&self.videoInput, to: nil) } + if d.uniqueID == self.audioInput?.device.uniqueID { self.swap(&self.audioInput, to: nil) } + } + } + + // MARK: devices + func videoDevices() -> [AVCaptureDevice] { + AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], + mediaType: .video, position: .unspecified).devices + } + func audioDevices() -> [AVCaptureDevice] { + AVCaptureDevice.DiscoverySession(deviceTypes: [.microphone, .external], + mediaType: .audio, position: .unspecified).devices + } + + func restore() { + if videoInput == nil { + if let id = defaults.string(forKey: "video"), let d = videoDevices().first(where: { $0.uniqueID == id }) { + setVideo(d) + } else if let d = videoDevices().first { setVideo(d) } + } + if audioInput == nil, let id = defaults.string(forKey: "audio"), let d = audioDevices().first(where: { $0.uniqueID == id }) { + setAudio(d) + } // default: audio off until chosen + if !session.isRunning { session.startRunning() } + } + + func swap(_ old: inout AVCaptureDeviceInput?, to device: AVCaptureDevice?) { + session.beginConfiguration() + if let o = old { session.removeInput(o); old = nil } + if let d = device, let i = try? AVCaptureDeviceInput(device: d), session.canAddInput(i) { + session.addInput(i); old = i + } + session.commitConfiguration() + } + + func setVideo(_ d: AVCaptureDevice?) { + swap(&videoInput, to: d) + defaults.set(d?.uniqueID, forKey: "video") + if let d = d { applyFormat(d) } + } + + // MARK: formats + func dims(_ f: AVCaptureDevice.Format) -> CMVideoDimensions { CMVideoFormatDescriptionGetDimensions(f.formatDescription) } + func fps(_ f: AVCaptureDevice.Format) -> Int { Int((f.videoSupportedFrameRateRanges.map(\.maxFrameRate).max() ?? 0).rounded()) } + func label(_ f: AVCaptureDevice.Format) -> String { "\(dims(f).width)x\(dims(f).height) @ \(fps(f)) fps" } + + func applyFormat(_ d: AVCaptureDevice) { + guard let l = defaults.string(forKey: "format.\(d.uniqueID)"), + let f = d.formats.first(where: { label($0) == l }), (try? d.lockForConfiguration()) != nil else { return } + d.activeFormat = f + d.activeVideoMinFrameDuration = CMTime(value: 1, timescale: CMTimeScale(fps(f))) + d.activeVideoMaxFrameDuration = d.activeVideoMinFrameDuration + d.unlockForConfiguration() + } + func setAudio(_ d: AVCaptureDevice?) { + swap(&audioInput, to: d) + // Preview output plays to default system output; null = follow default + preview.outputDeviceUniqueID = nil + defaults.set(d?.uniqueID, forKey: "audio") + } +} diff --git a/Sources/Menu.swift b/Sources/Menu.swift new file mode 100644 index 0000000..2ea5cec --- /dev/null +++ b/Sources/Menu.swift @@ -0,0 +1,91 @@ +import AppKit +import AVFoundation + +extension App: NSMenuDelegate { + func buildMainMenu() { + let main = NSMenu() + let appMenu = NSMenu() + appMenu.addItem(NSMenuItem(title: NSLocalizedString("Quit Second Display", comment: ""), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + for (title, sub) in [("", appMenu), (NSLocalizedString("Video", comment: ""), videoMenu), (NSLocalizedString("Audio", comment: ""), audioMenu), (NSLocalizedString("Options", comment: ""), optionsMenu), (NSLocalizedString("Window", comment: ""), windowMenu)] { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + sub.title = title; sub.delegate = self; item.submenu = sub + main.addItem(item) + } + func L(_ k: String) -> String { NSLocalizedString(k, comment: "") } + windowMenu.addItem(NSMenuItem(title: L("Minimize"), action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")) + windowMenu.addItem(NSMenuItem(title: L("Zoom"), action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")) + let fs = NSMenuItem(title: L("Enter Full Screen"), action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") + fs.keyEquivalentModifierMask = [.command, .control] + windowMenu.addItem(fs) + windowMenu.addItem(.separator()) + windowMenu.addItem(NSMenuItem(title: L("Bring All to Front"), action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")) + NSApp.windowsMenu = windowMenu // AppKit adds the system tiling ("Move & Resize") items + NSApp.mainMenu = main + } + + // MARK: menu + func menuNeedsUpdate(_ menu: NSMenu) { + if menu === videoMenu { + menu.removeAllItems() + for d in videoDevices() { + let it = NSMenuItem(title: d.localizedName, action: #selector(pickVideo(_:)), keyEquivalent: "") + it.target = self; it.representedObject = d + it.state = d.uniqueID == videoInput?.device.uniqueID ? .on : .off + menu.addItem(it) + } + if menu.items.isEmpty { menu.addItem(header(NSLocalizedString("No devices", comment: ""))) } + if let d = videoInput?.device { + menu.addItem(.separator()) + menu.addItem(header(NSLocalizedString("Format", comment: ""))) + var seen = Set() + for f in d.formats.sorted(by: { (dims($0).width, fps($0)) > (dims($1).width, fps($1)) }) where seen.insert(label(f)).inserted { + let it = NSMenuItem(title: label(f), action: #selector(pickFormat(_:)), keyEquivalent: "") + it.target = self; it.state = f == d.activeFormat ? .on : .off + menu.addItem(it) + } + } + } else if menu === audioMenu { + menu.removeAllItems() + let off = NSMenuItem(title: NSLocalizedString("Off", comment: ""), action: #selector(pickAudio(_:)), keyEquivalent: "") + off.target = self; off.state = audioInput == nil ? .on : .off + menu.addItem(off) + menu.addItem(.separator()) + for d in audioDevices() { + let it = NSMenuItem(title: d.localizedName, action: #selector(pickAudio(_:)), keyEquivalent: "") + it.target = self; it.representedObject = d + it.state = d.uniqueID == audioInput?.device.uniqueID ? .on : .off + menu.addItem(it) + } + } else if menu === optionsMenu { + menu.removeAllItems() + let k = NSMenuItem(title: NSLocalizedString("Prevent display sleep", comment: ""), action: #selector(toggleAwake), keyEquivalent: "") + k.target = self; k.state = keepAwake ? .on : .off + let f = NSMenuItem(title: NSLocalizedString("Start in full screen", comment: ""), action: #selector(toggleFull), keyEquivalent: "") + f.target = self; f.state = startFull ? .on : .off + let t = NSMenuItem(title: NSLocalizedString("Always on top", comment: ""), action: #selector(toggleTop), keyEquivalent: "") + t.target = self; t.state = onTop ? .on : .off + let m = NSMenuItem(title: NSLocalizedString("Mute", comment: ""), action: #selector(toggleMute), keyEquivalent: "M") + m.target = self; m.state = muted ? .on : .off + menu.addItem(k); menu.addItem(f); menu.addItem(t); menu.addItem(m) + menu.addItem(.separator()) + menu.addItem(header(NSLocalizedString("Scaling", comment: ""))) + for (key, name) in [("fit", "Fit"), ("fill", "Fill"), ("stretch", "Stretch")] { + let it = NSMenuItem(title: NSLocalizedString(name, comment: ""), action: #selector(pickGravity(_:)), keyEquivalent: "") + it.target = self; it.representedObject = key; it.state = (defaults.string(forKey: "gravity") ?? "fit") == key ? .on : .off + menu.addItem(it) + } + } + } + func header(_ s: String) -> NSMenuItem { let i = NSMenuItem(title: s, action: nil, keyEquivalent: ""); i.isEnabled = false; return i } + @objc func pickVideo(_ s: NSMenuItem) { setVideo(s.representedObject as? AVCaptureDevice) } + @objc func pickAudio(_ s: NSMenuItem) { setAudio(s.representedObject as? AVCaptureDevice) } + @objc func toggleFull() { defaults.set(!startFull, forKey: "startFull") } + @objc func pickFormat(_ s: NSMenuItem) { + guard let d = videoInput?.device else { return } + defaults.set(s.title, forKey: "format.\(d.uniqueID)"); applyFormat(d) + } + @objc func toggleTop() { defaults.set(!onTop, forKey: "onTop"); window.level = onTop ? .floating : .normal } + @objc func toggleMute() { muted.toggle(); preview.volume = muted ? 0 : 1 } + @objc func pickGravity(_ s: NSMenuItem) { defaults.set(s.representedObject as? String, forKey: "gravity"); layer.videoGravity = gravity } + @objc func toggleAwake() { setKeepAwake(!keepAwake) } +} diff --git a/Sources/Power.swift b/Sources/Power.swift new file mode 100644 index 0000000..e24df88 --- /dev/null +++ b/Sources/Power.swift @@ -0,0 +1,15 @@ +import IOKit.pwr_mgt + +extension App { + // MARK: sleep + func setKeepAwake(_ on: Bool) { + if on && sleepAssertion == 0 { + IOPMAssertionCreateWithName(kIOPMAssertPreventUserIdleDisplaySleep as CFString, + IOPMAssertionLevel(kIOPMAssertionLevelOn), "Second Display active" as CFString, &sleepAssertion) + } else if !on && sleepAssertion != 0 { + IOPMAssertionRelease(sleepAssertion); sleepAssertion = 0 + } + keepAwake = on + defaults.set(on, forKey: "keepAwake") + } +} diff --git a/Sources/Preferences.swift b/Sources/Preferences.swift new file mode 100644 index 0000000..d5d7520 --- /dev/null +++ b/Sources/Preferences.swift @@ -0,0 +1,10 @@ +import AppKit +import AVFoundation + +extension App { + var startFull: Bool { defaults.object(forKey: "startFull") as? Bool ?? true } + var onTop: Bool { defaults.bool(forKey: "onTop") } + var gravity: AVLayerVideoGravity { + switch defaults.string(forKey: "gravity") { case "fill": return .resizeAspectFill; case "stretch": return .resize; default: return .resizeAspect } + } +} diff --git a/Sources/main.swift b/Sources/main.swift index 3aeaad7..144eae4 100644 --- a/Sources/main.swift +++ b/Sources/main.swift @@ -1,222 +1,4 @@ import AppKit -import AVFoundation -import IOKit.pwr_mgt - -final class App: NSObject, NSApplicationDelegate, NSMenuDelegate { - let session = AVCaptureSession() - let preview = AVCaptureAudioPreviewOutput() - var videoInput: AVCaptureDeviceInput? - var audioInput: AVCaptureDeviceInput? - var window: NSWindow! - let videoMenu = NSMenu(), audioMenu = NSMenu(), optionsMenu = NSMenu(), windowMenu = NSMenu() - var sleepAssertion: IOPMAssertionID = 0 - var keepAwake = false - var layer: AVCaptureVideoPreviewLayer! - var muted = false - let defaults = UserDefaults.standard - - func applicationDidFinishLaunching(_ n: Notification) { - window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 960, height: 540), - styleMask: [.titled, .closable, .miniaturizable, .resizable], - backing: .buffered, defer: false) - window.title = "Second Display" - window.contentView!.wantsLayer = true - layer = AVCaptureVideoPreviewLayer(session: session) - layer.videoGravity = gravity - window.level = onTop ? .floating : .normal - layer.frame = window.contentView!.bounds - layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] - window.contentView!.layer!.addSublayer(layer) - window.contentView!.layer!.backgroundColor = NSColor.black.cgColor - window.center() - window.makeKeyAndOrderFront(nil) - if startFull { window.toggleFullScreen(nil) } - - let main = NSMenu() - let appMenu = NSMenu() - appMenu.addItem(NSMenuItem(title: NSLocalizedString("Quit Second Display", comment: ""), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) - for (title, sub) in [("", appMenu), (NSLocalizedString("Video", comment: ""), videoMenu), (NSLocalizedString("Audio", comment: ""), audioMenu), (NSLocalizedString("Options", comment: ""), optionsMenu), (NSLocalizedString("Window", comment: ""), windowMenu)] { - let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") - sub.title = title; sub.delegate = self; item.submenu = sub - main.addItem(item) - } - func L(_ k: String) -> String { NSLocalizedString(k, comment: "") } - windowMenu.addItem(NSMenuItem(title: L("Minimize"), action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")) - windowMenu.addItem(NSMenuItem(title: L("Zoom"), action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")) - let fs = NSMenuItem(title: L("Enter Full Screen"), action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") - fs.keyEquivalentModifierMask = [.command, .control] - windowMenu.addItem(fs) - windowMenu.addItem(.separator()) - windowMenu.addItem(NSMenuItem(title: L("Bring All to Front"), action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")) - NSApp.windowsMenu = windowMenu // AppKit adds the system tiling ("Move & Resize") items - NSApp.mainMenu = main - - keepAwake = defaults.object(forKey: "keepAwake") as? Bool ?? true - setKeepAwake(keepAwake) - - preview.volume = 1 - session.beginConfiguration() - if session.canAddOutput(preview) { session.addOutput(preview) } - session.commitConfiguration() - - let nc = NotificationCenter.default - nc.addObserver(forName: AVCaptureDevice.wasConnectedNotification, object: nil, queue: .main) { _ in self.restore() } - nc.addObserver(forName: AVCaptureDevice.wasDisconnectedNotification, object: nil, queue: .main) { n in - guard let d = n.object as? AVCaptureDevice else { return } - if d.uniqueID == self.videoInput?.device.uniqueID { self.swap(&self.videoInput, to: nil) } - if d.uniqueID == self.audioInput?.device.uniqueID { self.swap(&self.audioInput, to: nil) } - } - - AVCaptureDevice.requestAccess(for: .video) { _ in - AVCaptureDevice.requestAccess(for: .audio) { _ in - DispatchQueue.main.async { self.restore() } - } - } - } - - func applicationShouldTerminateAfterLastWindowClosed(_ a: NSApplication) -> Bool { true } - func applicationWillTerminate(_ n: Notification) { setKeepAwake(false) } - - // MARK: devices - func videoDevices() -> [AVCaptureDevice] { - AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], - mediaType: .video, position: .unspecified).devices - } - func audioDevices() -> [AVCaptureDevice] { - AVCaptureDevice.DiscoverySession(deviceTypes: [.microphone, .external], - mediaType: .audio, position: .unspecified).devices - } - - func restore() { - if videoInput == nil { - if let id = defaults.string(forKey: "video"), let d = videoDevices().first(where: { $0.uniqueID == id }) { - setVideo(d) - } else if let d = videoDevices().first { setVideo(d) } - } - if audioInput == nil, let id = defaults.string(forKey: "audio"), let d = audioDevices().first(where: { $0.uniqueID == id }) { - setAudio(d) - } // default: audio off until chosen - if !session.isRunning { session.startRunning() } - } - - func swap(_ old: inout AVCaptureDeviceInput?, to device: AVCaptureDevice?) { - session.beginConfiguration() - if let o = old { session.removeInput(o); old = nil } - if let d = device, let i = try? AVCaptureDeviceInput(device: d), session.canAddInput(i) { - session.addInput(i); old = i - } - session.commitConfiguration() - } - - func setVideo(_ d: AVCaptureDevice?) { - swap(&videoInput, to: d) - defaults.set(d?.uniqueID, forKey: "video") - if let d = d { applyFormat(d) } - } - - // MARK: formats - func dims(_ f: AVCaptureDevice.Format) -> CMVideoDimensions { CMVideoFormatDescriptionGetDimensions(f.formatDescription) } - func fps(_ f: AVCaptureDevice.Format) -> Int { Int((f.videoSupportedFrameRateRanges.map(\.maxFrameRate).max() ?? 0).rounded()) } - func label(_ f: AVCaptureDevice.Format) -> String { "\(dims(f).width)x\(dims(f).height) @ \(fps(f)) fps" } - - func applyFormat(_ d: AVCaptureDevice) { - guard let l = defaults.string(forKey: "format.\(d.uniqueID)"), - let f = d.formats.first(where: { label($0) == l }), (try? d.lockForConfiguration()) != nil else { return } - d.activeFormat = f - d.activeVideoMinFrameDuration = CMTime(value: 1, timescale: CMTimeScale(fps(f))) - d.activeVideoMaxFrameDuration = d.activeVideoMinFrameDuration - d.unlockForConfiguration() - } - func setAudio(_ d: AVCaptureDevice?) { - swap(&audioInput, to: d) - // Preview output plays to default system output; null = follow default - preview.outputDeviceUniqueID = nil - defaults.set(d?.uniqueID, forKey: "audio") - } - - // MARK: sleep - func setKeepAwake(_ on: Bool) { - if on && sleepAssertion == 0 { - IOPMAssertionCreateWithName(kIOPMAssertPreventUserIdleDisplaySleep as CFString, - IOPMAssertionLevel(kIOPMAssertionLevelOn), "Second Display active" as CFString, &sleepAssertion) - } else if !on && sleepAssertion != 0 { - IOPMAssertionRelease(sleepAssertion); sleepAssertion = 0 - } - keepAwake = on - defaults.set(on, forKey: "keepAwake") - } - - // MARK: menu - func menuNeedsUpdate(_ menu: NSMenu) { - if menu === videoMenu { - menu.removeAllItems() - for d in videoDevices() { - let it = NSMenuItem(title: d.localizedName, action: #selector(pickVideo(_:)), keyEquivalent: "") - it.target = self; it.representedObject = d - it.state = d.uniqueID == videoInput?.device.uniqueID ? .on : .off - menu.addItem(it) - } - if menu.items.isEmpty { menu.addItem(header(NSLocalizedString("No devices", comment: ""))) } - if let d = videoInput?.device { - menu.addItem(.separator()) - menu.addItem(header(NSLocalizedString("Format", comment: ""))) - var seen = Set() - for f in d.formats.sorted(by: { (dims($0).width, fps($0)) > (dims($1).width, fps($1)) }) where seen.insert(label(f)).inserted { - let it = NSMenuItem(title: label(f), action: #selector(pickFormat(_:)), keyEquivalent: "") - it.target = self; it.state = f == d.activeFormat ? .on : .off - menu.addItem(it) - } - } - } else if menu === audioMenu { - menu.removeAllItems() - let off = NSMenuItem(title: NSLocalizedString("Off", comment: ""), action: #selector(pickAudio(_:)), keyEquivalent: "") - off.target = self; off.state = audioInput == nil ? .on : .off - menu.addItem(off) - menu.addItem(.separator()) - for d in audioDevices() { - let it = NSMenuItem(title: d.localizedName, action: #selector(pickAudio(_:)), keyEquivalent: "") - it.target = self; it.representedObject = d - it.state = d.uniqueID == audioInput?.device.uniqueID ? .on : .off - menu.addItem(it) - } - } else if menu === optionsMenu { - menu.removeAllItems() - let k = NSMenuItem(title: NSLocalizedString("Prevent display sleep", comment: ""), action: #selector(toggleAwake), keyEquivalent: "") - k.target = self; k.state = keepAwake ? .on : .off - let f = NSMenuItem(title: NSLocalizedString("Start in full screen", comment: ""), action: #selector(toggleFull), keyEquivalent: "") - f.target = self; f.state = startFull ? .on : .off - let t = NSMenuItem(title: NSLocalizedString("Always on top", comment: ""), action: #selector(toggleTop), keyEquivalent: "") - t.target = self; t.state = onTop ? .on : .off - let m = NSMenuItem(title: NSLocalizedString("Mute", comment: ""), action: #selector(toggleMute), keyEquivalent: "M") - m.target = self; m.state = muted ? .on : .off - menu.addItem(k); menu.addItem(f); menu.addItem(t); menu.addItem(m) - menu.addItem(.separator()) - menu.addItem(header(NSLocalizedString("Scaling", comment: ""))) - for (key, name) in [("fit", "Fit"), ("fill", "Fill"), ("stretch", "Stretch")] { - let it = NSMenuItem(title: NSLocalizedString(name, comment: ""), action: #selector(pickGravity(_:)), keyEquivalent: "") - it.target = self; it.representedObject = key; it.state = (defaults.string(forKey: "gravity") ?? "fit") == key ? .on : .off - menu.addItem(it) - } - } - } - func header(_ s: String) -> NSMenuItem { let i = NSMenuItem(title: s, action: nil, keyEquivalent: ""); i.isEnabled = false; return i } - @objc func pickVideo(_ s: NSMenuItem) { setVideo(s.representedObject as? AVCaptureDevice) } - @objc func pickAudio(_ s: NSMenuItem) { setAudio(s.representedObject as? AVCaptureDevice) } - var startFull: Bool { defaults.object(forKey: "startFull") as? Bool ?? true } - @objc func toggleFull() { defaults.set(!startFull, forKey: "startFull") } - var onTop: Bool { defaults.bool(forKey: "onTop") } - var gravity: AVLayerVideoGravity { - switch defaults.string(forKey: "gravity") { case "fill": return .resizeAspectFill; case "stretch": return .resize; default: return .resizeAspect } - } - @objc func pickFormat(_ s: NSMenuItem) { - guard let d = videoInput?.device else { return } - defaults.set(s.title, forKey: "format.\(d.uniqueID)"); applyFormat(d) - } - @objc func toggleTop() { defaults.set(!onTop, forKey: "onTop"); window.level = onTop ? .floating : .normal } - @objc func toggleMute() { muted.toggle(); preview.volume = muted ? 0 : 1 } - @objc func pickGravity(_ s: NSMenuItem) { defaults.set(s.representedObject as? String, forKey: "gravity"); layer.videoGravity = gravity } - @objc func toggleAwake() { setKeepAwake(!keepAwake) } -} let app = NSApplication.shared let delegate = App()