From d4947f93e7a11b13f59034b3e789e0587c8f5aa4 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Tue, 6 Oct 2026 08:37:58 +0200 Subject: [PATCH 01/32] Add PLAN --- PLAN.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..5405083 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,46 @@ +# Player MVP (ROADMAP Phase 3) + +## Context +Pollux (Crux: Rust core + SwiftUI shell) has no playback yet. Scaffolding exists: `Episode.playback_status/playback_position_secs`, `StorageOperation::UpdatePlaybackStatus` (implemented in Swift, unused by `app.rs`), downloads with relative `local_path`, and `.stubbed()` UI placeholders. Milestone: find an episode, play it, control it from the lock screen, resume where left off. Specs: `docs/features/player.md`, `mini-player.md`, `episode.md`, `settings.md`. + +## Workspace +Implementation happens in a new worktree on branch `feat/player` (under `/.claude/worktrees/`, per global config), branched from `main`, not in this planning worktree. First step after approval: create it (EnterWorktree). No commits/pushes unless asked. + +## Decisions (from user) +- Not downloaded: **stream from `enclosure_url` and start a download**; swap to the local file when the download finishes. Preroll the local item, zero-tolerance seek to the current time, then swap. Measure in sim; if audible, defer swap to next pause/seek. +- Full player: `fullScreenCover` from root; mini-player as bottom `safeAreaInset` on root (hidden when cover open or nothing active). +- **No auto-advance** in MVP. Playback stops at end; no queue/next/previous. +- Skip 30/15 and rewind 3s **hard-coded** in `shared/src/defaults.rs` behind one accessor (cascade/Settings later). +- Persist position every ~10s while playing + pause/seek/background/end, via existing `UpdatePlaybackStatus`. UI ticks (~1/s) are transient events, not persisted. +- Played = within **15s** of end (use AVPlayer duration if feed `duration_secs` missing); status -> Played, position -> 0. Replay of Played reverts to InProgress. Note a user story: played threshold user-configurable. +- Rewind 3s on any resume after pause, clamped at 0 (also cold-start restore); not on fresh start. +- Audio session `.playback` + `spokenAudio`; `UIBackgroundModes: audio` in `iOS/project.yml`; pause on headphone unplug; pause on interruption, auto-resume if `shouldResume`. +- Remote commands: play, pause, toggle, skip ±(30/15), change-playback-position. Now Playing info: title, feed, artwork, elapsed, rate. +- Errors: transient banner in player and mini-player (reuse `DownloadNoticeBanner` pattern, episode-scoped); stay paused. Missing local file -> fall back to streaming and reset `download_status`. +- Stubs: wire `EpisodeRow` play button; remove `EpisodeDetailView.playbackControls` stub row; keep bookmarks stub. + +## Design +**Core (Rust, all logic/policy):** +- New `shared/src/capabilities/player.rs`: ops `Load{url, start_secs, autoplay}`, `Play`, `Pause`, `Seek{secs}`, `Stop`; outputs/events for position tick, duration known, ended, error, interruption. Add `Effect::Player` in `effect.rs`. +- `Model`: `active: Option`. +- Events: `PlayEpisode`, `TogglePlay`, `SkipForward/Back`, `SeekTo`, `PlayerTick`, `PlayerDuration`, `PlayerEnded`, `PlayerError`, `PlayerInterrupted`, `ClosePlayer`; reuse `DownloadFinished` to trigger source swap if that episode is active. +- `ViewModel`: `player: Option` (episode/feed title, artwork url, position, duration, playing, source label "From: ", error notice). `EpisodeSummary`/loader must supply `local_path` and `enclosure_url` (or a resolved play source). +- Persist active context on cold start: new migration `v2_play_context` (single-row table: subscription_id, episode_id, position_secs) + Storage ops Save/Load/Clear; on `Started` restore paused with mini-player. Load episode by id (not just `model.episodes`). +- Skip clamps within [0, duration]; never crosses episodes; skip-forward into the final 15s ends the episode as Played. + +**Shell (Swift):** +- `PlaybackManager` (`@MainActor`, new `iOS/Pollux/PlaybackManager.swift`) owns `AVPlayer`, periodic time observer, `AVAudioSession`, `MPNowPlayingInfoCenter`, `MPRemoteCommandCenter`, route/interruption observers. Handles `.player` in `Core.processEffect` (`iOS/Pollux/core.swift`). +- Expose local URL resolution: `DownloadManager.absoluteURL(for:)` is private; make a shared resolver (storage root + relative path). Digest filenames may lack an extension, so supply a type hint (`AVURLAssetOverrideMIMEType` / `AVURLAssetOutOfBandMIMETypeKey`) if sniffing fails. +- UI (themed via `themeColors`, a11y labels, adjustable-value scrubber): `MiniPlayerBar`, `PlayerScreen` (paged art/show-notes placeholder, dots, scrubber with tap-to-toggle remaining, skip labelled "+30"/"-15", play/pause, source context tappable -> dismiss cover and navigate to subscription, Hide, options menu with placeholders, episode menu). Chapter row hidden (no chapters yet). + +## Docs +Update `docs/features/player.md` (resolved choices, 15s played tolerance), `ROADMAP.md`, `DATA_MODEL.md` PlayContext; add user story for configurable played threshold. + +## Out of scope +Playlists, queue, chapters, speed, sleep timer, EQ, bookmarks, listening stats/PlayHistory, Settings screen, CarPlay specifics, background downloads. + +## Verification +- `cargo test` for core: play/pause/skip clamps, rewind, played threshold, replay revert, source swap on `DownloadFinished`, persistence events (Crux effect assertions). +- Swift Testing: PlaybackManager helpers, URL resolution, DB migration v2 round-trip. +- `make check`, `make test`; build via `make ios-build` (regenerates types). +- Simulator: stream an undownloaded episode, watch the download finish and the swap (listen for gap), lock-screen controls, background/lock continues, kill + relaunch shows paused mini-player at saved position, airplane mode error banner. From b4e7be34a867af20948edd76aa43509acea81b79 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Tue, 6 Oct 2026 09:06:34 +0200 Subject: [PATCH 02/32] feat: add audio player, mini-player and lock-screen controls Core (Rust): - new Player capability; playback policy in shared/src/player.rs - stream undownloaded episodes while downloading, then swap to the local file at the playhead; fall back to streaming if the file is gone - persist position every ~10s and on pause/seek/background/end - played once within 15s of the end; 3s rewind on resume - skip 30/15 hard-coded in defaults.rs until Settings exists - restore the active episode on cold start (v2_play_context table) Shell (Swift): - PlaybackManager (AVPlayer, audio session, interruptions, route changes) - NowPlayingController (lock-screen card and remote commands) - MiniPlayerBar and full-screen PlayerScreen - wire the episode row and detail Play buttons - enable the audio background mode No auto-advance yet; that arrives with playlists. Co-Authored-By: Claude Sonnet 5.5 --- docs/ROADMAP.md | 4 + docs/features/player.md | 35 ++ docs/user_stories/player/playback.md | 4 + iOS/Pollux/ContentView.swift | 5 +- iOS/Pollux/DatabaseManager+PlayContext.swift | 39 ++ iOS/Pollux/DatabaseManager.swift | 18 +- iOS/Pollux/DownloadManager.swift | 13 +- iOS/Pollux/EpisodeDetailView.swift | 42 +- iOS/Pollux/EpisodeRow.swift | 25 +- iOS/Pollux/Info.plist | 4 + iOS/Pollux/MiniPlayerBar.swift | 72 +++ iOS/Pollux/NowPlayingController.swift | 112 +++++ iOS/Pollux/PlaybackManager.swift | 198 ++++++++ iOS/Pollux/PlayerFormatting.swift | 41 ++ iOS/Pollux/PlayerScreen.swift | 225 +++++++++ iOS/Pollux/Pollux.swift | 56 ++- iOS/Pollux/SubscriptionDetailScreen.swift | 1 + iOS/Pollux/core.swift | 27 ++ iOS/PolluxTests/PlayContextTests.swift | 88 ++++ iOS/PolluxTests/PlayerFormattingTests.swift | 37 ++ iOS/project.yml | 2 + shared/src/app.rs | 177 +++++-- shared/src/capabilities/mod.rs | 1 + shared/src/capabilities/player.rs | 51 +++ shared/src/capabilities/storage.rs | 9 + shared/src/defaults.rs | 7 + shared/src/effect.rs | 2 + shared/src/lib.rs | 1 + shared/src/model.rs | 24 + shared/src/player.rs | 458 +++++++++++++++++++ shared/src/player/tests.rs | 424 +++++++++++++++++ shared/src/view_model.rs | 24 + 32 files changed, 2133 insertions(+), 93 deletions(-) create mode 100644 iOS/Pollux/DatabaseManager+PlayContext.swift create mode 100644 iOS/Pollux/MiniPlayerBar.swift create mode 100644 iOS/Pollux/NowPlayingController.swift create mode 100644 iOS/Pollux/PlaybackManager.swift create mode 100644 iOS/Pollux/PlayerFormatting.swift create mode 100644 iOS/Pollux/PlayerScreen.swift create mode 100644 iOS/PolluxTests/PlayContextTests.swift create mode 100644 iOS/PolluxTests/PlayerFormattingTests.swift create mode 100644 shared/src/capabilities/player.rs create mode 100644 shared/src/player.rs create mode 100644 shared/src/player/tests.rs diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 411b56b..a761d17 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -71,6 +71,10 @@ The core listening experience. strip, disappears when nothing active - **Active playlist persistence**: restore on cold start +*Status: the engine, player, mini-player and cold-start restore are built +(subscription-as-playlist only, no auto-advance). See "Decisions (MVP +implementation)" in `docs/features/player.md`.* + Milestone: can find an episode, play it, control it from lock screen, resume where you left off. diff --git a/docs/features/player.md b/docs/features/player.md index 1d27c5f..1f3c068 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -102,3 +102,38 @@ nav stack. The active playlist and current position are restored on cold start, so the mini-player is visible immediately if something was playing when the app closed. + +## Decisions (MVP implementation) + +Recorded so they are not silently reversed. The core owns all of this policy +(`shared/src/player.rs`); the shell's `PlaybackManager` only drives the audio +engine. + +- **Streaming.** Playing an episode that isn't downloaded streams from its + enclosure URL *and* queues a download. When the download finishes, playback + moves onto the local file at the current position (a `Load` at the playhead). + If the downloaded file turns out to be missing or unreadable, playback falls + back to streaming and the episode is reset to not-downloaded and re-queued. +- **Played tolerance.** An episode counts as played once the playhead is within + **15s** of the end (`PLAYED_TOLERANCE_SECS`); status becomes played and the + stored position resets to 0. The engine's duration wins over the feed's. + *Will become a user setting.* +- **Position persistence.** Written at most every 10s while playing, and on + pause, seek, backgrounding, switching episode, and end. Per-second ticks are + transient. +- **Resume rewind.** 3s on any resume after a pause (and on cold-start restore + and when resuming a saved position), clamped at 0. Hard-coded until Settings + exists (`defaults.rs`), as are the 30s/15s skips. +- **No auto-advance (yet).** At the end of an episode it is marked played and the + player goes inactive (the mini-player disappears). "Next episode" arrives with + playlists. +- **Presentation.** The full player is a full-screen cover from the root; the + mini-player is a bottom inset on the root. Tapping "From: …" dismisses the + cover and shows the podcast. +- **OS integration.** Audio session `.playback` / `spokenAudio`, background + audio mode, pause on headphone unplug, pause on interruption (auto-resume only + if the system says so). Lock-screen commands: play, pause, toggle, skip + ±(30/15), and scrubbing. Next/previous track are disabled (skip buttons stand + in for them, per podcast convention). +- **Errors.** A playback failure leaves playback paused with a transient banner + in the player and mini-player; pressing play retries. diff --git a/docs/user_stories/player/playback.md b/docs/user_stories/player/playback.md index 5fd3c4d..055d031 100644 --- a/docs/user_stories/player/playback.md +++ b/docs/user_stories/player/playback.md @@ -96,3 +96,7 @@ so that I can quickly control playback As a listener I want to select the output device (Bluetooth, AirPlay, speaker, etc) so that I can listen how I want + +As a listener +I want to choose how close to the end an episode must be to count as played +so that I can match it to how much outro I tolerate diff --git a/iOS/Pollux/ContentView.swift b/iOS/Pollux/ContentView.swift index b01bdbe..1eea377 100644 --- a/iOS/Pollux/ContentView.swift +++ b/iOS/Pollux/ContentView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ContentView: View { @ObservedObject var core: Core + @Binding var path: NavigationPath @Environment(\.themeColors) private var themeColors @State private var feedUrl = "" @State private var isSubscribing = false @@ -11,7 +12,7 @@ struct ContentView: View { } var body: some View { - NavigationStack { + NavigationStack(path: $path) { VStack(spacing: 0) { Group { if core.view.library.loading { @@ -79,5 +80,5 @@ struct ContentView: View { } #Preview { - ContentView(core: Core()) + ContentView(core: Core(), path: .constant(NavigationPath())) } diff --git a/iOS/Pollux/DatabaseManager+PlayContext.swift b/iOS/Pollux/DatabaseManager+PlayContext.swift new file mode 100644 index 0000000..25b8d30 --- /dev/null +++ b/iOS/Pollux/DatabaseManager+PlayContext.swift @@ -0,0 +1,39 @@ +import App +import GRDB + +/// The saved "active episode" the player restores at launch. Split from +/// `DatabaseManager` to keep that type under the body-length limit. +extension DatabaseManager { + func savePlayContext(episodeId: String) async throws -> StorageResult { + try await db.write { db in + try db.execute( + sql: "INSERT OR REPLACE INTO play_context (id, episode_id) VALUES (1, ?)", + arguments: [episodeId], + ) + } + return .success + } + + /// The saved active episode, or `.notFound` when none is saved. + func loadPlayContext() throws -> StorageResult { + let row = try db.read { db -> Row? in + try Row.fetchOne( + db, + sql: """ + SELECT episodes.* FROM play_context + JOIN episodes ON episodes.id = play_context.episode_id + WHERE play_context.id = 1 + """, + ) + } + guard let row else { return .notFound } + return .episode(Self.episode(from: row)) + } + + func clearPlayContext() async throws -> StorageResult { + try await db.write { db in + try db.execute(sql: "DELETE FROM play_context") + } + return .success + } +} diff --git a/iOS/Pollux/DatabaseManager.swift b/iOS/Pollux/DatabaseManager.swift index 722045b..f7f4040 100644 --- a/iOS/Pollux/DatabaseManager.swift +++ b/iOS/Pollux/DatabaseManager.swift @@ -16,7 +16,7 @@ enum DatabaseManagerError: Error, LocalizedError { } actor DatabaseManager { - private let db: DatabasePool + let db: DatabasePool init() throws { guard let support = FileManager.default.urls( @@ -74,6 +74,16 @@ actor DatabaseManager { columns: ["subscription_id"], ) } + // The episode the player was on, so the mini-player can be restored at launch. + // Single row (id pinned to 1); position lives on the episode itself. Deleting + // the episode (via its subscription) drops the row with it. + migrator.registerMigration("v2_play_context") { db in + try db.create(table: "play_context") { t in + t.column("id", .integer).primaryKey().check(sql: "id = 1") + t.column("episode_id", .text).notNull() + .references("episodes", column: "id", onDelete: .cascade) + } + } try migrator.migrate(db) } @@ -95,6 +105,12 @@ actor DatabaseManager { try await deleteSubscription(id: id) case let .upsertFeedWithEpisodes(subscription, episodes): try await upsertFeedWithEpisodes(subscription: subscription, episodes: episodes) + case let .savePlayContext(episodeId): + try await savePlayContext(episodeId: episodeId) + case .loadPlayContext: + try loadPlayContext() + case .clearPlayContext: + try await clearPlayContext() default: try await executeEpisode(operation) } diff --git a/iOS/Pollux/DownloadManager.swift b/iOS/Pollux/DownloadManager.swift index fdc71e6..3a51aad 100644 --- a/iOS/Pollux/DownloadManager.swift +++ b/iOS/Pollux/DownloadManager.swift @@ -196,7 +196,18 @@ actor DownloadManager { } private func absoluteURL(for relativePath: String) -> URL { - storageRoot.appendingPathComponent(relativePath) + Self.absoluteURL(for: relativePath, root: storageRoot) + } + + /// Resolves a stored (relative) download path against the storage root. Shared + /// with playback, which opens the same files. + static func absoluteURL(for relativePath: String, root: URL) -> URL { + root.appendingPathComponent(relativePath) + } + + /// The default storage root: Application Support. + static func defaultStorageRoot() -> URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first } /// A filesystem-safe, stable, collision-free file name for an episode. The episode diff --git a/iOS/Pollux/EpisodeDetailView.swift b/iOS/Pollux/EpisodeDetailView.swift index d64794e..e5d3697 100644 --- a/iOS/Pollux/EpisodeDetailView.swift +++ b/iOS/Pollux/EpisodeDetailView.swift @@ -2,8 +2,8 @@ import App import SwiftUI /// Full-page view for a single episode. Renders the stored metadata, show notes, -/// and a working download control; transport controls, chapters, and bookmarks are -/// DEBUG-marked placeholders because their engines don't exist yet. Static metadata +/// and working play and download controls; chapters and bookmarks are DEBUG-marked +/// placeholders because their backends don't exist yet. Static metadata /// is taken from the `episode` snapshot passed in from the list, but the download /// state is read live from `core.view` so it updates while the page is open. struct EpisodeDetailView: View { @@ -52,7 +52,7 @@ struct EpisodeDetailView: View { ) PlaceholderSection( title: "Bookmarks", - note: "Bookmarks need the playback engine.", + note: "Bookmarks aren't built yet.", ) } .padding() @@ -84,33 +84,19 @@ struct EpisodeDetailView: View { } } - /// Transport controls with no engine behind them — inert placeholders. + /// Starts playback; the transport controls live in the player (mini-player and + /// full screen), which takes over from here. private var playbackControls: some View { - VStack(spacing: 10) { - HStack(spacing: 24) { - Image(systemName: "gobackward.15") - .font(.title2) - .stubbed() - Image(systemName: "play.circle.fill") - .font(.system(size: 56)) - .stubbed() - Image(systemName: "goforward.30") - .font(.title2) - .stubbed() - } - ProgressView(value: 0) - .tint(.debug) - .disabled(true) - HStack(spacing: 6) { - Image(systemName: "nosign") - .font(.caption) - .foregroundStyle(Color.debug) - Text("No playback engine yet") - .font(.caption2) - .foregroundStyle(themeColors.secondaryText) - } + Button { core.update(.playEpisode(episode.id)) } label: { + Label(playButtonTitle, systemImage: "play.fill") + .frame(maxWidth: .infinity) } - .frame(maxWidth: .infinity) + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + + private var playButtonTitle: String { + liveEpisode.playbackStatus == .inProgress ? "Resume" : "Play" } /// Working download control, driven by the live download status. diff --git a/iOS/Pollux/EpisodeRow.swift b/iOS/Pollux/EpisodeRow.swift index 830b5f5..b0e8eaa 100644 --- a/iOS/Pollux/EpisodeRow.swift +++ b/iOS/Pollux/EpisodeRow.swift @@ -2,12 +2,13 @@ import App import SwiftUI /// One episode in the subscription details list. Shows stored data (art, title, -/// date, duration, description, read-only played/download status) and a working -/// download control. Playback and more-actions remain DEBUG-tinted, disabled -/// placeholders because their engines don't exist yet — see `DebugStyle.swift`. +/// date, duration, description, read-only played/download status) plus working play +/// and download controls. struct EpisodeRow: View { @Environment(\.themeColors) private var themeColors let episode: EpisodeSummary + /// Start (or resume) playing this episode. + let onPlay: (String) -> Void /// Queue this episode for download (or retry a failed one). let onDownload: (String) -> Void /// Remove this episode's downloaded file. @@ -137,14 +138,17 @@ struct EpisodeRow: View { } } - /// Trailing controls: the still-stubbed play button (no playback engine yet) and - /// the three-dots menu, which is where the download action now lives. Download - /// *status* stays inline (`metaRow`); this menu is the *action* surface. + /// Trailing controls: the play button and the three-dots menu, which is where the + /// download action lives. Download *status* stays inline (`metaRow`); this menu is + /// the *action* surface. private var trailingControls: some View { HStack(spacing: 12) { - Image(systemName: "play.circle.fill") - .font(.title2) - .stubbed() + Button { onPlay(episode.id) } label: { + Image(systemName: "play.circle.fill") + .font(.title2) + } + .buttonStyle(.borderless) + .accessibilityLabel("Play") moreMenu } } @@ -200,8 +204,7 @@ struct EpisodeRow: View { } } - /// Only meaningful for in-progress episodes; nothing sets a position yet, so - /// in practice this stays nil until the playback engine lands. + /// Only meaningful for in-progress episodes. private var playbackPositionText: String? { guard episode.playbackStatus == .inProgress, let position = EpisodeFormatting.formatDuration(episode.playbackPositionSecs) diff --git a/iOS/Pollux/Info.plist b/iOS/Pollux/Info.plist index 1301f20..4e291ed 100644 --- a/iOS/Pollux/Info.plist +++ b/iOS/Pollux/Info.plist @@ -18,6 +18,10 @@ 1.0 CFBundleVersion 1 + UIBackgroundModes + + audio + UILaunchScreen diff --git a/iOS/Pollux/MiniPlayerBar.swift b/iOS/Pollux/MiniPlayerBar.swift new file mode 100644 index 0000000..1621317 --- /dev/null +++ b/iOS/Pollux/MiniPlayerBar.swift @@ -0,0 +1,72 @@ +import App +import SwiftUI + +/// The persistent compact player, shown at the bottom whenever an episode is active +/// and the full player is closed. Exactly two touch zones: play/pause, and everything +/// else (which opens the full player). The progress strip is visual only. +struct MiniPlayerBar: View { + @Environment(\.themeColors) private var themeColors + let player: PlayerView + let onOpen: () -> Void + let onTogglePlay: () -> Void + + var body: some View { + VStack(spacing: 0) { + if let error = player.error { + DownloadNoticeBanner(message: error) + } + HStack(spacing: 12) { + Button(action: onOpen) { + HStack(spacing: 12) { + ArtworkView(urlString: player.artworkUrl, size: 44) + VStack(alignment: .leading, spacing: 2) { + Text(player.episodeTitle) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundStyle(themeColors.text) + .lineLimit(1) + // The active source is the subscription for now; playlists + // will make this a distinct "From:" label. + Text(player.feedTitle) + .font(.caption) + .foregroundStyle(themeColors.secondaryText) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Now playing: \(player.episodeTitle)") + .accessibilityHint("Opens the player") + + Button(action: onTogglePlay) { + Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") + .font(.title2) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .foregroundStyle(themeColors.text) + .accessibilityLabel(player.isPlaying ? "Pause" : "Play") + } + .padding(.horizontal) + .padding(.vertical, 8) + + progressStrip + } + .background(themeColors.secondaryBackground.ignoresSafeArea(edges: .bottom)) + } + + private var progressStrip: some View { + GeometryReader { geometry in + let fraction = PlayerFormatting.fraction( + position: player.positionSecs, duration: player.durationSecs, + ) + Rectangle() + .fill(themeColors.accent) + .frame(width: geometry.size.width * fraction) + } + .frame(height: 2) + .accessibilityHidden(true) + } +} diff --git a/iOS/Pollux/NowPlayingController.swift b/iOS/Pollux/NowPlayingController.swift new file mode 100644 index 0000000..d510981 --- /dev/null +++ b/iOS/Pollux/NowPlayingController.swift @@ -0,0 +1,112 @@ +import App +import Foundation +import MediaPlayer +import UIKit + +/// Lock-screen, Control Center, headphone and car integration. Remote commands are +/// forwarded to the core as the same events the in-app controls send, so the lock +/// screen and the player can never disagree; the Now Playing card is rebuilt from the +/// core's `PlayerView` on every render. +@MainActor +final class NowPlayingController { + private let send: (Event) -> Void + private var artworkURL: String? + private var artwork: MPMediaItemArtwork? + + init(send: @escaping (Event) -> Void) { + self.send = send + registerCommands() + } + + func update(_ player: PlayerView?) { + let center = MPNowPlayingInfoCenter.default() + guard let player else { + center.nowPlayingInfo = nil + center.playbackState = .stopped + return + } + loadArtworkIfNeeded(player.artworkUrl) + + var info: [String: Any] = [ + MPMediaItemPropertyTitle: player.episodeTitle, + MPMediaItemPropertyArtist: player.feedTitle, + MPMediaItemPropertyAlbumTitle: player.feedTitle, + MPNowPlayingInfoPropertyElapsedPlaybackTime: Double(player.positionSecs), + MPNowPlayingInfoPropertyPlaybackRate: player.isPlaying ? 1.0 : 0.0, + MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0, + ] + if let duration = player.durationSecs { + info[MPMediaItemPropertyPlaybackDuration] = Double(duration) + } + if let artwork { + info[MPMediaItemPropertyArtwork] = artwork + } + center.nowPlayingInfo = info + center.playbackState = player.isPlaying ? .playing : .paused + updateSkipIntervals(forward: player.skipForwardSecs, back: player.skipBackSecs) + } + + // MARK: - Remote commands + + private func registerCommands() { + let commands = MPRemoteCommandCenter.shared() + // Podcast convention: skip buttons stand in for next/previous track. + commands.nextTrackCommand.isEnabled = false + commands.previousTrackCommand.isEnabled = false + + add(commands.playCommand, .play) + add(commands.pauseCommand, .pause) + add(commands.togglePlayPauseCommand, .togglePlay) + add(commands.skipForwardCommand, .skipForward) + add(commands.skipBackwardCommand, .skipBack) + + commands.changePlaybackPositionCommand.isEnabled = true + commands.changePlaybackPositionCommand.addTarget { [weak self] event in + guard let event = event as? MPChangePlaybackPositionCommandEvent else { + return .commandFailed + } + let secs = UInt32(max(0, event.positionTime)) + Task { @MainActor in self?.send(.seekTo(secs)) } + return .success + } + } + + private func add(_ command: MPRemoteCommand, _ event: Event) { + command.isEnabled = true + command.addTarget { [weak self] _ in + Task { @MainActor in self?.send(event) } + return .success + } + } + + private func updateSkipIntervals(forward: UInt32, back: UInt32) { + let commands = MPRemoteCommandCenter.shared() + commands.skipForwardCommand.preferredIntervals = [NSNumber(value: forward)] + commands.skipBackwardCommand.preferredIntervals = [NSNumber(value: back)] + } + + // MARK: - Artwork + + /// Fetches the card's artwork once per URL; the card shows without it meanwhile. + private func loadArtworkIfNeeded(_ urlString: String?) { + guard urlString != artworkURL else { return } + artworkURL = urlString + artwork = nil + guard let urlString, let url = URL(string: urlString) else { return } + Task { [weak self] in + guard let (data, _) = try? await URLSession.shared.data(from: url), + let image = UIImage(data: data) else { return } + self?.applyArtwork(image, for: urlString) + } + } + + private func applyArtwork(_ image: UIImage, for urlString: String) { + // The episode may have changed while the image downloaded. + guard artworkURL == urlString else { return } + let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + self.artwork = artwork + var info = MPNowPlayingInfoCenter.default().nowPlayingInfo + info?[MPMediaItemPropertyArtwork] = artwork + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } +} diff --git a/iOS/Pollux/PlaybackManager.swift b/iOS/Pollux/PlaybackManager.swift new file mode 100644 index 0000000..9ab96ca --- /dev/null +++ b/iOS/Pollux/PlaybackManager.swift @@ -0,0 +1,198 @@ +import App +import AVFoundation +import Foundation + +/// The audio engine for the `Player` capability: owns the `AVPlayer`, the audio +/// session and its interruptions, and reports engine news back to the core as events. +/// It carries out what the core asks and makes no playback decisions of its own — when +/// to rewind, how far to skip, and what counts as played all live in the core. +@MainActor +final class PlaybackManager { + private let storageRoot: URL + private let player = AVPlayer() + /// Core events (ticks, end of file, interruptions…) go out through this. + private let send: (Event) -> Void + + private var currentEpisodeId: String? + /// Seeks still in flight. A streaming seek takes a moment, and the engine keeps + /// reporting the old position until it lands; those ticks would snap the UI back. + private var pendingSeeks = 0 + private var timeObserver: Any? + private var statusObservation: NSKeyValueObservation? + private var notificationTokens: [NSObjectProtocol] = [] + + init(storageRoot: URL, send: @escaping (Event) -> Void) { + self.storageRoot = storageRoot + self.send = send + observeTime() + observeSession() + } + + // MARK: - Operations + + /// Carries out one core operation. Synchronous: AVPlayer queues its own work. + func perform(_ operation: PlayerOperation) -> PlayerResult { + switch operation { + case let .load(episodeId, source, startSecs, autoplay): + return load(episodeId: episodeId, source: source, startSecs: startSecs, autoplay: autoplay) + case .play: + player.play() + case .pause: + player.pause() + case let .seek(secs): + seek(to: secs) + case .stop: + stop() + } + return .ok + } + + private func load( + episodeId: String, source: PlayerSource, startSecs: UInt32, autoplay: Bool, + ) -> PlayerResult { + let url: URL + switch source { + case let .stream(urlString): + guard let parsed = URL(string: urlString) else { + return .error("Invalid episode URL") + } + url = parsed + case let .local(localPath): + url = DownloadManager.absoluteURL(for: localPath, root: storageRoot) + guard FileManager.default.fileExists(atPath: url.path) else { + return .error("The downloaded file is missing") + } + } + + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .spokenAudio) + try session.setActive(true) + } catch { + return .error("Couldn't start audio: \(error.localizedDescription)") + } + + currentEpisodeId = episodeId + let item = AVPlayerItem(url: url) + observe(item, episodeId: episodeId) + player.replaceCurrentItem(with: item) + // Exact seek: a resume that lands a few seconds off would defeat the rewind. + seek(to: startSecs) + if autoplay { + player.play() + } + return .ok + } + + private func seek(to secs: UInt32) { + let target = CMTime(seconds: Double(secs), preferredTimescale: 600) + pendingSeeks += 1 + player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in + // Also fires (unfinished) for a seek superseded by a newer one. + MainActor.assumeIsolated { self?.pendingSeeks -= 1 } + } + } + + private func stop() { + player.pause() + player.replaceCurrentItem(with: nil) + statusObservation = nil + currentEpisodeId = nil + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + // MARK: - Engine → core + + private func observe(_ item: AVPlayerItem, episodeId: String) { + statusObservation = item.observe(\.status, options: [.new]) { [weak self] item, _ in + let status = item.status + let duration = item.duration.seconds + let message = item.error?.localizedDescription + Task { @MainActor [weak self] in + guard let self, currentEpisodeId == episodeId else { return } + switch status { + case .readyToPlay: + if duration.isFinite, duration > 0 { + send(.playerDuration(episodeId: episodeId, durationSecs: UInt32(duration))) + } + case .failed: + send(.playerFailed(episodeId: episodeId, message: message ?? "Playback failed")) + default: + break + } + } + } + notificationTokens.forEach(NotificationCenter.default.removeObserver) + notificationTokens = [ + NotificationCenter.default.addObserver( + forName: AVPlayerItem.didPlayToEndTimeNotification, object: item, queue: .main, + ) { [weak self] _ in + MainActor.assumeIsolated { self?.send(.playerEnded(episodeId)) } + }, + NotificationCenter.default.addObserver( + forName: AVPlayerItem.failedToPlayToEndTimeNotification, object: item, queue: .main, + ) { [weak self] note in + let error = note.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error + let message = error?.localizedDescription ?? "Playback stopped unexpectedly" + MainActor.assumeIsolated { + self?.send(.playerFailed(episodeId: episodeId, message: message)) + } + }, + ] + } + + /// Reports position about once a second while playing. + private func observeTime() { + let interval = CMTime(seconds: 1, preferredTimescale: 600) + timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in + let seconds = time.seconds + MainActor.assumeIsolated { self?.reportTick(seconds: seconds) } + } + } + + private func reportTick(seconds: Double) { + guard let id = currentEpisodeId, + pendingSeeks == 0, + player.timeControlStatus == .playing, + seconds.isFinite, seconds >= 0 else { return } + send(.playerTick(episodeId: id, positionSecs: UInt32(seconds))) + } + + /// Phone calls, Siri and unplugged headphones pause playback; the core is told so + /// its state and the persisted position stay right. Playback resumes only when the + /// system says it should (e.g. after a call), never after unplugging. + private func observeSession() { + let center = NotificationCenter.default + let session = AVAudioSession.sharedInstance() + center.addObserver( + forName: AVAudioSession.interruptionNotification, object: session, queue: .main, + ) { [weak self] note in + let info = note.userInfo + let type = (info?[AVAudioSessionInterruptionTypeKey] as? UInt) + .flatMap(AVAudioSession.InterruptionType.init(rawValue:)) + let options = (info?[AVAudioSessionInterruptionOptionKey] as? UInt) + .map(AVAudioSession.InterruptionOptions.init(rawValue:)) + MainActor.assumeIsolated { + switch type { + case .began: + self?.send(.pause) + case .ended where options?.contains(.shouldResume) == true: + self?.send(.play) + default: + break + } + } + } + center.addObserver( + forName: AVAudioSession.routeChangeNotification, object: session, queue: .main, + ) { [weak self] note in + let reason = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt) + .flatMap(AVAudioSession.RouteChangeReason.init(rawValue:)) + MainActor.assumeIsolated { + if reason == .oldDeviceUnavailable { + self?.send(.pause) + } + } + } + } +} diff --git a/iOS/Pollux/PlayerFormatting.swift b/iOS/Pollux/PlayerFormatting.swift new file mode 100644 index 0000000..f482c90 --- /dev/null +++ b/iOS/Pollux/PlayerFormatting.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Display strings for the player's clock and skip buttons. Kept out of the views so +/// it can be unit-tested without SwiftUI (like `EpisodeFormatting`). +enum PlayerFormatting { + /// "2:03" under an hour, "1:02:03" from an hour up. + static func clock(_ seconds: UInt32) -> String { + let total = Int(seconds) + let hours = total / 3600 + let minutes = (total % 3600) / 60 + let secs = total % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, secs) + } + return String(format: "%d:%02d", minutes, secs) + } + + /// Time left as "-2:03", never negative (a position at or past the end reads "-0:00"). + static func remaining(position: UInt32, duration: UInt32) -> String { + "-" + clock(duration > position ? duration - position : 0) + } + + /// The skip button's label: "+30" forward, "-15" back. + static func skipLabel(_ seconds: UInt32, forward: Bool) -> String { + (forward ? "+" : "-") + String(seconds) + } + + /// Spoken form for VoiceOver: "1 hour 2 minutes 3 seconds". + static func spoken(_ seconds: UInt32) -> String { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .full + formatter.allowedUnits = [.hour, .minute, .second] + return formatter.string(from: TimeInterval(seconds)) ?? "\(seconds) seconds" + } + + /// Fraction of the episode played, 0...1; 0 when the duration is unknown. + static func fraction(position: UInt32, duration: UInt32?) -> Double { + guard let duration, duration > 0 else { return 0 } + return min(1, Double(position) / Double(duration)) + } +} diff --git a/iOS/Pollux/PlayerScreen.swift b/iOS/Pollux/PlayerScreen.swift new file mode 100644 index 0000000..eab1610 --- /dev/null +++ b/iOS/Pollux/PlayerScreen.swift @@ -0,0 +1,225 @@ +import App +import AVKit +import SwiftUI + +/// The full player: a paged content area (artwork, show-notes placeholder), scrubber, +/// transport controls, and the source/option rows. Presented as a full-screen cover +/// from the root; "Hide" dismisses it back to the mini-player. +struct PlayerScreen: View { + @Environment(\.themeColors) private var themeColors + let player: PlayerView + let send: (Event) -> Void + let onHide: () -> Void + let onGoToSource: () -> Void + + /// While the thumb is dragged, the value shown (the core's position keeps ticking + /// underneath and must not fight the drag). Committed as one seek on release. + @State private var scrubPosition: Double? + @State private var showRemaining = false + @State private var page = 0 + + var body: some View { + VStack(spacing: 16) { + pages + if let error = player.error { + DownloadNoticeBanner(message: error) + } + scrubber + titleBlock + transport + bottomRow + } + .padding() + .background(themeColors.background.ignoresSafeArea()) + } + + // MARK: - Paged content + + private var pages: some View { + VStack(spacing: 8) { + TabView(selection: $page) { + ArtworkView(urlString: player.artworkUrl, size: 300) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .tag(0) + Text("Show notes coming soon") + .foregroundStyle(themeColors.secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + // The system dots are fixed light-on-light; draw our own in theme colors. + HStack(spacing: 8) { + ForEach(0 ..< 2, id: \.self) { index in + Circle() + .fill(index == page ? themeColors.text : themeColors.secondaryText.opacity(0.4)) + .frame(width: 8, height: 8) + } + } + .accessibilityHidden(true) + } + } + + // MARK: - Scrubber + + private var duration: UInt32? { + player.durationSecs + } + + private var displayedPosition: Double { + scrubPosition ?? Double(player.positionSecs) + } + + private var scrubber: some View { + VStack(spacing: 4) { + Slider( + value: Binding( + get: { displayedPosition }, + set: { scrubPosition = $0 }, + ), + in: 0 ... Double(max(duration ?? 1, 1)), + onEditingChanged: { editing in + guard !editing, let target = scrubPosition else { return } + scrubPosition = nil + send(.seekTo(UInt32(target))) + }, + ) + .disabled(duration == nil) + .accessibilityLabel("Playback position") + .accessibilityValue(PlayerFormatting.spoken(UInt32(displayedPosition))) + HStack { + Text(PlayerFormatting.clock(UInt32(displayedPosition))) + Spacer() + Button { + showRemaining.toggle() + } label: { + Text(trailingTime) + } + .buttonStyle(.plain) + .accessibilityLabel(showRemaining ? "Time remaining" : "Total duration") + .accessibilityHint("Toggles between total and remaining time") + } + .font(.caption) + .monospacedDigit() + .foregroundStyle(themeColors.secondaryText) + } + } + + private var trailingTime: String { + guard let duration else { return "--:--" } + return showRemaining + ? PlayerFormatting.remaining(position: UInt32(displayedPosition), duration: duration) + : PlayerFormatting.clock(duration) + } + + // MARK: - Title and source + + private var titleBlock: some View { + VStack(spacing: 4) { + Text(player.episodeTitle) + .font(.headline) + .foregroundStyle(themeColors.text) + .multilineTextAlignment(.center) + .lineLimit(2) + Button(action: onGoToSource) { + Text("From: \(player.feedTitle)") + .font(.subheadline) + .foregroundStyle(themeColors.accent) + .lineLimit(1) + } + .accessibilityHint("Goes to the podcast") + } + } + + // MARK: - Transport + + private var transport: some View { + HStack(spacing: 40) { + skipButton(forward: false) + Button { send(.togglePlay) } label: { + Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 64)) + } + .accessibilityLabel(player.isPlaying ? "Pause" : "Play") + skipButton(forward: true) + } + .foregroundStyle(themeColors.text) + } + + private func skipButton(forward: Bool) -> some View { + let seconds = forward ? player.skipForwardSecs : player.skipBackSecs + return Button { send(forward ? .skipForward : .skipBack) } label: { + VStack(spacing: 2) { + Image(systemName: forward ? "goforward" : "gobackward") + .font(.title) + Text(PlayerFormatting.skipLabel(seconds, forward: forward)) + .font(.caption) + .monospacedDigit() + } + } + .accessibilityLabel("Skip \(forward ? "forward" : "back") \(seconds) seconds") + } + + // MARK: - Bottom row + + private var bottomRow: some View { + HStack { + Button(action: onHide) { + Image(systemName: "chevron.down") + .font(.title3) + .frame(width: 44, height: 44) + } + .accessibilityLabel("Hide player") + Spacer() + playerOptions + Spacer() + episodeOptions + } + .foregroundStyle(themeColors.text) + } + + /// Output routing plus the not-yet-built options, shown disabled so the menu's + /// shape is visible without pretending they work. + private var playerOptions: some View { + HStack(spacing: 8) { + RoutePicker() + .frame(width: 44, height: 44) + Menu { + Button("Playback speed", systemImage: "speedometer") {}.disabled(true) + Button("Sleep timer", systemImage: "moon.zzz") {}.disabled(true) + Button("Equalizer", systemImage: "slider.vertical.3") {}.disabled(true) + } label: { + Image(systemName: "slider.horizontal.3") + .font(.title3) + .frame(width: 44, height: 44) + } + .accessibilityLabel("Player options") + } + } + + private var episodeOptions: some View { + Menu { + Button("Go to podcast", systemImage: "books.vertical", action: onGoToSource) + } label: { + Image(systemName: "ellipsis.circle") + .font(.title3) + .frame(width: 44, height: 44) + } + .accessibilityLabel("Episode options") + } +} + +/// The system AirPlay / output-route picker. +private struct RoutePicker: UIViewRepresentable { + @Environment(\.themeColors) private var themeColors + + func makeUIView(context _: Context) -> AVRoutePickerView { + let picker = AVRoutePickerView() + picker.prioritizesVideoDevices = false + return picker + } + + func updateUIView(_ picker: AVRoutePickerView, context _: Context) { + picker.tintColor = UIColor(themeColors.text) + picker.activeTintColor = UIColor(themeColors.accent) + } +} diff --git a/iOS/Pollux/Pollux.swift b/iOS/Pollux/Pollux.swift index 6b8bbee..5dcb683 100644 --- a/iOS/Pollux/Pollux.swift +++ b/iOS/Pollux/Pollux.swift @@ -18,6 +18,11 @@ struct PolluxApp: App { private struct RootView: View { @ObservedObject var core: Core @Environment(\.colorScheme) private var colorScheme + @Environment(\.scenePhase) private var scenePhase + @State private var showPlayer = false + /// The library's navigation stack, owned here so the player can jump back to the + /// podcast it is playing from. + @State private var path = NavigationPath() private var theme: ThemeView { core.view.theme @@ -28,10 +33,59 @@ private struct RootView: View { } var body: some View { - ContentView(core: core) + ContentView(core: core, path: $path) + // Hidden entirely when nothing is active, and while the full player is up. + .safeAreaInset(edge: .bottom, spacing: 0) { + if let player = core.view.player, !showPlayer { + MiniPlayerBar( + player: player, + onOpen: { showPlayer = true }, + onTogglePlay: { core.update(.togglePlay) }, + ) + } + } + .fullScreenCover(isPresented: $showPlayer) { + fullPlayer + } .environment(\.themeColors, colors) .tint(colors.accent) .background(colors.background.ignoresSafeArea()) .preferredColorScheme(theme.preferredColorScheme) + .onChange(of: core.view.player?.episodeId) { _, episodeId in + // The episode finished (or was cleared): nothing left to show. + if episodeId == nil { + showPlayer = false + } + } + .onChange(of: scenePhase) { _, phase in + if phase == .background { + core.update(.appBackgrounded) + } + } + } + + @ViewBuilder private var fullPlayer: some View { + if let player = core.view.player { + PlayerScreen( + player: player, + send: { core.update($0) }, + onHide: { showPlayer = false }, + onGoToSource: { goToSource(of: player) }, + ) + // A cover doesn't reliably inherit the theme applied below it. + .environment(\.themeColors, colors) + .tint(colors.accent) + .preferredColorScheme(theme.preferredColorScheme) + } + } + + /// Dismisses the player and shows the podcast the active episode belongs to. + private func goToSource(of player: PlayerView) { + guard let subscription = core.view.library.subscriptions + .first(where: { $0.id == player.subscriptionId }) + else { return } + showPlayer = false + path = NavigationPath() + path.append(subscription) } } diff --git a/iOS/Pollux/SubscriptionDetailScreen.swift b/iOS/Pollux/SubscriptionDetailScreen.swift index e8a7dd2..c182564 100644 --- a/iOS/Pollux/SubscriptionDetailScreen.swift +++ b/iOS/Pollux/SubscriptionDetailScreen.swift @@ -80,6 +80,7 @@ struct SubscriptionDetailScreen: View { NavigationLink(value: episode) { EpisodeRow( episode: episode, + onPlay: { core.update(.playEpisode($0)) }, onDownload: { core.update(.downloadEpisode($0)) }, onDeleteDownload: { core.update(.deleteDownload($0)) }, onCancelDownload: { core.update(.cancelDownload($0)) }, diff --git a/iOS/Pollux/core.swift b/iOS/Pollux/core.swift index 9c43e6c..6c574d3 100644 --- a/iOS/Pollux/core.swift +++ b/iOS/Pollux/core.swift @@ -9,6 +9,8 @@ class Core: ObservableObject { private var core: CoreFfi private let db: DatabaseManager private let downloads: DownloadManager + private var playback: PlaybackManager? + private var nowPlaying: NowPlayingController? /// Storage operations are funnelled here and executed one at a time, in order. private let storage: AsyncStream<(StorageOperation, UInt32)>.Continuation @@ -35,6 +37,14 @@ class Core: ObservableObject { fatalError("Failed to deserialize initial ViewModel from core") } self.view = view + if let root = DownloadManager.defaultStorageRoot() { + playback = PlaybackManager(storageRoot: root) { [weak self] event in + self?.update(event) + } + } + nowPlaying = NowPlayingController { [weak self] event in + self?.update(event) + } Task { @MainActor [weak self] in for await (operation, requestId) in storageStream { guard let self else { return } @@ -73,6 +83,7 @@ class Core: ObservableObject { fatalError("Failed to deserialize ViewModel during render") } view = updatedView + nowPlaying?.update(updatedView.player) case let .storage(operation): // Enqueue for the serial consumer set up in init (preserves write order). @@ -89,6 +100,15 @@ class Core: ObservableObject { case let .download(operation): handleDownload(operation, requestId: request.id) + + case let .player(operation): + // The engine acts synchronously; resolve on the next turn so the result + // doesn't re-enter the core while it is still handing us this batch. + let result = playback?.perform(operation) ?? .error("Audio engine unavailable") + let requestId = request.id + Task { @MainActor [weak self] in + self?.resolveAndDispatch(requestId: requestId, result: result) + } } } @@ -174,6 +194,13 @@ class Core: ObservableObject { resolveBytes(requestId: requestId, bytes: bytes) } + private func resolveAndDispatch(requestId: UInt32, result: PlayerResult) { + guard let bytes = try? result.bincodeSerialize() else { + fatalError("Failed to serialize PlayerResult for request \(requestId)") + } + resolveBytes(requestId: requestId, bytes: bytes) + } + private func resolveAndDispatch(requestId: UInt32, result: DownloadResult) { guard let bytes = try? result.bincodeSerialize() else { fatalError("Failed to serialize DownloadResult for request \(requestId)") diff --git a/iOS/PolluxTests/PlayContextTests.swift b/iOS/PolluxTests/PlayContextTests.swift new file mode 100644 index 0000000..81d46d6 --- /dev/null +++ b/iOS/PolluxTests/PlayContextTests.swift @@ -0,0 +1,88 @@ +import App +import Foundation +import Testing + +@testable import Pollux + +private func makeManager() throws -> DatabaseManager { + let path = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".sqlite").path + return try DatabaseManager(path: path) +} + +private func seed(_ db: DatabaseManager, episodeId: String) async throws { + let sub = Subscription( + id: "sub-1", feedUrl: "https://example.com/sub-1.rss", title: "Feed", + artworkUrl: nil, description: nil, lastRefreshed: nil, createdAt: 1) + try await db.execute(.upsertSubscription(sub)) + let episode = Episode( + id: episodeId, feedGuid: "guid-\(episodeId)", subscriptionId: "sub-1", + title: "Episode", description: nil, pubDate: 1, durationSecs: 600, + enclosureUrl: "https://example.com/\(episodeId).mp3", artworkUrl: nil, + playbackStatus: .inProgress, playbackPositionSecs: 120, + downloadStatus: .notDownloaded, isFlagged: false, fileSizeBytes: nil, localPath: nil) + try await db.execute(.upsertEpisode(episode)) +} + +@Suite struct PlayContextTests { + @Test func nothingSavedIsNotFound() async throws { + let db = try makeManager() + let result = try await db.execute(.loadPlayContext) + guard case .notFound = result else { + Issue.record("Expected .notFound, got \(result)") + return + } + } + + @Test func savedContextLoadsTheEpisodeWithItsPosition() async throws { + let db = try makeManager() + try await seed(db, episodeId: "ep-1") + try await db.execute(.savePlayContext(episodeId: "ep-1")) + + let result = try await db.execute(.loadPlayContext) + guard case let .episode(episode) = result else { + Issue.record("Expected .episode, got \(result)") + return + } + #expect(episode.id == "ep-1") + #expect(episode.playbackPositionSecs == 120) + } + + @Test func savingReplacesThePreviousContext() async throws { + let db = try makeManager() + try await seed(db, episodeId: "ep-1") + try await seed(db, episodeId: "ep-2") + try await db.execute(.savePlayContext(episodeId: "ep-1")) + try await db.execute(.savePlayContext(episodeId: "ep-2")) + + guard case let .episode(episode) = try await db.execute(.loadPlayContext) else { + Issue.record("Expected an episode") + return + } + #expect(episode.id == "ep-2") + } + + @Test func clearingRemovesIt() async throws { + let db = try makeManager() + try await seed(db, episodeId: "ep-1") + try await db.execute(.savePlayContext(episodeId: "ep-1")) + try await db.execute(.clearPlayContext) + + guard case .notFound = try await db.execute(.loadPlayContext) else { + Issue.record("Expected .notFound after clearing") + return + } + } + + @Test func deletingTheSubscriptionDropsTheContext() async throws { + let db = try makeManager() + try await seed(db, episodeId: "ep-1") + try await db.execute(.savePlayContext(episodeId: "ep-1")) + try await db.execute(.deleteSubscription(id: "sub-1")) + + guard case .notFound = try await db.execute(.loadPlayContext) else { + Issue.record("Expected .notFound after the episode was deleted") + return + } + } +} diff --git a/iOS/PolluxTests/PlayerFormattingTests.swift b/iOS/PolluxTests/PlayerFormattingTests.swift new file mode 100644 index 0000000..0007a1c --- /dev/null +++ b/iOS/PolluxTests/PlayerFormattingTests.swift @@ -0,0 +1,37 @@ +import Testing + +@testable import Pollux + +@Suite struct PlayerFormattingTests { + @Test func clockUnderAnHourIsMinutesAndSeconds() { + #expect(PlayerFormatting.clock(0) == "0:00") + #expect(PlayerFormatting.clock(9) == "0:09") + #expect(PlayerFormatting.clock(123) == "2:03") + } + + @Test func clockFromAnHourIncludesHours() { + #expect(PlayerFormatting.clock(3723) == "1:02:03") + #expect(PlayerFormatting.clock(36000) == "10:00:00") + } + + @Test func remainingCountsDownAndNeverGoesNegative() { + #expect(PlayerFormatting.remaining(position: 60, duration: 3600) == "-59:00") + #expect(PlayerFormatting.remaining(position: 4000, duration: 3600) == "-0:00") + } + + @Test func skipLabelsCarryTheirSign() { + #expect(PlayerFormatting.skipLabel(30, forward: true) == "+30") + #expect(PlayerFormatting.skipLabel(15, forward: false) == "-15") + } + + @Test func fractionIsClampedAndZeroWithoutADuration() { + #expect(PlayerFormatting.fraction(position: 50, duration: 100) == 0.5) + #expect(PlayerFormatting.fraction(position: 150, duration: 100) == 1) + #expect(PlayerFormatting.fraction(position: 50, duration: nil) == 0) + #expect(PlayerFormatting.fraction(position: 50, duration: 0) == 0) + } + + @Test func spokenTimeIsWords() { + #expect(PlayerFormatting.spoken(3723).contains("hour")) + } +} diff --git a/iOS/project.yml b/iOS/project.yml index cc3ca7a..87df1cc 100644 --- a/iOS/project.yml +++ b/iOS/project.yml @@ -22,6 +22,8 @@ targets: path: Pollux/Info.plist properties: UILaunchScreen: {} + # Keep playing with the screen locked or the app in the background. + UIBackgroundModes: [audio] dependencies: - package: Shared - package: App diff --git a/shared/src/app.rs b/shared/src/app.rs index 733aae2..b5bf8c0 100644 --- a/shared/src/app.rs +++ b/shared/src/app.rs @@ -4,12 +4,14 @@ use serde::{Deserialize, Serialize}; use crate::capabilities::download::{DownloadOperation, DownloadResult}; use crate::capabilities::http::{HttpOperation, HttpResult}; +use crate::capabilities::player::PlayerResult; use crate::capabilities::storage::{StorageOperation, StorageResult}; use crate::domain::{DownloadStatus, Episode, EpisodeSortOrder}; use crate::effect::Effect; use crate::feed_parser::parse_feed; use crate::html::strip_html_preview; use crate::model::{DownloadProgress, Model, QueuedDownload}; +use crate::player; use crate::theme::{theme_view, ThemeId, ThemeMode}; use crate::view_model::{ DownloadNotice, EpisodeSummary, LibraryView, SubscriptionDetailView, SubscriptionSummary, @@ -36,7 +38,11 @@ impl App for Pollux { // mid-download, and resume it. let pending = Command::request_from_shell(StorageOperation::ListPendingDownloads) .then_send(|r| Event::PendingDownloadsLoaded(Box::new(r))); - subscriptions.and(pending).and(render()) + // Restore the episode that was active when the app last closed, so the + // mini-player is up immediately (paused). + let context = Command::request_from_shell(StorageOperation::LoadPlayContext) + .then_send(|r| Event::PlayContextLoaded(Box::new(r))); + subscriptions.and(pending).and(context).and(render()) } Event::SubscriptionsLoaded(result) => { model.loading = false; @@ -226,50 +232,7 @@ impl App for Pollux { // downloaded, removed from feed, or an episode we don't have loaded — // is a no-op, so a stray or repeated event can't double-enqueue or // re-download a file we already have. - let found = model - .episodes - .iter() - .find(|e| e.id == episode_id) - .map(|e| (e.download_status.clone(), e.enclosure_url.clone())); - match found { - Some((DownloadStatus::NotDownloaded, url)) - | Some((DownloadStatus::Failed, url)) => { - if !download_allowed(model) { - // Fail-on-full: unlimited today, so this is unreachable. - // When a storage cap exists this is where a download is - // refused up front and marked Failed instead of started. - set_download_state( - model, - &episode_id, - DownloadStatus::Failed, - None, - None, - ); - persist_download_state(&episode_id, DownloadStatus::Failed, None, None) - .and(render()) - } else { - set_download_state( - model, - &episode_id, - DownloadStatus::Queued, - None, - None, - ); - model.download_queue.push(QueuedDownload { - episode_id: episode_id.clone(), - url, - }); - let persist = persist_download_state( - &episode_id, - DownloadStatus::Queued, - None, - None, - ); - persist.and(maybe_start_next(model)).and(render()) - } - } - _ => render(), - } + enqueue_download(model, &episode_id).and(render()) } Event::DownloadProgress { episode_id, @@ -302,13 +265,16 @@ impl App for Pollux { Some(local_path.clone()), Some(size_bytes), ); + // Playing this episode off the network? Move it onto the file. + let swap = + player::on_download_completed(model, &episode_id, &local_path, size_bytes); let persist = persist_download_state( &episode_id, DownloadStatus::Downloaded, Some(local_path), Some(size_bytes), ); - persist.and(maybe_start_next(model)).and(render()) + persist.and(swap).and(maybe_start_next(model)).and(render()) } DownloadResult::Error(reason) => { finish_active(model, &episode_id); @@ -436,6 +402,34 @@ impl App for Pollux { model.theme_mode = mode; render() } + Event::PlayEpisode(id) => player::play_episode(model, &id), + Event::Play => player::play(model), + Event::Pause => player::pause(model), + Event::TogglePlay => player::toggle(model), + Event::SkipForward => player::skip_forward(model), + Event::SkipBack => player::skip_back(model), + Event::SeekTo(secs) => player::seek_to(model, secs), + Event::AppBackgrounded => player::on_backgrounded(model), + Event::PlayerTick { + episode_id, + position_secs, + } => player::on_tick(model, &episode_id, position_secs), + Event::PlayerDuration { + episode_id, + duration_secs, + } => player::on_duration(model, &episode_id, duration_secs), + Event::PlayerEnded(episode_id) => player::on_ended(model, &episode_id), + Event::PlayerFailed { + episode_id, + message, + } => player::on_failure(model, &episode_id, message), + Event::PlayerResponded { episode_id, result } => { + player::on_response(model, &episode_id, *result) + } + Event::PlayContextLoaded(result) => player::on_context_loaded(model, *result), + // Required resolution sink for playback persistence writes. These are + // best-effort (the next checkpoint retries), so there is nothing to do. + Event::PlaybackPersisted(_) => Command::done(), } } @@ -460,6 +454,7 @@ impl App for Pollux { error: model.error.clone(), }, subscription_detail: build_subscription_detail(model), + player: player::player_view(model), theme: theme_view(model.theme_id, model.theme_mode), } } @@ -558,6 +553,40 @@ fn sort_episodes(episodes: &mut [EpisodeSummary], order: EpisodeSortOrder) { } } +/// Queues an episode's download (and starts it if nothing is in flight). Only a +/// not-downloaded or failed (retry) episode can start a download. Every other state — +/// already queued/downloading, already downloaded, removed from feed, or an episode we +/// don't have loaded — is a no-op, so a stray or repeated request can't double-enqueue +/// or re-download a file we already have. Shared by the explicit download action and +/// by playback, which downloads what it streams. +pub(crate) fn enqueue_download(model: &mut Model, episode_id: &str) -> Command { + let found = model + .episodes + .iter() + .find(|e| e.id == episode_id) + .map(|e| (e.download_status.clone(), e.enclosure_url.clone())); + match found { + Some((DownloadStatus::NotDownloaded, url)) | Some((DownloadStatus::Failed, url)) => { + if !download_allowed(model) { + // Fail-on-full: unlimited today, so this is unreachable. + // When a storage cap exists this is where a download is + // refused up front and marked Failed instead of started. + set_download_state(model, episode_id, DownloadStatus::Failed, None, None); + persist_download_state(episode_id, DownloadStatus::Failed, None, None) + } else { + set_download_state(model, episode_id, DownloadStatus::Queued, None, None); + model.download_queue.push(QueuedDownload { + episode_id: episode_id.to_string(), + url, + }); + persist_download_state(episode_id, DownloadStatus::Queued, None, None) + .and(maybe_start_next(model)) + } + } + _ => Command::done(), + } +} + /// Pre-download storage check. Unlimited today — the user-configurable soft cap /// arrives with the Settings screen (see ROADMAP follow-ups). Device-full is not /// predicted here; it surfaces as the shell's write failing (`DownloadResult::Error` @@ -620,7 +649,10 @@ fn persist_download_state( /// Resets an episode to not-downloaded (in the model) and returns the command that /// persists it. The single shape used everywhere a download ends without a file — /// cancelling an active or queued download, and deleting a stored one. -fn reset_to_not_downloaded(model: &mut Model, episode_id: &str) -> Command { +pub(crate) fn reset_to_not_downloaded( + model: &mut Model, + episode_id: &str, +) -> Command { set_download_state(model, episode_id, DownloadStatus::NotDownloaded, None, None); persist_download_state(episode_id, DownloadStatus::NotDownloaded, None, None) } @@ -724,6 +756,45 @@ pub enum Event { id: ThemeId, mode: ThemeMode, }, + /// Start playing an episode from the feed on screen (or resume it if active). + PlayEpisode(String), + Play, + Pause, + TogglePlay, + /// Skip by the configured increments (30s forward / 15s back for now). + SkipForward, + SkipBack, + /// Move the playhead to an absolute position (scrubber, lock-screen scrub). + SeekTo(u32), + /// The app left the foreground: flush the playback position. + AppBackgrounded, + /// Engine position report (~1/s while playing). Transient; the core persists on + /// its own checkpoints, not per tick. + PlayerTick { + episode_id: String, + position_secs: u32, + }, + /// The engine learned the real duration, which beats the feed's possibly-missing one. + PlayerDuration { + episode_id: String, + duration_secs: u32, + }, + /// The engine reached the end of the file. + PlayerEnded(String), + /// The engine failed to play or lost the item. + PlayerFailed { + episode_id: String, + message: String, + }, + /// Resolution of a player operation; an error is treated like `PlayerFailed`. + PlayerResponded { + episode_id: String, + result: Box, + }, + /// The episode saved as active by the previous session, loaded at launch. + PlayContextLoaded(Box), + /// Resolution sink for best-effort playback writes (position, play context). + PlaybackPersisted(Box), } #[cfg(test)] @@ -827,8 +898,16 @@ mod tests { assert!(model.loading); let effects: Vec = cmd.effects().collect(); - // ListSubscriptions + ListPendingDownloads + render. - assert_eq!(effects.len(), 3); + // ListSubscriptions + ListPendingDownloads + LoadPlayContext + render. + assert_eq!(effects.len(), 4); + + let has_context = effects + .iter() + .any(|e| matches!(e, Effect::Storage(r) if matches!(r.operation, StorageOperation::LoadPlayContext))); + assert!( + has_context, + "expected a LoadPlayContext storage effect to restore the mini-player" + ); let has_storage = effects .iter() diff --git a/shared/src/capabilities/mod.rs b/shared/src/capabilities/mod.rs index 497baa4..325d69c 100644 --- a/shared/src/capabilities/mod.rs +++ b/shared/src/capabilities/mod.rs @@ -1,3 +1,4 @@ pub mod download; pub mod http; +pub mod player; pub mod storage; diff --git a/shared/src/capabilities/player.rs b/shared/src/capabilities/player.rs new file mode 100644 index 0000000..1d774c7 --- /dev/null +++ b/shared/src/capabilities/player.rs @@ -0,0 +1,51 @@ +use crux_core::capability::Operation; +use facet::Facet; +use serde::{Deserialize, Serialize}; + +/// Where the shell should read an episode's audio from. +#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[repr(C)] +pub enum PlayerSource { + /// Stream straight from the enclosure URL. + Stream { url: String }, + /// A downloaded file; `local_path` is relative to the app's storage root and is + /// resolved to an absolute URL by the shell (the same convention as downloads). + Local { local_path: String }, +} + +/// Commands for the shell's audio engine. The engine (AVPlayer, audio session, lock +/// screen) lives in the shell; the core owns every policy decision — when to resume, +/// how far to skip, when an episode counts as played — and only tells the engine +/// what to do. Engine-initiated news (position ticks, duration, end of file, remote +/// commands, interruptions) comes back as ordinary `Event`s, not as results here. +#[derive(Facet, Serialize, Deserialize, Clone, Debug)] +#[repr(C)] +pub enum PlayerOperation { + /// Replace the current item with `source`, positioned at `start_secs`. Starts + /// playing immediately when `autoplay` is set. Also used to swap a streaming + /// episode onto its freshly downloaded local copy mid-play. + Load { + episode_id: String, + source: PlayerSource, + start_secs: u32, + autoplay: bool, + }, + Play, + Pause, + Seek { + secs: u32, + }, + /// Unload the current item and release the audio session. + Stop, +} + +#[derive(Facet, Serialize, Deserialize, Clone, Debug)] +#[repr(C)] +pub enum PlayerResult { + Ok, + Error(String), +} + +impl Operation for PlayerOperation { + type Output = PlayerResult; +} diff --git a/shared/src/capabilities/storage.rs b/shared/src/capabilities/storage.rs index 860a1d5..9343cee 100644 --- a/shared/src/capabilities/storage.rs +++ b/shared/src/capabilities/storage.rs @@ -45,6 +45,15 @@ pub enum StorageOperation { local_path: Option, size_bytes: Option, }, + /// Records which episode is the active one so the mini-player can be restored on + /// cold start. Position lives on the episode row (`UpdatePlaybackStatus`), so only + /// the identity is stored here. Single-row: saving replaces the previous value. + SavePlayContext { + episode_id: String, + }, + /// The episode last saved by `SavePlayContext` (as `Episode`), or `NotFound`. + LoadPlayContext, + ClearPlayContext, UpsertFeedWithEpisodes { subscription: Subscription, episodes: Vec, diff --git a/shared/src/defaults.rs b/shared/src/defaults.rs index 9af52e8..4edf91f 100644 --- a/shared/src/defaults.rs +++ b/shared/src/defaults.rs @@ -2,3 +2,10 @@ pub const SKIP_FORWARD_SECS: u32 = 30; pub const SKIP_BACKWARD_SECS: u32 = 15; pub const RESUME_REWIND_SECS: u32 = 3; pub const REFRESH_INTERVAL_HOURS: u32 = 12; +/// An episode counts as played once playback is within this many seconds of the end +/// (outros and credits shouldn't leave it in-progress forever). Will become a user +/// setting; see docs/user_stories/player/playback.md. +pub const PLAYED_TOLERANCE_SECS: u32 = 15; +/// While playing, position is persisted at least this often (in addition to pause, +/// seek, backgrounding and end), bounding what a crash can lose. +pub const POSITION_CHECKPOINT_SECS: u32 = 10; diff --git a/shared/src/effect.rs b/shared/src/effect.rs index 242c756..7cab073 100644 --- a/shared/src/effect.rs +++ b/shared/src/effect.rs @@ -5,6 +5,7 @@ use crux_core::{macros::effect, render::RenderOperation}; use crate::capabilities::download::DownloadOperation; use crate::capabilities::http::HttpOperation; +use crate::capabilities::player::PlayerOperation; use crate::capabilities::storage::StorageOperation; #[effect(facet_typegen)] @@ -14,4 +15,5 @@ pub enum Effect { Storage(StorageOperation), Http(HttpOperation), Download(DownloadOperation), + Player(PlayerOperation), } diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 4fa68d4..befdefc 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -7,6 +7,7 @@ mod feed_parser; pub mod ffi; mod html; pub mod model; +mod player; pub mod theme; pub mod view_model; diff --git a/shared/src/model.rs b/shared/src/model.rs index 59aa4a8..f32dae8 100644 --- a/shared/src/model.rs +++ b/shared/src/model.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use crate::capabilities::player::PlayerSource; use crate::domain::{Episode, EpisodeSortOrder, Subscription}; use crate::theme::{ThemeId, ThemeMode}; use crate::view_model::DownloadNotice; @@ -48,6 +49,10 @@ pub struct Model { // starts another download action or switches feeds. pub download_notice: Option, + // The episode the player is on (playing or paused), if any. Survives restarts via + // the stored play context; see `player.rs`. + pub active_playback: Option, + // Active theme selection. Defaults (System / FollowSystem) reproduce the OS's // native appearance. Hard-coded for now — no UI changes it until the Settings // appearance section lands and drives `Event::SetTheme`. @@ -55,6 +60,25 @@ pub struct Model { pub theme_mode: ThemeMode, } +/// The player's current episode and transport state. Holds its own `Episode` copy so +/// playback doesn't depend on `model.episodes` (which only holds the feed on screen, +/// and is empty after a cold-start restore). +#[derive(Clone, Debug)] +pub struct ActivePlayback { + pub episode: Episode, + pub position_secs: u32, + /// The engine's duration, authoritative over the feed's `episode.duration_secs`. + pub duration_secs: Option, + pub is_playing: bool, + pub source: PlayerSource, + /// Whether the shell's engine currently has this episode loaded. False right after + /// a cold-start restore (and after an error), so the next Play issues a `Load`. + pub loaded: bool, + /// Position at the last persisted checkpoint, to space periodic writes. + pub last_checkpoint_secs: u32, + pub error: Option, +} + /// An episode waiting to be downloaded. Carries the enclosure URL so the download /// can be issued from the queue alone, without the episode being loaded in /// `model.episodes`. diff --git a/shared/src/player.rs b/shared/src/player.rs new file mode 100644 index 0000000..733e4b4 --- /dev/null +++ b/shared/src/player.rs @@ -0,0 +1,458 @@ +//! Player policy. The shell owns the audio engine (see `capabilities::player`); this +//! module decides what it should do: which source to load, where to start, how far a +//! skip goes, when position is persisted, and when an episode counts as played. +//! +//! Engine-originated news arrives as ordinary events (`PlayerTick`, `PlayerEnded`, …) +//! rather than request results, mirroring how download progress is reported. + +use crux_core::{render::render, Command}; + +use crate::app::{enqueue_download, reset_to_not_downloaded, Event}; +use crate::capabilities::player::{PlayerOperation, PlayerResult, PlayerSource}; +use crate::capabilities::storage::{StorageOperation, StorageResult}; +use crate::defaults::{ + PLAYED_TOLERANCE_SECS, POSITION_CHECKPOINT_SECS, RESUME_REWIND_SECS, SKIP_BACKWARD_SECS, + SKIP_FORWARD_SECS, +}; +use crate::domain::{DownloadStatus, Episode, PlaybackStatus}; +use crate::effect::Effect; +use crate::model::{ActivePlayback, Model}; +use crate::view_model::PlayerView; + +#[cfg(test)] +mod tests; + +type Cmd = Command; + +/// Where an episode should play from: its downloaded file when we have one, else the +/// network. +fn source_for(episode: &Episode) -> PlayerSource { + match (&episode.download_status, &episode.local_path) { + (DownloadStatus::Downloaded, Some(path)) => PlayerSource::Local { + local_path: path.clone(), + }, + _ => PlayerSource::Stream { + url: episode.enclosure_url.clone(), + }, + } +} + +fn player_op(episode_id: &str, op: PlayerOperation) -> Cmd { + let id = episode_id.to_string(); + Command::request_from_shell(op).then_send(move |r| Event::PlayerResponded { + episode_id: id.clone(), + result: Box::new(r), + }) +} + +/// Persists an episode's playback state. Best-effort: a failed write only costs +/// durability (the next checkpoint retries), so the result is dropped. +fn persist_playback(episode_id: &str, status: PlaybackStatus, position_secs: Option) -> Cmd { + Command::request_from_shell(StorageOperation::UpdatePlaybackStatus { + episode_id: episode_id.to_string(), + status, + position_secs, + }) + .then_send(|r| Event::PlaybackPersisted(Box::new(r))) +} + +/// Mirrors a playback-state change onto the feed on screen, if that episode is loaded, +/// so its row updates without a reload (see the note at `SelectSubscription`). +fn sync_episode_row( + model: &mut Model, + episode_id: &str, + status: &PlaybackStatus, + position_secs: Option, +) { + if let Some(row) = model.episodes.iter_mut().find(|e| e.id == episode_id) { + row.playback_status = status.clone(); + row.playback_position_secs = position_secs; + } +} + +/// The status and stored position for the current playhead: within the played +/// tolerance of the end means played (position reset), otherwise in-progress. +fn checkpoint_state(active: &ActivePlayback) -> (PlaybackStatus, Option) { + let duration = active.duration_secs.or(active.episode.duration_secs); + match duration { + Some(d) + if active.position_secs > 0 + && active.position_secs.saturating_add(PLAYED_TOLERANCE_SECS) >= d => + { + (PlaybackStatus::Played, Some(0)) + } + _ => (PlaybackStatus::InProgress, Some(active.position_secs)), + } +} + +/// Persists the current position/status. Called on pause, seek, backgrounding and +/// every `POSITION_CHECKPOINT_SECS` of playback. +fn checkpoint(model: &mut Model) -> Cmd { + let Some(active) = model.active_playback.as_mut() else { + return Command::done(); + }; + let (status, position) = checkpoint_state(active); + active.last_checkpoint_secs = active.position_secs; + active.episode.playback_status = status.clone(); + active.episode.playback_position_secs = position; + let id = active.episode.id.clone(); + sync_episode_row(model, &id, &status, position); + persist_playback(&id, status, position) +} + +/// Starts playing `episode_id` from the feed on screen. Resumes in place if it is +/// already the active episode. +pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { + if model + .active_playback + .as_ref() + .is_some_and(|a| a.episode.id == episode_id) + { + return play(model); + } + let Some(episode) = model.episodes.iter().find(|e| e.id == episode_id).cloned() else { + return render(); + }; + + // Whatever was playing keeps its place before it's replaced. + let outgoing = checkpoint(model); + + let source = source_for(&episode); + // A played episode restarts from the top; otherwise resume with the usual rewind. + let saved = match episode.playback_status { + PlaybackStatus::Played => 0, + _ => episode.playback_position_secs.unwrap_or(0), + }; + let start = saved.saturating_sub(RESUME_REWIND_SECS); + + let mut playing_episode = episode.clone(); + playing_episode.playback_status = PlaybackStatus::InProgress; + playing_episode.playback_position_secs = Some(start); + sync_episode_row(model, episode_id, &PlaybackStatus::InProgress, Some(start)); + let streaming = matches!(source, PlayerSource::Stream { .. }); + model.active_playback = Some(ActivePlayback { + episode: playing_episode, + position_secs: start, + duration_secs: None, + is_playing: true, + source: source.clone(), + loaded: true, + last_checkpoint_secs: start, + error: None, + }); + + let mut cmd = outgoing + .and(persist_playback( + episode_id, + PlaybackStatus::InProgress, + Some(start), + )) + .and(save_play_context(episode_id)) + .and(player_op( + episode_id, + PlayerOperation::Load { + episode_id: episode_id.to_string(), + source, + start_secs: start, + autoplay: true, + }, + )); + // Streaming now, local later: kick off the download so the copy is there next + // time (and for the mid-play swap in `on_download_completed`). + if streaming { + cmd = cmd.and(enqueue_download(model, episode_id)); + } + cmd.and(render()) +} + +fn save_play_context(episode_id: &str) -> Cmd { + Command::request_from_shell(StorageOperation::SavePlayContext { + episode_id: episode_id.to_string(), + }) + .then_send(|r| Event::PlaybackPersisted(Box::new(r))) +} + +/// Resumes the active episode, rewinding slightly for context. +pub(crate) fn play(model: &mut Model) -> Cmd { + let Some(active) = model.active_playback.as_mut() else { + return Command::done(); + }; + if active.is_playing { + return Command::done(); + } + active.error = None; + active.is_playing = true; + let rewound = active.position_secs.saturating_sub(RESUME_REWIND_SECS); + active.position_secs = rewound; + let id = active.episode.id.clone(); + let cmd = if active.loaded { + player_op(&id, PlayerOperation::Seek { secs: rewound }) + .and(player_op(&id, PlayerOperation::Play)) + } else { + // Cold-start restore (or recovery after an error): nothing is loaded yet. + active.loaded = true; + player_op( + &id, + PlayerOperation::Load { + episode_id: id.clone(), + source: active.source.clone(), + start_secs: rewound, + autoplay: true, + }, + ) + }; + cmd.and(render()) +} + +pub(crate) fn pause(model: &mut Model) -> Cmd { + let Some(active) = model.active_playback.as_mut() else { + return Command::done(); + }; + if !active.is_playing { + return Command::done(); + } + active.is_playing = false; + let id = active.episode.id.clone(); + player_op(&id, PlayerOperation::Pause) + .and(checkpoint(model)) + .and(render()) +} + +pub(crate) fn toggle(model: &mut Model) -> Cmd { + match model.active_playback.as_ref() { + Some(a) if a.is_playing => pause(model), + Some(_) => play(model), + None => Command::done(), + } +} + +/// Moves the playhead, clamped to the episode: a skip never runs past the end (so it +/// can't spill into another episode) or before the start. +pub(crate) fn seek_to(model: &mut Model, secs: u32) -> Cmd { + let Some(active) = model.active_playback.as_mut() else { + return Command::done(); + }; + let duration = active.duration_secs.or(active.episode.duration_secs); + let target = duration.map_or(secs, |d| secs.min(d)); + active.position_secs = target; + let id = active.episode.id.clone(); + let op = if active.loaded { + PlayerOperation::Seek { secs: target } + } else { + // Seeking a restored-but-unloaded episode loads it paused at the new spot. + active.loaded = true; + PlayerOperation::Load { + episode_id: id.clone(), + source: active.source.clone(), + start_secs: target, + autoplay: false, + } + }; + player_op(&id, op).and(checkpoint(model)).and(render()) +} + +pub(crate) fn skip_forward(model: &mut Model) -> Cmd { + let Some(position) = model.active_playback.as_ref().map(|a| a.position_secs) else { + return Command::done(); + }; + seek_to(model, position.saturating_add(SKIP_FORWARD_SECS)) +} + +pub(crate) fn skip_back(model: &mut Model) -> Cmd { + let Some(position) = model.active_playback.as_ref().map(|a| a.position_secs) else { + return Command::done(); + }; + seek_to(model, position.saturating_sub(SKIP_BACKWARD_SECS)) +} + +pub(crate) fn on_tick(model: &mut Model, episode_id: &str, position_secs: u32) -> Cmd { + let Some(active) = model.active_playback.as_mut() else { + return Command::done(); + }; + if active.episode.id != episode_id { + return Command::done(); + } + active.position_secs = position_secs; + let due = active.is_playing + && position_secs.abs_diff(active.last_checkpoint_secs) >= POSITION_CHECKPOINT_SECS; + if due { + checkpoint(model).and(render()) + } else { + render() + } +} + +pub(crate) fn on_duration(model: &mut Model, episode_id: &str, duration_secs: u32) -> Cmd { + match model.active_playback.as_mut() { + Some(active) if active.episode.id == episode_id => { + active.duration_secs = Some(duration_secs); + render() + } + _ => Command::done(), + } +} + +/// The engine reached the end of the file: the episode is played and the player +/// goes inactive (no auto-advance until playlists exist). +pub(crate) fn on_ended(model: &mut Model, episode_id: &str) -> Cmd { + if model + .active_playback + .as_ref() + .is_none_or(|a| a.episode.id != episode_id) + { + return Command::done(); + } + model.active_playback = None; + sync_episode_row(model, episode_id, &PlaybackStatus::Played, Some(0)); + persist_playback(episode_id, PlaybackStatus::Played, Some(0)) + .and( + Command::request_from_shell(StorageOperation::ClearPlayContext) + .then_send(|r| Event::PlaybackPersisted(Box::new(r))), + ) + .and(player_op(episode_id, PlayerOperation::Stop)) + .and(render()) +} + +/// The engine couldn't play (or lost) the item. A missing local file falls back to +/// streaming and re-downloads; anything else leaves playback paused with a notice. +pub(crate) fn on_failure(model: &mut Model, episode_id: &str, message: String) -> Cmd { + let Some(active) = model.active_playback.as_mut() else { + return Command::done(); + }; + if active.episode.id != episode_id { + return Command::done(); + } + let was_playing = active.is_playing; + let position = active.position_secs; + let id = active.episode.id.clone(); + if !matches!(active.source, PlayerSource::Local { .. }) { + active.is_playing = false; + active.loaded = false; + active.error = Some(message); + return checkpoint(model).and(render()); + } + // The downloaded file is unreadable (deleted behind our back, say): forget it and + // stream instead, carrying on from the same spot. + let source = PlayerSource::Stream { + url: active.episode.enclosure_url.clone(), + }; + active.source = source.clone(); + active.episode.download_status = DownloadStatus::NotDownloaded; + active.episode.local_path = None; + active.error = None; + active.loaded = true; + reset_to_not_downloaded(model, &id) + .and(enqueue_download(model, &id)) + .and(player_op( + &id, + PlayerOperation::Load { + episode_id: id.clone(), + source, + start_secs: position, + autoplay: was_playing, + }, + )) + .and(render()) +} + +pub(crate) fn on_response(model: &mut Model, episode_id: &str, result: PlayerResult) -> Cmd { + match result { + PlayerResult::Ok => Command::done(), + PlayerResult::Error(message) => on_failure(model, episode_id, message), + } +} + +/// A download finished: if it's the episode on the player, move playback onto the +/// local file at the current position, so streaming stops using the network. +pub(crate) fn on_download_completed( + model: &mut Model, + episode_id: &str, + local_path: &str, + size_bytes: u64, +) -> Cmd { + let Some(active) = model.active_playback.as_mut() else { + return Command::done(); + }; + if active.episode.id != episode_id { + return Command::done(); + } + active.episode.download_status = DownloadStatus::Downloaded; + active.episode.local_path = Some(local_path.to_string()); + active.episode.file_size_bytes = Some(size_bytes); + if !matches!(active.source, PlayerSource::Stream { .. }) { + return Command::done(); + } + let source = PlayerSource::Local { + local_path: local_path.to_string(), + }; + active.source = source.clone(); + if !active.loaded { + return render(); + } + player_op( + episode_id, + PlayerOperation::Load { + episode_id: episode_id.to_string(), + source, + start_secs: active.position_secs, + autoplay: active.is_playing, + }, + ) +} + +/// Restores the active episode at launch, paused, so the mini-player is there +/// immediately. The engine isn't loaded until the first Play. +pub(crate) fn on_context_loaded(model: &mut Model, result: StorageResult) -> Cmd { + if model.active_playback.is_some() { + return Command::done(); + } + let StorageResult::Episode(episode) = result else { + return Command::done(); + }; + let position = match episode.playback_status { + PlaybackStatus::Played => 0, + _ => episode.playback_position_secs.unwrap_or(0), + }; + model.active_playback = Some(ActivePlayback { + source: source_for(&episode), + episode, + position_secs: position, + duration_secs: None, + is_playing: false, + loaded: false, + last_checkpoint_secs: position, + error: None, + }); + render() +} + +/// Flushes position when the app leaves the foreground. +pub(crate) fn on_backgrounded(model: &mut Model) -> Cmd { + checkpoint(model) +} + +pub(crate) fn player_view(model: &Model) -> Option { + let active = model.active_playback.as_ref()?; + let subscription = model + .subscriptions + .iter() + .find(|s| s.id == active.episode.subscription_id); + Some(PlayerView { + episode_id: active.episode.id.clone(), + subscription_id: active.episode.subscription_id.clone(), + episode_title: active.episode.title.clone(), + feed_title: subscription.map(|s| s.title.clone()).unwrap_or_default(), + artwork_url: active + .episode + .artwork_url + .clone() + .or_else(|| subscription.and_then(|s| s.artwork_url.clone())), + position_secs: active.position_secs, + duration_secs: active.duration_secs.or(active.episode.duration_secs), + is_playing: active.is_playing, + is_streaming: matches!(active.source, PlayerSource::Stream { .. }), + skip_forward_secs: SKIP_FORWARD_SECS, + skip_back_secs: SKIP_BACKWARD_SECS, + error: active.error.clone(), + }) +} diff --git a/shared/src/player/tests.rs b/shared/src/player/tests.rs new file mode 100644 index 0000000..1828291 --- /dev/null +++ b/shared/src/player/tests.rs @@ -0,0 +1,424 @@ +use crux_core::App; + +use super::*; +use crate::capabilities::download::DownloadResult; +use crate::domain::Subscription; +use crate::Pollux; + +fn episode(id: &str) -> Episode { + Episode { + id: id.to_string(), + feed_guid: format!("{id}-guid"), + subscription_id: "sub".to_string(), + title: format!("Episode {id}"), + description: None, + pub_date: Some(1), + duration_secs: Some(600), + enclosure_url: format!("https://example.com/{id}.mp3"), + artwork_url: None, + playback_status: PlaybackStatus::Unplayed, + playback_position_secs: None, + download_status: DownloadStatus::NotDownloaded, + is_flagged: false, + file_size_bytes: None, + local_path: None, + } +} + +fn model_with(episodes: Vec) -> Model { + Model { + subscriptions: vec![Subscription { + id: "sub".to_string(), + feed_url: "https://example.com/f.rss".to_string(), + title: "Feed".to_string(), + artwork_url: Some("https://example.com/feed.jpg".to_string()), + description: None, + last_refreshed: None, + created_at: 0, + }], + episodes, + ..Model::default() + } +} + +fn send(model: &mut Model, event: Event) -> Vec { + Pollux.update(event, model).effects().collect() +} + +fn tick(model: &mut Model, id: &str, secs: u32) -> Vec { + send( + model, + Event::PlayerTick { + episode_id: id.into(), + position_secs: secs, + }, + ) +} + +fn player_ops(effects: &[Effect]) -> Vec { + effects + .iter() + .filter_map(|e| match e { + Effect::Player(r) => Some(r.operation.clone()), + _ => None, + }) + .collect() +} + +fn storage_ops(effects: &[Effect]) -> Vec { + effects + .iter() + .filter_map(|e| match e { + Effect::Storage(r) => Some(r.operation.clone()), + _ => None, + }) + .collect() +} + +fn checkpoints(effects: &[Effect]) -> Vec<(PlaybackStatus, Option)> { + storage_ops(effects) + .into_iter() + .filter_map(|op| match op { + StorageOperation::UpdatePlaybackStatus { + status, + position_secs, + .. + } => Some((status, position_secs)), + _ => None, + }) + .collect() +} + +fn active(model: &Model) -> &ActivePlayback { + model.active_playback.as_ref().expect("an active episode") +} + +fn view_player(model: &Model) -> PlayerView { + Pollux.view(model).player.expect("a player view") +} + +#[test] +fn playing_an_undownloaded_episode_streams_and_starts_a_download() { + let mut model = model_with(vec![episode("e1")]); + let effects = send(&mut model, Event::PlayEpisode("e1".into())); + + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Load { + source: PlayerSource::Stream { .. }, + start_secs: 0, + autoplay: true, + .. + }] + )); + assert_eq!(model.downloading.as_deref(), Some("e1")); + assert!(active(&model).is_playing); + assert_eq!( + model.episodes[0].playback_status, + PlaybackStatus::InProgress + ); + assert!(storage_ops(&effects) + .iter() + .any(|op| matches!(op, StorageOperation::SavePlayContext { .. }))); +} + +#[test] +fn playing_a_downloaded_episode_uses_the_file_and_resumes_with_a_rewind() { + let mut e = episode("e1"); + e.download_status = DownloadStatus::Downloaded; + e.local_path = Some("Downloads/abc.mp3".into()); + e.playback_status = PlaybackStatus::InProgress; + e.playback_position_secs = Some(100); + let mut model = model_with(vec![e]); + + let effects = send(&mut model, Event::PlayEpisode("e1".into())); + + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Load { + source: PlayerSource::Local { .. }, + start_secs: 97, + .. + }] + )); + assert!(model.downloading.is_none(), "no download needed"); +} + +#[test] +fn a_played_episode_restarts_from_the_top() { + let mut e = episode("e1"); + e.playback_status = PlaybackStatus::Played; + e.playback_position_secs = Some(0); + let mut model = model_with(vec![e]); + let effects = send(&mut model, Event::PlayEpisode("e1".into())); + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Load { start_secs: 0, .. }] + )); + assert_eq!( + model.episodes[0].playback_status, + PlaybackStatus::InProgress + ); +} + +#[test] +fn pause_then_play_rewinds_and_persists_position() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, "e1", 50); + + let effects = send(&mut model, Event::Pause); + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Pause] + )); + assert_eq!( + checkpoints(&effects), + vec![(PlaybackStatus::InProgress, Some(50))] + ); + + let effects = send(&mut model, Event::Play); + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Seek { secs: 47 }, PlayerOperation::Play] + )); + assert_eq!(active(&model).position_secs, 47); +} + +#[test] +fn rewind_is_clamped_at_zero() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + send(&mut model, Event::Pause); + send(&mut model, Event::Play); + assert_eq!(active(&model).position_secs, 0); +} + +#[test] +fn ticks_persist_only_every_checkpoint_interval() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + assert!(checkpoints(&tick(&mut model, "e1", 3)).is_empty()); + assert!(checkpoints(&tick(&mut model, "e1", 9)).is_empty()); + assert_eq!( + checkpoints(&tick(&mut model, "e1", 10)), + vec![(PlaybackStatus::InProgress, Some(10))] + ); + assert!(checkpoints(&tick(&mut model, "e1", 12)).is_empty()); +} + +#[test] +fn skips_use_the_default_increments_and_clamp_to_the_episode() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + + let effects = send(&mut model, Event::SkipForward); + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Seek { secs: 30 }] + )); + + // Back from 30 → 15, then the next back clamps at 0 rather than wrapping. + send(&mut model, Event::SkipBack); + assert_eq!(active(&model).position_secs, 15); + send(&mut model, Event::SkipBack); + send(&mut model, Event::SkipBack); + assert_eq!(active(&model).position_secs, 0); + + // Forward past the end clamps to the duration (600s) — never beyond it. + send(&mut model, Event::SeekTo(595)); + send(&mut model, Event::SkipForward); + assert_eq!(active(&model).position_secs, 600); +} + +#[test] +fn pausing_within_the_tolerance_of_the_end_marks_played() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, "e1", 590); + let effects = send(&mut model, Event::Pause); + assert_eq!( + checkpoints(&effects), + vec![(PlaybackStatus::Played, Some(0))] + ); + assert_eq!(model.episodes[0].playback_status, PlaybackStatus::Played); +} + +#[test] +fn engine_duration_overrides_the_feed_duration() { + let mut e = episode("e1"); + e.duration_secs = None; + let mut model = model_with(vec![e]); + send(&mut model, Event::PlayEpisode("e1".into())); + send( + &mut model, + Event::PlayerDuration { + episode_id: "e1".into(), + duration_secs: 300, + }, + ); + assert_eq!(view_player(&model).duration_secs, Some(300)); +} + +#[test] +fn reaching_the_end_marks_played_and_clears_the_player() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + let effects = send(&mut model, Event::PlayerEnded("e1".into())); + + assert!(model.active_playback.is_none()); + assert!(Pollux.view(&model).player.is_none()); + assert_eq!(model.episodes[0].playback_status, PlaybackStatus::Played); + assert!(storage_ops(&effects) + .iter() + .any(|op| matches!(op, StorageOperation::ClearPlayContext))); + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Stop] + )); +} + +#[test] +fn starting_another_episode_checkpoints_the_first() { + let mut model = model_with(vec![episode("e1"), episode("e2")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, "e1", 42); + let effects = send(&mut model, Event::PlayEpisode("e2".into())); + assert!(storage_ops(&effects).iter().any(|op| matches!( + op, + StorageOperation::UpdatePlaybackStatus { episode_id, position_secs: Some(42), .. } + if episode_id == "e1" + ))); + assert_eq!(active(&model).episode.id, "e2"); +} + +#[test] +fn a_finished_download_swaps_the_stream_for_the_local_file_at_the_playhead() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, "e1", 77); + + let effects = send( + &mut model, + Event::DownloadFinished { + episode_id: "e1".into(), + result: Box::new(DownloadResult::Completed { + local_path: "Downloads/x.mp3".into(), + size_bytes: 10, + }), + }, + ); + + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Load { + source: PlayerSource::Local { .. }, + start_secs: 77, + autoplay: true, + .. + }] + )); + assert!(!view_player(&model).is_streaming); +} + +#[test] +fn a_failed_stream_pauses_with_a_notice_and_play_reloads() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + send( + &mut model, + Event::PlayerFailed { + episode_id: "e1".into(), + message: "offline".into(), + }, + ); + let view = view_player(&model); + assert!(!view.is_playing); + assert_eq!(view.error.as_deref(), Some("offline")); + + let effects = send(&mut model, Event::Play); + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Load { .. }] + )); + assert!(view_player(&model).error.is_none()); +} + +#[test] +fn a_missing_local_file_falls_back_to_streaming_and_redownloads() { + let mut e = episode("e1"); + e.download_status = DownloadStatus::Downloaded; + e.local_path = Some("Downloads/gone.mp3".into()); + let mut model = model_with(vec![e]); + send(&mut model, Event::PlayEpisode("e1".into())); + + let effects = send( + &mut model, + Event::PlayerFailed { + episode_id: "e1".into(), + message: "file missing".into(), + }, + ); + + assert!(player_ops(&effects).iter().any(|op| matches!( + op, + PlayerOperation::Load { + source: PlayerSource::Stream { .. }, + autoplay: true, + .. + } + ))); + assert_eq!(model.downloading.as_deref(), Some("e1")); + assert!(view_player(&model).error.is_none()); +} + +#[test] +fn cold_start_restores_the_active_episode_paused_and_loads_on_first_play() { + let mut e = episode("e1"); + e.playback_status = PlaybackStatus::InProgress; + e.playback_position_secs = Some(200); + let mut model = model_with(vec![]); + + let effects = send( + &mut model, + Event::PlayContextLoaded(Box::new(StorageResult::Episode(e))), + ); + assert!( + player_ops(&effects).is_empty(), + "restore must not touch the engine" + ); + let view = view_player(&model); + assert!(!view.is_playing); + assert_eq!(view.position_secs, 200); + assert_eq!(view.feed_title, "Feed"); + + let effects = send(&mut model, Event::TogglePlay); + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Load { + start_secs: 197, + autoplay: true, + .. + }] + )); +} + +#[test] +fn nothing_saved_means_no_player() { + let mut model = model_with(vec![]); + send( + &mut model, + Event::PlayContextLoaded(Box::new(StorageResult::NotFound)), + ); + assert!(Pollux.view(&model).player.is_none()); +} + +#[test] +fn stale_engine_events_for_another_episode_are_ignored() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, "other", 99); + send(&mut model, Event::PlayerEnded("other".into())); + assert_eq!(active(&model).position_secs, 0); + assert!(model.active_playback.is_some()); +} diff --git a/shared/src/view_model.rs b/shared/src/view_model.rs index ef9ccca..74771e6 100644 --- a/shared/src/view_model.rs +++ b/shared/src/view_model.rs @@ -8,11 +8,35 @@ use crate::theme::ThemeView; pub struct ViewModel { pub library: LibraryView, pub subscription_detail: SubscriptionDetailView, + /// The active player, if any (see `PlayerView`). + pub player: Option, /// Active theme, resolved by the shell into platform colors. Present on every /// render so the shell can apply it globally without a separate query. pub theme: ThemeView, } +/// The active episode's transport state, present whenever the mini-player should show +/// (playing or paused) and absent when nothing is active. The shell derives the +/// full-player and lock-screen presentation from this alone. +#[derive(Facet, Serialize, Deserialize, Clone)] +pub struct PlayerView { + pub episode_id: String, + pub subscription_id: String, + pub episode_title: String, + pub feed_title: String, + /// Episode art, falling back to the feed's; the shell supplies the placeholder. + pub artwork_url: Option, + pub position_secs: u32, + pub duration_secs: Option, + pub is_playing: bool, + /// True while playing from the network rather than a downloaded file. + pub is_streaming: bool, + pub skip_forward_secs: u32, + pub skip_back_secs: u32, + /// Why playback failed, shown as a transient banner; playback stays paused. + pub error: Option, +} + #[derive(Facet, Serialize, Deserialize, Clone, Default)] pub struct LibraryView { pub subscriptions: Vec, From acd5eb9deeca1a1c05f64833b065d69e5edd9bde Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Tue, 6 Oct 2026 09:35:34 +0200 Subject: [PATCH 03/32] Update PLAN --- PLAN.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/PLAN.md b/PLAN.md index 5405083..421aa8e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -44,3 +44,22 @@ Playlists, queue, chapters, speed, sleep timer, EQ, bookmarks, listening stats/P - Swift Testing: PlaybackManager helpers, URL resolution, DB migration v2 round-trip. - `make check`, `make test`; build via `make ios-build` (regenerates types). - Simulator: stream an undownloaded episode, watch the download finish and the swap (listen for gap), lock-screen controls, background/lock continues, kill + relaunch shows paused mini-player at saved position, airplane mode error banner. + +## Status (after round 1) +Built and merged with main; `make check` and `make ios-test` pass. Where this differs from the plan above: +- The `play_context` table is part of the `v1_initial` baseline (per the schema-baseline policy in `docs/DATA_MODEL.md`), not a `v2` migration. It stores only `episode_id`; position lives on the episode row. +- The error banner is `NoticeBanner` (renamed on main from `DownloadNoticeBanner`). +- Resolved design choices are recorded in `docs/features/player.md` ("Decisions (MVP implementation)"). + +Verified in the simulator: streaming playback, skip, pause/resume with rewind, cold-start restore, "From:" navigation. **Not verified** (needs a device or a repro): lock-screen rendering, background audio, the stream-to-local swap gap, the error banner, audio interruptions, the AirPlay picker. + +## Round 2: review follow-ups +Suggested order; each is its own commit. +1. **Restore and played logic.** Do not restore an episode whose status is Played; clear the saved context instead (`on_context_loaded` in `player.rs`). Likely cause of "finished episode comes back at 0:00": a checkpoint in the last 15s marks it Played (position 0) but leaves the player active and the context saved, and only the engine's natural-end event clears it; reproduce first. Make "played" one decision (natural end, or an explicit seek into the tail) rather than a side effect of every checkpoint, covering: later ticks must not overwrite the reset position, episodes shorter than the tolerance, unknown duration, and a wrong feed duration before the engine reports the real one. Add a per-load session id to engine events so a stale event for the same episode (after a source swap or retry) cannot change state. Hide stays UI-only. +2. **Source-context abstraction.** Add a small source context (subscription only for now) to `ActivePlayback` and `play_context` (kind + id, in the `v1_initial` baseline); "From:" and go-to-source read from it so playlists drop in later. Rename `PlayerSource` (stream/local) to `MediaSource` to avoid confusion. Keep `DATA_MODEL.md` PlayContext consistent. +3. **Show notes on the player's second page.** Replace the "coming soon" placeholder with real show notes (reuse `ShowNotesText`); add the episode description to `PlayerView`. Update `docs/features/player.md` (currently says placeholder for MVP), `player-show-notes.md` and the ROADMAP. +4. **Player UI polish.** Controls render black because they use the `text` token; use `accent` for primary controls and `text` for secondary (treatment TBD). Artwork fills the width at 1:1 (make `ArtworkView` flexible). A user-configurable player layout is a later feature; note it in `player.md`. +5. **Lock-screen controls are invisible but work.** Check on a real device first (may be a simulator quirk). If it persists: set `MPNowPlayingInfoPropertyMediaType` to audio and set the skip `preferredIntervals` before the first card update. +6. **Stream-to-local swap is audible (later).** It seeks back to the last whole-second tick, so up to ~1s repeats, plus load time. Options: use the engine's exact time; preroll and seek the local item before swapping; or defer the swap to the next pause or seek. + +Open questions: button color treatment (accent glyphs vs. an accent-filled play/pause circle); whether to create `PlaybackManager`/`NowPlayingController` lazily so background-refresh launches stay strictly metadata-only (today they are created in `Core.init`; harmless, since the audio session only activates on `Load`). From 9182298134db56f5b9603ed1295422c298b53067 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Tue, 6 Oct 2026 10:39:34 +0200 Subject: [PATCH 04/32] decide "played" when the listener leaves, not on every save A save in the last 15s (backgrounding, the 10s checkpoint) marked the episode played and reset its position while leaving the player active and the context saved. A relaunch then restored an already-played episode at 0:00, and listening on with the screen locked could be cut short. - played is decided only on natural end, an explicit pause in the tail, or starting another episode from the tail; checkpoints just record in-progress and a position - seeking to the very end finishes the episode (the engine only reports the end while playing) - don't restore an episode that is already played; clear its context - add Interrupted for system pauses (calls, unplugged headphones): keep the place and never finish, so playback can resume to the end - tag every load with a session id and ignore engine events from any other session, replacing the episode-id check - tail tolerance is capped at a quarter of the episode; an unknown duration never finishes by pausing Co-Authored-By: Claude Sonnet 5.5 --- docs/features/player.md | 23 +- iOS/Pollux/PlaybackManager.swift | 36 +-- shared/src/app.rs | 38 ++-- shared/src/capabilities/player.rs | 4 +- shared/src/model.rs | 5 + shared/src/player.rs | 363 ++++++++++++++++++++---------- shared/src/player/played_tests.rs | 361 +++++++++++++++++++++++++++++ shared/src/player/tests.rs | 85 ++++--- 8 files changed, 709 insertions(+), 206 deletions(-) create mode 100644 shared/src/player/played_tests.rs diff --git a/docs/features/player.md b/docs/features/player.md index 1f3c068..55d2cf8 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -114,10 +114,25 @@ engine. moves onto the local file at the current position (a `Load` at the playhead). If the downloaded file turns out to be missing or unreadable, playback falls back to streaming and the episode is reset to not-downloaded and re-queued. -- **Played tolerance.** An episode counts as played once the playhead is within - **15s** of the end (`PLAYED_TOLERANCE_SECS`); status becomes played and the - stored position resets to 0. The engine's duration wins over the feed's. - *Will become a user setting.* +- **Played tolerance.** Played is decided when the listener *leaves* an episode, + never as a side effect of saving progress. It happens when the engine reaches + the end, when they pause, or when they start another episode, while within + **15s** of the end (`PLAYED_TOLERANCE_SECS`, but never more than a quarter of + a short episode). The status becomes played, the stored position resets to 0, + the saved context is cleared and the player goes inactive. The engine's + duration wins over the feed's; with no known duration only the engine reaching + the end finishes an episode. *Will become a user setting.* +- **Not leaving.** Periodic saves and backgrounding only record in-progress and + a position, so listening on through the last seconds with the screen locked is + never cut short. A system pause (a call, Siri, unplugged headphones) keeps the + episode and its place and never counts as finishing, so it can resume and play + out the end. +- **Restore.** An episode already marked played is not restored at launch (its + saved context is cleared); an in-progress one comes back paused at its place. +- **Sessions.** Each load into the engine gets a session id, and everything the + engine reports carries it. The core ignores news from any other session, so a + late tick, end or failure from a replaced item (a source swap, a retry, another + episode) can't affect the current one. - **Position persistence.** Written at most every 10s while playing, and on pause, seek, backgrounding, switching episode, and end. Per-second ticks are transient. diff --git a/iOS/Pollux/PlaybackManager.swift b/iOS/Pollux/PlaybackManager.swift index 9ab96ca..9e3e6c2 100644 --- a/iOS/Pollux/PlaybackManager.swift +++ b/iOS/Pollux/PlaybackManager.swift @@ -13,7 +13,9 @@ final class PlaybackManager { /// Core events (ticks, end of file, interruptions…) go out through this. private let send: (Event) -> Void - private var currentEpisodeId: String? + /// The load the engine is on. Everything reported to the core carries it, so the + /// core can drop news from an item it has since replaced. + private var currentSession: UInt32? /// Seeks still in flight. A streaming seek takes a moment, and the engine keeps /// reporting the old position until it lands; those ticks would snap the UI back. private var pendingSeeks = 0 @@ -33,8 +35,8 @@ final class PlaybackManager { /// Carries out one core operation. Synchronous: AVPlayer queues its own work. func perform(_ operation: PlayerOperation) -> PlayerResult { switch operation { - case let .load(episodeId, source, startSecs, autoplay): - return load(episodeId: episodeId, source: source, startSecs: startSecs, autoplay: autoplay) + case let .load(session, source, startSecs, autoplay): + return load(session: session, source: source, startSecs: startSecs, autoplay: autoplay) case .play: player.play() case .pause: @@ -48,7 +50,7 @@ final class PlaybackManager { } private func load( - episodeId: String, source: PlayerSource, startSecs: UInt32, autoplay: Bool, + session: UInt32, source: PlayerSource, startSecs: UInt32, autoplay: Bool, ) -> PlayerResult { let url: URL switch source { @@ -72,9 +74,9 @@ final class PlaybackManager { return .error("Couldn't start audio: \(error.localizedDescription)") } - currentEpisodeId = episodeId + currentSession = session let item = AVPlayerItem(url: url) - observe(item, episodeId: episodeId) + observe(item, session: session) player.replaceCurrentItem(with: item) // Exact seek: a resume that lands a few seconds off would defeat the rewind. seek(to: startSecs) @@ -97,26 +99,26 @@ final class PlaybackManager { player.pause() player.replaceCurrentItem(with: nil) statusObservation = nil - currentEpisodeId = nil + currentSession = nil try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } // MARK: - Engine → core - private func observe(_ item: AVPlayerItem, episodeId: String) { + private func observe(_ item: AVPlayerItem, session: UInt32) { statusObservation = item.observe(\.status, options: [.new]) { [weak self] item, _ in let status = item.status let duration = item.duration.seconds let message = item.error?.localizedDescription Task { @MainActor [weak self] in - guard let self, currentEpisodeId == episodeId else { return } + guard let self, currentSession == session else { return } switch status { case .readyToPlay: if duration.isFinite, duration > 0 { - send(.playerDuration(episodeId: episodeId, durationSecs: UInt32(duration))) + send(.playerDuration(session: session, durationSecs: UInt32(duration))) } case .failed: - send(.playerFailed(episodeId: episodeId, message: message ?? "Playback failed")) + send(.playerFailed(session: session, message: message ?? "Playback failed")) default: break } @@ -127,7 +129,7 @@ final class PlaybackManager { NotificationCenter.default.addObserver( forName: AVPlayerItem.didPlayToEndTimeNotification, object: item, queue: .main, ) { [weak self] _ in - MainActor.assumeIsolated { self?.send(.playerEnded(episodeId)) } + MainActor.assumeIsolated { self?.send(.playerEnded(session: session)) } }, NotificationCenter.default.addObserver( forName: AVPlayerItem.failedToPlayToEndTimeNotification, object: item, queue: .main, @@ -135,7 +137,7 @@ final class PlaybackManager { let error = note.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error let message = error?.localizedDescription ?? "Playback stopped unexpectedly" MainActor.assumeIsolated { - self?.send(.playerFailed(episodeId: episodeId, message: message)) + self?.send(.playerFailed(session: session, message: message)) } }, ] @@ -151,11 +153,11 @@ final class PlaybackManager { } private func reportTick(seconds: Double) { - guard let id = currentEpisodeId, + guard let session = currentSession, pendingSeeks == 0, player.timeControlStatus == .playing, seconds.isFinite, seconds >= 0 else { return } - send(.playerTick(episodeId: id, positionSecs: UInt32(seconds))) + send(.playerTick(session: session, positionSecs: UInt32(seconds))) } /// Phone calls, Siri and unplugged headphones pause playback; the core is told so @@ -175,7 +177,7 @@ final class PlaybackManager { MainActor.assumeIsolated { switch type { case .began: - self?.send(.pause) + self?.send(.interrupted) case .ended where options?.contains(.shouldResume) == true: self?.send(.play) default: @@ -190,7 +192,7 @@ final class PlaybackManager { .flatMap(AVAudioSession.RouteChangeReason.init(rawValue:)) MainActor.assumeIsolated { if reason == .oldDeviceUnavailable { - self?.send(.pause) + self?.send(.interrupted) } } } diff --git a/shared/src/app.rs b/shared/src/app.rs index 17e3647..8208855 100644 --- a/shared/src/app.rs +++ b/shared/src/app.rs @@ -661,21 +661,19 @@ impl App for Pollux { Event::SkipBack => player::skip_back(model), Event::SeekTo(secs) => player::seek_to(model, secs), Event::AppBackgrounded => player::on_backgrounded(model), + Event::Interrupted => player::interrupt(model), Event::PlayerTick { - episode_id, + session, position_secs, - } => player::on_tick(model, &episode_id, position_secs), + } => player::on_tick(model, session, position_secs), Event::PlayerDuration { - episode_id, + session, duration_secs, - } => player::on_duration(model, &episode_id, duration_secs), - Event::PlayerEnded(episode_id) => player::on_ended(model, &episode_id), - Event::PlayerFailed { - episode_id, - message, - } => player::on_failure(model, &episode_id, message), - Event::PlayerResponded { episode_id, result } => { - player::on_response(model, &episode_id, *result) + } => player::on_duration(model, session, duration_secs), + Event::PlayerEnded { session } => player::on_ended(model, session), + Event::PlayerFailed { session, message } => player::on_failure(model, session, message), + Event::PlayerResponded { session, result } => { + player::on_response(model, session, *result) } Event::PlayContextLoaded(result) => player::on_context_loaded(model, *result), // Required resolution sink for playback persistence writes. These are @@ -1296,27 +1294,33 @@ pub enum Event { SeekTo(u32), /// The app left the foreground: flush the playback position. AppBackgrounded, + /// The system paused playback (a call, Siri, unplugged headphones). Unlike `Pause` + /// this is not the listener leaving: the place is saved but the episode is never + /// counted as played, so playback can resume to the end. + Interrupted, /// Engine position report (~1/s while playing). Transient; the core persists on - /// its own checkpoints, not per tick. + /// its own checkpoints, not per tick. `session` is the load that produced it. PlayerTick { - episode_id: String, + session: u32, position_secs: u32, }, /// The engine learned the real duration, which beats the feed's possibly-missing one. PlayerDuration { - episode_id: String, + session: u32, duration_secs: u32, }, /// The engine reached the end of the file. - PlayerEnded(String), + PlayerEnded { + session: u32, + }, /// The engine failed to play or lost the item. PlayerFailed { - episode_id: String, + session: u32, message: String, }, /// Resolution of a player operation; an error is treated like `PlayerFailed`. PlayerResponded { - episode_id: String, + session: u32, result: Box, }, /// The episode saved as active by the previous session, loaded at launch. diff --git a/shared/src/capabilities/player.rs b/shared/src/capabilities/player.rs index 1d774c7..c899403 100644 --- a/shared/src/capabilities/player.rs +++ b/shared/src/capabilities/player.rs @@ -25,7 +25,9 @@ pub enum PlayerOperation { /// playing immediately when `autoplay` is set. Also used to swap a streaming /// episode onto its freshly downloaded local copy mid-play. Load { - episode_id: String, + /// Tags everything the engine reports about this item, so the core can drop + /// news from an item it has since replaced. + session: u32, source: PlayerSource, start_secs: u32, autoplay: bool, diff --git a/shared/src/model.rs b/shared/src/model.rs index e459f17..909ef4c 100644 --- a/shared/src/model.rs +++ b/shared/src/model.rs @@ -61,6 +61,9 @@ pub struct Model { // The episode the player is on (playing or paused), if any. Survives restarts via // the stored play context; see `player.rs`. pub active_playback: Option, + // Last session id handed to the engine (see `player.rs`). Only ever increases, so an + // id is never reused across episodes or reloads. + pub player_sessions: u32, // Feed refresh. Serial like downloads: at most one feed is fetched at a time // (`refreshing` holds its subscription id), the rest wait in `refresh_queue` @@ -88,6 +91,8 @@ pub struct Model { #[derive(Clone, Debug)] pub struct ActivePlayback { pub episode: Episode, + /// The engine load this state belongs to; engine news for any other is stale. + pub session: u32, pub position_secs: u32, /// The engine's duration, authoritative over the feed's `episode.duration_secs`. pub duration_secs: Option, diff --git a/shared/src/player.rs b/shared/src/player.rs index 733e4b4..e025957 100644 --- a/shared/src/player.rs +++ b/shared/src/player.rs @@ -4,6 +4,28 @@ //! //! Engine-originated news arrives as ordinary events (`PlayerTick`, `PlayerEnded`, …) //! rather than request results, mirroring how download progress is reported. +//! +//! # Sessions +//! +//! Every `Load` gets a fresh *session* id, and the shell tags everything the engine +//! reports with the session of the item that produced it. The core ignores news from +//! any session but the current one, so a late tick, end-of-file or failure from a +//! replaced item (a source swap, a retry, or a different episode) can't touch the +//! current one. +//! +//! # When an episode counts as played +//! +//! The decision is made at the moments the listener *leaves* the episode, never as a +//! side effect of saving progress: +//! +//! - the engine reaches the end of the file (natural completion); +//! - they pause (explicitly) within the played tolerance of the end; +//! - they start another episode while within the tolerance of the end. +//! +//! Periodic and background saves only ever record "in progress" and a position, so +//! listening on through the last seconds with the screen locked is never cut short or +//! reset. A system interruption (a call, unplugged headphones) is not leaving: it +//! pauses and keeps the place so playback can resume to the end. use crux_core::{render::render, Command}; @@ -19,6 +41,8 @@ use crate::effect::Effect; use crate::model::{ActivePlayback, Model}; use crate::view_model::PlayerView; +#[cfg(test)] +mod played_tests; #[cfg(test)] mod tests; @@ -37,14 +61,21 @@ fn source_for(episode: &Episode) -> PlayerSource { } } -fn player_op(episode_id: &str, op: PlayerOperation) -> Cmd { - let id = episode_id.to_string(); +/// Issues an engine operation. Its resolution comes back tagged with `session`, so an +/// error from a replaced item is dropped rather than blamed on the current one. +fn player_op(session: u32, op: PlayerOperation) -> Cmd { Command::request_from_shell(op).then_send(move |r| Event::PlayerResponded { - episode_id: id.clone(), + session, result: Box::new(r), }) } +/// Hands out the next session id. Ids only ever increase, across episodes. +fn next_session(model: &mut Model) -> u32 { + model.player_sessions += 1; + model.player_sessions +} + /// Persists an episode's playback state. Best-effort: a failed write only costs /// durability (the next checkpoint retries), so the result is dropped. fn persist_playback(episode_id: &str, status: PlaybackStatus, position_secs: Option) -> Cmd { @@ -56,6 +87,18 @@ fn persist_playback(episode_id: &str, status: PlaybackStatus, position_secs: Opt .then_send(|r| Event::PlaybackPersisted(Box::new(r))) } +fn save_play_context(episode_id: &str) -> Cmd { + Command::request_from_shell(StorageOperation::SavePlayContext { + episode_id: episode_id.to_string(), + }) + .then_send(|r| Event::PlaybackPersisted(Box::new(r))) +} + +fn clear_play_context() -> Cmd { + Command::request_from_shell(StorageOperation::ClearPlayContext) + .then_send(|r| Event::PlaybackPersisted(Box::new(r))) +} + /// Mirrors a playback-state change onto the feed on screen, if that episode is loaded, /// so its row updates without a reload (see the note at `SelectSubscription`). fn sync_episode_row( @@ -70,34 +113,74 @@ fn sync_episode_row( } } -/// The status and stored position for the current playhead: within the played -/// tolerance of the end means played (position reset), otherwise in-progress. -fn checkpoint_state(active: &ActivePlayback) -> (PlaybackStatus, Option) { - let duration = active.duration_secs.or(active.episode.duration_secs); - match duration { - Some(d) - if active.position_secs > 0 - && active.position_secs.saturating_add(PLAYED_TOLERANCE_SECS) >= d => - { - (PlaybackStatus::Played, Some(0)) - } - _ => (PlaybackStatus::InProgress, Some(active.position_secs)), - } +/// The duration to judge the end by: the engine's once known, else the feed's. +fn known_duration(active: &ActivePlayback) -> Option { + active.duration_secs.or(active.episode.duration_secs) +} + +/// Whether the playhead is close enough to the end to count as finished. The tolerance +/// is `PLAYED_TOLERANCE_SECS`, but never more than a quarter of the episode, so a short +/// clip isn't "finished" almost as soon as it starts. An unknown (or zero) duration is +/// never in the tail: only the engine reaching the end can finish such an episode. +fn in_tail(active: &ActivePlayback) -> bool { + let Some(duration) = known_duration(active).filter(|&d| d > 0) else { + return false; + }; + let tolerance = PLAYED_TOLERANCE_SECS.min(duration / 4); + active.position_secs.saturating_add(tolerance) >= duration } -/// Persists the current position/status. Called on pause, seek, backgrounding and -/// every `POSITION_CHECKPOINT_SECS` of playback. +/// Persists the current position as in-progress. Called on seek, interruption, +/// backgrounding and every `POSITION_CHECKPOINT_SECS` of playback. It never decides +/// "played": see the module docs. fn checkpoint(model: &mut Model) -> Cmd { let Some(active) = model.active_playback.as_mut() else { return Command::done(); }; - let (status, position) = checkpoint_state(active); + let position = Some(active.position_secs); active.last_checkpoint_secs = active.position_secs; - active.episode.playback_status = status.clone(); + active.episode.playback_status = PlaybackStatus::InProgress; active.episode.playback_position_secs = position; let id = active.episode.id.clone(); - sync_episode_row(model, &id, &status, position); - persist_playback(&id, status, position) + sync_episode_row(model, &id, &PlaybackStatus::InProgress, position); + persist_playback(&id, PlaybackStatus::InProgress, position) +} + +/// Finishes the active episode: marked played with its position reset, the saved +/// context cleared, the engine stopped, and the player inactive. There is no +/// auto-advance until playlists exist. +fn complete(model: &mut Model) -> Cmd { + let Some(active) = model.active_playback.take() else { + return Command::done(); + }; + let id = active.episode.id; + sync_episode_row(model, &id, &PlaybackStatus::Played, Some(0)); + persist_playback(&id, PlaybackStatus::Played, Some(0)) + .and(clear_play_context()) + .and(player_op(active.session, PlayerOperation::Stop)) + .and(render()) +} + +/// Settles the episode about to be replaced by another: played if it was left in the +/// tail, otherwise its place is saved. +fn leave_for_another(model: &mut Model) -> Cmd { + let Some(active) = model.active_playback.as_ref() else { + return Command::done(); + }; + if !in_tail(active) { + return checkpoint(model); + } + let id = active.episode.id.clone(); + sync_episode_row(model, &id, &PlaybackStatus::Played, Some(0)); + persist_playback(&id, PlaybackStatus::Played, Some(0)) +} + +/// Whether `session` is the current load's, i.e. the news is not stale. +fn is_current(model: &Model, session: u32) -> bool { + model + .active_playback + .as_ref() + .is_some_and(|a| a.session == session) } /// Starts playing `episode_id` from the feed on screen. Resumes in place if it is @@ -114,8 +197,8 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { return render(); }; - // Whatever was playing keeps its place before it's replaced. - let outgoing = checkpoint(model); + // Whatever was playing keeps its place (or counts as played) before it's replaced. + let outgoing = leave_for_another(model); let source = source_for(&episode); // A played episode restarts from the top; otherwise resume with the usual rewind. @@ -130,8 +213,10 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { playing_episode.playback_position_secs = Some(start); sync_episode_row(model, episode_id, &PlaybackStatus::InProgress, Some(start)); let streaming = matches!(source, PlayerSource::Stream { .. }); + let session = next_session(model); model.active_playback = Some(ActivePlayback { episode: playing_episode, + session, position_secs: start, duration_secs: None, is_playing: true, @@ -149,9 +234,9 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { )) .and(save_play_context(episode_id)) .and(player_op( - episode_id, + session, PlayerOperation::Load { - episode_id: episode_id.to_string(), + session, source, start_secs: start, autoplay: true, @@ -165,45 +250,44 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { cmd.and(render()) } -fn save_play_context(episode_id: &str) -> Cmd { - Command::request_from_shell(StorageOperation::SavePlayContext { - episode_id: episode_id.to_string(), - }) - .then_send(|r| Event::PlaybackPersisted(Box::new(r))) -} - /// Resumes the active episode, rewinding slightly for context. pub(crate) fn play(model: &mut Model) -> Cmd { + if model.active_playback.as_ref().is_none_or(|a| a.is_playing) { + return Command::done(); + } + // A restored (or errored) episode has nothing loaded, so it needs a fresh session. + let needs_load = model.active_playback.as_ref().is_some_and(|a| !a.loaded); + let fresh = needs_load.then(|| next_session(model)); let Some(active) = model.active_playback.as_mut() else { return Command::done(); }; - if active.is_playing { - return Command::done(); - } active.error = None; active.is_playing = true; let rewound = active.position_secs.saturating_sub(RESUME_REWIND_SECS); active.position_secs = rewound; - let id = active.episode.id.clone(); - let cmd = if active.loaded { - player_op(&id, PlayerOperation::Seek { secs: rewound }) - .and(player_op(&id, PlayerOperation::Play)) - } else { - // Cold-start restore (or recovery after an error): nothing is loaded yet. + let cmd = if let Some(session) = fresh { + active.session = session; active.loaded = true; player_op( - &id, + session, PlayerOperation::Load { - episode_id: id.clone(), + session, source: active.source.clone(), start_secs: rewound, autoplay: true, }, ) + } else { + let session = active.session; + player_op(session, PlayerOperation::Seek { secs: rewound }) + .and(player_op(session, PlayerOperation::Play)) }; cmd.and(render()) } +/// An explicit pause (the pause button, the lock screen, headphone controls). Pausing +/// within the played tolerance of the end finishes the episode; otherwise it saves the +/// place. pub(crate) fn pause(model: &mut Model) -> Cmd { let Some(active) = model.active_playback.as_mut() else { return Command::done(); @@ -212,8 +296,29 @@ pub(crate) fn pause(model: &mut Model) -> Cmd { return Command::done(); } active.is_playing = false; - let id = active.episode.id.clone(); - player_op(&id, PlayerOperation::Pause) + if in_tail(active) { + // `complete` stops the engine, which pauses it. + return complete(model); + } + let session = active.session; + player_op(session, PlayerOperation::Pause) + .and(checkpoint(model)) + .and(render()) +} + +/// The system paused playback (a call, Siri, unplugged headphones). That is not the +/// listener leaving: the place is saved and the episode stays active, so playback can +/// resume and play out the final seconds. +pub(crate) fn interrupt(model: &mut Model) -> Cmd { + let Some(active) = model.active_playback.as_mut() else { + return Command::done(); + }; + if !active.is_playing { + return Command::done(); + } + active.is_playing = false; + let session = active.session; + player_op(session, PlayerOperation::Pause) .and(checkpoint(model)) .and(render()) } @@ -227,28 +332,39 @@ pub(crate) fn toggle(model: &mut Model) -> Cmd { } /// Moves the playhead, clamped to the episode: a skip never runs past the end (so it -/// can't spill into another episode) or before the start. +/// can't spill into another episode) or before the start. Seeking to the very end +/// finishes the episode. pub(crate) fn seek_to(model: &mut Model, secs: u32) -> Cmd { + let needs_load = model.active_playback.as_ref().is_some_and(|a| !a.loaded); + let fresh = needs_load.then(|| next_session(model)); let Some(active) = model.active_playback.as_mut() else { return Command::done(); }; - let duration = active.duration_secs.or(active.episode.duration_secs); + let duration = known_duration(active).filter(|&d| d > 0); + // Skipping or scrubbing to the very end is finishing: decided here rather than left + // to the engine, which only reports the end of the file while it is playing. + if duration.is_some_and(|d| secs >= d) { + return complete(model); + } let target = duration.map_or(secs, |d| secs.min(d)); active.position_secs = target; - let id = active.episode.id.clone(); - let op = if active.loaded { - PlayerOperation::Seek { secs: target } - } else { + let (session, op) = if let Some(session) = fresh { // Seeking a restored-but-unloaded episode loads it paused at the new spot. + active.session = session; active.loaded = true; - PlayerOperation::Load { - episode_id: id.clone(), - source: active.source.clone(), - start_secs: target, - autoplay: false, - } + ( + session, + PlayerOperation::Load { + session, + source: active.source.clone(), + start_secs: target, + autoplay: false, + }, + ) + } else { + (active.session, PlayerOperation::Seek { secs: target }) }; - player_op(&id, op).and(checkpoint(model)).and(render()) + player_op(session, op).and(checkpoint(model)).and(render()) } pub(crate) fn skip_forward(model: &mut Model) -> Cmd { @@ -265,13 +381,13 @@ pub(crate) fn skip_back(model: &mut Model) -> Cmd { seek_to(model, position.saturating_sub(SKIP_BACKWARD_SECS)) } -pub(crate) fn on_tick(model: &mut Model, episode_id: &str, position_secs: u32) -> Cmd { +pub(crate) fn on_tick(model: &mut Model, session: u32, position_secs: u32) -> Cmd { + if !is_current(model, session) { + return Command::done(); + } let Some(active) = model.active_playback.as_mut() else { return Command::done(); }; - if active.episode.id != episode_id { - return Command::done(); - } active.position_secs = position_secs; let due = active.is_playing && position_secs.abs_diff(active.last_checkpoint_secs) >= POSITION_CHECKPOINT_SECS; @@ -282,71 +398,65 @@ pub(crate) fn on_tick(model: &mut Model, episode_id: &str, position_secs: u32) - } } -pub(crate) fn on_duration(model: &mut Model, episode_id: &str, duration_secs: u32) -> Cmd { - match model.active_playback.as_mut() { - Some(active) if active.episode.id == episode_id => { - active.duration_secs = Some(duration_secs); - render() - } - _ => Command::done(), +pub(crate) fn on_duration(model: &mut Model, session: u32, duration_secs: u32) -> Cmd { + if !is_current(model, session) { + return Command::done(); } + if let Some(active) = model.active_playback.as_mut() { + active.duration_secs = Some(duration_secs); + } + render() } -/// The engine reached the end of the file: the episode is played and the player -/// goes inactive (no auto-advance until playlists exist). -pub(crate) fn on_ended(model: &mut Model, episode_id: &str) -> Cmd { - if model - .active_playback - .as_ref() - .is_none_or(|a| a.episode.id != episode_id) - { +/// The engine reached the end of the file: natural completion. +pub(crate) fn on_ended(model: &mut Model, session: u32) -> Cmd { + if !is_current(model, session) { return Command::done(); } - model.active_playback = None; - sync_episode_row(model, episode_id, &PlaybackStatus::Played, Some(0)); - persist_playback(episode_id, PlaybackStatus::Played, Some(0)) - .and( - Command::request_from_shell(StorageOperation::ClearPlayContext) - .then_send(|r| Event::PlaybackPersisted(Box::new(r))), - ) - .and(player_op(episode_id, PlayerOperation::Stop)) - .and(render()) + complete(model) } /// The engine couldn't play (or lost) the item. A missing local file falls back to /// streaming and re-downloads; anything else leaves playback paused with a notice. -pub(crate) fn on_failure(model: &mut Model, episode_id: &str, message: String) -> Cmd { - let Some(active) = model.active_playback.as_mut() else { - return Command::done(); - }; - if active.episode.id != episode_id { +pub(crate) fn on_failure(model: &mut Model, session: u32, message: String) -> Cmd { + if !is_current(model, session) { return Command::done(); } - let was_playing = active.is_playing; - let position = active.position_secs; - let id = active.episode.id.clone(); - if !matches!(active.source, PlayerSource::Local { .. }) { - active.is_playing = false; - active.loaded = false; - active.error = Some(message); + let is_local = model + .active_playback + .as_ref() + .is_some_and(|a| matches!(a.source, PlayerSource::Local { .. })); + if !is_local { + if let Some(active) = model.active_playback.as_mut() { + active.is_playing = false; + active.loaded = false; + active.error = Some(message); + } return checkpoint(model).and(render()); } // The downloaded file is unreadable (deleted behind our back, say): forget it and - // stream instead, carrying on from the same spot. + // stream instead, carrying on from the same spot under a new session. + let fresh = next_session(model); + let Some(active) = model.active_playback.as_mut() else { + return Command::done(); + }; let source = PlayerSource::Stream { url: active.episode.enclosure_url.clone(), }; + let id = active.episode.id.clone(); active.source = source.clone(); + active.session = fresh; active.episode.download_status = DownloadStatus::NotDownloaded; active.episode.local_path = None; active.error = None; active.loaded = true; + let (position, was_playing) = (active.position_secs, active.is_playing); reset_to_not_downloaded(model, &id) .and(enqueue_download(model, &id)) .and(player_op( - &id, + fresh, PlayerOperation::Load { - episode_id: id.clone(), + session: fresh, source, start_secs: position, autoplay: was_playing, @@ -355,10 +465,10 @@ pub(crate) fn on_failure(model: &mut Model, episode_id: &str, message: String) - .and(render()) } -pub(crate) fn on_response(model: &mut Model, episode_id: &str, result: PlayerResult) -> Cmd { +pub(crate) fn on_response(model: &mut Model, session: u32, result: PlayerResult) -> Cmd { match result { PlayerResult::Ok => Command::done(), - PlayerResult::Error(message) => on_failure(model, episode_id, message), + PlayerResult::Error(message) => on_failure(model, session, message), } } @@ -370,6 +480,10 @@ pub(crate) fn on_download_completed( local_path: &str, size_bytes: u64, ) -> Cmd { + let needs_swap = model.active_playback.as_ref().is_some_and(|a| { + a.episode.id == episode_id && a.loaded && matches!(a.source, PlayerSource::Stream { .. }) + }); + let swap_session = needs_swap.then(|| next_session(model)); let Some(active) = model.active_playback.as_mut() else { return Command::done(); }; @@ -379,21 +493,22 @@ pub(crate) fn on_download_completed( active.episode.download_status = DownloadStatus::Downloaded; active.episode.local_path = Some(local_path.to_string()); active.episode.file_size_bytes = Some(size_bytes); - if !matches!(active.source, PlayerSource::Stream { .. }) { - return Command::done(); + if matches!(active.source, PlayerSource::Stream { .. }) { + active.source = PlayerSource::Local { + local_path: local_path.to_string(), + }; } - let source = PlayerSource::Local { - local_path: local_path.to_string(), - }; - active.source = source.clone(); - if !active.loaded { + let Some(session) = swap_session else { + // Not loaded into the engine (restored, or errored): the new source is picked up + // by the next Load. Already local: nothing to swap. return render(); - } + }; + active.session = session; player_op( - episode_id, + session, PlayerOperation::Load { - episode_id: episode_id.to_string(), - source, + session, + source: active.source.clone(), start_secs: active.position_secs, autoplay: active.is_playing, }, @@ -401,7 +516,9 @@ pub(crate) fn on_download_completed( } /// Restores the active episode at launch, paused, so the mini-player is there -/// immediately. The engine isn't loaded until the first Play. +/// immediately. The engine isn't loaded until the first Play. An episode that is already +/// played is not restored (there's nothing left to resume), and its stale context is +/// cleared. pub(crate) fn on_context_loaded(model: &mut Model, result: StorageResult) -> Cmd { if model.active_playback.is_some() { return Command::done(); @@ -409,13 +526,14 @@ pub(crate) fn on_context_loaded(model: &mut Model, result: StorageResult) -> Cmd let StorageResult::Episode(episode) = result else { return Command::done(); }; - let position = match episode.playback_status { - PlaybackStatus::Played => 0, - _ => episode.playback_position_secs.unwrap_or(0), - }; + if episode.playback_status == PlaybackStatus::Played { + return clear_play_context(); + } + let position = episode.playback_position_secs.unwrap_or(0); model.active_playback = Some(ActivePlayback { source: source_for(&episode), episode, + session: 0, position_secs: position, duration_secs: None, is_playing: false, @@ -426,7 +544,8 @@ pub(crate) fn on_context_loaded(model: &mut Model, result: StorageResult) -> Cmd render() } -/// Flushes position when the app leaves the foreground. +/// Flushes position when the app leaves the foreground. Never decides "played": the +/// listener may well be still listening with the screen locked. pub(crate) fn on_backgrounded(model: &mut Model) -> Cmd { checkpoint(model) } @@ -448,7 +567,7 @@ pub(crate) fn player_view(model: &Model) -> Option { .clone() .or_else(|| subscription.and_then(|s| s.artwork_url.clone())), position_secs: active.position_secs, - duration_secs: active.duration_secs.or(active.episode.duration_secs), + duration_secs: known_duration(active), is_playing: active.is_playing, is_streaming: matches!(active.source, PlayerSource::Stream { .. }), skip_forward_secs: SKIP_FORWARD_SECS, diff --git a/shared/src/player/played_tests.rs b/shared/src/player/played_tests.rs new file mode 100644 index 0000000..54c64ef --- /dev/null +++ b/shared/src/player/played_tests.rs @@ -0,0 +1,361 @@ +//! When an episode counts as played, what restore brings back, and which engine news is +//! stale. See the module docs in `player.rs`. + +use crux_core::App; + +use super::tests::{ + active, checkpoints, episode, model_with, player_ops, send, send_ended, session, storage_ops, + tick, view_player, +}; +use super::*; +use crate::Pollux; + +#[test] +fn backgrounding_in_the_tail_while_listening_does_not_finish_the_episode() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, 590); + + let effects = send(&mut model, Event::AppBackgrounded); + + // Still listening to the last seconds: only the place is saved, never "played". + assert_eq!( + checkpoints(&effects), + vec![(PlaybackStatus::InProgress, Some(590))] + ); + assert!(model.active_playback.is_some()); + assert_eq!( + model.episodes[0].playback_status, + PlaybackStatus::InProgress + ); +} + +#[test] +fn a_periodic_checkpoint_in_the_tail_never_resets_the_position() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, 585); + + // Crosses the 10s checkpoint interval inside the tail. + let effects = tick(&mut model, 596); + + assert_eq!( + checkpoints(&effects), + vec![(PlaybackStatus::InProgress, Some(596))] + ); + assert_eq!(active(&model).position_secs, 596); +} + +#[test] +fn a_played_episode_is_not_restored_and_its_saved_context_is_cleared() { + let mut e = episode("e1"); + e.playback_status = PlaybackStatus::Played; + e.playback_position_secs = Some(0); + let mut model = model_with(vec![]); + + let effects = send( + &mut model, + Event::PlayContextLoaded(Box::new(StorageResult::Episode(e))), + ); + + assert!(Pollux.view(&model).player.is_none()); + assert!(storage_ops(&effects) + .iter() + .any(|op| matches!(op, StorageOperation::ClearPlayContext))); +} + +#[test] +fn an_in_progress_episode_is_still_restored() { + let mut e = episode("e1"); + e.playback_status = PlaybackStatus::InProgress; + e.playback_position_secs = Some(595); + let mut model = model_with(vec![]); + + send( + &mut model, + Event::PlayContextLoaded(Box::new(StorageResult::Episode(e))), + ); + + assert_eq!(view_player(&model).position_secs, 595); +} + +#[test] +fn pausing_in_the_tail_finishes_the_episode_and_clears_the_player() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, 590); + + let effects = send(&mut model, Event::Pause); + + assert!(model.active_playback.is_none()); + assert_eq!(model.episodes[0].playback_status, PlaybackStatus::Played); + assert_eq!( + checkpoints(&effects), + vec![(PlaybackStatus::Played, Some(0))] + ); + assert!(storage_ops(&effects) + .iter() + .any(|op| matches!(op, StorageOperation::ClearPlayContext))); + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Stop] + )); +} + +#[test] +fn pausing_before_the_tail_just_saves_the_place() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, 300); + + let effects = send(&mut model, Event::Pause); + + assert!(model.active_playback.is_some()); + assert_eq!( + checkpoints(&effects), + vec![(PlaybackStatus::InProgress, Some(300))] + ); +} + +#[test] +fn a_system_interruption_in_the_tail_pauses_without_finishing() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, 590); + + let effects = send(&mut model, Event::Interrupted); + + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Pause] + )); + assert_eq!( + checkpoints(&effects), + vec![(PlaybackStatus::InProgress, Some(590))] + ); + assert!(!view_player(&model).is_playing); + + // The system says it's fine to resume (the call ended): the last seconds play out. + let effects = send(&mut model, Event::Play); + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Seek { .. }, PlayerOperation::Play] + )); +} + +#[test] +fn switching_episode_from_the_tail_marks_the_outgoing_one_played() { + let mut model = model_with(vec![episode("e1"), episode("e2")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, 595); + + let effects = send(&mut model, Event::PlayEpisode("e2".into())); + + assert!(storage_ops(&effects).iter().any(|op| matches!( + op, + StorageOperation::UpdatePlaybackStatus { + episode_id, + status: PlaybackStatus::Played, + position_secs: Some(0), + } if episode_id == "e1" + ))); + assert_eq!(model.episodes[0].playback_status, PlaybackStatus::Played); + assert_eq!(active(&model).episode.id, "e2"); +} + +#[test] +fn switching_episode_before_the_tail_keeps_the_place() { + let mut model = model_with(vec![episode("e1"), episode("e2")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, 120); + + let effects = send(&mut model, Event::PlayEpisode("e2".into())); + + assert!(storage_ops(&effects).iter().any(|op| matches!( + op, + StorageOperation::UpdatePlaybackStatus { + episode_id, + status: PlaybackStatus::InProgress, + position_secs: Some(120), + } if episode_id == "e1" + ))); +} + +#[test] +fn a_short_episode_is_not_finished_by_pausing_near_its_start() { + let mut e = episode("e1"); + e.duration_secs = Some(10); + let mut model = model_with(vec![e]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, 3); + + send(&mut model, Event::Pause); + + assert!( + model.active_playback.is_some(), + "3s into a 10s episode is not the tail" + ); + assert_eq!( + model.episodes[0].playback_status, + PlaybackStatus::InProgress + ); + + // It still finishes when it actually ends. + send(&mut model, Event::Play); + send_ended(&mut model); + assert_eq!(model.episodes[0].playback_status, PlaybackStatus::Played); +} + +#[test] +fn an_unknown_duration_never_finishes_by_pausing() { + let mut e = episode("e1"); + e.duration_secs = None; + let mut model = model_with(vec![e]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, 5000); + + send(&mut model, Event::Pause); + + assert!(model.active_playback.is_some()); + assert_eq!( + model.episodes[0].playback_status, + PlaybackStatus::InProgress + ); + + // Only the engine reaching the end finishes it. + send(&mut model, Event::Play); + send_ended(&mut model); + assert_eq!(model.episodes[0].playback_status, PlaybackStatus::Played); +} + +#[test] +fn the_engines_duration_beats_a_too_short_feed_duration() { + // The feed claims 600s; the file is really an hour long. + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + let session = session(&model); + send( + &mut model, + Event::PlayerDuration { + session, + duration_secs: 3600, + }, + ); + tick(&mut model, 590); + + send(&mut model, Event::Pause); + + assert!( + model.active_playback.is_some(), + "590s of 3600s is nowhere near the end" + ); + assert_eq!( + model.episodes[0].playback_status, + PlaybackStatus::InProgress + ); +} + +#[test] +fn engine_events_from_a_replaced_load_are_ignored() { + let mut e = episode("e1"); + e.download_status = DownloadStatus::Downloaded; + e.local_path = Some("Downloads/gone.mp3".into()); + let mut model = model_with(vec![e]); + send(&mut model, Event::PlayEpisode("e1".into())); + let first = session(&model); + + // The local file fails, so playback is re-loaded from the network: a new session. + send( + &mut model, + Event::PlayerFailed { + session: first, + message: "file missing".into(), + }, + ); + assert_ne!(session(&model), first); + + // Late news from the first load must not touch the second. + send( + &mut model, + Event::PlayerTick { + session: first, + position_secs: 99, + }, + ); + send(&mut model, Event::PlayerEnded { session: first }); + assert_eq!(active(&model).position_secs, 0); + assert!(model.active_playback.is_some()); +} + +#[test] +fn a_source_swap_starts_a_new_session_and_drops_the_old_loads_news() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + let streaming = session(&model); + + send( + &mut model, + Event::DownloadFinished { + episode_id: "e1".into(), + result: Box::new(crate::capabilities::download::DownloadResult::Completed { + local_path: "Downloads/x.mp3".into(), + size_bytes: 10, + }), + }, + ); + assert_ne!(session(&model), streaming); + + // The streaming item's end-of-file arriving late must not finish the episode. + send(&mut model, Event::PlayerEnded { session: streaming }); + assert!(model.active_playback.is_some()); +} + +#[test] +fn news_for_a_finished_episode_is_ignored() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + let finished = session(&model); + send_ended(&mut model); + assert!(model.active_playback.is_none()); + + // A straggling tick or second end event after completion is a no-op. + let effects = send( + &mut model, + Event::PlayerTick { + session: finished, + position_secs: 599, + }, + ); + assert!(effects.is_empty()); + assert!(model.active_playback.is_none()); + assert_eq!(model.episodes[0].playback_status, PlaybackStatus::Played); +} + +#[test] +fn seeking_to_the_very_end_while_paused_finishes_the_episode() { + // The engine only reports the end of the file while it is playing, so a skip or + // scrub that lands on the end while paused would otherwise leave the episode + // stuck active at 100%. + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + send(&mut model, Event::Interrupted); + assert!(!view_player(&model).is_playing); + + let effects = send(&mut model, Event::SeekTo(600)); + + assert!(model.active_playback.is_none()); + assert_eq!(model.episodes[0].playback_status, PlaybackStatus::Played); + assert!(storage_ops(&effects) + .iter() + .any(|op| matches!(op, StorageOperation::ClearPlayContext))); +} + +#[test] +fn a_seek_that_lands_short_of_the_end_does_not_finish() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + + send(&mut model, Event::SeekTo(599)); + + assert_eq!(active(&model).position_secs, 599); +} diff --git a/shared/src/player/tests.rs b/shared/src/player/tests.rs index 119a3f5..55e84d6 100644 --- a/shared/src/player/tests.rs +++ b/shared/src/player/tests.rs @@ -5,7 +5,7 @@ use crate::capabilities::download::DownloadResult; use crate::domain::Subscription; use crate::Pollux; -fn episode(id: &str) -> Episode { +pub(super) fn episode(id: &str) -> Episode { Episode { id: id.to_string(), feed_guid: format!("{id}-guid"), @@ -25,7 +25,7 @@ fn episode(id: &str) -> Episode { } } -fn model_with(episodes: Vec) -> Model { +pub(super) fn model_with(episodes: Vec) -> Model { Model { subscriptions: vec![Subscription { id: "sub".to_string(), @@ -45,21 +45,34 @@ fn model_with(episodes: Vec) -> Model { } } -fn send(model: &mut Model, event: Event) -> Vec { +pub(super) fn send(model: &mut Model, event: Event) -> Vec { Pollux.update(event, model).effects().collect() } -fn tick(model: &mut Model, id: &str, secs: u32) -> Vec { +/// The current load's session, which the shell tags every engine event with. +pub(super) fn session(model: &Model) -> u32 { + active(model).session +} + +/// A position report from the engine for the current load. +pub(super) fn tick(model: &mut Model, secs: u32) -> Vec { + let session = session(model); send( model, Event::PlayerTick { - episode_id: id.into(), + session, position_secs: secs, }, ) } -fn player_ops(effects: &[Effect]) -> Vec { +/// The engine reaching the end of the current load. +pub(super) fn send_ended(model: &mut Model) -> Vec { + let session = session(model); + send(model, Event::PlayerEnded { session }) +} + +pub(super) fn player_ops(effects: &[Effect]) -> Vec { effects .iter() .filter_map(|e| match e { @@ -69,7 +82,7 @@ fn player_ops(effects: &[Effect]) -> Vec { .collect() } -fn storage_ops(effects: &[Effect]) -> Vec { +pub(super) fn storage_ops(effects: &[Effect]) -> Vec { effects .iter() .filter_map(|e| match e { @@ -79,7 +92,7 @@ fn storage_ops(effects: &[Effect]) -> Vec { .collect() } -fn checkpoints(effects: &[Effect]) -> Vec<(PlaybackStatus, Option)> { +pub(super) fn checkpoints(effects: &[Effect]) -> Vec<(PlaybackStatus, Option)> { storage_ops(effects) .into_iter() .filter_map(|op| match op { @@ -93,11 +106,11 @@ fn checkpoints(effects: &[Effect]) -> Vec<(PlaybackStatus, Option)> { .collect() } -fn active(model: &Model) -> &ActivePlayback { +pub(super) fn active(model: &Model) -> &ActivePlayback { model.active_playback.as_ref().expect("an active episode") } -fn view_player(model: &Model) -> PlayerView { +pub(super) fn view_player(model: &Model) -> PlayerView { Pollux.view(model).player.expect("a player view") } @@ -169,7 +182,7 @@ fn a_played_episode_restarts_from_the_top() { fn pause_then_play_rewinds_and_persists_position() { let mut model = model_with(vec![episode("e1")]); send(&mut model, Event::PlayEpisode("e1".into())); - tick(&mut model, "e1", 50); + tick(&mut model, 50); let effects = send(&mut model, Event::Pause); assert!(matches!( @@ -202,13 +215,13 @@ fn rewind_is_clamped_at_zero() { fn ticks_persist_only_every_checkpoint_interval() { let mut model = model_with(vec![episode("e1")]); send(&mut model, Event::PlayEpisode("e1".into())); - assert!(checkpoints(&tick(&mut model, "e1", 3)).is_empty()); - assert!(checkpoints(&tick(&mut model, "e1", 9)).is_empty()); + assert!(checkpoints(&tick(&mut model, 3)).is_empty()); + assert!(checkpoints(&tick(&mut model, 9)).is_empty()); assert_eq!( - checkpoints(&tick(&mut model, "e1", 10)), + checkpoints(&tick(&mut model, 10)), vec![(PlaybackStatus::InProgress, Some(10))] ); - assert!(checkpoints(&tick(&mut model, "e1", 12)).is_empty()); + assert!(checkpoints(&tick(&mut model, 12)).is_empty()); } #[test] @@ -229,22 +242,11 @@ fn skips_use_the_default_increments_and_clamp_to_the_episode() { send(&mut model, Event::SkipBack); assert_eq!(active(&model).position_secs, 0); - // Forward past the end clamps to the duration (600s) — never beyond it. + // Skipping forward from near the end never overshoots into another episode: it + // finishes this one (see `played_tests`). send(&mut model, Event::SeekTo(595)); send(&mut model, Event::SkipForward); - assert_eq!(active(&model).position_secs, 600); -} - -#[test] -fn pausing_within_the_tolerance_of_the_end_marks_played() { - let mut model = model_with(vec![episode("e1")]); - send(&mut model, Event::PlayEpisode("e1".into())); - tick(&mut model, "e1", 590); - let effects = send(&mut model, Event::Pause); - assert_eq!( - checkpoints(&effects), - vec![(PlaybackStatus::Played, Some(0))] - ); + assert!(model.active_playback.is_none()); assert_eq!(model.episodes[0].playback_status, PlaybackStatus::Played); } @@ -254,10 +256,11 @@ fn engine_duration_overrides_the_feed_duration() { e.duration_secs = None; let mut model = model_with(vec![e]); send(&mut model, Event::PlayEpisode("e1".into())); + let session = session(&model); send( &mut model, Event::PlayerDuration { - episode_id: "e1".into(), + session, duration_secs: 300, }, ); @@ -268,7 +271,7 @@ fn engine_duration_overrides_the_feed_duration() { fn reaching_the_end_marks_played_and_clears_the_player() { let mut model = model_with(vec![episode("e1")]); send(&mut model, Event::PlayEpisode("e1".into())); - let effects = send(&mut model, Event::PlayerEnded("e1".into())); + let effects = send_ended(&mut model); assert!(model.active_playback.is_none()); assert!(Pollux.view(&model).player.is_none()); @@ -286,7 +289,7 @@ fn reaching_the_end_marks_played_and_clears_the_player() { fn starting_another_episode_checkpoints_the_first() { let mut model = model_with(vec![episode("e1"), episode("e2")]); send(&mut model, Event::PlayEpisode("e1".into())); - tick(&mut model, "e1", 42); + tick(&mut model, 42); let effects = send(&mut model, Event::PlayEpisode("e2".into())); assert!(storage_ops(&effects).iter().any(|op| matches!( op, @@ -300,7 +303,7 @@ fn starting_another_episode_checkpoints_the_first() { fn a_finished_download_swaps_the_stream_for_the_local_file_at_the_playhead() { let mut model = model_with(vec![episode("e1")]); send(&mut model, Event::PlayEpisode("e1".into())); - tick(&mut model, "e1", 77); + tick(&mut model, 77); let effects = send( &mut model, @@ -329,10 +332,11 @@ fn a_finished_download_swaps_the_stream_for_the_local_file_at_the_playhead() { fn a_failed_stream_pauses_with_a_notice_and_play_reloads() { let mut model = model_with(vec![episode("e1")]); send(&mut model, Event::PlayEpisode("e1".into())); + let session = session(&model); send( &mut model, Event::PlayerFailed { - episode_id: "e1".into(), + session, message: "offline".into(), }, ); @@ -356,10 +360,11 @@ fn a_missing_local_file_falls_back_to_streaming_and_redownloads() { let mut model = model_with(vec![e]); send(&mut model, Event::PlayEpisode("e1".into())); + let session = session(&model); let effects = send( &mut model, Event::PlayerFailed { - episode_id: "e1".into(), + session, message: "file missing".into(), }, ); @@ -416,13 +421,3 @@ fn nothing_saved_means_no_player() { ); assert!(Pollux.view(&model).player.is_none()); } - -#[test] -fn stale_engine_events_for_another_episode_are_ignored() { - let mut model = model_with(vec![episode("e1")]); - send(&mut model, Event::PlayEpisode("e1".into())); - tick(&mut model, "other", 99); - send(&mut model, Event::PlayerEnded("other".into())); - assert_eq!(active(&model).position_secs, 0); - assert!(model.active_playback.is_some()); -} From a875f4ac6a34d7dbc9b8b0b0613db1ef1f5f1310 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Tue, 6 Oct 2026 11:05:53 +0200 Subject: [PATCH 05/32] feat: carry an EpisodeSource through playback instead of a bare sub id Playback now remembers what it was started from as an EpisodeSource (the internal abstraction from DATA_MODEL.md). Only Subscription exists today, so playlists can later be added as a variant without a schema or event change. - add the EpisodeSource enum; ActivePlayback, the saved play context and the player view carry it instead of a subscription id - play_context stores source_kind + source_id (in the v1_initial baseline); SavePlayContext takes the source and LoadPlayContext returns a PlayContext { episode, source } result - the "From:" row names and navigates to the source; the episode's own feed stays separate (the lock-screen artist) - rename PlayerSource (stream or local file) to MediaSource, and the engine's Load field to media, to avoid confusion with the new type - document EpisodeSource and PlayContext storage in DATA_MODEL.md and player.md Co-Authored-By: Claude Sonnet 5.5 --- docs/DATA_MODEL.md | 12 ++++ docs/features/player.md | 6 ++ iOS/Pollux/DatabaseManager+PlayContext.swift | 21 ++++-- iOS/Pollux/DatabaseManager.swift | 4 +- iOS/Pollux/DatabaseManagerRows.swift | 17 +++++ iOS/Pollux/DatabaseMigrations.swift | 10 ++- iOS/Pollux/PlaybackManager.swift | 8 +-- iOS/Pollux/PlayerScreen.swift | 2 +- iOS/Pollux/Pollux.swift | 18 +++-- iOS/PolluxTests/PlayContextTests.swift | 23 ++++--- shared/src/capabilities/player.rs | 4 +- shared/src/capabilities/storage.rs | 16 +++-- shared/src/domain/episode_source.rs | 19 ++++++ shared/src/domain/mod.rs | 2 + shared/src/model.rs | 10 ++- shared/src/player.rs | 67 +++++++++++------- shared/src/player/played_tests.rs | 14 ++-- shared/src/player/tests.rs | 71 +++++++++++++++++--- shared/src/view_model.rs | 8 ++- 19 files changed, 245 insertions(+), 87 deletions(-) create mode 100644 shared/src/domain/episode_source.rs diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 204fa6c..2982625 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -171,6 +171,10 @@ user-facing copy until this is resolved. Relationships: references a Playlist or Subscription (via EpisodeSource), and an Episode. +Stored in the single-row `play_context` table as the episode id plus the source as +a kind and an id (`Subscription` today). Position is not stored here: it lives on +the episode row, so the two can never disagree. + --- ## Deferred Entities @@ -209,6 +213,14 @@ This is a Rust trait or enum, not a database entity. The name is a placeholder the final name will be settled during implementation. Do not use in user-facing copy. +Implemented as the `EpisodeSource` enum (`shared/src/domain/episode_source.rs`), +with one variant today, `Subscription { id }`, since a subscription is an implicit +single-feed playlist. A `Playlist { id }` variant joins when playlists exist. The +player already carries an `EpisodeSource` (not a bare subscription id) through its +state, the saved play context, and the "From:" row, so adding the variant needs no +schema or event change; only the places that resolve a source (its title, its +episode order) gain a case. The name is kept for now. + See also: `GLOSSARY.md` — EpisodeSource. --- diff --git a/docs/features/player.md b/docs/features/player.md index 55d2cf8..877b682 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -127,6 +127,12 @@ engine. never cut short. A system pause (a call, Siri, unplugged headphones) keeps the episode and its place and never counts as finishing, so it can resume and play out the end. +- **Source.** Playback remembers what it was started from as an `EpisodeSource` + (a subscription today; see `DATA_MODEL.md`), saved with the play context and + restored with it. The "From:" row names it and tapping it navigates back to it. + It is separate from the episode's own feed, which is what the lock screen shows + as the artist. Playback started from a feed's episode list uses that feed; once + playlists exist the view that starts playback will say which source it is. - **Restore.** An episode already marked played is not restored at launch (its saved context is cleared); an in-progress one comes back paused at its place. - **Sessions.** Each load into the engine gets a session id, and everything the diff --git a/iOS/Pollux/DatabaseManager+PlayContext.swift b/iOS/Pollux/DatabaseManager+PlayContext.swift index 25b8d30..f2ffbb9 100644 --- a/iOS/Pollux/DatabaseManager+PlayContext.swift +++ b/iOS/Pollux/DatabaseManager+PlayContext.swift @@ -4,30 +4,39 @@ import GRDB /// The saved "active episode" the player restores at launch. Split from /// `DatabaseManager` to keep that type under the body-length limit. extension DatabaseManager { - func savePlayContext(episodeId: String) async throws -> StorageResult { + func savePlayContext(episodeId: String, source: EpisodeSource) async throws -> StorageResult { + let columns = Self.episodeSourceColumns(source) try await db.write { db in try db.execute( - sql: "INSERT OR REPLACE INTO play_context (id, episode_id) VALUES (1, ?)", - arguments: [episodeId], + sql: """ + INSERT OR REPLACE INTO play_context (id, episode_id, source_kind, source_id) + VALUES (1, ?, ?, ?) + """, + arguments: [episodeId, columns.kind, columns.id], ) } return .success } - /// The saved active episode, or `.notFound` when none is saved. + /// The saved active episode and where it was started from, or `.notFound` when none + /// is saved. func loadPlayContext() throws -> StorageResult { let row = try db.read { db -> Row? in try Row.fetchOne( db, sql: """ - SELECT episodes.* FROM play_context + SELECT episodes.*, play_context.source_kind, play_context.source_id + FROM play_context JOIN episodes ON episodes.id = play_context.episode_id WHERE play_context.id = 1 """, ) } guard let row else { return .notFound } - return .episode(Self.episode(from: row)) + let source = Self.episodeSource( + kind: row["source_kind"], id: row["source_id"], + ) + return .playContext(episode: Self.episode(from: row), source: source) } func clearPlayContext() async throws -> StorageResult { diff --git a/iOS/Pollux/DatabaseManager.swift b/iOS/Pollux/DatabaseManager.swift index 6c412e9..c3d964c 100644 --- a/iOS/Pollux/DatabaseManager.swift +++ b/iOS/Pollux/DatabaseManager.swift @@ -59,8 +59,8 @@ actor DatabaseManager { try await deleteSubscription(id: id) case let .upsertFeedWithEpisodes(subscription, episodes): try await upsertFeedWithEpisodes(subscription: subscription, episodes: episodes) - case let .savePlayContext(episodeId): - try await savePlayContext(episodeId: episodeId) + case let .savePlayContext(episodeId, source): + try await savePlayContext(episodeId: episodeId, source: source) case .loadPlayContext: try loadPlayContext() case .clearPlayContext: diff --git a/iOS/Pollux/DatabaseManagerRows.swift b/iOS/Pollux/DatabaseManagerRows.swift index 53c73e9..6e9d193 100644 --- a/iOS/Pollux/DatabaseManagerRows.swift +++ b/iOS/Pollux/DatabaseManagerRows.swift @@ -227,6 +227,23 @@ extension DatabaseManager { } } + /// An `EpisodeSource` as the `(kind, id)` pair stored in `play_context`. + static func episodeSourceColumns(_ source: EpisodeSource) -> (kind: String, id: String) { + switch source { + case let .subscription(id): ("Subscription", id) + } + } + + static func episodeSource(kind: String, id: String) -> EpisodeSource { + switch kind { + case "Subscription": .subscription(id: id) + default: fatalError( + "Unknown EpisodeSource kind in DB: '\(kind)' — add a case to " + + "episodeSource(kind:id:) and episodeSourceColumns(_:)", + ) + } + } + static func downloadStatusString(_ status: DownloadStatus) -> String { switch status { case .notDownloaded: "NotDownloaded" diff --git a/iOS/Pollux/DatabaseMigrations.swift b/iOS/Pollux/DatabaseMigrations.swift index 9156052..d05b02c 100644 --- a/iOS/Pollux/DatabaseMigrations.swift +++ b/iOS/Pollux/DatabaseMigrations.swift @@ -68,13 +68,17 @@ extension DatabaseManager { on: "episodes", columns: ["subscription_id"], ) - // The episode the player was on, so the mini-player can be restored at - // launch. Single row (id pinned to 1); position lives on the episode itself. - // Deleting the episode (via its subscription) drops the row with it. + // The episode the player was on and where it was started from, so the + // mini-player can be restored at launch. Single row (id pinned to 1); position + // lives on the episode itself. Deleting the episode (via its subscription) + // drops the row with it. The source is stored as a kind plus an id, so a + // playlist is a new kind rather than a new column. try db.create(table: "play_context") { t in t.column("id", .integer).primaryKey().check(sql: "id = 1") t.column("episode_id", .text).notNull() .references("episodes", column: "id", onDelete: .cascade) + t.column("source_kind", .text).notNull() + t.column("source_id", .text).notNull() } } } diff --git a/iOS/Pollux/PlaybackManager.swift b/iOS/Pollux/PlaybackManager.swift index 9e3e6c2..9a0c19f 100644 --- a/iOS/Pollux/PlaybackManager.swift +++ b/iOS/Pollux/PlaybackManager.swift @@ -35,8 +35,8 @@ final class PlaybackManager { /// Carries out one core operation. Synchronous: AVPlayer queues its own work. func perform(_ operation: PlayerOperation) -> PlayerResult { switch operation { - case let .load(session, source, startSecs, autoplay): - return load(session: session, source: source, startSecs: startSecs, autoplay: autoplay) + case let .load(session, media, startSecs, autoplay): + return load(session: session, media: media, startSecs: startSecs, autoplay: autoplay) case .play: player.play() case .pause: @@ -50,10 +50,10 @@ final class PlaybackManager { } private func load( - session: UInt32, source: PlayerSource, startSecs: UInt32, autoplay: Bool, + session: UInt32, media: MediaSource, startSecs: UInt32, autoplay: Bool, ) -> PlayerResult { let url: URL - switch source { + switch media { case let .stream(urlString): guard let parsed = URL(string: urlString) else { return .error("Invalid episode URL") diff --git a/iOS/Pollux/PlayerScreen.swift b/iOS/Pollux/PlayerScreen.swift index e0a2621..fdf838c 100644 --- a/iOS/Pollux/PlayerScreen.swift +++ b/iOS/Pollux/PlayerScreen.swift @@ -121,7 +121,7 @@ struct PlayerScreen: View { .multilineTextAlignment(.center) .lineLimit(2) Button(action: onGoToSource) { - Text("From: \(player.feedTitle)") + Text("From: \(player.sourceTitle)") .font(.subheadline) .foregroundStyle(themeColors.accent) .lineLimit(1) diff --git a/iOS/Pollux/Pollux.swift b/iOS/Pollux/Pollux.swift index 23cf38d..0d458eb 100644 --- a/iOS/Pollux/Pollux.swift +++ b/iOS/Pollux/Pollux.swift @@ -89,13 +89,17 @@ private struct RootView: View { } } - /// Dismisses the player and shows the podcast the active episode belongs to. + /// Dismisses the player and shows what playback was started from. A subscription is + /// the only source for now; a playlist will get its own case here. private func goToSource(of player: PlayerView) { - guard let subscription = core.view.library.subscriptions - .first(where: { $0.id == player.subscriptionId }) - else { return } - showPlayer = false - path = NavigationPath() - path.append(subscription) + switch player.source { + case let .subscription(id): + guard let subscription = core.view.library.subscriptions + .first(where: { $0.id == id }) + else { return } + showPlayer = false + path = NavigationPath() + path.append(subscription) + } } } diff --git a/iOS/PolluxTests/PlayContextTests.swift b/iOS/PolluxTests/PlayContextTests.swift index ea8f7c3..9d5c491 100644 --- a/iOS/PolluxTests/PlayContextTests.swift +++ b/iOS/PolluxTests/PlayContextTests.swift @@ -10,6 +10,8 @@ private func makeManager() throws -> DatabaseManager { return try DatabaseManager(path: path) } +private let feed: EpisodeSource = .subscription(id: "sub-1") + private func seed(_ db: DatabaseManager, episodeId: String) async throws { let sub = Subscription( id: "sub-1", feedUrl: "https://example.com/sub-1.rss", title: "Feed", @@ -35,29 +37,30 @@ private func seed(_ db: DatabaseManager, episodeId: String) async throws { } } - @Test func savedContextLoadsTheEpisodeWithItsPosition() async throws { + @Test func savedContextLoadsTheEpisodeWithItsPositionAndSource() async throws { let db = try makeManager() try await seed(db, episodeId: "ep-1") - try await db.execute(.savePlayContext(episodeId: "ep-1")) + try await db.execute(.savePlayContext(episodeId: "ep-1", source: feed)) let result = try await db.execute(.loadPlayContext) - guard case let .episode(episode) = result else { - Issue.record("Expected .episode, got \(result)") + guard case let .playContext(episode, source) = result else { + Issue.record("Expected .playContext, got \(result)") return } #expect(episode.id == "ep-1") #expect(episode.playbackPositionSecs == 120) + #expect(source == feed) } @Test func savingReplacesThePreviousContext() async throws { let db = try makeManager() try await seed(db, episodeId: "ep-1") try await seed(db, episodeId: "ep-2") - try await db.execute(.savePlayContext(episodeId: "ep-1")) - try await db.execute(.savePlayContext(episodeId: "ep-2")) + try await db.execute(.savePlayContext(episodeId: "ep-1", source: feed)) + try await db.execute(.savePlayContext(episodeId: "ep-2", source: feed)) - guard case let .episode(episode) = try await db.execute(.loadPlayContext) else { - Issue.record("Expected an episode") + guard case let .playContext(episode, _) = try await db.execute(.loadPlayContext) else { + Issue.record("Expected a play context") return } #expect(episode.id == "ep-2") @@ -66,7 +69,7 @@ private func seed(_ db: DatabaseManager, episodeId: String) async throws { @Test func clearingRemovesIt() async throws { let db = try makeManager() try await seed(db, episodeId: "ep-1") - try await db.execute(.savePlayContext(episodeId: "ep-1")) + try await db.execute(.savePlayContext(episodeId: "ep-1", source: feed)) try await db.execute(.clearPlayContext) guard case .notFound = try await db.execute(.loadPlayContext) else { @@ -78,7 +81,7 @@ private func seed(_ db: DatabaseManager, episodeId: String) async throws { @Test func deletingTheSubscriptionDropsTheContext() async throws { let db = try makeManager() try await seed(db, episodeId: "ep-1") - try await db.execute(.savePlayContext(episodeId: "ep-1")) + try await db.execute(.savePlayContext(episodeId: "ep-1", source: feed)) try await db.execute(.deleteSubscription(id: "sub-1")) guard case .notFound = try await db.execute(.loadPlayContext) else { diff --git a/shared/src/capabilities/player.rs b/shared/src/capabilities/player.rs index c899403..8d041b1 100644 --- a/shared/src/capabilities/player.rs +++ b/shared/src/capabilities/player.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; /// Where the shell should read an episode's audio from. #[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)] #[repr(C)] -pub enum PlayerSource { +pub enum MediaSource { /// Stream straight from the enclosure URL. Stream { url: String }, /// A downloaded file; `local_path` is relative to the app's storage root and is @@ -28,7 +28,7 @@ pub enum PlayerOperation { /// Tags everything the engine reports about this item, so the core can drop /// news from an item it has since replaced. session: u32, - source: PlayerSource, + media: MediaSource, start_secs: u32, autoplay: bool, }, diff --git a/shared/src/capabilities/storage.rs b/shared/src/capabilities/storage.rs index d4d6a36..422003f 100644 --- a/shared/src/capabilities/storage.rs +++ b/shared/src/capabilities/storage.rs @@ -2,7 +2,7 @@ use crux_core::capability::Operation; use facet::Facet; use serde::{Deserialize, Serialize}; -use crate::domain::{DownloadStatus, Episode, PlaybackStatus, Subscription}; +use crate::domain::{DownloadStatus, Episode, EpisodeSource, PlaybackStatus, Subscription}; #[derive(Facet, Serialize, Deserialize, Clone, Debug)] #[repr(C)] @@ -45,13 +45,14 @@ pub enum StorageOperation { local_path: Option, size_bytes: Option, }, - /// Records which episode is the active one so the mini-player can be restored on - /// cold start. Position lives on the episode row (`UpdatePlaybackStatus`), so only - /// the identity is stored here. Single-row: saving replaces the previous value. + /// Records the active playback so the mini-player can be restored on cold start: the + /// episode, and the source it was started from. Position lives on the episode row + /// (`UpdatePlaybackStatus`). Single-row: saving replaces the previous value. SavePlayContext { episode_id: String, + source: EpisodeSource, }, - /// The episode last saved by `SavePlayContext` (as `Episode`), or `NotFound`. + /// The playback last saved by `SavePlayContext`, as `PlayContext`, or `NotFound`. LoadPlayContext, ClearPlayContext, /// Records the outcome of a refresh that did not produce a new feed body (304, @@ -80,6 +81,11 @@ pub enum StorageResult { Subscriptions(Vec), Episode(Episode), Episodes(Vec), + /// Answer to `LoadPlayContext`: the saved active episode and where it was started from. + PlayContext { + episode: Episode, + source: EpisodeSource, + }, /// Answer to `UpdateRefreshState`: how many episodes that write flagged /// `RemovedFromFeed` (a 304 confirms pending absences; nothing else flags any). EpisodesRemoved(u64), diff --git a/shared/src/domain/episode_source.rs b/shared/src/domain/episode_source.rs new file mode 100644 index 0000000..26a7181 --- /dev/null +++ b/shared/src/domain/episode_source.rs @@ -0,0 +1,19 @@ +use facet::Facet; +use serde::{Deserialize, Serialize}; + +/// Where a run of episodes comes from: the thing playback was started *from*, and what +/// "From: …" names and navigates back to. See `docs/DATA_MODEL.md` — EpisodeSource. +/// +/// A subscription behaves as an implicit single-feed playlist, so today it is the only +/// variant. Playlists add a `Playlist { id }` variant; the playback engine, the saved +/// play context and the player's "From:" row already carry an `EpisodeSource` rather +/// than a bare subscription id, so adding one is a change here and in the places that +/// resolve a source (its title, its episode order), not a schema or event change. +/// +/// An internal abstraction, never user-facing copy. The name is a placeholder, per the +/// data model. +#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[repr(C)] +pub enum EpisodeSource { + Subscription { id: String }, +} diff --git a/shared/src/domain/mod.rs b/shared/src/domain/mod.rs index 1266b45..bc83d28 100644 --- a/shared/src/domain/mod.rs +++ b/shared/src/domain/mod.rs @@ -1,5 +1,7 @@ pub mod episode; +pub mod episode_source; pub mod subscription; pub use episode::*; +pub use episode_source::*; pub use subscription::*; diff --git a/shared/src/model.rs b/shared/src/model.rs index 909ef4c..e902c52 100644 --- a/shared/src/model.rs +++ b/shared/src/model.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; -use crate::capabilities::player::PlayerSource; -use crate::domain::{Episode, EpisodeSortOrder, Subscription}; +use crate::capabilities::player::MediaSource; +use crate::domain::{Episode, EpisodeSortOrder, EpisodeSource, Subscription}; use crate::theme::{ThemeId, ThemeMode}; use crate::view_model::DownloadNotice; @@ -97,7 +97,11 @@ pub struct ActivePlayback { /// The engine's duration, authoritative over the feed's `episode.duration_secs`. pub duration_secs: Option, pub is_playing: bool, - pub source: PlayerSource, + /// What the engine reads: the stream or the downloaded file. + pub media: MediaSource, + /// What playback was started from (a subscription today, a playlist later); named + /// by the player's "From:" row and navigated back to from it. + pub source: EpisodeSource, /// Whether the shell's engine currently has this episode loaded. False right after /// a cold-start restore (and after an error), so the next Play issues a `Load`. pub loaded: bool, diff --git a/shared/src/player.rs b/shared/src/player.rs index e025957..56919e3 100644 --- a/shared/src/player.rs +++ b/shared/src/player.rs @@ -30,13 +30,13 @@ use crux_core::{render::render, Command}; use crate::app::{enqueue_download, reset_to_not_downloaded, Event}; -use crate::capabilities::player::{PlayerOperation, PlayerResult, PlayerSource}; +use crate::capabilities::player::{MediaSource, PlayerOperation, PlayerResult}; use crate::capabilities::storage::{StorageOperation, StorageResult}; use crate::defaults::{ PLAYED_TOLERANCE_SECS, POSITION_CHECKPOINT_SECS, RESUME_REWIND_SECS, SKIP_BACKWARD_SECS, SKIP_FORWARD_SECS, }; -use crate::domain::{DownloadStatus, Episode, PlaybackStatus}; +use crate::domain::{DownloadStatus, Episode, EpisodeSource, PlaybackStatus}; use crate::effect::Effect; use crate::model::{ActivePlayback, Model}; use crate::view_model::PlayerView; @@ -50,12 +50,12 @@ type Cmd = Command; /// Where an episode should play from: its downloaded file when we have one, else the /// network. -fn source_for(episode: &Episode) -> PlayerSource { +fn source_for(episode: &Episode) -> MediaSource { match (&episode.download_status, &episode.local_path) { - (DownloadStatus::Downloaded, Some(path)) => PlayerSource::Local { + (DownloadStatus::Downloaded, Some(path)) => MediaSource::Local { local_path: path.clone(), }, - _ => PlayerSource::Stream { + _ => MediaSource::Stream { url: episode.enclosure_url.clone(), }, } @@ -87,9 +87,10 @@ fn persist_playback(episode_id: &str, status: PlaybackStatus, position_secs: Opt .then_send(|r| Event::PlaybackPersisted(Box::new(r))) } -fn save_play_context(episode_id: &str) -> Cmd { +fn save_play_context(episode_id: &str, source: &EpisodeSource) -> Cmd { Command::request_from_shell(StorageOperation::SavePlayContext { episode_id: episode_id.to_string(), + source: source.clone(), }) .then_send(|r| Event::PlaybackPersisted(Box::new(r))) } @@ -200,7 +201,13 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { // Whatever was playing keeps its place (or counts as played) before it's replaced. let outgoing = leave_for_another(model); - let source = source_for(&episode); + let media = source_for(&episode); + // Started from this episode's feed. When playlists exist the view that initiated + // playback decides (the event will carry the source); until then a feed's episode + // list is the only place playback starts. + let source = EpisodeSource::Subscription { + id: episode.subscription_id.clone(), + }; // A played episode restarts from the top; otherwise resume with the usual rewind. let saved = match episode.playback_status { PlaybackStatus::Played => 0, @@ -212,7 +219,7 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { playing_episode.playback_status = PlaybackStatus::InProgress; playing_episode.playback_position_secs = Some(start); sync_episode_row(model, episode_id, &PlaybackStatus::InProgress, Some(start)); - let streaming = matches!(source, PlayerSource::Stream { .. }); + let streaming = matches!(media, MediaSource::Stream { .. }); let session = next_session(model); model.active_playback = Some(ActivePlayback { episode: playing_episode, @@ -220,6 +227,7 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { position_secs: start, duration_secs: None, is_playing: true, + media: media.clone(), source: source.clone(), loaded: true, last_checkpoint_secs: start, @@ -232,12 +240,12 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { PlaybackStatus::InProgress, Some(start), )) - .and(save_play_context(episode_id)) + .and(save_play_context(episode_id, &source)) .and(player_op( session, PlayerOperation::Load { session, - source, + media, start_secs: start, autoplay: true, }, @@ -272,7 +280,7 @@ pub(crate) fn play(model: &mut Model) -> Cmd { session, PlayerOperation::Load { session, - source: active.source.clone(), + media: active.media.clone(), start_secs: rewound, autoplay: true, }, @@ -356,7 +364,7 @@ pub(crate) fn seek_to(model: &mut Model, secs: u32) -> Cmd { session, PlayerOperation::Load { session, - source: active.source.clone(), + media: active.media.clone(), start_secs: target, autoplay: false, }, @@ -425,7 +433,7 @@ pub(crate) fn on_failure(model: &mut Model, session: u32, message: String) -> Cm let is_local = model .active_playback .as_ref() - .is_some_and(|a| matches!(a.source, PlayerSource::Local { .. })); + .is_some_and(|a| matches!(a.media, MediaSource::Local { .. })); if !is_local { if let Some(active) = model.active_playback.as_mut() { active.is_playing = false; @@ -440,11 +448,11 @@ pub(crate) fn on_failure(model: &mut Model, session: u32, message: String) -> Cm let Some(active) = model.active_playback.as_mut() else { return Command::done(); }; - let source = PlayerSource::Stream { + let media = MediaSource::Stream { url: active.episode.enclosure_url.clone(), }; let id = active.episode.id.clone(); - active.source = source.clone(); + active.media = media.clone(); active.session = fresh; active.episode.download_status = DownloadStatus::NotDownloaded; active.episode.local_path = None; @@ -457,7 +465,7 @@ pub(crate) fn on_failure(model: &mut Model, session: u32, message: String) -> Cm fresh, PlayerOperation::Load { session: fresh, - source, + media, start_secs: position, autoplay: was_playing, }, @@ -481,7 +489,7 @@ pub(crate) fn on_download_completed( size_bytes: u64, ) -> Cmd { let needs_swap = model.active_playback.as_ref().is_some_and(|a| { - a.episode.id == episode_id && a.loaded && matches!(a.source, PlayerSource::Stream { .. }) + a.episode.id == episode_id && a.loaded && matches!(a.media, MediaSource::Stream { .. }) }); let swap_session = needs_swap.then(|| next_session(model)); let Some(active) = model.active_playback.as_mut() else { @@ -493,8 +501,8 @@ pub(crate) fn on_download_completed( active.episode.download_status = DownloadStatus::Downloaded; active.episode.local_path = Some(local_path.to_string()); active.episode.file_size_bytes = Some(size_bytes); - if matches!(active.source, PlayerSource::Stream { .. }) { - active.source = PlayerSource::Local { + if matches!(active.media, MediaSource::Stream { .. }) { + active.media = MediaSource::Local { local_path: local_path.to_string(), }; } @@ -508,7 +516,7 @@ pub(crate) fn on_download_completed( session, PlayerOperation::Load { session, - source: active.source.clone(), + media: active.media.clone(), start_secs: active.position_secs, autoplay: active.is_playing, }, @@ -523,7 +531,7 @@ pub(crate) fn on_context_loaded(model: &mut Model, result: StorageResult) -> Cmd if model.active_playback.is_some() { return Command::done(); } - let StorageResult::Episode(episode) = result else { + let StorageResult::PlayContext { episode, source } = result else { return Command::done(); }; if episode.playback_status == PlaybackStatus::Played { @@ -531,7 +539,8 @@ pub(crate) fn on_context_loaded(model: &mut Model, result: StorageResult) -> Cmd } let position = episode.playback_position_secs.unwrap_or(0); model.active_playback = Some(ActivePlayback { - source: source_for(&episode), + media: source_for(&episode), + source, episode, session: 0, position_secs: position, @@ -556,11 +565,21 @@ pub(crate) fn player_view(model: &Model) -> Option { .subscriptions .iter() .find(|s| s.id == active.episode.subscription_id); + // The source's display name. A subscription is named by its feed. + let source_title = match &active.source { + EpisodeSource::Subscription { id } => model + .subscriptions + .iter() + .find(|s| &s.id == id) + .map(|s| s.title.clone()) + .unwrap_or_default(), + }; Some(PlayerView { episode_id: active.episode.id.clone(), - subscription_id: active.episode.subscription_id.clone(), episode_title: active.episode.title.clone(), feed_title: subscription.map(|s| s.title.clone()).unwrap_or_default(), + source: active.source.clone(), + source_title, artwork_url: active .episode .artwork_url @@ -569,7 +588,7 @@ pub(crate) fn player_view(model: &Model) -> Option { position_secs: active.position_secs, duration_secs: known_duration(active), is_playing: active.is_playing, - is_streaming: matches!(active.source, PlayerSource::Stream { .. }), + is_streaming: matches!(active.media, MediaSource::Stream { .. }), skip_forward_secs: SKIP_FORWARD_SECS, skip_back_secs: SKIP_BACKWARD_SECS, error: active.error.clone(), diff --git a/shared/src/player/played_tests.rs b/shared/src/player/played_tests.rs index 54c64ef..b3ab62f 100644 --- a/shared/src/player/played_tests.rs +++ b/shared/src/player/played_tests.rs @@ -4,8 +4,8 @@ use crux_core::App; use super::tests::{ - active, checkpoints, episode, model_with, player_ops, send, send_ended, session, storage_ops, - tick, view_player, + active, checkpoints, episode, model_with, player_ops, saved_context, send, send_ended, session, + storage_ops, tick, view_player, }; use super::*; use crate::Pollux; @@ -53,10 +53,7 @@ fn a_played_episode_is_not_restored_and_its_saved_context_is_cleared() { e.playback_position_secs = Some(0); let mut model = model_with(vec![]); - let effects = send( - &mut model, - Event::PlayContextLoaded(Box::new(StorageResult::Episode(e))), - ); + let effects = send(&mut model, saved_context(e)); assert!(Pollux.view(&model).player.is_none()); assert!(storage_ops(&effects) @@ -71,10 +68,7 @@ fn an_in_progress_episode_is_still_restored() { e.playback_position_secs = Some(595); let mut model = model_with(vec![]); - send( - &mut model, - Event::PlayContextLoaded(Box::new(StorageResult::Episode(e))), - ); + send(&mut model, saved_context(e)); assert_eq!(view_player(&model).position_secs, 595); } diff --git a/shared/src/player/tests.rs b/shared/src/player/tests.rs index 55e84d6..f64e6f0 100644 --- a/shared/src/player/tests.rs +++ b/shared/src/player/tests.rs @@ -49,6 +49,15 @@ pub(super) fn send(model: &mut Model, event: Event) -> Vec { Pollux.update(event, model).effects().collect() } +/// What the shell answers `LoadPlayContext` with: the saved episode and where it was +/// started from (the subscription the test episodes belong to). +pub(super) fn saved_context(episode: Episode) -> Event { + Event::PlayContextLoaded(Box::new(StorageResult::PlayContext { + episode, + source: EpisodeSource::Subscription { id: "sub".into() }, + })) +} + /// The current load's session, which the shell tags every engine event with. pub(super) fn session(model: &Model) -> u32 { active(model).session @@ -122,7 +131,7 @@ fn playing_an_undownloaded_episode_streams_and_starts_a_download() { assert!(matches!( player_ops(&effects).as_slice(), [PlayerOperation::Load { - source: PlayerSource::Stream { .. }, + media: MediaSource::Stream { .. }, start_secs: 0, autoplay: true, .. @@ -153,7 +162,7 @@ fn playing_a_downloaded_episode_uses_the_file_and_resumes_with_a_rewind() { assert!(matches!( player_ops(&effects).as_slice(), [PlayerOperation::Load { - source: PlayerSource::Local { .. }, + media: MediaSource::Local { .. }, start_secs: 97, .. }] @@ -319,7 +328,7 @@ fn a_finished_download_swaps_the_stream_for_the_local_file_at_the_playhead() { assert!(matches!( player_ops(&effects).as_slice(), [PlayerOperation::Load { - source: PlayerSource::Local { .. }, + media: MediaSource::Local { .. }, start_secs: 77, autoplay: true, .. @@ -372,7 +381,7 @@ fn a_missing_local_file_falls_back_to_streaming_and_redownloads() { assert!(player_ops(&effects).iter().any(|op| matches!( op, PlayerOperation::Load { - source: PlayerSource::Stream { .. }, + media: MediaSource::Stream { .. }, autoplay: true, .. } @@ -388,10 +397,7 @@ fn cold_start_restores_the_active_episode_paused_and_loads_on_first_play() { e.playback_position_secs = Some(200); let mut model = model_with(vec![]); - let effects = send( - &mut model, - Event::PlayContextLoaded(Box::new(StorageResult::Episode(e))), - ); + let effects = send(&mut model, saved_context(e)); assert!( player_ops(&effects).is_empty(), "restore must not touch the engine" @@ -421,3 +427,52 @@ fn nothing_saved_means_no_player() { ); assert!(Pollux.view(&model).player.is_none()); } + +#[test] +fn playing_saves_the_source_with_the_play_context() { + let mut model = model_with(vec![episode("e1")]); + + let effects = send(&mut model, Event::PlayEpisode("e1".into())); + + assert!(storage_ops(&effects).iter().any(|op| matches!( + op, + StorageOperation::SavePlayContext { episode_id, source } + if episode_id == "e1" + && *source == EpisodeSource::Subscription { id: "sub".into() } + ))); + assert_eq!( + active(&model).source, + EpisodeSource::Subscription { id: "sub".into() } + ); +} + +#[test] +fn the_player_view_names_and_targets_the_source() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + + let view = view_player(&model); + + assert_eq!( + view.source, + EpisodeSource::Subscription { id: "sub".into() } + ); + assert_eq!(view.source_title, "Feed"); +} + +#[test] +fn a_restored_episode_keeps_the_source_it_was_started_from() { + let mut model = model_with(vec![]); + let mut e = episode("e1"); + e.playback_status = PlaybackStatus::InProgress; + e.playback_position_secs = Some(30); + + send(&mut model, saved_context(e)); + + let view = view_player(&model); + assert_eq!( + view.source, + EpisodeSource::Subscription { id: "sub".into() } + ); + assert_eq!(view.source_title, "Feed"); +} diff --git a/shared/src/view_model.rs b/shared/src/view_model.rs index 0a74012..ed62059 100644 --- a/shared/src/view_model.rs +++ b/shared/src/view_model.rs @@ -1,7 +1,7 @@ use facet::Facet; use serde::{Deserialize, Serialize}; -use crate::domain::{DownloadStatus, EpisodeSortOrder, PlaybackStatus}; +use crate::domain::{DownloadStatus, EpisodeSortOrder, EpisodeSource, PlaybackStatus}; use crate::theme::ThemeView; #[derive(Facet, Serialize, Deserialize, Clone, Default)] @@ -21,9 +21,13 @@ pub struct ViewModel { #[derive(Facet, Serialize, Deserialize, Clone)] pub struct PlayerView { pub episode_id: String, - pub subscription_id: String, pub episode_title: String, + /// The episode's own feed (the Now Playing "artist"), distinct from `source`. pub feed_title: String, + /// What playback was started from; the "From:" row names `source_title` and + /// navigates back to `source`. + pub source: EpisodeSource, + pub source_title: String, /// Episode art, falling back to the feed's; the shell supplies the placeholder. pub artwork_url: Option, pub position_secs: u32, From 85c77fd763ee025dfd8acc50dbbcc39d367b0c5c Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Tue, 6 Oct 2026 11:14:21 +0200 Subject: [PATCH 06/32] feat: show the episode's show notes on the player's second page Replace the "Show notes coming soon" placeholder with the real show notes, rendered as rich text with the same renderer as the episode detail page, in a scrollable page. - PlayerView carries the episode description (raw HTML) plus a plain-text fallback for the frame before the HTML is parsed; a blank description counts as none - the page and its dots are left out when the episode has no show notes, and the selection snaps back to the artwork if the next episode has none - update player.md, player-show-notes.md and the roadmap, which described the page as an MVP placeholder; timestamp detection is still unbuilt Co-Authored-By: Claude Sonnet 5.5 --- docs/ROADMAP.md | 5 ++-- docs/features/player-show-notes.md | 11 ++++--- docs/features/player.md | 9 ++++-- iOS/Pollux/PlayerScreen.swift | 48 +++++++++++++++++++++++------- shared/src/app.rs | 2 +- shared/src/player.rs | 15 +++++++++- shared/src/player/tests.rs | 32 ++++++++++++++++++++ shared/src/view_model.rs | 5 ++++ 8 files changed, 104 insertions(+), 23 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ed5fd12..3c9129c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -68,7 +68,7 @@ The core listening experience. indicators, three-dots menu, swipe gestures - **Audio playback engine**: OS media integration, position tracking, resume with rewind -- **Player UI**: artwork carousel (art page only, show notes placeholder), +- **Player UI**: artwork carousel (art page and show notes page), scrubber, skip buttons (30s/15s defaults), play/pause, source context, player options menu stub - **Mini-player**: visible when active, play/pause + tap to expand, progress @@ -133,7 +133,8 @@ Milestone: MVP complete. App is usable end-to-end for its core purpose. The archive binge feature is the reason this app exists. Once MVP is solid: -- **Show notes**: player carousel page, timestamp detection → synthetic chapters +- **Show notes**: timestamp detection → synthetic chapters (the player's show-notes + page itself is built) - **Archive binge playlist**: composite playlist template, surfaced in create playlist flow, one-tap setup - **OPML import/export**: standard import for migrating from other apps diff --git a/docs/features/player-show-notes.md b/docs/features/player-show-notes.md index 60c9716..05b0cd0 100644 --- a/docs/features/player-show-notes.md +++ b/docs/features/player-show-notes.md @@ -9,17 +9,17 @@ description / show notes. ## MVP State -Show notes are **not in MVP**. The carousel slot is reserved with a placeholder -page ("Show notes coming soon" or similar). The dot indicator for this page -is still shown so users know it's coming. +The page is built. It shows the episode's show notes rendered as rich text (HTML +from the feed, via the same renderer as the episode detail page) in a scrollable +area. If the episode has no show notes the page is not shown, and neither are the +dot indicators. Timestamp detection (below) is not built yet. ## Full Implementation ### Content - Scrollable text area showing the episode description extracted from the feed -- Rendered as plain text initially; HTML/rich text rendering is a later - enhancement +- Rendered as rich text (built); timestamps are not yet links - If no show notes are available, the page is hidden (dot indicator removed) ### Timestamp Detection @@ -44,6 +44,5 @@ This is the primary mechanism for shows that don't use embedded chapter formats. ## Future -- HTML/rich text rendering of show notes - Chapter list as a separate carousel page - Link detection and handling in show notes (e.g. tappable URLs) diff --git a/docs/features/player.md b/docs/features/player.md index 877b682..7d4af3e 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -24,8 +24,11 @@ indicators). MVP pages: 1. **Artwork**: episode image → feed image → placeholder. Chapter art shown if available and episode is in a chapter (see `episode-chapters.md`). -2. **Show notes**: placeholder page for MVP ("Show notes coming soon"). - See `player-show-notes.md` for full implementation. +2. **Show notes**: the episode's show notes, rendered as rich text (the same + renderer as the episode detail page) and scrollable. The page, and the dot + indicators, are left out when the episode has no show notes. Timestamp + detection and tappable timestamps are not built yet; see + `player-show-notes.md`. *(Future)* Additional pages: chapter list, visualizer (see `visualizer.md`) @@ -87,7 +90,7 @@ the player without leaving the view. ``` NavBar -[ Paged content area: Art | (Show notes placeholder) ] +[ Paged content area: Art | Show notes (if any) ] [ Dot indicators ] Position / scrubber (Chapter Back) | Title & Chapter, From Source | (Chapter Next) ← if chapters present diff --git a/iOS/Pollux/PlayerScreen.swift b/iOS/Pollux/PlayerScreen.swift index fdf838c..41cd4df 100644 --- a/iOS/Pollux/PlayerScreen.swift +++ b/iOS/Pollux/PlayerScreen.swift @@ -35,28 +35,56 @@ struct PlayerScreen: View { // MARK: - Paged content + /// The show-notes page exists only when the episode has show notes; with one page + /// there is nothing to swipe to, so no dots either. + private var pageCount: Int { + player.description == nil ? 1 : 2 + } + private var pages: some View { VStack(spacing: 8) { TabView(selection: $page) { ArtworkView(urlString: player.artworkUrl, size: 300) .frame(maxWidth: .infinity, maxHeight: .infinity) .tag(0) - Text("Show notes coming soon") - .foregroundStyle(themeColors.secondaryText) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .tag(1) + if let description = player.description { + showNotesPage(html: description) + .tag(1) + } } .tabViewStyle(.page(indexDisplayMode: .never)) // The system dots are fixed light-on-light; draw our own in theme colors. - HStack(spacing: 8) { - ForEach(0 ..< 2, id: \.self) { index in - Circle() - .fill(index == page ? themeColors.text : themeColors.secondaryText.opacity(0.4)) - .frame(width: 8, height: 8) + if pageCount > 1 { + HStack(spacing: 8) { + ForEach(0 ..< pageCount, id: \.self) { index in + Circle() + .fill(index == page ? themeColors.text : themeColors.secondaryText.opacity(0.4)) + .frame(width: 8, height: 8) + } } + .accessibilityHidden(true) + } + } + // The next episode may have no show notes while the old page was selected. + .onChange(of: pageCount) { _, count in + if page >= count { + page = 0 + } + } + } + + private func showNotesPage(html: String) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text("Show notes") + .font(.headline) + .foregroundStyle(themeColors.text) + ShowNotesText(html: html, fallback: player.descriptionText) } - .accessibilityHidden(true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) } + .accessibilityLabel("Show notes") } // MARK: - Scrubber diff --git a/shared/src/app.rs b/shared/src/app.rs index 8208855..8b9e66a 100644 --- a/shared/src/app.rs +++ b/shared/src/app.rs @@ -768,7 +768,7 @@ fn build_subscription_detail(model: &Model) -> SubscriptionDetailView { /// Number of characters of stripped description shipped for the row preview. A row /// shows a single line, so this is well above what can be displayed; the rest is /// never processed (see `strip_html_preview`). -const DESCRIPTION_PREVIEW_CHARS: usize = 200; +pub(crate) const DESCRIPTION_PREVIEW_CHARS: usize = 200; /// Projects a stored `Episode` into its display `EpisodeSummary`, stripping a short /// plain-text preview of the description for the row while keeping the raw HTML for diff --git a/shared/src/player.rs b/shared/src/player.rs index 56919e3..777d9ab 100644 --- a/shared/src/player.rs +++ b/shared/src/player.rs @@ -29,7 +29,7 @@ use crux_core::{render::render, Command}; -use crate::app::{enqueue_download, reset_to_not_downloaded, Event}; +use crate::app::{enqueue_download, reset_to_not_downloaded, Event, DESCRIPTION_PREVIEW_CHARS}; use crate::capabilities::player::{MediaSource, PlayerOperation, PlayerResult}; use crate::capabilities::storage::{StorageOperation, StorageResult}; use crate::defaults::{ @@ -38,6 +38,7 @@ use crate::defaults::{ }; use crate::domain::{DownloadStatus, Episode, EpisodeSource, PlaybackStatus}; use crate::effect::Effect; +use crate::html::strip_html_preview; use crate::model::{ActivePlayback, Model}; use crate::view_model::PlayerView; @@ -574,6 +575,16 @@ pub(crate) fn player_view(model: &Model) -> Option { .map(|s| s.title.clone()) .unwrap_or_default(), }; + // A blank description is no show notes at all, so the page can be left out. + let description = active + .episode + .description + .clone() + .filter(|d| !d.trim().is_empty()); + let description_text = description + .as_deref() + .map(|d| strip_html_preview(d, DESCRIPTION_PREVIEW_CHARS)) + .filter(|t| !t.is_empty()); Some(PlayerView { episode_id: active.episode.id.clone(), episode_title: active.episode.title.clone(), @@ -591,6 +602,8 @@ pub(crate) fn player_view(model: &Model) -> Option { is_streaming: matches!(active.media, MediaSource::Stream { .. }), skip_forward_secs: SKIP_FORWARD_SECS, skip_back_secs: SKIP_BACKWARD_SECS, + description, + description_text, error: active.error.clone(), }) } diff --git a/shared/src/player/tests.rs b/shared/src/player/tests.rs index f64e6f0..d45d87f 100644 --- a/shared/src/player/tests.rs +++ b/shared/src/player/tests.rs @@ -476,3 +476,35 @@ fn a_restored_episode_keeps_the_source_it_was_started_from() { ); assert_eq!(view.source_title, "Feed"); } + +#[test] +fn the_player_view_carries_the_show_notes_with_a_plain_text_fallback() { + let mut e = episode("e1"); + e.description = Some("

Hello world

".into()); + let mut model = model_with(vec![e]); + send(&mut model, Event::PlayEpisode("e1".into())); + + let view = view_player(&model); + + assert_eq!( + view.description.as_deref(), + Some("

Hello world

") + ); + assert_eq!(view.description_text.as_deref(), Some("Hello world")); +} + +#[test] +fn an_episode_without_show_notes_has_none_to_show() { + // Missing and blank descriptions both mean the show-notes page is left out. + for description in [None, Some(" \n ".to_string())] { + let mut e = episode("e1"); + e.description = description; + let mut model = model_with(vec![e]); + send(&mut model, Event::PlayEpisode("e1".into())); + + let view = view_player(&model); + + assert!(view.description.is_none()); + assert!(view.description_text.is_none()); + } +} diff --git a/shared/src/view_model.rs b/shared/src/view_model.rs index ed62059..e727fd0 100644 --- a/shared/src/view_model.rs +++ b/shared/src/view_model.rs @@ -37,6 +37,11 @@ pub struct PlayerView { pub is_streaming: bool, pub skip_forward_secs: u32, pub skip_back_secs: u32, + /// The episode's show notes (raw HTML from the feed) for the player's show-notes + /// page, or `None` when it has none (the page is then not shown). Plain-text + /// `description_text` is the fallback shown until the shell has rendered the HTML. + pub description: Option, + pub description_text: Option, /// Why playback failed, shown as a transient banner; playback stays paused. pub error: Option, } From 4df2db5e903a5cdbf708357fe2a2aafc30a58566 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Tue, 6 Oct 2026 11:20:11 +0200 Subject: [PATCH 07/32] feat: fill the player width with artwork and accent the primary controls - ArtworkView takes size: nil to fill the offered width as the largest square that fits; the player's artwork page uses it. Fixed-size callers are unchanged - play/pause and the skip buttons in the player, and the mini-player's play/pause, use the theme accent; the secondary row (hide, output routing, options) stays in the text color - note both, and the possibility of a configurable layout, in player.md Co-Authored-By: Claude Sonnet 5.5 --- docs/features/player.md | 7 +++++++ iOS/Pollux/ArtworkView.swift | 27 ++++++++++++++++++++++----- iOS/Pollux/MiniPlayerBar.swift | 2 +- iOS/Pollux/PlayerScreen.swift | 5 +++-- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/docs/features/player.md b/docs/features/player.md index 7d4af3e..2d383b9 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -32,6 +32,13 @@ indicators). MVP pages: *(Future)* Additional pages: chapter list, visualizer (see `visualizer.md`) +The artwork fills the width of the player (as large a square as fits). A +user-configurable player layout is a possible later feature; the default layout +below is the only one for now. + +Primary controls (play/pause, skip) are drawn in the theme's accent color; the +secondary row (hide, output routing, options) uses the text color. + Note: for large screens, MVP will remain paged, but multiple pages may be shown simultaneously if space allows. diff --git a/iOS/Pollux/ArtworkView.swift b/iOS/Pollux/ArtworkView.swift index 47c5e1c..dcd2bf5 100644 --- a/iOS/Pollux/ArtworkView.swift +++ b/iOS/Pollux/ArtworkView.swift @@ -2,21 +2,38 @@ import SwiftUI /// Square podcast/episode artwork loaded from a URL, with a neutral placeholder /// while loading or when the URL is missing or fails. +/// +/// With a `size` it is a fixed square. With `size: nil` it fills the width it is +/// offered, as large a square as fits (the full player's artwork). struct ArtworkView: View { @Environment(\.themeColors) private var themeColors let urlString: String? - var size: CGFloat = 56 + var size: CGFloat? = 56 + + private var cornerRadius: CGFloat { + size == nil ? 12 : 8 + } var body: some View { - artwork - .frame(width: size, height: size) - .clipShape(RoundedRectangle(cornerRadius: 8)) + square + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) .overlay( - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(themeColors.secondaryBackground, lineWidth: 0.5), ) } + @ViewBuilder private var square: some View { + if let size { + artwork.frame(width: size, height: size) + } else { + // Clear square sets the bounds (largest that fits); the image fills it. + Color.clear + .aspectRatio(1, contentMode: .fit) + .overlay { artwork } + } + } + @ViewBuilder private var artwork: some View { if let urlString, let url = URL(string: urlString) { AsyncImage(url: url) { phase in diff --git a/iOS/Pollux/MiniPlayerBar.swift b/iOS/Pollux/MiniPlayerBar.swift index f81bb38..7cb4f4e 100644 --- a/iOS/Pollux/MiniPlayerBar.swift +++ b/iOS/Pollux/MiniPlayerBar.swift @@ -46,7 +46,7 @@ struct MiniPlayerBar: View { .frame(width: 44, height: 44) } .buttonStyle(.plain) - .foregroundStyle(themeColors.text) + .foregroundStyle(themeColors.accent) .accessibilityLabel(player.isPlaying ? "Pause" : "Play") } .padding(.horizontal) diff --git a/iOS/Pollux/PlayerScreen.swift b/iOS/Pollux/PlayerScreen.swift index 41cd4df..df7081e 100644 --- a/iOS/Pollux/PlayerScreen.swift +++ b/iOS/Pollux/PlayerScreen.swift @@ -44,7 +44,7 @@ struct PlayerScreen: View { private var pages: some View { VStack(spacing: 8) { TabView(selection: $page) { - ArtworkView(urlString: player.artworkUrl, size: 300) + ArtworkView(urlString: player.artworkUrl, size: nil) .frame(maxWidth: .infinity, maxHeight: .infinity) .tag(0) if let description = player.description { @@ -170,7 +170,8 @@ struct PlayerScreen: View { .accessibilityLabel(player.isPlaying ? "Pause" : "Play") skipButton(forward: true) } - .foregroundStyle(themeColors.text) + // Primary controls take the accent; the secondary row below stays in text color. + .foregroundStyle(themeColors.accent) } private func skipButton(forward: Bool) -> some View { From a5333af07ea7013a53a032e60cc15171b5a34f67 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 07:41:24 +0200 Subject: [PATCH 08/32] refactor: create the audio engine and lock-screen integration on first play The core is also created when iOS wakes the app in the background to refresh feeds, and that launch should stay metadata-only. PlaybackManager and NowPlayingController were built in Core.init, so every such launch registered remote-command handlers it never needed. - create both in startPlaybackIfNeeded(), on the first player operation, and publish the current Now Playing state straight away - a background-refresh launch still restores the saved episode for the mini-player (a DB read) but touches neither audio nor the lock screen - a restored, paused episode now appears on the lock screen only once it has been played - add wiring tests: restoring does not create the engine; the first play request does Co-Authored-By: Claude Sonnet 5.5 --- iOS/Pollux/core.swift | 40 ++++++++++++++++++++----- iOS/PolluxTests/CoreWiringTests.swift | 42 +++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/iOS/Pollux/core.swift b/iOS/Pollux/core.swift index 3142d75..4926f2c 100644 --- a/iOS/Pollux/core.swift +++ b/iOS/Pollux/core.swift @@ -9,8 +9,20 @@ class Core: ObservableObject { private var core: CoreFfi private let db: DatabaseManager private let downloads: DownloadManager + /// The audio engine and the lock-screen integration. Created on the first playback + /// request, not at launch: the core is also created when iOS wakes the app in the + /// background to refresh feeds, and that launch must stay metadata-only (it restores + /// the saved episode for the mini-player, but touches neither audio nor the lock + /// screen). See `startPlaybackIfNeeded`. private var playback: PlaybackManager? private var nowPlaying: NowPlayingController? + + /// Whether the audio engine has been created, i.e. whether playback has ever been + /// requested in this launch. + var hasPlaybackEngine: Bool { + playback != nil + } + /// The session feed fetches go through. Injectable so tests can stub the network. private let feedSession: URLSession /// Asks the system for a future background-refresh wake-up. Injectable so tests can @@ -62,14 +74,6 @@ class Core: ObservableObject { fatalError("Failed to deserialize initial ViewModel from core") } self.view = view - if let root = DownloadManager.defaultStorageRoot() { - playback = PlaybackManager(storageRoot: root) { [weak self] event in - self?.update(event) - } - } - nowPlaying = NowPlayingController { [weak self] event in - self?.update(event) - } Task { @MainActor [weak self] in for await (operation, requestId) in storageStream { guard let self else { return } @@ -178,6 +182,7 @@ class Core: ObservableObject { case let .player(operation): // The engine acts synchronously; resolve on the next turn so the result // doesn't re-enter the core while it is still handing us this batch. + startPlaybackIfNeeded() let result = playback?.perform(operation) ?? .error("Audio engine unavailable") let requestId = request.id Task { @MainActor [weak self] in @@ -186,6 +191,25 @@ class Core: ObservableObject { } } + /// Creates the audio engine and the lock-screen integration the first time playback + /// is requested. Until then a launch, including a background-refresh one, has neither: + /// nothing is registered with the system's remote-command center, and no Now Playing + /// card is published (so a restored, paused episode isn't on the lock screen until + /// it is played). + private func startPlaybackIfNeeded() { + guard playback == nil else { return } + if let root = DownloadManager.defaultStorageRoot() { + playback = PlaybackManager(storageRoot: root) { [weak self] event in + self?.update(event) + } + } + nowPlaying = NowPlayingController { [weak self] event in + self?.update(event) + } + // Publish the current state now rather than at the next render. + nowPlaying?.update(view.player) + } + /// Runs a download-capability operation on the DownloadManager actor (so the UI /// isn't blocked) and resolves the request with its terminal result. Byte progress /// is funnelled through a stream drained by a single consumer, so DownloadProgress diff --git a/iOS/PolluxTests/CoreWiringTests.swift b/iOS/PolluxTests/CoreWiringTests.swift index 9abd4f8..406ed87 100644 --- a/iOS/PolluxTests/CoreWiringTests.swift +++ b/iOS/PolluxTests/CoreWiringTests.swift @@ -250,3 +250,45 @@ struct CoreWiringTests { #expect(rig.server.requestCount(for: "/e1.mp3") == 1) } } + +// MARK: - Playback is created on demand + +/// Saves an episode (with an unusable, empty enclosure URL, so a play attempt fails +/// before any audio is touched) as the active play context. +private func seedPlayContext(_ db: DatabaseManager) async throws { + let sub = Subscription( + id: "sub", feedUrl: "https://example.com/sub.rss", title: "Feed", artworkUrl: nil, + description: nil, lastRefreshed: longAgo, createdAt: longAgo, etag: nil, lastModified: nil, + lastRefreshError: nil, retryAfterUntil: nil, + ) + try await db.execute(.upsertSubscription(sub)) + let episode = Episode( + id: "ep", feedGuid: "ep-guid", subscriptionId: "sub", title: "Episode", description: nil, + pubDate: 1, durationSecs: 600, enclosureUrl: "", artworkUrl: nil, + playbackStatus: .inProgress, playbackPositionSecs: 30, downloadStatus: .notDownloaded, + isFlagged: false, fileSizeBytes: nil, localPath: nil, + ) + try await db.execute(.upsertEpisode(episode)) + try await db.execute(.savePlayContext(episodeId: "ep", source: .subscription(id: "sub"))) +} + +@Test @MainActor func restoringTheSavedEpisodeDoesNotCreateTheAudioEngine() async throws { + let rig = try await makeRig { db, _ in try await seedPlayContext(db) } + + // The mini-player comes back (this is also what a background-refresh launch does)… + try await waitUntil { rig.core.view.player != nil } + + // …without an audio engine or lock-screen integration behind it. + #expect(rig.core.view.player?.isPlaying == false) + #expect(!rig.core.hasPlaybackEngine) +} + +@Test @MainActor func theFirstPlayRequestCreatesTheAudioEngine() async throws { + let rig = try await makeRig { db, _ in try await seedPlayContext(db) } + try await waitUntil { rig.core.view.player != nil } + #expect(!rig.core.hasPlaybackEngine) + + rig.core.update(.togglePlay) + + #expect(rig.core.hasPlaybackEngine) +} From 55af588965ddad27ee4b40b962b2669d73fbe21a Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 07:50:02 +0200 Subject: [PATCH 09/32] docs: mark the player follow-ups done in PLAN.md; fix a stale doc comment - PLAN.md: mark round 2 items 1-4 done with what was built, keep 5 (lock screen) and 6 (swap gap) open, and record both open questions' answers; the original plan text is unchanged - capabilities/player.rs: the Load doc still said `source` after the rename to `media` --- PLAN.md | 16 ++++++++-------- shared/src/capabilities/player.rs | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/PLAN.md b/PLAN.md index 421aa8e..acd9fbb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -54,12 +54,12 @@ Built and merged with main; `make check` and `make ios-test` pass. Where this di Verified in the simulator: streaming playback, skip, pause/resume with rewind, cold-start restore, "From:" navigation. **Not verified** (needs a device or a repro): lock-screen rendering, background audio, the stream-to-local swap gap, the error banner, audio interruptions, the AirPlay picker. ## Round 2: review follow-ups -Suggested order; each is its own commit. -1. **Restore and played logic.** Do not restore an episode whose status is Played; clear the saved context instead (`on_context_loaded` in `player.rs`). Likely cause of "finished episode comes back at 0:00": a checkpoint in the last 15s marks it Played (position 0) but leaves the player active and the context saved, and only the engine's natural-end event clears it; reproduce first. Make "played" one decision (natural end, or an explicit seek into the tail) rather than a side effect of every checkpoint, covering: later ticks must not overwrite the reset position, episodes shorter than the tolerance, unknown duration, and a wrong feed duration before the engine reports the real one. Add a per-load session id to engine events so a stale event for the same episode (after a source swap or retry) cannot change state. Hide stays UI-only. -2. **Source-context abstraction.** Add a small source context (subscription only for now) to `ActivePlayback` and `play_context` (kind + id, in the `v1_initial` baseline); "From:" and go-to-source read from it so playlists drop in later. Rename `PlayerSource` (stream/local) to `MediaSource` to avoid confusion. Keep `DATA_MODEL.md` PlayContext consistent. -3. **Show notes on the player's second page.** Replace the "coming soon" placeholder with real show notes (reuse `ShowNotesText`); add the episode description to `PlayerView`. Update `docs/features/player.md` (currently says placeholder for MVP), `player-show-notes.md` and the ROADMAP. -4. **Player UI polish.** Controls render black because they use the `text` token; use `accent` for primary controls and `text` for secondary (treatment TBD). Artwork fills the width at 1:1 (make `ArtworkView` flexible). A user-configurable player layout is a later feature; note it in `player.md`. -5. **Lock-screen controls are invisible but work.** Check on a real device first (may be a simulator quirk). If it persists: set `MPNowPlayingInfoPropertyMediaType` to audio and set the skip `preferredIntervals` before the first card update. -6. **Stream-to-local swap is audible (later).** It seeks back to the last whole-second tick, so up to ~1s repeats, plus load time. Options: use the engine's exact time; preroll and seek the local item before swapping; or defer the swap to the next pause or seek. +Suggested order; each was its own commit. **Items 1 to 4 and both open questions are done**; 5 and 6 need a device (below). +1. **Restore and played logic. Done.** As built: played is decided only when the listener leaves (natural end, an explicit pause in the final 15s, or starting another episode from the tail), never by a checkpoint; a seek that lands on the end finishes the episode; system pauses send a new `Interrupted` event that keeps the place and never finishes; every load has a session id and engine events from any other session are ignored; a played episode is not restored and its context is cleared. Original plan: do not restore an episode whose status is Played; clear the saved context instead (`on_context_loaded` in `player.rs`). Likely cause of "finished episode comes back at 0:00": a checkpoint in the last 15s marks it Played (position 0) but leaves the player active and the context saved, and only the engine's natural-end event clears it; reproduce first. Make "played" one decision (natural end, or an explicit seek into the tail) rather than a side effect of every checkpoint, covering: later ticks must not overwrite the reset position, episodes shorter than the tolerance, unknown duration, and a wrong feed duration before the engine reports the real one. Add a per-load session id to engine events so a stale event for the same episode (after a source swap or retry) cannot change state. Hide stays UI-only. +2. **Source-context abstraction. Done.** As built: the `EpisodeSource` enum (the name `DATA_MODEL.md` already planned), saved as `source_kind` + `source_id` in `play_context`; `PlayerSource` is now `MediaSource` and the engine's `Load` field is `media`. Original plan: add a small source context (subscription only for now) to `ActivePlayback` and `play_context` (kind + id, in the `v1_initial` baseline); "From:" and go-to-source read from it so playlists drop in later. Rename `PlayerSource` (stream/local) to `MediaSource` to avoid confusion. Keep `DATA_MODEL.md` PlayContext consistent. +3. **Show notes on the player's second page. Done.** The page is hidden, with its dots, when the episode has no show notes; timestamp detection is still unbuilt. Original plan: replace the "coming soon" placeholder with real show notes (reuse `ShowNotesText`); add the episode description to `PlayerView`. Update `docs/features/player.md` (currently says placeholder for MVP), `player-show-notes.md` and the ROADMAP. +4. **Player UI polish. Done.** Play/pause and the skip buttons (and the mini-player's play/pause) use the accent as filled glyphs; the secondary row stays in the text color; the artwork fills the width. Original plan: controls render black because they use the `text` token; use `accent` for primary controls and `text` for secondary (treatment TBD). Artwork fills the width at 1:1 (make `ArtworkView` flexible). A user-configurable player layout is a later feature; note it in `player.md`. +5. **Lock-screen controls are invisible but work. Open (needs a device).** Check on a real device first (may be a simulator quirk). If it persists: set `MPNowPlayingInfoPropertyMediaType` to audio and set the skip `preferredIntervals` before the first card update. +6. **Stream-to-local swap is audible (later). Open.** It seeks back to the last whole-second tick, so up to ~1s repeats, plus load time. Options: use the engine's exact time; preroll and seek the local item before swapping; or defer the swap to the next pause or seek. -Open questions: button color treatment (accent glyphs vs. an accent-filled play/pause circle); whether to create `PlaybackManager`/`NowPlayingController` lazily so background-refresh launches stay strictly metadata-only (today they are created in `Core.init`; harmless, since the audio session only activates on `Load`). +Open questions, both resolved: the button treatment is accent-colored filled glyphs; `PlaybackManager` and `NowPlayingController` are now created on the first playback request (`startPlaybackIfNeeded` in `core.swift`) instead of in `Core.init`, so a background-refresh launch stays metadata-only. A restored, paused episode therefore appears on the lock screen only once it has been played. diff --git a/shared/src/capabilities/player.rs b/shared/src/capabilities/player.rs index 8d041b1..334baf6 100644 --- a/shared/src/capabilities/player.rs +++ b/shared/src/capabilities/player.rs @@ -21,7 +21,7 @@ pub enum MediaSource { #[derive(Facet, Serialize, Deserialize, Clone, Debug)] #[repr(C)] pub enum PlayerOperation { - /// Replace the current item with `source`, positioned at `start_secs`. Starts + /// Replace the current item with `media`, positioned at `start_secs`. Starts /// playing immediately when `autoplay` is set. Also used to swap a streaming /// episode onto its freshly downloaded local copy mid-play. Load { From ac173f8e3f569127b0f41f9986b1166bab7f5e60 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 07:58:16 +0200 Subject: [PATCH 10/32] docs: record the audible stream-to-local swap as a known limitation Add a user story and notes on the gap and repeated second when playback moves onto a freshly downloaded file, the options for fixing it (continue from the engine's exact time; cue just ahead and hand over via AVQueuePlayer; swap at a pause or seek), and the caveats (the engine conversion, judging it by ear on a device, and dynamic ad insertion). --- docs/features/player.md | 41 ++++++++++++++++++++++++++++ docs/user_stories/player/playback.md | 4 +++ 2 files changed, 45 insertions(+) diff --git a/docs/features/player.md b/docs/features/player.md index 2d383b9..39fbc2c 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -168,3 +168,44 @@ engine. in for them, per podcast convention). - **Errors.** A playback failure leaves playback paused with a transient banner in the player and mini-player; pressing play retries. + +## Known limitations and follow-ups + +### Audible stream-to-local swap + +When a download finishes while its episode is streaming, playback moves onto the +local file with a `Load` at the playhead. That is audible: the load itself leaves a +brief gap, and because the core's position is the last whole-second tick, up to +about a second of audio repeats. (User story: `user_stories/player/playback.md`, +"carry on seamlessly when it finishes downloading".) + +Options, cheapest first: + +1. **Continue from the engine's exact time.** A `Swap` operation meaning "carry on + from wherever you are", instead of a `Load` at the last tick. This removes the + repeated second. Prepare (preroll) the local item before replacing the current + one, so the load gap shrinks too. +2. **Cue and swap at a point just ahead.** On download completion the core sends a + `Cue` for the local file about 2 to 3 seconds ahead. The shell prepares the local + item, seeked to that point, while the stream keeps playing, then hands over there + (an `AVQueuePlayer`, ending the stream item with `forwardPlaybackEndTime`). The + lead is only preparation time: the hand-over is meant to be seamless, so it needs + no silence detection and no chapters. If the item isn't ready in time, fall back + to an immediate swap. +3. **Swap at a natural break.** A pause or a seek is already a discontinuity, so if + either arrives while a cue is pending the shell swaps then, which is inaudible. + +Notes for whoever builds this: + +- The session scheme already supports it: the core adopts the new session when it + cues, and the stream item's late end-of-file event carries the old session and is + ignored. +- Option 2 means converting the engine from `AVPlayer` to `AVQueuePlayer`, which + touches seeking, ticks and end-of-file handling. Budget for that. +- Whether it is audibly gapless can only be judged by listening on a device (MP3 + decoder priming can leave a small gap even when the hand-over is gapless on paper). + The simulator can confirm the playhead continues through the swap, not how it sounds. +- Hosts that insert ads dynamically can serve different audio per request, so the + download may not match what was streamed. At a given timestamp the swap would then + land in different content. No swap strategy fixes that; it applies to today's swap + too. Worth knowing before judging a swap by ear on such a feed. diff --git a/docs/user_stories/player/playback.md b/docs/user_stories/player/playback.md index 055d031..0d6f315 100644 --- a/docs/user_stories/player/playback.md +++ b/docs/user_stories/player/playback.md @@ -100,3 +100,7 @@ so that I can listen how I want As a listener I want to choose how close to the end an episode must be to count as played so that I can match it to how much outro I tolerate + +As a listener +I want an episode I'm streaming to carry on seamlessly when it finishes downloading +so that I don't hear a repeated second or a gap when playback moves to the local file From 34cac32c855345ae1af7c967e4fcdb3d50423fd4 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 08:30:28 +0200 Subject: [PATCH 11/32] fix: don't resume paused playback when an audio interruption ends iOS reports "should resume" after any interruption, including one that found playback already paused, so a call arriving while the listener had paused would restart audio when it ended. - the core records whether an interruption is what paused playback and resumes only then; Interrupted gains `resumable` and a new InterruptionEnded { should_resume } event replaces the shell's unconditional .play - unplugging headphones is never resumable, so a stale flag can't be triggered by a later, unrelated call - playing or scrubbing by hand during an interruption cancels the auto-resume - document the rule in player.md --- docs/features/player.md | 7 +- iOS/Pollux/PlaybackManager.swift | 16 ++-- shared/src/app.rs | 21 ++++- shared/src/model.rs | 4 + shared/src/player.rs | 27 ++++++- shared/src/player/played_tests.rs | 130 +++++++++++++++++++++++++++++- 6 files changed, 189 insertions(+), 16 deletions(-) diff --git a/docs/features/player.md b/docs/features/player.md index 39fbc2c..0147a50 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -136,7 +136,12 @@ engine. a position, so listening on through the last seconds with the screen locked is never cut short. A system pause (a call, Siri, unplugged headphones) keeps the episode and its place and never counts as finishing, so it can resume and play - out the end. + out the end. When an audio-session interruption (a call, Siri) ends, playback + resumes only if that interruption is what paused it and the system says it may: + the system also says "may resume" after interruptions that found playback + already paused, and the listener's pause then stands. A pause from unplugged + headphones is never resumed automatically, and playing or scrubbing by hand + during an interruption cancels the auto-resume. - **Source.** Playback remembers what it was started from as an `EpisodeSource` (a subscription today; see `DATA_MODEL.md`), saved with the play context and restored with it. The "From:" row names it and tapping it navigates back to it. diff --git a/iOS/Pollux/PlaybackManager.swift b/iOS/Pollux/PlaybackManager.swift index 9a0c19f..2414d1c 100644 --- a/iOS/Pollux/PlaybackManager.swift +++ b/iOS/Pollux/PlaybackManager.swift @@ -161,8 +161,10 @@ final class PlaybackManager { } /// Phone calls, Siri and unplugged headphones pause playback; the core is told so - /// its state and the persisted position stay right. Playback resumes only when the - /// system says it should (e.g. after a call), never after unplugging. + /// its state and the persisted position stay right. The core decides whether an + /// interruption's end resumes playback: only if the interruption is what paused it + /// and the system says it may (it also says so after interruptions that found + /// playback already paused). Unplugging headphones is never resumed. private func observeSession() { let center = NotificationCenter.default let session = AVAudioSession.sharedInstance() @@ -177,9 +179,10 @@ final class PlaybackManager { MainActor.assumeIsolated { switch type { case .began: - self?.send(.interrupted) - case .ended where options?.contains(.shouldResume) == true: - self?.send(.play) + self?.send(.interrupted(resumable: true)) + case .ended: + let shouldResume = options?.contains(.shouldResume) == true + self?.send(.interruptionEnded(shouldResume: shouldResume)) default: break } @@ -192,7 +195,8 @@ final class PlaybackManager { .flatMap(AVAudioSession.RouteChangeReason.init(rawValue:)) MainActor.assumeIsolated { if reason == .oldDeviceUnavailable { - self?.send(.interrupted) + // No "ended" notification follows a route change, so never resumable. + self?.send(.interrupted(resumable: false)) } } } diff --git a/shared/src/app.rs b/shared/src/app.rs index 8b9e66a..d529e98 100644 --- a/shared/src/app.rs +++ b/shared/src/app.rs @@ -661,7 +661,10 @@ impl App for Pollux { Event::SkipBack => player::skip_back(model), Event::SeekTo(secs) => player::seek_to(model, secs), Event::AppBackgrounded => player::on_backgrounded(model), - Event::Interrupted => player::interrupt(model), + Event::Interrupted { resumable } => player::interrupt(model, resumable), + Event::InterruptionEnded { should_resume } => { + player::on_interruption_ended(model, should_resume) + } Event::PlayerTick { session, position_secs, @@ -1296,8 +1299,20 @@ pub enum Event { AppBackgrounded, /// The system paused playback (a call, Siri, unplugged headphones). Unlike `Pause` /// this is not the listener leaving: the place is saved but the episode is never - /// counted as played, so playback can resume to the end. - Interrupted, + /// counted as played, so playback can resume to the end. `resumable` is true for an + /// audio-session interruption, which the system will announce the end of + /// (`InterruptionEnded`); false for a route change such as unplugged headphones, which + /// has no end event and must never be auto-resumed. + Interrupted { + resumable: bool, + }, + /// An audio-session interruption ended. `should_resume` is the system's hint that + /// playback may continue; the core only acts on it if it was playing when the + /// interruption began (the system also sends it after interruptions that found + /// playback already paused). + InterruptionEnded { + should_resume: bool, + }, /// Engine position report (~1/s while playing). Transient; the core persists on /// its own checkpoints, not per tick. `session` is the load that produced it. PlayerTick { diff --git a/shared/src/model.rs b/shared/src/model.rs index e902c52..0e8f728 100644 --- a/shared/src/model.rs +++ b/shared/src/model.rs @@ -107,6 +107,10 @@ pub struct ActivePlayback { pub loaded: bool, /// Position at the last persisted checkpoint, to space periodic writes. pub last_checkpoint_secs: u32, + /// Set when an audio-session interruption paused playback that was running, so the + /// end of that interruption may resume it. Cleared by any explicit play or seek, and + /// never set when playback was already paused (the listener's choice stands). + pub resume_after_interruption: bool, pub error: Option, } diff --git a/shared/src/player.rs b/shared/src/player.rs index 777d9ab..976624f 100644 --- a/shared/src/player.rs +++ b/shared/src/player.rs @@ -232,6 +232,7 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { source: source.clone(), loaded: true, last_checkpoint_secs: start, + resume_after_interruption: false, error: None, }); @@ -272,6 +273,8 @@ pub(crate) fn play(model: &mut Model) -> Cmd { }; active.error = None; active.is_playing = true; + // The listener took over: an interruption ending later must not second-guess them. + active.resume_after_interruption = false; let rewound = active.position_secs.saturating_sub(RESUME_REWIND_SECS); active.position_secs = rewound; let cmd = if let Some(session) = fresh { @@ -317,8 +320,9 @@ pub(crate) fn pause(model: &mut Model) -> Cmd { /// The system paused playback (a call, Siri, unplugged headphones). That is not the /// listener leaving: the place is saved and the episode stays active, so playback can -/// resume and play out the final seconds. -pub(crate) fn interrupt(model: &mut Model) -> Cmd { +/// resume and play out the final seconds. When `resumable`, the end of the interruption +/// may resume it (see `on_interruption_ended`); a route change never does. +pub(crate) fn interrupt(model: &mut Model, resumable: bool) -> Cmd { let Some(active) = model.active_playback.as_mut() else { return Command::done(); }; @@ -326,12 +330,29 @@ pub(crate) fn interrupt(model: &mut Model) -> Cmd { return Command::done(); } active.is_playing = false; + active.resume_after_interruption = resumable; let session = active.session; player_op(session, PlayerOperation::Pause) .and(checkpoint(model)) .and(render()) } +/// An audio-session interruption ended. Playback resumes only if the system says it may +/// *and* this interruption is what paused it: the system also reports "should resume" +/// after interruptions that found playback already paused, and resuming then would start +/// audio the listener had stopped. +pub(crate) fn on_interruption_ended(model: &mut Model, should_resume: bool) -> Cmd { + let was_interrupted = model + .active_playback + .as_mut() + .is_some_and(|a| std::mem::take(&mut a.resume_after_interruption)); + if should_resume && was_interrupted { + play(model) + } else { + Command::done() + } +} + pub(crate) fn toggle(model: &mut Model) -> Cmd { match model.active_playback.as_ref() { Some(a) if a.is_playing => pause(model), @@ -349,6 +370,7 @@ pub(crate) fn seek_to(model: &mut Model, secs: u32) -> Cmd { let Some(active) = model.active_playback.as_mut() else { return Command::done(); }; + active.resume_after_interruption = false; let duration = known_duration(active).filter(|&d| d > 0); // Skipping or scrubbing to the very end is finishing: decided here rather than left // to the engine, which only reports the end of the file while it is playing. @@ -549,6 +571,7 @@ pub(crate) fn on_context_loaded(model: &mut Model, result: StorageResult) -> Cmd is_playing: false, loaded: false, last_checkpoint_secs: position, + resume_after_interruption: false, error: None, }); render() diff --git a/shared/src/player/played_tests.rs b/shared/src/player/played_tests.rs index b3ab62f..a23e366 100644 --- a/shared/src/player/played_tests.rs +++ b/shared/src/player/played_tests.rs @@ -117,7 +117,7 @@ fn a_system_interruption_in_the_tail_pauses_without_finishing() { send(&mut model, Event::PlayEpisode("e1".into())); tick(&mut model, 590); - let effects = send(&mut model, Event::Interrupted); + let effects = send(&mut model, Event::Interrupted { resumable: true }); assert!(matches!( player_ops(&effects).as_slice(), @@ -129,8 +129,13 @@ fn a_system_interruption_in_the_tail_pauses_without_finishing() { ); assert!(!view_player(&model).is_playing); - // The system says it's fine to resume (the call ended): the last seconds play out. - let effects = send(&mut model, Event::Play); + // The call ended and the system says it's fine to resume: the last seconds play out. + let effects = send( + &mut model, + Event::InterruptionEnded { + should_resume: true, + }, + ); assert!(matches!( player_ops(&effects).as_slice(), [PlayerOperation::Seek { .. }, PlayerOperation::Play] @@ -332,7 +337,7 @@ fn seeking_to_the_very_end_while_paused_finishes_the_episode() { // stuck active at 100%. let mut model = model_with(vec![episode("e1")]); send(&mut model, Event::PlayEpisode("e1".into())); - send(&mut model, Event::Interrupted); + send(&mut model, Event::Interrupted { resumable: true }); assert!(!view_player(&model).is_playing); let effects = send(&mut model, Event::SeekTo(600)); @@ -353,3 +358,120 @@ fn a_seek_that_lands_short_of_the_end_does_not_finish() { assert_eq!(active(&model).position_secs, 599); } + +// --- Interruptions --------------------------------------------------------------------- + +fn interrupted() -> Event { + Event::Interrupted { resumable: true } +} + +fn interruption_ended() -> Event { + Event::InterruptionEnded { + should_resume: true, + } +} + +#[test] +fn an_interruption_that_paused_playback_resumes_when_it_ends() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, 100); + send(&mut model, interrupted()); + assert!(!view_player(&model).is_playing); + + let effects = send(&mut model, interruption_ended()); + + assert!(view_player(&model).is_playing); + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Seek { secs: 97 }, PlayerOperation::Play] + )); +} + +#[test] +fn an_interruption_does_not_resume_playback_the_listener_had_paused() { + // The system reports "should resume" after any interruption, including one that found + // playback already paused. The pause was the listener's choice and stands. + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + tick(&mut model, 100); + send(&mut model, Event::Pause); + + send(&mut model, interrupted()); + let effects = send(&mut model, interruption_ended()); + + assert!(!view_player(&model).is_playing); + assert!(player_ops(&effects).is_empty()); +} + +#[test] +fn an_interruption_that_says_not_to_resume_leaves_playback_paused() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + send(&mut model, interrupted()); + + let effects = send( + &mut model, + Event::InterruptionEnded { + should_resume: false, + }, + ); + + assert!(!view_player(&model).is_playing); + assert!(player_ops(&effects).is_empty()); +} + +#[test] +fn a_route_change_pause_is_never_resumed_by_a_later_interruption() { + // Unplugged headphones pause playback but have no matching "ended" event. The arming + // must not linger and let an unrelated call, hours later, restart the audio. + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + send(&mut model, Event::Interrupted { resumable: false }); + assert!(!view_player(&model).is_playing); + + // Later: a call begins and ends while playback is paused. + send(&mut model, interrupted()); + let effects = send(&mut model, interruption_ended()); + + assert!(!view_player(&model).is_playing); + assert!(player_ops(&effects).is_empty()); +} + +#[test] +fn the_listener_taking_over_during_an_interruption_cancels_the_auto_resume() { + // Interrupted, then the listener plays and pauses by hand while the interruption is + // still nominally open: its end must not start playback again. + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + send(&mut model, interrupted()); + send(&mut model, Event::Play); + send(&mut model, Event::Pause); + assert!(!view_player(&model).is_playing); + + let effects = send(&mut model, interruption_ended()); + + assert!(!view_player(&model).is_playing); + assert!(player_ops(&effects).is_empty()); +} + +#[test] +fn scrubbing_during_an_interruption_cancels_the_auto_resume() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + send(&mut model, interrupted()); + send(&mut model, Event::SeekTo(300)); + + send(&mut model, interruption_ended()); + + assert!(!view_player(&model).is_playing); +} + +#[test] +fn the_end_of_an_interruption_with_nothing_active_does_nothing() { + let mut model = model_with(vec![]); + + let effects = send(&mut model, interruption_ended()); + + assert!(effects.is_empty()); +} From fe4224812751b23e57f67bd39b236067b1b97fc3 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 08:37:24 +0200 Subject: [PATCH 12/32] unload the current item when a load fails A failed load (unparseable URL, missing file, audio session error) returned before touching the AVPlayer, so the previous episode kept playing while the core showed the new one paused with an error; the old item's ticks were dropped as stale and the controls acted on the wrong episode. - PlaybackManager.load unloads the current item and clears the session on any failure, leaving the audio session active so a fallback load doesn't flicker other apps' audio - add engine tests with a generated silent WAV: a failed load leaves nothing loaded, a good load still works after one Co-Authored-By: Claude Sonnet 5.5 --- iOS/Pollux/PlaybackManager.swift | 32 ++++- iOS/PolluxTests/PlaybackManagerTests.swift | 136 +++++++++++++++++++++ 2 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 iOS/PolluxTests/PlaybackManagerTests.swift diff --git a/iOS/Pollux/PlaybackManager.swift b/iOS/Pollux/PlaybackManager.swift index 2414d1c..c1ab37a 100644 --- a/iOS/Pollux/PlaybackManager.swift +++ b/iOS/Pollux/PlaybackManager.swift @@ -15,7 +15,7 @@ final class PlaybackManager { /// The load the engine is on. Everything reported to the core carries it, so the /// core can drop news from an item it has since replaced. - private var currentSession: UInt32? + private(set) var currentSession: UInt32? /// Seeks still in flight. A streaming seek takes a moment, and the engine keeps /// reporting the old position until it lands; those ticks would snap the UI back. private var pendingSeeks = 0 @@ -30,6 +30,12 @@ final class PlaybackManager { observeSession() } + /// Whether the engine has an item loaded. Exposed so tests can tell a failed load that + /// left the old item playing from one that unloaded it. + var hasLoadedItem: Bool { + player.currentItem != nil + } + // MARK: - Operations /// Carries out one core operation. Synchronous: AVPlayer queues its own work. @@ -49,8 +55,22 @@ final class PlaybackManager { return .ok } + /// Replaces the current item with `media`. If that can't be done the engine is left with + /// nothing loaded rather than with whatever was playing before: the core has already + /// moved on to this item (paused, with the error), so audio from the old one would play + /// on under a UI that says nothing is playing, with its news dropped as stale. private func load( session: UInt32, media: MediaSource, startSecs: UInt32, autoplay: Bool, + ) -> PlayerResult { + let result = startLoading(session: session, media: media, startSecs: startSecs, autoplay: autoplay) + if case .error = result { + unloadCurrentItem() + } + return result + } + + private func startLoading( + session: UInt32, media: MediaSource, startSecs: UInt32, autoplay: Bool, ) -> PlayerResult { let url: URL switch media { @@ -96,11 +116,19 @@ final class PlaybackManager { } private func stop() { + unloadCurrentItem() + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + /// Drops the current item and stops reporting for it. Leaves the audio session alone: + /// after a failed load the core may immediately load another source (a missing + /// download falls back to streaming), and releasing the session in between would + /// briefly hand audio back to other apps. + private func unloadCurrentItem() { player.pause() player.replaceCurrentItem(with: nil) statusObservation = nil currentSession = nil - try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } // MARK: - Engine → core diff --git a/iOS/PolluxTests/PlaybackManagerTests.swift b/iOS/PolluxTests/PlaybackManagerTests.swift new file mode 100644 index 0000000..e99f7c0 --- /dev/null +++ b/iOS/PolluxTests/PlaybackManagerTests.swift @@ -0,0 +1,136 @@ +import App +import Foundation +import Testing + +@testable import Pollux + +// These drive the real `PlaybackManager` (and so a real `AVPlayer`) against files in a +// temporary storage root. Nothing is played: loads are paused (`autoplay: false`), which is +// enough to see what the engine has loaded. + +/// One second of silence as a minimal PCM WAV file, the smallest valid audio `AVPlayer` +/// will open. +private func silentWAV() -> Data { + let sampleRate: UInt32 = 8000 + let payload = Data(count: Int(sampleRate)) // 8-bit mono: one byte per sample + var wav = Data() + func append(_ value: UInt32) { + withUnsafeBytes(of: value.littleEndian) { wav.append(contentsOf: $0) } + } + func append(_ value: UInt16) { + withUnsafeBytes(of: value.littleEndian) { wav.append(contentsOf: $0) } + } + wav.append(Data("RIFF".utf8)) + append(UInt32(36 + payload.count)) + wav.append(Data("WAVEfmt ".utf8)) + append(UInt32(16)) // fmt chunk size + append(UInt16(1)) // PCM + append(UInt16(1)) // mono + append(sampleRate) + append(sampleRate) // byte rate + append(UInt16(1)) // block align + append(UInt16(8)) // bits per sample + wav.append(Data("data".utf8)) + append(UInt32(payload.count)) + wav.append(payload) + return wav +} + +@MainActor +private struct Rig { + let manager: PlaybackManager + let root: URL + + /// A relative path (as the core stores them) to a real, playable file under the root. + func installAudio(named name: String = "silence.wav") throws -> String { + let directory = root.appendingPathComponent("Downloads", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try silentWAV().write(to: directory.appendingPathComponent(name)) + return "Downloads/\(name)" + } +} + +@MainActor +private func makeRig() -> Rig { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return Rig(manager: PlaybackManager(storageRoot: root) { _ in }, root: root) +} + +@Suite @MainActor struct PlaybackManagerTests { + @Test func loadingAPlayableFileLoadsIt() throws { + let rig = makeRig() + let path = try rig.installAudio() + + let result = rig.manager.perform( + .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: false), + ) + + #expect(result == .ok) + #expect(rig.manager.hasLoadedItem) + #expect(rig.manager.currentSession == 1) + } + + @Test func aFailedLoadLeavesNothingLoadedNotTheOldItem() throws { + let rig = makeRig() + let path = try rig.installAudio() + #expect( + rig.manager.perform( + .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: false), + ) == .ok, + ) + + // The next episode's file is gone: the engine must not keep the previous one. + let result = rig.manager.perform( + .load(session: 2, media: .local(localPath: "Downloads/missing.mp3"), startSecs: 0, autoplay: true), + ) + + #expect(result == .error("The downloaded file is missing")) + #expect(!rig.manager.hasLoadedItem) + #expect(rig.manager.currentSession == nil) + } + + @Test func anUnusableStreamURLAlsoUnloadsThePreviousItem() throws { + let rig = makeRig() + let path = try rig.installAudio() + _ = rig.manager.perform( + .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: false), + ) + + let result = rig.manager.perform( + .load(session: 2, media: .stream(url: ""), startSecs: 0, autoplay: true), + ) + + #expect(result == .error("Invalid episode URL")) + #expect(!rig.manager.hasLoadedItem) + #expect(rig.manager.currentSession == nil) + } + + @Test func aFailedLoadWithNothingLoadedIsHarmless() { + let rig = makeRig() + + let result = rig.manager.perform( + .load(session: 1, media: .local(localPath: "Downloads/missing.mp3"), startSecs: 0, autoplay: false), + ) + + #expect(result == .error("The downloaded file is missing")) + #expect(!rig.manager.hasLoadedItem) + } + + @Test func aSuccessfulLoadAfterAFailedOneWorks() throws { + // The core falls back to another source right after a failed load. + let rig = makeRig() + let path = try rig.installAudio() + _ = rig.manager.perform( + .load(session: 1, media: .local(localPath: "Downloads/missing.mp3"), startSecs: 0, autoplay: false), + ) + + let result = rig.manager.perform( + .load(session: 2, media: .local(localPath: path), startSecs: 0, autoplay: false), + ) + + #expect(result == .ok) + #expect(rig.manager.hasLoadedItem) + #expect(rig.manager.currentSession == 2) + } +} From 44b8ce7b42e87e224ce18473f50f5569866f36ee Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 08:48:34 +0200 Subject: [PATCH 13/32] fix: don't take audio focus for a load that won't play A paused load (a source swap finishing after the listener paused, or a scrub of a restored episode) activated the non-mixable playback session, cutting off whatever the listener had switched to. - PlaybackManager claims the audio session only for a load that will autoplay, and in the Play operation (which also covers resuming after a paused load or an interruption) - a refused session fails an autoplaying load (and unloads) or a play (keeping the item), instead of throwing it away - make the activation injectable, with the real one as the default, and test when the session is and isn't claimed Co-Authored-By: Claude Sonnet 5.5 --- iOS/Pollux/PlaybackManager.swift | 43 +++++++-- iOS/PolluxTests/PlaybackManagerTests.swift | 106 ++++++++++++++++++++- 2 files changed, 141 insertions(+), 8 deletions(-) diff --git a/iOS/Pollux/PlaybackManager.swift b/iOS/Pollux/PlaybackManager.swift index c1ab37a..34dca19 100644 --- a/iOS/Pollux/PlaybackManager.swift +++ b/iOS/Pollux/PlaybackManager.swift @@ -23,13 +23,29 @@ final class PlaybackManager { private var statusObservation: NSKeyValueObservation? private var notificationTokens: [NSObjectProtocol] = [] - init(storageRoot: URL, send: @escaping (Event) -> Void) { + /// Claims the audio session for playback. Injectable so tests can see when it is + /// claimed: doing so takes audio focus from other apps, so it must only happen when + /// audio is about to play. + private let activateAudioSession: () throws -> Void + + init( + storageRoot: URL, + activateAudioSession: @escaping () throws -> Void = PlaybackManager.activatePlaybackSession, + send: @escaping (Event) -> Void, + ) { self.storageRoot = storageRoot + self.activateAudioSession = activateAudioSession self.send = send observeTime() observeSession() } + nonisolated static func activatePlaybackSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .spokenAudio) + try session.setActive(true) + } + /// Whether the engine has an item loaded. Exposed so tests can tell a failed load that /// left the old item playing from one that unloaded it. var hasLoadedItem: Bool { @@ -44,6 +60,11 @@ final class PlaybackManager { case let .load(session, media, startSecs, autoplay): return load(session: session, media: media, startSecs: startSecs, autoplay: autoplay) case .play: + // The session may not be active: a paused load (a source swap, a scrub of a + // restored episode) deliberately doesn't claim it. + if let failure = claimAudioSession() { + return failure + } player.play() case .pause: player.pause() @@ -86,12 +107,10 @@ final class PlaybackManager { } } - do { - let session = AVAudioSession.sharedInstance() - try session.setCategory(.playback, mode: .spokenAudio) - try session.setActive(true) - } catch { - return .error("Couldn't start audio: \(error.localizedDescription)") + // A paused load must not take audio focus: the listener may have switched to another + // app's music, and a source swap finishing in the background would cut it off. + if autoplay, let failure = claimAudioSession() { + return failure } currentSession = session @@ -106,6 +125,16 @@ final class PlaybackManager { return .ok } + /// Claims the audio session, or returns the error to report. + private func claimAudioSession() -> PlayerResult? { + do { + try activateAudioSession() + return nil + } catch { + return .error("Couldn't start audio: \(error.localizedDescription)") + } + } + private func seek(to secs: UInt32) { let target = CMTime(seconds: Double(secs), preferredTimescale: 600) pendingSeeks += 1 diff --git a/iOS/PolluxTests/PlaybackManagerTests.swift b/iOS/PolluxTests/PlaybackManagerTests.swift index e99f7c0..56f1fbe 100644 --- a/iOS/PolluxTests/PlaybackManagerTests.swift +++ b/iOS/PolluxTests/PlaybackManagerTests.swift @@ -36,10 +36,29 @@ private func silentWAV() -> Data { return wav } +/// Stands in for claiming the audio session, so tests can see when it is claimed (which +/// takes audio focus from other apps) and make it fail. +private final class AudioSessionProbe: @unchecked Sendable { + struct Refused: Error, LocalizedError { + var errorDescription: String? { + "audio is busy" + } + } + + var claims = 0 + var refuse = false + + func claim() throws { + claims += 1 + if refuse { throw Refused() } + } +} + @MainActor private struct Rig { let manager: PlaybackManager let root: URL + let audio: AudioSessionProbe /// A relative path (as the core stores them) to a real, playable file under the root. func installAudio(named name: String = "silence.wav") throws -> String { @@ -54,7 +73,9 @@ private struct Rig { private func makeRig() -> Rig { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) - return Rig(manager: PlaybackManager(storageRoot: root) { _ in }, root: root) + let audio = AudioSessionProbe() + let manager = PlaybackManager(storageRoot: root, activateAudioSession: audio.claim) { _ in } + return Rig(manager: manager, root: root, audio: audio) } @Suite @MainActor struct PlaybackManagerTests { @@ -117,6 +138,89 @@ private func makeRig() -> Rig { #expect(!rig.manager.hasLoadedItem) } + // MARK: - Audio focus + + @Test func aPausedLoadDoesNotClaimTheAudioSession() throws { + // A source swap that finishes while the listener has paused (and moved on to another + // app's music) must not cut that audio off. + let rig = makeRig() + let path = try rig.installAudio() + + let result = rig.manager.perform( + .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: false), + ) + + #expect(result == .ok) + #expect(rig.audio.claims == 0) + } + + @Test func aLoadThatWillPlayClaimsTheAudioSession() throws { + let rig = makeRig() + let path = try rig.installAudio() + + let result = rig.manager.perform( + .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: true), + ) + + #expect(result == .ok) + #expect(rig.audio.claims == 1) + } + + @Test func playingAfterAPausedLoadClaimsTheAudioSessionThen() throws { + let rig = makeRig() + let path = try rig.installAudio() + _ = rig.manager.perform( + .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: false), + ) + #expect(rig.audio.claims == 0) + + let result = rig.manager.perform(.play) + + #expect(result == .ok) + #expect(rig.audio.claims == 1) + } + + @Test func pausingAndSeekingDoNotClaimTheAudioSession() throws { + let rig = makeRig() + let path = try rig.installAudio() + _ = rig.manager.perform( + .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: false), + ) + + _ = rig.manager.perform(.seek(secs: 0)) + _ = rig.manager.perform(.pause) + + #expect(rig.audio.claims == 0) + } + + @Test func aRefusedSessionFailsAPlayingLoadAndUnloadsTheItem() throws { + let rig = makeRig() + let path = try rig.installAudio() + rig.audio.refuse = true + + let result = rig.manager.perform( + .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: true), + ) + + #expect(result == .error("Couldn't start audio: audio is busy")) + #expect(!rig.manager.hasLoadedItem) + } + + @Test func aRefusedSessionFailsPlayButKeepsTheLoadedItem() throws { + let rig = makeRig() + let path = try rig.installAudio() + _ = rig.manager.perform( + .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: false), + ) + rig.audio.refuse = true + + let result = rig.manager.perform(.play) + + #expect(result == .error("Couldn't start audio: audio is busy")) + // Nothing is wrong with the item; a later play (once audio is free) can use it. + #expect(rig.manager.hasLoadedItem) + } + @Test func aSuccessfulLoadAfterAFailedOneWorks() throws { // The core falls back to another source right after a failed load. let rig = makeRig() From 08da3c0d781119ee3b7fcd681acddbfed3580f5d Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 08:53:34 +0200 Subject: [PATCH 14/32] fix: only discard a downloaded file when the engine says it is unusable Every error on a downloaded episode was treated as a missing file, so a transient failure (another app holding the audio session, say) reset the download to not-downloaded and fetched the same file again, orphaning the good copy on disk. - add PlayerResult::MediaUnusable and Event::PlayerMediaUnusable; the shell reports a missing, unparseable or undecodable item with them and everything else (a refused audio session, a failure mid-play) as before - the core falls back to streaming and re-downloads only for an unusable local file; any other failure pauses with the error and keeps the file, so playing again retries it - a failed load unloads the current item whichever way it failed - document the rule in player.md Co-Authored-By: Claude Sonnet 5.5 --- docs/features/player.md | 7 +- iOS/Pollux/PlaybackManager.swift | 11 +- iOS/PolluxTests/PlaybackManagerTests.swift | 6 +- shared/src/app.rs | 20 ++- shared/src/capabilities/player.rs | 6 + shared/src/player.rs | 33 ++++- shared/src/player/played_tests.rs | 152 ++++++++++++++++++++- shared/src/player/tests.rs | 2 +- 8 files changed, 218 insertions(+), 19 deletions(-) diff --git a/docs/features/player.md b/docs/features/player.md index 0147a50..a75b2c7 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -122,8 +122,11 @@ engine. - **Streaming.** Playing an episode that isn't downloaded streams from its enclosure URL *and* queues a download. When the download finishes, playback moves onto the local file at the current position (a `Load` at the playhead). - If the downloaded file turns out to be missing or unreadable, playback falls - back to streaming and the episode is reset to not-downloaded and re-queued. + If the engine reports the downloaded file unusable (missing, unreadable or + undecodable), playback falls back to streaming and the episode is reset to + not-downloaded and re-queued. Any other failure (another app holding the audio + session, playback stopping mid-play) leaves the download alone, pauses with an + error, and a later play retries the same file. - **Played tolerance.** Played is decided when the listener *leaves* an episode, never as a side effect of saving progress. It happens when the engine reaches the end, when they pause, or when they start another episode, while within diff --git a/iOS/Pollux/PlaybackManager.swift b/iOS/Pollux/PlaybackManager.swift index 34dca19..99ba2c4 100644 --- a/iOS/Pollux/PlaybackManager.swift +++ b/iOS/Pollux/PlaybackManager.swift @@ -84,8 +84,11 @@ final class PlaybackManager { session: UInt32, media: MediaSource, startSecs: UInt32, autoplay: Bool, ) -> PlayerResult { let result = startLoading(session: session, media: media, startSecs: startSecs, autoplay: autoplay) - if case .error = result { + switch result { + case .error, .mediaUnusable: unloadCurrentItem() + case .ok: + break } return result } @@ -97,13 +100,13 @@ final class PlaybackManager { switch media { case let .stream(urlString): guard let parsed = URL(string: urlString) else { - return .error("Invalid episode URL") + return .mediaUnusable("Invalid episode URL") } url = parsed case let .local(localPath): url = DownloadManager.absoluteURL(for: localPath, root: storageRoot) guard FileManager.default.fileExists(atPath: url.path) else { - return .error("The downloaded file is missing") + return .mediaUnusable("The downloaded file is missing") } } @@ -175,7 +178,7 @@ final class PlaybackManager { send(.playerDuration(session: session, durationSecs: UInt32(duration))) } case .failed: - send(.playerFailed(session: session, message: message ?? "Playback failed")) + send(.playerMediaUnusable(session: session, message: message ?? "Playback failed")) default: break } diff --git a/iOS/PolluxTests/PlaybackManagerTests.swift b/iOS/PolluxTests/PlaybackManagerTests.swift index 56f1fbe..5c86412 100644 --- a/iOS/PolluxTests/PlaybackManagerTests.swift +++ b/iOS/PolluxTests/PlaybackManagerTests.swift @@ -106,7 +106,7 @@ private func makeRig() -> Rig { .load(session: 2, media: .local(localPath: "Downloads/missing.mp3"), startSecs: 0, autoplay: true), ) - #expect(result == .error("The downloaded file is missing")) + #expect(result == .mediaUnusable("The downloaded file is missing")) #expect(!rig.manager.hasLoadedItem) #expect(rig.manager.currentSession == nil) } @@ -122,7 +122,7 @@ private func makeRig() -> Rig { .load(session: 2, media: .stream(url: ""), startSecs: 0, autoplay: true), ) - #expect(result == .error("Invalid episode URL")) + #expect(result == .mediaUnusable("Invalid episode URL")) #expect(!rig.manager.hasLoadedItem) #expect(rig.manager.currentSession == nil) } @@ -134,7 +134,7 @@ private func makeRig() -> Rig { .load(session: 1, media: .local(localPath: "Downloads/missing.mp3"), startSecs: 0, autoplay: false), ) - #expect(result == .error("The downloaded file is missing")) + #expect(result == .mediaUnusable("The downloaded file is missing")) #expect(!rig.manager.hasLoadedItem) } diff --git a/shared/src/app.rs b/shared/src/app.rs index d529e98..900bc79 100644 --- a/shared/src/app.rs +++ b/shared/src/app.rs @@ -674,7 +674,12 @@ impl App for Pollux { duration_secs, } => player::on_duration(model, session, duration_secs), Event::PlayerEnded { session } => player::on_ended(model, session), - Event::PlayerFailed { session, message } => player::on_failure(model, session, message), + Event::PlayerFailed { session, message } => { + player::on_failure(model, session, message, player::Failure::Other) + } + Event::PlayerMediaUnusable { session, message } => { + player::on_failure(model, session, message, player::Failure::MediaUnusable) + } Event::PlayerResponded { session, result } => { player::on_response(model, session, *result) } @@ -1328,12 +1333,21 @@ pub enum Event { PlayerEnded { session: u32, }, - /// The engine failed to play or lost the item. + /// Playback failed for a reason that says nothing about the media (it stopped + /// mid-play, say). A downloaded file is kept; the listener sees the error and can + /// retry. PlayerFailed { session: u32, message: String, }, - /// Resolution of a player operation; an error is treated like `PlayerFailed`. + /// The engine couldn't use the media itself: it failed to load or decode. A + /// downloaded file is then discarded in favor of streaming; see `player::on_failure`. + PlayerMediaUnusable { + session: u32, + message: String, + }, + /// Resolution of a player operation; an error is treated like the matching failure + /// event (`PlayerFailed`, or `PlayerMediaUnusable` for `MediaUnusable`). PlayerResponded { session: u32, result: Box, diff --git a/shared/src/capabilities/player.rs b/shared/src/capabilities/player.rs index 334baf6..dba613c 100644 --- a/shared/src/capabilities/player.rs +++ b/shared/src/capabilities/player.rs @@ -45,7 +45,13 @@ pub enum PlayerOperation { #[repr(C)] pub enum PlayerResult { Ok, + /// The operation failed for a reason that says nothing about the media: the audio + /// session was refused, say. A downloaded file is still good, and trying again later + /// may work. Error(String), + /// The media itself can't be used: the file is missing, unreadable or can't be + /// decoded. Only this lets the core give up on a downloaded file and stream instead. + MediaUnusable(String), } impl Operation for PlayerOperation { diff --git a/shared/src/player.rs b/shared/src/player.rs index 976624f..67785f9 100644 --- a/shared/src/player.rs +++ b/shared/src/player.rs @@ -447,9 +447,29 @@ pub(crate) fn on_ended(model: &mut Model, session: u32) -> Cmd { complete(model) } -/// The engine couldn't play (or lost) the item. A missing local file falls back to -/// streaming and re-downloads; anything else leaves playback paused with a notice. -pub(crate) fn on_failure(model: &mut Model, session: u32, message: String) -> Cmd { +/// Why the engine failed, as far as the shell can tell. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Failure { + /// The media itself can't be used: a missing, unreadable or undecodable file. + MediaUnusable, + /// Anything else (the audio session was refused, playback stopped mid-play). Says + /// nothing about the media. + Other, +} + +/// The engine couldn't play (or lost) the item. +/// +/// A downloaded file the engine reports as *unusable* (missing, unreadable, undecodable) +/// is given up on: the episode streams instead, from the same spot, and is re-downloaded. +/// Any other failure leaves playback paused with a notice and the download untouched, so +/// a transient problem (another app holding the audio session, say) can't make the app +/// discard a good multi-megabyte file and fetch it again. +pub(crate) fn on_failure( + model: &mut Model, + session: u32, + message: String, + failure: Failure, +) -> Cmd { if !is_current(model, session) { return Command::done(); } @@ -457,7 +477,7 @@ pub(crate) fn on_failure(model: &mut Model, session: u32, message: String) -> Cm .active_playback .as_ref() .is_some_and(|a| matches!(a.media, MediaSource::Local { .. })); - if !is_local { + if !(is_local && failure == Failure::MediaUnusable) { if let Some(active) = model.active_playback.as_mut() { active.is_playing = false; active.loaded = false; @@ -499,7 +519,10 @@ pub(crate) fn on_failure(model: &mut Model, session: u32, message: String) -> Cm pub(crate) fn on_response(model: &mut Model, session: u32, result: PlayerResult) -> Cmd { match result { PlayerResult::Ok => Command::done(), - PlayerResult::Error(message) => on_failure(model, session, message), + PlayerResult::Error(message) => on_failure(model, session, message, Failure::Other), + PlayerResult::MediaUnusable(message) => { + on_failure(model, session, message, Failure::MediaUnusable) + } } } diff --git a/shared/src/player/played_tests.rs b/shared/src/player/played_tests.rs index a23e366..1dc1fba 100644 --- a/shared/src/player/played_tests.rs +++ b/shared/src/player/played_tests.rs @@ -266,7 +266,7 @@ fn engine_events_from_a_replaced_load_are_ignored() { // The local file fails, so playback is re-loaded from the network: a new session. send( &mut model, - Event::PlayerFailed { + Event::PlayerMediaUnusable { session: first, message: "file missing".into(), }, @@ -475,3 +475,153 @@ fn the_end_of_an_interruption_with_nothing_active_does_nothing() { assert!(effects.is_empty()); } + +// --- Failures on a downloaded file ---------------------------------------------------------- + +/// A model whose only episode is downloaded and has just been started, so it plays from +/// the local file. +fn playing_a_download() -> Model { + let mut e = episode("e1"); + e.download_status = DownloadStatus::Downloaded; + e.local_path = Some("Downloads/e1.mp3".into()); + let mut model = model_with(vec![e]); + send(&mut model, Event::PlayEpisode("e1".into())); + model +} + +fn download_state_writes(effects: &[Effect]) -> usize { + storage_ops(effects) + .iter() + .filter(|op| matches!(op, StorageOperation::UpdateDownloadState { .. })) + .count() +} + +#[test] +fn a_refused_audio_session_does_not_discard_a_good_download() { + // Another app holds the audio session, so the load is refused. That says nothing about + // the file, which must not be reset to not-downloaded and fetched again. + let mut model = playing_a_download(); + let session = session(&model); + + let effects = send( + &mut model, + Event::PlayerResponded { + session, + result: Box::new(PlayerResult::Error("Couldn't start audio: busy".into())), + }, + ); + + assert_eq!(download_state_writes(&effects), 0); + assert!(model.downloading.is_none()); + assert!(model.download_queue.is_empty()); + assert_eq!( + model.episodes[0].download_status, + DownloadStatus::Downloaded + ); + assert_eq!( + active(&model).episode.download_status, + DownloadStatus::Downloaded + ); + let view = view_player(&model); + assert!(!view.is_playing); + assert!(!view.is_streaming, "still pointed at the local file"); + assert_eq!(view.error.as_deref(), Some("Couldn't start audio: busy")); +} + +#[test] +fn after_a_refused_session_playing_again_retries_the_same_file() { + let mut model = playing_a_download(); + let first = session(&model); + send( + &mut model, + Event::PlayerResponded { + session: first, + result: Box::new(PlayerResult::Error("Couldn't start audio: busy".into())), + }, + ); + + let effects = send(&mut model, Event::Play); + + assert!(matches!( + player_ops(&effects).as_slice(), + [PlayerOperation::Load { + media: MediaSource::Local { .. }, + autoplay: true, + .. + }] + )); + assert!(view_player(&model).error.is_none()); +} + +#[test] +fn a_mid_play_failure_on_a_download_is_not_taken_for_a_bad_file() { + let mut model = playing_a_download(); + let session = session(&model); + + let effects = send( + &mut model, + Event::PlayerFailed { + session, + message: "Playback stopped unexpectedly".into(), + }, + ); + + assert_eq!(download_state_writes(&effects), 0); + assert_eq!( + model.episodes[0].download_status, + DownloadStatus::Downloaded + ); + assert!(!view_player(&model).is_playing); +} + +#[test] +fn an_unusable_file_in_a_load_response_falls_back_to_streaming() { + // The same fallback as the engine's own "couldn't decode it" event, reached through + // the response to the Load itself (the file was missing). + let mut model = playing_a_download(); + let session = session(&model); + + let effects = send( + &mut model, + Event::PlayerResponded { + session, + result: Box::new(PlayerResult::MediaUnusable( + "The downloaded file is missing".into(), + )), + }, + ); + + assert!(player_ops(&effects).iter().any(|op| matches!( + op, + PlayerOperation::Load { + media: MediaSource::Stream { .. }, + autoplay: true, + .. + } + ))); + // Reset to not-downloaded, queued, then started (nothing else is downloading). + assert_eq!(download_state_writes(&effects), 3); + assert_eq!(model.downloading.as_deref(), Some("e1")); + assert!(view_player(&model).error.is_none()); +} + +#[test] +fn an_unusable_stream_just_shows_the_error() { + // There is no other source to fall back to: a streamed item the engine can't use is + // reported, not retried. + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + let session = session(&model); + + send( + &mut model, + Event::PlayerMediaUnusable { + session, + message: "Invalid episode URL".into(), + }, + ); + + let view = view_player(&model); + assert!(!view.is_playing); + assert_eq!(view.error.as_deref(), Some("Invalid episode URL")); +} diff --git a/shared/src/player/tests.rs b/shared/src/player/tests.rs index d45d87f..2539720 100644 --- a/shared/src/player/tests.rs +++ b/shared/src/player/tests.rs @@ -372,7 +372,7 @@ fn a_missing_local_file_falls_back_to_streaming_and_redownloads() { let session = session(&model); let effects = send( &mut model, - Event::PlayerFailed { + Event::PlayerMediaUnusable { session, message: "file missing".into(), }, From 17ea9e8e09c4f4c8b97dd6a656a1827a5149441d Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 09:01:48 +0200 Subject: [PATCH 15/32] perf: rewrite the lock-screen card only when it is out of date NowPlayingController rebuilt the whole card on every render: each tick, download progress update and refresh. Rewriting it resets the system's clock for the card, so its scrubber jittered, and it was needless work. - add NowPlayingPolicy: rewrite when the title, feed, artwork, duration, play state or skip intervals change, or when the playhead leaves the card's own clock by more than 2s (a seek, a skip, a stall); ordinary playback, paused time and error text never rewrite it - set the skip intervals only when they change, and clear the card only when it has content - when artwork arrives, republish from the latest state instead of patching the card in place with a stale elapsed time, which set the card's clock back - test the policy, including 600 ticks of steady playback Co-Authored-By: Claude Sonnet 5.5 --- iOS/Pollux/NowPlayingController.swift | 54 ++++++++-- iOS/Pollux/NowPlayingPolicy.swift | 61 +++++++++++ iOS/PolluxTests/NowPlayingPolicyTests.swift | 112 ++++++++++++++++++++ 3 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 iOS/Pollux/NowPlayingPolicy.swift create mode 100644 iOS/PolluxTests/NowPlayingPolicyTests.swift diff --git a/iOS/Pollux/NowPlayingController.swift b/iOS/Pollux/NowPlayingController.swift index d510981..9cf0c21 100644 --- a/iOS/Pollux/NowPlayingController.swift +++ b/iOS/Pollux/NowPlayingController.swift @@ -12,21 +12,42 @@ final class NowPlayingController { private let send: (Event) -> Void private var artworkURL: String? private var artwork: MPMediaItemArtwork? + /// What the card currently shows, so an unchanged state isn't rewritten (see + /// `NowPlayingPolicy`). Nil while the card is empty. + private var published: NowPlayingPublication? + /// The latest state seen, published or not, for republishing when artwork arrives. + private var latest: PlayerView? init(send: @escaping (Event) -> Void) { self.send = send registerCommands() } - func update(_ player: PlayerView?) { - let center = MPNowPlayingInfoCenter.default() + /// Called on every render; writes the card only when `NowPlayingPolicy` says it is out + /// of date. `now` is injectable for tests. + func update(_ player: PlayerView?, now: TimeInterval = ProcessInfo.processInfo.systemUptime) { guard let player else { - center.nowPlayingInfo = nil - center.playbackState = .stopped + clear() return } + latest = player loadArtworkIfNeeded(player.artworkUrl) + guard NowPlayingPolicy.needsPublishing(player, since: published, now: now) else { return } + publish(player, now: now) + } + + private func clear() { + latest = nil + // Renders with nothing playing are frequent; only touch the card if it has content. + guard published != nil else { return } + published = nil + let center = MPNowPlayingInfoCenter.default() + center.nowPlayingInfo = nil + center.playbackState = .stopped + } + private func publish(_ player: PlayerView, now: TimeInterval) { + let center = MPNowPlayingInfoCenter.default() var info: [String: Any] = [ MPMediaItemPropertyTitle: player.episodeTitle, MPMediaItemPropertyArtist: player.feedTitle, @@ -43,7 +64,18 @@ final class NowPlayingController { } center.nowPlayingInfo = info center.playbackState = player.isPlaying ? .playing : .paused - updateSkipIntervals(forward: player.skipForwardSecs, back: player.skipBackSecs) + + let snapshot = NowPlayingSnapshot(player) + let skipChanged = published.map { + $0.snapshot.skipForwardSecs != snapshot.skipForwardSecs + || $0.snapshot.skipBackSecs != snapshot.skipBackSecs + } ?? true + if skipChanged { + updateSkipIntervals(forward: player.skipForwardSecs, back: player.skipBackSecs) + } + published = NowPlayingPublication( + snapshot: snapshot, elapsed: Double(player.positionSecs), publishedAt: now, + ) } // MARK: - Remote commands @@ -103,10 +135,12 @@ final class NowPlayingController { private func applyArtwork(_ image: UIImage, for urlString: String) { // The episode may have changed while the image downloaded. guard artworkURL == urlString else { return } - let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } - self.artwork = artwork - var info = MPNowPlayingInfoCenter.default().nowPlayingInfo - info?[MPMediaItemPropertyArtwork] = artwork - MPNowPlayingInfoCenter.default().nowPlayingInfo = info + artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + // Republish from the latest state rather than patching the card in place: the card + // holds an elapsed time from when it was last written, which may be many seconds + // old now, and rewriting it would set the system's clock back to that. + if let latest { + publish(latest, now: ProcessInfo.processInfo.systemUptime) + } } } diff --git a/iOS/Pollux/NowPlayingPolicy.swift b/iOS/Pollux/NowPlayingPolicy.swift new file mode 100644 index 0000000..e4cf97d --- /dev/null +++ b/iOS/Pollux/NowPlayingPolicy.swift @@ -0,0 +1,61 @@ +import App +import Foundation + +/// What the lock-screen card shows, apart from where the playhead is. When any of these +/// changes the card must be rewritten. +struct NowPlayingSnapshot: Equatable { + let episodeId: String + let title: String + let feedTitle: String + let artworkUrl: String? + let duration: UInt32? + let isPlaying: Bool + let skipForwardSecs: UInt32 + let skipBackSecs: UInt32 + + init(_ player: PlayerView) { + episodeId = player.episodeId + title = player.episodeTitle + feedTitle = player.feedTitle + artworkUrl = player.artworkUrl + duration = player.durationSecs + isPlaying = player.isPlaying + skipForwardSecs = player.skipForwardSecs + skipBackSecs = player.skipBackSecs + } +} + +/// What was last written to the card, and when. The system runs the card's clock itself +/// from an elapsed time and a rate, so for as long as the playhead keeps pace with that +/// clock there is nothing to tell it. +struct NowPlayingPublication { + let snapshot: NowPlayingSnapshot + let elapsed: Double + /// A monotonic timestamp (`ProcessInfo.systemUptime`) of when it was written. + let publishedAt: TimeInterval +} + +enum NowPlayingPolicy { + /// How far the playhead may stray from the card's own clock before the card is + /// corrected. The core reports whole seconds, so ordinary playback is always within one + /// second of the clock; this leaves room for that and for timing jitter. + static let driftTolerance: Double = 2 + + /// Whether the card needs rewriting for `player`. + /// + /// Rewriting it resets the system's clock, so doing it on every render (every second + /// of playback, and on every download or refresh update besides) makes the card's + /// scrubber jitter and does needless work. It is rewritten when what it shows changes, + /// or when the playhead has left the clock's course: a seek or skip, or playback that + /// stalled (buffering) or caught up. + static func needsPublishing( + _ player: PlayerView, since last: NowPlayingPublication?, now: TimeInterval, + ) -> Bool { + guard let last else { return true } + if NowPlayingSnapshot(player) != last.snapshot { + return true + } + let expected = last.snapshot.isPlaying ? last.elapsed + (now - last.publishedAt) : last.elapsed + return abs(Double(player.positionSecs) - expected) > driftTolerance + } +} diff --git a/iOS/PolluxTests/NowPlayingPolicyTests.swift b/iOS/PolluxTests/NowPlayingPolicyTests.swift new file mode 100644 index 0000000..91fb8e3 --- /dev/null +++ b/iOS/PolluxTests/NowPlayingPolicyTests.swift @@ -0,0 +1,112 @@ +import App +import Foundation +import Testing + +@testable import Pollux + +// The lock-screen card runs its own clock from an elapsed time and a rate. Rewriting it +// resets that clock, so it should be rewritten only when the card's content changes or the +// playhead leaves the clock's course, not on every render. + +private func player( + position: UInt32 = 100, + playing: Bool = true, + title: String = "Episode", + duration: UInt32? = 3600, + artwork: String? = "https://example.com/a.jpg", + skipForward: UInt32 = 30, + skipBack: UInt32 = 15, + error: String? = nil, +) -> PlayerView { + PlayerView( + episodeId: "ep-1", episodeTitle: title, feedTitle: "Feed", + source: .subscription(id: "sub-1"), sourceTitle: "Feed", artworkUrl: artwork, + positionSecs: position, durationSecs: duration, isPlaying: playing, isStreaming: false, + skipForwardSecs: skipForward, skipBackSecs: skipBack, description: nil, + descriptionText: nil, error: error, + ) +} + +/// A card as last written for `player` at time `at`. +private func published(_ player: PlayerView, at time: TimeInterval = 1000) -> NowPlayingPublication { + NowPlayingPublication( + snapshot: NowPlayingSnapshot(player), elapsed: Double(player.positionSecs), publishedAt: time, + ) +} + +private func needsPublishing(_ current: PlayerView, since last: NowPlayingPublication?, at time: TimeInterval) -> Bool { + NowPlayingPolicy.needsPublishing(current, since: last, now: time) +} + +@Suite struct NowPlayingPolicyTests { + @Test func theFirstStateIsPublished() { + #expect(needsPublishing(player(), since: nil, at: 1000)) + } + + @Test func ordinaryPlaybackNeverRewritesTheCard() { + // A tick a second for ten minutes: the playhead keeps pace with the card's clock. + let first = player(position: 100) + let last = published(first, at: 1000) + + for second in 1 ... 600 { + let tick = player(position: 100 + UInt32(second)) + #expect(!needsPublishing(tick, since: last, at: 1000 + TimeInterval(second))) + } + } + + @Test func aWholeSecondOfJitterIsTolerated() { + // The core reports truncated whole seconds, so a tick can lag the clock by up to one. + let last = published(player(position: 100), at: 1000) + #expect(!needsPublishing(player(position: 101), since: last, at: 1002.9)) + #expect(!needsPublishing(player(position: 103), since: last, at: 1002.1)) + } + + @Test func pausingAndResumingRewriteTheCard() { + let playing = published(player(playing: true)) + #expect(needsPublishing(player(playing: false), since: playing, at: 1005)) + + let paused = published(player(playing: false)) + #expect(needsPublishing(player(playing: true), since: paused, at: 1005)) + } + + @Test func aSkipOrSeekRewritesTheCard() { + let last = published(player(position: 100), at: 1000) + // +30 skip a second later. + #expect(needsPublishing(player(position: 131), since: last, at: 1001)) + // -15 skip a second later. + #expect(needsPublishing(player(position: 86), since: last, at: 1001)) + } + + @Test func aStallRewritesTheCardOnceTheGapIsNoticeable() { + // Buffering: the player says it is playing but the position stands still, so the + // card's clock runs ahead of it. + let last = published(player(position: 100), at: 1000) + #expect(!needsPublishing(player(position: 100), since: last, at: 1001.5)) + #expect(needsPublishing(player(position: 100), since: last, at: 1005)) + } + + @Test func aPausedCardIsLeftAloneHoweverLongItStays() { + let last = published(player(position: 100, playing: false), at: 1000) + #expect(!needsPublishing(player(position: 100, playing: false), since: last, at: 50000)) + } + + @Test func scrubbingWhilePausedRewritesTheCard() { + let last = published(player(position: 100, playing: false), at: 1000) + #expect(needsPublishing(player(position: 400, playing: false), since: last, at: 1010)) + } + + @Test func contentChangesRewriteTheCard() { + let last = published(player()) + // The real duration arrives once the engine has loaded the file. + #expect(needsPublishing(player(duration: 3639), since: last, at: 1001)) + #expect(needsPublishing(player(title: "Another"), since: last, at: 1001)) + #expect(needsPublishing(player(artwork: nil), since: last, at: 1001)) + #expect(needsPublishing(player(skipForward: 45), since: last, at: 1001)) + #expect(needsPublishing(player(skipBack: 10), since: last, at: 1001)) + } + + @Test func thingsTheCardDoesNotShowAreIgnored() { + let last = published(player()) + #expect(!needsPublishing(player(position: 101, error: "Couldn't start audio"), since: last, at: 1001)) + } +} From a0301ef32bee01d8d02fe4d7f8a2a9ce77b996b5 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 09:33:22 +0200 Subject: [PATCH 16/32] perf: don't re-evaluate the episode list on every playback tick Everything that watched Core was re-evaluated whenever it published, so the once-a-second position tick made SwiftUI re-evaluate the whole episode list behind the player (about 5.4% CPU with a 375-episode list open, against about 1.0% with ticks not rendering). - keep the player in its own PlayerState object; Core.view no longer carries it (view.player is always nil) - republish Core.view only when something other than the player changed - the mini-player and full player observe PlayerState through small host views, and the root view subscribes to it for dismissal instead of observing it, so a tick wakes only the views that show the player - test that ticks republish the player but not the list view, that a real list change still does, and that identical players aren't republished Co-Authored-By: Claude Sonnet 5.5 --- iOS/Pollux/PlayerState.swift | 21 +++++++ iOS/Pollux/Pollux.swift | 85 +++++++++++++++++++-------- iOS/Pollux/core.swift | 29 +++++++-- iOS/PolluxTests/CoreWiringTests.swift | 58 +++++++++++++++++- iOS/PolluxTests/Publishes.swift | 15 +++++ 5 files changed, 176 insertions(+), 32 deletions(-) create mode 100644 iOS/Pollux/PlayerState.swift create mode 100644 iOS/PolluxTests/Publishes.swift diff --git a/iOS/Pollux/PlayerState.swift b/iOS/Pollux/PlayerState.swift new file mode 100644 index 0000000..d5bb6da --- /dev/null +++ b/iOS/Pollux/PlayerState.swift @@ -0,0 +1,21 @@ +import App +import Foundation + +/// The active player, kept apart from `Core.view` on purpose. +/// +/// The player changes every second while playing (the position). Everything that observes +/// `Core` is re-evaluated whenever it publishes, so if the player lived in `Core.view`, a +/// tick would make SwiftUI re-evaluate the whole episode list behind it. Here only the views +/// that show the player (the mini-player and the full player) observe it, and the list only +/// hears from `Core` when its own data changes. +@MainActor +final class PlayerState: ObservableObject { + @Published private(set) var player: PlayerView? + + /// Replaces the player, publishing only if it actually changed. + func update(_ new: PlayerView?) { + if new != player { + player = new + } + } +} diff --git a/iOS/Pollux/Pollux.swift b/iOS/Pollux/Pollux.swift index 0d458eb..33112f6 100644 --- a/iOS/Pollux/Pollux.swift +++ b/iOS/Pollux/Pollux.swift @@ -1,4 +1,5 @@ import App +import Combine import SwiftUI @main @@ -39,22 +40,32 @@ private struct RootView: View { ContentView(core: core, path: $path) // Hidden entirely when nothing is active, and while the full player is up. .safeAreaInset(edge: .bottom, spacing: 0) { - if let player = core.view.player, !showPlayer { - MiniPlayerBar( - player: player, - onOpen: { showPlayer = true }, - onTogglePlay: { core.update(.togglePlay) }, - ) - } + MiniPlayerHost( + playerState: core.playerState, + isPlayerOpen: showPlayer, + onOpen: { showPlayer = true }, + onTogglePlay: { core.update(.togglePlay) }, + ) } .fullScreenCover(isPresented: $showPlayer) { - fullPlayer + FullPlayerHost( + playerState: core.playerState, + colors: colors, + theme: theme, + send: { core.update($0) }, + onHide: { showPlayer = false }, + onGoToSource: { goToSource(of: $0) }, + ) } .environment(\.themeColors, colors) .tint(colors.accent) .background(colors.background.ignoresSafeArea()) .preferredColorScheme(theme.preferredColorScheme) - .onChange(of: core.view.player?.episodeId) { _, episodeId in + // Subscribed to rather than observed: this view must not be re-evaluated every + // second just because the playing position changed. + .onReceive( + core.playerState.$player.map { $0?.episodeId }.removeDuplicates(), + ) { episodeId in // The episode finished (or was cleared): nothing left to show. if episodeId == nil { showPlayer = false @@ -74,21 +85,6 @@ private struct RootView: View { } } - @ViewBuilder private var fullPlayer: some View { - if let player = core.view.player { - PlayerScreen( - player: player, - send: { core.update($0) }, - onHide: { showPlayer = false }, - onGoToSource: { goToSource(of: player) }, - ) - // A cover doesn't reliably inherit the theme applied below it. - .environment(\.themeColors, colors) - .tint(colors.accent) - .preferredColorScheme(theme.preferredColorScheme) - } - } - /// Dismisses the player and shows what playback was started from. A subscription is /// the only source for now; a playlist will get its own case here. private func goToSource(of player: PlayerView) { @@ -103,3 +99,44 @@ private struct RootView: View { } } } + +/// The mini-player. Observes the player state itself so the root view, and the list under +/// it, are not re-evaluated every time the playing position changes. +private struct MiniPlayerHost: View { + @ObservedObject var playerState: PlayerState + let isPlayerOpen: Bool + let onOpen: () -> Void + let onTogglePlay: () -> Void + + var body: some View { + // Hidden entirely when nothing is active, and while the full player is up. + if let player = playerState.player, !isPlayerOpen { + MiniPlayerBar(player: player, onOpen: onOpen, onTogglePlay: onTogglePlay) + } + } +} + +/// The full-screen player, observing the player state for the same reason. +private struct FullPlayerHost: View { + @ObservedObject var playerState: PlayerState + let colors: ThemeColors + let theme: ThemeView + let send: (Event) -> Void + let onHide: () -> Void + let onGoToSource: (PlayerView) -> Void + + var body: some View { + if let player = playerState.player { + PlayerScreen( + player: player, + send: send, + onHide: onHide, + onGoToSource: { onGoToSource(player) }, + ) + // A cover doesn't reliably inherit the theme applied below it. + .environment(\.themeColors, colors) + .tint(colors.accent) + .preferredColorScheme(theme.preferredColorScheme) + } + } +} diff --git a/iOS/Pollux/core.swift b/iOS/Pollux/core.swift index 4926f2c..5e304ae 100644 --- a/iOS/Pollux/core.swift +++ b/iOS/Pollux/core.swift @@ -4,7 +4,12 @@ import Shared @MainActor class Core: ObservableObject { - @Published var view: ViewModel + /// What the core last rendered, minus the player: `view.player` is always nil. The + /// player changes every second while playing, so it lives in `playerState`, and `view` is + /// republished only when something else (the library, the episode list, the theme) + /// changed. See `PlayerState`. + @Published private(set) var view: ViewModel + let playerState = PlayerState() private var core: CoreFfi private let db: DatabaseManager @@ -73,7 +78,9 @@ class Core: ObservableObject { guard let view = try? ViewModel.bincodeDeserialize(input: [UInt8](core.view())) else { fatalError("Failed to deserialize initial ViewModel from core") } - self.view = view + let (rest, player) = Core.splittingPlayer(view) + self.view = rest + playerState.update(player) Task { @MainActor [weak self] in for await (operation, requestId) in storageStream { guard let self else { return } @@ -159,8 +166,13 @@ class Core: ObservableObject { ) else { fatalError("Failed to deserialize ViewModel during render") } - view = updatedView - nowPlaying?.update(updatedView.player) + let (rest, player) = Core.splittingPlayer(updatedView) + // Equality is far cheaper than SwiftUI re-evaluating an unchanged episode list. + if rest != view { + view = rest + } + playerState.update(player) + nowPlaying?.update(player) case let .storage(operation): // Enqueue for the serial consumer set up in init (preserves write order). @@ -207,7 +219,14 @@ class Core: ObservableObject { self?.update(event) } // Publish the current state now rather than at the next render. - nowPlaying?.update(view.player) + nowPlaying?.update(playerState.player) + } + + /// Separates the player from the rest of a rendered view model. + private static func splittingPlayer(_ rendered: ViewModel) -> (rest: ViewModel, player: PlayerView?) { + var rest = rendered + rest.player = nil + return (rest, rendered.player) } /// Runs a download-capability operation on the DownloadManager actor (so the UI diff --git a/iOS/PolluxTests/CoreWiringTests.swift b/iOS/PolluxTests/CoreWiringTests.swift index 406ed87..354e98a 100644 --- a/iOS/PolluxTests/CoreWiringTests.swift +++ b/iOS/PolluxTests/CoreWiringTests.swift @@ -276,19 +276,71 @@ private func seedPlayContext(_ db: DatabaseManager) async throws { let rig = try await makeRig { db, _ in try await seedPlayContext(db) } // The mini-player comes back (this is also what a background-refresh launch does)… - try await waitUntil { rig.core.view.player != nil } + try await waitUntil { rig.core.playerState.player != nil } // …without an audio engine or lock-screen integration behind it. - #expect(rig.core.view.player?.isPlaying == false) + #expect(rig.core.playerState.player?.isPlaying == false) #expect(!rig.core.hasPlaybackEngine) } @Test @MainActor func theFirstPlayRequestCreatesTheAudioEngine() async throws { let rig = try await makeRig { db, _ in try await seedPlayContext(db) } - try await waitUntil { rig.core.view.player != nil } + try await waitUntil { rig.core.playerState.player != nil } #expect(!rig.core.hasPlaybackEngine) rig.core.update(.togglePlay) #expect(rig.core.hasPlaybackEngine) } + +// MARK: - The player changes without disturbing the list + +@Test @MainActor func aPositionTickRepublishesThePlayerButNotTheListView() async throws { + let rig = try await makeRig { db, _ in try await seedPlayContext(db) } + try await waitUntil { rig.core.playerState.player != nil } + let viewPublishes = Publishes(rig.core.$view) + let playerPublishes = Publishes(rig.core.playerState.$player) + + // A restored episode is session 0. Playing moves the position once a second. + for second in 31 ... 40 { + rig.core.update(.playerTick(session: 0, positionSecs: UInt32(second))) + } + + #expect(rig.core.playerState.player?.positionSecs == 40) + #expect(playerPublishes.count == 10) + // What the episode list and library read from did not change, so it was not republished: + // SwiftUI would otherwise re-evaluate the whole list on every tick. + #expect(viewPublishes.count == 0) +} + +@Test @MainActor func aRepeatedIdenticalPlayerIsNotRepublished() async throws { + let rig = try await makeRig { db, _ in try await seedPlayContext(db) } + try await waitUntil { rig.core.playerState.player != nil } + rig.core.update(.playerTick(session: 0, positionSecs: 50)) + let playerPublishes = Publishes(rig.core.playerState.$player) + + rig.core.update(.playerTick(session: 0, positionSecs: 50)) + rig.core.update(.playerTick(session: 0, positionSecs: 50)) + + #expect(playerPublishes.count == 0) +} + +@Test @MainActor func aRealChangeToTheListStillRepublishesTheView() async throws { + let rig = try await makeRig { db, _ in try await seedPlayContext(db) } + try await waitUntil { rig.core.playerState.player != nil } + let viewPublishes = Publishes(rig.core.$view) + + // Not a position tick: the library/list data itself changes. + rig.core.update(.setTheme(id: .nord, mode: .dark)) + + #expect(viewPublishes.count == 1) + #expect(rig.core.view.theme.id == .nord) +} + +@Test @MainActor func theViewModelKeptForTheListNeverCarriesAPlayer() async throws { + let rig = try await makeRig { db, _ in try await seedPlayContext(db) } + try await waitUntil { rig.core.playerState.player != nil } + + // The player lives only in `playerState`; nothing should read a stale copy from `view`. + #expect(rig.core.view.player == nil) +} diff --git a/iOS/PolluxTests/Publishes.swift b/iOS/PolluxTests/Publishes.swift new file mode 100644 index 0000000..747f3ed --- /dev/null +++ b/iOS/PolluxTests/Publishes.swift @@ -0,0 +1,15 @@ +import Combine +import Foundation + +/// Counts how often a published value changes after the point of subscription (the +/// initial value, which `@Published` emits on subscribe, is not counted). Lives in its own +/// file because importing Combine makes `Subscription` ambiguous with the app's own type. +@MainActor +final class Publishes { + private(set) var count = 0 + private var subscription: AnyCancellable? + + init(_ publisher: some Publisher) { + subscription = publisher.dropFirst().sink { [unowned self] _ in count += 1 } + } +} From a62cd9b00fb6f6f54a1bc42e7ae4a25cc46a2469 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 09:35:53 +0200 Subject: [PATCH 17/32] docs: note the per-tick render cost and how to follow up on it Record what was measured (payload, Rust and Swift cost, app CPU with the episode list open), what the player/list split changed, and what is not yet known: all numbers are from debug simulator builds. List the follow-ups in order (profile a release build on a device; shell-side clock for the position; compute previews once and ship raw HTML on demand) and how to re-measure. --- docs/features/player.md | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/docs/features/player.md b/docs/features/player.md index a75b2c7..b7ce666 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -217,3 +217,66 @@ Notes for whoever builds this: download may not match what was streamed. At a given timestamp the swap would then land in different content. No swap strategy fixes that; it applies to today's swap too. Worth knowing before judging a swap by ear on such a feed. + +### Cost of the once-a-second render (needs a device measurement) + +While playing, the engine reports the position about once a second and the core renders +on each report, so the shell re-reads the whole view model every second. Nothing is wrong +with that in principle, but how much it costs on a real device has **not been measured**, +and the simulator numbers below are from debug builds, which overstate it. + +What was done: the player state lives in its own object (`PlayerState`), and `Core` only +republishes `view` (what the library and the episode list read) when something other than +the player changed. A tick therefore wakes only the mini-player and the full player, not +the episode list. Tests: `CoreWiringTests` ("The player changes without disturbing the +list"). + +What was measured (a 375-episode feed, an M-series Mac, the iPhone simulator): + +| What | Cost | +|---|---| +| View model per render | about 235 KB; 150 KB of it is episode descriptions and their previews | +| Build the view in Rust, release | about 0.5 ms (nearly all of it stripping and cloning descriptions) | +| Build the view in Rust, debug | about 6 ms (the simulator builds use the debug core) | +| Serialize | 13 µs release, 0.3 ms debug | +| Decode in Swift (debug test build) | about 2 to 2.5 ms | +| App CPU while playing, list open, before the split | about 5.4% | +| Same, after the split | about 4.1% | +| Same, experiment where ticks do not render at all | about 1.0% | +| App CPU while playing on the library screen (before the split) | about 2.2% | + +The split removed the SwiftUI part (the list re-evaluating each second). The remaining gap +to 1.0% is probably the per-tick pipeline itself (build, serialize, decode, compare) running +in debug builds. That is an inference, not a measurement. + +Follow-up, in order: + +1. **Measure a release build on a device** (Instruments: Time Profiler and Energy Log) + while playing with the episode list open, and compare against paused. If it is small, + stop here. Note that `make ios-xcodebuild` builds the Rust core without `--release` + (the Makefile's `package` target runs `cargo swift package` with no profile flag), so + simulator numbers are not representative. A release option in the Makefile would make + this repeatable. +2. **If it still matters, stop rendering on ticks.** The core would update its position + without a render (rendering only on checkpoints and state changes), and the shell would + drive the displayed position from its own clock fed by the engine. In the experiment this + reached the 1.0% floor. The cost is a second source of truth for position that has to be + reconciled on every seek, skip, pause and source swap. +3. **Shrink the payload and stop recomputing previews.** Two separate, smaller wins: + - The Rust time per render is almost entirely the descriptions: the 200-character previews + are rebuilt from the raw HTML for every episode on every render (leaving descriptions + out of the view took it from about 0.5 ms to 29 µs in the measurement, so the exact + split between stripping and cloning is not known). Computing each preview once, when + the episodes load, and keeping it with the episode removes that cost from every render, + not just ticks. This is a core-only change. + - The list ships every episode's raw HTML description as well, though the rows only need + the preview and the detail page needs the raw HTML for one episode. Shipping it on + demand would cut the payload by about 35% (about 65% with the previews gone too). It + changes how the episode list is projected, which belongs to the library work, so + coordinate with that. + +To re-measure CPU: start playback, open the subscription page with the long list, find the +app's process id with `pgrep -f "Pollux.app/Pollux"`, then `top -l 5 -s 3 -pid -stats +pid,cpu`. Compare with playback on the library screen and with playback paused. For the Rust +side, time `Pollux::view` and the bincode serialization for the same feed in `cargo test +--release` against plain `cargo test`. From f7b8456a1b803d5bce4ba29d6f4d8a431954d291 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 09:40:55 +0200 Subject: [PATCH 18/32] fix: make ios-test and ios-xcodebuild fail when xcodebuild fails Both recipes pipe xcodebuild into xcbeautify, so the exit status was the formatter's and a failed build or test still exited 0. - set -o pipefail in both recipes - run recipes under bash, since make 3.81 (macOS) has no .SHELLFLAGS to set it globally and not every sh supports pipefail - verified: passing suite exits 0; a broken assertion and a compile error each exit 2 Co-Authored-By: Claude Sonnet 5.5 --- Makefile | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Makefile b/Makefile index a02162b..c43c18e 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,7 @@ +# Recipes run under bash so `set -o pipefail` is available (GNU Make 3.81, which macOS +# ships, has no .SHELLFLAGS to set it globally). +SHELL := /bin/bash + check: rust-all-checks swift-all-checks test: rust-test ios-test lint: rust-lint swift-lint @@ -113,7 +117,10 @@ generate-project: typegen package ios-build: generate-project +# `pipefail`: without it the pipeline's exit status is xcbeautify's, so a failed build or +# a failed test would still exit 0 (and `make check` / CI would pass). ios-xcodebuild: ios-build + set -o pipefail; \ xcodebuild \ -project $(XCODE_PROJECT) \ -scheme $(XCODE_SCHEME) \ @@ -141,7 +148,9 @@ ios-sim: ios-xcodebuild xcrun simctl launch --console $(SIM_ID) \ $$(/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "$(APP_PATH)/Info.plist") +# `pipefail`: see ios-xcodebuild. ios-test: ios-build + set -o pipefail; \ xcodebuild test \ -project $(XCODE_PROJECT) \ -scheme PolluxTests \ From 34e2dd054d136d917d5097101dcbb3a8bf6fc026 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 09:47:26 +0200 Subject: [PATCH 19/32] perf: build the spoken-time formatter once PlayerFormatting.spoken allocated a DateComponentsFormatter on every call, and the scrubber's accessibility value asks for it on every tick and every drag step. Hold one in a static let, as EpisodeFormatting does. Measured saving is modest (about 39 us to 35 us per call in a debug build; the formatting itself dominates). - test each unit appears when present, that the result is never empty, and that repeated calls on the shared formatter are stable Co-Authored-By: Claude Sonnet 5.5 --- iOS/Pollux/PlayerFormatting.swift | 10 +++++++-- iOS/PolluxTests/PlayerFormattingTests.swift | 23 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/iOS/Pollux/PlayerFormatting.swift b/iOS/Pollux/PlayerFormatting.swift index f482c90..3041271 100644 --- a/iOS/Pollux/PlayerFormatting.swift +++ b/iOS/Pollux/PlayerFormatting.swift @@ -27,11 +27,17 @@ enum PlayerFormatting { /// Spoken form for VoiceOver: "1 hour 2 minutes 3 seconds". static func spoken(_ seconds: UInt32) -> String { + spokenFormatter.string(from: TimeInterval(seconds)) ?? "\(seconds) seconds" + } + + /// Built once: creating a formatter is costly, and the scrubber's accessibility value + /// asks for this on every update (every tick, and every step of a drag). + private static let spokenFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.unitsStyle = .full formatter.allowedUnits = [.hour, .minute, .second] - return formatter.string(from: TimeInterval(seconds)) ?? "\(seconds) seconds" - } + return formatter + }() /// Fraction of the episode played, 0...1; 0 when the duration is unknown. static func fraction(position: UInt32, duration: UInt32?) -> Double { diff --git a/iOS/PolluxTests/PlayerFormattingTests.swift b/iOS/PolluxTests/PlayerFormattingTests.swift index 0007a1c..0ce00c9 100644 --- a/iOS/PolluxTests/PlayerFormattingTests.swift +++ b/iOS/PolluxTests/PlayerFormattingTests.swift @@ -34,4 +34,27 @@ import Testing @Test func spokenTimeIsWords() { #expect(PlayerFormatting.spoken(3723).contains("hour")) } + + @Test func spokenTimeNamesEachUnitPresent() { + let spoken = PlayerFormatting.spoken(3723) + #expect(spoken.contains("hour")) + #expect(spoken.contains("minute")) + #expect(spoken.contains("second")) + // Under an hour there is no hours part. + #expect(!PlayerFormatting.spoken(123).contains("hour")) + } + + @Test func spokenTimeIsNeverEmpty() { + for seconds: UInt32 in [0, 1, 59, 60, 3599, 3600, 86400] { + #expect(!PlayerFormatting.spoken(seconds).isEmpty) + } + } + + @Test func spokenTimeIsStableAcrossRepeatedCalls() { + // One shared formatter must give the same answer however often it is used. + let first = PlayerFormatting.spoken(754) + for _ in 0 ..< 100 { + #expect(PlayerFormatting.spoken(754) == first) + } + } } From a298588342218faa52f9f6b8b02f6d0f4e7f7765 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 09:49:49 +0200 Subject: [PATCH 20/32] refactor: look up the storage root in one place The Application Support directory was looked up inline in DownloadManager.init and DatabaseManager.init, with a third copy as a static on DownloadManager for the player. They must agree, or playback would read a different root than downloads and the database. - add StorageRoot.applicationSupport() and use it in all three; each caller keeps its own failure behavior - remove DownloadManager.defaultStorageRoot(); a neutral helper avoids the database depending on the download manager - test that it is the Application Support directory and that stored relative paths resolve under it Co-Authored-By: Claude Sonnet 5.5 --- iOS/Pollux/DatabaseManager.swift | 4 +--- iOS/Pollux/DownloadManager.swift | 9 +-------- iOS/Pollux/StorageRoot.swift | 11 +++++++++++ iOS/Pollux/core.swift | 2 +- iOS/PolluxTests/StorageRootTests.swift | 22 ++++++++++++++++++++++ 5 files changed, 36 insertions(+), 12 deletions(-) create mode 100644 iOS/Pollux/StorageRoot.swift create mode 100644 iOS/PolluxTests/StorageRootTests.swift diff --git a/iOS/Pollux/DatabaseManager.swift b/iOS/Pollux/DatabaseManager.swift index c3d964c..35cd757 100644 --- a/iOS/Pollux/DatabaseManager.swift +++ b/iOS/Pollux/DatabaseManager.swift @@ -24,9 +24,7 @@ actor DatabaseManager { init() throws { now = { Date() } - guard let support = FileManager.default.urls( - for: .applicationSupportDirectory, in: .userDomainMask, - ).first else { + guard let support = StorageRoot.applicationSupport() else { fatalError("Application Support directory unavailable") } try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) diff --git a/iOS/Pollux/DownloadManager.swift b/iOS/Pollux/DownloadManager.swift index 3a51aad..2e0cff5 100644 --- a/iOS/Pollux/DownloadManager.swift +++ b/iOS/Pollux/DownloadManager.swift @@ -66,9 +66,7 @@ actor DownloadManager { if let storageRoot { root = storageRoot } else { - guard let support = FileManager.default.urls( - for: .applicationSupportDirectory, in: .userDomainMask, - ).first else { + guard let support = StorageRoot.applicationSupport() else { throw DownloadManagerError.storageUnavailable } root = support @@ -205,11 +203,6 @@ actor DownloadManager { root.appendingPathComponent(relativePath) } - /// The default storage root: Application Support. - static func defaultStorageRoot() -> URL? { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - } - /// A filesystem-safe, stable, collision-free file name for an episode. The episode /// id can in principle be any string (GUID, URL, hash), so the base is a SHA-256 /// digest of the id rather than a lossy character replacement — two distinct ids diff --git a/iOS/Pollux/StorageRoot.swift b/iOS/Pollux/StorageRoot.swift new file mode 100644 index 0000000..bf840b9 --- /dev/null +++ b/iOS/Pollux/StorageRoot.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Where the app keeps its data: Application Support. The database, the downloaded audio +/// and the player (which opens the downloaded files) must all agree on this, so it is +/// looked up in one place. +enum StorageRoot { + /// The Application Support directory, or nil if the system can't provide one. + static func applicationSupport() -> URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + } +} diff --git a/iOS/Pollux/core.swift b/iOS/Pollux/core.swift index 5e304ae..3753e8e 100644 --- a/iOS/Pollux/core.swift +++ b/iOS/Pollux/core.swift @@ -210,7 +210,7 @@ class Core: ObservableObject { /// it is played). private func startPlaybackIfNeeded() { guard playback == nil else { return } - if let root = DownloadManager.defaultStorageRoot() { + if let root = StorageRoot.applicationSupport() { playback = PlaybackManager(storageRoot: root) { [weak self] event in self?.update(event) } diff --git a/iOS/PolluxTests/StorageRootTests.swift b/iOS/PolluxTests/StorageRootTests.swift new file mode 100644 index 0000000..8eba574 --- /dev/null +++ b/iOS/PolluxTests/StorageRootTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing + +@testable import Pollux + +@Suite struct StorageRootTests { + @Test func isTheApplicationSupportDirectory() throws { + let root = try #require(StorageRoot.applicationSupport()) + let expected = try #require( + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first, + ) + #expect(root == expected) + } + + @Test func downloadedFilesResolveUnderIt() throws { + // Playback opens what downloads stored, so a stored (relative) path must resolve to the + // same place whichever of them does the resolving. + let root = try #require(StorageRoot.applicationSupport()) + let url = DownloadManager.absoluteURL(for: "Downloads/abc.mp3", root: root) + #expect(url == root.appendingPathComponent("Downloads/abc.mp3")) + } +} From db0abcc41a3397c69417a0715e80e4f54c4ec820 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 10:01:12 +0200 Subject: [PATCH 21/32] test: cover the shell's audio-session notification mapping Post real interruption and route-change notifications at the PlaybackManager and check the core events it sends: interruption began is resumable, ended carries shouldResume, unplugging headphones is never resumable, other route changes and foreign senders are ignored. Also check that engine news carries the session of the load that produced it and that a load replaced before it is ready reports nothing for the old session. Co-Authored-By: Claude Sonnet 5.5 --- iOS/PolluxTests/PlaybackManagerTests.swift | 135 ++++++++++++++++++++- 1 file changed, 133 insertions(+), 2 deletions(-) diff --git a/iOS/PolluxTests/PlaybackManagerTests.swift b/iOS/PolluxTests/PlaybackManagerTests.swift index 5c86412..6df1b3e 100644 --- a/iOS/PolluxTests/PlaybackManagerTests.swift +++ b/iOS/PolluxTests/PlaybackManagerTests.swift @@ -1,4 +1,5 @@ import App +import AVFoundation import Foundation import Testing @@ -54,11 +55,22 @@ private final class AudioSessionProbe: @unchecked Sendable { } } +/// Collects what the engine reports to the core. +@MainActor +private final class EventLog { + private(set) var events: [Event] = [] + + func record(_ event: Event) { + events.append(event) + } +} + @MainActor private struct Rig { let manager: PlaybackManager let root: URL let audio: AudioSessionProbe + let log: EventLog /// A relative path (as the core stores them) to a real, playable file under the root. func installAudio(named name: String = "silence.wav") throws -> String { @@ -74,8 +86,47 @@ private func makeRig() -> Rig { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) let audio = AudioSessionProbe() - let manager = PlaybackManager(storageRoot: root, activateAudioSession: audio.claim) { _ in } - return Rig(manager: manager, root: root, audio: audio) + let log = EventLog() + let manager = PlaybackManager(storageRoot: root, activateAudioSession: audio.claim) { log.record($0) } + return Rig(manager: manager, root: root, audio: audio, log: log) +} + +/// Posts what the system posts when the audio session is interrupted or its route changes. +@MainActor +private func postInterruption( + _ type: AVAudioSession.InterruptionType, options: AVAudioSession.InterruptionOptions = [], +) { + NotificationCenter.default.post( + name: AVAudioSession.interruptionNotification, + object: AVAudioSession.sharedInstance(), + userInfo: [ + AVAudioSessionInterruptionTypeKey: type.rawValue, + AVAudioSessionInterruptionOptionKey: options.rawValue, + ], + ) +} + +@MainActor +private func postRouteChange(_ reason: AVAudioSession.RouteChangeReason) { + NotificationCenter.default.post( + name: AVAudioSession.routeChangeNotification, + object: AVAudioSession.sharedInstance(), + userInfo: [AVAudioSessionRouteChangeReasonKey: reason.rawValue], + ) +} + +/// Polls (on the main actor) until `condition` holds, failing the test if it doesn't within +/// `timeout` seconds. Engine news arrives asynchronously. +@MainActor +private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) async throws { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + guard Date() < deadline else { + Issue.record("timed out waiting for the engine") + return + } + try await Task.sleep(for: .milliseconds(20)) + } } @Suite @MainActor struct PlaybackManagerTests { @@ -237,4 +288,84 @@ private func makeRig() -> Rig { #expect(rig.manager.hasLoadedItem) #expect(rig.manager.currentSession == 2) } + + // MARK: - System notifications → core events + + @Test func anInterruptionBeginningIsReportedAsResumable() { + let rig = makeRig() + postInterruption(.began) + #expect(rig.log.events == [.interrupted(resumable: true)]) + } + + @Test func anInterruptionEndingReportsWhetherTheSystemAllowsResuming() { + let rig = makeRig() + postInterruption(.ended, options: [.shouldResume]) + postInterruption(.ended) + #expect(rig.log.events == [ + .interruptionEnded(shouldResume: true), + .interruptionEnded(shouldResume: false), + ]) + } + + @Test func unpluggingHeadphonesIsAnInterruptionThatNeverResumes() { + let rig = makeRig() + postRouteChange(.oldDeviceUnavailable) + #expect(rig.log.events == [.interrupted(resumable: false)]) + } + + @Test func otherRouteChangesAreNotReported() { + let rig = makeRig() + postRouteChange(.newDeviceAvailable) + postRouteChange(.categoryChange) + postRouteChange(.override) + #expect(rig.log.events.isEmpty) + } + + @Test func notificationsFromOtherObjectsAreIgnored() { + let rig = makeRig() + NotificationCenter.default.post( + name: AVAudioSession.interruptionNotification, + object: NSObject(), + userInfo: [AVAudioSessionInterruptionTypeKey: AVAudioSession.InterruptionType.began.rawValue], + ) + #expect(rig.log.events.isEmpty) + } + + // MARK: - Session tagging + + @Test func engineNewsCarriesTheSessionOfTheLoadThatProducedIt() async throws { + let rig = makeRig() + let path = try rig.installAudio() + + _ = rig.manager.perform( + .load(session: 7, media: .local(localPath: path), startSecs: 0, autoplay: false), + ) + try await waitUntil { rig.log.events.contains(.playerDuration(session: 7, durationSecs: 1)) } + + // A newer load replaces it; its news must carry the new session. + _ = rig.manager.perform( + .load(session: 8, media: .local(localPath: path), startSecs: 0, autoplay: false), + ) + try await waitUntil { rig.log.events.contains(.playerDuration(session: 8, durationSecs: 1)) } + } + + @Test func aLoadReplacedBeforeItIsReadyReportsNothingForTheOldSession() async throws { + let rig = makeRig() + let path = try rig.installAudio() + + // Replaced in the same turn, before the first item can report anything. + _ = rig.manager.perform( + .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: false), + ) + _ = rig.manager.perform( + .load(session: 2, media: .local(localPath: path), startSecs: 0, autoplay: false), + ) + try await waitUntil { rig.log.events.contains(.playerDuration(session: 2, durationSecs: 1)) } + + let stale = rig.log.events.filter { + if case let .playerDuration(session, _) = $0 { return session == 1 } + return false + } + #expect(stale.isEmpty) + } } From dc1ba83d19c496633d26b0f37b7ee1cd089f78b7 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 10:13:47 +0200 Subject: [PATCH 22/32] let playback download its episode without its feed open enqueue_download only searched the episodes of the feed on screen, which is empty after a cold start. A restored episode whose file went missing fell back to streaming but never re-downloaded, and resuming one whose download had failed never retried it. enqueue_download now also finds the active episode, set_download_state keeps the active episode's download status in step, and play() starts or retries the download when the episode is streaming with no file coming. A download already queued or underway is left alone. Co-Authored-By: Claude Sonnet 5.5 --- shared/src/app.rs | 24 ++- shared/src/player.rs | 12 +- shared/src/player/download_recovery_tests.rs | 174 +++++++++++++++++++ 3 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 shared/src/player/download_recovery_tests.rs diff --git a/shared/src/app.rs b/shared/src/app.rs index 900bc79..399e873 100644 --- a/shared/src/app.rs +++ b/shared/src/app.rs @@ -830,9 +830,13 @@ fn sort_episodes(episodes: &mut [EpisodeSummary], order: EpisodeSortOrder) { /// or re-download a file we already have. Shared by the explicit download action and /// by playback, which downloads what it streams. pub(crate) fn enqueue_download(model: &mut Model, episode_id: &str) -> Command { + // The episode on the player counts even when its feed isn't on screen (after a cold + // start the list is empty until a feed is opened): playback must be able to download + // what it streams regardless of navigation. let found = model .episodes .iter() + .chain(model.active_playback.as_ref().map(|a| &a.episode)) .find(|e| e.id == episode_id) .map(|e| (e.download_status.clone(), e.enclosure_url.clone())); match found { @@ -885,9 +889,23 @@ fn set_download_state( if !matches!(status, DownloadStatus::Failed) { model.download_errors.remove(episode_id); } - if let Some(episode) = model.episodes.iter_mut().find(|e| e.id == episode_id) { - episode.download_status = status; - episode.local_path = local_path; + // The episode on the player keeps its own copy, which must follow too: it is what a + // restored player (with no feed open) decides on, and what stops a repeated request + // from enqueueing the same download twice. + let active = model + .active_playback + .as_mut() + .map(|a| &mut a.episode) + .filter(|e| e.id == episode_id); + for episode in model + .episodes + .iter_mut() + .find(|e| e.id == episode_id) + .into_iter() + .chain(active) + { + episode.download_status = status.clone(); + episode.local_path = local_path.clone(); episode.file_size_bytes = size_bytes; } } diff --git a/shared/src/player.rs b/shared/src/player.rs index 67785f9..a172704 100644 --- a/shared/src/player.rs +++ b/shared/src/player.rs @@ -42,6 +42,8 @@ use crate::html::strip_html_preview; use crate::model::{ActivePlayback, Model}; use crate::view_model::PlayerView; +#[cfg(test)] +mod download_recovery_tests; #[cfg(test)] mod played_tests; #[cfg(test)] @@ -294,7 +296,15 @@ pub(crate) fn play(model: &mut Model) -> Cmd { player_op(session, PlayerOperation::Seek { secs: rewound }) .and(player_op(session, PlayerOperation::Play)) }; - cmd.and(render()) + // Still streaming with no file coming (a restored episode whose download was never + // started, or failed): playing is the moment to try again, as starting an episode + // does. A download already queued or underway is left alone. + let retry = + matches!(active.media, MediaSource::Stream { .. }).then(|| active.episode.id.clone()); + match retry { + Some(id) => cmd.and(enqueue_download(model, &id)).and(render()), + None => cmd.and(render()), + } } /// An explicit pause (the pause button, the lock screen, headphone controls). Pausing diff --git a/shared/src/player/download_recovery_tests.rs b/shared/src/player/download_recovery_tests.rs new file mode 100644 index 0000000..eedebda --- /dev/null +++ b/shared/src/player/download_recovery_tests.rs @@ -0,0 +1,174 @@ +//! Downloads the player starts or retries for the episode it has active, which must not +//! depend on that episode's feed being the one on screen: after a cold start the list is +//! empty until the user opens a feed. See the module docs in `player.rs`. + +use super::tests::{active, episode, model_with, player_ops, saved_context, send, session}; +use super::*; +use crate::capabilities::download::DownloadResult; + +/// A cold start: the saved episode is restored paused, and no feed has been opened. +fn cold_started(configure: impl FnOnce(&mut Episode)) -> Model { + let mut e = episode("e1"); + e.playback_status = PlaybackStatus::InProgress; + e.playback_position_secs = Some(200); + configure(&mut e); + let mut model = model_with(vec![]); + send(&mut model, saved_context(e)); + assert!( + model.episodes.is_empty(), + "no feed is open after a cold start" + ); + model +} + +fn download_state_writes(effects: &[Effect]) -> Vec { + super::tests::storage_ops(effects) + .into_iter() + .filter_map(|op| match op { + StorageOperation::UpdateDownloadState { status, .. } => Some(status), + _ => None, + }) + .collect() +} + +#[test] +fn a_missing_file_after_a_cold_start_still_redownloads() { + let mut model = cold_started(|e| { + e.download_status = DownloadStatus::Downloaded; + e.local_path = Some("Downloads/gone.mp3".into()); + }); + send(&mut model, Event::TogglePlay); + + let session = session(&model); + let effects = send( + &mut model, + Event::PlayerMediaUnusable { + session, + message: "file missing".into(), + }, + ); + + assert!(player_ops(&effects).iter().any(|op| matches!( + op, + PlayerOperation::Load { + media: MediaSource::Stream { .. }, + .. + } + ))); + assert_eq!(model.downloading.as_deref(), Some("e1")); + assert_eq!( + download_state_writes(&effects), + vec![ + DownloadStatus::NotDownloaded, + DownloadStatus::Queued, + DownloadStatus::Downloading + ] + ); +} + +#[test] +fn resuming_a_restored_episode_whose_download_failed_retries_it() { + let mut model = cold_started(|e| e.download_status = DownloadStatus::Failed); + + let effects = send(&mut model, Event::TogglePlay); + + assert_eq!(model.downloading.as_deref(), Some("e1")); + assert_eq!( + download_state_writes(&effects), + vec![DownloadStatus::Queued, DownloadStatus::Downloading] + ); + assert!( + player_ops(&effects) + .iter() + .any(|op| matches!(op, PlayerOperation::Load { autoplay: true, .. })), + "the retry must not get in the way of playing" + ); +} + +#[test] +fn resuming_a_restored_episode_that_was_never_downloaded_starts_the_download() { + let mut model = cold_started(|_| {}); + + send(&mut model, Event::TogglePlay); + + assert_eq!(model.downloading.as_deref(), Some("e1")); +} + +#[test] +fn resuming_does_not_redownload_a_file_that_is_there() { + let mut model = cold_started(|e| { + e.download_status = DownloadStatus::Downloaded; + e.local_path = Some("Downloads/e1.mp3".into()); + }); + + let effects = send(&mut model, Event::TogglePlay); + + assert!(model.downloading.is_none()); + assert!(download_state_writes(&effects).is_empty()); +} + +#[test] +fn resuming_does_not_enqueue_a_download_that_is_already_underway() { + let mut model = cold_started(|_| {}); + send(&mut model, Event::TogglePlay); + assert_eq!(model.downloading.as_deref(), Some("e1")); + + // Pause and resume while it downloads: still one download, not two. + send(&mut model, Event::TogglePlay); + let effects = send(&mut model, Event::TogglePlay); + + assert!(download_state_writes(&effects).is_empty()); + assert!(model.download_queue.is_empty()); +} + +#[test] +fn a_download_started_for_a_restored_episode_swaps_playback_onto_the_file() { + let mut model = cold_started(|_| {}); + send(&mut model, Event::TogglePlay); + + let effects = send( + &mut model, + Event::DownloadFinished { + episode_id: "e1".into(), + result: Box::new(DownloadResult::Completed { + local_path: "Downloads/e1.mp3".into(), + size_bytes: 1234, + }), + }, + ); + + assert_eq!( + active(&model).episode.download_status, + DownloadStatus::Downloaded + ); + assert!(player_ops(&effects).iter().any(|op| matches!( + op, + PlayerOperation::Load { + media: MediaSource::Local { .. }, + .. + } + ))); +} + +#[test] +fn a_failed_download_of_the_active_episode_is_recorded_so_a_resume_can_retry() { + let mut model = cold_started(|_| {}); + send(&mut model, Event::TogglePlay); + + send( + &mut model, + Event::DownloadFinished { + episode_id: "e1".into(), + result: Box::new(DownloadResult::Error("offline".into())), + }, + ); + assert_eq!( + active(&model).episode.download_status, + DownloadStatus::Failed + ); + + // Pause, then play again: the failed download is retried. + send(&mut model, Event::TogglePlay); + send(&mut model, Event::TogglePlay); + assert_eq!(model.downloading.as_deref(), Some("e1")); +} From 29955d3845fffa0dbefba548abbc1d9f335afe49 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 10:18:00 +0200 Subject: [PATCH 23/32] retry a failed play-context save The play context a relaunch restores was written once, when playback started, and its result was ignored. A failed write left the next launch restoring the wrong episode or none, with nothing to retry it (position writes recover at the next checkpoint; this did not). The save now resolves through its own event, and the active playback records whether storage confirmed it. While it is unconfirmed every checkpoint re-sends it. Answers about an episode that has since been replaced are ignored, and an episode restored from the saved context counts as already saved. Co-Authored-By: Claude Sonnet 5.5 --- shared/src/app.rs | 12 ++- shared/src/model.rs | 5 + shared/src/player.rs | 33 +++++- shared/src/player/context_save_tests.rs | 130 ++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 shared/src/player/context_save_tests.rs diff --git a/shared/src/app.rs b/shared/src/app.rs index 399e873..eadcf7c 100644 --- a/shared/src/app.rs +++ b/shared/src/app.rs @@ -687,6 +687,9 @@ impl App for Pollux { // Required resolution sink for playback persistence writes. These are // best-effort (the next checkpoint retries), so there is nothing to do. Event::PlaybackPersisted(_) => Command::done(), + Event::PlayContextSaved { episode_id, result } => { + player::on_context_saved(model, &episode_id, &result) + } } } @@ -1372,8 +1375,15 @@ pub enum Event { }, /// The episode saved as active by the previous session, loaded at launch. PlayContextLoaded(Box), - /// Resolution sink for best-effort playback writes (position, play context). + /// Resolution sink for best-effort playback writes (position, play-context clears). + /// Position writes retry at the next checkpoint, so there is nothing to do. PlaybackPersisted(Box), + /// A play-context save resolved. A failure is retried at the next checkpoint; see + /// `ActivePlayback::context_saved`. + PlayContextSaved { + episode_id: String, + result: Box, + }, } #[cfg(test)] diff --git a/shared/src/model.rs b/shared/src/model.rs index 0e8f728..fe14fa1 100644 --- a/shared/src/model.rs +++ b/shared/src/model.rs @@ -111,6 +111,11 @@ pub struct ActivePlayback { /// end of that interruption may resume it. Cleared by any explicit play or seek, and /// never set when playback was already paused (the listener's choice stands). pub resume_after_interruption: bool, + /// Whether this episode is known to be saved as the play context a relaunch restores. + /// False from the moment playback starts until storage confirms the write; while it is + /// false every checkpoint re-sends it, so one failed write can't leave a relaunch + /// restoring the wrong episode (or none). + pub context_saved: bool, pub error: Option, } diff --git a/shared/src/player.rs b/shared/src/player.rs index a172704..0bc3e84 100644 --- a/shared/src/player.rs +++ b/shared/src/player.rs @@ -42,6 +42,8 @@ use crate::html::strip_html_preview; use crate::model::{ActivePlayback, Model}; use crate::view_model::PlayerView; +#[cfg(test)] +mod context_save_tests; #[cfg(test)] mod download_recovery_tests; #[cfg(test)] @@ -91,11 +93,29 @@ fn persist_playback(episode_id: &str, status: PlaybackStatus, position_secs: Opt } fn save_play_context(episode_id: &str, source: &EpisodeSource) -> Cmd { + let id = episode_id.to_string(); Command::request_from_shell(StorageOperation::SavePlayContext { episode_id: episode_id.to_string(), source: source.clone(), }) - .then_send(|r| Event::PlaybackPersisted(Box::new(r))) + .then_send(move |r| Event::PlayContextSaved { + episode_id: id.clone(), + result: Box::new(r), + }) +} + +/// A play-context save resolved. Success means a relaunch will restore this episode; a +/// failure leaves it unsaved, and every checkpoint retries until it lands. News about an +/// episode that has since been replaced says nothing about the current one. +pub(crate) fn on_context_saved(model: &mut Model, episode_id: &str, result: &StorageResult) -> Cmd { + if let Some(active) = model + .active_playback + .as_mut() + .filter(|a| a.episode.id == episode_id) + { + active.context_saved = !matches!(result, StorageResult::Error(_)); + } + Command::done() } fn clear_play_context() -> Cmd { @@ -146,8 +166,13 @@ fn checkpoint(model: &mut Model) -> Cmd { active.episode.playback_status = PlaybackStatus::InProgress; active.episode.playback_position_secs = position; let id = active.episode.id.clone(); + let unsaved_context = (!active.context_saved).then(|| active.source.clone()); sync_episode_row(model, &id, &PlaybackStatus::InProgress, position); - persist_playback(&id, PlaybackStatus::InProgress, position) + let write = persist_playback(&id, PlaybackStatus::InProgress, position); + match unsaved_context { + Some(source) => write.and(save_play_context(&id, &source)), + None => write, + } } /// Finishes the active episode: marked played with its position reset, the saved @@ -235,6 +260,8 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { loaded: true, last_checkpoint_secs: start, resume_after_interruption: false, + // The save below is only a request until storage confirms it. + context_saved: false, error: None, }); @@ -605,6 +632,8 @@ pub(crate) fn on_context_loaded(model: &mut Model, result: StorageResult) -> Cmd loaded: false, last_checkpoint_secs: position, resume_after_interruption: false, + // It came from the saved context, so that is what is saved. + context_saved: true, error: None, }); render() diff --git a/shared/src/player/context_save_tests.rs b/shared/src/player/context_save_tests.rs new file mode 100644 index 0000000..f757a34 --- /dev/null +++ b/shared/src/player/context_save_tests.rs @@ -0,0 +1,130 @@ +//! The saved play context is what a relaunch restores, so a failed save must be retried +//! rather than left to restore the wrong episode (or none). Position writes retry +//! themselves at the next checkpoint; the context is written once when playback starts, +//! so it needs its own retry. See the module docs in `player.rs`. + +use super::tests::{ + active, checkpoints, episode, model_with, saved_context, send, storage_ops, tick, +}; +use super::*; + +fn context_saves(effects: &[Effect]) -> Vec { + storage_ops(effects) + .into_iter() + .filter_map(|op| match op { + StorageOperation::SavePlayContext { episode_id, .. } => Some(episode_id), + _ => None, + }) + .collect() +} + +fn saved(model: &mut Model, id: &str, result: StorageResult) { + send( + model, + Event::PlayContextSaved { + episode_id: id.into(), + result: Box::new(result), + }, + ); +} + +fn started() -> Model { + let mut model = model_with(vec![episode("e1"), episode("e2")]); + send(&mut model, Event::PlayEpisode("e1".into())); + model +} + +#[test] +fn a_failed_context_save_is_retried_at_the_next_checkpoint() { + let mut model = started(); + saved(&mut model, "e1", StorageResult::Error("disk full".into())); + + // The next periodic checkpoint carries the retry along with the position. + let effects = tick(&mut model, 30); + + assert_eq!(context_saves(&effects), vec!["e1".to_string()]); + assert_eq!(checkpoints(&effects).len(), 1); +} + +#[test] +fn the_retry_repeats_until_a_save_succeeds_and_then_stops() { + let mut model = started(); + saved(&mut model, "e1", StorageResult::Error("disk full".into())); + let effects = tick(&mut model, 30); + assert_eq!(context_saves(&effects).len(), 1); + + // It fails again: the next checkpoint tries again. + saved(&mut model, "e1", StorageResult::Error("disk full".into())); + let effects = tick(&mut model, 60); + assert_eq!(context_saves(&effects).len(), 1); + + saved(&mut model, "e1", StorageResult::Success); + let effects = tick(&mut model, 90); + assert!(context_saves(&effects).is_empty()); + assert_eq!(checkpoints(&effects).len(), 1, "positions still checkpoint"); +} + +#[test] +fn a_successful_save_is_not_repeated() { + let mut model = started(); + saved(&mut model, "e1", StorageResult::Success); + + let effects = tick(&mut model, 30); + + assert!(context_saves(&effects).is_empty()); +} + +#[test] +fn a_failed_save_is_also_retried_when_pausing() { + let mut model = started(); + saved(&mut model, "e1", StorageResult::Error("disk full".into())); + + let effects = send(&mut model, Event::Pause); + + assert_eq!(context_saves(&effects), vec!["e1".to_string()]); +} + +#[test] +fn a_late_answer_for_a_replaced_episode_does_not_mark_the_new_one_saved() { + let mut model = started(); + send(&mut model, Event::PlayEpisode("e2".into())); + + // e1's save resolves after e2 took over; e2's own is still outstanding. + saved(&mut model, "e1", StorageResult::Success); + + assert!(!active(&model).context_saved); +} + +#[test] +fn a_late_failure_for_a_replaced_episode_is_ignored() { + let mut model = started(); + send(&mut model, Event::PlayEpisode("e2".into())); + saved(&mut model, "e2", StorageResult::Success); + + saved(&mut model, "e1", StorageResult::Error("disk full".into())); + + let effects = tick(&mut model, 30); + assert!(context_saves(&effects).is_empty()); +} + +#[test] +fn starting_another_episode_saves_its_context_afresh() { + let mut model = started(); + saved(&mut model, "e1", StorageResult::Success); + + let effects = send(&mut model, Event::PlayEpisode("e2".into())); + + assert_eq!(context_saves(&effects), vec!["e2".to_string()]); + assert!(!active(&model).context_saved); +} + +#[test] +fn a_restored_episode_is_already_saved() { + let mut model = model_with(vec![]); + send(&mut model, saved_context(episode("e1"))); + send(&mut model, Event::TogglePlay); + + let effects = tick(&mut model, 30); + + assert!(context_saves(&effects).is_empty()); +} From b6010c73511fa11be6057bd966be12665e0d6e7a Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 10:19:26 +0200 Subject: [PATCH 24/32] docs: refresh PLAN.md with the branch review and record its decisions Append a round 3 section covering the ten-point branch review and the cross-check: what was fixed, what is partly done (render cost needs a device; shell notification tests), and what stays open (lock-screen controls, the swap gap, device-only checks). Correct the round 2 header to say item 6 is deferred rather than needing a device. Record two decisions in player.md that the plan points to: downloads for the active episode do not depend on its feed being open, and a failed play-context save is retried at every checkpoint. Co-Authored-By: Claude Sonnet 5.5 --- PLAN.md | 28 +++++++++++++++++++++++++++- docs/features/player.md | 9 ++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/PLAN.md b/PLAN.md index acd9fbb..a05a27b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -54,7 +54,7 @@ Built and merged with main; `make check` and `make ios-test` pass. Where this di Verified in the simulator: streaming playback, skip, pause/resume with rewind, cold-start restore, "From:" navigation. **Not verified** (needs a device or a repro): lock-screen rendering, background audio, the stream-to-local swap gap, the error banner, audio interruptions, the AirPlay picker. ## Round 2: review follow-ups -Suggested order; each was its own commit. **Items 1 to 4 and both open questions are done**; 5 and 6 need a device (below). +Suggested order; each was its own commit. **Items 1 to 4 and both open questions are done**; 5 needs a device and 6 is deferred (below). 1. **Restore and played logic. Done.** As built: played is decided only when the listener leaves (natural end, an explicit pause in the final 15s, or starting another episode from the tail), never by a checkpoint; a seek that lands on the end finishes the episode; system pauses send a new `Interrupted` event that keeps the place and never finishes; every load has a session id and engine events from any other session are ignored; a played episode is not restored and its context is cleared. Original plan: do not restore an episode whose status is Played; clear the saved context instead (`on_context_loaded` in `player.rs`). Likely cause of "finished episode comes back at 0:00": a checkpoint in the last 15s marks it Played (position 0) but leaves the player active and the context saved, and only the engine's natural-end event clears it; reproduce first. Make "played" one decision (natural end, or an explicit seek into the tail) rather than a side effect of every checkpoint, covering: later ticks must not overwrite the reset position, episodes shorter than the tolerance, unknown duration, and a wrong feed duration before the engine reports the real one. Add a per-load session id to engine events so a stale event for the same episode (after a source swap or retry) cannot change state. Hide stays UI-only. 2. **Source-context abstraction. Done.** As built: the `EpisodeSource` enum (the name `DATA_MODEL.md` already planned), saved as `source_kind` + `source_id` in `play_context`; `PlayerSource` is now `MediaSource` and the engine's `Load` field is `media`. Original plan: add a small source context (subscription only for now) to `ActivePlayback` and `play_context` (kind + id, in the `v1_initial` baseline); "From:" and go-to-source read from it so playlists drop in later. Rename `PlayerSource` (stream/local) to `MediaSource` to avoid confusion. Keep `DATA_MODEL.md` PlayContext consistent. 3. **Show notes on the player's second page. Done.** The page is hidden, with its dots, when the episode has no show notes; timestamp detection is still unbuilt. Original plan: replace the "coming soon" placeholder with real show notes (reuse `ShowNotesText`); add the episode description to `PlayerView`. Update `docs/features/player.md` (currently says placeholder for MVP), `player-show-notes.md` and the ROADMAP. @@ -63,3 +63,29 @@ Suggested order; each was its own commit. **Items 1 to 4 and both open questions 6. **Stream-to-local swap is audible (later). Open.** It seeks back to the last whole-second tick, so up to ~1s repeats, plus load time. Options: use the engine's exact time; preroll and seek the local item before swapping; or defer the swap to the next pause or seek. Open questions, both resolved: the button treatment is accent-colored filled glyphs; `PlaybackManager` and `NowPlayingController` are now created on the first playback request (`startPlaybackIfNeeded` in `core.swift`) instead of in `Core.init`, so a background-refresh launch stays metadata-only. A restored, paused episode therefore appears on the lock screen only once it has been played. + +## Round 3: branch review +A ten-point review of the whole branch, then a cross-check by a second reviewer. Each fix was its own commit with tests. Decisions are in `docs/features/player.md`. + +Fixed: +1. **An interruption's end no longer resumes paused playback.** `Interrupted { resumable }` and `InterruptionEnded { should_resume }` replace the shell's unconditional `Play`; the core resumes only if the interruption is what paused it. Unplugging headphones is never resumed. +2. **A failed load no longer leaves the old audio playing.** The engine unloads the previous item when a load fails. +3. **A paused load no longer takes audio focus.** The audio session is claimed only for an autoplaying load and for `Play`. +4. **Only an unusable file discards a download.** `PlayerResult::MediaUnusable` and `PlayerMediaUnusable` trigger the stream fallback and re-download; other errors (another app holding the session, say) pause with a notice and leave the file alone. +6. **Now Playing is republished only on a content change or more than 2s of drift** (`NowPlayingPolicy`). +7. **Show notes are not parsed when the player opens.** Measured: the paged `TabView` builds the notes page lazily, so the parse happens on the first swipe (about 142 ms cold, 15 to 50 ms warm). No change made; it relies on `TabView`'s laziness. +8. **The spoken-time formatter is built once.** +9. **The storage root is looked up in one place** (`StorageRoot`). + +From the cross-check: +- **Playback downloads its episode without its feed open.** `enqueue_download` also finds the active episode; `play()` starts or retries the download when the episode is streaming with no file coming. This fixes the missing-file fallback after a cold start. +- **A failed play-context save is retried** at each checkpoint until storage confirms it (`ActivePlayback::context_saved`, `PlayContextSaved`). + +Partly done: +- **Per-tick render cost (5).** The player state is split from the episode list so ticks do not re-evaluate the list. In the simulator (debug core, 375 episodes) app CPU while playing with the list open fell from 5.4% to 4.1%, against a 1.0% floor. Still to do: measure on a device with a release build. If it is not fine, render less per tick. See "Known limitations and follow-ups" in `player.md`. +- **Shell tests (10).** The interruption and route-change mapping and session tagging are tested. Not covered: tick suppression while a seek is pending, and the natural-end and failure notifications (they need the engine's private item or real playback). + +Still open: +- Lock-screen controls render invisibly (item 5 above); needs a device. +- The stream-to-local swap gap (item 6 above). +- Unverified without a device: background audio, the AirPlay picker, interruptions, the error banner, lock-screen rendering. diff --git a/docs/features/player.md b/docs/features/player.md index b7ce666..a18744e 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -126,7 +126,11 @@ engine. undecodable), playback falls back to streaming and the episode is reset to not-downloaded and re-queued. Any other failure (another app holding the audio session, playback stopping mid-play) leaves the download alone, pauses with an - error, and a later play retries the same file. + error, and a later play retries the same file. Downloads started or retried + for the active episode don't depend on its feed being open (after a cold start + no feed is): the core works from the active episode. Pressing play on an + episode that is still streaming with no download queued or underway (never + started, or failed) queues it, as starting an episode does. - **Played tolerance.** Played is decided when the listener *leaves* an episode, never as a side effect of saving progress. It happens when the engine reaches the end, when they pause, or when they start another episode, while within @@ -153,6 +157,9 @@ engine. playlists exist the view that starts playback will say which source it is. - **Restore.** An episode already marked played is not restored at launch (its saved context is cleared); an in-progress one comes back paused at its place. + The context is written once when playback starts, so a failed write is + retried at every checkpoint until storage confirms it; otherwise a relaunch + would restore the wrong episode, or none. - **Sessions.** Each load into the engine gets a session id, and everything the engine reports carries it. The core ignores news from any other session, so a late tick, end or failure from a replaced item (a source swap, a retry, another From af0e18af83912e5f1d4c82eb271a7501642a5177 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 10:36:45 +0200 Subject: [PATCH 25/32] don't resume after a pause during an interruption, refresh active URL Two cases found in review: - An interruption pauses playback and arms an automatic resume for its end. A listener's pause, or headphones being unplugged, during that interruption returned early without disarming it, so the call ending restarted audio the listener had stopped (or sent it to the speaker). Both now disarm it. - The player keeps its own copy of its episode, so a feed refresh that renewed or corrected the audio URL never reached it and retrying a failed stream loaded the old address. A reloaded episode list now updates the active episode's title, description, artwork, duration, date and stream URL, leaving position, status, download state and the loaded engine item alone. Co-Authored-By: Claude Sonnet 5.5 --- shared/src/app.rs | 3 + shared/src/player.rs | 40 +++++ .../src/player/interruption_resume_tests.rs | 78 ++++++++++ shared/src/player/metadata_refresh_tests.rs | 146 ++++++++++++++++++ 4 files changed, 267 insertions(+) create mode 100644 shared/src/player/interruption_resume_tests.rs create mode 100644 shared/src/player/metadata_refresh_tests.rs diff --git a/shared/src/app.rs b/shared/src/app.rs index eadcf7c..361e56e 100644 --- a/shared/src/app.rs +++ b/shared/src/app.rs @@ -457,6 +457,9 @@ impl App for Pollux { match *result { StorageResult::Episodes(rows) => { model.episodes = rows; + // The player keeps its own copy of its episode; a refresh may + // have corrected its details or audio URL. + player::refresh_active_from_list(model); model.detail_error = None; model.list_notice = None; } diff --git a/shared/src/player.rs b/shared/src/player.rs index 0bc3e84..1819927 100644 --- a/shared/src/player.rs +++ b/shared/src/player.rs @@ -47,6 +47,10 @@ mod context_save_tests; #[cfg(test)] mod download_recovery_tests; #[cfg(test)] +mod interruption_resume_tests; +#[cfg(test)] +mod metadata_refresh_tests; +#[cfg(test)] mod played_tests; #[cfg(test)] mod tests; @@ -341,6 +345,9 @@ pub(crate) fn pause(model: &mut Model) -> Cmd { let Some(active) = model.active_playback.as_mut() else { return Command::done(); }; + // The listener's pause settles it even if an interruption already paused playback: + // that interruption ending later must not start audio they have just stopped. + active.resume_after_interruption = false; if !active.is_playing { return Command::done(); } @@ -363,6 +370,12 @@ pub(crate) fn interrupt(model: &mut Model, resumable: bool) -> Cmd { let Some(active) = model.active_playback.as_mut() else { return Command::done(); }; + if !resumable { + // A route change (headphones out) during an interruption: the output the audio + // was going to is gone, so the interruption's end must not restart it, even + // though that interruption is what paused it. + active.resume_after_interruption = false; + } if !active.is_playing { return Command::done(); } @@ -639,6 +652,33 @@ pub(crate) fn on_context_loaded(model: &mut Model, result: StorageResult) -> Cmd render() } +/// Brings the active episode's feed-supplied details up to date from the reloaded episode +/// list, after a refresh. The player keeps its own copy of the episode, so without this a +/// corrected or renewed audio URL would never reach it, and retrying a failed stream would +/// load the old address again. +/// +/// Only what the feed owns changes: title, description, artwork, duration, publication date +/// and the audio URL, and the engine is not reloaded (the new URL is for the next load, not a +/// reason to interrupt playback). Position, status and download state are the player's own +/// and stay. A downloaded file keeps playing from the file. +pub(crate) fn refresh_active_from_list(model: &mut Model) { + let Some(active) = model.active_playback.as_mut() else { + return; + }; + let Some(fresh) = model.episodes.iter().find(|e| e.id == active.episode.id) else { + return; + }; + active.episode.title = fresh.title.clone(); + active.episode.description = fresh.description.clone(); + active.episode.artwork_url = fresh.artwork_url.clone(); + active.episode.duration_secs = fresh.duration_secs; + active.episode.pub_date = fresh.pub_date; + active.episode.enclosure_url = fresh.enclosure_url.clone(); + if let MediaSource::Stream { url } = &mut active.media { + url.clone_from(&fresh.enclosure_url); + } +} + /// Flushes position when the app leaves the foreground. Never decides "played": the /// listener may well be still listening with the screen locked. pub(crate) fn on_backgrounded(model: &mut Model) -> Cmd { diff --git a/shared/src/player/interruption_resume_tests.rs b/shared/src/player/interruption_resume_tests.rs new file mode 100644 index 0000000..bbe84a3 --- /dev/null +++ b/shared/src/player/interruption_resume_tests.rs @@ -0,0 +1,78 @@ +//! An interruption's end may only restart audio that the interruption itself stopped. Once +//! the listener (or a route change) has paused it, nothing may resume it behind their back. +//! See the module docs in `player.rs`. + +use super::tests::{episode, model_with, player_ops, send}; +use super::*; + +fn playing() -> Model { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + model +} + +fn call_ends(model: &mut Model) -> Vec { + player_ops(&send( + model, + Event::InterruptionEnded { + should_resume: true, + }, + )) +} + +#[test] +fn a_pause_during_the_interruption_cancels_the_auto_resume() { + let mut model = playing(); + send(&mut model, Event::Interrupted { resumable: true }); + // Already paused by the interruption; the listener also presses pause (a lock-screen + // or headphone command that reached us before the call ended). + send(&mut model, Event::Pause); + + assert!( + call_ends(&mut model).is_empty(), + "the listener paused: the call ending must not restart audio" + ); + assert!(!model.active_playback.as_ref().unwrap().is_playing); +} + +#[test] +fn unplugging_headphones_during_the_interruption_cancels_the_auto_resume() { + let mut model = playing(); + send(&mut model, Event::Interrupted { resumable: true }); + // The headphones come out while the call is up: a route change, never resumable. + send(&mut model, Event::Interrupted { resumable: false }); + + assert!( + call_ends(&mut model).is_empty(), + "the output went away: resuming could play through the speaker" + ); +} + +#[test] +fn a_second_resumable_interruption_keeps_the_resume_armed() { + let mut model = playing(); + send(&mut model, Event::Interrupted { resumable: true }); + // Siri during the call, say: still the interruption that paused it. + send(&mut model, Event::Interrupted { resumable: true }); + + assert!(!call_ends(&mut model).is_empty()); +} + +#[test] +fn an_interruption_still_resumes_playback_it_paused() { + let mut model = playing(); + send(&mut model, Event::Interrupted { resumable: true }); + + assert!(call_ends(&mut model) + .iter() + .any(|op| matches!(op, PlayerOperation::Seek { .. } | PlayerOperation::Play))); + assert!(model.active_playback.as_ref().unwrap().is_playing); +} + +#[test] +fn pausing_by_hand_when_nothing_was_interrupted_changes_nothing_else() { + let mut model = playing(); + send(&mut model, Event::Pause); + + assert!(call_ends(&mut model).is_empty()); +} diff --git a/shared/src/player/metadata_refresh_tests.rs b/shared/src/player/metadata_refresh_tests.rs new file mode 100644 index 0000000..9dd4fcf --- /dev/null +++ b/shared/src/player/metadata_refresh_tests.rs @@ -0,0 +1,146 @@ +//! The active episode keeps its own copy of the episode, so a feed refresh that corrects +//! its audio URL (or title, artwork) has to reach that copy, or the next load would use +//! the old one. Position and status are the player's own and must survive. See the module +//! docs in `player.rs`. + +use super::tests::{active, episode, model_with, player_ops, send, session}; +use super::*; + +/// What the refreshed feed now says about "e1": a renewed audio URL and a new title. +fn refreshed() -> Episode { + let mut e = episode("e1"); + e.enclosure_url = "https://cdn.example.com/renewed/e1.mp3".into(); + e.title = "Episode e1 (corrected)".into(); + e.duration_secs = Some(900); + e +} + +fn reload_list(model: &mut Model, rows: Vec) { + model.selected_subscription = model.subscriptions.first().cloned(); + send( + model, + Event::EpisodesLoaded { + subscription_id: "sub".into(), + result: Box::new(StorageResult::Episodes(rows)), + }, + ); +} + +fn fail_the_stream(model: &mut Model) { + let session = session(model); + send( + model, + Event::PlayerFailed { + session, + message: "403".into(), + }, + ); +} + +fn load_url(effects: &[Effect]) -> Option { + player_ops(effects).into_iter().find_map(|op| match op { + PlayerOperation::Load { + media: MediaSource::Stream { url }, + .. + } => Some(url), + _ => None, + }) +} + +#[test] +fn retrying_after_a_failure_loads_the_renewed_url() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + fail_the_stream(&mut model); + + // The refresh brings a working URL; the list reloads with it. + reload_list(&mut model, vec![refreshed()]); + let effects = send(&mut model, Event::PlayEpisode("e1".into())); + + assert_eq!( + load_url(&effects).as_deref(), + Some("https://cdn.example.com/renewed/e1.mp3") + ); +} + +#[test] +fn retrying_from_the_mini_player_loads_the_renewed_url_too() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + fail_the_stream(&mut model); + reload_list(&mut model, vec![refreshed()]); + + let effects = send(&mut model, Event::TogglePlay); + + assert_eq!( + load_url(&effects).as_deref(), + Some("https://cdn.example.com/renewed/e1.mp3") + ); +} + +#[test] +fn a_reload_keeps_the_position_and_status_of_the_active_episode() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + super::tests::tick(&mut model, 120); + + reload_list(&mut model, vec![refreshed()]); + + let a = active(&model); + assert_eq!(a.position_secs, 120); + assert_eq!(a.episode.playback_status, PlaybackStatus::InProgress); + assert_eq!(a.episode.title, "Episode e1 (corrected)"); + assert_eq!(a.episode.duration_secs, Some(900)); + assert!(a.is_playing, "a refresh must not interrupt playback"); +} + +#[test] +fn a_reload_does_not_reload_the_engine() { + let mut model = model_with(vec![episode("e1")]); + send(&mut model, Event::PlayEpisode("e1".into())); + model.selected_subscription = model.subscriptions.first().cloned(); + + let effects = send( + &mut model, + Event::EpisodesLoaded { + subscription_id: "sub".into(), + result: Box::new(StorageResult::Episodes(vec![refreshed()])), + }, + ); + + assert!( + player_ops(&effects).is_empty(), + "the new URL is for the next load, not a reason to interrupt this one" + ); +} + +#[test] +fn a_downloaded_file_is_not_swapped_for_the_streams_new_url() { + let mut e = episode("e1"); + e.download_status = DownloadStatus::Downloaded; + e.local_path = Some("Downloads/e1.mp3".into()); + let mut model = model_with(vec![e.clone()]); + send(&mut model, Event::PlayEpisode("e1".into())); + + let mut fresh = refreshed(); + fresh.download_status = DownloadStatus::Downloaded; + fresh.local_path = Some("Downloads/e1.mp3".into()); + reload_list(&mut model, vec![fresh]); + + assert!(matches!( + active(&model).media, + MediaSource::Local { ref local_path } if local_path == "Downloads/e1.mp3" + )); +} + +#[test] +fn other_episodes_in_the_reloaded_list_do_not_touch_the_active_one() { + let mut model = model_with(vec![episode("e1"), episode("e2")]); + send(&mut model, Event::PlayEpisode("e1".into())); + + let mut other = episode("e2"); + other.title = "Something else".into(); + reload_list(&mut model, vec![other]); + + assert_eq!(active(&model).episode.title, "Episode e1"); +} From 0e2bcb8e3b620793d1b22d41abbb0c1f49d5f796 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 10:39:23 +0200 Subject: [PATCH 26/32] stop re-downloading a file the engine can't play A downloaded file the engine reports unusable was replaced by a fresh download, which was then swapped in on completion. If the fresh file was unusable too (an error page served with a 200, a corrupt or undecodable file), this repeated for as long as playback went on: stream, download, swap, fail, stream, with the same large file fetched each time. Playback now remembers that a local file was found unusable. The first time it is replaced once, as before; if the replacement is unusable too the file itself is bad, so its download is marked failed (with the reason beside Retry), playback carries on from the stream, and pressing play no longer fetches it again. A local file that loads clears the memory. The failed-download shape is now a shared helper used by the download error handler as well. Co-Authored-By: Claude Sonnet 5.5 --- docs/features/player.md | 9 +- shared/src/app.rs | 24 ++- shared/src/model.rs | 6 + shared/src/player.rs | 37 ++++- shared/src/player/download_loop_tests.rs | 191 +++++++++++++++++++++++ 5 files changed, 253 insertions(+), 14 deletions(-) create mode 100644 shared/src/player/download_loop_tests.rs diff --git a/docs/features/player.md b/docs/features/player.md index a18744e..f3d8f6f 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -130,7 +130,14 @@ engine. for the active episode don't depend on its feed being open (after a cold start no feed is): the core works from the active episode. Pressing play on an episode that is still streaming with no download queued or underway (never - started, or failed) queues it, as starting an episode does. + started, or failed) queues it, as starting an episode does. A file the engine + can't use is replaced by a fresh download once; if the fresh file is unusable + too, the file itself is bad, so the download is marked failed (with the reason + beside Retry), playback carries on from the stream, and pressing play doesn't + fetch it again (otherwise stream, download, swap, fail would repeat for as long + as playback went on). The memory is per playback session and clears when a + local file loads; after a relaunch the cycle can run a couple of times before + it stops. The bad file stays on disk until a retry overwrites it. - **Played tolerance.** Played is decided when the listener *leaves* an episode, never as a side effect of saving progress. It happens when the engine reaches the end, when they pause, or when they start another episode, while within diff --git a/shared/src/app.rs b/shared/src/app.rs index 361e56e..2ecf265 100644 --- a/shared/src/app.rs +++ b/shared/src/app.rs @@ -532,13 +532,9 @@ impl App for Pollux { } DownloadResult::Error(reason) => { finish_active(model, &episode_id); - set_download_state(model, &episode_id, DownloadStatus::Failed, None, None); - // Keep the shell's specific reason so the UI can show it beside - // Retry (set_download_state cleared any older one above). - model.download_errors.insert(episode_id.clone(), reason); - let persist = - persist_download_state(&episode_id, DownloadStatus::Failed, None, None); - persist.and(maybe_start_next(model)).and(render()) + fail_download(model, &episode_id, reason) + .and(maybe_start_next(model)) + .and(render()) } DownloadResult::Cancelled => { // The shell cancelled the task and flushed the partial file, so @@ -940,6 +936,20 @@ fn persist_download_state( }) } +/// Marks an episode's download failed, keeping `reason` so the UI can show it beside Retry, +/// and returns the command that persists it. Used when a download errors, and when the +/// player finds a downloaded file is unusable even after being downloaded afresh. +pub(crate) fn fail_download( + model: &mut Model, + episode_id: &str, + reason: String, +) -> Command { + // `set_download_state` drops any older reason, so insert after it. + set_download_state(model, episode_id, DownloadStatus::Failed, None, None); + model.download_errors.insert(episode_id.to_string(), reason); + persist_download_state(episode_id, DownloadStatus::Failed, None, None) +} + /// Resets an episode to not-downloaded (in the model) and returns the command that /// persists it. The single shape used everywhere a download ends without a file — /// cancelling an active or queued download, and deleting a stored one. diff --git a/shared/src/model.rs b/shared/src/model.rs index fe14fa1..fd36d7d 100644 --- a/shared/src/model.rs +++ b/shared/src/model.rs @@ -116,6 +116,12 @@ pub struct ActivePlayback { /// false every checkpoint re-sends it, so one failed write can't leave a relaunch /// restoring the wrong episode (or none). pub context_saved: bool, + /// A downloaded file for this episode was found unusable, and it has not played fine + /// since. The first time that is put down to a missing or damaged file and it is + /// downloaded again; if the fresh file is unusable too the file itself is bad, so it is + /// not downloaded again (that would loop for as long as playback goes on). Cleared when + /// a local file loads, which is proof it works. + pub local_unusable: bool, pub error: Option, } diff --git a/shared/src/player.rs b/shared/src/player.rs index 1819927..239f35b 100644 --- a/shared/src/player.rs +++ b/shared/src/player.rs @@ -29,7 +29,9 @@ use crux_core::{render::render, Command}; -use crate::app::{enqueue_download, reset_to_not_downloaded, Event, DESCRIPTION_PREVIEW_CHARS}; +use crate::app::{ + enqueue_download, fail_download, reset_to_not_downloaded, Event, DESCRIPTION_PREVIEW_CHARS, +}; use crate::capabilities::player::{MediaSource, PlayerOperation, PlayerResult}; use crate::capabilities::storage::{StorageOperation, StorageResult}; use crate::defaults::{ @@ -45,6 +47,8 @@ use crate::view_model::PlayerView; #[cfg(test)] mod context_save_tests; #[cfg(test)] +mod download_loop_tests; +#[cfg(test)] mod download_recovery_tests; #[cfg(test)] mod interruption_resume_tests; @@ -266,6 +270,7 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { resume_after_interruption: false, // The save below is only a request until storage confirms it. context_saved: false, + local_unusable: false, error: None, }); @@ -329,9 +334,11 @@ pub(crate) fn play(model: &mut Model) -> Cmd { }; // Still streaming with no file coming (a restored episode whose download was never // started, or failed): playing is the moment to try again, as starting an episode - // does. A download already queued or underway is left alone. - let retry = - matches!(active.media, MediaSource::Stream { .. }).then(|| active.episode.id.clone()); + // does. A download already queued or underway is left alone, and so is one whose file + // already proved bad: that is a different problem, and fetching it again on every play + // would never end. + let retry = (matches!(active.media, MediaSource::Stream { .. }) && !active.local_unusable) + .then(|| active.episode.id.clone()); match retry { Some(id) => cmd.and(enqueue_download(model, &id)).and(render()), None => cmd.and(render()), @@ -485,6 +492,11 @@ pub(crate) fn on_duration(model: &mut Model, session: u32, duration_secs: u32) - } if let Some(active) = model.active_playback.as_mut() { active.duration_secs = Some(duration_secs); + // The engine opened the file well enough to read its length: it works. (A stream + // loading says nothing about the file, so it doesn't clear the suspicion.) + if matches!(active.media, MediaSource::Local { .. }) { + active.local_unusable = false; + } } render() } @@ -551,9 +563,21 @@ pub(crate) fn on_failure( active.episode.local_path = None; active.error = None; active.loaded = true; + // A file that was missing or damaged is replaced once. If this one was already the + // replacement, the file itself is bad and fetching it again would loop for as long as + // playback goes on (stream, download, swap, fail, stream…): give up on downloading. + let replacement_failed = std::mem::replace(&mut active.local_unusable, true); let (position, was_playing) = (active.position_secs, active.is_playing); - reset_to_not_downloaded(model, &id) - .and(enqueue_download(model, &id)) + let download = if replacement_failed { + fail_download( + model, + &id, + "The downloaded file can't be played. Playing from the stream instead.".to_string(), + ) + } else { + reset_to_not_downloaded(model, &id).and(enqueue_download(model, &id)) + }; + download .and(player_op( fresh, PlayerOperation::Load { @@ -647,6 +671,7 @@ pub(crate) fn on_context_loaded(model: &mut Model, result: StorageResult) -> Cmd resume_after_interruption: false, // It came from the saved context, so that is what is saved. context_saved: true, + local_unusable: false, error: None, }); render() diff --git a/shared/src/player/download_loop_tests.rs b/shared/src/player/download_loop_tests.rs new file mode 100644 index 0000000..8393e29 --- /dev/null +++ b/shared/src/player/download_loop_tests.rs @@ -0,0 +1,191 @@ +//! A downloaded file the engine can't use is replaced by a fresh download once. If the fresh +//! one is unusable too, the file is bad (not just missing), and downloading it again would +//! loop for as long as the listener keeps playing. See the module docs in `player.rs`. + +use super::tests::{active, episode, model_with, player_ops, send, session}; +use super::*; +use crate::capabilities::download::{DownloadOperation, DownloadResult}; + +fn downloaded_episode() -> Episode { + let mut e = episode("e1"); + e.download_status = DownloadStatus::Downloaded; + e.local_path = Some("Downloads/e1.mp3".into()); + e +} + +fn unusable(model: &mut Model) -> Vec { + let session = session(model); + send( + model, + Event::PlayerMediaUnusable { + session, + message: "can't decode".into(), + }, + ) +} + +fn download_finished(model: &mut Model) -> Vec { + send( + model, + Event::DownloadFinished { + episode_id: "e1".into(), + result: Box::new(DownloadResult::Completed { + local_path: "Downloads/e1.mp3".into(), + size_bytes: 1000, + }), + }, + ) +} + +fn downloads_started(effects: &[Effect]) -> usize { + effects + .iter() + .filter(|e| { + matches!( + e, + Effect::Download(r) if matches!(r.operation, DownloadOperation::Download { .. }) + ) + }) + .count() +} + +fn loads_stream(effects: &[Effect]) -> bool { + player_ops(effects).iter().any(|op| { + matches!( + op, + PlayerOperation::Load { + media: MediaSource::Stream { .. }, + .. + } + ) + }) +} + +/// Plays a downloaded episode whose file is bad, through the first fallback and the +/// re-download, up to the swap back onto the (still bad) fresh file. +fn up_to_the_second_failure() -> (Model, usize) { + let mut model = model_with(vec![downloaded_episode()]); + send(&mut model, Event::PlayEpisode("e1".into())); + + let first = unusable(&mut model); + let mut downloads = downloads_started(&first); + assert_eq!( + downloads, 1, + "a bad file is replaced by a fresh download once" + ); + + let finished = download_finished(&mut model); + downloads += downloads_started(&finished); + assert!( + player_ops(&finished).iter().any(|op| matches!( + op, + PlayerOperation::Load { + media: MediaSource::Local { .. }, + .. + } + )), + "the fresh file is tried" + ); + (model, downloads) +} + +#[test] +fn a_fresh_file_that_is_also_unusable_is_not_downloaded_again() { + let (mut model, downloads) = up_to_the_second_failure(); + + let second = unusable(&mut model); + + assert_eq!(downloads_started(&second), 0); + assert_eq!(downloads, 1); + assert!(model.downloading.is_none()); + assert!(model.download_queue.is_empty()); + assert!(loads_stream(&second), "playback carries on from the stream"); +} + +#[test] +fn the_failed_download_says_why() { + let (mut model, _) = up_to_the_second_failure(); + + let effects = unusable(&mut model); + + assert_eq!( + active(&model).episode.download_status, + DownloadStatus::Failed + ); + assert!(model.download_errors.contains_key("e1")); + assert!(super::tests::storage_ops(&effects) + .iter() + .any(|op| matches!( + op, + StorageOperation::UpdateDownloadState { + status: DownloadStatus::Failed, + .. + } + ))); +} + +#[test] +fn pressing_play_does_not_retry_a_file_that_proved_bad() { + let (mut model, _) = up_to_the_second_failure(); + unusable(&mut model); + + send(&mut model, Event::TogglePlay); + let effects = send(&mut model, Event::TogglePlay); + + assert_eq!(downloads_started(&effects), 0); + assert!(model.downloading.is_none()); +} + +#[test] +fn a_file_that_plays_clears_the_suspicion() { + let mut model = model_with(vec![downloaded_episode()]); + send(&mut model, Event::PlayEpisode("e1".into())); + unusable(&mut model); + download_finished(&mut model); + + // The fresh file loads and the engine reports its duration: it is good. + let session = session(&model); + send( + &mut model, + Event::PlayerDuration { + session, + duration_secs: 600, + }, + ); + + // Deleted behind our back some day later: a missing file again, replaced once more. + let effects = unusable(&mut model); + assert_eq!(downloads_started(&effects), 1); +} + +#[test] +fn a_stream_that_loads_does_not_clear_the_suspicion() { + let mut model = model_with(vec![downloaded_episode()]); + send(&mut model, Event::PlayEpisode("e1".into())); + unusable(&mut model); + + // The stream is fine, but says nothing about the file. + let session = session(&model); + send( + &mut model, + Event::PlayerDuration { + session, + duration_secs: 600, + }, + ); + download_finished(&mut model); + let effects = unusable(&mut model); + + assert_eq!(downloads_started(&effects), 0); +} + +#[test] +fn a_missing_file_is_still_replaced_the_first_time() { + let mut model = model_with(vec![downloaded_episode()]); + send(&mut model, Event::PlayEpisode("e1".into())); + + let effects = unusable(&mut model); + + assert_eq!(downloads_started(&effects), 1); + assert!(loads_stream(&effects)); +} From 1bb0f4664a4422a543e7aa539f5adfe2cf9a0e93 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 10:43:47 +0200 Subject: [PATCH 27/32] don't re-download what the listener cancelled or deleted Playback downloads what it streams, but it did so unconditionally: after the listener cancelled an episode's download, resuming it, starting it again, or recovering from a lost file queued the download once more, and a file they had just deleted came back through the missing-file fallback. The core now remembers, for the session, which episodes' downloads were cancelled or deleted. Every download playback starts on its own (starting an episode, retrying on play, replacing a lost file) goes through one helper that skips those; only an explicit Download request lifts it. Co-Authored-By: Claude Sonnet 5.5 --- docs/features/player.md | 4 + shared/src/app.rs | 6 + shared/src/model.rs | 6 +- shared/src/player.rs | 20 ++- shared/src/player/download_declined_tests.rs | 157 +++++++++++++++++++ 5 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 shared/src/player/download_declined_tests.rs diff --git a/docs/features/player.md b/docs/features/player.md index f3d8f6f..819416d 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -138,6 +138,10 @@ engine. as playback went on). The memory is per playback session and clears when a local file loads; after a relaunch the cycle can run a couple of times before it stops. The bad file stays on disk until a retry overwrites it. + Playback never downloads against the listener's wishes: once they cancel or + delete an episode's download, starting or resuming it, or replacing a file the + engine lost, streams without fetching it again. Only asking for the download + (the Download action) lifts that. This is remembered for the session only. - **Played tolerance.** Played is decided when the listener *leaves* an episode, never as a side effect of saving progress. It happens when the engine reaches the end, when they pause, or when they start another episode, while within diff --git a/shared/src/app.rs b/shared/src/app.rs index 2ecf265..bb9640f 100644 --- a/shared/src/app.rs +++ b/shared/src/app.rs @@ -481,6 +481,8 @@ impl App for Pollux { Event::DownloadEpisode(episode_id) => { // A fresh user action supersedes any stale op-failure notice. model.download_notice = None; + // Asking for it is the opposite of declining it. + model.declined_downloads.remove(&episode_id); // Only a not-downloaded or failed (retry) episode can start a // download. Every other state — already queued/downloading, already // downloaded, removed from feed, or an episode we don't have loaded — @@ -552,6 +554,8 @@ impl App for Pollux { Event::CancelDownload(episode_id) => { // A fresh user action supersedes any stale op-failure notice. model.download_notice = None; + // Their call: playback must not start it again behind their back. + model.declined_downloads.insert(episode_id.clone()); if model.downloading.as_deref() == Some(episode_id.as_str()) { // Active download: ask the shell to cancel the task and flush the // partial file. The in-flight Download request then resolves as @@ -588,6 +592,8 @@ impl App for Pollux { Event::DeleteDownload(episode_id) => { // A fresh user action supersedes any stale op-failure notice. model.download_notice = None; + // Deleting it says they don't want it kept: don't fetch it again for them. + model.declined_downloads.insert(episode_id.clone()); let local_path = model .episodes .iter() diff --git a/shared/src/model.rs b/shared/src/model.rs index fd36d7d..66d6d5e 100644 --- a/shared/src/model.rs +++ b/shared/src/model.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use crate::capabilities::player::MediaSource; use crate::domain::{Episode, EpisodeSortOrder, EpisodeSource, Subscription}; @@ -49,6 +49,10 @@ pub struct Model { // the episode leaves the Failed state; the persisted status is enough to know it // failed across a restart. pub download_errors: HashMap, + // Episodes whose download the listener cancelled or deleted this session. Playback + // downloads what it streams, but skips these: it must not bring back what they just + // turned away. Asking for the download again (`DownloadEpisode`) removes the entry. + pub declined_downloads: HashSet, // A transient, non-blocking notice for a download *operation* that failed without // changing the episode's state — a persistence write that didn't commit, or a file diff --git a/shared/src/player.rs b/shared/src/player.rs index 239f35b..4fc7b6d 100644 --- a/shared/src/player.rs +++ b/shared/src/player.rs @@ -47,6 +47,8 @@ use crate::view_model::PlayerView; #[cfg(test)] mod context_save_tests; #[cfg(test)] +mod download_declined_tests; +#[cfg(test)] mod download_loop_tests; #[cfg(test)] mod download_recovery_tests; @@ -83,6 +85,18 @@ fn player_op(session: u32, op: PlayerOperation) -> Cmd { }) } +/// Downloads what playback is streaming, unless the listener turned that download away +/// (cancelled it, or deleted the file). Every download playback starts on its own goes +/// through here: starting an episode, resuming one whose download never started or failed, +/// and replacing a file the engine lost. Only an explicit request (`DownloadEpisode`) can +/// override a decline. +fn auto_download(model: &mut Model, episode_id: &str) -> Cmd { + if model.declined_downloads.contains(episode_id) { + return Command::done(); + } + enqueue_download(model, episode_id) +} + /// Hands out the next session id. Ids only ever increase, across episodes. fn next_session(model: &mut Model) -> u32 { model.player_sessions += 1; @@ -293,7 +307,7 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { // Streaming now, local later: kick off the download so the copy is there next // time (and for the mid-play swap in `on_download_completed`). if streaming { - cmd = cmd.and(enqueue_download(model, episode_id)); + cmd = cmd.and(auto_download(model, episode_id)); } cmd.and(render()) } @@ -340,7 +354,7 @@ pub(crate) fn play(model: &mut Model) -> Cmd { let retry = (matches!(active.media, MediaSource::Stream { .. }) && !active.local_unusable) .then(|| active.episode.id.clone()); match retry { - Some(id) => cmd.and(enqueue_download(model, &id)).and(render()), + Some(id) => cmd.and(auto_download(model, &id)).and(render()), None => cmd.and(render()), } } @@ -575,7 +589,7 @@ pub(crate) fn on_failure( "The downloaded file can't be played. Playing from the stream instead.".to_string(), ) } else { - reset_to_not_downloaded(model, &id).and(enqueue_download(model, &id)) + reset_to_not_downloaded(model, &id).and(auto_download(model, &id)) }; download .and(player_op( diff --git a/shared/src/player/download_declined_tests.rs b/shared/src/player/download_declined_tests.rs new file mode 100644 index 0000000..0a8dcc1 --- /dev/null +++ b/shared/src/player/download_declined_tests.rs @@ -0,0 +1,157 @@ +//! Playback downloads what it streams, but not against the listener's wishes: once they +//! cancel or delete an episode's download, resuming, restarting or recovering from a lost +//! file must not bring it back. Only asking for the download again does. See the module +//! docs in `player.rs`. + +use super::tests::{episode, model_with, player_ops, send, session}; +use super::*; +use crate::capabilities::download::{DownloadOperation, DownloadResult}; + +fn downloads_started(effects: &[Effect]) -> usize { + effects + .iter() + .filter(|e| { + matches!( + e, + Effect::Download(r) if matches!(r.operation, DownloadOperation::Download { .. }) + ) + }) + .count() +} + +/// Streams "e1" (which starts its download), then the listener cancels that download. +fn streaming_with_the_download_cancelled() -> Model { + let mut model = model_with(vec![episode("e1"), episode("e2")]); + send(&mut model, Event::PlayEpisode("e1".into())); + assert_eq!(model.downloading.as_deref(), Some("e1")); + send(&mut model, Event::CancelDownload("e1".into())); + // The shell has cancelled the task and says so. + send( + &mut model, + Event::DownloadFinished { + episode_id: "e1".into(), + result: Box::new(DownloadResult::Cancelled), + }, + ); + assert!(model.downloading.is_none()); + model +} + +#[test] +fn resuming_does_not_restart_a_download_the_listener_cancelled() { + let mut model = streaming_with_the_download_cancelled(); + + send(&mut model, Event::TogglePlay); // pause + let effects = send(&mut model, Event::TogglePlay); // play + + assert_eq!(downloads_started(&effects), 0); + assert!(model.downloading.is_none()); +} + +#[test] +fn starting_the_episode_again_does_not_restart_it_either() { + let mut model = streaming_with_the_download_cancelled(); + send(&mut model, Event::PlayEpisode("e2".into())); + + let effects = send(&mut model, Event::PlayEpisode("e1".into())); + + // e2's download is the one running; e1's must not be waiting behind it. + assert_eq!(downloads_started(&effects), 0); + assert!(model.download_queue.is_empty()); + assert_eq!( + model + .episodes + .iter() + .find(|e| e.id == "e1") + .unwrap() + .download_status, + DownloadStatus::NotDownloaded + ); +} + +#[test] +fn a_cancelled_queued_download_is_respected_too() { + let mut model = model_with(vec![episode("e1"), episode("e2")]); + // e2's download is running, so e1's waits in the queue. + send(&mut model, Event::DownloadEpisode("e2".into())); + send(&mut model, Event::PlayEpisode("e1".into())); + assert_eq!(model.download_queue.len(), 1); + send(&mut model, Event::CancelDownload("e1".into())); + assert!(model.download_queue.is_empty()); + + send(&mut model, Event::TogglePlay); + send(&mut model, Event::TogglePlay); + + assert!(model.download_queue.is_empty()); +} + +#[test] +fn losing_a_file_the_listener_deleted_does_not_bring_it_back() { + let mut e = episode("e1"); + e.download_status = DownloadStatus::Downloaded; + e.local_path = Some("Downloads/e1.mp3".into()); + let mut model = model_with(vec![e]); + send(&mut model, Event::PlayEpisode("e1".into())); + + // They delete the download while it plays; the engine then loses the file. + send(&mut model, Event::DeleteDownload("e1".into())); + let session = session(&model); + let effects = send( + &mut model, + Event::PlayerMediaUnusable { + session, + message: "file gone".into(), + }, + ); + + assert_eq!(downloads_started(&effects), 0); + assert!(model.downloading.is_none()); + assert!( + player_ops(&effects).iter().any(|op| matches!( + op, + PlayerOperation::Load { + media: MediaSource::Stream { .. }, + .. + } + )), + "playback still carries on from the stream" + ); +} + +#[test] +fn asking_for_the_download_again_is_honoured() { + let mut model = streaming_with_the_download_cancelled(); + + send(&mut model, Event::DownloadEpisode("e1".into())); + + assert_eq!(model.downloading.as_deref(), Some("e1")); +} + +#[test] +fn after_asking_again_playback_may_retry_it_as_usual() { + let mut model = streaming_with_the_download_cancelled(); + send(&mut model, Event::DownloadEpisode("e1".into())); + // That download fails; a later resume tries again, as for any failed download. + send( + &mut model, + Event::DownloadFinished { + episode_id: "e1".into(), + result: Box::new(DownloadResult::Error("offline".into())), + }, + ); + + send(&mut model, Event::TogglePlay); + send(&mut model, Event::TogglePlay); + + assert_eq!(model.downloading.as_deref(), Some("e1")); +} + +#[test] +fn cancelling_one_episode_does_not_stop_another_from_downloading() { + let mut model = streaming_with_the_download_cancelled(); + + let effects = send(&mut model, Event::PlayEpisode("e2".into())); + + assert_eq!(downloads_started(&effects), 1); + assert_eq!(model.downloading.as_deref(), Some("e2")); +} From c86c9047ca6ec17db4cb5f94c3ad8e8f280edd51 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 10:45:20 +0200 Subject: [PATCH 28/32] fix: don't crash on a bad position from the lock screen or engine The lock-screen scrub converted the system's position straight to UInt32, which traps on NaN or a value above UInt32.max, so a misbehaving car or Bluetooth device could crash the app. The engine's tick and duration reports had the same conversion, guarded only against NaN and infinity. All three now go through PlayerFormatting.wholeSeconds, which refuses non-finite and out-of-range values and clamps negatives to zero. A bad scrub is answered with commandFailed rather than clamped, since clamping a huge value would seek past the end and mark the episode played. Co-Authored-By: Claude Sonnet 5.5 --- iOS/Pollux/NowPlayingController.swift | 6 +++++- iOS/Pollux/PlaybackManager.swift | 9 +++++---- iOS/Pollux/PlayerFormatting.swift | 10 ++++++++++ iOS/PolluxTests/PlayerFormattingTests.swift | 22 +++++++++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/iOS/Pollux/NowPlayingController.swift b/iOS/Pollux/NowPlayingController.swift index 9cf0c21..54ab05b 100644 --- a/iOS/Pollux/NowPlayingController.swift +++ b/iOS/Pollux/NowPlayingController.swift @@ -97,7 +97,11 @@ final class NowPlayingController { guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } - let secs = UInt32(max(0, event.positionTime)) + // A car or Bluetooth device can send nonsense (NaN, a huge value); refuse it + // rather than trap converting it. + guard let secs = PlayerFormatting.wholeSeconds(event.positionTime) else { + return .commandFailed + } Task { @MainActor in self?.send(.seekTo(secs)) } return .success } diff --git a/iOS/Pollux/PlaybackManager.swift b/iOS/Pollux/PlaybackManager.swift index 99ba2c4..627ea91 100644 --- a/iOS/Pollux/PlaybackManager.swift +++ b/iOS/Pollux/PlaybackManager.swift @@ -174,8 +174,8 @@ final class PlaybackManager { guard let self, currentSession == session else { return } switch status { case .readyToPlay: - if duration.isFinite, duration > 0 { - send(.playerDuration(session: session, durationSecs: UInt32(duration))) + if duration > 0, let secs = PlayerFormatting.wholeSeconds(duration) { + send(.playerDuration(session: session, durationSecs: secs)) } case .failed: send(.playerMediaUnusable(session: session, message: message ?? "Playback failed")) @@ -216,8 +216,9 @@ final class PlaybackManager { guard let session = currentSession, pendingSeeks == 0, player.timeControlStatus == .playing, - seconds.isFinite, seconds >= 0 else { return } - send(.playerTick(session: session, positionSecs: UInt32(seconds))) + seconds >= 0, + let secs = PlayerFormatting.wholeSeconds(seconds) else { return } + send(.playerTick(session: session, positionSecs: secs)) } /// Phone calls, Siri and unplugged headphones pause playback; the core is told so diff --git a/iOS/Pollux/PlayerFormatting.swift b/iOS/Pollux/PlayerFormatting.swift index 3041271..1db0ab5 100644 --- a/iOS/Pollux/PlayerFormatting.swift +++ b/iOS/Pollux/PlayerFormatting.swift @@ -39,6 +39,16 @@ enum PlayerFormatting { return formatter }() + /// A time from the system (the engine's position or duration, a lock-screen scrub) as + /// whole seconds for the core, or nil when it isn't a usable time: NaN, infinite, or too + /// large for the core's seconds. Negatives clamp to 0. `UInt32(_:)` traps on those + /// values, so a bad number from an accessory or the engine must be refused here rather + /// than crash the app. + static func wholeSeconds(_ value: Double) -> UInt32? { + guard value.isFinite, value < Double(UInt32.max) + 1 else { return nil } + return UInt32(max(0, value)) + } + /// Fraction of the episode played, 0...1; 0 when the duration is unknown. static func fraction(position: UInt32, duration: UInt32?) -> Double { guard let duration, duration > 0 else { return 0 } diff --git a/iOS/PolluxTests/PlayerFormattingTests.swift b/iOS/PolluxTests/PlayerFormattingTests.swift index 0ce00c9..7ffb96a 100644 --- a/iOS/PolluxTests/PlayerFormattingTests.swift +++ b/iOS/PolluxTests/PlayerFormattingTests.swift @@ -50,6 +50,28 @@ import Testing } } + // MARK: - Whole seconds from the system's Doubles + + @Test func wholeSecondsTruncateOrdinaryValues() { + #expect(PlayerFormatting.wholeSeconds(0) == 0) + #expect(PlayerFormatting.wholeSeconds(12.9) == 12) + #expect(PlayerFormatting.wholeSeconds(7200) == 7200) + #expect(PlayerFormatting.wholeSeconds(Double(UInt32.max)) == UInt32.max) + } + + @Test func wholeSecondsClampNegativesToZero() { + #expect(PlayerFormatting.wholeSeconds(-1) == 0) + #expect(PlayerFormatting.wholeSeconds(-0.5) == 0) + #expect(PlayerFormatting.wholeSeconds(-.infinity) == nil) + } + + @Test func wholeSecondsRefuseWhatIsNotATime() { + #expect(PlayerFormatting.wholeSeconds(.nan) == nil) + #expect(PlayerFormatting.wholeSeconds(.infinity) == nil) + #expect(PlayerFormatting.wholeSeconds(Double(UInt32.max) + 1) == nil) + #expect(PlayerFormatting.wholeSeconds(1e300) == nil) + } + @Test func spokenTimeIsStableAcrossRepeatedCalls() { // One shared formatter must give the same answer however often it is used. let first = PlayerFormatting.spoken(754) From 3d14c5ed8d22214c4aa28be7cc1257dff0d6ca2d Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 10:48:02 +0200 Subject: [PATCH 29/32] stop retrying a play-context save that can't succeed A failed play-context save was retried at every checkpoint (about every 10s of playback, plus every pause and backgrounding) for as long as the episode played, with no limit. A write that can never succeed, such as a constraint failure or a persistent I/O error, would repeat indefinitely. Failed saves are now counted per playback, and checkpoints retry only until CONTEXT_SAVE_ATTEMPTS (5, counting the write made when playback starts). The next episode starts afresh and a success still stops the retries. Co-Authored-By: Claude Sonnet 5.5 --- docs/features/player.md | 5 +- shared/src/defaults.rs | 5 ++ shared/src/model.rs | 4 ++ shared/src/player.rs | 15 ++++-- shared/src/player/context_save_tests.rs | 70 +++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 4 deletions(-) diff --git a/docs/features/player.md b/docs/features/player.md index 819416d..4668b70 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -170,7 +170,10 @@ engine. saved context is cleared); an in-progress one comes back paused at its place. The context is written once when playback starts, so a failed write is retried at every checkpoint until storage confirms it; otherwise a relaunch - would restore the wrong episode, or none. + would restore the wrong episode, or none. It is tried at most 5 times in all + (`CONTEXT_SAVE_ATTEMPTS`): a write that can't succeed isn't repeated for as long + as playback goes on, and the next episode starts afresh. Nothing is shown to the + listener; the cost of giving up is that a relaunch may not restore this episode. - **Sessions.** Each load into the engine gets a session id, and everything the engine reports carries it. The core ignores news from any other session, so a late tick, end or failure from a replaced item (a source swap, a retry, another diff --git a/shared/src/defaults.rs b/shared/src/defaults.rs index 1b84e58..fea3652 100644 --- a/shared/src/defaults.rs +++ b/shared/src/defaults.rs @@ -9,6 +9,11 @@ pub const PLAYED_TOLERANCE_SECS: u32 = 15; /// While playing, position is persisted at least this often (in addition to pause, /// seek, backgrounding and end), bounding what a crash can lose. pub const POSITION_CHECKPOINT_SECS: u32 = 10; +/// How many times the play context is written before giving up on it (the write made when +/// playback starts counts as the first; checkpoints retry a failed one). A write that can +/// never succeed would otherwise be retried at every checkpoint for as long as playback +/// goes on. The next episode starts afresh. +pub const CONTEXT_SAVE_ATTEMPTS: u32 = 5; /// Backoff applied to a 429 that carries no usable `Retry-After`. pub const RATE_LIMIT_BACKOFF_SECS: i64 = 3600; /// Longest wait honoured from a `Retry-After`. A host asking for more (a year, a date far diff --git a/shared/src/model.rs b/shared/src/model.rs index 66d6d5e..4bdd1f2 100644 --- a/shared/src/model.rs +++ b/shared/src/model.rs @@ -120,6 +120,10 @@ pub struct ActivePlayback { /// false every checkpoint re-sends it, so one failed write can't leave a relaunch /// restoring the wrong episode (or none). pub context_saved: bool, + /// How many writes of the play context have failed. Checkpoints retry only until + /// `CONTEXT_SAVE_ATTEMPTS` have, so a write that can never succeed isn't retried for as + /// long as playback goes on. + pub context_save_failures: u32, /// A downloaded file for this episode was found unusable, and it has not played fine /// since. The first time that is put down to a missing or damaged file and it is /// downloaded again; if the fresh file is unusable too the file itself is bad, so it is diff --git a/shared/src/player.rs b/shared/src/player.rs index 4fc7b6d..197f3c9 100644 --- a/shared/src/player.rs +++ b/shared/src/player.rs @@ -35,8 +35,8 @@ use crate::app::{ use crate::capabilities::player::{MediaSource, PlayerOperation, PlayerResult}; use crate::capabilities::storage::{StorageOperation, StorageResult}; use crate::defaults::{ - PLAYED_TOLERANCE_SECS, POSITION_CHECKPOINT_SECS, RESUME_REWIND_SECS, SKIP_BACKWARD_SECS, - SKIP_FORWARD_SECS, + CONTEXT_SAVE_ATTEMPTS, PLAYED_TOLERANCE_SECS, POSITION_CHECKPOINT_SECS, RESUME_REWIND_SECS, + SKIP_BACKWARD_SECS, SKIP_FORWARD_SECS, }; use crate::domain::{DownloadStatus, Episode, EpisodeSource, PlaybackStatus}; use crate::effect::Effect; @@ -136,6 +136,9 @@ pub(crate) fn on_context_saved(model: &mut Model, episode_id: &str, result: &Sto .filter(|a| a.episode.id == episode_id) { active.context_saved = !matches!(result, StorageResult::Error(_)); + if !active.context_saved { + active.context_save_failures += 1; + } } Command::done() } @@ -188,7 +191,11 @@ fn checkpoint(model: &mut Model) -> Cmd { active.episode.playback_status = PlaybackStatus::InProgress; active.episode.playback_position_secs = position; let id = active.episode.id.clone(); - let unsaved_context = (!active.context_saved).then(|| active.source.clone()); + // Retry an unconfirmed context save, but not for ever: after enough failures the write + // is one that won't succeed, and trying again at every checkpoint only costs writes. + let retry_context = + !active.context_saved && active.context_save_failures < CONTEXT_SAVE_ATTEMPTS; + let unsaved_context = retry_context.then(|| active.source.clone()); sync_episode_row(model, &id, &PlaybackStatus::InProgress, position); let write = persist_playback(&id, PlaybackStatus::InProgress, position); match unsaved_context { @@ -284,6 +291,7 @@ pub(crate) fn play_episode(model: &mut Model, episode_id: &str) -> Cmd { resume_after_interruption: false, // The save below is only a request until storage confirms it. context_saved: false, + context_save_failures: 0, local_unusable: false, error: None, }); @@ -685,6 +693,7 @@ pub(crate) fn on_context_loaded(model: &mut Model, result: StorageResult) -> Cmd resume_after_interruption: false, // It came from the saved context, so that is what is saved. context_saved: true, + context_save_failures: 0, local_unusable: false, error: None, }); diff --git a/shared/src/player/context_save_tests.rs b/shared/src/player/context_save_tests.rs index f757a34..73eb4ad 100644 --- a/shared/src/player/context_save_tests.rs +++ b/shared/src/player/context_save_tests.rs @@ -7,6 +7,7 @@ use super::tests::{ active, checkpoints, episode, model_with, saved_context, send, storage_ops, tick, }; use super::*; +use crate::defaults::CONTEXT_SAVE_ATTEMPTS; fn context_saves(effects: &[Effect]) -> Vec { storage_ops(effects) @@ -118,6 +119,75 @@ fn starting_another_episode_saves_its_context_afresh() { assert!(!active(&model).context_saved); } +fn fail_the_save(model: &mut Model) { + let id = active(model).episode.id.clone(); + saved(model, &id, StorageResult::Error("disk full".into())); +} + +/// Fails the save the playback start made, then lets every checkpoint retry and fail, until +/// the attempts are used up. Returns the attempts made, the first being the start's own. +fn exhaust_the_attempts(model: &mut Model) -> u32 { + let mut attempts = 1; + fail_the_save(model); + let mut position = 0; + while attempts < CONTEXT_SAVE_ATTEMPTS { + position += 30; + let effects = tick(model, position); + assert_eq!(context_saves(&effects).len(), 1, "attempt {}", attempts + 1); + attempts += 1; + fail_the_save(model); + } + attempts +} + +#[test] +fn a_save_that_keeps_failing_is_given_up_on() { + let mut model = started(); + let attempts = exhaust_the_attempts(&mut model); + assert_eq!(attempts, CONTEXT_SAVE_ATTEMPTS); + + // Further checkpoints no longer try, however long playback goes on. + for position in [400, 430, 460] { + let effects = tick(&mut model, position); + assert!(context_saves(&effects).is_empty()); + } + assert!(!active(&model).context_saved); +} + +#[test] +fn pausing_or_backgrounding_does_not_retry_a_given_up_save_either() { + let mut model = started(); + exhaust_the_attempts(&mut model); + + assert!(context_saves(&send(&mut model, Event::AppBackgrounded)).is_empty()); + assert!(context_saves(&send(&mut model, Event::Pause)).is_empty()); +} + +#[test] +fn giving_up_on_one_episode_does_not_stop_the_next_from_saving() { + let mut model = started(); + exhaust_the_attempts(&mut model); + + let effects = send(&mut model, Event::PlayEpisode("e2".into())); + + assert_eq!(context_saves(&effects), vec!["e2".to_string()]); + fail_the_save(&mut model); + let effects = tick(&mut model, 30); + assert_eq!(context_saves(&effects), vec!["e2".to_string()]); +} + +#[test] +fn a_save_that_lands_before_the_limit_stops_the_retries() { + let mut model = started(); + fail_the_save(&mut model); + tick(&mut model, 30); + saved(&mut model, "e1", StorageResult::Success); + + for position in [60, 90, 120] { + assert!(context_saves(&tick(&mut model, position)).is_empty()); + } +} + #[test] fn a_restored_episode_is_already_saved() { let mut model = model_with(vec![]); From b5168043fdc144404f7c05c7eac1b1c482adb819 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 10:50:54 +0200 Subject: [PATCH 30/32] docs: fix two comments that described behavior since changed PlayerScreen's header called the second page a show-notes placeholder, but it renders the episode's real show notes. NowPlayingController's header said the Now Playing card is rebuilt on every render, but it is rewritten only when out of date (NowPlayingPolicy), which is the point of that policy. Co-Authored-By: Claude Sonnet 5.5 --- iOS/Pollux/NowPlayingController.swift | 5 +++-- iOS/Pollux/PlayerScreen.swift | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/iOS/Pollux/NowPlayingController.swift b/iOS/Pollux/NowPlayingController.swift index 54ab05b..24c4bb8 100644 --- a/iOS/Pollux/NowPlayingController.swift +++ b/iOS/Pollux/NowPlayingController.swift @@ -5,8 +5,9 @@ import UIKit /// Lock-screen, Control Center, headphone and car integration. Remote commands are /// forwarded to the core as the same events the in-app controls send, so the lock -/// screen and the player can never disagree; the Now Playing card is rebuilt from the -/// core's `PlayerView` on every render. +/// screen and the player can never disagree; the Now Playing card is built from the +/// core's `PlayerView`, and rewritten only when it has gone out of date (see +/// `NowPlayingPolicy`), since every rewrite resets the system's clock. @MainActor final class NowPlayingController { private let send: (Event) -> Void diff --git a/iOS/Pollux/PlayerScreen.swift b/iOS/Pollux/PlayerScreen.swift index df7081e..2ce5172 100644 --- a/iOS/Pollux/PlayerScreen.swift +++ b/iOS/Pollux/PlayerScreen.swift @@ -2,8 +2,8 @@ import App import AVKit import SwiftUI -/// The full player: a paged content area (artwork, show-notes placeholder), scrubber, -/// transport controls, and the source/option rows. Presented as a full-screen cover +/// The full player: a paged content area (artwork, and the episode's show notes when it +/// has any), scrubber, transport controls, and the source/option rows. Presented as a full-screen cover /// from the root; "Hide" dismisses it back to the mini-player. struct PlayerScreen: View { @Environment(\.themeColors) private var themeColors From 3103f8cb87ef0f4bc0b3f694ac4ed25f34f1cf33 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 10:58:22 +0200 Subject: [PATCH 31/32] test: cover the engine's tick gate, end and failure notifications Add seams to PlaybackManager (the loaded item, the pending-seek count, and reportTick taking an explicit playing flag) and a suite that checks what the engine tells the core: ticks are held back while a seek is pending, when not playing, with nothing loaded, and for positions that are not times; the end of the file and failures carry their session and message; a file the engine cannot open is reported unusable. The tests found that unloading an item left its end and failure observers registered, so a removed item could still report under a session the core might consider current. Unloading now removes them. Co-Authored-By: Claude Sonnet 5.5 --- iOS/Pollux/PlaybackManager.swift | 27 +- .../PlaybackManagerEventTests.swift | 231 ++++++++++++++++++ iOS/PolluxTests/PlaybackManagerTests.swift | 54 ++-- 3 files changed, 281 insertions(+), 31 deletions(-) create mode 100644 iOS/PolluxTests/PlaybackManagerEventTests.swift diff --git a/iOS/Pollux/PlaybackManager.swift b/iOS/Pollux/PlaybackManager.swift index 627ea91..bc16876 100644 --- a/iOS/Pollux/PlaybackManager.swift +++ b/iOS/Pollux/PlaybackManager.swift @@ -18,7 +18,10 @@ final class PlaybackManager { private(set) var currentSession: UInt32? /// Seeks still in flight. A streaming seek takes a moment, and the engine keeps /// reporting the old position until it lands; those ticks would snap the UI back. - private var pendingSeeks = 0 + /// Internal so tests can see when a seek has landed, and hold the count up to check + /// that ticks are suppressed while one is pending (the test host's AVPlayer completes + /// seeks at once, so a real pending seek can't be observed there). Only `seek` changes it. + var pendingSeeks = 0 private var timeObserver: Any? private var statusObservation: NSKeyValueObservation? private var notificationTokens: [NSObjectProtocol] = [] @@ -52,6 +55,12 @@ final class PlaybackManager { player.currentItem != nil } + /// The item the engine has loaded. Exposed so tests can post the notifications the + /// system would post for it (the end of the file, a failure) without playing it. + var loadedItem: AVPlayerItem? { + player.currentItem + } + // MARK: - Operations /// Carries out one core operation. Synchronous: AVPlayer queues its own work. @@ -160,6 +169,10 @@ final class PlaybackManager { player.pause() player.replaceCurrentItem(with: nil) statusObservation = nil + // The removed item must not report any more (its end or failure would arrive tagged + // with a session the core may still think is current). + notificationTokens.forEach(NotificationCenter.default.removeObserver) + notificationTokens = [] currentSession = nil } @@ -208,14 +221,20 @@ final class PlaybackManager { let interval = CMTime(seconds: 1, preferredTimescale: 600) timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in let seconds = time.seconds - MainActor.assumeIsolated { self?.reportTick(seconds: seconds) } + MainActor.assumeIsolated { + guard let self else { return } + self.reportTick(seconds: seconds, isPlaying: self.player.timeControlStatus == .playing) + } } } - private func reportTick(seconds: Double) { + /// Reports the position to the core, unless it can't be trusted: nothing is loaded, a seek + /// is still landing (the engine keeps reporting the old spot until it does), or the engine + /// isn't playing. Takes `isPlaying` rather than asking the player so tests can drive it. + func reportTick(seconds: Double, isPlaying: Bool) { guard let session = currentSession, pendingSeeks == 0, - player.timeControlStatus == .playing, + isPlaying, seconds >= 0, let secs = PlayerFormatting.wholeSeconds(seconds) else { return } send(.playerTick(session: session, positionSecs: secs)) diff --git a/iOS/PolluxTests/PlaybackManagerEventTests.swift b/iOS/PolluxTests/PlaybackManagerEventTests.swift new file mode 100644 index 0000000..768d168 --- /dev/null +++ b/iOS/PolluxTests/PlaybackManagerEventTests.swift @@ -0,0 +1,231 @@ +import App +import AVFoundation +import Foundation +import Testing + +@testable import Pollux + +// What the engine tells the core while an item plays: position ticks, the end of the file, +// failures. Nothing is actually played: ticks are driven through `reportTick`, and the +// system's end and failure notifications are posted for the loaded item by hand. + +extension EngineRig { + /// A file under the storage root that is not audio, so the engine can't open it. + func installGarbage(named name: String = "garbage.mp3") throws -> String { + let directory = root.appendingPathComponent("Downloads", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("this is not an audio file".utf8).write(to: directory.appendingPathComponent(name)) + return "Downloads/\(name)" + } +} + +@MainActor +private func load(_ rig: EngineRig, session: UInt32, path: String, start: UInt32 = 0) { + _ = rig.manager.perform( + .load(session: session, media: .local(localPath: path), startSecs: start, autoplay: false), + ) +} + +/// The ticks the core was sent, as (session, position). +@MainActor +private func ticks(_ rig: EngineRig) -> [[UInt32]] { + rig.log.events.compactMap { + if case let .playerTick(session, position) = $0 { return [session, position] } + return nil + } +} + +@MainActor +private func endNotification(for item: AVPlayerItem?) { + NotificationCenter.default.post(name: AVPlayerItem.didPlayToEndTimeNotification, object: item) +} + +@MainActor +private func failureNotification(for item: AVPlayerItem?, message: String?) { + let userInfo = message.map { + [AVPlayerItemFailedToPlayToEndTimeErrorKey: NSError( + domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: $0], + )] + } + NotificationCenter.default.post( + name: AVPlayerItem.failedToPlayToEndTimeNotification, object: item, userInfo: userInfo, + ) +} + +@Suite @MainActor struct PlaybackManagerEventTests { + // MARK: - Position ticks + + @Test func aTickIsReportedOnceTheLoadHasSettled() async throws { + let rig = makeEngineRig() + load(rig, session: 3, path: try rig.installAudio()) + try await waitUntil { rig.manager.pendingSeeks == 0 } + + rig.manager.reportTick(seconds: 12.9, isPlaying: true) + + #expect(ticks(rig) == [[3, 12]]) + } + + @Test func ticksAreHeldBackWhileASeekIsPending() async throws { + let rig = makeEngineRig() + load(rig, session: 3, path: try rig.installAudio()) + try await waitUntil { rig.manager.pendingSeeks == 0 } + + // A seek is landing: the engine still reports the old spot, which would snap the + // scrubber back if it were passed on. + rig.manager.pendingSeeks = 1 + rig.manager.reportTick(seconds: 55, isPlaying: true) + #expect(ticks(rig).isEmpty) + + // Two overlapping seeks: still held until the last has landed. + rig.manager.pendingSeeks = 2 + rig.manager.reportTick(seconds: 55, isPlaying: true) + #expect(ticks(rig).isEmpty) + + rig.manager.pendingSeeks = 0 + rig.manager.reportTick(seconds: 1, isPlaying: true) + #expect(ticks(rig) == [[3, 1]]) + } + + @Test func everySeekIsAccountedForOnceItLands() async throws { + let rig = makeEngineRig() + // A load seeks to where to start; so do explicit seeks, some of them overlapping. + load(rig, session: 3, path: try rig.installAudio(), start: 1) + _ = rig.manager.perform(.seek(secs: 1)) + _ = rig.manager.perform(.seek(secs: 0)) + _ = rig.manager.perform(.seek(secs: 1)) + + try await waitUntil { rig.manager.pendingSeeks == 0 } + // Settled at zero, not stuck above it (ticks would be held back for good) and not + // below it (a later seek would then let stale ticks through). + try await Task.sleep(for: .milliseconds(200)) + #expect(rig.manager.pendingSeeks == 0) + } + + @Test func noTickIsReportedWhileNotPlaying() async throws { + let rig = makeEngineRig() + load(rig, session: 3, path: try rig.installAudio()) + try await waitUntil { rig.manager.pendingSeeks == 0 } + + rig.manager.reportTick(seconds: 5, isPlaying: false) + + #expect(ticks(rig).isEmpty) + } + + @Test func noTickIsReportedWithNothingLoaded() { + let rig = makeEngineRig() + + rig.manager.reportTick(seconds: 5, isPlaying: true) + + #expect(ticks(rig).isEmpty) + } + + @Test func noTickIsReportedAfterTheItemIsUnloaded() async throws { + let rig = makeEngineRig() + load(rig, session: 3, path: try rig.installAudio()) + try await waitUntil { rig.manager.pendingSeeks == 0 } + _ = rig.manager.perform(.stop) + + rig.manager.reportTick(seconds: 5, isPlaying: true) + + #expect(ticks(rig).isEmpty) + } + + @Test func aPositionThatIsNotATimeIsNotReported() async throws { + let rig = makeEngineRig() + load(rig, session: 3, path: try rig.installAudio()) + try await waitUntil { rig.manager.pendingSeeks == 0 } + + for bad in [Double.nan, .infinity, -1, 1e300] { + rig.manager.reportTick(seconds: bad, isPlaying: true) + } + + #expect(ticks(rig).isEmpty) + } + + // MARK: - End of the file and failures + + @Test func theEndOfTheFileIsReportedWithItsSession() throws { + let rig = makeEngineRig() + load(rig, session: 4, path: try rig.installAudio()) + + endNotification(for: rig.manager.loadedItem) + + #expect(rig.log.events.contains(.playerEnded(session: 4))) + } + + @Test func aFailureMidPlayIsReportedWithTheSystemsMessage() throws { + let rig = makeEngineRig() + load(rig, session: 4, path: try rig.installAudio()) + + failureNotification(for: rig.manager.loadedItem, message: "network lost") + + #expect(rig.log.events.contains(.playerFailed(session: 4, message: "network lost"))) + } + + @Test func aFailureWithNoMessageStillSaysSomething() throws { + let rig = makeEngineRig() + load(rig, session: 4, path: try rig.installAudio()) + + failureNotification(for: rig.manager.loadedItem, message: nil) + + #expect(rig.log.events.contains( + .playerFailed(session: 4, message: "Playback stopped unexpectedly"), + )) + } + + @Test func aFileTheEngineCantOpenIsReportedUnusable() async throws { + let rig = makeEngineRig() + load(rig, session: 6, path: try rig.installGarbage()) + + try await waitUntil { + rig.log.events.contains { + if case let .playerMediaUnusable(session, _) = $0 { return session == 6 } + return false + } + } + } + + // MARK: - Items that are no longer current + + @Test func theEndOfAReplacedItemIsIgnored() throws { + let rig = makeEngineRig() + let path = try rig.installAudio() + load(rig, session: 1, path: path) + let replaced = rig.manager.loadedItem + load(rig, session: 2, path: path) + + endNotification(for: replaced) + #expect(!rig.log.events.contains(.playerEnded(session: 1))) + + endNotification(for: rig.manager.loadedItem) + #expect(rig.log.events.contains(.playerEnded(session: 2))) + } + + @Test func theEndOfAnUnloadedItemIsIgnored() throws { + let rig = makeEngineRig() + load(rig, session: 5, path: try rig.installAudio()) + let item = rig.manager.loadedItem + _ = rig.manager.perform(.stop) + + endNotification(for: item) + failureNotification(for: item, message: "late") + + #expect(rig.log.events.isEmpty || !rig.log.events.contains(.playerEnded(session: 5))) + #expect(!rig.log.events.contains(.playerFailed(session: 5, message: "late"))) + } + + @Test func theEndOfAnItemUnloadedByAFailedLoadIsIgnored() throws { + let rig = makeEngineRig() + let path = try rig.installAudio() + load(rig, session: 7, path: path) + let item = rig.manager.loadedItem + // The next load fails up front, which unloads the engine. + _ = rig.manager.perform( + .load(session: 8, media: .local(localPath: "Downloads/missing.mp3"), startSecs: 0, autoplay: false), + ) + + endNotification(for: item) + + #expect(!rig.log.events.contains(.playerEnded(session: 7))) + } +} diff --git a/iOS/PolluxTests/PlaybackManagerTests.swift b/iOS/PolluxTests/PlaybackManagerTests.swift index 6df1b3e..297832a 100644 --- a/iOS/PolluxTests/PlaybackManagerTests.swift +++ b/iOS/PolluxTests/PlaybackManagerTests.swift @@ -11,7 +11,7 @@ import Testing /// One second of silence as a minimal PCM WAV file, the smallest valid audio `AVPlayer` /// will open. -private func silentWAV() -> Data { +func silentWAV() -> Data { let sampleRate: UInt32 = 8000 let payload = Data(count: Int(sampleRate)) // 8-bit mono: one byte per sample var wav = Data() @@ -39,7 +39,7 @@ private func silentWAV() -> Data { /// Stands in for claiming the audio session, so tests can see when it is claimed (which /// takes audio focus from other apps) and make it fail. -private final class AudioSessionProbe: @unchecked Sendable { +final class AudioSessionProbe: @unchecked Sendable { struct Refused: Error, LocalizedError { var errorDescription: String? { "audio is busy" @@ -57,7 +57,7 @@ private final class AudioSessionProbe: @unchecked Sendable { /// Collects what the engine reports to the core. @MainActor -private final class EventLog { +final class EventLog { private(set) var events: [Event] = [] func record(_ event: Event) { @@ -66,7 +66,7 @@ private final class EventLog { } @MainActor -private struct Rig { +struct EngineRig { let manager: PlaybackManager let root: URL let audio: AudioSessionProbe @@ -82,18 +82,18 @@ private struct Rig { } @MainActor -private func makeRig() -> Rig { +func makeEngineRig() -> EngineRig { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) let audio = AudioSessionProbe() let log = EventLog() let manager = PlaybackManager(storageRoot: root, activateAudioSession: audio.claim) { log.record($0) } - return Rig(manager: manager, root: root, audio: audio, log: log) + return EngineRig(manager: manager, root: root, audio: audio, log: log) } /// Posts what the system posts when the audio session is interrupted or its route changes. @MainActor -private func postInterruption( +func postInterruption( _ type: AVAudioSession.InterruptionType, options: AVAudioSession.InterruptionOptions = [], ) { NotificationCenter.default.post( @@ -107,7 +107,7 @@ private func postInterruption( } @MainActor -private func postRouteChange(_ reason: AVAudioSession.RouteChangeReason) { +func postRouteChange(_ reason: AVAudioSession.RouteChangeReason) { NotificationCenter.default.post( name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance(), @@ -118,7 +118,7 @@ private func postRouteChange(_ reason: AVAudioSession.RouteChangeReason) { /// Polls (on the main actor) until `condition` holds, failing the test if it doesn't within /// `timeout` seconds. Engine news arrives asynchronously. @MainActor -private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) async throws { +func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) async throws { let deadline = Date().addingTimeInterval(timeout) while !condition() { guard Date() < deadline else { @@ -131,7 +131,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) @Suite @MainActor struct PlaybackManagerTests { @Test func loadingAPlayableFileLoadsIt() throws { - let rig = makeRig() + let rig = makeEngineRig() let path = try rig.installAudio() let result = rig.manager.perform( @@ -144,7 +144,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) } @Test func aFailedLoadLeavesNothingLoadedNotTheOldItem() throws { - let rig = makeRig() + let rig = makeEngineRig() let path = try rig.installAudio() #expect( rig.manager.perform( @@ -163,7 +163,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) } @Test func anUnusableStreamURLAlsoUnloadsThePreviousItem() throws { - let rig = makeRig() + let rig = makeEngineRig() let path = try rig.installAudio() _ = rig.manager.perform( .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: false), @@ -179,7 +179,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) } @Test func aFailedLoadWithNothingLoadedIsHarmless() { - let rig = makeRig() + let rig = makeEngineRig() let result = rig.manager.perform( .load(session: 1, media: .local(localPath: "Downloads/missing.mp3"), startSecs: 0, autoplay: false), @@ -194,7 +194,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) @Test func aPausedLoadDoesNotClaimTheAudioSession() throws { // A source swap that finishes while the listener has paused (and moved on to another // app's music) must not cut that audio off. - let rig = makeRig() + let rig = makeEngineRig() let path = try rig.installAudio() let result = rig.manager.perform( @@ -206,7 +206,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) } @Test func aLoadThatWillPlayClaimsTheAudioSession() throws { - let rig = makeRig() + let rig = makeEngineRig() let path = try rig.installAudio() let result = rig.manager.perform( @@ -218,7 +218,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) } @Test func playingAfterAPausedLoadClaimsTheAudioSessionThen() throws { - let rig = makeRig() + let rig = makeEngineRig() let path = try rig.installAudio() _ = rig.manager.perform( .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: false), @@ -232,7 +232,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) } @Test func pausingAndSeekingDoNotClaimTheAudioSession() throws { - let rig = makeRig() + let rig = makeEngineRig() let path = try rig.installAudio() _ = rig.manager.perform( .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: false), @@ -245,7 +245,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) } @Test func aRefusedSessionFailsAPlayingLoadAndUnloadsTheItem() throws { - let rig = makeRig() + let rig = makeEngineRig() let path = try rig.installAudio() rig.audio.refuse = true @@ -258,7 +258,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) } @Test func aRefusedSessionFailsPlayButKeepsTheLoadedItem() throws { - let rig = makeRig() + let rig = makeEngineRig() let path = try rig.installAudio() _ = rig.manager.perform( .load(session: 1, media: .local(localPath: path), startSecs: 0, autoplay: false), @@ -274,7 +274,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) @Test func aSuccessfulLoadAfterAFailedOneWorks() throws { // The core falls back to another source right after a failed load. - let rig = makeRig() + let rig = makeEngineRig() let path = try rig.installAudio() _ = rig.manager.perform( .load(session: 1, media: .local(localPath: "Downloads/missing.mp3"), startSecs: 0, autoplay: false), @@ -292,13 +292,13 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) // MARK: - System notifications → core events @Test func anInterruptionBeginningIsReportedAsResumable() { - let rig = makeRig() + let rig = makeEngineRig() postInterruption(.began) #expect(rig.log.events == [.interrupted(resumable: true)]) } @Test func anInterruptionEndingReportsWhetherTheSystemAllowsResuming() { - let rig = makeRig() + let rig = makeEngineRig() postInterruption(.ended, options: [.shouldResume]) postInterruption(.ended) #expect(rig.log.events == [ @@ -308,13 +308,13 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) } @Test func unpluggingHeadphonesIsAnInterruptionThatNeverResumes() { - let rig = makeRig() + let rig = makeEngineRig() postRouteChange(.oldDeviceUnavailable) #expect(rig.log.events == [.interrupted(resumable: false)]) } @Test func otherRouteChangesAreNotReported() { - let rig = makeRig() + let rig = makeEngineRig() postRouteChange(.newDeviceAvailable) postRouteChange(.categoryChange) postRouteChange(.override) @@ -322,7 +322,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) } @Test func notificationsFromOtherObjectsAreIgnored() { - let rig = makeRig() + let rig = makeEngineRig() NotificationCenter.default.post( name: AVAudioSession.interruptionNotification, object: NSObject(), @@ -334,7 +334,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) // MARK: - Session tagging @Test func engineNewsCarriesTheSessionOfTheLoadThatProducedIt() async throws { - let rig = makeRig() + let rig = makeEngineRig() let path = try rig.installAudio() _ = rig.manager.perform( @@ -350,7 +350,7 @@ private func waitUntil(timeout: Double = 5, _ condition: @MainActor () -> Bool) } @Test func aLoadReplacedBeforeItIsReadyReportsNothingForTheOldSession() async throws { - let rig = makeRig() + let rig = makeEngineRig() let path = try rig.installAudio() // Replaced in the same turn, before the first item can report anything. From 204ab0b32fb58c18363d7f33c2ee980004df17ee Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Wed, 7 Oct 2026 11:07:26 +0200 Subject: [PATCH 32/32] chore: harden playback start-up, tighten a test, refresh the plan Create the audio engine and the now-playing controller independently, so a missing engine can't cause the controller (and its remote-command handlers) to be built again on every later request. Replace a vacuous assertion in an unload test with an explicit check that no end event is reported for the unloaded item's session. Bring PLAN.md's review section up to date with the later fixes, the engine tests, and the two limits still open. Co-Authored-By: Claude Sonnet 5.5 --- PLAN.md | 14 ++++++++++++-- iOS/Pollux/core.swift | 7 +++++-- iOS/PolluxTests/PlaybackManagerEventTests.swift | 2 +- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/PLAN.md b/PLAN.md index a05a27b..b6d827a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -79,13 +79,23 @@ Fixed: From the cross-check: - **Playback downloads its episode without its feed open.** `enqueue_download` also finds the active episode; `play()` starts or retries the download when the episode is streaming with no file coming. This fixes the missing-file fallback after a cold start. -- **A failed play-context save is retried** at each checkpoint until storage confirms it (`ActivePlayback::context_saved`, `PlayContextSaved`). +- **A failed play-context save is retried** at each checkpoint until storage confirms it (`ActivePlayback::context_saved`, `PlayContextSaved`), at most `CONTEXT_SAVE_ATTEMPTS` (5) times in all. +- **A pause or headphone removal during an interruption disarms the auto-resume** (the sibling of item 1: the early returns for already-paused playback used to leave it armed). +- **A refresh updates the active episode's details and stream URL** from the reloaded list (`refresh_active_from_list`), so retrying a failed stream loads the renewed URL. Only when that episode's feed is the open one. + +From a second full review: +- **A bad download no longer loops.** A file the engine can't use is replaced by a fresh download once; if the fresh file is unusable too the download is marked failed and playback streams (`ActivePlayback::local_unusable`, `fail_download`), and pressing play doesn't fetch it again. +- **Playback doesn't revive downloads the listener turned away.** Cancelling or deleting a download is remembered for the session (`declined_downloads`); starting, resuming, and the lost-file fallback skip those. Only the Download action lifts it. +- **A bad position can't crash the app.** `PlayerFormatting.wholeSeconds` refuses NaN, infinite and out-of-range values; the lock-screen scrub, the engine's ticks and its duration use it. +- **Two stale comments fixed**, and a removed item's end and failure observers are now unregistered. Partly done: - **Per-tick render cost (5).** The player state is split from the episode list so ticks do not re-evaluate the list. In the simulator (debug core, 375 episodes) app CPU while playing with the list open fell from 5.4% to 4.1%, against a 1.0% floor. Still to do: measure on a device with a release build. If it is not fine, render less per tick. See "Known limitations and follow-ups" in `player.md`. -- **Shell tests (10).** The interruption and route-change mapping and session tagging are tested. Not covered: tick suppression while a seek is pending, and the natural-end and failure notifications (they need the engine's private item or real playback). +- **Shell tests (10).** The interruption and route-change mapping, session tagging, the tick gate, and the end and failure notifications are tested (small seams on `PlaybackManager`). What can't be observed in tests is the timing of a real seek: the test host's AVPlayer completes seeks at once, so the pending-seek window is checked by setting the count and by checking it always returns to zero. Still open: +- A refresh reaches the active episode only when its feed is the open one; a mini-player retry with another feed open uses the old URL until relaunch. +- Declined downloads and the bad-file memory last for the session only; after a relaunch the cycles are bounded but can run a couple of times. - Lock-screen controls render invisibly (item 5 above); needs a device. - The stream-to-local swap gap (item 6 above). - Unverified without a device: background audio, the AirPlay picker, interruptions, the error banner, lock-screen rendering. diff --git a/iOS/Pollux/core.swift b/iOS/Pollux/core.swift index 3753e8e..2a4dc00 100644 --- a/iOS/Pollux/core.swift +++ b/iOS/Pollux/core.swift @@ -209,12 +209,15 @@ class Core: ObservableObject { /// card is published (so a restored, paused episode isn't on the lock screen until /// it is played). private func startPlaybackIfNeeded() { - guard playback == nil else { return } - if let root = StorageRoot.applicationSupport() { + // Each is created once, on its own: if the engine can't be (no storage root), the + // controller must not be built again on every later request, which would register + // another set of handlers on the shared remote command center. + if playback == nil, let root = StorageRoot.applicationSupport() { playback = PlaybackManager(storageRoot: root) { [weak self] event in self?.update(event) } } + guard nowPlaying == nil else { return } nowPlaying = NowPlayingController { [weak self] event in self?.update(event) } diff --git a/iOS/PolluxTests/PlaybackManagerEventTests.swift b/iOS/PolluxTests/PlaybackManagerEventTests.swift index 768d168..1911d3a 100644 --- a/iOS/PolluxTests/PlaybackManagerEventTests.swift +++ b/iOS/PolluxTests/PlaybackManagerEventTests.swift @@ -210,7 +210,7 @@ private func failureNotification(for item: AVPlayerItem?, message: String?) { endNotification(for: item) failureNotification(for: item, message: "late") - #expect(rig.log.events.isEmpty || !rig.log.events.contains(.playerEnded(session: 5))) + #expect(!rig.log.events.contains(.playerEnded(session: 5))) #expect(!rig.log.events.contains(.playerFailed(session: 5, message: "late"))) }