Phase 8: lock-screen lyrics (state L3)#8
Conversation
Completes the lock-screen surface (PRD §3.2 L3, reference image 3). From the expanded state, a two-finger right-to-left swipe reveals synced lyrics: the album art + control pill shift to the left column (art carries the hero morph, sliding left and shrinking) while the lyrics fill the right. Left-to-right swipe or click-away/Esc returns to the expanded view. - LockScreenLyricsView reuses LyricsService and the notch's SyncedLyricsView / PlainLyricsView (now internal + font-size parameterized) at larger lock-screen sizes; loading / plain / 'No lyrics found' states handled. - Extracted a shared LockScreenControlPill used by both the expanded and lyrics states. - Swipe detection via a LockScreenHostingView (same trackpad-scroll logic and SwipeDirection as the notch); vertical scrolling passes through to the lyrics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a lock-screen lyrics view (LockScreenLyricsView) to the pseudo lock-screen interface, allowing users to view synced or plain lyrics alongside media controls. It refactors existing views to promote reuse, extracting LockScreenControlPill and parameterizing SyncedLyricsView and PlainLyricsView for different font sizes and paddings. Additionally, it implements trackpad swipe detection in a custom LockScreenHostingView to transition between the expanded and lyrics states. The review feedback suggests two key improvements: first, refactoring the trackpad swipe detection to support both natural and traditional scrolling while preventing event leakage that could cause AppKit layout crashes; second, changing the lyrics dependency in LockScreenBackdropView from @ObservedObject to a simple let constant to avoid redundant and expensive re-renders of the backdrop, clock, and wallpaper views.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| 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) | ||
| } |
There was a problem hiding this comment.
Support Both Natural and Traditional Scrolling & Prevent Jitter/Crashes
This refactors the trackpad swipe detection to address two key issues:
- Natural Scrolling Dependency: By utilizing
event.isDirectionInvertedFromDevice, we can normalize the scroll deltas. This ensures that a right-to-left swipe always maps to.leftand a left-to-right swipe always maps to.right, regardless of whether the user has "Natural Scrolling" enabled in macOS System Settings. - Event Leakage & UI Transition Safety: Currently, calling
super.scrollWheel(with: event)after triggeringonSwipecan propagate the event to a view hierarchy that is transitioning or being dismantled, which can cause AppKit layout crashes. Additionally, leaking horizontal swipe events to the underlying vertical scroll view can cause jittery vertical scrolling. Consuming the event when a swipe is active and dispatching the state transition asynchronously viaDispatchQueue.main.asyncresolves both issues.
override func scrollWheel(with event: NSEvent) {
var handled = false
switch event.phase {
case .began:
swipeAccumX = 0
swipeAccumY = 0
swipeFired = false
case .changed:
// Normalize deltas to support both natural and traditional scrolling directions.
let isNatural = event.isDirectionInvertedFromDevice
let dx = isNatural ? event.scrollingDeltaX : -event.scrollingDeltaX
let dy = isNatural ? event.scrollingDeltaY : -event.scrollingDeltaY
swipeAccumX += dx
swipeAccumY += dy
if !swipeFired,
abs(swipeAccumX) > swipeThreshold,
abs(swipeAccumX) > abs(swipeAccumY) * 1.6 {
swipeFired = true
let direction: SwipeDirection = swipeAccumX < 0 ? .left : .right
DispatchQueue.main.async { [weak self] in
self?.onSwipe(direction)
}
}
if swipeFired {
handled = true
}
case .ended, .cancelled:
if swipeFired {
handled = true
}
swipeAccumX = 0
swipeAccumY = 0
swipeFired = false
default:
break
}
if !handled {
super.scrollWheel(with: event)
}
}| /// (L2, image 2). The wallpaper dims toward black as it expands. | ||
| struct LockScreenBackdropView: View { | ||
| @ObservedObject var metadata: MediaMetadataService | ||
| @ObservedObject var lyrics: LyricsService |
There was a problem hiding this comment.
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.
| @ObservedObject var lyrics: LyricsService | |
| let lyrics: LyricsService |
What changed
Phase 8 — the final piece of the lock-screen surface (PRD §3.2 state L3, reference image 3). The lock screen now matches your reference flow end to end.
LockScreenLyricsViewreusesLyricsServiceand the notch'sSyncedLyricsView/PlainLyricsView(now internal + font-size parameterized) at larger lock-screen sizes. Loading / plain-text / "No lyrics found" states all handled.LockScreenControlPillused by both L2 and L3.LockScreenHostingView— same trackpad-scroll logic andSwipeDirectionas the notch; vertical scrolling passes through so the lyrics list still scrolls.Why
Brings Surface B to full parity with the notch: idle widget (L1) → expand (L2) → lyrics (L3), reusing the shared engine throughout.
Base
Top of the lock-screen stack: 5 → 6 → 7 → 8, based on
phase-7-lockscreen-expand(open PR #7). Diff is Phase 8 only. Merge the stack in order, or rebase ontomain.Notes for review — needs your eyes in Xcode
LockScreenHostingView.scrollWheel.LyricsPanelView.swift) only to makeSyncedLyricsView/PlainLyricsViewreusable with default params — the notch's appearance is unchanged.🤖 Generated with Claude Code