11 Commits

Author SHA1 Message Date
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
39 changed files with 2145 additions and 332 deletions

View File

@@ -16,6 +16,8 @@ external player with full transport control — all from your terminal.
- **Search** across your subscribed shows.
- **Audio playback** through an external player with full transport control:
play/pause, next/previous, seek, speed, and per-episode resume progress.
When an episode finishes, the next one plays automatically, continuing
down the list you started it from (search results, a show, or the Feed).
- **Themeable** and **remappable keybindings**.
- Ships as a **standalone compiled binary** — no runtime or install step beyond
a system audio player.
@@ -61,7 +63,7 @@ Grab `podtui-<platform>-<arch>.tar.gz` from the latest
put `podtui` on your `PATH`:
```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
sudo mkdir -p /opt/podtui
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.
**No audio — playback is a silent no-op** — PodTui needs **mpv** on your
`PATH`. Install it (`brew install mpv`, `pacman -S mpv`, …) and relaunch.
`PATH`. Homebrew and AUR installs pull it in automatically; if you used the
standalone tarball, install it yourself (`brew install mpv`, `pacman -S mpv`,
…) and relaunch.
**Homebrew prints a dylib warning** — “load commands do not fit in the header
… needs `-headerpad`” is benign: the app loads its libraries by path, the

9
scripts/_hv.ts Normal file
View File

@@ -0,0 +1,9 @@
import { testRender } from "@opentui/solid";
const { ThemeProvider } = await import("../src/context/ThemeContext");
const { PaneRow } = await import("../src/components/PaneRow");
process.env.XDG_CONFIG_HOME = import.meta.dir + "/../.harness/config-home";
import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path";
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "hv-"));
const setup = (await testRender(
() => React.createElement...
));

View File

@@ -26,6 +26,8 @@
* bun scripts/tui-harness.tsx type "<text>"
* bun scripts/tui-harness.tsx wait <ms>
* 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 state [all|nav|audio|feed|app]
* bun scripts/tui-harness.tsx actions # print action log
@@ -79,7 +81,9 @@ type Action =
| { t: "enter" | "escape" | "tab" | "space" | "backspace"; mods?: Mod[] }
| { t: "type"; s: string }
| { 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[] {
try {
@@ -258,12 +262,32 @@ const BUILDERS: Record<string, (positional: string[]) => Action> = {
if (!p[0]) throw new Error("wait requires <ms>");
return { t: "wait", ms: parseInt(p[0], 10) || 0 };
},
resize: (p) => {
if (!p[0] || !p[1]) throw new Error("resize requires <w> <h>");
return {
t: "resize",
w: parseInt(p[0], 10) || 100,
h: parseInt(p[1], 10) || 30,
w: parseInt(p[0], 10) || 0,
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":
await new Promise((r) => setTimeout(r, a.ms));
break;
case "resize":
setup.resize(a.w, a.h);
case "mouse":
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;
}
await setup.renderOnce();

View File

@@ -16,6 +16,7 @@
import { Show } from "solid-js";
import { format } from "date-fns";
import type { RGBA } from "@opentui/core";
import { useTerminalDimensions } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { NF_ICONS } from "@/utils/nerd-fonts";
@@ -186,6 +187,7 @@ export function EpisodePreview(props: {
}) {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const dims = useTerminalDimensions();
return (
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textPrimary ?? theme.text}>
@@ -208,10 +210,14 @@ export function EpisodePreview(props: {
<text fg={muted()}>by {props.author()}</text>
</Show>
<box height={1} />
<text fg={theme.textSecondary}>
{props.episode().description?.slice(0, 400) ?? "No description available."}
{(props.episode().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<Show
when={props.episode().description}
fallback={<text fg={theme.textSecondary}>No description available.</text>}
>
<scrollbox maxHeight={Math.floor(dims().height * 0.3)}>
<text fg={theme.textSecondary}>{props.episode().description}</text>
</scrollbox>
</Show>
<box height={1} />
<text fg={muted()}>{props.hint()}</text>
</box>
@@ -221,7 +227,6 @@ export function EpisodePreview(props: {
// ── FetchMorePreview ────────────────────────────────────────────────────────
export function FetchMorePreview(props: {
isLoadingMore: () => boolean;
fetchMoreMode: () => string;
/** Manual-mode explanation line ("across all feeds" vs "for this show"). */
manualText: () => string;
}) {
@@ -235,9 +240,7 @@ export function FetchMorePreview(props: {
<text fg={muted()}>
{props.isLoadingMore()
? "Loading the next batch of episodes…"
: props.fetchMoreMode() === "auto"
? "Auto mode: the next batch loads automatically at the bottom of the list."
: props.manualText()}
: props.manualText()}
</text>
<box height={1} />
<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.
*
* Implements yazi's `mgr.ratio` contract: three columns grow at
* 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`,
* so every list tab renders an identical, layout-stable shell. Columns use
* `flexBasis={0}` so the ratio is exact regardless of content width — a
* column's content can never stretch its slot.
* Implements yazi's resizable `mgr.ratio` contract: the two borders of the
* CENTER (current) column are draggable and resize the neighboring panes.
* Split positions live in the shared pane-layout store (`@/stores/pane-layout`)
* as fractions of the row width; this component resolves them to pixel
* columns, gives each column an explicit width (so the drag strips sit
* exactly on the drawn borders), and renders two invisible grab handles over
* the border cells.
*
* Column semantics (per the yazi depth model):
* parent — the previous-depth list. Renders a muted `—` placeholder and
* KEEPS its 20% slot when blank (never collapses to width 0).
* Borderless (no left/right/top/bottom edge). Carries the single
* header row: the CURRENT column's title renders top-left in the
* parent's slot (the panes above current/preview were removed).
* 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.
* keeps a minimum 15-col slot. Borderless.
* current — the current-depth list. The only focusable content column; the
* ONLY bordered column — left/right edges only, always muted.
* preview — detail of the hovered item in `current`. Borderless.
*
* The primitive is purely structural: callers pass their own JSX per column
* (static elements or accessors) plus the current-column title. Theme colors
* are resolved internally via `useTheme()`. Only the current column's
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor (j/k
* stay in the current pane).
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor.
*
* Example:
* <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 { useTerminalDimensions } from "@opentui/solid";
import type { RGBA, BorderSides } from "@opentui/core";
import { useTheme } from "@/context/ThemeContext";
import { PANE_RATIO } from "@/utils/navigation";
import {
MIN_PANE_WIDTH,
splitPixels,
usePaneLayout,
} from "@/stores/pane-layout";
// ── Types ───────────────────────────────────────────────────────────────────
type PaneContent = JSX.Element | (() => JSX.Element);
@@ -46,7 +48,7 @@ type PaneLabel = string | (() => string);
export type PaneRowProps = {
/** 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;
/** Current column content (the focused list). */
current?: PaneContent;
@@ -99,7 +101,7 @@ function Placeholder(props: { color: () => RGBA }) {
// ── Pane column ─────────────────────────────────────────────────────────────
function Pane(props: {
grow: number;
width: number;
label: () => string;
content: () => JSX.Element | undefined;
border: boolean | BorderSides[];
@@ -116,8 +118,8 @@ function Pane(props: {
return (
<box
flexDirection="column"
flexGrow={props.grow}
flexBasis={0}
width={props.width}
flexShrink={0}
height="100%"
>
{/* ── title row: rendered only when the pane carries a label ────────── */}
@@ -158,6 +160,45 @@ function Pane(props: {
);
}
/** A 1-column invisible grab handle covering exactly one border of the
* current pane. `onBegin` is called on mousedown; subsequent drag/drag-end
* events bubble up the row and drive `usePaneLayout` there. On hover or
* while dragging it overdraws the border with a full-height accent `│`
* line (a bordered box would render as a blocky rectangle instead). */
function Splitter(props: {
left: number;
active: boolean;
onBegin: () => void;
}) {
const { theme } = useTheme();
const dims = useTerminalDimensions();
const [hovered, setHovered] = createSignal(false);
const highlighted = () => props.active || hovered();
return (
<box
position="absolute"
left={props.left}
top={0}
width={1}
height="100%"
onMouseDown={(e) => {
e.preventDefault?.();
props.onBegin();
}}
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 ───────────────────────────────────────────────────────────
export function PaneRow(props: PaneRowProps) {
/** true → the current column's scrollbox is focused (scroll follows cursor). */
@@ -179,20 +220,60 @@ export function PaneRow(props: PaneRowProps) {
// 2-pane mode (parent|current) grows the current column to fill the
// preview slot. Defaults to 3 (parent|current|preview).
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[]>(
() => 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; the
// row then lives-updates the split from the absolute drag x (bubbled up
// from whatever renderable the cursor captures) and commits on release.
const [activeSplit, setActiveSplit] = createSignal<"left" | "right" | null>(
null,
);
const beginDrag = (which: "left" | "right") => () => setActiveSplit(which);
const handleDrag = (e: { x: number }) => {
const which = activeSplit();
if (!which) return;
if (which === "left") layout.setLeft(e.x, width());
else layout.setRight(e.x, width());
};
const handleDragEnd = () => {
if (activeSplit()) layout.commit();
setActiveSplit(null);
};
return (
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
{/* ── parent (20%) — previous-depth list; title row top-left ────────── */}
<box
flexDirection="row"
width="100%"
height="100%"
flexGrow={1}
onMouseDrag={handleDrag}
onMouseDragEnd={handleDragEnd}
onMouseUp={handleDragEnd}
>
{/* ── parent — previous-depth list; title row top-left ─────────────── */}
<Pane
grow={PANE_RATIO.parent}
width={parentWidth()}
label={currentLabel}
content={parentContent}
border={false}
@@ -200,22 +281,37 @@ export function PaneRow(props: PaneRowProps) {
/>
{/* ── current — the focused list; left/right borders only ─────────── */}
<Pane
grow={currentGrow()}
width={currentWidth()}
label={() => ""}
content={currentContent}
border={currentBorder()}
scrollFocused={() => focused()}
/>
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
{/* ── preview (optional) — hovered-item detail; no border ─────────── */}
<Show when={panes() === 3}>
<Pane
grow={PANE_RATIO.preview}
width={previewWidth()}
label={() => ""}
content={previewContent}
border={false}
scrollFocused={() => false}
/>
</Show>
{/* ── drag handles over the current pane's borders ───────────────── */}
<Show when={hasRoom()}>
<Splitter
left={pixels().leftPx}
active={activeSplit() === "left"}
onBegin={beginDrag("left")}
/>
<Show when={panes() === 3}>
<Splitter
left={pixels().rightPx - 1}
active={activeSplit() === "right"}
onBegin={beginDrag("right")}
/>
</Show>
</Show>
</box>
);
}

View File

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

View File

@@ -120,6 +120,14 @@ const EMPTY_TERMINAL_COLORS: TerminalColors = {
/** Cached macOS appearance (dark/light), independent of the terminal. */
let cachedOsMode: "dark" | "light" | null = null;
/**
* How often to re-query the terminal for theme changes (OSC 10/11/12).
* Terminals only answer these queries — they never push a color change —
* so detection is a slow poll. 60 s keeps CPU cost unmeasurable while
* still tracking theme flips within a reasonable delay.
*/
const SYSTEM_THEME_POLL_MS = 60_000;
/**
* Detect the terminal's dark/light mode.
*
@@ -215,7 +223,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
});
}
async function resolveSystemTheme() {
/**
* Query the terminal's colors via OSC (palette + default fg/bg), with a
* legacy-tmux fallback for servers < 3.6 that don't forward OSC replies.
* Returns null when the terminal cannot answer.
*/
async function queryTerminalColors(): Promise<TerminalColors | null> {
if (process.env.TMUX) {
await waitForCapabilities();
}
@@ -254,6 +267,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
}
return colors;
}
async function resolveSystemTheme() {
const colors = await queryTerminalColors();
// ── dark/light mode detection ─────────────────────────────────────────
// The provider starts with a hardcoded mode (e.g. "dark"); detect the
// real one from the terminal's background color (OSC 11) or, when that
@@ -299,8 +318,55 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
}
/**
* Poll for terminal theme changes: re-query OSC colors, update the
* system palette when it differs, and re-detect dark/light mode.
* Runs on a slow timer (see SYSTEM_THEME_POLL_MS); most polls change
* nothing and only pay the idle query round-trip.
*/
async function pollSystemTheme() {
if (!store.ready) return;
const colors = await queryTerminalColors();
if (!colors) return;
const current = store.system;
const changed =
!current ||
current.defaultBackground !== colors.defaultBackground ||
current.defaultForeground !== colors.defaultForeground ||
current.palette.join(",") !== colors.palette.join(",");
if (changed) {
setStore(
produce((draft) => {
draft.system = colors;
}),
);
}
// Refresh the OS-appearance fallback only when the terminal cannot
// report a background (e.g. tmux without OSC forwarding), so the
// common path never spawns a subprocess.
if (process.platform === "darwin" && !colors.defaultBackground) {
cachedOsMode = null;
}
const detectedMode = detectSystemMode(colors);
if (detectedMode && detectedMode !== store.mode) {
setStore("mode", detectedMode);
emitThemeModeChanged(detectedMode);
}
}
onMount(init);
// Poll the terminal for theme changes (see pollSystemTheme). Registered
// once per provider init — SIGUSR2 re-runs the inner `init`, not this
// closure, so the timer cannot stack.
const pollTimer = setInterval(() => {
void pollSystemTheme();
}, SYSTEM_THEME_POLL_MS);
onCleanup(() => clearInterval(pollTimer));
// Setup SIGUSR2 signal handler for dynamic theme reload
// This allows external tools to trigger a theme refresh by sending:
// `kill -USR2 <pid>`

View File

@@ -13,10 +13,10 @@
*
* parent | current | preview
*
* Layout ratios (20% : 50% : 30% — PANE_RATIO 2:5:3) live in
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
* nav model — which column is focused and where its list cursor lives. The
* parent/preview columns are always derived, never focused.
* Pane sizes are user-resizable (draggable borders in `PaneRow`).
* This module owns only the *focusable* nav model — which column is focused
* and where its list cursor lives. The parent/preview columns are always
* derived, never focused.
*
* 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`:

View File

@@ -55,10 +55,16 @@ import {
saveLastPlayerSync,
} from "../utils/app-persistence";
import type { Episode, Progress } from "../types/episode";
import type { Feed } from "../types/feed";
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
import { feedForEpisode } from "../utils/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 "../utils/audio-queue";
export interface AudioControls {
// Signals (reactive getters)
@@ -180,8 +186,10 @@ const PAUSE_WATCH_TICKS = 7;
/** The player process died while we believed playback was live — track
* ended (mpv quits at EOF) or the process crashed. Persist the final
* position and stop polling. */
function finalizeTrackEnd(): void {
* position and stop polling. `autoAdvance` is true only when the track
* reached its natural end with the player still alive and no stream error
* — the signal to keep the queue going. */
function finalizeTrackEnd(autoAdvance: boolean): void {
setIsPlaying(false);
stopPolling();
const ep = currentEpisode();
@@ -189,6 +197,12 @@ function finalizeTrackEnd(): void {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
}
if (autoAdvance) {
// The episode finished: play the next one from the source that
// started it (search results / show / feed). No-op at the end of
// the list or when the episode isn't in the source list anymore.
void next().catch(() => {});
}
}
/** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal,
@@ -235,7 +249,12 @@ function startPolling(): void {
// and reports pause=true there, which would otherwise be
// mistaken for an external pause and never finalize.
if (!backend.isPlaying()) {
finalizeTrackEnd();
// Natural EOF (player alive, no stream error) auto-advances
// to the next episode; a crashed/killed daemon or a failed
// stream must not start the next episode on its own.
finalizeTrackEnd(
backend.isAlive() && !backend.getPlaybackError(),
);
return;
}
@@ -270,7 +289,7 @@ function startPolling(): void {
// still alive: a dead player while we thought we were paused
// means the track ended (mpv quits at EOF) or it crashed.
if (!backend.isAlive()) {
finalizeTrackEnd();
finalizeTrackEnd(false);
return;
}
const paused = await backend.getPauseState();
@@ -335,21 +354,51 @@ async function play(episode: Episode): Promise<void> {
return;
}
try {
const appStore = useAppStore();
const progressStore = useProgressStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
const vol = volume();
const spd = storeSpeed || speed();
const appStore = useAppStore();
const progressStore = useProgressStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
const vol = volume();
const spd = storeSpeed || speed();
const feedStore = useFeedStore();
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
// Play the downloaded file when present (offline + no network stalls);
// otherwise stream. Cover resolves to the feed art, falling back to the
// episode's own image (feeds added by URL may lack a channel cover).
const downloadStore = useDownloadStore();
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
const 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
@@ -360,13 +409,6 @@ async function play(episode: Episode): Promise<void> {
"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,
@@ -375,10 +417,8 @@ async function play(episode: Episode): Promise<void> {
coverArtPath: coverArtPath ?? undefined,
});
setCurrentEpisode(episode);
setIsPlaying(true);
setPosition(startPos);
setSpeed(spd);
if (episode.duration) setDuration(episode.duration);
startedPlayback = true;
@@ -387,12 +427,6 @@ async function play(episode: Episode): Promise<void> {
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
// Register with platform media controls
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(true);
if (startPos > 0) media.setPosition(startPos);
@@ -434,8 +468,7 @@ async function load(episode: Episode): Promise<void> {
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 feed = feedForEpisode(useFeedStore().feeds(), episode);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const media = useMediaRegistry();
media.setNowPlaying({
@@ -654,10 +687,7 @@ async function switchBackend(name: BackendName): Promise<void> {
// 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 feed = feedForEpisode(useFeedStore().feeds(), ep);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
const url =
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
@@ -728,6 +758,60 @@ export async function restoreLastSession(): Promise<void> {
* Returns a singleton — all components share the same playback state.
* Registers event bus listeners and cleans them up with onCleanup.
*/
// ── Episode queue navigation ──────────────────────────────────────────────
// `next`/`prev` (and the end-of-episode auto-advance in finalizeTrackEnd)
// move within the ordered list of the source that STARTED the current
// episode: the Feed's chronological list, the current show's episodes, or
// the search results (see utils/audio-queue). Module-level so
// finalizeTrackEnd can auto-advance without a mounted hook owner.
const audioNav = useAudioNavStore();
/** The ordered playable episodes for the source that started playback. */
function queueForCurrentSource(): Episode[] {
const feedStore = useFeedStore();
return queueForSource(
audioNav.getSource(),
audioNav.getPodcastId(),
feedStore.feeds(),
feedStore.getAllEpisodesChronological(),
useSearchStore().results(),
);
}
async function next(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const step = nextStep(queueForCurrentSource(), current.id);
// A duplicated queue entry (same episode id twice) must not make
// "next" replay the CURRENT episode — that would reload it from
// saved progress and audibly repeat already-played audio.
if (!step || step.episode.id === current.id) return;
await play(step.episode);
audioNav.next(step.index);
}
async function prev(): Promise<void> {
const current = currentEpisode();
if (!current) return;
// Standard transport behavior: past 30s in, "prev" restarts the current
// episode; before that it steps back within the source queue.
const NAV_START_THRESHOLD = 30;
const currentPos = position();
const currentDur = duration();
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
await seek(NAV_START_THRESHOLD);
return;
}
const step = prevStep(queueForCurrentSource(), current.id);
if (!step) return;
await play(step.episode);
audioNav.prev(step.index);
}
export function useAudio(): AudioControls {
// Initialize backend on first use
ensureBackend();
@@ -793,80 +877,6 @@ export function useAudio(): AudioControls {
await doSetSpeed(next);
});
const audioNav = useAudioNavStore();
const feedStore = useFeedStore();
async function prev(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const currentPos = position();
const currentDur = duration();
const NAV_START_THRESHOLD = 30;
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
await seek(NAV_START_THRESHOLD);
} else {
const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore
.getFilteredFeeds()
.find((f) => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.max(0, currentIndex - 1);
if (newIndex < episodes.length && episodes[newIndex]) {
const { episode } = episodes[newIndex];
await play(episode);
audioNav.prev(newIndex);
}
}
}
async function next(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore
.getFilteredFeeds()
.find((f) => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
if (newIndex >= 0 && episodes[newIndex]) {
const { episode } = episodes[newIndex];
await play(episode);
audioNav.next(newIndex);
}
}
onCleanup(() => {
refCount--;
unsubPlay();

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 { Episode } from "./types/episode"
const VERSION = "0.7.0";
const VERSION = "0.8.0";
interface CliArgs {
version: boolean;
@@ -234,10 +237,13 @@ if (cliArgs.query !== null || cliArgs.play !== null) {
const { NavigationProvider } = await import("./context/NavigationContext");
const { DialogProvider } = await import("./ui/dialog");
const { CommandProvider } = await import("./ui/command");
// Nested scroll sections favor the innermost one under the cursor.
installNestedScrollBehavior();
function RendererSetup(props: { children: unknown }) {
const renderer = useRenderer();
renderer.disableStdoutInterception();
onCleanup(setupTerminalRecovery(renderer));
return props.children;
}

View File

@@ -19,7 +19,6 @@
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { prefetchCoverArt } from "@/utils/cover-art";
import { DownloadStatus } from "@/types/episode";
import { useTheme } from "@/context/ThemeContext";
@@ -86,10 +85,7 @@ function FeedPage() {
// ── Fetch More ───────────────────────────────────────────────────────────
// 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:
// reaching the bottom row fetches automatically (see the effect below).
const app = useAppStore();
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
// loaded window by 50 episodes. Enter on the row to load the next batch.
const showFetchMore = () => feedStore.hasMoreAcrossAll();
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
const focus = () => nav.depthFocus(0);
@@ -137,16 +133,6 @@ function FeedPage() {
};
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(() => {
nav.registerResolver(
`${nav.activeTab()}:${DEPTH_CENTER_PANE}`,
@@ -337,7 +323,6 @@ function FeedPage() {
<Show when={focusedOnMore()}>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
"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.
* preview — detail of the hovered item in the current column.
*
* Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior
* as the Feed tab) that loads the next batch of episodes for that show.
* Depth 0's shows list and depth 1's episode list both end with a
* "[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
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
* 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 { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
@@ -269,20 +270,35 @@ export function MyShowsPage() {
// entry drops out the moment the user subscribes to its show.
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 = () =>
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
* row (past the shows list). */
const focusedOnUnsub = () =>
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0;
!focusedOnMore0() &&
depth() === 0 &&
focusedRow0() >= shows().length &&
unsubs().length > 0;
const focusedUnsub = (): DownloadedEpisode | 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 => {
if (focusedOnUnsub()) return undefined;
if (focusedOnUnsub() || focusedOnMore0()) return undefined;
return shows()[focusedShowIdx()];
};
@@ -300,10 +316,7 @@ export function MyShowsPage() {
// ── Fetch More ───────────────────────────────────────────────────────────
// 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
// counterpart to the Feed page's row (which loads every feed). manual
// mode: Enter on the row. auto mode: reaching the bottom row fetches
// automatically (see the effect below).
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
// counterpart to the Feed page's row (which loads every feed).
const showFetchMore = () =>
depth() >= 1 &&
!!drilledShowId() &&
@@ -366,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 ─────────────────────────────────────────────────────────────────
const downloadLabel = (id: string) => {
switch (downloadStore.getDownloadStatus(id)) {
@@ -431,6 +433,10 @@ export function MyShowsPage() {
// ── drill / open ───────────────────────────────────────────────────────────
function open() {
if (depth() === 0) {
if (focusedOnMore0()) {
feedStore.loadMoreAllFeeds().catch(() => {});
return;
}
const d = focusedUnsub();
if (d) {
playUnsubscribedDownload(d);
@@ -639,7 +645,7 @@ export function MyShowsPage() {
<UnsubscribedRow
d={d}
index={() => shows().length + index()}
focused={() => nav.depthFocus(0)}
focused={focusedRow0}
active={isActive}
marker={marker}
downloadLabel={() => downloadLabel(d.episodeId)}
@@ -652,8 +658,23 @@ export function MyShowsPage() {
)}
</For>
</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>
{/* depth ≥1: episodes */}
<Show when={depth() >= 1}>
<Show
@@ -738,45 +759,56 @@ export function MyShowsPage() {
const previewContent = () =>
depth() === 0 ? (
// depth 0 preview: hovered unsubscribed-show download, else the
// hovered show.
<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)}
/>
)}
// depth 0 preview: hovered "[Fetch More]" row, else the
// unsubscribed-show download, else the hovered show.
<>
<Show when={focusedOnMore0()}>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
manualText={() =>
"Load the next batch of older episodes across all subscribed shows (Enter)."
}
/>
</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)
<>
<Show when={focusedOnMore()}>
<FetchMorePreview
isLoadingMore={() => feedStore.isLoadingMore()}
fetchMoreMode={fetchMoreMode}
manualText={() =>
isLoadingMore={() => feedStore.isLoadingMore()}
manualText={() =>
"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 { useTheme } from "@/context/ThemeContext";
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
import { useTerminalDimensions } from "@opentui/solid";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
@@ -29,6 +30,7 @@ export function PlayerPage() {
const { theme } = useTheme();
const nav = useNavigation();
const viz = useVisualizer();
const dims = useTerminalDimensions();
const app = useAppStore();
const muted = () => theme.muted || theme.text;
// Settings master switch: off hides the waveform entirely (the store
@@ -89,9 +91,14 @@ export function PlayerPage() {
<text fg={theme.text}>
<strong>{ep().title}</strong>
</text>
<text fg={muted()}>
{ep().description?.slice(0, 500) ?? "No description available."}
</text>
<Show
when={ep().description}
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 />

View File

@@ -10,6 +10,7 @@ import { useTerminalDimensions } from "@opentui/solid";
import type { Renderable } from "@opentui/core";
import { useAudio } from "@/hooks/useAudio";
import { useTheme } from "@/context/ThemeContext";
import { usePaneLayout } from "@/stores/pane-layout";
// ── Component ────────────────────────────────────────────────────────
@@ -17,16 +18,19 @@ export function ProgressBar() {
const audio = useAudio();
const { theme } = useTheme();
const dimensions = useTerminalDimensions();
const layout = usePaneLayout();
// The bar's renderable, captured for its absolute left edge: MouseEvent.x
// 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;
// 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).
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));

View File

@@ -8,8 +8,9 @@
*
* This component only subscribes to store state, reports the width-derived
* bar count (terminal resize re-inits the running pipeline), and renders:
* a braille spinner while the pipeline is loading its first frames, the
* frequency bars once frames arrive, and a dotted placeholder when idle.
* a braille spinner while the pipeline is loading its first frames or the
* player is stalled (re-buffering), the frequency bars once frames arrive,
* and a dotted placeholder when idle.
*/
import { createEffect, on } from "solid-js";
@@ -18,7 +19,7 @@ import { useVisualizer } from "@/stores/visualizer";
import { useTheme } from "@/context/ThemeContext";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
import { PANE_RATIO } from "@/utils/navigation";
import { usePaneLayout } from "@/stores/pane-layout";
// ── Component ────────────────────────────────────────────────────────
@@ -26,20 +27,16 @@ export function RealtimeWaveform() {
const { theme } = useTheme();
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 layout = usePaneLayout();
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;
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(
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 count = numBars();
// Loading state: the braille spinner shows while the pipeline warms
// up — but only when there are no bars to render yet (first play /
// after an unload). On resume/seek the last bars stay on screen
// until fresh frames arrive, so the waveform never blanks out for
// the (multi-second, network-bound) cold start.
if (bars.length === 0 && viz.isLoading()) {
// Loading state: the braille spinner shows while the pipeline is
// warming up — cold start (first play / after an unload), resume
// into undecoded audio, or a stalled position clock (mpv
// re-buffering after a long pause on a network stream). The store
// clears it the moment the first fresh frame renders, so stale
// bars never masquerade as live data while the pipeline re-arms.
if (viz.isLoading() || viz.isStalled()) {
return <LoadingIndicator />;
}

View File

@@ -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",
label: "Feed Refresh Interval",

View File

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

View File

@@ -39,6 +39,11 @@ const MAX_EPISODES_REFRESH = 50;
/** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20;
/** 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. Overridden by episodeKeepFn. */
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;
@@ -123,7 +128,10 @@ const fullEpisodeCache = new Map<string, Episode[]>();
const episodeLoadCount = new Map<string, number>();
/** Read the episode cache bound from preferences: a closure that decides
* whether the episode at `index` (0 = newest, after sort) is kept. */
* 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. */
function episodeKeepFn(prefs: {
episodeCacheMode: "date" | "count";
episodeCacheCount: number;
@@ -132,10 +140,12 @@ function episodeKeepFn(prefs: {
const now = new Date();
if (prefs.episodeCacheMode === "count") {
const count = Math.max(1, prefs.episodeCacheCount);
return (_ep: Episode, index: number) => index < count;
return (_ep: Episode, index: number) =>
index < Math.max(count, MIN_EPISODES_PER_SHOW);
}
const days = Math.max(1, prefs.episodeCacheDays);
return (ep: Episode) => episodeInWindow(ep, now, days);
return (ep: Episode, index: number) =>
index < MIN_EPISODES_PER_SHOW || episodeInWindow(ep, now, days);
}
/** Timestamp for window math — undated episodes sort/compare as NEWEST

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

View File

@@ -25,6 +25,12 @@
* after the Player tab stops being focused it tears down. Reads outside
* decoded coverage return empty — the renderer simply holds the last frame
* until the decode frontier arrives.
*
* Loading semantics: `isLoading` is true from any pipeline start (cold
* start, resume into undecoded audio) until the first complete FFT frame,
* and `isStalled` while playback claims to be live but the position clock
* is frozen (player re-buffering). The component renders the spinner for
* either; bars replace it the moment fresh frames arrive.
*/
import {
@@ -55,6 +61,14 @@ const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512;
/**
* How long the position clock may stay frozen while the UI believes
* playback is live before the waveform reports a stall (loading state).
* mpv polls time-pos every ~150ms, so a frozen clock means the player is
* re-buffering — the long-pause-then-resume case on network streams.
*/
const STALL_DETECT_MS = 2000;
/** Timer handle as returned by setTimeout/setInterval in this runtime. */
type TimerHandle = ReturnType<typeof setTimeout>;
@@ -65,6 +79,10 @@ export interface VisualizerStore {
barData: () => number[];
/** True from pipeline start until the first complete FFT frame renders. */
isLoading: () => boolean;
/** True while playback claims to be live but the position clock has
* been frozen past STALL_DETECT_MS (player re-buffering, e.g. after a
* long pause on a network stream). */
isStalled: () => boolean;
/** True while the ~30fps render loop is armed. */
isRunning: () => boolean;
/** Report whether the Player tab is the visible tab. */
@@ -82,6 +100,10 @@ function createVisualizerStore(): VisualizerStore {
// True from pipeline start until the first complete FFT frame renders.
const [isLoading, setIsLoading] = createSignal(false);
// True while playback is live but the position clock is frozen
// (player re-buffering) — see STALL_DETECT_MS.
const [isStalled, setIsStalled] = createSignal(false);
// Whether the Player tab is the visible tab (fed by PlayerPage).
const [focused, setFocused] = createSignal(false);
@@ -103,6 +125,20 @@ function createVisualizerStore(): VisualizerStore {
let sampleBuffer: Float64Array | null = null;
let unloadTimer: TimerHandle | null = null;
// Stall tracker: last observed position-signal value and when it moved.
// Any change (forward, backward, seek) re-arms the clock; a frozen
// signal while playing trips isStalled after STALL_DETECT_MS.
let lastRenderPos = -1;
let lastPosMoveAt = 0;
// Resume point: the position a paused pipeline was re-armed at. The
// loading state set by resume 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
// tell "nothing changed, stay warm" from "must restart".
let activeUrl = "";
@@ -200,9 +236,19 @@ function createVisualizerStore(): VisualizerStore {
lastPolledPosition = position;
lastPolledAt = performance.now();
// Seed the stall tracker: a fresh pipeline should not report a
// stall just because the first position poll hasn't landed.
lastRenderPos = position;
lastPosMoveAt = performance.now();
// Cold start: the loading state clears on the first produced frame
// (see renderFrame) — no resume-position gating.
resumePos = -1;
activeUrl = url;
activeBars = barCount();
setIsLoading(true);
setIsStalled(false);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
@@ -224,6 +270,14 @@ function createVisualizerStore(): VisualizerStore {
}
sampleBuffer = null;
setIsLoading(false);
setIsStalled(false);
// Drop the last rendered frame: after a stop the bars are stale (a
// different episode, a different position) and would masquerade as
// live data while the next cold start warms up — and, because the
// component only shows the spinner while bars are empty, they'd
// also suppress the loading state. Cold restarts re-render fresh
// bars within the first frame.
setBarData([]);
};
// ── Pause: freeze the loop, keep the cache ──────────────────────────
@@ -248,6 +302,7 @@ function createVisualizerStore(): VisualizerStore {
// (still cold-starting when paused), the component should fall back
// to the placeholder, not freeze on a spinner.
setIsLoading(false);
setIsStalled(false);
};
// ── Resume: re-arm the render loop, top up the cache ───────────────
@@ -269,6 +324,20 @@ function createVisualizerStore(): VisualizerStore {
lastPolledPosition = pos;
lastPolledAt = performance.now();
// Re-arm the stall tracker from the resume position (a long pause
// left the old timestamps stale — they'd trip the stall detector on
// the very first frame otherwise).
lastRenderPos = pos;
lastPosMoveAt = performance.now();
// Resume re-arms a pipeline whose ffmpeg pass was killed at pause:
// the pre-pause bars are stale until fresh frames flow, so show the
// loading state IN THEIR PLACE. It clears only once the position
// clock has advanced past the resume point (see renderFrame) — a
// player still re-buffering after a long pause keeps the spinner
// instead of serving static cached bars.
resumePos = pos;
setIsLoading(true);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
return true;
};
@@ -282,6 +351,26 @@ function createVisualizerStore(): VisualizerStore {
// coverage (decode cold start, seek into a hole) the read is empty
// and the LAST FRAME simply holds — never clamped/repeated junk.
const target = smoothPosition();
// Stall detection: while the UI believes playback is live, the
// position signal must keep advancing (useAudio polls it every
// ~150ms). A frozen clock with a warm pipeline means the player is
// re-buffering — the classic long-pause-then-resume on a network
// stream — and without this the waveform shows dead-looking static
// bars for the whole stall. Report it as loading; the first frame
// after the clock moves again clears it.
const rawPos = audioPlaybackSignals.position();
if (rawPos !== lastRenderPos) {
lastRenderPos = rawPos;
lastPosMoveAt = performance.now();
if (isStalled()) setIsStalled(false);
} else if (
audioPlaybackSignals.isPlaying() &&
performance.now() - lastPosMoveAt > STALL_DETECT_MS
) {
setIsStalled(true);
}
const count = pcm.readWindow(sampleBuffer, target);
// Never feed a partial FFT window to cava.
if (count < sampleBuffer.length) return;
@@ -290,7 +379,16 @@ function createVisualizerStore(): VisualizerStore {
// Normalize against the running peak and copy to a new array
setBarData(scaler(output));
if (isLoading()) setIsLoading(false);
// Fresh frames only count once the position clock has MOVED 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 ──────────────────────────────────────────
@@ -425,6 +523,7 @@ function createVisualizerStore(): VisualizerStore {
// state
barData,
isLoading,
isStalled,
isRunning: () => frameTimer !== null,
// inputs
setFocused,

View File

@@ -90,9 +90,6 @@ export type AppSettings = {
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). */
export type AutoDownloadScope = "all" | "none" | "whitelist";
@@ -101,6 +98,15 @@ export type AutoDownloadScope = "all" | "none" | "whitelist";
* episodes (default: date). */
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 = {
showExplicit: boolean;
autoDownload: boolean;
@@ -112,8 +118,6 @@ export type UserPreferences = {
autoDownloadWhitelist: string[];
/** Jump to the Player view automatically when playback starts (default: true) */
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). */
refreshIntervalMinutes: number;
/** How the episode list cache is bounded — by date or by count (default: date). */
@@ -122,6 +126,8 @@ export type UserPreferences = {
episodeCacheCount: number;
/** Rolling window in days for the episode list when mode is "date" (default: 60). */
episodeCacheDays: number;
/** Pane split positions as fractions of the row width (default 0.2 / 0.7). */
paneSplit: PaneSplits;
};
export type AppState = {

View File

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

View File

@@ -82,6 +82,11 @@ export interface AudioBackend {
getPauseState(): Promise<boolean | undefined>;
/** True while the player process is running (regardless of pause). */
isAlive(): boolean;
/** Last playback error (end-file reason "error"), or null when the last
* track ended cleanly (or nothing has failed yet). Lets callers
* distinguish a natural end-of-file from a stream failure — a failed
* episode must not auto-advance the queue. */
getPlaybackError(): string | null;
dispose(): void;
}
@@ -378,7 +383,13 @@ export class MpvBackend implements AudioBackend {
this.proc = Bun.spawn(
[
"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",
"--really-quiet",
// Stay alive after finishing/unloading files; PodTUI owns one mpv
@@ -591,13 +602,22 @@ export class MpvBackend implements AudioBackend {
// play checks it and skips its own stale paused-load.
this._intentPlaying = true;
await this.runLoadExclusive(async () => {
// Fast path: this exact URL was PRELOADED paused (boot restore) —
// mpv has been buffering it since boot, so flipping pause off starts
// audio ~instantly. Re-acquire the start position only when it
// moved meaningfully since the preload (progress saved meanwhile).
if (this._loadedUrl === url && this._loadedPaused && !this._ended) {
// Same episode re-selected (Enter in a list, key-repeat, a
// second tap on the playing row): the file is ALREADY in the
// player. Reloading with start=<saved progress> would audibly
// skip BACK and repeat already-played audio (saved progress
// lags the live position by up to the 5s persist interval), so
// align in place instead:
// - preload park (loaded paused at boot restore): seek only
// when the caller's target moved materially since load;
// - user-paused: unpause at the CURRENT position (saved
// progress is stale and must not become a backward seek);
// - already playing: unpause is a no-op — nothing to do.
// A genuinely finished episode (_ended) still falls through to
// a fresh load, which replays from the top via isCompleted.
if (this._loadedUrl === url && !this._ended) {
const target = opts?.startPosition ?? this._position;
if (Math.abs(target - this._position) > 2) {
if (this._loadedPaused && Math.abs(target - this._position) > 2) {
await this.send(["set_property", "time-pos", target]);
this._position = target;
}
@@ -782,6 +802,9 @@ class NoopBackend implements AudioBackend {
isAlive(): boolean {
return false;
}
getPlaybackError(): string | null {
return null;
}
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 };
}

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

@@ -1,11 +1,11 @@
/**
* layer-graph — maps each TAB id to its page component + pane count.
*
* Split out of `navigation.ts` so that the nav-model primitives (TABS,
* TabsCount, DEPTH_TABS, rootFrameFor, TabPaneCount, PANE_RATIO) in
* `navigation.ts` stay free of any `.tsx` / JSX imports. This lets unit tests
* import the pure navigation store without pulling the OpenTUI JSX runtime
* (which is only provided by the build-time @opentui/solid bun-plugin).
* Split out of `navigation.ts` so that the navigation primitives (TABS,
* TabsCount, DEPTH_TABS, rootFrameFor, TabPaneCount) stay free of any
* `.tsx` / JSX imports. This lets unit tests import the pure navigation
* store without pulling the OpenTUI JSX runtime (which is only provided by
* the build-time @opentui/solid bun-plugin).
*
* The page modules live alongside their pages and export `<count>PaneCount`
* 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`,
// split out so this module stays free of `.tsx`/JSX imports (unit-testable).
// Yazi-style pane grow ratios (parent : current : preview). Panes use
// 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;
// Pane sizes are now user-resizable: the split positions (fractions of the
// row width) live in the shared pane-layout store (`@/stores/pane-layout`),
// which `PaneRow` consumes. `PANE_RATIO` was removed — see DEFAULT_PANE_SPLITS
// (0.2 / 0.7) for the historical 2:5:3 start.
// Number of *focusable* content panes per tab. The three visible columns
// (parent | current | preview) are a *render* concern, NOT three panes — for

View File

@@ -0,0 +1,72 @@
/**
* 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.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;
}

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

@@ -172,6 +172,76 @@ test.skipIf(!hasMpv)(
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"play() of the already-playing url does NOT reload (no audible skip-back)",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
// Start mid-episode (as a resume would) and let it advance.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
await waitFor(
"position advances past the start offset",
async () => (await backend.getPosition()) > 1.8,
);
const before = await backend.getPosition();
// Re-selecting the SAME episode (Enter in a list, key-repeat)
// calls play() with the STALE saved progress. The file is
// already loaded — this must not reload from that earlier
// position, or the listener hears already-played audio again.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
// A reload would drop the position back to ~1; a correct no-op
// keeps advancing from where it was.
await waitFor(
"playback continues past the pre-play position",
async () => (await backend.getPosition()) > before + 0.3,
);
expect(backend.isPlaying()).toBe(true);
// And the position never fell back toward the stale offset.
expect(await backend.getPosition()).toBeGreaterThan(1.8);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"play() of the same url while user-paused resumes at the current position",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
await backend.play(wavB, { volume: 0, speed: 1, startPosition: 1 });
await waitFor(
"position advances",
async () => (await backend.getPosition()) > 2,
);
await backend.pause();
await waitFor(
"paused observed",
async () => (await backend.getPauseState()) === true,
);
const pausedAt = await backend.getPosition();
// Re-selecting the paused episode resumes where it PAUSED — the
// stale saved progress must not become a backward seek target.
await backend.play(wavB, { volume: 0, speed: 1, startPosition: 1 });
expect(backend.isPlaying()).toBe(true);
await waitFor(
"resumed at the paused position",
async () => (await backend.getPosition()) > pausedAt + 0.3,
);
expect(await backend.getPosition()).toBeGreaterThan(1.5);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"daemon killed mid-play: resume() rejects on the fresh idle daemon; play() recovers a new one",
async () => {

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,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

@@ -329,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);
});
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 now = Date.now();
servedEpisodes = [
@@ -344,13 +344,15 @@ test("date mode boundary: 25 days in, 70 days out", async () => {
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"In Window",
"Out Window",
]);
// The 70d episode is ~45 days past the 2-week band beyond the oldest
// loaded episode (25d → 39d band): a sparse show must NOT drag it in.
// The 70d episode is the second-newest available, so the min-5 floor
// pulls it in despite the 60-day cache window.
expect(store.hasMoreEpisodes(id)).toBe(false);
await store.loadMoreEpisodes(id);
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
"In Window",
"Out Window",
]);
});
@@ -369,17 +371,19 @@ test("date mode: a dormant show (nothing in the window or next band) never fetch
const id = feed!.id;
addedFeedIds.push(id);
expect(store.getFeed(id)!.episodes.length).toBe(0);
// 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(0);
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) but inside the
// 14-day band past its edge (60d → 74d) — fetch-more reveals them.
// 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() },
@@ -390,8 +394,9 @@ test("date mode: episodes just outside the window load via the band anchored at
const id = feed!.id;
addedFeedIds.push(id);
expect(store.getFeed(id)!.episodes.length).toBe(0);
expect(store.hasMoreEpisodes(id)).toBe(true);
// 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",

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

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

@@ -0,0 +1,162 @@
/**
* 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 drag strips overlay the border cells (left strip at [left, left+2),
* right strip at [right-2, right)). The test presses inside a strip 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 strip (border at 20 → strip covers 20) 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 strip (border at 69 → strip covers 69) 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("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

@@ -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

@@ -230,15 +230,146 @@ test.skipIf(skip)(
app.updateVisualizer({ enabled: false });
await waitFor(() => !viz.isRunning(), 10000);
expect(viz.isLoading()).toBe(false);
// Stopping the pipeline must drop the last rendered frame — a cold
// restart (re-enable, unload, episode change) would otherwise show
// stale bars from the previous run and never reach the loading
// state (the spinner only shows while bars are empty).
expect(viz.barData().length).toBe(0);
app.updateVisualizer({ enabled: true });
await waitFor(() => viz.isRunning(), 10000);
// The restart surfaces the loading state before the first frame.
await waitFor(() => viz.isLoading(), 5000);
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
expect(viz.barData().length).toBe(64);
},
{ timeout: 20000 },
);
// A pause followed by a seek while paused, then resume, lands OUTSIDE the
// decoded sliding window: the cache can't serve bars instantly, so the
// store must surface the warm-up as a loading state instead of silently
// holding the stale pre-pause frame. Regression: resumeVisualization never
// set isLoading, so the last frame froze with no feedback until the
// re-decode's first frame landed.
test.skipIf(skip)(
"resume into undecoded audio shows the loading state until bars land",
async () => {
const viz = useVisualizer();
await startPlaying();
expect(viz.barData().length).toBe(64);
// Pause, then seek far ahead while paused (outside the ~10s of
// decoded coverage), then resume.
setIsPlaying(false);
await waitFor(() => !viz.isRunning(), 10000);
setPosition(30);
setIsPlaying(true);
// The resume position isn't decoded yet — loading, not frozen bars.
await waitFor(() => viz.isLoading(), 5000);
expect(viz.isRunning()).toBe(true);
// Playback advances past the resume point (mpv moves the clock);
// once the re-decode covers it, fresh bars replace the stale
// pre-pause frame (chirp spectrum at 30s ≠ 2s) and the loading
// state clears.
setPosition(31);
const barsBefore = viz.barData();
await waitFor(
() => !viz.isLoading() && viz.barData() !== barsBefore,
15000,
);
expect(viz.barData().length).toBe(64);
},
{ timeout: 30000 },
);
// After a long pause on a network stream, the player (mpv) re-buffers:
// `isPlaying` stays true but the position clock freezes. Without
// detection the waveform rendered the same cached window forever — static
// bars and no feedback. The render loop must report the stall as a
// loading state and clear it the moment the clock moves again.
test.skipIf(skip)(
"a frozen position clock while playing surfaces a stall; recovery clears it",
async () => {
const viz = useVisualizer();
await startPlaying();
expect(viz.isStalled()).toBe(false);
// Freeze the position: isPlaying stays true, the clock never moves.
await waitFor(() => viz.isStalled(), 10000);
// Player recovers — the clock advances again.
setPosition(4);
await waitFor(() => !viz.isStalled(), 3000);
expect(viz.isRunning()).toBe(true);
},
{ timeout: 20000 },
);
// Resume re-arms a pipeline whose ffmpeg pass was killed at pause: the
// stale pre-pause bars must not masquerade as live data while the player
// recovers. The spinner shows IN THEIR PLACE until the position clock
// advances past the resume point — a frozen clock (mpv re-buffering after
// a long pause) keeps the spinner even though the cache can serve the
// same window.
test.skipIf(skip)(
"resume shows the loading state in place of stale bars until the position clock advances",
async () => {
const viz = useVisualizer();
await startPlaying();
expect(viz.isLoading()).toBe(false);
// Pause, then resume against the still-covered position.
setIsPlaying(false);
await waitFor(() => !viz.isRunning(), 10000);
setIsPlaying(true);
// The spinner replaces the bars immediately on resume.
await waitFor(() => viz.isLoading(), 5000);
expect(viz.isRunning()).toBe(true);
// Position clock stays frozen at the resume point (re-buffering):
// the loading state must persist, not yield to static cached bars.
await Bun.sleep(250);
expect(viz.isLoading()).toBe(true);
// Player recovers — the clock advances → fresh bars, spinner gone.
setPosition(3);
await waitFor(() => !viz.isLoading(), 3000);
expect(viz.barData().length).toBe(64);
},
{ timeout: 20000 },
);
// 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 ─────────────────────────────────────────────────────────────
afterAll(() => {