diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index edfd56a..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -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"], -} diff --git a/AGENTS.md b/AGENTS.md index 20ba1e4..cebc811 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ - `bun tests/cavacore-smoke.ts` - Run specific native library smoke test ### Linting -- `bun run lint` - Run ESLint with TypeScript rules +- `bun run lint` - Run the TypeScript typecheck (`bun tsc --noEmit`) ## Code Style Guidelines diff --git a/bun.lockb b/bun.lockb index 3cf431d..bad57d5 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 5659716..1f7172e 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,6 @@ }, "devDependencies": { "@types/bun": "latest", - "@typescript-eslint/eslint-plugin": "^8.54.0", - "@typescript-eslint/parser": "^8.54.0", - "eslint": "^9.39.2", "typescript": "^5.9.3" }, "dependencies": { diff --git a/src/api/rss-parser.ts b/src/api/rss-parser.ts index c7fa0dd..5104dc6 100644 --- a/src/api/rss-parser.ts +++ b/src/api/rss-parser.ts @@ -100,24 +100,20 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis const epDescription = cleanField(getTagValue(item, "description")) const pubDate = new Date(getTagValue(item, "pubDate") || Date.now()) - // Audio URL + file size + MIME type from const enclosure = item.match(/]*url=["']([^"']+)["'][^>]*>/i) const audioUrl = enclosure?.[1] ?? "" const fileSizeStr = getAttr(item, "enclosure", "length") const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined const mimeType = getAttr(item, "enclosure", "type") || undefined - // Duration from const durationRaw = getTagValue(item, "itunes:duration") const duration = parseDuration(durationRaw) - // Episode & season numbers const episodeNumRaw = getTagValue(item, "itunes:episode") const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined const seasonNumRaw = getTagValue(item, "itunes:season") const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined - // Episode type & explicit const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType")) const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase() const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined @@ -135,7 +131,6 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis pubDate, } - // Only set optional fields if present if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber if (episodeType) ep.episodeType = episodeType diff --git a/src/components/EpisodeList.tsx b/src/components/EpisodeList.tsx new file mode 100644 index 0000000..9434477 --- /dev/null +++ b/src/components/EpisodeList.tsx @@ -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 ( + + + + {isFocused() ? props.marker() : " "} + + + {props.episode.episodeNumber ? `#${props.episode.episodeNumber} ` : ""} + {props.episode.title} + + + {/* 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 */} + + + + {props.subtitle?.()} + + + + + + {formatDate(props.episode.pubDate)} + + + {formatDuration(props.episode.duration)} + + + + ● + + + + + {props.downloadLabel()} + + + + + ); +} + +// ── 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 ( + + {props.onMore() ? props.marker() : " "} + {props.nerd && ( + {NF_ICONS.more} + )} + } + > + [Fetch More] + + + ); +} + +// ── 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 ( + + + + {props.episode().episodeNumber ? `#${props.episode().episodeNumber} ` : ""} + {props.episode().title} + + + + {formatDate(props.episode().pubDate)} + {formatDuration(props.episode().duration)} + + {props.downloadLabel()} + + + + {props.subtitle?.()} + + + by {props.author()} + + + + {props.episode().description?.slice(0, 400) ?? "No description available."} + {(props.episode().description?.length ?? 0) > 400 ? "…" : ""} + + + {props.hint()} + + ); +} + +// ── 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 ( + + + [Fetch More] + + + {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()} + + + enter: load more · h back + + ); +} diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index 0c18ebe..c57709b 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -169,7 +169,6 @@ export function Shell() { nav.backspaceCommand(); return; } - // printable char if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) { evt.preventDefault(); nav.appendCommand(evt.name); diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index ec09501..12820f7 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -10,7 +10,8 @@ import { generateSyntax, generateSubtleSyntax, } from "../utils/syntax-highlighter"; -import { resolveTerminalTheme, loadThemes } from "../utils/theme"; +import { resolveTerminalTheme } from "../utils/theme"; +import { getCustomThemes } from "../utils/custom-themes"; import { detectModeFromBackground } from "../utils/system-theme"; import { createSimpleContext } from "./helper"; import { @@ -175,7 +176,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ function init() { resolveSystemTheme(); - loadThemes() + getCustomThemes() .then((custom) => { setStore( produce((draft) => { @@ -187,7 +188,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ setStore("active", "catppuccin"); }) .finally(() => { - // Only set ready if not waiting for system theme if (store.active !== "system") { setStore("ready", true); } diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index a51f4f0..03ee1f3 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -297,6 +297,34 @@ function stopPolling(): void { // from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the // podcast cover to a temp file BEFORE playback starts, bounded to 3s. +/** Resolve cover art to a local path for mpv's --cover-art-files, per the + * call site's latency budget: + * "cache" — disk cache only (sync): resume paths must never wait on the + * network, so a miss plays artless and warms for next time. + * "bounded" — disk hit, else fetch capped at 1.2s: cold play needs the art + * at file LOAD, but a slow cover server must not stall audio. + * "await" — disk hit, else full (8s-bounded) fetch: boot restore preloads + * while feeds/progress load anyway, so the wait is free and the + * cover must be present when the file loads. + * fetchCoverArt already short-circuits on the disk cache, so "await" costs + * nothing on a warm cache. */ +async function resolveCoverArt( + coverUrl: string | undefined, + mode: "cache" | "bounded" | "await", +): Promise { + 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((resolve) => setTimeout(() => resolve(null), 1200)), + ]); + } + return fetchCoverArt(coverUrl); +} + async function play(episode: Episode): Promise { const b = ensureBackend(); setError(null); @@ -321,21 +349,15 @@ async function play(episode: Episode): Promise { // episode's own image (feeds added by URL may lack a channel cover). const downloadStore = useDownloadStore(); const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl; - const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl; // Cover art only applies at file LOAD (the runtime video-add fallback // never becomes an albumart track), so a cold-cache play must wait for // the fetch or play artless. Serve the disk cache synchronously; on a - // miss, await the single-flight fetch with a 1.2s cap (covers fetch in - // ~300ms typically) — past the cap, play bare and let the fetch warm - // the cache for next time. - let coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null; - if (coverUrl && !coverArtPath) { - const path = await Promise.race([ - fetchCoverArt(coverUrl), - new Promise((resolve) => setTimeout(() => resolve(null), 1200)), - ]); - if (path) coverArtPath = path; - } + // miss, await the bounded fetch (covers fetch in ~300ms typically) — + // past the 1.2s cap, play bare and let the fetch warm the cache. + const coverArtPath = await resolveCoverArt( + feed?.podcast.coverUrl ?? episode.imageUrl, + "bounded", + ); // Resume from saved progress if available and not completed const savedProgress = progressStore.get(episode.id); @@ -436,8 +458,10 @@ async function load(episode: Episode): Promise { // on feeds/progress at boot, so the bounded fetch (~300ms typical, // 8s worst case) is free. Falls back to the episode's own image when // the feed has no channel cover. - const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl; - const coverArtPath = coverUrl ? await fetchCoverArt(coverUrl) : null; + const coverArtPath = await resolveCoverArt( + feed?.podcast.coverUrl ?? episode.imageUrl, + "await", + ); const backendSnap = backend; backendSnap .preload(url, { @@ -612,8 +636,10 @@ async function switchBackend(name: BackendName): Promise { const podcastTitle = feed?.customName || feed?.podcast.title || ""; const url = useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl; - const coverUrl = feed?.podcast.coverUrl ?? ep.imageUrl; - const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null; + const coverArtPath = await resolveCoverArt( + feed?.podcast.coverUrl ?? ep.imageUrl, + "cache", + ); await backend.play(url, { startPosition: pos, volume: vol, diff --git a/src/index.tsx b/src/index.tsx index c127b4c..64d86c1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -42,7 +42,6 @@ if (cliArgs.version) { // ── CLI handlers ────────────────────────────────────────────────────── -/** Find the most recent episode across all feeds */ function findLatestEpisode( feeds: Feed[], ): { feed: Feed; episode: Episode } | null { diff --git a/src/pages/Feed/FeedPage.tsx b/src/pages/Feed/FeedPage.tsx index d84c385..c0b1c43 100644 --- a/src/pages/Feed/FeedPage.tsx +++ b/src/pages/Feed/FeedPage.tsx @@ -22,7 +22,6 @@ import { useDownloadStore } from "@/stores/download"; import { useAppStore } from "@/stores/app"; import { prefetchCoverArt } from "@/utils/cover-art"; import { DownloadStatus } from "@/types/episode"; -import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { @@ -33,14 +32,19 @@ import { } from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; import { on, off } from "@/utils/event-bus"; -import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts"; +import { supportsNerdFonts } from "@/utils/nerd-fonts"; import type { KeybindActionName } from "@/context/KeybindContext"; import type { Episode } from "@/types/episode"; import type { Feed } from "@/types/feed"; +import { + EpisodeRow, + FetchMoreRow, + EpisodePreview, + FetchMorePreview, +} from "@/components/EpisodeList"; import { LoadingIndicator } from "@/components/LoadingIndicator"; import { PaneRow } from "@/components/PaneRow"; import { TabListPane } from "@/components/TabPanel"; -import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useSelectionMarker } from "@/hooks/useSelectionMarker"; export const FeedPaneCount = 1; @@ -87,7 +91,6 @@ function FeedPage() { const app = useAppStore(); const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto"; const showFetchMore = () => feedStore.hasMoreAcrossAll(); - // Total navigable rows: episodes + the optional Fetch More row. const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0); const focus = () => nav.depthFocus(0); const focusedRow = () => @@ -103,7 +106,6 @@ function FeedPage() { const focusedItem = (): EpItem | undefined => focusedOnMore() ? undefined : episodes()[focusedEpIdx()]; const curLen = () => rowCount(); - const moreRef = useScrollIntoView(() => focusedOnMore()); const ensureFocus = () => { if (rowCount() > 0 && focus() >= rowCount()) @@ -129,12 +131,6 @@ function FeedPage() { }); // ── helpers ──────────────────────────────────────────────────────────────── - const formatDate = (d: Date) => format(d, "MMM d, yyyy"); - const formatDuration = (s: number) => { - const mins = Math.floor(s / 60); - const hrs = Math.floor(mins / 60); - return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`; - }; const downloadLabel = (id: string) => { switch (downloadStore.getDownloadStatus(id)) { case DownloadStatus.QUEUED: @@ -229,19 +225,6 @@ function FeedPage() { // ── render ────────────────────────────────────────────────────────────────── const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; - // Row highlight within the list. `active=true` only for the current pane. - const focusBg = (i: number, listFocus: number, active: boolean) => - i === listFocus && active - ? theme.primary - : i === listFocus - ? theme.border - : undefined; - const focusFg = (i: number, listFocus: number, active: boolean) => - i === listFocus && active - ? theme.surface - : i === listFocus - ? theme.selectedListItemText ?? theme.text - : theme.text; const currentLabel = () => `Feed · ${episodes().length}`; @@ -268,108 +251,38 @@ function FeedPage() { } > - {(item, index) => { - const fi = () => focusedEpIdx(); - const ref = useScrollIntoView(() => index() === fi()); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 0); - }} - > - - - {index() === fi() ? marker() : " "} - - - {item.episode.episodeNumber - ? `#${item.episode.episodeNumber} ` - : ""} - {item.episode.title} - - - {/* 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 */} - - - {item.feed.customName || item.feed.podcast.title} - - - - - {formatDate(item.episode.pubDate)} - - - {formatDuration(item.episode.duration)} - - - - ● - - - - - {downloadLabel(item.episode.id)} - - - - - ); - }} + {(item, index) => ( + item.feed.customName || item.feed.podcast.title} + index={index} + focused={focusedEpIdx} + active={isActive} + selected={() => nav.isSelected(item.episode.id)} + downloadLabel={() => downloadLabel(item.episode.id)} + downloadColor={() => downloadColor(item.episode.id)} + marker={marker} + onMouseDown={() => { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + }} + /> + )} - episodes().length} + focused={focusedRow} + onMore={focusedOnMore} + active={isActive} + isLoadingMore={() => feedStore.isLoadingMore()} + nerd={nerd} + marker={marker} onMouseDown={() => { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(episodes().length, 0); }} - > - - {focusedOnMore() ? marker() : " "} - - {nerd && ( - - {NF_ICONS.more} - - )} - } - > - - [Fetch More] - - - + /> @@ -380,23 +293,23 @@ function FeedPage() { ); // ── preview pane: hovered-episode detail (or the Fetch More row) ────────── + const episodeHint = (item: EpItem) => + `enter: play · d: download${ + downloadStore.getDownloadStatus(item.episode.id) !== DownloadStatus.NONE + ? " · D: delete" + : "" + } · space: select · h back`; + const previewContent = () => ( <> - - - [Fetch More] - - - {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)."} - - - enter: load more · h back - + feedStore.isLoadingMore()} + fetchMoreMode={fetchMoreMode} + manualText={() => + "Load the next batch of older episodes across all feeds (Enter)." + } + /> {(item) => ( - - - - {item().episode.episodeNumber - ? `#${item().episode.episodeNumber} ` - : ""} - {item().episode.title} - - - - {formatDate(item().episode.pubDate)} - {formatDuration(item().episode.duration)} - - - {downloadLabel(item().episode.id)} - - - - - {item().feed.customName || item().feed.podcast.title} - - - by {item().feed.podcast.author} - - - - {item().episode.description?.slice(0, 400) ?? - "No description available."} - {(item().episode.description?.length ?? 0) > 400 ? "…" : ""} - - - - enter: play · d: download - {downloadStore.getDownloadStatus(item().episode.id) !== - DownloadStatus.NONE - ? " · D: delete" - : ""}{" "} - · space: select · h back - - + item().episode} + subtitle={() => + item().feed.customName || item().feed.podcast.title + } + author={() => item().feed.podcast.author} + downloadLabel={() => downloadLabel(item().episode.id)} + downloadColor={() => downloadColor(item().episode.id)} + hint={() => episodeHint(item())} + /> )} diff --git a/src/pages/MyShows/MyShowsPage.tsx b/src/pages/MyShows/MyShowsPage.tsx index f10fb03..c75fe53 100644 --- a/src/pages/MyShows/MyShowsPage.tsx +++ b/src/pages/MyShows/MyShowsPage.tsx @@ -15,11 +15,11 @@ */ import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js"; +import type { RGBA } from "@opentui/core"; import { useFeedStore } from "@/stores/feed"; import { useDownloadStore } from "@/stores/download"; import { useAppStore } from "@/stores/app"; import { DownloadStatus } from "@/types/episode"; -import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; import { @@ -31,16 +31,208 @@ import { } from "@/context/NavigationContext"; import { useAudio } from "@/hooks/useAudio"; import { on, off } from "@/utils/event-bus"; -import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts"; +import { supportsNerdFonts } from "@/utils/nerd-fonts"; import type { KeybindActionName } from "@/context/KeybindContext"; import type { Episode, DownloadedEpisode } from "@/types/episode"; import type { Feed } from "@/types/feed"; -import { LoadingIndicator } from "@/components/LoadingIndicator"; +import { + EpisodeRow, + FetchMoreRow, + EpisodePreview, + FetchMorePreview, + formatDate, +} from "@/components/EpisodeList"; import { PaneRow } from "@/components/PaneRow"; import { TabListPane } from "@/components/TabPanel"; import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useSelectionMarker } from "@/hooks/useSelectionMarker"; +// ── render components ──────────────────────────────────────────────────────── +// Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their +// preview panes are My Shows-specific; episode rows/previews are shared with +// the Feed page (see EpisodeList.tsx). + +/** A subscribed-show row (depth 0). */ +function ShowRow(props: { + feed: Feed; + title: string; + index: () => number; + focused: () => number; + active: () => boolean; + marker: () => string; + wlScope: () => boolean; + wlInList: () => boolean; + onMouseDown: () => void; +}) { + const { theme } = useTheme(); + const muted = () => theme.muted || theme.text; + const ref = useScrollIntoView(() => props.index() === props.focused()); + const isFocused = () => props.index() === props.focused(); + const bg = () => + isFocused() && props.active() + ? theme.primary + : isFocused() + ? theme.border + : undefined; + const fg = () => + isFocused() && props.active() + ? theme.surface + : isFocused() + ? theme.selectedListItemText ?? theme.text + : theme.text; + return ( + + {isFocused() ? props.marker() : " "} + {props.title} + + ({props.feed.episodes.length}) + + + + {props.wlInList() ? "●" : "○"} + + + + ); +} + +/** 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 ( + + + + {isFocused() ? props.marker() : " "} + + + {props.d.episodeTitle ?? props.d.episodeId} + + + + {props.downloadLabel()} + + + + + + {props.d.podcastTitle ?? props.d.feedId} + + + + ); +} + +/** 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 ( + + + {props.title()} + + + by {show().podcast.author} + + {show().episodes.length} episodes + + {show().podcast.description?.slice(0, 400) ?? "No description."} + + + {props.hint()} + + ); +} + +/** 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 ( + + + {d().episodeTitle ?? d().episodeId} + + {d().podcastTitle ?? d().feedId} + + + {formatDate(new Date(d().pubDate!))} + + + + {props.downloadLabel()} + + + + + Downloaded from episode search — the show is not subscribed. + + + enter: play · D: delete download · h: back + + ); +} + export const MyShowsPaneCount = 1; export function MyShowsPage() { @@ -67,7 +259,6 @@ export function MyShowsPage() { // entry drops out the moment the user subscribes to its show. const unsubs = () => downloadStore.getUnsubscribedDownloads(); - // Total depth-0 rows: subscribed shows + unsubscribed-show downloads. const depth0Count = () => shows().length + unsubs().length; const focusedShowIdx = () => @@ -107,7 +298,6 @@ export function MyShowsPage() { depth() >= 1 && !!drilledShowId() && feedStore.hasMoreEpisodes(drilledShowId()); - // Total navigable rows at depth 1: episodes + the optional Fetch More row. const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0); const focusedRow = () => rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1); @@ -121,7 +311,6 @@ export function MyShowsPage() { : Math.min(focusedRow(), Math.max(episodes().length - 1, 0)); const focusedEpisode = () => focusedOnMore() ? undefined : episodes()[focusedEpIdx()]; - const moreRef = useScrollIntoView(() => focusedOnMore()); const curLen = () => (depth() === 0 ? depth0Count() : rowCount()); @@ -155,12 +344,6 @@ export function MyShowsPage() { }); // ── helpers ───────────────────────────────────────────────────────────────── - const formatDate = (d: Date) => format(d, "MMM d, yyyy"); - const formatDuration = (s: number) => { - const mins = Math.floor(s / 60); - const hrs = Math.floor(mins / 60); - return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`; - }; const downloadLabel = (id: string) => { switch (downloadStore.getDownloadStatus(id)) { case DownloadStatus.QUEUED: @@ -323,14 +506,6 @@ export function MyShowsPage() { // ── render ────────────────────────────────────────────────────────────────── const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; - const focusBg = (i: number, lf: number, active: boolean) => - i === lf && active ? theme.primary : i === lf ? theme.border : undefined; - const focusFg = (i: number, lf: number, active: boolean) => - i === lf && active - ? theme.surface - : i === lf - ? theme.selectedListItemText ?? theme.text - : theme.text; const showTitle = (f: Feed) => f.customName || f.podcast.title; const currentLabel = () => @@ -349,18 +524,21 @@ export function MyShowsPage() { {(feed, index) => { const lf = () => nav.depthFocus(0); const ref = useScrollIntoView(() => index() === lf()); + const focused = () => index() === lf(); + const fg = () => + focused() + ? theme.selectedListItemText ?? theme.text + : theme.text; return ( - - {index() === lf() ? marker() : " "} - - {showTitle(feed)} + {focused() ? marker() : " "} + {showTitle(feed)} ({feed.episodes.length}) ); @@ -385,51 +563,28 @@ export function MyShowsPage() { } > - {(feed, index) => { - const lf = () => focusedShowIdx(); - const ref = useScrollIntoView(() => index() === lf()); - const wlScope = - app.state().preferences.autoDownloadScope === "whitelist"; - const wlInList = ( - app.state().preferences.autoDownloadWhitelist ?? [] - ).includes(feed.id); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 0); - }} - > - - {index() === lf() ? marker() : " "} - - - {showTitle(feed)} - - - ({feed.episodes.length}) - - - - {wlInList ? "●" : "○"} - - - - ); - }} + {(feed, index) => ( + + app.state().preferences.autoDownloadScope === "whitelist" + } + wlInList={() => + (app.state().preferences.autoDownloadWhitelist ?? []).includes( + feed.id, + ) + } + onMouseDown={() => { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 0); + }} + /> + )} 0}> @@ -438,62 +593,21 @@ export function MyShowsPage() { - {(d, index) => { - // Rows continue after the shows list. - const rowIdx = () => shows().length + index(); - const lf = () => nav.depthFocus(0); - const ref = useScrollIntoView(() => rowIdx() === lf()); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(rowIdx(), 0); - }} - > - - - {rowIdx() === lf() ? marker() : " "} - - - {d.episodeTitle ?? d.episodeId} - - - - {downloadLabel(d.episodeId)} - - - - - - {d.podcastTitle ?? d.feedId} - - - - ); - }} + {(d, index) => ( + shows().length + index()} + focused={() => nav.depthFocus(0)} + active={isActive} + marker={marker} + downloadLabel={() => downloadLabel(d.episodeId)} + downloadColor={() => downloadColor(d.episodeId)} + onMouseDown={() => { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(shows().length + index(), 0); + }} + /> + )} @@ -509,98 +623,37 @@ export function MyShowsPage() { } > - {(ep, index) => { - const lf = () => focusedEpIdx(); - const ref = useScrollIntoView(() => index() === lf()); - return ( - { - nav.setActivePane(DEPTH_CENTER_PANE); - nav.setDepthFocus(index(), 1); - }} - > - - - {index() === lf() ? marker() : " "} - - - {ep.episodeNumber ? `#${ep.episodeNumber} ` : ""} - {ep.title} - - - - - {formatDate(ep.pubDate)} - - - {formatDuration(ep.duration)} - - - - ● - - - - - {downloadLabel(ep.id)} - - - - - ); - }} + {(ep, index) => ( + nav.isSelected(ep.id)} + downloadLabel={() => downloadLabel(ep.id)} + downloadColor={() => downloadColor(ep.id)} + marker={marker} + onMouseDown={() => { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 1); + }} + /> + )} - episodes().length} + focused={focusedRow} + onMore={focusedOnMore} + active={isActive} + isLoadingMore={() => feedStore.isLoadingMore()} + nerd={nerd} + marker={marker} onMouseDown={() => { nav.setActivePane(DEPTH_CENTER_PANE); nav.setDepthFocus(episodes().length, 1); }} - > - - {focusedOnMore() ? marker() : " "} - - {nerd && ( - - {NF_ICONS.more} - - )} - } - > - - [Fetch More] - - - + /> @@ -608,6 +661,30 @@ export function MyShowsPage() { ); // ── preview pane ─────────────────────────────────────────────────────────── + const episodeHint = (epId: string) => + `enter: play · d: download${ + downloadStore.getDownloadStatus(epId) !== DownloadStatus.NONE + ? " · D: delete" + : "" + }${ + app.state().preferences.autoDownloadScope === "whitelist" + ? (app.state().preferences.autoDownloadWhitelist ?? []).includes( + drilledShowId(), + ) + ? " · w: un-whitelist" + : " · w: whitelist" + : "" + } · space: select · h: back`; + + const showHint = (show: Feed) => + `enter/l: open · h: back · x: unsubscribe${ + app.state().preferences.autoDownloadScope === "whitelist" + ? (app.state().preferences.autoDownloadWhitelist ?? []).includes(show.id) + ? " · w: un-whitelist" + : " · w: whitelist" + : "" + }`; + const previewContent = () => depth() === 0 ? ( // depth 0 preview: hovered unsubscribed-show download, else the @@ -624,86 +701,34 @@ export function MyShowsPage() { } > {(show) => ( - - - {showTitle(show())} - - - by {show().podcast.author} - - - {show().episodes.length} episodes - - - {show().podcast.description?.slice(0, 400) ?? - "No description."} - - - - enter/l: open · h: back · x: unsubscribe - {app.state().preferences.autoDownloadScope === - "whitelist" - ? (app.state().preferences.autoDownloadWhitelist ?? - [] - ).includes(show().id) - ? " · w: un-whitelist" - : " · w: whitelist" - : ""} - - + show()} + title={() => showTitle(show())} + hint={() => showHint(show())} + /> )} } > {(d) => ( - - - {d().episodeTitle ?? d().episodeId} - - - {d().podcastTitle ?? d().feedId} - - - - - {formatDate(new Date(d().pubDate!))} - - - - - {downloadLabel(d().episodeId)} - - - - - Downloaded from episode search — the show is not - subscribed. - - - - enter: play · D: delete download · h: back - - + d()} + downloadLabel={() => downloadLabel(d().episodeId)} + downloadColor={() => downloadColor(d().episodeId)} + /> )} ) : ( // depth ≥1 preview: hovered episode (or the Fetch More row) <> - - - [Fetch More] - - - {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)."} - - - enter: load more · h back - + feedStore.isLoadingMore()} + fetchMoreMode={fetchMoreMode} + manualText={() => + "Load the next batch of older episodes for this show (Enter)." + } + /> } > - {(ep) => ( - - - - {ep().episodeNumber ? `#${ep().episodeNumber} ` : ""} - {ep().title} - - - - {formatDate(ep().pubDate)} - {formatDuration(ep().duration)} - - - {downloadLabel(ep().id)} - - - - - by {selectedShow()!.podcast.author} - - - - {ep().description?.slice(0, 400) ?? "No description available."} - {(ep().description?.length ?? 0) > 400 ? "…" : ""} - - - - 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 - - - )} + {(ep) => ( + ep()} + author={() => selectedShow()?.podcast.author} + downloadLabel={() => downloadLabel(ep().id)} + downloadColor={() => downloadColor(ep().id)} + hint={() => episodeHint(ep().id)} + /> + )} + - - - ); + + ); return ( stack()[1]?.ctx ?? searchStore.query(); // ── input focusing ──────────────────────────────────────────────────────── - // `inputFocused` is true while the query input is being typed in. The Shell - // router yields keys to the while this is true; Escape (in Shell) - // sets it false so navigation resumes; `s` (search action) sets it true. - // - // The input's REAL focus is the source of truth for the flag: - // useInputFocusNav (the same hook the Settings forms use) flips - // `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag - // and the renderable in lockstep. That matters when the user clicks OFF the - // input: opentui's mouse dispatch auto-focuses the clicked target's nearest - // focusable ancestor (a pane scrollbox), blurring the input. The BLURRED - // event drops the flag, so the Shell router immediately resumes j/k/h - // instead of swallowing keys with no input to receive them — no more - // stuck "typing" state where Esc/j/k/s all do nothing. - // - // The depth stack still SEEDS the flag on transitions, since the query - // depth defaults to typing: re-entering depth 0 (h back from results, or a - // fresh mount) focuses the input; mounting at depth 1 (returning to the - // tab after a search) stays list-navigation — a stuck-on flag there would - // have the Shell yield j/k to a non-existent input. The depth STACK signal - // is also written by focus moves (setDepthFocus), so gate the seed on the - // depth VALUE via a memo: the effect must re-run only on an actual depth - // transition. Without the memo every j/k at the query depth re-focuses the - // input (undoing Escape), which keeps the recents list unreachable by - // keyboard. + // `inputFocused` tells the Shell router to yield keys to the query input. + // The input's REAL focus is the source of truth: useInputFocusNav flips + // the flag from the input's FOCUSED/BLURRED events, so clicking off the + // input drops it and the router resumes j/k/h — no stuck "typing" state. + // The depth stack only SEEDS it on transitions (re-entering depth 0 + // focuses the input; mounting at depth 1 stays list-nav), gated on the + // depth VALUE via a memo because setDepthFocus also writes the stack + // signal — without the memo every j/k at query depth re-focuses the input + // and strands the recents list. onMount(() => nav.setInputFocused(depth() === 0)); onCleanup(() => nav.setInputFocused(false)); const focusNavRef = useInputFocusNav(); diff --git a/src/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 0d10c12..e8b4b25 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -140,7 +140,6 @@ export function SettingsPage() { function open() { const d = depth(); if (d === 0) { - // drill into the focused section's items const id = focusedSection().id; nav.pushDepth({ kind: `settings:${id}`, @@ -206,7 +205,6 @@ export function SettingsPage() { function step(delta: number) { const d = depth(); if (d === 2) { - // editor: j/k nudges the value const it = editorItem(); if (it?.kind === "number" || it?.kind === "select") it.cycle?.(delta as -1 | 1); @@ -220,7 +218,6 @@ export function SettingsPage() { pane: PaneId; mode: NavMode; }) => { - // ignore actions meant for non-center panes if (data.pane !== DEPTH_CENTER_PANE) return; if (nav.activePane() !== DEPTH_CENTER_PANE) return; const handler = PAGE_ACTIONS[data.action]; diff --git a/src/stores/activity.ts b/src/stores/activity.ts index a2341ac..e4f55ec 100644 --- a/src/stores/activity.ts +++ b/src/stores/activity.ts @@ -10,7 +10,6 @@ import { createSignal } from "solid-js"; -/** Create activity store */ function createActivityStore() { const [count, setCount] = createSignal(0); const [labels, setLabels] = createSignal([]); @@ -64,7 +63,6 @@ function createActivityStore() { }; } -/** Singleton activity store */ let activityStoreInstance: ReturnType | null = null; export function useActivityStore() { diff --git a/src/stores/audio-nav.ts b/src/stores/audio-nav.ts index 21ac799..447e721 100644 --- a/src/stores/audio-nav.ts +++ b/src/stores/audio-nav.ts @@ -9,14 +9,12 @@ import { saveAudioNavToFile, } from "../utils/app-persistence"; -/** Source type for audio navigation */ export enum AudioSource { FEED = "feed", MY_SHOWS = "my_shows", SEARCH = "search", } -/** Audio navigation state */ export interface AudioNavState { /** Current source type */ source: AudioSource; @@ -28,14 +26,12 @@ export interface AudioNavState { lastUpdated: Date; } -/** Default navigation state */ const defaultNavState: AudioNavState = { source: AudioSource.FEED, currentIndex: 0, lastUpdated: new Date(), }; -/** Create audio navigation store */ function createAudioNavStore() { const [navState, setNavState] = createSignal(defaultNavState); @@ -56,12 +52,10 @@ function createAudioNavStore() { init(); return { - /** Get current navigation state */ get state(): AudioNavState { return navState(); }, - /** Update source type */ setSource: (source: AudioSource, podcastId?: string) => { setNavState((prev) => ({ ...prev, @@ -72,7 +66,6 @@ function createAudioNavStore() { persist(); }, - /** Move to next episode */ next: (currentIndex: number) => { setNavState((prev) => ({ ...prev, @@ -82,7 +75,6 @@ function createAudioNavStore() { persist(); }, - /** Move to previous episode */ prev: (currentIndex: number) => { setNavState((prev) => ({ ...prev, @@ -92,23 +84,19 @@ function createAudioNavStore() { persist(); }, - /** Reset to default state */ reset: () => { setNavState(defaultNavState); persist(); }, - /** Get current index */ getCurrentIndex: (): number => { return navState().currentIndex; }, - /** Get current source */ getSource: (): AudioSource => { return navState().source; }, - /** Get current podcast ID */ getPodcastId: (): string | undefined => { return navState().podcastId; }, diff --git a/src/stores/discover.ts b/src/stores/discover.ts index 47c18dc..a6e9384 100644 --- a/src/stores/discover.ts +++ b/src/stores/discover.ts @@ -85,7 +85,6 @@ function syncSubscriptionState( })); } -/** Create discover store */ export function createDiscoverStore() { const [selectedCategory, setSelectedCategory] = createSignal("all"); const [isLoading, setIsLoading] = createSignal(false); @@ -107,7 +106,6 @@ export function createDiscoverStore() { const refresh = async () => { setIsLoading(true); try { - // Skip if cache is still fresh const now = Date.now(); if (now - cachedAt < FEATURED_CACHE_TTL_MS) { syncSubscriptions(); @@ -131,7 +129,6 @@ export function createDiscoverStore() { cachedAt = now; setPodcasts(fetched); - // Reflect current feed-store subscriptions syncSubscriptions(); } catch { // Network failure — keep whatever we have (stale or empty) @@ -140,7 +137,6 @@ export function createDiscoverStore() { } }; - /** Get filtered podcasts by category */ const filteredPodcasts = () => { const category = selectedCategory(); if (category === "all") { @@ -155,7 +151,6 @@ export function createDiscoverStore() { }); }; - /** Subscribe to a podcast */ const subscribe = (podcastId: string) => { const podcast = podcasts().find((p) => p.id === podcastId); if (podcast) { @@ -168,7 +163,6 @@ export function createDiscoverStore() { ); }; - /** Unsubscribe from a podcast */ const unsubscribe = (podcastId: string) => { const podcast = podcasts().find((p) => p.id === podcastId); if (podcast) { @@ -180,7 +174,6 @@ export function createDiscoverStore() { ); }; - /** Toggle subscription */ const toggleSubscription = (podcastId: string) => { const podcast = podcasts().find((p) => p.id === podcastId); if (podcast?.isSubscribed) { @@ -207,7 +200,6 @@ export function createDiscoverStore() { }; } -/** Singleton discover store */ let discoverStoreInstance: ReturnType | null = null; export function useDiscoverStore() { diff --git a/src/stores/download.ts b/src/stores/download.ts index 91da978..2df9bc8 100644 --- a/src/stores/download.ts +++ b/src/stores/download.ts @@ -63,7 +63,62 @@ interface QueueItem { episodeTitle: string; } -/** Create download store */ +// ── post-download decoration ───────────────────────────────────────────────── +/** Write the podcast cover beside the audio so mpv's --cover-art-auto=exact + * picks it up for Now Playing art when the local file plays (same basename, + * .jpg extension — verified against mpv 0.41). curl, NOT fetch: Bun's fetch + * hangs in compiled binaries, so the shipped app never wrote this file. */ +function writeCoverArt(filePath: string, coverUrl: string): void { + const dot = filePath.lastIndexOf("."); + if (dot <= 0) return; + const coverPath = filePath.slice(0, dot) + ".jpg"; + Bun.spawn([ + "curl", + "-sS", + "--fail", + "-m", + "8", + "--max-filesize", + "2097152", + "-o", + coverPath, + coverUrl, + ]) + .exited.catch(() => {}); +} + +/** Tag the local file (codec-copy, no re-encode) so mpv's Now Playing + * metadata for local playback is title=episode, artist=podcast — the source + * streams carry no usable tags and macOS composes "title - artist" from + * exactly these fields. Atomic: ffmpeg writes a temp file, then renames + * into place. */ +function tagLocalFile( + filePath: string, + episode: Episode, + podcastTitle: string, +): void { + const tmp = `${filePath}.tag.mp3`; + Bun.spawn([ + "ffmpeg", + "-y", + "-i", + filePath, + "-c", + "copy", + "-metadata", + `title=${episode.title}`, + "-metadata", + `artist=${podcastTitle}`, + tmp, + ]) + .exited.then(async (code) => { + if (code !== 0) return; + const { renameSync } = await import("node:fs"); + renameSync(tmp, filePath); + }) + .catch(() => {}); +} + function createDownloadStore() { const [downloads, setDownloads] = createSignal< Map @@ -195,7 +250,6 @@ function createDownloadStore() { } } - /** Execute a single download */ async function executeDownload(item: QueueItem): Promise { const controller = new AbortController(); abortControllers.set(item.episodeId, controller); @@ -236,70 +290,23 @@ function createDownloadStore() { error: null, }); - // Write the podcast cover beside the audio so mpv's - // --cover-art-auto=exact picks it up for Now Playing art when the - // local file plays (same basename, .jpg extension — verified - // against mpv 0.41). curl, NOT fetch: Bun's fetch hangs in - // compiled binaries, so the shipped app never wrote this file. - // Falls back to the episode's own image when the feed has no - // channel cover (URL-added feeds). + // Decorate the local file: cover art + ID3 tags (see the + // module-level helpers above) — the source streams carry neither. + // Cover falls back to the episode's own image when the feed has + // no channel cover (URL-added feeds). const feedStore = useFeedStore(); const episode = feedStore.findEpisode(item.episodeId); - const coverUrl = - feedStore - .feeds() - .find((f) => f.id === item.feedId)?.podcast.coverUrl ?? - episode?.imageUrl; - if (coverUrl && result.filePath) { - const dot = result.filePath.lastIndexOf("."); - if (dot > 0) { - const coverPath = result.filePath.slice(0, dot) + ".jpg"; - Bun.spawn([ - "curl", - "-sS", - "--fail", - "-m", - "8", - "--max-filesize", - "2097152", - "-o", - coverPath, - coverUrl, - ]) - .exited.catch(() => {}); - } + const feed = feedStore.feeds().find((f) => f.id === item.feedId); + const coverUrl = feed?.podcast.coverUrl ?? episode?.imageUrl; + if (result.filePath && coverUrl) { + writeCoverArt(result.filePath, coverUrl); } - - // Tag the local file (codec-copy, no re-encode) so mpv's Now - // Playing metadata for local playback is title=episode, - // artist=podcast — the source streams carry no usable tags and - // macOS composes "title - artist" from exactly these fields. - // Atomic: ffmpeg writes a temp file, then renames into place. if (result.filePath && episode) { const podcastTitle = - feedStore.feeds().find((f) => f.id === item.feedId)?.podcast.title ?? + feed?.podcast.title ?? downloads().get(item.episodeId)?.podcastTitle; if (podcastTitle) { - const tmp = `${result.filePath}.tag.mp3`; - Bun.spawn([ - "ffmpeg", - "-y", - "-i", - result.filePath, - "-c", - "copy", - "-metadata", - `title=${episode.title}`, - "-metadata", - `artist=${podcastTitle}`, - tmp, - ]) - .exited.then(async (code) => { - if (code !== 0) return; - const { renameSync } = await import("node:fs"); - renameSync(tmp, result.filePath); - }) - .catch(() => {}); + tagLocalFile(result.filePath, episode, podcastTitle); } } } else { @@ -315,22 +322,18 @@ function createDownloadStore() { processQueue(); } - /** Get download status for an episode */ const getDownloadStatus = (episodeId: string): DownloadStatus => { return downloads().get(episodeId)?.status ?? DownloadStatus.NONE; }; - /** Get download progress for an episode (0-100) */ const getDownloadProgress = (episodeId: string): number => { return downloads().get(episodeId)?.progress ?? 0; }; - /** Get full download info for an episode */ const getDownload = (episodeId: string): DownloadedEpisode | undefined => { return downloads().get(episodeId); }; - /** Get the local file path for a completed download */ const getDownloadedFilePath = (episodeId: string): string | null => { const dl = downloads().get(episodeId); if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) { @@ -347,7 +350,6 @@ function createDownloadStore() { podcastFeedUrl?: string; } - /** Start downloading an episode */ const startDownload = ( episode: Episode, feedId: string, @@ -411,7 +413,6 @@ function createDownloadStore() { }); }; - /** Cancel a download */ const cancelDownload = (episodeId: string): void => { // Abort active download const controller = abortControllers.get(episodeId); @@ -432,7 +433,6 @@ function createDownloadStore() { saveDownloads().catch(() => {}); }; - /** Remove a completed download (delete file and metadata) */ const removeDownload = async (episodeId: string): Promise => { const dl = downloads().get(episodeId); if (dl?.filePath) { @@ -478,7 +478,6 @@ function createDownloadStore() { } }; - /** Get all downloads as an array */ const getAllDownloads = (): DownloadedEpisode[] => { return Array.from(downloads().values()); }; @@ -501,12 +500,10 @@ function createDownloadStore() { }); }; - /** Get the current queue */ const getQueue = (): QueueItem[] => { return queue(); }; - /** Get count of active downloads */ const getActiveCount = (): number => { return activeCount(); }; @@ -531,7 +528,6 @@ function createDownloadStore() { }; } -/** Singleton download store */ let downloadStoreInstance: ReturnType | null = null; export function useDownloadStore() { diff --git a/src/stores/feed.ts b/src/stores/feed.ts index d18bc04..6895ab9 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -49,6 +49,16 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 30; * feeds) can't stall the renderer. */ const PARSE_CHUNK_SIZE = 5; +/** Hard ceiling on the in-memory full-parse cache per feed (newest first). + * The cache exists so fetch-more can page deeper without a refetch; without + * a ceiling a 5,000-episode archive pins tens of MB of Episode objects in + * RAM for the whole session (the old cache held EVERY parsed episode of + * every feed, contributing hundreds of MB for archive-heavy + * subscriptions). 1000 covers any realistic show's entire history — + * beyond it, hasMoreEpisodes flips false and the visible list is bounded + * by the user's cache preference as usual. */ +const MAX_CACHED_EPISODES_PER_FEED = 1000; + /** Yield to the event loop (task queue) so the renderer can paint between * parse chunks. MessageChannel instead of setTimeout/setImmediate because * bun:test fake timers trap those (feed-refresh/pagination tests run under @@ -214,7 +224,6 @@ async function mapWithConcurrency( return results; } -/** Create feed store */ function createFeedStore() { const [feeds, setFeeds] = createSignal([]); const [sources, setSources] = createSignal([ @@ -262,7 +271,6 @@ function createFeedStore() { saveFeeds(feeds()); }; - /** Get filtered and sorted feeds */ const getFilteredFeeds = (): Feed[] => { let result = [...feeds()]; const f = filter(); @@ -320,7 +328,6 @@ function createFeedStore() { return result; }; - /** Get episodes in reverse chronological order across all feeds */ const getAllEpisodesChronological = (): Array<{ episode: Episode; feed: Feed; @@ -341,7 +348,6 @@ function createFeedStore() { return allEpisodes; }; - /** Sort episodes in reverse chronological order (newest first) */ const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => { return [...episodes].sort( (a, b) => b.pubDate.getTime() - a.pubDate.getTime(), @@ -384,8 +390,14 @@ function createFeedStore() { if (feedId) { // Cache the FULL parse — the bound is applied when reading, // not when writing, so a preference change takes effect - // without a refetch. - fullEpisodeCache.set(feedId, allEpisodes); + // without a refetch. Capped at MAX_CACHED_EPISODES_PER_FEED + // so an archive-heavy feed can't pin its entire history in + // RAM for the session (the visible window below is bounded + // by the user's preference regardless). + fullEpisodeCache.set( + feedId, + allEpisodes.slice(0, MAX_CACHED_EPISODES_PER_FEED), + ); } // Bound the visible window by the user's cache preference. @@ -410,7 +422,6 @@ function createFeedStore() { } }; - /** Check if a feed with this URL already exists */ const hasFeedByUrl = (feedUrl: string): boolean => { return feeds().some((f) => f.podcast.feedUrl === feedUrl); }; @@ -675,7 +686,6 @@ function createFeedStore() { }; scheduleNextRefresh(); - /** Remove a feed */ const removeFeed = (feedId: string) => { fullEpisodeCache.delete(feedId); episodeLoadCount.delete(feedId); @@ -706,7 +716,6 @@ function createFeedStore() { } }; - /** Update a feed */ const updateFeed = (feedId: string, updates: Partial) => { setFeeds((prev) => { const updated = prev.map((f) => @@ -717,7 +726,6 @@ function createFeedStore() { }); }; - /** Toggle feed pinned status */ const togglePinned = (feedId: string) => { setFeeds((prev) => { const updated = prev.map((f) => @@ -728,7 +736,6 @@ function createFeedStore() { }); }; - /** Add a source */ const addSource = (source: Omit) => { const newSource: PodcastSource = { ...source, @@ -742,7 +749,6 @@ function createFeedStore() { return newSource; }; - /** Update a source */ const updateSource = (sourceId: string, updates: Partial) => { setSources((prev) => { const updated = prev.map((source) => @@ -753,7 +759,6 @@ function createFeedStore() { }); }; - /** Remove a source */ const removeSource = (sourceId: string) => { // Don't remove default sources if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false; @@ -766,7 +771,6 @@ function createFeedStore() { return true; }; - /** Toggle source enabled status */ const toggleSource = (sourceId: string) => { setSources((prev) => { const updated = prev.map((s) => @@ -777,7 +781,6 @@ function createFeedStore() { }); }; - /** Get feed by ID */ const getFeed = (feedId: string): Feed | undefined => { return feeds().find((f) => f.id === feedId); }; @@ -792,7 +795,6 @@ function createFeedStore() { return undefined; }; - /** Get selected feed */ const getSelectedFeed = (): Feed | undefined => { const id = selectedFeedId(); return id ? getFeed(id) : undefined; @@ -853,6 +855,9 @@ function createFeedStore() { // is its own sync block). await yieldToUI(); cached = sortEpisodesReverseChronological(cached); + // Same ceiling as fetchEpisodes: the cache (and the paging + // window below) never exceeds MAX_CACHED_EPISODES_PER_FEED. + cached = cached.slice(0, MAX_CACHED_EPISODES_PER_FEED); fullEpisodeCache.set(feedId, cached); // Set current load count to match what's already displayed episodeLoadCount.set(feedId, feed.episodes.length); @@ -900,7 +905,6 @@ function createFeedStore() { } }; - /** True if any feed still has cached episodes beyond its loaded window. */ const hasMoreAcrossAll = (): boolean => { return feeds().some((f) => hasMoreEpisodes(f.id)); }; @@ -920,7 +924,6 @@ function createFeedStore() { } }; - /** Run the global auto-download pass (see runAutoDownload above). */ const runAutoDownloadNow = (): void => { runAutoDownload(); }; @@ -969,7 +972,6 @@ function createFeedStore() { }; } -/** Singleton feed store */ let feedStoreInstance: ReturnType | null = null; export function useFeedStore() { diff --git a/src/stores/progress.ts b/src/stores/progress.ts index a97f2cb..8fe08fe 100644 --- a/src/stores/progress.ts +++ b/src/stores/progress.ts @@ -64,16 +64,10 @@ function createProgressStore() { */ whenReady: () => progressInit, - /** - * Get progress for a specific episode. - */ get(episodeId: string): Progress | undefined { return progressMap()[episodeId]; }, - /** - * Get all progress entries. - */ all(): Record { return progressMap(); }, @@ -102,18 +96,12 @@ function createProgressStore() { persist(); }, - /** - * Check if an episode is completed. - */ isCompleted(episodeId: string): boolean { const p = progressMap()[episodeId]; if (!p || p.duration <= 0) return false; return p.position / p.duration >= COMPLETION_THRESHOLD; }, - /** - * Get progress percentage (0-100) for an episode. - */ getPercent(episodeId: string): number { const p = progressMap()[episodeId]; if (!p || p.duration <= 0) return 0; @@ -151,9 +139,6 @@ function createProgressStore() { persist(); }, - /** - * Clear all progress data. - */ clear(): void { setProgressMap({}); persist(); diff --git a/src/stores/search.ts b/src/stores/search.ts index ff0ee60..763e831 100644 --- a/src/stores/search.ts +++ b/src/stores/search.ts @@ -60,7 +60,6 @@ function saveScope(scope: SearchScope): void { } } -/** Create search store */ export function createSearchStore() { const feedStore = useFeedStore(); const [query, setQuery] = createSignal(""); @@ -167,7 +166,6 @@ export function createSearchStore() { } }; - /** Add query to history */ const addToHistory = (q: string) => { setHistory((prev) => { const updated = sanitizeHistory([q, ...prev]); @@ -176,13 +174,11 @@ export function createSearchStore() { }); }; - /** Clear search history */ const clearHistory = () => { setHistory([]); saveSearchHistoryToFile([]); }; - /** Remove single history item */ const removeFromHistory = (q: string) => { setHistory((prev) => { const updated = prev.filter((h) => h !== q); @@ -191,14 +187,12 @@ export function createSearchStore() { }); }; - /** Clear results */ const clearResults = () => { setResults([]); setQuery(""); setError(null); }; - /** Mark a podcast as subscribed in results */ const markSubscribed = (podcastId: string, feedUrl?: string) => { setResults((prev) => prev.map((result) => { @@ -262,7 +256,6 @@ export function createSearchStore() { }; } -/** Singleton search store */ let searchStoreInstance: ReturnType | null = null; export function useSearchStore() { diff --git a/src/ui/command.tsx b/src/ui/command.tsx index bf995d1..d47aec9 100644 --- a/src/ui/command.tsx +++ b/src/ui/command.tsx @@ -156,9 +156,6 @@ function init() { setRegistrations((arr) => arr.filter((x) => x !== results)); }); }, - /** - * Get all visible options. - */ get options() { return visibleOptions(); }, @@ -195,9 +192,6 @@ export function CommandProvider(props: ParentProps) { return {props.children}; } -/** - * Command palette dialog component. - */ function CommandDialog(props: { options: CommandOption[]; suggestedOptions: CommandOption[]; diff --git a/src/ui/dialog.tsx b/src/ui/dialog.tsx index ac31dac..ec2a22b 100644 --- a/src/ui/dialog.tsx +++ b/src/ui/dialog.tsx @@ -98,9 +98,6 @@ function init() { }) return { - /** - * Clear all dialogs from the stack. - */ clear() { for (const item of store.stack) { if (item.onClose) item.onClose() @@ -113,9 +110,6 @@ function init() { emit("dialog.close", {}) }, - /** - * Replace all dialogs with a new one. - */ replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) { if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable @@ -130,9 +124,6 @@ function init() { emit("dialog.open", { dialogId: "dialog" }) }, - /** - * Push a new dialog onto the stack. - */ push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) { if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable @@ -143,9 +134,6 @@ function init() { emit("dialog.open", { dialogId: "dialog" }) }, - /** - * Pop the top dialog from the stack. - */ pop() { if (store.stack.length === 0) return const current = store.stack.at(-1)! diff --git a/src/utils/app-persistence.ts b/src/utils/app-persistence.ts index ab79b0c..10eed17 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -62,7 +62,6 @@ const defaultState: AppState = { // ── App State (config.json) ───────────────────────────────────────────────── -/** Load app state from config.json */ export async function loadAppStateFromFile(): Promise { try { const cfg = await loadConfig(); @@ -88,7 +87,6 @@ export async function loadAppStateFromFile(): Promise { } } -/** Save app state to config.json */ export function saveAppStateToFile(state: AppState): void { updateConfig({ settings: state.settings, @@ -109,7 +107,6 @@ interface ProgressEntry { playbackSpeed?: number; } -/** Load progress map from JSON file */ export async function loadProgressFromFile(): Promise< Record > { @@ -145,7 +142,6 @@ export function saveProgressToFile(data: Record): void { const SEARCH_HISTORY_FILE = "search-history.json"; -/** Load search history from JSON file */ export async function loadSearchHistoryFromFile(): Promise { try { const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE)); @@ -159,7 +155,6 @@ export async function loadSearchHistoryFromFile(): Promise { } } -/** Save search history to JSON file (overwrite, no backup) */ export function saveSearchHistoryToFile(history: string[]): void { (async () => { try { @@ -178,7 +173,6 @@ export function saveSearchHistoryToFile(history: string[]): void { const AUDIO_NAV_FILE = "audio-nav.json"; -/** Load audio navigation state from JSON file */ export async function loadAudioNavFromFile(): Promise { try { const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE)); diff --git a/src/utils/audio-pcm-cache.ts b/src/utils/audio-pcm-cache.ts index 13ed908..6b29923 100644 --- a/src/utils/audio-pcm-cache.ts +++ b/src/utils/audio-pcm-cache.ts @@ -23,9 +23,16 @@ * pass over just that region) — earlier segments stay valid, mp3 decode of * the same file is deterministic so abutting segments agree. * - * Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min (~80 MB per 30 min), - * freed on stop(). 22050 Hz covers Nyquist 11 kHz, above the default 10 kHz - * high-cutoff of the visualizer's FFT config. + * Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min. The cache is a + * SLIDING WINDOW around the playback position — the decode pass stops + * once it is maxAheadSec ahead of the cursor and segments entirely older + * than keepBehindSec behind it are dropped (both re-filled/restarted on + * demand). Steady state is bounded by (maxAheadSec + keepBehindSec) of + * audio (~40 MB at the defaults) INDEPENDENT of episode length; the old + * whole-episode cache grew ~160 MB per hour of audio and hit 2.5 GB on + * long-form episodes. Fully freed on stop(). 22050 Hz covers Nyquist + * 11 kHz, above the default 10 kHz high-cutoff of the visualizer's FFT + * config. * * Downloads via ffmpeg's own http stack with reconnect flags, matching the * old reader; local files skip them (ffmpeg rejects http-only options for @@ -49,6 +56,24 @@ const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024; */ const CLOSE_IN_PLACE_GAP_SEC = 15; +/** + * Default decode-head budget: the ffmpeg pass pauses once it is this far + * ahead of the playback cursor. Bounds RAM (~26 MB of s16 at 22050 Hz) AND + * the network pull — the old cache decoded the whole episode at 4x, so a + * 3h show pinned ~500 MB (2.5 GB+ for long-form) and dragged the entire + * remote file even when only the first 10 minutes were listened to. At 4x + * pacing a refill costs ~150s of background decode, one ffmpeg spawn per + * ~10 min of playback. + */ +const DEFAULT_DECODE_AHEAD_SEC = 600; + +/** + * Default retention behind the cursor: decoded audio entirely older than + * this is dropped. Keeps pause/resume and small backward seeks instant + * without letting the window grow with playback time. + */ +const DEFAULT_KEEP_BEHIND_SEC = 300; + /** * Monotonically increasing generation counter. * Each startDecode() increments this; the read loop checks it to know @@ -73,6 +98,10 @@ export interface EpisodePcmCacheOptions { url: string; /** Sample rate (default: 22050) */ sampleRate?: number; + /** Decode-head budget in seconds ahead of the cursor (default: 600). */ + maxAheadSec?: number; + /** Retention in seconds behind the cursor (default: 300). */ + keepBehindSec?: number; } export class EpisodePcmCache { @@ -84,10 +113,15 @@ export class EpisodePcmCache { private activeSegment: Segment | null = null; readonly url: string; readonly sampleRate: number; + /** Sliding-window budgets (see maintainWindow). */ + readonly maxAheadSec: number; + readonly keepBehindSec: number; constructor(options: EpisodePcmCacheOptions) { this.url = options.url; this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE; + this.maxAheadSec = options.maxAheadSec ?? DEFAULT_DECODE_AHEAD_SEC; + this.keepBehindSec = options.keepBehindSec ?? DEFAULT_KEEP_BEHIND_SEC; } /** Whether an ffmpeg decode pass is currently running. */ @@ -238,11 +272,20 @@ export class EpisodePcmCache { * new segment at `sec` (seek into a hole / resume past cached audio). */ ensureDecodeAround(sec: number): void { + // Enforce the sliding-window budget first (head cap, prune, refill) + // so a resume or seek never leaves stale segments behind the cursor. + this.maintainWindow(sec); + // Data already on hand: nothing needed here; only keep the tail - // filling if the decode is idle and the episode is unfinished. + // filling if the decode is idle, the episode is unfinished, AND the + // head is inside its budget. A head-capped cache ("we're maxAheadSec + // ahead, enough decoded") is NOT a stalled decode — restarting it + // here would fight maintainWindow's cap on every resume call. if (this.covers(sec)) { if (this._decoding || this.decodeFinished) return; - this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec); + const end = this.coverageEndSec; + if (end >= sec + this.maxAheadSec) return; + this.startDecode(end > sec ? end : sec); return; } @@ -265,6 +308,48 @@ export class EpisodePcmCache { this.startDecode(Math.max(0, sec)); } + /** + * Sliding-window budget for the in-memory cache, driven by the live + * playback position. Runs on every read (the render loop is the only + * consumer that knows the cursor continuously) and on resume/seek: + * - capHead: the decode pass pauses once it is maxAheadSec ahead of the + * cursor (pauseDecode keeps the decoded data — a plain startDecode + * from the frontier refills it later). + * - prune: segments entirely keepBehindSec behind the cursor are + * dropped. A backward seek past the window restarts a segment there — + * the same mechanism as a seek into an undecoded hole, so no new + * failure mode. + * - topUp: when the cursor has outrun the head, restart the tail decode + * from the frontier (one ffmpeg spawn per maxAheadSec of playback). + * Together these bound memory to (maxAheadSec + keepBehindSec) of audio + * regardless of episode length. + */ + private maintainWindow(atSec: number): void { + const pos = Math.max(0, atSec); + + if (this._decoding && this.coverageEndSec >= pos + this.maxAheadSec) { + this.pauseDecode(); + } + + const keepFromSec = pos - this.keepBehindSec; + if ( + this.segments.some( + (seg) => seg.baseSec + seg.written / this.sampleRate < keepFromSec, + ) + ) { + this.segments = this.segments.filter( + (seg) => seg.baseSec + seg.written / this.sampleRate >= keepFromSec, + ); + } + + if (!this._decoding && !this.decodeFinished) { + const end = this.coverageEndSec; + if (end < pos + this.maxAheadSec) { + this.startDecode(Math.max(end, pos)); + } + } + } + /** * Read the PCM window ENDING at `atSec` of playback into `out` * (Int16 magnitudes widened to f64, the scale cavacore expects). @@ -275,6 +360,7 @@ export class EpisodePcmCache { */ readWindow(out: Float64Array, atSec: number): number { if (out.length === 0) return 0; + this.maintainWindow(atSec); const endIdx = Math.round(atSec * this.sampleRate); const startIdx = endIdx - out.length + 1; for (const seg of this.segments) { diff --git a/src/utils/cavacore.ts b/src/utils/cavacore.ts index 8126e91..f659540 100644 --- a/src/utils/cavacore.ts +++ b/src/utils/cavacore.ts @@ -52,7 +52,6 @@ const DEFAULTS: Required = { scalingMode: 0, }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any type CavaLib = { symbols: Record any>; close(): void; @@ -102,7 +101,6 @@ export class CavaCore { this.lib = lib; } - /** Number of frequency bars configured. */ get bars(): number { return this._bars; } diff --git a/src/utils/event-bus.ts b/src/utils/event-bus.ts index d8e4587..d9e7b6c 100644 --- a/src/utils/event-bus.ts +++ b/src/utils/event-bus.ts @@ -43,7 +43,6 @@ function createEventBus(): EventBusInstance { } handlers.get(event)!.add(handler as EventHandler); - // Return unsubscribe function return () => { this.off(event, handler); }; diff --git a/src/utils/feeds-persistence.ts b/src/utils/feeds-persistence.ts index 0728910..4908293 100644 --- a/src/utils/feeds-persistence.ts +++ b/src/utils/feeds-persistence.ts @@ -134,7 +134,6 @@ export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void { } })().catch(() => {}); } -/** Load sources from config.json */ export async function loadSourcesFromFile(): Promise { try { const cfg = await loadConfig(); @@ -144,7 +143,6 @@ export async function loadSourcesFromFile(): Promise { return null; } } -/** Save sources to config.json */ export function saveSourcesToFile(sources: T[]): void { updateConfig({ sources: sources as unknown as PodcastSource[] }); } diff --git a/src/utils/jsonc.ts b/src/utils/jsonc.ts index 1d8ae56..8c518ec 100644 --- a/src/utils/jsonc.ts +++ b/src/utils/jsonc.ts @@ -5,9 +5,6 @@ * and multi-line comments, which is useful for configuration files. */ -/** - * Remove JSONC comments from a string - */ function stripComments(jsonString: string): string { const comments = [ { pattern: /\/\/.*$/gm, replacement: "" }, @@ -23,9 +20,6 @@ function stripComments(jsonString: string): string { return result; } -/** - * Parse JSONC string into a JavaScript object - */ export function parseJSONC(jsonString: string): unknown { const stripped = stripComments(jsonString); return JSON.parse(stripped); diff --git a/src/utils/keybinds-persistence.ts b/src/utils/keybinds-persistence.ts index 8d6eb00..e3468a0 100644 --- a/src/utils/keybinds-persistence.ts +++ b/src/utils/keybinds-persistence.ts @@ -95,7 +95,6 @@ export async function copyKeybindsIfNeeded(): Promise { } } -/** Load keybinds from JSONC file */ export async function loadKeybindsFromFile(): Promise { try { const filePath = getConfigFilePath(KEYBINDS_FILE); diff --git a/src/utils/theme-observer.ts b/src/utils/theme-observer.ts index 02c76f3..e364668 100644 --- a/src/utils/theme-observer.ts +++ b/src/utils/theme-observer.ts @@ -9,23 +9,14 @@ import { emit } from "./event-bus" -/** - * Emit a theme reload event. - */ function emitThemeReload(): void { emit("theme.reload", {}) } -/** - * Emit a theme changed event. - */ export function emitThemeChanged(theme: string, mode: "dark" | "light"): void { emit("theme.changed", { theme, mode }) } -/** - * Emit a theme mode changed event. - */ export function emitThemeModeChanged(mode: "dark" | "light"): void { emit("theme.mode.changed", { mode }) } diff --git a/src/utils/theme.ts b/src/utils/theme.ts index cf43658..5ffbdce 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -1,28 +1,13 @@ /** - * Theme CSS Variable Manager - * Handles dynamic theme switching by updating CSS custom properties + * Terminal Theme Resolver + * Resolves the active theme (built-in, custom, or system-derived) to colors. */ import type { TerminalColors } from "@opentui/core"; import type { ThemeJson } from "../types/theme-schema"; -import { THEME_JSON } from "../constants/themes"; -import { getCustomThemes } from "./custom-themes"; import { resolveTheme as resolveThemeJson } from "./theme-resolver"; import { generateSystemTheme } from "./system-theme"; -/** - * Apply CSS variable data-theme attribute - */ -export function setThemeAttribute(themeName: string) { - if (typeof document === "undefined") return; - const root = document.documentElement; - root.setAttribute("data-theme", themeName); -} - -export async function loadThemes() { - return await getCustomThemes(); -} - export function resolveTerminalTheme( themes: Record, name: string, @@ -32,9 +17,5 @@ export function resolveTerminalTheme( if (name === "system" && system) { return resolveThemeJson(generateSystemTheme(system, mode), mode); } - const theme = themes[name] ?? themes.catppuccin; - if (!theme) { - return resolveThemeJson(THEME_JSON.catppuccin, mode); - } - return resolveThemeJson(theme, mode); + return resolveThemeJson(themes[name] ?? themes.catppuccin, mode); } diff --git a/tests/audio-pcm-cache.test.ts b/tests/audio-pcm-cache.test.ts index 1e1cb7e..019ba5a 100644 --- a/tests/audio-pcm-cache.test.ts +++ b/tests/audio-pcm-cache.test.ts @@ -278,3 +278,87 @@ test.skipIf(!hasFfmpeg)( }, { timeout: 20000 }, ); + +test.skipIf(!hasFfmpeg)( + "decode head caps at maxAheadSec ahead of the cursor — the cache is a window, not a whole-episode dump", + async () => { + const wav = tmpWav(); + writeSineWav(wav, 30); + const cache = new EpisodePcmCache({ + url: wav, + maxAheadSec: 4, + keepBehindSec: 2, + }); + try { + cache.startDecode(0); + // The 8s initial burst delivers the front of the file instantly. + await waitForCoverage(cache, 5); + + // Park the cursor at 0 and drive the cap (the render loop reads + // every frame; the cap applies on the first read past the head). + const out = new Float64Array(512); + for (let i = 0; i < 30 && cache.decoding; i++) { + cache.readWindow(out, 0); + await Bun.sleep(20); + } + + // Paused at the head budget (4s) + one 8s burst of slack — NOT + // decoded to the 30s EOF. + expect(cache.decoding).toBe(false); + expect(cache.coverageEndSec).toBeGreaterThanOrEqual(4); + expect(cache.coverageEndSec).toBeLessThanOrEqual(4 + 8 + 1); + expect(cache.decodeFinished).toBe(false); + + // A parked cursor keeps the cap: more reads must not restart + // the pass or grow the cache. + const cappedAt = cache.coverageEndSec; + for (let i = 0; i < 10; i++) { + cache.readWindow(out, 0); + await Bun.sleep(20); + } + expect(cache.decoding).toBe(false); + expect(cache.coverageEndSec).toBeLessThanOrEqual(cappedAt + 1); + } finally { + cache.stop(); + await Bun.$`rm -f ${wav}`.quiet(); + } + }, + { timeout: 20000 }, +); + +test.skipIf(!hasFfmpeg)( + "the window prunes segments behind the cursor as playback advances", + async () => { + const wav = tmpWav(); + writeSineWav(wav, 30); + const cache = new EpisodePcmCache({ + url: wav, + maxAheadSec: 4, + keepBehindSec: 2, + }); + try { + // Two segments: the back half [10, ~18] and, after the seek, + // the front [2, ~10]. + cache.startDecode(10); + await waitForCoverage(cache, 11); + cache.ensureDecodeAround(2); + await waitForCoverage(cache, 2.2); + expect(cache.covers(2.5)).toBe(true); + expect(cache.covers(10.5)).toBe(true); + + // Cursor advances past the front segment's end + keepBehind: + // the front must fall out of the window, the back must survive. + const out = new Float64Array(512); + for (let i = 0; i < 40 && cache.covers(2.5); i++) { + cache.readWindow(out, 13); + await Bun.sleep(25); + } + expect(cache.covers(2.5)).toBe(false); + expect(cache.covers(10.5)).toBe(true); + } finally { + cache.stop(); + await Bun.$`rm -f ${wav}`.quiet(); + } + }, + { timeout: 20000 }, +);