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.
This commit is contained in:
2026-08-11 14:07:58 -04:00
parent 5e3ad48a2d
commit 8496922aaf
6 changed files with 687 additions and 236 deletions

View File

@@ -12,7 +12,7 @@
* ```
*/
import { createSignal, onCleanup } from "solid-js";
import { onCleanup } from "solid-js";
import { unlinkSync } from "fs";
import { fetchCoverArt, coverTempPath } from "../utils/cover-art";
import {
@@ -22,6 +22,26 @@ import {
type BackendName,
type DetectedPlayer,
} from "../utils/audio-player";
import {
isPlaying,
setIsPlaying,
position,
setPosition,
duration,
setDuration,
volume,
setVolume,
speed,
setSpeed,
backendName,
setBackendName,
error,
setError,
currentEpisode,
setCurrentEpisode,
availablePlayers,
setAvailablePlayers,
} from "../utils/audio-signals";
import { emit, on } from "../utils/event-bus";
import { useAppStore } from "../stores/app";
import { useProgressStore } from "../stores/progress";
@@ -71,17 +91,9 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
let refCount = 0;
let pollCount = 0; // Counts poll ticks for throttling progress saves
const [isPlaying, setIsPlaying] = createSignal(false);
const [position, setPosition] = createSignal(0);
const [duration, setDuration] = createSignal(0);
const [volume, setVolume] = createSignal(1);
const [speed, setSpeed] = createSignal(1);
const [backendName, setBackendName] = createSignal<BackendName>("none");
const [error, setError] = createSignal<string | null>(null);
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
[],
);
// Playback signals are declared in utils/audio-signals.ts (imported above)
// so non-component consumers (the visualizer store) can subscribe without
// mounting a useAudio() owner.
/** True once the current episode has been handed to the backend (play
* started). `false` means the episode is only LOADED in the player (e.g.