Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d4947f9
Add PLAN
AlexAxthelm Oct 6, 2026
b4e7be3
feat: add audio player, mini-player and lock-screen controls
AlexAxthelm Oct 6, 2026
1dcd9fd
Merge main into feat/player
AlexAxthelm Oct 6, 2026
acd5eb9
Update PLAN
AlexAxthelm Oct 6, 2026
9182298
decide "played" when the listener leaves, not on every save
AlexAxthelm Oct 6, 2026
a875f4a
feat: carry an EpisodeSource through playback instead of a bare sub id
AlexAxthelm Oct 6, 2026
85c77fd
feat: show the episode's show notes on the player's second page
AlexAxthelm Oct 6, 2026
4df2db5
feat: fill the player width with artwork and accent the primary controls
AlexAxthelm Oct 6, 2026
a5333af
refactor: create the audio engine and lock-screen integration on firs…
AlexAxthelm Oct 7, 2026
55af588
docs: mark the player follow-ups done in PLAN.md; fix a stale doc com…
AlexAxthelm Oct 7, 2026
ac173f8
docs: record the audible stream-to-local swap as a known limitation
AlexAxthelm Oct 7, 2026
34cac32
fix: don't resume paused playback when an audio interruption ends
AlexAxthelm Oct 7, 2026
fe42248
unload the current item when a load fails
AlexAxthelm Oct 7, 2026
44b8ce7
fix: don't take audio focus for a load that won't play
AlexAxthelm Oct 7, 2026
08da3c0
fix: only discard a downloaded file when the engine says it is unusable
AlexAxthelm Oct 7, 2026
17ea9e8
perf: rewrite the lock-screen card only when it is out of date
AlexAxthelm Oct 7, 2026
a0301ef
perf: don't re-evaluate the episode list on every playback tick
AlexAxthelm Oct 7, 2026
a62cd9b
docs: note the per-tick render cost and how to follow up on it
AlexAxthelm Oct 7, 2026
f7b8456
fix: make ios-test and ios-xcodebuild fail when xcodebuild fails
AlexAxthelm Oct 7, 2026
34e2dd0
perf: build the spoken-time formatter once
AlexAxthelm Oct 7, 2026
a298588
refactor: look up the storage root in one place
AlexAxthelm Oct 7, 2026
db0abcc
test: cover the shell's audio-session notification mapping
AlexAxthelm Oct 7, 2026
dc1ba83
let playback download its episode without its feed open
AlexAxthelm Oct 7, 2026
29955d3
retry a failed play-context save
AlexAxthelm Oct 7, 2026
b6010c7
docs: refresh PLAN.md with the branch review and record its decisions
AlexAxthelm Oct 7, 2026
af0e18a
don't resume after a pause during an interruption, refresh active URL
AlexAxthelm Oct 7, 2026
0e2bcb8
stop re-downloading a file the engine can't play
AlexAxthelm Oct 7, 2026
1bb0f46
don't re-download what the listener cancelled or deleted
AlexAxthelm Oct 7, 2026
c86c904
fix: don't crash on a bad position from the lock screen or engine
AlexAxthelm Oct 7, 2026
3d14c5e
stop retrying a play-context save that can't succeed
AlexAxthelm Oct 7, 2026
b516804
docs: fix two comments that described behavior since changed
AlexAxthelm Oct 7, 2026
3103f8c
test: cover the engine's tick gate, end and failure notifications
AlexAxthelm Oct 7, 2026
204ab0b
chore: harden playback start-up, tighten a test, refresh the plan
AlexAxthelm Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) \
Expand Down Expand Up @@ -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 \
Expand Down
101 changes: 101 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -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 `<repo>/.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<PlayContext{subscription_id, episode_id, position, duration, is_playing, source: Stream|Local}>`.
- 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<PlayerView>` (episode/feed title, artwork url, position, duration, playing, source label "From: <subscription>", 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.
12 changes: 12 additions & 0 deletions docs/DATA_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

---
Expand Down
9 changes: 7 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions docs/features/player-show-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Loading
Loading