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:
42
src/utils/audio-signals.ts
Normal file
42
src/utils/audio-signals.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* audio-signals — module-level playback state shared by useAudio and
|
||||
* non-component consumers.
|
||||
*
|
||||
* useAudio's playback state is a module-level singleton (signals live at
|
||||
* module scope, every `useAudio()` call shares them). Those signals are
|
||||
* declared here so components that must react to playback WITHOUT mounting
|
||||
* a `useAudio()` owner — the visualizer store — can subscribe directly via
|
||||
* `audioPlaybackSignals` (or the individual accessors/setters), instead of
|
||||
* going through the hook. `useAudio()` re-exports nothing from this module
|
||||
* for callers; it imports the accessors and setters for its own use.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import type { Episode } from "../types/episode";
|
||||
import type { BackendName, DetectedPlayer } from "./audio-player";
|
||||
|
||||
export const [isPlaying, setIsPlaying] = createSignal(false);
|
||||
export const [position, setPosition] = createSignal(0);
|
||||
export const [duration, setDuration] = createSignal(0);
|
||||
export const [volume, setVolume] = createSignal(1);
|
||||
export const [speed, setSpeed] = createSignal(1);
|
||||
export const [backendName, setBackendName] = createSignal<BackendName>("none");
|
||||
export const [error, setError] = createSignal<string | null>(null);
|
||||
export const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(
|
||||
null,
|
||||
);
|
||||
export const [availablePlayers, setAvailablePlayers] = createSignal<
|
||||
DetectedPlayer[]
|
||||
>([]);
|
||||
|
||||
/**
|
||||
* The playback signals the visualizer pipeline reacts to. `useAudio()`
|
||||
* itself remains the component-facing surface; this is for module-level
|
||||
* consumers that must track playback without a component owner.
|
||||
*/
|
||||
export const audioPlaybackSignals = {
|
||||
isPlaying,
|
||||
position,
|
||||
speed,
|
||||
currentEpisode,
|
||||
} as const;
|
||||
Reference in New Issue
Block a user