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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user