The visualizer's PCM cache decoded the entire episode into RAM (22050 Hz mono s16 ~160 MB/hr of audio) and held it until stop() — a 3-hour episode pinned ~500 MB and long-form content hit 2.5 GB. The 4x decode also pulled the whole remote file even when only minutes were listened to. - audio-pcm-cache: sliding window around the playback position — the decode head caps at maxAheadSec (600s) ahead of the cursor, segments older than keepBehindSec (300s) are pruned, and the tail refills as playback advances. Steady state ~40 MB regardless of episode length; a backward seek past the window restarts a segment there (the existing seek-hole mechanism, no new failure mode). - feed: cap the full-parse episode cache at 1000 episodes/feed so archive-heavy subscriptions can't pin their entire history in RAM; the visible list stays bounded by the user's cache preference and fetch-more keeps working within the ceiling. - tests: pin the new head-cap and prune contracts (8/8 in audio-pcm-cache.test.ts; full suite 193 pass). Also includes the in-flight cleanup/refactor pass (cover-art resolve helper, page and comment tightening, ESLint config removal).
528 lines
16 KiB
TypeScript
528 lines
16 KiB
TypeScript
/**
|
|
* Shell — yazi-style application chrome.
|
|
*
|
|
* Renders the active page (which owns its own three-column parent | current |
|
|
* preview panes) full-width, with a bottom status/command bar that also
|
|
* carries the tab strip. A single `useKeyboard` router translates keystrokes
|
|
* (via the sequence-aware keybind matcher) into actions: the unified router
|
|
* in `@/utils/dispatch` handles tabs (digits `1`-`6`, `[`/`]`), h/l depth
|
|
* drill/pop + fixed-pane swipe, modes, audio, quit, help, and command; the
|
|
* pane/list ones are dispatched to the active page over the `nav.action`
|
|
* event bus. There is no sidebar pane.
|
|
*/
|
|
|
|
import { createEffect, createSignal, onCleanup, Show, For } from "solid-js";
|
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid";
|
|
import { useTheme } from "@/context/ThemeContext";
|
|
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
|
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
|
import { useAudio } from "@/hooks/useAudio";
|
|
import { useAudioNavStore } from "@/stores/audio-nav";
|
|
import { useFeedStore } from "@/stores/feed";
|
|
import { useAppStore } from "@/stores/app";
|
|
import { useToast } from "@/ui/toast";
|
|
import { emit, on } from "@/utils/event-bus";
|
|
import { LayerGraph } from "@/utils/layer-graph";
|
|
import { TABS } from "@/utils/navigation";
|
|
import { createDispatcher } from "@/utils/dispatch";
|
|
import { TabListPane } from "@/components/TabPanel";
|
|
import { PaneRow } from "@/components/PaneRow";
|
|
import { GlobalActivityIndicator } from "@/components/GlobalActivityIndicator";
|
|
|
|
export function Shell() {
|
|
const theme = useTheme();
|
|
const t = theme.theme;
|
|
const nav = useNavigation();
|
|
const k = useKeybinds();
|
|
const audio = useAudio();
|
|
const renderer = useRenderer();
|
|
const audioNav = useAudioNavStore();
|
|
const toast = useToast();
|
|
const feedStore = useFeedStore();
|
|
|
|
const [showHelp, setShowHelp] = createSignal(false);
|
|
|
|
// ── Auto jump to Player on podcast start ───────────────────────────────────
|
|
// Honor the `autoJumpToPlayer` preference: when a NEW episode starts (see
|
|
// "player.started" — distinct from "player.play", which also fires on
|
|
// resume), switch to the Player tab and drop into its content pane.
|
|
on("player.started", () => {
|
|
const app = useAppStore();
|
|
if (app.state().preferences.autoJumpToPlayer) {
|
|
nav.setActiveTab(TABS.PLAYER);
|
|
nav.enterTabContent(); // PLAYER is a depth-tab — enter its content.
|
|
}
|
|
});
|
|
|
|
/** Play the episode adjacent (offset ±1) to the currently-playing one,
|
|
* within its feed's episode list. Updates audio-nav context accordingly. */
|
|
function advanceEpisode(offset: number) {
|
|
const cur = audio.currentEpisode();
|
|
if (!cur) {
|
|
toast.show({ message: "Nothing playing", variant: "warning" });
|
|
return;
|
|
}
|
|
const pid = audioNav.getPodcastId();
|
|
const feeds = feedStore.getFilteredFeeds();
|
|
const feed =
|
|
feeds.find((f) => f.podcast.id === pid) ??
|
|
feeds.find((f) => f.episodes.some((e) => e.id === cur.id));
|
|
if (!feed) {
|
|
toast.show({ message: "Show not found", variant: "warning" });
|
|
return;
|
|
}
|
|
const eps = [...feed.episodes].sort(
|
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
|
);
|
|
const idx = eps.findIndex((e) => e.id === cur.id);
|
|
const next = eps[idx + offset];
|
|
if (!next) {
|
|
toast.show({
|
|
message: offset > 0 ? "No next episode" : "No previous episode",
|
|
variant: "warning",
|
|
});
|
|
return;
|
|
}
|
|
audio.play(next).catch(() => {});
|
|
audioNav.next(eps.length - 1 - (idx + offset) >= 0 ? idx + offset : idx);
|
|
toast.show({ message: `♪ ${next.title}`.slice(0, 60), variant: "info" });
|
|
}
|
|
|
|
// ── Command bar dispatch ────────────────────────────────────────────────────
|
|
const COMMANDS: Record<string, (arg: string) => void> = {
|
|
quit: () => process.exit(0),
|
|
exit: () => process.exit(0),
|
|
q: () => process.exit(0),
|
|
refresh: () =>
|
|
emit("nav.action", {
|
|
action: "refresh",
|
|
tab: nav.activeTab(),
|
|
pane: nav.activePane(),
|
|
mode: nav.mode(),
|
|
}),
|
|
r: () =>
|
|
emit("nav.action", {
|
|
action: "refresh",
|
|
tab: nav.activeTab(),
|
|
pane: nav.activePane(),
|
|
mode: nav.mode(),
|
|
}),
|
|
play: () => audio.togglePlayback().catch(() => {}),
|
|
pause: () => audio.togglePlayback().catch(() => {}),
|
|
p: () => audio.togglePlayback().catch(() => {}),
|
|
next: () => advanceEpisode(1),
|
|
n: () => advanceEpisode(1),
|
|
prev: () => advanceEpisode(-1),
|
|
seek: (arg) => {
|
|
const n = Number(arg) || 0;
|
|
audio.seek(n).catch(() => {});
|
|
},
|
|
feed: () => nav.setActiveTab(TABS.FEED),
|
|
f: () => nav.setActiveTab(TABS.FEED),
|
|
shows: () => nav.setActiveTab(TABS.MYSHOWS),
|
|
myshows: () => nav.setActiveTab(TABS.MYSHOWS),
|
|
discover: () => nav.setActiveTab(TABS.DISCOVER),
|
|
d: () => nav.setActiveTab(TABS.DISCOVER),
|
|
search: () => nav.setActiveTab(TABS.SEARCH),
|
|
player: () => nav.setActiveTab(TABS.PLAYER),
|
|
settings: () => nav.setActiveTab(TABS.SETTINGS),
|
|
set: () => nav.setActiveTab(TABS.SETTINGS),
|
|
help: () => setShowHelp((v) => !v),
|
|
h: () => setShowHelp((v) => !v),
|
|
};
|
|
|
|
function runCommand(raw: string) {
|
|
const cmd = raw.trim();
|
|
if (!cmd) return;
|
|
const name = cmd.split(/\s+/)[0].toLowerCase();
|
|
const arg = cmd.slice(name.length).trim();
|
|
const unknownCommand = () => {
|
|
nav.setCommandError(`unknown command: ${name}`);
|
|
// re-enter command mode so the user sees the error + can correct
|
|
nav.enterCommand();
|
|
nav.setCommandBuffer(cmd);
|
|
};
|
|
(COMMANDS[name] ?? unknownCommand)(arg);
|
|
}
|
|
|
|
// ── Command-mode key handling ───────────────────────────────────────────────
|
|
function handleCommandKey(evt: any) {
|
|
if (k.match("escape", evt) || evt.name === "ctrl-c") {
|
|
evt.preventDefault();
|
|
nav.exitCommand();
|
|
return;
|
|
}
|
|
if (evt.name === "return" || evt.name === "enter") {
|
|
evt.preventDefault();
|
|
const cmd = nav.commandBuffer();
|
|
runCommand(cmd);
|
|
nav.exitCommand();
|
|
return;
|
|
}
|
|
if (evt.name === "backspace") {
|
|
evt.preventDefault();
|
|
const buf = nav.commandBuffer();
|
|
if (buf.length === 0) {
|
|
nav.exitCommand();
|
|
return;
|
|
}
|
|
nav.backspaceCommand();
|
|
return;
|
|
}
|
|
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
|
evt.preventDefault();
|
|
nav.appendCommand(evt.name);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ── Unified router (normal + visual) ───────────────────────────────────────
|
|
const { dispatch } = createDispatcher({
|
|
nav,
|
|
audio: {
|
|
togglePlayback: audio.togglePlayback,
|
|
seekRelative: audio.seekRelative,
|
|
},
|
|
k,
|
|
setShowHelp,
|
|
advanceEpisode,
|
|
});
|
|
|
|
useKeyboard(
|
|
(evt: any) => {
|
|
// Input fields (search boxes, dialogs) own their keys — except Escape,
|
|
// which defocuses the input so j/k/h navigation resumes (search: h back
|
|
// to the tab root, j/k to move the recent-searches list).
|
|
if (nav.inputFocused() && nav.mode() !== NavMode.COMMAND) {
|
|
if (evt.name === "escape") {
|
|
evt.preventDefault();
|
|
nav.setInputFocused(false);
|
|
// Actually blur the focused renderable too — setting the flag alone
|
|
// leaves the opentui input owning keys, so nav keys would still be
|
|
// typed into it. Blurring fires our useInputFocusNav BLURRED handler
|
|
// (and re-blurs the SearchPage input via its `focused` prop).
|
|
renderer.currentFocusedRenderable?.blur();
|
|
}
|
|
return;
|
|
}
|
|
if (nav.mode() === NavMode.COMMAND) {
|
|
handleCommandKey(evt);
|
|
return;
|
|
}
|
|
const action = k.tryMatch(evt);
|
|
if (action) dispatch(action, evt);
|
|
},
|
|
{ release: false },
|
|
);
|
|
|
|
// ── Status bar fragments ──────────────────────────────────────────────────
|
|
// Now-playing text carries the podcast name (custom name when set) when
|
|
// the episode's feed is resolvable, mirroring advanceEpisode's lookup.
|
|
const nowPlayingText = () => {
|
|
const ep = audio.currentEpisode();
|
|
if (!ep) return null;
|
|
const feeds = feedStore.getFilteredFeeds();
|
|
const feed =
|
|
feeds.find((f) => f.podcast.id === ep.podcastId) ??
|
|
feeds.find((f) => f.episodes.some((e) => e.id === ep.id));
|
|
return feed
|
|
? `♪ ${feed.customName || feed.podcast.title} — ${ep.title}`
|
|
: `♪ ${ep.title}`;
|
|
};
|
|
const modeLabel = () =>
|
|
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
|
|
const pendingLabel = () =>
|
|
k
|
|
.pending()
|
|
.map((s) => s.key)
|
|
.join(" ");
|
|
|
|
// ── Now-playing marquee ────────────────────────────────────────────────────
|
|
// The now-playing segment takes the full remaining status-bar width and
|
|
// marquee-scrolls when its text overflows; when it fits (or the bar is too
|
|
// narrow to show anything) it renders statically. Each pass scrolls at
|
|
// SCROLL_STEP_MS per char, then holds at the start for SCROLL_HOLD_MS
|
|
// before scrolling again.
|
|
const dims = useTerminalDimensions();
|
|
const GAP = 3;
|
|
const SCROLL_STEP_MS = 150;
|
|
const SCROLL_HOLD_MS = 10_000;
|
|
const [scrollOffset, setScrollOffset] = createSignal(0);
|
|
const leftFixed = () =>
|
|
modeLabel().length +
|
|
(nav.selectedIds().length > 0
|
|
? 4 + String(nav.selectedIds().length).length
|
|
: 0);
|
|
const rightFixed = () => k.pending().map((p) => p.key).join(" ").length + 3;
|
|
const availableWidth = () =>
|
|
Math.max(0, dims().width - leftFixed() - rightFixed() - 2);
|
|
const visible = () => {
|
|
const text = nowPlayingText();
|
|
const avail = availableWidth();
|
|
if (!text || avail <= 0) return "";
|
|
if (text.length <= avail) return text;
|
|
// Double the text with a gap so the wrap is seamless: the window
|
|
// slides over text + gap + text without ever hitting the tail.
|
|
return (text + " ".repeat(GAP) + text).slice(
|
|
scrollOffset(),
|
|
scrollOffset() + avail,
|
|
);
|
|
};
|
|
createEffect(() => {
|
|
const text = nowPlayingText();
|
|
const avail = availableWidth();
|
|
setScrollOffset(0);
|
|
if (!text || avail <= 0 || text.length <= avail) return;
|
|
const cycle = text.length + GAP - avail;
|
|
// Hold at the start position for SCROLL_HOLD_MS, scroll one pass,
|
|
// then hold again before the next pass.
|
|
let holdId: ReturnType<typeof setTimeout> | null = null;
|
|
let scrollId: ReturnType<typeof setInterval> | null = null;
|
|
const startHold = () => {
|
|
setScrollOffset(0);
|
|
holdId = setTimeout(() => {
|
|
scrollId = setInterval(() => {
|
|
const next = scrollOffset() + 1;
|
|
if (next >= cycle) {
|
|
clearInterval(scrollId!);
|
|
startHold();
|
|
} else {
|
|
setScrollOffset(next);
|
|
}
|
|
}, SCROLL_STEP_MS);
|
|
}, SCROLL_HOLD_MS);
|
|
};
|
|
startHold();
|
|
onCleanup(() => {
|
|
if (holdId) clearTimeout(holdId);
|
|
if (scrollId) clearInterval(scrollId);
|
|
});
|
|
});
|
|
|
|
return (
|
|
<box
|
|
flexDirection="column"
|
|
width="100%"
|
|
height="100%"
|
|
backgroundColor={
|
|
theme.transparentBackground() ? "transparent" : t.surface
|
|
}
|
|
>
|
|
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
|
|
<box flexDirection="row" flexGrow={1} width="100%">
|
|
<Show
|
|
when={nav.atRootTab()}
|
|
fallback={
|
|
<box flexGrow={1} width="100%">
|
|
{LayerGraph[nav.activeTab()]()}
|
|
</box>
|
|
}
|
|
>
|
|
{/* app root: the tab list is the CURRENT pane, nothing in UP */}
|
|
<PaneRow
|
|
parent={
|
|
<box padding={1}>
|
|
<text fg={t.textMuted}>—</text>
|
|
</box>
|
|
}
|
|
current={<TabListPane />}
|
|
preview={
|
|
<box padding={1}>
|
|
<text fg={t.textMuted}>j/k move · l/Enter open a tab</text>
|
|
</box>
|
|
}
|
|
currentLabel="Tabs"
|
|
focused
|
|
/>
|
|
</Show>
|
|
</box>
|
|
{/* ── Bottom status / command bar ─────────────────────────────────────── */}
|
|
<box
|
|
flexDirection="row"
|
|
height={1}
|
|
width="100%"
|
|
backgroundColor={
|
|
theme.transparentBackground()
|
|
? "transparent"
|
|
: (t.backgroundPanel ?? t.background)
|
|
}
|
|
>
|
|
<Show
|
|
when={nav.mode() === NavMode.COMMAND}
|
|
fallback={
|
|
<>
|
|
<text fg={t.accent} paddingLeft={1}>
|
|
{modeLabel()}
|
|
</text>
|
|
<Show when={nav.selectedIds().length > 0}>
|
|
<text fg={t.warning} paddingLeft={1}>
|
|
● {nav.selectedIds().length}
|
|
</text>
|
|
</Show>
|
|
<Show when={nowPlayingText()}>
|
|
<box flexGrow={1} paddingLeft={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={t.primary} content={visible()} />
|
|
</box>
|
|
</Show>
|
|
<text fg={t.textMuted} paddingRight={1}>
|
|
{pendingLabel()}
|
|
</text>
|
|
<text fg={t.textMuted} paddingRight={1}>
|
|
~
|
|
</text>
|
|
</>
|
|
}
|
|
>
|
|
<text fg={t.accent} paddingLeft={1}>
|
|
:
|
|
</text>
|
|
<text fg={t.text}>{nav.commandBuffer()}</text>
|
|
<text fg={t.textMuted}>▏</text>
|
|
<Show when={nav.commandError()}>
|
|
<text fg={t.error} paddingLeft={1}>
|
|
{nav.commandError()}
|
|
</text>
|
|
</Show>
|
|
</Show>
|
|
</box>
|
|
{/* ── Help overlay ─────────────────────────────────────────────────────── */}
|
|
<Show when={showHelp()}>
|
|
<HelpOverlay
|
|
onClose={() => setShowHelp(false)}
|
|
sections={helpSections(k)}
|
|
theme={t as any}
|
|
/>
|
|
</Show>
|
|
{/* ── Global activity indicator (top-right overlay) ─────────────────────── */}
|
|
<GlobalActivityIndicator />
|
|
</box>
|
|
);
|
|
}
|
|
|
|
function helpSections(k: ReturnType<typeof useKeybinds>) {
|
|
const p = (a: KeybindActionName) => k.print(a);
|
|
return [
|
|
{
|
|
group: "Move",
|
|
items: [
|
|
["j/k", "move"],
|
|
["J/K", "5 lines"],
|
|
["ctrl-d/u", "half page"],
|
|
["gg/G", "top/bottom"],
|
|
],
|
|
},
|
|
{
|
|
group: "Panes",
|
|
items: [
|
|
["j/k", "switch tab (tab panel)"],
|
|
["l/enter", "enter tab content"],
|
|
["h", "back to tab panel"],
|
|
["1-6 / [ ]", "switch tabs"],
|
|
[":", "command"],
|
|
["~", "help"],
|
|
],
|
|
},
|
|
{
|
|
group: "Select",
|
|
items: [
|
|
["space", "toggle"],
|
|
["v", "visual"],
|
|
["ctrl-a", "all"],
|
|
["esc", "clear"],
|
|
],
|
|
},
|
|
{
|
|
group: "Audio",
|
|
items: [
|
|
[p("audio-toggle"), "play/pause"],
|
|
[p("audio-next"), "next"],
|
|
[p("audio-seek-forward"), "fwd 10s"],
|
|
[p("audio-seek-backward"), "back 10s"],
|
|
],
|
|
},
|
|
{
|
|
group: "List",
|
|
items: [
|
|
["enter", "open"],
|
|
["r", "refresh"],
|
|
["s", "search"],
|
|
[p("search-scope-toggle"), "shows/episodes"],
|
|
["f", "filter"],
|
|
[",", "sort"],
|
|
[".", "hidden"],
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
function HelpOverlay(props: {
|
|
onClose: () => void;
|
|
sections: { group: string; items: string[][] }[];
|
|
theme: any;
|
|
}) {
|
|
useKeyboard((evt: any) => {
|
|
if (k_match_escape(evt)) {
|
|
evt.preventDefault();
|
|
props.onClose();
|
|
}
|
|
});
|
|
const th = props.theme;
|
|
return (
|
|
<box
|
|
position="absolute"
|
|
top={2}
|
|
left={0}
|
|
width="100%"
|
|
alignItems="center"
|
|
backgroundColor="rgba(0,0,0,160)"
|
|
onMouseUp={() => props.onClose()}
|
|
>
|
|
<box
|
|
flexDirection="column"
|
|
border
|
|
borderColor={th.border}
|
|
backgroundColor={th.backgroundPanel ?? th.background}
|
|
padding={1}
|
|
width={60}
|
|
onMouseUp={(e: any) => e.stopPropagation()}
|
|
>
|
|
<text fg={th.accent}>
|
|
Yazi-style keybinds — press ~ or Esc to close
|
|
</text>
|
|
<For each={props.sections}>
|
|
{(sec) => (
|
|
<box flexDirection="column" marginTop={1}>
|
|
<text fg={th.textSecondary}>{sec.group}</text>
|
|
<For each={sec.items}>
|
|
{(it) => (
|
|
<box flexDirection="row" gap={2}>
|
|
<text fg={th.accent}>{String(it[0]).padEnd(14, " ")}</text>
|
|
<text fg={th.textPrimary ?? th.text}>{it[1]}</text>
|
|
</box>
|
|
)}
|
|
</For>
|
|
</box>
|
|
)}
|
|
</For>
|
|
<box marginTop={1}>
|
|
<text fg={th.textMuted}>
|
|
Edit ~/.config/podtui/keybinds.jsonc to remap.
|
|
</text>
|
|
</box>
|
|
</box>
|
|
</box>
|
|
);
|
|
}
|
|
|
|
function k_match_escape(evt: any): boolean {
|
|
return (
|
|
evt.name === "escape" || evt.name === "~" || (evt.ctrl && evt.name === "[")
|
|
);
|
|
}
|
|
|
|
// Re-export Episode type for callers building pane trees.
|
|
export type { Episode } from "@/types/episode";
|