diff --git a/NotchBeat.xcodeproj/project.pbxproj b/NotchBeat.xcodeproj/project.pbxproj index f562679..7e454eb 100644 --- a/NotchBeat.xcodeproj/project.pbxproj +++ b/NotchBeat.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 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 */; }; + AE3D9D73EE09457F9FC9F512 /* LockScreenLyricsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6DDE1A937A6DE6D87F8066F /* LockScreenLyricsView.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 */; }; @@ -57,6 +58,7 @@ 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 = ""; }; 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 = ""; }; @@ -74,6 +76,7 @@ 9404B9EDA37A59B491EF92E1 /* LockScreenBackdropView.swift */, 30FA4A6CC5E1F4A8DBCB4DAA /* LockScreenControls.swift */, A614F3F6690B30F4FB4E602A /* LockScreenExpandedView.swift */, + C6DDE1A937A6DE6D87F8066F /* LockScreenLyricsView.swift */, 8741427FCCF71EA5F049344D /* LockScreenNowPlayingWidget.swift */, 474238E0460DBDA760586035 /* LockScreenUIState.swift */, 68EB8D95060ED9D416B0AD35 /* PseudoLockScreenController.swift */, @@ -248,6 +251,7 @@ B03F55117440084F1A9EEC47 /* LockScreenBackdropView.swift in Sources */, ECBB61F19F9B55A9BB57631E /* LockScreenControls.swift in Sources */, EFA41F7AEDAB74B765C30B56 /* LockScreenExpandedView.swift in Sources */, + AE3D9D73EE09457F9FC9F512 /* LockScreenLyricsView.swift in Sources */, F1494A1F40A2D6FFCBC28F17 /* LockScreenNowPlayingWidget.swift in Sources */, C1A583D31E0BE32E48DD776A /* LockScreenUIState.swift in Sources */, 316A63FA9FCD92E88E0BD9A0 /* LyricsPanelView.swift in Sources */, diff --git a/NotchBeat/NotchWindowController/LyricsPanelView.swift b/NotchBeat/NotchWindowController/LyricsPanelView.swift index ce474bd..5338bb8 100644 --- a/NotchBeat/NotchWindowController/LyricsPanelView.swift +++ b/NotchBeat/NotchWindowController/LyricsPanelView.swift @@ -68,10 +68,16 @@ struct LyricsPanelView: View { } /// Auto-scrolling synced lyrics. A light timer advances the current line off -/// the estimated playback position (interpolated between ~2.5s polls). -private struct SyncedLyricsView: View { +/// the estimated playback position (interpolated between ~2.5s polls). Shared +/// by the notch lyrics panel and the lock screen (which passes larger fonts); +/// the defaults match the notch's original sizing. +struct SyncedLyricsView: View { let lines: [LyricLine] let track: NowPlaying + var currentSize: CGFloat = 16 + var otherSize: CGFloat = 13 + var lineSpacing: CGFloat = 10 + var horizontalPadding: CGFloat = 24 @State private var currentIndex = 0 @@ -80,7 +86,7 @@ private struct SyncedLyricsView: View { var body: some View { ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: false) { - LazyVStack(alignment: .leading, spacing: 10) { + LazyVStack(alignment: .leading, spacing: lineSpacing) { // Breathing room so the first and last lines can still // center under the highlight. Spacer().frame(height: 40) @@ -90,7 +96,7 @@ private struct SyncedLyricsView: View { } Spacer().frame(height: 60) } - .padding(.horizontal, 24) + .padding(.horizontal, horizontalPadding) .frame(maxWidth: .infinity, alignment: .leading) } .onReceive(tick) { _ in @@ -109,7 +115,7 @@ private struct SyncedLyricsView: View { private func lineView(_ line: LyricLine) -> some View { let isCurrent = line.index == currentIndex Text(line.text.isEmpty ? "♪" : line.text) - .font(.system(size: isCurrent ? 16 : 13, weight: isCurrent ? .bold : .medium)) + .font(.system(size: isCurrent ? currentSize : otherSize, weight: isCurrent ? .bold : .medium)) .foregroundStyle(.white.opacity(isCurrent ? 1 : 0.38)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) @@ -126,17 +132,19 @@ private struct SyncedLyricsView: View { } } -/// Unsynced fallback: static, scrollable plain text. -private struct PlainLyricsView: View { +/// Unsynced fallback: static, scrollable plain text. Shared by both surfaces. +struct PlainLyricsView: View { let text: String + var fontSize: CGFloat = 13 + var horizontalPadding: CGFloat = 24 var body: some View { ScrollView(.vertical, showsIndicators: false) { Text(text) - .font(.system(size: 13, weight: .medium)) + .font(.system(size: fontSize, weight: .medium)) .foregroundStyle(.white.opacity(0.75)) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 24) + .padding(.horizontal, horizontalPadding) .padding(.vertical, 16) } } diff --git a/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift b/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift index fcfe29e..493fb88 100644 --- a/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift +++ b/NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift @@ -6,6 +6,7 @@ import SwiftUI /// (L2, image 2). The wallpaper dims toward black as it expands. struct LockScreenBackdropView: View { @ObservedObject var metadata: MediaMetadataService + @ObservedObject var lyrics: LyricsService @ObservedObject var ui: LockScreenUIState @Namespace private var morph @@ -49,14 +50,8 @@ struct LockScreenBackdropView: View { .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() } - + case .expanded: + dimmedLayer { LockScreenExpandedView( nowPlaying: nowPlaying, albumArt: metadata.albumArt, @@ -68,8 +63,35 @@ struct LockScreenBackdropView: View { ) .transition(.opacity) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + + 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) + } + } + } + + /// 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() } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/NotchBeat/PseudoLockScreenController/LockScreenControls.swift b/NotchBeat/PseudoLockScreenController/LockScreenControls.swift index 28dd3e8..4206484 100644 --- a/NotchBeat/PseudoLockScreenController/LockScreenControls.swift +++ b/NotchBeat/PseudoLockScreenController/LockScreenControls.swift @@ -35,3 +35,48 @@ struct TransportButtons: View { .buttonStyle(.plain) } } + +/// The frosted control pill (title/artist, scrubber, transport) shown below the +/// album art in the expanded (L2) and lyrics (L3) lock-screen states. +struct LockScreenControlPill: View { + let nowPlaying: NowPlaying + let onPlayPause: () -> Void + let onPrevious: () -> Void + let onNext: () -> Void + 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) + + 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)) + } +} diff --git a/NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift b/NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift index 4fccc4d..aff5709 100644 --- a/NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift +++ b/NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift @@ -22,43 +22,14 @@ struct LockScreenExpandedView: View { .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, + LockScreenControlPill( + nowPlaying: nowPlaying, onPlayPause: onPlayPause, + onPrevious: onPrevious, onNext: onNext, - primarySize: 30 + onSeek: onSeek ) - .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 diff --git a/NotchBeat/PseudoLockScreenController/LockScreenLyricsView.swift b/NotchBeat/PseudoLockScreenController/LockScreenLyricsView.swift new file mode 100644 index 0000000..7a08b44 --- /dev/null +++ b/NotchBeat/PseudoLockScreenController/LockScreenLyricsView.swift @@ -0,0 +1,98 @@ +import AppKit +import SwiftUI + +/// Lock-screen lyrics (PRD §3.2 L3, reference image 3): album art + controls +/// shifted to the left column, synced lyrics filling the right. The art carries +/// the same hero morph as the widget/expanded states, so it slides left and +/// shrinks as the lyrics reveal. Reuses LyricsService and the notch's +/// SyncedLyricsView (with larger fonts). +struct LockScreenLyricsView: View { + @ObservedObject var lyrics: LyricsService + let track: 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 { + HStack(spacing: 56) { + VStack(spacing: 24) { + artwork + .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) + + LockScreenControlPill( + nowPlaying: track, + onPlayPause: onPlayPause, + onPrevious: onPrevious, + onNext: onNext, + onSeek: onSeek + ) + } + .frame(width: 360) + + lyricsContent + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .padding(.horizontal, 90) + .padding(.vertical, 70) + .task(id: track.trackID) { lyrics.load(for: track) } + } + + @ViewBuilder + private var lyricsContent: some View { + switch lyrics.state { + case .idle, .loading: + centered { + ProgressView().controlSize(.large).tint(.white) + } + case .loaded(.synced(let lines)): + SyncedLyricsView( + lines: lines, + track: track, + currentSize: 26, + otherSize: 20, + lineSpacing: 16, + horizontalPadding: 0 + ) + case .loaded(.plain(let text)): + PlainLyricsView(text: text, fontSize: 20, horizontalPadding: 0) + case .loaded(.unavailable): + centered { + VStack(spacing: 12) { + Image(systemName: "music.note.list") + .font(.system(size: 34)) + .foregroundStyle(.white.opacity(0.5)) + Text("No lyrics found") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(.white.opacity(0.6)) + } + } + } + } + + private func centered(@ViewBuilder _ content: () -> Content) -> some View { + content().frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private var artwork: some View { + if let albumArt { + Image(nsImage: albumArt) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(white: 0.22)) + .overlay { + Image(systemName: "music.note") + .font(.system(size: 56, weight: .medium)) + .foregroundStyle(.white.opacity(0.8)) + } + } + } +} diff --git a/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift b/NotchBeat/PseudoLockScreenController/PseudoLockScreenController.swift index 8440108..fa07258 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 lyrics = LyricsService() private let ui = LockScreenUIState() private var window: LockScreenWindow? private var hotKey: GlobalHotKey? @@ -53,7 +54,14 @@ final class PseudoLockScreenController { window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] window.isReleasedWhenClosed = false window.onDismiss = { [weak self] in self?.handleEscape() } - window.contentView = NSHostingView(rootView: LockScreenBackdropView(metadata: metadata, ui: ui)) + + let hostingView = LockScreenHostingView( + rootView: LockScreenBackdropView(metadata: metadata, lyrics: lyrics, ui: ui) + ) + hostingView.onSwipe = { [weak self] direction in + self?.handleSwipe(direction) + } + window.contentView = hostingView window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) @@ -62,6 +70,17 @@ final class PseudoLockScreenController { isVisible = true } + /// Two-finger swipe (PRD §3.2 L3): right-to-left from the expanded state + /// reveals lyrics; left-to-right from lyrics returns to the expanded view. + private func handleSwipe(_ direction: SwipeDirection) { + switch direction { + case .left: + if ui.presentation == .expanded { ui.showLyrics() } + case .right: + if ui.presentation == .lyrics { ui.returnToExpanded() } + } + } + func hide() { window?.orderOut(nil) window = nil @@ -102,3 +121,51 @@ final class LockScreenWindow: NSWindow { } } } + +/// Hosting view that recognizes a deliberate two-finger horizontal swipe from +/// trackpad scroll events, mirroring the notch's detection (PRD §3.2 L3). +/// Vertical scrolling is passed through so the lyrics list still scrolls. +private final class LockScreenHostingView: NSHostingView { + var onSwipe: (SwipeDirection) -> Void = { _ in } + + private var swipeAccumX: CGFloat = 0 + private var swipeAccumY: CGFloat = 0 + private var swipeFired = false + private let swipeThreshold: CGFloat = 42 + + override func scrollWheel(with event: NSEvent) { + switch event.phase { + case .began: + swipeAccumX = 0 + swipeAccumY = 0 + swipeFired = false + case .changed: + swipeAccumX += event.scrollingDeltaX + swipeAccumY += event.scrollingDeltaY + if !swipeFired, + abs(swipeAccumX) > swipeThreshold, + abs(swipeAccumX) > abs(swipeAccumY) * 1.6 { + swipeFired = true + // Assumes natural scrolling: right-to-left produces a negative + // cumulative deltaX. Same convention as the notch. + onSwipe(swipeAccumX < 0 ? .left : .right) + } + case .ended, .cancelled: + swipeAccumX = 0 + swipeAccumY = 0 + swipeFired = false + default: + break + } + super.scrollWheel(with: event) + } + + required init(rootView: Content) { + super.init(rootView: rootView) + } + + @available(*, unavailable) + required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) is not supported") + } +}