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:
@@ -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"],
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
|
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
|
||||||
|
|
||||||
### Linting
|
### Linting
|
||||||
- `bun run lint` - Run ESLint with TypeScript rules
|
- `bun run lint` - Run the TypeScript typecheck (`bun tsc --noEmit`)
|
||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
|
||||||
"@typescript-eslint/parser": "^8.54.0",
|
|
||||||
"eslint": "^9.39.2",
|
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -100,24 +100,20 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis
|
|||||||
const epDescription = cleanField(getTagValue(item, "description"))
|
const epDescription = cleanField(getTagValue(item, "description"))
|
||||||
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
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 enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
||||||
const audioUrl = enclosure?.[1] ?? ""
|
const audioUrl = enclosure?.[1] ?? ""
|
||||||
const fileSizeStr = getAttr(item, "enclosure", "length")
|
const fileSizeStr = getAttr(item, "enclosure", "length")
|
||||||
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
||||||
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
||||||
|
|
||||||
// Duration from <itunes:duration>
|
|
||||||
const durationRaw = getTagValue(item, "itunes:duration")
|
const durationRaw = getTagValue(item, "itunes:duration")
|
||||||
const duration = parseDuration(durationRaw)
|
const duration = parseDuration(durationRaw)
|
||||||
|
|
||||||
// Episode & season numbers
|
|
||||||
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
||||||
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
||||||
const seasonNumRaw = getTagValue(item, "itunes:season")
|
const seasonNumRaw = getTagValue(item, "itunes:season")
|
||||||
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
||||||
|
|
||||||
// Episode type & explicit
|
|
||||||
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
||||||
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
||||||
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
||||||
@@ -135,7 +131,6 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis
|
|||||||
pubDate,
|
pubDate,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only set optional fields if present
|
|
||||||
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
||||||
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
||||||
if (episodeType) ep.episodeType = episodeType
|
if (episodeType) ep.episodeType = episodeType
|
||||||
|
|||||||
246
src/components/EpisodeList.tsx
Normal file
246
src/components/EpisodeList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -169,7 +169,6 @@ export function Shell() {
|
|||||||
nav.backspaceCommand();
|
nav.backspaceCommand();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// printable char
|
|
||||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
nav.appendCommand(evt.name);
|
nav.appendCommand(evt.name);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
generateSyntax,
|
generateSyntax,
|
||||||
generateSubtleSyntax,
|
generateSubtleSyntax,
|
||||||
} from "../utils/syntax-highlighter";
|
} 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 { detectModeFromBackground } from "../utils/system-theme";
|
||||||
import { createSimpleContext } from "./helper";
|
import { createSimpleContext } from "./helper";
|
||||||
import {
|
import {
|
||||||
@@ -175,7 +176,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
resolveSystemTheme();
|
resolveSystemTheme();
|
||||||
loadThemes()
|
getCustomThemes()
|
||||||
.then((custom) => {
|
.then((custom) => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -187,7 +188,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
setStore("active", "catppuccin");
|
setStore("active", "catppuccin");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// Only set ready if not waiting for system theme
|
|
||||||
if (store.active !== "system") {
|
if (store.active !== "system") {
|
||||||
setStore("ready", true);
|
setStore("ready", true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,6 +297,34 @@ function stopPolling(): void {
|
|||||||
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
|
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
|
||||||
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
|
// 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> {
|
async function play(episode: Episode): Promise<void> {
|
||||||
const b = ensureBackend();
|
const b = ensureBackend();
|
||||||
setError(null);
|
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).
|
// episode's own image (feeds added by URL may lack a channel cover).
|
||||||
const downloadStore = useDownloadStore();
|
const downloadStore = useDownloadStore();
|
||||||
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
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
|
// 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
|
// 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
|
// 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
|
// miss, await the bounded fetch (covers fetch in ~300ms typically) —
|
||||||
// ~300ms typically) — past the cap, play bare and let the fetch warm
|
// past the 1.2s cap, play bare and let the fetch warm the cache.
|
||||||
// the cache for next time.
|
const coverArtPath = await resolveCoverArt(
|
||||||
let coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||||
if (coverUrl && !coverArtPath) {
|
"bounded",
|
||||||
const path = await Promise.race([
|
);
|
||||||
fetchCoverArt(coverUrl),
|
|
||||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
|
|
||||||
]);
|
|
||||||
if (path) coverArtPath = path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resume from saved progress if available and not completed
|
// Resume from saved progress if available and not completed
|
||||||
const savedProgress = progressStore.get(episode.id);
|
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,
|
// 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
|
// 8s worst case) is free. Falls back to the episode's own image when
|
||||||
// the feed has no channel cover.
|
// the feed has no channel cover.
|
||||||
const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl;
|
const coverArtPath = await resolveCoverArt(
|
||||||
const coverArtPath = coverUrl ? await fetchCoverArt(coverUrl) : null;
|
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||||
|
"await",
|
||||||
|
);
|
||||||
const backendSnap = backend;
|
const backendSnap = backend;
|
||||||
backendSnap
|
backendSnap
|
||||||
.preload(url, {
|
.preload(url, {
|
||||||
@@ -612,8 +636,10 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
const url =
|
const url =
|
||||||
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
|
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
|
||||||
const coverUrl = feed?.podcast.coverUrl ?? ep.imageUrl;
|
const coverArtPath = await resolveCoverArt(
|
||||||
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
feed?.podcast.coverUrl ?? ep.imageUrl,
|
||||||
|
"cache",
|
||||||
|
);
|
||||||
await backend.play(url, {
|
await backend.play(url, {
|
||||||
startPosition: pos,
|
startPosition: pos,
|
||||||
volume: vol,
|
volume: vol,
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ if (cliArgs.version) {
|
|||||||
|
|
||||||
// ── CLI handlers ──────────────────────────────────────────────────────
|
// ── CLI handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Find the most recent episode across all feeds */
|
|
||||||
function findLatestEpisode(
|
function findLatestEpisode(
|
||||||
feeds: Feed[],
|
feeds: Feed[],
|
||||||
): { feed: Feed; episode: Episode } | null {
|
): { feed: Feed; episode: Episode } | null {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { useDownloadStore } from "@/stores/download";
|
|||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { prefetchCoverArt } from "@/utils/cover-art";
|
import { prefetchCoverArt } from "@/utils/cover-art";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
import {
|
import {
|
||||||
@@ -33,14 +32,19 @@ import {
|
|||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { on, off } from "@/utils/event-bus";
|
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 { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
|
import {
|
||||||
|
EpisodeRow,
|
||||||
|
FetchMoreRow,
|
||||||
|
EpisodePreview,
|
||||||
|
FetchMorePreview,
|
||||||
|
} from "@/components/EpisodeList";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
|
||||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
export const FeedPaneCount = 1;
|
export const FeedPaneCount = 1;
|
||||||
@@ -87,7 +91,6 @@ function FeedPage() {
|
|||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
|
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
|
||||||
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
||||||
// Total navigable rows: episodes + the optional Fetch More row.
|
|
||||||
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||||
const focus = () => nav.depthFocus(0);
|
const focus = () => nav.depthFocus(0);
|
||||||
const focusedRow = () =>
|
const focusedRow = () =>
|
||||||
@@ -103,7 +106,6 @@ function FeedPage() {
|
|||||||
const focusedItem = (): EpItem | undefined =>
|
const focusedItem = (): EpItem | undefined =>
|
||||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
const curLen = () => rowCount();
|
const curLen = () => rowCount();
|
||||||
const moreRef = useScrollIntoView(() => focusedOnMore());
|
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (rowCount() > 0 && focus() >= rowCount())
|
if (rowCount() > 0 && focus() >= rowCount())
|
||||||
@@ -129,12 +131,6 @@ function FeedPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
// ── 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) => {
|
const downloadLabel = (id: string) => {
|
||||||
switch (downloadStore.getDownloadStatus(id)) {
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
case DownloadStatus.QUEUED:
|
case DownloadStatus.QUEUED:
|
||||||
@@ -229,19 +225,6 @@ function FeedPage() {
|
|||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
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}`;
|
const currentLabel = () => `Feed · ${episodes().length}`;
|
||||||
|
|
||||||
@@ -268,108 +251,38 @@ function FeedPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(item, index) => {
|
{(item, index) => (
|
||||||
const fi = () => focusedEpIdx();
|
<EpisodeRow
|
||||||
const ref = useScrollIntoView(() => index() === fi());
|
episode={item.episode}
|
||||||
return (
|
subtitle={() => item.feed.customName || item.feed.podcast.title}
|
||||||
<box
|
index={index}
|
||||||
ref={ref}
|
focused={focusedEpIdx}
|
||||||
flexDirection="column"
|
active={isActive}
|
||||||
gap={0}
|
selected={() => nav.isSelected(item.episode.id)}
|
||||||
paddingRight={1}
|
downloadLabel={() => downloadLabel(item.episode.id)}
|
||||||
backgroundColor={focusBg(index(), fi(), isActive())}
|
downloadColor={() => downloadColor(item.episode.id)}
|
||||||
|
marker={marker}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 0);
|
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>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
<Show when={showFetchMore()}>
|
<Show when={showFetchMore()}>
|
||||||
<box
|
<FetchMoreRow
|
||||||
ref={moreRef}
|
index={() => episodes().length}
|
||||||
flexDirection="row"
|
focused={focusedRow}
|
||||||
gap={1}
|
onMore={focusedOnMore}
|
||||||
paddingRight={1}
|
active={isActive}
|
||||||
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
|
nerd={nerd}
|
||||||
|
marker={marker}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(episodes().length, 0);
|
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>
|
||||||
<Show when={feedStore.isLoadingFeeds()}>
|
<Show when={feedStore.isLoadingFeeds()}>
|
||||||
<box alignItems="center" paddingTop={1}>
|
<box alignItems="center" paddingTop={1}>
|
||||||
@@ -380,23 +293,23 @@ function FeedPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
|
// ── 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 = () => (
|
const previewContent = () => (
|
||||||
<>
|
<>
|
||||||
<Show when={focusedOnMore()}>
|
<Show when={focusedOnMore()}>
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<FetchMorePreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
<strong>[Fetch More]</strong>
|
fetchMoreMode={fetchMoreMode}
|
||||||
</text>
|
manualText={() =>
|
||||||
<text fg={muted()}>
|
"Load the next batch of older episodes across all feeds (Enter)."
|
||||||
{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>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!focusedOnMore()}>
|
<Show when={!focusedOnMore()}>
|
||||||
<Show
|
<Show
|
||||||
@@ -408,46 +321,16 @@ function FeedPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<EpisodePreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
episode={() => item().episode}
|
||||||
<strong>
|
subtitle={() =>
|
||||||
{item().episode.episodeNumber
|
item().feed.customName || item().feed.podcast.title
|
||||||
? `#${item().episode.episodeNumber} `
|
}
|
||||||
: ""}
|
author={() => item().feed.podcast.author}
|
||||||
{item().episode.title}
|
downloadLabel={() => downloadLabel(item().episode.id)}
|
||||||
</strong>
|
downloadColor={() => downloadColor(item().episode.id)}
|
||||||
</text>
|
hint={() => episodeHint(item())}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
|
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
|
import type { RGBA } from "@opentui/core";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { useDownloadStore } from "@/stores/download";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
import {
|
import {
|
||||||
@@ -31,16 +31,208 @@ import {
|
|||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { on, off } from "@/utils/event-bus";
|
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 { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { Episode, DownloadedEpisode } from "@/types/episode";
|
import type { Episode, DownloadedEpisode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
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 { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
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 const MyShowsPaneCount = 1;
|
||||||
|
|
||||||
export function MyShowsPage() {
|
export function MyShowsPage() {
|
||||||
@@ -67,7 +259,6 @@ export function MyShowsPage() {
|
|||||||
// entry drops out the moment the user subscribes to its show.
|
// entry drops out the moment the user subscribes to its show.
|
||||||
const unsubs = () => downloadStore.getUnsubscribedDownloads();
|
const unsubs = () => downloadStore.getUnsubscribedDownloads();
|
||||||
|
|
||||||
// Total depth-0 rows: subscribed shows + unsubscribed-show downloads.
|
|
||||||
const depth0Count = () => shows().length + unsubs().length;
|
const depth0Count = () => shows().length + unsubs().length;
|
||||||
|
|
||||||
const focusedShowIdx = () =>
|
const focusedShowIdx = () =>
|
||||||
@@ -107,7 +298,6 @@ export function MyShowsPage() {
|
|||||||
depth() >= 1 &&
|
depth() >= 1 &&
|
||||||
!!drilledShowId() &&
|
!!drilledShowId() &&
|
||||||
feedStore.hasMoreEpisodes(drilledShowId());
|
feedStore.hasMoreEpisodes(drilledShowId());
|
||||||
// Total navigable rows at depth 1: episodes + the optional Fetch More row.
|
|
||||||
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||||
const focusedRow = () =>
|
const focusedRow = () =>
|
||||||
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
|
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));
|
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
||||||
const focusedEpisode = () =>
|
const focusedEpisode = () =>
|
||||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
const moreRef = useScrollIntoView(() => focusedOnMore());
|
|
||||||
|
|
||||||
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
||||||
|
|
||||||
@@ -155,12 +344,6 @@ export function MyShowsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
// ── 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) => {
|
const downloadLabel = (id: string) => {
|
||||||
switch (downloadStore.getDownloadStatus(id)) {
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
case DownloadStatus.QUEUED:
|
case DownloadStatus.QUEUED:
|
||||||
@@ -323,14 +506,6 @@ export function MyShowsPage() {
|
|||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
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 showTitle = (f: Feed) => f.customName || f.podcast.title;
|
||||||
|
|
||||||
const currentLabel = () =>
|
const currentLabel = () =>
|
||||||
@@ -349,18 +524,21 @@ export function MyShowsPage() {
|
|||||||
{(feed, index) => {
|
{(feed, index) => {
|
||||||
const lf = () => nav.depthFocus(0);
|
const lf = () => nav.depthFocus(0);
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
|
const focused = () => index() === lf();
|
||||||
|
const fg = () =>
|
||||||
|
focused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf(), false)}
|
backgroundColor={focused() ? theme.border : undefined}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), lf(), false)}>
|
<text fg={fg()}>{focused() ? marker() : " "}</text>
|
||||||
{index() === lf() ? marker() : " "}
|
<text fg={fg()}>{showTitle(feed)}</text>
|
||||||
</text>
|
|
||||||
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
|
|
||||||
<text fg={muted()}>({feed.episodes.length})</text>
|
<text fg={muted()}>({feed.episodes.length})</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
@@ -385,51 +563,28 @@ export function MyShowsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={shows()}>
|
<For each={shows()}>
|
||||||
{(feed, index) => {
|
{(feed, index) => (
|
||||||
const lf = () => focusedShowIdx();
|
<ShowRow
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
feed={feed}
|
||||||
const wlScope =
|
title={showTitle(feed)}
|
||||||
app.state().preferences.autoDownloadScope === "whitelist";
|
index={index}
|
||||||
const wlInList = (
|
focused={focusedShowIdx}
|
||||||
app.state().preferences.autoDownloadWhitelist ?? []
|
active={isActive}
|
||||||
).includes(feed.id);
|
marker={marker}
|
||||||
return (
|
wlScope={() =>
|
||||||
<box
|
app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
ref={ref}
|
}
|
||||||
flexDirection="row"
|
wlInList={() =>
|
||||||
gap={1}
|
(app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
||||||
paddingRight={1}
|
feed.id,
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
)
|
||||||
|
}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 0);
|
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>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
<Show when={unsubs().length > 0}>
|
<Show when={unsubs().length > 0}>
|
||||||
<box paddingLeft={1} paddingTop={1}>
|
<box paddingLeft={1} paddingTop={1}>
|
||||||
@@ -438,62 +593,21 @@ export function MyShowsPage() {
|
|||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<For each={unsubs()}>
|
<For each={unsubs()}>
|
||||||
{(d, index) => {
|
{(d, index) => (
|
||||||
// Rows continue after the shows list.
|
<UnsubscribedRow
|
||||||
const rowIdx = () => shows().length + index();
|
d={d}
|
||||||
const lf = () => nav.depthFocus(0);
|
index={() => shows().length + index()}
|
||||||
const ref = useScrollIntoView(() => rowIdx() === lf());
|
focused={() => nav.depthFocus(0)}
|
||||||
return (
|
active={isActive}
|
||||||
<box
|
marker={marker}
|
||||||
ref={ref}
|
downloadLabel={() => downloadLabel(d.episodeId)}
|
||||||
flexDirection="column"
|
downloadColor={() => downloadColor(d.episodeId)}
|
||||||
gap={0}
|
|
||||||
paddingRight={1}
|
|
||||||
backgroundColor={focusBg(rowIdx(), lf(), isActive())}
|
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(rowIdx(), 0);
|
nav.setDepthFocus(shows().length + index(), 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>
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -509,98 +623,37 @@ export function MyShowsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(ep, index) => {
|
{(ep, index) => (
|
||||||
const lf = () => focusedEpIdx();
|
<EpisodeRow
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
episode={ep}
|
||||||
return (
|
index={index}
|
||||||
<box
|
focused={focusedEpIdx}
|
||||||
ref={ref}
|
active={isActive}
|
||||||
flexDirection="column"
|
selected={() => nav.isSelected(ep.id)}
|
||||||
gap={0}
|
downloadLabel={() => downloadLabel(ep.id)}
|
||||||
paddingRight={1}
|
downloadColor={() => downloadColor(ep.id)}
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
marker={marker}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 1);
|
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>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
<Show when={showFetchMore()}>
|
<Show when={showFetchMore()}>
|
||||||
<box
|
<FetchMoreRow
|
||||||
ref={moreRef}
|
index={() => episodes().length}
|
||||||
flexDirection="row"
|
focused={focusedRow}
|
||||||
gap={1}
|
onMore={focusedOnMore}
|
||||||
paddingRight={1}
|
active={isActive}
|
||||||
backgroundColor={focusBg(
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
episodes().length,
|
nerd={nerd}
|
||||||
focusedRow(),
|
marker={marker}
|
||||||
isActive(),
|
|
||||||
)}
|
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(episodes().length, 1);
|
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>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -608,6 +661,30 @@ export function MyShowsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── preview pane ───────────────────────────────────────────────────────────
|
// ── 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 = () =>
|
const previewContent = () =>
|
||||||
depth() === 0 ? (
|
depth() === 0 ? (
|
||||||
// depth 0 preview: hovered unsubscribed-show download, else the
|
// depth 0 preview: hovered unsubscribed-show download, else the
|
||||||
@@ -624,86 +701,34 @@ export function MyShowsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(show) => (
|
{(show) => (
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<ShowPreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
show={() => show()}
|
||||||
<strong>{showTitle(show())}</strong>
|
title={() => showTitle(show())}
|
||||||
</text>
|
hint={() => showHint(show())}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(d) => (
|
{(d) => (
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<UnsubscribedPreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
d={() => d()}
|
||||||
<strong>{d().episodeTitle ?? d().episodeId}</strong>
|
downloadLabel={() => downloadLabel(d().episodeId)}
|
||||||
</text>
|
downloadColor={() => downloadColor(d().episodeId)}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
) : (
|
) : (
|
||||||
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
||||||
<>
|
<>
|
||||||
<Show when={focusedOnMore()}>
|
<Show when={focusedOnMore()}>
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<FetchMorePreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
<strong>[Fetch More]</strong>
|
fetchMoreMode={fetchMoreMode}
|
||||||
</text>
|
manualText={() =>
|
||||||
<text fg={muted()}>
|
"Load the next batch of older episodes for this show (Enter)."
|
||||||
{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>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!focusedOnMore()}>
|
<Show when={!focusedOnMore()}>
|
||||||
<Show
|
<Show
|
||||||
@@ -715,47 +740,13 @@ export function MyShowsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(ep) => (
|
{(ep) => (
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<EpisodePreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
episode={() => ep()}
|
||||||
<strong>
|
author={() => selectedShow()?.podcast.author}
|
||||||
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
|
downloadLabel={() => downloadLabel(ep().id)}
|
||||||
{ep().title}
|
downloadColor={() => downloadColor(ep().id)}
|
||||||
</strong>
|
hint={() => episodeHint(ep().id)}
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -77,30 +77,15 @@ function SearchPage() {
|
|||||||
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
||||||
|
|
||||||
// ── input focusing ────────────────────────────────────────────────────────
|
// ── input focusing ────────────────────────────────────────────────────────
|
||||||
// `inputFocused` is true while the query input is being typed in. The Shell
|
// `inputFocused` tells the Shell router to yield keys to the query input.
|
||||||
// router yields keys to the <input> while this is true; Escape (in Shell)
|
// The input's REAL focus is the source of truth: useInputFocusNav flips
|
||||||
// sets it false so navigation resumes; `s` (search action) sets it true.
|
// 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 input's REAL focus is the source of truth for the flag:
|
// The depth stack only SEEDS it on transitions (re-entering depth 0
|
||||||
// useInputFocusNav (the same hook the Settings forms use) flips
|
// focuses the input; mounting at depth 1 stays list-nav), gated on the
|
||||||
// `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag
|
// depth VALUE via a memo because setDepthFocus also writes the stack
|
||||||
// and the renderable in lockstep. That matters when the user clicks OFF the
|
// signal — without the memo every j/k at query depth re-focuses the input
|
||||||
// input: opentui's mouse dispatch auto-focuses the clicked target's nearest
|
// and strands the recents list.
|
||||||
// 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.
|
|
||||||
onMount(() => nav.setInputFocused(depth() === 0));
|
onMount(() => nav.setInputFocused(depth() === 0));
|
||||||
onCleanup(() => nav.setInputFocused(false));
|
onCleanup(() => nav.setInputFocused(false));
|
||||||
const focusNavRef = useInputFocusNav();
|
const focusNavRef = useInputFocusNav();
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ export function SettingsPage() {
|
|||||||
function open() {
|
function open() {
|
||||||
const d = depth();
|
const d = depth();
|
||||||
if (d === 0) {
|
if (d === 0) {
|
||||||
// drill into the focused section's items
|
|
||||||
const id = focusedSection().id;
|
const id = focusedSection().id;
|
||||||
nav.pushDepth({
|
nav.pushDepth({
|
||||||
kind: `settings:${id}`,
|
kind: `settings:${id}`,
|
||||||
@@ -206,7 +205,6 @@ export function SettingsPage() {
|
|||||||
function step(delta: number) {
|
function step(delta: number) {
|
||||||
const d = depth();
|
const d = depth();
|
||||||
if (d === 2) {
|
if (d === 2) {
|
||||||
// editor: j/k nudges the value
|
|
||||||
const it = editorItem();
|
const it = editorItem();
|
||||||
if (it?.kind === "number" || it?.kind === "select")
|
if (it?.kind === "number" || it?.kind === "select")
|
||||||
it.cycle?.(delta as -1 | 1);
|
it.cycle?.(delta as -1 | 1);
|
||||||
@@ -220,7 +218,6 @@ export function SettingsPage() {
|
|||||||
pane: PaneId;
|
pane: PaneId;
|
||||||
mode: NavMode;
|
mode: NavMode;
|
||||||
}) => {
|
}) => {
|
||||||
// ignore actions meant for non-center panes
|
|
||||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||||
const handler = PAGE_ACTIONS[data.action];
|
const handler = PAGE_ACTIONS[data.action];
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
|
|
||||||
/** Create activity store */
|
|
||||||
function createActivityStore() {
|
function createActivityStore() {
|
||||||
const [count, setCount] = createSignal(0);
|
const [count, setCount] = createSignal(0);
|
||||||
const [labels, setLabels] = createSignal<string[]>([]);
|
const [labels, setLabels] = createSignal<string[]>([]);
|
||||||
@@ -64,7 +63,6 @@ function createActivityStore() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton activity store */
|
|
||||||
let activityStoreInstance: ReturnType<typeof createActivityStore> | null = null;
|
let activityStoreInstance: ReturnType<typeof createActivityStore> | null = null;
|
||||||
|
|
||||||
export function useActivityStore() {
|
export function useActivityStore() {
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ import {
|
|||||||
saveAudioNavToFile,
|
saveAudioNavToFile,
|
||||||
} from "../utils/app-persistence";
|
} from "../utils/app-persistence";
|
||||||
|
|
||||||
/** Source type for audio navigation */
|
|
||||||
export enum AudioSource {
|
export enum AudioSource {
|
||||||
FEED = "feed",
|
FEED = "feed",
|
||||||
MY_SHOWS = "my_shows",
|
MY_SHOWS = "my_shows",
|
||||||
SEARCH = "search",
|
SEARCH = "search",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Audio navigation state */
|
|
||||||
export interface AudioNavState {
|
export interface AudioNavState {
|
||||||
/** Current source type */
|
/** Current source type */
|
||||||
source: AudioSource;
|
source: AudioSource;
|
||||||
@@ -28,14 +26,12 @@ export interface AudioNavState {
|
|||||||
lastUpdated: Date;
|
lastUpdated: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default navigation state */
|
|
||||||
const defaultNavState: AudioNavState = {
|
const defaultNavState: AudioNavState = {
|
||||||
source: AudioSource.FEED,
|
source: AudioSource.FEED,
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Create audio navigation store */
|
|
||||||
function createAudioNavStore() {
|
function createAudioNavStore() {
|
||||||
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
||||||
|
|
||||||
@@ -56,12 +52,10 @@ function createAudioNavStore() {
|
|||||||
init();
|
init();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** Get current navigation state */
|
|
||||||
get state(): AudioNavState {
|
get state(): AudioNavState {
|
||||||
return navState();
|
return navState();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Update source type */
|
|
||||||
setSource: (source: AudioSource, podcastId?: string) => {
|
setSource: (source: AudioSource, podcastId?: string) => {
|
||||||
setNavState((prev) => ({
|
setNavState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -72,7 +66,6 @@ function createAudioNavStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Move to next episode */
|
|
||||||
next: (currentIndex: number) => {
|
next: (currentIndex: number) => {
|
||||||
setNavState((prev) => ({
|
setNavState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -82,7 +75,6 @@ function createAudioNavStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Move to previous episode */
|
|
||||||
prev: (currentIndex: number) => {
|
prev: (currentIndex: number) => {
|
||||||
setNavState((prev) => ({
|
setNavState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -92,23 +84,19 @@ function createAudioNavStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Reset to default state */
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
setNavState(defaultNavState);
|
setNavState(defaultNavState);
|
||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get current index */
|
|
||||||
getCurrentIndex: (): number => {
|
getCurrentIndex: (): number => {
|
||||||
return navState().currentIndex;
|
return navState().currentIndex;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get current source */
|
|
||||||
getSource: (): AudioSource => {
|
getSource: (): AudioSource => {
|
||||||
return navState().source;
|
return navState().source;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get current podcast ID */
|
|
||||||
getPodcastId: (): string | undefined => {
|
getPodcastId: (): string | undefined => {
|
||||||
return navState().podcastId;
|
return navState().podcastId;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ function syncSubscriptionState(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create discover store */
|
|
||||||
export function createDiscoverStore() {
|
export function createDiscoverStore() {
|
||||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
||||||
const [isLoading, setIsLoading] = createSignal(false);
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
@@ -107,7 +106,6 @@ export function createDiscoverStore() {
|
|||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// Skip if cache is still fresh
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
|
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
|
||||||
syncSubscriptions();
|
syncSubscriptions();
|
||||||
@@ -131,7 +129,6 @@ export function createDiscoverStore() {
|
|||||||
cachedAt = now;
|
cachedAt = now;
|
||||||
setPodcasts(fetched);
|
setPodcasts(fetched);
|
||||||
|
|
||||||
// Reflect current feed-store subscriptions
|
|
||||||
syncSubscriptions();
|
syncSubscriptions();
|
||||||
} catch {
|
} catch {
|
||||||
// Network failure — keep whatever we have (stale or empty)
|
// Network failure — keep whatever we have (stale or empty)
|
||||||
@@ -140,7 +137,6 @@ export function createDiscoverStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get filtered podcasts by category */
|
|
||||||
const filteredPodcasts = () => {
|
const filteredPodcasts = () => {
|
||||||
const category = selectedCategory();
|
const category = selectedCategory();
|
||||||
if (category === "all") {
|
if (category === "all") {
|
||||||
@@ -155,7 +151,6 @@ export function createDiscoverStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Subscribe to a podcast */
|
|
||||||
const subscribe = (podcastId: string) => {
|
const subscribe = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast) {
|
if (podcast) {
|
||||||
@@ -168,7 +163,6 @@ export function createDiscoverStore() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Unsubscribe from a podcast */
|
|
||||||
const unsubscribe = (podcastId: string) => {
|
const unsubscribe = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast) {
|
if (podcast) {
|
||||||
@@ -180,7 +174,6 @@ export function createDiscoverStore() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Toggle subscription */
|
|
||||||
const toggleSubscription = (podcastId: string) => {
|
const toggleSubscription = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast?.isSubscribed) {
|
if (podcast?.isSubscribed) {
|
||||||
@@ -207,7 +200,6 @@ export function createDiscoverStore() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton discover store */
|
|
||||||
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
|
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
|
||||||
|
|
||||||
export function useDiscoverStore() {
|
export function useDiscoverStore() {
|
||||||
|
|||||||
@@ -63,7 +63,62 @@ interface QueueItem {
|
|||||||
episodeTitle: string;
|
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() {
|
function createDownloadStore() {
|
||||||
const [downloads, setDownloads] = createSignal<
|
const [downloads, setDownloads] = createSignal<
|
||||||
Map<string, DownloadedEpisode>
|
Map<string, DownloadedEpisode>
|
||||||
@@ -195,7 +250,6 @@ function createDownloadStore() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Execute a single download */
|
|
||||||
async function executeDownload(item: QueueItem): Promise<void> {
|
async function executeDownload(item: QueueItem): Promise<void> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortControllers.set(item.episodeId, controller);
|
abortControllers.set(item.episodeId, controller);
|
||||||
@@ -236,70 +290,23 @@ function createDownloadStore() {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Write the podcast cover beside the audio so mpv's
|
// Decorate the local file: cover art + ID3 tags (see the
|
||||||
// --cover-art-auto=exact picks it up for Now Playing art when the
|
// module-level helpers above) — the source streams carry neither.
|
||||||
// local file plays (same basename, .jpg extension — verified
|
// Cover falls back to the episode's own image when the feed has
|
||||||
// against mpv 0.41). curl, NOT fetch: Bun's fetch hangs in
|
// no channel cover (URL-added feeds).
|
||||||
// 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).
|
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const episode = feedStore.findEpisode(item.episodeId);
|
const episode = feedStore.findEpisode(item.episodeId);
|
||||||
const coverUrl =
|
const feed = feedStore.feeds().find((f) => f.id === item.feedId);
|
||||||
feedStore
|
const coverUrl = feed?.podcast.coverUrl ?? episode?.imageUrl;
|
||||||
.feeds()
|
if (result.filePath && coverUrl) {
|
||||||
.find((f) => f.id === item.feedId)?.podcast.coverUrl ??
|
writeCoverArt(result.filePath, 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(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
if (result.filePath && episode) {
|
||||||
const podcastTitle =
|
const podcastTitle =
|
||||||
feedStore.feeds().find((f) => f.id === item.feedId)?.podcast.title ??
|
feed?.podcast.title ??
|
||||||
downloads().get(item.episodeId)?.podcastTitle;
|
downloads().get(item.episodeId)?.podcastTitle;
|
||||||
if (podcastTitle) {
|
if (podcastTitle) {
|
||||||
const tmp = `${result.filePath}.tag.mp3`;
|
tagLocalFile(result.filePath, episode, podcastTitle);
|
||||||
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(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -315,22 +322,18 @@ function createDownloadStore() {
|
|||||||
processQueue();
|
processQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get download status for an episode */
|
|
||||||
const getDownloadStatus = (episodeId: string): DownloadStatus => {
|
const getDownloadStatus = (episodeId: string): DownloadStatus => {
|
||||||
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
|
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get download progress for an episode (0-100) */
|
|
||||||
const getDownloadProgress = (episodeId: string): number => {
|
const getDownloadProgress = (episodeId: string): number => {
|
||||||
return downloads().get(episodeId)?.progress ?? 0;
|
return downloads().get(episodeId)?.progress ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get full download info for an episode */
|
|
||||||
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
|
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
|
||||||
return downloads().get(episodeId);
|
return downloads().get(episodeId);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get the local file path for a completed download */
|
|
||||||
const getDownloadedFilePath = (episodeId: string): string | null => {
|
const getDownloadedFilePath = (episodeId: string): string | null => {
|
||||||
const dl = downloads().get(episodeId);
|
const dl = downloads().get(episodeId);
|
||||||
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
|
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
|
||||||
@@ -347,7 +350,6 @@ function createDownloadStore() {
|
|||||||
podcastFeedUrl?: string;
|
podcastFeedUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start downloading an episode */
|
|
||||||
const startDownload = (
|
const startDownload = (
|
||||||
episode: Episode,
|
episode: Episode,
|
||||||
feedId: string,
|
feedId: string,
|
||||||
@@ -411,7 +413,6 @@ function createDownloadStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Cancel a download */
|
|
||||||
const cancelDownload = (episodeId: string): void => {
|
const cancelDownload = (episodeId: string): void => {
|
||||||
// Abort active download
|
// Abort active download
|
||||||
const controller = abortControllers.get(episodeId);
|
const controller = abortControllers.get(episodeId);
|
||||||
@@ -432,7 +433,6 @@ function createDownloadStore() {
|
|||||||
saveDownloads().catch(() => {});
|
saveDownloads().catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Remove a completed download (delete file and metadata) */
|
|
||||||
const removeDownload = async (episodeId: string): Promise<void> => {
|
const removeDownload = async (episodeId: string): Promise<void> => {
|
||||||
const dl = downloads().get(episodeId);
|
const dl = downloads().get(episodeId);
|
||||||
if (dl?.filePath) {
|
if (dl?.filePath) {
|
||||||
@@ -478,7 +478,6 @@ function createDownloadStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get all downloads as an array */
|
|
||||||
const getAllDownloads = (): DownloadedEpisode[] => {
|
const getAllDownloads = (): DownloadedEpisode[] => {
|
||||||
return Array.from(downloads().values());
|
return Array.from(downloads().values());
|
||||||
};
|
};
|
||||||
@@ -501,12 +500,10 @@ function createDownloadStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get the current queue */
|
|
||||||
const getQueue = (): QueueItem[] => {
|
const getQueue = (): QueueItem[] => {
|
||||||
return queue();
|
return queue();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get count of active downloads */
|
|
||||||
const getActiveCount = (): number => {
|
const getActiveCount = (): number => {
|
||||||
return activeCount();
|
return activeCount();
|
||||||
};
|
};
|
||||||
@@ -531,7 +528,6 @@ function createDownloadStore() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton download store */
|
|
||||||
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
|
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
|
||||||
|
|
||||||
export function useDownloadStore() {
|
export function useDownloadStore() {
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
|
|||||||
* feeds) can't stall the renderer. */
|
* feeds) can't stall the renderer. */
|
||||||
const PARSE_CHUNK_SIZE = 5;
|
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
|
/** Yield to the event loop (task queue) so the renderer can paint between
|
||||||
* parse chunks. MessageChannel instead of setTimeout/setImmediate because
|
* parse chunks. MessageChannel instead of setTimeout/setImmediate because
|
||||||
* bun:test fake timers trap those (feed-refresh/pagination tests run under
|
* bun:test fake timers trap those (feed-refresh/pagination tests run under
|
||||||
@@ -214,7 +224,6 @@ async function mapWithConcurrency<T, R>(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create feed store */
|
|
||||||
function createFeedStore() {
|
function createFeedStore() {
|
||||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||||
@@ -262,7 +271,6 @@ function createFeedStore() {
|
|||||||
saveFeeds(feeds());
|
saveFeeds(feeds());
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get filtered and sorted feeds */
|
|
||||||
const getFilteredFeeds = (): Feed[] => {
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
let result = [...feeds()];
|
let result = [...feeds()];
|
||||||
const f = filter();
|
const f = filter();
|
||||||
@@ -320,7 +328,6 @@ function createFeedStore() {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get episodes in reverse chronological order across all feeds */
|
|
||||||
const getAllEpisodesChronological = (): Array<{
|
const getAllEpisodesChronological = (): Array<{
|
||||||
episode: Episode;
|
episode: Episode;
|
||||||
feed: Feed;
|
feed: Feed;
|
||||||
@@ -341,7 +348,6 @@ function createFeedStore() {
|
|||||||
return allEpisodes;
|
return allEpisodes;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Sort episodes in reverse chronological order (newest first) */
|
|
||||||
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
|
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
|
||||||
return [...episodes].sort(
|
return [...episodes].sort(
|
||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||||
@@ -384,8 +390,14 @@ function createFeedStore() {
|
|||||||
if (feedId) {
|
if (feedId) {
|
||||||
// Cache the FULL parse — the bound is applied when reading,
|
// Cache the FULL parse — the bound is applied when reading,
|
||||||
// not when writing, so a preference change takes effect
|
// not when writing, so a preference change takes effect
|
||||||
// without a refetch.
|
// without a refetch. Capped at MAX_CACHED_EPISODES_PER_FEED
|
||||||
fullEpisodeCache.set(feedId, allEpisodes);
|
// 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.
|
// 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 => {
|
const hasFeedByUrl = (feedUrl: string): boolean => {
|
||||||
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
|
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
|
||||||
};
|
};
|
||||||
@@ -675,7 +686,6 @@ function createFeedStore() {
|
|||||||
};
|
};
|
||||||
scheduleNextRefresh();
|
scheduleNextRefresh();
|
||||||
|
|
||||||
/** Remove a feed */
|
|
||||||
const removeFeed = (feedId: string) => {
|
const removeFeed = (feedId: string) => {
|
||||||
fullEpisodeCache.delete(feedId);
|
fullEpisodeCache.delete(feedId);
|
||||||
episodeLoadCount.delete(feedId);
|
episodeLoadCount.delete(feedId);
|
||||||
@@ -706,7 +716,6 @@ function createFeedStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Update a feed */
|
|
||||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
@@ -717,7 +726,6 @@ function createFeedStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Toggle feed pinned status */
|
|
||||||
const togglePinned = (feedId: string) => {
|
const togglePinned = (feedId: string) => {
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
@@ -728,7 +736,6 @@ function createFeedStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Add a source */
|
|
||||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||||
const newSource: PodcastSource = {
|
const newSource: PodcastSource = {
|
||||||
...source,
|
...source,
|
||||||
@@ -742,7 +749,6 @@ function createFeedStore() {
|
|||||||
return newSource;
|
return newSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Update a source */
|
|
||||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
||||||
setSources((prev) => {
|
setSources((prev) => {
|
||||||
const updated = prev.map((source) =>
|
const updated = prev.map((source) =>
|
||||||
@@ -753,7 +759,6 @@ function createFeedStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Remove a source */
|
|
||||||
const removeSource = (sourceId: string) => {
|
const removeSource = (sourceId: string) => {
|
||||||
// Don't remove default sources
|
// Don't remove default sources
|
||||||
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
|
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
|
||||||
@@ -766,7 +771,6 @@ function createFeedStore() {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Toggle source enabled status */
|
|
||||||
const toggleSource = (sourceId: string) => {
|
const toggleSource = (sourceId: string) => {
|
||||||
setSources((prev) => {
|
setSources((prev) => {
|
||||||
const updated = prev.map((s) =>
|
const updated = prev.map((s) =>
|
||||||
@@ -777,7 +781,6 @@ function createFeedStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get feed by ID */
|
|
||||||
const getFeed = (feedId: string): Feed | undefined => {
|
const getFeed = (feedId: string): Feed | undefined => {
|
||||||
return feeds().find((f) => f.id === feedId);
|
return feeds().find((f) => f.id === feedId);
|
||||||
};
|
};
|
||||||
@@ -792,7 +795,6 @@ function createFeedStore() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get selected feed */
|
|
||||||
const getSelectedFeed = (): Feed | undefined => {
|
const getSelectedFeed = (): Feed | undefined => {
|
||||||
const id = selectedFeedId();
|
const id = selectedFeedId();
|
||||||
return id ? getFeed(id) : undefined;
|
return id ? getFeed(id) : undefined;
|
||||||
@@ -853,6 +855,9 @@ function createFeedStore() {
|
|||||||
// is its own sync block).
|
// is its own sync block).
|
||||||
await yieldToUI();
|
await yieldToUI();
|
||||||
cached = sortEpisodesReverseChronological(cached);
|
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);
|
fullEpisodeCache.set(feedId, cached);
|
||||||
// Set current load count to match what's already displayed
|
// Set current load count to match what's already displayed
|
||||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
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 => {
|
const hasMoreAcrossAll = (): boolean => {
|
||||||
return feeds().some((f) => hasMoreEpisodes(f.id));
|
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 => {
|
const runAutoDownloadNow = (): void => {
|
||||||
runAutoDownload();
|
runAutoDownload();
|
||||||
};
|
};
|
||||||
@@ -969,7 +972,6 @@ function createFeedStore() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton feed store */
|
|
||||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
||||||
|
|
||||||
export function useFeedStore() {
|
export function useFeedStore() {
|
||||||
|
|||||||
@@ -64,16 +64,10 @@ function createProgressStore() {
|
|||||||
*/
|
*/
|
||||||
whenReady: () => progressInit,
|
whenReady: () => progressInit,
|
||||||
|
|
||||||
/**
|
|
||||||
* Get progress for a specific episode.
|
|
||||||
*/
|
|
||||||
get(episodeId: string): Progress | undefined {
|
get(episodeId: string): Progress | undefined {
|
||||||
return progressMap()[episodeId];
|
return progressMap()[episodeId];
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all progress entries.
|
|
||||||
*/
|
|
||||||
all(): Record<string, Progress> {
|
all(): Record<string, Progress> {
|
||||||
return progressMap();
|
return progressMap();
|
||||||
},
|
},
|
||||||
@@ -102,18 +96,12 @@ function createProgressStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an episode is completed.
|
|
||||||
*/
|
|
||||||
isCompleted(episodeId: string): boolean {
|
isCompleted(episodeId: string): boolean {
|
||||||
const p = progressMap()[episodeId];
|
const p = progressMap()[episodeId];
|
||||||
if (!p || p.duration <= 0) return false;
|
if (!p || p.duration <= 0) return false;
|
||||||
return p.position / p.duration >= COMPLETION_THRESHOLD;
|
return p.position / p.duration >= COMPLETION_THRESHOLD;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Get progress percentage (0-100) for an episode.
|
|
||||||
*/
|
|
||||||
getPercent(episodeId: string): number {
|
getPercent(episodeId: string): number {
|
||||||
const p = progressMap()[episodeId];
|
const p = progressMap()[episodeId];
|
||||||
if (!p || p.duration <= 0) return 0;
|
if (!p || p.duration <= 0) return 0;
|
||||||
@@ -151,9 +139,6 @@ function createProgressStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all progress data.
|
|
||||||
*/
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
setProgressMap({});
|
setProgressMap({});
|
||||||
persist();
|
persist();
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ function saveScope(scope: SearchScope): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create search store */
|
|
||||||
export function createSearchStore() {
|
export function createSearchStore() {
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const [query, setQuery] = createSignal("");
|
const [query, setQuery] = createSignal("");
|
||||||
@@ -167,7 +166,6 @@ export function createSearchStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Add query to history */
|
|
||||||
const addToHistory = (q: string) => {
|
const addToHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const updated = sanitizeHistory([q, ...prev]);
|
const updated = sanitizeHistory([q, ...prev]);
|
||||||
@@ -176,13 +174,11 @@ export function createSearchStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Clear search history */
|
|
||||||
const clearHistory = () => {
|
const clearHistory = () => {
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
saveSearchHistoryToFile([]);
|
saveSearchHistoryToFile([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Remove single history item */
|
|
||||||
const removeFromHistory = (q: string) => {
|
const removeFromHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const updated = prev.filter((h) => h !== q);
|
const updated = prev.filter((h) => h !== q);
|
||||||
@@ -191,14 +187,12 @@ export function createSearchStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Clear results */
|
|
||||||
const clearResults = () => {
|
const clearResults = () => {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Mark a podcast as subscribed in results */
|
|
||||||
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
||||||
setResults((prev) =>
|
setResults((prev) =>
|
||||||
prev.map((result) => {
|
prev.map((result) => {
|
||||||
@@ -262,7 +256,6 @@ export function createSearchStore() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton search store */
|
|
||||||
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
|
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
|
||||||
|
|
||||||
export function useSearchStore() {
|
export function useSearchStore() {
|
||||||
|
|||||||
@@ -156,9 +156,6 @@ function init() {
|
|||||||
setRegistrations((arr) => arr.filter((x) => x !== results));
|
setRegistrations((arr) => arr.filter((x) => x !== results));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Get all visible options.
|
|
||||||
*/
|
|
||||||
get options() {
|
get options() {
|
||||||
return visibleOptions();
|
return visibleOptions();
|
||||||
},
|
},
|
||||||
@@ -195,9 +192,6 @@ export function CommandProvider(props: ParentProps) {
|
|||||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Command palette dialog component.
|
|
||||||
*/
|
|
||||||
function CommandDialog(props: {
|
function CommandDialog(props: {
|
||||||
options: CommandOption[];
|
options: CommandOption[];
|
||||||
suggestedOptions: CommandOption[];
|
suggestedOptions: CommandOption[];
|
||||||
|
|||||||
@@ -98,9 +98,6 @@ function init() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/**
|
|
||||||
* Clear all dialogs from the stack.
|
|
||||||
*/
|
|
||||||
clear() {
|
clear() {
|
||||||
for (const item of store.stack) {
|
for (const item of store.stack) {
|
||||||
if (item.onClose) item.onClose()
|
if (item.onClose) item.onClose()
|
||||||
@@ -113,9 +110,6 @@ function init() {
|
|||||||
emit("dialog.close", {})
|
emit("dialog.close", {})
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace all dialogs with a new one.
|
|
||||||
*/
|
|
||||||
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
||||||
if (store.stack.length === 0) {
|
if (store.stack.length === 0) {
|
||||||
focus = renderer.currentFocusedRenderable
|
focus = renderer.currentFocusedRenderable
|
||||||
@@ -130,9 +124,6 @@ function init() {
|
|||||||
emit("dialog.open", { dialogId: "dialog" })
|
emit("dialog.open", { dialogId: "dialog" })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Push a new dialog onto the stack.
|
|
||||||
*/
|
|
||||||
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
||||||
if (store.stack.length === 0) {
|
if (store.stack.length === 0) {
|
||||||
focus = renderer.currentFocusedRenderable
|
focus = renderer.currentFocusedRenderable
|
||||||
@@ -143,9 +134,6 @@ function init() {
|
|||||||
emit("dialog.open", { dialogId: "dialog" })
|
emit("dialog.open", { dialogId: "dialog" })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Pop the top dialog from the stack.
|
|
||||||
*/
|
|
||||||
pop() {
|
pop() {
|
||||||
if (store.stack.length === 0) return
|
if (store.stack.length === 0) return
|
||||||
const current = store.stack.at(-1)!
|
const current = store.stack.at(-1)!
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ const defaultState: AppState = {
|
|||||||
|
|
||||||
// ── App State (config.json) ─────────────────────────────────────────────────
|
// ── App State (config.json) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Load app state from config.json */
|
|
||||||
export async function loadAppStateFromFile(): Promise<AppState> {
|
export async function loadAppStateFromFile(): Promise<AppState> {
|
||||||
try {
|
try {
|
||||||
const cfg = await loadConfig();
|
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 {
|
export function saveAppStateToFile(state: AppState): void {
|
||||||
updateConfig({
|
updateConfig({
|
||||||
settings: state.settings,
|
settings: state.settings,
|
||||||
@@ -109,7 +107,6 @@ interface ProgressEntry {
|
|||||||
playbackSpeed?: number;
|
playbackSpeed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load progress map from JSON file */
|
|
||||||
export async function loadProgressFromFile(): Promise<
|
export async function loadProgressFromFile(): Promise<
|
||||||
Record<string, ProgressEntry>
|
Record<string, ProgressEntry>
|
||||||
> {
|
> {
|
||||||
@@ -145,7 +142,6 @@ export function saveProgressToFile(data: Record<string, unknown>): void {
|
|||||||
|
|
||||||
const SEARCH_HISTORY_FILE = "search-history.json";
|
const SEARCH_HISTORY_FILE = "search-history.json";
|
||||||
|
|
||||||
/** Load search history from JSON file */
|
|
||||||
export async function loadSearchHistoryFromFile(): Promise<string[]> {
|
export async function loadSearchHistoryFromFile(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE));
|
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 {
|
export function saveSearchHistoryToFile(history: string[]): void {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -178,7 +173,6 @@ export function saveSearchHistoryToFile(history: string[]): void {
|
|||||||
|
|
||||||
const AUDIO_NAV_FILE = "audio-nav.json";
|
const AUDIO_NAV_FILE = "audio-nav.json";
|
||||||
|
|
||||||
/** Load audio navigation state from JSON file */
|
|
||||||
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
|
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
|
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
|
||||||
|
|||||||
@@ -23,9 +23,16 @@
|
|||||||
* pass over just that region) — earlier segments stay valid, mp3 decode of
|
* pass over just that region) — earlier segments stay valid, mp3 decode of
|
||||||
* the same file is deterministic so abutting segments agree.
|
* 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),
|
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min. The cache is a
|
||||||
* freed on stop(). 22050 Hz covers Nyquist 11 kHz, above the default 10 kHz
|
* SLIDING WINDOW around the playback position — the decode pass stops
|
||||||
* high-cutoff of the visualizer's FFT config.
|
* 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
|
* Downloads via ffmpeg's own http stack with reconnect flags, matching the
|
||||||
* old reader; local files skip them (ffmpeg rejects http-only options for
|
* 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;
|
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.
|
* Monotonically increasing generation counter.
|
||||||
* Each startDecode() increments this; the read loop checks it to know
|
* Each startDecode() increments this; the read loop checks it to know
|
||||||
@@ -73,6 +98,10 @@ export interface EpisodePcmCacheOptions {
|
|||||||
url: string;
|
url: string;
|
||||||
/** Sample rate (default: 22050) */
|
/** Sample rate (default: 22050) */
|
||||||
sampleRate?: number;
|
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 {
|
export class EpisodePcmCache {
|
||||||
@@ -84,10 +113,15 @@ export class EpisodePcmCache {
|
|||||||
private activeSegment: Segment | null = null;
|
private activeSegment: Segment | null = null;
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
readonly sampleRate: number;
|
readonly sampleRate: number;
|
||||||
|
/** Sliding-window budgets (see maintainWindow). */
|
||||||
|
readonly maxAheadSec: number;
|
||||||
|
readonly keepBehindSec: number;
|
||||||
|
|
||||||
constructor(options: EpisodePcmCacheOptions) {
|
constructor(options: EpisodePcmCacheOptions) {
|
||||||
this.url = options.url;
|
this.url = options.url;
|
||||||
this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE;
|
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. */
|
/** 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).
|
* new segment at `sec` (seek into a hole / resume past cached audio).
|
||||||
*/
|
*/
|
||||||
ensureDecodeAround(sec: number): void {
|
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
|
// 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.covers(sec)) {
|
||||||
if (this._decoding || this.decodeFinished) return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +308,48 @@ export class EpisodePcmCache {
|
|||||||
this.startDecode(Math.max(0, sec));
|
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`
|
* Read the PCM window ENDING at `atSec` of playback into `out`
|
||||||
* (Int16 magnitudes widened to f64, the scale cavacore expects).
|
* (Int16 magnitudes widened to f64, the scale cavacore expects).
|
||||||
@@ -275,6 +360,7 @@ export class EpisodePcmCache {
|
|||||||
*/
|
*/
|
||||||
readWindow(out: Float64Array, atSec: number): number {
|
readWindow(out: Float64Array, atSec: number): number {
|
||||||
if (out.length === 0) return 0;
|
if (out.length === 0) return 0;
|
||||||
|
this.maintainWindow(atSec);
|
||||||
const endIdx = Math.round(atSec * this.sampleRate);
|
const endIdx = Math.round(atSec * this.sampleRate);
|
||||||
const startIdx = endIdx - out.length + 1;
|
const startIdx = endIdx - out.length + 1;
|
||||||
for (const seg of this.segments) {
|
for (const seg of this.segments) {
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ const DEFAULTS: Required<CavaCoreConfig> = {
|
|||||||
scalingMode: 0,
|
scalingMode: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
type CavaLib = {
|
type CavaLib = {
|
||||||
symbols: Record<string, (...args: any[]) => any>;
|
symbols: Record<string, (...args: any[]) => any>;
|
||||||
close(): void;
|
close(): void;
|
||||||
@@ -102,7 +101,6 @@ export class CavaCore {
|
|||||||
this.lib = lib;
|
this.lib = lib;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Number of frequency bars configured. */
|
|
||||||
get bars(): number {
|
get bars(): number {
|
||||||
return this._bars;
|
return this._bars;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ function createEventBus(): EventBusInstance {
|
|||||||
}
|
}
|
||||||
handlers.get(event)!.add(handler as EventHandler);
|
handlers.get(event)!.add(handler as EventHandler);
|
||||||
|
|
||||||
// Return unsubscribe function
|
|
||||||
return () => {
|
return () => {
|
||||||
this.off(event, handler);
|
this.off(event, handler);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -134,7 +134,6 @@ export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
|
|||||||
}
|
}
|
||||||
})().catch(() => {});
|
})().catch(() => {});
|
||||||
}
|
}
|
||||||
/** Load sources from config.json */
|
|
||||||
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||||
try {
|
try {
|
||||||
const cfg = await loadConfig();
|
const cfg = await loadConfig();
|
||||||
@@ -144,7 +143,6 @@ export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** Save sources to config.json */
|
|
||||||
export function saveSourcesToFile<T>(sources: T[]): void {
|
export function saveSourcesToFile<T>(sources: T[]): void {
|
||||||
updateConfig({ sources: sources as unknown as PodcastSource[] });
|
updateConfig({ sources: sources as unknown as PodcastSource[] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,6 @@
|
|||||||
* and multi-line comments, which is useful for configuration files.
|
* and multi-line comments, which is useful for configuration files.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove JSONC comments from a string
|
|
||||||
*/
|
|
||||||
function stripComments(jsonString: string): string {
|
function stripComments(jsonString: string): string {
|
||||||
const comments = [
|
const comments = [
|
||||||
{ pattern: /\/\/.*$/gm, replacement: "" },
|
{ pattern: /\/\/.*$/gm, replacement: "" },
|
||||||
@@ -23,9 +20,6 @@ function stripComments(jsonString: string): string {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse JSONC string into a JavaScript object
|
|
||||||
*/
|
|
||||||
export function parseJSONC(jsonString: string): unknown {
|
export function parseJSONC(jsonString: string): unknown {
|
||||||
const stripped = stripComments(jsonString);
|
const stripped = stripComments(jsonString);
|
||||||
return JSON.parse(stripped);
|
return JSON.parse(stripped);
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load keybinds from JSONC file */
|
|
||||||
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
|
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(KEYBINDS_FILE);
|
const filePath = getConfigFilePath(KEYBINDS_FILE);
|
||||||
|
|||||||
@@ -9,23 +9,14 @@
|
|||||||
|
|
||||||
import { emit } from "./event-bus"
|
import { emit } from "./event-bus"
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit a theme reload event.
|
|
||||||
*/
|
|
||||||
function emitThemeReload(): void {
|
function emitThemeReload(): void {
|
||||||
emit("theme.reload", {})
|
emit("theme.reload", {})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit a theme changed event.
|
|
||||||
*/
|
|
||||||
export function emitThemeChanged(theme: string, mode: "dark" | "light"): void {
|
export function emitThemeChanged(theme: string, mode: "dark" | "light"): void {
|
||||||
emit("theme.changed", { theme, mode })
|
emit("theme.changed", { theme, mode })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit a theme mode changed event.
|
|
||||||
*/
|
|
||||||
export function emitThemeModeChanged(mode: "dark" | "light"): void {
|
export function emitThemeModeChanged(mode: "dark" | "light"): void {
|
||||||
emit("theme.mode.changed", { mode })
|
emit("theme.mode.changed", { mode })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Theme CSS Variable Manager
|
* Terminal Theme Resolver
|
||||||
* Handles dynamic theme switching by updating CSS custom properties
|
* Resolves the active theme (built-in, custom, or system-derived) to colors.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TerminalColors } from "@opentui/core";
|
import type { TerminalColors } from "@opentui/core";
|
||||||
import type { ThemeJson } from "../types/theme-schema";
|
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 { resolveTheme as resolveThemeJson } from "./theme-resolver";
|
||||||
import { generateSystemTheme } from "./system-theme";
|
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(
|
export function resolveTerminalTheme(
|
||||||
themes: Record<string, ThemeJson>,
|
themes: Record<string, ThemeJson>,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -32,9 +17,5 @@ export function resolveTerminalTheme(
|
|||||||
if (name === "system" && system) {
|
if (name === "system" && system) {
|
||||||
return resolveThemeJson(generateSystemTheme(system, mode), mode);
|
return resolveThemeJson(generateSystemTheme(system, mode), mode);
|
||||||
}
|
}
|
||||||
const theme = themes[name] ?? themes.catppuccin;
|
return resolveThemeJson(themes[name] ?? themes.catppuccin, mode);
|
||||||
if (!theme) {
|
|
||||||
return resolveThemeJson(THEME_JSON.catppuccin, mode);
|
|
||||||
}
|
|
||||||
return resolveThemeJson(theme, mode);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,3 +278,87 @@ test.skipIf(!hasFfmpeg)(
|
|||||||
},
|
},
|
||||||
{ timeout: 20000 },
|
{ 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 },
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user