diff --git a/NotchBeat.xcodeproj/project.pbxproj b/NotchBeat.xcodeproj/project.pbxproj index 7c61999..5186842 100644 --- a/NotchBeat.xcodeproj/project.pbxproj +++ b/NotchBeat.xcodeproj/project.pbxproj @@ -16,9 +16,12 @@ 72522930F2F1674F82C61598 /* VolumeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EBE1A010C9C568DF7EF30B9 /* VolumeController.swift */; }; 7CE2042F64E912316794D314 /* ExpandedPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A326FFF2D90FCF3CA1C1C /* ExpandedPlayerView.swift */; }; 852BCE6356651B307827CFA9 /* LiquidGlassUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C001B28DA5319FDF1493DCE6 /* LiquidGlassUI.swift */; }; + 8CECBA1806341C3ACDF9666D /* GlobalHotKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E114462CC31BFC8BFDB6571D /* GlobalHotKey.swift */; }; 935A65F30FA58FFC1F3F9D3D /* NotchUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797E3149A2DA6244346D8F0A /* NotchUIState.swift */; }; A879C2CBF374A2AA1F4532C1 /* SpotifyAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 709D63BE29CA0D22B1F331FB /* SpotifyAPIClient.swift */; }; AA4978CC7D080FB667E9CFA7 /* NotchMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3213245D2618045EA009B9 /* NotchMetrics.swift */; }; + B03F55117440084F1A9EEC47 /* LockScreenBackdropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9404B9EDA37A59B491EF92E1 /* LockScreenBackdropView.swift */; }; + B2D670FE2164BE0E5F5971D3 /* PseudoLockScreenController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68EB8D95060ED9D416B0AD35 /* PseudoLockScreenController.swift */; }; B543D6063513F9830EC5CE7A /* SpotifyAuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58A3D07D6AF6BC0759ABB9C7 /* SpotifyAuthManager.swift */; }; B68F48D70CDCD0AA188976C7 /* CompactNotchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC64A71BE0F2587FD91FC2F8 /* CompactNotchView.swift */; }; BD741B1B99AB90F0372BE471 /* LyricsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E371951F75508989A077CFC5 /* LyricsService.swift */; }; @@ -35,6 +38,7 @@ 5CE226CBE5A055D67B715329 /* MenuBarContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarContentView.swift; sourceTree = ""; }; 5DDE8CB4F434738E3DA92CE4 /* PKCE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PKCE.swift; sourceTree = ""; }; 5EBE1A010C9C568DF7EF30B9 /* VolumeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeController.swift; sourceTree = ""; }; + 68EB8D95060ED9D416B0AD35 /* PseudoLockScreenController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PseudoLockScreenController.swift; sourceTree = ""; }; 709D63BE29CA0D22B1F331FB /* SpotifyAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyAPIClient.swift; sourceTree = ""; }; 797E3149A2DA6244346D8F0A /* NotchUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchUIState.swift; sourceTree = ""; }; 7C75977B9D3E98AEEFDDE820 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -42,17 +46,29 @@ 7F19FAF1275C541AB173F1F7 /* MediaMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaMetadataService.swift; sourceTree = ""; }; 81D658A3FC6D7C63D43E2149 /* LyricsPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsPanelView.swift; sourceTree = ""; }; 898EE9859801623F36E78015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 9404B9EDA37A59B491EF92E1 /* LockScreenBackdropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenBackdropView.swift; sourceTree = ""; }; A862BCC79D94A263060B84F3 /* NotchRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchRootView.swift; sourceTree = ""; }; C001B28DA5319FDF1493DCE6 /* LiquidGlassUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassUI.swift; sourceTree = ""; }; C8436228A3EE4EBCB67BAE67 /* NotchBeat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NotchBeat.app; sourceTree = BUILT_PRODUCTS_DIR; }; C99A7818B9692BE3A3237E75 /* NotchWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindowController.swift; sourceTree = ""; }; D463E53A2075B4EFF62193C8 /* GestureEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureEngine.swift; sourceTree = ""; }; D87D141A7BB7819335674E9B /* NotchLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchLayout.swift; sourceTree = ""; }; + E114462CC31BFC8BFDB6571D /* GlobalHotKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalHotKey.swift; sourceTree = ""; }; E371951F75508989A077CFC5 /* LyricsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsService.swift; sourceTree = ""; }; EC64A71BE0F2587FD91FC2F8 /* CompactNotchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactNotchView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ + 30DAA1F3C75615A20920FBC1 /* PseudoLockScreenController */ = { + isa = PBXGroup; + children = ( + E114462CC31BFC8BFDB6571D /* GlobalHotKey.swift */, + 9404B9EDA37A59B491EF92E1 /* LockScreenBackdropView.swift */, + 68EB8D95060ED9D416B0AD35 /* PseudoLockScreenController.swift */, + ); + path = PseudoLockScreenController; + sourceTree = ""; + }; 37C1C34D15B0CD5F0D2E277D /* GestureEngine */ = { isa = PBXGroup; children = ( @@ -114,6 +130,7 @@ 7FE4B98368DF4DFDFD86788F /* LyricsService */, 4B98F2266DC599916DFF0B2D /* MediaMetadataService */, F2DD7F4510AC85ED32374B6D /* NotchWindowController */, + 30DAA1F3C75615A20920FBC1 /* PseudoLockScreenController */, 611867AB07875D7872A85B4F /* VolumeController */, ); path = NotchBeat; @@ -214,7 +231,9 @@ B68F48D70CDCD0AA188976C7 /* CompactNotchView.swift in Sources */, 7CE2042F64E912316794D314 /* ExpandedPlayerView.swift in Sources */, 0732678AD96EAF7126898805 /* GestureEngine.swift in Sources */, + 8CECBA1806341C3ACDF9666D /* GlobalHotKey.swift in Sources */, 852BCE6356651B307827CFA9 /* LiquidGlassUI.swift in Sources */, + B03F55117440084F1A9EEC47 /* LockScreenBackdropView.swift in Sources */, 316A63FA9FCD92E88E0BD9A0 /* LyricsPanelView.swift in Sources */, BD741B1B99AB90F0372BE471 /* LyricsService.swift in Sources */, 2D36EB8D1A9988C8C2B7B781 /* MediaMetadataService.swift in Sources */, @@ -226,6 +245,7 @@ 935A65F30FA58FFC1F3F9D3D /* NotchUIState.swift in Sources */, E7227127738431B9CA49DA60 /* NotchWindowController.swift in Sources */, 1B21A56838FE8FD8C02B0907 /* PKCE.swift in Sources */, + B2D670FE2164BE0E5F5971D3 /* PseudoLockScreenController.swift in Sources */, A879C2CBF374A2AA1F4532C1 /* SpotifyAPIClient.swift in Sources */, B543D6063513F9830EC5CE7A /* SpotifyAuthManager.swift in Sources */, 72522930F2F1674F82C61598 /* VolumeController.swift in Sources */, diff --git a/NotchBeat/App/AppDelegate.swift b/NotchBeat/App/AppDelegate.swift index 4c1ce65..20270d6 100644 --- a/NotchBeat/App/AppDelegate.swift +++ b/NotchBeat/App/AppDelegate.swift @@ -5,9 +5,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let authManager = SpotifyAuthManager() private(set) lazy var metadataService = MediaMetadataService(auth: authManager) private lazy var notchWindowController = NotchWindowController(metadata: metadataService) + let lockScreenController = PseudoLockScreenController() func applicationDidFinishLaunching(_ notification: Notification) { notchWindowController.show() metadataService.startPolling() + // Surface B: register the global ⌃⌘L toggle (also available in the menu). + lockScreenController.installHotKey() + } + + func toggleLockScreen() { + lockScreenController.toggle() } } diff --git a/NotchBeat/App/MenuBarContentView.swift b/NotchBeat/App/MenuBarContentView.swift index 34c124d..11b0730 100644 --- a/NotchBeat/App/MenuBarContentView.swift +++ b/NotchBeat/App/MenuBarContentView.swift @@ -4,6 +4,7 @@ import SwiftUI struct MenuBarContentView: View { @ObservedObject var auth: SpotifyAuthManager + let onToggleLockScreen: () -> Void var body: some View { if !auth.isConfigured { @@ -25,6 +26,10 @@ struct MenuBarContentView: View { } } Divider() + Button("Toggle Lock Screen (⌃⌘L)") { + onToggleLockScreen() + } + Divider() Button("Quit NotchBeat") { NSApplication.shared.terminate(nil) } diff --git a/NotchBeat/App/NotchBeatApp.swift b/NotchBeat/App/NotchBeatApp.swift index 7b8f28f..553bc21 100644 --- a/NotchBeat/App/NotchBeatApp.swift +++ b/NotchBeat/App/NotchBeatApp.swift @@ -7,7 +7,10 @@ struct NotchBeatApp: App { var body: some Scene { MenuBarExtra("NotchBeat", systemImage: "music.note") { - MenuBarContentView(auth: appDelegate.authManager) + MenuBarContentView( + auth: appDelegate.authManager, + onToggleLockScreen: { appDelegate.toggleLockScreen() } + ) } } } diff --git a/NotchBeat/PseudoLockScreenController/GlobalHotKey.swift b/NotchBeat/PseudoLockScreenController/GlobalHotKey.swift new file mode 100644 index 0000000..29d2a3f --- /dev/null +++ b/NotchBeat/PseudoLockScreenController/GlobalHotKey.swift @@ -0,0 +1,90 @@ +import AppKit +import Carbon.HIToolbox + +/// A process-global hotkey via Carbon's `RegisterEventHotKey` — the standard, +/// permission-free way for a menu-bar (LSUIElement) agent to get a system-wide +/// shortcut. NSEvent global monitors would need Accessibility / Input +/// Monitoring permission and can't consume the event; RegisterEventHotKey +/// needs neither and fires regardless of which app is focused. +final class GlobalHotKey { + struct Combo { + let keyCode: UInt32 + let modifiers: UInt32 + + /// ⌃⌘L — toggle the lock-screen surface (PRD §3.2 trigger). + static let toggleLockScreen = Combo( + keyCode: UInt32(kVK_ANSI_L), + modifiers: UInt32(controlKey | cmdKey) + ) + } + + private var ref: EventHotKeyRef? + private let id: UInt32 + private let callback: () -> Void + + // Carbon dispatches to a C function pointer with no captured context, so we + // route hotkey presses back to instances through a shared id → instance map. + private static var registry: [UInt32: GlobalHotKey] = [:] + private static var nextID: UInt32 = 1 + private static var handlerInstalled = false + private static let signature: OSType = 0x4E_54_42_54 // 'NTBT' + + init(_ combo: Combo, callback: @escaping () -> Void) { + self.callback = callback + self.id = GlobalHotKey.nextID + GlobalHotKey.nextID += 1 + GlobalHotKey.registry[id] = self + + GlobalHotKey.installDispatchHandlerIfNeeded() + let hotKeyID = EventHotKeyID(signature: GlobalHotKey.signature, id: id) + RegisterEventHotKey( + combo.keyCode, + combo.modifiers, + hotKeyID, + GetApplicationEventTarget(), + 0, + &ref + ) + } + + deinit { + if let ref { UnregisterEventHotKey(ref) } + GlobalHotKey.registry[id] = nil + } + + private static func installDispatchHandlerIfNeeded() { + guard !handlerInstalled else { return } + handlerInstalled = true + + var spec = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + InstallEventHandler( + GetApplicationEventTarget(), + { _, event, _ -> OSStatus in + var hkID = EventHotKeyID() + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hkID + ) + if status == noErr { + let targetID = hkID.id + DispatchQueue.main.async { + GlobalHotKey.registry[targetID]?.callback() + } + } + return noErr + }, + 1, + &spec, + nil, + nil + ) + } +} diff --git a/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift b/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift new file mode 100644 index 0000000..fd6067f --- /dev/null +++ b/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift @@ -0,0 +1,69 @@ +import AppKit +import SwiftUI + +/// The lock-screen backdrop (PRD §3.2 L1, reference image 1): the desktop +/// wallpaper filling the screen with a large clock + date near the top. The +/// now-playing widget is layered on in phase 6. +struct LockScreenBackdropView: View { + var body: some View { + ZStack(alignment: .top) { + WallpaperView() + ClockView() + .padding(.top, 64) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + } +} + +/// Fills the window with the current desktop wallpaper (aspect-fill), falling +/// back to a dark gradient if it can't be read. +private struct WallpaperView: View { + @State private var image: NSImage? + + var body: some View { + GeometryReader { geo in + Group { + if let image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + LinearGradient( + colors: [Color(white: 0.18), .black], + startPoint: .top, + endPoint: .bottom + ) + } + } + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + } + .ignoresSafeArea() + .task { loadWallpaper() } + } + + private func loadWallpaper() { + guard let screen = NSScreen.main, + let url = NSWorkspace.shared.desktopImageURL(for: screen) else { return } + image = NSImage(contentsOf: url) + } +} + +/// Big time + date, updating each second, styled like the iOS/macOS lock screen. +private struct ClockView: View { + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + VStack(spacing: 0) { + Text(context.date, format: .dateTime.weekday(.abbreviated).month(.abbreviated).day()) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + Text(context.date, format: .dateTime.hour().minute()) + .font(.system(size: 96, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .monospacedDigit() + } + .shadow(color: .black.opacity(0.35), radius: 18, y: 4) + } + } +} diff --git a/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift b/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift new file mode 100644 index 0000000..08e58f7 --- /dev/null +++ b/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift @@ -0,0 +1,86 @@ +import AppKit +import SwiftUI + +/// Surface B: a borderless, fullscreen window on the built-in display that +/// recreates a lock-screen backdrop (wallpaper + clock). It hosts the same +/// compact → expand → lyrics flow as the notch (added in phases 6–8); phase 5 +/// is just the shell + trigger. +/// +/// This is NOT the real macOS lock screen — that can't host third-party UI +/// (PRD §4). It's an ordinary top-level window shown while logged in; it does +/// not overlay the password screen or gate unlock. +@MainActor +final class PseudoLockScreenController { + private var window: LockScreenWindow? + private var hotKey: GlobalHotKey? + private(set) var isVisible = false + + /// Registers the global ⌃⌘L toggle. Call once at launch. + func installHotKey() { + hotKey = GlobalHotKey(.toggleLockScreen) { [weak self] in + self?.toggle() + } + } + + func toggle() { + isVisible ? hide() : show() + } + + func show() { + guard !isVisible else { return } + // The lock screen belongs on the built-in display; fall back to the + // main screen if there's no notched screen (e.g. clamshell). + guard let screen = NotchMetrics.builtInNotchedScreen() ?? NSScreen.main else { return } + + let window = LockScreenWindow( + contentRect: screen.frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.setFrame(screen.frame, display: true) + window.isOpaque = true + window.backgroundColor = .black + // Above the menu bar and normal windows so it reads as a full takeover, + // like a lock screen. Esc / the hotkey / click-away dismiss it. + window.level = .screenSaver + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] + window.isReleasedWhenClosed = false + window.onDismiss = { [weak self] in self?.hide() } + window.contentView = NSHostingView(rootView: LockScreenBackdropView()) + + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + self.window = window + isVisible = true + } + + func hide() { + window?.orderOut(nil) + window = nil + isVisible = false + } +} + +/// Borderless window that can take key focus (so Esc works) and reports +/// dismissal intents (Esc key) back to the controller. +final class LockScreenWindow: NSWindow { + var onDismiss: (() -> Void)? + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + /// Esc — AppKit routes it here as the standard "cancel" action. + override func cancelOperation(_ sender: Any?) { + onDismiss?() + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { // Esc, as a backup to cancelOperation + onDismiss?() + } else { + super.keyDown(with: event) + } + } +}