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 \ diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..b6d827a --- /dev/null +++ b/PLAN.md @@ -0,0 +1,101 @@ +# 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. + +## 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 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. +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, 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`), 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, 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/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/ROADMAP.md b/docs/ROADMAP.md index 114ab74..3c9129c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -68,13 +68,17 @@ 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 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. @@ -129,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 1d27c5f..4668b70 100644 --- a/docs/features/player.md +++ b/docs/features/player.md @@ -24,11 +24,21 @@ 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`) +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. @@ -87,7 +97,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 @@ -102,3 +112,192 @@ 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 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. 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. 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. + 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 + **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. 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. + 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. + 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. 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 + 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. +- **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. + +## 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. + +### 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`. diff --git a/docs/user_stories/player/playback.md b/docs/user_stories/player/playback.md index 5fd3c4d..0d6f315 100644 --- a/docs/user_stories/player/playback.md +++ b/docs/user_stories/player/playback.md @@ -96,3 +96,11 @@ 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 + +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 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/ContentView.swift b/iOS/Pollux/ContentView.swift index 904284e..866f63f 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 @@ -18,7 +19,7 @@ struct ContentView: View { } var body: some View { - NavigationStack { + NavigationStack(path: $path) { VStack(spacing: 0) { Group { if core.view.library.loading { @@ -101,5 +102,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..f2ffbb9 --- /dev/null +++ b/iOS/Pollux/DatabaseManager+PlayContext.swift @@ -0,0 +1,48 @@ +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, 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, source_kind, source_id) + VALUES (1, ?, ?, ?) + """, + arguments: [episodeId, columns.kind, columns.id], + ) + } + return .success + } + + /// 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.*, 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 } + 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 { + 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 39c3836..35cd757 100644 --- a/iOS/Pollux/DatabaseManager.swift +++ b/iOS/Pollux/DatabaseManager.swift @@ -16,16 +16,15 @@ enum DatabaseManagerError: Error, LocalizedError { } actor DatabaseManager { - private let db: DatabasePool + /// Internal (not private) so the play-context extension in its own file can use it. + let db: DatabasePool /// Wall clock, injectable so tests can move time (the removed-from-feed rule is /// time-based). private let now: @Sendable () -> Date 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) @@ -58,6 +57,12 @@ actor DatabaseManager { try await deleteSubscription(id: id) case let .upsertFeedWithEpisodes(subscription, episodes): try await upsertFeedWithEpisodes(subscription: subscription, episodes: episodes) + case let .savePlayContext(episodeId, source): + try await savePlayContext(episodeId: episodeId, source: source) + case .loadPlayContext: + try loadPlayContext() + case .clearPlayContext: + try await clearPlayContext() case let .updateRefreshState(subscriptionId, lastRefreshed, lastRefreshError, retryAfterUntil): try await updateRefreshState( subscriptionId: subscriptionId, lastRefreshed: lastRefreshed, 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 a97b326..d05b02c 100644 --- a/iOS/Pollux/DatabaseMigrations.swift +++ b/iOS/Pollux/DatabaseMigrations.swift @@ -68,6 +68,18 @@ extension DatabaseManager { on: "episodes", columns: ["subscription_id"], ) + // 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/DownloadManager.swift b/iOS/Pollux/DownloadManager.swift index fdc71e6..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 @@ -196,7 +194,13 @@ 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) } /// 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 38ec62e..707da33 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 8d63f5f..3d1f5f1 100644 --- a/iOS/Pollux/Info.plist +++ b/iOS/Pollux/Info.plist @@ -24,6 +24,7 @@ 1 UIBackgroundModes + audio fetch UILaunchScreen diff --git a/iOS/Pollux/MiniPlayerBar.swift b/iOS/Pollux/MiniPlayerBar.swift new file mode 100644 index 0000000..7cb4f4e --- /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 { + NoticeBanner(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.accent) + .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..24c4bb8 --- /dev/null +++ b/iOS/Pollux/NowPlayingController.swift @@ -0,0 +1,151 @@ +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 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 + 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() + } + + /// 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 { + 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, + 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 + + 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 + + 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 + } + // 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 + } + } + + 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 } + 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/Pollux/PlaybackManager.swift b/iOS/Pollux/PlaybackManager.swift new file mode 100644 index 0000000..bc16876 --- /dev/null +++ b/iOS/Pollux/PlaybackManager.swift @@ -0,0 +1,284 @@ +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 + + /// 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(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. + /// 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] = [] + + /// 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 { + 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. + func perform(_ operation: PlayerOperation) -> PlayerResult { + switch operation { + 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() + case let .seek(secs): + seek(to: secs) + case .stop: + stop() + } + 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) + switch result { + case .error, .mediaUnusable: + unloadCurrentItem() + case .ok: + break + } + return result + } + + private func startLoading( + session: UInt32, media: MediaSource, startSecs: UInt32, autoplay: Bool, + ) -> PlayerResult { + let url: URL + switch media { + case let .stream(urlString): + guard let parsed = URL(string: urlString) else { + 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 .mediaUnusable("The downloaded file is missing") + } + } + + // 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 + let item = AVPlayerItem(url: url) + 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) + if autoplay { + player.play() + } + 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 + 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() { + 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 + // 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 + } + + // MARK: - Engine → core + + 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, currentSession == session else { return } + switch status { + case .readyToPlay: + if duration > 0, let secs = PlayerFormatting.wholeSeconds(duration) { + send(.playerDuration(session: session, durationSecs: secs)) + } + case .failed: + send(.playerMediaUnusable(session: session, 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(session: session)) } + }, + 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(session: session, 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 { + guard let self else { return } + self.reportTick(seconds: seconds, isPlaying: self.player.timeControlStatus == .playing) + } + } + } + + /// 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, + isPlaying, + 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 + /// 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() + 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(.interrupted(resumable: true)) + case .ended: + let shouldResume = options?.contains(.shouldResume) == true + self?.send(.interruptionEnded(shouldResume: shouldResume)) + 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 { + // No "ended" notification follows a route change, so never resumable. + self?.send(.interrupted(resumable: false)) + } + } + } + } +} diff --git a/iOS/Pollux/PlayerFormatting.swift b/iOS/Pollux/PlayerFormatting.swift new file mode 100644 index 0000000..1db0ab5 --- /dev/null +++ b/iOS/Pollux/PlayerFormatting.swift @@ -0,0 +1,57 @@ +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 { + 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 + }() + + /// 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 } + 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..2ce5172 --- /dev/null +++ b/iOS/Pollux/PlayerScreen.swift @@ -0,0 +1,254 @@ +import App +import AVKit +import SwiftUI + +/// 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 + 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 { + NoticeBanner(message: error) + } + scrubber + titleBlock + transport + bottomRow + } + .padding() + .background(themeColors.background.ignoresSafeArea()) + } + + // 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: nil) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .tag(0) + 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. + 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) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + .accessibilityLabel("Show notes") + } + + // 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.sourceTitle)") + .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) + } + // Primary controls take the accent; the secondary row below stays in text color. + .foregroundStyle(themeColors.accent) + } + + 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/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 93f7205..33112f6 100644 --- a/iOS/Pollux/Pollux.swift +++ b/iOS/Pollux/Pollux.swift @@ -1,4 +1,5 @@ import App +import Combine import SwiftUI @main @@ -22,6 +23,10 @@ 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 @@ -32,11 +37,40 @@ 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) { + MiniPlayerHost( + playerState: core.playerState, + isPlayerOpen: showPlayer, + onOpen: { showPlayer = true }, + onTogglePlay: { core.update(.togglePlay) }, + ) + } + .fullScreenCover(isPresented: $showPlayer) { + 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) + // 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 + } + } .onChange(of: scenePhase, initial: true) { _, phase in // The lifecycle logic lives in `Core` so it can be tested; this only maps // the scene phase onto it. `initial: true` covers cold launch. @@ -50,4 +84,59 @@ private struct RootView: View { } } } + + /// 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) { + 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) + } + } +} + +/// 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/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/SubscriptionDetailScreen.swift b/iOS/Pollux/SubscriptionDetailScreen.swift index 108dfa1..48cc894 100644 --- a/iOS/Pollux/SubscriptionDetailScreen.swift +++ b/iOS/Pollux/SubscriptionDetailScreen.swift @@ -106,6 +106,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 992adf4..2a4dc00 100644 --- a/iOS/Pollux/core.swift +++ b/iOS/Pollux/core.swift @@ -4,11 +4,30 @@ 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 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 @@ -59,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 } @@ -88,8 +109,10 @@ class Core: ObservableObject { update(.resumePendingDownloads) } - /// The app went to the background: queue the next best-effort wake-up. + /// The app went to the background: flush the playback position (the app may be + /// suspended or killed next) and queue the next best-effort wake-up. func appEnteredBackground() { + update(.appBackgrounded) scheduleBackgroundRefresh() } @@ -143,7 +166,13 @@ class Core: ObservableObject { ) else { fatalError("Failed to deserialize ViewModel during render") } - view = updatedView + 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). @@ -161,9 +190,48 @@ 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. + startPlaybackIfNeeded() + let result = playback?.perform(operation) ?? .error("Audio engine unavailable") + let requestId = request.id + Task { @MainActor [weak self] in + self?.resolveAndDispatch(requestId: requestId, result: result) + } } } + /// 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() { + // 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) + } + // Publish the current state now rather than at the next render. + 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 /// 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 @@ -233,6 +301,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/CoreWiringTests.swift b/iOS/PolluxTests/CoreWiringTests.swift index 9abd4f8..354e98a 100644 --- a/iOS/PolluxTests/CoreWiringTests.swift +++ b/iOS/PolluxTests/CoreWiringTests.swift @@ -250,3 +250,97 @@ 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.playerState.player != nil } + + // …without an audio engine or lock-screen integration behind it. + #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.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/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)) + } +} diff --git a/iOS/PolluxTests/PlayContextTests.swift b/iOS/PolluxTests/PlayContextTests.swift new file mode 100644 index 0000000..9d5c491 --- /dev/null +++ b/iOS/PolluxTests/PlayContextTests.swift @@ -0,0 +1,92 @@ +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 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", + artworkUrl: nil, description: nil, lastRefreshed: nil, createdAt: 1, + etag: nil, lastModified: nil, lastRefreshError: nil, retryAfterUntil: nil) + 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 savedContextLoadsTheEpisodeWithItsPositionAndSource() async throws { + let db = try makeManager() + try await seed(db, episodeId: "ep-1") + try await db.execute(.savePlayContext(episodeId: "ep-1", source: feed)) + + let result = try await db.execute(.loadPlayContext) + 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", source: feed)) + try await db.execute(.savePlayContext(episodeId: "ep-2", source: feed)) + + guard case let .playContext(episode, _) = try await db.execute(.loadPlayContext) else { + Issue.record("Expected a play context") + 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", source: feed)) + 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", source: feed)) + 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/PlaybackManagerEventTests.swift b/iOS/PolluxTests/PlaybackManagerEventTests.swift new file mode 100644 index 0000000..1911d3a --- /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.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 new file mode 100644 index 0000000..297832a --- /dev/null +++ b/iOS/PolluxTests/PlaybackManagerTests.swift @@ -0,0 +1,371 @@ +import App +import AVFoundation +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. +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 +} + +/// 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. +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() } + } +} + +/// Collects what the engine reports to the core. +@MainActor +final class EventLog { + private(set) var events: [Event] = [] + + func record(_ event: Event) { + events.append(event) + } +} + +@MainActor +struct EngineRig { + 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 { + 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 +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 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 +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 +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 +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 { + @Test func loadingAPlayableFileLoadsIt() throws { + let rig = makeEngineRig() + 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 = makeEngineRig() + 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 == .mediaUnusable("The downloaded file is missing")) + #expect(!rig.manager.hasLoadedItem) + #expect(rig.manager.currentSession == nil) + } + + @Test func anUnusableStreamURLAlsoUnloadsThePreviousItem() throws { + let rig = makeEngineRig() + 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 == .mediaUnusable("Invalid episode URL")) + #expect(!rig.manager.hasLoadedItem) + #expect(rig.manager.currentSession == nil) + } + + @Test func aFailedLoadWithNothingLoadedIsHarmless() { + let rig = makeEngineRig() + + let result = rig.manager.perform( + .load(session: 1, media: .local(localPath: "Downloads/missing.mp3"), startSecs: 0, autoplay: false), + ) + + #expect(result == .mediaUnusable("The downloaded file is missing")) + #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 = makeEngineRig() + 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 = makeEngineRig() + 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 = makeEngineRig() + 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 = makeEngineRig() + 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 = makeEngineRig() + 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 = makeEngineRig() + 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 = makeEngineRig() + 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) + } + + // MARK: - System notifications → core events + + @Test func anInterruptionBeginningIsReportedAsResumable() { + let rig = makeEngineRig() + postInterruption(.began) + #expect(rig.log.events == [.interrupted(resumable: true)]) + } + + @Test func anInterruptionEndingReportsWhetherTheSystemAllowsResuming() { + let rig = makeEngineRig() + postInterruption(.ended, options: [.shouldResume]) + postInterruption(.ended) + #expect(rig.log.events == [ + .interruptionEnded(shouldResume: true), + .interruptionEnded(shouldResume: false), + ]) + } + + @Test func unpluggingHeadphonesIsAnInterruptionThatNeverResumes() { + let rig = makeEngineRig() + postRouteChange(.oldDeviceUnavailable) + #expect(rig.log.events == [.interrupted(resumable: false)]) + } + + @Test func otherRouteChangesAreNotReported() { + let rig = makeEngineRig() + postRouteChange(.newDeviceAvailable) + postRouteChange(.categoryChange) + postRouteChange(.override) + #expect(rig.log.events.isEmpty) + } + + @Test func notificationsFromOtherObjectsAreIgnored() { + let rig = makeEngineRig() + 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 = makeEngineRig() + 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 = makeEngineRig() + 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) + } +} diff --git a/iOS/PolluxTests/PlayerFormattingTests.swift b/iOS/PolluxTests/PlayerFormattingTests.swift new file mode 100644 index 0000000..7ffb96a --- /dev/null +++ b/iOS/PolluxTests/PlayerFormattingTests.swift @@ -0,0 +1,82 @@ +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")) + } + + @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) + } + } + + // 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) + for _ in 0 ..< 100 { + #expect(PlayerFormatting.spoken(754) == first) + } + } +} 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 } + } +} 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")) + } +} diff --git a/iOS/project.yml b/iOS/project.yml index f64367d..fa74fca 100644 --- a/iOS/project.yml +++ b/iOS/project.yml @@ -22,8 +22,9 @@ targets: path: Pollux/Info.plist properties: UILaunchScreen: {} - # Background feed refresh (BGAppRefreshTask); see Pollux/BackgroundRefresh.swift. - UIBackgroundModes: [fetch] + # audio: keep playing with the screen locked or the app in the background. + # fetch: background feed refresh (BGAppRefreshTask); see Pollux/BackgroundRefresh.swift. + UIBackgroundModes: [audio, fetch] BGTaskSchedulerPermittedIdentifiers: [com.example.pollux.refresh] dependencies: - package: Shared diff --git a/shared/src/app.rs b/shared/src/app.rs index aa3b6f5..bb9640f 100644 --- a/shared/src/app.rs +++ b/shared/src/app.rs @@ -6,6 +6,7 @@ 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::defaults::{ FAILURE_BACKOFF_SECS, MAX_RETRY_AFTER_SECS, RATE_LIMIT_BACKOFF_SECS, REFRESH_INTERVAL_HOURS, @@ -15,6 +16,7 @@ use crate::effect::Effect; use crate::feed_parser::{now_unix, 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, @@ -38,9 +40,16 @@ impl App for Pollux { // also created when iOS launches the app in the background (for a // refresh task), and that launch must stay metadata-only. They resume // on the first foreground activation instead (`ResumePendingDownloads`). - Command::request_from_shell(StorageOperation::ListSubscriptions) - .then_send(|r| Event::SubscriptionsLoaded(Box::new(r))) - .and(render()) + let subscriptions = + Command::request_from_shell(StorageOperation::ListSubscriptions) + .then_send(|r| Event::SubscriptionsLoaded(Box::new(r))); + // Restore the episode that was active when the app last closed, so the + // mini-player is up immediately (paused). This is a metadata read only — + // nothing is loaded into the audio engine until the user presses play — + // so it is safe on a background-refresh launch too. + let context = Command::request_from_shell(StorageOperation::LoadPlayContext) + .then_send(|r| Event::PlayContextLoaded(Box::new(r))); + subscriptions.and(context).and(render()) } Event::ResumePendingDownloads => { // Once per process: the shell sends this on every activation, but the @@ -448,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; } @@ -469,55 +481,14 @@ 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 — // 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, @@ -550,23 +521,22 @@ 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); - 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 @@ -584,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 @@ -620,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() @@ -684,6 +658,43 @@ 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::Interrupted { resumable } => player::interrupt(model, resumable), + Event::InterruptionEnded { should_resume } => { + player::on_interruption_ended(model, should_resume) + } + Event::PlayerTick { + session, + position_secs, + } => player::on_tick(model, session, position_secs), + Event::PlayerDuration { + session, + 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, 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) + } + 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(), + Event::PlayContextSaved { episode_id, result } => { + player::on_context_saved(model, &episode_id, &result) + } } } @@ -716,6 +727,7 @@ impl App for Pollux { refreshing: model.refreshing.is_some() || !model.refresh_queue.is_empty(), }, subscription_detail: build_subscription_detail(model), + player: player::player_view(model), theme: theme_view(model.theme_id, model.theme_mode), } } @@ -772,7 +784,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 @@ -819,6 +831,44 @@ 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 { + // 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 { + 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` @@ -847,9 +897,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; } } @@ -878,10 +942,27 @@ 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. -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) } @@ -1249,6 +1330,79 @@ 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, + /// 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. `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 { + session: u32, + position_secs: u32, + }, + /// The engine learned the real duration, which beats the feed's possibly-missing one. + PlayerDuration { + session: u32, + duration_secs: u32, + }, + /// The engine reached the end of the file. + PlayerEnded { + session: u32, + }, + /// 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, + }, + /// 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, + }, + /// The episode saved as active by the previous session, loaded at launch. + PlayContextLoaded(Box), + /// 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)] @@ -1366,8 +1520,16 @@ mod tests { assert!(model.loading); let effects: Vec = cmd.effects().collect(); - // ListSubscriptions + render. - assert_eq!(effects.len(), 2); + // ListSubscriptions + LoadPlayContext + render. + assert_eq!(effects.len(), 3); + + 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..dba613c --- /dev/null +++ b/shared/src/capabilities/player.rs @@ -0,0 +1,59 @@ +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 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 + /// 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 `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 { + /// Tags everything the engine reports about this item, so the core can drop + /// news from an item it has since replaced. + session: u32, + media: MediaSource, + 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, + /// 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 { + type Output = PlayerResult; +} diff --git a/shared/src/capabilities/storage.rs b/shared/src/capabilities/storage.rs index 83ce189..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,6 +45,16 @@ pub enum StorageOperation { local_path: Option, size_bytes: Option, }, + /// 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 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, /// 429, or a failure). `last_refreshed` is left unchanged when `None`; the error /// and retry-after columns are written verbatim (NULL when `None`). A moved @@ -71,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/defaults.rs b/shared/src/defaults.rs index 230fca0..fea3652 100644 --- a/shared/src/defaults.rs +++ b/shared/src/defaults.rs @@ -2,6 +2,18 @@ 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; +/// 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/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/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 945afc3..4bdd1f2 100644 --- a/shared/src/model.rs +++ b/shared/src/model.rs @@ -1,6 +1,7 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; -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; @@ -48,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 @@ -57,6 +62,13 @@ 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, + // 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` // (front = next up). Kept apart from the library `loading`/`error`, which the @@ -77,6 +89,50 @@ 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, + /// 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, + pub is_playing: bool, + /// 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, + /// 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, + /// 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, + /// 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 + /// 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, +} + /// 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..197f3c9 --- /dev/null +++ b/shared/src/player.rs @@ -0,0 +1,782 @@ +//! 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. +//! +//! # 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}; + +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::{ + 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; +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_declined_tests; +#[cfg(test)] +mod download_loop_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; + +type Cmd = Command; + +/// Where an episode should play from: its downloaded file when we have one, else the +/// network. +fn source_for(episode: &Episode) -> MediaSource { + match (&episode.download_status, &episode.local_path) { + (DownloadStatus::Downloaded, Some(path)) => MediaSource::Local { + local_path: path.clone(), + }, + _ => MediaSource::Stream { + url: episode.enclosure_url.clone(), + }, + } +} + +/// 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 { + session, + result: Box::new(r), + }) +} + +/// 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; + 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 { + Command::request_from_shell(StorageOperation::UpdatePlaybackStatus { + episode_id: episode_id.to_string(), + status, + position_secs, + }) + .then_send(|r| Event::PlaybackPersisted(Box::new(r))) +} + +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(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(_)); + if !active.context_saved { + active.context_save_failures += 1; + } + } + Command::done() +} + +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( + 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 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 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 position = Some(active.position_secs); + active.last_checkpoint_secs = active.position_secs; + active.episode.playback_status = PlaybackStatus::InProgress; + active.episode.playback_position_secs = position; + let id = active.episode.id.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 { + Some(source) => write.and(save_play_context(&id, &source)), + None => write, + } +} + +/// 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 +/// 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 (or counts as played) before it's replaced. + let outgoing = leave_for_another(model); + + 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, + _ => 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!(media, MediaSource::Stream { .. }); + let session = next_session(model); + model.active_playback = Some(ActivePlayback { + episode: playing_episode, + session, + position_secs: start, + duration_secs: None, + is_playing: true, + media: media.clone(), + source: source.clone(), + loaded: true, + last_checkpoint_secs: start, + 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, + }); + + let mut cmd = outgoing + .and(persist_playback( + episode_id, + PlaybackStatus::InProgress, + Some(start), + )) + .and(save_play_context(episode_id, &source)) + .and(player_op( + session, + PlayerOperation::Load { + session, + media, + 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(auto_download(model, episode_id)); + } + cmd.and(render()) +} + +/// 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(); + }; + 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 { + active.session = session; + active.loaded = true; + player_op( + session, + PlayerOperation::Load { + session, + media: active.media.clone(), + start_secs: rewound, + autoplay: true, + }, + ) + } else { + let session = active.session; + player_op(session, PlayerOperation::Seek { secs: rewound }) + .and(player_op(session, PlayerOperation::Play)) + }; + // 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, 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(auto_download(model, &id)).and(render()), + None => 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(); + }; + // 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(); + } + active.is_playing = false; + 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. 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(); + }; + 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(); + } + 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), + 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. 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(); + }; + 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. + 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 (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; + ( + session, + PlayerOperation::Load { + session, + media: active.media.clone(), + start_secs: target, + autoplay: false, + }, + ) + } else { + (active.session, PlayerOperation::Seek { secs: target }) + }; + player_op(session, 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, 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(); + }; + 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, 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); + // 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() +} + +/// 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(); + } + complete(model) +} + +/// 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(); + } + let is_local = model + .active_playback + .as_ref() + .is_some_and(|a| matches!(a.media, MediaSource::Local { .. })); + if !(is_local && failure == Failure::MediaUnusable) { + 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 under a new session. + let fresh = next_session(model); + let Some(active) = model.active_playback.as_mut() else { + return Command::done(); + }; + let media = MediaSource::Stream { + url: active.episode.enclosure_url.clone(), + }; + let id = active.episode.id.clone(); + active.media = media.clone(); + active.session = fresh; + active.episode.download_status = DownloadStatus::NotDownloaded; + 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); + 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(auto_download(model, &id)) + }; + download + .and(player_op( + fresh, + PlayerOperation::Load { + session: fresh, + media, + start_secs: position, + autoplay: was_playing, + }, + )) + .and(render()) +} + +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, Failure::Other), + PlayerResult::MediaUnusable(message) => { + on_failure(model, session, message, Failure::MediaUnusable) + } + } +} + +/// 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 needs_swap = model.active_playback.as_ref().is_some_and(|a| { + 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 { + 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.media, MediaSource::Stream { .. }) { + active.media = MediaSource::Local { + local_path: local_path.to_string(), + }; + } + 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( + session, + PlayerOperation::Load { + session, + media: active.media.clone(), + 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. 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(); + } + let StorageResult::PlayContext { episode, source } = result else { + return Command::done(); + }; + if episode.playback_status == PlaybackStatus::Played { + return clear_play_context(); + } + let position = episode.playback_position_secs.unwrap_or(0); + model.active_playback = Some(ActivePlayback { + media: source_for(&episode), + source, + episode, + session: 0, + position_secs: position, + duration_secs: None, + is_playing: false, + 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, + context_save_failures: 0, + local_unusable: false, + error: None, + }); + 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 { + 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); + // 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(), + }; + // 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(), + feed_title: subscription.map(|s| s.title.clone()).unwrap_or_default(), + source: active.source.clone(), + source_title, + artwork_url: active + .episode + .artwork_url + .clone() + .or_else(|| subscription.and_then(|s| s.artwork_url.clone())), + position_secs: active.position_secs, + duration_secs: known_duration(active), + is_playing: active.is_playing, + 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/context_save_tests.rs b/shared/src/player/context_save_tests.rs new file mode 100644 index 0000000..73eb4ad --- /dev/null +++ b/shared/src/player/context_save_tests.rs @@ -0,0 +1,200 @@ +//! 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::*; +use crate::defaults::CONTEXT_SAVE_ATTEMPTS; + +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); +} + +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![]); + send(&mut model, saved_context(episode("e1"))); + send(&mut model, Event::TogglePlay); + + let effects = tick(&mut model, 30); + + assert!(context_saves(&effects).is_empty()); +} 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")); +} 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)); +} 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")); +} 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"); +} diff --git a/shared/src/player/played_tests.rs b/shared/src/player/played_tests.rs new file mode 100644 index 0000000..1dc1fba --- /dev/null +++ b/shared/src/player/played_tests.rs @@ -0,0 +1,627 @@ +//! 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, saved_context, 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, saved_context(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, saved_context(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 { resumable: true }); + + 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 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] + )); +} + +#[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::PlayerMediaUnusable { + 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 { resumable: true }); + 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); +} + +// --- 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()); +} + +// --- 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 new file mode 100644 index 0000000..2539720 --- /dev/null +++ b/shared/src/player/tests.rs @@ -0,0 +1,510 @@ +use crux_core::App; + +use super::*; +use crate::capabilities::download::DownloadResult; +use crate::domain::Subscription; +use crate::Pollux; + +pub(super) 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, + } +} + +pub(super) 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, + etag: None, + last_modified: None, + last_refresh_error: None, + retry_after_until: None, + }], + episodes, + ..Model::default() + } +} + +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 +} + +/// 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 { + session, + position_secs: secs, + }, + ) +} + +/// 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 { + Effect::Player(r) => Some(r.operation.clone()), + _ => None, + }) + .collect() +} + +pub(super) fn storage_ops(effects: &[Effect]) -> Vec { + effects + .iter() + .filter_map(|e| match e { + Effect::Storage(r) => Some(r.operation.clone()), + _ => None, + }) + .collect() +} + +pub(super) 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() +} + +pub(super) fn active(model: &Model) -> &ActivePlayback { + model.active_playback.as_ref().expect("an active episode") +} + +pub(super) 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 { + media: MediaSource::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 { + media: MediaSource::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, 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, 3)).is_empty()); + assert!(checkpoints(&tick(&mut model, 9)).is_empty()); + assert_eq!( + checkpoints(&tick(&mut model, 10)), + vec![(PlaybackStatus::InProgress, Some(10))] + ); + assert!(checkpoints(&tick(&mut model, 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); + + // 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!(model.active_playback.is_none()); + 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())); + let session = session(&model); + send( + &mut model, + Event::PlayerDuration { + session, + 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_ended(&mut model); + + 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, 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, 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 { + media: MediaSource::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())); + let session = session(&model); + send( + &mut model, + Event::PlayerFailed { + session, + 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 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 { .. }, + 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, saved_context(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 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"); +} + +#[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 8490d57..e727fd0 100644 --- a/shared/src/view_model.rs +++ b/shared/src/view_model.rs @@ -1,18 +1,51 @@ 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)] 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 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, + 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, + /// 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, +} + #[derive(Facet, Serialize, Deserialize, Clone, Default)] pub struct LibraryView { pub subscriptions: Vec,