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).
This commit is contained in:
2026-08-12 21:02:19 -04:00
parent 77531ce41d
commit d7aec4e810
33 changed files with 941 additions and 774 deletions

View File

@@ -1,11 +0,0 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
env: {
es2022: true,
node: true,
},
ignorePatterns: ["dist", "node_modules"],
}

View File

@@ -15,7 +15,7 @@
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
### Linting
- `bun run lint` - Run ESLint with TypeScript rules
- `bun run lint` - Run the TypeScript typecheck (`bun tsc --noEmit`)
## Code Style Guidelines

BIN
bun.lockb

Binary file not shown.

View File

@@ -18,9 +18,6 @@
},
"devDependencies": {
"@types/bun": "latest",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.39.2",
"typescript": "^5.9.3"
},
"dependencies": {

View File

@@ -100,24 +100,20 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis
const epDescription = cleanField(getTagValue(item, "description"))
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
// Audio URL + file size + MIME type from <enclosure>
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
const audioUrl = enclosure?.[1] ?? ""
const fileSizeStr = getAttr(item, "enclosure", "length")
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
const mimeType = getAttr(item, "enclosure", "type") || undefined
// Duration from <itunes:duration>
const durationRaw = getTagValue(item, "itunes:duration")
const duration = parseDuration(durationRaw)
// Episode & season numbers
const episodeNumRaw = getTagValue(item, "itunes:episode")
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
const seasonNumRaw = getTagValue(item, "itunes:season")
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
// Episode type & explicit
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
@@ -135,7 +131,6 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis
pubDate,
}
// Only set optional fields if present
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
if (episodeType) ep.episodeType = episodeType

View File

@@ -0,0 +1,246 @@
/**
* Shared list-row and preview components for the Feed and My Shows pages.
*
* Both pages render the same episode rows (marker + title, optional subtitle
* line, date/duration/selection/download meta line), "[Fetch More]" rows, and
* hovered-episode / fetch-more preview panes; the pages differ only in the
* props they pass (subtitle line, hint text, manual-mode wording). Extracted
* so the previously 3-4-level-nested render blocks run as flat named
* components.
*
* Anything that can change at runtime arrives as a signal getter: Solid
* components do not re-render, so only props that are called inside the
* component's own JSX stay reactive (focus, selection, download state).
*/
import { Show } from "solid-js";
import { format } from "date-fns";
import type { RGBA } from "@opentui/core";
import { useTheme } from "@/context/ThemeContext";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { NF_ICONS } from "@/utils/nerd-fonts";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import type { Episode } from "@/types/episode";
// ── formatting helpers ──────────────────────────────────────────────────────
export const formatDate = (d: Date) => format(d, "MMM d, yyyy");
export const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
// ── EpisodeRow ──────────────────────────────────────────────────────────────
export function EpisodeRow(props: {
/** The episode this row renders. */
episode: Episode;
/** Optional second line under the title (podcast/show name). */
subtitle?: () => string | undefined;
/** For index signal (row position). */
index: () => number;
/** Focused row index in this list (-1 while the Fetch More row is
* focused, so no episode row draws the cursor). */
focused: () => number;
/** Whether the current pane has keyboard focus. */
active: () => boolean;
/** Whether this episode is selection-marked. */
selected: () => boolean;
downloadLabel: () => string;
downloadColor: () => RGBA;
marker: () => string;
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="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.episode.episodeNumber ? `#${props.episode.episodeNumber} ` : ""}
{props.episode.title}
</text>
</box>
{/* podcast name on its own row — readable at a glance; the 50%
current pane fits it in full for typical names, and truncate
keeps the row one line tall either way */}
<Show when={props.subtitle?.()}>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={isFocused() ? theme.surface : theme.textSecondary}
>
{props.subtitle?.()}
</text>
</box>
</Show>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text flexShrink={0} fg={isFocused() ? theme.surface : theme.info}>
{formatDate(props.episode.pubDate)}
</text>
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
{formatDuration(props.episode.duration)}
</text>
<Show when={props.selected()}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={props.downloadLabel()}>
<text flexShrink={0} fg={props.downloadColor()}>
{props.downloadLabel()}
</text>
</Show>
</box>
</box>
);
}
// ── FetchMoreRow ────────────────────────────────────────────────────────────
export function FetchMoreRow(props: {
/** Row index of the Fetch More button within the list. */
index: () => number;
/** Focused row index. */
focused: () => number;
/** True while the Fetch More row itself is focused. */
onMore: () => boolean;
/** Whether the current pane has keyboard focus. */
active: () => boolean;
isLoadingMore: () => boolean;
nerd: boolean;
marker: () => string;
onMouseDown: () => void;
}) {
const { theme } = useTheme();
const ref = useScrollIntoView(props.onMore);
const bg = () =>
props.index() === props.focused() && props.active()
? theme.primary
: props.index() === props.focused()
? theme.border
: undefined;
const fg = () =>
props.index() === props.focused() && props.active()
? theme.surface
: props.index() === props.focused()
? theme.selectedListItemText ?? theme.text
: theme.text;
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<text fg={fg()}>{props.onMore() ? props.marker() : " "}</text>
{props.nerd && (
<text fg={fg()}>{NF_ICONS.more}</text>
)}
<Show
when={!props.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={fg()}>[Fetch More]</text>
</Show>
</box>
);
}
// ── EpisodePreview ──────────────────────────────────────────────────────────
export function EpisodePreview(props: {
episode: () => Episode;
/** Optional line under the meta row (podcast/show name). */
subtitle?: () => string | undefined;
author: () => string | undefined;
downloadLabel: () => string;
downloadColor: () => RGBA;
/** Page-specific action-hint line. */
hint: () => string;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{props.episode().episodeNumber ? `#${props.episode().episodeNumber} ` : ""}
{props.episode().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(props.episode().pubDate)}</text>
<text fg={muted()}>{formatDuration(props.episode().duration)}</text>
<Show when={props.downloadLabel()}>
<text fg={props.downloadColor()}>{props.downloadLabel()}</text>
</Show>
</box>
<Show when={props.subtitle?.()}>
<text fg={muted()}>{props.subtitle?.()}</text>
</Show>
<Show when={props.author()}>
<text fg={muted()}>by {props.author()}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{props.episode().description?.slice(0, 400) ?? "No description available."}
{(props.episode().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>{props.hint()}</text>
</box>
);
}
// ── FetchMorePreview ────────────────────────────────────────────────────────
export function FetchMorePreview(props: {
isLoadingMore: () => boolean;
fetchMoreMode: () => string;
/** Manual-mode explanation line ("across all feeds" vs "for this show"). */
manualText: () => string;
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{props.isLoadingMore()
? "Loading the next batch of episodes…"
: props.fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: props.manualText()}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
);
}

View File

@@ -169,7 +169,6 @@ export function Shell() {
nav.backspaceCommand();
return;
}
// printable char
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
evt.preventDefault();
nav.appendCommand(evt.name);

View File

@@ -10,7 +10,8 @@ import {
generateSyntax,
generateSubtleSyntax,
} from "../utils/syntax-highlighter";
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
import { resolveTerminalTheme } from "../utils/theme";
import { getCustomThemes } from "../utils/custom-themes";
import { detectModeFromBackground } from "../utils/system-theme";
import { createSimpleContext } from "./helper";
import {
@@ -175,7 +176,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
function init() {
resolveSystemTheme();
loadThemes()
getCustomThemes()
.then((custom) => {
setStore(
produce((draft) => {
@@ -187,7 +188,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore("active", "catppuccin");
})
.finally(() => {
// Only set ready if not waiting for system theme
if (store.active !== "system") {
setStore("ready", true);
}

View File

@@ -297,6 +297,34 @@ function stopPolling(): void {
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
/** Resolve cover art to a local path for mpv's --cover-art-files, per the
* call site's latency budget:
* "cache" — disk cache only (sync): resume paths must never wait on the
* network, so a miss plays artless and warms for next time.
* "bounded" — disk hit, else fetch capped at 1.2s: cold play needs the art
* at file LOAD, but a slow cover server must not stall audio.
* "await" — disk hit, else full (8s-bounded) fetch: boot restore preloads
* while feeds/progress load anyway, so the wait is free and the
* cover must be present when the file loads.
* fetchCoverArt already short-circuits on the disk cache, so "await" costs
* nothing on a warm cache. */
async function resolveCoverArt(
coverUrl: string | undefined,
mode: "cache" | "bounded" | "await",
): Promise<string | null> {
if (!coverUrl) return null;
if (mode === "cache") return cachedCoverPath(coverUrl);
if (mode === "bounded") {
const cached = cachedCoverPath(coverUrl);
if (cached) return cached;
return Promise.race([
fetchCoverArt(coverUrl),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
]);
}
return fetchCoverArt(coverUrl);
}
async function play(episode: Episode): Promise<void> {
const b = ensureBackend();
setError(null);
@@ -321,21 +349,15 @@ async function play(episode: Episode): Promise<void> {
// episode's own image (feeds added by URL may lack a channel cover).
const downloadStore = useDownloadStore();
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl;
// Cover art only applies at file LOAD (the runtime video-add fallback
// never becomes an albumart track), so a cold-cache play must wait for
// the fetch or play artless. Serve the disk cache synchronously; on a
// miss, await the single-flight fetch with a 1.2s cap (covers fetch in
// ~300ms typically) — past the cap, play bare and let the fetch warm
// the cache for next time.
let coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
if (coverUrl && !coverArtPath) {
const path = await Promise.race([
fetchCoverArt(coverUrl),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
]);
if (path) coverArtPath = path;
}
// miss, await the bounded fetch (covers fetch in ~300ms typically) —
// past the 1.2s cap, play bare and let the fetch warm the cache.
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? episode.imageUrl,
"bounded",
);
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
@@ -436,8 +458,10 @@ async function load(episode: Episode): Promise<void> {
// on feeds/progress at boot, so the bounded fetch (~300ms typical,
// 8s worst case) is free. Falls back to the episode's own image when
// the feed has no channel cover.
const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl;
const coverArtPath = coverUrl ? await fetchCoverArt(coverUrl) : null;
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? episode.imageUrl,
"await",
);
const backendSnap = backend;
backendSnap
.preload(url, {
@@ -612,8 +636,10 @@ async function switchBackend(name: BackendName): Promise<void> {
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const url =
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
const coverUrl = feed?.podcast.coverUrl ?? ep.imageUrl;
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? ep.imageUrl,
"cache",
);
await backend.play(url, {
startPosition: pos,
volume: vol,

View File

@@ -42,7 +42,6 @@ if (cliArgs.version) {
// ── CLI handlers ──────────────────────────────────────────────────────
/** Find the most recent episode across all feeds */
function findLatestEpisode(
feeds: Feed[],
): { feed: Feed; episode: Episode } | null {

View File

@@ -22,7 +22,6 @@ import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { prefetchCoverArt } from "@/utils/cover-art";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
@@ -33,14 +32,19 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import { supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import {
EpisodeRow,
FetchMoreRow,
EpisodePreview,
FetchMorePreview,
} from "@/components/EpisodeList";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const FeedPaneCount = 1;
@@ -87,7 +91,6 @@ function FeedPage() {
const app = useAppStore();
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
const showFetchMore = () => feedStore.hasMoreAcrossAll();
// Total navigable rows: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focus = () => nav.depthFocus(0);
const focusedRow = () =>
@@ -103,7 +106,6 @@ function FeedPage() {
const focusedItem = (): EpItem | undefined =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const curLen = () => rowCount();
const moreRef = useScrollIntoView(() => focusedOnMore());
const ensureFocus = () => {
if (rowCount() > 0 && focus() >= rowCount())
@@ -129,12 +131,6 @@ function FeedPage() {
});
// ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
@@ -229,19 +225,6 @@ function FeedPage() {
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
// Row highlight within the list. `active=true` only for the current pane.
const focusBg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.primary
: i === listFocus
? theme.border
: undefined;
const focusFg = (i: number, listFocus: number, active: boolean) =>
i === listFocus && active
? theme.surface
: i === listFocus
? theme.selectedListItemText ?? theme.text
: theme.text;
const currentLabel = () => `Feed · ${episodes().length}`;
@@ -268,108 +251,38 @@ function FeedPage() {
}
>
<For each={episodes()}>
{(item, index) => {
const fi = () => focusedEpIdx();
const ref = useScrollIntoView(() => index() === fi());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(index(), fi(), isActive())}
>
{index() === fi() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(index(), fi(), isActive())}
>
{item.episode.episodeNumber
? `#${item.episode.episodeNumber} `
: ""}
{item.episode.title}
</text>
</box>
{/* podcast name on its own row — readable at a glance; the
50% current pane fits it in full for typical names, and
truncate keeps the row one line tall either way */}
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={index() === fi() ? theme.surface : theme.textSecondary}
>
{item.feed.customName || item.feed.podcast.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
flexShrink={0}
fg={index() === fi() ? theme.surface : theme.info}
>
{formatDate(item.episode.pubDate)}
</text>
<text
flexShrink={0}
fg={index() === fi() ? theme.surface : muted()}
>
{formatDuration(item.episode.duration)}
</text>
<Show when={nav.isSelected(item.episode.id)}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(item.episode.id)}>
<text flexShrink={0} fg={downloadColor(item.episode.id)}>
{downloadLabel(item.episode.id)}
</text>
</Show>
</box>
</box>
);
}}
{(item, index) => (
<EpisodeRow
episode={item.episode}
subtitle={() => item.feed.customName || item.feed.podcast.title}
index={index}
focused={focusedEpIdx}
active={isActive}
selected={() => nav.isSelected(item.episode.id)}
downloadLabel={() => downloadLabel(item.episode.id)}
downloadColor={() => downloadColor(item.episode.id)}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
/>
)}
</For>
<Show when={showFetchMore()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
<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, 0);
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
[Fetch More]
</text>
</Show>
</box>
/>
</Show>
<Show when={feedStore.isLoadingFeeds()}>
<box alignItems="center" paddingTop={1}>
@@ -380,23 +293,23 @@ function FeedPage() {
);
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
const episodeHint = (item: EpItem) =>
`enter: play · d: download${
downloadStore.getDownloadStatus(item.episode.id) !== DownloadStatus.NONE
? " · D: delete"
: ""
} · space: select · h back`;
const previewContent = () => (
<>
<Show when={focusedOnMore()}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{feedStore.isLoadingMore()
? "Loading the next batch of episodes…"
: fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: "Load the next batch of older episodes across all feeds (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
"Load the next batch of older episodes across all feeds (Enter)."
}
/>
</Show>
<Show when={!focusedOnMore()}>
<Show
@@ -408,46 +321,16 @@ function FeedPage() {
}
>
{(item) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{item().episode.episodeNumber
? `#${item().episode.episodeNumber} `
: ""}
{item().episode.title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
<Show when={downloadLabel(item().episode.id)}>
<text fg={downloadColor(item().episode.id)}>
{downloadLabel(item().episode.id)}
</text>
</Show>
</box>
<text fg={muted()}>
{item().feed.customName || item().feed.podcast.title}
</text>
<Show when={item().feed.podcast.author}>
<text fg={muted()}>by {item().feed.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{item().episode.description?.slice(0, 400) ??
"No description available."}
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · d: download
{downloadStore.getDownloadStatus(item().episode.id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}{" "}
· space: select · h back
</text>
</box>
<EpisodePreview
episode={() => item().episode}
subtitle={() =>
item().feed.customName || item().feed.podcast.title
}
author={() => item().feed.podcast.author}
downloadLabel={() => downloadLabel(item().episode.id)}
downloadColor={() => downloadColor(item().episode.id)}
hint={() => episodeHint(item())}
/>
)}
</Show>
</Show>

View File

@@ -15,11 +15,11 @@
*/
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 { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import {
@@ -31,16 +31,208 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
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 { LoadingIndicator } from "@/components/LoadingIndicator";
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() {
@@ -67,7 +259,6 @@ export function MyShowsPage() {
// entry drops out the moment the user subscribes to its show.
const unsubs = () => downloadStore.getUnsubscribedDownloads();
// Total depth-0 rows: subscribed shows + unsubscribed-show downloads.
const depth0Count = () => shows().length + unsubs().length;
const focusedShowIdx = () =>
@@ -107,7 +298,6 @@ export function MyShowsPage() {
depth() >= 1 &&
!!drilledShowId() &&
feedStore.hasMoreEpisodes(drilledShowId());
// Total navigable rows at depth 1: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focusedRow = () =>
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
@@ -121,7 +311,6 @@ export function MyShowsPage() {
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
const focusedEpisode = () =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const moreRef = useScrollIntoView(() => focusedOnMore());
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
@@ -155,12 +344,6 @@ export function MyShowsPage() {
});
// ── helpers ─────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
const formatDuration = (s: number) => {
const mins = Math.floor(s / 60);
const hrs = Math.floor(mins / 60);
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
};
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
case DownloadStatus.QUEUED:
@@ -323,14 +506,6 @@ export function MyShowsPage() {
// ── render ──────────────────────────────────────────────────────────────────
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
const focusBg = (i: number, lf: number, active: boolean) =>
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
const focusFg = (i: number, lf: number, active: boolean) =>
i === lf && active
? theme.surface
: i === lf
? theme.selectedListItemText ?? theme.text
: theme.text;
const showTitle = (f: Feed) => f.customName || f.podcast.title;
const currentLabel = () =>
@@ -349,18 +524,21 @@ export function MyShowsPage() {
{(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={focusBg(index(), lf(), false)}
backgroundColor={focused() ? theme.border : undefined}
>
<text fg={focusFg(index(), lf(), false)}>
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
<text fg={fg()}>{focused() ? marker() : " "}</text>
<text fg={fg()}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
</box>
);
@@ -385,51 +563,28 @@ export function MyShowsPage() {
}
>
<For each={shows()}>
{(feed, index) => {
const lf = () => focusedShowIdx();
const ref = useScrollIntoView(() => index() === lf());
const wlScope =
app.state().preferences.autoDownloadScope === "whitelist";
const wlInList = (
app.state().preferences.autoDownloadWhitelist ?? []
).includes(feed.id);
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{showTitle(feed)}
</text>
<text fg={index() === lf() ? theme.surface : muted()}>
({feed.episodes.length})
</text>
<Show when={wlScope}>
<text
fg={
index() === lf()
? theme.surface
: wlInList
? theme.warning
: muted()
}
>
{wlInList ? "●" : "○"}
</text>
</Show>
</box>
);
}}
{(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}>
@@ -438,62 +593,21 @@ export function MyShowsPage() {
</text>
</box>
<For each={unsubs()}>
{(d, index) => {
// Rows continue after the shows list.
const rowIdx = () => shows().length + index();
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => rowIdx() === lf());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(rowIdx(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(rowIdx(), 0);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(rowIdx(), lf(), isActive())}
>
{rowIdx() === lf() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(rowIdx(), lf(), isActive())}
>
{d.episodeTitle ?? d.episodeId}
</text>
<Show when={downloadLabel(d.episodeId)}>
<text
flexShrink={0}
fg={downloadColor(d.episodeId)}
>
{downloadLabel(d.episodeId)}
</text>
</Show>
</box>
<box paddingLeft={2}>
<text
wrapMode="none"
truncate
fg={
rowIdx() === lf()
? theme.surface
: theme.textSecondary
}
>
{d.podcastTitle ?? d.feedId}
</text>
</box>
</box>
);
}}
{(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>
@@ -509,98 +623,37 @@ export function MyShowsPage() {
}
>
<For each={episodes()}>
{(ep, index) => {
const lf = () => focusedEpIdx();
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="column"
gap={0}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1);
}}
>
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
fg={focusFg(index(), lf(), isActive())}
>
{index() === lf() ? marker() : " "}
</text>
<text
wrapMode="none"
truncate
fg={focusFg(index(), lf(), isActive())}
>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
{ep.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : theme.info}
>
{formatDate(ep.pubDate)}
</text>
<text
flexShrink={0}
fg={index() === lf() ? theme.surface : muted()}
>
{formatDuration(ep.duration)}
</text>
<Show when={nav.isSelected(ep.id)}>
<text flexShrink={0} fg={theme.warning}>
</text>
</Show>
<Show when={downloadLabel(ep.id)}>
<text flexShrink={0} fg={downloadColor(ep.id)}>
{downloadLabel(ep.id)}
</text>
</Show>
</box>
</box>
);
}}
{(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()}>
<box
ref={moreRef}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(
episodes().length,
focusedRow(),
isActive(),
)}
<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);
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
[Fetch More]
</text>
</Show>
</box>
/>
</Show>
</Show>
</Show>
@@ -608,6 +661,30 @@ export function MyShowsPage() {
);
// ── 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
@@ -624,86 +701,34 @@ export function MyShowsPage() {
}
>
{(show) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>{showTitle(show())}</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()}>
enter/l: open · h: back · x: unsubscribe
{app.state().preferences.autoDownloadScope ===
"whitelist"
? (app.state().preferences.autoDownloadWhitelist ??
[]
).includes(show().id)
? " · w: un-whitelist"
: " · w: whitelist"
: ""}
</text>
</box>
<ShowPreview
show={() => show()}
title={() => showTitle(show())}
hint={() => showHint(show())}
/>
)}
</Show>
}
>
{(d) => (
<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={downloadLabel(d().episodeId)}>
<text fg={downloadColor(d().episodeId)}>
{downloadLabel(d().episodeId)}
</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>
<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()}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>[Fetch More]</strong>
</text>
<text fg={muted()}>
{feedStore.isLoadingMore()
? "Loading the next batch of episodes…"
: fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: "Load the next batch of older episodes for this show (Enter)."}
</text>
<box height={1} />
<text fg={muted()}>enter: load more · h back</text>
</box>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
"Load the next batch of older episodes for this show (Enter)."
}
/>
</Show>
<Show when={!focusedOnMore()}>
<Show
@@ -714,53 +739,19 @@ export function MyShowsPage() {
</box>
}
>
{(ep) => (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
<strong>
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
{ep().title}
</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
<text fg={muted()}>{formatDuration(ep().duration)}</text>
<Show when={downloadLabel(ep().id)}>
<text fg={downloadColor(ep().id)}>
{downloadLabel(ep().id)}
</text>
</Show>
</box>
<Show when={selectedShow()?.podcast.author}>
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{ep().description?.slice(0, 400) ?? "No description available."}
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>
enter: play · d: download
{downloadStore.getDownloadStatus(ep().id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}
{app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
drilledShowId(),
)
? " · w: un-whitelist"
: " · w: whitelist"
: ""}{" "}
· space: select · h: back
</text>
</box>
)}
{(ep) => (
<EpisodePreview
episode={() => ep()}
author={() => selectedShow()?.podcast.author}
downloadLabel={() => downloadLabel(ep().id)}
downloadColor={() => downloadColor(ep().id)}
hint={() => episodeHint(ep().id)}
/>
)}
</Show>
</Show>
</Show>
</>
);
</>
);
return (
<PaneRow

View File

@@ -77,30 +77,15 @@ function SearchPage() {
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
// ── input focusing ────────────────────────────────────────────────────────
// `inputFocused` is true while the query input is being typed in. The Shell
// router yields keys to the <input> while this is true; Escape (in Shell)
// sets it false so navigation resumes; `s` (search action) sets it true.
//
// The input's REAL focus is the source of truth for the flag:
// useInputFocusNav (the same hook the Settings forms use) flips
// `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag
// and the renderable in lockstep. That matters when the user clicks OFF the
// input: opentui's mouse dispatch auto-focuses the clicked target's nearest
// focusable ancestor (a pane scrollbox), blurring the input. The BLURRED
// event drops the flag, so the Shell router immediately resumes j/k/h
// instead of swallowing keys with no input to receive them — no more
// stuck "typing" state where Esc/j/k/s all do nothing.
//
// The depth stack still SEEDS the flag on transitions, since the query
// depth defaults to typing: re-entering depth 0 (h back from results, or a
// fresh mount) focuses the input; mounting at depth 1 (returning to the
// tab after a search) stays list-navigation — a stuck-on flag there would
// have the Shell yield j/k to a non-existent input. The depth STACK signal
// is also written by focus moves (setDepthFocus), so gate the seed on the
// depth VALUE via a memo: the effect must re-run only on an actual depth
// transition. Without the memo every j/k at the query depth re-focuses the
// input (undoing Escape), which keeps the recents list unreachable by
// keyboard.
// `inputFocused` tells the Shell router to yield keys to the query input.
// The input's REAL focus is the source of truth: useInputFocusNav flips
// the flag from the input's FOCUSED/BLURRED events, so clicking off the
// input drops it and the router resumes j/k/h — no stuck "typing" state.
// The depth stack only SEEDS it on transitions (re-entering depth 0
// focuses the input; mounting at depth 1 stays list-nav), gated on the
// depth VALUE via a memo because setDepthFocus also writes the stack
// signal — without the memo every j/k at query depth re-focuses the input
// and strands the recents list.
onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false));
const focusNavRef = useInputFocusNav();

View File

@@ -140,7 +140,6 @@ export function SettingsPage() {
function open() {
const d = depth();
if (d === 0) {
// drill into the focused section's items
const id = focusedSection().id;
nav.pushDepth({
kind: `settings:${id}`,
@@ -206,7 +205,6 @@ export function SettingsPage() {
function step(delta: number) {
const d = depth();
if (d === 2) {
// editor: j/k nudges the value
const it = editorItem();
if (it?.kind === "number" || it?.kind === "select")
it.cycle?.(delta as -1 | 1);
@@ -220,7 +218,6 @@ export function SettingsPage() {
pane: PaneId;
mode: NavMode;
}) => {
// ignore actions meant for non-center panes
if (data.pane !== DEPTH_CENTER_PANE) return;
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
const handler = PAGE_ACTIONS[data.action];

View File

@@ -10,7 +10,6 @@
import { createSignal } from "solid-js";
/** Create activity store */
function createActivityStore() {
const [count, setCount] = createSignal(0);
const [labels, setLabels] = createSignal<string[]>([]);
@@ -64,7 +63,6 @@ function createActivityStore() {
};
}
/** Singleton activity store */
let activityStoreInstance: ReturnType<typeof createActivityStore> | null = null;
export function useActivityStore() {

View File

@@ -9,14 +9,12 @@ import {
saveAudioNavToFile,
} from "../utils/app-persistence";
/** Source type for audio navigation */
export enum AudioSource {
FEED = "feed",
MY_SHOWS = "my_shows",
SEARCH = "search",
}
/** Audio navigation state */
export interface AudioNavState {
/** Current source type */
source: AudioSource;
@@ -28,14 +26,12 @@ export interface AudioNavState {
lastUpdated: Date;
}
/** Default navigation state */
const defaultNavState: AudioNavState = {
source: AudioSource.FEED,
currentIndex: 0,
lastUpdated: new Date(),
};
/** Create audio navigation store */
function createAudioNavStore() {
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
@@ -56,12 +52,10 @@ function createAudioNavStore() {
init();
return {
/** Get current navigation state */
get state(): AudioNavState {
return navState();
},
/** Update source type */
setSource: (source: AudioSource, podcastId?: string) => {
setNavState((prev) => ({
...prev,
@@ -72,7 +66,6 @@ function createAudioNavStore() {
persist();
},
/** Move to next episode */
next: (currentIndex: number) => {
setNavState((prev) => ({
...prev,
@@ -82,7 +75,6 @@ function createAudioNavStore() {
persist();
},
/** Move to previous episode */
prev: (currentIndex: number) => {
setNavState((prev) => ({
...prev,
@@ -92,23 +84,19 @@ function createAudioNavStore() {
persist();
},
/** Reset to default state */
reset: () => {
setNavState(defaultNavState);
persist();
},
/** Get current index */
getCurrentIndex: (): number => {
return navState().currentIndex;
},
/** Get current source */
getSource: (): AudioSource => {
return navState().source;
},
/** Get current podcast ID */
getPodcastId: (): string | undefined => {
return navState().podcastId;
},

View File

@@ -85,7 +85,6 @@ function syncSubscriptionState(
}));
}
/** Create discover store */
export function createDiscoverStore() {
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
const [isLoading, setIsLoading] = createSignal(false);
@@ -107,7 +106,6 @@ export function createDiscoverStore() {
const refresh = async () => {
setIsLoading(true);
try {
// Skip if cache is still fresh
const now = Date.now();
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
syncSubscriptions();
@@ -131,7 +129,6 @@ export function createDiscoverStore() {
cachedAt = now;
setPodcasts(fetched);
// Reflect current feed-store subscriptions
syncSubscriptions();
} catch {
// Network failure — keep whatever we have (stale or empty)
@@ -140,7 +137,6 @@ export function createDiscoverStore() {
}
};
/** Get filtered podcasts by category */
const filteredPodcasts = () => {
const category = selectedCategory();
if (category === "all") {
@@ -155,7 +151,6 @@ export function createDiscoverStore() {
});
};
/** Subscribe to a podcast */
const subscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) {
@@ -168,7 +163,6 @@ export function createDiscoverStore() {
);
};
/** Unsubscribe from a podcast */
const unsubscribe = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast) {
@@ -180,7 +174,6 @@ export function createDiscoverStore() {
);
};
/** Toggle subscription */
const toggleSubscription = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId);
if (podcast?.isSubscribed) {
@@ -207,7 +200,6 @@ export function createDiscoverStore() {
};
}
/** Singleton discover store */
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
export function useDiscoverStore() {

View File

@@ -63,7 +63,62 @@ interface QueueItem {
episodeTitle: string;
}
/** Create download store */
// ── post-download decoration ─────────────────────────────────────────────────
/** Write the podcast cover beside the audio so mpv's --cover-art-auto=exact
* picks it up for Now Playing art when the local file plays (same basename,
* .jpg extension — verified against mpv 0.41). curl, NOT fetch: Bun's fetch
* hangs in compiled binaries, so the shipped app never wrote this file. */
function writeCoverArt(filePath: string, coverUrl: string): void {
const dot = filePath.lastIndexOf(".");
if (dot <= 0) return;
const coverPath = filePath.slice(0, dot) + ".jpg";
Bun.spawn([
"curl",
"-sS",
"--fail",
"-m",
"8",
"--max-filesize",
"2097152",
"-o",
coverPath,
coverUrl,
])
.exited.catch(() => {});
}
/** Tag the local file (codec-copy, no re-encode) so mpv's Now Playing
* metadata for local playback is title=episode, artist=podcast — the source
* streams carry no usable tags and macOS composes "title - artist" from
* exactly these fields. Atomic: ffmpeg writes a temp file, then renames
* into place. */
function tagLocalFile(
filePath: string,
episode: Episode,
podcastTitle: string,
): void {
const tmp = `${filePath}.tag.mp3`;
Bun.spawn([
"ffmpeg",
"-y",
"-i",
filePath,
"-c",
"copy",
"-metadata",
`title=${episode.title}`,
"-metadata",
`artist=${podcastTitle}`,
tmp,
])
.exited.then(async (code) => {
if (code !== 0) return;
const { renameSync } = await import("node:fs");
renameSync(tmp, filePath);
})
.catch(() => {});
}
function createDownloadStore() {
const [downloads, setDownloads] = createSignal<
Map<string, DownloadedEpisode>
@@ -195,7 +250,6 @@ function createDownloadStore() {
}
}
/** Execute a single download */
async function executeDownload(item: QueueItem): Promise<void> {
const controller = new AbortController();
abortControllers.set(item.episodeId, controller);
@@ -236,70 +290,23 @@ function createDownloadStore() {
error: null,
});
// Write the podcast cover beside the audio so mpv's
// --cover-art-auto=exact picks it up for Now Playing art when the
// local file plays (same basename, .jpg extension — verified
// against mpv 0.41). curl, NOT fetch: Bun's fetch hangs in
// compiled binaries, so the shipped app never wrote this file.
// Falls back to the episode's own image when the feed has no
// channel cover (URL-added feeds).
// Decorate the local file: cover art + ID3 tags (see the
// module-level helpers above) — the source streams carry neither.
// Cover falls back to the episode's own image when the feed has
// no channel cover (URL-added feeds).
const feedStore = useFeedStore();
const episode = feedStore.findEpisode(item.episodeId);
const coverUrl =
feedStore
.feeds()
.find((f) => f.id === item.feedId)?.podcast.coverUrl ??
episode?.imageUrl;
if (coverUrl && result.filePath) {
const dot = result.filePath.lastIndexOf(".");
if (dot > 0) {
const coverPath = result.filePath.slice(0, dot) + ".jpg";
Bun.spawn([
"curl",
"-sS",
"--fail",
"-m",
"8",
"--max-filesize",
"2097152",
"-o",
coverPath,
coverUrl,
])
.exited.catch(() => {});
}
const feed = feedStore.feeds().find((f) => f.id === item.feedId);
const coverUrl = feed?.podcast.coverUrl ?? episode?.imageUrl;
if (result.filePath && coverUrl) {
writeCoverArt(result.filePath, coverUrl);
}
// Tag the local file (codec-copy, no re-encode) so mpv's Now
// Playing metadata for local playback is title=episode,
// artist=podcast — the source streams carry no usable tags and
// macOS composes "title - artist" from exactly these fields.
// Atomic: ffmpeg writes a temp file, then renames into place.
if (result.filePath && episode) {
const podcastTitle =
feedStore.feeds().find((f) => f.id === item.feedId)?.podcast.title ??
feed?.podcast.title ??
downloads().get(item.episodeId)?.podcastTitle;
if (podcastTitle) {
const tmp = `${result.filePath}.tag.mp3`;
Bun.spawn([
"ffmpeg",
"-y",
"-i",
result.filePath,
"-c",
"copy",
"-metadata",
`title=${episode.title}`,
"-metadata",
`artist=${podcastTitle}`,
tmp,
])
.exited.then(async (code) => {
if (code !== 0) return;
const { renameSync } = await import("node:fs");
renameSync(tmp, result.filePath);
})
.catch(() => {});
tagLocalFile(result.filePath, episode, podcastTitle);
}
}
} else {
@@ -315,22 +322,18 @@ function createDownloadStore() {
processQueue();
}
/** Get download status for an episode */
const getDownloadStatus = (episodeId: string): DownloadStatus => {
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
};
/** Get download progress for an episode (0-100) */
const getDownloadProgress = (episodeId: string): number => {
return downloads().get(episodeId)?.progress ?? 0;
};
/** Get full download info for an episode */
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
return downloads().get(episodeId);
};
/** Get the local file path for a completed download */
const getDownloadedFilePath = (episodeId: string): string | null => {
const dl = downloads().get(episodeId);
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
@@ -347,7 +350,6 @@ function createDownloadStore() {
podcastFeedUrl?: string;
}
/** Start downloading an episode */
const startDownload = (
episode: Episode,
feedId: string,
@@ -411,7 +413,6 @@ function createDownloadStore() {
});
};
/** Cancel a download */
const cancelDownload = (episodeId: string): void => {
// Abort active download
const controller = abortControllers.get(episodeId);
@@ -432,7 +433,6 @@ function createDownloadStore() {
saveDownloads().catch(() => {});
};
/** Remove a completed download (delete file and metadata) */
const removeDownload = async (episodeId: string): Promise<void> => {
const dl = downloads().get(episodeId);
if (dl?.filePath) {
@@ -478,7 +478,6 @@ function createDownloadStore() {
}
};
/** Get all downloads as an array */
const getAllDownloads = (): DownloadedEpisode[] => {
return Array.from(downloads().values());
};
@@ -501,12 +500,10 @@ function createDownloadStore() {
});
};
/** Get the current queue */
const getQueue = (): QueueItem[] => {
return queue();
};
/** Get count of active downloads */
const getActiveCount = (): number => {
return activeCount();
};
@@ -531,7 +528,6 @@ function createDownloadStore() {
};
}
/** Singleton download store */
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
export function useDownloadStore() {

View File

@@ -49,6 +49,16 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
* feeds) can't stall the renderer. */
const PARSE_CHUNK_SIZE = 5;
/** Hard ceiling on the in-memory full-parse cache per feed (newest first).
* The cache exists so fetch-more can page deeper without a refetch; without
* a ceiling a 5,000-episode archive pins tens of MB of Episode objects in
* RAM for the whole session (the old cache held EVERY parsed episode of
* every feed, contributing hundreds of MB for archive-heavy
* subscriptions). 1000 covers any realistic show's entire history —
* beyond it, hasMoreEpisodes flips false and the visible list is bounded
* by the user's cache preference as usual. */
const MAX_CACHED_EPISODES_PER_FEED = 1000;
/** Yield to the event loop (task queue) so the renderer can paint between
* parse chunks. MessageChannel instead of setTimeout/setImmediate because
* bun:test fake timers trap those (feed-refresh/pagination tests run under
@@ -214,7 +224,6 @@ async function mapWithConcurrency<T, R>(
return results;
}
/** Create feed store */
function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([
@@ -262,7 +271,6 @@ function createFeedStore() {
saveFeeds(feeds());
};
/** Get filtered and sorted feeds */
const getFilteredFeeds = (): Feed[] => {
let result = [...feeds()];
const f = filter();
@@ -320,7 +328,6 @@ function createFeedStore() {
return result;
};
/** Get episodes in reverse chronological order across all feeds */
const getAllEpisodesChronological = (): Array<{
episode: Episode;
feed: Feed;
@@ -341,7 +348,6 @@ function createFeedStore() {
return allEpisodes;
};
/** Sort episodes in reverse chronological order (newest first) */
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
return [...episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
@@ -384,8 +390,14 @@ function createFeedStore() {
if (feedId) {
// Cache the FULL parse — the bound is applied when reading,
// not when writing, so a preference change takes effect
// without a refetch.
fullEpisodeCache.set(feedId, allEpisodes);
// without a refetch. Capped at MAX_CACHED_EPISODES_PER_FEED
// so an archive-heavy feed can't pin its entire history in
// RAM for the session (the visible window below is bounded
// by the user's preference regardless).
fullEpisodeCache.set(
feedId,
allEpisodes.slice(0, MAX_CACHED_EPISODES_PER_FEED),
);
}
// Bound the visible window by the user's cache preference.
@@ -410,7 +422,6 @@ function createFeedStore() {
}
};
/** Check if a feed with this URL already exists */
const hasFeedByUrl = (feedUrl: string): boolean => {
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
};
@@ -675,7 +686,6 @@ function createFeedStore() {
};
scheduleNextRefresh();
/** Remove a feed */
const removeFeed = (feedId: string) => {
fullEpisodeCache.delete(feedId);
episodeLoadCount.delete(feedId);
@@ -706,7 +716,6 @@ function createFeedStore() {
}
};
/** Update a feed */
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
@@ -717,7 +726,6 @@ function createFeedStore() {
});
};
/** Toggle feed pinned status */
const togglePinned = (feedId: string) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
@@ -728,7 +736,6 @@ function createFeedStore() {
});
};
/** Add a source */
const addSource = (source: Omit<PodcastSource, "id">) => {
const newSource: PodcastSource = {
...source,
@@ -742,7 +749,6 @@ function createFeedStore() {
return newSource;
};
/** Update a source */
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => {
const updated = prev.map((source) =>
@@ -753,7 +759,6 @@ function createFeedStore() {
});
};
/** Remove a source */
const removeSource = (sourceId: string) => {
// Don't remove default sources
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
@@ -766,7 +771,6 @@ function createFeedStore() {
return true;
};
/** Toggle source enabled status */
const toggleSource = (sourceId: string) => {
setSources((prev) => {
const updated = prev.map((s) =>
@@ -777,7 +781,6 @@ function createFeedStore() {
});
};
/** Get feed by ID */
const getFeed = (feedId: string): Feed | undefined => {
return feeds().find((f) => f.id === feedId);
};
@@ -792,7 +795,6 @@ function createFeedStore() {
return undefined;
};
/** Get selected feed */
const getSelectedFeed = (): Feed | undefined => {
const id = selectedFeedId();
return id ? getFeed(id) : undefined;
@@ -853,6 +855,9 @@ function createFeedStore() {
// is its own sync block).
await yieldToUI();
cached = sortEpisodesReverseChronological(cached);
// Same ceiling as fetchEpisodes: the cache (and the paging
// window below) never exceeds MAX_CACHED_EPISODES_PER_FEED.
cached = cached.slice(0, MAX_CACHED_EPISODES_PER_FEED);
fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length);
@@ -900,7 +905,6 @@ function createFeedStore() {
}
};
/** True if any feed still has cached episodes beyond its loaded window. */
const hasMoreAcrossAll = (): boolean => {
return feeds().some((f) => hasMoreEpisodes(f.id));
};
@@ -920,7 +924,6 @@ function createFeedStore() {
}
};
/** Run the global auto-download pass (see runAutoDownload above). */
const runAutoDownloadNow = (): void => {
runAutoDownload();
};
@@ -969,7 +972,6 @@ function createFeedStore() {
};
}
/** Singleton feed store */
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
export function useFeedStore() {

View File

@@ -64,16 +64,10 @@ function createProgressStore() {
*/
whenReady: () => progressInit,
/**
* Get progress for a specific episode.
*/
get(episodeId: string): Progress | undefined {
return progressMap()[episodeId];
},
/**
* Get all progress entries.
*/
all(): Record<string, Progress> {
return progressMap();
},
@@ -102,18 +96,12 @@ function createProgressStore() {
persist();
},
/**
* Check if an episode is completed.
*/
isCompleted(episodeId: string): boolean {
const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return false;
return p.position / p.duration >= COMPLETION_THRESHOLD;
},
/**
* Get progress percentage (0-100) for an episode.
*/
getPercent(episodeId: string): number {
const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return 0;
@@ -151,9 +139,6 @@ function createProgressStore() {
persist();
},
/**
* Clear all progress data.
*/
clear(): void {
setProgressMap({});
persist();

View File

@@ -60,7 +60,6 @@ function saveScope(scope: SearchScope): void {
}
}
/** Create search store */
export function createSearchStore() {
const feedStore = useFeedStore();
const [query, setQuery] = createSignal("");
@@ -167,7 +166,6 @@ export function createSearchStore() {
}
};
/** Add query to history */
const addToHistory = (q: string) => {
setHistory((prev) => {
const updated = sanitizeHistory([q, ...prev]);
@@ -176,13 +174,11 @@ export function createSearchStore() {
});
};
/** Clear search history */
const clearHistory = () => {
setHistory([]);
saveSearchHistoryToFile([]);
};
/** Remove single history item */
const removeFromHistory = (q: string) => {
setHistory((prev) => {
const updated = prev.filter((h) => h !== q);
@@ -191,14 +187,12 @@ export function createSearchStore() {
});
};
/** Clear results */
const clearResults = () => {
setResults([]);
setQuery("");
setError(null);
};
/** Mark a podcast as subscribed in results */
const markSubscribed = (podcastId: string, feedUrl?: string) => {
setResults((prev) =>
prev.map((result) => {
@@ -262,7 +256,6 @@ export function createSearchStore() {
};
}
/** Singleton search store */
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
export function useSearchStore() {

View File

@@ -156,9 +156,6 @@ function init() {
setRegistrations((arr) => arr.filter((x) => x !== results));
});
},
/**
* Get all visible options.
*/
get options() {
return visibleOptions();
},
@@ -195,9 +192,6 @@ export function CommandProvider(props: ParentProps) {
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
}
/**
* Command palette dialog component.
*/
function CommandDialog(props: {
options: CommandOption[];
suggestedOptions: CommandOption[];

View File

@@ -98,9 +98,6 @@ function init() {
})
return {
/**
* Clear all dialogs from the stack.
*/
clear() {
for (const item of store.stack) {
if (item.onClose) item.onClose()
@@ -113,9 +110,6 @@ function init() {
emit("dialog.close", {})
},
/**
* Replace all dialogs with a new one.
*/
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
@@ -130,9 +124,6 @@ function init() {
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Push a new dialog onto the stack.
*/
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
@@ -143,9 +134,6 @@ function init() {
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Pop the top dialog from the stack.
*/
pop() {
if (store.stack.length === 0) return
const current = store.stack.at(-1)!

View File

@@ -62,7 +62,6 @@ const defaultState: AppState = {
// ── App State (config.json) ─────────────────────────────────────────────────
/** Load app state from config.json */
export async function loadAppStateFromFile(): Promise<AppState> {
try {
const cfg = await loadConfig();
@@ -88,7 +87,6 @@ export async function loadAppStateFromFile(): Promise<AppState> {
}
}
/** Save app state to config.json */
export function saveAppStateToFile(state: AppState): void {
updateConfig({
settings: state.settings,
@@ -109,7 +107,6 @@ interface ProgressEntry {
playbackSpeed?: number;
}
/** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise<
Record<string, ProgressEntry>
> {
@@ -145,7 +142,6 @@ export function saveProgressToFile(data: Record<string, unknown>): void {
const SEARCH_HISTORY_FILE = "search-history.json";
/** Load search history from JSON file */
export async function loadSearchHistoryFromFile(): Promise<string[]> {
try {
const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE));
@@ -159,7 +155,6 @@ export async function loadSearchHistoryFromFile(): Promise<string[]> {
}
}
/** Save search history to JSON file (overwrite, no backup) */
export function saveSearchHistoryToFile(history: string[]): void {
(async () => {
try {
@@ -178,7 +173,6 @@ export function saveSearchHistoryToFile(history: string[]): void {
const AUDIO_NAV_FILE = "audio-nav.json";
/** Load audio navigation state from JSON file */
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
try {
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));

View File

@@ -23,9 +23,16 @@
* pass over just that region) — earlier segments stay valid, mp3 decode of
* the same file is deterministic so abutting segments agree.
*
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min (~80 MB per 30 min),
* freed on stop(). 22050 Hz covers Nyquist 11 kHz, above the default 10 kHz
* high-cutoff of the visualizer's FFT config.
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min. The cache is a
* SLIDING WINDOW around the playback position — the decode pass stops
* once it is maxAheadSec ahead of the cursor and segments entirely older
* than keepBehindSec behind it are dropped (both re-filled/restarted on
* demand). Steady state is bounded by (maxAheadSec + keepBehindSec) of
* audio (~40 MB at the defaults) INDEPENDENT of episode length; the old
* whole-episode cache grew ~160 MB per hour of audio and hit 2.5 GB on
* long-form episodes. Fully freed on stop(). 22050 Hz covers Nyquist
* 11 kHz, above the default 10 kHz high-cutoff of the visualizer's FFT
* config.
*
* Downloads via ffmpeg's own http stack with reconnect flags, matching the
* old reader; local files skip them (ffmpeg rejects http-only options for
@@ -49,6 +56,24 @@ const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024;
*/
const CLOSE_IN_PLACE_GAP_SEC = 15;
/**
* Default decode-head budget: the ffmpeg pass pauses once it is this far
* ahead of the playback cursor. Bounds RAM (~26 MB of s16 at 22050 Hz) AND
* the network pull — the old cache decoded the whole episode at 4x, so a
* 3h show pinned ~500 MB (2.5 GB+ for long-form) and dragged the entire
* remote file even when only the first 10 minutes were listened to. At 4x
* pacing a refill costs ~150s of background decode, one ffmpeg spawn per
* ~10 min of playback.
*/
const DEFAULT_DECODE_AHEAD_SEC = 600;
/**
* Default retention behind the cursor: decoded audio entirely older than
* this is dropped. Keeps pause/resume and small backward seeks instant
* without letting the window grow with playback time.
*/
const DEFAULT_KEEP_BEHIND_SEC = 300;
/**
* Monotonically increasing generation counter.
* Each startDecode() increments this; the read loop checks it to know
@@ -73,6 +98,10 @@ export interface EpisodePcmCacheOptions {
url: string;
/** Sample rate (default: 22050) */
sampleRate?: number;
/** Decode-head budget in seconds ahead of the cursor (default: 600). */
maxAheadSec?: number;
/** Retention in seconds behind the cursor (default: 300). */
keepBehindSec?: number;
}
export class EpisodePcmCache {
@@ -84,10 +113,15 @@ export class EpisodePcmCache {
private activeSegment: Segment | null = null;
readonly url: string;
readonly sampleRate: number;
/** Sliding-window budgets (see maintainWindow). */
readonly maxAheadSec: number;
readonly keepBehindSec: number;
constructor(options: EpisodePcmCacheOptions) {
this.url = options.url;
this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE;
this.maxAheadSec = options.maxAheadSec ?? DEFAULT_DECODE_AHEAD_SEC;
this.keepBehindSec = options.keepBehindSec ?? DEFAULT_KEEP_BEHIND_SEC;
}
/** Whether an ffmpeg decode pass is currently running. */
@@ -238,11 +272,20 @@ export class EpisodePcmCache {
* new segment at `sec` (seek into a hole / resume past cached audio).
*/
ensureDecodeAround(sec: number): void {
// Enforce the sliding-window budget first (head cap, prune, refill)
// so a resume or seek never leaves stale segments behind the cursor.
this.maintainWindow(sec);
// Data already on hand: nothing needed here; only keep the tail
// filling if the decode is idle and the episode is unfinished.
// filling if the decode is idle, the episode is unfinished, AND the
// head is inside its budget. A head-capped cache ("we're maxAheadSec
// ahead, enough decoded") is NOT a stalled decode — restarting it
// here would fight maintainWindow's cap on every resume call.
if (this.covers(sec)) {
if (this._decoding || this.decodeFinished) return;
this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec);
const end = this.coverageEndSec;
if (end >= sec + this.maxAheadSec) return;
this.startDecode(end > sec ? end : sec);
return;
}
@@ -265,6 +308,48 @@ export class EpisodePcmCache {
this.startDecode(Math.max(0, sec));
}
/**
* Sliding-window budget for the in-memory cache, driven by the live
* playback position. Runs on every read (the render loop is the only
* consumer that knows the cursor continuously) and on resume/seek:
* - capHead: the decode pass pauses once it is maxAheadSec ahead of the
* cursor (pauseDecode keeps the decoded data — a plain startDecode
* from the frontier refills it later).
* - prune: segments entirely keepBehindSec behind the cursor are
* dropped. A backward seek past the window restarts a segment there —
* the same mechanism as a seek into an undecoded hole, so no new
* failure mode.
* - topUp: when the cursor has outrun the head, restart the tail decode
* from the frontier (one ffmpeg spawn per maxAheadSec of playback).
* Together these bound memory to (maxAheadSec + keepBehindSec) of audio
* regardless of episode length.
*/
private maintainWindow(atSec: number): void {
const pos = Math.max(0, atSec);
if (this._decoding && this.coverageEndSec >= pos + this.maxAheadSec) {
this.pauseDecode();
}
const keepFromSec = pos - this.keepBehindSec;
if (
this.segments.some(
(seg) => seg.baseSec + seg.written / this.sampleRate < keepFromSec,
)
) {
this.segments = this.segments.filter(
(seg) => seg.baseSec + seg.written / this.sampleRate >= keepFromSec,
);
}
if (!this._decoding && !this.decodeFinished) {
const end = this.coverageEndSec;
if (end < pos + this.maxAheadSec) {
this.startDecode(Math.max(end, pos));
}
}
}
/**
* Read the PCM window ENDING at `atSec` of playback into `out`
* (Int16 magnitudes widened to f64, the scale cavacore expects).
@@ -275,6 +360,7 @@ export class EpisodePcmCache {
*/
readWindow(out: Float64Array, atSec: number): number {
if (out.length === 0) return 0;
this.maintainWindow(atSec);
const endIdx = Math.round(atSec * this.sampleRate);
const startIdx = endIdx - out.length + 1;
for (const seg of this.segments) {

View File

@@ -52,7 +52,6 @@ const DEFAULTS: Required<CavaCoreConfig> = {
scalingMode: 0,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type CavaLib = {
symbols: Record<string, (...args: any[]) => any>;
close(): void;
@@ -102,7 +101,6 @@ export class CavaCore {
this.lib = lib;
}
/** Number of frequency bars configured. */
get bars(): number {
return this._bars;
}

View File

@@ -43,7 +43,6 @@ function createEventBus(): EventBusInstance {
}
handlers.get(event)!.add(handler as EventHandler);
// Return unsubscribe function
return () => {
this.off(event, handler);
};

View File

@@ -134,7 +134,6 @@ export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
}
})().catch(() => {});
}
/** Load sources from config.json */
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
try {
const cfg = await loadConfig();
@@ -144,7 +143,6 @@ export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
return null;
}
}
/** Save sources to config.json */
export function saveSourcesToFile<T>(sources: T[]): void {
updateConfig({ sources: sources as unknown as PodcastSource[] });
}

View File

@@ -5,9 +5,6 @@
* and multi-line comments, which is useful for configuration files.
*/
/**
* Remove JSONC comments from a string
*/
function stripComments(jsonString: string): string {
const comments = [
{ pattern: /\/\/.*$/gm, replacement: "" },
@@ -23,9 +20,6 @@ function stripComments(jsonString: string): string {
return result;
}
/**
* Parse JSONC string into a JavaScript object
*/
export function parseJSONC(jsonString: string): unknown {
const stripped = stripComments(jsonString);
return JSON.parse(stripped);

View File

@@ -95,7 +95,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
}
}
/** Load keybinds from JSONC file */
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
try {
const filePath = getConfigFilePath(KEYBINDS_FILE);

View File

@@ -9,23 +9,14 @@
import { emit } from "./event-bus"
/**
* Emit a theme reload event.
*/
function emitThemeReload(): void {
emit("theme.reload", {})
}
/**
* Emit a theme changed event.
*/
export function emitThemeChanged(theme: string, mode: "dark" | "light"): void {
emit("theme.changed", { theme, mode })
}
/**
* Emit a theme mode changed event.
*/
export function emitThemeModeChanged(mode: "dark" | "light"): void {
emit("theme.mode.changed", { mode })
}

View File

@@ -1,28 +1,13 @@
/**
* Theme CSS Variable Manager
* Handles dynamic theme switching by updating CSS custom properties
* Terminal Theme Resolver
* Resolves the active theme (built-in, custom, or system-derived) to colors.
*/
import type { TerminalColors } from "@opentui/core";
import type { ThemeJson } from "../types/theme-schema";
import { THEME_JSON } from "../constants/themes";
import { getCustomThemes } from "./custom-themes";
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
import { generateSystemTheme } from "./system-theme";
/**
* Apply CSS variable data-theme attribute
*/
export function setThemeAttribute(themeName: string) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.setAttribute("data-theme", themeName);
}
export async function loadThemes() {
return await getCustomThemes();
}
export function resolveTerminalTheme(
themes: Record<string, ThemeJson>,
name: string,
@@ -32,9 +17,5 @@ export function resolveTerminalTheme(
if (name === "system" && system) {
return resolveThemeJson(generateSystemTheme(system, mode), mode);
}
const theme = themes[name] ?? themes.catppuccin;
if (!theme) {
return resolveThemeJson(THEME_JSON.catppuccin, mode);
}
return resolveThemeJson(theme, mode);
return resolveThemeJson(themes[name] ?? themes.catppuccin, mode);
}

View File

@@ -278,3 +278,87 @@ test.skipIf(!hasFfmpeg)(
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"decode head caps at maxAheadSec ahead of the cursor — the cache is a window, not a whole-episode dump",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({
url: wav,
maxAheadSec: 4,
keepBehindSec: 2,
});
try {
cache.startDecode(0);
// The 8s initial burst delivers the front of the file instantly.
await waitForCoverage(cache, 5);
// Park the cursor at 0 and drive the cap (the render loop reads
// every frame; the cap applies on the first read past the head).
const out = new Float64Array(512);
for (let i = 0; i < 30 && cache.decoding; i++) {
cache.readWindow(out, 0);
await Bun.sleep(20);
}
// Paused at the head budget (4s) + one 8s burst of slack — NOT
// decoded to the 30s EOF.
expect(cache.decoding).toBe(false);
expect(cache.coverageEndSec).toBeGreaterThanOrEqual(4);
expect(cache.coverageEndSec).toBeLessThanOrEqual(4 + 8 + 1);
expect(cache.decodeFinished).toBe(false);
// A parked cursor keeps the cap: more reads must not restart
// the pass or grow the cache.
const cappedAt = cache.coverageEndSec;
for (let i = 0; i < 10; i++) {
cache.readWindow(out, 0);
await Bun.sleep(20);
}
expect(cache.decoding).toBe(false);
expect(cache.coverageEndSec).toBeLessThanOrEqual(cappedAt + 1);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"the window prunes segments behind the cursor as playback advances",
async () => {
const wav = tmpWav();
writeSineWav(wav, 30);
const cache = new EpisodePcmCache({
url: wav,
maxAheadSec: 4,
keepBehindSec: 2,
});
try {
// Two segments: the back half [10, ~18] and, after the seek,
// the front [2, ~10].
cache.startDecode(10);
await waitForCoverage(cache, 11);
cache.ensureDecodeAround(2);
await waitForCoverage(cache, 2.2);
expect(cache.covers(2.5)).toBe(true);
expect(cache.covers(10.5)).toBe(true);
// Cursor advances past the front segment's end + keepBehind:
// the front must fall out of the window, the back must survive.
const out = new Float64Array(512);
for (let i = 0; i < 40 && cache.covers(2.5); i++) {
cache.readWindow(out, 13);
await Bun.sleep(25);
}
expect(cache.covers(2.5)).toBe(false);
expect(cache.covers(10.5)).toBe(true);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);