When a track reaches natural EOF (player alive, no stream error), play the
next episode from the source that started it — search results, show, or
Feed — and stop at the end of the list. A crashed/killed daemon or failed
stream never auto-advances.
- add audio-queue.ts: pure next/prev selection from the navigation source
- audio-player: expose getPlaybackError() to distinguish EOF from failure
- useAudio: finalizeTrackEnd(autoAdvance) wiring, re-selecting the current
episode no longer reloads from stale saved progress
- tests: audio-queue units, auto-advance integration (real mpv + local
WAVs over HTTP), backend re-select no-reload test
The visualizer's PCM cache decoded the entire episode into RAM (22050 Hz
mono s16 ~160 MB/hr of audio) and held it until stop() — a 3-hour episode
pinned ~500 MB and long-form content hit 2.5 GB. The 4x decode also pulled
the whole remote file even when only minutes were listened to.
- audio-pcm-cache: sliding window around the playback position — the
decode head caps at maxAheadSec (600s) ahead of the cursor, segments
older than keepBehindSec (300s) are pruned, and the tail refills as
playback advances. Steady state ~40 MB regardless of episode length;
a backward seek past the window restarts a segment there (the existing
seek-hole mechanism, no new failure mode).
- feed: cap the full-parse episode cache at 1000 episodes/feed so
archive-heavy subscriptions can't pin their entire history in RAM;
the visible list stays bounded by the user's cache preference and
fetch-more keeps working within the ceiling.
- tests: pin the new head-cap and prune contracts (8/8 in
audio-pcm-cache.test.ts; full suite 193 pass).
Also includes the in-flight cleanup/refactor pass (cover-art resolve
helper, page and comment tightening, ESLint config removal).
The card put the podcast name in the title slot ('Podcast — Episode'
prefix truncating on long names) with an empty artist slot for local
playback — the downloaded files carry no artist tag. Now:
- mediaTitle is the episode title only (UI + CLI); streams keep their
artist from stream tags, matching Apple Podcasts' title/artist layout.
- downloads are tagged at completion (ffmpeg -c copy, atomic rename):
title=episode, artist=podcast — verified on a real 72MB file in 1.5s.
The Fifth Column (and any feed added by URL) had NO coverUrl — the RSS
parser never captured channel artwork, so Now Playing had nothing to show.
- rss-parser: parseChannelCoverUrl (<itunes:image href> / RSS2 <image><url>);
parseRSSFeed sets it on the Podcast.
- feed store: fetchEpisodes returns the channel cover; subscribe + both
refresh paths backfill coverUrl when missing (no second fetch).
- useAudio + CLI --play: cover resolves feed.podcast.coverUrl ?? episode.imageUrl,
so episodes without channel art still get their own image.
- useAudio play/load/switchBackend: prefer the downloaded file
(getDownloadedFilePath) over the stream URL — downloaded episodes now
play from disk.
Verified: Fifth Column episode, cold cache -> cover-art-files set at load
-> albumart track present.
Prefetch alone can't cover every first play (any surface, feeds outside
the FeedPage focus window, quick plays) — the live session showed
cover-art-files empty with zero albumart tracks on a cold-cache play.
play() now serves the disk cache synchronously and, on a miss, awaits the
single-flight fetch with a 1.2s cap (covers fetch in ~300ms typically);
past the cap it plays bare and warms the cache. Verified: cold-cache play
-> cover present at load -> albumart track.
Two gaps left cover art missing for the most common play paths:
- Boot-restore preloaded the episode with cachedCoverPath??undefined racing
the fire-and-forget prefetch; a cold preload + fast-path play re-applied
art via video-add, which produces a NON-albumart track (verified) that
mpv's Now Playing artwork logic ignores. load() now awaits the bounded
fetch (covers ~300ms, 8s cap) so the cover is present at load-time.
- Removed the dead video-add re-apply (fast path + addCoverArt method +
interface) and the play() late-add fallback; a cold-cache play now
prefetches for next time instead.
- FeedPage prefetches covers for the focus window (single-flight, cached)
so ordinary plays land on a warm cache.
Verified: preload with awaited cover -> albumart track present (2 tracks).
Two fragility points, rebuilt at the root:
Playback: one resident mpv daemon (--idle --keep-open) with a persistent
IPC connection and observe_property state instead of spawn-per-episode and
connect-per-poll. Play/pause/seek are sub-ms commands; time-pos pushes at
~20Hz; external pauses arrive as events. Boot session restore preloads the
episode paused (loadfile + paused time-pos seek, since mpv defers --start
stream work until playback) so first Play is a ~400ms unpause instead of a
cold 4.3s open+seek. Load ops are mutex-serialized so a raced preload
cannot clobber an in-flight play.
Data throttling: mpv demuxer cache capped (cache-secs=90, max-bytes=40MiB)
so a paused preload no longer races to its 150MiB default (measured
45.7MB/12s); decoder paced at 4x realtime instead of 84x so playback start
isn't starved by the visualizer ripping the whole episode.
Visualization: replaced the paced-ring reader (AudioStreamReader) with a
position-indexed PCM cache (audio-pcm-cache). ffmpeg fills a cache indexed
by absolute playback time; reads at the player position are always exact.
Pause freezes the render loop, resume re-arms it — no coverage guessing,
no clamped-buffer freeze (the pause->broken-waveform->freeze bug). Seeks
and speed changes need no pipeline restarts; uncovered reads return empty
and the last frame holds.
Cover art: persistent per-URL disk cache under XDG cache dir; play() no
longer awaits a curl subprocess (up to 8s). Cache hit = one stat; misses
apply late via mpv video-add.
Test suite: 161 pass. New tests pin the position-index contract (sample-
exact window reads, hold-on-uncovered, pause-keeps-cache, seek segments),
the daemon contract (play/pause/resume/seek/stop, preload fast path,
EOF->replay), and cover cache/single-flight/404.
The waveform's ffmpeg decode + cavacore FFT pipeline lived inside
RealtimeWaveform, so switching away from the Player tab unmounted it and
killed the pipeline instantly — respawning ffmpeg on every return.
Move the pipeline into a module-level store (stores/visualizer.ts) that
outlives the page:
- losing Player-tab focus keeps the pipeline warm for
VISUALIZER_UNLOAD_DELAY_MS (30s), then tears it down (kills ffmpeg,
destroys the cava plan); regaining focus within the delay resumes the
warm pipeline with no restart churn; after an unload it restarts from
the current playback position.
- playback signals move to utils/audio-signals.ts (module-level, no
useAudio() owner needed) so the store reacts to play/pause/seek/speed
while no Player page is mounted; useAudio re-imports them.
- render a braille spinner as the loading state for the visualizer
(first play / after unload); stale bars stay on screen during warmup
restarts so the waveform never blanks out for the network-bound cold
start.
- seed the smooth position clock at pipeline start: with the position
still frozen at 0 while mpv opens the stream, the reader sampled a
1-sample window that could never fill, starving the bars until mpv's
first poll.
Pins the store contract in tests/visualizer-store.test.ts: loading→bars,
warm resume without restart, 30s unload, and bars while position is
frozen at 0.
mpv can pause or resume OUTSIDE PodTUI — system sleep/lock, AirPod
removal, device swap, OS media keys, the Now Playing center. The poll
previously only reflected commands PodTUI sent, so the UI stayed stuck
on "playing" (or "paused") with a frozen position clock.
The poll now reads mpv's live pause state each tick: an external pause
reconciles the UI to paused (persisting progress, syncing media
controls) while keeping the poll armed; an external resume brings the
UI back to playing. A paused player is polled at a throttled rate
(PAUSE_WATCH_TICKS) so the watch costs ~1 IPC read per second instead
of hammering mpv; a dead process (track end / crash) finalizes the
track.
Store the playback volume in app settings (config.json) whenever it
changes and re-apply the previous session's level at boot, instead of
always starting at the old 70% fallback.
- AppSettings gains volume (default 1 = 100%); both default-settings
copies and the volume signal default are raised from 0.7 to 1.
- doSetVolume persists via the app store (mirrors playbackSpeed).
- The boot sync awaits the app store's async config load (new
whenReady()) so a persisted level is applied even when settings load
finishes after useAudio mounts.
- tests/volume-persistence.test.ts: default, clamp, and cross-session
reuse (fresh module instance simulates the next launch).
Reload the episode that was loaded in the player when the previous run
ended (persisted on play/load and synchronously at exit) into the Player
tab paused at its saved position — never autostarted. Episodes at or
above 98% completion are skipped, as are empty-player and unsubscribed
episodes.
- useAudio gains load(episode) (sets currentEpisode/position/Now Playing
without starting the backend) and restoreLastSession(), triggered once
at boot and serialized through a chain so a late-finishing boot restore
can't clobber later state.
- togglePlayback branches on a startedPlayback flag: a restored episode
starts the backend from the saved position; a paused one resumes.
- stop() clears the marker; the exit teardown writes it synchronously
(process.exit bypasses async writes).
- feed/progress stores expose whenReady() so restore waits for the async
boot loads; feed store gains findEpisode().
- app-persistence serializes last-player marker writes and exposes
waitForLastPlayerWrite() for deterministic tests.
- tests/restore-session.test.ts: real modules + local RSS feed server;
the real useAudio is imported via a ?restore-test query suffix to
bypass the suite's mock.module leak across shared bun workers.
Adds a Selection Marker toggle under Settings → Preferences that
controls the ❯ cursor glyph on the focused row of every list.
Default off; rows keep a leading space for alignment either way.
Every list pane (tab strip, feed, my shows, discover, search,
settings, whitelist editor) reads the glyph through the shared
useSelectionMarker hook.
Also aligns the lead column across panes: page list rows drop
their extra box paddingLeft so labels start at the same column
as the tab strip (2 cells without nerd icons, 4 with), keeping
episode sub-rows aligned to the title.
The cover was previously only wired through the UI hook; --play skipped it.
Also: Bun's fetch hangs in compiled binaries (Bun 1.3.8) — every cover
download silently failed in shipped builds, timing out against any host.
Cover staging now shells out to curl (present on macOS/Linux, 8s bound),
shared via src/utils/cover-art.ts so the UI and CLI paths stage the same
temp file and pass it to mpv as --cover-art-files (albumart track).
- mpv: --force-media-title '<podcast> — <episode>' so macOS Now Playing shows
the podcast name instead of the download-hash filename (mediaTitle PlayOption)
- media registry: artist is now the human podcast title (customName ||
podcast.title), falling back to podcastId
- downloads: write the podcast cover as a <base>.jpg sibling so mpv's
cover-art-auto=exact picks it up for artwork; delete it with the download