Commit Graph

23 Commits

Author SHA1 Message Date
9ddfd21685 feat(audio): auto-advance to next episode in source queue on track end
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
2026-08-17 20:50:05 -04:00
badbc6a037 fix(audio): recover playback when the mpv daemon dies or restarts 2026-08-13 17:46:30 -04:00
d7aec4e810 fix(memory): bound visualizer PCM cache and feed episode cache
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).
2026-08-12 21:02:19 -04:00
e7ed89056e fix(now-playing): episode as title, podcast as artist on both paths
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.
2026-08-12 11:10:07 -04:00
13664c3cec fix(cover+downloads): channel art parsing, episode image fallback, local playback
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.
2026-08-12 10:52:54 -04:00
ed75c2fff7 fix(cover): await bounded cover fetch on cold-cache play
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.
2026-08-12 10:44:20 -04:00
bd7d988741 fix(cover): art reaches Now Playing on first play (restore + cold cache)
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).
2026-08-12 10:22:49 -04:00
20336ea716 feat(audio): rebuild playback + visualization on resident daemon and PCM cache
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.
2026-08-11 19:54:05 -04:00
8496922aaf feat(player): waveform pipeline survives tab switches — 30s unload grace + braille spinner loading
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.
2026-08-11 14:07:58 -04:00
5e3ad48a2d feat(audio): reconcile externally-initiated pause/resume via live mpv pause state
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.
2026-08-11 14:07:43 -04:00
8049d02457 feat(player): persist volume across sessions, default to 100%
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).
2026-08-11 13:30:01 -04:00
1b55b7117c feat(player): restore the last player session at boot
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.
2026-08-11 13:18:00 -04:00
de6d0ccbf6 feat(audio): podcast cover art in system Now Playing, CLI --play included
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).
2026-08-10 18:18:58 -04:00
5dce21c038 feat(media): expose podcast name + cover art to system Now Playing
- 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
2026-08-10 16:50:08 -04:00
19eae4fd5a feat: sync waveform to live player position (mpv time-pos, position-window reads, smooth clock) 2026-08-10 15:51:02 -04:00
30b7ff57d3 refactor: seek via keybind router (< / >), free arrows for navigation 2026-08-10 15:50:51 -04:00
2abdbaa4e9 cleaning up code 2026-08-09 09:54:52 -04:00
91a831c5f9 audio playback fixes 2026-08-07 19:28:57 -04:00
85cb9fba26 ui cleanup 2026-08-07 13:38:32 -04:00
8e0f90f449 nonworking keybinds 2026-02-13 17:25:32 -05:00
0e4f47323f mulitmedia pass, downloads 2026-02-06 00:00:15 -05:00
6b00871c32 working playback 2026-02-05 21:18:44 -05:00
f3344fbed2 start player 2026-02-05 18:42:56 -05:00