Files
PodTui/src/pages/Player/PlayerPage.tsx
Michael Freno 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

132 lines
4.2 KiB
TypeScript

/**
* PlayerPage — 2-pane yazi depth view of the now-playing episode.
*
* depth 0 (parent) — tab list (muted, read-only).
* depth 0 (current) — the single now-playing pane (rich view + controls).
*
* No preview pane (PaneRow `panes={2}`). Audio transport (play/pause,
* next/prev, seek) is handled globally by the Shell router (P/N/B/</>); this
* page only renders the now-playing surface. `h` at depth 0 returns to the
* tab root.
*/
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 { useVisualizer } from "@/stores/visualizer";
import { useTheme } from "@/context/ThemeContext";
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
export const PlayerPaneCount = 1;
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 = () => {
const d = audio.duration();
if (d <= 0) return 0;
return Math.min(100, Math.round((audio.position() / d) * 100));
};
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
};
// ── parent pane: the tab list (muted) ──────────────────────────────────────
const parentContent = () => <TabListPane muted />;
// ── current pane: now playing ───────────────────────────────────────────────
const currentContent = () => (
<box flexDirection="column" gap={1} padding={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<strong>Now Playing</strong>
</text>
<text fg={muted()}>
{formatTime(audio.position())} / {formatTime(audio.duration())} (
{progressPercent()}%)
</text>
</box>
<Show when={audio.error()}>
{(err) => <text fg={theme.error}>{err()}</text>}
</Show>
<Show
when={audio.currentEpisode()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode loaded.</text>
</box>
}
>
{(ep) => (
<box flexDirection="column" gap={1}>
<text fg={theme.text}>
<strong>{ep().title}</strong>
</text>
<text fg={muted()}>
{ep().description?.slice(0, 500) ?? "No description available."}
</text>
<ProgressBar />
<RealtimeWaveform />
</box>
)}
</Show>
<PlaybackControls
isPlaying={audio.isPlaying()}
volume={audio.volume()}
speed={audio.speed()}
backendName={audio.backendName()}
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
onToggle={audio.togglePlayback}
onPrev={() => audio.seek(0)}
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)}
onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)}
/>
<box height={1} />
{/* content prop (not a text child): the babel-preset-solid JSX
* transform HTML-escapes static string children (`<` → `&lt;`),
* which opentui renders verbatim; content bypasses that. */}
<text
fg={muted()}
content={"P play/pause N next B prev < > seek h back"}
/>
</box>
);
return (
<PaneRow
parent={parentContent}
current={currentContent}
currentLabel="Player"
panes={2}
focused={isActive}
/>
);
}