S10.4 — macOS system control (Now Playing + media keys)#58
Merged
Conversation
Vetted design for S10.4 (last R1 gate): media keys + Now Playing/Control Center (MediaPlayer) + Stop/Jump shortcuts, folding in the on-screen footer/mini-player metadata fix (same resolver). architect + swift-expert (SDK-grounded) + Fool + founder brainstorm. Locked: no entitlement/Info.plist change needed; explicit macOS playbackState; event-driven pushes (not per-tick); NowPlayingController peer via the onNowPlayingRefresh hook; pure NowPlayingSnapshot/RemoteCommandIntent in PlaybackQueueKit for tests; fix the latent ⌘←/⌘→ text-focus bug in passing. sprint-plan §Status + tables: S10.6 ✅ (shipped #57), S10.4 in progress → then S10.3.
Chunk A: pure NowPlayingSnapshot (PlaybackQueueKit) + AudioViewModel onNowPlayingRefresh hook fired from isPlaying/selectedTrackIndex/seek/ refreshDuration; canGoNext/canGoPrevious computed. NP-01..04 tests. Chunk B: NowPlayingController (MediaPlayer shell) — MPRemoteCommandCenter targets (togglePlayPause/play/pause/next/previous/seek) routed to the VM's transport verbs; coalesced MPNowPlayingInfoCenter pushes (info then playbackState, macOS-explicit); token-guarded async metadata+artwork enrichment via injected store/artwork closures. Wired in the composition root (Edge 4, one-directional hook) + AppDelegate clear() at quit.
…adata (D2) Controls menu: Stop (⌘.) → stopPlayback(); Jump to Now Playing (⌘0) → selectedTab. D5: guard ⌘←/⌘→ on isTextEntryFocused so they fall through to the field editor while typing (were stealing move-to-line-start/end). D2: NowPlayingController is @observable + injected; footer (NowPlayingBar) and Now Playing widget read its token-guarded currentArtist/currentArtwork accessors — real artist + cover art instead of the hardcoded 'Unknown Artist' + music.note placeholder. Single resolver, no duplication. Design §9/§10 updated to as-built (no separate infoDictionary/ RemoteCommandIntent pure types — MP-keyed, can't leave the executable target SPM can't @testable-import; NowPlayingSnapshot carries the tested decision logic).
…ale enablement Batch of confirmed break-it findings (qa-expert + Fool), most-severe first: - FN-1/QA#2: Stop (⌘.), end-of-queue, and a fresh restored cursor left a phantom paused-at-0:00 track in Control Center; NowPlayingState.stopped was dead. refresh() now clears when the session is stopped — extracted as pure, tested NowPlayingSnapshot.isStopped(isPlaying:elapsed:hasResumePoint:) (NP-05..07). Restores locked design §3/§7. performStop() fires the hook so Stop-from-paused (an isPlaying self-assign) still clears. - FN-2/QA#3: quit-while-playing re-pushed the track during async engine teardown, un-clearing Now Playing. Added an isTerminating latch; prepareForTermination() latches then clears; scheduleRefresh/refresh early-return. AppDelegate calls it. - QA#1: canGoNext/canGoPrevious enable-state went stale on shuffle/repeat toggle + paused queue edits (no push fired). queue(count)/shuffleEnabled/ repeatMode didSets now fire onNowPlayingRefresh. - CG-4: canGoNext returned !queue.isEmpty with no selection while nextTrack() no-ops → now returns false (single source of truth with the verb). - QA#5: loose-file meta=nil rewrite every push thrashed the @observable footer/widget → guarded on token change. - Corrected the inaccurate 'runs once' onAppear comment (idempotent-by-guard on window reopen). Deferred (noted for founder by-ear): media-key best-effort expectation (FN-3), loose-file embedded-tag fallback (FN-5), scrubber seam/manual-skip duration flash (FN-4, self-correcting).
Founder reversed D3 (was title-only for loose files): loose (non-library, trackID==nil) files now read embedded ID3/MP4 tags — artist/album/cover — via a new nonisolated EmbeddedMetadataReader (AVAsset.load(.commonMetadata)), so drag-dropped tracks show full metadata in Control Center + the footer/ widget, same as library tracks. Artwork carried as Sendable Data across the actor boundary; the NSImage is built on the main actor (design §5). Falls back to title-only only when a file has no usable tags. ResolvedTrackMeta.artist is now optional (nil → 'Unknown Artist' fallback); loose-file resolve mirrors the library path's synchronous token-claim + stale-guarded async apply. D3 annotated as reversed in the design doc.
ramith
added a commit
that referenced
this pull request
Jul 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
S10.4 — macOS system control (Now Playing + media keys)
Integrates AdaptiveSound with macOS's system playback surfaces and folds in the footer/widget metadata fix (D2).
What ships
MPNowPlayingInfoCenter) — title / artist / album / cover art / duration / extrapolated scrubber appear in Control Center, the menu-bar Now Playing widget, and the lock screen. Rate/state pushed on transitions (never per-tick).MPRemoteCommandCenter) — Control Center buttons + scrubber and the hardware media keys drive play / pause / next / previous / seek, routed to the existing transport verbs. (Hardware F7/F8/F9 are best-effort on macOS — Control Center / widget / scrubber are the reliable surface.)EmbeddedMetadataReader, so drag-dropped tracks also show full metadata; title-only only when a file has no usable tags.Architecture
@Observable @MainActor NowPlayingController— a composition-root peer (Edge 4), read-only overAudioViewModel+ caller of its transport verbs; no new playback state, no engine change. One-directionalonNowPlayingRefreshhook (mirrorsonEngineReady/onError), coalesced pushes, token-guarded async metadata/artwork enrichment. Store/artwork access injected as closures (controller imports neither the store nor AVFoundation directly).PlaybackQueueKit.NowPlayingSnapshot(rate/artist/album normalization; theisStoppedclear rule).QA break-it (first-class) — qa-expert + the-Fool ran against the code
No HIGH severity (RT/engine paths untouched). 6 confirmed findings fixed & re-gated:
.stoppedwas dead.refresh()now clears via the pure, testedNowPlayingSnapshot.isStopped(...)(NP-05..07). Restores locked §3/§7.isTerminatinglatch (prepareForTermination()).queue(count)/shuffleEnabled/repeatModedidSets fire the refresh hook.canGoNextreturnedtruewith no selection whilenextTrack()no-ops → returnsfalse.meta=nilrewrite thrashed the@Observableviews → guarded on token change.Testing
swift test):NowPlayingSnapshotNP-01..07 (rate/artist/album/passthrough +isStopped).make strict-gategreen.Deferred (noted, not in this PR)
Design:
docs/sprints/s10-4-macos-system-control-design.md.