4 Commits

Author SHA1 Message Date
22059c24ca fix(visualizer): show loading spinner on resume until fresh bars arrive
Resume re-arms a pipeline whose ffmpeg pass was killed at pause, so the
pre-pause bars are stale until fresh frames flow. Three changes:

- resumeVisualization always sets the loading state (previously only for
  positions outside decoded coverage) and records the resume point;
  renderFrame clears it only once the position clock advances past that
  point — a player still re-buffering after a long pause keeps the
  spinner instead of serving static cached bars.
- stopVisualization clears barData so cold restarts (unload, disable,
  episode change) show the spinner rather than stale bars, and never
  suppress it.
- renderFrame detects a frozen position clock while playing (STALL_DETECT_MS)
  and surfaces it as a loading state; recovery clears it.

Tests: resume-into-undecoded-audio shows loading until bars land; frozen
position clock surfaces a stall and recovery clears it; disable/enable
pins barData cleared on stop and the restart loading flash.
2026-08-13 21:04:12 -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
8b7b38276e feat(player): toggleable waveform visualizer in settings, default on 2026-08-11 14:15:48 -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