Persisted feeds keep only episodes from the last 30 days (plus completed downloads); older episodes live in volatile memory and survive refreshes via union merge, with per-feed in-memory caches capped at 500. Refresh batches run at FETCH_CONCURRENCY=4 with per-feed incremental apply (no Promise.all barrier), config.json writes are trailing-edge debounced (250ms, immediate flushPendingSave for unsubscribes), and cold fetch-more refetches abort at FETCH_TIMEOUT_MS. A shared activity store powers a global top-right indicator covering refresh, fetch-more, subscribe, search, and downloads. Also includes the in-flight incremental RSS parsing (chunked with event-loop yields) and refresh spinner work this tree already carried.
140 lines
4.5 KiB
TypeScript
140 lines
4.5 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 { useAppStore } from "@/stores/app";
|
|
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 app = useAppStore();
|
|
const muted = () => theme.muted || theme.text;
|
|
// Settings master switch: off hides the waveform entirely (the store
|
|
// also stops the decode+FFT pipeline, see stores/visualizer.ts).
|
|
const vizEnabled = () => app.state().settings.visualizer.enabled;
|
|
|
|
// 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 />
|
|
|
|
<Show when={vizEnabled()}>
|
|
<RealtimeWaveform />
|
|
</Show>
|
|
</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 (`<` → `<`),
|
|
* 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}
|
|
currentBorder={["left"]}
|
|
/>
|
|
);
|
|
}
|