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
4 changes: 4 additions & 0 deletions NotchBeat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -57,6 +58,7 @@
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>"; };
C6DDE1A937A6DE6D87F8066F /* LockScreenLyricsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenLyricsView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
D463E53A2075B4EFF62193C8 /* GestureEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureEngine.swift; sourceTree = "<group>"; };
Expand All @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
26 changes: 17 additions & 9 deletions NotchBeat/NotchWindowController/LyricsPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
}
}
Expand Down
40 changes: 31 additions & 9 deletions NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Optimize Performance by Avoiding Redundant Observations

LockScreenBackdropView does not read any properties of lyrics directly; it only passes it down to LockScreenLyricsView (which already observes it via @ObservedObject). Marking lyrics as @ObservedObject here causes the entire backdrop view—including the heavy WallpaperView and the ClockView—to re-evaluate and re-render whenever the lyrics loading state updates. Changing this to a simple let dependency avoids these redundant renders.

Suggested change
@ObservedObject var lyrics: LyricsService
let lyrics: LyricsService

@ObservedObject var ui: LockScreenUIState
@Namespace private var morph

Expand Down Expand Up @@ -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,
Expand All @@ -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<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
ZStack {
Color.clear
.contentShape(Rectangle())
.onTapGesture { ui.stepDown() }
content()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

Expand Down
45 changes: 45 additions & 0 deletions NotchBeat/PseudoLockScreenController/LockScreenControls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
37 changes: 4 additions & 33 deletions NotchBeat/PseudoLockScreenController/LockScreenExpandedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions NotchBeat/PseudoLockScreenController/LockScreenLyricsView.swift
Original file line number Diff line number Diff line change
@@ -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<Content: View>(@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))
}
}
}
}
Loading