Skip to content

Feat: Player - #23

Draft
AlexAxthelm wants to merge 33 commits into
mainfrom
feat/player
Draft

AlexAxthelm wants to merge 33 commits into
mainfrom
feat/player

Conversation

@AlexAxthelm

Copy link
Copy Markdown
Owner

No description provided.

AlexAxthelm and others added 30 commits October 6, 2026 08:37
Core (Rust):
- new Player capability; playback policy in shared/src/player.rs
- stream undownloaded episodes while downloading, then swap to the
  local file at the playhead; fall back to streaming if the file is gone
- persist position every ~10s and on pause/seek/background/end
- played once within 15s of the end; 3s rewind on resume
- skip 30/15 hard-coded in defaults.rs until Settings exists
- restore the active episode on cold start (v2_play_context table)

Shell (Swift):
- PlaybackManager (AVPlayer, audio session, interruptions, route changes)
- NowPlayingController (lock-screen card and remote commands)
- MiniPlayerBar and full-screen PlayerScreen
- wire the episode row and detail Play buttons
- enable the audio background mode

No auto-advance yet; that arrives with playlists.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- fold the play_context table into the v1_initial baseline instead of a
  v2 migration, per the schema baseline policy
- restore the saved play context in Started (a metadata-only read), and leave
  the download resume on activation
- flush playback position from Core.appEnteredBackground
- combine UIBackgroundModes into [audio, fetch]
- use the renamed NoticeBanner in the player views
A save in the last 15s (backgrounding, the 10s checkpoint) marked the
episode played and reset its position while leaving the player active
and the context saved. A relaunch then restored an already-played
episode at 0:00, and listening on with the screen locked could be cut
short.

- played is decided only on natural end, an explicit pause in the tail,
  or starting another episode from the tail; checkpoints just record
  in-progress and a position
- seeking to the very end finishes the episode (the engine only reports
  the end while playing)
- don't restore an episode that is already played; clear its context
- add Interrupted for system pauses (calls, unplugged headphones): keep
  the place and never finish, so playback can resume to the end
- tag every load with a session id and ignore engine events from any
  other session, replacing the episode-id check
- tail tolerance is capped at a quarter of the episode; an unknown
  duration never finishes by pausing

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Playback now remembers what it was started from as an EpisodeSource (the
internal abstraction from DATA_MODEL.md). Only Subscription exists today,
so playlists can later be added as a variant without a schema or event
change.

- add the EpisodeSource enum; ActivePlayback, the saved play context and
  the player view carry it instead of a subscription id
- play_context stores source_kind + source_id (in the v1_initial
  baseline); SavePlayContext takes the source and LoadPlayContext returns
  a PlayContext { episode, source } result
- the "From:" row names and navigates to the source; the episode's own
  feed stays separate (the lock-screen artist)
- rename PlayerSource (stream or local file) to MediaSource, and the
  engine's Load field to media, to avoid confusion with the new type
- document EpisodeSource and PlayContext storage in DATA_MODEL.md and
  player.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Replace the "Show notes coming soon" placeholder with the real show notes,
rendered as rich text with the same renderer as the episode detail page, in
a scrollable page.

- PlayerView carries the episode description (raw HTML) plus a plain-text
  fallback for the frame before the HTML is parsed; a blank description
  counts as none
- the page and its dots are left out when the episode has no show notes,
  and the selection snaps back to the artwork if the next episode has none
- update player.md, player-show-notes.md and the roadmap, which described
  the page as an MVP placeholder; timestamp detection is still unbuilt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- ArtworkView takes size: nil to fill the offered width as the largest
  square that fits; the player's artwork page uses it. Fixed-size callers
  are unchanged
- play/pause and the skip buttons in the player, and the mini-player's
  play/pause, use the theme accent; the secondary row (hide, output
  routing, options) stays in the text color
- note both, and the possibility of a configurable layout, in player.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t play

The core is also created when iOS wakes the app in the background to
refresh feeds, and that launch should stay metadata-only. PlaybackManager
and NowPlayingController were built in Core.init, so every such launch
registered remote-command handlers it never needed.

- create both in startPlaybackIfNeeded(), on the first player operation,
  and publish the current Now Playing state straight away
- a background-refresh launch still restores the saved episode for the
  mini-player (a DB read) but touches neither audio nor the lock screen
- a restored, paused episode now appears on the lock screen only once it
  has been played
- add wiring tests: restoring does not create the engine; the first play
  request does

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ment

- PLAN.md: mark round 2 items 1-4 done with what was built, keep 5 (lock
  screen) and 6 (swap gap) open, and record both open questions' answers;
  the original plan text is unchanged
- capabilities/player.rs: the Load doc still said `source` after the
  rename to `media`
Add a user story and notes on the gap and repeated second when playback
moves onto a freshly downloaded file, the options for fixing it (continue
from the engine's exact time; cue just ahead and hand over via
AVQueuePlayer; swap at a pause or seek), and the caveats (the engine
conversion, judging it by ear on a device, and dynamic ad insertion).
iOS reports "should resume" after any interruption, including one that
found playback already paused, so a call arriving while the listener had
paused would restart audio when it ended.

- the core records whether an interruption is what paused playback and
  resumes only then; Interrupted gains `resumable` and a new
  InterruptionEnded { should_resume } event replaces the shell's
  unconditional .play
- unplugging headphones is never resumable, so a stale flag can't be
  triggered by a later, unrelated call
- playing or scrubbing by hand during an interruption cancels the
  auto-resume
- document the rule in player.md
A failed load (unparseable URL, missing file, audio session error)
returned before touching the AVPlayer, so the previous episode kept
playing while the core showed the new one paused with an error; the old
item's ticks were dropped as stale and the controls acted on the wrong
episode.

- PlaybackManager.load unloads the current item and clears the session
  on any failure, leaving the audio session active so a fallback load
  doesn't flicker other apps' audio
- add engine tests with a generated silent WAV: a failed load leaves
  nothing loaded, a good load still works after one

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A paused load (a source swap finishing after the listener paused, or a
scrub of a restored episode) activated the non-mixable playback session,
cutting off whatever the listener had switched to.

- PlaybackManager claims the audio session only for a load that will
  autoplay, and in the Play operation (which also covers resuming after a
  paused load or an interruption)
- a refused session fails an autoplaying load (and unloads) or a play
  (keeping the item), instead of throwing it away
- make the activation injectable, with the real one as the default, and
  test when the session is and isn't claimed

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Every error on a downloaded episode was treated as a missing file, so a
transient failure (another app holding the audio session, say) reset the
download to not-downloaded and fetched the same file again, orphaning
the good copy on disk.

- add PlayerResult::MediaUnusable and Event::PlayerMediaUnusable; the
  shell reports a missing, unparseable or undecodable item with them and
  everything else (a refused audio session, a failure mid-play) as before
- the core falls back to streaming and re-downloads only for an unusable
  local file; any other failure pauses with the error and keeps the file,
  so playing again retries it
- a failed load unloads the current item whichever way it failed
- document the rule in player.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
NowPlayingController rebuilt the whole card on every render: each tick,
download progress update and refresh. Rewriting it resets the system's
clock for the card, so its scrubber jittered, and it was needless work.

- add NowPlayingPolicy: rewrite when the title, feed, artwork, duration,
  play state or skip intervals change, or when the playhead leaves the
  card's own clock by more than 2s (a seek, a skip, a stall); ordinary
  playback, paused time and error text never rewrite it
- set the skip intervals only when they change, and clear the card only
  when it has content
- when artwork arrives, republish from the latest state instead of
  patching the card in place with a stale elapsed time, which set the
  card's clock back
- test the policy, including 600 ticks of steady playback

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Everything that watched Core was re-evaluated whenever it published, so
the once-a-second position tick made SwiftUI re-evaluate the whole
episode list behind the player (about 5.4% CPU with a 375-episode list
open, against about 1.0% with ticks not rendering).

- keep the player in its own PlayerState object; Core.view no longer
  carries it (view.player is always nil)
- republish Core.view only when something other than the player changed
- the mini-player and full player observe PlayerState through small host
  views, and the root view subscribes to it for dismissal instead of
  observing it, so a tick wakes only the views that show the player
- test that ticks republish the player but not the list view, that a real
  list change still does, and that identical players aren't republished

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Record what was measured (payload, Rust and Swift cost, app CPU with the
episode list open), what the player/list split changed, and what is not
yet known: all numbers are from debug simulator builds. List the
follow-ups in order (profile a release build on a device; shell-side
clock for the position; compute previews once and ship raw HTML on
demand) and how to re-measure.
Both recipes pipe xcodebuild into xcbeautify, so the exit status was
the formatter's and a failed build or test still exited 0.

- set -o pipefail in both recipes
- run recipes under bash, since make 3.81 (macOS) has no .SHELLFLAGS to
  set it globally and not every sh supports pipefail
- verified: passing suite exits 0; a broken assertion and a compile
  error each exit 2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
PlayerFormatting.spoken allocated a DateComponentsFormatter on every
call, and the scrubber's accessibility value asks for it on every tick
and every drag step. Hold one in a static let, as EpisodeFormatting does.
Measured saving is modest (about 39 us to 35 us per call in a debug
build; the formatting itself dominates).

- test each unit appears when present, that the result is never empty,
  and that repeated calls on the shared formatter are stable

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The Application Support directory was looked up inline in
DownloadManager.init and DatabaseManager.init, with a third copy as a
static on DownloadManager for the player. They must agree, or playback
would read a different root than downloads and the database.

- add StorageRoot.applicationSupport() and use it in all three; each
  caller keeps its own failure behavior
- remove DownloadManager.defaultStorageRoot(); a neutral helper avoids
  the database depending on the download manager
- test that it is the Application Support directory and that stored
  relative paths resolve under it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Post real interruption and route-change notifications at the
PlaybackManager and check the core events it sends: interruption
began is resumable, ended carries shouldResume, unplugging
headphones is never resumable, other route changes and foreign
senders are ignored. Also check that engine news carries the
session of the load that produced it and that a load replaced
before it is ready reports nothing for the old session.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
enqueue_download only searched the episodes of the feed on screen,
which is empty after a cold start. A restored episode whose file went
missing fell back to streaming but never re-downloaded, and resuming
one whose download had failed never retried it.

enqueue_download now also finds the active episode, set_download_state
keeps the active episode's download status in step, and play() starts
or retries the download when the episode is streaming with no file
coming. A download already queued or underway is left alone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The play context a relaunch restores was written once, when playback
started, and its result was ignored. A failed write left the next
launch restoring the wrong episode or none, with nothing to retry it
(position writes recover at the next checkpoint; this did not).

The save now resolves through its own event, and the active playback
records whether storage confirmed it. While it is unconfirmed every
checkpoint re-sends it. Answers about an episode that has since been
replaced are ignored, and an episode restored from the saved context
counts as already saved.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Append a round 3 section covering the ten-point branch review and the
cross-check: what was fixed, what is partly done (render cost needs a
device; shell notification tests), and what stays open (lock-screen
controls, the swap gap, device-only checks). Correct the round 2 header
to say item 6 is deferred rather than needing a device.

Record two decisions in player.md that the plan points to: downloads
for the active episode do not depend on its feed being open, and a
failed play-context save is retried at every checkpoint.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Two cases found in review:

- An interruption pauses playback and arms an automatic resume for its end.
  A listener's pause, or headphones being unplugged, during that interruption
  returned early without disarming it, so the call ending restarted audio the
  listener had stopped (or sent it to the speaker). Both now disarm it.

- The player keeps its own copy of its episode, so a feed refresh that renewed
  or corrected the audio URL never reached it and retrying a failed stream
  loaded the old address. A reloaded episode list now updates the active
  episode's title, description, artwork, duration, date and stream URL,
  leaving position, status, download state and the loaded engine item alone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A downloaded file the engine reports unusable was replaced by a fresh
download, which was then swapped in on completion. If the fresh file was
unusable too (an error page served with a 200, a corrupt or undecodable
file), this repeated for as long as playback went on: stream, download,
swap, fail, stream, with the same large file fetched each time.

Playback now remembers that a local file was found unusable. The first
time it is replaced once, as before; if the replacement is unusable too the
file itself is bad, so its download is marked failed (with the reason beside
Retry), playback carries on from the stream, and pressing play no longer
fetches it again. A local file that loads clears the memory.

The failed-download shape is now a shared helper used by the download error
handler as well.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Playback downloads what it streams, but it did so unconditionally: after
the listener cancelled an episode's download, resuming it, starting it
again, or recovering from a lost file queued the download once more, and a
file they had just deleted came back through the missing-file fallback.

The core now remembers, for the session, which episodes' downloads were
cancelled or deleted. Every download playback starts on its own (starting
an episode, retrying on play, replacing a lost file) goes through one
helper that skips those; only an explicit Download request lifts it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The lock-screen scrub converted the system's position straight to UInt32,
which traps on NaN or a value above UInt32.max, so a misbehaving car or
Bluetooth device could crash the app. The engine's tick and duration
reports had the same conversion, guarded only against NaN and infinity.

All three now go through PlayerFormatting.wholeSeconds, which refuses
non-finite and out-of-range values and clamps negatives to zero. A bad
scrub is answered with commandFailed rather than clamped, since clamping a
huge value would seek past the end and mark the episode played.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A failed play-context save was retried at every checkpoint (about every
10s of playback, plus every pause and backgrounding) for as long as the
episode played, with no limit. A write that can never succeed, such as a
constraint failure or a persistent I/O error, would repeat indefinitely.

Failed saves are now counted per playback, and checkpoints retry only
until CONTEXT_SAVE_ATTEMPTS (5, counting the write made when playback
starts). The next episode starts afresh and a success still stops the
retries.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
AlexAxthelm and others added 3 commits October 7, 2026 10:50
PlayerScreen's header called the second page a show-notes placeholder,
but it renders the episode's real show notes. NowPlayingController's
header said the Now Playing card is rebuilt on every render, but it is
rewritten only when out of date (NowPlayingPolicy), which is the point
of that policy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Add seams to PlaybackManager (the loaded item, the pending-seek count,
and reportTick taking an explicit playing flag) and a suite that checks
what the engine tells the core: ticks are held back while a seek is
pending, when not playing, with nothing loaded, and for positions that
are not times; the end of the file and failures carry their session and
message; a file the engine cannot open is reported unusable.

The tests found that unloading an item left its end and failure
observers registered, so a removed item could still report under a
session the core might consider current. Unloading now removes them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Create the audio engine and the now-playing controller independently, so
a missing engine can't cause the controller (and its remote-command
handlers) to be built again on every later request.

Replace a vacuous assertion in an unload test with an explicit check that
no end event is reported for the unloaded item's session.

Bring PLAN.md's review section up to date with the later fixes, the engine
tests, and the two limits still open.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant