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 @@ -29,6 +29,7 @@
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 */; };
F1494A1F40A2D6FFCBC28F17 /* LockScreenNowPlayingWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8741427FCCF71EA5F049344D /* LockScreenNowPlayingWidget.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand All @@ -45,6 +46,7 @@
7D3213245D2618045EA009B9 /* NotchMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchMetrics.swift; sourceTree = "<group>"; };
7F19FAF1275C541AB173F1F7 /* MediaMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaMetadataService.swift; sourceTree = "<group>"; };
81D658A3FC6D7C63D43E2149 /* LyricsPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsPanelView.swift; sourceTree = "<group>"; };
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>"; };
A862BCC79D94A263060B84F3 /* NotchRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchRootView.swift; sourceTree = "<group>"; };
Expand All @@ -64,6 +66,7 @@
children = (
E114462CC31BFC8BFDB6571D /* GlobalHotKey.swift */,
9404B9EDA37A59B491EF92E1 /* LockScreenBackdropView.swift */,
8741427FCCF71EA5F049344D /* LockScreenNowPlayingWidget.swift */,
68EB8D95060ED9D416B0AD35 /* PseudoLockScreenController.swift */,
);
path = PseudoLockScreenController;
Expand Down Expand Up @@ -234,6 +237,7 @@
8CECBA1806341C3ACDF9666D /* GlobalHotKey.swift in Sources */,
852BCE6356651B307827CFA9 /* LiquidGlassUI.swift in Sources */,
B03F55117440084F1A9EEC47 /* LockScreenBackdropView.swift in Sources */,
F1494A1F40A2D6FFCBC28F17 /* LockScreenNowPlayingWidget.swift in Sources */,
316A63FA9FCD92E88E0BD9A0 /* LyricsPanelView.swift in Sources */,
BD741B1B99AB90F0372BE471 /* LyricsService.swift in Sources */,
2D36EB8D1A9988C8C2B7B781 /* MediaMetadataService.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion NotchBeat/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let authManager = SpotifyAuthManager()
private(set) lazy var metadataService = MediaMetadataService(auth: authManager)
private lazy var notchWindowController = NotchWindowController(metadata: metadataService)
let lockScreenController = PseudoLockScreenController()
private lazy var lockScreenController = PseudoLockScreenController(metadata: metadataService)

func applicationDidFinishLaunching(_ notification: Notification) {
notchWindowController.show()
Expand Down
34 changes: 27 additions & 7 deletions NotchBeat/PseudoLockScreenController/LockScreenBackdropView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,36 @@ import AppKit
import SwiftUI

/// The lock-screen backdrop (PRD §3.2 L1, reference image 1): the desktop
/// wallpaper filling the screen with a large clock + date near the top. The
/// now-playing widget is layered on in phase 6.
/// 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.
struct LockScreenBackdropView: View {
@ObservedObject var metadata: MediaMetadataService

var body: some View {
ZStack(alignment: .top) {
WallpaperView()
ClockView()
.padding(.top, 64)
GeometryReader { geo in
ZStack(alignment: .top) {
WallpaperView()

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)
}
Comment on lines +15 to +29

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

Using .position(x:y:) in SwiftUI causes the modified views to expand and occupy the entire available space of their parent container (the full screen inside GeometryReader). This can interfere with hit-testing, potentially blocking user interactions (such as 'click-away' dismissal) on the background WallpaperView even in areas that appear empty.

Using .offset(y:) with a top-aligned ZStack is a safer and more idiomatic alternative because it shifts the views' rendering without expanding their layout bounds to fill the screen.

Suggested change
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)
}
ClockView()
.offset(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) } }
)
.offset(y: geo.size.height * 0.60)
.transition(.opacity)
}

}
// Flips only on idle↔active, not on every poll, so the widget fades
// in/out cleanly without re-animating as playback progresses.
.animation(.easeInOut(duration: 0.3), value: metadata.state == .idle)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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. Reuses `ScrubberView` (from the
/// notch player) so the progress bar / drag-to-seek behaves identically.
struct LockScreenNowPlayingWidget: View {
let nowPlaying: NowPlaying
let albumArt: NSImage?
let onPlayPause: () -> Void
let onPrevious: () -> Void
let onNext: () -> Void
let onSeek: (TimeInterval) -> Void

var body: some View {
VStack(spacing: 14) {
HStack(spacing: 12) {
artwork
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(nowPlaying.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
Text(nowPlaying.artist)
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.65))
}
.lineLimit(1)
Spacer(minLength: 0)
}

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)
}
}
.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))
}
}
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: .black.opacity(0.35), radius: 24, y: 8)
}

@ViewBuilder
private var artwork: some View {
if let albumArt {
Image(nsImage: albumArt)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(white: 0.25))
.overlay {
Image(systemName: "music.note")
.font(.system(size: 24, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
}
}
}

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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ import SwiftUI
/// not overlay the password screen or gate unlock.
@MainActor
final class PseudoLockScreenController {
private let metadata: MediaMetadataService
private var window: LockScreenWindow?
private var hotKey: GlobalHotKey?
private(set) var isVisible = false

init(metadata: MediaMetadataService) {
self.metadata = metadata
}

/// Registers the global ⌃⌘L toggle. Call once at launch.
func installHotKey() {
hotKey = GlobalHotKey(.toggleLockScreen) { [weak self] in
Expand Down Expand Up @@ -47,7 +52,7 @@ final class PseudoLockScreenController {
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
window.isReleasedWhenClosed = false
window.onDismiss = { [weak self] in self?.hide() }
window.contentView = NSHostingView(rootView: LockScreenBackdropView())
window.contentView = NSHostingView(rootView: LockScreenBackdropView(metadata: metadata))

window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
Expand Down