diff --git a/AppleNugs/App/AppModel.swift b/AppleNugs/App/AppModel.swift index 941dc60..038c002 100644 --- a/AppleNugs/App/AppModel.swift +++ b/AppleNugs/App/AppModel.swift @@ -17,6 +17,7 @@ final class AppModel { let client: NugsClient let player: PlayerService let favorites = FavoritesStore() + let listening = ListeningStatsStore() let videoProgress = VideoProgressStore() let video: VideoPlayerService let downloads: DownloadStore @@ -60,6 +61,7 @@ final class AppModel { downloads = DownloadStore(client: client) // Local files take precedence over network stream resolution. player.downloads = downloads + player.listening = listening } /// Resolve the persisted session at launch (refreshing the token if it diff --git a/AppleNugs/Core/LedgerSummary.swift b/AppleNugs/Core/LedgerSummary.swift new file mode 100644 index 0000000..6a80f01 --- /dev/null +++ b/AppleNugs/Core/LedgerSummary.swift @@ -0,0 +1,104 @@ +import Foundation + +/// The Home listening ledger's rows and totals, as pure data. +/// +/// Takes loose primitives rather than the `ListeningStats` table — the +/// `SignalReadout` idiom, and for the same reason: it stays Foundation-only and +/// compiles straight into the host-free `AppleNugsTests` target. The view does +/// the one-line mapping from the stored dictionary. +/// +/// Every string the ledger shows is produced here, so the formatting is pinned +/// by tests rather than by a view body nobody can assert against. +enum LedgerSummary { + + /// One artist's totals, already folded. `key` is the stored dictionary key; + /// it is used only for identity and the final tie-break, and is never shown. + struct Tally: Equatable { + var key: String + var displayName: String + var plays: Int + var seconds: Double + } + + struct Row: Identifiable, Equatable { + let rank: Int + let key: String + let displayName: String + let playsText: String + let hoursText: String + + var id: String { key } + } + + struct Totals: Equatable { + let hoursText: String + let artistCountText: String + let artistCount: Int + } + + /// The ledger lives inline on Home, so it is capped at what fits there. + static let topCount = 5 + + /// The section renders nothing at all until at least one play is credited. + /// Seconds alone are not enough: a few stray partial listens are not a + /// ledger, and Home already has a hero and a crate. + static func isEmpty(_ tallies: [Tally]) -> Bool { + !tallies.contains { $0.plays > 0 } + } + + /// Two totals rows only. A `Plays` total was considered and cut — every + /// leaderboard row already carries a play count. + static func totals(_ tallies: [Tally]) -> Totals { + let seconds = tallies + .map(\.seconds) + .filter { $0.isFinite && $0 > 0 } + .reduce(0, +) + return Totals(hoursText: totalHoursText(seconds: seconds), + artistCountText: String(tallies.count), + artistCount: tallies.count) + } + + /// Top artists by seconds. Ties break by plays, then by name, then by key, + /// so the order never flickers between renders. + static func top(_ tallies: [Tally]) -> [Row] { + tallies + .sorted { a, b in + if a.seconds != b.seconds { return a.seconds > b.seconds } + if a.plays != b.plays { return a.plays > b.plays } + let byName = a.displayName.localizedStandardCompare(b.displayName) + if byName != .orderedSame { return byName == .orderedAscending } + return a.key < b.key + } + .prefix(topCount) + .enumerated() + .map { offset, tally in + Row(rank: offset + 1, + key: tally.key, + displayName: tally.displayName, + playsText: playsText(tally.plays), + hoursText: hoursText(seconds: tally.seconds)) + } + } + + /// The Hours total: whole hours once there is one, `"<1"` below that. No + /// decimals — this is a ledger, not a stopwatch, and the row label already + /// says "Hours". + static func totalHoursText(seconds: Double) -> String { + guard seconds.isFinite, seconds > 0 else { return "0" } + let hours = seconds / 3600 + return hours >= 1 ? String(Int(hours)) : "<1" + } + + /// A leaderboard row's duration: whole hours once there is one, else whole + /// minutes, so a short-but-frequent artist never reads "0h". + static func hoursText(seconds: Double) -> String { + guard seconds.isFinite, seconds > 0 else { return "0m" } + let hours = Int(seconds / 3600) + if hours > 0 { return "\(hours)h" } + return "\(max(1, Int(seconds / 60)))m" + } + + static func playsText(_ plays: Int) -> String { + plays == 1 ? "1 play" : "\(plays) plays" + } +} diff --git a/AppleNugs/Core/ListeningCredit.swift b/AppleNugs/Core/ListeningCredit.swift new file mode 100644 index 0000000..88ff5dd --- /dev/null +++ b/AppleNugs/Core/ListeningCredit.swift @@ -0,0 +1,129 @@ +import Foundation + +/// The credit policy for listening stats: what one playback tick is worth, and +/// when a track visit has earned a "play". +/// +/// This type also HOLDS the per-track accumulator, and that placement is +/// load-bearing. `PlayerService` keeps one of these as an `@ObservationIgnored` +/// property and feeds it from the ~4Hz tick (and, while the iOS app is +/// backgrounded and the tick is off, from playhead spans settled at commit +/// boundaries and the once-a-minute background wake); the store is only told about a +/// batch at a commit boundary — roughly twice a minute, never four times a +/// second. `@Observable` notifies on every set without comparing values, so if +/// ticking seconds landed in the store's observed counters the Home ledger +/// would re-diff at tick rate — precisely the failure mode the dashboard's +/// leaf-view isolation rule exists to prevent. +/// +/// Deliberately Foundation-only (no SwiftUI, no AVFoundation, no Observation) +/// and taking loose primitives, so it compiles straight into the host-free +/// `AppleNugsTests` target. +struct ListeningCredit: Equatable { + + /// What one commit hands to the store. + struct Batch: Equatable { + var seconds: Double + var completedPlay: Bool + + /// Nothing to hand over — the caller should skip the store call entirely. + var isEmpty: Bool { seconds <= 0 && !completedPlay } + } + + /// A tick contributes at most one second. A normal tick is 0.25s; a seek, a + /// wake-from-sleep, and a stall each produce one large jump, and the clamp + /// caps all of them without the policy having to detect any of them. + static let maxTickSeconds: Double = 1 + + /// The ceiling on the play threshold, and the flat threshold for a stream + /// that reports no duration. + static let playSecondsCap: Double = 240 + + /// How much uncommitted listening forces a commit. + static let commitIntervalSeconds: Double = 30 + + /// Listened seconds on the current track visit. Reset only by `startTrack()`. + private(set) var trackSeconds: Double = 0 + + /// Seconds not yet handed to the store. + private(set) var uncommittedSeconds: Double = 0 + + /// True once this visit has earned its play — the latch that stops one visit + /// earning two. + private(set) var playCredited = false + + /// True when an earned play has not yet been handed to the store. + private(set) var uncommittedPlay = false + + /// Listened seconds that earn a play: half the track, capped at four + /// minutes. A duration-less stream (livestream, webcast) takes the flat cap + /// — the same treatment the pill's progress ring and all six scrub sliders + /// already give `duration <= 0`. + static func playThreshold(duration: Double) -> Double { + guard duration.isFinite, duration > 0 else { return playSecondsCap } + return min(0.5 * duration, playSecondsCap) + } + + /// Fold one playback tick. + /// + /// `delta` is playhead movement since the previous tick, which is why it can + /// be negative (a backward seek) or huge (a forward seek, a stall, a wake): + /// both are handled by the clamp, not by special cases. Paused time + /// contributes nothing because the accumulator only runs while playing. + mutating func tick(delta: Double, isPlaying: Bool, duration: Double) { + guard isPlaying, delta.isFinite else { return } + add(min(max(delta, 0), Self.maxTickSeconds), duration: duration) + } + + /// Fold a span with no ticks inside it — the iOS app backgrounded, where + /// the tick is off (see `PlayerService.appDidEnterBackground()`). + /// + /// `delta` is playhead movement since the last sample and `wallSeconds` the + /// real time that passed meanwhile. A playhead being listened to can't move + /// faster than real time, so the wall clock is the clamp here — what the + /// 1s cap is for a tick: a forward seek inside the span earns at most the + /// time that actually passed. There is no `isPlaying` gate because none is + /// needed: a paused or stalled playhead doesn't move, so its delta is 0. + mutating func span(delta: Double, wallSeconds: Double, duration: Double) { + guard delta.isFinite, wallSeconds.isFinite else { return } + add(min(max(delta, 0), max(wallSeconds, 0)), duration: duration) + } + + /// The accumulator and the play latch, shared by both entry points so a + /// visit that mixes ticks and spans still earns exactly one play. + private mutating func add(_ credited: Double, duration: Double) { + guard credited > 0 else { return } + + trackSeconds += credited + uncommittedSeconds += credited + + if !playCredited, trackSeconds >= Self.playThreshold(duration: duration) { + playCredited = true + uncommittedPlay = true + } + } + + /// True when the caller should commit. An earned play commits immediately so + /// the ledger updates when it lands; otherwise a commit is due every 30s. + var isCommitDue: Bool { + uncommittedPlay || uncommittedSeconds >= Self.commitIntervalSeconds + } + + /// Hand over everything accumulated since the last commit. Idempotent: a + /// second call with nothing accumulated returns an empty batch. + mutating func flush() -> Batch { + let batch = Batch(seconds: uncommittedSeconds, completedPlay: uncommittedPlay) + uncommittedSeconds = 0 + uncommittedPlay = false + return batch + } + + /// Begin a new track visit. + /// + /// Call this only AFTER flushing, or the finished track's uncommitted + /// seconds are lost. + mutating func startTrack() { + trackSeconds = 0 + uncommittedSeconds = 0 + uncommittedPlay = false + playCredited = false + } +} diff --git a/AppleNugs/Core/ListeningStats.swift b/AppleNugs/Core/ListeningStats.swift new file mode 100644 index 0000000..6657838 --- /dev/null +++ b/AppleNugs/Core/ListeningStats.swift @@ -0,0 +1,84 @@ +import Foundation + +/// One artist's all-time tally. +/// +/// `displayName` is the spelling last seen in the player and is what the ledger +/// renders; the dictionary key it is filed under is the folded form and is +/// never shown. +struct ArtistTally: Codable, Equatable { + var displayName: String + var plays: Int + var seconds: Double + var firstPlayedAt: Date + var lastPlayedAt: Date +} + +/// The whole listening table: rolling aggregates, artists only, all-time. +/// +/// There is no event log behind this — nothing here can be re-derived after the +/// fact, and a change to the counting rule can only ever apply going forward. +/// That trade was made deliberately (see +/// `docs/superpowers/specs/2026-08-21-listening-stats-design.md`); the reset in +/// the account menu is the only remedy for polluted data, which is why it is +/// load-bearing rather than a nicety. +/// +/// Deliberately Foundation-only so it compiles straight into the host-free +/// `AppleNugsTests` target. +struct ListeningStats: Codable, Equatable { + var artists: [String: ArtistTally] + var version: Int + + static let currentVersion = 1 + static let empty = ListeningStats(artists: [:], version: currentVersion) + + /// The dictionary key for a display name. + /// + /// `QueueTrack.artist` is a display name, not an id — no artist id exists + /// anywhere in the playback path — so the key has to be derived from the + /// name. The rule is `DeepLinkMatch.normalize`, the one the venue matcher + /// already uses: apostrophes (ASCII and U+2019) deleted outright, every + /// other non-alphanumeric run collapsed to one space, lowercased and + /// diacritic-folded. Do NOT write a second folding rule. + /// + /// Returns nil for a name that folds to nothing, which is how blank and + /// punctuation-only artist names get dropped. + static func key(for displayName: String) -> String? { + let folded = DeepLinkMatch.normalize(displayName) + return folded.isEmpty ? nil : folded + } + + /// Fold one commit into the table. + /// + /// Pure: the caller supplies `now`, so timestamps are testable. A nil, + /// blank, or unfoldable name is dropped silently — the seam is deliberately + /// forgiving because `QueueTrack.artist` is optional. + /// + /// Non-finite and negative seconds contribute zero. That guard is not + /// theoretical: a NaN would fail to encode and leave `listening.json` + /// undecodable on the next launch. + mutating func credit(artistName: String?, + seconds: Double, + completedPlay: Bool, + now: Date) { + guard let artistName else { return } + let trimmed = artistName.trimmingCharacters(in: .whitespacesAndNewlines) + guard let key = Self.key(for: trimmed) else { return } + + let addedSeconds = (seconds.isFinite && seconds > 0) ? seconds : 0 + guard addedSeconds > 0 || completedPlay else { return } + + if var existing = artists[key] { + existing.displayName = trimmed + existing.plays += completedPlay ? 1 : 0 + existing.seconds += addedSeconds + existing.lastPlayedAt = now + artists[key] = existing + } else { + artists[key] = ArtistTally(displayName: trimmed, + plays: completedPlay ? 1 : 0, + seconds: addedSeconds, + firstPlayedAt: now, + lastPlayedAt: now) + } + } +} diff --git a/AppleNugs/Core/ListeningStatsStore.swift b/AppleNugs/Core/ListeningStatsStore.swift new file mode 100644 index 0000000..4dd47ec --- /dev/null +++ b/AppleNugs/Core/ListeningStatsStore.swift @@ -0,0 +1,171 @@ +import Foundation +import Observation + +/// Persists all-time listening tallies as JSON in Application Support, beside +/// session.json / favorites.json / nowplaying.json — the FavoritesStore idiom. +/// +/// `@Observable` so the Home ledger updates when a commit lands. It is only ever +/// written at a COMMIT BOUNDARY (roughly twice a minute), never from the +/// player's ~4Hz tick: `PlayerService` owns the accumulator (see +/// `ListeningCredit`) and hands over a batch. Do not add a per-tick entry point, +/// and do not move the accumulator in here — `@Observable` notifies on every set +/// without comparing values, so ticking seconds in `stats` would make the Home +/// ledger re-diff four times a second. +@MainActor +@Observable +final class ListeningStatsStore { + + private(set) var stats: ListeningStats = .empty + + /// Cheap gate for the Home ledger's presence. + /// + /// `HomeView` reads THIS, never `stats`. Reading `stats` from Home's body + /// subscribes the whole body to the table, so every commit — twice a minute + /// during playback, plus every track change and every pause — would + /// invalidate the greeting, the resume hero, the favorites strip and the + /// crate grid, not just the ledger. This flips at most once per install. + /// Same class of hazard as the `artColor` read HomeView guards behind + /// `#if os(macOS)` for exactly the same reason. + private(set) var hasAnyPlay = false + + /// Latched when `listening.json` exists but could not be read cleanly. + /// + /// While set, `save()` refuses to write. These are rolling aggregates with + /// no event log, so a file we failed to parse is the ONLY copy of that + /// history: overwriting it with whatever we managed to salvage would + /// destroy it permanently. Better to keep serving the salvaged table + /// read-only and leave the file on disk for a human. + @ObservationIgnored private var loadFailed = false + + private let fileURL: URL + + init() { + let dir = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("AppleNugs", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + fileURL = dir.appendingPathComponent("listening.json") + load() + } + + /// The seam. + /// + /// Deliberately neutral — no `QueueTrack`, no player types — so + /// `VideoPlayerService` can become a second call site later without + /// reshaping anything (webcasts and concert films do not count today). And + /// deliberately coarse: callers batch seconds and hand them over at commit + /// boundaries rather than streaming them. A nil or blank `artistName` is + /// dropped silently. + func credit(artistName: String?, seconds: Double, completedPlay: Bool) { + var updated = stats + updated.credit(artistName: artistName, seconds: seconds, + completedPlay: completedPlay, now: Date()) + guard updated != stats else { return } + stats = updated + refreshHasAnyPlay() + save() + } + + /// Wipe every tally. + /// + /// Load-bearing rather than a nicety: these are rolling aggregates with no + /// event log behind them, so a reset is the only remedy for polluted data. + func reset() { + // A deliberate wipe is exactly the human intervention a failed load was + // waiting for: the user is telling us the unreadable file no longer + // matters. Clearing the latch first keeps the escape hatch working — + // otherwise `save()` would refuse and Reset would silently do nothing. + loadFailed = false + stats = .empty + refreshHasAnyPlay() + save() + } + + // --- persistence -------------------------------------------------------- + + private func load() { + guard let data = try? Data(contentsOf: fileURL), !data.isEmpty else { return } + + // Strict first — a clean file is overwhelmingly the common case. + if let decoded = try? Self.decoder.decode(ListeningStats.self, from: data) { + stats = decoded + refreshHasAnyPlay() + return + } + + // Something in the file no longer fits the current shape — a truncated + // write, a hand-edit, or (the realistic one) a future schema change + // that added a non-optional field to `ArtistTally`. Salvage every tally + // that still decodes and latch `loadFailed` so we never write back over + // the ones that didn't. + // + // `FavoritesStore` carries the same tolerance for the same reason, and + // documents the failure mode: without it, one bad element resets the + // collection to empty and the next write overwrites the file with that + // empty state. It is worse here — favorites can be re-added by hand, + // all-time totals cannot be reconstructed at all. + loadFailed = true + if let salvaged = try? Self.decoder.decode(SalvagedStats.self, from: data) { + stats = ListeningStats(artists: salvaged.artists, + version: ListeningStats.currentVersion) + refreshHasAnyPlay() + } + } + + private func refreshHasAnyPlay() { + // @Observable notifies on every set without comparing, so only write + // when it actually changes. + let any = stats.artists.values.contains { $0.plays > 0 } + if hasAnyPlay != any { hasAnyPlay = any } + } + + /// Per-element-tolerant view of the file, used only on the salvage path. + /// + /// A tally that no longer decodes drops itself instead of taking the whole + /// table down with it. + private struct SalvagedStats: Decodable { + var artists: [String: ArtistTally] + + private struct LossyTally: Decodable { + let value: ArtistTally? + init(from decoder: Decoder) throws { value = try? ArtistTally(from: decoder) } + } + + private enum CodingKeys: String, CodingKey { case artists } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let raw = try c.decode([String: LossyTally].self, forKey: .artists) + artists = raw.compactMapValues(\.value) + } + } + + /// The ONLY write path — reset routes through it too — and it is suppressed + /// under `-UITEST`. + /// + /// A `-UITestSeedQueue` launch parks stub tracks. Without this guard, + /// touching the transport from a seeded launch would fold fictional artists + /// into the real all-time totals, and "Reset Listening Stats" would erase + /// the real ones outright. That is the same hazard class `PlaybackStateStore` + /// guards, which has already cost a real resume queue once. Reads are + /// deliberately left live. + /// + /// `AppModel.isUITestRun` only exists in DEBUG, hence the surrounding `#if`. + private func save() { + #if DEBUG + guard !AppModel.isUITestRun else { return } + #endif + guard !loadFailed else { return } + guard let data = try? Self.encoder.encode(stats) else { return } + try? data.write(to: fileURL, options: .atomic) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], + ofItemAtPath: fileURL.path) + } + + private static let encoder: JSONEncoder = { + let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e + }() + private static let decoder: JSONDecoder = { + let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d + }() +} diff --git a/AppleNugs/Player/PlayerService.swift b/AppleNugs/Player/PlayerService.swift index 77d6bc4..804d4a7 100644 --- a/AppleNugs/Player/PlayerService.swift +++ b/AppleNugs/Player/PlayerService.swift @@ -95,6 +95,37 @@ final class PlayerService { /// Latched when the current track's download failed to play, so the /// stream fallback never hands playback back to the same broken file. private var localPickFailed = false + + // --- listening stats ------------------------------------------------------ + + /// All-time listening tallies, injected by AppModel. Optional so the player + /// still works standalone (and under the UITest harness, where nothing is + /// injected and nothing is credited). + @ObservationIgnored var listening: ListeningStatsStore? + + /// The per-track accumulator. + /// + /// `@ObservationIgnored` is LOAD-BEARING, not tidiness: this is mutated from + /// the ~4Hz tick, and `@Observable` notifies on every set without comparing + /// values, so leaving it tracked would wake observers four times a second + /// for state no view ever reads. + @ObservationIgnored private var credit = ListeningCredit() + + /// The artist the accumulator's seconds belong to. + /// + /// Held separately because a track change flushes the FINISHED track's + /// earnings AFTER `index` has already moved on — `current?.artist` is the + /// wrong answer by then. + @ObservationIgnored private var creditedArtist: String? + + /// Playhead position at the previous sample (tick or settle), so the + /// policy gets a delta. + @ObservationIgnored private var lastCreditPosition: Double = 0 + + /// When that sample was taken (monotonic `systemUptime`). A settle credits + /// at most the real time since — see `ListeningCredit.span`. + @ObservationIgnored private var lastCreditSampleAt = ProcessInfo.processInfo.systemUptime + private let player = AVQueuePlayer() /// The ~4Hz `tick()` observer. Installed only while the app is in the /// foreground — see `appDidEnterBackground()`. @@ -209,7 +240,7 @@ final class PlayerService { terminationObserver = NotificationCenter.default.addObserver( forName: terminateNotification, object: nil, queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.persistState() } + MainActor.assumeIsolated { self?.persistNow() } } #if os(iOS) // Phone call / Siri / another app taking the session: pause, and @@ -280,6 +311,10 @@ final class PlayerService { let playing = status != .paused if playing != self.isPlaying { if !playing { self.externalPauseAt = Date() } + // Listening stats: an external pause ends a span + // (settle what was heard), an external resume starts + // one (see rebaseListening). + if playing { self.rebaseListening() } else { self.settleListening() } self.isPlaying = playing changed = true } @@ -319,8 +354,9 @@ final class PlayerService { timeObserver = nil backgroundPersistObserver = player.addPeriodicTimeObserver( forInterval: CMTime(seconds: 60, preferredTimescale: 1), queue: .main) { [weak self] _ in - MainActor.assumeIsolated { self?.persistIfMoved() } + MainActor.assumeIsolated { self?.backgroundWake() } } + commitListening() persistState() } @@ -332,6 +368,9 @@ final class PlayerService { inBackground = false if let backgroundPersistObserver { player.removeTimeObserver(backgroundPersistObserver) } backgroundPersistObserver = nil + // Before the tick returns: its first delta would span the whole + // background stretch, and the tick's 1s clamp would discard it. + settleListening() installTickObserver() tick() } @@ -355,6 +394,16 @@ final class PlayerService { return t.isFinite ? max(0, t) : currentTime } + /// The once-a-minute background wake — the ONLY periodic work while + /// backgrounded. Listening stats ride it rather than adding a wake of their + /// own: settle the minute just played, commit if due (so a long locked-screen + /// listen writes listening.json at most once a minute), then the resume save. + private func backgroundWake() { + settleListening() + if credit.isCommitDue { flushListening() } + persistIfMoved() + } + private func persistIfMoved() { if abs(livePosition - lastSavedPosition) >= 5 { persistState() } } @@ -439,6 +488,9 @@ final class PlayerService { // promote it — manual skips ride the gapless path too. if let p = preload, queue[index + 1].id == p.trackUID, currentItem != nil, player.currentItem === currentItem { + // Settle while the head is still this track: once it advances, + // its playhead can no longer be read. + settleListening() player.advanceToNextItem() adoptPreload(p) isPlaying = startPlayback() @@ -491,10 +543,12 @@ final class PlayerService { return } if player.timeControlStatus == .paused { + rebaseListening() isPlaying = startPlayback() } else { player.pause() isPlaying = false + commitListening() persistState() // as in pause() } pushNowPlayingInfo() @@ -505,6 +559,7 @@ final class PlayerService { if current != nil { startCurrent() } return } + if !isPlaying { rebaseListening() } isPlaying = startPlayback() pushNowPlayingInfo() } @@ -512,6 +567,7 @@ final class PlayerService { func pause() { player.pause() isPlaying = false + commitListening() pushNowPlayingInfo() // A paused position is the one to resume from — and while backgrounded // no tick is around to save it. @@ -544,6 +600,11 @@ final class PlayerService { } func seek(to seconds: Double) { + // Credit what was listened to up to here, then move the baseline with + // the playhead — a scrub forward (say from the lock screen, where no + // tick clamps it) must earn nothing it skipped. + settleListening() + lastCreditPosition = max(0, seconds) let t = CMTime(seconds: max(0, seconds), preferredTimescale: 600) player.seek(to: t, toleranceBefore: .zero, toleranceAfter: .zero) currentTime = max(0, seconds) @@ -556,7 +617,9 @@ final class PlayerService { /// No-op until a duration is known. func seek(by delta: Double) { guard duration > 0 else { return } - seek(to: min(max(currentTime + delta, 0), duration)) + // livePosition, not currentTime: the lock screen's skip buttons fire + // while backgrounded, where the tick-fed currentTime is stale. + seek(to: min(max(livePosition + delta, 0), duration)) } // --- playback engine -------------------------------------------------------- @@ -570,6 +633,7 @@ final class PlayerService { } private func startCurrent() { + rotateListening() loadGeneration += 1 let generation = loadGeneration detachEngine() @@ -799,6 +863,10 @@ final class PlayerService { /// Fired whenever any of our items plays to the end. private func itemDidEnd(_ itemID: ObjectIdentifier?) { guard let itemID, let currentItem, itemID == ObjectIdentifier(currentItem) else { return } + // It played to its end. Settle there explicitly: on a gapless hand-off + // the player's head is already the successor (and at a queue's end it + // has none), so the playhead can no longer be read for this track. + settleListening(at: duration > 0 ? duration : nil) if let p = preload, queue.indices.contains(index + 1), queue[index + 1].id == p.trackUID { // AVQueuePlayer has already advanced into the preloaded item — @@ -810,6 +878,7 @@ final class PlayerService { startAt(index + 1) } else { ended = true // queue finished — next enqueue/play-next restarts playback + commitListening() isPlaying = false // The player has dropped the item, so livePosition falls back to // currentTime — stale if this happened while backgrounded. @@ -833,8 +902,12 @@ final class PlayerService { preload = nil index += 1 - ended = false + // Clear BEFORE rotating: rotateListening anchors the new visit at + // pendingSeekPosition, and a restored track's unapplied resume offset + // is not where its successor starts. pendingSeekPosition = nil + rotateListening() + ended = false currentItem = p.item picks = p.picks pickIndex = p.pickIndex @@ -950,6 +1023,7 @@ final class PlayerService { } private func stopPlayback() { + rotateListening() loadGeneration += 1 detachEngine() loadArtwork(for: nil) @@ -1003,6 +1077,17 @@ final class PlayerService { // only needs to cover what it alone observes: duration discovery and // external play/pause flips (e.g. a headphone unplug). if pushWorthy { pushNowPlayingInfo() } + + // Listening stats. The pure policy holds the accumulator; the store only + // hears about it at a commit boundary, never at 4Hz — see ListeningCredit + // and the design spec's risk #3. A seek, a stall, or a wake produces one + // huge delta and the policy clamps it; that is why the raw delta is + // handed over rather than pre-filtered here. + let creditDelta = newTime - lastCreditPosition + lastCreditPosition = newTime + lastCreditSampleAt = ProcessInfo.processInfo.systemUptime + credit.tick(delta: creditDelta, isPlaying: newIsPlaying, duration: newDuration) + if credit.isCommitDue { flushListening() } // just sampled — no settle } /// Exact specs from the decoder once the asset is readable — replaces the @@ -1042,10 +1127,20 @@ final class PlayerService { if saved.position > 1 { pendingSeekPosition = saved.position currentTime = saved.position + // Anchor listening there too: the offset was heard last session. + lastCreditPosition = saved.position } pushNowPlayingInfo() } + /// The app-termination hook, and a listening-stats commit boundary. (iOS + /// backgrounding — where willTerminate is not guaranteed to fire — goes + /// through `appDidEnterBackground()`, which is a commit boundary too.) + private func persistNow() { + commitListening() + persistState() + } + #if DEBUG /// UITest-only: park a queue without resolving streams or starting /// playback, so transport UI renders under the `-UITestSeedQueue` flag. @@ -1057,6 +1152,84 @@ final class PlayerService { } #endif + // --- listening stats commit boundaries ------------------------------------ + + /// Settle, then hand the accumulated batch to the store. + /// + /// Idempotent: a second call with nothing accumulated is a no-op, which is + /// what makes it safe to call from several boundaries that can overlap. + /// The settle is what makes every commit boundary correct while the iOS app + /// is backgrounded, where no tick has been accumulating. + private func commitListening() { + settleListening() + flushListening() + } + + /// Fold the span since the last sample — the tick's job in the foreground, + /// and the only feed while backgrounded (the tick is off there). + /// + /// `position` overrides `creditPosition` for callers that know better (a + /// track that just ended). Credit is capped at the real time since the last + /// sample, so even a wrong position can never credit more than actually + /// passed — and a stale one (the usual way to be wrong) only under-counts. + /// Cheap and idempotent: settling twice in a row folds a ~0 span. + private func settleListening(at position: Double? = nil) { + // No trustworthy position (the player's head isn't our item: a gapless + // hand-off in flight, or the queue just ended): skip the sample and + // leave the baseline alone. Falling back to the tick-fed currentTime + // here — stale while backgrounded — would pull the baseline down and + // let the NEXT settle re-credit the gap, up to a whole track. + guard let at = position ?? creditPosition else { return } + let now = ProcessInfo.processInfo.systemUptime + credit.span(delta: at - lastCreditPosition, + wallSeconds: now - lastCreditSampleAt, + duration: duration) + lastCreditPosition = at + lastCreditSampleAt = now + } + + /// Where the playhead is, for crediting — or nil when that can't be read. + /// Unlike `livePosition` it never falls back to `currentTime`. + private var creditPosition: Double? { + if let pendingSeekPosition { return pendingSeekPosition } + guard let item = currentItem, player.currentItem === item else { return nil } + let t = player.currentTime().seconds + return t.isFinite ? max(0, t) : nil + } + + /// Paused → playing: move the baseline to here and now, crediting nothing. + /// Paused time can't have earned anything, and anchoring fresh means a + /// settle that read a pre-seek position mid-seek (the player reports the + /// old time until a seek lands) can't later credit the skipped distance + /// across a paused span the wall-clock cap doesn't see as idle. + private func rebaseListening() { + if let at = creditPosition { lastCreditPosition = at } + lastCreditSampleAt = ProcessInfo.processInfo.systemUptime + } + + /// Hand over what's accumulated, without sampling first. + private func flushListening() { + let batch = credit.flush() + guard !batch.isEmpty else { return } + listening?.credit(artistName: creditedArtist, + seconds: batch.seconds, + completedPlay: batch.completedPlay) + } + + /// Flush what the finished track earned, then re-arm the accumulator for + /// whatever `current` now is. + /// + /// Call this AFTER `index` has moved — `commitListening()` uses the + /// remembered `creditedArtist`, so the order is correct by construction. + private func rotateListening() { + commitListening() + credit.startTrack() + creditedArtist = current?.artist + // A restored track resumes mid-way: that offset was never listened to. + lastCreditPosition = pendingSeekPosition ?? 0 + lastCreditSampleAt = ProcessInfo.processInfo.systemUptime + } + private func persistState() { guard !queue.isEmpty else { stateStore.clear() diff --git a/AppleNugs/Views/HomeView.swift b/AppleNugs/Views/HomeView.swift index 1c7f450..419af3b 100644 --- a/AppleNugs/Views/HomeView.swift +++ b/AppleNugs/Views/HomeView.swift @@ -47,7 +47,10 @@ struct HomeView: View { #if os(macOS) entryRow.reveal(appeared, 3) #endif - crate.reveal(appeared, 4) + if app.listening.hasAnyPlay { + ListeningLedger().reveal(appeared, 4) + } + crate.reveal(appeared, 5) } .padding(.horizontal, horizontalPadding) .padding(.top, 34) diff --git a/AppleNugs/Views/ListeningLedger.swift b/AppleNugs/Views/ListeningLedger.swift new file mode 100644 index 0000000..4c5d8ba --- /dev/null +++ b/AppleNugs/Views/ListeningLedger.swift @@ -0,0 +1,140 @@ +import SwiftUI + +/// The Home listening ledger: total hours, distinct artists, and a top-five +/// artist leaderboard, set as type rather than as cards. +/// +/// Deliberately container-free — no fill, no border, no rounded rect, no shadow. +/// Rows sit on the page background and are separated by a hairline rule. It +/// lives inline on Home and has no destination of its own, which is why it is +/// capped at five rows and two totals. +/// +/// It never reads the player's live position tick — the ~4Hz clock the +/// dashboard's leaf views exist to isolate. It reads `app.listening.stats`, +/// which changes only at a commit boundary (roughly twice a minute), so it +/// stays outside that leaf-isolation rule rather than becoming a new case of +/// it. Do not add a live-position readout here; the plan's self-audit greps +/// this file for that symbol by name and expects no hits, comments included. +/// +/// Every string it renders comes from `LedgerSummary`, so the formatting is +/// pinned by tests rather than by this body. Do not format here. +/// +/// Shared across macOS and iOS — nothing platform-specific. +struct ListeningLedger: View { + @Environment(AppModel.self) private var app + @Environment(\.theme) private var theme + + /// Folded key → catalog entry, for the rows that resolve. A row that does + /// not resolve renders as plain text rather than as a dead tap target. + /// + /// Resolution is the ASYNC `app.artist(named:)`, never the synchronous + /// `cachedArtist(named:)`: the sync fast path would make the same name + /// clickable on one launch and not the next. + @State private var resolved: [String: ArtistEntry] = [:] + + var body: some View { + let tallies = Self.tallies(from: app.listening.stats) + let rows = LedgerSummary.top(tallies) + let totals = LedgerSummary.totals(tallies) + + VStack(alignment: .leading, spacing: 12) { + Text("LISTENING") + .font(theme.type.numeric(10)) + .tracking(1.8) + .foregroundStyle(theme.palette.textSecondary) + + VStack(spacing: 0) { + totalRow(label: "Hours", value: totals.hoursText) + hairline + totalRow(label: "Artists", value: totals.artistCountText) + ForEach(rows) { row in + hairline + leaderRow(row) + } + } + } + .task(id: rows.map(\.key).joined(separator: "|")) { + await resolveNames(rows) + } + } + + // --- rows --------------------------------------------------------------- + + /// Row separation is a rule, not a box. 1pt, `theme.palette.hairline`. + private var hairline: some View { + Rectangle() + .fill(theme.palette.hairline) + .frame(height: 1) + } + + private func totalRow(label: String, value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(label) + .font(theme.type.body(14)) + .foregroundStyle(theme.palette.textSecondary) + Spacer(minLength: 8) + Text(value) + .font(theme.type.numeric(14)) + .foregroundStyle(theme.palette.textPrimary) + } + .padding(.vertical, 9) + } + + /// Tappable only when the stored display name resolved against the catalog. + /// An unresolved name renders as plain text — never a dead tap target. + @ViewBuilder + private func leaderRow(_ row: LedgerSummary.Row) -> some View { + if let entry = resolved[row.key] { + NavigationLink(value: Route.artist(entry)) { + leaderRowBody(row) + } + .buttonStyle(.plain) + } else { + leaderRowBody(row) + } + } + + private func leaderRowBody(_ row: LedgerSummary.Row) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text("\(row.rank)") + .font(theme.type.numeric(12)) + .foregroundStyle(theme.palette.textIdle) + .frame(width: 16, alignment: .leading) + Text(row.displayName) + .font(theme.type.body(14)) + .foregroundStyle(theme.palette.textPrimary) + .lineLimit(1) + Spacer(minLength: 8) + Text("\(row.playsText) · \(row.hoursText)") + .font(theme.type.numeric(12)) + .foregroundStyle(theme.palette.textSecondary) + .lineLimit(1) + .layoutPriority(1) + } + .padding(.vertical, 9) + .contentShape(Rectangle()) + } + + // --- resolution --------------------------------------------------------- + + private func resolveNames(_ rows: [LedgerSummary.Row]) async { + var found: [String: ArtistEntry] = [:] + for row in rows { + if let entry = try? await app.artist(named: row.displayName) { + found[row.key] = entry + } + } + resolved = found + } +} + +extension ListeningLedger { + /// The one mapping from the stored table to the pure policy's input. + /// `LedgerSummary` deliberately takes loose primitives so it stays + /// Foundation-only and host-free — the `SignalReadout` idiom. + static func tallies(from stats: ListeningStats) -> [LedgerSummary.Tally] { + stats.artists.map { key, tally in + LedgerSummary.Tally(key: key, displayName: tally.displayName, + plays: tally.plays, seconds: tally.seconds) + } + } +} diff --git a/AppleNugs/iOS/IOSRootView.swift b/AppleNugs/iOS/IOSRootView.swift index e09c338..4f75432 100644 --- a/AppleNugs/iOS/IOSRootView.swift +++ b/AppleNugs/iOS/IOSRootView.swift @@ -36,6 +36,9 @@ struct IOSRootView: View { @State private var probeShown = ProcessInfo.processInfo.arguments.contains("-UITestShowNetworkProbe") #endif + /// Guards the listening-stats reset. There is no event log behind those + /// tallies, so the wipe is unrecoverable — it does not happen on one tap. + @State private var confirmingStatsReset = false /// The accent the chrome should use right now: the live art color when the /// active theme is art-driven, else nil (so static themes are untouched). @@ -277,6 +280,10 @@ struct IOSRootView: View { Button("Network Probe (Debug)") { probeShown = true } #endif Divider() + Button("Reset Listening Stats…", role: .destructive) { + confirmingStatsReset = true + } + Divider() Button("Log Out") { app.logout() } // iOS has no About panel, so the disclaimer the Mac carries in // Credits.rtf lives here: non-interactive rows, like the plan above. @@ -287,6 +294,14 @@ struct IOSRootView: View { } label: { Label("Account", systemImage: "person.circle") } + .confirmationDialog("Reset listening stats?", + isPresented: $confirmingStatsReset, + titleVisibility: .visible) { + Button("Reset Stats", role: .destructive) { app.listening.reset() } + Button("Cancel", role: .cancel) { } + } message: { + Text("This erases your artist totals permanently. There's no history to restore them from.") + } } /// "Yeti 1.5 (6)", from the bundle so it can't drift from project.yml. diff --git a/AppleNugs/macOS/RootView.swift b/AppleNugs/macOS/RootView.swift index 80dbed6..ff7dcee 100644 --- a/AppleNugs/macOS/RootView.swift +++ b/AppleNugs/macOS/RootView.swift @@ -14,6 +14,10 @@ struct RootView: View { ProcessInfo.processInfo.arguments.contains("-UITestShowNetworkProbe") #endif + /// Guards the listening-stats reset. There is no event log behind those + /// tallies, so the wipe is unrecoverable — it does not happen on one click. + @State private var confirmingStatsReset = false + var body: some View { Group { switch app.sessionState { @@ -217,10 +221,22 @@ struct RootView: View { Button("Network Probe (Debug)") { probeShown = true } #endif Divider() + Button("Reset Listening Stats…", role: .destructive) { + confirmingStatsReset = true + } + Divider() Button("Log Out") { app.logout() } } label: { Label("Account", systemImage: "person.circle") } + .confirmationDialog("Reset listening stats?", + isPresented: $confirmingStatsReset, + titleVisibility: .visible) { + Button("Reset Stats", role: .destructive) { app.listening.reset() } + Button("Cancel", role: .cancel) { } + } message: { + Text("This erases your artist totals permanently. There's no history to restore them from.") + } } @ViewBuilder diff --git a/AppleNugsTests/LedgerSummaryTests.swift b/AppleNugsTests/LedgerSummaryTests.swift new file mode 100644 index 0000000..bcc2d3c --- /dev/null +++ b/AppleNugsTests/LedgerSummaryTests.swift @@ -0,0 +1,87 @@ +import XCTest + +final class LedgerSummaryTests: XCTestCase { + + private func tally(_ name: String, plays: Int, hours: Double) -> LedgerSummary.Tally { + LedgerSummary.Tally(key: name.lowercased(), displayName: name, + plays: plays, seconds: hours * 3600) + } + + private var sample: [LedgerSummary.Tally] { + [tally("Goose", plays: 41, hours: 63), + tally("Billy Strings", plays: 28, hours: 44), + tally("Umphrey's McGee", plays: 19, hours: 31), + tally("Phish", plays: 9, hours: 14), + tally("Khruangbin", plays: 6, hours: 9), + tally("Dopapod", plays: 2, hours: 3)] + } + + func testTotalsReportHoursAndDistinctArtistCount() { + let totals = LedgerSummary.totals(sample) + + XCTAssertEqual(totals.hoursText, "164") // 63+44+31+14+9+3 + XCTAssertEqual(totals.artistCount, 6) + XCTAssertEqual(totals.artistCountText, "6") + } + + func testTotalHoursBelowOneHourReadsLessThanOne() { + XCTAssertEqual(LedgerSummary.totalHoursText(seconds: 0), "0") + XCTAssertEqual(LedgerSummary.totalHoursText(seconds: 1_800), "<1") + XCTAssertEqual(LedgerSummary.totalHoursText(seconds: 3_600), "1") + XCTAssertEqual(LedgerSummary.totalHoursText(seconds: 7_199), "1") + XCTAssertEqual(LedgerSummary.totalHoursText(seconds: .nan), "0") + } + + func testTopFiveAreRankedBySeconds() { + let rows = LedgerSummary.top(sample) + + XCTAssertEqual(LedgerSummary.topCount, 5) + XCTAssertEqual(rows.count, 5) + XCTAssertEqual(rows.map(\.displayName), + ["Goose", "Billy Strings", "Umphrey's McGee", "Phish", "Khruangbin"]) + XCTAssertEqual(rows.map(\.rank), [1, 2, 3, 4, 5]) + XCTAssertEqual(rows[0].playsText, "41 plays") + XCTAssertEqual(rows[0].hoursText, "63h") + XCTAssertEqual(rows[0].id, rows[0].key) + } + + /// The order must never flicker between renders on equal seconds. + func testTiesBreakDeterministicallyByPlaysThenName() { + let tied = [tally("Zeta", plays: 3, hours: 10), + tally("Alpha", plays: 3, hours: 10), + tally("Mu", plays: 9, hours: 10)] + + let names = LedgerSummary.top(tied).map(\.displayName) + XCTAssertEqual(names, ["Mu", "Alpha", "Zeta"]) + XCTAssertEqual(LedgerSummary.top(tied.reversed()).map(\.displayName), names) + } + + func testFewerThanFiveArtistsRenderOnlyWhatExists() { + let two = [tally("Goose", plays: 4, hours: 5), tally("Phish", plays: 2, hours: 2)] + + XCTAssertEqual(LedgerSummary.top(two).count, 2) + XCTAssertTrue(LedgerSummary.top([]).isEmpty) + XCTAssertEqual(LedgerSummary.totals([]).artistCountText, "0") + } + + /// The whole section is absent until a play has been credited — an empty + /// ledger on Home is noise. + func testIsEmptyUntilAPlayIsCredited() { + XCTAssertTrue(LedgerSummary.isEmpty([])) + XCTAssertTrue(LedgerSummary.isEmpty([tally("Goose", plays: 0, hours: 0.2)])) + XCTAssertFalse(LedgerSummary.isEmpty([tally("Goose", plays: 1, hours: 0.2)])) + } + + func testRowDurationFallsBackToMinutesAndPlaysTextSingularizes() { + XCTAssertEqual(LedgerSummary.hoursText(seconds: 63 * 3600), "63h") + XCTAssertEqual(LedgerSummary.hoursText(seconds: 3_600), "1h") + XCTAssertEqual(LedgerSummary.hoursText(seconds: 2_460), "41m") + XCTAssertEqual(LedgerSummary.hoursText(seconds: 5), "1m") + XCTAssertEqual(LedgerSummary.hoursText(seconds: 0), "0m") + XCTAssertEqual(LedgerSummary.hoursText(seconds: .infinity), "0m") + + XCTAssertEqual(LedgerSummary.playsText(0), "0 plays") + XCTAssertEqual(LedgerSummary.playsText(1), "1 play") + XCTAssertEqual(LedgerSummary.playsText(2), "2 plays") + } +} diff --git a/AppleNugsTests/ListeningCreditTests.swift b/AppleNugsTests/ListeningCreditTests.swift new file mode 100644 index 0000000..135c822 --- /dev/null +++ b/AppleNugsTests/ListeningCreditTests.swift @@ -0,0 +1,230 @@ +import XCTest + +final class ListeningCreditTests: XCTestCase { + + /// A four-minute track: threshold is min(120, 240) == 120. + private let normalDuration: Double = 240 + /// A twenty-five-minute jam: threshold is capped at 240. + private let longDuration: Double = 1_500 + + /// Feed `count` ordinary 0.25s ticks. + private func run(_ credit: inout ListeningCredit, + ticks count: Int, + duration: Double, + isPlaying: Bool = true) { + for _ in 0..:main` — fast-forward only, never a merge/rebase, so hashes here stay valid. +Pushing is Tim's call. **Next steps, in Tim's order of interest:** -1. **Land `pill-duck` + `yeti`** — same path as PR #5: push, open a PR (CI runs on the `xcode-27` runner), - fast-forward `main` (`git push origin :main`, never a merge/rebase so commit hashes in this file stay - valid). Pushing is Tim's call — ask ("land it"). +1. (done 2026-11-07 — both branches landed via PR #6.) 2. **Finish verifying Yeti on hardware:** logout wipes downloads; the **Mac** build (old plaintext downloads there must migrate to sealed `.yeti`; Reveal in Finder is gone). Mac local installs must be Developer ID-signed (see the local-install-signing memory). @@ -70,8 +111,8 @@ Branches, all local, NOT pushed: pill-duck overlay, Mac Now Playing window, sheets, toasts); (c) add Yeti as theme #6 through the existing token system and verify on device; (d) make it the only theme (remove pickers, force dark); (e) delete the five themes, transport variants, art-color pipeline, wash/grain, ThemeID/ThemeManager (~2,300 lines); keep - ONE token struct as the design system. The Home "nice list" ledger depends on the unmerged - `listening-stats` branch. Spec it before building. Source design: `docs/design/yeti-theme/yeti-theme.html` + ONE token struct as the design system. The Home "nice list" ledger is `ListeningLedger` (landed + 2026-11-07). Spec it before building. Source design: `docs/design/yeti-theme/yeti-theme.html` (+ `.png`, and the three concepts it beat). It carries a token sheet that maps 1:1 onto `Theme` fields and a "Wiring it in" list (ThemeID `.yeti`, EqualizerBars branch, play-sticker sites, FoilSweep, BlisterBubble). Never quote the song's lyrics in the app or docs (copyrighted) — paraphrase only. diff --git a/project.yml b/project.yml index 61024c5..27c9960 100644 --- a/project.yml +++ b/project.yml @@ -271,6 +271,19 @@ targets: - path: AppleNugs/Downloads/SealedAssetLoader.swift # AVFoundation + CryptoKit only (the Keychain key is injected), so the # integration tests can play a sealed FLAC/ALAC through it host-free. + - path: AppleNugs/Core/ListeningStats.swift + # The listening tally table (artist counters + the folded-name key). + # Pure Foundation; compiled in so the merge rule — two spellings of one + # artist folding to a single tally — is covered host-free. + - path: AppleNugs/Core/ListeningCredit.swift + # Pure Foundation policy holding the per-track listening accumulator: + # the [0,1]s tick clamp, the min(50%, 240s) play threshold, and the + # commit-boundary batching that keeps ticking seconds OUT of the + # store's @Observable state. Compiled in to stay host-free. + - path: AppleNugs/Core/LedgerSummary.swift + # Pure Foundation presentation logic for the Home listening ledger — + # totals, top-5 ranking, and the display copy. Compiled in so the + # rendered strings are pinned by test rather than by eyeball. scheme: testTargets: - AppleNugsTests