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 @@ -179,6 +179,7 @@ let package = Package(
"QueueAdvanceTests.swift",
"QueueOpsTests.swift",
"QueueInsertTests.swift",
"PlayThroughTrackerTests.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
6 changes: 3 additions & 3 deletions Sources/AdaptiveSound/AudioViewModel+AutoAdvance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ extension AudioViewModel {
// completed naturally — count it before returning (a playlist-shrank-after-
// arming edge; the normal end-of-queue path below is what usually counts the
// final track).
countOutgoingTrackCompletion()
countPlayIfNaturalEndQualifies()
pendingNextIndex = nil
isPlaying = false
playbackPosition = 0
Expand All @@ -36,10 +36,10 @@ extension AudioViewModel {

// §12.3: count the OUTGOING track (pre-advance selectedTrackIndex) before it
// reassigns below — this IS the gapless-seam natural-completion site.
countOutgoingTrackCompletion()
countPlayIfNaturalEndQualifies()

selectedTrackIndex = nextIdx
recordPlayStart(advancedTrack) // the incoming track begins playing — log it (S10.2 3a)
resetPlayTracking() // the gapless seam / repeat-one begins a NEW ≥60% play-through (S10.6)
playbackPosition = 0
duration = 0 // zeroed now; async Task below refreshes from AVAudioFile

Expand Down
29 changes: 0 additions & 29 deletions Sources/AdaptiveSound/AudioViewModel+History.swift

This file was deleted.

89 changes: 55 additions & 34 deletions Sources/AdaptiveSound/AudioViewModel+PlayTracking.swift
Original file line number Diff line number Diff line change
@@ -1,59 +1,80 @@
import Foundation
import LibraryStore

// MARK: - AudioViewModel play-tracking (S9.5 §12.3 — pulled forward from S10)
// MARK: - AudioViewModel play-tracking (S9.5 §12.3 → S10.6 ≥60%-heard rule)

@MainActor
extension AudioViewModel {
/// Count the OUTGOING track — the one at `selectedTrackIndex` right now, BEFORE any
/// reassignment at the call site — as a natural-completion play. A no-op if
/// `selectedTrackIndex` is nil or stale (out of range, e.g. the playlist shrank).
///
/// Shared by all FOUR completion sites (`AudioViewModel+AutoAdvance`'s
/// `handleTrackTransition` normal path + its out-of-range guard, and
/// `AudioViewModel+SpectrumTimer`'s `tickTransport` reconfigure + end-of-queue
/// branches) so none of their call sites' own guard adds to that function's
/// cyclomatic complexity.
func countOutgoingTrackCompletion() {
guard let completedIdx = selectedTrackIndex, completedIdx < queue.count else { return }
countPlayCompletion(file: queue[completedIdx].file)
/// Accrue play-through time for the current track (S10.6 R1/FIX-3). Called EVERY transport tick:
/// it advances the monotonic reference unconditionally (so a pause/stall doesn't later read as
/// one huge delta), but feeds a delta to the tracker only while actually playing. When the
/// accrual first crosses the qualifying threshold (≥ min(60%·duration, 240s), ≥30s track), the
/// current track's play is recorded — once per play-through. Seeks add ~0 wall-time between
/// ticks, so they don't accrue; a UI-tick stall's delta IS real playtime and accrues.
func accruePlayThrough() {
let now = DispatchTime.now().uptimeNanoseconds // suspend-stopping (excludes system sleep)
defer { lastPlayThroughMonoNanos = now }
guard let last = lastPlayThroughMonoNanos else { return } // first tick: just seed
guard isPlaying, duration > 0 else { return } // accrue only genuine playback
let delta = Double(now &- last) / 1_000_000_000
if playThroughTracker.accrue(delta, duration: duration) {
countCurrentPlay()
}
}

/// Count a natural-completion play for the track at `url`: fires a detached,
/// fire-and-forget write to the persistent store's `play_count`/`last_played`
/// columns (`LibraryStore.incrementPlayCount`).
///
/// Invoked (via `countOutgoingTrackCompletion()` above) at all FOUR natural-completion
/// sites (the gapless seam in `handleTrackTransition`, its out-of-range guard, the Pure
/// reconfigure advance, and the true end-of-queue branch in `tickTransport`) — NEVER from
/// a manual skip/jump (`nextTrack`/`previousTrack`/`playTrack`/`playTrackNextNow`/
/// `playNext`/`startPlayback` are all deliberately excluded, per the pre-change review:
/// "a play = heard through").
///
/// Errors (including "store not ready yet" and a write failure) are swallowed via
/// `logUX`, never thrown — a play-count write must never stall or fail the audio path.
/// Begin a new play-through: clear the accrued heard-time + the once-per-play-through guard and
/// reseed the monotonic reference. Called at exactly the two "new play begins" points — a fresh
/// `startPlayback` (not a pause-resume) and the gapless `handleTrackTransition` (incl. a
/// repeat-one re-arm, so each repeat can count again).
func resetPlayTracking() {
playThroughTracker.reset()
lastPlayThroughMonoNanos = nil
}

/// Count the CURRENT track (`selectedTrackIndex`) as a play. Invoked from the tick accrual (on
/// threshold crossing) and from the natural-end gate below. A no-op if the selection is nil or
/// stale (out of range).
func countCurrentPlay() {
guard let index = selectedTrackIndex, index < queue.count else { return }
writePlayCount(file: queue[index].file)
}

/// A track reached its natural end (the four completion sites): count it ONLY if it qualifies by
/// the SAME ≥60%/≥30s gate as the tick path (FIX-1) — so a scrub-to-end or a short/unresolved-
/// duration track does NOT count. Idempotent: if the tick path already counted this play-through
/// (`didCount`), this is a no-op. `selectedTrackIndex`/`duration` still refer to the just-
/// finished track at every call site (they reassign AFTER).
func countPlayIfNaturalEndQualifies() {
guard playThroughTracker.naturalEnd(duration: duration) else { return }
countCurrentPlay()
}

/// Fire the detached, fire-and-forget play-count write (`play_count`/`last_played` + the S10.6
/// frecency `score`/`rank` — `LibraryStore.incrementPlayCount`), then bump `playCountRevision`
/// on the main actor AFTER the write commits (R4 — the Recently-Played view refreshes on it, so
/// the bump must not race the write). Errors are swallowed via `logUX`, never thrown — a
/// play-count write must never stall or fail the audio path.
///
/// S3 F5: this is the ONE audio→library edge — it reaches the store through the injected
/// `library` peer (`library?.store`) rather than owning the store itself.
private func countPlayCompletion(file: AudioFile) {
/// S3 F5: the ONE audio→library edge — reaches the store through the injected `library` peer.
private func writePlayCount(file: AudioFile) {
guard let store = library?.store else {
logUX("countPlayCompletion: store not ready; skipping play-count for \(file.absoluteURL.lastPathComponent)")
logUX("writePlayCount: store not ready; skipping play-count for \(file.absoluteURL.lastPathComponent)")
return
}
let playedAt = Int64(Date().timeIntervalSince1970)
let trackID = file.trackID
let url = file.absoluteURL
Task.detached(priority: .utility) {
Task.detached(priority: .utility) { [weak self] in
do {
// Prefer the durable id (S10.2 — the queue carries it, no url→id lookup);
// fall back to url for a slot with no library row.
// Prefer the durable id (the queue carries it, no url→id lookup); fall back to url.
if let trackID {
try await store.incrementPlayCount(id: trackID, playedAt: playedAt)
} else {
try await store.incrementPlayCount(url: url, playedAt: playedAt)
}
await MainActor.run { self?.playCountRevision += 1 }
} catch {
logUX("countPlayCompletion: write failed for \(url.lastPathComponent) — \(error)")
logUX("writePlayCount: write failed for \(url.lastPathComponent) — \(error)")
}
}
}
Expand Down
11 changes: 9 additions & 2 deletions Sources/AdaptiveSound/AudioViewModel+Playback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ extension AudioViewModel {

refreshDuration(for: fileURL)

// A fresh start (not a pause/resume seek) begins a NEW ≥60% play-through (S10.6). Reset
// SYNCHRONOUSLY here, BEFORE the async Task below: on a manual switch `selectedTrackIndex`
// is already the new track, and the 20 Hz tick fires while the Task is parked at
// `await engine.startAudio`. If the reset were deferred into the Task, those ticks would
// keep accruing the OUTGOING track's `heardSeconds` and could cross the threshold — counting
// the play against the newly-selected track (QA break-it #1). A pause-resume (resumeFrom !=
// nil) continues the same play-through — no reset.
if resumeFrom == nil { resetPlayTracking() }

// Snapshot index and mode for use inside the Task (avoids capturing `self` for
// values that could change between now and when the Task body runs).
let startIndex = selectedTrackIndex
Expand All @@ -52,8 +61,6 @@ extension AudioViewModel {
await primeGaplessPipeline(startIndex: startIndex, pureMode: pureModeSnapshot)
isPlaying = true
errorMessage = nil
// A fresh start (not a pause/resume seek) logs a session-history play (S10.2 3a).
if resumeFrom == nil { recordPlayStart(startFile) }
} catch {
errorMessage = "Playback failed: \(error.localizedDescription)"
isPlaying = false
Expand Down
7 changes: 5 additions & 2 deletions Sources/AdaptiveSound/AudioViewModel+SpectrumTimer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ extension AudioViewModel {
} else if pausedResumePosition == nil {
playbackPosition = 0
}
// S10.6: accrue ≥60%-heard play-through time (advances the monotonic reference every tick;
// accrues only while playing → a pause/stall/seek never mis-counts).
accruePlayThrough()
loudness = engine.currentLoudness()
var freshPath = engine.currentSignalPath()
// F4: copy enhancement overlay fields so the badge is a pure function of the snapshot.
Expand Down Expand Up @@ -103,7 +106,7 @@ extension AudioViewModel {
logUX("playbackEnded — advancing to queued track[\(nextIdx)] (reconfigure gap)")
// §12.3: count the OUTGOING track (pre-advance selectedTrackIndex) before it
// reassigns below — the Pure cross-rate reconfigure natural-completion site.
countOutgoingTrackCompletion()
countPlayIfNaturalEndQualifies()
selectedTrackIndex = nextIdx
// Clear the trigger SYNCHRONOUSLY before the async startPlayback: `playbackEnded()`
// stays true until startPlayback's Task runs `pureModeEngineStart` (≤ a few ticks
Expand All @@ -116,7 +119,7 @@ extension AudioViewModel {
logUX("playbackEnded — no next track, stopping")
// §12.3: true end-of-queue / single-track completion — count the track that
// just finished before clearing isPlaying below.
countOutgoingTrackCompletion()
countPlayIfNaturalEndQualifies()
isPlaying = false
playbackPosition = 0
}
Expand Down
17 changes: 12 additions & 5 deletions Sources/AdaptiveSound/AudioViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import PlaybackQueueKit

// MARK: - AudioViewModel

Expand Down Expand Up @@ -194,11 +195,17 @@ final class AudioViewModel {
/// One-shot guard so launch hydration (`+QueueHydration`) runs at most once. Not UI-bound.
var queueHydrated = false

/// The session play-history (`+History`), newest LAST — the History view shows it reversed.
/// A track is appended when it BEGINS a genuine new play (manual start / gapless auto-advance);
/// dups allowed. Session-scoped + in-memory: NOT persisted across relaunch, and Clear Queue
/// leaves it untouched (founder §3a). UI-bound (the History list observes it).
var sessionHistory: [HistoryItem] = []
/// The ≥60%-heard play-through detector (S10.6). Pure state (PlaybackQueueKit); the transport
/// tick feeds it monotonic playback-time deltas. When it crosses the threshold the current
/// track's play is recorded (once per play-through). Not UI-bound.
var playThroughTracker = PlayThroughTracker()
/// Monotonic reference (suspend-stopping uptime nanos) for the play-through accrual — advanced
/// EVERY tick, consumed only while playing (FIX-3), so a pause/stall/seek never mis-accrues.
/// `nil` reseeds on the next tick. Not UI-bound.
var lastPlayThroughMonoNanos: UInt64?
/// Bumped (on the main actor) AFTER a play-count write commits (S10.6 R4) so the Recently-Played
/// view can reload without racing the detached store write. UI-bound.
var playCountRevision = 0

/// Track selection (does NOT auto-play). Selection and playback are separate.
/// Use playTrack() or startPlayback() to actually play the selected track.
Expand Down
27 changes: 27 additions & 0 deletions Sources/AdaptiveSound/LibraryBrowseModel+History.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation
import LibraryStore

// MARK: - LibraryBrowseModel Recently-Played (frecency) loader (S10.6)

extension LibraryBrowseModel {
/// Load the Recently-Played (frecency) rows. Mirrors `loadSongs`' epoch / `isStoreReady` guard;
/// the DAO returns tracks played at least once, already frecency-ordered. Empty ⇒ the tab shows
/// its empty state ("nothing finished yet") — no firstRun/roots distinction is needed here.
func loadHistory() async {
guard let store else {
historyState = .loading // store still building; the tab reloads on `isStoreReady`
return
}
historyLoadEpoch &+= 1
let epoch = historyLoadEpoch
do {
let loaded = try await store.frecencyTracksDisplay()
guard epoch == historyLoadEpoch else { return } // a newer load superseded this one
history = loaded
historyState = loaded.isEmpty ? .empty : .loaded
} catch {
guard epoch == historyLoadEpoch else { return }
historyState = .failed(error.localizedDescription)
}
}
}
9 changes: 9 additions & 0 deletions Sources/AdaptiveSound/LibraryBrowseModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ final class LibraryBrowseModel {
]
private(set) var songsState: LoadState = .idle

/// Recently-Played (frecency) rows (S10.6) — the DAO returns them already frecency-ordered
/// (recency-weighted play frequency). Loaded when the Recently-Played tab appears and refreshed
/// when a play crosses the ≥60% threshold (`AudioViewModel.playCountRevision`). Declared
/// `internal` (not `private(set)`) because the loader lives in a same-type extension
/// (`LibraryBrowseModel+History`).
var history: [LibraryTrackDisplay] = []
var historyState: LoadState = .idle
var historyLoadEpoch = 0

// Songs filter (S9.5 chunk 2b — "filter-preserves-sort", design §3.3/§5). The filter NARROWS
// the already-`songSort`-ordered `songs` in place (A2): it never touches `songSort`/`sortOrder`,
// so the sort triangle is preserved and headers keep re-sorting the filtered subset.
Expand Down
16 changes: 0 additions & 16 deletions Sources/AdaptiveSound/Models/HistoryItem.swift

This file was deleted.

11 changes: 6 additions & 5 deletions Sources/AdaptiveSound/UI/Playlist/PlaylistView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ struct PlaylistView: View {
PlaylistHeaderView(jumpToCurrentRequestID: $jumpToCurrentRequestID, panelMode: $panelMode)
Picker("View", selection: $panelMode) {
ForEach(QueuePanelMode.allCases) { mode in
Text(mode.title).tag(mode)
Text(mode.pickerLabel).tag(mode)
}
}
.pickerStyle(.segmented)
Expand Down Expand Up @@ -59,25 +59,26 @@ struct PlaylistView: View {

private struct PlaylistHeaderView: View {
@Environment(AudioViewModel.self) var viewModel
@Environment(LibraryBrowseModel.self) var library
@Binding var jumpToCurrentRequestID: Int
@Binding var panelMode: QueuePanelMode

/// Mode-aware subtitle: the queue's track count, or the number of session plays.
/// Mode-aware subtitle: the queue's track count, or the number of recently-played tracks.
private var subtitle: String {
switch panelMode {
case .upNext:
let count = viewModel.queue.count
return "\(count) \(count == 1 ? "track" : "tracks")"
case .history:
let count = viewModel.sessionHistory.count
return "\(count) played"
let count = library.history.count
return "\(count) \(count == 1 ? "track" : "tracks")"
}
}

var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(panelMode == .history ? "History" : "Queue")
Text(panelMode == .history ? "Recently Played" : "Queue")
.font(.caption.weight(.semibold))
.tracking(0.5)
.textCase(.uppercase)
Expand Down
Loading
Loading