Files
PodTui/src/pages/MyShows/MyShowsPage.tsx
Michael Freno d7aec4e810 fix(memory): bound visualizer PCM cache and feed episode cache
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).
2026-08-12 21:02:19 -04:00

766 lines
23 KiB
TypeScript

/**
* MyShowsPage — yazi depth-stack view of subscribed shows.
*
* depth 0 (current) — subscribed shows. Parent pane shows the muted
* placeholder (1/5 slot kept).
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
* preview — detail of the hovered item in the current column.
*
* Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior
* as the Feed tab) that loads the next batch of episodes for that show.
*
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
* 0). j/k move only within the current column.
*/
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
import type { RGBA } from "@opentui/core";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { DownloadStatus } from "@/types/episode";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
useNavigation,
NavMode,
DEPTH_CENTER_PANE,
type PaneId,
type DepthFrame,
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode, DownloadedEpisode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import {
EpisodeRow,
FetchMoreRow,
EpisodePreview,
FetchMorePreview,
formatDate,
} from "@/components/EpisodeList";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
// ── render components ────────────────────────────────────────────────────────
// Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their
// preview panes are My Shows-specific; episode rows/previews are shared with
// the Feed page (see EpisodeList.tsx).
/** A subscribed-show row (depth 0). */
function ShowRow(props: {
feed: Feed;
title: string;
index: () => number;
focused: () => number;
active: () => boolean;
marker: () => string;
wlScope: () => boolean;
wlInList: () => boolean;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const ref = useScrollIntoView(() => props.index() === props.focused());
const isFocused = () => props.index() === props.focused();
const bg = () =>
isFocused() && props.active()
? theme.primary
: isFocused()
? theme.border
: undefined;
const fg = () =>
isFocused() && props.active()
? theme.surface
: isFocused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<text fg={fg()}>{isFocused() ? props.marker() : " "}</text>
<text fg={fg()}>{props.title}</text>
<text fg={isFocused() ? theme.surface : muted()}>
({props.feed.episodes.length})
</text>
<Show when={props.wlScope()}>
<text
fg={
isFocused()
? theme.surface
: props.wlInList()
? theme.warning
: muted()
}
>
{props.wlInList() ? "●" : "○"}
</text>
</Show>
</box>
);
}
/** An unsubscribed-show download row (depth 0, below the shows list). */
function UnsubscribedRow(props: {
d: DownloadedEpisode;
index: () => number;
focused: () => number;
active: () => boolean;
marker: () => string;
downloadLabel: () => string;
downloadColor: () => RGBA;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const ref = useScrollIntoView(() => props.index() === props.focused());
const isFocused = () => props.index() === props.focused();
const bg = () =>
isFocused() && props.active()
? theme.primary
: isFocused()
? theme.border
: undefined;
const fg = () =>
isFocused() && props.active()
? theme.surface
: isFocused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={fg()}>
{isFocused() ? props.marker() : " "}
</text>
<text wrapMode="none" truncate fg={fg()}>
{props.d.episodeTitle ?? props.d.episodeId}
</text>
<Show when={props.downloadLabel()}>
<text flexShrink={0} fg={props.downloadColor()}>
{props.downloadLabel()}
</text>
</Show>
</box>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={isFocused() ? theme.surface : theme.textSecondary}
>
{props.d.podcastTitle ?? props.d.feedId}
</text>
</box>
</box>
);
}
/** Depth-0 preview: the hovered subscribed show. */
function ShowPreview(props: {
show: () => Feed;
title: () => string;
hint: () => string;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const show = props.show;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{props.title()}</strong>
</text>
<Show when={show().podcast.author}>
<text fg={muted()}>by {show().podcast.author}</text>
</Show>
<text fg={theme.textSecondary}>{show().episodes.length} episodes</text>
<text fg={muted()}>
{show().podcast.description?.slice(0, 400) ?? "No description."}
</text>
<box height={1} />
<text fg={muted()}>{props.hint()}</text>
</box>
);
}
/** Depth-0 preview: the hovered unsubscribed-show download. */
function UnsubscribedPreview(props: {
d: () => DownloadedEpisode;
downloadLabel: () => string;
downloadColor: () => RGBA;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const d = props.d;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{d().episodeTitle ?? d().episodeId}</strong>
</text>
<text fg={theme.textSecondary}>{d().podcastTitle ?? d().feedId}</text>
<box flexDirection="row" gap={2}>
<Show when={d().pubDate}>
<text fg={theme.info}>{formatDate(new Date(d().pubDate!))}</text>
</Show>
<Show when={props.downloadLabel()}>
<text fg={props.downloadColor()}>
{props.downloadLabel()}
</text>
</Show>
</box>
<text fg={muted()}>
Downloaded from episode search the show is not subscribed.
</text>
<box height={1} />
<text fg={muted()}>enter: play · D: delete download · h: back</text>
</box>
);
}
export const MyShowsPaneCount = 1;
export function MyShowsPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const app = useAppStore();
const audioNav = useAudioNavStore();
const audio = useAudio();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const marker = useSelectionMarker();
const stack = nav.depthStack;
const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d);
const shows = () => feedStore.getFilteredFeeds();
// Downloads of shows that are NOT subscribed (made from episode search) —
// listed as their own section under the shows list. Reads feeds() so an
// entry drops out the moment the user subscribes to its show.
const unsubs = () => downloadStore.getUnsubscribedDownloads();
const depth0Count = () => shows().length + unsubs().length;
const focusedShowIdx = () =>
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
/** True when the depth-0 cursor sits on an unsubscribed-show download
* row (past the shows list). */
const focusedOnUnsub = () =>
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0;
const focusedUnsub = (): DownloadedEpisode | undefined => {
if (!focusedOnUnsub()) return undefined;
return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)];
};
const selectedShow = (): Feed | undefined => {
if (focusedOnUnsub()) return undefined;
return shows()[focusedShowIdx()];
};
// depth-1 frame ctx = the drilled feed id
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
const episodes = createMemo<Episode[]>(() => {
if (depth() < 1) return [];
const id = drilledShowId();
const show = shows().find((s) => s.id === id);
if (!show) return [];
return [...show.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
});
// ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of a drilled show's episode list
// advances that show's loaded window by 50 episodes — the per-show
// counterpart to the Feed page's row (which loads every feed). manual
// mode: Enter on the row. auto mode: reaching the bottom row fetches
// automatically (see the effect below).
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
const showFetchMore = () =>
depth() >= 1 &&
!!drilledShowId() &&
feedStore.hasMoreEpisodes(drilledShowId());
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focusedRow = () =>
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
const focusedOnMore = () =>
showFetchMore() && focusedRow() === episodes().length;
// -1 while the Fetch More row is focused so no episode row renders the
// cursor/highlight (the button is the focused row, not the last episode).
const focusedEpIdx = () =>
focusedOnMore()
? -1
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
const focusedEpisode = () =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
const ensureFocus = () => {
if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
nav.setDepthFocus(depth0Count() - 1, 0);
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
nav.setDepthFocus(rowCount() - 1, 1);
};
onMount(ensureFocus);
onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) {
if (i < shows().length) return shows()[i]?.id;
return unsubs()[i - shows().length]?.episodeId;
}
return episodes()[i]?.id;
});
});
// Auto mode: reaching the bottom of a drilled show's list loads its next
// batch. Guarded by isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (depth() < 1) return;
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
});
// ── helpers ─────────────────────────────────────────────────────────────────
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING:
return `[${downloadStore.getDownloadProgress(id)}%]`;
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const downloadColor = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
return theme.warning;
case DownloadStatus.DOWNLOADING:
return theme.primary;
case DownloadStatus.COMPLETED:
return theme.success;
case DownloadStatus.FAILED:
return theme.error;
default:
return muted();
}
};
const playEpisode = (ep: Episode) => {
audio.play(ep).catch(() => {});
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
};
/** Stream an unsubscribed-show download. The record carries only what was
* persisted at download time, so a minimal Episode is reconstructed. */
const playUnsubscribedDownload = (d: DownloadedEpisode) => {
audio
.play({
id: d.episodeId,
podcastId: d.feedId,
title: d.episodeTitle ?? d.episodeId,
description: "",
audioUrl: d.audioUrl ?? "",
duration: 0,
pubDate: d.pubDate ? new Date(d.pubDate) : new Date(),
})
.catch(() => {});
audioNav.setSource(AudioSource.SEARCH, d.feedId);
};
// ── drill / open ───────────────────────────────────────────────────────────
function open() {
if (depth() === 0) {
const d = focusedUnsub();
if (d) {
playUnsubscribedDownload(d);
return;
}
const show = selectedShow();
if (!show) return;
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
nav.setActivePane(DEPTH_CENTER_PANE);
audioNav.setSource(AudioSource.MY_SHOWS, show.podcast.id);
return;
}
if (depth() >= 1) {
if (focusedOnMore()) {
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
return;
}
const ep = focusedEpisode();
if (ep) playEpisode(ep);
}
}
// ── nav.action ──────────────────────────────────────────────────────────────
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
"move-down": () => step(1),
"move-up": () => step(-1),
"jump-down": () => step(5),
"jump-up": () => step(-5),
"page-down": () => step(10),
"page-up": () => step(-10),
"goto-top": () => nav.gotoIndex(0, curLen()),
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(),
"toggle-select": () => {
if (depth() >= 1) {
const ep = focusedEpisode();
if (ep) nav.toggleSelected(ep.id);
}
},
download: () => {
if (depth() < 1) return;
const ep = focusedEpisode();
if (ep) downloadStore.startDownload(ep, drilledShowId());
},
"delete-download": () => {
if (depth() === 0) {
const d = focusedUnsub();
if (d) {
downloadStore.cancelDownload(d.episodeId);
downloadStore.removeDownload(d.episodeId).catch(() => {});
}
return;
}
if (depth() < 1) return;
const ep = focusedEpisode();
if (!ep) return;
const id = ep.id;
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
downloadStore.cancelDownload(id);
downloadStore.removeDownload(id).catch(() => {});
},
"whitelist-toggle": () => {
const prefs = app.state().preferences;
if (prefs.autoDownloadScope !== "whitelist") return;
// depth 0: the focused show; depth ≥1: the drilled show.
const id = depth() >= 1 ? drilledShowId() : selectedShow()?.id;
if (!id) return;
const cur = prefs.autoDownloadWhitelist ?? [];
const next = cur.includes(id)
? cur.filter((x) => x !== id)
: [...cur, id];
app.updatePreferences({ autoDownloadWhitelist: next });
feedStore.runAutoDownload();
},
refresh: () => {
const show = selectedShow();
if (show) feedStore.refreshFeed(show.id).catch(() => {});
},
unsubscribe: () => {
if (depth() !== 0) return;
const show = selectedShow();
if (show) {
// unsubscribe = remove feed + purge its downloaded files
feedStore.removeFeed(show.id);
downloadStore.removeDownloadsForFeed(show.id).catch(() => {});
ensureFocus();
}
},
};
function step(delta: number) {
nav.move(delta, curLen());
}
const onAction = (data: {
action: KeybindActionName;
pane: PaneId;
mode: NavMode;
}) => {
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
ensureFocus();
PAGE_ACTIONS[data.action]?.();
};
onMount(() => {
on("nav.action", onAction);
onCleanup(() => off("nav.action", onAction));
});
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const showTitle = (f: Feed) => f.customName || f.podcast.title;
const currentLabel = () =>
depth() === 0
? `Shows (${shows().length})${
unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : ""
}`
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={shows()}>
{(feed, index) => {
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => index() === lf());
const focused = () => index() === lf();
const fg = () =>
focused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focused() ? theme.border : undefined}
>
<text fg={fg()}>{focused() ? marker() : " "}</text>
<text fg={fg()}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
</box>
);
}}
</For>
</Show>
);
// ── current pane: the current-depth list ───────────────────────────────────
const currentContent = () => (
<>
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
<Show when={depth() === 0}>
<Show
when={depth0Count() > 0}
fallback={
<box padding={1}>
<text fg={muted()}>
No shows. Subscribe from Discover/Search.
</text>
</box>
}
>
<For each={shows()}>
{(feed, index) => (
<ShowRow
feed={feed}
title={showTitle(feed)}
index={index}
focused={focusedShowIdx}
active={isActive}
marker={marker}
wlScope={() =>
app.state().preferences.autoDownloadScope === "whitelist"
}
wlInList={() =>
(app.state().preferences.autoDownloadWhitelist ?? []).includes(
feed.id,
)
}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
/>
)}
</For>
<Show when={unsubs().length > 0}>
<box paddingLeft={1} paddingTop={1}>
<text fg={theme.textSecondary}>
Unsubscribed Show Downloads
</text>
</box>
<For each={unsubs()}>
{(d, index) => (
<UnsubscribedRow
d={d}
index={() => shows().length + index()}
focused={() => nav.depthFocus(0)}
active={isActive}
marker={marker}
downloadLabel={() => downloadLabel(d.episodeId)}
downloadColor={() => downloadColor(d.episodeId)}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(shows().length + index(), 0);
}}
/>
)}
</For>
</Show>
</Show>
</Show>
{/* depth ≥1: episodes */}
<Show when={depth() >= 1}>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={muted()}>No episodes. :refresh</text>
</box>
}
>
<For each={episodes()}>
{(ep, index) => (
<EpisodeRow
episode={ep}
index={index}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(ep.id)}
downloadLabel={() => downloadLabel(ep.id)}
downloadColor={() => downloadColor(ep.id)}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
/>
)}
</For>
<Show when={showFetchMore()}>
<FetchMoreRow
index={() => episodes().length}
focused={focusedRow}
onMore={focusedOnMore}
active={isActive}
isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(episodes().length, 1);
}}
/>
</Show>
</Show>
</Show>
</>
);
// ── preview pane ───────────────────────────────────────────────────────────
const episodeHint = (epId: string) =>
`enter: play · d: download${
downloadStore.getDownloadStatus(epId) !== DownloadStatus.NONE
? " · D: delete"
: ""
}${
app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
drilledShowId(),
)
? " · w: un-whitelist"
: " · w: whitelist"
: ""
} · space: select · h: back`;
const showHint = (show: Feed) =>
`enter/l: open · h: back · x: unsubscribe${
app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(show.id)
? " · w: un-whitelist"
: " · w: whitelist"
: ""
}`;
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered unsubscribed-show download, else the
// hovered show.
<Show
when={focusedUnsub()}
fallback={
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
}
>
{(show) => (
<ShowPreview
show={() => show()}
title={() => showTitle(show())}
hint={() => showHint(show())}
/>
)}
</Show>
}
>
{(d) => (
<UnsubscribedPreview
d={() => d()}
downloadLabel={() => downloadLabel(d().episodeId)}
downloadColor={() => downloadColor(d().episodeId)}
/>
)}
</Show>
) : (
// depth ≥1 preview: hovered episode (or the Fetch More row)
<>
<Show when={focusedOnMore()}>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
"Load the next batch of older episodes for this show (Enter)."
}
/>
</Show>
<Show when={!focusedOnMore()}>
<Show
when={focusedEpisode()}
fallback={
<box padding={1}>
<text fg={muted()}>No episode focused</text>
</box>
}
>
{(ep) => (
<EpisodePreview
episode={() => ep()}
author={() => selectedShow()?.podcast.author}
downloadLabel={() => downloadLabel(ep().id)}
downloadColor={() => downloadColor(ep().id)}
hint={() => episodeHint(ep().id)}
/>
)}
</Show>
</Show>
</>
);
return (
<PaneRow
parent={parentContent}
current={currentContent}
preview={previewContent}
currentLabel={currentLabel}
focused={isActive}
/>
);
}