From bed3d25f90a3c647594b4daadc35779fb54da83a Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 7 Nov 2026 15:33:07 -0500 Subject: [PATCH 1/8] docs: START HERE reflects the landed main (e6ab835) and the yeti-design branch Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN --- CLAUDE.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 834b278..8c9c7d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,19 +48,23 @@ Native **macOS + iOS SwiftUI** player for nugs.net, shipped to users as **Yeti ## START HERE — state as of 2026-11-07 (handoff for a new session) **Where the code is.** Worktree `.claude/worktrees/iphone-duo` (stay in it; don't cd to the main checkout). -Branches, all local, NOT pushed: -- `main` == `origin/main` == `805d766` (the iPhone Duo work, PR #5, merged 2026-11-06). The main checkout's - local `main` ref may still be behind — `git -C /Users/tim/applenugs fetch origin main:main` (Tim's call). -- `pill-duck` (2 commits on `805d766`: the pill ducks on scroll on the Duo, verified on device). -- **`yeti` (checked out; stacked on `pill-duck`)** — the compliance pass + Yeti rename + encrypted downloads + - perf pass + the Yeti theme DESIGN (docs only). Gate green: both schemes warning-free, **307 tests**. +- **`main` == `origin/main` == `e6ab835`** — everything above is LANDED: the iPhone Duo work (PR #5) and the + pill duck + Yeti compliance/rename/encrypted downloads/perf + the theme design (PR #6, merged 2026-11-07, + CI green on `xcode-27`, fast-forward — every hash in this file is on `main`). 307 tests. +- **`yeti-design`** (checked out, no upstream, starts at `e6ab835`) — the branch for the single-design + refactor (step 3 below). +- The main checkout's local `main` ref is behind — `git -C /Users/tim/applenugs fetch origin main:main` + (run by Tim; the worktree guard blocks `git -C` outside the worktree). Remote branches `iphone-duo` and + `yeti` are merged and can be deleted on GitHub (Tim's call). - `listening-stats` (in the main checkout; built, unmerged, based on the OLD main — needs a rebase onto - `805d766`+, with a CLAUDE.md conflict to resolve). + `e6ab835`, with a CLAUDE.md conflict to resolve). Its Home "nice list" ledger is part of the Yeti design. + +**Landing recipe (used for PR #5 and #6):** push the branch, open a PR (CI runs on the `xcode-27` runner), +then `git push origin :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). From 6f16e7b876a85228ceedca1d29cf5f9f6524b309 Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 7 Nov 2026 15:51:35 -0500 Subject: [PATCH 2/8] docs: local main is synced to e6ab835 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VriHZgxfrBNm1js1UNLYTN --- CLAUDE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8c9c7d2..137f5c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,8 +53,9 @@ Native **macOS + iOS SwiftUI** player for nugs.net, shipped to users as **Yeti CI green on `xcode-27`, fast-forward — every hash in this file is on `main`). 307 tests. - **`yeti-design`** (checked out, no upstream, starts at `e6ab835`) — the branch for the single-design refactor (step 3 below). -- The main checkout's local `main` ref is behind — `git -C /Users/tim/applenugs fetch origin main:main` - (run by Tim; the worktree guard blocks `git -C` outside the worktree). Remote branches `iphone-duo` and +- Local `main` ref is synced to `e6ab835` (fast-forwarded from inside the worktree with + `git branch -f main origin/main` — the worktree shares the repo's refs, and `main` isn't checked out + anywhere; `git -C …` / network fetches from Tim's remote-control `!` shell fail). Remote branches `iphone-duo` and `yeti` are merged and can be deleted on GitHub (Tim's call). - `listening-stats` (in the main checkout; built, unmerged, based on the OLD main — needs a rebase onto `e6ab835`, with a CLAUDE.md conflict to resolve). Its Home "nice list" ledger is part of the Yeti design. From 365b74dabd82f467cc845a22129d771c2b6df836 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 21 Aug 2026 22:15:07 -0400 Subject: [PATCH 3/8] feat: keep all-time listening stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artists only, rolling aggregates, no event log. Every dimension left out can only ever count forward from the day it's added — that trade was made deliberately, along with all-time-only, Home-only, and audio-only. ListeningCredit holds the per-track accumulator and the min(50%, 240s) play threshold, measured against listened time rather than playhead position so scrubbing to the end earns nothing. PlayerService feeds it from the existing 4Hz tick but calls the store only at commit boundaries, so ticking seconds never reach @Observable state. LedgerSummary is the pure totals-and-ranking policy; all three compile into AppleNugsTests. The Home ledger is typographic — no cards, no tiles — and gates on hasAnyPlay rather than on the table, so a commit doesn't invalidate the greeting, hero, favorites strip and crate grid along with it. The store refuses to write while a failed load is latched. These tallies have nothing behind them to re-derive from, so a file that didn't decode is salvaged per-element and left on disk rather than overwritten; reset clears the latch, since a deliberate wipe is the intervention it was waiting for. Co-Authored-By: Claude Opus 5 --- AppleNugs/App/AppModel.swift | 2 + AppleNugs/Core/LedgerSummary.swift | 104 +++++++++++++ AppleNugs/Core/ListeningCredit.swift | 108 ++++++++++++++ AppleNugs/Core/ListeningStats.swift | 84 +++++++++++ AppleNugs/Core/ListeningStatsStore.swift | 171 ++++++++++++++++++++++ AppleNugs/Player/PlayerService.swift | 79 +++++++++- AppleNugs/Views/HomeView.swift | 5 +- AppleNugs/Views/ListeningLedger.swift | 140 ++++++++++++++++++ AppleNugs/iOS/IOSRootView.swift | 15 ++ AppleNugs/macOS/RootView.swift | 16 ++ AppleNugsTests/LedgerSummaryTests.swift | 87 +++++++++++ AppleNugsTests/ListeningCreditTests.swift | 159 ++++++++++++++++++++ AppleNugsTests/ListeningStatsTests.swift | 87 +++++++++++ project.yml | 13 ++ 14 files changed, 1068 insertions(+), 2 deletions(-) create mode 100644 AppleNugs/Core/LedgerSummary.swift create mode 100644 AppleNugs/Core/ListeningCredit.swift create mode 100644 AppleNugs/Core/ListeningStats.swift create mode 100644 AppleNugs/Core/ListeningStatsStore.swift create mode 100644 AppleNugs/Views/ListeningLedger.swift create mode 100644 AppleNugsTests/LedgerSummaryTests.swift create mode 100644 AppleNugsTests/ListeningCreditTests.swift create mode 100644 AppleNugsTests/ListeningStatsTests.swift 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..a92cd15 --- /dev/null +++ b/AppleNugs/Core/ListeningCredit.swift @@ -0,0 +1,108 @@ +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; 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 } + let credited = min(max(delta, 0), Self.maxTickSeconds) + 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..f9e152d 100644 --- a/AppleNugs/Player/PlayerService.swift +++ b/AppleNugs/Player/PlayerService.swift @@ -95,6 +95,32 @@ 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 tick, so the policy gets a delta. + @ObservationIgnored private var lastCreditPosition: Double = 0 + private let player = AVQueuePlayer() /// The ~4Hz `tick()` observer. Installed only while the app is in the /// foreground — see `appDidEnterBackground()`. @@ -209,7 +235,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 @@ -321,6 +347,7 @@ final class PlayerService { forInterval: CMTime(seconds: 60, preferredTimescale: 1), queue: .main) { [weak self] _ in MainActor.assumeIsolated { self?.persistIfMoved() } } + commitListening() persistState() } @@ -495,6 +522,7 @@ final class PlayerService { } else { player.pause() isPlaying = false + commitListening() persistState() // as in pause() } pushNowPlayingInfo() @@ -512,6 +540,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. @@ -570,6 +599,7 @@ final class PlayerService { } private func startCurrent() { + rotateListening() loadGeneration += 1 let generation = loadGeneration detachEngine() @@ -810,6 +840,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,6 +864,7 @@ final class PlayerService { preload = nil index += 1 + rotateListening() ended = false pendingSeekPosition = nil currentItem = p.item @@ -950,6 +982,7 @@ final class PlayerService { } private func stopPlayback() { + rotateListening() loadGeneration += 1 detachEngine() loadArtwork(for: nil) @@ -1003,6 +1036,16 @@ 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 + credit.tick(delta: creditDelta, isPlaying: newIsPlaying, duration: newDuration) + if credit.isCommitDue { commitListening() } } /// Exact specs from the decoder once the asset is readable — replaces the @@ -1046,6 +1089,14 @@ final class PlayerService { 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 +1108,32 @@ final class PlayerService { } #endif + // --- listening stats commit boundaries ------------------------------------ + + /// 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. + private func commitListening() { + 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 + lastCreditPosition = 0 + } + 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..564f646 --- /dev/null +++ b/AppleNugsTests/ListeningCreditTests.swift @@ -0,0 +1,159 @@ +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.. Date: Sat, 7 Nov 2026 16:04:28 -0500 Subject: [PATCH 4/8] fix: credit listening while the iOS app is backgrounded Rebasing listening-stats onto main met 8c3c0d7, which takes the 4Hz tick off in the background. Credit was fed only from that tick, so locked-screen listening earned nothing, and the first tick back in front clamped the whole stretch to 1s. ListeningCredit gains span(delta:wallSeconds:duration:): playhead movement since the last sample, capped at the real time that passed, so a scrub forward inside a span still earns at most what elapsed. PlayerService keeps one baseline (position + monotonic uptime) stamped by every tick and every settle, and settles at each commit boundary, on the existing once-a-minute background wake (no new wake, no background tick), before the tick returns on foregrounding, at a track's end, before a gapless manual skip, and before a seek, which then rebases the baseline. A rotation rebases to the resume point rather than 0. Co-Authored-By: Claude Opus 5.5 --- AppleNugs/Core/ListeningCredit.swift | 25 +++++++- AppleNugs/Player/PlayerService.swift | 70 ++++++++++++++++++++-- AppleNugsTests/ListeningCreditTests.swift | 71 +++++++++++++++++++++++ 3 files changed, 159 insertions(+), 7 deletions(-) diff --git a/AppleNugs/Core/ListeningCredit.swift b/AppleNugs/Core/ListeningCredit.swift index a92cd15..88ff5dd 100644 --- a/AppleNugs/Core/ListeningCredit.swift +++ b/AppleNugs/Core/ListeningCredit.swift @@ -5,7 +5,9 @@ import Foundation /// /// 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; the store is only told about a +/// 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 @@ -68,7 +70,26 @@ struct ListeningCredit: Equatable { /// contributes nothing because the accumulator only runs while playing. mutating func tick(delta: Double, isPlaying: Bool, duration: Double) { guard isPlaying, delta.isFinite else { return } - let credited = min(max(delta, 0), Self.maxTickSeconds) + 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 diff --git a/AppleNugs/Player/PlayerService.swift b/AppleNugs/Player/PlayerService.swift index f9e152d..4d1efdc 100644 --- a/AppleNugs/Player/PlayerService.swift +++ b/AppleNugs/Player/PlayerService.swift @@ -118,9 +118,14 @@ final class PlayerService { /// wrong answer by then. @ObservationIgnored private var creditedArtist: String? - /// Playhead position at the previous tick, so the policy gets a delta. + /// 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()`. @@ -345,7 +350,7 @@ 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() @@ -359,6 +364,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() } @@ -382,6 +390,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() } } @@ -466,6 +484,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, + // livePosition can no longer read where this track got to. + settleListening() player.advanceToNextItem() adoptPreload(p) isPlaying = startPlayback() @@ -573,6 +594,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) @@ -829,6 +855,11 @@ 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, so livePosition would + // fall back to the tick-fed currentTime — up to a minute stale when + // backgrounded. + 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 — @@ -1044,8 +1075,9 @@ final class PlayerService { // 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 { commitListening() } + if credit.isCommitDue { flushListening() } // just sampled — no settle } /// Exact specs from the decoder once the asset is readable — replaces the @@ -1110,11 +1142,37 @@ final class PlayerService { // --- listening stats commit boundaries ------------------------------------ - /// Hand the accumulated batch to the store. + /// 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 `livePosition` 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) { + let now = ProcessInfo.processInfo.systemUptime + let at = position ?? livePosition + credit.span(delta: at - lastCreditPosition, + wallSeconds: now - lastCreditSampleAt, + duration: duration) + lastCreditPosition = at + lastCreditSampleAt = now + } + + /// Hand over what's accumulated, without sampling first. + private func flushListening() { let batch = credit.flush() guard !batch.isEmpty else { return } listening?.credit(artistName: creditedArtist, @@ -1131,7 +1189,9 @@ final class PlayerService { commitListening() credit.startTrack() creditedArtist = current?.artist - lastCreditPosition = 0 + // A restored track resumes mid-way: that offset was never listened to. + lastCreditPosition = pendingSeekPosition ?? 0 + lastCreditSampleAt = ProcessInfo.processInfo.systemUptime } private func persistState() { diff --git a/AppleNugsTests/ListeningCreditTests.swift b/AppleNugsTests/ListeningCreditTests.swift index 564f646..135c822 100644 --- a/AppleNugsTests/ListeningCreditTests.swift +++ b/AppleNugsTests/ListeningCreditTests.swift @@ -156,4 +156,75 @@ final class ListeningCreditTests: XCTestCase { earner.tick(delta: 1, isPlaying: true, duration: 2) // threshold 1s XCTAssertTrue(earner.isCommitDue) } + + // --- spans: the iOS app backgrounded, no tick running --------------------- + + /// A minute of locked-screen listening is a minute — not the 1s a tick + /// would clamp it to. + func testASpanCreditsThePlayheadMovement() { + var credit = ListeningCredit() + credit.span(delta: 60, wallSeconds: 60.2, duration: longDuration) + + XCTAssertEqual(credit.trackSeconds, 60, accuracy: 0.0001) + XCTAssertEqual(credit.uncommittedSeconds, 60, accuracy: 0.0001) + } + + /// A lock-screen scrub forward inside a span: the playhead jumped ten + /// minutes in one minute of real time. Only the minute is credited. + func testASpanIsCappedAtTheWallClock() { + var credit = ListeningCredit() + credit.span(delta: 660, wallSeconds: 60, duration: longDuration) + + XCTAssertEqual(credit.trackSeconds, 60, accuracy: 0.0001) + } + + /// Paused (or stalled) for most of the span: the playhead didn't move, so + /// the delta is the whole story — no isPlaying flag needed. + func testAPausedSpanCreditsOnlyWhatThePlayheadMoved() { + var credit = ListeningCredit() + credit.span(delta: 0, wallSeconds: 3_600, duration: normalDuration) + XCTAssertEqual(credit.trackSeconds, 0) + + credit.span(delta: 10, wallSeconds: 60, duration: normalDuration) + XCTAssertEqual(credit.trackSeconds, 10, accuracy: 0.0001) + } + + func testANegativeOrNonFiniteSpanContributesNothing() { + var credit = ListeningCredit() + credit.span(delta: -120, wallSeconds: 60, duration: normalDuration) // backward seek + credit.span(delta: 30, wallSeconds: -5, duration: normalDuration) // clock went backwards + credit.span(delta: .nan, wallSeconds: 60, duration: normalDuration) + credit.span(delta: 30, wallSeconds: .infinity, duration: normalDuration) + + XCTAssertEqual(credit.trackSeconds, 0) + XCTAssertEqual(credit.uncommittedSeconds, 0) + XCTAssertFalse(credit.playCredited) + } + + /// One locked-screen stretch can carry a whole track past its threshold. + func testASingleSpanCanEarnThePlayAndMakesACommitDue() { + var credit = ListeningCredit() + credit.span(delta: 125, wallSeconds: 130, duration: normalDuration) // threshold 120 + + XCTAssertTrue(credit.playCredited) + XCTAssertTrue(credit.isCommitDue) + XCTAssertEqual(credit.flush(), .init(seconds: 125, completedPlay: true)) + } + + /// Ticks then a span then ticks, all on one visit: the thresholds add up + /// across both entry points and the latch still allows exactly one play. + func testTicksAndSpansShareOneVisitAndOnePlay() { + var credit = ListeningCredit() + run(&credit, ticks: 200, duration: normalDuration) // 50s + credit.span(delta: 60, wallSeconds: 60, duration: normalDuration) // 110s + XCTAssertFalse(credit.playCredited) + + run(&credit, ticks: 40, duration: normalDuration) // 120s + XCTAssertTrue(credit.playCredited) + XCTAssertTrue(credit.flush().completedPlay) + + credit.span(delta: 120, wallSeconds: 120, duration: normalDuration) // 240s + XCTAssertFalse(credit.flush().completedPlay) + XCTAssertEqual(credit.trackSeconds, 240, accuracy: 0.0001) + } } From 337b5f81a1af6349f74bc39d14e9f22060046ead Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 7 Nov 2026 16:05:10 -0500 Subject: [PATCH 5/8] =?UTF-8?q?docs:=20listening=20stats=20landed=20?= =?UTF-8?q?=E2=80=94=20invariants,=20background-settle=20rule,=20what's=20?= =?UTF-8?q?unverified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the old branch's CLAUDE.md notes (the three silent-failure rules, the deliberate scope cuts, the unverified list) into the current file rather than replaying its conflicting Pick-up-here edits, and adds the fourth rule this rebase introduced: backgrounded credit is settled, not ticked. Suite count 307 -> 338. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 47 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 137f5c1..1a1280a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Native **macOS + iOS SwiftUI** player for nugs.net, shipped to users as **Yeti ## Environment & build - Work in the git **worktree** you were started in; **don't `cd` to the main checkout** (the repo-root clone). Merge to `main` via a fast-forward from the main checkout using `git -C`, then push (releases/pushes are the maintainer's call). - `xcodegen generate` after any `project.yml` edit (`AppleNugs.xcodeproj/` is **gitignored**). Regenerate before building/opening. -- Schemes: `AppleNugs` (macOS, `-destination 'platform=macOS'`), `AppleNugs-iOS` (`-destination 'generic/platform=iOS Simulator'` or a device id), `AppleNugsTests` (host-free unit tests, **131** as of 2026-08-18 — CI runs them on every push, so take the count from the suite rather than from this line). +- Schemes: `AppleNugs` (macOS, `-destination 'platform=macOS'`), `AppleNugs-iOS` (`-destination 'generic/platform=iOS Simulator'` or a device id), `AppleNugsTests` (host-free unit tests, **338** as of 2026-11-07 — CI runs them on every push, so take the count from the suite rather than from this line). - **Always build/verify with a fresh `-derivedDataPath`** — incremental builds can report a false "clean". Project must stay warning-free under `SWIFT_STRICT_CONCURRENCY=complete` on **both** schemes. SourceKit "No such module 'UIKit'" on iOS-only files = per-target index noise; `xcodebuild` is authoritative. - `docs/superpowers/` is **gitignored** (specs, plans, reports — local only, and therefore NOT a safe place for handoff state: a worktree removal deletes it. Durable session state belongs in this file plus the auto-memory index). - Visual verify without a login: launch with `-UITEST -UITestSeedQueue` (bypasses login, seeds a now-playing queue). Theme switcher = account menu (person icon). Dashboard = `sidebar.right` toolbar button / ⌥⌘I. @@ -44,6 +44,28 @@ Native **macOS + iOS SwiftUI** player for nugs.net, shipped to users as **Yeti fixed-geometry cluster uses `NowPlayingFit.scale` + `scaleEffect` + a matching `.frame` — `scaleEffect` alone keeps the old footprint. - Catalog dates are UTC-midnight instants — use `CrateSection.catalogCalendar`, not `Calendar.current` (see the `applenugs-catalog-dates-are-utc` memory). +- **Listening stats are artists-only rolling aggregates with NO event log** (landed 2026-11-07; the Home + "nice list" the Yeti design builds on). `ListeningStats` (the table), `ListeningCredit` (accumulator + + threshold policy, play = min(50%, 240s) LISTENED, not playhead) and `LedgerSummary` (totals, ranking, + copy) are pure Foundation and compiled into `AppleNugsTests`; the `@Observable` `ListeningStatsStore` + writes `listening.json`. Four rules, all of which fail silently if broken. (1) **`PlayerService` owns + the accumulator, not the store** — its credit properties are `@ObservationIgnored` and + `store.credit(...)` is called ONLY at commit boundaries (track change, 30s, pause, background, + terminate), never per tick; a per-tick entry point would make Home re-diff four times a second. + (2) **`HomeView` gates the ledger on `listening.hasAnyPlay`, never on `stats`** — reading the table + from Home's body subscribes the greeting, hero, favorites strip and crate grid to every commit. + (3) **`save()` refuses to write while `loadFailed` is latched**: a file that didn't decode is the only + copy of that history, so it is salvaged per-element and left on disk; `reset()` clears the latch on + purpose. The `-UITEST` guard covers WRITES only — **a screenshot run renders real listening data on + Home.** (4) **Backgrounded iOS has no tick, so credit is SETTLED, not ticked** (`ListeningCredit.span`: + playhead delta capped at wall-clock time since the last sample). `PlayerService.settleListening` runs + at every commit, on the existing 60s background wake, before the tick returns on foregrounding, at a + track's end (at `duration` — a gapless head has already moved on), before a gapless `next()`, and + before a seek, which then rebases the baseline. Don't add a wake or a background tick for stats. + Scope cuts are deliberate — don't "restore" them: artists only (no shows/tracks/venues/months), + all-time only, Home only (no sidebar row or tab), typographic ledger (cards/tiles rejected), audio only + (the store seam is neutral so `VideoPlayerService` could become a second caller). Reset lives in the + account menu behind a `.confirmationDialog` (verified presenting from the macOS toolbar `Menu`). ## START HERE — state as of 2026-11-07 (handoff for a new session) @@ -51,14 +73,25 @@ Native **macOS + iOS SwiftUI** player for nugs.net, shipped to users as **Yeti - **`main` == `origin/main` == `e6ab835`** — everything above is LANDED: the iPhone Duo work (PR #5) and the pill duck + Yeti compliance/rename/encrypted downloads/perf + the theme design (PR #6, merged 2026-11-07, CI green on `xcode-27`, fast-forward — every hash in this file is on `main`). 307 tests. -- **`yeti-design`** (checked out, no upstream, starts at `e6ab835`) — the branch for the single-design - refactor (step 3 below). +- **Listening stats LANDED on top (2026-11-07)** — `listening-stats` rebuilt on this base as + `listening-stats-rebased` (Tim's feature commit replayed as-is, plus a fix crediting background + listening — see the Conventions bullet), fast-forwarded onto `main`. **338 tests.** The old + `listening-stats` branch in the main checkout is superseded and can be deleted (Tim's call — it's + checked out there, so it can't be touched from this worktree). +- **`yeti-design`** (no upstream) — the branch for the single-design refactor (step 3 below); fast-forward + it to the new `main` before starting. - Local `main` ref is synced to `e6ab835` (fast-forwarded from inside the worktree with `git branch -f main origin/main` — the worktree shares the repo's refs, and `main` isn't checked out anywhere; `git -C …` / network fetches from Tim's remote-control `!` shell fail). Remote branches `iphone-duo` and `yeti` are merged and can be deleted on GitHub (Tim's call). -- `listening-stats` (in the main checkout; built, unmerged, based on the OLD main — needs a rebase onto - `e6ab835`, with a CLAUDE.md conflict to resolve). Its Home "nice list" ledger is part of the Yeti design. +- **Listening stats, still unverified on screen** (a green build can't see these): the ledger POPULATED + (nothing has drawn a real row yet — needs a credited play); **locked-screen credit on the Duo** (play a + show locked for 5+ min, unlock, the artist's time should include it; `debugBackgroundTicks` must stay 0); + the iOS reset dialog and that Reset empties the ledger; row tap → `ArtistDetailView` (an unresolvable + name should be inert text); long name vs the `19 plays · 31h` block at the narrowest Mac window; Dynamic + Type / VoiceOver; a duration-less stream earning its play at 4 min, not before. Accepted, not defects: + the `Artists` total counts artists with seconds but no play yet; hours truncate; `firstPlayedAt` / + `lastPlayedAt` are stored but unused. The ledger will be restyled by the Yeti design anyway. **Landing recipe (used for PR #5 and #6):** push the branch, open a PR (CI runs on the `xcode-27` runner), then `git push origin :main` — fast-forward only, never a merge/rebase, so hashes here stay valid. @@ -75,8 +108,8 @@ Pushing is Tim's call. 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. From e1629a3722709302e41aabb2bc111a665283a5f3 Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 7 Nov 2026 16:19:05 -0500 Subject: [PATCH 6/8] fix: settle listening only from a readable playhead; re-anchor on resume Review of the background-credit change found that settleListening trusted livePosition, which falls back to the tick-fed currentTime whenever the player's head isn't our item. Stale in the background, that pulled the baseline down and let the next settle re-credit the gap. At a queue's end this credited the closing track a second time (up to its full length) and could mint a play; a wake landing inside a gapless hand-off lost a span. Settles now read creditPosition, which is nil rather than stale when the head doesn't match, and a nil sample leaves the baseline alone. Paused to playing (our own resume/toggle and external flips seen by the KVO) re-anchors the baseline without crediting, so a mid-seek settle can't carry a skipped distance across a paused span; an external pause settles. adoptPreload clears an unapplied resume offset before rotating, and a restored queue anchors at its resume point. Co-Authored-By: Claude Opus 5.5 --- AppleNugs/Player/PlayerService.swift | 48 ++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/AppleNugs/Player/PlayerService.swift b/AppleNugs/Player/PlayerService.swift index 4d1efdc..942d1bb 100644 --- a/AppleNugs/Player/PlayerService.swift +++ b/AppleNugs/Player/PlayerService.swift @@ -311,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 } @@ -485,7 +489,7 @@ final class PlayerService { 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, - // livePosition can no longer read where this track got to. + // its playhead can no longer be read. settleListening() player.advanceToNextItem() adoptPreload(p) @@ -539,6 +543,7 @@ final class PlayerService { return } if player.timeControlStatus == .paused { + rebaseListening() isPlaying = startPlayback() } else { player.pause() @@ -554,6 +559,7 @@ final class PlayerService { if current != nil { startCurrent() } return } + if !isPlaying { rebaseListening() } isPlaying = startPlayback() pushNowPlayingInfo() } @@ -856,9 +862,8 @@ final class PlayerService { 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, so livePosition would - // fall back to the tick-fed currentTime — up to a minute stale when - // backgrounded. + // 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 { @@ -895,9 +900,12 @@ final class PlayerService { preload = nil index += 1 + // 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 - pendingSeekPosition = nil currentItem = p.item picks = p.picks pickIndex = p.pickIndex @@ -1117,6 +1125,8 @@ 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() } @@ -1156,14 +1166,19 @@ final class PlayerService { /// 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 `livePosition` for callers that know better (a + /// `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 - let at = position ?? livePosition credit.span(delta: at - lastCreditPosition, wallSeconds: now - lastCreditSampleAt, duration: duration) @@ -1171,6 +1186,25 @@ final class PlayerService { 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() From 710130d6d3163c197c7fc5f7088073677be7967a Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 7 Nov 2026 16:19:19 -0500 Subject: [PATCH 7/8] fix: lock-screen skip seeks from the live playhead seek(by:) added the delta to the tick-fed currentTime, which has been stale in the background since 8c3c0d7 took the tick off there. Ten minutes after locking, the lock screen's +30s landed about 9.5 minutes back. Co-Authored-By: Claude Opus 5.5 --- AppleNugs/Player/PlayerService.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AppleNugs/Player/PlayerService.swift b/AppleNugs/Player/PlayerService.swift index 942d1bb..804d4a7 100644 --- a/AppleNugs/Player/PlayerService.swift +++ b/AppleNugs/Player/PlayerService.swift @@ -617,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 -------------------------------------------------------- From 11bb8d71024eaea3b0a9ae8ec06afd99fd196e44 Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 7 Nov 2026 16:19:27 -0500 Subject: [PATCH 8/8] docs: the creditPosition and re-anchor rules Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1a1280a..4e2a16b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,10 @@ Native **macOS + iOS SwiftUI** player for nugs.net, shipped to users as **Yeti playhead delta capped at wall-clock time since the last sample). `PlayerService.settleListening` runs at every commit, on the existing 60s background wake, before the tick returns on foregrounding, at a track's end (at `duration` — a gapless head has already moved on), before a gapless `next()`, and - before a seek, which then rebases the baseline. Don't add a wake or a background tick for stats. + before a seek, which then rebases the baseline. Settles read `creditPosition`, which is **nil — never the + stale `currentTime`** — when the player's head isn't our item, and a nil sample leaves the baseline alone + (falling back once re-credited a whole closing track at a background queue end). Paused → playing + re-anchors the baseline without crediting (`rebaseListening`). Don't add a wake or a background tick for stats. Scope cuts are deliberate — don't "restore" them: artists only (no shows/tracks/venues/months), all-time only, Home only (no sidebar row or tab), typographic ledger (cards/tiles rejected), audio only (the store seam is neutral so `VideoPlayerService` could become a second caller). Reset lives in the