Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions NotchBeat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<group>"; };
21253425E6FA10293E2FFD4A /* NotchBeatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchBeatApp.swift; sourceTree = "<group>"; };
30FA4A6CC5E1F4A8DBCB4DAA /* LockScreenControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenControls.swift; sourceTree = "<group>"; };
474238E0460DBDA760586035 /* LockScreenUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenUIState.swift; sourceTree = "<group>"; };
58A3D07D6AF6BC0759ABB9C7 /* SpotifyAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyAuthManager.swift; sourceTree = "<group>"; };
5CE226CBE5A055D67B715329 /* MenuBarContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarContentView.swift; sourceTree = "<group>"; };
5DDE8CB4F434738E3DA92CE4 /* PKCE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PKCE.swift; sourceTree = "<group>"; };
Expand All @@ -49,6 +54,7 @@
8741427FCCF71EA5F049344D /* LockScreenNowPlayingWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenNowPlayingWidget.swift; sourceTree = "<group>"; };
898EE9859801623F36E78015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
9404B9EDA37A59B491EF92E1 /* LockScreenBackdropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenBackdropView.swift; sourceTree = "<group>"; };
A614F3F6690B30F4FB4E602A /* LockScreenExpandedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenExpandedView.swift; sourceTree = "<group>"; };
A862BCC79D94A263060B84F3 /* NotchRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchRootView.swift; sourceTree = "<group>"; };
C001B28DA5319FDF1493DCE6 /* LiquidGlassUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassUI.swift; sourceTree = "<group>"; };
C8436228A3EE4EBCB67BAE67 /* NotchBeat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NotchBeat.app; sourceTree = BUILT_PRODUCTS_DIR; };
Expand All @@ -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;
Expand Down Expand Up @@ -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 */,
Expand Down
67 changes: 52 additions & 15 deletions NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
37 changes: 37 additions & 0 deletions NotchBeat/PseudoLockScreenController/LockScreenControls.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
80 changes: 80 additions & 0 deletions NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand All @@ -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
)
Comment on lines +43 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The primarySize parameter is redundant here because TransportButtons already defaults primarySize to 26. Removing it simplifies the call site.

            TransportButtons(
                isPlaying: nowPlaying.isPlaying,
                onPrevious: onPrevious,
                onPlayPause: onPlayPause,
                onNext: onNext
            )

}
.padding(18)
.frame(width: 340)
Expand Down Expand Up @@ -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)
}
}
62 changes: 62 additions & 0 deletions NotchBeat/PseudoLockScreenController/LockScreenUIState.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
Loading