35 Commits

Author SHA1 Message Date
649baf40ab bump VERSION to 0.9.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 8s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-09-07 19:58:19 -04:00
9702af640b revert visualizer throttling 2026-09-07 19:57:53 -04:00
48076fcef5 bump VERSION to 0.9.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 8s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-09-06 18:50:47 -04:00
06c5cc9184 chore(scroll): typed alias for wrapped wheel handler
onMouseEvent is untyped on ScrollBoxRenderable.prototype; cast once
at capture instead of loosening call sites.
2026-09-06 18:46:42 -04:00
a3641d100e perf(download): stream response body straight to file
Bun.file().writer() per chunk replaces the in-memory chunk array +
final concat copy: no more whole-episode buffering.
2026-09-06 18:46:42 -04:00
8a173a5180 fix(feed): stop persisting legacy podcast.episodes
search-time parseRSSFeed once embedded the full episode history
inside Feed.podcast (2,100+ stale copies, 3.8 MB of config). Nothing
reads it — feed.episodes is the source of truth — so load and save
now strip podcast.episodes, and searchByFeedUrl drops them at the
parse site.
2026-09-06 18:46:42 -04:00
132d2079f7 fix(pane): 3-column grab zones with cursor offset
Splitter strips widen to border +/- 1 help-padded column so the thin
border is easy to target; mousedown records the cursor's offset from
the border column and drag subtracts it, so the border tracks the
cursor instead of jumping. Padded columns must never overlap
interactive content (rect-based hit grid). Dead one-off render
harness scripts/_hv.ts removed. Tests cover far/inner-edge grabs
and no-resize on padded-column clicks.
2026-09-06 18:46:42 -04:00
b53d4add29 test(scratch): leak-hunt probes for cava, render loop, decode stream
One-off diagnostics behind the FFTW leak fix: init/destroy x50 RSS
bound, Mach VM region walker, render-churn region growth, ffmpeg
decode-stream region/rss sampling.
2026-09-06 18:46:42 -04:00
6c3ad5d925 perf(visualizer): write bar data at ~10fps
Each Solid setBarData costs a renderer diff pass. Cava already
smooths (noise reduction + peak release), so 3 of every 4 frames now
update only the pipeline; the UI signal writes at >=95ms intervals.
2026-09-06 18:46:42 -04:00
09d5732b55 fix(cava): identical init re-uses the live plan
cava_init/destroy churn leaks the old plan's FFTW work buffers —
upstream frees only its own struct. init() now serializes the config
and no-ops when unchanged, so pipeline restarts (focus/episode churn)
keep the live plan instead of leaking a new one each cycle.
2026-09-06 18:46:42 -04:00
9e2f232d27 chore(harness): drop dead selectedFeedId read 2026-09-03 08:33:50 -04:00
9143078b12 refactor(feed): episode windows, source registry, dead interface
episode-windows.ts: keep-fn, date-band walk (was copy-pasted twice),
sameRefreshWindow — one pure module. source-registry.ts owns source
CRUD + its persistence. Dead members with zero callers deleted:
updateFeed, togglePinned, removeSource, setFilter/selectedFeedId.
Feed persistence routed through the persist scheduler.
2026-09-03 08:33:32 -04:00
ca46de4d70 refactor(audio): engine module absorbs the hook
useAudio shrinks 928→152: backend lifecycle, poll, session restore,
crash recovery, queue advance and event-bus commands live in
createAudioEngine; the hook is a thin Solid adapter. audio-player:
preload of a dead URL stalls the load mutex 5s — end-file (open
failure) now races file-loaded.
2026-09-03 08:33:24 -04:00
3e90f9e783 refactor(feed): single RSS client closes search timeout gap
fetchFeedXml owns headers + 20s timeout; both hand-rolled fetches in
feed.ts (fetchEpisodes, load-more cold path) and searchByFeedUrl route
through it — direct-URL search hung indefinitely before.
2026-09-03 08:33:19 -04:00
c2ec356a5f refactor(persist): one per-domain persist scheduler
stores/persist.ts: createPersistScheduler — trailing-edge debounce,
flush, per-domain isolation. search-history writes collapse from
per-keystroke to one debounced write; scope save stays direct.
2026-09-03 08:33:11 -04:00
6aac138629 bump VERSION to 0.8.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 5s
release / Attach to GitHub Release (push) Has been cancelled
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
2026-08-28 21:58:31 -04:00
3f0001b0d5 fix inner scroll behavior 2026-08-28 21:58:11 -04:00
74158d75d4 fix border resizing ui 2026-08-28 19:45:14 -04:00
4ff8aabb51 bump VERSION to 0.7.2
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 3s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-27 13:47:06 -04:00
895138357f visualizer fixes 2026-08-27 13:46:41 -04:00
02c957f584 fix curl flag 2026-08-26 21:56:26 -04:00
d7ceb9d045 bump VERSION to 0.7.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m17s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-19 09:42:57 -04:00
0677f82c44 docs(readme): document auto-advance and mpv tarball install note 2026-08-17 20:50:14 -04:00
4990eae60f feat(theme): poll terminal OSC colors to track live theme changes
Terminals answer OSC 10/11/12 color queries but never push changes, so
detect theme flips with a 60 s poll (legacy-tmux fallback for servers
< 3.6). Re-queries palette + default fg/bg, updates the system palette
and re-detects dark/light mode.
2026-08-17 20:50:14 -04:00
9ddfd21685 feat(audio): auto-advance to next episode in source queue on track end
When a track reaches natural EOF (player alive, no stream error), play the
next episode from the source that started it — search results, show, or
Feed — and stop at the end of the list. A crashed/killed daemon or failed
stream never auto-advances.

- add audio-queue.ts: pure next/prev selection from the navigation source
- audio-player: expose getPlaybackError() to distinguish EOF from failure
- useAudio: finalizeTrackEnd(autoAdvance) wiring, re-selecting the current
  episode no longer reloads from stale saved progress
- tests: audio-queue units, auto-advance integration (real mpv + local
  WAVs over HTTP), backend re-select no-reload test
2026-08-17 20:50:05 -04:00
22059c24ca fix(visualizer): show loading spinner on resume until fresh bars arrive
Resume re-arms a pipeline whose ffmpeg pass was killed at pause, so the
pre-pause bars are stale until fresh frames flow. Three changes:

- resumeVisualization always sets the loading state (previously only for
  positions outside decoded coverage) and records the resume point;
  renderFrame clears it only once the position clock advances past that
  point — a player still re-buffering after a long pause keeps the
  spinner instead of serving static cached bars.
- stopVisualization clears barData so cold restarts (unload, disable,
  episode change) show the spinner rather than stale bars, and never
  suppress it.
- renderFrame detects a frozen position clock while playing (STALL_DETECT_MS)
  and surfaces it as a loading state; recovery clears it.

Tests: resume-into-undecoded-audio shows loading until bars land; frozen
position clock surfaces a stall and recovery clears it; disable/enable
pins barData cleared on stop and the restart loading flash.
2026-08-13 21:04:12 -04:00
0aac0a157f bump VERSION to 0.7.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m15s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-13 19:35:13 -04:00
df4701957b refactor(feed): port refresh batch to Effect for concurrency and timeout
Replace the hand-rolled worker pool (mapWithConcurrency) with an Effect
program (src/effects/feed-refresh.ts): Effect.forEach bounds in-flight
fetches, Effect.timeout bounds each feed via the Clock service, and
failures fold to null so a bad feed never fails the batch. Per-feed
apply-as-it-lands is preserved — the apply callback runs inside each
feed's own fiber, so there is no Promise.all barrier.

Store boundary unchanged: refreshAllFeeds runs the program through
Effect.runPromise, keeping runAutoDownload + flushPendingSave after the
batch and isLoadingFeeds around it.

Adds TestClock-driven tests (tests/feed-refresh-effect.test.ts) that pin
concurrency, per-feed apply, timeout, and failure isolation without real
20s waits. Pins effect@^3 (V4 is in beta).
2026-08-13 19:34:30 -04:00
4b44623891 fix(tests): stop feed-store mock.module leak that broke the full suite
discover-store-preview mocked src/stores/feed via mock.module, which bun
applies process-globally: with workers reused across test files, every file
that later shared a worker imported the stub (fetchEpisodes only) and failed
with 'addFeed is not a function' — ~35 tests, drifting run to run with worker
scheduling. Rewrote the test against the REAL feed store and a local gated
Bun.serve server (repo-dominant harness), importing the discover store via a
query-suffixed specifier so a sibling discover-store mock cannot leak in.
Full suite: 215 pass, 0 fail (baseline: 194).
2026-08-13 18:01:40 -04:00
4ef9ab7e59 perf(ui): render only a bounded window around the focused row 2026-08-13 17:46:30 -04:00
9df8eebf6c feat(discover): drill into show episode previews without subscribing 2026-08-13 17:46:30 -04:00
badbc6a037 fix(audio): recover playback when the mpv daemon dies or restarts 2026-08-13 17:46:30 -04:00
878d1e01ab feat(feed): signature-aware volatile merge with date-banded fetch-more 2026-08-13 17:46:27 -04:00
42c48e59fb feat(feed): stable episode ids derived from guid or enclosure URL 2026-08-13 17:46:26 -04:00
91d4acca90 fix: fetch more respects episode cache mode 2026-08-12 22:28:13 -04:00
74 changed files with 5859 additions and 1431 deletions

View File

@@ -16,6 +16,8 @@ external player with full transport control — all from your terminal.
- **Search** across your subscribed shows. - **Search** across your subscribed shows.
- **Audio playback** through an external player with full transport control: - **Audio playback** through an external player with full transport control:
play/pause, next/previous, seek, speed, and per-episode resume progress. 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**. - **Themeable** and **remappable keybindings**.
- Ships as a **standalone compiled binary** — no runtime or install step beyond - Ships as a **standalone compiled binary** — no runtime or install step beyond
a system audio player. a system audio player.
@@ -61,7 +63,7 @@ Grab `podtui-<platform>-<arch>.tar.gz` from the latest
put `podtui` on your `PATH`: put `podtui` on your `PATH`:
```bash ```bash
curl -sS -o /tmp/podtui.tar.gz \ curl -sSL -o /tmp/podtui.tar.gz \
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
sudo mkdir -p /opt/podtui sudo mkdir -p /opt/podtui
sudo tar -xzf /tmp/podtui.tar.gz -C /opt/podtui --strip-components=1 sudo tar -xzf /tmp/podtui.tar.gz -C /opt/podtui --strip-components=1
@@ -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. 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 **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 **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 … needs `-headerpad`” is benign: the app loads its libraries by path, the

BIN
bun.lockb

Binary file not shown.

View File

@@ -24,6 +24,7 @@
"@opentui/core": "^0.1.77", "@opentui/core": "^0.1.77",
"@opentui/solid": "^0.1.77", "@opentui/solid": "^0.1.77",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"effect": "^3",
"solid-js": "^1.9.9" "solid-js": "^1.9.9"
} }
} }

View File

@@ -26,6 +26,8 @@
* bun scripts/tui-harness.tsx type "<text>" * bun scripts/tui-harness.tsx type "<text>"
* bun scripts/tui-harness.tsx wait <ms> * bun scripts/tui-harness.tsx wait <ms>
* bun scripts/tui-harness.tsx resize <w> <h> * bun scripts/tui-harness.tsx resize <w> <h>
* bun scripts/tui-harness.tsx mouse <x> <y> <down|up|click>
* bun scripts/tui-harness.tsx mdrag <x1> <y1> <x2> <y2> # border/seek drag
* bun scripts/tui-harness.tsx frame # re-render, no new action * bun scripts/tui-harness.tsx frame # re-render, no new action
* bun scripts/tui-harness.tsx state [all|nav|audio|feed|app] * bun scripts/tui-harness.tsx state [all|nav|audio|feed|app]
* bun scripts/tui-harness.tsx actions # print action log * bun scripts/tui-harness.tsx actions # print action log
@@ -79,7 +81,9 @@ type Action =
| { t: "enter" | "escape" | "tab" | "space" | "backspace"; mods?: Mod[] } | { t: "enter" | "escape" | "tab" | "space" | "backspace"; mods?: Mod[] }
| { t: "type"; s: string } | { t: "type"; s: string }
| { t: "wait"; ms: number } | { t: "wait"; ms: number }
| { t: "resize"; w: number; h: number }; | { t: "resize"; w: number; h: number }
| { t: "mouse"; kind: "down" | "up" | "click"; x: number; y: number }
| { t: "mdrag"; x1: number; y1: number; x2: number; y2: number };
function loadActions(): Action[] { function loadActions(): Action[] {
try { try {
@@ -258,12 +262,32 @@ const BUILDERS: Record<string, (positional: string[]) => Action> = {
if (!p[0]) throw new Error("wait requires <ms>"); if (!p[0]) throw new Error("wait requires <ms>");
return { t: "wait", ms: parseInt(p[0], 10) || 0 }; return { t: "wait", ms: parseInt(p[0], 10) || 0 };
}, },
resize: (p) => { resize: (p) => {
if (!p[0] || !p[1]) throw new Error("resize requires <w> <h>"); if (!p[0] || !p[1]) throw new Error("resize requires <w> <h>");
return { return {
t: "resize", t: "resize",
w: parseInt(p[0], 10) || 100, w: parseInt(p[0], 10) || 0,
h: parseInt(p[1], 10) || 30, h: parseInt(p[1], 10) || 0,
};
},
mouse: (p) => {
if (!p[0] || !p[1] || !p[2]) throw new Error("mouse requires <x> <y> <down|up|click>");
return {
t: "mouse",
kind: p[2] as "down" | "up" | "click",
x: parseInt(p[0], 10),
y: parseInt(p[1], 10),
};
},
mdrag: (p) => {
if (p.length < 4) throw new Error("mdrag requires <x1> <y1> <x2> <y2>");
return {
t: "mdrag",
x1: parseInt(p[0], 10),
y1: parseInt(p[1], 10),
x2: parseInt(p[2], 10),
y2: parseInt(p[3], 10),
}; };
}, },
}; };
@@ -324,8 +348,13 @@ async function execAction(setup: any, a: Action): Promise<void> {
case "wait": case "wait":
await new Promise((r) => setTimeout(r, a.ms)); await new Promise((r) => setTimeout(r, a.ms));
break; break;
case "resize": case "mouse":
setup.resize(a.w, a.h); if (a.kind === "click") await setup.mockMouse.click(a.x, a.y);
else if (a.kind === "down") await setup.mockMouse.pressDown(a.x, a.y);
else await setup.mockMouse.release(a.x, a.y);
break;
case "mdrag":
await setup.mockMouse.drag(a.x1, a.y1, a.x2, a.y2);
break; break;
} }
await setup.renderOnce(); await setup.renderOnce();
@@ -566,9 +595,7 @@ async function snapshotState(audioControls: any): Promise<Record<string, unknown
const feeds = fs_.feeds ? fs_.feeds() : []; const feeds = fs_.feeds ? fs_.feeds() : [];
state.feed = { state.feed = {
count: feeds?.length ?? 0, count: feeds?.length ?? 0,
sel: fs_.selectedFeedId ? fs_.selectedFeedId() : null,
loading: fs_.isLoadingFeeds ? fs_.isLoadingFeeds() : null, loading: fs_.isLoadingFeeds ? fs_.isLoadingFeeds() : null,
titles: (feeds ?? []).slice(0, 8).map((f: any) => f?.podcast?.title),
}; };
} catch (e) { } catch (e) {
state.feed = "ERR: " + String(e); state.feed = "ERR: " + String(e);

View File

@@ -74,6 +74,43 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
return 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 /** Extract the `<item>` blocks from an RSS document. Matches items directly
* on the full XML string — scoping to <channel> first is a redundant 5MB * 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 * regex pass that doubles parse cost with no practical benefit (well-formed
@@ -122,7 +159,7 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis
const imageUrl = getAttr(item, "itunes:image", "href") || undefined const imageUrl = getAttr(item, "itunes:image", "href") || undefined
const ep: Episode = { const ep: Episode = {
id: `${feedUrl}#${index}`, id: stableEpisodeId(feedUrl, item, audioUrl, index),
podcastId: feedUrl, podcastId: feedUrl,
title: epTitle, title: epTitle,
description: epDescription, description: epDescription,

View File

@@ -16,6 +16,7 @@
import { Show } from "solid-js"; import { Show } from "solid-js";
import { format } from "date-fns"; import { format } from "date-fns";
import type { RGBA } from "@opentui/core"; import type { RGBA } from "@opentui/core";
import { useTerminalDimensions } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { NF_ICONS } from "@/utils/nerd-fonts"; import { NF_ICONS } from "@/utils/nerd-fonts";
@@ -186,6 +187,7 @@ export function EpisodePreview(props: {
}) { }) {
const { theme } = useTheme(); const { theme } = useTheme();
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
const dims = useTerminalDimensions();
return ( return (
<box flexDirection="column" gap={1} padding={1}> <box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}> <text fg={theme.textPrimary ?? theme.text}>
@@ -208,10 +210,14 @@ export function EpisodePreview(props: {
<text fg={muted()}>by {props.author()}</text> <text fg={muted()}>by {props.author()}</text>
</Show> </Show>
<box height={1} /> <box height={1} />
<text fg={theme.textSecondary}> <Show
{props.episode().description?.slice(0, 400) ?? "No description available."} when={props.episode().description}
{(props.episode().description?.length ?? 0) > 400 ? "…" : ""} fallback={<text fg={theme.textSecondary}>No description available.</text>}
</text> >
<scrollbox maxHeight={Math.floor(dims().height * 0.3)}>
<text fg={theme.textSecondary}>{props.episode().description}</text>
</scrollbox>
</Show>
<box height={1} /> <box height={1} />
<text fg={muted()}>{props.hint()}</text> <text fg={muted()}>{props.hint()}</text>
</box> </box>
@@ -221,7 +227,6 @@ export function EpisodePreview(props: {
// ── FetchMorePreview ──────────────────────────────────────────────────────── // ── FetchMorePreview ────────────────────────────────────────────────────────
export function FetchMorePreview(props: { export function FetchMorePreview(props: {
isLoadingMore: () => boolean; isLoadingMore: () => boolean;
fetchMoreMode: () => string;
/** Manual-mode explanation line ("across all feeds" vs "for this show"). */ /** Manual-mode explanation line ("across all feeds" vs "for this show"). */
manualText: () => string; manualText: () => string;
}) { }) {
@@ -235,9 +240,7 @@ export function FetchMorePreview(props: {
<text fg={muted()}> <text fg={muted()}>
{props.isLoadingMore() {props.isLoadingMore()
? "Loading the next batch of episodes…" ? "Loading the next batch of episodes…"
: props.fetchMoreMode() === "auto" : props.manualText()}
? "Auto mode: the next batch loads automatically at the bottom of the list."
: props.manualText()}
</text> </text>
<box height={1} /> <box height={1} />
<text fg={muted()}>enter: load more · h back</text> <text fg={muted()}>enter: load more · h back</text>

View File

@@ -1,28 +1,25 @@
/** /**
* PaneRow — the shared parent | current | preview 3-pane layout primitive. * PaneRow — the shared parent | current | preview 3-pane layout primitive.
* *
* Implements yazi's `mgr.ratio` contract: three columns grow at * Implements yazi's resizable `mgr.ratio` contract: the two borders of the
* 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`, * CENTER (current) column are draggable and resize the neighboring panes.
* so every list tab renders an identical, layout-stable shell. Columns use * Split positions live in the shared pane-layout store (`@/stores/pane-layout`)
* `flexBasis={0}` so the ratio is exact regardless of content width — a * as fractions of the row width; this component resolves them to pixel
* column's content can never stretch its slot. * columns, gives each column an explicit width (so the grab zones sit
* exactly on the drawn borders), and renders two 3-column invisible grab
* zones over the borders.
* *
* Column semantics (per the yazi depth model): * Column semantics (per the yazi depth model):
* parent — the previous-depth list. Renders a muted `—` placeholder and * parent — the previous-depth list. Renders a muted `—` placeholder and
* KEEPS its 20% slot when blank (never collapses to width 0). * keeps a minimum 15-col slot. Borderless.
* Borderless (no left/right/top/bottom edge). Carries the single * current — the current-depth list. The only focusable content column; the
* header row: the CURRENT column's title renders top-left in the * ONLY bordered column — left/right edges only, always muted.
* parent's slot (the panes above current/preview were removed). * preview — detail of the hovered item in `current`. Borderless.
* current — the current-depth list. The only focusable content column; it
* is the ONLY bordered column — left/right edges only, always
* muted (no active-border highlight, focused or not).
* preview — detail of the hovered item in `current`. Borderless, no header.
* *
* The primitive is purely structural: callers pass their own JSX per column * The primitive is purely structural: callers pass their own JSX per column
* (static elements or accessors) plus the current-column title. Theme colors * (static elements or accessors) plus the current-column title. Theme colors
* are resolved internally via `useTheme()`. Only the current column's * are resolved internally via `useTheme()`. Only the current column's
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor (j/k * `<scrollbox>` receives `focused`, so scroll focus follows the cursor.
* stay in the current pane).
* *
* Example: * Example:
* <PaneRow * <PaneRow
@@ -34,11 +31,16 @@
* /> * />
*/ */
import { createMemo, Show } from "solid-js"; import { createMemo, createSignal, Show } from "solid-js";
import type { JSX } from "solid-js"; import type { JSX } from "solid-js";
import { useTerminalDimensions } from "@opentui/solid";
import type { RGBA, BorderSides } from "@opentui/core"; import type { RGBA, BorderSides } from "@opentui/core";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { PANE_RATIO } from "@/utils/navigation"; import {
MIN_PANE_WIDTH,
splitPixels,
usePaneLayout,
} from "@/stores/pane-layout";
// ── Types ─────────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────────
type PaneContent = JSX.Element | (() => JSX.Element); type PaneContent = JSX.Element | (() => JSX.Element);
@@ -46,7 +48,7 @@ type PaneLabel = string | (() => string);
export type PaneRowProps = { export type PaneRowProps = {
/** Parent column content (previous-depth list, or null for a muted /** Parent column content (previous-depth list, or null for a muted
* placeholder — the 1/5 slot is always preserved). */ * placeholder — a minimum slot is always preserved). */
parent?: PaneContent; parent?: PaneContent;
/** Current column content (the focused list). */ /** Current column content (the focused list). */
current?: PaneContent; current?: PaneContent;
@@ -99,7 +101,7 @@ function Placeholder(props: { color: () => RGBA }) {
// ── Pane column ───────────────────────────────────────────────────────────── // ── Pane column ─────────────────────────────────────────────────────────────
function Pane(props: { function Pane(props: {
grow: number; width: number;
label: () => string; label: () => string;
content: () => JSX.Element | undefined; content: () => JSX.Element | undefined;
border: boolean | BorderSides[]; border: boolean | BorderSides[];
@@ -116,8 +118,8 @@ function Pane(props: {
return ( return (
<box <box
flexDirection="column" flexDirection="column"
flexGrow={props.grow} width={props.width}
flexBasis={0} flexShrink={0}
height="100%" height="100%"
> >
{/* ── title row: rendered only when the pane carries a label ────────── */} {/* ── title row: rendered only when the pane carries a label ────────── */}
@@ -158,6 +160,52 @@ function Pane(props: {
); );
} }
/** A 3-column invisible grab zone centered on one border of the current
* pane: the border column plus one column of help padding on each side,
* so the thin border is easy to target with a mouse. `onBegin` is called
* on mousedown with the cursor's x; the row records that grab offset so
* the border stays glued to the cursor while dragging. On hover or while
* dragging it overdraws just the border column with a full-height accent
* `│` line (a bordered box would render as a blocky rectangle instead).
* The two padding columns are transparent; the hit grid is rect-based, so
* they capture clicks too — they must never overlap interactive content. */
function Splitter(props: {
/** Column of the border itself. The strip spans `left - 1` .. `left + 1`
* (the border plus one help-padded column each side); the highlight
* renders at `left`. */
left: number;
active: boolean;
onBegin: (x: number) => void;
}) {
const { theme } = useTheme();
const dims = useTerminalDimensions();
const [hovered, setHovered] = createSignal(false);
const highlighted = () => props.active || hovered();
return (
<box
position="absolute"
left={props.left - 1}
top={0}
width={3}
height="100%"
onMouseDown={(e) => {
e.preventDefault?.();
props.onBegin(e.x);
}}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
>
<Show when={highlighted()}>
{/* Draw the accent edge down the full pane height; the box clips
* any excess rows below the row's bottom edge. */}
<text fg={theme.primary} selectable={false}>
{" │\n".repeat(dims().height)}
</text>
</Show>
</box>
);
}
// ── Row primitive ─────────────────────────────────────────────────────────── // ── Row primitive ───────────────────────────────────────────────────────────
export function PaneRow(props: PaneRowProps) { export function PaneRow(props: PaneRowProps) {
/** true → the current column's scrollbox is focused (scroll follows cursor). */ /** true → the current column's scrollbox is focused (scroll follows cursor). */
@@ -179,20 +227,72 @@ export function PaneRow(props: PaneRowProps) {
// 2-pane mode (parent|current) grows the current column to fill the // 2-pane mode (parent|current) grows the current column to fill the
// preview slot. Defaults to 3 (parent|current|preview). // preview slot. Defaults to 3 (parent|current|preview).
const panes = createMemo(() => props.panes ?? 3); const panes = createMemo(() => props.panes ?? 3);
const currentGrow = createMemo(() =>
panes() === 2
? PANE_RATIO.current + PANE_RATIO.preview
: PANE_RATIO.current,
);
const currentBorder = createMemo<boolean | BorderSides[]>( const currentBorder = createMemo<boolean | BorderSides[]>(
() => props.currentBorder ?? ["left", "right"], () => props.currentBorder ?? ["left", "right"],
); );
// Shared split state + terminal width drive explicit column widths so the
// drag strips sit exactly on the drawn borders.
const layout = usePaneLayout();
const dims = useTerminalDimensions();
const width = () => dims().width;
const pixels = createMemo(() => splitPixels(width(), layout.splits()));
const hasRoom = () =>
width() >=
MIN_PANE_WIDTH.parent + MIN_PANE_WIDTH.current + MIN_PANE_WIDTH.preview;
// Column widths in pixels (sum to the row width).
const parentWidth = () => pixels().leftPx;
const currentWidth = () =>
panes() === 2
? width() - pixels().leftPx
: pixels().rightPx - pixels().leftPx;
const previewWidth = () => width() - pixels().rightPx;
// ── Drag state ──────────────────────────────────────────────────────────
// onMouseDown on a Splitter records which border is being dragged and
// the cursor's grab offset from that border's column; the row then
// lives-updates the split from the drag x (minus the offset, so the
// border stays glued to the cursor) and commits on release.
const [activeSplit, setActiveSplit] = createSignal<"left" | "right" | null>(
null,
);
// Column of the border a strip centers on (the current pane's edge).
const borderCol = (which: "left" | "right") =>
which === "left" ? pixels().leftPx : pixels().rightPx - 1;
// Cursor x relative to the grabbed border column. Set on mousedown and
// subtracted from every drag x so the border tracks the cursor rather
// than jumping to it.
let grabOffset = 0;
const beginDrag = (which: "left" | "right") => (x: number) => {
grabOffset = x - borderCol(which);
setActiveSplit(which);
};
const handleDrag = (e: { x: number }) => {
const which = activeSplit();
if (!which) return;
if (which === "left") layout.setLeft(e.x - grabOffset, width());
else layout.setRight(e.x - grabOffset, width());
};
const handleDragEnd = () => {
if (activeSplit()) layout.commit();
setActiveSplit(null);
grabOffset = 0;
};
return ( return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%"> <box
{/* ── parent (20%) — previous-depth list; title row top-left ────────── */} flexDirection="row"
width="100%"
height="100%"
flexGrow={1}
onMouseDrag={handleDrag}
onMouseDragEnd={handleDragEnd}
onMouseUp={handleDragEnd}
>
{/* ── parent — previous-depth list; title row top-left ─────────────── */}
<Pane <Pane
grow={PANE_RATIO.parent} width={parentWidth()}
label={currentLabel} label={currentLabel}
content={parentContent} content={parentContent}
border={false} border={false}
@@ -200,22 +300,37 @@ export function PaneRow(props: PaneRowProps) {
/> />
{/* ── current — the focused list; left/right borders only ─────────── */} {/* ── current — the focused list; left/right borders only ─────────── */}
<Pane <Pane
grow={currentGrow()} width={currentWidth()}
label={() => ""} label={() => ""}
content={currentContent} content={currentContent}
border={currentBorder()} border={currentBorder()}
scrollFocused={() => focused()} scrollFocused={() => focused()}
/> />
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */} {/* ── preview (optional) — hovered-item detail; no border ─────────── */}
<Show when={panes() === 3}> <Show when={panes() === 3}>
<Pane <Pane
grow={PANE_RATIO.preview} width={previewWidth()}
label={() => ""} label={() => ""}
content={previewContent} content={previewContent}
border={false} border={false}
scrollFocused={() => false} scrollFocused={() => false}
/> />
</Show> </Show>
{/* ── drag handles over the current pane's borders ───────────────── */}
<Show when={hasRoom()}>
<Splitter
left={borderCol("left")}
active={activeSplit() === "left"}
onBegin={beginDrag("left")}
/>
<Show when={panes() === 3}>
<Splitter
left={borderCol("right")}
active={activeSplit() === "right"}
onBegin={beginDrag("right")}
/>
</Show>
</Show>
</box> </box>
); );
} }

View File

@@ -22,6 +22,7 @@ import { useFeedStore } from "@/stores/feed";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useToast } from "@/ui/toast"; import { useToast } from "@/ui/toast";
import { emit, on } from "@/utils/event-bus"; import { emit, on } from "@/utils/event-bus";
import { feedForEpisode } from "@/utils/feed-resolve";
import { LayerGraph } from "@/utils/layer-graph"; import { LayerGraph } from "@/utils/layer-graph";
import { TABS } from "@/utils/navigation"; import { TABS } from "@/utils/navigation";
import { createDispatcher } from "@/utils/dispatch"; import { createDispatcher } from "@/utils/dispatch";
@@ -222,9 +223,7 @@ export function Shell() {
const ep = audio.currentEpisode(); const ep = audio.currentEpisode();
if (!ep) return null; if (!ep) return null;
const feeds = feedStore.getFilteredFeeds(); const feeds = feedStore.getFilteredFeeds();
const feed = const feed = feedForEpisode(feeds, ep);
feeds.find((f) => f.podcast.id === ep.podcastId) ??
feeds.find((f) => f.episodes.some((e) => e.id === ep.id));
return feed return feed
? `${feed.customName || feed.podcast.title}${ep.title}` ? `${feed.customName || feed.podcast.title}${ep.title}`
: `${ep.title}`; : `${ep.title}`;

View File

@@ -67,7 +67,7 @@
"sort": [","], "sort": [","],
"toggle-hidden": ["."], "toggle-hidden": ["."],
"refresh": ["r"], "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 "unsubscribe": ["x"], // unsubscribe focused show in My Shows
// ── Downloads & auto-download whitelist ─────────────────────────────────── // ── Downloads & auto-download whitelist ───────────────────────────────────

View File

@@ -120,6 +120,14 @@ const EMPTY_TERMINAL_COLORS: TerminalColors = {
/** Cached macOS appearance (dark/light), independent of the terminal. */ /** Cached macOS appearance (dark/light), independent of the terminal. */
let cachedOsMode: "dark" | "light" | null = null; 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. * Detect the terminal's dark/light mode.
* *
@@ -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) { if (process.env.TMUX) {
await waitForCapabilities(); 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 ───────────────────────────────────────── // ── dark/light mode detection ─────────────────────────────────────────
// The provider starts with a hardcoded mode (e.g. "dark"); detect the // 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 // 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); 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 // Setup SIGUSR2 signal handler for dynamic theme reload
// This allows external tools to trigger a theme refresh by sending: // This allows external tools to trigger a theme refresh by sending:
// `kill -USR2 <pid>` // `kill -USR2 <pid>`

View File

@@ -13,10 +13,10 @@
* *
* parent | current | preview * parent | current | preview
* *
* Layout ratios (20% : 50% : 30% — PANE_RATIO 2:5:3) live in * Pane sizes are user-resizable (draggable borders in `PaneRow`).
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable* * This module owns only the *focusable* nav model — which column is focused
* nav model — which column is focused and where its list cursor lives. The * and where its list cursor lives. The parent/preview columns are always
* parent/preview columns are always derived, never focused. * derived, never focused.
* *
* The tab list is the app's ROOT and participates in the same pane flow as * The tab list is the app's ROOT and participates in the same pane flow as
* any other pane. View renders at most three panes, `UP | CURRENT | PREVIEW`: * any other pane. View renders at most three panes, `UP | CURRENT | PREVIEW`:

View 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 },
)

View File

@@ -1,8 +1,12 @@
/** /**
* Reactive SolidJS hook wrapping the AudioBackend. * Reactive SolidJS hook over the module-level audio engine.
* *
* Provides signals for playback state and methods for controlling * Wraps utils/audio-engine: every useAudio() call shares ONE engine (all
* audio. Integrates with the event bus and app store. * playback logic, the 150ms poll, session restore, and the event-bus
* commands live there). This hook keeps only what is tied to the Solid
* lifecycle — the ref-counted last-owner dispose and the process-exit
* teardown — and re-exposes the two controls the engine deliberately omits
* (availablePlayers, switchBackend).
* *
* Usage: * Usage:
* ```tsx * ```tsx
@@ -14,117 +18,42 @@
import { onCleanup } from "solid-js"; import { onCleanup } from "solid-js";
import { import {
cachedCoverPath, availablePlayers,
fetchCoverArt, currentEpisode,
} from "../utils/cover-art";
import {
createAudioBackend,
detectPlayers,
type AudioBackend,
type BackendName,
type DetectedPlayer,
} from "../utils/audio-player";
import {
isPlaying,
setIsPlaying,
position,
setPosition,
duration,
setDuration,
volume,
setVolume,
speed, speed,
setSpeed, setSpeed,
backendName, volume,
setBackendName, setVolume,
error,
setError,
currentEpisode,
setCurrentEpisode,
availablePlayers,
setAvailablePlayers,
} from "../utils/audio-signals"; } from "../utils/audio-signals";
import { emit, on } from "../utils/event-bus";
import { useAppStore } from "../stores/app"; import { useAppStore } from "../stores/app";
import { useProgressStore } from "../stores/progress";
import { useMediaRegistry } from "../utils/media-registry"; import { useMediaRegistry } from "../utils/media-registry";
import { saveLastPlayerSync } from "../utils/app-persistence";
import type { BackendName, DetectedPlayer } from "../utils/audio-player";
import { import {
loadLastPlayerFromFile, createAudioEngine,
saveLastPlayerToFile, ensureEngineBackend,
saveLastPlayerSync, disposeEngineBackend,
} from "../utils/app-persistence"; stopEnginePolling,
import type { Episode, Progress } from "../types/episode"; getEngineBackend,
import type { Feed } from "../types/feed"; switchBackend,
import { useAudioNavStore, AudioSource } from "../stores/audio-nav"; restoreLastSession,
import { useDownloadStore } from "../stores/download"; type AudioEngine,
import { useFeedStore } from "../stores/feed"; } from "../utils/audio-engine";
export interface AudioControls { // Re-exported so the session-restore test can pull it from this module.
// Signals (reactive getters) export { restoreLastSession };
isPlaying: () => boolean;
position: () => number; // useAudio() surface: the engine plus the two controls it doesn't expose.
duration: () => number; export type AudioControls = AudioEngine & {
volume: () => number;
speed: () => number;
backendName: () => BackendName;
error: () => string | null;
currentEpisode: () => Episode | null;
availablePlayers: () => DetectedPlayer[]; availablePlayers: () => DetectedPlayer[];
// Actions
play: (episode: Episode) => Promise<void>;
/** Load an episode into the player WITHOUT starting playback. */
load: (episode: Episode) => Promise<void>;
pause: () => Promise<void>;
resume: () => Promise<void>;
togglePlayback: () => Promise<void>;
stop: () => Promise<void>;
seek: (seconds: number) => Promise<void>;
seekRelative: (delta: number) => Promise<void>;
setVolume: (volume: number) => Promise<void>;
setSpeed: (speed: number) => Promise<void>;
switchBackend: (name: BackendName) => Promise<void>; switchBackend: (name: BackendName) => Promise<void>;
prev: () => Promise<void>; };
next: () => Promise<void>;
}
// Singleton state — shared across all components that call useAudio() const engine = createAudioEngine();
let backend: AudioBackend | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null; // Singleton ref count — how many live useAudio() owners there are. The engine
// is shared; the last owner to unmount disposes the backend.
let refCount = 0; let refCount = 0;
let pollCount = 0; // Counts poll ticks for throttling progress saves
// Playback signals are declared in utils/audio-signals.ts (imported above)
// so non-component consumers (the visualizer store) can subscribe without
// mounting a useAudio() owner.
/** True once the current episode has been handed to the backend (play
* started). `false` means the episode is only LOADED in the player (e.g.
* restored at boot) and the first play action must start the backend
* instead of unpausing it. */
let startedPlayback = false;
/** Completion fraction at/above which an episode is NOT restored at boot. */
const RESTORE_COMPLETION_THRESHOLD = 0.98;
/** True when saved progress is below the restore cutoff. Episodes with no
* progress (never reached the persist threshold) or unknown duration count
* as eligible — they restore from the start. */
function isRestoreEligible(progress: Progress | undefined): boolean {
if (!progress || progress.duration <= 0) return true;
return progress.position / progress.duration < RESTORE_COMPLETION_THRESHOLD;
}
function ensureBackend(): AudioBackend {
if (!backend) {
const detected = detectPlayers();
setAvailablePlayers(detected);
backend = createAudioBackend();
setBackendName(backend.name);
registerExitTeardown();
}
return backend;
}
// ── Process-exit teardown ───────────────────────────────────────────── // ── Process-exit teardown ─────────────────────────────────────────────
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's // `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
@@ -139,7 +68,7 @@ function registerExitTeardown(): void {
if (exitTeardownRegistered) return; if (exitTeardownRegistered) return;
exitTeardownRegistered = true; exitTeardownRegistered = true;
const teardown = (): void => { const teardown = (): void => {
stopPolling(); stopEnginePolling();
// Persist "what's loaded in the player right now" synchronously — // Persist "what's loaded in the player right now" synchronously —
// process.exit(0) runs this handler synchronously and an async write // process.exit(0) runs this handler synchronously and an async write
// would never land. The next launch restores this episode paused. // would never land. The next launch restores this episode paused.
@@ -152,7 +81,7 @@ function registerExitTeardown(): void {
/* best-effort at exit */ /* best-effort at exit */
} }
try { try {
backend?.dispose(); getEngineBackend()?.dispose();
} catch { } catch {
/* best-effort at exit */ /* best-effort at exit */
} }
@@ -171,544 +100,24 @@ function registerExitTeardown(): void {
} }
} }
/** Poll ticks between paused-state checks (~1s at 150ms/tick). While the
* UI believes playback is paused we only need to catch an external
* resume (AirPod play tap, lock-screen/media-center play); checking every
* tick would just hammer mpv IPC for nothing. */
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 {
setIsPlaying(false);
stopPolling();
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
}
}
/** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal,
* device swap, OS media keys, the Now Playing center. Bring the UI in
* sync; the poll stays armed so an external resume is caught too. */
function reconcileExternalPause(): void {
setIsPlaying(false);
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
emit("player.pause", { episodeId: ep.id });
const media = useMediaRegistry();
media.setPlaybackState(false);
media.setPosition(position());
}
}
/** Playback was restarted from outside PodTUI (AirPods, lock-screen or
* media-center play, OS media keys). Bring the UI back to "playing". */
function reconcileExternalResume(): void {
setIsPlaying(true);
const ep = currentEpisode();
if (ep) {
emit("player.play", { episodeId: ep.id });
useMediaRegistry().setPlaybackState(true);
}
}
function startPolling(): void {
stopPolling();
pollCount = 0;
// Guard against overlapping ticks if a socket read ever outlives the
// interval (getPosition opens a fresh mpv IPC connection per call).
let pollInFlight = false;
pollTimer = setInterval(async () => {
if (!backend || pollInFlight) return;
pollInFlight = true;
try {
pollCount++;
if (isPlaying()) {
// Track ended (eof-reached observed) or process died. Check
// BEFORE pause reconciliation: mpv keeps the file open at EOF
// and reports pause=true there, which would otherwise be
// mistaken for an external pause and never finalize.
if (!backend.isPlaying()) {
finalizeTrackEnd();
return;
}
// mpv can pause itself outside PodTUI. Reconcile instead of
// staying stuck on "playing" with a frozen waveform
// (getPosition would just re-read the same frozen time-pos).
const paused = await backend.getPauseState();
if (paused === true) {
reconcileExternalPause();
return;
}
const pos = await backend.getPosition();
const dur = await backend.getDuration();
setPosition(pos);
if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (33 ticks * 150ms)
if (pollCount % 33 === 0) {
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
const media = useMediaRegistry();
media.setPosition(pos);
}
}
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
// Paused — watch for playback restarted from outside (AirPods,
// lock-screen/media-center play). Only while the player is
// 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();
return;
}
const paused = await backend.getPauseState();
if (paused === false) {
reconcileExternalResume();
}
}
} catch {
// Backend may have been disposed
} finally {
pollInFlight = false;
}
}, 150);
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// ── Cover art for system Now Playing ─────────────────────────────────────────
// macOS shows the media session's albumart in the audio center; mpv reads it
// 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);
if (!episode.audioUrl) {
setError("No audio URL for this episode");
return;
}
try {
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;
// 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 bounded fetch (covers fetch in ~300ms typically) —
// past the 1.2s cap, play bare and let the fetch warm the cache.
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? episode.imageUrl,
"bounded",
);
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
let startPos = 0;
if (savedProgress && !progressStore.isCompleted(episode.id)) {
startPos = savedProgress.position;
}
await b.play(url, {
volume: vol,
speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
mediaTitle: episode.title,
coverArtPath: coverArtPath ?? undefined,
});
setCurrentEpisode(episode);
setIsPlaying(true);
setPosition(startPos);
setSpeed(spd);
if (episode.duration) setDuration(episode.duration);
startedPlayback = true;
// Remember this episode as "loaded in the player" so the next launch
// can restore it paused (cleared by stop()).
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);
startPolling();
emit("player.play", { episodeId: episode.id });
// Distinct from "player.play" (which also fires on resume): signals a
// fresh episode start so Shell can honor the auto-jump-to-player pref.
emit("player.started", { episodeId: episode.id });
} catch (err) {
setError(err instanceof Error ? err.message : "Playback failed");
setIsPlaying(false);
}
}
/**
* Load an episode into the player WITHOUT starting playback. The player tab
* renders it paused at its saved position; the first play action starts the
* backend from there (see togglePlayback). Used to restore the last player
* session at boot.
*/
async function load(episode: Episode): Promise<void> {
ensureBackend();
setError(null);
setCurrentEpisode(episode);
setIsPlaying(false);
startedPlayback = false;
// Show the saved position so the player tab reflects where playback
// will resume; episodes at/above the completion threshold start from 0.
const progressStore = useProgressStore();
const saved = progressStore.get(episode.id);
const pos = saved && isRestoreEligible(saved) ? saved.position : 0;
setPosition(pos);
if (episode.duration) setDuration(episode.duration);
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
setSpeed(storeSpeed || speed());
// Surface the loaded-but-paused track to the OS media controls.
const feedStore = useFeedStore();
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(false);
if (pos > 0) media.setPosition(pos);
// Preload the episode into the backend PAUSED: mpv opens the stream and
// fills its demuxer cache while parked, so the user's first Play flips
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
// — a failed preload just makes the first play take the cold path.
const downloadStore = useDownloadStore();
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
if (episode.audioUrl && backend) {
// The preload must carry the cover AT LOAD: cover-art-files only
// applies when the file loads, and the runtime video-add fallback
// never becomes an albumart track (verified). Restore already waits
// 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 coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? episode.imageUrl,
"await",
);
const backendSnap = backend;
backendSnap
.preload(url, {
volume: volume(),
speed: storeSpeed || speed(),
startPosition: pos > 0 ? pos : undefined,
mediaTitle: episode.title,
coverArtPath: coverArtPath ?? undefined,
})
.catch(() => {});
}
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
}
async function pause(): Promise<void> {
if (!backend) return;
try {
await backend.pause();
setIsPlaying(false);
// Polling stays armed (paused-watch mode): playback can be resumed
// from OUTSIDE PodTUI — AirPods, lock-screen/media-center play —
// and the poll must be live to catch it.
const ep = currentEpisode();
if (ep) {
// Save progress on pause
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
emit("player.pause", { episodeId: ep.id });
// Update platform media controls
const media = useMediaRegistry();
media.setPlaybackState(false);
media.setPosition(position());
}
} catch (err) {
setError(err instanceof Error ? err.message : "Pause failed");
}
}
async function resume(): Promise<void> {
if (!backend) return;
try {
await backend.resume();
setIsPlaying(true);
startPolling();
const ep = currentEpisode();
if (ep) {
emit("player.play", { episodeId: ep.id });
const media = useMediaRegistry();
media.setPlaybackState(true);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Resume failed");
}
}
async function togglePlayback(): Promise<void> {
if (isPlaying()) {
await pause();
} else if (currentEpisode()) {
if (startedPlayback) {
await resume();
} else {
// Episode is only LOADED (e.g. restored at boot) — the backend
// was never started, so unpausing a dead player would fail
// silently. Start playback from the saved position instead.
const ep = currentEpisode();
if (ep) await play(ep);
}
}
}
async function stop(): Promise<void> {
if (!backend) return;
try {
// Save progress before stopping
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
}
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
startedPlayback = false;
stopPolling();
emit("player.stop", {});
// Player is empty again — nothing to restore on the next launch.
saveLastPlayerToFile({ episodeId: null, timestamp: null });
const media = useMediaRegistry();
media.clearNowPlaying();
} catch (err) {
setError(err instanceof Error ? err.message : "Stop failed");
}
}
async function seek(seconds: number): Promise<void> {
if (!backend) return;
const clamped = Math.max(0, Math.min(seconds, duration()));
try {
await backend.seek(clamped);
setPosition(clamped);
} catch (err) {
setError(err instanceof Error ? err.message : "Seek failed");
}
}
async function seekRelative(delta: number): Promise<void> {
await seek(position() + delta);
}
async function doSetVolume(vol: number): Promise<void> {
const clamped = Math.max(0, Math.min(1, vol));
if (backend) {
try {
await backend.setVolume(clamped);
} catch {
// Some backends can't change volume at runtime
}
}
setVolume(clamped);
// Sync back to app store (persisted to config.json for the next launch).
const appStore = useAppStore();
appStore.updateSettings({ volume: clamped });
}
async function doSetSpeed(spd: number): Promise<void> {
const clamped = Math.max(0.25, Math.min(3, spd));
if (backend) {
try {
await backend.setSpeed(clamped);
} catch {
// Some backends can't change speed at runtime
}
}
setSpeed(clamped);
// Sync back to app store
const appStore = useAppStore();
appStore.updateSettings({ playbackSpeed: clamped });
}
async function switchBackend(name: BackendName): Promise<void> {
const wasPlaying = isPlaying();
const ep = currentEpisode();
const pos = position();
const vol = volume();
const spd = speed();
if (backend) {
stopPolling();
backend.dispose();
backend = null;
}
backend = createAudioBackend(name);
setBackendName(backend.name);
setAvailablePlayers(detectPlayers());
// Resume playback if we were playing
if (wasPlaying && ep && ep.audioUrl) {
try {
const feedStore = useFeedStore();
const feed = feedStore
.feeds()
.find((f) => f.podcast.id === ep.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const url =
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? ep.imageUrl,
"cache",
);
await backend.play(url, {
startPosition: pos,
volume: vol,
speed: spd,
mediaTitle: ep.title,
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);
startedPlayback = true;
startPolling();
} catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed");
setIsPlaying(false);
}
}
}
/** Serialized restore chain: the boot-triggered restore and any explicit
* call run one after another, so a late-finishing earlier restore can never
* overwrite state changed by a later one (and callers can await the latest
* attempt deterministically). */
let restoreChain: Promise<void> = Promise.resolve();
/**
* Boot-time session restore: reload the episode that was loaded in the
* player when the previous run ended (persisted on play/load and at exit),
* paused at its saved position — never autostarted. Episodes at/above the
* completion threshold are skipped. Silently no-ops when there is nothing
* to restore (empty player, unsubscribed show, or completed episode).
*/
export async function restoreLastSession(): Promise<void> {
const attempt = restoreChain.then(async () => {
const marker = await loadLastPlayerFromFile();
if (!marker?.episodeId) return;
// Feeds and progress load asynchronously at boot; wait for both
// before looking the episode up.
await Promise.all([
useProgressStore().whenReady(),
useFeedStore().whenReady(),
]);
const episode = useFeedStore().findEpisode(marker.episodeId);
if (!episode) return;
// Only restore episodes below the completion threshold.
const saved = useProgressStore().get(episode.id);
if (!isRestoreEligible(saved)) return;
await load(episode);
});
// Keep the chain alive even when an attempt fails; the caller awaiting
// this attempt still observes its own outcome.
restoreChain = attempt.catch(() => {});
await attempt;
}
/** /**
* Reactive audio controls hook. * Reactive audio controls hook.
* *
* Returns a singleton — all components share the same playback state. * Returns the shared audio engine wrapped with the two extra controls, so
* Registers event bus listeners and cleans them up with onCleanup. * all components observe the same playback state. The first useAudio()
* owner creates the backend, runs the one-time boot (volume/speed sync +
* session restore) and registers the process-exit teardown; the last
* owner disposes the backend.
*/ */
export function useAudio(): AudioControls { export function useAudio(): AudioControls {
// Initialize backend on first use const engine = createAudioEngine();
ensureBackend(); ensureEngineBackend();
registerExitTeardown();
// Sync initial speed/volume from app store (reuse the previous session's // First owner: sync speed/volume from the persisted settings and restore
// playback levels; defaults are 1x and 100%). // the last player session once (loaded, not playing). Raw signal
// accessors are used here on purpose — this is boot-only, not a user
// volume/speed change, so it must not re-persist to the app store.
if (refCount === 0) { if (refCount === 0) {
const appStore = useAppStore(); const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed; const storeSpeed = appStore.state().settings.playbackSpeed;
@@ -734,160 +143,13 @@ export function useAudio(): AudioControls {
refCount++; refCount++;
// Listen for event bus commands (e.g. from other components)
const unsubPlay = on("player.play", async (data) => {
// External play requests — currently just tracks episodeId.
// Episode lookup would require feed store integration.
});
const unsubStop = on("player.stop", async () => {
if (backend && isPlaying()) {
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
stopPolling();
}
});
// Listen for global multimedia key events (from useMultimediaKeys)
const unsubMediaToggle = on("media.toggle", async () => {
await togglePlayback();
});
const unsubMediaVolUp = on("media.volumeUp", async () => {
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
});
const unsubMediaVolDown = on("media.volumeDown", async () => {
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
});
const unsubMediaSpeed = on("media.speedCycle", async () => {
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
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(() => { onCleanup(() => {
refCount--; refCount--;
unsubPlay();
unsubStop();
unsubMediaToggle();
unsubMediaVolUp();
unsubMediaVolDown();
unsubMediaSpeed();
if (refCount <= 0) { if (refCount <= 0) {
stopPolling(); disposeEngineBackend();
if (backend) {
backend.dispose();
backend = null;
}
// Clear media registry on full teardown
const media = useMediaRegistry();
media.clearNowPlaying();
refCount = 0; refCount = 0;
} }
}); });
return { return { ...engine, availablePlayers, switchBackend };
isPlaying,
position,
duration,
volume,
speed,
backendName,
error,
currentEpisode,
availablePlayers,
play,
load,
pause,
resume,
togglePlayback,
stop,
seek,
seekRelative,
setVolume: doSetVolume,
setSpeed: doSetSpeed,
switchBackend,
prev,
next,
};
} }

View File

@@ -1,7 +1,10 @@
import { onCleanup } from "solid-js";
import { setupTerminalRecovery } from "./utils/terminal-recovery";
import { installNestedScrollBehavior } from "./utils/nested-scroll";
import type { Feed } from "./types/feed" import type { Feed } from "./types/feed"
import type { Episode } from "./types/episode" import type { Episode } from "./types/episode"
const VERSION = "0.6.2"; const VERSION = "0.9.1";
interface CliArgs { interface CliArgs {
version: boolean; version: boolean;
@@ -234,10 +237,13 @@ if (cliArgs.query !== null || cliArgs.play !== null) {
const { NavigationProvider } = await import("./context/NavigationContext"); const { NavigationProvider } = await import("./context/NavigationContext");
const { DialogProvider } = await import("./ui/dialog"); const { DialogProvider } = await import("./ui/dialog");
const { CommandProvider } = await import("./ui/command"); const { CommandProvider } = await import("./ui/command");
// Nested scroll sections favor the innermost one under the cursor.
installNestedScrollBehavior();
function RendererSetup(props: { children: unknown }) { function RendererSetup(props: { children: unknown }) {
const renderer = useRenderer(); const renderer = useRenderer();
renderer.disableStdoutInterception(); renderer.disableStdoutInterception();
onCleanup(setupTerminalRecovery(renderer));
return props.children; return props.children;
} }

View File

@@ -5,18 +5,28 @@
* placeholder (1/5 slot kept). * placeholder (1/5 slot kept).
* depth 1 (current) — podcast results for the drilled category. Parent * depth 1 (current) — podcast results for the drilled category. Parent
* pane = the categories list. * pane = the categories list.
* preview — detail of the hovered item (category summary, or * depth 2 (current) — episodes of the drilled show, fetched on demand
* podcast detail + subscribe action). * 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 * Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (category → results) or subscribes (on a * remains. `l`/Enter drills in (category → results → episodes); `a`
* podcast); `h` pops a depth (noop at 0). j/k move only within the current * subscribes the focused show (enter/l never subscribe — they open the
* column. Moving through categories at depth 0 updates the store's selected * episode list); `h` pops a depth (noop at 0). j/k move only within the
* category so the preview follows. * 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 { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover"; 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 { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { import {
@@ -32,6 +42,7 @@ import type { KeybindActionName } from "@/context/KeybindContext";
import { PaneRow } from "@/components/PaneRow"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { EpisodeRow, EpisodePreview } from "@/components/EpisodeList";
import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker"; import { useSelectionMarker } from "@/hooks/useSelectionMarker";
@@ -41,11 +52,16 @@ function DiscoverPage() {
// Static: detection never changes mid-session. // Static: detection never changes mid-session.
const nerd = supportsNerdFonts(); const nerd = supportsNerdFonts();
const discoverStore = useDiscoverStore(); const discoverStore = useDiscoverStore();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audio = useAudio();
const audioNav = useAudioNavStore();
const { theme } = useTheme(); const { theme } = useTheme();
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
const nav = useNavigation(); const nav = useNavigation();
const marker = useSelectionMarker(); const marker = useSelectionMarker();
const stack = nav.depthStack;
const depth = nav.currentDepth; const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d); 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); podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]); 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 = () => const curLen = () =>
depth() === 0 ? categories().length : podcasts().length; depth() === 0
? categories().length
: depth() === 1
? podcasts().length
: episodes().length;
const ensureFocus = () => { const ensureFocus = () => {
if (categories().length > 0 && focus(0) >= categories().length) if (categories().length > 0 && focus(0) >= categories().length)
nav.setDepthFocus(categories().length - 1, 0); nav.setDepthFocus(categories().length - 1, 0);
if (podcasts().length > 0 && focus(1) >= podcasts().length) if (podcasts().length > 0 && focus(1) >= podcasts().length)
nav.setDepthFocus(podcasts().length - 1, 1); nav.setDepthFocus(podcasts().length - 1, 1);
if (episodes().length > 0 && focus(2) >= episodes().length)
nav.setDepthFocus(episodes().length - 1, 2);
}; };
onMount(ensureFocus); onMount(ensureFocus);
@@ -80,13 +119,56 @@ function DiscoverPage() {
onMount(() => { onMount(() => {
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
if (depth() === 0) return categories()[i]?.id; if (depth() === 0) return categories()[i]?.id;
return podcasts()[i]?.id; if (depth() === 1) return podcasts()[i]?.id;
return episodes()[i]?.id;
}); });
}); });
// ── helpers ──────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────
const formatDate = (d: Date) => format(d, "MMM d, yyyy"); 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 ─────────────────────────────────────────────────────────── // ── drill / open ───────────────────────────────────────────────────────────
function open() { function open() {
if (depth() === 0) { if (depth() === 0) {
@@ -97,9 +179,19 @@ function DiscoverPage() {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
return; return;
} }
if (depth() >= 1) { if (depth() === 1) {
const pod = focusedPodcast(); 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()), "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
open: () => open(), open: () => open(),
"toggle-select": () => { "toggle-select": () => {
if (depth() >= 1) { if (depth() === 1) {
const pod = focusedPodcast(); const pod = focusedPodcast();
if (pod) nav.toggleSelected(pod.id); 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: () => { refresh: () => {
if (depth() >= 2) {
const pod = drilledPodcast();
if (pod) discoverStore.refreshEpisodes(pod).catch(() => {});
return;
}
discoverStore.refresh().catch(() => {}); discoverStore.refresh().catch(() => {});
}, },
}; };
@@ -161,7 +300,9 @@ function DiscoverPage() {
const currentLabel = () => const currentLabel = () =>
depth() === 0 depth() === 0
? "Categories" ? "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) ───────────── // ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// Sibling <Show> blocks per depth (the known-good opentui disposal // Sibling <Show> blocks per depth (the known-good opentui disposal
@@ -174,7 +315,7 @@ function DiscoverPage() {
<Show when={depth() === 0}> <Show when={depth() === 0}>
<TabListPane muted /> <TabListPane muted />
</Show> </Show>
<Show when={depth() >= 1}> <Show when={depth() === 1}>
<For each={categories()}> <For each={categories()}>
{(cat, index) => { {(cat, index) => {
const lf = () => nav.depthFocus(0); const lf = () => nav.depthFocus(0);
@@ -203,6 +344,33 @@ function DiscoverPage() {
}} }}
</For> </For>
</Show> </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> </For>
</Show> </Show>
{/* depth ≥1: results */} {/* depth ≥1: results */}
<Show when={depth() >= 1}> <Show when={depth() === 1}>
<Show <Show
when={podcasts().length > 0} when={podcasts().length > 0}
fallback={ fallback={
@@ -304,11 +472,59 @@ function DiscoverPage() {
</For> </For>
<Show when={discoverStore.isLoading()}> <Show when={discoverStore.isLoading()}>
<box paddingLeft={2} paddingTop={1}> <box paddingLeft={2} paddingTop={1}>
<LoadingIndicator label="Refreshing…" /> <LoadingIndicator />
</box> </box>
</Show> </Show>
</Show> </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> </box>
)} )}
</Show> </Show>
) : ( ) : depth() === 1 ? (
// depth 1 preview: hovered podcast + subscribe // depth 1 preview: hovered podcast + episode-list hint
<Show <Show
when={focusedPodcast()} when={focusedPodcast()}
fallback={ fallback={
@@ -376,10 +592,10 @@ function DiscoverPage() {
<text fg={muted()}>by {pod().author}</text> <text fg={muted()}>by {pod().author}</text>
</Show> </Show>
<Show when={pod().isSubscribed}> <Show when={pod().isSubscribed}>
<text fg={theme.success}> Subscribed</text> <text fg={theme.success}> Subscribed · x: unsubscribe</text>
</Show> </Show>
<Show when={!pod().isSubscribed}> <Show when={!pod().isSubscribed}>
<text fg={theme.primary}>[+] Subscribe (enter)</text> <text fg={theme.primary}>a: subscribe</text>
</Show> </Show>
<box height={1} /> <box height={1} />
<text fg={theme.textSecondary}> <text fg={theme.textSecondary}>
@@ -398,10 +614,67 @@ function DiscoverPage() {
</Show> </Show>
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text> <text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
<box height={1} /> <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> </box>
)} )}
</Show> </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 ( return (

View File

@@ -19,7 +19,6 @@
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js"; import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download"; import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { prefetchCoverArt } from "@/utils/cover-art"; import { prefetchCoverArt } from "@/utils/cover-art";
import { DownloadStatus } from "@/types/episode"; import { DownloadStatus } from "@/types/episode";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
@@ -86,10 +85,7 @@ function FeedPage() {
// ── Fetch More ─────────────────────────────────────────────────────────── // ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of the list advances every feed's // A "[Fetch More]" row at the bottom of the list advances every feed's
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode: // loaded window by 50 episodes. Enter on the row to load the next batch.
// reaching the bottom row fetches automatically (see the effect below).
const app = useAppStore();
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
const showFetchMore = () => feedStore.hasMoreAcrossAll(); const showFetchMore = () => feedStore.hasMoreAcrossAll();
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0); const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focus = () => nav.depthFocus(0); const focus = () => nav.depthFocus(0);
@@ -107,22 +103,36 @@ function FeedPage() {
focusedOnMore() ? undefined : episodes()[focusedEpIdx()]; focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
const curLen = () => rowCount(); const curLen = () => rowCount();
// ── 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 = () => { const ensureFocus = () => {
if (rowCount() > 0 && focus() >= rowCount()) if (rowCount() > 0 && focus() >= rowCount())
nav.setDepthFocus(rowCount() - 1, 0); nav.setDepthFocus(rowCount() - 1, 0);
}; };
onMount(ensureFocus); onMount(ensureFocus);
// Auto mode: reaching the bottom row loads the next batch. Guarded by
// isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreAllFeeds().catch(() => {});
});
onMount(() => { onMount(() => {
nav.registerResolver( nav.registerResolver(
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, `${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
@@ -245,17 +255,22 @@ function FeedPage() {
</text> </text>
} }
> >
<LoadingIndicator label="Refreshing…" /> <LoadingIndicator />
</Show> </Show>
</box> </box>
} }
> >
<For each={episodes()}> {/* 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) => ( {(item, index) => (
<EpisodeRow <EpisodeRow
episode={item.episode} episode={item.episode}
subtitle={() => item.feed.customName || item.feed.podcast.title} subtitle={() => item.feed.customName || item.feed.podcast.title}
index={index} index={() => listWindow()[0] + index()}
focused={focusedEpIdx} focused={focusedEpIdx}
active={isActive} active={isActive}
selected={() => nav.isSelected(item.episode.id)} selected={() => nav.isSelected(item.episode.id)}
@@ -264,11 +279,14 @@ function FeedPage() {
marker={marker} marker={marker}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0); nav.setDepthFocus(listWindow()[0] + index(), 0);
}} }}
/> />
)} )}
</For> </For>
<Show when={episodes().length - listWindow()[1] > 0}>
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
</Show>
<Show when={showFetchMore()}> <Show when={showFetchMore()}>
<FetchMoreRow <FetchMoreRow
index={() => episodes().length} index={() => episodes().length}
@@ -286,7 +304,7 @@ function FeedPage() {
</Show> </Show>
<Show when={feedStore.isLoadingFeeds()}> <Show when={feedStore.isLoadingFeeds()}>
<box alignItems="center" paddingTop={1}> <box alignItems="center" paddingTop={1}>
<LoadingIndicator label="Refreshing…" /> <LoadingIndicator />
</box> </box>
</Show> </Show>
</Show> </Show>
@@ -305,7 +323,6 @@ function FeedPage() {
<Show when={focusedOnMore()}> <Show when={focusedOnMore()}>
<FetchMorePreview <FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()} isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode}
manualText={() => manualText={() =>
"Load the next batch of older episodes across all feeds (Enter)." "Load the next batch of older episodes across all feeds (Enter)."
} }

View File

@@ -6,15 +6,16 @@
* depth 1 (current) — episodes of the drilled show. Parent pane = shows. * depth 1 (current) — episodes of the drilled show. Parent pane = shows.
* preview — detail of the hovered item in the current column. * preview — detail of the hovered item in the current column.
* *
* Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior * Depth 0's shows list and depth 1's episode list both end with a
* as the Feed tab) that loads the next batch of episodes for that show. * "[Fetch More]" row that loads the next batch of episodes — depth 0 for
* every subscribed show, depth 1 for the drilled show.
* *
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX * Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at * remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
* 0). j/k move only within the current column. * 0). j/k move only within the current column.
*/ */
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js"; import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import type { RGBA } from "@opentui/core"; import type { RGBA } from "@opentui/core";
import { useFeedStore } from "@/stores/feed"; import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download"; import { useDownloadStore } from "@/stores/download";
@@ -89,13 +90,23 @@ function ShowRow(props: {
backgroundColor={bg()} backgroundColor={bg()}
onMouseDown={props.onMouseDown} onMouseDown={props.onMouseDown}
> >
<text fg={fg()}>{isFocused() ? props.marker() : " "}</text> <text flexShrink={0} fg={fg()}>
<text fg={fg()}>{props.title}</text> {isFocused() ? props.marker() : " "}
<text fg={isFocused() ? theme.surface : muted()}> </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}) ({props.feed.episodes.length})
</text> </text>
<Show when={props.wlScope()}> <Show when={props.wlScope()}>
<text <text
flexShrink={0}
fg={ fg={
isFocused() isFocused()
? theme.surface ? theme.surface
@@ -259,20 +270,35 @@ export function MyShowsPage() {
// entry drops out the moment the user subscribes to its show. // entry drops out the moment the user subscribes to its show.
const unsubs = () => downloadStore.getUnsubscribedDownloads(); const unsubs = () => downloadStore.getUnsubscribedDownloads();
const depth0Count = () => shows().length + unsubs().length; /** True while some subscribed show has more episodes to load — shows the
* depth-0 "[Fetch More]" row. */
const showLoadMore = () => feedStore.hasMoreAcrossAll();
const depth0Count = () =>
shows().length + unsubs().length + (showLoadMore() ? 1 : 0);
const focusedRow0 = () =>
depth0Count() === 0 ? 0 : Math.min(focus(0), depth0Count() - 1);
/** True while the depth-0 cursor sits on the "[Fetch More]" row. */
const focusedOnMore0 = () =>
showLoadMore() && focusedRow0() === shows().length + unsubs().length;
const focusedShowIdx = () => const focusedShowIdx = () =>
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1); focusedOnMore0()
? -1
: Math.min(focusedRow0(), Math.max(shows().length - 1, 0));
/** True when the depth-0 cursor sits on an unsubscribed-show download /** True when the depth-0 cursor sits on an unsubscribed-show download
* row (past the shows list). */ * row (past the shows list). */
const focusedOnUnsub = () => const focusedOnUnsub = () =>
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0; !focusedOnMore0() &&
depth() === 0 &&
focusedRow0() >= shows().length &&
unsubs().length > 0;
const focusedUnsub = (): DownloadedEpisode | undefined => { const focusedUnsub = (): DownloadedEpisode | undefined => {
if (!focusedOnUnsub()) return undefined; if (!focusedOnUnsub()) return undefined;
return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)]; return unsubs()[
Math.min(focusedRow0() - shows().length, unsubs().length - 1)
];
}; };
const selectedShow = (): Feed | undefined => { const selectedShow = (): Feed | undefined => {
if (focusedOnUnsub()) return undefined; if (focusedOnUnsub() || focusedOnMore0()) return undefined;
return shows()[focusedShowIdx()]; return shows()[focusedShowIdx()];
}; };
@@ -290,10 +316,7 @@ export function MyShowsPage() {
// ── Fetch More ─────────────────────────────────────────────────────────── // ── Fetch More ───────────────────────────────────────────────────────────
// A "[Fetch More]" row at the bottom of a drilled show's episode list // A "[Fetch More]" row at the bottom of a drilled show's episode list
// advances that show's loaded window by 50 episodes — the per-show // advances that show's loaded window by 50 episodes — the per-show
// counterpart to the Feed page's row (which loads every feed). manual // counterpart to the Feed page's row (which loads every feed).
// mode: Enter on the row. auto mode: reaching the bottom row fetches
// automatically (see the effect below).
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
const showFetchMore = () => const showFetchMore = () =>
depth() >= 1 && depth() >= 1 &&
!!drilledShowId() && !!drilledShowId() &&
@@ -312,6 +335,30 @@ export function MyShowsPage() {
const focusedEpisode = () => const focusedEpisode = () =>
focusedOnMore() ? undefined : episodes()[focusedEpIdx()]; focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
// ── 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()); const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
const ensureFocus = () => { const ensureFocus = () => {
@@ -332,17 +379,6 @@ export function MyShowsPage() {
}); });
}); });
// Auto mode: reaching the bottom of a drilled show's list loads its next
// batch. Guarded by isLoadingMore so concurrent loads never stack.
createEffect(() => {
if (depth() < 1) return;
if (fetchMoreMode() !== "auto") return;
if (!showFetchMore()) return;
if (feedStore.isLoadingMore()) return;
if (focusedRow() < rowCount() - 1) return;
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
});
// ── helpers ───────────────────────────────────────────────────────────────── // ── helpers ─────────────────────────────────────────────────────────────────
const downloadLabel = (id: string) => { const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) { switch (downloadStore.getDownloadStatus(id)) {
@@ -397,6 +433,10 @@ export function MyShowsPage() {
// ── drill / open ─────────────────────────────────────────────────────────── // ── drill / open ───────────────────────────────────────────────────────────
function open() { function open() {
if (depth() === 0) { if (depth() === 0) {
if (focusedOnMore0()) {
feedStore.loadMoreAllFeeds().catch(() => {});
return;
}
const d = focusedUnsub(); const d = focusedUnsub();
if (d) { if (d) {
playUnsubscribedDownload(d); playUnsubscribedDownload(d);
@@ -537,9 +577,17 @@ export function MyShowsPage() {
paddingRight={1} paddingRight={1}
backgroundColor={focused() ? theme.border : undefined} backgroundColor={focused() ? theme.border : undefined}
> >
<text fg={fg()}>{focused() ? marker() : " "}</text> <text flexShrink={0} fg={fg()}>
<text fg={fg()}>{showTitle(feed)}</text> {focused() ? marker() : " "}
<text fg={muted()}>({feed.episodes.length})</text> </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>
</box> </box>
); );
}} }}
@@ -597,7 +645,7 @@ export function MyShowsPage() {
<UnsubscribedRow <UnsubscribedRow
d={d} d={d}
index={() => shows().length + index()} index={() => shows().length + index()}
focused={() => nav.depthFocus(0)} focused={focusedRow0}
active={isActive} active={isActive}
marker={marker} marker={marker}
downloadLabel={() => downloadLabel(d.episodeId)} downloadLabel={() => downloadLabel(d.episodeId)}
@@ -610,8 +658,23 @@ export function MyShowsPage() {
)} )}
</For> </For>
</Show> </Show>
<Show when={showLoadMore()}>
<FetchMoreRow
index={() => shows().length + unsubs().length}
focused={focusedRow0}
onMore={focusedOnMore0}
active={isActive}
isLoadingMore={() => feedStore.isLoadingMore()}
nerd={nerd}
marker={marker}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(shows().length + unsubs().length, 0);
}}
/>
</Show> </Show>
</Show> </Show>
</Show>
{/* depth ≥1: episodes */} {/* depth ≥1: episodes */}
<Show when={depth() >= 1}> <Show when={depth() >= 1}>
<Show <Show
@@ -622,11 +685,17 @@ export function MyShowsPage() {
</box> </box>
} }
> >
<For each={episodes()}> {/* 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) => ( {(ep, index) => (
<EpisodeRow <EpisodeRow
episode={ep} episode={ep}
index={index} index={() => listWindow()[0] + index()}
focused={focusedEpIdx} focused={focusedEpIdx}
active={isActive} active={isActive}
selected={() => nav.isSelected(ep.id)} selected={() => nav.isSelected(ep.id)}
@@ -635,11 +704,14 @@ export function MyShowsPage() {
marker={marker} marker={marker}
onMouseDown={() => { onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE); nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 1); nav.setDepthFocus(listWindow()[0] + index(), 1);
}} }}
/> />
)} )}
</For> </For>
<Show when={episodes().length - listWindow()[1] > 0}>
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
</Show>
<Show when={showFetchMore()}> <Show when={showFetchMore()}>
<FetchMoreRow <FetchMoreRow
index={() => episodes().length} index={() => episodes().length}
@@ -687,45 +759,56 @@ export function MyShowsPage() {
const previewContent = () => const previewContent = () =>
depth() === 0 ? ( depth() === 0 ? (
// depth 0 preview: hovered unsubscribed-show download, else the // depth 0 preview: hovered "[Fetch More]" row, else the
// hovered show. // unsubscribed-show download, else the hovered show.
<Show <>
when={focusedUnsub()} <Show when={focusedOnMore0()}>
fallback={ <FetchMorePreview
<Show isLoadingMore={() => feedStore.isLoadingMore()}
when={selectedShow()} manualText={() =>
fallback={ "Load the next batch of older episodes across all subscribed shows (Enter)."
<box padding={1}> }
<text fg={muted()}>No show focused</text> />
</box>
}
>
{(show) => (
<ShowPreview
show={() => show()}
title={() => showTitle(show())}
hint={() => showHint(show())}
/>
)}
</Show>
}
>
{(d) => (
<UnsubscribedPreview
d={() => d()}
downloadLabel={() => downloadLabel(d().episodeId)}
downloadColor={() => downloadColor(d().episodeId)}
/>
)}
</Show> </Show>
) : ( <Show when={!focusedOnMore0()}>
<Show
when={focusedUnsub()}
fallback={
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg={muted()}>No show focused</text>
</box>
}
>
{(show) => (
<ShowPreview
show={() => show()}
title={() => showTitle(show())}
hint={() => showHint(show())}
/>
)}
</Show>
}
>
{(d) => (
<UnsubscribedPreview
d={() => d()}
downloadLabel={() => downloadLabel(d().episodeId)}
downloadColor={() => downloadColor(d().episodeId)}
/>
)}
</Show>
</Show>
</>
) : (
// depth ≥1 preview: hovered episode (or the Fetch More row) // depth ≥1 preview: hovered episode (or the Fetch More row)
<> <>
<Show when={focusedOnMore()}> <Show when={focusedOnMore()}>
<FetchMorePreview <FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()} isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode} manualText={() =>
manualText={() =>
"Load the next batch of older episodes for this show (Enter)." "Load the next batch of older episodes for this show (Enter)."
} }
/> />

View File

@@ -19,6 +19,7 @@ import { useVisualizer } from "@/stores/visualizer";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext"; import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
import { useTerminalDimensions } from "@opentui/solid";
import { PaneRow } from "@/components/PaneRow"; import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel"; import { TabListPane } from "@/components/TabPanel";
@@ -29,6 +30,7 @@ export function PlayerPage() {
const { theme } = useTheme(); const { theme } = useTheme();
const nav = useNavigation(); const nav = useNavigation();
const viz = useVisualizer(); const viz = useVisualizer();
const dims = useTerminalDimensions();
const app = useAppStore(); const app = useAppStore();
const muted = () => theme.muted || theme.text; const muted = () => theme.muted || theme.text;
// Settings master switch: off hides the waveform entirely (the store // Settings master switch: off hides the waveform entirely (the store
@@ -89,9 +91,14 @@ export function PlayerPage() {
<text fg={theme.text}> <text fg={theme.text}>
<strong>{ep().title}</strong> <strong>{ep().title}</strong>
</text> </text>
<text fg={muted()}> <Show
{ep().description?.slice(0, 500) ?? "No description available."} when={ep().description}
</text> fallback={<text fg={muted()}>No description available.</text>}
>
<scrollbox maxHeight={Math.floor(dims().height * 0.3)}>
<text fg={muted()}>{ep().description}</text>
</scrollbox>
</Show>
<ProgressBar /> <ProgressBar />

View File

@@ -10,6 +10,7 @@ import { useTerminalDimensions } from "@opentui/solid";
import type { Renderable } from "@opentui/core"; import type { Renderable } from "@opentui/core";
import { useAudio } from "@/hooks/useAudio"; import { useAudio } from "@/hooks/useAudio";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { usePaneLayout } from "@/stores/pane-layout";
// ── Component ──────────────────────────────────────────────────────── // ── Component ────────────────────────────────────────────────────────
@@ -17,16 +18,19 @@ export function ProgressBar() {
const audio = useAudio(); const audio = useAudio();
const { theme } = useTheme(); const { theme } = useTheme();
const dimensions = useTerminalDimensions(); const dimensions = useTerminalDimensions();
const layout = usePaneLayout();
// The bar's renderable, captured for its absolute left edge: MouseEvent.x // The bar's renderable, captured for its absolute left edge: MouseEvent.x
// is terminal-absolute (not bar-relative), so local x needs the offset // is terminal-absolute (not bar-relative), so local x needs the offset
// of the bar inside the 2-pane row (parent pane ≈ 20% of the width). // of the bar inside the 2-pane row (parent pane = left split of the width).
let bar: Renderable | undefined; let bar: Renderable | undefined;
// Full content width of the player pane: the player is a 2-pane row // Full content width of the player pane: the player is a 2-pane row
// (parent 1/5 + current 4/5 of the terminal width). Subtract ~8 chars // (parent = left split, current = the rest of the terminal width). Track
// the live split so a dragged border re-sizes the bar. Subtract ~8 chars
// of border/padding chrome (same math as RealtimeWaveform's numBars). // of border/padding chrome (same math as RealtimeWaveform's numBars).
const width = () => Math.max(8, Math.floor((dimensions().width * 4) / 5) - 8); const width = () =>
Math.max(8, Math.floor(dimensions().width * (1 - layout.splits().left)) - 8);
const clamp01 = (value: number) => Math.max(0, Math.min(1, value)); const clamp01 = (value: number) => Math.max(0, Math.min(1, value));

View File

@@ -8,8 +8,9 @@
* *
* This component only subscribes to store state, reports the width-derived * This component only subscribes to store state, reports the width-derived
* bar count (terminal resize re-inits the running pipeline), and renders: * bar count (terminal resize re-inits the running pipeline), and renders:
* a braille spinner while the pipeline is loading its first frames, the * a braille spinner while the pipeline is loading its first frames or the
* frequency bars once frames arrive, and a dotted placeholder when idle. * player is stalled (re-buffering), the frequency bars once frames arrive,
* and a dotted placeholder when idle.
*/ */
import { createEffect, on } from "solid-js"; import { createEffect, on } from "solid-js";
@@ -18,7 +19,7 @@ import { useVisualizer } from "@/stores/visualizer";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { LoadingIndicator } from "@/components/LoadingIndicator"; import { LoadingIndicator } from "@/components/LoadingIndicator";
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping"; import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
import { PANE_RATIO } from "@/utils/navigation"; import { usePaneLayout } from "@/stores/pane-layout";
// ── Component ──────────────────────────────────────────────────────── // ── Component ────────────────────────────────────────────────────────
@@ -26,20 +27,16 @@ export function RealtimeWaveform() {
const { theme } = useTheme(); const { theme } = useTheme();
const viz = useVisualizer(); const viz = useVisualizer();
// Bar count scales with terminal width so the waveform fills its pane.
// The player is a 2-pane row: current column = (current+preview) of
// (parent+current+preview) of the terminal width. Subtract ~8 chars of
// chrome (scrollbox border + box padding + waveform border + padding).
// Falls back to 64 before the renderer reports a real size.
const dimensions = useTerminalDimensions(); const dimensions = useTerminalDimensions();
const layout = usePaneLayout();
const numBars = () => { const numBars = () => {
const total = PANE_RATIO.parent + PANE_RATIO.current + PANE_RATIO.preview;
const current = PANE_RATIO.current + PANE_RATIO.preview; // 2-pane grows current
const width = dimensions().width; const width = dimensions().width;
if (!width) return 64; if (!width) return 64;
// The player is a 2-pane row: the current column = whole width minus
// the parent (left split). Subtract ~8 chars of chrome.
return Math.max( return Math.max(
8, 8,
Math.min(256, Math.floor((width * current) / total) - 8), Math.min(256, Math.floor(width * (1 - layout.splits().left)) - 8),
); );
}; };
@@ -53,12 +50,13 @@ export function RealtimeWaveform() {
const bars = viz.barData(); const bars = viz.barData();
const count = numBars(); const count = numBars();
// Loading state: the braille spinner shows while the pipeline warms // Loading state: the braille spinner shows while the pipeline is
// up — but only when there are no bars to render yet (first play / // warming up — cold start (first play / after an unload), resume
// after an unload). On resume/seek the last bars stay on screen // into undecoded audio, or a stalled position clock (mpv
// until fresh frames arrive, so the waveform never blanks out for // re-buffering after a long pause on a network stream). The store
// the (multi-second, network-bound) cold start. // clears it the moment the first fresh frame renders, so stale
if (bars.length === 0 && viz.isLoading()) { // bars never masquerade as live data while the pipeline re-arms.
if (viz.isLoading() || viz.isStalled()) {
return <LoadingIndicator />; return <LoadingIndicator />;
} }

View File

@@ -221,7 +221,7 @@ export function usePreferencesItems(): SettingItem[] {
kind: "select", kind: "select",
display: () => cacheModeLabel(prefs().episodeCacheMode), display: () => cacheModeLabel(prefs().episodeCacheMode),
help: () => 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) => { cycle: (dir) => {
const idx = CACHE_MODE_LABELS.findIndex( const idx = CACHE_MODE_LABELS.findIndex(
(s) => s.value === prefs().episodeCacheMode, (s) => s.value === prefs().episodeCacheMode,
@@ -288,20 +288,6 @@ export function usePreferencesItems(): SettingItem[] {
/> />
), ),
}, },
{
id: "fetchMore",
label: "Fetch More",
kind: "select",
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
help: () =>
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: auto\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
const idx = modes.indexOf(prefs().fetchMoreMode ?? "auto");
const next = modes[(idx + dir + modes.length) % modes.length];
app.updatePreferences({ fetchMoreMode: next });
},
},
{ {
id: "refreshInterval", id: "refreshInterval",
label: "Feed Refresh Interval", label: "Feed Refresh Interval",

View File

@@ -43,11 +43,11 @@ const defaultPreferences: UserPreferences = {
autoDownloadScope: "all", autoDownloadScope: "all",
autoDownloadWhitelist: [], autoDownloadWhitelist: [],
autoJumpToPlayer: true, autoJumpToPlayer: true,
fetchMoreMode: "auto",
refreshIntervalMinutes: 30, refreshIntervalMinutes: 30,
episodeCacheMode: "date", episodeCacheMode: "date",
episodeCacheCount: 25, episodeCacheCount: 25,
episodeCacheDays: 60, episodeCacheDays: 60,
paneSplit: { left: 0.2, right: 0.7 },
}; };
const defaultState: AppState = { const defaultState: AppState = {

View File

@@ -11,6 +11,7 @@
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import type { Podcast } from "../types/podcast"; import type { Podcast } from "../types/podcast";
import type { Episode } from "../types/episode";
import { useFeedStore } from "./feed"; import { useFeedStore } from "./feed";
export interface DiscoverCategory { export interface DiscoverCategory {
@@ -42,6 +43,10 @@ const FEATURED_JSON_URL =
/** Cache window for the remote featured list (24 hours) */ /** Cache window for the remote featured list (24 hours) */
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000; 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 */ /** Shape of a single entry in the remote JSON */
interface FeaturedEntry { interface FeaturedEntry {
id: string; id: string;
@@ -90,6 +95,19 @@ export function createDiscoverStore() {
const [isLoading, setIsLoading] = createSignal(false); const [isLoading, setIsLoading] = createSignal(false);
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]); 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) // In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
let cachedAt = 0; let cachedAt = 0;
@@ -174,13 +192,64 @@ export function createDiscoverStore() {
); );
}; };
const toggleSubscription = (podcastId: string) => { // ── episode preview (drill-in, no subscription) ──────────────────────────
const podcast = podcasts().find((p) => p.id === podcastId); /** Cached episode list for a previewed show (empty until first drill-in). */
if (podcast?.isSubscribed) { const episodesForPodcast = (podcastId: string): Episode[] =>
unsubscribe(podcastId); previewEpisodes()[podcastId] ?? [];
} else {
subscribe(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 { return {
@@ -195,8 +264,14 @@ export function createDiscoverStore() {
setSelectedCategory, setSelectedCategory,
subscribe, subscribe,
unsubscribe, unsubscribe,
toggleSubscription,
refresh, refresh,
// Episode preview (drill-in, no subscription)
episodesForPodcast,
isLoadingEpisodesFor,
previewError,
openEpisodes,
refreshEpisodes,
}; };
} }

View File

@@ -4,38 +4,47 @@
*/ */
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import { Effect } from "effect";
import { refreshFeedsBatch } from "../effects/feed-refresh";
import { FeedVisibility } from "../types/feed"; import { FeedVisibility } from "../types/feed";
import type { Feed, FeedFilter, FeedSortField } from "../types/feed"; import type { Feed } from "../types/feed";
import type { Podcast } from "../types/podcast"; import type { Podcast } from "../types/podcast";
import type { Episode } from "../types/episode"; import type { Episode } from "../types/episode";
import type { PodcastSource } from "../types/source"; import type { PodcastSource } from "../types/source";
import { DEFAULT_SOURCES } from "../types/source"; import { DEFAULT_SOURCES } from "../types/source";
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser"; import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
import { FETCH_TIMEOUT_MS, fetchFeedXml } from "../utils/rss-client";
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver"; import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
import { savePodcastIndexCredentials } from "../utils/source-credentials"; import { savePodcastIndexCredentials } from "../utils/source-credentials";
import { mergeEpisodesBounded } from "../utils/episode-merge"; import { mergeEpisodesBounded } from "../utils/episode-merge";
import { import {
episodeInWindow, episodeKeepFn,
episodeTs,
dateFetchMoreCutoff,
dateBandCount,
sameRefreshWindow,
} from "../utils/episode-windows";
import { createSourceRegistry } from "../utils/source-registry";
import { createPersistScheduler } from "./persist";
import {
DEFAULT_EPISODE_WINDOW_DAYS,
loadFeedsFromFile, loadFeedsFromFile,
saveFeedsToFile, saveFeedsToFile,
loadSourcesFromFile,
saveSourcesToFile, saveSourcesToFile,
loadSourcesFromFile,
} from "../utils/feeds-persistence"; } from "../utils/feeds-persistence";
import { useActivityStore } from "./activity"; import { useActivityStore } from "./activity";
import { useDownloadStore } from "./download"; import { useDownloadStore } from "./download";
import { useAppStore } from "./app"; import { useAppStore } from "./app";
import { DownloadStatus } from "../types/episode"; 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 episode-windows' fetch-more band instead). */
const MAX_EPISODES_REFRESH = 50; const MAX_EPISODES_REFRESH = 50;
/** Max episodes to fetch on initial subscribe */ /** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20; const MAX_EPISODES_SUBSCRIBE = 20;
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
* the background refresh loop. */
const FETCH_TIMEOUT_MS = 20_000;
/** Bounds simultaneous RSS requests during a refresh batch — a hung feed /** Bounds simultaneous RSS requests during a refresh batch — a hung feed
* burns at most one slot for FETCH_TIMEOUT_MS instead of pinning the whole * burns at most one slot for FETCH_TIMEOUT_MS instead of pinning the whole
* batch. */ * batch. */
@@ -111,36 +120,21 @@ const fullEpisodeCache = new Map<string, Episode[]>();
* holds — when it reaches the cache length, hasMoreEpisodes flips false. */ * holds — when it reaches the cache length, hasMoreEpisodes flips false. */
const episodeLoadCount = new Map<string, number>(); const episodeLoadCount = new Map<string, number>();
/** Read the episode cache bound from preferences: a closure that decides /** Write closure for the persist scheduler — reads the live feed signal
* whether the episode at `index` (0 = newest, after sort) is kept. */ * (wired by createFeedStore) so a flush always lands the latest value. */
function episodeKeepFn(prefs: { let readFeeds: () => Feed[] = () => [];
episodeCacheMode: "date" | "count";
episodeCacheCount: number;
episodeCacheDays: number;
}): (ep: Episode, index: number) => boolean {
const now = new Date();
if (prefs.episodeCacheMode === "count") {
const count = Math.max(1, prefs.episodeCacheCount);
return (_ep: Episode, index: number) => index < count;
}
const days = Math.max(1, prefs.episodeCacheDays);
return (ep: Episode) => episodeInWindow(ep, now, days);
}
/** Save feeds to file (async, fire-and-forget). */ /** Shared trailing-edge debouncer for config.json writes ("feeds" domain);
function saveFeeds(feeds: Feed[]): void { * sources persist immediately instead. */
const persistScheduler = createPersistScheduler(() => {
const prefs = useAppStore().state().preferences; const prefs = useAppStore().state().preferences;
const days = saveFeedsToFile(
readFeeds(),
prefs.episodeCacheMode === "date" prefs.episodeCacheMode === "date"
? Math.max(1, prefs.episodeCacheDays) ? Math.max(1, prefs.episodeCacheDays)
: undefined; : undefined,
saveFeedsToFile(feeds, days); );
} });
/** Save sources to file (async, fire-and-forget) */
function saveSources(sources: PodcastSource[]): void {
saveSourcesToFile(sources);
}
/** Move plaintext apiKey/apiSecret (pre-keychain persistence) into the macOS /** Move plaintext apiKey/apiSecret (pre-keychain persistence) into the macOS
* keychain, marking the source hasCredentials and stripping the plaintext. * keychain, marking the source hasCredentials and stripping the plaintext.
@@ -186,145 +180,38 @@ async function migratePlaintextCredentials(
return changed ? migrated : sources; return changed ? migrated : sources;
} }
/** True when the freshly fetched window matches the corresponding PREFIX of
* the existing episode list (id-set equality, order-insensitive). With
* 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 {
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));
}
/** 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;
}
function createFeedStore() { function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]); const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([ readFeeds = () => feeds();
...DEFAULT_SOURCES, const registry = createSourceRegistry(DEFAULT_SOURCES);
]);
const [filter, setFilter] = createSignal<FeedFilter>({
visibility: "all",
sortBy: "updated" as FeedSortField,
sortDirection: "desc",
});
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
const [isLoadingMore, setIsLoadingMore] = createSignal(false); const [isLoadingMore, setIsLoadingMore] = createSignal(false);
const [isLoadingFeeds, setIsLoadingFeeds] = 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. */
const SAVE_DEBOUNCE_MS = 250;
/** True when a save is scheduled but has not flushed yet. */
let savePending = false;
let pendingSaveTimer: ReturnType<typeof setTimeout> | null = null;
/** Schedule a config.json write (trailing edge) — rapid state changes
* (a refresh batch landing feed-by-feed, pin toggles, load-more pages)
* collapse into one final write instead of one file rewrite per step. */
const scheduleSaveFeeds = (): void => { const scheduleSaveFeeds = (): void => {
savePending = true; persistScheduler.schedule("feeds");
if (pendingSaveTimer) clearTimeout(pendingSaveTimer);
pendingSaveTimer = setTimeout(() => {
pendingSaveTimer = null;
flushPendingSave();
}, SAVE_DEBOUNCE_MS);
}; };
/** Persist immediately when anything is dirty; exported for tests and
* quit hooks. Cancels a pending debounced save — the state it would
* have written is already reflected in feeds(), so writing now is
* strictly more current. */
const flushPendingSave = (): void => { const flushPendingSave = (): void => {
if (pendingSaveTimer) { persistScheduler.flush("feeds");
clearTimeout(pendingSaveTimer);
pendingSaveTimer = null;
}
if (!savePending) return;
savePending = false;
saveFeeds(feeds());
}; };
const getFilteredFeeds = (): Feed[] => { const getFilteredFeeds = (): Feed[] => {
let result = [...feeds()]; // The filter signal is write-only (no caller mutates it), so every
const f = filter(); // caller observes the defaults: "all" visibility and the stable
// "updated desc" sort with pinned feeds first.
if (f.visibility && f.visibility !== "all") { const result = [...feeds()];
result = result.filter((feed) => feed.visibility === f.visibility); result.sort(
} (a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime(),
);
if (f.sourceId) {
result = result.filter((feed) => feed.sourceId === f.sourceId);
}
if (f.pinnedOnly) {
result = result.filter((feed) => feed.isPinned);
}
if (f.searchQuery) {
const query = f.searchQuery.toLowerCase();
result = result.filter(
(feed) =>
feed.podcast.title.toLowerCase().includes(query) ||
feed.customName?.toLowerCase().includes(query) ||
feed.podcast.description?.toLowerCase().includes(query),
);
}
const sortDir = f.sortDirection === "asc" ? 1 : -1;
result.sort((a, b) => {
switch (f.sortBy) {
case "title":
return (
sortDir *
(a.customName || a.podcast.title).localeCompare(
b.customName || b.podcast.title,
)
);
case "episodeCount":
return sortDir * (a.episodes.length - b.episodes.length);
case "latestEpisode":
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
return sortDir * (aLatest - bLatest);
case "updated":
default:
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
}
});
result.sort((a, b) => { result.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1; if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1; if (!a.isPinned && b.isPinned) return 1;
return 0; return 0;
}); });
return result; return result;
}; };
@@ -345,6 +232,20 @@ function createFeedStore() {
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(), (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; return allEpisodes;
}; };
@@ -369,17 +270,8 @@ function createFeedStore() {
feedId?: string, feedId?: string,
): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => { ): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => {
try { try {
const response = await fetch(feedUrl, { const xml = await fetchFeedXml(feedUrl);
headers: { if (xml === null) return { episodes: null, coverUrl: undefined };
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
// Hung feeds must not stall a refresh batch (or the
// background refresh loop) indefinitely.
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return { episodes: null, coverUrl: undefined };
const xml = await response.text();
// Yield after the network read so the renderer gets a turn // Yield after the network read so the renderer gets a turn
// before the sync regex + parse work begins. // before the sync regex + parse work begins.
await yieldToUI(); await yieldToUI();
@@ -575,40 +467,40 @@ function createFeedStore() {
})(), "Refreshing"); })(), "Refreshing");
}; };
/** Refresh all feeds — bounded concurrency (at most FETCH_CONCURRENCY /** Refresh all feeds via the Effect batch program (effects/feed-refresh):
* in-flight requests), and each feed's refreshed episodes are applied * bounded concurrency (at most FETCH_CONCURRENCY in-flight requests)
* AS ITS OWN FETCH LANDS (no Promise.all barrier). Per-feed apply is * and each feed's refreshed episodes applied AS ITS OWN FETCH LANDS
* safe because applyRefreshedEpisodes keeps unchanged feeds' object * (no barrier — the apply runs inside the feed's own fiber). Per-feed
* identity and lastUpdated (union merge), so each feed's refreshed * apply is safe because applyRefreshedEpisodes keeps unchanged feeds'
* episodes render as its own fetch resolves — the order flapping the * object identity and lastUpdated (union merge), so each feed's
* old atomic barrier existed to hide can no longer happen. */ * 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 () => { const refreshAllFeeds = async () => {
setIsLoadingFeeds(true); setIsLoadingFeeds(true);
try { try {
await mapWithConcurrency( await Effect.runPromise(
feeds(), refreshFeedsBatch(
FETCH_CONCURRENCY, feeds(),
async (feed) => { (feed) =>
const { episodes, coverUrl } = await fetchEpisodes( fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id),
feed.podcast.feedUrl, (feed, { episodes, coverUrl }) => {
MAX_EPISODES_REFRESH, setFeeds((prev) => {
feed.id, let updated = applyRefreshedEpisodes(prev, feed.id, episodes);
); if (coverUrl) {
// A failed fetch (null) leaves that feed untouched. updated = updated.map((f) =>
if (!episodes) return; f.id === feed.id && !f.podcast.coverUrl && coverUrl
setFeeds((prev) => { ? { ...f, podcast: { ...f.podcast, coverUrl } }
let updated = applyRefreshedEpisodes(prev, feed.id, episodes); : f,
if (coverUrl) { );
updated = updated.map((f) => }
f.id === feed.id && !f.podcast.coverUrl && coverUrl if (updated !== prev) scheduleSaveFeeds();
? { ...f, podcast: { ...f.podcast, coverUrl } } return updated;
: f, });
); },
} { concurrency: FETCH_CONCURRENCY, timeoutMs: FETCH_TIMEOUT_MS },
if (updated !== prev) scheduleSaveFeeds(); ),
return updated;
});
},
); );
// Global auto-download: one idempotent pass after the batch. // Global auto-download: one idempotent pass after the batch.
runAutoDownload(); runAutoDownload();
@@ -656,8 +548,8 @@ function createFeedStore() {
// apiKey/apiSecret (pre-keychain builds) move into the macOS // apiKey/apiSecret (pre-keychain builds) move into the macOS
// keychain and are stripped from config.json. // keychain and are stripped from config.json.
const secured = await migratePlaintextCredentials(mergedSources); const secured = await migratePlaintextCredentials(mergedSources);
setSources(secured); registry.replaceAll(secured);
if (secured !== mergedSources) saveSources(secured); if (secured !== mergedSources) saveSourcesToFile(secured);
} }
await refreshAllFeeds(); await refreshAllFeeds();
})(); })();
@@ -716,71 +608,6 @@ function createFeedStore() {
} }
}; };
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
);
scheduleSaveFeeds();
return updated;
});
};
const togglePinned = (feedId: string) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
);
scheduleSaveFeeds();
return updated;
});
};
const addSource = (source: Omit<PodcastSource, "id">) => {
const newSource: PodcastSource = {
...source,
id: crypto.randomUUID(),
};
setSources((prev) => {
const updated = [...prev, newSource];
saveSources(updated);
return updated;
});
return newSource;
};
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => {
const updated = prev.map((source) =>
source.id === sourceId ? { ...source, ...updates } : source,
);
saveSources(updated);
return updated;
});
};
const removeSource = (sourceId: string) => {
// Don't remove default sources
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
setSources((prev) => {
const updated = prev.filter((s) => s.id !== sourceId);
saveSources(updated);
return updated;
});
return true;
};
const toggleSource = (sourceId: string) => {
setSources((prev) => {
const updated = prev.map((s) =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
);
saveSources(updated);
return updated;
});
};
const getFeed = (feedId: string): Feed | undefined => { const getFeed = (feedId: string): Feed | undefined => {
return feeds().find((f) => f.id === feedId); return feeds().find((f) => f.id === feedId);
}; };
@@ -795,22 +622,27 @@ function createFeedStore() {
return undefined; return undefined;
}; };
const getSelectedFeed = (): Feed | undefined => {
const id = selectedFeedId();
return id ? getFeed(id) : undefined;
};
/** Check if a feed has more episodes available beyond what's currently /** Check if a feed has more episodes available beyond what's currently
* loaded. The full parse cache holds ALL episodes (including beyond the * loaded. The full parse cache holds ALL episodes (including beyond the
* cache bound), so fetch-more can always page deeper — the bound limits * cache bound), so fetch-more can page deeper — but in DATE mode only
* what the Feed/My Shows list shows initially, not what fetch-more can * when the next unloaded episode falls inside the next 2-week band: a
* reach. When the loaded window reaches the cache length, this flips * sparse/dormant show whose band is empty reports false, so fetch-more
* false. */ * 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 hasMoreEpisodes = (feedId: string): boolean => {
const cached = fullEpisodeCache.get(feedId); const cached = fullEpisodeCache.get(feedId);
if (!cached) return false; if (!cached) return false;
const loaded = episodeLoadCount.get(feedId) ?? 0; 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 episodeTs(cached[loaded]) >= cutoff;
}; };
/** Load the next chunk of episodes for one feed from the full parse /** Load the next chunk of episodes for one feed from the full parse
@@ -832,17 +664,8 @@ function createFeedStore() {
// restart). The cache holds the FULL parse — no bound applied here. // restart). The cache holds the FULL parse — no bound applied here.
if (!cached) { if (!cached) {
try { try {
const response = await fetch(feed.podcast.feedUrl, { const xml = await fetchFeedXml(feed.podcast.feedUrl);
headers: { if (xml === null) return;
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
// A hung feed must not stall the load-more path forever —
// mirror fetchEpisodes' per-feed timeout.
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) return;
const xml = await response.text();
cached = await parseEpisodesIncremental(xml, feed.podcast.feedUrl); cached = await parseEpisodesIncremental(xml, feed.podcast.feedUrl);
} catch { } catch {
// Failed/hung refetch: leave the feed's loaded episodes // Failed/hung refetch: leave the feed's loaded episodes
@@ -864,10 +687,27 @@ function createFeedStore() {
} }
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length; const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
const newCount = Math.min( const prefs = useAppStore().state().preferences;
currentCount + MAX_EPISODES_REFRESH,
cached.length, // 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 = dateBandCount(cached, currentCount, cutoff);
} else {
newCount = currentCount + MAX_EPISODES_REFRESH;
}
newCount = Math.min(newCount, cached.length);
if (newCount <= currentCount) return; // nothing more to load if (newCount <= currentCount) return; // nothing more to load
@@ -909,15 +749,71 @@ function createFeedStore() {
return feeds().some((f) => hasMoreEpisodes(f.id)); return feeds().some((f) => hasMoreEpisodes(f.id));
}; };
/** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that /** Power the Feed page's "[Fetch More]".
* still has cached episodes — powers 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 () => { const loadMoreAllFeeds = async () => {
if (isLoadingMore()) return; if (isLoadingMore()) return;
setIsLoadingMore(true); setIsLoadingMore(true);
try { try {
const pending = feeds().filter((f) => hasMoreEpisodes(f.id)); const prefs = useAppStore().state().preferences;
for (const feed of pending) { const count = Math.max(1, prefs.episodeCacheCount ?? 25);
await loadMoreEpisodesForFeed(feed.id); 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,
);
newCount = dateBandCount(cached, currentCount, cutoff);
}
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 { } finally {
setIsLoadingMore(false); setIsLoadingMore(false);
@@ -931,9 +827,7 @@ function createFeedStore() {
return { return {
// State // State
feeds, feeds,
sources, sources: registry.sources,
filter,
selectedFeedId,
isLoadingMore, isLoadingMore,
/** Resolves once persisted feeds are loaded from disk (before the /** Resolves once persisted feeds are loaded from disk (before the
@@ -945,35 +839,36 @@ function createFeedStore() {
getAllEpisodesChronological, getAllEpisodesChronological,
getFeed, getFeed,
findEpisode, findEpisode,
getSelectedFeed,
hasMoreEpisodes, hasMoreEpisodes,
isLoadingFeeds, isLoadingFeeds,
// Actions // Actions
setFilter, /** Fetch + parse an RSS feed WITHOUT subscribing or touching any feed
setSelectedFeedId, * 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, addFeed,
hasFeedByUrl,
removeFeed, removeFeed,
removeFeedByUrl, removeFeedByUrl,
updateFeed,
togglePinned,
refreshFeed, refreshFeed,
refreshAllFeeds, refreshAllFeeds,
loadMoreEpisodes, loadMoreEpisodes,
loadMoreAllFeeds, loadMoreAllFeeds,
hasMoreAcrossAll, hasMoreAcrossAll,
flushPendingSave, flushPendingSave,
addSource, addSource: registry.addSource,
removeSource, toggleSource: registry.toggleSource,
toggleSource, updateSource: registry.updateSource,
updateSource,
runAutoDownload: runAutoDownloadNow, runAutoDownload: runAutoDownloadNow,
}; };
} }
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null; let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
/** Re-exported: refresh-merge tests import it from the store module. */
export { sameRefreshWindow } from "../utils/episode-windows";
export function useFeedStore() { export function useFeedStore() {
if (!feedStoreInstance) { if (!feedStoreInstance) {
feedStoreInstance = createFeedStore(); feedStoreInstance = createFeedStore();

144
src/stores/pane-layout.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* pane-layout — global split positions for the yazi-style pane rows.
*
* Every tab renders its parent|current|preview columns through `PaneRow`
* sharing one layout: the two borders of the CENTER (current) column are
* draggable, and their positions are stored here as fractions of the row
* width (so a terminal resize re-derives pixel positions proportionally).
*
* parent (15+) | current (30+) | preview (15+)
* ── left ───────── right ──────── <-- draggable borders
*
* Defaults mirror the old fixed 2:5:3 ratio (20% / 50% / 30%). Minimum
* pane widths are enforced in `splitPixels` whenever a border is dragged
* or the row is re-derived. The positions persist to the app preferences
* on `commit()` (drag end) — never per drag event, so drags don't thrash
* config.json.
*/
import type { PaneSplits } from "@/types/settings";
import { createSignal } from "solid-js";
import { useAppStore } from "./app";
// ── Types ───────────────────────────────────────────────────────────────
/** Public surface of the shared pane-layout store. */
export interface PaneLayoutStore {
/** Current split positions (fractions of the row width). */
splits(): PaneSplits;
/** Move the left border of the current pane to column `x`. */
setLeft(x: number, width: number): void;
/** Move the right border of the current pane to column `x`. */
setRight(x: number, width: number): void;
/** Persist the current splits (called on drag end, not per drag event). */
commit(): void;
}
// ── Constants ───────────────────────────────────────────────────────────
/** Default split — the historical 2:5:3 ratio (parent 20 / current 50 / preview 30). */
export const DEFAULT_PANE_SPLITS: PaneSplits = { left: 0.2, right: 0.7 };
/** Per-pane minimum widths (columns) enforced while a border is being
* dragged. Static rendering maps stored fractions 1:1 to pixels — the
* minimums never distort the user's chosen layout on narrow terminals. */
export const MIN_PANE_WIDTH = {
parent: 15,
current: 30,
preview: 15,
} as const;
/** Clamp `v` into [`lo`, `hi`] (bounds may invert on degenerate widths). */
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
/** Minimum row width that can hold all three panes at their drag minimums. */
const MIN_TOTAL_WIDTH =
MIN_PANE_WIDTH.parent + MIN_PANE_WIDTH.current + MIN_PANE_WIDTH.preview;
/** Resolve stored splits into concrete pixel columns for a row `width`.
* A pure 1:1 fraction→pixel mapping (keeping the ratio exact for every
* terminal size); min-width enforcement lives in the drag setters only. */
export function splitPixels(
width: number,
splits: PaneSplits,
): { leftPx: number; rightPx: number } {
if (width <= 0) return { leftPx: 0, rightPx: 0 };
const leftPx = Math.round(width * splits.left);
const rightPx = Math.max(leftPx + 1, Math.round(width * splits.right));
return { leftPx, rightPx };
}
export function createPaneLayoutStore(): PaneLayoutStore {
const app = useAppStore();
// Seeded from persisted preferences; `saved` is always defined because
// the app store backfills the default when the config predates it.
const [splits, setSplits] = createSignal<PaneSplits>(
app.state().preferences.paneSplit ?? DEFAULT_PANE_SPLITS,
);
/** Store the normalized pixel positions as fractions of `width`. */
const applyPixels = (leftPx: number, rightPx: number, width: number) => {
if (width <= 0) return;
setSplits({ left: leftPx / width, right: rightPx / width });
};
/** Clamp a dragged border to the per-pane minimums (only on terminals
* wide enough to hold them; smaller rows just follow the cursor). */
const dragPixels = (
leftPx: number,
rightPx: number,
width: number,
): { leftPx: number; rightPx: number } => {
if (width < MIN_TOTAL_WIDTH)
return {
leftPx: clamp(leftPx, 1, width - 2),
rightPx: Math.max(rightPx, leftPx + 1),
};
const minLeft = MIN_PANE_WIDTH.parent;
const maxLeft = width - MIN_PANE_WIDTH.current - MIN_PANE_WIDTH.preview;
const maxRight = width - MIN_PANE_WIDTH.preview;
const newLeft = clamp(leftPx, minLeft, maxLeft);
// The dragged border follows the cursor; the other border is pushed
// only as far as needed to keep the current pane at its minimum.
return {
leftPx: newLeft,
rightPx: clamp(rightPx, newLeft + MIN_PANE_WIDTH.current, maxRight),
};
};
/** Move the left border to column `x` (the current pane's left edge). */
const setLeft = (x: number, width: number) => {
if (width <= 0) return;
const { rightPx: curRight } = splitPixels(width, splits());
const { leftPx, rightPx } = dragPixels(Math.round(x), curRight, width);
applyPixels(leftPx, rightPx, width);
};
/** Move the right border to column `x` (the current pane's right edge). */
const setRight = (x: number, width: number) => {
if (width <= 0) return;
const { leftPx: curLeft, rightPx: curRight } = splitPixels(width, splits());
const { leftPx, rightPx } = dragPixels(curLeft, Math.round(x), width);
applyPixels(leftPx, rightPx, width);
};
/** Persist the current splits (called on drag end, not per drag event). */
const commit = () => {
app.updatePreferences({ paneSplit: splits() });
};
return { splits, setLeft, setRight, commit };
}
// ── Singleton ───────────────────────────────────────────────────────────
let paneLayoutInstance: PaneLayoutStore | null = null;
/** Accessor for the shared pane-layout store (all tabs share one split). */
export function usePaneLayout(): PaneLayoutStore {
if (!paneLayoutInstance) paneLayoutInstance = createPaneLayoutStore();
return paneLayoutInstance;
}

57
src/stores/persist.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Persistence scheduler for PodTUI
* Per-domain trailing-edge debounced writes
*/
/** Debounced writer: rapid schedules collapse into one write per domain. */
export interface PersistScheduler {
/** Mark a domain dirty and (re)arm its trailing-edge write timer. */
schedule(domain: string): void;
/** Write the domain immediately if dirty; cancels any pending timer. */
flush(domain: string): void;
/** Write every dirty domain immediately. */
flushAll(): void;
}
/** Timer handle as returned by setTimeout in this runtime. */
type TimerHandle = ReturnType<typeof setTimeout>;
/** Build a per-domain trailing-edge debouncer. `write` is invoked with the
* domain name; callers read current state inside it, so a flush always
* lands the latest value. Rapid schedule() calls share one timer. */
export function createPersistScheduler(
write: (domain: string) => void,
debounceMs = 250,
): PersistScheduler {
const dirty = new Set<string>();
const timers = new Map<string, TimerHandle>();
const flush = (domain: string): void => {
const timer = timers.get(domain);
if (timer) {
clearTimeout(timer);
timers.delete(domain);
}
if (!dirty.has(domain)) return;
dirty.delete(domain);
write(domain);
};
const schedule = (domain: string): void => {
dirty.add(domain);
clearTimeout(timers.get(domain));
timers.set(
domain,
setTimeout(() => {
timers.delete(domain);
flush(domain);
}, debounceMs),
);
};
const flushAll = (): void => {
for (const domain of [...dirty]) flush(domain);
};
return { schedule, flush, flushAll };
}

View File

@@ -11,6 +11,7 @@ import {
} from "../utils/app-persistence"; } from "../utils/app-persistence";
import { useFeedStore } from "./feed"; import { useFeedStore } from "./feed";
import type { SearchResult, SearchScope } from "../types/source"; import type { SearchResult, SearchScope } from "../types/source";
import { createPersistScheduler } from "./persist";
const STORAGE_SCOPE_KEY = "podtui_search_scope"; const STORAGE_SCOPE_KEY = "podtui_search_scope";
const MAX_HISTORY = 10; const MAX_HISTORY = 10;
@@ -70,6 +71,15 @@ export function createSearchStore() {
const [selectedSources, setSelectedSources] = createSignal<string[]>([]); const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
const [scope, setScopeState] = createSignal<SearchScope>(loadScope()); const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
/** History persistence: rapid mutations collapse into one debounced
* write; the closure reads the live signal so a flush lands the
* latest list. */
const persistHistory = createPersistScheduler((domain: string) => {
if (domain === "search-history") {
saveSearchHistoryToFile(history());
}
});
/** Load search history from file (fire-and-forget; recents appear as /** Load search history from file (fire-and-forget; recents appear as
* soon as the file is read). */ * soon as the file is read). */
async function init(): Promise<void> { async function init(): Promise<void> {
@@ -167,24 +177,18 @@ export function createSearchStore() {
}; };
const addToHistory = (q: string) => { const addToHistory = (q: string) => {
setHistory((prev) => { setHistory((prev) => sanitizeHistory([q, ...prev]));
const updated = sanitizeHistory([q, ...prev]); persistHistory.schedule("search-history");
saveSearchHistoryToFile(updated);
return updated;
});
}; };
const clearHistory = () => { const clearHistory = () => {
setHistory([]); setHistory([]);
saveSearchHistoryToFile([]); persistHistory.schedule("search-history");
}; };
const removeFromHistory = (q: string) => { const removeFromHistory = (q: string) => {
setHistory((prev) => { setHistory((prev) => prev.filter((h) => h !== q));
const updated = prev.filter((h) => h !== q); persistHistory.schedule("search-history");
saveSearchHistoryToFile(updated);
return updated;
});
}; };
const clearResults = () => { const clearResults = () => {

View File

@@ -25,6 +25,12 @@
* after the Player tab stops being focused it tears down. Reads outside * after the Player tab stops being focused it tears down. Reads outside
* decoded coverage return empty — the renderer simply holds the last frame * decoded coverage return empty — the renderer simply holds the last frame
* until the decode frontier arrives. * 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 { import {
@@ -55,6 +61,14 @@ const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */ /** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512; 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. */ /** Timer handle as returned by setTimeout/setInterval in this runtime. */
type TimerHandle = ReturnType<typeof setTimeout>; type TimerHandle = ReturnType<typeof setTimeout>;
@@ -65,6 +79,10 @@ export interface VisualizerStore {
barData: () => number[]; barData: () => number[];
/** True from pipeline start until the first complete FFT frame renders. */ /** True from pipeline start until the first complete FFT frame renders. */
isLoading: () => boolean; 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. */ /** True while the ~30fps render loop is armed. */
isRunning: () => boolean; isRunning: () => boolean;
/** Report whether the Player tab is the visible tab. */ /** 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. // True from pipeline start until the first complete FFT frame renders.
const [isLoading, setIsLoading] = createSignal(false); 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). // Whether the Player tab is the visible tab (fed by PlayerPage).
const [focused, setFocused] = createSignal(false); const [focused, setFocused] = createSignal(false);
@@ -103,6 +125,20 @@ function createVisualizerStore(): VisualizerStore {
let sampleBuffer: Float64Array | null = null; let sampleBuffer: Float64Array | null = null;
let unloadTimer: TimerHandle | 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 clears once the position clock has MOVED
// from this (either direction) — while the player is still re-buffering
// the clock is frozen, and the cache serving the same window must not
// let stale pre-pause bars 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 // What the running pipeline was started with — lets the playback effect
// tell "nothing changed, stay warm" from "must restart". // tell "nothing changed, stay warm" from "must restart".
let activeUrl = ""; let activeUrl = "";
@@ -200,9 +236,19 @@ function createVisualizerStore(): VisualizerStore {
lastPolledPosition = position; lastPolledPosition = position;
lastPolledAt = performance.now(); 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; activeUrl = url;
activeBars = barCount(); activeBars = barCount();
setIsLoading(true); setIsLoading(true);
setIsStalled(false);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL); frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
}; };
@@ -224,6 +270,14 @@ function createVisualizerStore(): VisualizerStore {
} }
sampleBuffer = null; sampleBuffer = null;
setIsLoading(false); 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 ────────────────────────── // ── Pause: freeze the loop, keep the cache ──────────────────────────
@@ -248,6 +302,7 @@ function createVisualizerStore(): VisualizerStore {
// (still cold-starting when paused), the component should fall back // (still cold-starting when paused), the component should fall back
// to the placeholder, not freeze on a spinner. // to the placeholder, not freeze on a spinner.
setIsLoading(false); setIsLoading(false);
setIsStalled(false);
}; };
// ── Resume: re-arm the render loop, top up the cache ─────────────── // ── Resume: re-arm the render loop, top up the cache ───────────────
@@ -269,6 +324,20 @@ function createVisualizerStore(): VisualizerStore {
lastPolledPosition = pos; lastPolledPosition = pos;
lastPolledAt = performance.now(); 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); frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
return true; return true;
}; };
@@ -282,15 +351,43 @@ function createVisualizerStore(): VisualizerStore {
// coverage (decode cold start, seek into a hole) the read is empty // coverage (decode cold start, seek into a hole) the read is empty
// and the LAST FRAME simply holds — never clamped/repeated junk. // and the LAST FRAME simply holds — never clamped/repeated junk.
const target = smoothPosition(); 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); const count = pcm.readWindow(sampleBuffer, target);
// Never feed a partial FFT window to cava. // Never feed a partial FFT window to cava.
if (count < sampleBuffer.length) return; if (count < sampleBuffer.length) return;
const output = cava.execute(sampleBuffer); const output = cava.execute(sampleBuffer);
// Normalize against the running peak and copy to a new array // Normalize against the running peak and copy to a new array
setBarData(scaler(output)); setBarData(scaler(output));
if (isLoading()) setIsLoading(false); // Fresh frames only count once the position clock has MOVED from
// 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. Any move counts — including a
// backward seek, whose window is live data for the new position and
// would strand the spinner forever under a `>` gate. Cold starts
// (resumePos < 0) clear on the first frame as before.
if (isLoading() && (resumePos < 0 || rawPos !== resumePos)) {
setIsLoading(false);
}
}; };
// ── Playback subscription ────────────────────────────────────────── // ── Playback subscription ──────────────────────────────────────────
@@ -425,6 +522,7 @@ function createVisualizerStore(): VisualizerStore {
// state // state
barData, barData,
isLoading, isLoading,
isStalled,
isRunning: () => frameTimer !== null, isRunning: () => frameTimer !== null,
// inputs // inputs
setFocused, setFocused,

View File

@@ -90,9 +90,6 @@ export type AppSettings = {
visualizer: VisualizerSettings; visualizer: VisualizerSettings;
}; };
/** How the Feed and per-show episode lists load older episodes (default: auto). */
export type FetchMoreMode = "manual" | "auto";
/** Which shows the auto-download setting applies to (default: all). */ /** Which shows the auto-download setting applies to (default: all). */
export type AutoDownloadScope = "all" | "none" | "whitelist"; export type AutoDownloadScope = "all" | "none" | "whitelist";
@@ -101,6 +98,15 @@ export type AutoDownloadScope = "all" | "none" | "whitelist";
* episodes (default: date). */ * episodes (default: date). */
export type EpisodeCacheMode = "date" | "count"; export type EpisodeCacheMode = "date" | "count";
/** Left/right edges of the current (center) pane as fractions of the row
* width, shared by the draggable pane borders in every depth tab. */
export type PaneSplits = {
/** Left edge of the current pane (default 0.2 = 20% of the row). */
left: number;
/** Right edge of the current pane (default 0.7 = 70% of the row). */
right: number;
};
export type UserPreferences = { export type UserPreferences = {
showExplicit: boolean; showExplicit: boolean;
autoDownload: boolean; autoDownload: boolean;
@@ -112,8 +118,6 @@ export type UserPreferences = {
autoDownloadWhitelist: string[]; autoDownloadWhitelist: string[];
/** Jump to the Player view automatically when playback starts (default: true) */ /** Jump to the Player view automatically when playback starts (default: true) */
autoJumpToPlayer: boolean; autoJumpToPlayer: boolean;
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: auto). */
fetchMoreMode: FetchMoreMode;
/** Minutes between automatic background feed refreshes (default: 30). */ /** Minutes between automatic background feed refreshes (default: 30). */
refreshIntervalMinutes: number; refreshIntervalMinutes: number;
/** How the episode list cache is bounded — by date or by count (default: date). */ /** How the episode list cache is bounded — by date or by count (default: date). */
@@ -122,6 +126,8 @@ export type UserPreferences = {
episodeCacheCount: number; episodeCacheCount: number;
/** Rolling window in days for the episode list when mode is "date" (default: 60). */ /** Rolling window in days for the episode list when mode is "date" (default: 60). */
episodeCacheDays: number; episodeCacheDays: number;
/** Pane split positions as fractions of the row width (default 0.2 / 0.7). */
paneSplit: PaneSplits;
}; };
export type AppState = { export type AppState = {

View File

@@ -47,11 +47,11 @@ const defaultPreferences: UserPreferences = {
autoDownloadScope: "all", autoDownloadScope: "all",
autoDownloadWhitelist: [], autoDownloadWhitelist: [],
autoJumpToPlayer: true, autoJumpToPlayer: true,
fetchMoreMode: "auto",
refreshIntervalMinutes: 30, refreshIntervalMinutes: 30,
episodeCacheMode: "date", episodeCacheMode: "date",
episodeCacheCount: 25, episodeCacheCount: 25,
episodeCacheDays: 60, episodeCacheDays: 60,
paneSplit: { left: 0.2, right: 0.7 },
}; };
const defaultState: AppState = { const defaultState: AppState = {

874
src/utils/audio-engine.ts Normal file
View File

@@ -0,0 +1,874 @@
/**
* Module-level audio engine — owns the AudioBackend lifecycle, the 150ms
* playback poll (progress save + external pause/resume reconciliation),
* cover-art resolution, session restore, and the event-bus playback
* commands.
*
* `createAudioEngine()` is the only factory. It builds a lazily-booting
* engine (the backend is created on the first play/load, not here) and
* returns the SAME instance for the life of the process, so every
* useAudio() call shares one engine. The Solid-lifecycle parts that can't
* live at module scope — the ref-counted last-owner dispose and the
* process-exit teardown — stay in hooks/useAudio, the thin wrapper.
*/
import {
cachedCoverPath,
fetchCoverArt,
} from "./cover-art";
import {
createAudioBackend,
detectPlayers,
PlayerRestartedError,
type AudioBackend,
type BackendName,
type DetectedPlayer,
} from "./audio-player";
import {
isPlaying,
setIsPlaying,
position,
setPosition,
duration,
setDuration,
volume,
setVolume,
speed,
setSpeed,
backendName,
setBackendName,
error,
setError,
currentEpisode,
setCurrentEpisode,
availablePlayers,
setAvailablePlayers,
} from "./audio-signals";
import { emit, on } from "./event-bus";
import { useAppStore } from "../stores/app";
import { useProgressStore } from "../stores/progress";
import { useMediaRegistry } from "./media-registry";
import {
loadLastPlayerFromFile,
saveLastPlayerToFile,
} from "./app-persistence";
import type { Episode, Progress } from "../types/episode";
import { feedForEpisode } from "./feed-resolve";
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 "./audio-queue";
// Singleton state — shared by every useAudio() owner through the one engine
let backend: AudioBackend | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let pollCount = 0; // Counts poll ticks for throttling progress saves
// Playback signals are declared in utils/audio-signals.ts (imported above)
// so non-component consumers (the visualizer store) can subscribe without
// mounting a useAudio() owner.
/** True once the current episode has been handed to the backend (play
* started). `false` means the episode is only LOADED in the player (e.g.
* restored at boot) and the first play action must start the backend
* instead of unpausing it. */
let startedPlayback = false;
/** Completion fraction at/above which an episode is NOT restored at boot. */
const RESTORE_COMPLETION_THRESHOLD = 0.98;
/** The engine surface useAudio() wraps. Deliberately omits
* availablePlayers and switchBackend — the hook re-exposes those from
* audio-signals / this module on top of the engine. */
export interface AudioEngine {
// Signals (reactive getters)
isPlaying: () => boolean;
position: () => number;
duration: () => number;
volume: () => number;
speed: () => number;
backendName: () => BackendName;
error: () => string | null;
currentEpisode: () => Episode | null;
// Actions
play: (episode: Episode) => Promise<void>;
/** Load an episode into the player WITHOUT starting playback. */
load: (episode: Episode) => Promise<void>;
pause: () => Promise<void>;
resume: () => Promise<void>;
togglePlayback: () => Promise<void>;
stop: () => Promise<void>;
seek: (seconds: number) => Promise<void>;
seekRelative: (delta: number) => Promise<void>;
setVolume: (volume: number) => Promise<void>;
setSpeed: (speed: number) => Promise<void>;
prev: () => Promise<void>;
next: () => Promise<void>;
}
/** True when saved progress is below the restore cutoff. Episodes with no
* progress (never reached the persist threshold) or unknown duration count
* as eligible — they restore from the start. */
function isRestoreEligible(progress: Progress | undefined): boolean {
if (!progress || progress.duration <= 0) return true;
return progress.position / progress.duration < RESTORE_COMPLETION_THRESHOLD;
}
/** Lazily create the shared backend on first use. The process-exit
* teardown lives in useAudio (it must survive last-owner dispose), so it is
* registered there, not here. */
function ensureBackend(): AudioBackend {
if (!backend) {
const detected = detectPlayers();
setAvailablePlayers(detected);
backend = createAudioBackend();
setBackendName(backend.name);
}
return backend;
}
/** Poll ticks between paused-state checks (~1s at 150ms/tick). While the
* UI believes playback is paused we only need to catch an external
* resume (AirPod play tap, lock-screen/media-center play); checking every
* tick would just hammer mpv IPC for nothing. */
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. `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();
if (ep) {
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,
* device swap, OS media keys, the Now Playing center. Bring the UI in
* sync; the poll stays armed so an external resume is caught too. */
function reconcileExternalPause(): void {
setIsPlaying(false);
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
emit("player.pause", { episodeId: ep.id });
const media = useMediaRegistry();
media.setPlaybackState(false);
media.setPosition(position());
}
}
/** Playback was restarted from outside PodTUI (AirPods, lock-screen or
* media-center play, OS media keys). Bring the UI back to "playing". */
function reconcileExternalResume(): void {
setIsPlaying(true);
const ep = currentEpisode();
if (ep) {
emit("player.play", { episodeId: ep.id });
useMediaRegistry().setPlaybackState(true);
}
}
function startPolling(): void {
stopPolling();
pollCount = 0;
// Guard against overlapping ticks if a socket read ever outlives the
// interval (getPosition opens a fresh mpv IPC connection per call).
let pollInFlight = false;
pollTimer = setInterval(async () => {
if (!backend || pollInFlight) return;
pollInFlight = true;
try {
pollCount++;
if (isPlaying()) {
// Track ended (eof-reached observed) or process died. Check
// BEFORE pause reconciliation: mpv keeps the file open at EOF
// and reports pause=true there, which would otherwise be
// mistaken for an external pause and never finalize.
if (!backend.isPlaying()) {
// 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;
}
// mpv can pause itself outside PodTUI. Reconcile instead of
// staying stuck on "playing" with a frozen waveform
// (getPosition would just re-read the same frozen time-pos).
const paused = await backend.getPauseState();
if (paused === true) {
reconcileExternalPause();
return;
}
const pos = await backend.getPosition();
const dur = await backend.getDuration();
setPosition(pos);
if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (33 ticks * 150ms)
if (pollCount % 33 === 0) {
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
const media = useMediaRegistry();
media.setPosition(pos);
}
}
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
// Paused — watch for playback restarted from outside (AirPods,
// lock-screen/media-center play). Only while the player is
// 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(false);
return;
}
const paused = await backend.getPauseState();
if (paused === false) {
reconcileExternalResume();
}
}
} catch {
// Backend may have been disposed
} finally {
pollInFlight = false;
}
}, 150);
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
// ── Cover art for system Now Playing ─────────────────────────────────────────
// macOS shows the media session's albumart in the audio center; mpv reads it
// 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);
if (!episode.audioUrl) {
setError("No audio URL for this episode");
return;
}
const appStore = useAppStore();
const progressStore = useProgressStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
const vol = volume();
const spd = storeSpeed || speed();
const feed = feedForEpisode(useFeedStore().feeds(), episode);
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 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,
speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
mediaTitle: episode.title,
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);
setPosition(startPos);
if (episode.duration) setDuration(episode.duration);
startedPlayback = true;
// Remember this episode as "loaded in the player" so the next launch
// can restore it paused (cleared by stop()).
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
// Register with platform media controls
media.setPlaybackState(true);
if (startPos > 0) media.setPosition(startPos);
startPolling();
emit("player.play", { episodeId: episode.id });
// Distinct from "player.play" (which also fires on resume): signals a
// fresh episode start so Shell can honor the auto-jump-to-player pref.
emit("player.started", { episodeId: episode.id });
} catch (err) {
setError(err instanceof Error ? err.message : "Playback failed");
setIsPlaying(false);
}
}
/**
* Load an episode into the player WITHOUT starting playback. The player tab
* renders it paused at its saved position; the first play action starts the
* backend from there (see togglePlayback). Used to restore the last player
* session at boot.
*/
async function load(episode: Episode): Promise<void> {
ensureBackend();
setError(null);
setCurrentEpisode(episode);
setIsPlaying(false);
startedPlayback = false;
// Show the saved position so the player tab reflects where playback
// will resume; episodes at/above the completion threshold start from 0.
const progressStore = useProgressStore();
const saved = progressStore.get(episode.id);
const pos = saved && isRestoreEligible(saved) ? saved.position : 0;
setPosition(pos);
if (episode.duration) setDuration(episode.duration);
const appStore = useAppStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
setSpeed(storeSpeed || speed());
// Surface the loaded-but-paused track to the OS media controls.
const feed = feedForEpisode(useFeedStore().feeds(), episode);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(false);
if (pos > 0) media.setPosition(pos);
// Preload the episode into the backend PAUSED: mpv opens the stream and
// fills its demuxer cache while parked, so the user's first Play flips
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
// — a failed preload just makes the first play take the cold path.
const downloadStore = useDownloadStore();
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
if (episode.audioUrl && backend) {
// The preload must carry the cover AT LOAD: cover-art-files only
// applies when the file loads, and the runtime video-add fallback
// never becomes an albumart track (verified). Restore already waits
// 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 coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? episode.imageUrl,
"await",
);
const backendSnap = backend;
backendSnap
.preload(url, {
volume: volume(),
speed: storeSpeed || speed(),
startPosition: pos > 0 ? pos : undefined,
mediaTitle: episode.title,
coverArtPath: coverArtPath ?? undefined,
})
.catch(() => {});
}
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
}
async function pause(): Promise<void> {
if (!backend) return;
try {
await backend.pause();
setIsPlaying(false);
// Polling stays armed (paused-watch mode): playback can be resumed
// from OUTSIDE PodTUI — AirPods, lock-screen/media-center play —
// and the poll must be live to catch it.
const ep = currentEpisode();
if (ep) {
// Save progress on pause
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
emit("player.pause", { episodeId: ep.id });
// Update platform media controls
const media = useMediaRegistry();
media.setPlaybackState(false);
media.setPosition(position());
}
} catch (err) {
setError(err instanceof Error ? err.message : "Pause failed");
}
}
/** 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);
startPolling();
const ep = currentEpisode();
if (ep) {
emit("player.play", { episodeId: ep.id });
const media = useMediaRegistry();
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");
}
}
async function togglePlayback(): Promise<void> {
if (isPlaying()) {
await pause();
} else if (currentEpisode()) {
if (startedPlayback) {
await resume();
} else {
// Episode is only LOADED (e.g. restored at boot) — the backend
// was never started, so unpausing a dead player would fail
// silently. Start playback from the saved position instead.
const ep = currentEpisode();
if (ep) await play(ep);
}
}
}
async function stop(): Promise<void> {
if (!backend) return;
try {
// Save progress before stopping
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
}
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
startedPlayback = false;
stopPolling();
emit("player.stop", {});
// Player is empty again — nothing to restore on the next launch.
saveLastPlayerToFile({ episodeId: null, timestamp: null });
const media = useMediaRegistry();
media.clearNowPlaying();
} catch (err) {
setError(err instanceof Error ? err.message : "Stop failed");
}
}
async function seek(seconds: number): Promise<void> {
if (!backend) return;
const clamped = Math.max(0, Math.min(seconds, duration()));
try {
await backend.seek(clamped);
setPosition(clamped);
} catch (err) {
setError(err instanceof Error ? err.message : "Seek failed");
}
}
async function seekRelative(delta: number): Promise<void> {
await seek(position() + delta);
}
async function doSetVolume(vol: number): Promise<void> {
const clamped = Math.max(0, Math.min(1, vol));
if (backend) {
try {
await backend.setVolume(clamped);
} catch {
// Some backends can't change volume at runtime
}
}
setVolume(clamped);
// Sync back to app store (persisted to config.json for the next launch).
const appStore = useAppStore();
appStore.updateSettings({ volume: clamped });
}
async function doSetSpeed(spd: number): Promise<void> {
const clamped = Math.max(0.25, Math.min(3, spd));
if (backend) {
try {
await backend.setSpeed(clamped);
} catch {
// Some backends can't change speed at runtime
}
}
setSpeed(clamped);
// Sync back to app store
const appStore = useAppStore();
appStore.updateSettings({ playbackSpeed: clamped });
}
/** Switch the active player backend (mpv / afplay / ...). Off the
* AudioEngine interface by contract, but kept here (module-scoped) so the
* engine owns backend teardown/creation; useAudio re-exposes it. */
export async function switchBackend(name: BackendName): Promise<void> {
const wasPlaying = isPlaying();
const ep = currentEpisode();
const pos = position();
const vol = volume();
const spd = speed();
if (backend) {
stopPolling();
backend.dispose();
backend = null;
}
backend = createAudioBackend(name);
setBackendName(backend.name);
setAvailablePlayers(detectPlayers());
// Resume playback if we were playing
if (wasPlaying && ep && ep.audioUrl) {
try {
const feed = feedForEpisode(useFeedStore().feeds(), ep);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const url =
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
const coverArtPath = await resolveCoverArt(
feed?.podcast.coverUrl ?? ep.imageUrl,
"cache",
);
await backend.play(url, {
startPosition: pos,
volume: vol,
speed: spd,
mediaTitle: ep.title,
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);
startedPlayback = true;
startPolling();
} catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed");
setIsPlaying(false);
}
}
}
/** Serialized restore chain: the boot-triggered restore and any explicit
* call run one after another, so a late-finishing earlier restore can never
* overwrite state changed by a later one (and callers can await the latest
* attempt deterministically). */
let restoreChain: Promise<void> = Promise.resolve();
/**
* Boot-time session restore: reload the episode that was loaded in the
* player when the previous run ended (persisted on play/load and at exit),
* paused at its saved position — never autostarted. Episodes at/above the
* completion threshold are skipped. Silently no-ops when there is nothing
* to restore (empty player, unsubscribed show, or completed episode).
*/
export async function restoreLastSession(): Promise<void> {
const attempt = restoreChain.then(async () => {
const marker = await loadLastPlayerFromFile();
if (!marker?.episodeId) return;
// Feeds and progress load asynchronously at boot; wait for both
// before looking the episode up.
await Promise.all([
useProgressStore().whenReady(),
useFeedStore().whenReady(),
]);
const episode = useFeedStore().findEpisode(marker.episodeId);
if (!episode) return;
// Only restore episodes below the completion threshold.
const saved = useProgressStore().get(episode.id);
if (!isRestoreEligible(saved)) return;
await load(episode);
});
// Keep the chain alive even when an attempt fails; the caller awaiting
// this attempt still observes its own outcome.
restoreChain = attempt.catch(() => {});
await attempt;
}
// ── 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);
}
// ── Event bus commands ────────────────────────────────────────────────────
// Registered once per process (in createAudioEngine), not per hook owner.
// Every handler is no-op-safe when the backend is absent (e.g. after the
// last owner disposed it).
let eventListenersRegistered = false;
function registerEventListeners(): void {
if (eventListenersRegistered) return;
eventListenersRegistered = true;
on("player.play", async (data) => {
// External play requests — currently just tracks episodeId.
// Episode lookup would require feed store integration.
});
on("player.stop", async () => {
if (backend && isPlaying()) {
await backend.stop();
setIsPlaying(false);
setPosition(0);
setCurrentEpisode(null);
stopPolling();
}
});
// Global multimedia key events (from useMultimediaKeys)
on("media.toggle", async () => {
await togglePlayback();
});
on("media.volumeUp", async () => {
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
});
on("media.volumeDown", async () => {
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
});
on("media.speedCycle", async () => {
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
await doSetSpeed(next);
});
}
/** Lazily create the shared backend on first use. Called from useAudio's
* boot path (the old hook created it eagerly; tests and the mpv IPC test
* rely on the backend existing before the first play). */
export function ensureEngineBackend(): AudioBackend {
return ensureBackend();
}
/** Full engine teardown for when the last hook owner unmounts: stop the
* poll, dispose the backend, and clear the OS media session. (The
* process-exit teardown in useAudio does the same minus the media clear,
* since the process is ending.) */
export function disposeEngineBackend(): void {
stopPolling();
if (backend) {
backend.dispose();
backend = null;
}
// Clear media registry on full teardown
useMediaRegistry().clearNowPlaying();
}
/** Stop the poll — one-line wrapper so the process-exit teardown in
* useAudio doesn't reach into engine internals. */
export function stopEnginePolling(): void {
stopPolling();
}
/** The current backend (or null), for useAudio's exit-time dispose. */
export function getEngineBackend(): AudioBackend | null {
return backend;
}
let engineInstance: AudioEngine | null = null;
/** Build (once) and return the process-wide audio engine. Side-effect-free:
* the backend is created lazily on the first play/load, and the event-bus
* listeners are registered here. */
export function createAudioEngine(): AudioEngine {
if (engineInstance) return engineInstance;
registerEventListeners();
engineInstance = {
isPlaying,
position,
duration,
volume,
speed,
backendName,
error,
currentEpisode,
play,
load,
pause,
resume,
togglePlayback,
stop,
seek,
seekRelative,
setVolume: doSetVolume,
setSpeed: doSetSpeed,
prev,
next,
};
return engineInstance;
}

View File

@@ -24,6 +24,10 @@
* fills its demuxer cache ahead of time; the first real play just flips * 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 * `pause` to false — the ~2s network open is paid at boot, not on the
* user's first Play. * 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"; import { platform } from "os";
@@ -78,6 +82,11 @@ export interface AudioBackend {
getPauseState(): Promise<boolean | undefined>; getPauseState(): Promise<boolean | undefined>;
/** True while the player process is running (regardless of pause). */ /** True while the player process is running (regardless of pause). */
isAlive(): boolean; 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; dispose(): void;
} }
@@ -274,12 +283,28 @@ class MpvConnection {
} }
this.handleTeardown(); this.handleTeardown();
} }
/** True while the Unix socket is open — a live, reachable daemon. */
isConnected(): boolean {
return this.sock !== null;
}
} }
// ── mpv Backend ────────────────────────────────────────────────────── // ── mpv Backend ──────────────────────────────────────────────────────
// One resident daemon for the app's lifetime, controlled over a single // One resident daemon for the app's lifetime, controlled over a single
// persistent JSON IPC connection with property observation. // 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). */ /** Property observation ids (correlate property-change events). */
const OBS_TIME_POS = 1; const OBS_TIME_POS = 1;
const OBS_PAUSE = 2; const OBS_PAUSE = 2;
@@ -318,14 +343,35 @@ export class MpvBackend implements AudioBackend {
// ── Daemon lifecycle ───────────────────────────────────────────── // ── Daemon lifecycle ─────────────────────────────────────────────
private async ensureDaemon(): Promise<void> { 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; if (this.startPromise) return this.startPromise;
this.startPromise = this.spawnDaemon().finally(() => { this.startPromise = this.recoverDaemon().finally(() => {
this.startPromise = null; this.startPromise = null;
}); });
return this.startPromise; 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> { private async spawnDaemon(): Promise<void> {
// Clean up stale socket // Clean up stale socket
try { try {
@@ -337,7 +383,13 @@ export class MpvBackend implements AudioBackend {
this.proc = Bun.spawn( this.proc = Bun.spawn(
[ [
"mpv", "mpv",
"--no-video", // --vo=null (not --no-video): the albumart track must stay the
// CURRENT video track or macOS Now Playing shows no artwork.
// --no-video drops it to unselected (albumart:true, selected:false),
// so the system media center renders no cover. --vo=null is equally
// headless — no window, no rendering — but keeps the cover current
// so Now Playing gets the art.
"--vo=null",
"--no-terminal", "--no-terminal",
"--really-quiet", "--really-quiet",
// Stay alive after finishing/unloading files; PodTUI owns one mpv // Stay alive after finishing/unloading files; PodTUI owns one mpv
@@ -360,9 +412,15 @@ export class MpvBackend implements AudioBackend {
this._exited = false; this._exited = false;
this.proc.exited this.proc.exited
.then(() => { .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._exited = true;
this._intentPlaying = false; this._intentPlaying = false;
this._loadedUrl = null; this._loadedUrl = null;
this._loadedPaused = false;
this._ended = false;
this._paused = null; this._paused = null;
}) })
.catch(() => {}); .catch(() => {});
@@ -521,8 +579,12 @@ export class MpvBackend implements AudioBackend {
if (pausedSeek) { if (pausedSeek) {
// time-pos sent before file-loaded is silently dropped by mpv // time-pos sent before file-loaded is silently dropped by mpv
// (no file yet) — the preload then parked at 0 and the restore // (no file yet) — the preload then parked at 0 and the restore
// position was lost. Wait for the open, then seek. // position was lost. Wait for the open, then seek. A dead URL
await fileLoaded; // never fires file-loaded at all (mpv keeps retrying the
// open), so end-file (the open-failure notification) races it
// and the wait folds to "not loaded" instead of stalling the
// load mutex for the full 5s timeout.
await Promise.race([fileLoaded, this.conn?.waitEvent("end-file", 5000)]);
await this.send(["set_property", "time-pos", pausedSeek]); await this.send(["set_property", "time-pos", pausedSeek]);
this._position = pausedSeek; this._position = pausedSeek;
} }
@@ -544,13 +606,22 @@ export class MpvBackend implements AudioBackend {
// play checks it and skips its own stale paused-load. // play checks it and skips its own stale paused-load.
this._intentPlaying = true; this._intentPlaying = true;
await this.runLoadExclusive(async () => { await this.runLoadExclusive(async () => {
// Fast path: this exact URL was PRELOADED paused (boot restore) — // Same episode re-selected (Enter in a list, key-repeat, a
// mpv has been buffering it since boot, so flipping pause off starts // second tap on the playing row): the file is ALREADY in the
// audio ~instantly. Re-acquire the start position only when it // player. Reloading with start=<saved progress> would audibly
// moved meaningfully since the preload (progress saved meanwhile). // skip BACK and repeat already-played audio (saved progress
if (this._loadedUrl === url && this._loadedPaused && !this._ended) { // 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; 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]); await this.send(["set_property", "time-pos", target]);
this._position = target; this._position = target;
} }
@@ -590,6 +661,13 @@ export class MpvBackend implements AudioBackend {
} }
async resume(): Promise<void> { 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) { if (this._ended && this._loadedUrl) {
// Play pressed on a finished episode: replay from the top. // Play pressed on a finished episode: replay from the top.
this._ended = false; this._ended = false;
@@ -609,7 +687,12 @@ export class MpvBackend implements AudioBackend {
this._loadedPaused = false; this._loadedPaused = false;
} }
this._ended = 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; this._intentPlaying = true;
} }
@@ -723,6 +806,9 @@ class NoopBackend implements AudioBackend {
isAlive(): boolean { isAlive(): boolean {
return false; return false;
} }
getPlaybackError(): string | null {
return null;
}
dispose(): void {} dispose(): void {}
} }

88
src/utils/audio-queue.ts Normal file
View 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 };
}

View File

@@ -95,6 +95,8 @@ export class CavaCore {
private _bars = 0; private _bars = 0;
private _channels = 1; private _channels = 1;
private _destroyed = false; private _destroyed = false;
/** Serialized last init config — identical init() calls are no-ops. */
private lastConfigKey = "";
/** Use loadCavaCore() instead of constructing directly. */ /** Use loadCavaCore() instead of constructing directly. */
constructor(lib: CavaLib) { constructor(lib: CavaLib) {
@@ -112,15 +114,25 @@ export class CavaCore {
/** /**
* Initialize the cavacore engine with the given configuration. * Initialize the cavacore engine with the given configuration.
* Must be called before execute(). Can be called again after destroy() * Must be called before execute(). Identical configs are a no-op:
* to reinitialize with different parameters. * cava_init/destroy churn leaks the old plan's FFTW work buffers
* (upstream frees only its own struct), so a pipeline restart with
* unchanged bars/rate/cutoffs must re-USE the live plan.
*/ */
init(config: CavaCoreConfig = {}): void { init(config: CavaCoreConfig = {}): void {
const cfg = { ...DEFAULTS, ...config };
if (
this.plan !== null &&
!this._destroyed &&
this.lastConfigKey === JSON.stringify(cfg)
) {
return;
}
this.lastConfigKey = JSON.stringify(cfg);
if (this.plan) { if (this.plan) {
this.destroy(); this.destroy();
} }
const cfg = { ...DEFAULTS, ...config };
this._bars = cfg.bars; this._bars = cfg.bars;
this._channels = cfg.channels; this._channels = cfg.channels;

View File

@@ -124,17 +124,17 @@ export async function downloadEpisode(
} }
} }
const reader = body.getReader() const fileWriter = Bun.file(filePath).writer()
const chunks: Uint8Array[] = []
let bytesDownloaded = 0 let bytesDownloaded = 0
let lastProgressTime = Date.now() let lastProgressTime = Date.now()
let lastProgressBytes = 0 let lastProgressBytes = 0
const reader = body.getReader()
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) break
chunks.push(value) fileWriter.write(value)
bytesDownloaded += value.length bytesDownloaded += value.length
// Report progress roughly every 250ms // Report progress roughly every 250ms
@@ -152,22 +152,14 @@ export async function downloadEpisode(
} }
} }
// Concatenate chunks and write to file // Finalize the streamed file
const totalSize = bytesDownloaded await fileWriter.end()
const buffer = new Uint8Array(totalSize)
let offset = 0
for (const chunk of chunks) {
buffer.set(chunk, offset)
offset += chunk.length
}
await Bun.write(filePath, buffer)
// Final progress report // Final progress report
if (onProgress) { if (onProgress) {
onProgress({ onProgress({
bytesDownloaded: totalSize, bytesDownloaded,
totalBytes: contentLength || totalSize, totalBytes: contentLength || bytesDownloaded,
percent: 100, percent: 100,
speed: 0, speed: 0,
}) })
@@ -176,7 +168,7 @@ export async function downloadEpisode(
return { return {
success: true, success: true,
filePath, filePath,
fileSize: totalSize, fileSize: bytesDownloaded,
} }
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") { if (err instanceof DOMException && err.name === "AbortError") {

View File

@@ -8,11 +8,35 @@ const ts = (ep: Episode): number => {
return t === undefined || Number.isNaN(t) ? Infinity : t 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 * 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 * wins (fresh metadata). An existing episode whose id differs from every
* by the supplied `keep` predicate: episodes outside the configured cache * fetched id but whose content signature matches a fetched episode is a
* bound (date window or count) are dropped. Never mutates either input. * 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 * 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. * 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, keep: (ep: Episode, index: number) => boolean,
): Episode[] { ): Episode[] {
const byId = new Map<string, Episode>() const byId = new Map<string, Episode>()
for (const ep of existing) byId.set(ep.id, ep) const bySignature = new Map<string, Episode>()
for (const ep of fetched) byId.set(ep.id, ep) for (const ep of fetched) {
const sorted = [...byId.values()].sort((a, b) => ts(b) - ts(a)) 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)) return sorted.filter((ep, i) => keep(ep, i))
} }

View File

@@ -0,0 +1,111 @@
/**
* Episode cache-window math — shared by the feed store's refresh, retention,
* and fetch-more paging paths. Pure module: no Solid, no store imports.
*/
import type { Episode } from "../types/episode";
import { episodeSignature } from "./episode-merge";
import { episodeInWindow } from "./feeds-persistence";
/** Floor on the visible episode window for a subscribed show: at least this
* many most-recent episodes always load, regardless of a stricter count or
* date cache bound. */
const MIN_EPISODES_PER_SHOW = 5;
/** 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;
/** Timestamp for window math — undated episodes sort/compare as NEWEST
* (Infinity) so they can never be excluded by a date cutoff. */
export const episodeTs = (ep: Episode): number => {
const t = ep.pubDate?.getTime();
return t === undefined || Number.isNaN(t) ? Infinity : t;
};
/** Read the episode cache bound from preferences: a closure that decides
* whether the episode at `index` (0 = newest, after sort) is kept. The five
* most-recent episodes of a subscribed show always stay (MIN_EPISODES_PER_SHOW),
* overriding a stricter count or date bound so every show surfaces at least
* five episodes. */
export function episodeKeepFn(
prefs: {
episodeCacheMode: "date" | "count";
episodeCacheCount: number;
episodeCacheDays: number;
},
now?: Date,
): (ep: Episode, index: number) => boolean {
const at = now ?? new Date();
if (prefs.episodeCacheMode === "count") {
const count = Math.max(1, prefs.episodeCacheCount);
return (_ep: Episode, index: number) =>
index < Math.max(count, MIN_EPISODES_PER_SHOW);
}
const days = Math.max(1, prefs.episodeCacheDays);
return (ep: Episode, index: number) =>
index < MIN_EPISODES_PER_SHOW || episodeInWindow(ep, at, days);
}
/** 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. */
export const dateFetchMoreCutoff = (
cached: Episode[],
loaded: number,
windowDays: number,
): number => {
if (loaded > 0) {
const t = episodeTs(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 6074d slice).
return (
Date.now() -
Math.max(1, windowDays) * 24 * 3600 * 1000 -
FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000
);
};
/** Episodes the date band adds past the loaded window: count forward while
* each next cached episode still falls on/after the cutoff. The single
* implementation behind both fetch-more paths (one feed / all feeds) — an
* empty band adds nothing, which doubles as the "has more" guard. */
export function dateBandCount(
cached: Episode[],
loaded: number,
cutoff: number,
): number {
let count = loaded;
while (count < cached.length && episodeTs(cached[count]) >= cutoff) {
count++;
}
return count;
}
/** True when the freshly fetched window matches the corresponding PREFIX of
* the existing episode list (id-set equality, order-insensitive). With
* 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. 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));
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)));
}

22
src/utils/feed-resolve.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Feed resolution for an episode. `episode.podcastId` is the RSS feed url
* (rss-parser), which differs from `podcast.id` (the iTunes directory id) for
* iTunes-added shows — so a strict `podcast.id` match fails and the feed (and
* its cover) is never found. Match by podcast id, then feed url, then episode
* membership, in that order.
*/
import type { Feed } from "../types/feed";
import type { Episode } from "../types/episode";
/** The feed backing `episode`, by podcast id, then feed url, then membership. */
export function feedForEpisode(
feeds: Feed[],
episode: Episode,
): Feed | undefined {
return (
feeds.find((f) => f.podcast.id === episode.podcastId) ??
feeds.find((f) => f.podcast.feedUrl === episode.podcastId) ??
feeds.find((f) => f.episodes.some((e) => e.id === episode.id))
);
}

View File

@@ -83,6 +83,18 @@ function reviveDates(feed: Feed): Feed {
})), })),
}; };
} }
/** Config-legacy baggage: search-time parseRSSFeed once embedded the full
* episode history inside podcast.episodes (2,100+ stale copies, 3.8 MB of
* config). Nothing reads them — feed.episodes is the source of truth — so
* every load/save drops them. */
function stripLegacyPodcastEpisodes(feed: Feed): Feed {
if (!("episodes" in feed.podcast)) return feed;
const { episodes: _legacy, ...podcast } = feed.podcast;
void _legacy;
return { ...feed, podcast: podcast as Feed["podcast"] };
}
/** Load feeds from config.json, pruning episodes outside the retention /** Load feeds from config.json, pruning episodes outside the retention
* window (completed downloads always kept). When anything was pruned, the * window (completed downloads always kept). When anything was pruned, the
* pruned list is rewritten to config.json (startup cleanup for legacy * pruned list is rewritten to config.json (startup cleanup for legacy
@@ -93,7 +105,9 @@ export async function loadFeedsFromFile(
try { try {
const cfg = await loadConfig(); const cfg = await loadConfig();
if (!Array.isArray(cfg.feeds)) return []; if (!Array.isArray(cfg.feeds)) return [];
const feeds = cfg.feeds.map(reviveDates); const feeds = cfg.feeds
.map(reviveDates)
.map(stripLegacyPodcastEpisodes);
const downloadedIds = await readDownloadedEpisodeIds(); const downloadedIds = await readDownloadedEpisodeIds();
const now = new Date(); const now = new Date();
let prunedAny = false; let prunedAny = false;
@@ -122,12 +136,14 @@ export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
(async () => { (async () => {
try { try {
const downloadedIds = await readDownloadedEpisodeIds(); const downloadedIds = await readDownloadedEpisodeIds();
const pruned = feeds.map((f) => ({ const pruned = feeds
...f, .map(stripLegacyPodcastEpisodes)
episodes: f.episodes.filter((ep) => .map((f) => ({
episodeIsPersistable(ep, downloadedIds, new Date(), windowDays), ...f,
), episodes: f.episodes.filter((ep) =>
})); episodeIsPersistable(ep, downloadedIds, new Date(), windowDays),
),
}));
updateConfig({ feeds: pruned }); updateConfig({ feeds: pruned });
} catch { } catch {
updateConfig({ feeds }); /* never lose data on an error path */ updateConfig({ feeds }); /* never lose data on an error path */

View File

@@ -1,11 +1,11 @@
/** /**
* layer-graph — maps each TAB id to its page component + pane count. * layer-graph — maps each TAB id to its page component + pane count.
* *
* Split out of `navigation.ts` so that the nav-model primitives (TABS, * Split out of `navigation.ts` so that the navigation primitives (TABS,
* TabsCount, DEPTH_TABS, rootFrameFor, TabPaneCount, PANE_RATIO) in * TabsCount, DEPTH_TABS, rootFrameFor, TabPaneCount) stay free of any
* `navigation.ts` stay free of any `.tsx` / JSX imports. This lets unit tests * `.tsx` / JSX imports. This lets unit tests import the pure navigation
* import the pure navigation store without pulling the OpenTUI JSX runtime * store without pulling the OpenTUI JSX runtime (which is only provided by
* (which is only provided by the build-time @opentui/solid bun-plugin). * the build-time @opentui/solid bun-plugin).
* *
* The page modules live alongside their pages and export `<count>PaneCount` * The page modules live alongside their pages and export `<count>PaneCount`
* constants describing how many focusable panes each fixed page owns. * constants describing how many focusable panes each fixed page owns.

View File

@@ -49,22 +49,10 @@ export function rootFrameFor(
} }
} }
// The per-tab page components + pane counts live in `src/utils/layer-graph.ts`, // Pane sizes are now user-resizable: the split positions (fractions of the
// split out so this module stays free of `.tsx`/JSX imports (unit-testable). // row width) live in the shared pane-layout store (`@/stores/pane-layout`),
// which `PaneRow` consumes. `PANE_RATIO` was removed — see DEFAULT_PANE_SPLITS
// Yazi-style pane grow ratios (parent : current : preview). Panes use // (0.2 / 0.7) for the historical 2:5:3 start.
// flexGrow (Yoga) so columns always sum to the row width regardless of
// terminal size — more robust than fixed percentages and exactly mirrors
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
//
// Current ratios: parent : current : preview = 2 : 5 : 3, i.e. 20% / 50% / 30%
// of the row width (2 : 5 : 3 of 10). 2-pane tabs drop the preview slot and
// give `current` the combined 8/10 (80%).
export const PANE_RATIO = {
parent: 2,
current: 5,
preview: 3,
} as const;
// Number of *focusable* content panes per tab. The three visible columns // Number of *focusable* content panes per tab. The three visible columns
// (parent | current | preview) are a *render* concern, NOT three panes — for // (parent | current | preview) are a *render* concern, NOT three panes — for

View File

@@ -0,0 +1,74 @@
/**
* Nested scroll sections favor the innermost one under the cursor.
*
* opentui bubbles a wheel event up the renderable tree, so every ancestor
* `ScrollBoxRenderable` that has room to move scrolls — nested sections (e.g.
* the episode-description scrollbox inside a page's list pane) scroll in
* lockstep. This patches the scrollbox's wheel handler so the innermost
* scrollbox under the cursor wins instead:
*
* • The first scrollbox that can move in the wheel's direction scrolls and
* stops propagation, so its ancestors don't also scroll.
* • When it is already at its boundary it lets the next outer scrollbox
* take over (wheel chaining), matching typical nested-scroll UX.
*/
import { ScrollBoxRenderable } from "@opentui/core";
import type { MouseEvent } from "@opentui/core";
type ScrollDir = "up" | "down" | "left" | "right";
// The scrollbox's own wheel handler (scrolls, then bubbles to its parent).
const original = (ScrollBoxRenderable.prototype as unknown as {
onMouseEvent: (event: MouseEvent) => void;
}).onMouseEvent;
let installed = false;
/** True when `sb` has room to move in `dir` from its current position. */
function canScroll(sb: ScrollBoxRenderable, dir: ScrollDir): boolean {
const maxTop = Math.max(0, sb.scrollHeight - sb.viewport.height);
const maxLeft = Math.max(0, sb.scrollWidth - sb.viewport.width);
switch (dir) {
case "up":
return sb.scrollTop > 0;
case "down":
return sb.scrollTop < maxTop;
case "left":
return sb.scrollLeft > 0;
case "right":
return sb.scrollLeft < maxLeft;
}
}
const handleWheel = function (
this: ScrollBoxRenderable,
event: MouseEvent,
): void {
if (event.type !== "scroll" || !event.scroll?.direction) {
original.call(this, event);
return;
}
const dir = event.scroll.direction;
const effective: ScrollDir = event.modifiers.shift
? (dir === "up" ? "left" : dir === "down" ? "right" : dir === "right" ? "down" : "up")
: dir;
const moves = canScroll(this, effective);
original.call(this, event);
// Only claim the wheel when this box actually moved; otherwise let the
// next outer scrollbox (also under the cursor) take over.
if (moves) event.stopPropagation();
};
export function installNestedScrollBehavior(): void {
if (installed || typeof original !== "function") return;
installed = true;
// `onMouseEvent` is a well-known protected method; the cast only bypasses
// TypeScript's protected-access check and trusts the shipped class shape.
const scrollboxProto = ScrollBoxRenderable.prototype as unknown as {
onMouseEvent: typeof handleWheel;
};
scrollboxProto.onMouseEvent = handleWheel;
}

31
src/utils/rss-client.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* RSS feed client — single owner of feed XML fetches: headers, timeout,
* and failure folding to null.
*/
/** Default per-feed fetch timeout (ms). */
export const FETCH_TIMEOUT_MS = 20_000;
/**
* Fetch a feed's raw XML. Identity encoding keeps the response raw; the
* Accept list matches what podcast servers send. Any failure (network,
* non-ok, timeout) resolves to null — callers must leave data untouched.
*/
export const fetchFeedXml = async (
url: string,
opts?: { timeoutMs?: number },
): Promise<string | null> => {
try {
const response = await fetch(url, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
signal: AbortSignal.timeout(opts?.timeoutMs ?? FETCH_TIMEOUT_MS),
});
if (!response.ok) return null;
return await response.text();
} catch {
return null;
}
};

View File

@@ -1,5 +1,6 @@
import { searchSourceByType, searchEpisodesByType } from "./source-searcher"; import { searchSourceByType, searchEpisodesByType } from "./source-searcher";
import { parseRSSFeed } from "../api/rss-parser"; import { parseRSSFeed } from "../api/rss-parser";
import { fetchFeedXml } from "./rss-client";
import { SourceType } from "../types/source"; import { SourceType } from "../types/source";
import type { PodcastSource, SearchResult } from "../types/source"; import type { PodcastSource, SearchResult } from "../types/source";
@@ -81,16 +82,13 @@ export const searchByFeedUrl = async (
if (!FEED_URL_RE.test(trimmed)) return []; if (!FEED_URL_RE.test(trimmed)) return [];
try { try {
const response = await fetch(trimmed, { const xml = await fetchFeedXml(trimmed);
headers: { if (xml === null) return [];
"Accept-Encoding": "identity", // Full parse's episodes are dead weight here (2,100+ stale copies were
Accept: "application/rss+xml, application/xml, text/xml, */*", // previously persisted inside Feed.podcast): addFeed refetches through
}, // fetchEpisodes and nothing reads Podcast.episodes off a search result.
}); const { episodes: _episodes, ...podcast } = parseRSSFeed(xml, trimmed);
if (!response.ok) return []; void _episodes;
const xml = await response.text();
const podcast = parseRSSFeed(xml, trimmed);
return [ return [
{ {

View File

@@ -0,0 +1,59 @@
/**
* Source registry — owns the podcast source list and its immediate file
* persistence. The feed store seeds it at boot and wires the loaded list in.
*/
import { createSignal } from "solid-js";
import type { PodcastSource } from "../types/source";
import { saveSourcesToFile } from "./feeds-persistence";
/** Create a source registry around the given initial list. Every mutation
* persists immediately (async, fire-and-forget) — source edits are rare
* and must not sit in a debounce window across a process exit. */
export function createSourceRegistry(initial: PodcastSource[]) {
const [sources, setSources] = createSignal<PodcastSource[]>([...initial]);
/** Swap in a fully rebuilt list WITHOUT persisting — the boot-time
* loader saves only when its migration actually changed data. */
const replaceAll = (list: PodcastSource[]): void => {
setSources(list);
};
const addSource = (source: Omit<PodcastSource, "id">): PodcastSource => {
const newSource: PodcastSource = {
...source,
id: crypto.randomUUID(),
};
setSources((prev) => {
const updated = [...prev, newSource];
saveSourcesToFile(updated);
return updated;
});
return newSource;
};
const updateSource = (
sourceId: string,
updates: Partial<PodcastSource>,
): void => {
setSources((prev) => {
const updated = prev.map((source) =>
source.id === sourceId ? { ...source, ...updates } : source,
);
saveSourcesToFile(updated);
return updated;
});
};
const toggleSource = (sourceId: string): void => {
setSources((prev) => {
const updated = prev.map((s) =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
);
saveSourcesToFile(updated);
return updated;
});
};
return { sources, replaceAll, addSource, updateSource, toggleSource };
}

View File

@@ -0,0 +1,47 @@
/**
* Terminal recovery for suspend/resume and system sleep/wake cycles.
*
* The renderer enters the alternate screen, enables raw mode and attaches its
* stdin listener exactly once at startup. The diff renderer also keeps
* `currentRenderBuffer` as its model of what is on screen and only writes the
* cells that changed against that model.
*
* When the session is suspended (Ctrl-Z) or the system sleeps and the process
* is later resumed, the terminal screen can desync from that model: the stale
* buffer makes the diff rewrite only "changed" cells, leaving garbled or
* previous content on screen, and the raw-mode / stdin wiring can be dropped.
* The result is a frozen, non-interactive screen that shows raw markup instead
* of the UI.
*
* SIGCONT is the standard signal delivered when a stopped process resumes.
* On it we call `renderer.resume()`, the library's own recovery path, which:
* - re-enters the alternate screen (native resumeRenderer)
* - re-enables raw mode, re-attaches the stdin listener and flushes stale input
* - clears currentRenderBuffer so the next frame performs a full repaint
*/
import type { CliRenderer } from "@opentui/core";
/**
* Register a SIGCONT handler that recovers the terminal after suspend/resume.
*
* @param renderer - the active CLI renderer
* @returns cleanup function that removes the handler
*/
export function setupTerminalRecovery(renderer: CliRenderer): () => void {
const onContinue = () => {
// Best-effort: resume() re-establishes terminal state and forces a full
// repaint by clearing the render buffer. Idempotent if fired repeatedly.
try {
renderer.resume();
} catch {
// recovery is best-effort; never crash on the recovery path itself
}
};
process.on("SIGCONT", onContinue);
return () => {
process.off("SIGCONT", onContinue);
};
}

View File

@@ -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. - 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() }`. - 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`). - 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. - `tests/feed-volatile-merge.test.ts` (reworked) — see tests section.
steps: steps:
@@ -57,7 +57,9 @@ tests:
- input arrays not mutated. - 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): - 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). - 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. - 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. - 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. - 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.

View File

@@ -12,6 +12,10 @@
* it by unpausing — the boot-restore fast path with no second load. * it by unpausing — the boot-restore fast path with no second load.
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing * 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
* resume() afterwards replays from the top. * 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; * All playback runs silent (volume 0). Requires a real mpv on PATH;
* tests skip where it is missing. * tests skip where it is missing.
@@ -19,7 +23,10 @@
import { test, expect } from "bun:test"; import { test, expect } from "bun:test";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { MpvBackend } from "../src/utils/audio-player"; import {
MpvBackend,
PlayerRestartedError,
} from "../src/utils/audio-player";
const SAMPLE_RATE = 22050; const SAMPLE_RATE = 22050;
const FREQ = 440; const FREQ = 440;
@@ -165,6 +172,116 @@ test.skipIf(!hasMpv)(
{ timeout: 20000 }, { 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)( test.skipIf(!hasMpv)(
"EOF marks playback ended; resume() then replays from the top", "EOF marks playback ended; resume() then replays from the top",
async () => { async () => {

155
tests/audio-queue.test.ts Normal file
View 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
View 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 },
);

View File

@@ -0,0 +1,58 @@
/**
* CavaCore.init() must be a no-op for an identical config: re-initializing
* the same plan leaks the old plan's native FFTW work buffers, so pipeline
* restarts (focus/episode churn) have to re-USE the live plan.
*/
import { test, expect } from "bun:test"
import { CavaCore } from "../src/utils/cavacore"
function stubLib() {
const calls = { init: 0, destroy: 0 }
let plans = 0
const lib = {
symbols: {
cava_init: () => {
calls.init++
return { p: ++plans }
},
cava_execute: () => {},
cava_destroy: () => {
calls.destroy++
},
},
close: () => {},
}
return { lib, calls }
}
test("identical init config reuses the plan", () => {
const { lib, calls } = stubLib()
const cava = new CavaCore(lib as never)
const cfg = { bars: 64, sampleRate: 22050, channels: 1, autosens: 0 }
cava.init(cfg)
cava.init(cfg)
cava.init(cfg)
expect(calls.init).toBe(1)
expect(cava.isReady).toBe(true)
})
test("changed config re-inits, destroying the old plan", () => {
const { lib, calls } = stubLib()
const cava = new CavaCore(lib as never)
cava.init({ bars: 64, sampleRate: 22050, channels: 1, autosens: 0 })
cava.init({ bars: 32, sampleRate: 22050, channels: 1, autosens: 0 })
expect(calls.init).toBe(2)
expect(calls.destroy).toBe(1)
expect(cava.bars).toBe(32)
})
test("init after destroy creates a fresh plan", () => {
const { lib, calls } = stubLib()
const cava = new CavaCore(lib as never)
const cfg = { bars: 64, sampleRate: 22050, channels: 1, autosens: 0 }
cava.init(cfg)
cava.destroy()
cava.init(cfg)
expect(calls.init).toBe(2)
expect(cava.isReady).toBe(true)
})

View 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();
}
});

View 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();
});

View File

@@ -16,6 +16,11 @@
* property the same way the OS does and asserts useAudio reconciles in * property the same way the OS does and asserts useAudio reconciles in
* both directions. Skipped when mpv isn't installed. * 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 * Real-timer note: the reconcile path runs on useAudio's real 150ms poll
* interval against a real mpv process, with no injectable clock — the * interval against a real mpv process, with no injectable clock — the
* deliberate-exception case from the no-real-timers rule (same as * 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 = { const episode = {
id: "ep1", id: "ep1",
podcastId: "pod1", podcastId: "pod1",
@@ -235,6 +261,42 @@ test.skipIf(!hasMpv)(
{ timeout: 30000 }, { 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 ────────────────────────────────────────────────────────────── // ── Teardown ──────────────────────────────────────────────────────────────
afterAll(async () => { afterAll(async () => {

View File

@@ -0,0 +1,72 @@
/**
* Unit test for feedForEpisode: resolving the feed behind an episode.
*
* The critical case is the reported regression — an iTunes show's episode has
* `podcastId` set to the RSS feed url (rss-parser:163) while the feed's
* `podcast.id` is the iTunes directory id. Those differ, so a strict
* `podcast.id` match loses the feed (and its cover, stalling Now Playing art).
*/
import { describe, expect, test } from "bun:test";
import { feedForEpisode } from "../src/utils/feed-resolve";
import { FeedVisibility } from "../src/types/feed";
import type { Episode } from "../src/types/episode";
import type { Feed } from "../src/types/feed";
function makeFeed(id: string, feedUrl: string, title = `Show ${id}`): Feed {
return {
id,
podcast: {
id,
title,
description: "",
feedUrl,
coverUrl: `https://cover/${id}.jpg`,
lastUpdated: new Date(),
isSubscribed: true,
},
episodes: [],
visibility: FeedVisibility.PUBLIC,
sourceId: "test",
lastUpdated: new Date(),
isPinned: false,
};
}
const episode = (podcastId: string, id = "ep"): Episode => ({
id,
podcastId,
title: "Episode",
description: "",
audioUrl: "https://audio/ep.mp3",
duration: 60,
pubDate: new Date(),
});
describe("feedForEpisode", () => {
test("matches when episode.podcastId equals the feed's podcast.id", () => {
const f = makeFeed("id-a", "http://a/feed.xml");
expect(feedForEpisode([f], episode("id-a"))?.podcast.id).toBe("id-a");
});
test("matches an iTunes show by feed url (podcastId != podcast.id)", () => {
// The regression: feed.podcast.id is the directory id, podcastId the
// RSS url — a strict id match loses the feed.
const feedUrl = "http://itunes.example/feed.xml";
const f = makeFeed("itunes-1177068388", feedUrl);
const got = feedForEpisode([f], episode(feedUrl));
expect(got?.podcast.id).toBe("itunes-1177068388");
});
test("falls back to episode membership when neither id nor feedUrl match", () => {
const f = makeFeed("id-b", "http://b/feed.xml");
const ep = episode("unrelated", "ep-42");
f.episodes = [ep];
expect(feedForEpisode([f], ep)?.podcast.id).toBe("id-b");
});
test("returns undefined when no feed matches", () => {
const f = makeFeed("id-c", "http://c/feed.xml");
expect(feedForEpisode([f], episode("nowhere"))).toBeUndefined();
});
});

View File

@@ -62,7 +62,7 @@ const addedFeedIds: string[] = [];
/** Feed created by the debounce test, reused by the flushPendingSave test. */ /** Feed created by the debounce test, reused by the flushPendingSave test. */
let debounceFeedId = ""; 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 { function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes const items = episodes
.map( .map(

View File

@@ -3,12 +3,10 @@
* row in a drilled show's episode list (My Shows depth 1) and the Feed * row in a drilled show's episode list (My Shows depth 1) and the Feed
* page's row. * page's row.
* *
* addFeed caches every episode inside the lifecycle window (the last * Runs in COUNT cache mode: `loadMoreEpisodes` advances the loaded window in
* EPISODE_WINDOW_DAYS days — the date bound, not a count) while exposing * fixed MAX_EPISODES_REFRESH (50) chunks until the cache is exhausted.
* only the first MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` * (Date-mode fetch-more steps by a two-week window instead — that contract
* reports when the cache holds more than the loaded window; * is pinned in feed-volatile-merge.test.ts.) This pins:
* `loadMoreEpisodes` advances that window in MAX_EPISODES_REFRESH (50)
* chunks until it is exhausted. This pins:
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes. * 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch * 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
* needed) and hasMoreEpisodes flips false once the window reaches the end. * 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; process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed"; import { useFeedStore } from "../src/stores/feed";
import { useAppStore } from "../src/stores/app";
import type { Podcast } from "../src/types/podcast"; import type { Podcast } from "../src/types/podcast";
const HOUR = 3600 * 1000; const HOUR = 3600 * 1000;
@@ -43,7 +42,7 @@ let servedEpisodes: ServedEpisode[] = [];
// store (execution order between files is not guaranteed). // store (execution order between files is not guaranteed).
const addedFeedIds: string[] = []; 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 { function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes const items = episodes
.map( .map(
@@ -72,7 +71,15 @@ const makePodcast = (feedUrl: string): Podcast => ({
isSubscribed: true, 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({ server = Bun.serve({
port: 0, port: 0,
fetch(req) { fetch(req) {
@@ -89,6 +96,7 @@ beforeAll(() => {
afterAll(() => { afterAll(() => {
// Leave the shared singleton as we found it (see addedFeedIds note). // Leave the shared singleton as we found it (see addedFeedIds note).
useAppStore().updatePreferences({ episodeCacheMode: "date" });
const store = useFeedStore(); const store = useFeedStore();
for (const id of addedFeedIds) store.removeFeed(id); for (const id of addedFeedIds) store.removeFeed(id);
server?.stop(true); server?.stop(true);

View 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"])
})

View File

@@ -42,7 +42,7 @@ let feedAId = "";
/** When set, the server 503s this path — simulates a feed going down. */ /** When set, the server 503s this path — simulates a feed going down. */
let failPath: string | null = null; 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 { function feedXml(episodes: ServedEpisode[], origin: string): string {
const items = episodes const items = episodes
.map( .map(
@@ -79,6 +79,18 @@ beforeAll(() => {
if (failPath && url.pathname === failPath) { if (failPath && url.pathname === failPath) {
return new Response("feed unavailable", { status: 503 }); 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")) { if (url.pathname.endsWith(".xml")) {
return new Response(feedXml(servedEpisodes, url.origin), { return new Response(feedXml(servedEpisodes, url.origin), {
headers: { "Content-Type": "application/rss+xml" }, 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 () => { test("a failed refresh does not wipe the feed's episodes", async () => {
const store = useFeedStore(); const store = useFeedStore();
const savedEpisodes = servedEpisodes; // /flaky.xml serves its own fixed single-episode feed (see server route)
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }]; // 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 feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
expect(feed).not.toBeNull(); expect(feed).not.toBeNull();
@@ -161,11 +174,6 @@ test("a failed refresh does not wipe the feed's episodes", async () => {
failPath = null; failPath = null;
store.removeFeed(feedId); 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 () => { test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {

View File

@@ -36,6 +36,7 @@ import { whenConfigIdle } from "../src/utils/config";
import { FeedVisibility } from "../src/types/feed"; import { FeedVisibility } from "../src/types/feed";
import type { Feed } from "../src/types/feed"; import type { Feed } from "../src/types/feed";
import type { Episode } from "../src/types/episode"; import type { Episode } from "../src/types/episode";
import type { PodcastWithEpisodes } from "../src/types/podcast";
const configJsonPath = join(configHome, "podtui", "config.json"); const configJsonPath = join(configHome, "podtui", "config.json");
const downloadsJsonPath = join(configHome, "podtui", "downloads.json"); const downloadsJsonPath = join(configHome, "podtui", "downloads.json");
@@ -169,6 +170,73 @@ test("DEFAULT_EPISODE_WINDOW_DAYS is 60", () => {
expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60); expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60);
}); });
// ── Legacy podcast.episodes baggage ────────────────────────────────────────
test("saveFeedsToFile strips legacy podcast.episodes from the persisted feed", async () => {
const feed = makeFeed([
makeEpisode({ id: "recent-id", pubDate: new Date(Date.now() - 5 * DAY) }),
]);
// Simulate the pre-fix shape: parseRSSFeed's full history embedded on
// the podcast object (841 stale copies were persisted this way).
const podcastWithLegacy = feed.podcast as PodcastWithEpisodes;
podcastWithLegacy.episodes = [
makeEpisode({ id: "stale-history-1" }),
makeEpisode({ id: "stale-history-2" }),
];
saveFeedsToFile([feed]);
await settleWrites();
const raw = await Bun.file(configJsonPath).json();
expect("episodes" in raw.feeds[0].podcast).toBe(false);
});
test("loadFeedsFromFile drops legacy podcast.episodes from a seeded config", async () => {
await Bun.write(
configJsonPath,
JSON.stringify({
feeds: [
{
id: "feed-1",
podcast: {
id: "feed-1",
title: "Baggage Show",
description: "",
author: "tester",
feedUrl: "https://example.com/baggage.xml",
lastUpdated: new Date().toISOString(),
isSubscribed: true,
episodes: [
{ id: "huge-stale-1", title: "archived copy" },
{ id: "huge-stale-2", title: "archived copy" },
],
},
episodes: [
{
id: "recent-id",
podcastId: "feed-1",
title: "Recent",
description: "",
audioUrl: "https://example.com/audio/recent.mp3",
duration: 60,
pubDate: new Date().toISOString(),
},
],
visibility: "public",
sourceId: "source-1",
lastUpdated: new Date().toISOString(),
isPinned: false,
},
],
}),
);
const feeds = await loadFeedsFromFile();
expect(feeds).toHaveLength(1);
expect(feeds[0].episodes.map((e) => e.id)).toEqual(["recent-id"]);
expect("episodes" in feeds[0].podcast).toBe(false);
});
// ── Save path: retention window applied with completed-download exemption ── // ── Save path: retention window applied with completed-download exemption ──
test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => { test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => {

View File

@@ -28,7 +28,7 @@ import { join } from "path";
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-")); const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
process.env.XDG_CONFIG_HOME = configHome; 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 { mergeEpisodesBounded } from "../src/utils/episode-merge";
import { episodeInWindow } from "../src/utils/feeds-persistence"; import { episodeInWindow } from "../src/utils/feeds-persistence";
import { useAppStore } from "../src/stores/app"; 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"); 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", () => { test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => {
const existing = [ const existing = [
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")), makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
@@ -268,7 +329,7 @@ test("date mode: episodes outside the 60-day window never enter the list", async
expect(store.getFeed(id)!.episodes.length).toBe(600); expect(store.getFeed(id)!.episodes.length).toBe(600);
}); });
test("date mode boundary: 25 days in, 70 days out", async () => { test("date mode: the 5 newest episodes load even outside the date window", async () => {
const store = useFeedStore(); const store = useFeedStore();
const now = Date.now(); const now = Date.now();
servedEpisodes = [ servedEpisodes = [
@@ -283,10 +344,11 @@ test("date mode boundary: 25 days in, 70 days out", async () => {
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([ expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"In Window", "In Window",
"Out Window",
]); ]);
// The full cache holds both, but the visible list only shows the in-window // The 70d episode is the second-newest available, so the min-5 floor
// one — fetch-more surfaces the out-of-window one (volatile). // pulls it in despite the 60-day cache window.
expect(store.hasMoreEpisodes(id)).toBe(true); expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id); await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([ expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"In Window", "In Window",
@@ -294,11 +356,97 @@ test("date mode boundary: 25 days in, 70 days out", async () => {
]); ]);
}); });
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);
// The min-5 floor surfaces the show's only 2 episodes; it still cannot
// fetch-more (nothing further exists to load).
expect(store.getFeed(id)!.episodes.length).toBe(2);
expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.length).toBe(2);
});
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); the min-5
// floor loads them at subscribe time regardless.
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);
// The min-5 floor loads both out-of-window episodes immediately.
expect(store.getFeed(id)!.episodes.length).toBe(2);
expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"Just Out A",
"Just Out B",
]);
});
// ── count mode ──────────────────────────────────────────────────────────── // ── 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 (6069d).
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 (7281d).
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 (8487d).
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 () => { test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => {
const store = useFeedStore(); const store = useFeedStore();
const app = useAppStore(); 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 }); app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
const now = Date.now(); const now = Date.now();
@@ -329,3 +477,67 @@ test("count mode: only N most-recent episodes are visible, but fetch-more goes b
// Reset to date mode for subsequent tests. // Reset to date mode for subsequent tests.
app.updatePreferences({ episodeCacheMode: "date" }); 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 0199d). Feed B: 200 at
// 1-day spacing shifted 200 days older (ages 200399d) — 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" });
});

View File

@@ -0,0 +1,128 @@
/**
* Nested scroll behavior — the innermost scrollbox under the cursor wins.
*
* opentui bubbles wheel events up the renderable tree, so without a guard
* every ancestor scrollbox scrolls in lockstep. This pins the fix from
* `src/utils/nested-scroll.ts`: two nested scrollboxes (an inner one nested
* inside an outer one, as a description pane sits inside a list pane) must
* treat the wheel as owned by the innermost scrollbox under the cursor, and
* only chain out to the outer one when the inner is at its boundary.
*/
import { describe, test, expect, afterAll } from "bun:test";
import { testRender } from "@opentui/solid";
import { installNestedScrollBehavior } from "../src/utils/nested-scroll";
import type { ScrollBoxRenderable } from "@opentui/core";
installNestedScrollBehavior();
type TestSetup = {
renderOnce: () => Promise<void>;
mockMouse: {
scroll: (x: number, y: number, direction: "up" | "down") => Promise<void>;
};
renderer: { destroy: () => Promise<void> };
};
async function renderNested(): Promise<{
setup: TestSetup;
outer: () => ScrollBoxRenderable;
inner: () => ScrollBoxRenderable;
destroy: () => Promise<void>;
}> {
let outer: ScrollBoxRenderable | undefined;
let inner: ScrollBoxRenderable | undefined;
const setup = (await testRender(
() => (
// Outer spans the full 25-row terminal; the inner scrollbox sits at
// rows 3..12 (a top spacer above, a tall spacer below so the outer
// has room to scroll). Inner holds 30 rows -> max scroll 20.
<box flexDirection="column" width={60} height={25}>
<scrollbox ref={(el: ScrollBoxRenderable) => (outer = el)} height="100%">
<box height={3} />
<scrollbox
ref={(el: ScrollBoxRenderable) => (inner = el)}
height={10}
width="100%"
>
{Array.from({ length: 30 }, (_, i) => (
<box height={1}>
<text>row {i}</text>
</box>
))}
</scrollbox>
<box height={40} />
</scrollbox>
</box>
),
{ width: 60, height: 25, useThread: false },
)) as unknown as TestSetup;
// Give the renderer a chance to compute scrollbox layout (scrollHeight).
for (let i = 0; i < 40 && (inner?.scrollHeight ?? 0) <= 10; i++) {
await setup.renderOnce();
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, 50);
await promise;
}
if (!inner || !outer) throw new Error("scrollboxes did not render");
return {
setup,
outer: () => outer!,
inner: () => inner!,
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("nested scroll favors the innermost scrollbox under the cursor", () => {
test("wheel over the inner section scrolls only the inner scrollbox", async () => {
const { setup, inner, outer, destroy } = await renderNested();
cleanups.push(destroy);
expect(inner().scrollTop).toBe(0);
await setup.mockMouse.scroll(5, 5, "down"); // inside inner (rows 3..12)
expect(inner().scrollTop).toBe(1);
expect(outer().scrollTop).toBe(0);
});
test("wheel over the outer section (outside the inner) scrolls only the outer", async () => {
const { setup, inner, outer, destroy } = await renderNested();
cleanups.push(destroy);
await setup.mockMouse.scroll(5, 20, "down"); // below inner, still in outer
expect(outer().scrollTop).toBe(1);
expect(inner().scrollTop).toBe(0);
});
test("at the inner's bottom edge the wheel chains out to the outer scrollbox", async () => {
const { setup, inner, outer, destroy } = await renderNested();
cleanups.push(destroy);
for (let i = 0; i < 30; i++) await setup.mockMouse.scroll(5, 5, "down");
expect(inner().scrollTop).toBe(20); // pinned at max (30 rows - 10 viewport)
const before = outer().scrollTop;
await setup.mockMouse.scroll(5, 5, "down");
expect(inner().scrollTop).toBe(20); // inner stays pinned
expect(outer().scrollTop).toBe(before + 1); // outer took over
});
test("wheel up favors the inner again once it has room above", async () => {
const { setup, inner, outer, destroy } = await renderNested();
cleanups.push(destroy);
await setup.mockMouse.scroll(5, 5, "down");
expect(inner().scrollTop).toBe(1);
await setup.mockMouse.scroll(5, 5, "up");
expect(inner().scrollTop).toBe(0); // inner wins again
expect(outer().scrollTop).toBe(0);
});
});

View File

@@ -0,0 +1,101 @@
/**
* pane-layout-store.test.ts — the shared pane-split store: splitPixels
* clamping, border moves that respect per-pane minimum widths, and
* commit() persistence into the app preferences.
*/
import { test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Point the config dir at a throwaway directory BEFORE importing the store
// (its module-level init reads it).
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "podtui-panecfg-"));
// The config dir must be set before the module is evaluated, so the app
// store is loaded dynamically here rather than statically at the top.
const { splitPixels, createPaneLayoutStore, DEFAULT_PANE_SPLITS } = await import(
"../src/stores/pane-layout"
);
const { useAppStore } = await import("../src/stores/app");
beforeAll(async () => {
await useAppStore().whenReady();
});
afterAll(() => {
// Restore pristine preferences so a later file sharing this process
// (bun test reuses the module registry) renders the default split.
useAppStore().updatePreferences({ paneSplit: DEFAULT_PANE_SPLITS });
});
test("default splits mirror the historical 2:5:3 ratio at width 100", () => {
expect(DEFAULT_PANE_SPLITS).toEqual({ left: 0.2, right: 0.7 });
const { leftPx, rightPx } = splitPixels(100, DEFAULT_PANE_SPLITS);
expect(leftPx).toBe(20);
expect(rightPx).toBe(70);
});
test("splitPixels maps splits 1:1 to pixels (ratio exact at every width)", () => {
// Pure fraction→pixel mapping — no minimum enforcement in rendering.
expect(splitPixels(100, { left: 0.6, right: 0.7 })).toEqual({
leftPx: 60,
rightPx: 70,
});
expect(splitPixels(70, { left: 0.2, right: 0.7 })).toEqual({
leftPx: 14,
rightPx: 49,
});
});
test("splitPixels handles zero-width and degenerate terminals", () => {
expect(splitPixels(0, DEFAULT_PANE_SPLITS)).toEqual({ leftPx: 0, rightPx: 0 });
const tiny = splitPixels(40, { left: 0.2, right: 0.7 });
expect(tiny.leftPx).toBe(8);
expect(tiny.rightPx).toBe(28);
});
test("setRight clamps the preview to its minimum", () => {
const store = createPaneLayoutStore();
store.setRight(97, 100); // preview can't shrink below 15
expect(store.splits().right).toBeCloseTo(0.85, 5);
// The parent minimum also holds when the left border is dragged.
store.setLeft(1, 100);
expect(store.splits().left).toBeCloseTo(0.15, 5);
});
test("setLeft moves the border and normalizes stored fractions", () => {
const store = createPaneLayoutStore();
store.setLeft(40, 100);
expect(store.splits().left).toBeCloseTo(0.4, 5);
expect(store.splits().right).toBeCloseTo(0.7, 5);
});
test("setLeft below the parent minimum clamps up", () => {
const store = createPaneLayoutStore();
store.setLeft(5, 100);
expect(store.splits().left).toBeCloseTo(0.15, 5);
});
test("setLeft beyond the current minimum forces the right border right", () => {
const store = createPaneLayoutStore();
store.setLeft(60, 100);
expect(store.splits().left).toBeCloseTo(0.55, 5);
expect(store.splits().right).toBeCloseTo(0.85, 5);
});
test("setRight respects the current and preview minimums", () => {
const store = createPaneLayoutStore();
store.setRight(80, 100);
expect(store.splits().right).toBeCloseTo(0.8, 5);
store.setRight(40, 100); // must not cross below leftPx(20) + minCurrent(30)
expect(store.splits().right).toBeCloseTo(0.5, 5);
});
test("commit persists the split into the app preferences", async () => {
const store = createPaneLayoutStore();
store.setLeft(35, 100);
store.commit();
await useAppStore().whenReady();
expect(useAppStore().state().preferences.paneSplit.left).toBeCloseTo(0.35, 5);
expect(useAppStore().state().preferences.paneSplit.right).toBeCloseTo(0.7, 5);
});

209
tests/pane-resize.test.tsx Normal file
View File

@@ -0,0 +1,209 @@
/**
* pane-resize.test.tsx — dragging the center column's borders actually
* resizes the panes in a rendered PaneRow.
*
* Each pane renders a long run of a unique character (P / C / V). A line
* where all three meet encodes the boundary columns directly: the current
* pane carries the only borders (cols `leftPx` and `rightPx - 1`), so its
* content starts one column in — `leftPx + 1`. Hence
* `leftPx = firstC - 1`, `rightPx = firstV`.
*
* The grab zones overlay each border: 3 columns wide, the border plus one
* help-padded column each side (left zone at [left-1, left+1], right zone
* at [right-2, right)). The test presses inside a zone and drags across
* the row — the drag bubbles to the row container which moves the split,
* so the panes must re-render at the new columns.
*/
import { test, expect, afterAll } from "bun:test";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { PaneRow } from "../src/components/PaneRow";
import { usePaneLayout } from "../src/stores/pane-layout";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Point the config dir at a throwaway directory BEFORE importing the store
// (module-level init reads it).
const configHome = mkdtempSync(join(tmpdir(), "podtui-paneresize-"));
process.env.XDG_CONFIG_HOME = configHome;
type Span = { text: string };
type Frame = { cols: number; lines: { spans: Span[] }[] };
interface BoundCols {
left: number;
right: number;
}
function readBounds(frame: Frame): BoundCols {
const line = frame.lines
.map((l) => l.spans.map((s) => s.text).join(""))
.find((l) => l.includes("C"));
if (!line) throw new Error("pane row did not render");
return { left: line.indexOf("C") - 1, right: line.indexOf("V") };
}
async function renderRow(panes: 2 | 3 = 3) {
const setup = (await testRender(
() => (
<ThemeProvider mode="dark">
<PaneRow
parent={<text selectable={false}>{"P".repeat(300)}</text>}
current={<text selectable={false}>{"C".repeat(600)}</text>}
preview={<text selectable={false}>{"V".repeat(300)}</text>}
currentLabel=""
panes={panes}
/>
</ThemeProvider>
),
{ width: 100, height: 10, useThread: false },
)) as unknown as {
renderOnce: () => Promise<void>;
captureSpans: () => Frame;
mockMouse: {
drag: (a: number, b: number, c: number, d: number) => Promise<void>;
click: (a: number, b: number) => Promise<void>;
};
renderer: { destroy: () => void };
};
for (let i = 0; i < 10; i++) await setup.renderOnce();
return setup;
}
/** Reset the shared store to the default split for a deterministic start. */
function resetSplits() {
usePaneLayout().setLeft(20, 100);
usePaneLayout().setRight(70, 100);
}
const cleanups: (() => void)[] = [];
afterAll(() => {
for (const c of cleanups) c();
// Restore the default split so a later file sharing this process (bun
// test reuses the module registry) renders the default layout.
usePaneLayout().setLeft(20, 100);
usePaneLayout().setRight(70, 100);
rmSync(configHome, { recursive: true, force: true });
});
test("panes render at the default 20/70 split", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
const { left, right } = readBounds(setup.captureSpans());
expect(left).toBe(20);
expect(right).toBe(70);
});
test("dragging the left border resizes parent vs current", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press on the left zone (border at 20 → zone covers 19-21) and drag
// toward the middle of the row.
await setup.mockMouse.drag(20, 5, 45, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.left).toBeGreaterThanOrEqual(44);
expect(after.left).toBeLessThanOrEqual(46);
// Pushing the left border to 45 would shrink the current pane below its
// 30-col minimum (45..70 = 25), so the right border is forced right to
// 75, keeping the current pane at exactly 30 and absorbing the overflow
// in the preview.
expect(after.right).toBe(75);
});
test("dragging the right border resizes current vs preview", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press on the right zone (border at 69 → zone covers 68-70) and drag
// toward the right edge of the row.
await setup.mockMouse.drag(69, 5, 90, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.right).toBeGreaterThanOrEqual(84);
expect(after.right).toBeLessThanOrEqual(85); // clamped at preview min 15
expect(after.left).toBe(20);
});
test("grabbing the left zone from its far edge does not jump the border", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press one column LEFT of the border (x=19, border at 20 → offset -1)
// and drag to 37. The border must track the grab, landing at 38 (37 + 1),
// not at 37. Without the grab offset it would jump one column.
await setup.mockMouse.drag(19, 5, 37, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.left).toBe(38);
expect(after.right).toBe(70);
});
test("grabbing the left zone from its inner edge does not jump the border", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press one column RIGHT of the border (x=21, border at 20 → offset +1)
// and drag to 37. The border lands at 36 (37 - 1), not 37.
await setup.mockMouse.drag(21, 5, 37, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.left).toBe(36);
expect(after.right).toBe(70);
});
test("a click inside a padded grab zone (off the border) does not resize", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// A bare click (no drag) on the help-padded column beside the border
// must not move the split — only an actual drag does.
await setup.mockMouse.click(19, 5);
await setup.renderOnce();
const { left, right } = readBounds(setup.captureSpans());
expect(left).toBe(20);
expect(right).toBe(70);
});
test("a plain click away from the borders does not resize", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
await setup.mockMouse.click(5, 5);
await setup.renderOnce();
const { left, right } = readBounds(setup.captureSpans());
expect(left).toBe(20);
expect(right).toBe(70);
});
test("2-pane rows offer no right border (current fills the row)", async () => {
const setup = await renderRow(2);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
for (let i = 0; i < 10; i++) await setup.renderOnce();
// No 'V' pane: the line runs to the screen edge.
const frame = setup.captureSpans();
const { left } = readBounds(frame);
expect(left).toBe(20);
const line = frame.lines
.map((l) => l.spans.map((s) => s.text).join(""))
.find((l) => l.includes("C"));
expect(line?.includes("V")).toBe(false);
});

View File

@@ -31,7 +31,7 @@ process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = DATA; process.env.XDG_DATA_HOME = DATA;
process.env.PODTUI_AUDIO_BACKEND = "none"; 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; let server: ReturnType<typeof Bun.serve> | null = null;
function feedXml(origin: string): string { function feedXml(origin: string): string {
const items = ["Episode One", "Episode Two"] const items = ["Episode One", "Episode Two"]

View 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)
})
})

View File

@@ -0,0 +1,23 @@
/** Scratch: cava init/destroy cycles leak native fftw buffers? */
import { test, expect } from "bun:test"
import { loadCavaCore } from "../src/utils/cavacore"
const cava = loadCavaCore()
const skip = !cava
test.skipIf(skip)("init/destroy x50: RSS bounded", () => {
const cfg = { bars: 64, sampleRate: 22050, channels: 1, autosens: 0 }
const samples = new Float64Array(8192)
Bun.gc(true)
const startRss = process.memoryUsage.rss()
for (let i = 0; i < 50; i++) {
cava!.init(cfg)
cava!.execute(samples)
cava!.destroy()
}
Bun.gc(true)
const endRss = process.memoryUsage.rss()
const grown = (endRss - startRss) / 1048576
console.log(`init/destroy x50: rss delta=${grown.toFixed(1)}MB`)
expect(grown).toBeLessThan(100)
}, 60_000)

View File

@@ -0,0 +1,59 @@
/** Scratch — verify full-height hover accent line. */
import { test, expect } from "bun:test";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { PaneRow } from "../src/components/PaneRow";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "podtui-phover3-"));
type Span = { text: string; fg?: { r: number; g: number; b: number; a: number } | null };
type Frame = { cols: number; lines: { spans: Span[] }[] };
test("hover renders accent line on every row", async () => {
const setup = (await testRender(
() => (
<ThemeProvider mode="dark">
<PaneRow
parent={<text selectable={false}>{"P".repeat(300)}</text>}
current={<text selectable={false}>{"C".repeat(600)}</text>}
preview={<text selectable={false}>{"V".repeat(300)}</text>}
currentLabel=""
/>
</ThemeProvider>
),
{ width: 100, height: 10, useThread: false },
)) as unknown as {
renderOnce: () => Promise<void>;
captureSpans: () => Frame;
captureCharFrame: () => string;
mockMouse: { moveTo: (x: number, y: number) => Promise<void> };
renderer: { destroy: () => void };
};
for (let i = 0; i < 10; i++) await setup.renderOnce();
await setup.mockMouse.moveTo(20, 5);
for (let i = 0; i < 3; i++) await setup.renderOnce();
const frame = setup.captureSpans();
let accentRows = 0;
for (let y = 0; y < frame.lines.length; y++) {
const line = frame.lines[y].spans.map((s) => s.text).join("");
// Border column = 20.
const ch = line[20];
const isAccent = frame.lines[y].spans
.filter((s) => s.text.length > 0)
.some((s) => {
const t = s.text;
let c = 0;
// recompute col: approximate by scanning previous spans
return false;
});
if (ch === "│") accentRows++;
console.log(`row ${y}: ${JSON.stringify(line.slice(16, 25))} ch20=${JSON.stringify(ch)}`);
}
console.log("accentRows:", accentRows, "of", frame.lines.length);
expect(accentRows).toBeGreaterThanOrEqual(6);
setup.renderer.destroy();
});

View File

@@ -0,0 +1,34 @@
/** Mach VM region walker via FFI (self-process). */
import { dlopen, FFIType, ptr } from "bun:ffi"
const k = dlopen("/usr/lib/system/libsystem_kernel.dylib", {
mach_task_self: { args: [], returns: FFIType.u64 },
mach_vm_region: {
args: [FFIType.u64, FFIType.ptr, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr],
returns: FFIType.i32,
},
})
/** Walk own VM map; count total regions and ~128K ones. */
export function countRegions(): { total: number; r128k: number } {
const task = (k.symbols.mach_task_self as any)() as number
const addr = new BigUint64Array(1)
const size = new BigUint64Array(1)
const info = new Uint32Array(16)
const infoCnt = new Uint32Array(1)
const objectName = new Uint32Array(1)
let total = 0
let r128k = 0
addr[0] = 1n
const walk = k.symbols.mach_vm_region as any
for (;;) {
infoCnt[0] = 16
const kr = walk(BigInt(task), ptr(addr), ptr(size), 9, ptr(info), ptr(infoCnt), ptr(objectName))
if (kr !== 0) break
total++
if (size[0] >= 131072n && size[0] <= 139264n) r128k++
addr[0] = addr[0] + size[0]
if (addr[0] === 0n || total > 500000) break
}
return { total, r128k }
}

View File

@@ -0,0 +1,27 @@
/** Minimal opentui app: VM region growth from render loop and/or text churn. */
import { appendFileSync } from "node:fs"
import { createSignal } from "solid-js"
import { render } from "@opentui/solid"
import { countRegions } from "./scratch-region-count"
const CHURN = Bun.argv.includes("--churn")
const OUT = Bun.argv.find((a) => a.startsWith("--out="))?.slice(6) ?? "/tmp/render-leak.log"
const log = (m: string) => Bun.write(Bun.stderr, m + "\n") // stderr may be hijacked too; use fd via file:
const append = (m: string) => appendFileSync(OUT, m + "\n")
const [s, setS] = createSignal("hello")
if (CHURN) {
let i = 0
setInterval(() => setS(`hello ${++i} ${"x".repeat(i % 50)}`), 33)
}
render(() => <text>{s()}</text>)
await Bun.sleep(1000)
const c0 = countRegions()
append(`start churn=${CHURN}: total=${c0.total}`)
for (let w = 1; w <= 4; w++) {
await Bun.sleep(15_000)
const c = countRegions()
append(`t=${w * 15}s total=${c.total} (delta ${c.total - c0.total})`)
}
process.exit(0)

View File

@@ -0,0 +1,36 @@
/** Scratch: does the ffmpeg decode stream leak VM regions per chunk? */
import { test, expect } from "bun:test"
import { EpisodePcmCache } from "../src/utils/audio-pcm-cache"
import { spawnSync } from "child_process"
const wav = "/tmp/podtui-stream.wav"
if (!(await Bun.file(wav).exists()) && (await Bun.$`which ffmpeg`.nothrow())) {
await Bun.$`ffmpeg -f lavfi -i "sine=frequency=440:duration=600" -ar 22050 -ac 1 -sample_fmt s16 ${wav}`.quiet().nothrow()
}
function regions(): number {
const out = spawnSync("vmmap", [String(process.pid)], { timeout: 20000 }).stdout?.toString() ?? ""
return out.split("\n").filter((l) => l.includes("VM_ALLOCATE")).length
}
function rss(): number {
return Number(spawnSync("ps", ["-o", "rss=", "-p", String(process.pid)]).stdout?.toString().trim() || 0)
}
test("decode stream 90s: regions and rss bounded", async () => {
Bun.gc(true)
await Bun.sleep(200)
const r0 = regions(), m0 = rss()
const pcm = new EpisodePcmCache({ url: wav })
pcm.startDecode(0)
const t0 = Date.now()
while (Date.now() - t0 < 90_000) {
await Bun.sleep(5_000)
const pos = ((Date.now() - t0) / 1000) * 4
pcm.readWindow(new Float64Array(512), pos)
}
Bun.gc(true)
const r1 = regions(), m1 = rss()
console.log(`stream 90s: regions ${r0}->${r1} (delta ${r1 - r0}), rss ${(m0 / 1048576) | 0}->${(m1 / 1048576) | 0}MB`)
pcm.stop()
expect(r1 - r0).toBeLessThan(100)
}, 140_000)

View File

@@ -0,0 +1,22 @@
/** Scratch runner: decode stream, self-measure VM regions over time. */
import { EpisodePcmCache } from "../src/utils/audio-pcm-cache"
import { countRegions } from "./scratch-region-count"
const wav = "/tmp/podtui-stream.wav"
if (!(await Bun.file(wav).exists())) {
await Bun.$`ffmpeg -f lavfi -i "sine=frequency=440:duration=600" -ar 22050 -ac 1 -sample_fmt s16 ${wav}`.quiet().nothrow()
}
const pcm = new EpisodePcmCache({ url: wav })
Bun.gc(true)
console.log(`start: ${JSON.stringify(countRegions())}`)
pcm.startDecode(0)
const t0 = Date.now()
const buf = new Float64Array(512)
while (Date.now() - t0 < 90_000) {
await Bun.sleep(15_000)
const pos = ((Date.now() - t0) / 1000) * 4
pcm.readWindow(buf, pos)
const c = countRegions()
console.log(`t=${((Date.now() - t0) / 1000) | 0}s total=${c.total} r128k=${c.r128k} rss=${(process.memoryUsage.rss() / 1048576) | 0}MB`)
}
pcm.stop()
console.log("done")

View 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);
});
});

View File

@@ -230,15 +230,146 @@ test.skipIf(skip)(
app.updateVisualizer({ enabled: false }); app.updateVisualizer({ enabled: false });
await waitFor(() => !viz.isRunning(), 10000); await waitFor(() => !viz.isRunning(), 10000);
expect(viz.isLoading()).toBe(false); 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 }); 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); await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
expect(viz.barData().length).toBe(64); expect(viz.barData().length).toBe(64);
}, },
{ timeout: 20000 }, { 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 },
);
// The post-resume loading gate must be "the clock MOVED from the resume
// point", not "the clock moved PAST it". Gating on `>` strands the spinner
// forever when the user seeks BACKWARD during the resume spinner (the
// classic "missed that, rewind" while a network stream re-buffers): the
// position never again exceeds the resume point, the cache serves live
// frames for the new position, and the loading state never clears.
test.skipIf(skip)(
"backward seek during the resume loading state clears it",
async () => {
const viz = useVisualizer();
await startPlaying();
expect(viz.isLoading()).toBe(false);
// Pause, then resume against the still-covered position: spinner.
setIsPlaying(false);
await waitFor(() => !viz.isRunning(), 10000);
setIsPlaying(true);
await waitFor(() => viz.isLoading(), 5000);
// User seeks BACKWARD while the player re-buffers. The position is
// inside decoded coverage, so fresh bars must replace the spinner.
setPosition(1);
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 3000);
expect(viz.barData().length).toBe(64);
},
{ timeout: 20000 },
);
// ── Teardown ───────────────────────────────────────────────────────────── // ── Teardown ─────────────────────────────────────────────────────────────
afterAll(() => { afterAll(() => {