diff --git a/NotchBeat.xcodeproj/project.pbxproj b/NotchBeat.xcodeproj/project.pbxproj index bf37ed9..daaf88e 100644 --- a/NotchBeat.xcodeproj/project.pbxproj +++ b/NotchBeat.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 1B21A56838FE8FD8C02B0907 /* PKCE.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDE8CB4F434738E3DA92CE4 /* PKCE.swift */; }; 2D36EB8D1A9988C8C2B7B781 /* MediaMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F19FAF1275C541AB173F1F7 /* MediaMetadataService.swift */; }; 316A63FA9FCD92E88E0BD9A0 /* LyricsPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81D658A3FC6D7C63D43E2149 /* LyricsPanelView.swift */; }; + 437E1ACCA99EA5DC1F2AC1C6 /* LockScreenLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = E34E04AFF0EF337B09488D2F /* LockScreenLayout.swift */; }; 57EAB3E0EA2FA7D3A9EFF38A /* NotchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A862BCC79D94A263060B84F3 /* NotchRootView.swift */; }; 5BB1E43ADDFAFED421D13ACF /* MenuBarContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE226CBE5A055D67B715329 /* MenuBarContentView.swift */; }; 72522930F2F1674F82C61598 /* VolumeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EBE1A010C9C568DF7EF30B9 /* VolumeController.swift */; }; @@ -27,18 +28,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 */; }; + C177B8C0317B675C7F35D539 /* LockScreenWallpaper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16113F68F8AABB7034250A19 /* LockScreenWallpaper.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 */; }; + E1403BD41DF3C6FEEE6AE730 /* AlbumArtColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A6B47FE1219D7765B6B11F6 /* AlbumArtColor.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 = ""; }; + 16113F68F8AABB7034250A19 /* LockScreenWallpaper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenWallpaper.swift; sourceTree = ""; }; + 1A6B47FE1219D7765B6B11F6 /* AlbumArtColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumArtColor.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 = ""; }; @@ -57,7 +61,6 @@ 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 = ""; }; C6DDE1A937A6DE6D87F8066F /* LockScreenLyricsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenLyricsView.swift; sourceTree = ""; }; @@ -66,6 +69,7 @@ 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 = ""; }; + E34E04AFF0EF337B09488D2F /* LockScreenLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenLayout.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 */ @@ -77,10 +81,11 @@ E114462CC31BFC8BFDB6571D /* GlobalHotKey.swift */, 9404B9EDA37A59B491EF92E1 /* LockScreenBackdropView.swift */, 30FA4A6CC5E1F4A8DBCB4DAA /* LockScreenControls.swift */, - A614F3F6690B30F4FB4E602A /* LockScreenExpandedView.swift */, + E34E04AFF0EF337B09488D2F /* LockScreenLayout.swift */, C6DDE1A937A6DE6D87F8066F /* LockScreenLyricsView.swift */, 8741427FCCF71EA5F049344D /* LockScreenNowPlayingWidget.swift */, 474238E0460DBDA760586035 /* LockScreenUIState.swift */, + 16113F68F8AABB7034250A19 /* LockScreenWallpaper.swift */, 68EB8D95060ED9D416B0AD35 /* PseudoLockScreenController.swift */, ); path = PseudoLockScreenController; @@ -98,6 +103,7 @@ 4B98F2266DC599916DFF0B2D /* MediaMetadataService */ = { isa = PBXGroup; children = ( + 1A6B47FE1219D7765B6B11F6 /* AlbumArtColor.swift */, 7F19FAF1275C541AB173F1F7 /* MediaMetadataService.swift */, 5DDE8CB4F434738E3DA92CE4 /* PKCE.swift */, 709D63BE29CA0D22B1F331FB /* SpotifyAPIClient.swift */, @@ -245,6 +251,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E1403BD41DF3C6FEEE6AE730 /* AlbumArtColor.swift in Sources */, DFA3175B057F2A2348AB9A1F /* AppDelegate.swift in Sources */, B68F48D70CDCD0AA188976C7 /* CompactNotchView.swift in Sources */, 7CE2042F64E912316794D314 /* ExpandedPlayerView.swift in Sources */, @@ -253,10 +260,11 @@ 852BCE6356651B307827CFA9 /* LiquidGlassUI.swift in Sources */, B03F55117440084F1A9EEC47 /* LockScreenBackdropView.swift in Sources */, ECBB61F19F9B55A9BB57631E /* LockScreenControls.swift in Sources */, - EFA41F7AEDAB74B765C30B56 /* LockScreenExpandedView.swift in Sources */, + 437E1ACCA99EA5DC1F2AC1C6 /* LockScreenLayout.swift in Sources */, AE3D9D73EE09457F9FC9F512 /* LockScreenLyricsView.swift in Sources */, F1494A1F40A2D6FFCBC28F17 /* LockScreenNowPlayingWidget.swift in Sources */, C1A583D31E0BE32E48DD776A /* LockScreenUIState.swift in Sources */, + C177B8C0317B675C7F35D539 /* LockScreenWallpaper.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 408bcda..b3a8461 100644 --- a/NotchBeat/App/AppDelegate.swift +++ b/NotchBeat/App/AppDelegate.swift @@ -4,9 +4,12 @@ import AppKit final class AppDelegate: NSObject, NSApplicationDelegate { let authManager = SpotifyAuthManager() let gestureEngine = GestureEngine() + let lockScreenWallpaper = LockScreenWallpaper() private(set) lazy var metadataService = MediaMetadataService(auth: authManager) private lazy var notchWindowController = NotchWindowController(metadata: metadataService) - private lazy var lockScreenController = PseudoLockScreenController(metadata: metadataService) + private lazy var lockScreenController = PseudoLockScreenController( + metadata: metadataService, wallpaper: lockScreenWallpaper + ) func applicationDidFinishLaunching(_ notification: Notification) { notchWindowController.show() @@ -18,4 +21,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func toggleLockScreen() { lockScreenController.toggle() } + + func chooseLockScreenWallpaper() { + lockScreenWallpaper.choose() + } + + func resetLockScreenWallpaper() { + lockScreenWallpaper.resetToDesktop() + } } diff --git a/NotchBeat/App/MenuBarContentView.swift b/NotchBeat/App/MenuBarContentView.swift index 8e6c873..f706135 100644 --- a/NotchBeat/App/MenuBarContentView.swift +++ b/NotchBeat/App/MenuBarContentView.swift @@ -5,7 +5,10 @@ import SwiftUI struct MenuBarContentView: View { @ObservedObject var auth: SpotifyAuthManager @ObservedObject var gestureEngine: GestureEngine + @ObservedObject var wallpaper: LockScreenWallpaper let onToggleLockScreen: () -> Void + let onChooseWallpaper: () -> Void + let onResetWallpaper: () -> Void var body: some View { if !auth.isConfigured { @@ -30,6 +33,14 @@ struct MenuBarContentView: View { Button("Toggle Lock Screen (⌃⌘L)") { onToggleLockScreen() } + Button("Set Lock Screen Wallpaper…") { + onChooseWallpaper() + } + if wallpaper.isCustom { + Button("Use Desktop Wallpaper") { + onResetWallpaper() + } + } // Opt-in: enabling prompts for Input Monitoring the first time, since // the left-edge volume slide reads raw trackpad data (PRD F4). Toggle("Left-edge Volume Gesture", isOn: Binding( diff --git a/NotchBeat/App/NotchBeatApp.swift b/NotchBeat/App/NotchBeatApp.swift index d381cc5..7eb7468 100644 --- a/NotchBeat/App/NotchBeatApp.swift +++ b/NotchBeat/App/NotchBeatApp.swift @@ -10,7 +10,10 @@ struct NotchBeatApp: App { MenuBarContentView( auth: appDelegate.authManager, gestureEngine: appDelegate.gestureEngine, - onToggleLockScreen: { appDelegate.toggleLockScreen() } + wallpaper: appDelegate.lockScreenWallpaper, + onToggleLockScreen: { appDelegate.toggleLockScreen() }, + onChooseWallpaper: { appDelegate.chooseLockScreenWallpaper() }, + onResetWallpaper: { appDelegate.resetLockScreenWallpaper() } ) } } diff --git a/NotchBeat/LiquidGlassUI/LiquidGlassUI.swift b/NotchBeat/LiquidGlassUI/LiquidGlassUI.swift index 97e1a1f..b635b35 100644 --- a/NotchBeat/LiquidGlassUI/LiquidGlassUI.swift +++ b/NotchBeat/LiquidGlassUI/LiquidGlassUI.swift @@ -1,23 +1,38 @@ import SwiftUI +/// Visual weight of a `LiquidGlassPanel`. +enum LiquidGlassStyle { + /// Dark-tinted glass — legible container for dense UI (notch panels). + case dark + /// Untinted, fully transparent glass — the backdrop reads straight + /// through (lock-screen now-playing card). + case clear +} + /// Thin wrapper around the glass background material (ARCHITECTURE.md §3): /// real Liquid Glass (`.glassEffect()`) when running on macOS 26+, an /// `.ultraThinMaterial` + specular-gradient approximation otherwise. Keeping /// the choice here means swapping implementations never ripples into the UI. struct LiquidGlassPanel: View { let shape: PanelShape + var style: LiquidGlassStyle = .dark @ViewBuilder let content: () -> Content var body: some View { if #available(macOS 26.0, *) { content() - .glassEffect(.regular.tint(.black.opacity(0.35)), in: shape) + .glassEffect( + style == .clear ? .clear : .regular.tint(.black.opacity(0.35)), + in: shape + ) } else { content() .background { ZStack { shape.fill(.ultraThinMaterial) - shape.fill(.black.opacity(0.3)) + if style == .dark { + shape.fill(.black.opacity(0.3)) + } // Soft specular sheen standing in for real glass. shape.fill( LinearGradient( diff --git a/NotchBeat/MediaMetadataService/AlbumArtColor.swift b/NotchBeat/MediaMetadataService/AlbumArtColor.swift new file mode 100644 index 0000000..5ffad3d --- /dev/null +++ b/NotchBeat/MediaMetadataService/AlbumArtColor.swift @@ -0,0 +1,62 @@ +import AppKit +import SwiftUI + +extension NSImage { + /// A vibrant accent color sampled from the album art, used to tint the glass + /// edge glow and the current lyric line on both surfaces (creative polish, + /// PRD "dynamic album-art accent color"). + /// + /// Downsamples to a small bitmap, averages the reasonably saturated, + /// mid-bright pixels (so near-black/near-white backgrounds don't dominate), + /// then normalizes saturation/brightness so the result always reads as an + /// accent rather than a muddy or washed-out tone. Falls back to white. + func accentColor() -> Color { + let side = 24 + guard let rep = downsampledBitmap(side: side) else { return .white } + + var rSum = 0.0, gSum = 0.0, bSum = 0.0, weight = 0.0 + for y in 0.. 0.12 && v < 0.97) ? 1.0 : 0.08) + rSum += Double(pixel.redComponent) * w + gSum += Double(pixel.greenComponent) * w + bSum += Double(pixel.blueComponent) * w + weight += w + } + } + guard weight > 0.0001 else { return .white } + + let base = NSColor( + srgbRed: rSum / weight, green: gSum / weight, blue: bSum / weight, alpha: 1 + ) + // Normalize so the tint always pops, even from desaturated art. + let boosted = NSColor( + hue: base.hueComponent, + saturation: min(max(base.saturationComponent, 0.55), 0.9), + brightness: max(base.brightnessComponent, 0.78), + alpha: 1 + ) + return Color(nsColor: boosted) + } + + private func downsampledBitmap(side: Int) -> NSBitmapImageRep? { + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: side, pixelsHigh: side, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 + ) else { return nil } + rep.size = NSSize(width: side, height: side) + + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + draw(in: NSRect(x: 0, y: 0, width: side, height: side), + from: .zero, operation: .copy, fraction: 1) + NSGraphicsContext.restoreGraphicsState() + return rep + } +} diff --git a/NotchBeat/MediaMetadataService/MediaMetadataService.swift b/NotchBeat/MediaMetadataService/MediaMetadataService.swift index 09f7ba6..30b03da 100644 --- a/NotchBeat/MediaMetadataService/MediaMetadataService.swift +++ b/NotchBeat/MediaMetadataService/MediaMetadataService.swift @@ -1,6 +1,7 @@ import AppKit import Combine import Foundation +import SwiftUI /// Abstraction boundary for the now-playing metadata source (ARCHITECTURE.md /// §3). v1 polls the Spotify Web API every ~2.5s; the UI only observes @@ -19,7 +20,13 @@ final class MediaMetadataService: ObservableObject { } @Published private(set) var state: State = .idle - @Published private(set) var albumArt: NSImage? + @Published private(set) var albumArt: NSImage? { + // Recompute the accent tint whenever the art actually changes (the + // load path early-returns when the URL is unchanged, so this is rare). + didSet { accentColor = albumArt?.accentColor() ?? .white } + } + /// Vibrant color sampled from the current album art; .white when idle. + @Published private(set) var accentColor: Color = .white private let auth: SpotifyAuthManager private let client: SpotifyAPIClient diff --git a/NotchBeat/NotchWindowController/ExpandedPlayerView.swift b/NotchBeat/NotchWindowController/ExpandedPlayerView.swift index d1f27e1..75ad235 100644 --- a/NotchBeat/NotchWindowController/ExpandedPlayerView.swift +++ b/NotchBeat/NotchWindowController/ExpandedPlayerView.swift @@ -124,6 +124,9 @@ struct ScrubberView: View { let nowPlaying: NowPlaying let onSeek: (TimeInterval) -> Void let onActivity: () -> Void + /// Condensed variant for the lock screen's compact pill: thinner bar, + /// smaller timestamps. + var compact = false @State private var dragFraction: Double? @@ -133,7 +136,7 @@ struct ScrubberView: View { let fraction = dragFraction ?? (nowPlaying.duration > 0 ? progress / nowPlaying.duration : 0) - VStack(spacing: 4) { + VStack(spacing: compact ? 2 : 4) { GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(.white.opacity(0.18)) @@ -141,7 +144,7 @@ struct ScrubberView: View { .fill(.white.opacity(0.85)) .frame(width: max(4, geo.size.width * fraction)) } - .frame(height: 4) + .frame(height: compact ? 3 : 4) .frame(maxHeight: .infinity, alignment: .center) .contentShape(Rectangle()) .gesture( @@ -157,14 +160,14 @@ struct ScrubberView: View { } ) } - .frame(height: 14) + .frame(height: compact ? 8 : 14) HStack { Text(Self.timestamp(dragFraction.map { $0 * nowPlaying.duration } ?? progress)) Spacer() Text(Self.timestamp(nowPlaying.duration)) } - .font(.system(size: 9, weight: .medium)) + .font(.system(size: compact ? 7 : 9, weight: .medium)) .monospacedDigit() .foregroundStyle(.white.opacity(0.45)) } diff --git a/NotchBeat/NotchWindowController/LyricsPanelView.swift b/NotchBeat/NotchWindowController/LyricsPanelView.swift index 5338bb8..a5fd2f2 100644 --- a/NotchBeat/NotchWindowController/LyricsPanelView.swift +++ b/NotchBeat/NotchWindowController/LyricsPanelView.swift @@ -7,6 +7,7 @@ struct LyricsPanelView: View { @ObservedObject var lyrics: LyricsService let track: NowPlaying let layout: NotchLayout + var accent: Color = .white private var panelShape: UnevenRoundedRectangle { UnevenRoundedRectangle( @@ -45,7 +46,7 @@ struct LyricsPanelView: View { .tint(.white) } case .loaded(.synced(let lines)): - SyncedLyricsView(lines: lines, track: track) + SyncedLyricsView(lines: lines, track: track, currentColor: accent) case .loaded(.plain(let text)): PlainLyricsView(text: text) case .loaded(.unavailable): @@ -78,6 +79,8 @@ struct SyncedLyricsView: View { var otherSize: CGFloat = 13 var lineSpacing: CGFloat = 10 var horizontalPadding: CGFloat = 24 + /// Color of the line currently being sung (album-art accent); default white. + var currentColor: Color = .white @State private var currentIndex = 0 @@ -116,7 +119,7 @@ struct SyncedLyricsView: View { let isCurrent = line.index == currentIndex Text(line.text.isEmpty ? "♪" : line.text) .font(.system(size: isCurrent ? currentSize : otherSize, weight: isCurrent ? .bold : .medium)) - .foregroundStyle(.white.opacity(isCurrent ? 1 : 0.38)) + .foregroundStyle(isCurrent ? currentColor : Color.white.opacity(0.38)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) .animation(.easeInOut(duration: 0.25), value: isCurrent) diff --git a/NotchBeat/NotchWindowController/NotchRootView.swift b/NotchBeat/NotchWindowController/NotchRootView.swift index 131af3a..99b0c53 100644 --- a/NotchBeat/NotchWindowController/NotchRootView.swift +++ b/NotchBeat/NotchWindowController/NotchRootView.swift @@ -32,7 +32,7 @@ struct NotchRootView: View { private func content(for nowPlaying: NowPlaying) -> some View { switch ui.presentation { case .lyrics: - LyricsPanelView(lyrics: lyrics, track: nowPlaying, layout: layout) + LyricsPanelView(lyrics: lyrics, track: nowPlaying, layout: layout, accent: metadata.accentColor) .transition(.move(edge: .trailing).combined(with: .opacity)) case .player: diff --git a/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift b/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift index 493fb88..e0f7301 100644 --- a/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift +++ b/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift @@ -1,31 +1,35 @@ import AppKit import SwiftUI -/// 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. +/// The lock-screen backdrop: wallpaper + liquid-glass clock, plus the +/// now-playing surface that morphs between the compact cluster (L1), the +/// expanded cluster (L2), and lyrics (L3). The clock and the now-playing +/// cluster are ONE persistent layout across L1↔L2 — the clock keeps its font +/// size and only shifts down slightly, while art/pill/gaps interpolate — so +/// expand/collapse is a single coordinated spring rather than a crossfade +/// (all constants: LockScreenLayout). Expanding also frosts the wallpaper. struct LockScreenBackdropView: View { @ObservedObject var metadata: MediaMetadataService @ObservedObject var lyrics: LyricsService + @ObservedObject var wallpaper: LockScreenWallpaper @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 } + private var isExpanded: Bool { ui.presentation != .widget } + /// Frost the wallpaper once expanded; at idle, the user-adjustable blur. + private var backdropBlur: CGFloat { isExpanded ? 60 : CGFloat(wallpaper.idleBlur) } + /// A light tint only — just enough to keep white text legible over the blur. + private var backdropTint: Double { isExpanded ? 0.34 : 0 } var body: some View { GeometryReader { geo in ZStack { - WallpaperView() - Color.black.opacity(scrim).ignoresSafeArea() + WallpaperView(image: wallpaper.image, blurRadius: backdropBlur) + Color.black.opacity(backdropTint).ignoresSafeArea() - ClockView() - .position(x: geo.size.width / 2, y: max(96, geo.size.height * 0.17)) - - if case .active(let nowPlaying) = metadata.state { - nowPlayingSurface(nowPlaying, in: geo) - } + content(in: geo) } + .animation(LockScreenUIState.spring, value: isExpanded) // 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) @@ -34,71 +38,88 @@ struct LockScreenBackdropView: View { } @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: - dimmedLayer { - 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) + private func content(in geo: GeometryProxy) -> some View { + ZStack { + // Click-away on the frosted backdrop steps back down a level. + if isExpanded { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { ui.stepDown() } } - case .lyrics: - dimmedLayer { - LockScreenLyricsView( - lyrics: lyrics, - track: 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) + // Clock: rendered at the idle "Large Clock" size; the numerals + // scale DOWN for the card state (date keeps its size) — one + // persistent view, so the shrink rides the same spring as the + // card's growth, and downscaled text stays crisp. + GlassClockView( + idleFontSize: geo.size.height * LockScreenLayout.timeFontIdleRatio, + timeScale: isExpanded + ? LockScreenLayout.timeFontCardRatio / LockScreenLayout.timeFontIdleRatio + : 1 + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.top, geo.size.height * LockScreenLayout.clockTopRatio) + + if case .active(let nowPlaying) = metadata.state { + if ui.presentation == .lyrics { + LockScreenLyricsView( + lyrics: lyrics, + track: nowPlaying, + albumArt: metadata.albumArt, + accent: metadata.accentColor, + 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) } } + ) + .padding(.top, geo.size.height * 0.14) // clear the clock + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) + } else { + LockScreenNowPlayingCluster( + nowPlaying: nowPlaying, + albumArt: metadata.albumArt, + isExpanded: isExpanded, + screen: geo.size, + namespace: morph, + onToggleExpand: { + ui.presentation == .widget ? ui.expand() : ui.collapse() + }, + 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) + } } - } - } - /// Centers `content` over a full-screen click-away catcher that steps back - /// down one level (lyrics → expanded → widget). - @ViewBuilder - private func dimmedLayer(@ViewBuilder _ content: () -> Content) -> some View { - ZStack { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { ui.stepDown() } - content() + if !isExpanded { + // Wallpaper-blur adjuster, tucked in the top-left corner. + BlurAdjustControl(wallpaper: wallpaper) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(20) + + // Cosmetic nod to the real lock screen (reference image); Esc + // or ⌃⌘L actually dismiss — this surface never gates unlock. + Text("Touch ID or Enter Password") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.75)) + .shadow(color: .black.opacity(0.35), radius: 5, y: 1) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .padding(.bottom, max(28, geo.size.height * 0.035)) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) } } -/// Fills the window with the current desktop wallpaper (aspect-fill), falling -/// back to a dark gradient if it can't be read. +/// Fills the window with the lock-screen wallpaper (aspect-fill), falling back +/// to a dark gradient if there's no image. `blurRadius` frosts it when the +/// now-playing surface is expanded; a slight scale hides the blurred edges. private struct WallpaperView: View { - @State private var image: NSImage? + var image: NSImage? + var blurRadius: CGFloat = 0 var body: some View { GeometryReader { geo in @@ -117,32 +138,96 @@ private struct WallpaperView: View { } .frame(width: geo.size.width, height: geo.size.height) .clipped() + .blur(radius: blurRadius) + .scaleEffect(blurRadius > 0 ? 1.08 : 1) } .ignoresSafeArea() - .task { loadWallpaper() } } +} + +/// A small glass pill in the top-left corner: a droplet button that reveals a +/// slider adjusting the idle wallpaper blur (persisted via LockScreenWallpaper). +private struct BlurAdjustControl: View { + @ObservedObject var wallpaper: LockScreenWallpaper + @State private var showsSlider = false - private func loadWallpaper() { - guard let screen = NSScreen.main, - let url = NSWorkspace.shared.desktopImageURL(for: screen) else { return } - image = NSImage(contentsOf: url) + var body: some View { + LiquidGlassPanel(shape: Capsule(), style: .clear) { + HStack(spacing: 10) { + Button { + withAnimation(.smooth(duration: 0.25)) { showsSlider.toggle() } + } label: { + Image(systemName: "drop.halffull") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + .shadow(color: .black.opacity(0.35), radius: 3, y: 1) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if showsSlider { + Slider(value: $wallpaper.idleBlur, in: 0...60) + .controlSize(.small) + .tint(.white) + .frame(width: 140) + .transition(.opacity) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } } } -/// Big time + date, updating each second, styled like the iOS/macOS lock screen. -private struct ClockView: View { +/// Liquid-glass clock: translucent frosted numerals over the wallpaper with a +/// soft light bloom. The time is rendered at the idle "Large Clock" size and +/// visually scaled by `timeScale` for the card state; the date line keeps its +/// size in both states (per ref-1, only the digits shrink). +private struct GlassClockView: View { + /// The time's font size at idle (screen height × timeFontIdleRatio). + let idleFontSize: CGFloat + /// 1 at idle; timeFontCardRatio / timeFontIdleRatio when the card is open. + var timeScale: CGFloat = 1 + 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() + .font(.system(size: LockScreenLayout.clockDateFontSize, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + .shadow(color: .black.opacity(0.3), radius: 6, y: 1) + + // Bare "9:24"-style time (reference image): 12-hour, no leading + // zero, no AM/PM — hardcoded format because locale-driven + // AM/PM omission zero-pads the hour. + glassNumerals( + Text(verbatim: Self.timeFormatter.string(from: context.date)), + size: idleFontSize + ) + .scaleEffect(timeScale, anchor: .top) } - .shadow(color: .black.opacity(0.35), radius: 18, y: 4) } } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm" + return formatter + }() + + /// Frosted-glass numerals: material-filled glyphs (so the blurred wallpaper + /// shows through) brightened by a translucent white overlay, with a soft + /// white bloom and a dark shadow for legibility on light wallpapers. + /// SF Pro Display Bold with proportional digits, matching the reference + /// (tabular figures made "9:24" read too wide and boxy). + private func glassNumerals(_ text: Text, size: CGFloat) -> some View { + let styled = text + .font(.system(size: size, weight: .bold)) + return styled + .foregroundStyle(.thinMaterial) + .overlay { styled.foregroundStyle(.white.opacity(0.3)) } + .shadow(color: .white.opacity(0.22), radius: 14) + .shadow(color: .black.opacity(0.25), radius: 10, y: 3) + } } diff --git a/NotchBeat/PseudoLockScreenController/LockScreenControls.swift b/NotchBeat/PseudoLockScreenController/LockScreenControls.swift index 4206484..f92a17f 100644 --- a/NotchBeat/PseudoLockScreenController/LockScreenControls.swift +++ b/NotchBeat/PseudoLockScreenController/LockScreenControls.swift @@ -14,9 +14,10 @@ struct TransportButtons: View { let onPlayPause: () -> Void let onNext: () -> Void var primarySize: CGFloat = 26 + var spacing: CGFloat = 40 var body: some View { - HStack(spacing: 40) { + HStack(spacing: spacing) { button("backward.fill", primarySize * 0.7, onPrevious) button(isPlaying ? "pause.fill" : "play.fill", primarySize, onPlayPause) .contentTransition(.symbolEffect(.replace)) @@ -36,8 +37,9 @@ struct TransportButtons: View { } } -/// The frosted control pill (title/artist, scrubber, transport) shown below the -/// album art in the expanded (L2) and lyrics (L3) lock-screen states. +/// The card-style glass pill (title/artist, scrubber, transport) shown under +/// the album art in the lyrics state (L3). The chip ↔ card morph has its own +/// container inside LockScreenNowPlayingCluster. struct LockScreenControlPill: View { let nowPlaying: NowPlaying let onPlayPause: () -> Void @@ -46,37 +48,38 @@ struct LockScreenControlPill: View { let onSeek: (TimeInterval) -> Void var body: 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) + // 100% transparent Liquid Glass — no dark tint, soft text shadows + // keep the white content legible. + LiquidGlassPanel( + shape: RoundedRectangle(cornerRadius: LockScreenLayout.pillCornerRadius, style: .continuous), + style: .clear + ) { + VStack(spacing: 12) { + 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: {}) + 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)) + TransportButtons( + isPlaying: nowPlaying.isPlaying, + onPrevious: onPrevious, + onPlayPause: onPlayPause, + onNext: onNext, + primarySize: 26 + ) } + .shadow(color: .black.opacity(0.3), radius: 4, y: 1) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .frame(width: LockScreenLayout.lyricsPillSize.width, + height: LockScreenLayout.lyricsPillSize.height) } - .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) } } diff --git a/NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift b/NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift deleted file mode 100644 index aff5709..0000000 --- a/NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift +++ /dev/null @@ -1,51 +0,0 @@ -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) - - LockScreenControlPill( - nowPlaying: nowPlaying, - onPlayPause: onPlayPause, - onPrevious: onPrevious, - onNext: onNext, - onSeek: onSeek - ) - } - } - - @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/LockScreenLayout.swift b/NotchBeat/PseudoLockScreenController/LockScreenLayout.swift new file mode 100644 index 0000000..1a6c911 --- /dev/null +++ b/NotchBeat/PseudoLockScreenController/LockScreenLayout.swift @@ -0,0 +1,61 @@ +import SwiftUI + +/// Every size, ratio, and spring parameter for the pseudo-lock-screen in one +/// place — no magic numbers in view code. +/// +/// Source: notchbeat-lockscreen-fix-prompt.md (pixel-analyzed reference +/// screenshots). Ratios are fractions of screen height (H) or width (W) so +/// the layout scales to any display. The idle chip's numbers are directional +/// ("small corner chip" — ref-4 is an off-angle photo), tune on-device. +enum LockScreenLayout { + // MARK: Clock + /// Top padding of the clock block, as a fraction of H. + static let clockTopRatio: CGFloat = 0.055 + static let clockDateFontSize: CGFloat = 17 + /// Idle "Large Clock". Target digit-ink height is 21.4% of H; digit ink + /// is ~63% of the font size, so the font ratio compensates (round-3: + /// 0.275 rendered only 17.3% ink — scaled up 1.24×). Rendered at this + /// size and scaled DOWN for the card state (downscaling stays crisp). + static let timeFontIdleRatio: CGFloat = 0.34 + /// Card state (round-3: measured 10.9% ink vs 10.5% target — correct; + /// keep this absolute value even when the idle ratio moves). + static let timeFontCardRatio: CGFloat = 0.145 + + // MARK: Album art — card state (round-2, target-4) + /// Perfect square, horizontally centered; top edge 22% down (pulled up + /// closer to the clock), side 47% of H (≈31% of W). + static let artTopRatio: CGFloat = 0.22 + static let artSideRatio: CGFloat = 0.47 + static let artCornerRadius: CGFloat = 12 + + // MARK: Now-playing pill — card state + /// Width matches the album art above it exactly (round-2: "widen to match + /// the album's new width"); height ≈21% of H (round-3: +30%, the pill + /// read short/cramped), starting 5.4% of H below the art's bottom edge. + static let pillHeightRatio: CGFloat = 0.21 + static let artToPillGapRatio: CGFloat = 0.054 + static let pillCornerRadius: CGFloat = 24 + + // MARK: Idle chip (round-3, target-3): a compact left-aligned chip — + // album thumbnail + title/artist ONLY. No scrubber, no transport; those + // belong to the expanded pill. Size is a directional read of a low-res + // reference photo — eyeball against target-3 when tuning. + static let chipSize = CGSize(width: 240, height: 64) + static let chipCornerRadius: CGFloat = 16 + /// Margin from the screen's left edge. + static let chipLeftMargin: CGFloat = 28 + /// Vertical center of the idle chip, as a fraction of H (spec: the chip + /// sits ~46–60% down the screen, not hugging the bottom). + static let chipCenterYRatio: CGFloat = 0.53 + static let chipThumbSize: CGFloat = 44 + static let chipThumbCornerRadius: CGFloat = 8 + /// Chip's inner padding (the thumbnail sits in the leading slot). + static let chipContentInset: CGFloat = 10 + + // MARK: Lyrics state (L3) keeps a fixed card-style pill under its art. + static let lyricsPillSize = CGSize(width: 360, height: 190) + + /// The single coordinated expand/collapse spring (response ≈0.45, + /// dampingFraction ≈0.75) — every animating property rides this curve. + static let spring = Animation.spring(response: 0.45, dampingFraction: 0.75) +} diff --git a/NotchBeat/PseudoLockScreenController/LockScreenLyricsView.swift b/NotchBeat/PseudoLockScreenController/LockScreenLyricsView.swift index 7a08b44..ef7e6c0 100644 --- a/NotchBeat/PseudoLockScreenController/LockScreenLyricsView.swift +++ b/NotchBeat/PseudoLockScreenController/LockScreenLyricsView.swift @@ -10,6 +10,7 @@ struct LockScreenLyricsView: View { @ObservedObject var lyrics: LyricsService let track: NowPlaying let albumArt: NSImage? + let accent: Color let namespace: Namespace.ID let onPlayPause: () -> Void let onPrevious: () -> Void @@ -23,7 +24,9 @@ struct LockScreenLyricsView: View { .frame(width: 220, height: 220) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .matchedGeometryEffect(id: LockScreenMorph.art, in: namespace) - .shadow(color: .black.opacity(0.5), radius: 26, y: 12) + .shadow(color: .black.opacity(0.45), radius: 24, y: 12) + // Accent bloom picked from the art itself. + .shadow(color: accent.opacity(0.5), radius: 34) LockScreenControlPill( nowPlaying: track, @@ -57,7 +60,8 @@ struct LockScreenLyricsView: View { currentSize: 26, otherSize: 20, lineSpacing: 16, - horizontalPadding: 0 + horizontalPadding: 0, + currentColor: accent ) case .loaded(.plain(let text)): PlainLyricsView(text: text, fontSize: 20, horizontalPadding: 0) diff --git a/NotchBeat/PseudoLockScreenController/LockScreenNowPlayingWidget.swift b/NotchBeat/PseudoLockScreenController/LockScreenNowPlayingWidget.swift index 0fdbd52..416e81d 100644 --- a/NotchBeat/PseudoLockScreenController/LockScreenNowPlayingWidget.swift +++ b/NotchBeat/PseudoLockScreenController/LockScreenNowPlayingWidget.swift @@ -1,66 +1,172 @@ 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. Tapping the art / track info -/// expands to the full view (L2). Reuses `ScrubberView` and `TransportButtons`. -struct LockScreenNowPlayingWidget: View { +/// The lock screen's now-playing surface: a compact left-aligned chip at idle +/// (album thumbnail + title/artist ONLY — the scrubber and transport belong +/// to the expanded state) that grows into the big centered card (square cover +/// + control pill) when tapped. +/// +/// Both states are the SAME two persistent views — the glass container and the +/// album art — whose frames/positions/radii interpolate from LockScreenLayout +/// ratios in one coordinated spring, so the expand reads as *growing*: the +/// cover visibly flies out of the chip's thumbnail slot to the card position +/// while the chip stretches into the control pill. Only the container's inner +/// content crossfades. Tapping the cover or track info collapses again. +struct LockScreenNowPlayingCluster: View { let nowPlaying: NowPlaying let albumArt: NSImage? + let isExpanded: Bool + /// The lock-screen window's size — all layout derives from it. + let screen: CGSize let namespace: Namespace.ID - let onExpand: () -> Void + let onToggleExpand: () -> Void let onPlayPause: () -> Void let onPrevious: () -> Void let onNext: () -> Void let onSeek: (TimeInterval) -> Void + // MARK: Derived geometry (all from LockScreenLayout ratios) + + private var artSide: CGFloat { + isExpanded ? screen.height * LockScreenLayout.artSideRatio + : LockScreenLayout.chipThumbSize + } + + private var chipCenterY: CGFloat { + screen.height * LockScreenLayout.chipCenterYRatio + } + + private var artCenter: CGPoint { + if isExpanded { + return CGPoint( + x: screen.width / 2, + y: screen.height * (LockScreenLayout.artTopRatio + LockScreenLayout.artSideRatio / 2) + ) + } + // Nested in the chip's leading thumbnail slot (vertically centered). + let x = LockScreenLayout.chipLeftMargin + LockScreenLayout.chipContentInset + + LockScreenLayout.chipThumbSize / 2 + return CGPoint(x: x, y: chipCenterY) + } + + private var artRadius: CGFloat { + isExpanded ? LockScreenLayout.artCornerRadius : LockScreenLayout.chipThumbCornerRadius + } + + private var containerSize: CGSize { + if isExpanded { + return CGSize( + // The pill's width matches the album art exactly (round-2). + width: screen.height * LockScreenLayout.artSideRatio, + height: screen.height * LockScreenLayout.pillHeightRatio + ) + } + return LockScreenLayout.chipSize + } + + private var containerCenter: CGPoint { + if isExpanded { + let artBottom = screen.height * (LockScreenLayout.artTopRatio + LockScreenLayout.artSideRatio) + let top = artBottom + screen.height * LockScreenLayout.artToPillGapRatio + return CGPoint(x: screen.width / 2, y: top + containerSize.height / 2) + } + return CGPoint( + x: LockScreenLayout.chipLeftMargin + LockScreenLayout.chipSize.width / 2, + y: chipCenterY + ) + } + + private var containerRadius: CGFloat { + isExpanded ? LockScreenLayout.pillCornerRadius : LockScreenLayout.chipCornerRadius + } + + // MARK: Body + var body: some View { - VStack(spacing: 14) { - HStack(spacing: 12) { - artwork - .frame(width: 56, height: 56) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .matchedGeometryEffect(id: LockScreenMorph.art, in: namespace) - VStack(alignment: .leading, spacing: 2) { + ZStack { + LiquidGlassPanel( + shape: RoundedRectangle(cornerRadius: containerRadius, style: .continuous), + style: .clear + ) { + containerContent + .frame(width: containerSize.width, height: containerSize.height) + } + .position(containerCenter) + + artwork + .frame(width: artSide, height: artSide) + .clipShape(RoundedRectangle(cornerRadius: artRadius, style: .continuous)) + // Hero id for the L2 ↔ L3 (lyrics) morph; inert during + // chip ↔ card where this same view animates its own frame. + .matchedGeometryEffect(id: LockScreenMorph.art, in: namespace) + .shadow(color: .black.opacity(0.45), + radius: isExpanded ? 28 : 8, + y: isExpanded ? 14 : 3) + .contentShape(Rectangle()) + .onTapGesture(perform: onToggleExpand) + .position(artCenter) + } + } + + /// The glass container's inner content: chip layout at idle, full control + /// pill when expanded. These crossfade inside the morphing container. + @ViewBuilder + private var containerContent: some View { + if isExpanded { + VStack(spacing: 10) { + VStack(spacing: 3) { Text(nowPlaying.title) - .font(.system(size: 15, weight: .semibold)) + .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.white) Text(nowPlaying.artist) - .font(.system(size: 13)) + .font(.system(size: 14)) .foregroundStyle(.white.opacity(0.65)) } .lineLimit(1) - Spacer(minLength: 0) + .contentShape(Rectangle()) + .onTapGesture(perform: onToggleExpand) + + ScrubberView(nowPlaying: nowPlaying, onSeek: onSeek, onActivity: {}) + + TransportButtons( + isPlaying: nowPlaying.isPlaying, + onPrevious: onPrevious, + onPlayPause: onPlayPause, + onNext: onNext, + primarySize: 26 + ) } - // Tapping the art / track info opens the expanded view; the - // transport buttons below keep their own taps. - .contentShape(Rectangle()) - .onTapGesture(perform: onExpand) + .shadow(color: .black.opacity(0.3), radius: 4, y: 1) + .padding(.horizontal, 20) + .padding(.vertical, 14) + .transition(.opacity) + } else { + // Idle chip: thumbnail + title/artist only — no scrubber, no + // transport (round-3 spec; those live in the expanded pill). + HStack(spacing: 10) { + // Empty slot — the flying album-art view sits exactly here. + Color.clear + .frame(width: LockScreenLayout.chipThumbSize, + height: LockScreenLayout.chipThumbSize) - ScrubberView(nowPlaying: nowPlaying, onSeek: onSeek, onActivity: {}) + VStack(alignment: .leading, spacing: 1) { + Text(nowPlaying.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + Text(nowPlaying.artist) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.65)) + } + .lineLimit(1) - TransportButtons( - isPlaying: nowPlaying.isPlaying, - onPrevious: onPrevious, - onPlayPause: onPlayPause, - onNext: onNext, - primarySize: 26 - ) - } - .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)) + Spacer(minLength: 0) } + .shadow(color: .black.opacity(0.3), radius: 4, y: 1) + .padding(.horizontal, LockScreenLayout.chipContentInset) + .contentShape(Rectangle()) + .onTapGesture(perform: onToggleExpand) + .transition(.opacity) } - .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) - .shadow(color: .black.opacity(0.35), radius: 24, y: 8) } @ViewBuilder @@ -68,13 +174,14 @@ struct LockScreenNowPlayingWidget: View { if let albumArt { Image(nsImage: albumArt) .resizable() + // .fill on a square frame — the cover is a true 1:1 square. .aspectRatio(contentMode: .fill) } else { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(white: 0.25)) + Rectangle() + .fill(Color(white: 0.22)) .overlay { Image(systemName: "music.note") - .font(.system(size: 24, weight: .medium)) + .font(.system(size: isExpanded ? 80 : 20, weight: .medium)) .foregroundStyle(.white.opacity(0.8)) } } diff --git a/NotchBeat/PseudoLockScreenController/LockScreenUIState.swift b/NotchBeat/PseudoLockScreenController/LockScreenUIState.swift index 5e26e35..9381b26 100644 --- a/NotchBeat/PseudoLockScreenController/LockScreenUIState.swift +++ b/NotchBeat/PseudoLockScreenController/LockScreenUIState.swift @@ -11,8 +11,9 @@ final class LockScreenUIState: ObservableObject { 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) + /// The single coordinated expand/collapse spring (defined with the rest of + /// the layout constants in LockScreenLayout). + static let spring = LockScreenLayout.spring @Published private(set) var presentation: Presentation = .widget diff --git a/NotchBeat/PseudoLockScreenController/LockScreenWallpaper.swift b/NotchBeat/PseudoLockScreenController/LockScreenWallpaper.swift new file mode 100644 index 0000000..34eadbd --- /dev/null +++ b/NotchBeat/PseudoLockScreenController/LockScreenWallpaper.swift @@ -0,0 +1,61 @@ +import AppKit +import SwiftUI + +/// The lock-screen wallpaper: a user-chosen image, or the current desktop +/// wallpaper as the default. The chosen path persists in UserDefaults so it +/// survives relaunches; changing it updates the backdrop live. +@MainActor +final class LockScreenWallpaper: ObservableObject { + @Published private(set) var image: NSImage? + @Published private(set) var isCustom = false + /// Blur applied to the wallpaper in the idle (widget) state, adjustable + /// from the lock screen itself. The expanded states frost fully regardless. + @Published var idleBlur: Double { + didSet { UserDefaults.standard.set(idleBlur, forKey: blurKey) } + } + + private let key = "lockscreen.wallpaperPath" + private let blurKey = "lockscreen.wallpaperBlur" + + init() { + idleBlur = UserDefaults.standard.double(forKey: blurKey) + reload() + } + + /// Loads the saved custom wallpaper if set, else the desktop wallpaper. + func reload() { + if let path = UserDefaults.standard.string(forKey: key), + let custom = NSImage(contentsOfFile: path) { + image = custom + isCustom = true + return + } + isCustom = false + if let screen = NSScreen.main, + let url = NSWorkspace.shared.desktopImageURL(for: screen) { + image = NSImage(contentsOf: url) + } else { + image = nil + } + } + + /// Opens a file picker to choose a custom wallpaper image. + func choose() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.image] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.prompt = "Set Wallpaper" + NSApp.activate(ignoringOtherApps: true) + guard panel.runModal() == .OK, let url = panel.url else { return } + UserDefaults.standard.set(url.path, forKey: key) + image = NSImage(contentsOf: url) + isCustom = true + } + + /// Reverts to the current desktop wallpaper. + func resetToDesktop() { + UserDefaults.standard.removeObject(forKey: key) + reload() + } +} diff --git a/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift b/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift index fa07258..0158d6b 100644 --- a/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift +++ b/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift @@ -12,14 +12,16 @@ import SwiftUI @MainActor final class PseudoLockScreenController { private let metadata: MediaMetadataService + private let wallpaper: LockScreenWallpaper private let lyrics = LyricsService() private let ui = LockScreenUIState() private var window: LockScreenWindow? private var hotKey: GlobalHotKey? private(set) var isVisible = false - init(metadata: MediaMetadataService) { + init(metadata: MediaMetadataService, wallpaper: LockScreenWallpaper) { self.metadata = metadata + self.wallpaper = wallpaper } /// Registers the global ⌃⌘L toggle. Call once at launch. @@ -55,8 +57,10 @@ final class PseudoLockScreenController { window.isReleasedWhenClosed = false window.onDismiss = { [weak self] in self?.handleEscape() } + // Pick up any wallpaper change (custom or desktop) each time it opens. + wallpaper.reload() let hostingView = LockScreenHostingView( - rootView: LockScreenBackdropView(metadata: metadata, lyrics: lyrics, ui: ui) + rootView: LockScreenBackdropView(metadata: metadata, lyrics: lyrics, wallpaper: wallpaper, ui: ui) ) hostingView.onSwipe = { [weak self] direction in self?.handleSwipe(direction)