From 055866d0a9d0e277f707d5ec3c863e626a2e2585 Mon Sep 17 00:00:00 2001 From: Tirth Date: Fri, 10 Jul 2026 16:52:38 +0530 Subject: [PATCH] Phase 6: lock-screen now-playing widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds state L1 (PRD §3.2, reference image 1): a live now-playing widget on the lock-screen backdrop. LockScreenNowPlayingWidget is a frosted card (album art, title/artist, scrubber, prev/pause-play/next) floating in the lower-middle over the wallpaper, wired to the shared MediaMetadataService — same live Spotify state and transport controls the notch uses. Reuses ScrubberView from the notch player. Shown for any active track (playing or paused), fades out to just wallpaper + clock when idle. Co-Authored-By: Claude Fable 5 --- NotchBeat.xcodeproj/project.pbxproj | 4 + NotchBeat/App/AppDelegate.swift | 2 +- .../LockScreenBackdropView.swift | 34 ++++++-- .../LockScreenNowPlayingWidget.swift | 85 +++++++++++++++++++ .../PseudoLockScreenController.swift | 7 +- 5 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 NotchBeat/PseudoLockScreenController/LockScreenNowPlayingWidget.swift diff --git a/NotchBeat.xcodeproj/project.pbxproj b/NotchBeat.xcodeproj/project.pbxproj index 5186842..2c9f80d 100644 --- a/NotchBeat.xcodeproj/project.pbxproj +++ b/NotchBeat.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ CF607D3364BA55B9E3E6AD90 /* NotchBeatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21253425E6FA10293E2FFD4A /* NotchBeatApp.swift */; }; DFA3175B057F2A2348AB9A1F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C75977B9D3E98AEEFDDE820 /* AppDelegate.swift */; }; E7227127738431B9CA49DA60 /* NotchWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99A7818B9692BE3A3237E75 /* NotchWindowController.swift */; }; + F1494A1F40A2D6FFCBC28F17 /* LockScreenNowPlayingWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8741427FCCF71EA5F049344D /* LockScreenNowPlayingWidget.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -45,6 +46,7 @@ 7D3213245D2618045EA009B9 /* NotchMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchMetrics.swift; sourceTree = ""; }; 7F19FAF1275C541AB173F1F7 /* MediaMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaMetadataService.swift; sourceTree = ""; }; 81D658A3FC6D7C63D43E2149 /* LyricsPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsPanelView.swift; sourceTree = ""; }; + 8741427FCCF71EA5F049344D /* LockScreenNowPlayingWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenNowPlayingWidget.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 = ""; }; @@ -64,6 +66,7 @@ children = ( E114462CC31BFC8BFDB6571D /* GlobalHotKey.swift */, 9404B9EDA37A59B491EF92E1 /* LockScreenBackdropView.swift */, + 8741427FCCF71EA5F049344D /* LockScreenNowPlayingWidget.swift */, 68EB8D95060ED9D416B0AD35 /* PseudoLockScreenController.swift */, ); path = PseudoLockScreenController; @@ -234,6 +237,7 @@ 8CECBA1806341C3ACDF9666D /* GlobalHotKey.swift in Sources */, 852BCE6356651B307827CFA9 /* LiquidGlassUI.swift in Sources */, B03F55117440084F1A9EEC47 /* LockScreenBackdropView.swift in Sources */, + F1494A1F40A2D6FFCBC28F17 /* LockScreenNowPlayingWidget.swift in Sources */, 316A63FA9FCD92E88E0BD9A0 /* LyricsPanelView.swift in Sources */, BD741B1B99AB90F0372BE471 /* LyricsService.swift in Sources */, 2D36EB8D1A9988C8C2B7B781 /* MediaMetadataService.swift in Sources */, diff --git a/NotchBeat/App/AppDelegate.swift b/NotchBeat/App/AppDelegate.swift index 20270d6..9eea3bb 100644 --- a/NotchBeat/App/AppDelegate.swift +++ b/NotchBeat/App/AppDelegate.swift @@ -5,7 +5,7 @@ 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() + private lazy var lockScreenController = PseudoLockScreenController(metadata: metadataService) func applicationDidFinishLaunching(_ notification: Notification) { notchWindowController.show() diff --git a/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift b/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift index fd6067f..2f3c027 100644 --- a/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift +++ b/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift @@ -2,16 +2,36 @@ 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. +/// wallpaper filling the screen, a large clock + date near the top, and the +/// live now-playing widget floating in the lower-middle when a track is active. struct LockScreenBackdropView: View { + @ObservedObject var metadata: MediaMetadataService + var body: some View { - ZStack(alignment: .top) { - WallpaperView() - ClockView() - .padding(.top, 64) + GeometryReader { geo in + ZStack(alignment: .top) { + WallpaperView() + + ClockView() + .position(x: geo.size.width / 2, y: max(96, geo.size.height * 0.17)) + + if case .active(let nowPlaying) = metadata.state { + LockScreenNowPlayingWidget( + nowPlaying: nowPlaying, + albumArt: metadata.albumArt, + onPlayPause: { Task { await metadata.togglePlayPause() } }, + onPrevious: { Task { await metadata.skipToPrevious() } }, + onNext: { Task { await metadata.skipToNext() } }, + onSeek: { position in Task { await metadata.seek(to: position) } } + ) + .position(x: geo.size.width / 2, y: geo.size.height * 0.60) + .transition(.opacity) + } + } + // Flips only on idle↔active, not on every poll, so the widget fades + // in/out cleanly without re-animating as playback progresses. + .animation(.easeInOut(duration: 0.3), value: metadata.state == .idle) } - .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() } } diff --git a/NotchBeat/PseudoLockScreenController/LockScreenNowPlayingWidget.swift b/NotchBeat/PseudoLockScreenController/LockScreenNowPlayingWidget.swift new file mode 100644 index 0000000..be90564 --- /dev/null +++ b/NotchBeat/PseudoLockScreenController/LockScreenNowPlayingWidget.swift @@ -0,0 +1,85 @@ +import AppKit +import SwiftUI + +/// The compact now-playing widget on the lock screen (PRD §3.2 L1, reference +/// image 1): a frosted card floating over the wallpaper with album art, track +/// info, a scrubber, and transport controls. Reuses `ScrubberView` (from the +/// notch player) so the progress bar / drag-to-seek behaves identically. +struct LockScreenNowPlayingWidget: View { + let nowPlaying: NowPlaying + let albumArt: NSImage? + let onPlayPause: () -> Void + let onPrevious: () -> Void + let onNext: () -> Void + let onSeek: (TimeInterval) -> Void + + var body: some View { + VStack(spacing: 14) { + HStack(spacing: 12) { + artwork + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + VStack(alignment: .leading, spacing: 2) { + Text(nowPlaying.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + Text(nowPlaying.artist) + .font(.system(size: 13)) + .foregroundStyle(.white.opacity(0.65)) + } + .lineLimit(1) + Spacer(minLength: 0) + } + + ScrubberView(nowPlaying: nowPlaying, onSeek: onSeek, onActivity: {}) + + HStack(spacing: 40) { + transportButton("backward.fill", size: 18, action: onPrevious) + transportButton(nowPlaying.isPlaying ? "pause.fill" : "play.fill", size: 26, action: onPlayPause) + .contentTransition(.symbolEffect(.replace)) + transportButton("forward.fill", size: 18, action: onNext) + } + } + .padding(18) + .frame(width: 340) + .background { + let shape = RoundedRectangle(cornerRadius: 22, style: .continuous) + // Frosted glass + a dark tint so white text stays legible over any + // wallpaper (bright or dark). + ZStack { + shape.fill(.ultraThinMaterial) + shape.fill(.black.opacity(0.28)) + } + } + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .shadow(color: .black.opacity(0.35), radius: 24, y: 8) + } + + @ViewBuilder + private var artwork: some View { + if let albumArt { + Image(nsImage: albumArt) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(white: 0.25)) + .overlay { + Image(systemName: "music.note") + .font(.system(size: 24, weight: .medium)) + .foregroundStyle(.white.opacity(0.8)) + } + } + } + + private func transportButton(_ symbol: String, size: CGFloat, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: size, weight: .semibold)) + .foregroundStyle(.white.opacity(0.95)) + .frame(width: size + 18, height: size + 14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift b/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift index 08e58f7..74d8fbb 100644 --- a/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift +++ b/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift @@ -11,10 +11,15 @@ import SwiftUI /// not overlay the password screen or gate unlock. @MainActor final class PseudoLockScreenController { + private let metadata: MediaMetadataService private var window: LockScreenWindow? private var hotKey: GlobalHotKey? private(set) var isVisible = false + init(metadata: MediaMetadataService) { + self.metadata = metadata + } + /// Registers the global ⌃⌘L toggle. Call once at launch. func installHotKey() { hotKey = GlobalHotKey(.toggleLockScreen) { [weak self] in @@ -47,7 +52,7 @@ final class PseudoLockScreenController { window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] window.isReleasedWhenClosed = false window.onDismiss = { [weak self] in self?.hide() } - window.contentView = NSHostingView(rootView: LockScreenBackdropView()) + window.contentView = NSHostingView(rootView: LockScreenBackdropView(metadata: metadata)) window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true)