Skip to content

S10.4 — macOS system control (Now Playing + media keys)#58

Merged
ramith merged 6 commits into
mainfrom
sprint/s10-4-macos-system-control
Jul 15, 2026
Merged

S10.4 — macOS system control (Now Playing + media keys)#58
ramith merged 6 commits into
mainfrom
sprint/s10-4-macos-system-control

Conversation

@ramith

@ramith ramith commented Jul 15, 2026

Copy link
Copy Markdown
Owner

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

  • Now Playing (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).
  • Remote commands (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.)
  • Controls menuStop (⌘.) and Jump to Now Playing (⌘0); D5: ⌘←/⌘→ now fall through to a focused text field instead of skipping tracks.
  • D2 footer/widget — the in-app footer + Now Playing widget show the real artist + cover art (was hardcoded "Unknown Artist" + a placeholder), reading the controller's single resolved-metadata source (no duplicated resolver).
  • D3 (reversed by founder) — loose (non-library) files read their embedded ID3/MP4 tags via 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 over AudioViewModel + caller of its transport verbs; no new playback state, no engine change. One-directional onNowPlayingRefresh hook (mirrors onEngineReady/onError), coalesced pushes, token-guarded async metadata/artwork enrichment. Store/artwork access injected as closures (controller imports neither the store nor AVFoundation directly).
  • Pure display decisions isolated + unit-tested in PlaybackQueueKit.NowPlayingSnapshot (rate/artist/album normalization; the isStopped clear 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:

  1. FN-1 — Stop / end-of-queue / fresh-restore left a phantom paused-at-0:00 track; .stopped was dead. refresh() now clears via the pure, tested NowPlayingSnapshot.isStopped(...) (NP-05..07). Restores locked §3/§7.
  2. FN-2 — quit-while-playing re-pushed the track during async teardown. Added an isTerminating latch (prepareForTermination()).
  3. QA-1 — Control Center Next/Prev enable-state went stale on shuffle/repeat toggle + paused queue edits → queue(count)/shuffleEnabled/repeatMode didSets fire the refresh hook.
  4. CG-4canGoNext returned true with no selection while nextTrack() no-ops → returns false.
  5. QA-5 — loose-file meta=nil rewrite thrashed the @Observable views → guarded on token change.
  6. Corrected an inaccurate "runs once" comment.

Testing

  • Pure (swift test): NowPlayingSnapshot NP-01..07 (rate/artist/album/passthrough + isStopped).
  • Gate: make strict-gate green.
  • Manual / by-ear (founder): Control Center / widget appearance, media keys + buttons + scrubber, Stop/EOQ clear, quit-while-playing, shuffle/repeat enablement, loose-file embedded art. (System integration is inherently manual-verify — no headless test proves "appears in Control Center".)

Deferred (noted, not in this PR)

  • Scrubber seam/manual-skip duration flash (FN-4, self-correcting, LOW).
  • Media-key expectation is a UX note, not code.

Design: docs/sprints/s10-4-macos-system-control-design.md.

ramith added 6 commits July 15, 2026 02:46
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
ramith merged commit 4651ee8 into main Jul 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant