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.

View File

@@ -10,12 +10,12 @@
* tab root.
*/
import { Show } from "solid-js";
import { Show, onMount, onCleanup } from "solid-js";
import { PlaybackControls } from "./PlaybackControls";
import { ProgressBar } from "./ProgressBar";
import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio";
import { useAppStore } from "@/stores/app";
import { useVisualizer } from "@/stores/visualizer";
import { useTheme } from "@/context/ThemeContext";
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
import { PaneRow } from "@/components/PaneRow";
@@ -27,8 +27,16 @@ export function PlayerPage() {
const audio = useAudio();
const { theme } = useTheme();
const nav = useNavigation();
const viz = useVisualizer();
const muted = () => theme.muted || theme.text;
// The page is mounted exactly while the Player tab is in focus (Shell
// renders only the active tab), so mount ⇔ focused. Report it to the
// visualizer store: losing focus starts the unload grace timer instead
// of killing the pipeline with the page; regaining focus restarts it.
onMount(() => viz.setFocused(true));
onCleanup(() => viz.setFocused(false));
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const progressPercent = () => {
@@ -82,18 +90,7 @@ export function PlayerPage() {
<ProgressBar />
<RealtimeWaveform
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer;
// bars is width-derived in RealtimeWaveform; pass only the
// audio-processing params here.
return {
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
<RealtimeWaveform />
</box>
)}
</Show>

View File

@@ -1,55 +1,30 @@
/**
* RealtimeWaveform — live audio frequency visualization using cavacore.
* RealtimeWaveform — renders the shared visualizer pipeline state.
*
* Spawns an independent ffmpeg
* process to decode the audio stream, feeds PCM samples through cavacore
* for FFT analysis, and renders frequency bars as colored terminal
* characters at ~30fps.
* The pipeline (ffmpeg decode + cavacore FFT) lives in the module-level
* visualizer store (`@/stores/visualizer`), not in this component, so it
* survives PlayerPage unmounts: leaving the Player tab keeps the waveform
* warm for VISUALIZER_UNLOAD_DELAY_MS, then the store tears it down.
*
* This component only subscribes to store state, reports the width-derived
* bar count (terminal resize re-inits the running pipeline), and renders:
* a braille spinner while the pipeline is loading its first frames, the
* frequency bars once frames arrive, and a dotted placeholder when idle.
*/
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
import { createEffect, on } from "solid-js";
import { useTerminalDimensions } from "@opentui/solid";
import {
loadCavaCore,
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
import { BAR_LEVELS, barChars, createBarScaler } from "@/utils/bar-mapping";
import { useAudio } from "@/hooks/useAudio";
import { useVisualizer } from "@/stores/visualizer";
import { useTheme } from "@/context/ThemeContext";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
import { PANE_RATIO } from "@/utils/navigation";
// ── Types ────────────────────────────────────────────────────────────
export type RealtimeWaveformProps = {
visualizerConfig?: Partial<CavaCoreConfig>;
};
/** Target frame interval in ms (~30 fps) */
const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512;
// ── Component ────────────────────────────────────────────────────────
export function RealtimeWaveform(props: RealtimeWaveformProps) {
export function RealtimeWaveform() {
const { theme } = useTheme();
const audio = useAudio();
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]);
// Peak-follower scaler replaces cava's autosens: normalizes each FFT
// frame against the running peak so a loud start can't pin every bar
// at full height and quiet content still gets normalized up.
const scaler = createBarScaler();
let cava: CavaCore | null = null;
let reader: AudioStreamReader | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null;
let sampleBuffer: Float64Array | null = null;
const viz = useVisualizer();
// Bar count scales with terminal width so the waveform fills its pane.
// The player is a 2-pane row: current column = (current+preview) of
@@ -68,181 +43,25 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
);
};
// ── Lifecycle: init cavacore once ──────────────────────────────────
const initCava = () => {
if (cava) return true;
cava = loadCavaCore();
if (!cava) {
return false;
}
return true;
};
// ── Smooth position clock ──────────────────────────────────────────
//
// audio.position() updates at the useAudio poll rate (~150ms). Between
// polls, interpolate the position from wall time so the FFT window
// tracks the audio continuously instead of stepping. The 0.5s cap
// prevents extrapolating far beyond reality when the player stalls
// (e.g. network re-buffering).
let lastPolledPosition = 0;
let lastPolledAt = 0;
const smoothPosition = () => {
const pos = audio.position();
const now = performance.now();
if (pos !== lastPolledPosition) {
lastPolledPosition = pos;
lastPolledAt = now;
return pos;
}
if (lastPolledAt === 0) return pos;
const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5);
return lastPolledPosition + elapsed * (audio.speed() ?? 1);
};
// ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number, speed: number) => {
stopVisualization();
if (!url || !initCava() || !cava) return;
// Initialize cavacore with current resolution + any overrides.
// bars is width-derived (see numBars); visualizerConfig supplies the
// audio-processing params (noise reduction, cutoffs, etc.).
// autosens is disabled (after the spread so it always wins): cava's
// autosens gain-ramps during silence then clips everything to 1.0
// when audio arrives — the JS peak scaler handles dynamics instead.
const config: CavaCoreConfig = {
bars: numBars(),
sampleRate: 44100,
channels: 1,
...props.visualizerConfig,
autosens: 0,
};
cava.init(config);
// Pre-warm the FFT window: libcavacore's window is malloc'd
// uninitialized, so the first real frame would FFT garbage and
// render full-scale bars. One zero frame the size of the whole
// input buffer clears it (at 44.1kHz mono the window is 8192
// samples — FFTbassbufferSize × channels; a 512-sample frame would
// leave the tail garbage).
cava.execute(new Float64Array(8192));
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
if (!reader || reader.url !== url) {
if (reader) reader.stop();
reader = new AudioStreamReader({ url });
}
reader.start(position, speed);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
const stopVisualization = () => {
if (frameTimer) {
clearInterval(frameTimer);
frameTimer = null;
}
if (reader) {
reader.stop();
// Don't null reader — we reuse it across start/stop cycles
}
if (cava?.isReady) {
cava.destroy();
}
sampleBuffer = null;
};
// ── Render loop (called at ~30fps) ─────────────────────────────────
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// Sample the FFT window at the player's position, not the decode
// head — the reader decodes independently (paced at the player's
// clock rate with a LEAD_SECONDS burst head start) and only the
// position clock ties the bars to what's actually playing.
const target = smoothPosition();
const count = reader.read(sampleBuffer, target);
// Never feed a partial FFT window to cava.
if (count < sampleBuffer.length) return;
const output = cava.execute(sampleBuffer);
// Normalize against the running peak and copy to a new array
setBarData(scaler(output));
};
createEffect(
on(
[
audio.isPlaying,
() => audio.currentEpisode()?.audioUrl ?? "",
audio.speed,
numBars,
],
([playing, url, speed]) => {
if (playing && url) {
const pos = untrack(audio.position);
startVisualization(url, pos, speed);
} else {
stopVisualization();
}
},
),
);
// ── Seek detection: lightweight effect for position jumps ──────────
//
// Watches position and restarts the reader (not the whole pipeline)
// only on significant jumps (>2s), which indicate a user seek.
// This is intentionally a separate effect — it should NOT trigger a
// full pipeline restart, just restart the ffmpeg stream at the new pos.
let lastSyncPosition = 0;
createEffect(
on(audio.position, (pos) => {
if (!audio.isPlaying || !reader?.running) {
lastSyncPosition = pos;
return;
}
const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos;
if (delta > 2) {
reader.restart(pos, audio.speed() ?? 1);
}
}),
);
onCleanup(() => {
stopVisualization();
if (reader) {
reader.stop();
reader = null;
}
// Don't null cava itself — it can be reused. But do destroy its plan.
if (cava?.isReady) {
cava.destroy();
}
});
// Keep the store's bar count in sync with the terminal width; the store
// re-inits the running pipeline when it changes (terminal resize).
createEffect(on(numBars, (n) => viz.setBarCount(n)));
// ── Rendering ──────────────────────────────────────────────────────
const renderLine = () => {
const bars = barData();
const bars = viz.barData();
const count = numBars();
// Loading state: the braille spinner shows while the pipeline warms
// up — but only when there are no bars to render yet (first play /
// after an unload). On resume/seek the last bars stay on screen
// until fresh frames arrive, so the waveform never blanks out for
// the (multi-second, network-bound) cold start.
if (bars.length === 0 && viz.isLoading()) {
return <LoadingIndicator />;
}
if (bars.length === 0) {
const placeholder = ".".repeat(count);
return (

359
src/stores/visualizer.ts Normal file
View File

@@ -0,0 +1,359 @@
/**
* visualizer-store — module-level singleton owning the realtime waveform
* pipeline (ffmpeg decode + cavacore FFT), shared across PlayerPage mounts.
*
* The pipeline lives here rather than in the Player page component so it can
* outlive the page: Shell unmounts a tab's page the moment the tab loses
* focus, which would otherwise kill the ffmpeg decode + FFT loop instantly.
* Instead the store keeps the visualization warm for UNLOAD_DELAY_MS after
* the Player tab stops being focused, then tears it down (kills ffmpeg,
* destroys the cava plan). Returning to the tab within the delay resumes
* seamlessly; after an unload, regaining focus restarts the pipeline from
* the current playback position.
*
* The store subscribes to the module-level playback signals in
* `utils/audio-signals.ts` (`audioPlaybackSignals`), so it reacts to
* play/pause/seek/speed even while no Player page is mounted. `focused` is
* fed by PlayerPage (mounted ⇔ Player tab visible), `barCount` by
* RealtimeWaveform (terminal width).
*/
import {
createSignal,
createEffect,
createRoot,
on,
untrack,
} from "solid-js";
import {
loadCavaCore,
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
import { createBarScaler } from "@/utils/bar-mapping";
import { audioPlaybackSignals } from "@/utils/audio-signals";
import { useAppStore } from "@/stores/app";
// ── Constants ────────────────────────────────────────────────────────────
/** How long the pipeline keeps running after the Player tab loses focus. */
export const VISUALIZER_UNLOAD_DELAY_MS = 30_000;
/** Target frame interval in ms (~30 fps) */
const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512;
// ── Types ────────────────────────────────────────────────────────────────
export interface VisualizerStore {
/** Frequency bar values (0.01.0 per bar), empty until the first frame. */
barData: () => number[];
/** True from pipeline start until the first complete FFT frame renders. */
isLoading: () => boolean;
/** True while the ~30fps render loop is armed. */
isRunning: () => boolean;
/** Report whether the Player tab is the visible tab. */
setFocused: (focused: boolean) => void;
/** Report the terminal-width-derived bar count (resize re-inits). */
setBarCount: (count: number) => void;
}
// ── Store factory ────────────────────────────────────────────────────────
function createVisualizerStore(): VisualizerStore {
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]);
// True from pipeline start until the first complete FFT frame renders.
const [isLoading, setIsLoading] = createSignal(false);
// Whether the Player tab is the visible tab (fed by PlayerPage).
const [focused, setFocused] = createSignal(false);
// Width-derived bar count (fed by RealtimeWaveform; default before the
// renderer reports a real size).
const [barCount, setBarCount] = createSignal(64);
// Peak-follower scaler replaces cava's autosens: normalizes each FFT
// frame against the running peak so a loud start can't pin every bar
// at full height and quiet content still gets normalized up.
const scaler = createBarScaler();
let cava: CavaCore | null = null;
let reader: AudioStreamReader | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null;
let sampleBuffer: Float64Array | null = null;
let unloadTimer: ReturnType<typeof setTimeout> | null = null;
// What the running pipeline was started with — lets the playback effect
// tell "nothing changed, stay warm" from "must restart".
let activeUrl = "";
let activeSpeed = 1;
let activeBars = 64;
// ── Lifecycle helpers ──────────────────────────────────────────────
const clearUnloadTimer = () => {
if (unloadTimer) {
clearTimeout(unloadTimer);
unloadTimer = null;
}
};
const initCava = () => {
if (cava) return true;
cava = loadCavaCore();
if (!cava) {
return false;
}
return true;
};
// ── Smooth position clock ──────────────────────────────────────────
//
// audio.position() updates at the useAudio poll rate (~150ms). Between
// polls, interpolate the position from wall time so the FFT window
// tracks the audio continuously instead of stepping. The 0.5s cap
// prevents extrapolating far beyond reality when the player stalls
// (e.g. network re-buffering).
let lastPolledPosition = 0;
let lastPolledAt = 0;
const smoothPosition = () => {
const pos = audioPlaybackSignals.position();
const now = performance.now();
if (pos !== lastPolledPosition) {
lastPolledPosition = pos;
lastPolledAt = now;
return pos;
}
if (lastPolledAt === 0) return pos;
const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5);
return lastPolledPosition + elapsed * (audioPlaybackSignals.speed() ?? 1);
};
// ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number, speed: number) => {
stopVisualization();
if (!url || !initCava() || !cava) return;
// Initialize cavacore with current resolution + the user's
// audio-processing params (noise reduction, cutoffs, etc.).
// autosens is disabled (after the spread so it always wins): cava's
// autosens gain-ramps during silence then clips everything to 1.0
// when audio arrives — the JS peak scaler handles dynamics instead.
const viz = useAppStore().state().settings.visualizer;
const config: CavaCoreConfig = {
bars: barCount(),
sampleRate: 44100,
channels: 1,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
autosens: 0,
};
cava.init(config);
// Pre-warm the FFT window: libcavacore's window is malloc'd
// uninitialized, so the first real frame would FFT garbage and
// render full-scale bars. One zero frame the size of the whole
// input buffer clears it (at 44.1kHz mono the window is 8192
// samples — FFTbassbufferSize × channels; a 512-sample frame would
// leave the tail garbage).
cava.execute(new Float64Array(8192));
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
if (!reader || reader.url !== url) {
if (reader) reader.stop();
reader = new AudioStreamReader({ url });
}
reader.start(position, speed);
// Seed the smooth position clock with the start position. Without
// this, a fresh play at position 0 would sample the window ending at
// exactly 0 — a 1-sample slice the reader can never fill — so the
// bars would be starved until the first mpv poll advanced the
// position clock. Seeding makes the interpolated target advance
// immediately, so bars render as soon as ffmpeg has any audio.
lastPolledPosition = position;
lastPolledAt = performance.now();
activeUrl = url;
activeSpeed = speed;
activeBars = barCount();
setIsLoading(true);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
const stopVisualization = () => {
clearUnloadTimer();
if (frameTimer) {
clearInterval(frameTimer);
frameTimer = null;
}
if (reader) {
reader.stop();
// Don't null reader — we reuse it across start/stop cycles
}
if (cava?.isReady) {
cava.destroy();
}
sampleBuffer = null;
setIsLoading(false);
};
// ── Render loop (called at ~30fps) ─────────────────────────────────
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// Sample the FFT window at the player's position, not the decode
// head — the reader decodes independently (paced at the player's
// clock rate with a LEAD_SECONDS burst head start) and only the
// position clock ties the bars to what's actually playing.
const target = smoothPosition();
const count = reader.read(sampleBuffer, target);
// Never feed a partial FFT window to cava.
if (count < sampleBuffer.length) return;
const output = cava.execute(sampleBuffer);
// Normalize against the running peak and copy to a new array
setBarData(scaler(output));
if (isLoading()) setIsLoading(false);
};
// ── Playback subscription ──────────────────────────────────────────
//
// Keeps the pipeline matched to playback. `focused` is a dep so focus
// regain re-evaluates (and can restart an unloaded pipeline), but the
// guard below makes a focus flip on an already-correct warm pipeline a
// no-op — no churn when flipping back to the Player tab within the
// unload delay. A real change (url/speed/barCount, stop/start, or a
// stale pipeline after an unload) restarts from the current position.
createEffect(
on(
[
audioPlaybackSignals.isPlaying,
() => audioPlaybackSignals.currentEpisode()?.audioUrl ?? "",
audioPlaybackSignals.speed,
barCount,
focused,
],
([playing, url, speed]) => {
if (!playing || !url) {
stopVisualization();
return;
}
// Warm and already correct — nothing to do (e.g. focus
// regained within the unload delay).
if (
frameTimer !== null &&
url === activeUrl &&
speed === activeSpeed &&
barCount() === activeBars
) {
return;
}
if (!focused()) return; // playing away: stay warm; unload timer decides
startVisualization(
url,
untrack(audioPlaybackSignals.position),
speed,
);
},
),
);
// ── Focus subscription: unload after the grace delay ───────────────
createEffect(
on(focused, (f) => {
clearUnloadTimer();
if (f) {
// Pipeline was unloaded (or never started) but playback is
// still going — restart from the current position. When the
// pipeline is warm the playback effect above is the one that
// acts (guard: no-op for an unchanged warm pipeline).
if (
audioPlaybackSignals.isPlaying() &&
audioPlaybackSignals.currentEpisode()?.audioUrl &&
frameTimer === null
) {
startVisualization(
audioPlaybackSignals.currentEpisode()!.audioUrl,
untrack(audioPlaybackSignals.position),
audioPlaybackSignals.speed() ?? 1,
);
}
} else if (frameTimer !== null) {
unloadTimer = setTimeout(() => {
unloadTimer = null;
stopVisualization();
}, VISUALIZER_UNLOAD_DELAY_MS);
}
}),
);
// ── Seek detection: lightweight effect for position jumps ──────────
//
// Watches position and restarts the reader (not the whole pipeline)
// only on significant jumps (>2s), which indicate a user seek.
// This is intentionally a separate effect — it should NOT trigger a
// full pipeline restart, just restart the ffmpeg stream at the new pos.
let lastSyncPosition = 0;
createEffect(
on(audioPlaybackSignals.position, (pos) => {
if (!audioPlaybackSignals.isPlaying() || !reader?.running) {
lastSyncPosition = pos;
return;
}
const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos;
if (delta > 2) {
reader.restart(pos, audioPlaybackSignals.speed() ?? 1);
}
}),
);
return {
// state
barData,
isLoading,
isRunning: () => frameTimer !== null,
// inputs
setFocused,
setBarCount,
};
}
// ── Singleton ─────────────────────────────────────────────────────────────
let visualizerStoreInstance: VisualizerStore | null = null;
/**
* Accessor for the shared visualizer store. Created once inside a
* `createRoot` so its effects are owned by a detached root — not by
* whichever component happens to call first (PlayerPage unmounts would
* otherwise dispose the pipeline effects with it).
*/
export function useVisualizer(): VisualizerStore {
if (!visualizerStoreInstance) {
visualizerStoreInstance = createRoot(() => createVisualizerStore());
}
return visualizerStoreInstance;
}

View 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;