From 03914ff18dfc7b19aee7327e769d0d9c2d20c053 Mon Sep 17 00:00:00 2001 From: Tirth Date: Fri, 10 Jul 2026 17:05:35 +0530 Subject: [PATCH] Phase 7: lock-screen expand (state L2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the widget's art/track expands to the full view (PRD §3.2 L2, reference image 2): the wallpaper dims toward black, the album art scales up to center via a matched-geometry hero morph, and a frosted control pill (title/artist, scrubber, transport) settles below it. Click-away on the dimmed backdrop or Esc collapses back to the widget; Esc at the widget level dismisses the surface. New LockScreenUIState (widget/expanded/lyrics presentation, shared spring), LockScreenExpandedView, and a shared TransportButtons component reused by both the widget and the expanded view. Co-Authored-By: Claude Fable 5 --- NotchBeat.xcodeproj/project.pbxproj | 12 +++ .../LockScreenBackdropView.swift | 67 ++++++++++++---- .../LockScreenControls.swift | 37 +++++++++ .../LockScreenExpandedView.swift | 80 +++++++++++++++++++ .../LockScreenNowPlayingWidget.swift | 35 ++++---- .../LockScreenUIState.swift | 62 ++++++++++++++ .../PseudoLockScreenController.swift | 17 +++- 7 files changed, 274 insertions(+), 36 deletions(-) create mode 100644 NotchBeat/PseudoLockScreenController/LockScreenControls.swift create mode 100644 NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift create mode 100644 NotchBeat/PseudoLockScreenController/LockScreenUIState.swift diff --git a/NotchBeat.xcodeproj/project.pbxproj b/NotchBeat.xcodeproj/project.pbxproj index 2c9f80d..f562679 100644 --- a/NotchBeat.xcodeproj/project.pbxproj +++ b/NotchBeat.xcodeproj/project.pbxproj @@ -25,16 +25,21 @@ 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 */; }; + C1A583D31E0BE32E48DD776A /* LockScreenUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474238E0460DBDA760586035 /* LockScreenUIState.swift */; }; C7236C71AD7D95EED5695CFD /* NotchLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87D141A7BB7819335674E9B /* NotchLayout.swift */; }; 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 */; }; + ECBB61F19F9B55A9BB57631E /* LockScreenControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30FA4A6CC5E1F4A8DBCB4DAA /* LockScreenControls.swift */; }; + EFA41F7AEDAB74B765C30B56 /* LockScreenExpandedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A614F3F6690B30F4FB4E602A /* LockScreenExpandedView.swift */; }; F1494A1F40A2D6FFCBC28F17 /* LockScreenNowPlayingWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8741427FCCF71EA5F049344D /* LockScreenNowPlayingWidget.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 0C0A326FFF2D90FCF3CA1C1C /* ExpandedPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandedPlayerView.swift; sourceTree = ""; }; 21253425E6FA10293E2FFD4A /* NotchBeatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchBeatApp.swift; sourceTree = ""; }; + 30FA4A6CC5E1F4A8DBCB4DAA /* LockScreenControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenControls.swift; sourceTree = ""; }; + 474238E0460DBDA760586035 /* LockScreenUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenUIState.swift; sourceTree = ""; }; 58A3D07D6AF6BC0759ABB9C7 /* SpotifyAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyAuthManager.swift; sourceTree = ""; }; 5CE226CBE5A055D67B715329 /* MenuBarContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarContentView.swift; sourceTree = ""; }; 5DDE8CB4F434738E3DA92CE4 /* PKCE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PKCE.swift; sourceTree = ""; }; @@ -49,6 +54,7 @@ 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 = ""; }; + A614F3F6690B30F4FB4E602A /* LockScreenExpandedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenExpandedView.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; }; @@ -66,7 +72,10 @@ children = ( E114462CC31BFC8BFDB6571D /* GlobalHotKey.swift */, 9404B9EDA37A59B491EF92E1 /* LockScreenBackdropView.swift */, + 30FA4A6CC5E1F4A8DBCB4DAA /* LockScreenControls.swift */, + A614F3F6690B30F4FB4E602A /* LockScreenExpandedView.swift */, 8741427FCCF71EA5F049344D /* LockScreenNowPlayingWidget.swift */, + 474238E0460DBDA760586035 /* LockScreenUIState.swift */, 68EB8D95060ED9D416B0AD35 /* PseudoLockScreenController.swift */, ); path = PseudoLockScreenController; @@ -237,7 +246,10 @@ 8CECBA1806341C3ACDF9666D /* GlobalHotKey.swift in Sources */, 852BCE6356651B307827CFA9 /* LiquidGlassUI.swift in Sources */, B03F55117440084F1A9EEC47 /* LockScreenBackdropView.swift in Sources */, + ECBB61F19F9B55A9BB57631E /* LockScreenControls.swift in Sources */, + EFA41F7AEDAB74B765C30B56 /* LockScreenExpandedView.swift in Sources */, F1494A1F40A2D6FFCBC28F17 /* LockScreenNowPlayingWidget.swift in Sources */, + C1A583D31E0BE32E48DD776A /* LockScreenUIState.swift in Sources */, 316A63FA9FCD92E88E0BD9A0 /* LyricsPanelView.swift in Sources */, BD741B1B99AB90F0372BE471 /* LyricsService.swift in Sources */, 2D36EB8D1A9988C8C2B7B781 /* MediaMetadataService.swift in Sources */, diff --git a/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift b/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift index 2f3c027..fcfe29e 100644 --- a/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift +++ b/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift @@ -1,39 +1,76 @@ import AppKit import SwiftUI -/// The lock-screen backdrop (PRD §3.2 L1, reference image 1): the desktop -/// 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. +/// The lock-screen backdrop: wallpaper + clock, plus the now-playing surface +/// that morphs between the compact widget (L1, image 1) and the expanded view +/// (L2, image 2). The wallpaper dims toward black as it expands. struct LockScreenBackdropView: View { @ObservedObject var metadata: MediaMetadataService + @ObservedObject var ui: LockScreenUIState + @Namespace private var morph + + /// Wallpaper stays bright at the widget level, dims near-black once expanded. + private var scrim: Double { ui.presentation == .widget ? 0 : 0.92 } var body: some View { GeometryReader { geo in - ZStack(alignment: .top) { + ZStack { WallpaperView() + Color.black.opacity(scrim).ignoresSafeArea() 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) + nowPlayingSurface(nowPlaying, in: geo) } } - // Flips only on idle↔active, not on every poll, so the widget fades + // Flips only on idle↔active, not on every poll, so the surface fades // in/out cleanly without re-animating as playback progresses. .animation(.easeInOut(duration: 0.3), value: metadata.state == .idle) } .ignoresSafeArea() } + + @ViewBuilder + private func nowPlayingSurface(_ nowPlaying: NowPlaying, in geo: GeometryProxy) -> some View { + switch ui.presentation { + case .widget: + LockScreenNowPlayingWidget( + nowPlaying: nowPlaying, + albumArt: metadata.albumArt, + namespace: morph, + onExpand: { ui.expand() }, + 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) + + case .expanded, .lyrics: + // (.lyrics renders the expanded view until phase 8 adds the lyrics panel.) + ZStack { + // Click-away on the dimmed backdrop steps back down a level. + Color.clear + .contentShape(Rectangle()) + .onTapGesture { ui.stepDown() } + + LockScreenExpandedView( + nowPlaying: nowPlaying, + albumArt: metadata.albumArt, + namespace: morph, + onPlayPause: { Task { await metadata.togglePlayPause() } }, + onPrevious: { Task { await metadata.skipToPrevious() } }, + onNext: { Task { await metadata.skipToNext() } }, + onSeek: { position in Task { await metadata.seek(to: position) } } + ) + .transition(.opacity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } } /// Fills the window with the current desktop wallpaper (aspect-fill), falling diff --git a/NotchBeat/PseudoLockScreenController/LockScreenControls.swift b/NotchBeat/PseudoLockScreenController/LockScreenControls.swift new file mode 100644 index 0000000..28dd3e8 --- /dev/null +++ b/NotchBeat/PseudoLockScreenController/LockScreenControls.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// Shared matched-geometry id for the lock-screen album-art hero morph +/// (widget ↔ expanded). +enum LockScreenMorph { + static let art = "ls-art" +} + +/// Shared prev / play-pause / next row used by the lock-screen widget (L1) and +/// the expanded view (L2), so the transport controls stay identical. +struct TransportButtons: View { + let isPlaying: Bool + let onPrevious: () -> Void + let onPlayPause: () -> Void + let onNext: () -> Void + var primarySize: CGFloat = 26 + + var body: some View { + HStack(spacing: 40) { + button("backward.fill", primarySize * 0.7, onPrevious) + button(isPlaying ? "pause.fill" : "play.fill", primarySize, onPlayPause) + .contentTransition(.symbolEffect(.replace)) + button("forward.fill", primarySize * 0.7, onNext) + } + } + + private func button(_ 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 + 20, height: size + 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift b/NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift new file mode 100644 index 0000000..4fccc4d --- /dev/null +++ b/NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift @@ -0,0 +1,80 @@ +import AppKit +import SwiftUI + +/// Expanded lock-screen now-playing (PRD §3.2 L2, reference image 2): large +/// album art centered on the dimmed backdrop, with a frosted control pill +/// (title/artist, scrubber, transport) below it. The art carries the hero +/// morph from the compact widget. +struct LockScreenExpandedView: View { + let nowPlaying: NowPlaying + let albumArt: NSImage? + let namespace: Namespace.ID + let onPlayPause: () -> Void + let onPrevious: () -> Void + let onNext: () -> Void + let onSeek: (TimeInterval) -> Void + + var body: some View { + VStack(spacing: 28) { + artwork + .frame(width: 320, height: 320) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .matchedGeometryEffect(id: LockScreenMorph.art, in: namespace) + .shadow(color: .black.opacity(0.5), radius: 30, y: 14) + + controlPill + } + } + + private var controlPill: some View { + VStack(spacing: 14) { + VStack(spacing: 3) { + Text(nowPlaying.title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + Text(nowPlaying.artist) + .font(.system(size: 14)) + .foregroundStyle(.white.opacity(0.65)) + } + .lineLimit(1) + + ScrubberView(nowPlaying: nowPlaying, onSeek: onSeek, onActivity: {}) + + TransportButtons( + isPlaying: nowPlaying.isPlaying, + onPrevious: onPrevious, + onPlayPause: onPlayPause, + onNext: onNext, + primarySize: 30 + ) + .padding(.top, 2) + } + .padding(20) + .frame(width: 360) + .background { + let shape = RoundedRectangle(cornerRadius: 24, style: .continuous) + ZStack { + shape.fill(.ultraThinMaterial) + shape.fill(.black.opacity(0.28)) + } + } + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + } + + @ViewBuilder + private var artwork: some View { + if let albumArt { + Image(nsImage: albumArt) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(white: 0.22)) + .overlay { + Image(systemName: "music.note") + .font(.system(size: 80, weight: .medium)) + .foregroundStyle(.white.opacity(0.8)) + } + } + } +} diff --git a/NotchBeat/PseudoLockScreenController/LockScreenNowPlayingWidget.swift b/NotchBeat/PseudoLockScreenController/LockScreenNowPlayingWidget.swift index be90564..0fdbd52 100644 --- a/NotchBeat/PseudoLockScreenController/LockScreenNowPlayingWidget.swift +++ b/NotchBeat/PseudoLockScreenController/LockScreenNowPlayingWidget.swift @@ -3,11 +3,13 @@ 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. +/// info, a scrubber, and transport controls. Tapping the art / track info +/// expands to the full view (L2). Reuses `ScrubberView` and `TransportButtons`. struct LockScreenNowPlayingWidget: View { let nowPlaying: NowPlaying let albumArt: NSImage? + let namespace: Namespace.ID + let onExpand: () -> Void let onPlayPause: () -> Void let onPrevious: () -> Void let onNext: () -> Void @@ -19,6 +21,7 @@ struct LockScreenNowPlayingWidget: View { artwork .frame(width: 56, height: 56) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .matchedGeometryEffect(id: LockScreenMorph.art, in: namespace) VStack(alignment: .leading, spacing: 2) { Text(nowPlaying.title) .font(.system(size: 15, weight: .semibold)) @@ -30,15 +33,20 @@ struct LockScreenNowPlayingWidget: View { .lineLimit(1) Spacer(minLength: 0) } + // Tapping the art / track info opens the expanded view; the + // transport buttons below keep their own taps. + .contentShape(Rectangle()) + .onTapGesture(perform: onExpand) 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) - } + TransportButtons( + isPlaying: nowPlaying.isPlaying, + onPrevious: onPrevious, + onPlayPause: onPlayPause, + onNext: onNext, + primarySize: 26 + ) } .padding(18) .frame(width: 340) @@ -71,15 +79,4 @@ struct LockScreenNowPlayingWidget: View { } } } - - 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/LockScreenUIState.swift b/NotchBeat/PseudoLockScreenController/LockScreenUIState.swift new file mode 100644 index 0000000..5e26e35 --- /dev/null +++ b/NotchBeat/PseudoLockScreenController/LockScreenUIState.swift @@ -0,0 +1,62 @@ +import SwiftUI + +/// Interaction state for the lock-screen surface: which of the three states +/// (widget / expanded / lyrics) is presented, and the shared spring. Parallels +/// the notch's `NotchUIState`; closing is always opening reversed. +@MainActor +final class LockScreenUIState: ObservableObject { + enum Presentation: Equatable { + case widget // L1 + case expanded // L2 + case lyrics // L3 (phase 8) + } + + /// Same feel as the notch's open/close (NotchUIState.spring). + static let spring = Animation.smooth(duration: 0.5, extraBounce: 0.12) + + @Published private(set) var presentation: Presentation = .widget + + /// L1 → L2: click the widget. + func expand() { + guard presentation == .widget else { return } + set(.expanded) + } + + /// L2/L1 → L3: two-finger swipe (phase 8). + func showLyrics() { + guard presentation != .lyrics else { return } + set(.lyrics) + } + + /// L3 → L2: swipe back (phase 8). + func returnToExpanded() { + guard presentation == .lyrics else { return } + set(.expanded) + } + + /// Any → L1. + func collapse() { + set(.widget) + } + + /// Esc / click-away steps down one level: lyrics → expanded → widget. The + /// controller hides the whole surface when this is called at the widget + /// level. + func stepDown() { + switch presentation { + case .lyrics: set(.expanded) + case .expanded: set(.widget) + case .widget: break + } + } + + /// Instant reset (no animation) when the surface is hidden, so it reopens + /// at the widget state. + func reset() { + presentation = .widget + } + + private func set(_ next: Presentation) { + withAnimation(Self.spring) { presentation = next } + } +} diff --git a/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift b/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift index 74d8fbb..8440108 100644 --- a/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift +++ b/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift @@ -12,6 +12,7 @@ import SwiftUI @MainActor final class PseudoLockScreenController { private let metadata: MediaMetadataService + private let ui = LockScreenUIState() private var window: LockScreenWindow? private var hotKey: GlobalHotKey? private(set) var isVisible = false @@ -51,8 +52,8 @@ final class PseudoLockScreenController { window.level = .screenSaver window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] window.isReleasedWhenClosed = false - window.onDismiss = { [weak self] in self?.hide() } - window.contentView = NSHostingView(rootView: LockScreenBackdropView(metadata: metadata)) + window.onDismiss = { [weak self] in self?.handleEscape() } + window.contentView = NSHostingView(rootView: LockScreenBackdropView(metadata: metadata, ui: ui)) window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) @@ -65,6 +66,18 @@ final class PseudoLockScreenController { window?.orderOut(nil) window = nil isVisible = false + // Reopen fresh at the widget state. + ui.reset() + } + + /// Esc steps back one level (lyrics → expanded → widget); pressing it at + /// the widget level dismisses the whole surface. + private func handleEscape() { + if ui.presentation == .widget { + hide() + } else { + ui.stepDown() + } } }