diff --git a/Package.swift b/Package.swift index 305686c..5368a0f 100644 --- a/Package.swift +++ b/Package.swift @@ -180,6 +180,7 @@ let package = Package( "QueueOpsTests.swift", "QueueInsertTests.swift", "PlayThroughTrackerTests.swift", + "NowPlayingSnapshotTests.swift", ] // swift-testing is provided natively by the toolchain under swift-tools 6.2; // no manual Testing.framework linkage. (The former -F/-framework hack pointed diff --git a/Sources/AdaptiveSound/AdaptiveSound.swift b/Sources/AdaptiveSound/AdaptiveSound.swift index 9ceca5e..4ea7076 100644 --- a/Sources/AdaptiveSound/AdaptiveSound.swift +++ b/Sources/AdaptiveSound/AdaptiveSound.swift @@ -10,6 +10,9 @@ struct AdaptiveSound: App { @State private var eqViewModel: EQViewModel @State private var library: LibraryModel @State private var libraryModel: LibraryBrowseModel + /// S10.4: macOS system control (Control Center / media keys / Now Playing widget). A peer, not a + /// view — held in `@State` only for its lifetime; it reads the audio VM + calls its transport verbs. + @State private var nowPlaying: NowPlayingController /// Suppresses the global Space play/pause accelerator while a text field is focused (S4 SW1). @State private var keyboardFocus = KeyboardTransportFocus() @@ -39,7 +42,30 @@ struct AdaptiveSound: App { // S9.4: the browse model is owned HERE (above the tab switch) and injected, so Library // nav/selection/loaded state survives tab changes (LibraryTabView is switch-destroyed). It // composes BOTH peers — library reads + audio play verbs. - _libraryModel = State(initialValue: LibraryBrowseModel(audio: audio, library: lib)) + let browse = LibraryBrowseModel(audio: audio, library: lib) + _libraryModel = State(initialValue: browse) + // Edge 4 (S10.4): macOS system control. `NowPlayingController` reads `audio` + calls its + // transport verbs from MPRemoteCommandCenter handlers, and pushes Now Playing on the VM's + // `onNowPlayingRefresh` hook — same one-directional closure pattern as the edges above (no + // back-reference). Store/artwork access is injected as closures so the controller imports + // neither the store nor the artwork cache: metadata resolves through `lib.store`, artwork + // reuses the browse model's thumbnail cache. + let np = NowPlayingController() + np.audio = audio + np.resolveMetadata = { [weak lib] id in + guard let store = lib?.store, + let display = (try? await store.tracksDisplay(ids: [id]))?[id] else { return nil } + return ResolvedTrackMeta( + artist: display.artistName.isEmpty ? nil : display.artistName, + album: display.albumName, + artworkKey: display.artworkKey + ) + } + np.loadArtwork = { [weak browse] key in await browse?.artworkImage(forKey: key, maxPixel: 512) } + // Loose (non-library) files have no store row → read their embedded tags directly (S10.4 FN-5). + np.resolveLooseMetadata = { url in await EmbeddedMetadataReader.read(url) } + audio.onNowPlayingRefresh = { [weak np] in np?.scheduleRefresh() } + _nowPlaying = State(initialValue: np) } var body: some Scene { @@ -49,16 +75,24 @@ struct AdaptiveSound: App { .environment(eqViewModel) .environment(library) .environment(libraryModel) + .environment(nowPlaying) // S10.4 D2: footer + widget read the resolved metadata .environment(keyboardFocus) .onAppear { // Engine lifecycle belongs to the app/scene, NOT a child view's // `.task`/`.onDisappear` (the latter is an unreliable teardown signal and // was the fire-and-forget shutdown that couldn't complete at quit). Wire the // terminate-time teardown owners (both peers — the library tears down BEFORE - // the engine, see AppDelegate) and start the engine here (single-window app, so - // this runs once); teardown runs in `AppDelegate.applicationShouldTerminate`. + // the engine, see AppDelegate) and start the engine here. In the resident + // `.accessory` model this `onAppear` re-fires when a closed window reopens, so + // every call below is idempotent by guard (`initializeEngine` on `!isEngineReady`, + // `registerCommands` on `commandsRegistered`); teardown runs in + // `AppDelegate.applicationShouldTerminate`. appDelegate.audioViewModel = audioViewModel appDelegate.libraryModel = library + appDelegate.nowPlaying = nowPlaying + // Register the remote-command handlers once (marks the app a media app so the + // media keys + Control Center transport route here). Idempotent. + nowPlaying.registerCommands() audioViewModel.initializeEngine() } } @@ -93,13 +127,28 @@ struct AdaptiveSound: App { Divider() + // D5: ⌘→/⌘← also carry text-navigation ("move to line end/start"). Guard on + // isTextEntryFocused like Play/Pause above so they fall through to the field editor + // while a Library filter / Save-Preset field is focused, instead of skipping tracks. Button("Next Track") { audioViewModel.nextTrack() } .keyboardShortcut(.rightArrow, modifiers: .command) - .disabled(audioViewModel.selectedTrackIndex == nil) + .disabled(audioViewModel.selectedTrackIndex == nil || keyboardFocus.isTextEntryFocused) Button("Previous Track") { audioViewModel.previousTrack() } .keyboardShortcut(.leftArrow, modifiers: .command) + .disabled(audioViewModel.selectedTrackIndex == nil || keyboardFocus.isTextEntryFocused) + + Divider() + + // D1: Stop (⌘.) resets the playhead to 0 (distinct from position-preserving Pause); + // Jump to Now Playing (⌘0) switches the shell to the Now Playing tab. Both ⌘-combos + // produce no text, so no focus guard is needed. + Button("Stop") { audioViewModel.stopPlayback() } + .keyboardShortcut(".", modifiers: .command) .disabled(audioViewModel.selectedTrackIndex == nil) + + Button("Jump to Now Playing") { audioViewModel.selectedTab = .nowPlaying } + .keyboardShortcut("0", modifiers: .command) } } diff --git a/Sources/AdaptiveSound/AppDelegate.swift b/Sources/AdaptiveSound/AppDelegate.swift index 82b0154..40b1310 100644 --- a/Sources/AdaptiveSound/AppDelegate.swift +++ b/Sources/AdaptiveSound/AppDelegate.swift @@ -20,6 +20,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// The library subsystem peer (S3 F5), also owned by the App's `@State`. Torn down BEFORE the /// engine at quit so no scan/reconcile writes to the store while the C audio handles are freed. weak var libraryModel: LibraryModel? + /// S10.4: macOS system control, also owned by the App's `@State`. Cleared at quit so the Now + /// Playing widget / Control Center don't keep showing a stopped track after the app exits. + weak var nowPlaying: NowPlayingController? func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { // Last window closed (the red traffic-light button): retreat to the menu bar — hide the @@ -45,6 +48,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// to the menu bar instead). func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { guard audioViewModel != nil || libraryModel != nil else { return .terminateNow } + // Tear down Now Playing synchronously (cheap, no await): latch off further refreshes THEN + // clear, so the widget / Control Center drop the track the instant quit begins and the + // async engine teardown's isPlaying flip can't re-push it back (S10.4 QA #3 / Fool FN-2). + nowPlaying?.prepareForTermination() Task { @MainActor in // Ordered teardown across the two peers (S3 F5): tear the LIBRARY down first — stop the // FSEvents watcher + volume monitor and cancel any in-flight scan/reconcile — so nothing diff --git a/Sources/AdaptiveSound/AudioViewModel+Lifecycle.swift b/Sources/AdaptiveSound/AudioViewModel+Lifecycle.swift index 0d4de89..2ad9600 100644 --- a/Sources/AdaptiveSound/AudioViewModel+Lifecycle.swift +++ b/Sources/AdaptiveSound/AudioViewModel+Lifecycle.swift @@ -146,6 +146,10 @@ extension AudioViewModel { isPlaying = false playbackPosition = 0 duration = 0 + // Stop from an ALREADY-paused state is an `isPlaying` self-assign → its didSet's + // `!= oldValue` guard suppresses the Now Playing hook, so Control Center would keep + // showing the paused track. Fire explicitly so the clear rule runs (S10.4 QA #2). + onNowPlayingRefresh?() } catch { errorMessage = "Stop playback failed: \(error.localizedDescription)" } diff --git a/Sources/AdaptiveSound/AudioViewModel+Playback.swift b/Sources/AdaptiveSound/AudioViewModel+Playback.swift index 68010d5..526ea4d 100644 --- a/Sources/AdaptiveSound/AudioViewModel+Playback.swift +++ b/Sources/AdaptiveSound/AudioViewModel+Playback.swift @@ -83,6 +83,9 @@ extension AudioViewModel { await MainActor.run { [computedDuration] in self?.duration = computedDuration logUX("\(logLabel) = \(secs(computedDuration))s") + // The real (async) duration landed — re-push Now Playing so Control Center's + // scrubber shows the true length (closes the M4A 0-length flash, S10.4 D4). + self?.onNowPlayingRefresh?() } } } @@ -115,6 +118,7 @@ extension AudioViewModel { + "(from \(secs(playbackPosition))s, dur \(secs(duration))s, " + "path=\(signalPath.path == .pure ? "Pure" : "Enhanced"))") playbackPosition = seconds + onNowPlayingRefresh?() // re-anchor the Control Center scrubber to the new position (S10.4) Task { await engine.seek(to: seconds) } diff --git a/Sources/AdaptiveSound/AudioViewModel.swift b/Sources/AdaptiveSound/AudioViewModel.swift index 0904bf7..bff08cb 100644 --- a/Sources/AdaptiveSound/AudioViewModel.swift +++ b/Sources/AdaptiveSound/AudioViewModel.swift @@ -17,6 +17,12 @@ final class AudioViewModel { /// engine's `onOutputDevicesChanged`; this is NOT a device-recall callback. Set and /// invoked on the main actor (`@MainActor` isolation), so no `@Sendable` is required. var onEngineReady: (() -> Void)? + + /// Fired (on the main actor) when Now-Playing–relevant state changes — track / play-pause / + /// seek / resolved-duration — so the composition-root-wired `NowPlayingController` refreshes + /// Control Center + the Now Playing widget (S10.4). One-directional hook (mirrors + /// `onEngineReady`/`onError`); `nil` in tests. NEVER fired from the 20 Hz tick. + var onNowPlayingRefresh: (() -> Void)? /// Re-entrant `initialize()` guard. `true` from the moment an init is kicked off /// (`initializeEngine()` / `retryInitialization()`) until its `Task` finishes (success OR /// failure). A second init while one is in-flight would race the retry's teardown over the @@ -24,7 +30,14 @@ final class AudioViewModel { /// Internal (NOT private) so the `+Lifecycle` / `+Devices` extensions (separate files) can /// read/write it; `@MainActor` isolation makes the flag check/set race-free. Not UI-bound. var isInitializing = false - var isPlaying = false + var isPlaying = false { + didSet { + // Every play/pause/stop/end-of-queue/device-loss transition → refresh Now Playing + // (rate + elapsed + playbackState). Guarded on change; not self-assignment (S10.4). + if isPlaying != oldValue { onNowPlayingRefresh?() } + } + } + /// Selected top-level tab. Owned here (not in `ContentView` `@State`) so deep views — e.g. /// a double-click on the Now Playing spectrum — can navigate without binding-plumbing. var selectedTab: TabSelection = .nowPlaying { @@ -173,7 +186,14 @@ final class AudioViewModel { /// The play queue (S10.2). Each slot is a `QueueItem` (stable UUID identity), so the same /// track may appear more than once. `selectedTrackIndex`/`pendingNextIndex` are plain `Int` /// offsets into this array (the engine's index math is unchanged). - var queue: [QueueItem] = [] + var queue: [QueueItem] = [] { + // A size change can flip `canGoNext`/`canGoPrevious` (the remote-command enable state) + // without touching `selectedTrackIndex`/`isPlaying` — e.g. append/remove-after-current + // while paused (S10.4 QA #1). Refresh so Control Center's enable flags don't go stale. A + // pure reorder keeps the count and re-anchors `selectedTrackIndex` (whose didSet fires), so + // count is the right, non-spammy trigger. + didSet { if queue.count != oldValue.count { onNowPlayingRefresh?() } } + } /// Read-only view of the queue as plain `AudioFile`s, for cold display consumers that don't /// need slot identity (menu-bar, now-playing widget, transport). Queue *edits* go through the @@ -219,10 +239,28 @@ final class AudioViewModel { // before pressing Play (QA break-it #1). A same-value re-assignment (re-selecting the // paused track) preserves the resume point. Not self-assignment → no @Observable // didSet recursion. - if selectedTrackIndex != oldValue { pausedResumePosition = nil } + if selectedTrackIndex != oldValue { + pausedResumePosition = nil + onNowPlayingRefresh?() // track change (incl. gapless advance) → refresh Now Playing (S10.4) + } } } + /// Whether a manual Next would advance (honors shuffle / repeat / end-of-queue) — the single + /// source of truth with `nextTrack()`, used for the remote command's `.isEnabled` (S10.4). + var canGoNext: Bool { + // No selection → `nextTrack()` no-ops, so Next must read as disabled too (single source of + // truth with the verb — S10.4 CG-4; matches `canGoPrevious`). + guard let current = selectedTrackIndex else { return false } + return computeNextIndex(current: current, playlistCount: queue.count, manualSkip: true) != nil + } + + /// Whether a manual Previous would move — mirrors `previousTrack()`. + var canGoPrevious: Bool { + guard let current = selectedTrackIndex else { return false } + return computePreviousIndex(current: current, playlistCount: queue.count) != nil + } + // MARK: - Library (S3 F5 — extracted to the LibraryModel peer) /// Non-owning back-reference to the library subsystem (a `@MainActor @Observable` PEER, owned by @@ -235,11 +273,17 @@ final class AudioViewModel { // MARK: - Playback Modes (WinAmp Style) - /// Shuffle mode: when enabled, plays tracks in random order - var shuffleEnabled = false + /// Shuffle mode: when enabled, plays tracks in random order. + /// didSet: shuffle/repeat flip `canGoNext`/`canGoPrevious`, so refresh the remote-command + /// enable state (S10.4 QA #1) — else Control Center's Next/Prev stay stale until the next play. + var shuffleEnabled = false { + didSet { if shuffleEnabled != oldValue { onNowPlayingRefresh?() } } + } /// Repeat mode: 0 = no repeat, 1 = repeat all, 2 = repeat one - var repeatMode: Int = 0 + var repeatMode: Int = 0 { + didSet { if repeatMode != oldValue { onNowPlayingRefresh?() } } + } // MARK: - Gapless / Auto-Advance State diff --git a/Sources/AdaptiveSound/EmbeddedMetadataReader.swift b/Sources/AdaptiveSound/EmbeddedMetadataReader.swift new file mode 100644 index 0000000..ab86a0c --- /dev/null +++ b/Sources/AdaptiveSound/EmbeddedMetadataReader.swift @@ -0,0 +1,45 @@ +import AVFoundation +import Foundation + +// MARK: - Loose-file embedded metadata (S10.4 D2 / FN-5) + +/// A loose (non-library) file's embedded tags, read from the file itself for the Now Playing path. +/// Artwork is carried as `Data` (Sendable) so nothing non-Sendable crosses the actor boundary — the +/// `NSImage` is built on the main actor by the controller (design §5). +struct LooseTrackMetadata { + let artist: String? + let album: String? + let artworkData: Data? +} + +/// Reads embedded ID3/MP4 common metadata (artist / album / cover) from a file URL. Used ONLY for +/// loose files (`trackID == nil`), where there's no library row to resolve — library tracks go +/// through the store instead. `nonisolated`/off-main: `AVAsset` loading runs off the caller's actor. +enum EmbeddedMetadataReader { + static func read(_ url: URL) async -> LooseTrackMetadata? { + let asset = AVURLAsset(url: url) + guard let items = try? await asset.load(.commonMetadata), !items.isEmpty else { return nil } + + let artist = await string(from: items, key: .commonKeyArtist) + let album = await string(from: items, key: .commonKeyAlbumName) + let artworkData = await data(from: items, key: .commonKeyArtwork) + // Nothing usable found → nil so the caller keeps title-only rather than an empty card. + guard artist != nil || album != nil || artworkData != nil else { return nil } + return LooseTrackMetadata(artist: artist, album: album, artworkData: artworkData) + } + + /// The first item for `key`, loaded as a trimmed non-empty string (nil = absent/empty, so the + /// footer falls back to "Unknown Artist"). + private static func string(from items: [AVMetadataItem], key: AVMetadataKey) async -> String? { + guard let item = items.first(where: { $0.commonKey == key }), + let value = try? await item.load(.stringValue) else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// The first item for `key`, loaded as raw data (embedded cover art). + private static func data(from items: [AVMetadataItem], key: AVMetadataKey) async -> Data? { + guard let item = items.first(where: { $0.commonKey == key }) else { return nil } + return try? await item.load(.dataValue) + } +} diff --git a/Sources/AdaptiveSound/NowPlayingController.swift b/Sources/AdaptiveSound/NowPlayingController.swift new file mode 100644 index 0000000..8c8dfc6 --- /dev/null +++ b/Sources/AdaptiveSound/NowPlayingController.swift @@ -0,0 +1,281 @@ +import AppKit +import Foundation +import MediaPlayer +import PlaybackQueueKit + +// MARK: - Resolved track metadata + +/// Display metadata for the current track (artist / album / artwork key), returned by the injected +/// `resolveMetadata` closure. A struct, not a tuple, so it stays under the `large_tuple` lint (same +/// reason `FrecencyState` is a struct). `artist` is optional (nil = unknown → the footer falls back +/// to "Unknown Artist"); `artworkKey` is the library cache key (nil for loose files, whose artwork +/// is applied directly as an image). +struct ResolvedTrackMeta { + let artist: String? + let album: String? + let artworkKey: String? +} + +// MARK: - NowPlayingController (S10.4) + +/// Drives macOS system control: `MPNowPlayingInfoCenter` (Control Center + the menu-bar Now Playing +/// widget + lock screen) and `MPRemoteCommandCenter` (media keys + Control Center transport). A +/// read-only consumer of `AudioViewModel` + a caller of its existing transport verbs — no new +/// playback state, no engine change. Owned by the composition root; the VM's `onNowPlayingRefresh` +/// closure calls `scheduleRefresh()` (one-directional, no back-reference). Store access is via +/// injected closures so this stays store-agnostic + the pure display logic lives in +/// `PlaybackQueueKit.NowPlayingSnapshot`. +@MainActor +@Observable +final class NowPlayingController { + /// Non-owning reference to pull snapshot state + call transport verbs from command handlers. + weak var audio: AudioViewModel? + + /// Resolve a library track's display metadata (artist / album / artwork key) by durable id. + /// Injected over `library.store` so the controller never imports the store. Called on the main + /// actor (not `@Sendable`), so it may capture the main-actor peers. Nil = not resolved. + var resolveMetadata: ((Int64) async -> ResolvedTrackMeta?)? + /// Load a cover image by artwork cache key. Called on the main actor. Nil = no image. + var loadArtwork: ((String) async -> NSImage?)? + /// Read a loose (non-library) file's embedded artist/album/artwork off-main (the `trackID == nil` + /// path — S10.4 FN-5). Injected so the controller doesn't import AVFoundation. Nil = unreadable. + var resolveLooseMetadata: ((URL) async -> LooseTrackMetadata?)? + + /// Cached resolved metadata + artwork, keyed by the current track token, so play/pause/seek + /// pushes reuse them (only a track CHANGE re-resolves). + private var metaToken: String? + private var meta: ResolvedTrackMeta? + private var artworkToken: String? + private var artwork: NSImage? + + /// Coalescing guard: a burst of `didSet`s at a track start collapses into one push. + private var refreshScheduled = false + private var commandTokens: [(command: MPRemoteCommand, token: Any)] = [] + private var commandsRegistered = false + /// Latched at quit (`prepareForTermination`). Blocks all further refreshes so the async engine + /// teardown — whose `performStop()` fires `isPlaying=false` → the refresh hook — cannot re-push + /// the track AFTER `clear()` cleared it (S10.4 QA #3 / Fool FN-2). + private var isTerminating = false + + // MARK: UI display (D2 — footer / Now Playing widget read these) + + /// The single resolved metadata source the footer + Now Playing widget read (D2 — instead of a + /// hardcoded "Unknown Artist"), so the id→display resolve happens ONCE here, not duplicated in + /// the views. Both are token-guarded: they return nil unless the resolved value belongs to the + /// track currently selected, so the async-resolve gap never flashes the previous track's + /// metadata. nil → the view shows its own fallback. (Album goes only to Control Center via the + /// snapshot's `albumName`; the compact in-app footer/widget show artist only.) + var currentArtist: String? { + liveMeta?.artist + } + + var currentArtwork: NSImage? { + guard let artworkToken, isStillCurrent(artworkToken) else { return nil } + return artwork + } + + private var liveMeta: ResolvedTrackMeta? { + guard let metaToken, isStillCurrent(metaToken) else { return nil } + return meta + } + + // MARK: Command registration (once, at launch) + + /// Register the remote-command handlers ONCE. Enabling only the handled commands (leaving the + /// rest disabled) is what marks the app a media app + keeps the media keys routed to it. + func registerCommands() { + guard !commandsRegistered else { return } + commandsRegistered = true + let center = MPRemoteCommandCenter.shared() + + add(center.togglePlayPauseCommand) { [weak self] in self?.audio?.togglePlayPause() } + add(center.playCommand) { [weak self] in self?.audio?.play() } + add(center.pauseCommand) { [weak self] in self?.audio?.pause() } + add(center.nextTrackCommand) { [weak self] in self?.audio?.nextTrack() } + add(center.previousTrackCommand) { [weak self] in self?.audio?.previousTrack() } + + center.changePlaybackPositionCommand.isEnabled = true + let seekToken = center.changePlaybackPositionCommand.addTarget { [weak self] event in + guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } + let position = event.positionTime + Task { @MainActor in self?.audio?.seek(to: position) } + return .success + } + commandTokens.append((center.changePlaybackPositionCommand, seekToken)) + } + + /// Register one no-argument command → transport verb (handlers fire off-main → hop to @MainActor). + private func add(_ command: MPRemoteCommand, _ verb: @escaping @MainActor () -> Void) { + command.isEnabled = true + let token = command.addTarget { _ in + Task { @MainActor in verb() } + return .success + } + commandTokens.append((command, token)) + } + + // MARK: Refresh (coalesced, event-driven — never per-tick) + + /// Coalesce a refresh onto the next runloop turn so the `didSet` burst at a track start + /// (selectedTrackIndex, then isPlaying) becomes ONE push carrying final state. + func scheduleRefresh() { + guard !refreshScheduled, !isTerminating else { return } + refreshScheduled = true + Task { @MainActor [weak self] in + self?.refreshScheduled = false + self?.refresh() + } + } + + private func refresh() { + guard !isTerminating else { return } + guard let audio, let index = audio.selectedTrackIndex, index < audio.queue.count else { + clear() // no current track → clear Now Playing + return + } + // Stopped / finished / never-started: Stop (⌘.), end-of-queue, or a fresh restored cursor + // all leave the track SELECTED but at position 0 with no resume point. That's not an active + // or paused-mid-track session, so clear Now Playing rather than push a phantom paused-at-0:00 + // track (design §3/§7; S10.4 FN-1). A Pause keeps `pausedResumePosition`, so it stays shown. + if NowPlayingSnapshot.isStopped( + isPlaying: audio.isPlaying, + elapsedSeconds: audio.playbackPosition, + hasResumePoint: audio.pausedResumePosition != nil + ) { + clear() + return + } + let file = audio.queue[index].file + let token = trackToken(file) + // Reuse cached metadata/artwork only if it belongs to the current track. + let resolved = (metaToken == token) ? meta : nil + let image = (artworkToken == token) ? artwork : nil + + let snapshot = NowPlayingSnapshot( + title: file.name, + artistName: resolved?.artist ?? "", + albumName: resolved?.album ?? nil, + durationSeconds: audio.duration, + elapsedSeconds: audio.playbackPosition, + state: audio.isPlaying ? .playing : .paused, + artworkKey: resolved?.artworkKey, + trackToken: token + ) + push(snapshot, artwork: image) + updateCommandEnablement(audio) + + // Track changed → resolve metadata + artwork asynchronously, then re-push (stale-guarded). + if metaToken != token { + // Claim the token now (title-only immediately) so a same-value rewrite every play/pause + // push doesn't thrash the @Observable footer/widget, and a slower loose-file read isn't + // re-triggered on each push before it lands (S10.4 QA #5). + metaToken = token + meta = nil + if let trackID = file.trackID { + resolveAndRepush(trackID: trackID, token: token) + } else { + resolveLooseAndApply(url: file.absoluteURL, token: token) // loose file: embedded tags + } + } + } + + /// Read a loose file's embedded tags off-main, then apply artist/album + build the cover + /// `NSImage` from the Sendable `Data` on the main actor — stale-guarded like the library path. + private func resolveLooseAndApply(url: URL, token: String) { + Task { @MainActor [weak self] in + guard let self, let loose = await resolveLooseMetadata?(url) else { return } + guard isStillCurrent(token), metaToken == token else { return } + meta = ResolvedTrackMeta(artist: loose.artist, album: loose.album, artworkKey: nil) + if let data = loose.artworkData, let image = NSImage(data: data) { + artworkToken = token + artwork = image + } + refresh() // re-push with embedded artist/album/art + } + } + + /// Async-enrich the current track's metadata + artwork; apply each only if the track is still + /// current (a fast skip past a track must not stamp its art onto the next one). + private func resolveAndRepush(trackID: Int64, token: String) { + Task { @MainActor [weak self] in + guard let self, let resolved = await resolveMetadata?(trackID) else { return } + guard isStillCurrent(token) else { return } + metaToken = token + meta = resolved + refresh() // re-push with artist/album + guard let key = resolved.artworkKey, let image = await loadArtwork?(key), isStillCurrent(token) else { + return + } + artworkToken = token + artwork = image + refresh() // re-push with artwork + } + } + + // MARK: MediaPlayer push + + private func push(_ snapshot: NowPlayingSnapshot, artwork: NSImage?) { + var info: [String: Any] = [ + MPMediaItemPropertyTitle: snapshot.title, + MPMediaItemPropertyPlaybackDuration: snapshot.durationSeconds, + MPNowPlayingInfoPropertyElapsedPlaybackTime: snapshot.elapsedSeconds, + MPNowPlayingInfoPropertyPlaybackRate: snapshot.rate, + ] + if let artist = snapshot.artist { info[MPMediaItemPropertyArtist] = artist } + if let album = snapshot.album { info[MPMediaItemPropertyAlbumTitle] = album } + if let artwork { + info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: artwork.size) { _ in artwork } + } + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = info + center.playbackState = playbackState(snapshot.state) // macOS: MUST be set explicitly + } + + /// Clear Now Playing (stopped / no track). Latch-free so the stopped-state path in `refresh()` + /// can call it repeatedly without disabling future refreshes. + func clear() { + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = nil + center.playbackState = .stopped + metaToken = nil; meta = nil; artworkToken = nil; artwork = nil + } + + /// Quit teardown: latch OFF all further refreshes, THEN clear — so the async engine shutdown + /// (which flips `isPlaying` and fires the refresh hook) can't re-push the track after this + /// clears it (S10.4 QA #3 / Fool FN-2). Called synchronously from `applicationShouldTerminate`. + func prepareForTermination() { + isTerminating = true + clear() + } + + private func updateCommandEnablement(_ audio: AudioViewModel) { + let center = MPRemoteCommandCenter.shared() + let hasTrack = audio.selectedTrackIndex != nil + center.togglePlayPauseCommand.isEnabled = hasTrack + center.playCommand.isEnabled = hasTrack + center.pauseCommand.isEnabled = audio.isPlaying + center.nextTrackCommand.isEnabled = audio.canGoNext + center.previousTrackCommand.isEnabled = audio.canGoPrevious + center.changePlaybackPositionCommand.isEnabled = audio.duration > 0 + } + + // MARK: Helpers + + /// Stable per-track identity: the durable id when present, else the file URL (loose files). + private func trackToken(_ file: AudioFile) -> String { + file.trackID.map(String.init) ?? file.absoluteURL.absoluteString + } + + private func isStillCurrent(_ token: String) -> Bool { + guard let audio, let index = audio.selectedTrackIndex, index < audio.queue.count else { return false } + return trackToken(audio.queue[index].file) == token + } + + private func playbackState(_ state: NowPlayingState) -> MPNowPlayingPlaybackState { + switch state { + case .playing: .playing + case .paused: .paused + case .stopped: .stopped + } + } +} diff --git a/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingWidget.swift b/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingWidget.swift index bc4addd..53cba10 100644 --- a/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingWidget.swift +++ b/Sources/AdaptiveSound/UI/NowPlaying/NowPlayingWidget.swift @@ -22,17 +22,14 @@ struct NowPlayingWidget: View { private struct TrackCard: View { @Environment(AudioViewModel.self) private var viewModel + // S10.4 D2: the current track's resolved artist/artwork (nil until resolved / for loose files). + @Environment(NowPlayingController.self) private var nowPlaying let track: AudioFile var body: some View { VStack(spacing: 12) { HStack(spacing: 12) { - Image(systemName: "music.note") - .font(.system(size: 24)) - .foregroundStyle(Color.asAccent) - .frame(width: 52, height: 52) - .background(Color.asWindow) - .clipShape(.rect(cornerRadius: 8)) + artwork VStack(alignment: .leading, spacing: 4) { Text(track.name) @@ -40,7 +37,7 @@ private struct TrackCard: View { .foregroundStyle(Color.asLabel) .lineLimit(1) - Text("Unknown Artist") + Text(nowPlaying.currentArtist ?? "Unknown Artist") .font(DesignSystem.Font.caption) .foregroundStyle(Color.asLabelSecond) .lineLimit(1) @@ -56,6 +53,25 @@ private struct TrackCard: View { .background(Color.asWindow) .clipShape(.rect(cornerRadius: 8)) } + + /// Real cover when resolved (S10.4 D2); the music.note placeholder otherwise. + private var artwork: some View { + Group { + if let image = nowPlaying.currentArtwork { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "music.note") + .font(.system(size: 24)) + .foregroundStyle(Color.asAccent) + .frame(width: 52, height: 52) + .background(Color.asWindow) + } + } + .frame(width: 52, height: 52) + .clipShape(.rect(cornerRadius: 8)) + } } // MARK: - Empty Track Card diff --git a/Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift b/Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift index 5004afc..334cab8 100644 --- a/Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift +++ b/Sources/AdaptiveSound/UI/Shell/NowPlayingBar.swift @@ -44,6 +44,8 @@ struct NowPlayingBar: View { private struct NowPlayingInfoRegion: View { @Environment(AudioViewModel.self) private var viewModel + // S10.4 D2: the current track's resolved artist/artwork (nil until resolved / for loose files). + @Environment(NowPlayingController.self) private var nowPlaying @Environment(\.accessibilityReduceMotion) private var reduceMotion let track: AudioFile? @State private var hovering = false @@ -52,6 +54,13 @@ private struct NowPlayingInfoRegion: View { track != nil } + /// The footer subtitle: the resolved artist when known, else the honest "Unknown Artist" + /// fallback (a loose file or a track with no artist tag), else the idle prompt. + private var subtitle: String { + guard isLoaded else { return "Select a track to play" } + return nowPlaying.currentArtist ?? "Unknown Artist" + } + var body: some View { Button { viewModel.selectedTab = .nowPlaying @@ -64,7 +73,7 @@ private struct NowPlayingInfoRegion: View { .foregroundStyle(titleColor) .lineLimit(1) .truncationMode(.tail) - Text(isLoaded ? "Unknown Artist" : "Select a track to play") + Text(subtitle) .font(DesignSystem.Font.trackSubtitle) .foregroundStyle(subtitleColor) .lineLimit(1) @@ -96,17 +105,28 @@ private struct NowPlayingInfoRegion: View { .accessibilityHint(isLoaded ? "Opens the Now Playing tab" : "") } - private var artThumb: some View { - Image(systemName: "music.note") - .font(.system(size: 18)) - .foregroundStyle(DesignSystem.Color.labelTertiary) - .frame(width: DesignSystem.Artwork.thumb, height: DesignSystem.Artwork.thumb) - .background(DesignSystem.Color.card) - .clipShape(.rect(cornerRadius: DesignSystem.Radius.control, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: DesignSystem.Radius.control, style: .continuous) - .strokeBorder(DesignSystem.Color.hairline, lineWidth: 0.5) + @ViewBuilder private var artThumb: some View { + let side = DesignSystem.Artwork.thumb + Group { + // Real cover when resolved (S10.4 D2); the music.note placeholder otherwise. + if let artwork = nowPlaying.currentArtwork { + Image(nsImage: artwork) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "music.note") + .font(.system(size: 18)) + .foregroundStyle(DesignSystem.Color.labelTertiary) + .frame(width: side, height: side) + .background(DesignSystem.Color.card) } + } + .frame(width: side, height: side) + .clipShape(.rect(cornerRadius: DesignSystem.Radius.control, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: DesignSystem.Radius.control, style: .continuous) + .strokeBorder(DesignSystem.Color.hairline, lineWidth: 0.5) + } } private var titleColor: Color { @@ -119,7 +139,7 @@ private struct NowPlayingInfoRegion: View { private var accessibilityLabel: String { guard let track else { return "Nothing playing" } - return "Now Playing, \(track.name), Unknown Artist" + return "Now Playing, \(track.name), \(nowPlaying.currentArtist ?? "Unknown Artist")" } } diff --git a/Sources/PlaybackQueueKit/NowPlayingSnapshot.swift b/Sources/PlaybackQueueKit/NowPlayingSnapshot.swift new file mode 100644 index 0000000..d7742c6 --- /dev/null +++ b/Sources/PlaybackQueueKit/NowPlayingSnapshot.swift @@ -0,0 +1,62 @@ +// NowPlayingSnapshot — the pure, MediaPlayer-free model of what to show in Now Playing (S10.4). +// +// The `NowPlayingController` (app target) builds one of these from `AudioViewModel` state on each +// coalesced push, then maps it to the `MPNowPlayingInfoCenter` dict + `playbackState`. Keeping the +// display DECISIONS here (empty artist/album → omitted; rate derived from state) makes them +// unit-testable without touching the MediaPlayer C-API (which mutates global system state). + +public enum NowPlayingState: Sendable, Equatable { + case playing + case paused + case stopped +} + +public struct NowPlayingSnapshot: Sendable, Equatable { + public let title: String + /// nil when the source artist is empty (omit the key rather than show a blank). + public let artist: String? + /// nil when the source album is nil/empty. + public let album: String? + public let durationSeconds: Double + public let elapsedSeconds: Double + /// 1.0 while playing, else 0.0 — the second half of the macOS Now-Playing rate/state pair. + public let rate: Double + public let state: NowPlayingState + /// Artwork cache key for async resolution (nil = no artwork; loose files / no key). + public let artworkKey: String? + /// Stable identity of the track this snapshot describes — the async artwork load applies its + /// result only if this still matches the current track (stale-guard). + public let trackToken: String + + /// Build from raw VM state, applying the display decisions: an empty artist/album is omitted, + /// and the playback rate is derived from the state (1 playing, 0 paused/stopped). + public init( + title: String, + artistName: String, + albumName: String?, + durationSeconds: Double, + elapsedSeconds: Double, + state: NowPlayingState, + artworkKey: String?, + trackToken: String + ) { + self.title = title + artist = artistName.isEmpty ? nil : artistName + album = (albumName?.isEmpty ?? true) ? nil : albumName + self.durationSeconds = durationSeconds + self.elapsedSeconds = elapsedSeconds + rate = state == .playing ? 1.0 : 0.0 + self.state = state + self.artworkKey = artworkKey + self.trackToken = trackToken + } + + /// Whether the transport is "stopped / finished / never-started" — NOT playing, at position 0, + /// with no resume point. Now Playing should be CLEARED in this state, not shown as a phantom + /// paused-at-0:00 track (S10.4 FN-1; covers ⌘. Stop, end-of-queue, and a fresh restored cursor). + /// A paused-mid-track session keeps a resume point (or a non-zero position), so it is NOT + /// stopped and stays shown. + public static func isStopped(isPlaying: Bool, elapsedSeconds: Double, hasResumePoint: Bool) -> Bool { + !isPlaying && elapsedSeconds == 0 && !hasResumePoint + } +} diff --git a/Tests/AudioViewModelTests/NowPlayingSnapshotTests.swift b/Tests/AudioViewModelTests/NowPlayingSnapshotTests.swift new file mode 100644 index 0000000..a35ac8c --- /dev/null +++ b/Tests/AudioViewModelTests/NowPlayingSnapshotTests.swift @@ -0,0 +1,68 @@ +import PlaybackQueueKit +import Testing + +// MARK: - NowPlayingSnapshot — the pure Now-Playing display decisions (S10.4) + +@Suite("PlaybackQueueKit — NowPlayingSnapshot") +struct NowPlayingSnapshotTests { + private func make( + artist: String = "Miles Davis", album: String? = "Kind of Blue", state: NowPlayingState = .playing + ) -> NowPlayingSnapshot { + NowPlayingSnapshot( + title: "So What", artistName: artist, albumName: album, + durationSeconds: 545, elapsedSeconds: 120, state: state, + artworkKey: "abc", trackToken: "t1" + ) + } + + @Test("NP-01: empty artist is omitted (nil), non-empty is kept") + func emptyArtistOmitted() { + #expect(make(artist: "").artist == nil) + #expect(make(artist: "Miles Davis").artist == "Miles Davis") + } + + @Test("NP-02: nil or empty album is omitted (nil)") + func emptyAlbumOmitted() { + #expect(make(album: nil).album == nil) + #expect(make(album: "").album == nil) + #expect(make(album: "Kind of Blue").album == "Kind of Blue") + } + + @Test("NP-03: rate is 1.0 playing, 0.0 paused/stopped") + func rateFromState() { + #expect(make(state: .playing).rate == 1.0) + #expect(make(state: .paused).rate == 0.0) + #expect(make(state: .stopped).rate == 0.0) + } + + @Test("NP-04: title / duration / elapsed / artworkKey / token pass through unchanged") + func passthrough() { + let snapshot = make() + #expect(snapshot.title == "So What") + #expect(snapshot.durationSeconds == 545) + #expect(snapshot.elapsedSeconds == 120) + #expect(snapshot.artworkKey == "abc") + #expect(snapshot.trackToken == "t1") + } + + // MARK: isStopped — the "clear Now Playing" decision (S10.4 FN-1) + + @Test("NP-05: stopped = not playing, at 0, no resume point (Stop / end-of-queue / fresh restore)") + func stoppedState() { + #expect(NowPlayingSnapshot.isStopped(isPlaying: false, elapsedSeconds: 0, hasResumePoint: false)) + } + + @Test("NP-06: playing is never stopped, even at position 0") + func playingNotStopped() { + #expect(!NowPlayingSnapshot.isStopped(isPlaying: true, elapsedSeconds: 0, hasResumePoint: false)) + #expect(!NowPlayingSnapshot.isStopped(isPlaying: true, elapsedSeconds: 42, hasResumePoint: false)) + } + + @Test("NP-07: paused-mid-track is not stopped — a resume point OR a non-zero position keeps it shown") + func pausedNotStopped() { + // Pause records a resume point (even a resume point of 0 → paused at the very start). + #expect(!NowPlayingSnapshot.isStopped(isPlaying: false, elapsedSeconds: 0, hasResumePoint: true)) + // Or a non-zero elapsed (paused mid-track). + #expect(!NowPlayingSnapshot.isStopped(isPlaying: false, elapsedSeconds: 90, hasResumePoint: false)) + } +} diff --git a/docs/sprints/s10-4-macos-system-control-design.md b/docs/sprints/s10-4-macos-system-control-design.md new file mode 100644 index 0000000..fd381f9 --- /dev/null +++ b/docs/sprints/s10-4-macos-system-control-design.md @@ -0,0 +1,98 @@ +# S10.4 — macOS system control — design + +**Status:** Vetted (architect-reviewer + swift-expert design, both SDK/doc-grounded; Fool frame-pass; founder brainstorm 2026-07-15). Awaiting implementation. Last R1-gating sprint (R1 = S10.1–S10.4). + +Media keys + Now Playing / Control Center (MediaPlayer) + a couple of app-wide keyboard shortcuts, plus (folded in per the founder) fixing the on-screen footer/mini-player to show real track metadata via the same resolver. + +## 0. Locked decisions (founder brainstorm 2026-07-15) + +| # | Decision | Choice | +|---|---|---| +| D1 | New shortcuts | **Stop (⌘.)** + **Jump to Now Playing (⌘0)**. No seek/volume shortcuts (system volume keys own volume; `masterGain` is a distinct DSP gain). | +| D2 | Footer/mini-player metadata | **Fold the fix into S10.4** — reuse the new resolver so `NowPlayingBar`/`NowPlayingWidget` show real artist/album/artwork instead of the hardcoded "Unknown Artist" + placeholder. | +| D3 | Loose (non-library) files | ~~Now Playing shows **title only** (`trackID == nil` → no artist/album/art).~~ **REVERSED (founder, 2026-07-15):** loose files now read their **embedded ID3/MP4 tags** (artist/album/cover) via `EmbeddedMetadataReader`, so drag-dropped tracks show full metadata too. Falls back to title-only only when the file has no usable tags. | +| D4 | M4A duration | **Two-push** (track-change push may carry duration 0, `refreshDuration` completion re-pushes the real duration) rather than delaying playback. | +| D5 | ⌘←/⌘→ latent bug | **Fix in passing** — add the `keyboardFocus.isTextEntryFocused` guard to Next/Prev (today ⌘← steals "move to line start" while typing). | + +## 1. Reality checks (from the research/SDK, not assumptions) +- MediaPlayer (`MPNowPlayingInfoCenter`, `MPRemoteCommandCenter`, `changePlaybackPositionCommand`, `playbackState`) is **macOS 10.12.2+** — the online "iOS/tvOS only" badge is a misread. App targets macOS 14, so **no `@available` guards**. +- **macOS requires `MPNowPlayingInfoCenter.default().playbackState` to be set explicitly** (`.playing`/`.paused`/`.stopped`) — it is NOT inferred (the #1 gotcha for appearing in Control Center + capturing media keys). +- **No entitlement / Info.plist / background-mode change** — verified (no `.entitlements`/sandbox in the repo; `UIBackgroundModes` is iOS-only). `MediaPlayer.framework` auto-links via `import`. +- **Media-key play/pause is best-effort on macOS** (can trigger Music.app; no API to force key ownership). Reliable surfaces: Control Center, the menu-bar Now Playing widget, next/prev. Maximize reliability: register commands at launch, set `playbackState` + fresh `nowPlayingInfo` on first play, enable only handled commands. +- **Metadata gap:** the queue's `AudioFile` carries only title/format/url/duration/`trackID` — artist/album/artworkKey live in `LibraryTrackDisplay`, resolved by id via `store.tracksDisplay(ids:)`. S10.4 builds the first `playing-track → display-metadata` resolver (D2 makes the footer/widget reuse it). + +## 2. Architecture +- **`@MainActor final class NowPlayingController`** — a composition-root peer (like `EQViewModel`/`LibraryBrowseModel`), a read-only consumer of `AudioViewModel` + caller of its existing transport verbs. No new playback state, no engine change. +- **Wiring (one-directional, house idiom):** VM exposes `var onNowPlayingRefresh: (() -> Void)?` (mirrors `onEngineReady`/`onError`); the composition root wires `audio.onNowPlayingRefresh = { [weak nowPlaying] in nowPlaying?.scheduleRefresh() }`. The controller holds `weak var audio` to pull snapshot state + call verbs (like `LibraryBrowseModel`). Metadata/artwork resolvers injected as closures over `library.store` (controller stays store-agnostic). +- **Fire the hook from existing funnels:** `selectedTrackIndex.didSet` (already exists) covers track change incl. gapless advance; add `isPlaying.didSet` (covers every play/pause/stop/end-of-queue/device-loss); explicit calls in `seek(to:)` + `refreshDuration` completion. **Never** from the 20 Hz tick. +- **Coalescing:** `scheduleRefresh()` sets a flag + hops one runloop (guarded), so the burst of `didSet`s at a track start collapses into ONE push. + +## 3. Sync (event-driven, extrapolated scrubber) +Each coalesced push builds a pure `NowPlayingSnapshot` and writes `nowPlayingInfo` (title/artist/album/duration/elapsed/rate/artwork) **then** `playbackState`. Elapsed comes from `viewModel.playbackPosition` (authoritative on the main actor); the system extrapolates via `elapsed + PlaybackRate(0/1) + wall-clock`. +- Track change → full rebuild (re-resolve metadata + artwork; elapsed 0 or `resumeFrom`). +- Play/pause → rate + elapsed + `playbackState` (no metadata re-resolve). +- Seek → elapsed (re-anchor); rate/state unchanged. +- `refreshDuration` completion → duration only (closes the M4A 0-scrubber flash, D4). +- Stop / end-of-queue → `playbackState = .stopped`, `nowPlayingInfo = nil`. + +## 4. Commands +Registered ONCE at launch (never per-track — re-adding stacks duplicate handlers); toggle with `.isEnabled`. Handlers fire off-main → `Task { @MainActor in vm.() }`, return `.success` synchronously. Keep the target tokens; remove on teardown. + +| Command | enabled when | verb | return | +|---|---|---|---| +| togglePlayPause / play | track loaded | `togglePlayPause()` / `play()` | `.success` / `.noSuchContent` | +| pause | `isPlaying` | `pause()` | `.success` | +| nextTrack | `canGoNext` | `nextTrack()` | `.success` / `.noSuchContent` at end | +| previousTrack | `canGoPrevious` | `previousTrack()` | `.success` / `.noSuchContent` | +| changePlaybackPosition | `duration > 0` | `seek(to: event.positionTime)` | `.success` | +| (all others) | disabled | — | — | + +`canGoNext`/`canGoPrevious` = new computed props on the VM reusing `computeNextIndex(manualSkip:true)`/`computePreviousIndex` (single source of truth with the verbs). Recomputed per push. + +## 5. Artwork +Current `trackID` → resolve `LibraryTrackDisplay` (same `tracksDisplay(ids:)` round-trip as the metadata) → decode via an `ArtworkThumbnailStore` (own instance, 512 px thumb) off-main → `MPMediaItemArtwork(boundsSize:) { _ in image }`. Push text immediately (warm-cache peek if available); apply artwork asynchronously ONLY if the track token still matches (stale-guard). No artwork (`trackID`/`artworkKey` nil or miss) → omit the key. Swift-6: if the request handler is `@Sendable` and can't capture `NSImage`, capture the JPEG `Data` and build `NSImage(data:)` inside the block. + +## 6. Footer / mini-player metadata (D2, folded in) +`NowPlayingBar` + `NowPlayingWidget` currently hardcode "Unknown Artist" + a placeholder. Feed them the SAME resolved metadata (artist/album/artwork for the current `trackID`). Simplest: the resolved display metadata for the current track becomes observable VM/controller state the footer reads (exact placement decided at impl — reuse the resolver, don't duplicate it). + +## 7. Lifecycle +Register commands + build the controller at launch. First `nowPlayingInfo` push on first `startPlayback`. Persist while playing even when the window closes (`.accessory` menu-bar mode keeps playing). Clear deterministically on quit: `AppDelegate` holds a `weak nowPlaying` and calls a synchronous `clear()` (`nowPlayingInfo = nil`, `playbackState = .stopped`) inside the `applicationShouldTerminate` teardown (don't rely on the coalesced Task before process exit). + +## 8. Shortcuts (D1 + D5) +Extend `CommandMenu("Controls")`: **Stop (⌘.)** → `stopPlayback()`; **Jump to Now Playing (⌘0)** → `selectedTab = .nowPlaying`. Both ⌘-combos produce no text → no focus guard needed. **Fix D5:** add `|| keyboardFocus.isTextEntryFocused` to the existing Next (⌘→) / Prev (⌘←) `.disabled` so they don't steal text-navigation while a field is focused. + +## 9. Testability + QA + +**As-built note (deviation from the pre-impl plan, recorded not excised):** the planned pure +`infoDictionary()` + `RemoteCommandIntent` types were NOT created. The MP dict needs MediaPlayer key +constants, so it can only live in the executable `AdaptiveSound` target — which SPM cannot +`@testable import` (the same constraint that forces every `AudioViewModel` test through a mock +mirror). Extracting them buys no testable surface over the inline code. All *decision-bearing* pure +logic — rate 0/1, artist-omitted-when-empty, album-omitted-when-nil, elapsed/duration passthrough — +is isolated in `NowPlayingSnapshot` (PlaybackQueueKit, library) and IS unit-tested. The remaining +glue (`push()` mapping snapshot→MP keys 1:1; the command→verb table; `updateCommandEnablement`; +`canGoNext`/`canGoPrevious` `!= nil` wrappers over the already-tested `computeNext/PreviousIndex`) +is thin and its correctness is a manual-verify concern (does Control Center render / drive). + +- **Pure (`swift test`):** `NowPlayingSnapshot` — rate 0/1 by state, artist omitted when empty, + album omitted when nil/empty, title/duration/elapsed/artworkKey/token passthrough (NP-01..04); + `isStopped()` — the clear-Now-Playing decision for Stop / end-of-queue / fresh-restore vs + playing vs paused-mid-track (NP-05..07, the S10.4 FN-1 fix). +- **VerifyLibraryStore:** none new (only reads the already-gated `tracksDisplay(ids:)`). +- **Manual / by-ear (founder):** appears in Control Center + the menu-bar Now Playing widget; media + keys (F7/F8/F9) + Control Center buttons + scrubber drive playback; artwork + title/artist/album + render; scrubber tracks smoothly (extrapolation) + re-anchors on seek; the footer/mini-player now + show real metadata; behavior while menu-bar-only (`.accessory`). **The system-integration itself + is manual-verify — no headless test proves "appears in Control Center".** A qa-expert + Fool + break-it runs on the impl. + +## 10. Files (as built) +New: `Sources/AdaptiveSound/NowPlayingController.swift` (`@Observable @MainActor` impure shell — also +the single resolved-metadata source the footer/widget read, D2); `Sources/AdaptiveSound/EmbeddedMetadataReader.swift` +(loose-file ID3/MP4 read, D3-reversed); `Sources/PlaybackQueueKit/NowPlayingSnapshot.swift` +(pure) + `NowPlayingSnapshotTests.swift`. Edit: `AudioViewModel.swift` (`onNowPlayingRefresh`, +`isPlaying.didSet`, `selectedTrackIndex.didSet` fire, `canGoNext`/`canGoPrevious`), +`AudioViewModel+Playback.swift` (fire in `seek`/`refreshDuration`), `AdaptiveSound.swift` (own+wire +controller as Edge 4; inject into environment; extend Controls menu with Stop ⌘. + Jump ⌘0; D5 +guard on ⌘←/⌘→), `AppDelegate.swift` (weak controller + `clear()`), `NowPlayingBar.swift` + +`NowPlayingWidget.swift` (real artist + artwork via the controller's token-guarded accessors, D2). diff --git a/docs/sprints/s10-queue-playlists-macos-plan.md b/docs/sprints/s10-queue-playlists-macos-plan.md index 8f99b4d..4911b8c 100644 --- a/docs/sprints/s10-queue-playlists-macos-plan.md +++ b/docs/sprints/s10-queue-playlists-macos-plan.md @@ -1,7 +1,7 @@ # S10 — Queue + playlists + macOS control (sub-sprints S10.1–S10.6) **Document ID:** S10-PLAN-001 -**Status:** S10.1 ✅ + S10.2 ✅ shipped & merged; **S10.6 (Recently Played) in design → then S10.4**; S10.3 still open. S10 runs as **individual done-done sub-sprints** (S10.1–S10.6), each via the **usual development process** (vetted design → multi-SME review panel → architect + the-fool gate → build-enforced gate + commit). *(Authoritative project status: [sprint-plan.md §Status](sprint-plan.md). Deprioritized 2026-07-14: drag-from-Library-into-queue + M3U/M3U8 import-export.)* +**Status:** S10.1 ✅ + S10.2 ✅ + S10.6 ✅ shipped & merged; **S10.4 in review (PR #58 — gated + break-it-hardened, awaiting founder by-ear/merge) → then S10.3** closes R1. S10 runs as **individual done-done sub-sprints** (S10.1–S10.6), each via the **usual development process** (vetted design → multi-SME review panel → architect + the-fool gate → build-enforced gate + commit). *(Authoritative project status: [sprint-plan.md §Status](sprint-plan.md). Deprioritized 2026-07-14: drag-from-Library-into-queue + M3U/M3U8 import-export.)* **Relates to:** [sprint-plan.md](sprint-plan.md) — the S10.x sprint series, the last work before **Release R1**. **Depends on:** S8 (library spine, GRDB store) ✅, S9 (browse/search) ✅. @@ -16,9 +16,9 @@ | **S10.1** ✅ | 8 | **Playlist/queue persistence spine** — `playlists` + `playlist_entries` tables (GRDB, delete-rebuild migration; keyed on `tracks.id` with a `position` + own entry id so a track can repeat); DAO for create/rename/delete + ordered add/remove/reorder + loose-file add; the built-in non-deletable **"current"** playlist; `untitled-N` lowest-unused naming; **closes Gate 1** (`unreferencedTrackIDs` gains `AND id NOT IN (SELECT track_id FROM playlist_entries)`). Gated by `VerifyLibraryStore`. | US-PLIST-01, -05, -06 (store), -07; known-issues **SEQ-1 Gate 1** | S8.1 store | | **S10.2** ✅ | 6 | **Queue UX** — persistent play queue (the "current" playlist; survives quit/relaunch, restore-paused); Up Next|History + session history; Clear Queue; reorder (grip-drag + context-menu + keyboard); wires to `PlaybackQueueKit`. *(drag-from-Library-into-queue deprioritized 2026-07-14 — Play Next / Add to Queue verbs cover add.)* | US-PLIST-06 (UI), US-PLAY reorder/history | S10.1 | | **S10.3** | 6 | **Playlists UX** — playlist browse/sidebar (create/edit/rename/delete, scales to hundreds); add songs → playlist as a **reference-add, never a file move** (separate handler from folder-move); add a single non-library file. *(M3U/M3U8 import-export deprioritized 2026-07-14.)* | US-PLIST-02, -03, -04; + the US-PLIST-08 cross-seam test | S10.1 (+ S8.4 ✅ for -08) | -| **S10.4** | 5 | **macOS system control** — media keys + Now-Playing / Control Center (`MPNowPlayingInfoCenter` + `MPRemoteCommandCenter`); app-wide keyboard shortcuts. Independent of the persistence spine. | (new — trace to sprint-plan S10.4) | S9 | +| **S10.4** 🔨 | 5 | **macOS system control** — media keys + Now-Playing / Control Center (`MPNowPlayingInfoCenter` + `MPRemoteCommandCenter`); Stop/Jump shortcuts; folds in the footer/mini-player metadata fix (same resolver). Design: [s10-4-macos-system-control-design.md](s10-4-macos-system-control-design.md). Independent of the persistence spine. | (new — trace to sprint-plan S10.4) | S9 | | **S10.5** | 3 | **Browse polish** — folder-browse mode; the **deferred A–Z jump rail** from S9. *(Polish — not an R1 gate.)* | (S9 carry-overs) | S9 | -| **S10.6** | 5 | **Recently Played (frecency)** — rework the S10.2 History tab into an all-time, per-track, **frecency-ranked** "Recently Played": persisted play count (a play = **≥60% heard, ~4-min cap**, cumulative), decayed-score accumulator column (`frecency_score`, schema v4, 7-day half-life), dedicated read + `RecentlyPlayedRow`. Design: [recently-played-frecency-design.md](recently-played-frecency-design.md). *(Added 2026-07-14; enhancement, not an R1 gate.)* | US-PLAY-10 | S10.2 | +| **S10.6** ✅ | 5 | **Recently Played (frecency)** — rework the S10.2 History tab into an all-time, per-track, **frecency-ranked** "Recently Played": persisted play count (a play = **≥60% heard, ~4-min cap**, cumulative), decayed-score accumulator column (`frecency_score`, schema v4, 7-day half-life), dedicated read + `RecentlyPlayedRow`. Design: [recently-played-frecency-design.md](recently-played-frecency-design.md). *(Added 2026-07-14; enhancement, not an R1 gate. Shipped #57.)* | US-PLAY-10 | S10.2 | --- diff --git a/docs/sprints/sprint-plan.md b/docs/sprints/sprint-plan.md index 5500a17..05cbc30 100644 --- a/docs/sprints/sprint-plan.md +++ b/docs/sprints/sprint-plan.md @@ -49,9 +49,9 @@ The rationale is sound: **an audiophile player lives or dies on library + playba | **S10.1** ✅ | Playlist/queue persistence spine | 8 | `playlists` + `playlist_entries` tables (GRDB, keyed on `tracks.id` + position); create/rename/delete + ordered-membership DAO; built-in non-deletable **"current"** queue playlist; `untitled-N` naming. **Closes Gate 1** (SEQ-1). `VerifyLibraryStore`-gated. | S8 | | **S10.2** ✅ | Queue UX | 6 | Persistent play queue (the "current" playlist; survives quit/relaunch, restore-paused); Up Next|History + session history; Clear Queue; reorder via grip-drag + context-menu + keyboard. *(drag-from-Library-into-queue deprioritized 2026-07-14.)* | S10.1 | | **S10.3** | Playlists UX | 6 | Playlist browse/edit (create/rename/delete, scales to hundreds); add songs → playlist = **reference-add** (never a file move); add a single non-library file; the US-PLIST-08 move-survival seam test. *(M3U/M3U8 import-export deprioritized 2026-07-14.)* | S10.1 | -| **S10.4** | macOS system control | 5 | **Media keys + Now-Playing/Control Center** (`MPNowPlayingInfoCenter` / `MPRemoteCommandCenter`); app-wide keyboard shortcuts. *(independent of the store)* | S9 | +| **S10.4** 🔨 | macOS system control | 5 | **Media keys + Now-Playing/Control Center** (`MPNowPlayingInfoCenter` / `MPRemoteCommandCenter`); Stop/Jump shortcuts; folds in the on-screen footer/mini-player metadata fix (same resolver). Design: [s10-4-macos-system-control-design.md](s10-4-macos-system-control-design.md). *(independent of the store)* | S9 | | **S10.5** | Browse polish | 3 | Folder-browse mode; the deferred **A–Z jump rail** from S9. *(polish — not an R1 gate)* | S9 | -| **S10.6** | Recently Played (frecency) | 5 | Rework the S10.2 History tab → all-time, per-track, **frecency-ranked** "Recently Played": play count persisted (a play = **≥60% heard, ~4-min cap**), decayed-score accumulator column (`frecency_score`, schema v4, 7-day half-life), dedicated read + row. Design: [recently-played-frecency-design.md](recently-played-frecency-design.md). *(US-PLAY-10; enhancement, not an R1 gate.)* | S10.2 | +| **S10.6** ✅ | Recently Played (frecency) | 5 | Rework the S10.2 History tab → all-time, per-track, **frecency-ranked** "Recently Played": play count persisted (a play = **≥60% heard, ~4-min cap**), decayed-score accumulator column (`frecency_score`, schema v4, 7-day half-life), dedicated read + row. Design: [recently-played-frecency-design.md](recently-played-frecency-design.md). *(US-PLAY-10; enhancement, not an R1 gate. Shipped #57.)* | S10.2 | *S10 expands into the five individual done-done sprints above (~31 SP total — the playlist domain alone is ~26 SP); each runs the full dev process. Sub-numbered `S10.x` to avoid renumbering S11–S18 and the R1/R2/R3 anchors. Breakdown: [s10-queue-playlists-macos-plan.md](s10-queue-playlists-macos-plan.md).* | **S11** | CUE sheets + format hardening | 7 | External + embedded **CUE** → virtual tracks (reuse gapless); FLAC seektable/fast-seek verification; enable WavPack/APE if free via FFmpeg; full metadata-display panel; close **gapless Stage 2b** (lossy AAC/MP3 encoder-delay trim — US-PLAY-07). | S8, gapless | @@ -69,7 +69,7 @@ The rationale is sound: **an audiophile player lives or dies on library + playba ### Status -**S6–S9 ✅ + S10.1–S10.2 ✅ shipped · S10.6 (Recently Played) in design → then S10.4 → Release R1.** S9 browse complete; S10.1 (playlist/queue persistence spine) + S10.2 (queue UX — persistent queue, Up Next|History, reorder) shipped & merged. Current work: **S10.6 Recently Played** (frecency rework of the History tab — [design](recently-played-frecency-design.md)), then **S10.4** (macOS system control); **S10.3** (Playlists UX) still open. **R1 gates on S10.1–S10.4** (S10.5/S10.6 are enhancements/polish, not gates). Deprioritized 2026-07-14 (founder): drag-from-Library-into-queue + M3U/M3U8 import-export (see [roadmap Deferred](../product/roadmap.md)). **This line is the single prose status surface for the project** — README/roadmap defer here; everything finer-grained lives in the source + git log (which are authoritative if they disagree with this). +**S6–S9 ✅ + S10.1 ✅ + S10.2 ✅ + S10.6 ✅ shipped · S10.4 in progress → then S10.3 → Release R1.** S9 browse complete; S10.1 (playlist/queue spine), S10.2 (queue UX), and S10.6 (Recently Played — frecency) shipped & merged. Current work: **S10.4** (macOS system control — media keys + Now Playing/Control Center + shortcuts; folds in the on-screen now-playing metadata fix — [design](s10-4-macos-system-control-design.md)); then **S10.3** (Playlists UX) closes R1. **R1 gates on S10.1–S10.4** (S10.5/S10.6 are enhancements/polish, not gates). Deprioritized 2026-07-14 (founder): drag-from-Library-into-queue + M3U/M3U8 import-export (see [roadmap Deferred](../product/roadmap.md)). **This line is the single prose status surface for the project** — README/roadmap defer here; everything finer-grained lives in the source + git log (which are authoritative if they disagree with this). ---