Skip to content
Merged
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
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 53 additions & 4 deletions Sources/AdaptiveSound/AdaptiveSound.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 {
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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)
}
}

Expand Down
7 changes: 7 additions & 0 deletions Sources/AdaptiveSound/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Sources/AdaptiveSound/AudioViewModel+Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/AdaptiveSound/AudioViewModel+Playback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?()
}
}
}
Expand Down Expand Up @@ -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)
}
Expand Down
56 changes: 50 additions & 6 deletions Sources/AdaptiveSound/AudioViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,27 @@ 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
/// same ARC class refs (`avEngine` / `loudnessMeter` / `pureEngine`) → retain-count corruption.
/// 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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
45 changes: 45 additions & 0 deletions Sources/AdaptiveSound/EmbeddedMetadataReader.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading