Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7ceb9d045 | |||
| 0677f82c44 | |||
| 4990eae60f | |||
| 9ddfd21685 | |||
| 22059c24ca | |||
| 0aac0a157f | |||
| df4701957b | |||
| 4b44623891 | |||
| 4ef9ab7e59 | |||
| 9df8eebf6c | |||
| badbc6a037 | |||
| 878d1e01ab | |||
| 42c48e59fb | |||
| 91d4acca90 | |||
| 20d5b57cb6 | |||
| d7aec4e810 |
@@ -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
|
||||
|
||||
### Linting
|
||||
- `bun run lint` - Run ESLint with TypeScript rules
|
||||
- `bun run lint` - Run the TypeScript typecheck (`bun tsc --noEmit`)
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ external player with full transport control — all from your terminal.
|
||||
- **Search** across your subscribed shows.
|
||||
- **Audio playback** through an external player with full transport control:
|
||||
play/pause, next/previous, seek, speed, and per-episode resume progress.
|
||||
When an episode finishes, the next one plays automatically, continuing
|
||||
down the list you started it from (search results, a show, or the Feed).
|
||||
- **Themeable** and **remappable keybindings**.
|
||||
- Ships as a **standalone compiled binary** — no runtime or install step beyond
|
||||
a system audio player.
|
||||
@@ -208,7 +210,9 @@ entry. Releases are compiled with bunfig autoload disabled
|
||||
entirely. If you still hit it, you're on an old release — upgrade.
|
||||
|
||||
**No audio — playback is a silent no-op** — PodTui needs **mpv** on your
|
||||
`PATH`. Install it (`brew install mpv`, `pacman -S mpv`, …) and relaunch.
|
||||
`PATH`. Homebrew and AUR installs pull it in automatically; if you used the
|
||||
standalone tarball, install it yourself (`brew install mpv`, `pacman -S mpv`,
|
||||
…) and relaunch.
|
||||
|
||||
**Homebrew prints a dylib warning** — “load commands do not fit in the header
|
||||
… needs `-headerpad`” is benign: the app loads its libraries by path, the
|
||||
|
||||
@@ -18,15 +18,13 @@
|
||||
},
|
||||
"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": {
|
||||
"@opentui/core": "^0.1.77",
|
||||
"@opentui/solid": "^0.1.77",
|
||||
"date-fns": "^4.1.0",
|
||||
"effect": "^3",
|
||||
"solid-js": "^1.9.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,43 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** FNV-1a 32-bit hash. Deterministic across processes and Bun versions
|
||||
* (unlike Bun.hash) — used to derive stable episode ids from audio URLs so
|
||||
* a feed's episode ids never change between refreshes. */
|
||||
const fnv1a = (input: string): number => {
|
||||
let hash = 0x811c9dc5
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i)
|
||||
hash = Math.imul(hash, 0x01000193)
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable per-episode identity. The old positional id (`feedUrl#index`) was
|
||||
* invalidated by ANY feed change: a new episode or a pruned one shifted
|
||||
* every episode's index, so progress/downloads saved under `feedUrl#5`
|
||||
* attached to whatever episode now sat at index 5 — new episodes resumed
|
||||
* minutes in. Identity derives from stable content instead:
|
||||
* 1. `<guid>` — the canonical per-episode identifier (required by Apple
|
||||
* Podcasts; nearly universal).
|
||||
* 2. The enclosure URL, hashed to keep the id compact (hosts serve
|
||||
* permanent per-episode URLs; guids can be absent in hand-rolled feeds).
|
||||
* 3. Positional index as a last resort: no guid AND no audio URL means
|
||||
* the episode cannot be played, so nothing persistent keys off it.
|
||||
*/
|
||||
const stableEpisodeId = (
|
||||
feedUrl: string,
|
||||
item: string,
|
||||
audioUrl: string,
|
||||
index: number,
|
||||
): string => {
|
||||
const guid = getTagValue(item, "guid")
|
||||
if (guid) return `${feedUrl}#guid:${guid}`
|
||||
if (audioUrl) return `${feedUrl}#url:${fnv1a(audioUrl).toString(36)}`
|
||||
return `${feedUrl}#${index}`
|
||||
}
|
||||
|
||||
/** Extract the `<item>` blocks from an RSS document. Matches items directly
|
||||
* on the full XML string — scoping to <channel> first is a redundant 5MB
|
||||
* regex pass that doubles parse cost with no practical benefit (well-formed
|
||||
@@ -100,24 +137,20 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis
|
||||
const epDescription = cleanField(getTagValue(item, "description"))
|
||||
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
||||
|
||||
// Audio URL + file size + MIME type from <enclosure>
|
||||
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
||||
const audioUrl = enclosure?.[1] ?? ""
|
||||
const fileSizeStr = getAttr(item, "enclosure", "length")
|
||||
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
||||
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
||||
|
||||
// Duration from <itunes:duration>
|
||||
const durationRaw = getTagValue(item, "itunes:duration")
|
||||
const duration = parseDuration(durationRaw)
|
||||
|
||||
// Episode & season numbers
|
||||
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
||||
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
||||
const seasonNumRaw = getTagValue(item, "itunes:season")
|
||||
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
||||
|
||||
// Episode type & explicit
|
||||
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
||||
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
||||
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
||||
@@ -126,7 +159,7 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis
|
||||
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
||||
|
||||
const ep: Episode = {
|
||||
id: `${feedUrl}#${index}`,
|
||||
id: stableEpisodeId(feedUrl, item, audioUrl, index),
|
||||
podcastId: feedUrl,
|
||||
title: epTitle,
|
||||
description: epDescription,
|
||||
@@ -135,7 +168,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
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
// printable char
|
||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||
evt.preventDefault();
|
||||
nav.appendCommand(evt.name);
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"sort": [","],
|
||||
"toggle-hidden": ["."],
|
||||
"refresh": ["r"],
|
||||
"subscribe": ["a"], // subscribe focused show/episode result in place (Search)
|
||||
"subscribe": ["a"], // subscribe focused show in place (Discover/Search)
|
||||
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
||||
|
||||
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
||||
|
||||
@@ -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 {
|
||||
@@ -119,6 +120,14 @@ const EMPTY_TERMINAL_COLORS: TerminalColors = {
|
||||
/** Cached macOS appearance (dark/light), independent of the terminal. */
|
||||
let cachedOsMode: "dark" | "light" | null = null;
|
||||
|
||||
/**
|
||||
* How often to re-query the terminal for theme changes (OSC 10/11/12).
|
||||
* Terminals only answer these queries — they never push a color change —
|
||||
* so detection is a slow poll. 60 s keeps CPU cost unmeasurable while
|
||||
* still tracking theme flips within a reasonable delay.
|
||||
*/
|
||||
const SYSTEM_THEME_POLL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Detect the terminal's dark/light mode.
|
||||
*
|
||||
@@ -175,7 +184,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
|
||||
function init() {
|
||||
resolveSystemTheme();
|
||||
loadThemes()
|
||||
getCustomThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
@@ -187,7 +196,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);
|
||||
}
|
||||
@@ -215,7 +223,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSystemTheme() {
|
||||
/**
|
||||
* Query the terminal's colors via OSC (palette + default fg/bg), with a
|
||||
* legacy-tmux fallback for servers < 3.6 that don't forward OSC replies.
|
||||
* Returns null when the terminal cannot answer.
|
||||
*/
|
||||
async function queryTerminalColors(): Promise<TerminalColors | null> {
|
||||
if (process.env.TMUX) {
|
||||
await waitForCapabilities();
|
||||
}
|
||||
@@ -254,6 +267,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
async function resolveSystemTheme() {
|
||||
const colors = await queryTerminalColors();
|
||||
|
||||
// ── dark/light mode detection ─────────────────────────────────────────
|
||||
// The provider starts with a hardcoded mode (e.g. "dark"); detect the
|
||||
// real one from the terminal's background color (OSC 11) or, when that
|
||||
@@ -299,8 +318,55 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for terminal theme changes: re-query OSC colors, update the
|
||||
* system palette when it differs, and re-detect dark/light mode.
|
||||
* Runs on a slow timer (see SYSTEM_THEME_POLL_MS); most polls change
|
||||
* nothing and only pay the idle query round-trip.
|
||||
*/
|
||||
async function pollSystemTheme() {
|
||||
if (!store.ready) return;
|
||||
const colors = await queryTerminalColors();
|
||||
if (!colors) return;
|
||||
|
||||
const current = store.system;
|
||||
const changed =
|
||||
!current ||
|
||||
current.defaultBackground !== colors.defaultBackground ||
|
||||
current.defaultForeground !== colors.defaultForeground ||
|
||||
current.palette.join(",") !== colors.palette.join(",");
|
||||
|
||||
if (changed) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.system = colors;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh the OS-appearance fallback only when the terminal cannot
|
||||
// report a background (e.g. tmux without OSC forwarding), so the
|
||||
// common path never spawns a subprocess.
|
||||
if (process.platform === "darwin" && !colors.defaultBackground) {
|
||||
cachedOsMode = null;
|
||||
}
|
||||
const detectedMode = detectSystemMode(colors);
|
||||
if (detectedMode && detectedMode !== store.mode) {
|
||||
setStore("mode", detectedMode);
|
||||
emitThemeModeChanged(detectedMode);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(init);
|
||||
|
||||
// Poll the terminal for theme changes (see pollSystemTheme). Registered
|
||||
// once per provider init — SIGUSR2 re-runs the inner `init`, not this
|
||||
// closure, so the timer cannot stack.
|
||||
const pollTimer = setInterval(() => {
|
||||
void pollSystemTheme();
|
||||
}, SYSTEM_THEME_POLL_MS);
|
||||
onCleanup(() => clearInterval(pollTimer));
|
||||
|
||||
// Setup SIGUSR2 signal handler for dynamic theme reload
|
||||
// This allows external tools to trigger a theme refresh by sending:
|
||||
// `kill -USR2 <pid>`
|
||||
|
||||
85
src/effects/feed-refresh.ts
Normal file
85
src/effects/feed-refresh.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Feed-refresh batch as an Effect program.
|
||||
*
|
||||
* Replaces the hand-rolled worker pool (mapWithConcurrency) + per-feed
|
||||
* fetch/apply plumbing in stores/feed.ts with Effect's structured
|
||||
* concurrency:
|
||||
* - `Effect.forEach(..., { concurrency })` bounds in-flight fetches to
|
||||
* `concurrency` (starts exactly that many fibers; each completion pulls
|
||||
* the next feed — identical semantics to the old shared-counter pool).
|
||||
* - `Effect.timeout` bounds each feed's fetch to `timeoutMs`. It runs
|
||||
* through the `Clock` service, so under `TestContext` the TestClock
|
||||
* drives it deterministically (no real 20s wait in tests).
|
||||
* - Failures are folded to a null result: a failed or timed-out feed is
|
||||
* left untouched instead of failing the batch.
|
||||
* - The apply callback runs inside each feed's own fiber, so a feed's
|
||||
* refreshed episodes land AS ITS OWN FETCH COMPLETES — the
|
||||
* per-feed-apply-as-it-lands contract, no Promise.all barrier.
|
||||
*
|
||||
* The store boundary (stores/feed.ts) supplies the real fetch and apply
|
||||
* closures and runs the program with Effect.runPromise.
|
||||
*/
|
||||
|
||||
import { Duration, Effect } from "effect"
|
||||
import type { Episode } from "../types/episode"
|
||||
import type { Feed } from "../types/feed"
|
||||
|
||||
/** Result of fetching one feed's RSS. `episodes: null` means the fetch
|
||||
* failed or timed out — callers must leave that feed untouched. */
|
||||
export interface RefreshFetchResult {
|
||||
episodes: Episode[] | null
|
||||
coverUrl: string | undefined
|
||||
}
|
||||
|
||||
/** Result guaranteed to have parsed episodes (the apply path only). */
|
||||
export interface RefreshSuccess {
|
||||
episodes: Episode[]
|
||||
coverUrl: string | undefined
|
||||
}
|
||||
|
||||
export interface RefreshBatchOptions {
|
||||
/** Max simultaneous in-flight fetches. */
|
||||
concurrency: number
|
||||
/** Per-feed fetch timeout in milliseconds. */
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
/** Fold any failure (network error, timeout, rejection) to a null result so
|
||||
* one bad feed can never fail the batch. */
|
||||
const failedResult: RefreshFetchResult = { episodes: null, coverUrl: undefined }
|
||||
|
||||
/** Fetch one feed with a timeout, applying its result as its own fetch
|
||||
* lands. A failed or timed-out fetch yields null — the feed is untouched. */
|
||||
const refreshOne = (
|
||||
feed: Feed,
|
||||
fetchOne: (feed: Feed) => Promise<RefreshFetchResult>,
|
||||
applyOne: (feed: Feed, result: RefreshSuccess) => void,
|
||||
timeoutMs: number,
|
||||
): Effect.Effect<void> =>
|
||||
Effect.tryPromise(() => fetchOne(feed)).pipe(
|
||||
Effect.timeout(Duration.millis(timeoutMs)),
|
||||
Effect.catchAll(() => Effect.succeed(failedResult)),
|
||||
Effect.flatMap((result) => {
|
||||
if (result.episodes === null) return Effect.void
|
||||
// Capture the narrowed array before the closure — TS drops the
|
||||
// `episodes !== null` narrowing inside Effect.sync's callback.
|
||||
const episodes = result.episodes
|
||||
return Effect.sync(() => applyOne(feed, { episodes, coverUrl: result.coverUrl }))
|
||||
}),
|
||||
)
|
||||
|
||||
/** Refresh every feed with bounded concurrency. Each feed's refreshed
|
||||
* episodes are applied as its own fetch lands (no barrier); a failed or
|
||||
* timed-out feed is left untouched. The program never fails — failures
|
||||
* are folded to per-feed no-ops. */
|
||||
export const refreshFeedsBatch = (
|
||||
feeds: readonly Feed[],
|
||||
fetchOne: (feed: Feed) => Promise<RefreshFetchResult>,
|
||||
applyOne: (feed: Feed, result: RefreshSuccess) => void,
|
||||
options: RefreshBatchOptions,
|
||||
): Effect.Effect<void> =>
|
||||
Effect.forEach(
|
||||
feeds,
|
||||
(feed) => refreshOne(feed, fetchOne, applyOne, options.timeoutMs),
|
||||
{ concurrency: options.concurrency, discard: true },
|
||||
)
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
createAudioBackend,
|
||||
detectPlayers,
|
||||
PlayerRestartedError,
|
||||
type AudioBackend,
|
||||
type BackendName,
|
||||
type DetectedPlayer,
|
||||
@@ -54,10 +55,15 @@ import {
|
||||
saveLastPlayerSync,
|
||||
} from "../utils/app-persistence";
|
||||
import type { Episode, Progress } from "../types/episode";
|
||||
import type { Feed } from "../types/feed";
|
||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
||||
import { useAudioNavStore } from "../stores/audio-nav";
|
||||
import { useDownloadStore } from "../stores/download";
|
||||
import { useFeedStore } from "../stores/feed";
|
||||
import { useSearchStore } from "../stores/search";
|
||||
import {
|
||||
nextStep,
|
||||
prevStep,
|
||||
queueForSource,
|
||||
} from "../utils/audio-queue";
|
||||
|
||||
export interface AudioControls {
|
||||
// Signals (reactive getters)
|
||||
@@ -179,8 +185,10 @@ const PAUSE_WATCH_TICKS = 7;
|
||||
|
||||
/** The player process died while we believed playback was live — track
|
||||
* ended (mpv quits at EOF) or the process crashed. Persist the final
|
||||
* position and stop polling. */
|
||||
function finalizeTrackEnd(): void {
|
||||
* position and stop polling. `autoAdvance` is true only when the track
|
||||
* reached its natural end with the player still alive and no stream error
|
||||
* — the signal to keep the queue going. */
|
||||
function finalizeTrackEnd(autoAdvance: boolean): void {
|
||||
setIsPlaying(false);
|
||||
stopPolling();
|
||||
const ep = currentEpisode();
|
||||
@@ -188,6 +196,12 @@ function finalizeTrackEnd(): void {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
}
|
||||
if (autoAdvance) {
|
||||
// The episode finished: play the next one from the source that
|
||||
// started it (search results / show / feed). No-op at the end of
|
||||
// the list or when the episode isn't in the source list anymore.
|
||||
void next().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal,
|
||||
@@ -234,7 +248,12 @@ function startPolling(): void {
|
||||
// and reports pause=true there, which would otherwise be
|
||||
// mistaken for an external pause and never finalize.
|
||||
if (!backend.isPlaying()) {
|
||||
finalizeTrackEnd();
|
||||
// Natural EOF (player alive, no stream error) auto-advances
|
||||
// to the next episode; a crashed/killed daemon or a failed
|
||||
// stream must not start the next episode on its own.
|
||||
finalizeTrackEnd(
|
||||
backend.isAlive() && !backend.getPlaybackError(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -269,7 +288,7 @@ function startPolling(): void {
|
||||
// still alive: a dead player while we thought we were paused
|
||||
// means the track ended (mpv quits at EOF) or it crashed.
|
||||
if (!backend.isAlive()) {
|
||||
finalizeTrackEnd();
|
||||
finalizeTrackEnd(false);
|
||||
return;
|
||||
}
|
||||
const paused = await backend.getPauseState();
|
||||
@@ -297,6 +316,34 @@ function stopPolling(): void {
|
||||
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
|
||||
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
|
||||
|
||||
/** Resolve cover art to a local path for mpv's --cover-art-files, per the
|
||||
* call site's latency budget:
|
||||
* "cache" — disk cache only (sync): resume paths must never wait on the
|
||||
* network, so a miss plays artless and warms for next time.
|
||||
* "bounded" — disk hit, else fetch capped at 1.2s: cold play needs the art
|
||||
* at file LOAD, but a slow cover server must not stall audio.
|
||||
* "await" — disk hit, else full (8s-bounded) fetch: boot restore preloads
|
||||
* while feeds/progress load anyway, so the wait is free and the
|
||||
* cover must be present when the file loads.
|
||||
* fetchCoverArt already short-circuits on the disk cache, so "await" costs
|
||||
* nothing on a warm cache. */
|
||||
async function resolveCoverArt(
|
||||
coverUrl: string | undefined,
|
||||
mode: "cache" | "bounded" | "await",
|
||||
): Promise<string | null> {
|
||||
if (!coverUrl) return null;
|
||||
if (mode === "cache") return cachedCoverPath(coverUrl);
|
||||
if (mode === "bounded") {
|
||||
const cached = cachedCoverPath(coverUrl);
|
||||
if (cached) return cached;
|
||||
return Promise.race([
|
||||
fetchCoverArt(coverUrl),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
|
||||
]);
|
||||
}
|
||||
return fetchCoverArt(coverUrl);
|
||||
}
|
||||
|
||||
async function play(episode: Episode): Promise<void> {
|
||||
const b = ensureBackend();
|
||||
setError(null);
|
||||
@@ -306,43 +353,61 @@ async function play(episode: Episode): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const appStore = useAppStore();
|
||||
const progressStore = useProgressStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
const vol = volume();
|
||||
const spd = storeSpeed || speed();
|
||||
const appStore = useAppStore();
|
||||
const progressStore = useProgressStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
const vol = volume();
|
||||
const spd = storeSpeed || speed();
|
||||
|
||||
const feedStore = useFeedStore();
|
||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
// Play the downloaded file when present (offline + no network stalls);
|
||||
// otherwise stream. Cover resolves to the feed art, falling back to the
|
||||
// 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;
|
||||
const feedStore = useFeedStore();
|
||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
// Play the downloaded file when present (offline + no network stalls);
|
||||
// otherwise stream. Cover resolves to the feed art, falling back to the
|
||||
// episode's own image (feeds added by URL may lack a channel cover).
|
||||
const downloadStore = useDownloadStore();
|
||||
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||
|
||||
// Resume from saved progress if available and not completed
|
||||
const savedProgress = progressStore.get(episode.id);
|
||||
let startPos = 0;
|
||||
if (savedProgress && !progressStore.isCompleted(episode.id)) {
|
||||
startPos = savedProgress.position;
|
||||
}
|
||||
|
||||
// Present the new episode in the UI IMMEDIATELY, before the backend load
|
||||
// (cover fetch + loadfile can take a few hundred ms): the player tab,
|
||||
// status bar, and OS Now Playing must not keep showing the previous
|
||||
// episode during the swap. The previous track's poll is stopped so it
|
||||
// can't attribute its position/progress to the new episode; polling
|
||||
// restarts once the backend is actually playing. Mirrors load()'s
|
||||
// synchronous presentation.
|
||||
stopPolling();
|
||||
setCurrentEpisode(episode);
|
||||
setIsPlaying(false);
|
||||
startedPlayback = false;
|
||||
setPosition(startPos);
|
||||
setSpeed(spd);
|
||||
if (episode.duration) setDuration(episode.duration);
|
||||
const media = useMediaRegistry();
|
||||
media.setNowPlaying({
|
||||
title: episode.title,
|
||||
artist: podcastTitle || episode.podcastId,
|
||||
duration: episode.duration,
|
||||
});
|
||||
media.setPlaybackState(false);
|
||||
if (startPos > 0) media.setPosition(startPos);
|
||||
|
||||
try {
|
||||
// Cover art only applies at file LOAD (the runtime video-add fallback
|
||||
// never becomes an albumart track), so a cold-cache play must wait for
|
||||
// the fetch or play artless. Serve the disk cache synchronously; on a
|
||||
// miss, await the single-flight fetch with a 1.2s cap (covers fetch in
|
||||
// ~300ms typically) — past the cap, play bare and let the fetch warm
|
||||
// the cache for next time.
|
||||
let coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
||||
if (coverUrl && !coverArtPath) {
|
||||
const path = await Promise.race([
|
||||
fetchCoverArt(coverUrl),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
|
||||
]);
|
||||
if (path) coverArtPath = path;
|
||||
}
|
||||
|
||||
// Resume from saved progress if available and not completed
|
||||
const savedProgress = progressStore.get(episode.id);
|
||||
let startPos = 0;
|
||||
if (savedProgress && !progressStore.isCompleted(episode.id)) {
|
||||
startPos = savedProgress.position;
|
||||
}
|
||||
// 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",
|
||||
);
|
||||
|
||||
await b.play(url, {
|
||||
volume: vol,
|
||||
@@ -352,10 +417,8 @@ async function play(episode: Episode): Promise<void> {
|
||||
coverArtPath: coverArtPath ?? undefined,
|
||||
});
|
||||
|
||||
setCurrentEpisode(episode);
|
||||
setIsPlaying(true);
|
||||
setPosition(startPos);
|
||||
setSpeed(spd);
|
||||
if (episode.duration) setDuration(episode.duration);
|
||||
startedPlayback = true;
|
||||
|
||||
@@ -364,12 +427,6 @@ async function play(episode: Episode): Promise<void> {
|
||||
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||
|
||||
// Register with platform media controls
|
||||
const media = useMediaRegistry();
|
||||
media.setNowPlaying({
|
||||
title: episode.title,
|
||||
artist: podcastTitle || episode.podcastId,
|
||||
duration: episode.duration,
|
||||
});
|
||||
media.setPlaybackState(true);
|
||||
if (startPos > 0) media.setPosition(startPos);
|
||||
|
||||
@@ -436,8 +493,10 @@ async function load(episode: Episode): Promise<void> {
|
||||
// on feeds/progress at boot, so the bounded fetch (~300ms typical,
|
||||
// 8s worst case) is free. Falls back to the episode's own image when
|
||||
// the feed has no channel cover.
|
||||
const coverUrl = feed?.podcast.coverUrl ?? episode.imageUrl;
|
||||
const coverArtPath = coverUrl ? await fetchCoverArt(coverUrl) : null;
|
||||
const coverArtPath = await resolveCoverArt(
|
||||
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||
"await",
|
||||
);
|
||||
const backendSnap = backend;
|
||||
backendSnap
|
||||
.preload(url, {
|
||||
@@ -478,8 +537,25 @@ async function pause(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** mpv was killed/crashed: respawn it and restart playback from the saved
|
||||
* position via the full play path (fresh loadfile, cover art, media
|
||||
* registry). A bare unpause would target a dead — or freshly-idle —
|
||||
* daemon and silently do nothing. */
|
||||
async function recoverPlayback(): Promise<void> {
|
||||
const ep = currentEpisode();
|
||||
if (ep && ep.audioUrl) {
|
||||
await play(ep);
|
||||
} else {
|
||||
setError("Player is not running");
|
||||
}
|
||||
}
|
||||
|
||||
async function resume(): Promise<void> {
|
||||
if (!backend) return;
|
||||
if (!backend.isAlive()) {
|
||||
await recoverPlayback();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await backend.resume();
|
||||
setIsPlaying(true);
|
||||
@@ -491,6 +567,13 @@ async function resume(): Promise<void> {
|
||||
media.setPlaybackState(true);
|
||||
}
|
||||
} catch (err) {
|
||||
// Race: the daemon died between the liveness check above and the
|
||||
// unpause — backend.resume() respawned it and threw
|
||||
// PlayerRestartedError (the fresh daemon has no file loaded).
|
||||
if (err instanceof PlayerRestartedError) {
|
||||
await recoverPlayback();
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Resume failed");
|
||||
}
|
||||
}
|
||||
@@ -612,8 +695,10 @@ async function switchBackend(name: BackendName): Promise<void> {
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
const url =
|
||||
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
|
||||
const coverUrl = feed?.podcast.coverUrl ?? ep.imageUrl;
|
||||
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
||||
const coverArtPath = await resolveCoverArt(
|
||||
feed?.podcast.coverUrl ?? ep.imageUrl,
|
||||
"cache",
|
||||
);
|
||||
await backend.play(url, {
|
||||
startPosition: pos,
|
||||
volume: vol,
|
||||
@@ -677,6 +762,60 @@ export async function restoreLastSession(): Promise<void> {
|
||||
* Returns a singleton — all components share the same playback state.
|
||||
* Registers event bus listeners and cleans them up with onCleanup.
|
||||
*/
|
||||
|
||||
// ── Episode queue navigation ──────────────────────────────────────────────
|
||||
// `next`/`prev` (and the end-of-episode auto-advance in finalizeTrackEnd)
|
||||
// move within the ordered list of the source that STARTED the current
|
||||
// episode: the Feed's chronological list, the current show's episodes, or
|
||||
// the search results (see utils/audio-queue). Module-level so
|
||||
// finalizeTrackEnd can auto-advance without a mounted hook owner.
|
||||
|
||||
const audioNav = useAudioNavStore();
|
||||
|
||||
/** The ordered playable episodes for the source that started playback. */
|
||||
function queueForCurrentSource(): Episode[] {
|
||||
const feedStore = useFeedStore();
|
||||
return queueForSource(
|
||||
audioNav.getSource(),
|
||||
audioNav.getPodcastId(),
|
||||
feedStore.feeds(),
|
||||
feedStore.getAllEpisodesChronological(),
|
||||
useSearchStore().results(),
|
||||
);
|
||||
}
|
||||
|
||||
async function next(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
const step = nextStep(queueForCurrentSource(), current.id);
|
||||
// A duplicated queue entry (same episode id twice) must not make
|
||||
// "next" replay the CURRENT episode — that would reload it from
|
||||
// saved progress and audibly repeat already-played audio.
|
||||
if (!step || step.episode.id === current.id) return;
|
||||
await play(step.episode);
|
||||
audioNav.next(step.index);
|
||||
}
|
||||
|
||||
async function prev(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
|
||||
// Standard transport behavior: past 30s in, "prev" restarts the current
|
||||
// episode; before that it steps back within the source queue.
|
||||
const NAV_START_THRESHOLD = 30;
|
||||
const currentPos = position();
|
||||
const currentDur = duration();
|
||||
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
|
||||
await seek(NAV_START_THRESHOLD);
|
||||
return;
|
||||
}
|
||||
|
||||
const step = prevStep(queueForCurrentSource(), current.id);
|
||||
if (!step) return;
|
||||
await play(step.episode);
|
||||
audioNav.prev(step.index);
|
||||
}
|
||||
|
||||
export function useAudio(): AudioControls {
|
||||
// Initialize backend on first use
|
||||
ensureBackend();
|
||||
@@ -742,80 +881,6 @@ export function useAudio(): AudioControls {
|
||||
await doSetSpeed(next);
|
||||
});
|
||||
|
||||
const audioNav = useAudioNavStore();
|
||||
const feedStore = useFeedStore();
|
||||
|
||||
async function prev(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
|
||||
const currentPos = position();
|
||||
const currentDur = duration();
|
||||
|
||||
const NAV_START_THRESHOLD = 30;
|
||||
|
||||
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
|
||||
await seek(NAV_START_THRESHOLD);
|
||||
} else {
|
||||
const source = audioNav.getSource();
|
||||
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
|
||||
if (source === AudioSource.FEED) {
|
||||
episodes = feedStore.getAllEpisodesChronological();
|
||||
} else if (source === AudioSource.MY_SHOWS) {
|
||||
const podcastId = audioNav.getPodcastId();
|
||||
if (!podcastId) return;
|
||||
|
||||
const feed = feedStore
|
||||
.getFilteredFeeds()
|
||||
.find((f) => f.podcast.id === podcastId);
|
||||
if (!feed) return;
|
||||
|
||||
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
|
||||
}
|
||||
|
||||
const currentIndex = audioNav.getCurrentIndex();
|
||||
const newIndex = Math.max(0, currentIndex - 1);
|
||||
|
||||
if (newIndex < episodes.length && episodes[newIndex]) {
|
||||
const { episode } = episodes[newIndex];
|
||||
await play(episode);
|
||||
audioNav.prev(newIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function next(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
|
||||
const source = audioNav.getSource();
|
||||
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
|
||||
if (source === AudioSource.FEED) {
|
||||
episodes = feedStore.getAllEpisodesChronological();
|
||||
} else if (source === AudioSource.MY_SHOWS) {
|
||||
const podcastId = audioNav.getPodcastId();
|
||||
if (!podcastId) return;
|
||||
|
||||
const feed = feedStore
|
||||
.getFilteredFeeds()
|
||||
.find((f) => f.podcast.id === podcastId);
|
||||
if (!feed) return;
|
||||
|
||||
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
|
||||
}
|
||||
|
||||
const currentIndex = audioNav.getCurrentIndex();
|
||||
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
|
||||
|
||||
if (newIndex >= 0 && episodes[newIndex]) {
|
||||
const { episode } = episodes[newIndex];
|
||||
await play(episode);
|
||||
audioNav.next(newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
refCount--;
|
||||
unsubPlay();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Feed } from "./types/feed"
|
||||
import type { Episode } from "./types/episode"
|
||||
|
||||
const VERSION = "0.6.1";
|
||||
const VERSION = "0.7.1";
|
||||
|
||||
interface CliArgs {
|
||||
version: boolean;
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,18 +5,28 @@
|
||||
* placeholder (1/5 slot kept).
|
||||
* depth 1 (current) — podcast results for the drilled category. Parent
|
||||
* pane = the categories list.
|
||||
* preview — detail of the hovered item (category summary, or
|
||||
* podcast detail + subscribe action).
|
||||
* depth 2 (current) — episodes of the drilled show, fetched on demand
|
||||
* WITHOUT subscribing. Parent pane = the results list.
|
||||
* preview — detail of the hovered item (category summary,
|
||||
* podcast detail, or episode detail).
|
||||
*
|
||||
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||
* remains. `l`/Enter drills in (category → results) or subscribes (on a
|
||||
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
||||
* column. Moving through categories at depth 0 updates the store's selected
|
||||
* category so the preview follows.
|
||||
* remains. `l`/Enter drills in (category → results → episodes); `a`
|
||||
* subscribes the focused show (enter/l never subscribe — they open the
|
||||
* episode list); `h` pops a depth (noop at 0). j/k move only within the
|
||||
* current column. Moving through categories at depth 0 updates the store's
|
||||
* selected category so the preview follows.
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Podcast } from "@/types/podcast";
|
||||
import { format } from "date-fns";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import {
|
||||
@@ -32,6 +42,7 @@ import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { EpisodeRow, EpisodePreview } from "@/components/EpisodeList";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||
|
||||
@@ -41,11 +52,16 @@ function DiscoverPage() {
|
||||
// Static: detection never changes mid-session.
|
||||
const nerd = supportsNerdFonts();
|
||||
const discoverStore = useDiscoverStore();
|
||||
const feedStore = useFeedStore();
|
||||
const downloadStore = useDownloadStore();
|
||||
const audio = useAudio();
|
||||
const audioNav = useAudioNavStore();
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
const marker = useSelectionMarker();
|
||||
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||
|
||||
@@ -60,14 +76,37 @@ function DiscoverPage() {
|
||||
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
|
||||
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
|
||||
|
||||
// depth-2 frame ctx = the drilled podcast id (episode preview, no
|
||||
// subscription). Episodes come from the discover store's session cache.
|
||||
const drilledPodcastId = (): string => stack()[2]?.ctx ?? "";
|
||||
const drilledPodcast = (): Podcast | undefined =>
|
||||
podcasts().find((p) => p.id === drilledPodcastId());
|
||||
const episodes = createMemo<Episode[]>(() => {
|
||||
if (depth() < 2) return [];
|
||||
return discoverStore.episodesForPodcast(drilledPodcastId());
|
||||
});
|
||||
const episodesLoading = () =>
|
||||
depth() >= 2 && discoverStore.isLoadingEpisodesFor(drilledPodcastId());
|
||||
const episodesError = () =>
|
||||
depth() >= 2 ? discoverStore.previewError(drilledPodcastId()) : undefined;
|
||||
const focusedEpIdx = () =>
|
||||
episodes().length === 0 ? 0 : Math.min(focus(2), episodes().length - 1);
|
||||
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
||||
|
||||
const curLen = () =>
|
||||
depth() === 0 ? categories().length : podcasts().length;
|
||||
depth() === 0
|
||||
? categories().length
|
||||
: depth() === 1
|
||||
? podcasts().length
|
||||
: episodes().length;
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (categories().length > 0 && focus(0) >= categories().length)
|
||||
nav.setDepthFocus(categories().length - 1, 0);
|
||||
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
||||
nav.setDepthFocus(podcasts().length - 1, 1);
|
||||
if (episodes().length > 0 && focus(2) >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 2);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
@@ -80,13 +119,56 @@ function DiscoverPage() {
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) return categories()[i]?.id;
|
||||
return podcasts()[i]?.id;
|
||||
if (depth() === 1) return podcasts()[i]?.id;
|
||||
return episodes()[i]?.id;
|
||||
});
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
|
||||
/** The subscribed feed backing a podcast, if any (matched by directory id
|
||||
* or feed URL — a Discover show may already be subscribed). */
|
||||
const feedForPodcast = (p: Podcast) =>
|
||||
feedStore.feeds().find(
|
||||
(f) =>
|
||||
f.podcast.id === p.id ||
|
||||
(!!p.feedUrl && f.podcast.feedUrl === p.feedUrl),
|
||||
);
|
||||
|
||||
const downloadLabel = (id: string) => {
|
||||
switch (downloadStore.getDownloadStatus(id)) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return "[Q]";
|
||||
case DownloadStatus.DOWNLOADING:
|
||||
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||
case DownloadStatus.COMPLETED:
|
||||
return "[DL]";
|
||||
case DownloadStatus.FAILED:
|
||||
return "[ERR]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const downloadColor = (id: string) => {
|
||||
switch (downloadStore.getDownloadStatus(id)) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return theme.warning;
|
||||
case DownloadStatus.DOWNLOADING:
|
||||
return theme.primary;
|
||||
case DownloadStatus.COMPLETED:
|
||||
return theme.success;
|
||||
case DownloadStatus.FAILED:
|
||||
return theme.error;
|
||||
default:
|
||||
return muted();
|
||||
}
|
||||
};
|
||||
const playEpisode = (ep: Episode) => {
|
||||
audio.play(ep).catch(() => {});
|
||||
audioNav.setSource(AudioSource.SEARCH, drilledPodcast()?.id);
|
||||
};
|
||||
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
@@ -97,9 +179,19 @@ function DiscoverPage() {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 1) {
|
||||
if (depth() === 1) {
|
||||
const pod = focusedPodcast();
|
||||
if (pod) discoverStore.toggleSubscription(pod.id);
|
||||
if (!pod) return;
|
||||
// Drill into the show's episode list WITHOUT subscribing — `l`,
|
||||
// right, and Enter open the episodes; `a` is the subscribe key.
|
||||
discoverStore.openEpisodes(pod).catch(() => {});
|
||||
nav.pushDepth({ kind: "episodes", ctx: pod.id, focus: 0 } as DepthFrame);
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 2) {
|
||||
const ep = focusedEpisode();
|
||||
if (ep) playEpisode(ep);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,12 +207,59 @@ function DiscoverPage() {
|
||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||
open: () => open(),
|
||||
"toggle-select": () => {
|
||||
if (depth() >= 1) {
|
||||
if (depth() === 1) {
|
||||
const pod = focusedPodcast();
|
||||
if (pod) nav.toggleSelected(pod.id);
|
||||
}
|
||||
if (depth() >= 2) {
|
||||
const ep = focusedEpisode();
|
||||
if (ep) nav.toggleSelected(ep.id);
|
||||
}
|
||||
},
|
||||
download: () => {
|
||||
if (depth() !== 2) return;
|
||||
const pod = drilledPodcast();
|
||||
const ep = focusedEpisode();
|
||||
if (!pod || !ep) return;
|
||||
// Under its subscribed feed when already subscribed, otherwise as
|
||||
// an "unsubscribed show" download (mirrors Search).
|
||||
const feed = feedForPodcast(pod);
|
||||
if (feed) downloadStore.startDownload(ep, feed.id);
|
||||
else downloadStore.startUnsubscribedDownload(ep, pod);
|
||||
},
|
||||
"delete-download": () => {
|
||||
if (depth() !== 2) return;
|
||||
const ep = focusedEpisode();
|
||||
if (!ep) return;
|
||||
const id = ep.id;
|
||||
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||
downloadStore.cancelDownload(id);
|
||||
downloadStore.removeDownload(id).catch(() => {});
|
||||
},
|
||||
// `a`/`x` — the dedicated subscribe/unsubscribe keys (enter/l now open
|
||||
// the episode list, so subscribing moved off open).
|
||||
subscribe: () => {
|
||||
if (depth() === 1) {
|
||||
const pod = focusedPodcast();
|
||||
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
|
||||
return;
|
||||
}
|
||||
if (depth() >= 2) {
|
||||
const pod = drilledPodcast();
|
||||
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
|
||||
}
|
||||
},
|
||||
unsubscribe: () => {
|
||||
if (depth() !== 1) return;
|
||||
const pod = focusedPodcast();
|
||||
if (pod?.isSubscribed) discoverStore.unsubscribe(pod.id);
|
||||
},
|
||||
refresh: () => {
|
||||
if (depth() >= 2) {
|
||||
const pod = drilledPodcast();
|
||||
if (pod) discoverStore.refreshEpisodes(pod).catch(() => {});
|
||||
return;
|
||||
}
|
||||
discoverStore.refresh().catch(() => {});
|
||||
},
|
||||
};
|
||||
@@ -161,7 +300,9 @@ function DiscoverPage() {
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? "Categories"
|
||||
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
||||
: depth() === 1
|
||||
? `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`
|
||||
: `${drilledPodcast()?.title ?? "Episodes"} · ${episodes().length}`;
|
||||
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||
// Sibling <Show> blocks per depth (the known-good opentui disposal
|
||||
@@ -174,7 +315,7 @@ function DiscoverPage() {
|
||||
<Show when={depth() === 0}>
|
||||
<TabListPane muted />
|
||||
</Show>
|
||||
<Show when={depth() >= 1}>
|
||||
<Show when={depth() === 1}>
|
||||
<For each={categories()}>
|
||||
{(cat, index) => {
|
||||
const lf = () => nav.depthFocus(0);
|
||||
@@ -203,6 +344,33 @@ function DiscoverPage() {
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<Show when={depth() >= 2}>
|
||||
<For each={podcasts()}>
|
||||
{(podcast, index) => {
|
||||
const lf = () => nav.depthFocus(1);
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf(), false)}
|
||||
>
|
||||
<text fg={focusFg(index(), lf(), false)}>
|
||||
{index() === lf() ? marker() : " "}
|
||||
</text>
|
||||
<text wrapMode="none" truncate fg={focusFg(index(), lf(), false)}>
|
||||
{podcast.title}
|
||||
</text>
|
||||
<Show when={podcast.isSubscribed}>
|
||||
<text flexShrink={0} fg={muted()}>[+]</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -243,7 +411,7 @@ function DiscoverPage() {
|
||||
</For>
|
||||
</Show>
|
||||
{/* depth ≥1: results */}
|
||||
<Show when={depth() >= 1}>
|
||||
<Show when={depth() === 1}>
|
||||
<Show
|
||||
when={podcasts().length > 0}
|
||||
fallback={
|
||||
@@ -304,11 +472,59 @@ function DiscoverPage() {
|
||||
</For>
|
||||
<Show when={discoverStore.isLoading()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator label="Refreshing…" />
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
{/* depth ≥2: episodes of the drilled show (preview, no subscription) */}
|
||||
<Show when={depth() >= 2}>
|
||||
<Show when={episodesLoading()}>
|
||||
<box padding={1}>
|
||||
<LoadingIndicator label="Loading episodes…" />
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={episodesError() && !episodesLoading()}>
|
||||
<box padding={1}>
|
||||
<text fg={theme.error}>{episodesError()}</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>r: retry · h: back</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
!episodesLoading() && !episodesError() && episodes().length === 0
|
||||
}
|
||||
>
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes found. :refresh</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
!episodesLoading() && !episodesError() && episodes().length > 0
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(ep, index) => (
|
||||
<EpisodeRow
|
||||
episode={ep}
|
||||
index={index}
|
||||
focused={focusedEpIdx}
|
||||
active={isActive}
|
||||
selected={() => nav.isSelected(ep.id)}
|
||||
downloadLabel={() => downloadLabel(ep.id)}
|
||||
downloadColor={() => downloadColor(ep.id)}
|
||||
marker={marker}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 2);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -357,8 +573,8 @@ function DiscoverPage() {
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
) : (
|
||||
// depth ≥1 preview: hovered podcast + subscribe
|
||||
) : depth() === 1 ? (
|
||||
// depth 1 preview: hovered podcast + episode-list hint
|
||||
<Show
|
||||
when={focusedPodcast()}
|
||||
fallback={
|
||||
@@ -376,10 +592,10 @@ function DiscoverPage() {
|
||||
<text fg={muted()}>by {pod().author}</text>
|
||||
</Show>
|
||||
<Show when={pod().isSubscribed}>
|
||||
<text fg={theme.success}>✓ Subscribed</text>
|
||||
<text fg={theme.success}>✓ Subscribed · x: unsubscribe</text>
|
||||
</Show>
|
||||
<Show when={!pod().isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
<text fg={theme.primary}>a: subscribe</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
@@ -398,10 +614,67 @@ function DiscoverPage() {
|
||||
</Show>
|
||||
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
|
||||
<text fg={muted()}>enter/l: episodes · h: back · r: refresh</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
) : (
|
||||
// depth ≥2 preview: hovered episode (or loading/error/empty)
|
||||
<>
|
||||
<Show when={episodesLoading()}>
|
||||
<box padding={1}>
|
||||
<LoadingIndicator label="Loading episodes…" />
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={episodesError() && !episodesLoading()}>
|
||||
<box padding={1}>
|
||||
<text fg={theme.error}>{episodesError()}</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>r: retry · h: back</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
!episodesLoading() && !episodesError() && episodes().length === 0
|
||||
}
|
||||
>
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episodes found.</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
!episodesLoading() &&
|
||||
!episodesError() &&
|
||||
episodes().length > 0 &&
|
||||
focusedEpisode()
|
||||
}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(ep) => (
|
||||
<EpisodePreview
|
||||
episode={() => ep()}
|
||||
author={() => drilledPodcast()?.author}
|
||||
downloadLabel={() => downloadLabel(ep().id)}
|
||||
downloadColor={() => downloadColor(ep().id)}
|
||||
hint={() =>
|
||||
`enter: play · d: download${
|
||||
downloadStore.getDownloadStatus(ep().id) !==
|
||||
DownloadStatus.NONE
|
||||
? " · D: delete"
|
||||
: ""
|
||||
}${
|
||||
drilledPodcast()?.isSubscribed ? "" : " · a: subscribe"
|
||||
} · h: back`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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,30 @@ function FeedPage() {
|
||||
const focusedItem = (): EpItem | undefined =>
|
||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||
const curLen = () => rowCount();
|
||||
const moreRef = useScrollIntoView(() => focusedOnMore());
|
||||
|
||||
// ── Render window ────────────────────────────────────────────────────────
|
||||
// The union grows to thousands of episodes after repeated fetch-more
|
||||
// presses; rendering every row per frame froze the UI. Render only a
|
||||
// bounded slice around the focus (real indexes preserved) — the scrollbox
|
||||
// still keeps the focused row in view. Spacers above/below the window
|
||||
// restore the full content height so the scrollbar tracks the real list.
|
||||
// Each EpisodeRow is 3 lines tall (title, subtitle, date).
|
||||
const LIST_WINDOW = 30;
|
||||
const ROW_HEIGHT = 3;
|
||||
const listWindow = createMemo<[number, number]>(() => {
|
||||
const len = episodes().length;
|
||||
// Focusing the Fetch More button keeps the window anchored at the
|
||||
// last episode — no jump when the focus crosses onto the button.
|
||||
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
|
||||
return [
|
||||
Math.max(0, f - LIST_WINDOW),
|
||||
Math.min(len, f + LIST_WINDOW + 1),
|
||||
];
|
||||
});
|
||||
const visibleEpisodes = createMemo(() => {
|
||||
const [start, end] = listWindow();
|
||||
return episodes().slice(start, end);
|
||||
});
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (rowCount() > 0 && focus() >= rowCount())
|
||||
@@ -129,12 +155,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 +249,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}`;
|
||||
|
||||
@@ -262,141 +269,79 @@ function FeedPage() {
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<LoadingIndicator label="Refreshing…" />
|
||||
<LoadingIndicator />
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(item, index) => {
|
||||
const fi = () => focusedEpIdx();
|
||||
const ref = useScrollIntoView(() => index() === fi());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={focusFg(index(), fi(), isActive())}
|
||||
>
|
||||
{index() === fi() ? marker() : " "}
|
||||
</text>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={focusFg(index(), fi(), isActive())}
|
||||
>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
{/* podcast name on its own row — readable at a glance; the
|
||||
50% current pane fits it in full for typical names, and
|
||||
truncate keeps the row one line tall either way */}
|
||||
<box paddingLeft={2}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={index() === fi() ? theme.surface : theme.textSecondary}
|
||||
>
|
||||
{item.feed.customName || item.feed.podcast.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={index() === fi() ? theme.surface : theme.info}
|
||||
>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={index() === fi() ? theme.surface : muted()}
|
||||
>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(item.episode.id)}>
|
||||
<text flexShrink={0} fg={theme.warning}>
|
||||
●
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(item.episode.id)}>
|
||||
<text flexShrink={0} fg={downloadColor(item.episode.id)}>
|
||||
{downloadLabel(item.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
{/* Spacers keep the scrollbox content at the FULL list height so
|
||||
the scrollbar reflects the real list, not the render window. */}
|
||||
<Show when={listWindow()[0] > 0}>
|
||||
<box height={listWindow()[0] * ROW_HEIGHT} />
|
||||
</Show>
|
||||
<For each={visibleEpisodes()}>
|
||||
{(item, index) => (
|
||||
<EpisodeRow
|
||||
episode={item.episode}
|
||||
subtitle={() => item.feed.customName || item.feed.podcast.title}
|
||||
index={() => listWindow()[0] + 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(listWindow()[0] + index(), 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={episodes().length - listWindow()[1] > 0}>
|
||||
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
|
||||
</Show>
|
||||
<Show when={showFetchMore()}>
|
||||
<box
|
||||
ref={moreRef}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
|
||||
<FetchMoreRow
|
||||
index={() => episodes().length}
|
||||
focused={focusedRow}
|
||||
onMore={focusedOnMore}
|
||||
active={isActive}
|
||||
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||
nerd={nerd}
|
||||
marker={marker}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(episodes().length, 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
{focusedOnMore() ? marker() : " "}
|
||||
</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
{NF_ICONS.more}
|
||||
</text>
|
||||
)}
|
||||
<Show
|
||||
when={!feedStore.isLoadingMore()}
|
||||
fallback={<LoadingIndicator label="Fetching…" />}
|
||||
>
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
[Fetch More]
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
/>
|
||||
</Show>
|
||||
<Show when={feedStore.isLoadingFeeds()}>
|
||||
<box alignItems="center" paddingTop={1}>
|
||||
<LoadingIndicator label="Refreshing…" />
|
||||
<LoadingIndicator />
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
|
||||
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
|
||||
const episodeHint = (item: EpItem) =>
|
||||
`enter: play · d: download${
|
||||
downloadStore.getDownloadStatus(item.episode.id) !== DownloadStatus.NONE
|
||||
? " · D: delete"
|
||||
: ""
|
||||
} · space: select · h back`;
|
||||
|
||||
const previewContent = () => (
|
||||
<>
|
||||
<Show when={focusedOnMore()}>
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>[Fetch More]</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{feedStore.isLoadingMore()
|
||||
? "Loading the next batch of episodes…"
|
||||
: fetchMoreMode() === "auto"
|
||||
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
||||
: "Load the next batch of older episodes across all feeds (Enter)."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: load more · h back</text>
|
||||
</box>
|
||||
<FetchMorePreview
|
||||
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||
fetchMoreMode={fetchMoreMode}
|
||||
manualText={() =>
|
||||
"Load the next batch of older episodes across all feeds (Enter)."
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!focusedOnMore()}>
|
||||
<Show
|
||||
@@ -408,46 +353,16 @@ function FeedPage() {
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{item().episode.episodeNumber
|
||||
? `#${item().episode.episodeNumber} `
|
||||
: ""}
|
||||
{item().episode.title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
|
||||
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
|
||||
<Show when={downloadLabel(item().episode.id)}>
|
||||
<text fg={downloadColor(item().episode.id)}>
|
||||
{downloadLabel(item().episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{item().feed.customName || item().feed.podcast.title}
|
||||
</text>
|
||||
<Show when={item().feed.podcast.author}>
|
||||
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{item().episode.description?.slice(0, 400) ??
|
||||
"No description available."}
|
||||
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: play · d: download
|
||||
{downloadStore.getDownloadStatus(item().episode.id) !==
|
||||
DownloadStatus.NONE
|
||||
? " · D: delete"
|
||||
: ""}{" "}
|
||||
· space: select · h back
|
||||
</text>
|
||||
</box>
|
||||
<EpisodePreview
|
||||
episode={() => item().episode}
|
||||
subtitle={() =>
|
||||
item().feed.customName || item().feed.podcast.title
|
||||
}
|
||||
author={() => item().feed.podcast.author}
|
||||
downloadLabel={() => downloadLabel(item().episode.id)}
|
||||
downloadColor={() => downloadColor(item().episode.id)}
|
||||
hint={() => episodeHint(item())}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -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,218 @@ import {
|
||||
} from "@/context/NavigationContext";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
import { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { Episode, DownloadedEpisode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import {
|
||||
EpisodeRow,
|
||||
FetchMoreRow,
|
||||
EpisodePreview,
|
||||
FetchMorePreview,
|
||||
formatDate,
|
||||
} from "@/components/EpisodeList";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
import { TabListPane } from "@/components/TabPanel";
|
||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||
|
||||
// ── render components ────────────────────────────────────────────────────────
|
||||
// Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their
|
||||
// preview panes are My Shows-specific; episode rows/previews are shared with
|
||||
// the Feed page (see EpisodeList.tsx).
|
||||
|
||||
/** A subscribed-show row (depth 0). */
|
||||
function ShowRow(props: {
|
||||
feed: Feed;
|
||||
title: string;
|
||||
index: () => number;
|
||||
focused: () => number;
|
||||
active: () => boolean;
|
||||
marker: () => string;
|
||||
wlScope: () => boolean;
|
||||
wlInList: () => boolean;
|
||||
onMouseDown: () => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||
const isFocused = () => props.index() === props.focused();
|
||||
const bg = () =>
|
||||
isFocused() && props.active()
|
||||
? theme.primary
|
||||
: isFocused()
|
||||
? theme.border
|
||||
: undefined;
|
||||
const fg = () =>
|
||||
isFocused() && props.active()
|
||||
? theme.surface
|
||||
: isFocused()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={bg()}
|
||||
onMouseDown={props.onMouseDown}
|
||||
>
|
||||
<text flexShrink={0} fg={fg()}>
|
||||
{isFocused() ? props.marker() : " "}
|
||||
</text>
|
||||
{/* Long titles truncate with middle-ellipsis instead of wrapping —
|
||||
a wrapped title grows the row to 2+ lines and shifts every row
|
||||
below (see EpisodeList for the same guard). The episode-count
|
||||
and watchlist cells are flexShrink=0 so they never shrink or
|
||||
wrap; the flexible title takes the remaining width. */}
|
||||
<text wrapMode="none" truncate fg={fg()}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
|
||||
({props.feed.episodes.length})
|
||||
</text>
|
||||
<Show when={props.wlScope()}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={
|
||||
isFocused()
|
||||
? theme.surface
|
||||
: props.wlInList()
|
||||
? theme.warning
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
{props.wlInList() ? "●" : "○"}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/** An unsubscribed-show download row (depth 0, below the shows list). */
|
||||
function UnsubscribedRow(props: {
|
||||
d: DownloadedEpisode;
|
||||
index: () => number;
|
||||
focused: () => number;
|
||||
active: () => boolean;
|
||||
marker: () => string;
|
||||
downloadLabel: () => string;
|
||||
downloadColor: () => RGBA;
|
||||
onMouseDown: () => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||
const isFocused = () => props.index() === props.focused();
|
||||
const bg = () =>
|
||||
isFocused() && props.active()
|
||||
? theme.primary
|
||||
: isFocused()
|
||||
? theme.border
|
||||
: undefined;
|
||||
const fg = () =>
|
||||
isFocused() && props.active()
|
||||
? theme.surface
|
||||
: isFocused()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingRight={1}
|
||||
backgroundColor={bg()}
|
||||
onMouseDown={props.onMouseDown}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={fg()}>
|
||||
{isFocused() ? props.marker() : " "}
|
||||
</text>
|
||||
<text wrapMode="none" truncate fg={fg()}>
|
||||
{props.d.episodeTitle ?? props.d.episodeId}
|
||||
</text>
|
||||
<Show when={props.downloadLabel()}>
|
||||
<text flexShrink={0} fg={props.downloadColor()}>
|
||||
{props.downloadLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={isFocused() ? theme.surface : theme.textSecondary}
|
||||
>
|
||||
{props.d.podcastTitle ?? props.d.feedId}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Depth-0 preview: the hovered subscribed show. */
|
||||
function ShowPreview(props: {
|
||||
show: () => Feed;
|
||||
title: () => string;
|
||||
hint: () => string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const show = props.show;
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{props.title()}</strong>
|
||||
</text>
|
||||
<Show when={show().podcast.author}>
|
||||
<text fg={muted()}>by {show().podcast.author}</text>
|
||||
</Show>
|
||||
<text fg={theme.textSecondary}>{show().episodes.length} episodes</text>
|
||||
<text fg={muted()}>
|
||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>{props.hint()}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Depth-0 preview: the hovered unsubscribed-show download. */
|
||||
function UnsubscribedPreview(props: {
|
||||
d: () => DownloadedEpisode;
|
||||
downloadLabel: () => string;
|
||||
downloadColor: () => RGBA;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const d = props.d;
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{d().episodeTitle ?? d().episodeId}</strong>
|
||||
</text>
|
||||
<text fg={theme.textSecondary}>{d().podcastTitle ?? d().feedId}</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={d().pubDate}>
|
||||
<text fg={theme.info}>{formatDate(new Date(d().pubDate!))}</text>
|
||||
</Show>
|
||||
<Show when={props.downloadLabel()}>
|
||||
<text fg={props.downloadColor()}>
|
||||
{props.downloadLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
Downloaded from episode search — the show is not subscribed.
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: play · D: delete download · h: back</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export const MyShowsPaneCount = 1;
|
||||
|
||||
export function MyShowsPage() {
|
||||
@@ -67,7 +269,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 +308,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 +321,30 @@ export function MyShowsPage() {
|
||||
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
||||
const focusedEpisode = () =>
|
||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||
const moreRef = useScrollIntoView(() => focusedOnMore());
|
||||
|
||||
// ── Render window ────────────────────────────────────────────────────────
|
||||
// The drilled show's list grows deep after repeated fetch-more presses;
|
||||
// rendering every row per frame froze the UI. Render only a bounded slice
|
||||
// around the focus (real indexes preserved) — the scrollbox still keeps
|
||||
// the focused row in view. Spacers above/below the window restore the
|
||||
// full content height so the scrollbar tracks the real list.
|
||||
// Each episode row is 2 lines tall (title, date) — no subtitle here.
|
||||
const LIST_WINDOW = 30;
|
||||
const ROW_HEIGHT = 2;
|
||||
const listWindow = createMemo<[number, number]>(() => {
|
||||
const len = episodes().length;
|
||||
// Focusing the Fetch More button keeps the window anchored at the
|
||||
// last episode — no jump when the focus crosses onto the button.
|
||||
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
|
||||
return [
|
||||
Math.max(0, f - LIST_WINDOW),
|
||||
Math.min(len, f + LIST_WINDOW + 1),
|
||||
];
|
||||
});
|
||||
const visibleEpisodes = createMemo(() => {
|
||||
const [start, end] = listWindow();
|
||||
return episodes().slice(start, end);
|
||||
});
|
||||
|
||||
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
||||
|
||||
@@ -155,12 +378,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 +540,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,19 +558,30 @@ export function MyShowsPage() {
|
||||
{(feed, index) => {
|
||||
const lf = () => nav.depthFocus(0);
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
const focused = () => index() === lf();
|
||||
const fg = () =>
|
||||
focused()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf(), false)}
|
||||
backgroundColor={focused() ? theme.border : undefined}
|
||||
>
|
||||
<text fg={focusFg(index(), lf(), false)}>
|
||||
{index() === lf() ? marker() : " "}
|
||||
<text flexShrink={0} fg={fg()}>
|
||||
{focused() ? marker() : " "}
|
||||
</text>
|
||||
{/* 20%-wide parent pane truncates hard — same
|
||||
middle-ellipsis guard as the depth-0 rows. */}
|
||||
<text wrapMode="none" truncate fg={fg()}>
|
||||
{showTitle(feed)}
|
||||
</text>
|
||||
<text flexShrink={0} fg={muted()}>
|
||||
({feed.episodes.length})
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
|
||||
<text fg={muted()}>({feed.episodes.length})</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
@@ -385,51 +605,28 @@ export function MyShowsPage() {
|
||||
}
|
||||
>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => {
|
||||
const lf = () => focusedShowIdx();
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
const wlScope =
|
||||
app.state().preferences.autoDownloadScope === "whitelist";
|
||||
const wlInList = (
|
||||
app.state().preferences.autoDownloadWhitelist ?? []
|
||||
).includes(feed.id);
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? marker() : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{showTitle(feed)}
|
||||
</text>
|
||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
||||
({feed.episodes.length})
|
||||
</text>
|
||||
<Show when={wlScope}>
|
||||
<text
|
||||
fg={
|
||||
index() === lf()
|
||||
? theme.surface
|
||||
: wlInList
|
||||
? theme.warning
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
{wlInList ? "●" : "○"}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
{(feed, index) => (
|
||||
<ShowRow
|
||||
feed={feed}
|
||||
title={showTitle(feed)}
|
||||
index={index}
|
||||
focused={focusedShowIdx}
|
||||
active={isActive}
|
||||
marker={marker}
|
||||
wlScope={() =>
|
||||
app.state().preferences.autoDownloadScope === "whitelist"
|
||||
}
|
||||
wlInList={() =>
|
||||
(app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
||||
feed.id,
|
||||
)
|
||||
}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={unsubs().length > 0}>
|
||||
<box paddingLeft={1} paddingTop={1}>
|
||||
@@ -438,62 +635,21 @@ export function MyShowsPage() {
|
||||
</text>
|
||||
</box>
|
||||
<For each={unsubs()}>
|
||||
{(d, index) => {
|
||||
// Rows continue after the shows list.
|
||||
const rowIdx = () => shows().length + index();
|
||||
const lf = () => nav.depthFocus(0);
|
||||
const ref = useScrollIntoView(() => rowIdx() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(rowIdx(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(rowIdx(), 0);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={focusFg(rowIdx(), lf(), isActive())}
|
||||
>
|
||||
{rowIdx() === lf() ? marker() : " "}
|
||||
</text>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={focusFg(rowIdx(), lf(), isActive())}
|
||||
>
|
||||
{d.episodeTitle ?? d.episodeId}
|
||||
</text>
|
||||
<Show when={downloadLabel(d.episodeId)}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={downloadColor(d.episodeId)}
|
||||
>
|
||||
{downloadLabel(d.episodeId)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={
|
||||
rowIdx() === lf()
|
||||
? theme.surface
|
||||
: theme.textSecondary
|
||||
}
|
||||
>
|
||||
{d.podcastTitle ?? d.feedId}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
{(d, index) => (
|
||||
<UnsubscribedRow
|
||||
d={d}
|
||||
index={() => shows().length + index()}
|
||||
focused={() => nav.depthFocus(0)}
|
||||
active={isActive}
|
||||
marker={marker}
|
||||
downloadLabel={() => downloadLabel(d.episodeId)}
|
||||
downloadColor={() => downloadColor(d.episodeId)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(shows().length + index(), 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -508,99 +664,47 @@ export function MyShowsPage() {
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<For each={episodes()}>
|
||||
{(ep, index) => {
|
||||
const lf = () => focusedEpIdx();
|
||||
const ref = useScrollIntoView(() => index() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(index(), 1);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={focusFg(index(), lf(), isActive())}
|
||||
>
|
||||
{index() === lf() ? marker() : " "}
|
||||
</text>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={focusFg(index(), lf(), isActive())}
|
||||
>
|
||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||
{ep.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={index() === lf() ? theme.surface : theme.info}
|
||||
>
|
||||
{formatDate(ep.pubDate)}
|
||||
</text>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={index() === lf() ? theme.surface : muted()}
|
||||
>
|
||||
{formatDuration(ep.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(ep.id)}>
|
||||
<text flexShrink={0} fg={theme.warning}>
|
||||
●
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(ep.id)}>
|
||||
<text flexShrink={0} fg={downloadColor(ep.id)}>
|
||||
{downloadLabel(ep.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
{/* Spacers keep the scrollbox content at the FULL list
|
||||
height so the scrollbar reflects the real list, not the
|
||||
render window. */}
|
||||
<Show when={listWindow()[0] > 0}>
|
||||
<box height={listWindow()[0] * ROW_HEIGHT} />
|
||||
</Show>
|
||||
<For each={visibleEpisodes()}>
|
||||
{(ep, index) => (
|
||||
<EpisodeRow
|
||||
episode={ep}
|
||||
index={() => listWindow()[0] + index()}
|
||||
focused={focusedEpIdx}
|
||||
active={isActive}
|
||||
selected={() => nav.isSelected(ep.id)}
|
||||
downloadLabel={() => downloadLabel(ep.id)}
|
||||
downloadColor={() => downloadColor(ep.id)}
|
||||
marker={marker}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(listWindow()[0] + index(), 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={episodes().length - listWindow()[1] > 0}>
|
||||
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
|
||||
</Show>
|
||||
<Show when={showFetchMore()}>
|
||||
<box
|
||||
ref={moreRef}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(
|
||||
episodes().length,
|
||||
focusedRow(),
|
||||
isActive(),
|
||||
)}
|
||||
<FetchMoreRow
|
||||
index={() => episodes().length}
|
||||
focused={focusedRow}
|
||||
onMore={focusedOnMore}
|
||||
active={isActive}
|
||||
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||
nerd={nerd}
|
||||
marker={marker}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(episodes().length, 1);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
{focusedOnMore() ? marker() : " "}
|
||||
</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
{NF_ICONS.more}
|
||||
</text>
|
||||
)}
|
||||
<Show
|
||||
when={!feedStore.isLoadingMore()}
|
||||
fallback={<LoadingIndicator label="Fetching…" />}
|
||||
>
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
[Fetch More]
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -608,6 +712,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 +752,34 @@ export function MyShowsPage() {
|
||||
}
|
||||
>
|
||||
{(show) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{showTitle(show())}</strong>
|
||||
</text>
|
||||
<Show when={show().podcast.author}>
|
||||
<text fg={muted()}>by {show().podcast.author}</text>
|
||||
</Show>
|
||||
<text fg={theme.textSecondary}>
|
||||
{show().episodes.length} episodes
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{show().podcast.description?.slice(0, 400) ??
|
||||
"No description."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter/l: open · h: back · x: unsubscribe
|
||||
{app.state().preferences.autoDownloadScope ===
|
||||
"whitelist"
|
||||
? (app.state().preferences.autoDownloadWhitelist ??
|
||||
[]
|
||||
).includes(show().id)
|
||||
? " · w: un-whitelist"
|
||||
: " · w: whitelist"
|
||||
: ""}
|
||||
</text>
|
||||
</box>
|
||||
<ShowPreview
|
||||
show={() => show()}
|
||||
title={() => showTitle(show())}
|
||||
hint={() => showHint(show())}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(d) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{d().episodeTitle ?? d().episodeId}</strong>
|
||||
</text>
|
||||
<text fg={theme.textSecondary}>
|
||||
{d().podcastTitle ?? d().feedId}
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={d().pubDate}>
|
||||
<text fg={theme.info}>
|
||||
{formatDate(new Date(d().pubDate!))}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(d().episodeId)}>
|
||||
<text fg={downloadColor(d().episodeId)}>
|
||||
{downloadLabel(d().episodeId)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
Downloaded from episode search — the show is not
|
||||
subscribed.
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: play · D: delete download · h: back
|
||||
</text>
|
||||
</box>
|
||||
<UnsubscribedPreview
|
||||
d={() => d()}
|
||||
downloadLabel={() => downloadLabel(d().episodeId)}
|
||||
downloadColor={() => downloadColor(d().episodeId)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
) : (
|
||||
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
||||
<>
|
||||
<Show when={focusedOnMore()}>
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>[Fetch More]</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{feedStore.isLoadingMore()
|
||||
? "Loading the next batch of episodes…"
|
||||
: fetchMoreMode() === "auto"
|
||||
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
||||
: "Load the next batch of older episodes for this show (Enter)."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: load more · h back</text>
|
||||
</box>
|
||||
<FetchMorePreview
|
||||
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||
fetchMoreMode={fetchMoreMode}
|
||||
manualText={() =>
|
||||
"Load the next batch of older episodes for this show (Enter)."
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!focusedOnMore()}>
|
||||
<Show
|
||||
@@ -714,53 +790,19 @@ export function MyShowsPage() {
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(ep) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>
|
||||
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
|
||||
{ep().title}
|
||||
</strong>
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
|
||||
<text fg={muted()}>{formatDuration(ep().duration)}</text>
|
||||
<Show when={downloadLabel(ep().id)}>
|
||||
<text fg={downloadColor(ep().id)}>
|
||||
{downloadLabel(ep().id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={selectedShow()?.podcast.author}>
|
||||
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>
|
||||
{ep().description?.slice(0, 400) ?? "No description available."}
|
||||
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: play · d: download
|
||||
{downloadStore.getDownloadStatus(ep().id) !==
|
||||
DownloadStatus.NONE
|
||||
? " · D: delete"
|
||||
: ""}
|
||||
{app.state().preferences.autoDownloadScope === "whitelist"
|
||||
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
||||
drilledShowId(),
|
||||
)
|
||||
? " · w: un-whitelist"
|
||||
: " · w: whitelist"
|
||||
: ""}{" "}
|
||||
· space: select · h: back
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
{(ep) => (
|
||||
<EpisodePreview
|
||||
episode={() => ep()}
|
||||
author={() => selectedShow()?.podcast.author}
|
||||
downloadLabel={() => downloadLabel(ep().id)}
|
||||
downloadColor={() => downloadColor(ep().id)}
|
||||
hint={() => episodeHint(ep().id)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneRow
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
*
|
||||
* This component only subscribes to store state, reports the width-derived
|
||||
* bar count (terminal resize re-inits the running pipeline), and renders:
|
||||
* a braille spinner while the pipeline is loading its first frames, the
|
||||
* frequency bars once frames arrive, and a dotted placeholder when idle.
|
||||
* a braille spinner while the pipeline is loading its first frames or the
|
||||
* player is stalled (re-buffering), the frequency bars once frames arrive,
|
||||
* and a dotted placeholder when idle.
|
||||
*/
|
||||
|
||||
import { createEffect, on } from "solid-js";
|
||||
@@ -53,12 +54,13 @@ export function RealtimeWaveform() {
|
||||
const bars = viz.barData();
|
||||
const count = numBars();
|
||||
|
||||
// Loading state: the braille spinner shows while the pipeline warms
|
||||
// up — but only when there are no bars to render yet (first play /
|
||||
// after an unload). On resume/seek the last bars stay on screen
|
||||
// until fresh frames arrive, so the waveform never blanks out for
|
||||
// the (multi-second, network-bound) cold start.
|
||||
if (bars.length === 0 && viz.isLoading()) {
|
||||
// Loading state: the braille spinner shows while the pipeline is
|
||||
// warming up — cold start (first play / after an unload), resume
|
||||
// into undecoded audio, or a stalled position clock (mpv
|
||||
// re-buffering after a long pause on a network stream). The store
|
||||
// clears it the moment the first fresh frame renders, so stale
|
||||
// bars never masquerade as live data while the pipeline re-arms.
|
||||
if (viz.isLoading() || viz.isStalled()) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,30 +77,15 @@ function SearchPage() {
|
||||
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
||||
|
||||
// ── input focusing ────────────────────────────────────────────────────────
|
||||
// `inputFocused` is true while the query input is being typed in. The Shell
|
||||
// router yields keys to the <input> while this is true; Escape (in Shell)
|
||||
// sets it false so navigation resumes; `s` (search action) sets it true.
|
||||
//
|
||||
// The input's REAL focus is the source of truth for the flag:
|
||||
// useInputFocusNav (the same hook the Settings forms use) flips
|
||||
// `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag
|
||||
// and the renderable in lockstep. That matters when the user clicks OFF the
|
||||
// input: opentui's mouse dispatch auto-focuses the clicked target's nearest
|
||||
// focusable ancestor (a pane scrollbox), blurring the input. The BLURRED
|
||||
// event drops the flag, so the Shell router immediately resumes j/k/h
|
||||
// instead of swallowing keys with no input to receive them — no more
|
||||
// stuck "typing" state where Esc/j/k/s all do nothing.
|
||||
//
|
||||
// The depth stack still SEEDS the flag on transitions, since the query
|
||||
// depth defaults to typing: re-entering depth 0 (h back from results, or a
|
||||
// fresh mount) focuses the input; mounting at depth 1 (returning to the
|
||||
// tab after a search) stays list-navigation — a stuck-on flag there would
|
||||
// have the Shell yield j/k to a non-existent input. The depth STACK signal
|
||||
// is also written by focus moves (setDepthFocus), so gate the seed on the
|
||||
// depth VALUE via a memo: the effect must re-run only on an actual depth
|
||||
// transition. Without the memo every j/k at the query depth re-focuses the
|
||||
// input (undoing Escape), which keeps the recents list unreachable by
|
||||
// keyboard.
|
||||
// `inputFocused` tells the Shell router to yield keys to the query input.
|
||||
// The input's REAL focus is the source of truth: useInputFocusNav flips
|
||||
// the flag from the input's FOCUSED/BLURRED events, so clicking off the
|
||||
// input drops it and the router resumes j/k/h — no stuck "typing" state.
|
||||
// The depth stack only SEEDS it on transitions (re-entering depth 0
|
||||
// focuses the input; mounting at depth 1 stays list-nav), gated on the
|
||||
// depth VALUE via a memo because setDepthFocus also writes the stack
|
||||
// signal — without the memo every j/k at query depth re-focuses the input
|
||||
// and strands the recents list.
|
||||
onMount(() => nav.setInputFocused(depth() === 0));
|
||||
onCleanup(() => nav.setInputFocused(false));
|
||||
const focusNavRef = useInputFocusNav();
|
||||
|
||||
@@ -221,7 +221,7 @@ export function usePreferencesItems(): SettingItem[] {
|
||||
kind: "select",
|
||||
display: () => cacheModeLabel(prefs().episodeCacheMode),
|
||||
help: () =>
|
||||
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below).\nCount: keep the N most recent episodes (see Cache Count below).\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
|
||||
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below); Fetch More reveals the next 2 weeks per press.\nCount: the Feed list is the N most-recent episodes across ALL shows (not N per show); Fetch More reveals N more of the newest episodes each press — deep history only appears once you page to it.\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
|
||||
cycle: (dir) => {
|
||||
const idx = CACHE_MODE_LABELS.findIndex(
|
||||
(s) => s.value === prefs().episodeCacheMode,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
/** Create activity store */
|
||||
function createActivityStore() {
|
||||
const [count, setCount] = createSignal(0);
|
||||
const [labels, setLabels] = createSignal<string[]>([]);
|
||||
@@ -64,7 +63,6 @@ function createActivityStore() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton activity store */
|
||||
let activityStoreInstance: ReturnType<typeof createActivityStore> | null = null;
|
||||
|
||||
export function useActivityStore() {
|
||||
|
||||
@@ -9,14 +9,12 @@ import {
|
||||
saveAudioNavToFile,
|
||||
} from "../utils/app-persistence";
|
||||
|
||||
/** Source type for audio navigation */
|
||||
export enum AudioSource {
|
||||
FEED = "feed",
|
||||
MY_SHOWS = "my_shows",
|
||||
SEARCH = "search",
|
||||
}
|
||||
|
||||
/** Audio navigation state */
|
||||
export interface AudioNavState {
|
||||
/** Current source type */
|
||||
source: AudioSource;
|
||||
@@ -28,14 +26,12 @@ export interface AudioNavState {
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
/** Default navigation state */
|
||||
const defaultNavState: AudioNavState = {
|
||||
source: AudioSource.FEED,
|
||||
currentIndex: 0,
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
|
||||
/** Create audio navigation store */
|
||||
function createAudioNavStore() {
|
||||
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
||||
|
||||
@@ -56,12 +52,10 @@ function createAudioNavStore() {
|
||||
init();
|
||||
|
||||
return {
|
||||
/** Get current navigation state */
|
||||
get state(): AudioNavState {
|
||||
return navState();
|
||||
},
|
||||
|
||||
/** Update source type */
|
||||
setSource: (source: AudioSource, podcastId?: string) => {
|
||||
setNavState((prev) => ({
|
||||
...prev,
|
||||
@@ -72,7 +66,6 @@ function createAudioNavStore() {
|
||||
persist();
|
||||
},
|
||||
|
||||
/** Move to next episode */
|
||||
next: (currentIndex: number) => {
|
||||
setNavState((prev) => ({
|
||||
...prev,
|
||||
@@ -82,7 +75,6 @@ function createAudioNavStore() {
|
||||
persist();
|
||||
},
|
||||
|
||||
/** Move to previous episode */
|
||||
prev: (currentIndex: number) => {
|
||||
setNavState((prev) => ({
|
||||
...prev,
|
||||
@@ -92,23 +84,19 @@ function createAudioNavStore() {
|
||||
persist();
|
||||
},
|
||||
|
||||
/** Reset to default state */
|
||||
reset: () => {
|
||||
setNavState(defaultNavState);
|
||||
persist();
|
||||
},
|
||||
|
||||
/** Get current index */
|
||||
getCurrentIndex: (): number => {
|
||||
return navState().currentIndex;
|
||||
},
|
||||
|
||||
/** Get current source */
|
||||
getSource: (): AudioSource => {
|
||||
return navState().source;
|
||||
},
|
||||
|
||||
/** Get current podcast ID */
|
||||
getPodcastId: (): string | undefined => {
|
||||
return navState().podcastId;
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import type { Podcast } from "../types/podcast";
|
||||
import type { Episode } from "../types/episode";
|
||||
import { useFeedStore } from "./feed";
|
||||
|
||||
export interface DiscoverCategory {
|
||||
@@ -42,6 +43,10 @@ const FEATURED_JSON_URL =
|
||||
/** Cache window for the remote featured list (24 hours) */
|
||||
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Max episodes to load when previewing an unsubscribed show's episode list
|
||||
* from Discover (drill-in, no subscription). Mirrors the refresh window. */
|
||||
const PREVIEW_EPISODE_LIMIT = 50;
|
||||
|
||||
/** Shape of a single entry in the remote JSON */
|
||||
interface FeaturedEntry {
|
||||
id: string;
|
||||
@@ -85,12 +90,24 @@ function syncSubscriptionState(
|
||||
}));
|
||||
}
|
||||
|
||||
/** Create discover store */
|
||||
export function createDiscoverStore() {
|
||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
|
||||
|
||||
// Episodes fetched for an unsubscribed show's preview list (drill-in from
|
||||
// a podcast result, no subscription). Cached per podcast id for the
|
||||
// session; keyed by id so switching shows never clobbers another's list.
|
||||
const [previewEpisodes, setPreviewEpisodes] = createSignal<
|
||||
Record<string, Episode[]>
|
||||
>({});
|
||||
const [previewLoading, setPreviewLoading] = createSignal<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [previewErrors, setPreviewErrors] = createSignal<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
|
||||
let cachedAt = 0;
|
||||
|
||||
@@ -107,7 +124,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 +147,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 +155,6 @@ export function createDiscoverStore() {
|
||||
}
|
||||
};
|
||||
|
||||
/** Get filtered podcasts by category */
|
||||
const filteredPodcasts = () => {
|
||||
const category = selectedCategory();
|
||||
if (category === "all") {
|
||||
@@ -155,7 +169,6 @@ export function createDiscoverStore() {
|
||||
});
|
||||
};
|
||||
|
||||
/** Subscribe to a podcast */
|
||||
const subscribe = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||
if (podcast) {
|
||||
@@ -168,7 +181,6 @@ export function createDiscoverStore() {
|
||||
);
|
||||
};
|
||||
|
||||
/** Unsubscribe from a podcast */
|
||||
const unsubscribe = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||
if (podcast) {
|
||||
@@ -180,14 +192,64 @@ export function createDiscoverStore() {
|
||||
);
|
||||
};
|
||||
|
||||
/** Toggle subscription */
|
||||
const toggleSubscription = (podcastId: string) => {
|
||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||
if (podcast?.isSubscribed) {
|
||||
unsubscribe(podcastId);
|
||||
} else {
|
||||
subscribe(podcastId);
|
||||
// ── episode preview (drill-in, no subscription) ──────────────────────────
|
||||
/** Cached episode list for a previewed show (empty until first drill-in). */
|
||||
const episodesForPodcast = (podcastId: string): Episode[] =>
|
||||
previewEpisodes()[podcastId] ?? [];
|
||||
|
||||
const isLoadingEpisodesFor = (podcastId: string): boolean =>
|
||||
previewLoading().has(podcastId);
|
||||
|
||||
const previewError = (podcastId: string): string | undefined =>
|
||||
previewErrors()[podcastId];
|
||||
|
||||
/** Fetch a show's episode list WITHOUT subscribing (Discover preview).
|
||||
* The list is cached per podcast id; a failed fetch records an error
|
||||
* and keeps any previous cache (a retry via refreshEpisodes clears it). */
|
||||
const openEpisodes = async (podcast: Podcast): Promise<void> => {
|
||||
if (previewEpisodes()[podcast.id] || previewLoading().has(podcast.id))
|
||||
return;
|
||||
if (!podcast.feedUrl) {
|
||||
setPreviewErrors((prev) => ({
|
||||
...prev,
|
||||
[podcast.id]: "No RSS feed listed for this show.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setPreviewLoading((prev) => new Set(prev).add(podcast.id));
|
||||
const feedStore = useFeedStore();
|
||||
const { episodes } = await feedStore.fetchEpisodes(
|
||||
podcast.feedUrl,
|
||||
PREVIEW_EPISODE_LIMIT,
|
||||
);
|
||||
if (episodes) {
|
||||
setPreviewEpisodes((prev) => ({ ...prev, [podcast.id]: episodes }));
|
||||
} else {
|
||||
setPreviewErrors((prev) => ({
|
||||
...prev,
|
||||
[podcast.id]: "Couldn't load episodes.",
|
||||
}));
|
||||
}
|
||||
setPreviewLoading((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(podcast.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/** Re-fetch a previewed show's episode list (`r` on the episodes depth). */
|
||||
const refreshEpisodes = async (podcast: Podcast): Promise<void> => {
|
||||
setPreviewErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[podcast.id];
|
||||
return next;
|
||||
});
|
||||
setPreviewEpisodes((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[podcast.id];
|
||||
return next;
|
||||
});
|
||||
await openEpisodes(podcast);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -202,12 +264,17 @@ export function createDiscoverStore() {
|
||||
setSelectedCategory,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
toggleSubscription,
|
||||
refresh,
|
||||
|
||||
// Episode preview (drill-in, no subscription)
|
||||
episodesForPodcast,
|
||||
isLoadingEpisodesFor,
|
||||
previewError,
|
||||
openEpisodes,
|
||||
refreshEpisodes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton discover store */
|
||||
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
|
||||
|
||||
export function useDiscoverStore() {
|
||||
|
||||
@@ -63,7 +63,62 @@ interface QueueItem {
|
||||
episodeTitle: string;
|
||||
}
|
||||
|
||||
/** Create download store */
|
||||
// ── post-download decoration ─────────────────────────────────────────────────
|
||||
/** Write the podcast cover beside the audio so mpv's --cover-art-auto=exact
|
||||
* picks it up for Now Playing art when the local file plays (same basename,
|
||||
* .jpg extension — verified against mpv 0.41). curl, NOT fetch: Bun's fetch
|
||||
* hangs in compiled binaries, so the shipped app never wrote this file. */
|
||||
function writeCoverArt(filePath: string, coverUrl: string): void {
|
||||
const dot = filePath.lastIndexOf(".");
|
||||
if (dot <= 0) return;
|
||||
const coverPath = filePath.slice(0, dot) + ".jpg";
|
||||
Bun.spawn([
|
||||
"curl",
|
||||
"-sS",
|
||||
"--fail",
|
||||
"-m",
|
||||
"8",
|
||||
"--max-filesize",
|
||||
"2097152",
|
||||
"-o",
|
||||
coverPath,
|
||||
coverUrl,
|
||||
])
|
||||
.exited.catch(() => {});
|
||||
}
|
||||
|
||||
/** Tag the local file (codec-copy, no re-encode) so mpv's Now Playing
|
||||
* metadata for local playback is title=episode, artist=podcast — the source
|
||||
* streams carry no usable tags and macOS composes "title - artist" from
|
||||
* exactly these fields. Atomic: ffmpeg writes a temp file, then renames
|
||||
* into place. */
|
||||
function tagLocalFile(
|
||||
filePath: string,
|
||||
episode: Episode,
|
||||
podcastTitle: string,
|
||||
): void {
|
||||
const tmp = `${filePath}.tag.mp3`;
|
||||
Bun.spawn([
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
filePath,
|
||||
"-c",
|
||||
"copy",
|
||||
"-metadata",
|
||||
`title=${episode.title}`,
|
||||
"-metadata",
|
||||
`artist=${podcastTitle}`,
|
||||
tmp,
|
||||
])
|
||||
.exited.then(async (code) => {
|
||||
if (code !== 0) return;
|
||||
const { renameSync } = await import("node:fs");
|
||||
renameSync(tmp, filePath);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function createDownloadStore() {
|
||||
const [downloads, setDownloads] = createSignal<
|
||||
Map<string, DownloadedEpisode>
|
||||
@@ -195,7 +250,6 @@ function createDownloadStore() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute a single download */
|
||||
async function executeDownload(item: QueueItem): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
abortControllers.set(item.episodeId, controller);
|
||||
@@ -236,70 +290,23 @@ function createDownloadStore() {
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Write the podcast cover beside the audio so mpv's
|
||||
// --cover-art-auto=exact picks it up for Now Playing art when the
|
||||
// local file plays (same basename, .jpg extension — verified
|
||||
// against mpv 0.41). curl, NOT fetch: Bun's fetch hangs in
|
||||
// compiled binaries, so the shipped app never wrote this file.
|
||||
// Falls back to the episode's own image when the feed has no
|
||||
// channel cover (URL-added feeds).
|
||||
// Decorate the local file: cover art + ID3 tags (see the
|
||||
// module-level helpers above) — the source streams carry neither.
|
||||
// Cover falls back to the episode's own image when the feed has
|
||||
// no channel cover (URL-added feeds).
|
||||
const feedStore = useFeedStore();
|
||||
const episode = feedStore.findEpisode(item.episodeId);
|
||||
const coverUrl =
|
||||
feedStore
|
||||
.feeds()
|
||||
.find((f) => f.id === item.feedId)?.podcast.coverUrl ??
|
||||
episode?.imageUrl;
|
||||
if (coverUrl && result.filePath) {
|
||||
const dot = result.filePath.lastIndexOf(".");
|
||||
if (dot > 0) {
|
||||
const coverPath = result.filePath.slice(0, dot) + ".jpg";
|
||||
Bun.spawn([
|
||||
"curl",
|
||||
"-sS",
|
||||
"--fail",
|
||||
"-m",
|
||||
"8",
|
||||
"--max-filesize",
|
||||
"2097152",
|
||||
"-o",
|
||||
coverPath,
|
||||
coverUrl,
|
||||
])
|
||||
.exited.catch(() => {});
|
||||
}
|
||||
const feed = feedStore.feeds().find((f) => f.id === item.feedId);
|
||||
const coverUrl = feed?.podcast.coverUrl ?? episode?.imageUrl;
|
||||
if (result.filePath && coverUrl) {
|
||||
writeCoverArt(result.filePath, coverUrl);
|
||||
}
|
||||
|
||||
// Tag the local file (codec-copy, no re-encode) so mpv's Now
|
||||
// Playing metadata for local playback is title=episode,
|
||||
// artist=podcast — the source streams carry no usable tags and
|
||||
// macOS composes "title - artist" from exactly these fields.
|
||||
// Atomic: ffmpeg writes a temp file, then renames into place.
|
||||
if (result.filePath && episode) {
|
||||
const podcastTitle =
|
||||
feedStore.feeds().find((f) => f.id === item.feedId)?.podcast.title ??
|
||||
feed?.podcast.title ??
|
||||
downloads().get(item.episodeId)?.podcastTitle;
|
||||
if (podcastTitle) {
|
||||
const tmp = `${result.filePath}.tag.mp3`;
|
||||
Bun.spawn([
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
result.filePath,
|
||||
"-c",
|
||||
"copy",
|
||||
"-metadata",
|
||||
`title=${episode.title}`,
|
||||
"-metadata",
|
||||
`artist=${podcastTitle}`,
|
||||
tmp,
|
||||
])
|
||||
.exited.then(async (code) => {
|
||||
if (code !== 0) return;
|
||||
const { renameSync } = await import("node:fs");
|
||||
renameSync(tmp, result.filePath);
|
||||
})
|
||||
.catch(() => {});
|
||||
tagLocalFile(result.filePath, episode, podcastTitle);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -315,22 +322,18 @@ function createDownloadStore() {
|
||||
processQueue();
|
||||
}
|
||||
|
||||
/** Get download status for an episode */
|
||||
const getDownloadStatus = (episodeId: string): DownloadStatus => {
|
||||
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
|
||||
};
|
||||
|
||||
/** Get download progress for an episode (0-100) */
|
||||
const getDownloadProgress = (episodeId: string): number => {
|
||||
return downloads().get(episodeId)?.progress ?? 0;
|
||||
};
|
||||
|
||||
/** Get full download info for an episode */
|
||||
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
|
||||
return downloads().get(episodeId);
|
||||
};
|
||||
|
||||
/** Get the local file path for a completed download */
|
||||
const getDownloadedFilePath = (episodeId: string): string | null => {
|
||||
const dl = downloads().get(episodeId);
|
||||
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
|
||||
@@ -347,7 +350,6 @@ function createDownloadStore() {
|
||||
podcastFeedUrl?: string;
|
||||
}
|
||||
|
||||
/** Start downloading an episode */
|
||||
const startDownload = (
|
||||
episode: Episode,
|
||||
feedId: string,
|
||||
@@ -411,7 +413,6 @@ function createDownloadStore() {
|
||||
});
|
||||
};
|
||||
|
||||
/** Cancel a download */
|
||||
const cancelDownload = (episodeId: string): void => {
|
||||
// Abort active download
|
||||
const controller = abortControllers.get(episodeId);
|
||||
@@ -432,7 +433,6 @@ function createDownloadStore() {
|
||||
saveDownloads().catch(() => {});
|
||||
};
|
||||
|
||||
/** Remove a completed download (delete file and metadata) */
|
||||
const removeDownload = async (episodeId: string): Promise<void> => {
|
||||
const dl = downloads().get(episodeId);
|
||||
if (dl?.filePath) {
|
||||
@@ -478,7 +478,6 @@ function createDownloadStore() {
|
||||
}
|
||||
};
|
||||
|
||||
/** Get all downloads as an array */
|
||||
const getAllDownloads = (): DownloadedEpisode[] => {
|
||||
return Array.from(downloads().values());
|
||||
};
|
||||
@@ -501,12 +500,10 @@ function createDownloadStore() {
|
||||
});
|
||||
};
|
||||
|
||||
/** Get the current queue */
|
||||
const getQueue = (): QueueItem[] => {
|
||||
return queue();
|
||||
};
|
||||
|
||||
/** Get count of active downloads */
|
||||
const getActiveCount = (): number => {
|
||||
return activeCount();
|
||||
};
|
||||
@@ -531,7 +528,6 @@ function createDownloadStore() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton download store */
|
||||
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
|
||||
|
||||
export function useDownloadStore() {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { Effect } from "effect";
|
||||
import { refreshFeedsBatch } from "../effects/feed-refresh";
|
||||
import { FeedVisibility } from "../types/feed";
|
||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
||||
import type { Podcast } from "../types/podcast";
|
||||
@@ -13,8 +15,12 @@ import { DEFAULT_SOURCES } from "../types/source";
|
||||
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
||||
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
||||
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
||||
import { mergeEpisodesBounded } from "../utils/episode-merge";
|
||||
import {
|
||||
episodeSignature,
|
||||
mergeEpisodesBounded,
|
||||
} from "../utils/episode-merge";
|
||||
import {
|
||||
DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
episodeInWindow,
|
||||
loadFeedsFromFile,
|
||||
saveFeedsToFile,
|
||||
@@ -26,12 +32,17 @@ import { useDownloadStore } from "./download";
|
||||
import { useAppStore } from "./app";
|
||||
import { DownloadStatus } from "../types/episode";
|
||||
|
||||
/** Max episodes to load per page/chunk */
|
||||
/** Max episodes to load per page/chunk (count mode only — date mode steps
|
||||
* by FETCH_MORE_WINDOW_DAYS instead). */
|
||||
const MAX_EPISODES_REFRESH = 50;
|
||||
|
||||
/** Max episodes to fetch on initial subscribe */
|
||||
const MAX_EPISODES_SUBSCRIBE = 20;
|
||||
|
||||
/** Fetch-more step in date mode: each press reveals the next two weeks of
|
||||
* episodes past the oldest loaded one, instead of a fixed episode count. */
|
||||
const FETCH_MORE_WINDOW_DAYS = 14;
|
||||
|
||||
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
|
||||
* the background refresh loop. */
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
@@ -49,6 +60,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
|
||||
@@ -117,6 +138,38 @@ function episodeKeepFn(prefs: {
|
||||
return (ep: Episode) => episodeInWindow(ep, now, days);
|
||||
}
|
||||
|
||||
/** Timestamp for window math — undated episodes sort/compare as NEWEST
|
||||
* (Infinity) so they can never be excluded by a date cutoff. */
|
||||
const epTs = (ep: Episode): number => {
|
||||
const t = ep.pubDate?.getTime();
|
||||
return t === undefined || Number.isNaN(t) ? Infinity : t;
|
||||
};
|
||||
|
||||
/** Date-mode fetch-more cutoff: the oldest loaded episode's pubDate minus the
|
||||
* 2-week band. With nothing loaded (a show whose episodes all fall outside
|
||||
* the cache window), the band anchors at the cache-window edge (now minus
|
||||
* the configured days) — a dormant show can't drag in arbitrarily old
|
||||
* episodes just because the button is pressed. */
|
||||
const dateFetchMoreCutoff = (
|
||||
cached: Episode[],
|
||||
loaded: number,
|
||||
windowDays: number,
|
||||
): number => {
|
||||
if (loaded > 0) {
|
||||
const t = epTs(cached[loaded - 1]);
|
||||
if (Number.isFinite(t)) {
|
||||
return t - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000;
|
||||
}
|
||||
}
|
||||
// Nothing loaded: the band extends FETCH_MORE_WINDOW_DAYS before the
|
||||
// cache-window edge (e.g. 60d → reveals the 60–74d slice).
|
||||
return (
|
||||
Date.now() -
|
||||
Math.max(1, windowDays) * 24 * 3600 * 1000 -
|
||||
FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000
|
||||
);
|
||||
};
|
||||
|
||||
/** Save feeds to file (async, fire-and-forget). */
|
||||
function saveFeeds(feeds: Feed[]): void {
|
||||
const prefs = useAppStore().state().preferences;
|
||||
@@ -181,40 +234,23 @@ async function migratePlaintextCredentials(
|
||||
* union semantics the merged list legitimately contains episodes BEYOND the
|
||||
* fetched window, so unchanged-detection must compare the fetched window
|
||||
* against the existing list's prefix — comparing full lists would bump
|
||||
* `lastUpdated` on every refresh. */
|
||||
function sameRefreshWindow(existing: Episode[], fetched: Episode[]): boolean {
|
||||
* `lastUpdated` on every refresh. When ids drifted between refreshes (the
|
||||
* one-time positional-id migration, or a feed that rotates enclosure URLs)
|
||||
* the id sets differ for the SAME content, so a content-signature
|
||||
* comparison decides: an unchanged feed stays unchanged. */
|
||||
export function sameRefreshWindow(
|
||||
existing: Episode[],
|
||||
fetched: Episode[],
|
||||
): boolean {
|
||||
if (fetched.length === 0) return true;
|
||||
const prefix = existing.slice(0, fetched.length);
|
||||
const ids = new Set(prefix.map((e) => e.id));
|
||||
return fetched.every((e) => ids.has(e.id));
|
||||
if (fetched.every((e) => ids.has(e.id))) return true;
|
||||
if (prefix.length !== fetched.length) return false;
|
||||
const signatures = new Set(prefix.map(episodeSignature));
|
||||
return fetched.every((e) => signatures.has(episodeSignature(e)));
|
||||
}
|
||||
|
||||
/** Run `fn` over every item with at most `limit` executions in flight — a
|
||||
* classic worker pool. Workers pull indexes from a shared counter, so the
|
||||
* first `limit` calls start immediately and each completion frees its slot
|
||||
* for the next item; results are assembled in INPUT order regardless of
|
||||
* completion order. A hung `fn` holds at most one slot. */
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(limit, items.length) },
|
||||
async () => {
|
||||
let i: number;
|
||||
while ((i = nextIndex++) < items.length) {
|
||||
results[i] = await fn(items[i]);
|
||||
}
|
||||
},
|
||||
);
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Create feed store */
|
||||
function createFeedStore() {
|
||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||
@@ -228,6 +264,11 @@ function createFeedStore() {
|
||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||
/** Feed-page fetch-more presses in COUNT mode: the global list is capped
|
||||
* at episodeCacheCount × (presses + 1) episodes, so one press reveals
|
||||
* exactly N more of the NEWEST episodes across all shows — it can never
|
||||
* dump deep history (see getAllEpisodesChronological). */
|
||||
const [countFetchMorePresses, setCountFetchMorePresses] = createSignal(0);
|
||||
|
||||
// ── Debounced persistence ───────────────────────────────────────────────
|
||||
/** Trailing-edge debounce window for config.json writes. */
|
||||
@@ -262,7 +303,6 @@ function createFeedStore() {
|
||||
saveFeeds(feeds());
|
||||
};
|
||||
|
||||
/** Get filtered and sorted feeds */
|
||||
const getFilteredFeeds = (): Feed[] => {
|
||||
let result = [...feeds()];
|
||||
const f = filter();
|
||||
@@ -320,7 +360,6 @@ function createFeedStore() {
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Get episodes in reverse chronological order across all feeds */
|
||||
const getAllEpisodesChronological = (): Array<{
|
||||
episode: Episode;
|
||||
feed: Feed;
|
||||
@@ -338,10 +377,23 @@ function createFeedStore() {
|
||||
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
|
||||
);
|
||||
|
||||
// COUNT mode: the Feed page is a GLOBAL top-K list — the newest
|
||||
// `episodeCacheCount × (fetch-more presses + 1)` episodes across ALL
|
||||
// shows, not N per show. A press reveals exactly N more recent
|
||||
// episodes; deep history never surfaces in one jump. The cap stays
|
||||
// even once every cache is exhausted (the button hides) — lifting it
|
||||
// rendered the full deep union and froze the UI.
|
||||
const prefs = useAppStore().state().preferences;
|
||||
if (prefs.episodeCacheMode === "count") {
|
||||
const limit =
|
||||
Math.max(1, prefs.episodeCacheCount ?? 25) *
|
||||
(countFetchMorePresses() + 1);
|
||||
return allEpisodes.slice(0, limit);
|
||||
}
|
||||
|
||||
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 +436,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 +468,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);
|
||||
};
|
||||
@@ -564,40 +621,40 @@ function createFeedStore() {
|
||||
})(), "Refreshing");
|
||||
};
|
||||
|
||||
/** Refresh all feeds — bounded concurrency (at most FETCH_CONCURRENCY
|
||||
* in-flight requests), and each feed's refreshed episodes are applied
|
||||
* AS ITS OWN FETCH LANDS (no Promise.all barrier). Per-feed apply is
|
||||
* safe because applyRefreshedEpisodes keeps unchanged feeds' object
|
||||
* identity and lastUpdated (union merge), so each feed's refreshed
|
||||
* episodes render as its own fetch resolves — the order flapping the
|
||||
* old atomic barrier existed to hide can no longer happen. */
|
||||
/** Refresh all feeds via the Effect batch program (effects/feed-refresh):
|
||||
* bounded concurrency (at most FETCH_CONCURRENCY in-flight requests)
|
||||
* and each feed's refreshed episodes applied AS ITS OWN FETCH LANDS
|
||||
* (no barrier — the apply runs inside the feed's own fiber). Per-feed
|
||||
* apply is safe because applyRefreshedEpisodes keeps unchanged feeds'
|
||||
* object identity and lastUpdated (union merge), so each feed's
|
||||
* refreshed episodes render as its own fetch resolves — the order
|
||||
* flapping the old atomic barrier existed to hide can no longer
|
||||
* happen. A failed or timed-out fetch (null episodes) leaves that
|
||||
* feed untouched. */
|
||||
const refreshAllFeeds = async () => {
|
||||
setIsLoadingFeeds(true);
|
||||
try {
|
||||
await mapWithConcurrency(
|
||||
feeds(),
|
||||
FETCH_CONCURRENCY,
|
||||
async (feed) => {
|
||||
const { episodes, coverUrl } = await fetchEpisodes(
|
||||
feed.podcast.feedUrl,
|
||||
MAX_EPISODES_REFRESH,
|
||||
feed.id,
|
||||
);
|
||||
// A failed fetch (null) leaves that feed untouched.
|
||||
if (!episodes) return;
|
||||
setFeeds((prev) => {
|
||||
let updated = applyRefreshedEpisodes(prev, feed.id, episodes);
|
||||
if (coverUrl) {
|
||||
updated = updated.map((f) =>
|
||||
f.id === feed.id && !f.podcast.coverUrl && coverUrl
|
||||
? { ...f, podcast: { ...f.podcast, coverUrl } }
|
||||
: f,
|
||||
);
|
||||
}
|
||||
if (updated !== prev) scheduleSaveFeeds();
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
await Effect.runPromise(
|
||||
refreshFeedsBatch(
|
||||
feeds(),
|
||||
(feed) =>
|
||||
fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id),
|
||||
(feed, { episodes, coverUrl }) => {
|
||||
setFeeds((prev) => {
|
||||
let updated = applyRefreshedEpisodes(prev, feed.id, episodes);
|
||||
if (coverUrl) {
|
||||
updated = updated.map((f) =>
|
||||
f.id === feed.id && !f.podcast.coverUrl && coverUrl
|
||||
? { ...f, podcast: { ...f.podcast, coverUrl } }
|
||||
: f,
|
||||
);
|
||||
}
|
||||
if (updated !== prev) scheduleSaveFeeds();
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
{ concurrency: FETCH_CONCURRENCY, timeoutMs: FETCH_TIMEOUT_MS },
|
||||
),
|
||||
);
|
||||
// Global auto-download: one idempotent pass after the batch.
|
||||
runAutoDownload();
|
||||
@@ -675,7 +732,6 @@ function createFeedStore() {
|
||||
};
|
||||
scheduleNextRefresh();
|
||||
|
||||
/** Remove a feed */
|
||||
const removeFeed = (feedId: string) => {
|
||||
fullEpisodeCache.delete(feedId);
|
||||
episodeLoadCount.delete(feedId);
|
||||
@@ -706,7 +762,6 @@ function createFeedStore() {
|
||||
}
|
||||
};
|
||||
|
||||
/** Update a feed */
|
||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
@@ -717,7 +772,6 @@ function createFeedStore() {
|
||||
});
|
||||
};
|
||||
|
||||
/** Toggle feed pinned status */
|
||||
const togglePinned = (feedId: string) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
@@ -728,7 +782,6 @@ function createFeedStore() {
|
||||
});
|
||||
};
|
||||
|
||||
/** Add a source */
|
||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||
const newSource: PodcastSource = {
|
||||
...source,
|
||||
@@ -742,7 +795,6 @@ function createFeedStore() {
|
||||
return newSource;
|
||||
};
|
||||
|
||||
/** Update a source */
|
||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((source) =>
|
||||
@@ -753,7 +805,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 +817,6 @@ function createFeedStore() {
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Toggle source enabled status */
|
||||
const toggleSource = (sourceId: string) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((s) =>
|
||||
@@ -777,7 +827,6 @@ function createFeedStore() {
|
||||
});
|
||||
};
|
||||
|
||||
/** Get feed by ID */
|
||||
const getFeed = (feedId: string): Feed | undefined => {
|
||||
return feeds().find((f) => f.id === feedId);
|
||||
};
|
||||
@@ -792,7 +841,6 @@ function createFeedStore() {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Get selected feed */
|
||||
const getSelectedFeed = (): Feed | undefined => {
|
||||
const id = selectedFeedId();
|
||||
return id ? getFeed(id) : undefined;
|
||||
@@ -800,15 +848,25 @@ function createFeedStore() {
|
||||
|
||||
/** Check if a feed has more episodes available beyond what's currently
|
||||
* loaded. The full parse cache holds ALL episodes (including beyond the
|
||||
* cache bound), so fetch-more can always page deeper — the bound limits
|
||||
* what the Feed/My Shows list shows initially, not what fetch-more can
|
||||
* reach. When the loaded window reaches the cache length, this flips
|
||||
* false. */
|
||||
* cache bound), so fetch-more can page deeper — but in DATE mode only
|
||||
* when the next unloaded episode falls inside the next 2-week band: a
|
||||
* sparse/dormant show whose band is empty reports false, so fetch-more
|
||||
* never drags in arbitrarily old episodes just because the parse cache
|
||||
* holds them. When the loaded window reaches the cache length (or the
|
||||
* band is empty), this flips false. */
|
||||
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||
const cached = fullEpisodeCache.get(feedId);
|
||||
if (!cached) return false;
|
||||
const loaded = episodeLoadCount.get(feedId) ?? 0;
|
||||
return loaded < cached.length;
|
||||
if (loaded >= cached.length) return false;
|
||||
const prefs = useAppStore().state().preferences;
|
||||
if (prefs.episodeCacheMode === "count") return true;
|
||||
const cutoff = dateFetchMoreCutoff(
|
||||
cached,
|
||||
loaded,
|
||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
);
|
||||
return epTs(cached[loaded]) >= cutoff;
|
||||
};
|
||||
|
||||
/** Load the next chunk of episodes for one feed from the full parse
|
||||
@@ -853,16 +911,42 @@ 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);
|
||||
}
|
||||
|
||||
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
|
||||
const newCount = Math.min(
|
||||
currentCount + MAX_EPISODES_REFRESH,
|
||||
cached.length,
|
||||
);
|
||||
const prefs = useAppStore().state().preferences;
|
||||
|
||||
// Date mode: each press reveals the next FETCH_MORE_WINDOW_DAYS band
|
||||
// past the oldest loaded episode (or the cache-window edge when
|
||||
// nothing is loaded) — a daily show gains ~2 weeks of episodes, a
|
||||
// weekly show gains its next 2, never a fixed count. An empty band
|
||||
// is a genuine stop (hasMoreEpisodes hides the button) — no minimum,
|
||||
// so a sparse/dormant show can't grab arbitrarily old episodes.
|
||||
// Count mode keeps the fixed MAX_EPISODES_REFRESH chunk.
|
||||
let newCount: number;
|
||||
if (prefs.episodeCacheMode === "date") {
|
||||
const cutoff = dateFetchMoreCutoff(
|
||||
cached,
|
||||
currentCount,
|
||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
);
|
||||
newCount = currentCount;
|
||||
while (
|
||||
newCount < cached.length &&
|
||||
epTs(cached[newCount]) >= cutoff
|
||||
) {
|
||||
newCount++;
|
||||
}
|
||||
} else {
|
||||
newCount = currentCount + MAX_EPISODES_REFRESH;
|
||||
}
|
||||
newCount = Math.min(newCount, cached.length);
|
||||
|
||||
if (newCount <= currentCount) return; // nothing more to load
|
||||
|
||||
@@ -900,27 +984,88 @@ function createFeedStore() {
|
||||
}
|
||||
};
|
||||
|
||||
/** True if any feed still has cached episodes beyond its loaded window. */
|
||||
const hasMoreAcrossAll = (): boolean => {
|
||||
return feeds().some((f) => hasMoreEpisodes(f.id));
|
||||
};
|
||||
|
||||
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that
|
||||
* still has cached episodes — powers the Feed page's "[Fetch More]". */
|
||||
/** Power the Feed page's "[Fetch More]".
|
||||
* Date mode: advance each feed's window by its 2-week band (empty bands
|
||||
* — sparse/dormant shows — are skipped).
|
||||
* Count mode: the global list cap grows by one count (see
|
||||
* getAllEpisodesChronological) and every feed's window deepens by one
|
||||
* count so the growing cap has material; one press reveals exactly N
|
||||
* more RECENT episodes, never a far-back dump.
|
||||
* Both modes compute every feed's new window FIRST (yielding between
|
||||
* feeds so the renderer keeps painting) and apply ONE setFeeds — the
|
||||
* Feed list rebuilds once per press instead of once per feed (the
|
||||
* per-feed storms froze the UI). */
|
||||
const loadMoreAllFeeds = async () => {
|
||||
if (isLoadingMore()) return;
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const pending = feeds().filter((f) => hasMoreEpisodes(f.id));
|
||||
for (const feed of pending) {
|
||||
await loadMoreEpisodesForFeed(feed.id);
|
||||
const prefs = useAppStore().state().preferences;
|
||||
const count = Math.max(1, prefs.episodeCacheCount ?? 25);
|
||||
if (prefs.episodeCacheMode === "count") {
|
||||
setCountFetchMorePresses((p) => p + 1);
|
||||
}
|
||||
const windowDays =
|
||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS;
|
||||
|
||||
const updates: Array<{ feedId: string; episodes: Episode[] }> = [];
|
||||
for (const feed of feeds()) {
|
||||
const cached = fullEpisodeCache.get(feed.id);
|
||||
if (!cached) continue;
|
||||
const currentCount =
|
||||
episodeLoadCount.get(feed.id) ?? feed.episodes.length;
|
||||
if (currentCount >= cached.length) continue;
|
||||
let newCount: number;
|
||||
if (prefs.episodeCacheMode === "count") {
|
||||
newCount = Math.min(currentCount + count, cached.length);
|
||||
} else {
|
||||
// Date mode: skip feeds whose next band is empty — the
|
||||
// button must not surface arbitrarily old episodes.
|
||||
const cutoff = dateFetchMoreCutoff(
|
||||
cached,
|
||||
currentCount,
|
||||
windowDays,
|
||||
);
|
||||
if (epTs(cached[currentCount]) < cutoff) continue;
|
||||
newCount = currentCount;
|
||||
while (
|
||||
newCount < cached.length &&
|
||||
epTs(cached[newCount]) >= cutoff
|
||||
) {
|
||||
newCount++;
|
||||
}
|
||||
}
|
||||
if (newCount <= currentCount) continue;
|
||||
episodeLoadCount.set(feed.id, newCount);
|
||||
updates.push({
|
||||
feedId: feed.id,
|
||||
episodes: cached.slice(0, newCount),
|
||||
});
|
||||
// Yield so the renderer paints between feed computations.
|
||||
await yieldToUI();
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
const byId = new Map(
|
||||
updates.map((u) => [u.feedId, u.episodes]),
|
||||
);
|
||||
setFeeds((prev) =>
|
||||
prev.map((f) =>
|
||||
byId.has(f.id)
|
||||
? { ...f, episodes: byId.get(f.id)! }
|
||||
: f,
|
||||
),
|
||||
);
|
||||
scheduleSaveFeeds();
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Run the global auto-download pass (see runAutoDownload above). */
|
||||
const runAutoDownloadNow = (): void => {
|
||||
runAutoDownload();
|
||||
};
|
||||
@@ -949,6 +1094,11 @@ function createFeedStore() {
|
||||
// Actions
|
||||
setFilter,
|
||||
setSelectedFeedId,
|
||||
/** Fetch + parse an RSS feed WITHOUT subscribing or touching any feed
|
||||
* record (Discover's episode preview). Pass no feedId to skip the
|
||||
* full-parse cache; the visible window is bounded by the user's
|
||||
* cache preference and `limit`. */
|
||||
fetchEpisodes,
|
||||
addFeed,
|
||||
hasFeedByUrl,
|
||||
removeFeed,
|
||||
@@ -969,7 +1119,6 @@ function createFeedStore() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton feed store */
|
||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
||||
|
||||
export function useFeedStore() {
|
||||
|
||||
@@ -64,16 +64,10 @@ function createProgressStore() {
|
||||
*/
|
||||
whenReady: () => progressInit,
|
||||
|
||||
/**
|
||||
* Get progress for a specific episode.
|
||||
*/
|
||||
get(episodeId: string): Progress | undefined {
|
||||
return progressMap()[episodeId];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all progress entries.
|
||||
*/
|
||||
all(): Record<string, Progress> {
|
||||
return progressMap();
|
||||
},
|
||||
@@ -102,18 +96,12 @@ function createProgressStore() {
|
||||
persist();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if an episode is completed.
|
||||
*/
|
||||
isCompleted(episodeId: string): boolean {
|
||||
const p = progressMap()[episodeId];
|
||||
if (!p || p.duration <= 0) return false;
|
||||
return p.position / p.duration >= COMPLETION_THRESHOLD;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get progress percentage (0-100) for an episode.
|
||||
*/
|
||||
getPercent(episodeId: string): number {
|
||||
const p = progressMap()[episodeId];
|
||||
if (!p || p.duration <= 0) return 0;
|
||||
@@ -151,9 +139,6 @@ function createProgressStore() {
|
||||
persist();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all progress data.
|
||||
*/
|
||||
clear(): void {
|
||||
setProgressMap({});
|
||||
persist();
|
||||
|
||||
@@ -60,7 +60,6 @@ function saveScope(scope: SearchScope): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Create search store */
|
||||
export function createSearchStore() {
|
||||
const feedStore = useFeedStore();
|
||||
const [query, setQuery] = createSignal("");
|
||||
@@ -167,7 +166,6 @@ export function createSearchStore() {
|
||||
}
|
||||
};
|
||||
|
||||
/** Add query to history */
|
||||
const addToHistory = (q: string) => {
|
||||
setHistory((prev) => {
|
||||
const updated = sanitizeHistory([q, ...prev]);
|
||||
@@ -176,13 +174,11 @@ export function createSearchStore() {
|
||||
});
|
||||
};
|
||||
|
||||
/** Clear search history */
|
||||
const clearHistory = () => {
|
||||
setHistory([]);
|
||||
saveSearchHistoryToFile([]);
|
||||
};
|
||||
|
||||
/** Remove single history item */
|
||||
const removeFromHistory = (q: string) => {
|
||||
setHistory((prev) => {
|
||||
const updated = prev.filter((h) => h !== q);
|
||||
@@ -191,14 +187,12 @@ export function createSearchStore() {
|
||||
});
|
||||
};
|
||||
|
||||
/** Clear results */
|
||||
const clearResults = () => {
|
||||
setResults([]);
|
||||
setQuery("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
/** Mark a podcast as subscribed in results */
|
||||
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
||||
setResults((prev) =>
|
||||
prev.map((result) => {
|
||||
@@ -262,7 +256,6 @@ export function createSearchStore() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton search store */
|
||||
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
|
||||
|
||||
export function useSearchStore() {
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
* after the Player tab stops being focused it tears down. Reads outside
|
||||
* decoded coverage return empty — the renderer simply holds the last frame
|
||||
* until the decode frontier arrives.
|
||||
*
|
||||
* Loading semantics: `isLoading` is true from any pipeline start (cold
|
||||
* start, resume into undecoded audio) until the first complete FFT frame,
|
||||
* and `isStalled` while playback claims to be live but the position clock
|
||||
* is frozen (player re-buffering). The component renders the spinner for
|
||||
* either; bars replace it the moment fresh frames arrive.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -55,6 +61,14 @@ const FRAME_INTERVAL = 33;
|
||||
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
||||
const SAMPLES_PER_FRAME = 512;
|
||||
|
||||
/**
|
||||
* How long the position clock may stay frozen while the UI believes
|
||||
* playback is live before the waveform reports a stall (loading state).
|
||||
* mpv polls time-pos every ~150ms, so a frozen clock means the player is
|
||||
* re-buffering — the long-pause-then-resume case on network streams.
|
||||
*/
|
||||
const STALL_DETECT_MS = 2000;
|
||||
|
||||
/** Timer handle as returned by setTimeout/setInterval in this runtime. */
|
||||
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
@@ -65,6 +79,10 @@ export interface VisualizerStore {
|
||||
barData: () => number[];
|
||||
/** True from pipeline start until the first complete FFT frame renders. */
|
||||
isLoading: () => boolean;
|
||||
/** True while playback claims to be live but the position clock has
|
||||
* been frozen past STALL_DETECT_MS (player re-buffering, e.g. after a
|
||||
* long pause on a network stream). */
|
||||
isStalled: () => boolean;
|
||||
/** True while the ~30fps render loop is armed. */
|
||||
isRunning: () => boolean;
|
||||
/** Report whether the Player tab is the visible tab. */
|
||||
@@ -82,6 +100,10 @@ function createVisualizerStore(): VisualizerStore {
|
||||
// True from pipeline start until the first complete FFT frame renders.
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
// True while playback is live but the position clock is frozen
|
||||
// (player re-buffering) — see STALL_DETECT_MS.
|
||||
const [isStalled, setIsStalled] = createSignal(false);
|
||||
|
||||
// Whether the Player tab is the visible tab (fed by PlayerPage).
|
||||
const [focused, setFocused] = createSignal(false);
|
||||
|
||||
@@ -103,6 +125,20 @@ function createVisualizerStore(): VisualizerStore {
|
||||
let sampleBuffer: Float64Array | null = null;
|
||||
let unloadTimer: TimerHandle | null = null;
|
||||
|
||||
// Stall tracker: last observed position-signal value and when it moved.
|
||||
// Any change (forward, backward, seek) re-arms the clock; a frozen
|
||||
// signal while playing trips isStalled after STALL_DETECT_MS.
|
||||
let lastRenderPos = -1;
|
||||
let lastPosMoveAt = 0;
|
||||
|
||||
// Resume point: the position a paused pipeline was re-armed at. The
|
||||
// loading state set by resume only clears once the position clock has
|
||||
// advanced PAST this — while the player is still re-buffering, the
|
||||
// cache can serve the same window forever and the stale pre-pause bars
|
||||
// must not masquerade as live data. -1 = cold start (clear on the
|
||||
// first produced frame, regardless of the clock).
|
||||
let resumePos = -1;
|
||||
|
||||
// What the running pipeline was started with — lets the playback effect
|
||||
// tell "nothing changed, stay warm" from "must restart".
|
||||
let activeUrl = "";
|
||||
@@ -200,9 +236,19 @@ function createVisualizerStore(): VisualizerStore {
|
||||
lastPolledPosition = position;
|
||||
lastPolledAt = performance.now();
|
||||
|
||||
// Seed the stall tracker: a fresh pipeline should not report a
|
||||
// stall just because the first position poll hasn't landed.
|
||||
lastRenderPos = position;
|
||||
lastPosMoveAt = performance.now();
|
||||
|
||||
// Cold start: the loading state clears on the first produced frame
|
||||
// (see renderFrame) — no resume-position gating.
|
||||
resumePos = -1;
|
||||
|
||||
activeUrl = url;
|
||||
activeBars = barCount();
|
||||
setIsLoading(true);
|
||||
setIsStalled(false);
|
||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||
};
|
||||
|
||||
@@ -224,6 +270,14 @@ function createVisualizerStore(): VisualizerStore {
|
||||
}
|
||||
sampleBuffer = null;
|
||||
setIsLoading(false);
|
||||
setIsStalled(false);
|
||||
// Drop the last rendered frame: after a stop the bars are stale (a
|
||||
// different episode, a different position) and would masquerade as
|
||||
// live data while the next cold start warms up — and, because the
|
||||
// component only shows the spinner while bars are empty, they'd
|
||||
// also suppress the loading state. Cold restarts re-render fresh
|
||||
// bars within the first frame.
|
||||
setBarData([]);
|
||||
};
|
||||
|
||||
// ── Pause: freeze the loop, keep the cache ──────────────────────────
|
||||
@@ -248,6 +302,7 @@ function createVisualizerStore(): VisualizerStore {
|
||||
// (still cold-starting when paused), the component should fall back
|
||||
// to the placeholder, not freeze on a spinner.
|
||||
setIsLoading(false);
|
||||
setIsStalled(false);
|
||||
};
|
||||
|
||||
// ── Resume: re-arm the render loop, top up the cache ───────────────
|
||||
@@ -269,6 +324,20 @@ function createVisualizerStore(): VisualizerStore {
|
||||
|
||||
lastPolledPosition = pos;
|
||||
lastPolledAt = performance.now();
|
||||
// Re-arm the stall tracker from the resume position (a long pause
|
||||
// left the old timestamps stale — they'd trip the stall detector on
|
||||
// the very first frame otherwise).
|
||||
lastRenderPos = pos;
|
||||
lastPosMoveAt = performance.now();
|
||||
|
||||
// Resume re-arms a pipeline whose ffmpeg pass was killed at pause:
|
||||
// the pre-pause bars are stale until fresh frames flow, so show the
|
||||
// loading state IN THEIR PLACE. It clears only once the position
|
||||
// clock has advanced past the resume point (see renderFrame) — a
|
||||
// player still re-buffering after a long pause keeps the spinner
|
||||
// instead of serving static cached bars.
|
||||
resumePos = pos;
|
||||
setIsLoading(true);
|
||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||
return true;
|
||||
};
|
||||
@@ -282,6 +351,26 @@ function createVisualizerStore(): VisualizerStore {
|
||||
// coverage (decode cold start, seek into a hole) the read is empty
|
||||
// and the LAST FRAME simply holds — never clamped/repeated junk.
|
||||
const target = smoothPosition();
|
||||
|
||||
// Stall detection: while the UI believes playback is live, the
|
||||
// position signal must keep advancing (useAudio polls it every
|
||||
// ~150ms). A frozen clock with a warm pipeline means the player is
|
||||
// re-buffering — the classic long-pause-then-resume on a network
|
||||
// stream — and without this the waveform shows dead-looking static
|
||||
// bars for the whole stall. Report it as loading; the first frame
|
||||
// after the clock moves again clears it.
|
||||
const rawPos = audioPlaybackSignals.position();
|
||||
if (rawPos !== lastRenderPos) {
|
||||
lastRenderPos = rawPos;
|
||||
lastPosMoveAt = performance.now();
|
||||
if (isStalled()) setIsStalled(false);
|
||||
} else if (
|
||||
audioPlaybackSignals.isPlaying() &&
|
||||
performance.now() - lastPosMoveAt > STALL_DETECT_MS
|
||||
) {
|
||||
setIsStalled(true);
|
||||
}
|
||||
|
||||
const count = pcm.readWindow(sampleBuffer, target);
|
||||
// Never feed a partial FFT window to cava.
|
||||
if (count < sampleBuffer.length) return;
|
||||
@@ -290,7 +379,14 @@ function createVisualizerStore(): VisualizerStore {
|
||||
|
||||
// Normalize against the running peak and copy to a new array
|
||||
setBarData(scaler(output));
|
||||
if (isLoading()) setIsLoading(false);
|
||||
// Fresh frames only count once the position clock has moved past
|
||||
// the resume point: while the player is still re-buffering after a
|
||||
// long pause, the cache serves the same window and the spinner must
|
||||
// stay in place of the stale bars. Cold starts (resumePos < 0)
|
||||
// clear on the first frame as before.
|
||||
if (isLoading() && (resumePos < 0 || rawPos > resumePos)) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Playback subscription ──────────────────────────────────────────
|
||||
@@ -425,6 +521,7 @@ function createVisualizerStore(): VisualizerStore {
|
||||
// state
|
||||
barData,
|
||||
isLoading,
|
||||
isStalled,
|
||||
isRunning: () => frameTimer !== null,
|
||||
// inputs
|
||||
setFocused,
|
||||
|
||||
@@ -156,9 +156,6 @@ function init() {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Get all visible options.
|
||||
*/
|
||||
get options() {
|
||||
return visibleOptions();
|
||||
},
|
||||
@@ -195,9 +192,6 @@ export function CommandProvider(props: ParentProps) {
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command palette dialog component.
|
||||
*/
|
||||
function CommandDialog(props: {
|
||||
options: CommandOption[];
|
||||
suggestedOptions: CommandOption[];
|
||||
|
||||
@@ -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)!
|
||||
|
||||
@@ -62,7 +62,6 @@ const defaultState: AppState = {
|
||||
|
||||
// ── App State (config.json) ─────────────────────────────────────────────────
|
||||
|
||||
/** Load app state from config.json */
|
||||
export async function loadAppStateFromFile(): Promise<AppState> {
|
||||
try {
|
||||
const cfg = await loadConfig();
|
||||
@@ -88,7 +87,6 @@ export async function loadAppStateFromFile(): Promise<AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Save app state to config.json */
|
||||
export function saveAppStateToFile(state: AppState): void {
|
||||
updateConfig({
|
||||
settings: state.settings,
|
||||
@@ -109,7 +107,6 @@ interface ProgressEntry {
|
||||
playbackSpeed?: number;
|
||||
}
|
||||
|
||||
/** Load progress map from JSON file */
|
||||
export async function loadProgressFromFile(): Promise<
|
||||
Record<string, ProgressEntry>
|
||||
> {
|
||||
@@ -145,7 +142,6 @@ export function saveProgressToFile(data: Record<string, unknown>): void {
|
||||
|
||||
const SEARCH_HISTORY_FILE = "search-history.json";
|
||||
|
||||
/** Load search history from JSON file */
|
||||
export async function loadSearchHistoryFromFile(): Promise<string[]> {
|
||||
try {
|
||||
const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE));
|
||||
@@ -159,7 +155,6 @@ export async function loadSearchHistoryFromFile(): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Save search history to JSON file (overwrite, no backup) */
|
||||
export function saveSearchHistoryToFile(history: string[]): void {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -178,7 +173,6 @@ export function saveSearchHistoryToFile(history: string[]): void {
|
||||
|
||||
const AUDIO_NAV_FILE = "audio-nav.json";
|
||||
|
||||
/** Load audio navigation state from JSON file */
|
||||
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
|
||||
try {
|
||||
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
* fills its demuxer cache ahead of time; the first real play just flips
|
||||
* `pause` to false — the ~2s network open is paid at boot, not on the
|
||||
* user's first Play.
|
||||
* - Crash/kill recovery: a dead daemon (process exit or broken IPC socket)
|
||||
* is detected on the next command; play() respawns a fresh daemon and
|
||||
* reloads. resume() cannot unpause a freshly-idle daemon — it throws
|
||||
* PlayerRestartedError so the caller reloads the episode via play().
|
||||
*/
|
||||
|
||||
import { platform } from "os";
|
||||
@@ -78,6 +82,11 @@ export interface AudioBackend {
|
||||
getPauseState(): Promise<boolean | undefined>;
|
||||
/** True while the player process is running (regardless of pause). */
|
||||
isAlive(): boolean;
|
||||
/** Last playback error (end-file reason "error"), or null when the last
|
||||
* track ended cleanly (or nothing has failed yet). Lets callers
|
||||
* distinguish a natural end-of-file from a stream failure — a failed
|
||||
* episode must not auto-advance the queue. */
|
||||
getPlaybackError(): string | null;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -274,12 +283,28 @@ class MpvConnection {
|
||||
}
|
||||
this.handleTeardown();
|
||||
}
|
||||
|
||||
/** True while the Unix socket is open — a live, reachable daemon. */
|
||||
isConnected(): boolean {
|
||||
return this.sock !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── mpv Backend ──────────────────────────────────────────────────────
|
||||
// One resident daemon for the app's lifetime, controlled over a single
|
||||
// persistent JSON IPC connection with property observation.
|
||||
|
||||
/** Thrown by resume() when the daemon restarted (killed/crashed) and the
|
||||
* previously-loaded file is gone — the fresh daemon is idle, so the
|
||||
* caller must reload the episode via the full play path instead of
|
||||
* unpausing (which would silently do nothing). */
|
||||
export class PlayerRestartedError extends Error {
|
||||
constructor() {
|
||||
super("mpv restarted; episode must be reloaded");
|
||||
this.name = "PlayerRestartedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Property observation ids (correlate property-change events). */
|
||||
const OBS_TIME_POS = 1;
|
||||
const OBS_PAUSE = 2;
|
||||
@@ -318,14 +343,35 @@ export class MpvBackend implements AudioBackend {
|
||||
// ── Daemon lifecycle ─────────────────────────────────────────────
|
||||
|
||||
private async ensureDaemon(): Promise<void> {
|
||||
if (this.proc && !this._exited && this.conn) return;
|
||||
// Healthy = process alive AND its IPC socket open. A socket teardown
|
||||
// with a living process (rare) is just as unusable as a dead one —
|
||||
// every command would fail "not-connected" forever.
|
||||
if (this.proc && !this._exited && this.conn?.isConnected()) return;
|
||||
if (this.startPromise) return this.startPromise;
|
||||
this.startPromise = this.spawnDaemon().finally(() => {
|
||||
this.startPromise = this.recoverDaemon().finally(() => {
|
||||
this.startPromise = null;
|
||||
});
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
/** Bring up a usable daemon. If the old process still lives with a dead
|
||||
* IPC connection, kill it so the fresh spawn owns the socket path and
|
||||
* no orphan lingers — and await its exit so its exit handler can't run
|
||||
* after spawnDaemon() and clobber the new daemon's `_exited` flag. */
|
||||
private async recoverDaemon(): Promise<void> {
|
||||
const stale = this.proc;
|
||||
if (stale && !this._exited) {
|
||||
try {
|
||||
stale.kill();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
if (stale) await stale.exited.catch(() => {});
|
||||
this.conn = null;
|
||||
await this.spawnDaemon();
|
||||
}
|
||||
|
||||
private async spawnDaemon(): Promise<void> {
|
||||
// Clean up stale socket
|
||||
try {
|
||||
@@ -360,9 +406,15 @@ export class MpvBackend implements AudioBackend {
|
||||
this._exited = false;
|
||||
this.proc.exited
|
||||
.then(() => {
|
||||
// Daemon died (crash or external kill): every per-file state
|
||||
// is gone with it. _loadedUrl null forces the next play()
|
||||
// down the full reload path; _position/_volume/_speed are
|
||||
// kept so a recovery reload can carry them over.
|
||||
this._exited = true;
|
||||
this._intentPlaying = false;
|
||||
this._loadedUrl = null;
|
||||
this._loadedPaused = false;
|
||||
this._ended = false;
|
||||
this._paused = null;
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -544,13 +596,22 @@ export class MpvBackend implements AudioBackend {
|
||||
// play checks it and skips its own stale paused-load.
|
||||
this._intentPlaying = true;
|
||||
await this.runLoadExclusive(async () => {
|
||||
// Fast path: this exact URL was PRELOADED paused (boot restore) —
|
||||
// mpv has been buffering it since boot, so flipping pause off starts
|
||||
// audio ~instantly. Re-acquire the start position only when it
|
||||
// moved meaningfully since the preload (progress saved meanwhile).
|
||||
if (this._loadedUrl === url && this._loadedPaused && !this._ended) {
|
||||
// Same episode re-selected (Enter in a list, key-repeat, a
|
||||
// second tap on the playing row): the file is ALREADY in the
|
||||
// player. Reloading with start=<saved progress> would audibly
|
||||
// skip BACK and repeat already-played audio (saved progress
|
||||
// lags the live position by up to the 5s persist interval), so
|
||||
// align in place instead:
|
||||
// - preload park (loaded paused at boot restore): seek only
|
||||
// when the caller's target moved materially since load;
|
||||
// - user-paused: unpause at the CURRENT position (saved
|
||||
// progress is stale and must not become a backward seek);
|
||||
// - already playing: unpause is a no-op — nothing to do.
|
||||
// A genuinely finished episode (_ended) still falls through to
|
||||
// a fresh load, which replays from the top via isCompleted.
|
||||
if (this._loadedUrl === url && !this._ended) {
|
||||
const target = opts?.startPosition ?? this._position;
|
||||
if (Math.abs(target - this._position) > 2) {
|
||||
if (this._loadedPaused && Math.abs(target - this._position) > 2) {
|
||||
await this.send(["set_property", "time-pos", target]);
|
||||
this._position = target;
|
||||
}
|
||||
@@ -590,6 +651,13 @@ export class MpvBackend implements AudioBackend {
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
// The daemon may have died while we were paused (crash/kill): bring
|
||||
// a fresh one up. It starts idle — no file to unpause — so throw
|
||||
// PlayerRestartedError and let the caller reload the episode.
|
||||
await this.ensureDaemon();
|
||||
if (!this._loadedUrl) {
|
||||
throw new PlayerRestartedError();
|
||||
}
|
||||
if (this._ended && this._loadedUrl) {
|
||||
// Play pressed on a finished episode: replay from the top.
|
||||
this._ended = false;
|
||||
@@ -609,7 +677,12 @@ export class MpvBackend implements AudioBackend {
|
||||
this._loadedPaused = false;
|
||||
}
|
||||
this._ended = false;
|
||||
await this.send(["set_property", "pause", false]);
|
||||
const resp = await this.send(["set_property", "pause", false]);
|
||||
// Never claim success when the unpause didn't land: a dead/restarted
|
||||
// daemon would otherwise leave the UI "playing" with no audio.
|
||||
if (resp.error && resp.error !== "success") {
|
||||
throw new Error(`mpv resume failed: ${resp.error}`);
|
||||
}
|
||||
this._intentPlaying = true;
|
||||
}
|
||||
|
||||
@@ -723,6 +796,9 @@ class NoopBackend implements AudioBackend {
|
||||
isAlive(): boolean {
|
||||
return false;
|
||||
}
|
||||
getPlaybackError(): string | null {
|
||||
return null;
|
||||
}
|
||||
dispose(): void {}
|
||||
}
|
||||
|
||||
|
||||
88
src/utils/audio-queue.ts
Normal file
88
src/utils/audio-queue.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* audio-queue — ordered episode queue for "what plays next" navigation.
|
||||
*
|
||||
* Pure selection logic for source-based auto-advance (and manual next/prev):
|
||||
* given the navigation source that STARTED the current episode, which
|
||||
* episodes come after it?
|
||||
*
|
||||
* FEED — the global chronological Feed list (newest first), so "next"
|
||||
* walks toward older episodes — further down the list.
|
||||
* MY_SHOWS — the current show's episode list (newest first), scoped to the
|
||||
* podcast that started playback.
|
||||
* SEARCH — the current search results, in display order (episode-kind
|
||||
* results only — a show result has nothing to play).
|
||||
*
|
||||
* Kept dependency-light (pure functions over plain data) so the ordering and
|
||||
* bounds contract is unit-testable without stores or audio.
|
||||
*/
|
||||
|
||||
import type { Episode } from "../types/episode";
|
||||
import type { Feed } from "../types/feed";
|
||||
import type { SearchResult } from "../types/source";
|
||||
import { AudioSource } from "../stores/audio-nav";
|
||||
|
||||
/** The ordered playable queue for a navigation source. Empty when the
|
||||
* source's context is missing (no podcastId, no search results, no feeds). */
|
||||
export function queueForSource(
|
||||
source: AudioSource,
|
||||
podcastId: string | undefined,
|
||||
feeds: Feed[],
|
||||
allEpisodes: Array<{ episode: Episode; feed: Feed }>,
|
||||
searchResults: SearchResult[],
|
||||
): Episode[] {
|
||||
if (source === AudioSource.FEED) {
|
||||
// Dedupe by episode id: the same episode can appear twice after a
|
||||
// refresh merge or when two feeds list it — a duplicate would make
|
||||
// next/auto-advance step onto the CURRENT episode and replay it.
|
||||
const seen = new Set<string>();
|
||||
const unique: Episode[] = [];
|
||||
for (const e of allEpisodes) {
|
||||
if (seen.has(e.episode.id)) continue;
|
||||
seen.add(e.episode.id);
|
||||
unique.push(e.episode);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
if (source === AudioSource.MY_SHOWS) {
|
||||
const feed = feeds.find((f) => f.podcast.id === podcastId);
|
||||
return feed ? feed.episodes : [];
|
||||
}
|
||||
if (source === AudioSource.SEARCH) {
|
||||
return searchResults
|
||||
.filter((r) => r.kind === "episode")
|
||||
.map((r) => r.episode);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Index of an episode in the queue, or -1 when the episode isn't in it. */
|
||||
export function queueIndex(queue: Episode[], episodeId: string): number {
|
||||
return queue.findIndex((e) => e.id === episodeId);
|
||||
}
|
||||
|
||||
export interface QueueStep {
|
||||
episode: Episode;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/** The episode after `episodeId` in the queue, with its index. Null when
|
||||
* the episode isn't in the queue or is already the last one. */
|
||||
export function nextStep(
|
||||
queue: Episode[],
|
||||
episodeId: string,
|
||||
): QueueStep | null {
|
||||
const idx = queueIndex(queue, episodeId);
|
||||
if (idx < 0 || idx + 1 >= queue.length) return null;
|
||||
return { episode: queue[idx + 1], index: idx + 1 };
|
||||
}
|
||||
|
||||
/** The episode before `episodeId` in the queue, with its index. Null when
|
||||
* the episode isn't in the queue or is already the first one. */
|
||||
export function prevStep(
|
||||
queue: Episode[],
|
||||
episodeId: string,
|
||||
): QueueStep | null {
|
||||
const idx = queueIndex(queue, episodeId);
|
||||
if (idx <= 0) return null;
|
||||
return { episode: queue[idx - 1], index: idx - 1 };
|
||||
}
|
||||
@@ -52,7 +52,6 @@ const DEFAULTS: Required<CavaCoreConfig> = {
|
||||
scalingMode: 0,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type CavaLib = {
|
||||
symbols: Record<string, (...args: any[]) => any>;
|
||||
close(): void;
|
||||
@@ -102,7 +101,6 @@ export class CavaCore {
|
||||
this.lib = lib;
|
||||
}
|
||||
|
||||
/** Number of frequency bars configured. */
|
||||
get bars(): number {
|
||||
return this._bars;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,35 @@ const ts = (ep: Episode): number => {
|
||||
return t === undefined || Number.isNaN(t) ? Infinity : t
|
||||
}
|
||||
|
||||
/** PubDate stamp for identity matching — undated episodes collapse to a
|
||||
* single token so their twins match by title alone. */
|
||||
const stamp = (ep: Episode): string => {
|
||||
const t = ep.pubDate?.getTime()
|
||||
return t === undefined || Number.isNaN(t) ? "undated" : String(t)
|
||||
}
|
||||
|
||||
/**
|
||||
* Content signature identifying the SAME episode across id changes. Episode
|
||||
* ids are stable (guid / enclosure-URL derived), but a feed can still change
|
||||
* an episode's id between refreshes: the one-time migration from the old
|
||||
* positional-id scheme, or a host that rotates signed enclosure URLs. title +
|
||||
* pubDate is the most stable combination that survives both — a feed
|
||||
* re-issuing an episode with the same title and date IS that episode.
|
||||
*/
|
||||
export const episodeSignature = (ep: Episode): string =>
|
||||
`${ep.title}\u0000${stamp(ep)}`
|
||||
|
||||
/**
|
||||
* Union of two episode lists keyed by id — on collision the fetched copy
|
||||
* wins (fresh metadata). Result is sorted newest-first by pubDate and pruned
|
||||
* by the supplied `keep` predicate: episodes outside the configured cache
|
||||
* bound (date window or count) are dropped. Never mutates either input.
|
||||
* wins (fresh metadata). An existing episode whose id differs from every
|
||||
* fetched id but whose content signature matches a fetched episode is a
|
||||
* stale-id twin (id migration / rotating enclosure URLs) and is dropped,
|
||||
* otherwise the union would double every episode on the first refresh after
|
||||
* the id scheme changed. Existing episodes with NO fetched twin survive
|
||||
* (volatile in-memory window). Result is sorted newest-first by pubDate and
|
||||
* pruned by the supplied `keep` predicate: episodes outside the configured
|
||||
* cache bound (date window or count) are dropped. Never mutates either
|
||||
* input.
|
||||
*
|
||||
* The caller supplies `keep` so this module stays free of the preference
|
||||
* types — the feed store passes a closure bound to the user's mode/count/days.
|
||||
@@ -23,8 +47,17 @@ export function mergeEpisodesBounded(
|
||||
keep: (ep: Episode, index: number) => boolean,
|
||||
): Episode[] {
|
||||
const byId = new Map<string, Episode>()
|
||||
for (const ep of existing) byId.set(ep.id, ep)
|
||||
for (const ep of fetched) byId.set(ep.id, ep)
|
||||
const sorted = [...byId.values()].sort((a, b) => ts(b) - ts(a))
|
||||
const bySignature = new Map<string, Episode>()
|
||||
for (const ep of fetched) {
|
||||
byId.set(ep.id, ep)
|
||||
bySignature.set(episodeSignature(ep), ep)
|
||||
}
|
||||
const merged = [...byId.values()]
|
||||
for (const ep of existing) {
|
||||
if (byId.has(ep.id)) continue
|
||||
if (bySignature.has(episodeSignature(ep))) continue
|
||||
merged.push(ep)
|
||||
}
|
||||
const sorted = merged.sort((a, b) => ts(b) - ts(a))
|
||||
return sorted.filter((ep, i) => keep(ep, i))
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ function createEventBus(): EventBusInstance {
|
||||
}
|
||||
handlers.get(event)!.add(handler as EventHandler);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.off(event, handler);
|
||||
};
|
||||
|
||||
@@ -134,7 +134,6 @@ export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
|
||||
}
|
||||
})().catch(() => {});
|
||||
}
|
||||
/** Load sources from config.json */
|
||||
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||
try {
|
||||
const cfg = await loadConfig();
|
||||
@@ -144,7 +143,6 @@ export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/** Save sources to config.json */
|
||||
export function saveSourcesToFile<T>(sources: T[]): void {
|
||||
updateConfig({ sources: sources as unknown as PodcastSource[] });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -95,7 +95,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Load keybinds from JSONC file */
|
||||
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
|
||||
try {
|
||||
const filePath = getConfigFilePath(KEYBINDS_FILE);
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
/**
|
||||
* Theme CSS Variable Manager
|
||||
* Handles dynamic theme switching by updating CSS custom properties
|
||||
* Terminal Theme Resolver
|
||||
* Resolves the active theme (built-in, custom, or system-derived) to colors.
|
||||
*/
|
||||
|
||||
import type { TerminalColors } from "@opentui/core";
|
||||
import type { ThemeJson } from "../types/theme-schema";
|
||||
import { THEME_JSON } from "../constants/themes";
|
||||
import { getCustomThemes } from "./custom-themes";
|
||||
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
|
||||
import { generateSystemTheme } from "./system-theme";
|
||||
|
||||
/**
|
||||
* Apply CSS variable data-theme attribute
|
||||
*/
|
||||
export function setThemeAttribute(themeName: string) {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
root.setAttribute("data-theme", themeName);
|
||||
}
|
||||
|
||||
export async function loadThemes() {
|
||||
return await getCustomThemes();
|
||||
}
|
||||
|
||||
export function resolveTerminalTheme(
|
||||
themes: Record<string, ThemeJson>,
|
||||
name: string,
|
||||
@@ -32,9 +17,5 @@ export function resolveTerminalTheme(
|
||||
if (name === "system" && system) {
|
||||
return resolveThemeJson(generateSystemTheme(system, mode), mode);
|
||||
}
|
||||
const theme = themes[name] ?? themes.catppuccin;
|
||||
if (!theme) {
|
||||
return resolveThemeJson(THEME_JSON.catppuccin, mode);
|
||||
}
|
||||
return resolveThemeJson(theme, mode);
|
||||
return resolveThemeJson(themes[name] ?? themes.catppuccin, mode);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ deliverables:
|
||||
- Unchanged detection must compare the FETCHED window against the corresponding prefix of the existing list, i.e. keep a small `sameRefreshWindow(existing: Episode[], fetched: Episode[])` helper next to (and replacing the use of) `sameEpisodes`: `fetched.length === 0 → true`; otherwise compare id-sets of `fetched` and `existing.slice(0, fetched.length)`. Rationale: with union semantics `merged` legitimately contains episodes beyond the fetched window, so comparing full lists would bump `lastUpdated` on every refresh and resurrect the order-flapping bug `tests/feed-refresh.test.ts` guards.
|
||||
- Return unmodified `prev` when every feed's window is unchanged (preserve the existing identity-no-save contract); on change, set `{ ...f, episodes: merged, lastUpdated: new Date() }`.
|
||||
- Delete the now-unused `sameEpisodes` if nothing else references it (grep first: `grep sameEpisodes src tests`).
|
||||
- `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the filtered cache.
|
||||
- `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering). Fetch-more stepping is mode-dependent: DATE mode advances the loaded window by a `FETCH_MORE_WINDOW_DAYS` (14) band past the oldest loaded episode — a daily show gains ~2 weeks of episodes per press, not a fixed count — with a +1 minimum so a sparse band can't wedge the button into a no-op; COUNT mode keeps the fixed `MAX_EPISODES_REFRESH` (50) chunk. `hasMoreEpisodes` still compares `episodeLoadCount < cached.length`.
|
||||
- `tests/feed-volatile-merge.test.ts` (reworked) — see tests section.
|
||||
|
||||
steps:
|
||||
@@ -57,7 +57,9 @@ tests:
|
||||
- input arrays not mutated.
|
||||
- Store integration (harness per `tests/feed-refresh.test.ts`: temp `XDG_CONFIG_HOME` BEFORE imports, `Bun.serve` on port 0 serving generated RSS, fake timers):
|
||||
- Refresh-keeps-volatile-window: serve 3 episodes at t0, `addFeed`; then serve the same 3 plus 2 new ones, `refreshFeed`. Assert `feed.episodes.length === 5` AND `lastUpdated` advanced AND a second identical refresh leaves `lastUpdated` untouched (window-compare, not union-compare).
|
||||
- Boundary: a 25-day-old episode loads; a 31-day-old episode is neither visible nor cached.
|
||||
- Boundary: a 25-day-old episode loads; a 70-day-old episode is neither visible nor cached initially, but fetch-more surfaces it (volatile).
|
||||
- Date stepping: 30 episodes at 3-day spacing — each fetch-more press reveals the next 2-week band (24 → 28 → 30), NOT a fixed 50-chunk.
|
||||
- Count-mode global step: two feeds with staggered dates — one Feed-page press adds the configured N most-recent UNLOADED episodes across ALL shows (N total, not N per show), via the k-way frontier merge in `loadMoreAllFeedsByCount`.
|
||||
- Out-of-window never cached: 600 items at 2h spacing span ~50 days — only the in-window tail is loadable (fewer than the old 500 cap), `hasMoreEpisodes` flips false there.
|
||||
- No count ceiling: 600 items at 1h spacing (all within 25 days) are ALL loadable — the bound is the date, not a number.
|
||||
- Clock constraint: these tests run under fake timers, and a large `vi.advanceTimersByTime` (past ~5 days of fake time) makes Bun 1.3.8 hang every subsequent network fetch — the boundary is pinned with relative pubDates, never by moving the clock across it.
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
* it by unpausing — the boot-restore fast path with no second load.
|
||||
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
|
||||
* resume() afterwards replays from the top.
|
||||
* 7. Daemon death: a killed/crashed mpv is detected (isAlive drops);
|
||||
* resume() refuses to unpause the fresh idle daemon (throws
|
||||
* PlayerRestartedError) and play() recovers by respawning a fresh
|
||||
* daemon and loading the file.
|
||||
*
|
||||
* All playback runs silent (volume 0). Requires a real mpv on PATH;
|
||||
* tests skip where it is missing.
|
||||
@@ -19,7 +23,10 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { MpvBackend } from "../src/utils/audio-player";
|
||||
import {
|
||||
MpvBackend,
|
||||
PlayerRestartedError,
|
||||
} from "../src/utils/audio-player";
|
||||
|
||||
const SAMPLE_RATE = 22050;
|
||||
const FREQ = 440;
|
||||
@@ -165,6 +172,116 @@ test.skipIf(!hasMpv)(
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
test.skipIf(!hasMpv)(
|
||||
"play() of the already-playing url does NOT reload (no audible skip-back)",
|
||||
async () => {
|
||||
fixtureWavs();
|
||||
const backend = new MpvBackend();
|
||||
try {
|
||||
// Start mid-episode (as a resume would) and let it advance.
|
||||
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
|
||||
await waitFor(
|
||||
"position advances past the start offset",
|
||||
async () => (await backend.getPosition()) > 1.8,
|
||||
);
|
||||
const before = await backend.getPosition();
|
||||
|
||||
// Re-selecting the SAME episode (Enter in a list, key-repeat)
|
||||
// calls play() with the STALE saved progress. The file is
|
||||
// already loaded — this must not reload from that earlier
|
||||
// position, or the listener hears already-played audio again.
|
||||
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
|
||||
|
||||
// A reload would drop the position back to ~1; a correct no-op
|
||||
// keeps advancing from where it was.
|
||||
await waitFor(
|
||||
"playback continues past the pre-play position",
|
||||
async () => (await backend.getPosition()) > before + 0.3,
|
||||
);
|
||||
expect(backend.isPlaying()).toBe(true);
|
||||
// And the position never fell back toward the stale offset.
|
||||
expect(await backend.getPosition()).toBeGreaterThan(1.8);
|
||||
} finally {
|
||||
await cleanup(backend);
|
||||
}
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
test.skipIf(!hasMpv)(
|
||||
"play() of the same url while user-paused resumes at the current position",
|
||||
async () => {
|
||||
fixtureWavs();
|
||||
const backend = new MpvBackend();
|
||||
try {
|
||||
await backend.play(wavB, { volume: 0, speed: 1, startPosition: 1 });
|
||||
await waitFor(
|
||||
"position advances",
|
||||
async () => (await backend.getPosition()) > 2,
|
||||
);
|
||||
await backend.pause();
|
||||
await waitFor(
|
||||
"paused observed",
|
||||
async () => (await backend.getPauseState()) === true,
|
||||
);
|
||||
const pausedAt = await backend.getPosition();
|
||||
|
||||
// Re-selecting the paused episode resumes where it PAUSED — the
|
||||
// stale saved progress must not become a backward seek target.
|
||||
await backend.play(wavB, { volume: 0, speed: 1, startPosition: 1 });
|
||||
expect(backend.isPlaying()).toBe(true);
|
||||
await waitFor(
|
||||
"resumed at the paused position",
|
||||
async () => (await backend.getPosition()) > pausedAt + 0.3,
|
||||
);
|
||||
expect(await backend.getPosition()).toBeGreaterThan(1.5);
|
||||
} finally {
|
||||
await cleanup(backend);
|
||||
}
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
test.skipIf(!hasMpv)(
|
||||
"daemon killed mid-play: resume() rejects on the fresh idle daemon; play() recovers a new one",
|
||||
async () => {
|
||||
fixtureWavs();
|
||||
const backend = new MpvBackend();
|
||||
try {
|
||||
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 0 });
|
||||
await waitFor("playing", () => backend.isPlaying());
|
||||
await waitFor(
|
||||
"position advances",
|
||||
async () => (await backend.getPosition()) > 0.5,
|
||||
);
|
||||
|
||||
// Simulate a crash: SIGKILL the daemon out from under us.
|
||||
const proc = (backend as unknown as { proc: { pid: number } }).proc;
|
||||
expect(proc).toBeTruthy();
|
||||
process.kill(proc.pid, "SIGKILL");
|
||||
await waitFor("death observed", () => !backend.isAlive());
|
||||
|
||||
// resume() must NOT silently no-op on the dead daemon: it
|
||||
// respawns, finds the fresh daemon idle (no file loaded), and
|
||||
// throws — the hook falls back to the full play path.
|
||||
await expect(backend.resume()).rejects.toThrow(PlayerRestartedError);
|
||||
|
||||
// play() (the hook's recovery) reuses the respawned daemon and
|
||||
// plays the file — audio must actually advance again.
|
||||
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 0 });
|
||||
expect(backend.isAlive()).toBe(true);
|
||||
await waitFor(
|
||||
"recovered playback advances",
|
||||
async () =>
|
||||
(await backend.getPosition()) > 0.5 && backend.isPlaying(),
|
||||
);
|
||||
} finally {
|
||||
await cleanup(backend);
|
||||
}
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
test.skipIf(!hasMpv)(
|
||||
"EOF marks playback ended; resume() then replays from the top",
|
||||
async () => {
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
155
tests/audio-queue.test.ts
Normal file
155
tests/audio-queue.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* audio-queue unit tests — pure selection logic for next/prev navigation
|
||||
* and source-based auto-advance. Covers ordering, bounds, and the
|
||||
* deduplication that prevents "next" from replaying the current episode.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
queueForSource,
|
||||
queueIndex,
|
||||
nextStep,
|
||||
prevStep,
|
||||
} from "../src/utils/audio-queue";
|
||||
import { AudioSource } from "../src/stores/audio-nav";
|
||||
import type { Episode } from "../src/types/episode";
|
||||
import type { Feed } from "../src/types/feed";
|
||||
import { FeedVisibility } from "../src/types/feed";
|
||||
import type { SearchResult } from "../src/types/source";
|
||||
|
||||
function ep(id: string, n: number): Episode {
|
||||
return {
|
||||
id,
|
||||
podcastId: "pod-" + id,
|
||||
title: `Episode ${n}`,
|
||||
description: "",
|
||||
audioUrl: `https://example.com/${id}.mp3`,
|
||||
duration: 600,
|
||||
pubDate: new Date(2026, 0, n),
|
||||
};
|
||||
}
|
||||
|
||||
function feed(id: string, episodes: Episode[]): Feed {
|
||||
return {
|
||||
id,
|
||||
podcast: {
|
||||
id,
|
||||
title: "Feed " + id,
|
||||
description: "",
|
||||
feedUrl: `https://example.com/${id}.xml`,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes,
|
||||
visibility: FeedVisibility.PUBLIC,
|
||||
sourceId: "rss",
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
};
|
||||
}
|
||||
|
||||
function episodeResult(episode: Episode): SearchResult {
|
||||
return {
|
||||
sourceId: "itunes",
|
||||
kind: "episode",
|
||||
podcast: {
|
||||
id: episode.podcastId,
|
||||
title: "Show " + episode.podcastId,
|
||||
description: "",
|
||||
feedUrl: `https://example.com/${episode.podcastId}.xml`,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
episode,
|
||||
};
|
||||
}
|
||||
|
||||
const e1 = ep("e1", 1);
|
||||
const e2 = ep("e2", 2);
|
||||
const e3 = ep("e3", 3);
|
||||
|
||||
test("FEED queue is the chronological global list, newest first", () => {
|
||||
const f1 = feed("f1", [e3, e2]);
|
||||
const f2 = feed("f2", [e1]);
|
||||
const queue = queueForSource(
|
||||
AudioSource.FEED,
|
||||
undefined,
|
||||
[f1, f2],
|
||||
[
|
||||
{ episode: e3, feed: f1 },
|
||||
{ episode: e2, feed: f1 },
|
||||
{ episode: e1, feed: f2 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(queue.map((e) => e.id)).toEqual(["e3", "e2", "e1"]);
|
||||
expect(queueIndex(queue, "e2")).toBe(1);
|
||||
expect(nextStep(queue, "e2")?.episode.id).toBe("e1");
|
||||
expect(prevStep(queue, "e2")?.episode.id).toBe("e3");
|
||||
expect(nextStep(queue, "e1")).toBeNull();
|
||||
expect(prevStep(queue, "e3")).toBeNull();
|
||||
});
|
||||
|
||||
test("FEED queue dedupes repeated episode ids (same episode listed twice)", () => {
|
||||
// The same episode appears twice in the global list (e.g. a refresh
|
||||
// merge duplicated a feed's entries). Without dedupe, nextStep after
|
||||
// e2 would step onto e2 AGAIN — replaying the current episode.
|
||||
const f1 = feed("f1", [e3, e2, e2, e1]);
|
||||
const queue = queueForSource(
|
||||
AudioSource.FEED,
|
||||
undefined,
|
||||
[f1],
|
||||
[
|
||||
{ episode: e3, feed: f1 },
|
||||
{ episode: e2, feed: f1 },
|
||||
{ episode: e2, feed: f1 },
|
||||
{ episode: e1, feed: f1 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(queue.map((e) => e.id)).toEqual(["e3", "e2", "e1"]);
|
||||
// Distinct objects sharing an id dedupe too.
|
||||
const e2clone = { ...e2 };
|
||||
const queue2 = queueForSource(
|
||||
AudioSource.FEED,
|
||||
undefined,
|
||||
[f1],
|
||||
[
|
||||
{ episode: e3, feed: f1 },
|
||||
{ episode: e2, feed: f1 },
|
||||
{ episode: e2clone, feed: f1 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(queue2.map((e) => e.id)).toEqual(["e3", "e2"]);
|
||||
expect(nextStep(queue2, "e2")).toBeNull(); // no self-step
|
||||
});
|
||||
|
||||
test("MY_SHOWS queue scopes to the podcast that started playback", () => {
|
||||
const fA = feed("podA", [e3, e2]);
|
||||
const fB = feed("podB", [e1]);
|
||||
const queue = queueForSource(
|
||||
AudioSource.MY_SHOWS,
|
||||
"podA",
|
||||
[fA, fB],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
expect(queue.map((e) => e.id)).toEqual(["e3", "e2"]);
|
||||
// Unknown podcastId → empty queue (nothing to play next).
|
||||
expect(
|
||||
queueForSource(AudioSource.MY_SHOWS, "podX", [fA, fB], [], []),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("SEARCH queue filters to episode-kind results in display order", () => {
|
||||
const queue = queueForSource(
|
||||
AudioSource.SEARCH,
|
||||
undefined,
|
||||
[],
|
||||
[],
|
||||
[episodeResult(e1), episodeResult(e2)],
|
||||
);
|
||||
expect(queue.map((e) => e.id)).toEqual(["e1", "e2"]);
|
||||
expect(queueIndex(queue, "e1")).toBe(0);
|
||||
expect(queueIndex(queue, "e3")).toBe(-1);
|
||||
});
|
||||
197
tests/auto-advance.test.ts
Normal file
197
tests/auto-advance.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* auto-advance.test.ts — "at the end of episodes play the next one, from
|
||||
* the source that started it" feature.
|
||||
*
|
||||
* When a track reaches its natural end (mpv eof-reached), useAudio must
|
||||
* advance to the next episode in the source queue — the current show's
|
||||
* episode list (MY_SHOWS), the Feed's chronological list, or the search
|
||||
* results — and must STOP at the end of the list (no wrap-around). A
|
||||
* crashed/killed daemon must NOT auto-advance (that path is pinned by
|
||||
* external-pause-reconcile.test.ts).
|
||||
*
|
||||
* Integration style (like external-pause-reconcile.test.ts): real stores,
|
||||
* real persistence sandbox, and the REAL mpv backend driven by real audio
|
||||
* files — two short local WAVs served over HTTP, so EOF happens on a
|
||||
* deterministic timer. The show is subscribed through the real feed store's
|
||||
* addFeed() API (no config seeding — works on whatever singleton state this
|
||||
* worker holds), and the audio-nav source is pinned to MY_SHOWS for that
|
||||
* podcast so the queue is scoped and deterministic. Skipped when mpv isn't
|
||||
* installed.
|
||||
*/
|
||||
import { test, expect, afterAll } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const hasMpv = !!Bun.which("mpv");
|
||||
|
||||
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
|
||||
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-autoadv-"));
|
||||
const DATA = mkdtempSync(join(tmpdir(), "podtui-autoadv-data-"));
|
||||
process.env.XDG_CONFIG_HOME = CONFIG;
|
||||
process.env.XDG_DATA_HOME = DATA;
|
||||
process.env.PODTUI_AUDIO_BACKEND = "mpv"; // real backend; EOF is the signal under test
|
||||
|
||||
/** 2s mono 16-bit WAV with a sine tone — short enough to EOF fast,
|
||||
* distinct per episode so playback is unambiguous. */
|
||||
function makeWav(freq: number): Buffer {
|
||||
const SAMPLE_RATE = 44100;
|
||||
const DURATION = 2;
|
||||
const dataLen = SAMPLE_RATE * DURATION;
|
||||
const buf = Buffer.alloc(44 + dataLen * 2);
|
||||
buf.write("RIFF", 0);
|
||||
buf.writeUInt32LE(36 + dataLen * 2, 4);
|
||||
buf.write("WAVE", 8);
|
||||
buf.write("fmt ", 12);
|
||||
buf.writeUInt32LE(16, 16); // fmt chunk size
|
||||
buf.writeUInt16LE(1, 20); // PCM
|
||||
buf.writeUInt16LE(1, 22); // mono
|
||||
buf.writeUInt32LE(SAMPLE_RATE, 24);
|
||||
buf.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate
|
||||
buf.writeUInt16LE(2, 32); // block align
|
||||
buf.writeUInt16LE(16, 34); // bits per sample
|
||||
buf.write("data", 36);
|
||||
buf.writeUInt32LE(dataLen * 2, 40);
|
||||
for (let i = 0; i < dataLen; i++) {
|
||||
const sample = Math.round(
|
||||
Math.sin((2 * Math.PI * freq * i) / SAMPLE_RATE) * 8000,
|
||||
);
|
||||
buf.writeInt16LE(sample, 44 + i * 2);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
const wav1 = makeWav(440);
|
||||
const wav2 = makeWav(880);
|
||||
|
||||
// ── Local HTTP server: the RSS feed + both audio files ────────────────────
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
function feedXml(origin: string): string {
|
||||
// Distinct pubDates so ep1 (newest) is episodes[0], ep2 older — "next"
|
||||
// must step DOWN the list toward the older episode.
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Auto Advance Show</title>
|
||||
<description>auto-advance test feed</description>
|
||||
<item>
|
||||
<title>Episode One</title>
|
||||
<pubDate>2026-08-10T00:00:00Z</pubDate>
|
||||
<enclosure url="${origin}/e1.wav" length="${wav1.length}" type="audio/wav"/>
|
||||
</item>
|
||||
<item>
|
||||
<title>Episode Two</title>
|
||||
<pubDate>2026-08-01T00:00:00Z</pubDate>
|
||||
<enclosure url="${origin}/e2.wav" length="${wav2.length}" type="audio/wav"/>
|
||||
</item>
|
||||
</channel></rss>`;
|
||||
}
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml(url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
}
|
||||
if (url.pathname.endsWith("e1.wav")) {
|
||||
return new Response(wav1.buffer as ArrayBuffer, {
|
||||
headers: { "Content-Type": "audio/wav" },
|
||||
});
|
||||
}
|
||||
if (url.pathname.endsWith("e2.wav")) {
|
||||
return new Response(wav2.buffer as ArrayBuffer, {
|
||||
headers: { "Content-Type": "audio/wav" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
// ── Real modules (loaded after env + server are up) ───────────────────────
|
||||
// @ts-expect-error — bun-only query suffix: distinct module identity that
|
||||
// loads the real file instead of a leaked mock.module from another test file.
|
||||
const { useAudio } = await import("../src/hooks/useAudio?auto-advance-test");
|
||||
const { useFeedStore } = await import("../src/stores/feed");
|
||||
const { useAudioNavStore, AudioSource } = await import(
|
||||
"../src/stores/audio-nav"
|
||||
);
|
||||
|
||||
const feedStore = useFeedStore();
|
||||
const audioNav = useAudioNavStore();
|
||||
|
||||
/** Poll `check` every 25ms until truthy; throw after `timeoutMs`. */
|
||||
async function waitFor(
|
||||
check: () => boolean,
|
||||
timeoutMs = 15000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!check()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("condition not met in time");
|
||||
}
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to the local feed through the real store API; unique podcast id
|
||||
// so the MY_SHOWS queue lookup is deterministic whatever else this worker's
|
||||
// shared feed store holds.
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
|
||||
const PODCAST_ID = `auto-advance-pod-${process.pid}`;
|
||||
const feed = await feedStore.addFeed(
|
||||
{
|
||||
id: PODCAST_ID,
|
||||
title: "Auto Advance Show",
|
||||
description: "auto-advance test feed",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
"test-source",
|
||||
);
|
||||
if (!feed || feed.episodes.length < 2) {
|
||||
throw new Error("test feed did not load two episodes");
|
||||
}
|
||||
const ep1 = feed.episodes[0]; // newest — plays first
|
||||
const ep2 = feed.episodes[1]; // older — must follow automatically
|
||||
if (ep1.title !== "Episode One") {
|
||||
throw new Error("episode order unexpected — ep1 is not the newest");
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
audioNav.reset(); // don't leak nav state into shared-worker tests
|
||||
server?.stop(true);
|
||||
rmSync(CONFIG, { recursive: true, force: true });
|
||||
rmSync(DATA, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.skipIf(!hasMpv)(
|
||||
"episode ending auto-plays the next in the show; the last episode stops",
|
||||
async () => {
|
||||
const audio = useAudio();
|
||||
audioNav.setSource(AudioSource.MY_SHOWS, PODCAST_ID);
|
||||
|
||||
// Start the newest episode.
|
||||
await audio.play(ep1);
|
||||
expect(audio.isPlaying()).toBe(true);
|
||||
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
||||
|
||||
// EOF → the next (older) episode starts automatically, and the nav
|
||||
// index moves with it.
|
||||
await waitFor(
|
||||
() =>
|
||||
audio.currentEpisode()?.id === ep2.id && audio.isPlaying(),
|
||||
);
|
||||
expect(audioNav.getCurrentIndex()).toBe(1);
|
||||
|
||||
// The last episode ends → playback stops; no wrap-around to ep1.
|
||||
await waitFor(() => !audio.isPlaying());
|
||||
expect(audio.currentEpisode()?.id).toBe(ep2.id);
|
||||
await Bun.sleep(600); // give any (wrong) auto-advance time to fire
|
||||
expect(audio.currentEpisode()?.id).toBe(ep2.id);
|
||||
expect(audio.isPlaying()).toBe(false);
|
||||
|
||||
await audio.stop();
|
||||
},
|
||||
{ timeout: 45000 },
|
||||
);
|
||||
337
tests/discover-episode-preview.test.tsx
Normal file
337
tests/discover-episode-preview.test.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* discover-episode-preview.test.tsx — Discover: `l`/right/enter on a podcast
|
||||
* result must OPEN the show's episode list, NOT subscribe.
|
||||
*
|
||||
* Regression: `open` on a Discover podcast result (bound to `l`/right via
|
||||
* `swipe-next`, and to enter) used to toggle subscription — pressing `l` on a
|
||||
* show you wanted to browse subscribed it instead. `l`/right/enter now drill
|
||||
* into a fetched-on-demand episode list (depth 2, no subscription), and `a`
|
||||
* (the app-wide `subscribe` action) is the dedicated subscribe key.
|
||||
*
|
||||
* Mounts the real app (sandboxed, silent audio, mocked discover store) and
|
||||
* drives the Discover tab with the test renderer's mock keys: drill category
|
||||
* → podcast, `l` opens the episode list WITHOUT subscribing (feed store
|
||||
* untouched, subscribe not called); `h` pops back; `a` subscribes the
|
||||
* focused show; `l` then re-opens the episodes.
|
||||
*
|
||||
* App modules are loaded dynamically (never statically) because the sandbox
|
||||
* config/data dirs must be set BEFORE they evaluate — their module-level init
|
||||
* reads those env vars at import time.
|
||||
*/
|
||||
|
||||
import { test, expect, afterAll, beforeAll, mock } from "bun:test";
|
||||
import { testRender } from "@opentui/solid";
|
||||
import { createSignal } from "solid-js";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { AudioControls } from "../src/hooks/useAudio";
|
||||
import type { Episode } from "../src/types/episode";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
import type { DepthFrame, NavigationState } from "../src/context/navigation-store";
|
||||
|
||||
// Recording audio stub: `play` pushes what was streamed. Registered FIRST so
|
||||
// a leaked partial useAudio mock from another file in this worker can't break
|
||||
// the app mount (see tests/search-focus.test.tsx for the same hazard).
|
||||
const played: Episode[] = [];
|
||||
const stubAudio: AudioControls = {
|
||||
isPlaying: () => false,
|
||||
position: () => 0,
|
||||
duration: () => 0,
|
||||
volume: () => 1,
|
||||
speed: () => 1,
|
||||
backendName: () => "none",
|
||||
error: () => null,
|
||||
currentEpisode: () => null,
|
||||
availablePlayers: () => [],
|
||||
play: async (episode: Episode) => {
|
||||
played.push(episode);
|
||||
},
|
||||
load: async () => {},
|
||||
pause: async () => {},
|
||||
resume: async () => {},
|
||||
togglePlayback: async () => {},
|
||||
stop: async () => {},
|
||||
seek: async () => {},
|
||||
seekRelative: async () => {},
|
||||
setVolume: async () => {},
|
||||
setSpeed: async () => {},
|
||||
switchBackend: async () => {},
|
||||
prev: async () => {},
|
||||
next: async () => {},
|
||||
};
|
||||
mock.module("../src/hooks/useAudio", () => ({
|
||||
useAudio: () => stubAudio,
|
||||
}));
|
||||
|
||||
// Deterministic discover store: `openEpisodes` seeds the episode list
|
||||
// synchronously (no network), `subscribe`/`unsubscribe` flip the show's flag
|
||||
// and are recorded so the test can assert l/enter never subscribed.
|
||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
|
||||
const [preview, setPreview] = createSignal<Record<string, Episode[]>>({});
|
||||
const [previewLoading, setPreviewLoading] = createSignal<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [previewErrors, setPreviewErrors] = createSignal<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const subscribeCalls: string[] = [];
|
||||
const openCalls: string[] = [];
|
||||
const flip = (id: string, subscribed: boolean) =>
|
||||
setPodcasts((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, isSubscribed: subscribed } : p)),
|
||||
);
|
||||
const mockDiscoverStore = {
|
||||
selectedCategory,
|
||||
isLoading,
|
||||
podcasts,
|
||||
categories: [
|
||||
{ id: "all", name: "All", icon: "" },
|
||||
{ id: "technology", name: "Technology", icon: "" },
|
||||
],
|
||||
filteredPodcasts: () => {
|
||||
const cat = selectedCategory();
|
||||
if (cat === "all") return podcasts();
|
||||
return podcasts().filter((p) =>
|
||||
(p.categories ?? []).some((c) =>
|
||||
c.toLowerCase().includes(cat.toLowerCase()),
|
||||
),
|
||||
);
|
||||
},
|
||||
setSelectedCategory,
|
||||
subscribe: (id: string) => {
|
||||
subscribeCalls.push(id);
|
||||
flip(id, true);
|
||||
},
|
||||
unsubscribe: (id: string) => {
|
||||
flip(id, false);
|
||||
},
|
||||
refresh: async () => {},
|
||||
episodesForPodcast: (id: string) => preview()[id] ?? [],
|
||||
isLoadingEpisodesFor: (id: string) => previewLoading().has(id),
|
||||
previewError: (id: string) => previewErrors()[id],
|
||||
openEpisodes: async (pod: Podcast) => {
|
||||
openCalls.push(pod.id);
|
||||
setPreview((prev) => ({
|
||||
...prev,
|
||||
[pod.id]: [makeEpisode(1), makeEpisode(2)],
|
||||
}));
|
||||
},
|
||||
refreshEpisodes: async () => {},
|
||||
};
|
||||
mock.module("../src/stores/discover", () => ({
|
||||
DISCOVER_CATEGORIES: mockDiscoverStore.categories,
|
||||
useDiscoverStore: () => mockDiscoverStore,
|
||||
}));
|
||||
|
||||
// Sandbox BEFORE any app module evaluates — config-dir/persistence read these
|
||||
// env vars at import time, so the app modules are loaded dynamically.
|
||||
const SANDBOX = join(process.cwd(), ".harness", "test-discover-preview");
|
||||
mkdirSync(join(SANDBOX, "config-home"), { recursive: true });
|
||||
mkdirSync(join(SANDBOX, "data-home"), { recursive: true });
|
||||
process.env.XDG_CONFIG_HOME = join(SANDBOX, "config-home");
|
||||
process.env.XDG_DATA_HOME = join(SANDBOX, "data-home");
|
||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||
|
||||
const { App } = await import("../src/App");
|
||||
const { ThemeProvider } = await import("../src/context/ThemeContext");
|
||||
const toast = await import("../src/ui/toast");
|
||||
const { KeybindProvider, useKeybinds } = await import(
|
||||
"../src/context/KeybindContext"
|
||||
);
|
||||
const { NavigationProvider, useNavigation } = await import(
|
||||
"../src/context/NavigationContext"
|
||||
);
|
||||
const { DialogProvider } = await import("../src/ui/dialog");
|
||||
const { CommandProvider } = await import("../src/ui/command");
|
||||
const { TABS } = await import("../src/utils/navigation");
|
||||
const { useFeedStore } = await import("../src/stores/feed");
|
||||
|
||||
function makePodcast(): Podcast {
|
||||
return {
|
||||
id: "featured-show",
|
||||
title: "Featured Show",
|
||||
description: "A featured show.",
|
||||
feedUrl: "https://example.test/featured.xml",
|
||||
author: "tester",
|
||||
categories: ["Technology"],
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEpisode(n: number): Episode {
|
||||
return {
|
||||
id: `featured-ep-${n}`,
|
||||
podcastId: "featured-show",
|
||||
title: `Featured Episode ${n}`,
|
||||
description: "",
|
||||
audioUrl: "https://example.test/ep.mp3",
|
||||
duration: 0,
|
||||
pubDate: new Date(`2026-08-0${n}T00:00:00Z`),
|
||||
};
|
||||
}
|
||||
|
||||
type MockInput = { pressKey: (key: string) => void; pressEnter: () => void };
|
||||
type Mounted = {
|
||||
renderer: { destroy: () => void };
|
||||
renderOnce: () => Promise<void>;
|
||||
mockInput: MockInput;
|
||||
nav: () => NavigationState;
|
||||
keybindsReady: () => boolean;
|
||||
};
|
||||
|
||||
async function mountApp(): Promise<Mounted> {
|
||||
let navRef: NavigationState | null = null;
|
||||
let keybindsRef: { ready: boolean } | null = null;
|
||||
const StateProbe = () => {
|
||||
navRef = useNavigation();
|
||||
keybindsRef = useKeybinds();
|
||||
return null;
|
||||
};
|
||||
const HarnessRoot = () => (
|
||||
<toast.ToastProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
<KeybindProvider>
|
||||
<NavigationProvider>
|
||||
<StateProbe />
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<App />
|
||||
<toast.Toast />
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</NavigationProvider>
|
||||
</KeybindProvider>
|
||||
</ThemeProvider>
|
||||
</toast.ToastProvider>
|
||||
);
|
||||
const setup = await testRender(() => <HarnessRoot />, {
|
||||
width: 100,
|
||||
height: 30,
|
||||
useThread: false,
|
||||
});
|
||||
// The test renderer intercepts stdout; the app is a TUI that writes frames
|
||||
// asynchronously, so silence that interception (same as search-focus).
|
||||
(
|
||||
setup.renderer as unknown as {
|
||||
disableStdoutInterception?: () => void;
|
||||
}
|
||||
).disableStdoutInterception?.();
|
||||
await setup.renderOnce();
|
||||
await sleep(60);
|
||||
return {
|
||||
renderer: setup.renderer,
|
||||
renderOnce: setup.renderOnce,
|
||||
mockInput: setup.mockInput,
|
||||
nav: () => navRef!,
|
||||
keybindsReady: () => keybindsRef?.ready ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
setTimeout(resolve, ms);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function settleReady(m: Mounted): Promise<void> {
|
||||
for (let i = 0; i < 80; i++) {
|
||||
await m.renderOnce();
|
||||
await sleep(60);
|
||||
if (m.keybindsReady()) return;
|
||||
}
|
||||
throw new Error("keybinds never became ready");
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
m: Mounted,
|
||||
cond: () => boolean,
|
||||
what: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (cond()) return;
|
||||
await m.renderOnce();
|
||||
await sleep(25);
|
||||
}
|
||||
throw new Error(`timed out waiting for: ${what}`);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
setPodcasts([makePodcast()]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(SANDBOX, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("l on a podcast opens its episode list without subscribing; a subscribes", async () => {
|
||||
const m = await mountApp();
|
||||
try {
|
||||
await settleReady(m);
|
||||
|
||||
// Open the Discover tab (digit press retried until the router attaches).
|
||||
for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.DISCOVER; i++) {
|
||||
m.mockInput.pressKey("3");
|
||||
await m.renderOnce();
|
||||
await sleep(40);
|
||||
}
|
||||
expect(m.nav().activeTab()).toBe(TABS.DISCOVER);
|
||||
m.mockInput.pressEnter(); // open the tab's content (category depth)
|
||||
await waitFor(
|
||||
m,
|
||||
() => m.nav().currentDepth() === 0 && !m.nav().atRootTab(),
|
||||
"discover content mounted",
|
||||
);
|
||||
|
||||
// l on the focused category drills to the podcast results (depth 1).
|
||||
m.mockInput.pressKey("l");
|
||||
await waitFor(m, () => m.nav().currentDepth() === 1, "results depth");
|
||||
expect(m.nav().topFrame()?.kind).toBe("results");
|
||||
|
||||
// l on the focused podcast opens its episode list (depth 2) — the
|
||||
// show must NOT be subscribed, the feed store untouched.
|
||||
m.mockInput.pressKey("l");
|
||||
await waitFor(m, () => m.nav().currentDepth() === 2, "episodes depth");
|
||||
expect(m.nav().topFrame()?.kind).toBe("episodes");
|
||||
expect(m.nav().topFrame()?.ctx).toBe("featured-show");
|
||||
expect(openCalls).toEqual(["featured-show"]);
|
||||
expect(subscribeCalls).toHaveLength(0);
|
||||
expect(played).toHaveLength(0);
|
||||
expect(
|
||||
useFeedStore()
|
||||
.feeds()
|
||||
.some((f) => f.podcast.id === "featured-show"),
|
||||
).toBe(false);
|
||||
// The seeded episode list is what the page renders at depth 2.
|
||||
expect(mockDiscoverStore.episodesForPodcast("featured-show")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
|
||||
// h pops back to the results (depth 1).
|
||||
m.mockInput.pressKey("h");
|
||||
await waitFor(m, () => m.nav().currentDepth() === 1, "back to results");
|
||||
|
||||
// a subscribes the focused show (the dedicated subscribe key).
|
||||
m.mockInput.pressKey("a");
|
||||
await waitFor(
|
||||
m,
|
||||
() => mockDiscoverStore.podcasts()[0]?.isSubscribed === true,
|
||||
"a subscribes the show",
|
||||
);
|
||||
expect(subscribeCalls).toEqual(["featured-show"]);
|
||||
|
||||
// l still opens the episode list for a subscribed show (no toggle).
|
||||
m.mockInput.pressKey("l");
|
||||
await waitFor(m, () => m.nav().currentDepth() === 2, "episodes re-opened");
|
||||
expect(subscribeCalls).toEqual(["featured-show"]);
|
||||
expect(mockDiscoverStore.episodesForPodcast("featured-show")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
} finally {
|
||||
m.renderer.destroy();
|
||||
}
|
||||
});
|
||||
184
tests/discover-store-preview.test.ts
Normal file
184
tests/discover-store-preview.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* discover-store-preview.test.ts — the Discover episode-preview store API.
|
||||
*
|
||||
* `openEpisodes` fetches a show's RSS feed WITHOUT subscribing (drill-in from
|
||||
* a podcast result), caches it per podcast id for the session, records a
|
||||
* per-show error on failure, and never refetches while cached or in flight.
|
||||
* `refreshEpisodes` clears the cache/error and refetches.
|
||||
*
|
||||
* The REAL feed store runs against a local RSS server. No `mock.module`:
|
||||
* bun test reuses workers across files and module mocks leak into the shared
|
||||
* registry, so a feed-store mock here (whose stub lacks addFeed/refreshFeed/
|
||||
* isLoadingFeeds) breaks every later file that shares a worker — the suite's
|
||||
* documented failure mode. The repo's defense is importing the REAL modules
|
||||
* via a query-suffixed specifier, which `mock.module` does not intercept.
|
||||
*/
|
||||
|
||||
import { test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||
// (their module-level init reads it).
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-discprev-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
// Query-suffixed module identity: loads the REAL discover store even when a
|
||||
// sibling file's `mock.module("../src/stores/discover")` leaked into this
|
||||
// worker. Its internal `./feed` import resolves the real feed store, which
|
||||
// no file mocks anymore.
|
||||
// @ts-expect-error — bun-only query suffix: distinct module identity that
|
||||
// loads the real file instead of a leaked mock.module from another test file.
|
||||
const { useDiscoverStore } = await import("../src/stores/discover?discover-store-preview");
|
||||
|
||||
interface ServedEpisode {
|
||||
title: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
/** Pathnames the local server has served, in order (fetch tracking). */
|
||||
const requests: string[] = [];
|
||||
/** Per-path episode lists served by the local server. */
|
||||
const served: Record<string, ServedEpisode[]> = {};
|
||||
/** When set, responses for this path wait on the release callback. */
|
||||
let gatePath: string | null = null;
|
||||
let releaseGate: (() => void) | null = null;
|
||||
|
||||
/** XML for one show's episode list (ids derive from enclosure URLs). */
|
||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||
const items = episodes
|
||||
.map(
|
||||
(ep, i) => `<item>
|
||||
<title>${ep.title}</title>
|
||||
<pubDate>${ep.date}</pubDate>
|
||||
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Test Show</title>
|
||||
<description>Discover preview test feed</description>
|
||||
${items}
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
let server: Bun.Server<undefined> | null = null;
|
||||
let origin = "";
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
requests.push(url.pathname);
|
||||
if (gatePath && url.pathname === gatePath) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseGate = resolve;
|
||||
});
|
||||
}
|
||||
// A permanently-failing feed (simulates a show that went down).
|
||||
if (url.pathname === "/fail.xml") {
|
||||
return new Response("feed unavailable", { status: 503 });
|
||||
}
|
||||
const eps = served[url.pathname];
|
||||
if (!eps) return new Response("not found", { status: 404 });
|
||||
return new Response(feedXml(eps, url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
},
|
||||
});
|
||||
origin = `http://127.0.0.1:${server.port}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server?.stop(true);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makePodcast(overrides: Partial<Podcast> = {}): Podcast {
|
||||
return {
|
||||
id: "show-1",
|
||||
title: "Show 1",
|
||||
description: "",
|
||||
feedUrl: "https://example.test/feed.xml",
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("openEpisodes fetches, caches, and never refetches on cache hit or in flight", async () => {
|
||||
const store = useDiscoverStore();
|
||||
const pod = makePodcast({ feedUrl: `${origin}/show1.xml` });
|
||||
served["/show1.xml"] = [{ title: "Ep 1", date: "2026-08-10T00:00:00Z" }];
|
||||
|
||||
expect(store.episodesForPodcast(pod.id)).toHaveLength(0);
|
||||
|
||||
await store.openEpisodes(pod);
|
||||
expect(requests).toEqual(["/show1.xml"]);
|
||||
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
|
||||
expect(store.episodesForPodcast(pod.id)[0].title).toBe("Ep 1");
|
||||
expect(store.isLoadingEpisodesFor(pod.id)).toBe(false);
|
||||
expect(store.previewError(pod.id)).toBeUndefined();
|
||||
|
||||
// Cache hit: second open must not refetch.
|
||||
await store.openEpisodes(pod);
|
||||
expect(requests).toEqual(["/show1.xml"]);
|
||||
|
||||
// In-flight guard: a concurrent open during loading must not refetch.
|
||||
// The server holds this show's response until the gate is released.
|
||||
const pod2 = makePodcast({ id: "show-2", feedUrl: `${origin}/slow.xml` });
|
||||
served["/slow.xml"] = [{ title: "Ep 2", date: "2026-08-09T00:00:00Z" }];
|
||||
gatePath = "/slow.xml";
|
||||
const pending = store.openEpisodes(pod2);
|
||||
// Loading is set synchronously before the fetch resolves.
|
||||
expect(store.isLoadingEpisodesFor(pod2.id)).toBe(true);
|
||||
await store.openEpisodes(pod2); // must early-return, not queue a second fetch
|
||||
// The request is held by the server gate; wait until it was actually
|
||||
// received so the assertion isn't racing the network.
|
||||
const deadline = Date.now() + 1000;
|
||||
while (requests.length < 2 && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
expect(requests).toEqual(["/show1.xml", "/slow.xml"]);
|
||||
releaseGate?.();
|
||||
gatePath = null;
|
||||
await pending;
|
||||
expect(store.episodesForPodcast(pod2.id)[0].title).toBe("Ep 2");
|
||||
expect(store.isLoadingEpisodesFor(pod2.id)).toBe(false);
|
||||
});
|
||||
|
||||
test("openEpisodes records an error for feedless shows and failed fetches", async () => {
|
||||
const store = useDiscoverStore();
|
||||
const feedless = makePodcast({ id: "show-3", feedUrl: undefined });
|
||||
|
||||
await store.openEpisodes(feedless);
|
||||
expect(requests).not.toContain(feedless.id);
|
||||
expect(store.previewError(feedless.id)).toBe("No RSS feed listed for this show.");
|
||||
expect(store.episodesForPodcast(feedless.id)).toHaveLength(0);
|
||||
|
||||
// Failed fetch (server 503) → error recorded, nothing cached.
|
||||
const failing = makePodcast({ id: "show-4", feedUrl: `${origin}/fail.xml` });
|
||||
await store.openEpisodes(failing);
|
||||
expect(store.previewError(failing.id)).toBe("Couldn't load episodes.");
|
||||
expect(store.episodesForPodcast(failing.id)).toHaveLength(0);
|
||||
expect(store.isLoadingEpisodesFor(failing.id)).toBe(false);
|
||||
});
|
||||
|
||||
test("refreshEpisodes clears the cache and error, then refetches", async () => {
|
||||
const store = useDiscoverStore();
|
||||
const pod = makePodcast({ id: "show-5", feedUrl: `${origin}/show5.xml` });
|
||||
served["/show5.xml"] = [{ title: "Ep 1", date: "2026-08-10T00:00:00Z" }];
|
||||
|
||||
await store.openEpisodes(pod);
|
||||
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
|
||||
const callsBefore = requests.length;
|
||||
|
||||
await store.refreshEpisodes(pod);
|
||||
expect(requests.length).toBe(callsBefore + 1);
|
||||
expect(store.episodesForPodcast(pod.id)).toHaveLength(1);
|
||||
expect(store.previewError(pod.id)).toBeUndefined();
|
||||
});
|
||||
@@ -16,6 +16,11 @@
|
||||
* property the same way the OS does and asserts useAudio reconciles in
|
||||
* both directions. Skipped when mpv isn't installed.
|
||||
*
|
||||
* Also covers daemon crash recovery: killing mpv out from under the app
|
||||
* must drop the UI out of "playing" (finalizeTrackEnd), and the next Play
|
||||
* press must respawn a fresh daemon and resume audio from the saved
|
||||
* position — the play button may never silently no-op on a dead player.
|
||||
*
|
||||
* Real-timer note: the reconcile path runs on useAudio's real 150ms poll
|
||||
* interval against a real mpv process, with no injectable clock — the
|
||||
* deliberate-exception case from the no-real-timers rule (same as
|
||||
@@ -190,6 +195,27 @@ async function waitFor(
|
||||
}
|
||||
}
|
||||
|
||||
/** SIGKILL the backend's mpv daemon — a crash/kill out from under the app.
|
||||
* The mpv command line carries the IPC socket path, so pgrep finds it by
|
||||
* that (the socket name is unique to this test process). */
|
||||
async function killMpvDaemon(): Promise<void> {
|
||||
const socket = mpvSocket();
|
||||
if (!socket) throw new Error("backend mpv socket not found");
|
||||
const pids = (await Bun.$`pgrep -f ${socket}`.quiet().text())
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
.map(Number);
|
||||
expect(pids.length).toBeGreaterThan(0);
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const episode = {
|
||||
id: "ep1",
|
||||
podcastId: "pod1",
|
||||
@@ -235,6 +261,42 @@ test.skipIf(!hasMpv)(
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
test.skipIf(!hasMpv)(
|
||||
"mpv killed mid-play: UI drops out of playing; pressing play recovers a fresh daemon",
|
||||
async () => {
|
||||
const audio = useAudio();
|
||||
await audio.play(episode);
|
||||
// Instant assertion: play() sets isPlaying synchronously when it
|
||||
// succeeded. (In a shared worker that leaked a store mock from
|
||||
// another test file, play() fails and this catches it at 0ms
|
||||
// instead of burning the waitFor timeout below.)
|
||||
expect(audio.isPlaying()).toBe(true);
|
||||
// Let the clock advance past the 5s progress-save floor so recovery
|
||||
// has a saved position to resume from (positions <5s are not stored).
|
||||
await waitFor(() => audio.position() > 6);
|
||||
const crashPos = audio.position();
|
||||
|
||||
// Crash the player out from under the app.
|
||||
await killMpvDaemon();
|
||||
await waitFor(() => !audio.isPlaying());
|
||||
expect(audio.isPlaying()).toBe(false);
|
||||
// The episode stays current — recovery can restart it.
|
||||
expect(audio.currentEpisode()?.id).toBe("ep1");
|
||||
|
||||
// Press play: must respawn mpv and resume from the saved position —
|
||||
// not silently flip the UI to "playing" with no process behind it.
|
||||
await audio.togglePlayback();
|
||||
expect(audio.isPlaying()).toBe(true);
|
||||
expect(audio.position()).toBeGreaterThanOrEqual(crashPos - 0.5);
|
||||
// Audio actually advances again — proof a fresh daemon is playing.
|
||||
await waitFor(() => audio.position() > crashPos + 0.5);
|
||||
|
||||
await audio.stop();
|
||||
expect(audio.isPlaying()).toBe(false);
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
// ── Teardown ──────────────────────────────────────────────────────────────
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -62,7 +62,7 @@ const addedFeedIds: string[] = [];
|
||||
/** Feed created by the debounce test, reused by the flushPendingSave test. */
|
||||
let debounceFeedId = "";
|
||||
|
||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||
/** XML for the current served episode list (episode ids derive from enclosure URLs). */
|
||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||
const items = episodes
|
||||
.map(
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
* row in a drilled show's episode list (My Shows depth 1) and the Feed
|
||||
* page's row.
|
||||
*
|
||||
* addFeed caches every episode inside the lifecycle window (the last
|
||||
* EPISODE_WINDOW_DAYS days — the date bound, not a count) while exposing
|
||||
* only the first MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes`
|
||||
* reports when the cache holds more than the loaded window;
|
||||
* `loadMoreEpisodes` advances that window in MAX_EPISODES_REFRESH (50)
|
||||
* chunks until it is exhausted. This pins:
|
||||
* Runs in COUNT cache mode: `loadMoreEpisodes` advances the loaded window in
|
||||
* fixed MAX_EPISODES_REFRESH (50) chunks until the cache is exhausted.
|
||||
* (Date-mode fetch-more steps by a two-week window instead — that contract
|
||||
* is pinned in feed-volatile-merge.test.ts.) This pins:
|
||||
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
|
||||
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
|
||||
* needed) and hasMoreEpisodes flips false once the window reaches the end.
|
||||
@@ -26,6 +24,7 @@ const configHome = mkdtempSync(join(tmpdir(), "podtui-pagination-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
import { useFeedStore } from "../src/stores/feed";
|
||||
import { useAppStore } from "../src/stores/app";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
const HOUR = 3600 * 1000;
|
||||
@@ -43,7 +42,7 @@ let servedEpisodes: ServedEpisode[] = [];
|
||||
// store (execution order between files is not guaranteed).
|
||||
const addedFeedIds: string[] = [];
|
||||
|
||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||
/** XML for the current served episode list (episode ids derive from enclosure URLs). */
|
||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||
const items = episodes
|
||||
.map(
|
||||
@@ -72,7 +71,15 @@ const makePodcast = (feedUrl: string): Podcast => ({
|
||||
isSubscribed: true,
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
// The app store loads its persisted prefs asynchronously at import; wait
|
||||
// for that so our count-mode override isn't clobbered by the load.
|
||||
await useAppStore().whenReady();
|
||||
// Chunk-based stepping is count-mode behavior (see header comment).
|
||||
useAppStore().updatePreferences({
|
||||
episodeCacheMode: "count",
|
||||
episodeCacheCount: 25,
|
||||
});
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
@@ -89,6 +96,7 @@ beforeAll(() => {
|
||||
|
||||
afterAll(() => {
|
||||
// Leave the shared singleton as we found it (see addedFeedIds note).
|
||||
useAppStore().updatePreferences({ episodeCacheMode: "date" });
|
||||
const store = useFeedStore();
|
||||
for (const id of addedFeedIds) store.removeFeed(id);
|
||||
server?.stop(true);
|
||||
|
||||
230
tests/feed-refresh-effect.test.ts
Normal file
230
tests/feed-refresh-effect.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Feed-refresh Effect program tests (src/effects/feed-refresh.ts).
|
||||
*
|
||||
* These test the Effect program in isolation — no store singleton, no
|
||||
* network, no fake timers. The fetch/apply closures are injected, and the
|
||||
* `Clock` service comes from TestContext's TestClock, so timeouts are driven
|
||||
* deterministically with TestClock.adjust instead of real 20s waits.
|
||||
*
|
||||
* Contracts pinned here (mirrored at the store level by
|
||||
* feed-nonblocking.test.ts / feed-refresh.test.ts against a real Bun.serve):
|
||||
* 1. Bounded concurrency — never more than `concurrency` fetches in
|
||||
* flight, and the pool pulls the next feed as one completes.
|
||||
* 2. Per-feed apply as its own fetch lands (no barrier).
|
||||
* 3. A timed-out fetch leaves that feed untouched and does not stall the
|
||||
* batch (TestClock.adjust fires the timeout deterministically).
|
||||
* 4. A rejecting fetch leaves that feed untouched and does not fail the
|
||||
* batch.
|
||||
*/
|
||||
|
||||
import { test, expect } from "bun:test"
|
||||
import { Duration, Effect, Fiber, TestClock, TestContext } from "effect"
|
||||
import {
|
||||
refreshFeedsBatch,
|
||||
type RefreshFetchResult,
|
||||
} from "../src/effects/feed-refresh"
|
||||
import type { Feed } from "../src/types/feed"
|
||||
import type { Podcast } from "../src/types/podcast"
|
||||
import type { Episode } from "../src/types/episode"
|
||||
|
||||
const makePodcast = (id: string): Podcast => ({
|
||||
id,
|
||||
title: `Show ${id}`,
|
||||
description: `Show ${id} description`,
|
||||
feedUrl: `http://example.com/${id}.xml`,
|
||||
lastUpdated: new Date(0),
|
||||
isSubscribed: true,
|
||||
})
|
||||
|
||||
const makeFeed = (id: string): Feed => ({
|
||||
id,
|
||||
podcast: makePodcast(id),
|
||||
episodes: [],
|
||||
visibility: "public" as Feed["visibility"],
|
||||
sourceId: "test",
|
||||
lastUpdated: new Date(0),
|
||||
isPinned: false,
|
||||
})
|
||||
|
||||
const makeEpisode = (id: string): Episode => ({
|
||||
id,
|
||||
podcastId: "pod",
|
||||
title: `Ep ${id}`,
|
||||
description: "",
|
||||
audioUrl: `https://example.com/${id}.mp3`,
|
||||
duration: 60,
|
||||
pubDate: new Date(0),
|
||||
})
|
||||
|
||||
/** Resolve an episode result without dragging in the full RSS shape. */
|
||||
const ok = (episodeIds: string[]): RefreshFetchResult => ({
|
||||
episodes: episodeIds.map(makeEpisode),
|
||||
coverUrl: undefined,
|
||||
})
|
||||
|
||||
/** One macrotask turn — lets microtask-scheduled Effect fibers run. */
|
||||
const tick = (): Promise<void> => {
|
||||
const { promise, resolve } = Promise.withResolvers<void>()
|
||||
setImmediate(resolve)
|
||||
return promise
|
||||
}
|
||||
|
||||
/** A resolvable fetch gate: the pool parks on `promise` until the test
|
||||
* resolves it. (Promise.withResolvers's return type is not in tsconfig's
|
||||
* ES2015.Promise lib, hence the explicit shape.) */
|
||||
interface Gate {
|
||||
promise: Promise<RefreshFetchResult>
|
||||
resolve: (value: RefreshFetchResult) => void
|
||||
}
|
||||
|
||||
/** Poll `cond` across up to `iterations` event-loop turns. */
|
||||
async function pollUntil(
|
||||
cond: () => boolean,
|
||||
iterations = 500,
|
||||
): Promise<boolean> {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
if (cond()) return true
|
||||
await tick()
|
||||
}
|
||||
return cond()
|
||||
}
|
||||
|
||||
test("bounds in-flight fetches to the configured concurrency", async () => {
|
||||
const feeds = Array.from({ length: 10 }, (_, i) => makeFeed(`feed-${i}`))
|
||||
let inFlight = 0
|
||||
let maxInFlight = 0
|
||||
const gates: Gate[] = []
|
||||
const applied: string[] = []
|
||||
|
||||
const program = refreshFeedsBatch(
|
||||
feeds,
|
||||
(feed) => {
|
||||
inFlight++
|
||||
if (inFlight > maxInFlight) maxInFlight = inFlight
|
||||
const gate = Promise.withResolvers<RefreshFetchResult>()
|
||||
gates.push(gate)
|
||||
return gate.promise.finally(() => {
|
||||
inFlight--
|
||||
})
|
||||
},
|
||||
(feed) => {
|
||||
applied.push(feed.id)
|
||||
},
|
||||
{ concurrency: 4, timeoutMs: 60_000 },
|
||||
)
|
||||
|
||||
// Run the batch in flight (NOT awaited) and observe the pool from
|
||||
// outside via the gate side effects.
|
||||
const done = Effect.runPromise(program)
|
||||
// The pool starts exactly `concurrency` fetches up front.
|
||||
const sawStart = await pollUntil(() => gates.length >= 4)
|
||||
expect(sawStart).toBe(true)
|
||||
expect(maxInFlight).toBe(4)
|
||||
expect(gates.length).toBe(4)
|
||||
|
||||
// Resolve one gate: the pool pulls the next feed, still bounded at 4.
|
||||
gates[0].resolve(ok(["a"]))
|
||||
const sawPull = await pollUntil(() => gates.length >= 5)
|
||||
expect(sawPull).toBe(true)
|
||||
expect(maxInFlight).toBeLessThanOrEqual(4)
|
||||
|
||||
// Release everything, re-draining as the pool pulls new gates, until
|
||||
// every feed has been fetched and applied.
|
||||
while (applied.length < 10) {
|
||||
for (const gate of gates.splice(0)) gate.resolve(ok(["x"]))
|
||||
await tick()
|
||||
}
|
||||
await done
|
||||
expect(maxInFlight).toBeLessThanOrEqual(4)
|
||||
expect(applied).toHaveLength(10)
|
||||
})
|
||||
|
||||
test("applies each feed as its own fetch lands (no barrier)", async () => {
|
||||
const a = makeFeed("a")
|
||||
const b = makeFeed("b")
|
||||
const applied: string[] = []
|
||||
let bCalled = false
|
||||
const gateB = Promise.withResolvers<RefreshFetchResult>()
|
||||
|
||||
const program = refreshFeedsBatch(
|
||||
[a, b],
|
||||
(feed) => {
|
||||
if (feed.id === "a") return Promise.resolve(ok(["a-1"]))
|
||||
bCalled = true
|
||||
return gateB.promise
|
||||
},
|
||||
(feed) => {
|
||||
applied.push(feed.id)
|
||||
},
|
||||
{ concurrency: 4, timeoutMs: 60_000 },
|
||||
)
|
||||
|
||||
// Run the batch in flight; A's fetch resolves and applies while B's is
|
||||
// still parked at the gate.
|
||||
const done = Effect.runPromise(program)
|
||||
const aApplied = await pollUntil(() => applied.includes("a"))
|
||||
expect(aApplied).toBe(true)
|
||||
expect(bCalled).toBe(true)
|
||||
expect(applied).toEqual(["a"])
|
||||
expect(applied).not.toContain("b")
|
||||
|
||||
gateB.resolve(ok(["b-1"]))
|
||||
await done
|
||||
expect(applied).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("a timed-out fetch leaves that feed untouched, without stalling the batch", async () => {
|
||||
const fast = makeFeed("fast")
|
||||
const hung = makeFeed("hung")
|
||||
const applied: string[] = []
|
||||
// A promise that never settles — the fetch hangs past the timeout.
|
||||
const never = new Promise<RefreshFetchResult>(() => {})
|
||||
|
||||
const program = refreshFeedsBatch(
|
||||
[fast, hung],
|
||||
(feed) =>
|
||||
feed.id === "fast"
|
||||
? Promise.resolve(ok(["f-1"]))
|
||||
: never,
|
||||
(feed) => {
|
||||
applied.push(feed.id)
|
||||
},
|
||||
{ concurrency: 4, timeoutMs: 5_000 },
|
||||
)
|
||||
|
||||
const timed = Effect.gen(function* () {
|
||||
const fiber = yield* Effect.fork(program)
|
||||
// Advance the TestClock past the timeout: the hung fetch's
|
||||
// Effect.timeout fires deterministically — no real 5s wait.
|
||||
yield* TestClock.adjust(Duration.millis(5_000))
|
||||
yield* Fiber.join(fiber)
|
||||
})
|
||||
await Effect.runPromise(
|
||||
timed.pipe(Effect.provide(TestContext.TestContext)),
|
||||
)
|
||||
|
||||
// The fast feed applied; the hung one was dropped, and the batch
|
||||
// completed anyway.
|
||||
expect(applied).toEqual(["fast"])
|
||||
})
|
||||
|
||||
test("a rejecting fetch leaves that feed untouched and does not fail the batch", async () => {
|
||||
const bad = makeFeed("bad")
|
||||
const good = makeFeed("good")
|
||||
const applied: string[] = []
|
||||
|
||||
const program = refreshFeedsBatch(
|
||||
[bad, good],
|
||||
(feed) =>
|
||||
feed.id === "bad"
|
||||
? Promise.reject(new Error("feed exploded"))
|
||||
: Promise.resolve(ok(["g-1"])),
|
||||
(feed) => {
|
||||
applied.push(feed.id)
|
||||
},
|
||||
{ concurrency: 4, timeoutMs: 60_000 },
|
||||
)
|
||||
|
||||
await Effect.runPromise(program)
|
||||
expect(applied).toEqual(["good"])
|
||||
})
|
||||
@@ -42,7 +42,7 @@ let feedAId = "";
|
||||
/** When set, the server 503s this path — simulates a feed going down. */
|
||||
let failPath: string | null = null;
|
||||
|
||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||
/** XML for the current served episode list (episode ids derive from enclosure URLs). */
|
||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||
const items = episodes
|
||||
.map(
|
||||
@@ -79,6 +79,18 @@ beforeAll(() => {
|
||||
if (failPath && url.pathname === failPath) {
|
||||
return new Response("feed unavailable", { status: 503 });
|
||||
}
|
||||
// A dedicated single-episode feed for the failed-refresh test:
|
||||
// it must not depend on (or shrink) the shared servedEpisodes
|
||||
// list, which other tests' feeds read on refreshAllFeeds.
|
||||
if (url.pathname === "/flaky.xml") {
|
||||
return new Response(
|
||||
feedXml(
|
||||
[{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }],
|
||||
url.origin,
|
||||
),
|
||||
{ headers: { "Content-Type": "application/rss+xml" } },
|
||||
);
|
||||
}
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
@@ -139,8 +151,9 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => {
|
||||
|
||||
test("a failed refresh does not wipe the feed's episodes", async () => {
|
||||
const store = useFeedStore();
|
||||
const savedEpisodes = servedEpisodes;
|
||||
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }];
|
||||
// /flaky.xml serves its own fixed single-episode feed (see server route)
|
||||
// so the shared servedEpisodes list stays untouched for feedA, which
|
||||
// refreshAllFeeds below also refreshes.
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
@@ -161,11 +174,6 @@ test("a failed refresh does not wipe the feed's episodes", async () => {
|
||||
|
||||
failPath = null;
|
||||
store.removeFeed(feedId);
|
||||
// Restore the shared served content: with union merge semantics (volatile
|
||||
// episodes survive refreshes) this feed keeps its larger in-memory window,
|
||||
// so later tests must serve the same episodes they added — a shrink here
|
||||
// would make the next test's "unchanged" refresh genuinely different.
|
||||
servedEpisodes = savedEpisodes;
|
||||
});
|
||||
|
||||
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { join } from "path";
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
import { useFeedStore } from "../src/stores/feed";
|
||||
import { sameRefreshWindow, useFeedStore } from "../src/stores/feed";
|
||||
import { mergeEpisodesBounded } from "../src/utils/episode-merge";
|
||||
import { episodeInWindow } from "../src/utils/feeds-persistence";
|
||||
import { useAppStore } from "../src/stores/app";
|
||||
@@ -132,6 +132,67 @@ test("mergeEpisodesBounded dedupes on id collision and keeps the fetched copy",
|
||||
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
|
||||
});
|
||||
|
||||
test("mergeEpisodesBounded drops stale-id twins (id migration / rotating enclosure URLs)", () => {
|
||||
// The same two episodes with different ids on both sides — exactly what a
|
||||
// refresh sees after the positional-id → stable-id migration (or a host
|
||||
// that rotates signed audio URLs). Without content matching the union
|
||||
// would double every episode.
|
||||
const d1 = new Date("2026-08-01T00:00:00Z");
|
||||
const d2 = new Date("2026-08-02T00:00:00Z");
|
||||
const existing = [
|
||||
makeEpisode("feed#0", "Ep 1", d1),
|
||||
makeEpisode("feed#1", "Ep 2", d2),
|
||||
];
|
||||
const fetched = [
|
||||
makeEpisode("feed#guid:g1", "Ep 1", d1),
|
||||
makeEpisode("feed#guid:g2", "Ep 2", d2),
|
||||
];
|
||||
const keepAll = () => true;
|
||||
|
||||
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
|
||||
|
||||
expect(merged.map((e) => e.id)).toEqual(["feed#guid:g2", "feed#guid:g1"]);
|
||||
expect(merged).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("mergeEpisodesBounded keeps an existing episode with no fetched twin (volatile window)", () => {
|
||||
// Fetched covers Ep 1 only (by content twin). Ep 2 exists only in memory
|
||||
// — the volatile window — and must survive the refresh.
|
||||
const d1 = new Date("2026-08-01T00:00:00Z");
|
||||
const d2 = new Date("2026-08-02T00:00:00Z");
|
||||
const existing = [
|
||||
makeEpisode("feed#0", "Ep 1", d1),
|
||||
makeEpisode("feed#1", "Ep 2", d2),
|
||||
];
|
||||
const fetched = [makeEpisode("feed#guid:g1", "Ep 1", d1)];
|
||||
const keepAll = () => true;
|
||||
|
||||
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
|
||||
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged.map((e) => e.title).sort()).toEqual(["Ep 1", "Ep 2"]);
|
||||
});
|
||||
|
||||
test("sameRefreshWindow treats id drift with identical content as unchanged", () => {
|
||||
// Same episode, id changed between refreshes (migration / URL rotation):
|
||||
// the refresh must NOT bump lastUpdated or re-render.
|
||||
const d = new Date("2026-08-01T00:00:00Z");
|
||||
const existing = [makeEpisode("feed#0", "Ep 1", d)];
|
||||
const fetched = [makeEpisode("feed#guid:g1", "Ep 1", d)];
|
||||
expect(sameRefreshWindow(existing, fetched)).toBe(true);
|
||||
});
|
||||
|
||||
test("sameRefreshWindow flags a genuinely new episode even when ids drift", () => {
|
||||
const d1 = new Date("2026-08-01T00:00:00Z");
|
||||
const d2 = new Date("2026-08-02T00:00:00Z");
|
||||
const existing = [makeEpisode("feed#0", "Ep 1", d1)];
|
||||
const fetched = [
|
||||
makeEpisode("feed#guid:g2", "Ep 2", d2),
|
||||
makeEpisode("feed#guid:g1", "Ep 1", d1),
|
||||
];
|
||||
expect(sameRefreshWindow(existing, fetched)).toBe(false);
|
||||
});
|
||||
|
||||
test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => {
|
||||
const existing = [
|
||||
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
|
||||
@@ -284,21 +345,103 @@ test("date mode boundary: 25 days in, 70 days out", async () => {
|
||||
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||
"In Window",
|
||||
]);
|
||||
// The full cache holds both, but the visible list only shows the in-window
|
||||
// one — fetch-more surfaces the out-of-window one (volatile).
|
||||
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||
// The 70d episode is ~45 days past the 2-week band beyond the oldest
|
||||
// loaded episode (25d → 39d band): a sparse show must NOT drag it in.
|
||||
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||
await store.loadMoreEpisodes(id);
|
||||
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||
"In Window",
|
||||
"Out Window",
|
||||
]);
|
||||
});
|
||||
|
||||
test("date mode: a dormant show (nothing in the window or next band) never fetch-mores", async () => {
|
||||
const store = useFeedStore();
|
||||
const now = Date.now();
|
||||
// Newest episode 100 days old, next 200 days old — both far outside the
|
||||
// 60-day cache window and the 14-day band past its edge.
|
||||
servedEpisodes = [
|
||||
{ title: "Old A", date: new Date(now - 100 * DAY).toISOString() },
|
||||
{ title: "Old B", date: new Date(now - 200 * DAY).toISOString() },
|
||||
];
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/dormant.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
const id = feed!.id;
|
||||
addedFeedIds.push(id);
|
||||
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(0);
|
||||
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||
await store.loadMoreEpisodes(id);
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(0);
|
||||
});
|
||||
|
||||
test("date mode: episodes just outside the window load via the band anchored at the window edge", async () => {
|
||||
const store = useFeedStore();
|
||||
const now = Date.now();
|
||||
// Both episodes are outside the 60-day window (61d / 65d) but inside the
|
||||
// 14-day band past its edge (60d → 74d) — fetch-more reveals them.
|
||||
servedEpisodes = [
|
||||
{ title: "Just Out A", date: new Date(now - 61 * DAY).toISOString() },
|
||||
{ title: "Just Out B", date: new Date(now - 65 * DAY).toISOString() },
|
||||
];
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/just-out.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
const id = feed!.id;
|
||||
addedFeedIds.push(id);
|
||||
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(0);
|
||||
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||
await store.loadMoreEpisodes(id);
|
||||
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||
"Just Out A",
|
||||
"Just Out B",
|
||||
]);
|
||||
});
|
||||
|
||||
// ── count mode ────────────────────────────────────────────────────────────
|
||||
|
||||
test("date mode: fetch-more steps by a two-week window, not a count", async () => {
|
||||
const store = useFeedStore();
|
||||
const now = Date.now();
|
||||
// 30 episodes at 3-day spacing span 87 days. The 60-day cache window
|
||||
// holds the first 21 (subscribe shows 20); fetch-more then reveals the
|
||||
// next 2-week band per press — 3-day cadence → ~4 episodes per band —
|
||||
// NOT a fixed 50-episode chunk (which would load all 30 at once).
|
||||
servedEpisodes = Array.from({ length: 30 }, (_, i) => ({
|
||||
title: `Ep ${30 - i}`,
|
||||
date: new Date(now - i * 3 * DAY).toISOString(),
|
||||
}));
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/date-step.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
const id = feed!.id;
|
||||
addedFeedIds.push(id);
|
||||
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(20);
|
||||
|
||||
// Press 1: oldest loaded is 57d old → cutoff 71d → i=20..23 (60–69d).
|
||||
await store.loadMoreEpisodes(id);
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(24);
|
||||
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||
|
||||
// Press 2: oldest loaded is 69d old → cutoff 83d → i=24..27 (72–81d).
|
||||
await store.loadMoreEpisodes(id);
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(28);
|
||||
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||
|
||||
// Press 3: oldest loaded is 81d old → cutoff 95d → i=28..29 (84–87d).
|
||||
await store.loadMoreEpisodes(id);
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(30);
|
||||
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||
});
|
||||
|
||||
test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => {
|
||||
const store = useFeedStore();
|
||||
const app = useAppStore();
|
||||
// The app store loads persisted prefs asynchronously at import — wait so
|
||||
// the override below isn't clobbered by the load.
|
||||
await app.whenReady();
|
||||
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
|
||||
|
||||
const now = Date.now();
|
||||
@@ -329,3 +472,67 @@ test("count mode: only N most-recent episodes are visible, but fetch-more goes b
|
||||
// Reset to date mode for subsequent tests.
|
||||
app.updatePreferences({ episodeCacheMode: "date" });
|
||||
});
|
||||
|
||||
test("count mode: Feed list is a GLOBAL top-N that grows N per press, never a far-back dump", async () => {
|
||||
const store = useFeedStore();
|
||||
const app = useAppStore();
|
||||
// Wait out the async pref load (see the single-show count test).
|
||||
await app.whenReady();
|
||||
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
|
||||
|
||||
const now = Date.now();
|
||||
// Feed A: 200 episodes at 1-day spacing (ages 0–199d). Feed B: 200 at
|
||||
// 1-day spacing shifted 200 days older (ages 200–399d) — every A episode
|
||||
// is newer than every B episode, so the global top-K is deterministic.
|
||||
const serve = (prefix: string, shiftDays: number) =>
|
||||
Array.from({ length: 200 }, (_, i) => ({
|
||||
title: `${prefix} Ep ${200 - i}`,
|
||||
date: new Date(now - (shiftDays + i) * DAY).toISOString(),
|
||||
}));
|
||||
servedEpisodes = serve("A", 0);
|
||||
const aUrl = `http://127.0.0.1:${server!.port}/global-a.xml`;
|
||||
const a = await store.addFeed(makePodcast(aUrl), "test-source");
|
||||
expect(a).not.toBeNull();
|
||||
const aId = a!.id;
|
||||
addedFeedIds.push(aId);
|
||||
servedEpisodes = serve("B", 200);
|
||||
const bUrl = `http://127.0.0.1:${server!.port}/global-b.xml`;
|
||||
const b = await store.addFeed(makePodcast(bUrl), "test-source");
|
||||
expect(b).not.toBeNull();
|
||||
const bId = b!.id;
|
||||
addedFeedIds.push(bId);
|
||||
|
||||
// The Feed page's global list is capped at the configured count (25),
|
||||
// NOT 20 per show (the union would be 40).
|
||||
expect(store.getAllEpisodesChronological().length).toBe(25);
|
||||
|
||||
// Press 1: cap grows to 50 AND every feed's window deepens by 25 — the
|
||||
// list reveals exactly the next 25 most-recent episodes (A's 25 more),
|
||||
// not 25 from every show.
|
||||
await store.loadMoreAllFeeds();
|
||||
expect(store.getAllEpisodesChronological().length).toBe(50);
|
||||
expect(store.getFeed(aId)!.episodes.length).toBe(45);
|
||||
expect(store.getFeed(bId)!.episodes.length).toBe(45);
|
||||
expect(store.hasMoreAcrossAll()).toBe(true);
|
||||
|
||||
// Press 2: cap grows to 75.
|
||||
await store.loadMoreAllFeeds();
|
||||
expect(store.getAllEpisodesChronological().length).toBe(75);
|
||||
|
||||
// Keep pressing until every cache is exhausted. The global cap stays
|
||||
// (never lifts — rendering the full deep union froze the UI), so the
|
||||
// Feed list stays at count×(presses+1) = 25×9 = 225 while the per-show
|
||||
// windows hold everything.
|
||||
let guard = 0;
|
||||
while (store.hasMoreAcrossAll() && guard++ < 30) {
|
||||
await store.loadMoreAllFeeds();
|
||||
}
|
||||
expect(guard).toBeLessThan(30);
|
||||
expect(store.hasMoreAcrossAll()).toBe(false);
|
||||
expect(store.getFeed(aId)!.episodes.length).toBe(200);
|
||||
expect(store.getFeed(bId)!.episodes.length).toBe(200);
|
||||
expect(store.getAllEpisodesChronological().length).toBe(225);
|
||||
|
||||
// Reset to date mode for subsequent tests.
|
||||
app.updatePreferences({ episodeCacheMode: "date" });
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ process.env.XDG_CONFIG_HOME = CONFIG;
|
||||
process.env.XDG_DATA_HOME = DATA;
|
||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||
|
||||
// ── Local RSS feed server (episode ids = feedUrl#index) ────────────────────
|
||||
// ── Local RSS feed server (episode ids derive from enclosure URLs) ─────────
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
function feedXml(origin: string): string {
|
||||
const items = ["Episode One", "Episode Two"]
|
||||
|
||||
76
tests/rss-episode-ids.test.ts
Normal file
76
tests/rss-episode-ids.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Episode id stability regression tests.
|
||||
*
|
||||
* The parser used to key episodes by their position in the feed
|
||||
* (`feedUrl#index`). Any feed change — a new episode published, an old one
|
||||
* pruned — shifted every episode's id, so saved progress/downloads attached
|
||||
* to whichever episode happened to occupy that index afterward ("start a new
|
||||
* episode and it resumes minutes in"). Ids must instead be stable per
|
||||
* episode: <guid> when present, else the enclosure URL, else the index.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getRSSItems, parseRSSItem } from "../src/api/rss-parser"
|
||||
|
||||
const FEED = "https://example.com/feed.xml"
|
||||
|
||||
const item = (
|
||||
title: string,
|
||||
date: string,
|
||||
audioUrl: string,
|
||||
guid?: string,
|
||||
): string =>
|
||||
`<item>
|
||||
<title>${title}</title>
|
||||
<pubDate>${date}</pubDate>
|
||||
${guid ? `<guid>${guid}</guid>` : ""}
|
||||
<enclosure url="${audioUrl}" length="12345" type="audio/mpeg"/>
|
||||
</item>`
|
||||
|
||||
const parse = (xml: string): ReturnType<typeof parseRSSItem>[] =>
|
||||
getRSSItems(xml).map((it, i) => parseRSSItem(it, FEED, i))
|
||||
|
||||
describe("stable episode ids", () => {
|
||||
test("guid-based ids survive a new episode being prepended", () => {
|
||||
// Two episodes, newest first. Positional ids would be feedUrl#0 / #1.
|
||||
const before = parse(
|
||||
`<rss><channel>${item("Ep 2", "2026-08-02", "https://cdn.example.com/e2.mp3", "ep-2")}${item("Ep 1", "2026-08-01", "https://cdn.example.com/e1.mp3", "ep-1")}</channel></rss>`,
|
||||
)
|
||||
|
||||
// A third, newer episode appears at the top — every index shifts.
|
||||
const after = parse(
|
||||
`<rss><channel>${item("Ep 3", "2026-08-03", "https://cdn.example.com/e3.mp3", "ep-3")}${item("Ep 2", "2026-08-02", "https://cdn.example.com/e2.mp3", "ep-2")}${item("Ep 1", "2026-08-01", "https://cdn.example.com/e1.mp3", "ep-1")}</channel></rss>`,
|
||||
)
|
||||
|
||||
// The known episodes keep their ids — only the newcomer differs.
|
||||
expect(after[1].id).toBe(before[0].id) // Ep 2
|
||||
expect(after[2].id).toBe(before[1].id) // Ep 1
|
||||
expect(after[0].id).not.toBe(before[0].id) // Ep 3 is new
|
||||
})
|
||||
|
||||
test("enclosure-URL fallback ids survive reordering (no guid)", () => {
|
||||
const a = item("A", "2026-08-02", "https://cdn.example.com/a.mp3")
|
||||
const b = item("B", "2026-08-01", "https://cdn.example.com/b.mp3")
|
||||
const first = parse(`<rss><channel>${a}${b}</channel></rss>`)
|
||||
const reordered = parse(`<rss><channel>${b}${a}</channel></rss>`)
|
||||
|
||||
// The same episodes at different indexes still carry their own ids.
|
||||
expect(reordered[0].id).toBe(first[1].id) // B moved to index 0
|
||||
expect(reordered[1].id).toBe(first[0].id) // A moved to index 1
|
||||
})
|
||||
|
||||
test("ids are namespaced per feed", () => {
|
||||
const xml = `<rss><channel>${item("Ep", "2026-08-01", "https://cdn.example.com/e.mp3", "same-guid")}</channel></rss>`
|
||||
const parsed = getRSSItems(xml)
|
||||
const a = parseRSSItem(parsed[0], "https://a.example/feed.xml", 0)
|
||||
const b = parseRSSItem(parsed[0], "https://b.example/feed.xml", 0)
|
||||
expect(a.id).not.toBe(b.id)
|
||||
})
|
||||
|
||||
test("id is deterministic across parses of the same item", () => {
|
||||
const xml = `<rss><channel>${item("Ep", "2026-08-01", "https://cdn.example.com/e.mp3")}</channel></rss>`
|
||||
const one = parse(xml)
|
||||
const two = parse(xml)
|
||||
expect(one[0].id).toBe(two[0].id)
|
||||
})
|
||||
})
|
||||
189
tests/show-row-wrap.test.tsx
Normal file
189
tests/show-row-wrap.test.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Show-row height regression — My Shows depth-0 rows must stay exactly one
|
||||
* line tall: marker + show title + episode count (+ watchlist dot). The
|
||||
* flexible title carries `wrapMode="none"` + `truncate` (middle-ellipsis:
|
||||
* head and tail of the title stay visible) and the fixed-width cells carry
|
||||
* `flexShrink={0}`, so Yoga can never shrink them and wrap the row — a
|
||||
* wrapped row grows to 2+ lines and the episode count + watchlist dot shift
|
||||
* below the title while scrolling (the original bug). Same guard for the
|
||||
* 20%-wide parent-pane shows list at depth ≥1.
|
||||
*
|
||||
* Rendered at 70 columns so the 35-col current pane / 14-col parent pane are
|
||||
* narrow enough to force truncation on the long title; at the default
|
||||
* 100-col/50-col pane the same rows show everything in full.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterAll } from "bun:test";
|
||||
import type { JSX } from "solid-js";
|
||||
import { testRender } from "@opentui/solid";
|
||||
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||
import { PaneRow } from "../src/components/PaneRow";
|
||||
|
||||
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
|
||||
|
||||
function frameText(spans: Frame): string[] {
|
||||
return spans.lines.map((l) => l.spans.map((s) => s.text).join(""));
|
||||
}
|
||||
|
||||
const LONG_TITLE =
|
||||
"Out of Whiskey and Reaching for the Rotgut (Members Only #338)";
|
||||
|
||||
// The exact depth-0 row shape MyShowsPage renders: marker + title + count +
|
||||
// watchlist dot. Static text, no store hooks — pure layout probe.
|
||||
const ShowRowFixed = () => (
|
||||
<box flexDirection="row" gap={1} paddingRight={1}>
|
||||
<text flexShrink={0}>❯</text>
|
||||
<text wrapMode="none" truncate>
|
||||
{LONG_TITLE}
|
||||
</text>
|
||||
<text flexShrink={0}>(123)</text>
|
||||
<text flexShrink={0}>●</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
// Pre-fix shape: no wrapMode/truncate/flexShrink props — the long title
|
||||
// wraps at the shrunken width and pushes the count + dot onto wrapped lines.
|
||||
const ShowRowNaive = () => (
|
||||
<box flexDirection="row" gap={1} paddingRight={1}>
|
||||
<text>❯</text>
|
||||
<text>{LONG_TITLE}</text>
|
||||
<text>(123)</text>
|
||||
<text>●</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
// The depth-1 parent-pane shows-list row (marker + title + count).
|
||||
const ParentRowFixed = () => (
|
||||
<box flexDirection="row" gap={1} paddingRight={1}>
|
||||
<text flexShrink={0}>❯</text>
|
||||
<text wrapMode="none" truncate>
|
||||
{LONG_TITLE}
|
||||
</text>
|
||||
<text flexShrink={0}>(123)</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
const ParentRowNaive = () => (
|
||||
<box flexDirection="row" gap={1} paddingRight={1}>
|
||||
<text>❯</text>
|
||||
<text>{LONG_TITLE}</text>
|
||||
<text>(123)</text>
|
||||
</box>
|
||||
);
|
||||
|
||||
async function renderRow(
|
||||
row: () => JSX.Element,
|
||||
pane: "current" | "parent",
|
||||
width = 70,
|
||||
): Promise<{ lines: string[]; destroy: () => Promise<void> }> {
|
||||
const setup = await testRender(
|
||||
() => (
|
||||
<ThemeProvider mode="dark">
|
||||
<PaneRow
|
||||
parent={pane === "parent" ? row : null}
|
||||
current={pane === "current" ? row : null}
|
||||
preview={null}
|
||||
currentLabel="List"
|
||||
/>
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width, height: 10, useThread: false },
|
||||
);
|
||||
// ThemeProvider mounts children only once the theme resolves; poll for
|
||||
// the header row so the captured frame is a mounted PaneRow.
|
||||
let lines: string[] | null = null;
|
||||
for (let i = 0; i < 40 && !lines; i++) {
|
||||
await setup.renderOnce();
|
||||
const frame = setup.captureSpans() as unknown as Frame;
|
||||
const ls = frameText(frame);
|
||||
if (ls.some((l) => l.includes("List"))) lines = ls;
|
||||
else await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (!lines) throw new Error("PaneRow did not render before timeout");
|
||||
return {
|
||||
lines,
|
||||
destroy: async () => {
|
||||
setup.renderer.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const cleanups: (() => void | Promise<void>)[] = [];
|
||||
afterAll(async () => {
|
||||
for (const c of cleanups) {
|
||||
try {
|
||||
await c();
|
||||
} catch {
|
||||
// renderer already torn down — ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("show row height in the current pane (70-wide → 35-col pane)", () => {
|
||||
test("long title stays on one line — middle-ellipsis keeps head AND tail, count + dot stay aligned", async () => {
|
||||
const { lines, destroy } = await renderRow(ShowRowFixed, "current");
|
||||
cleanups.push(destroy);
|
||||
|
||||
// Middle-ellipsis: the title head and its tail both survive, on a
|
||||
// single line (end-truncation would drop the tail).
|
||||
expect(lines.filter((l) => l.includes("Out of Wh"))).toHaveLength(1);
|
||||
expect(lines.filter((l) => l.includes("#338)"))).toHaveLength(1);
|
||||
// The count and watchlist dot sit on that same line — nothing wrapped.
|
||||
const aligned = lines.filter(
|
||||
(l) =>
|
||||
l.includes("Out of Wh") &&
|
||||
l.includes("#338)") &&
|
||||
l.includes("(123)") &&
|
||||
l.includes("●"),
|
||||
);
|
||||
expect(aligned).toHaveLength(1);
|
||||
// Row occupies exactly 1 content line below the header.
|
||||
const content = lines.filter(
|
||||
(l) => l.includes("Out of Wh") || l.includes("(123)"),
|
||||
);
|
||||
expect(content).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("naive row (pre-fix props) wraps the title — the regression the test guards", async () => {
|
||||
const { lines, destroy } = await renderRow(ShowRowNaive, "current");
|
||||
cleanups.push(destroy);
|
||||
|
||||
// The title's wrapped fragments span 3 frame lines instead of 1 —
|
||||
// every row below shifts while scrolling.
|
||||
const titleFragments = lines.filter(
|
||||
(l) =>
|
||||
l.includes("Out of Whiskey") ||
|
||||
l.includes("Rotgut") ||
|
||||
l.includes("#338)"),
|
||||
);
|
||||
expect(titleFragments).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("show row in the parent pane (70-wide → 14-col pane)", () => {
|
||||
test("long title stays on one line with count aligned", async () => {
|
||||
const { lines, destroy } = await renderRow(ParentRowFixed, "parent");
|
||||
cleanups.push(destroy);
|
||||
|
||||
// The 14-col slot truncates the title to a head stub (too narrow for
|
||||
// head + tail), but the row stays one line and the count pins to it.
|
||||
expect(lines.filter((l) => l.includes("O..."))).toHaveLength(1);
|
||||
expect(
|
||||
lines.filter((l) => l.includes("O...") && l.includes("(123)")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("naive parent row wraps the title — the regression the test guards", async () => {
|
||||
const { lines, destroy } = await renderRow(ParentRowNaive, "parent");
|
||||
cleanups.push(destroy);
|
||||
|
||||
const titleFragments = lines.filter(
|
||||
(l) =>
|
||||
l.includes("Out of") ||
|
||||
l.includes("Whiskey") ||
|
||||
l.includes("Rotgut") ||
|
||||
l.includes("#3"),
|
||||
);
|
||||
expect(titleFragments.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -230,15 +230,118 @@ test.skipIf(skip)(
|
||||
app.updateVisualizer({ enabled: false });
|
||||
await waitFor(() => !viz.isRunning(), 10000);
|
||||
expect(viz.isLoading()).toBe(false);
|
||||
// Stopping the pipeline must drop the last rendered frame — a cold
|
||||
// restart (re-enable, unload, episode change) would otherwise show
|
||||
// stale bars from the previous run and never reach the loading
|
||||
// state (the spinner only shows while bars are empty).
|
||||
expect(viz.barData().length).toBe(0);
|
||||
|
||||
app.updateVisualizer({ enabled: true });
|
||||
await waitFor(() => viz.isRunning(), 10000);
|
||||
// The restart surfaces the loading state before the first frame.
|
||||
await waitFor(() => viz.isLoading(), 5000);
|
||||
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
|
||||
expect(viz.barData().length).toBe(64);
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
// A pause followed by a seek while paused, then resume, lands OUTSIDE the
|
||||
// decoded sliding window: the cache can't serve bars instantly, so the
|
||||
// store must surface the warm-up as a loading state instead of silently
|
||||
// holding the stale pre-pause frame. Regression: resumeVisualization never
|
||||
// set isLoading, so the last frame froze with no feedback until the
|
||||
// re-decode's first frame landed.
|
||||
test.skipIf(skip)(
|
||||
"resume into undecoded audio shows the loading state until bars land",
|
||||
async () => {
|
||||
const viz = useVisualizer();
|
||||
await startPlaying();
|
||||
expect(viz.barData().length).toBe(64);
|
||||
|
||||
// Pause, then seek far ahead while paused (outside the ~10s of
|
||||
// decoded coverage), then resume.
|
||||
setIsPlaying(false);
|
||||
await waitFor(() => !viz.isRunning(), 10000);
|
||||
setPosition(30);
|
||||
setIsPlaying(true);
|
||||
|
||||
// The resume position isn't decoded yet — loading, not frozen bars.
|
||||
await waitFor(() => viz.isLoading(), 5000);
|
||||
expect(viz.isRunning()).toBe(true);
|
||||
|
||||
// Playback advances past the resume point (mpv moves the clock);
|
||||
// once the re-decode covers it, fresh bars replace the stale
|
||||
// pre-pause frame (chirp spectrum at 30s ≠ 2s) and the loading
|
||||
// state clears.
|
||||
setPosition(31);
|
||||
const barsBefore = viz.barData();
|
||||
await waitFor(
|
||||
() => !viz.isLoading() && viz.barData() !== barsBefore,
|
||||
15000,
|
||||
);
|
||||
expect(viz.barData().length).toBe(64);
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
// After a long pause on a network stream, the player (mpv) re-buffers:
|
||||
// `isPlaying` stays true but the position clock freezes. Without
|
||||
// detection the waveform rendered the same cached window forever — static
|
||||
// bars and no feedback. The render loop must report the stall as a
|
||||
// loading state and clear it the moment the clock moves again.
|
||||
test.skipIf(skip)(
|
||||
"a frozen position clock while playing surfaces a stall; recovery clears it",
|
||||
async () => {
|
||||
const viz = useVisualizer();
|
||||
await startPlaying();
|
||||
expect(viz.isStalled()).toBe(false);
|
||||
|
||||
// Freeze the position: isPlaying stays true, the clock never moves.
|
||||
await waitFor(() => viz.isStalled(), 10000);
|
||||
|
||||
// Player recovers — the clock advances again.
|
||||
setPosition(4);
|
||||
await waitFor(() => !viz.isStalled(), 3000);
|
||||
expect(viz.isRunning()).toBe(true);
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
// Resume re-arms a pipeline whose ffmpeg pass was killed at pause: the
|
||||
// stale pre-pause bars must not masquerade as live data while the player
|
||||
// recovers. The spinner shows IN THEIR PLACE until the position clock
|
||||
// advances past the resume point — a frozen clock (mpv re-buffering after
|
||||
// a long pause) keeps the spinner even though the cache can serve the
|
||||
// same window.
|
||||
test.skipIf(skip)(
|
||||
"resume shows the loading state in place of stale bars until the position clock advances",
|
||||
async () => {
|
||||
const viz = useVisualizer();
|
||||
await startPlaying();
|
||||
expect(viz.isLoading()).toBe(false);
|
||||
|
||||
// Pause, then resume against the still-covered position.
|
||||
setIsPlaying(false);
|
||||
await waitFor(() => !viz.isRunning(), 10000);
|
||||
setIsPlaying(true);
|
||||
|
||||
// The spinner replaces the bars immediately on resume.
|
||||
await waitFor(() => viz.isLoading(), 5000);
|
||||
expect(viz.isRunning()).toBe(true);
|
||||
|
||||
// Position clock stays frozen at the resume point (re-buffering):
|
||||
// the loading state must persist, not yield to static cached bars.
|
||||
await Bun.sleep(250);
|
||||
expect(viz.isLoading()).toBe(true);
|
||||
|
||||
// Player recovers — the clock advances → fresh bars, spinner gone.
|
||||
setPosition(3);
|
||||
await waitFor(() => !viz.isLoading(), 3000);
|
||||
expect(viz.barData().length).toBe(64);
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
// ── Teardown ─────────────────────────────────────────────────────────────
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
Reference in New Issue
Block a user