Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48076fcef5 | |||
| 06c5cc9184 | |||
| a3641d100e | |||
| 8a173a5180 | |||
| 132d2079f7 | |||
| b53d4add29 | |||
| 6c3ad5d925 | |||
| 09d5732b55 | |||
| 9e2f232d27 | |||
| 9143078b12 | |||
| ca46de4d70 | |||
| 3e90f9e783 | |||
| c2ec356a5f | |||
| 6aac138629 | |||
| 3f0001b0d5 | |||
| 74158d75d4 | |||
| 4ff8aabb51 | |||
| 895138357f | |||
| 02c957f584 | |||
| d7ceb9d045 | |||
| 0677f82c44 | |||
| 4990eae60f | |||
| 9ddfd21685 | |||
| 22059c24ca |
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -566,9 +595,7 @@ async function snapshotState(audioControls: any): Promise<Record<string, unknown
|
||||
const feeds = fs_.feeds ? fs_.feeds() : [];
|
||||
state.feed = {
|
||||
count: feeds?.length ?? 0,
|
||||
sel: fs_.selectedFeedId ? fs_.selectedFeedId() : null,
|
||||
loading: fs_.isLoadingFeeds ? fs_.isLoadingFeeds() : null,
|
||||
titles: (feeds ?? []).slice(0, 8).map((f: any) => f?.podcast?.title),
|
||||
};
|
||||
} catch (e) {
|
||||
state.feed = "ERR: " + String(e);
|
||||
|
||||
@@ -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,8 +240,6 @@ 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()}
|
||||
</text>
|
||||
<box height={1} />
|
||||
|
||||
@@ -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 grab zones sit
|
||||
* exactly on the drawn borders), and renders two 3-column invisible grab
|
||||
* zones over the borders.
|
||||
*
|
||||
* Column semantics (per the yazi depth model):
|
||||
* 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,52 @@ function Pane(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/** A 3-column invisible grab zone centered on one border of the current
|
||||
* pane: the border column plus one column of help padding on each side,
|
||||
* so the thin border is easy to target with a mouse. `onBegin` is called
|
||||
* on mousedown with the cursor's x; the row records that grab offset so
|
||||
* the border stays glued to the cursor while dragging. On hover or while
|
||||
* dragging it overdraws just the border column with a full-height accent
|
||||
* `│` line (a bordered box would render as a blocky rectangle instead).
|
||||
* The two padding columns are transparent; the hit grid is rect-based, so
|
||||
* they capture clicks too — they must never overlap interactive content. */
|
||||
function Splitter(props: {
|
||||
/** Column of the border itself. The strip spans `left - 1` .. `left + 1`
|
||||
* (the border plus one help-padded column each side); the highlight
|
||||
* renders at `left`. */
|
||||
left: number;
|
||||
active: boolean;
|
||||
onBegin: (x: number) => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const dims = useTerminalDimensions();
|
||||
const [hovered, setHovered] = createSignal(false);
|
||||
const highlighted = () => props.active || hovered();
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
left={props.left - 1}
|
||||
top={0}
|
||||
width={3}
|
||||
height="100%"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault?.();
|
||||
props.onBegin(e.x);
|
||||
}}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
>
|
||||
<Show when={highlighted()}>
|
||||
{/* Draw the accent edge down the full pane height; the box clips
|
||||
* any excess rows below the row's bottom edge. */}
|
||||
<text fg={theme.primary} selectable={false}>
|
||||
{" │\n".repeat(dims().height)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Row primitive ───────────────────────────────────────────────────────────
|
||||
export function PaneRow(props: PaneRowProps) {
|
||||
/** true → the current column's scrollbox is focused (scroll follows cursor). */
|
||||
@@ -179,20 +227,72 @@ export function PaneRow(props: PaneRowProps) {
|
||||
// 2-pane mode (parent|current) grows the current column to fill the
|
||||
// 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 and
|
||||
// the cursor's grab offset from that border's column; the row then
|
||||
// lives-updates the split from the drag x (minus the offset, so the
|
||||
// border stays glued to the cursor) and commits on release.
|
||||
const [activeSplit, setActiveSplit] = createSignal<"left" | "right" | null>(
|
||||
null,
|
||||
);
|
||||
// Column of the border a strip centers on (the current pane's edge).
|
||||
const borderCol = (which: "left" | "right") =>
|
||||
which === "left" ? pixels().leftPx : pixels().rightPx - 1;
|
||||
// Cursor x relative to the grabbed border column. Set on mousedown and
|
||||
// subtracted from every drag x so the border tracks the cursor rather
|
||||
// than jumping to it.
|
||||
let grabOffset = 0;
|
||||
const beginDrag = (which: "left" | "right") => (x: number) => {
|
||||
grabOffset = x - borderCol(which);
|
||||
setActiveSplit(which);
|
||||
};
|
||||
const handleDrag = (e: { x: number }) => {
|
||||
const which = activeSplit();
|
||||
if (!which) return;
|
||||
if (which === "left") layout.setLeft(e.x - grabOffset, width());
|
||||
else layout.setRight(e.x - grabOffset, width());
|
||||
};
|
||||
const handleDragEnd = () => {
|
||||
if (activeSplit()) layout.commit();
|
||||
setActiveSplit(null);
|
||||
grabOffset = 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<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 +300,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={borderCol("left")}
|
||||
active={activeSplit() === "left"}
|
||||
onBegin={beginDrag("left")}
|
||||
/>
|
||||
<Show when={panes() === 3}>
|
||||
<Splitter
|
||||
left={borderCol("right")}
|
||||
active={activeSplit() === "right"}
|
||||
onBegin={beginDrag("right")}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* Reactive SolidJS hook wrapping the AudioBackend.
|
||||
* Reactive SolidJS hook over the module-level audio engine.
|
||||
*
|
||||
* Provides signals for playback state and methods for controlling
|
||||
* audio. Integrates with the event bus and app store.
|
||||
* Wraps utils/audio-engine: every useAudio() call shares ONE engine (all
|
||||
* playback logic, the 150ms poll, session restore, and the event-bus
|
||||
* commands live there). This hook keeps only what is tied to the Solid
|
||||
* lifecycle — the ref-counted last-owner dispose and the process-exit
|
||||
* teardown — and re-exposes the two controls the engine deliberately omits
|
||||
* (availablePlayers, switchBackend).
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
@@ -14,118 +18,42 @@
|
||||
|
||||
import { onCleanup } from "solid-js";
|
||||
import {
|
||||
cachedCoverPath,
|
||||
fetchCoverArt,
|
||||
} from "../utils/cover-art";
|
||||
import {
|
||||
createAudioBackend,
|
||||
detectPlayers,
|
||||
PlayerRestartedError,
|
||||
type AudioBackend,
|
||||
type BackendName,
|
||||
type DetectedPlayer,
|
||||
} from "../utils/audio-player";
|
||||
import {
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
position,
|
||||
setPosition,
|
||||
duration,
|
||||
setDuration,
|
||||
volume,
|
||||
setVolume,
|
||||
availablePlayers,
|
||||
currentEpisode,
|
||||
speed,
|
||||
setSpeed,
|
||||
backendName,
|
||||
setBackendName,
|
||||
error,
|
||||
setError,
|
||||
currentEpisode,
|
||||
setCurrentEpisode,
|
||||
availablePlayers,
|
||||
setAvailablePlayers,
|
||||
volume,
|
||||
setVolume,
|
||||
} from "../utils/audio-signals";
|
||||
import { emit, on } from "../utils/event-bus";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useProgressStore } from "../stores/progress";
|
||||
import { useMediaRegistry } from "../utils/media-registry";
|
||||
import { saveLastPlayerSync } from "../utils/app-persistence";
|
||||
import type { BackendName, DetectedPlayer } from "../utils/audio-player";
|
||||
import {
|
||||
loadLastPlayerFromFile,
|
||||
saveLastPlayerToFile,
|
||||
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 { useDownloadStore } from "../stores/download";
|
||||
import { useFeedStore } from "../stores/feed";
|
||||
createAudioEngine,
|
||||
ensureEngineBackend,
|
||||
disposeEngineBackend,
|
||||
stopEnginePolling,
|
||||
getEngineBackend,
|
||||
switchBackend,
|
||||
restoreLastSession,
|
||||
type AudioEngine,
|
||||
} from "../utils/audio-engine";
|
||||
|
||||
export interface AudioControls {
|
||||
// Signals (reactive getters)
|
||||
isPlaying: () => boolean;
|
||||
position: () => number;
|
||||
duration: () => number;
|
||||
volume: () => number;
|
||||
speed: () => number;
|
||||
backendName: () => BackendName;
|
||||
error: () => string | null;
|
||||
currentEpisode: () => Episode | null;
|
||||
// Re-exported so the session-restore test can pull it from this module.
|
||||
export { restoreLastSession };
|
||||
|
||||
// useAudio() surface: the engine plus the two controls it doesn't expose.
|
||||
export type AudioControls = AudioEngine & {
|
||||
availablePlayers: () => DetectedPlayer[];
|
||||
|
||||
// Actions
|
||||
play: (episode: Episode) => Promise<void>;
|
||||
/** Load an episode into the player WITHOUT starting playback. */
|
||||
load: (episode: Episode) => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
resume: () => Promise<void>;
|
||||
togglePlayback: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
seek: (seconds: number) => Promise<void>;
|
||||
seekRelative: (delta: number) => Promise<void>;
|
||||
setVolume: (volume: number) => Promise<void>;
|
||||
setSpeed: (speed: number) => Promise<void>;
|
||||
switchBackend: (name: BackendName) => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
next: () => Promise<void>;
|
||||
}
|
||||
};
|
||||
|
||||
// Singleton state — shared across all components that call useAudio()
|
||||
let backend: AudioBackend | null = null;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const engine = createAudioEngine();
|
||||
|
||||
// Singleton ref count — how many live useAudio() owners there are. The engine
|
||||
// is shared; the last owner to unmount disposes the backend.
|
||||
let refCount = 0;
|
||||
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
||||
|
||||
// Playback signals are declared in utils/audio-signals.ts (imported above)
|
||||
// so non-component consumers (the visualizer store) can subscribe without
|
||||
// mounting a useAudio() owner.
|
||||
|
||||
/** True once the current episode has been handed to the backend (play
|
||||
* started). `false` means the episode is only LOADED in the player (e.g.
|
||||
* restored at boot) and the first play action must start the backend
|
||||
* instead of unpausing it. */
|
||||
let startedPlayback = false;
|
||||
|
||||
/** Completion fraction at/above which an episode is NOT restored at boot. */
|
||||
const RESTORE_COMPLETION_THRESHOLD = 0.98;
|
||||
|
||||
/** True when saved progress is below the restore cutoff. Episodes with no
|
||||
* progress (never reached the persist threshold) or unknown duration count
|
||||
* as eligible — they restore from the start. */
|
||||
function isRestoreEligible(progress: Progress | undefined): boolean {
|
||||
if (!progress || progress.duration <= 0) return true;
|
||||
return progress.position / progress.duration < RESTORE_COMPLETION_THRESHOLD;
|
||||
}
|
||||
|
||||
function ensureBackend(): AudioBackend {
|
||||
if (!backend) {
|
||||
const detected = detectPlayers();
|
||||
setAvailablePlayers(detected);
|
||||
backend = createAudioBackend();
|
||||
setBackendName(backend.name);
|
||||
registerExitTeardown();
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
// ── Process-exit teardown ─────────────────────────────────────────────
|
||||
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
|
||||
@@ -140,7 +68,7 @@ function registerExitTeardown(): void {
|
||||
if (exitTeardownRegistered) return;
|
||||
exitTeardownRegistered = true;
|
||||
const teardown = (): void => {
|
||||
stopPolling();
|
||||
stopEnginePolling();
|
||||
// Persist "what's loaded in the player right now" synchronously —
|
||||
// process.exit(0) runs this handler synchronously and an async write
|
||||
// would never land. The next launch restores this episode paused.
|
||||
@@ -153,7 +81,7 @@ function registerExitTeardown(): void {
|
||||
/* best-effort at exit */
|
||||
}
|
||||
try {
|
||||
backend?.dispose();
|
||||
getEngineBackend()?.dispose();
|
||||
} catch {
|
||||
/* best-effort at exit */
|
||||
}
|
||||
@@ -172,568 +100,24 @@ function registerExitTeardown(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll ticks between paused-state checks (~1s at 150ms/tick). While the
|
||||
* UI believes playback is paused we only need to catch an external
|
||||
* resume (AirPod play tap, lock-screen/media-center play); checking every
|
||||
* tick would just hammer mpv IPC for nothing. */
|
||||
const PAUSE_WATCH_TICKS = 7;
|
||||
|
||||
/** The player process died while we believed playback was live — track
|
||||
* ended (mpv quits at EOF) or the process crashed. Persist the final
|
||||
* position and stop polling. */
|
||||
function finalizeTrackEnd(): void {
|
||||
setIsPlaying(false);
|
||||
stopPolling();
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
}
|
||||
}
|
||||
|
||||
/** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal,
|
||||
* device swap, OS media keys, the Now Playing center. Bring the UI in
|
||||
* sync; the poll stays armed so an external resume is caught too. */
|
||||
function reconcileExternalPause(): void {
|
||||
setIsPlaying(false);
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
emit("player.pause", { episodeId: ep.id });
|
||||
const media = useMediaRegistry();
|
||||
media.setPlaybackState(false);
|
||||
media.setPosition(position());
|
||||
}
|
||||
}
|
||||
|
||||
/** Playback was restarted from outside PodTUI (AirPods, lock-screen or
|
||||
* media-center play, OS media keys). Bring the UI back to "playing". */
|
||||
function reconcileExternalResume(): void {
|
||||
setIsPlaying(true);
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
emit("player.play", { episodeId: ep.id });
|
||||
useMediaRegistry().setPlaybackState(true);
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
stopPolling();
|
||||
pollCount = 0;
|
||||
// Guard against overlapping ticks if a socket read ever outlives the
|
||||
// interval (getPosition opens a fresh mpv IPC connection per call).
|
||||
let pollInFlight = false;
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!backend || pollInFlight) return;
|
||||
pollInFlight = true;
|
||||
try {
|
||||
pollCount++;
|
||||
if (isPlaying()) {
|
||||
// Track ended (eof-reached observed) or process died. Check
|
||||
// BEFORE pause reconciliation: mpv keeps the file open at EOF
|
||||
// and reports pause=true there, which would otherwise be
|
||||
// mistaken for an external pause and never finalize.
|
||||
if (!backend.isPlaying()) {
|
||||
finalizeTrackEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// mpv can pause itself outside PodTUI. Reconcile instead of
|
||||
// staying stuck on "playing" with a frozen waveform
|
||||
// (getPosition would just re-read the same frozen time-pos).
|
||||
const paused = await backend.getPauseState();
|
||||
if (paused === true) {
|
||||
reconcileExternalPause();
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = await backend.getPosition();
|
||||
const dur = await backend.getDuration();
|
||||
setPosition(pos);
|
||||
if (dur > 0) setDuration(dur);
|
||||
|
||||
// Save progress every ~5 seconds (33 ticks * 150ms)
|
||||
if (pollCount % 33 === 0) {
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||
|
||||
const media = useMediaRegistry();
|
||||
media.setPosition(pos);
|
||||
}
|
||||
}
|
||||
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
|
||||
// Paused — watch for playback restarted from outside (AirPods,
|
||||
// lock-screen/media-center play). Only while the player is
|
||||
// still alive: a dead player while we thought we were paused
|
||||
// means the track ended (mpv quits at EOF) or it crashed.
|
||||
if (!backend.isAlive()) {
|
||||
finalizeTrackEnd();
|
||||
return;
|
||||
}
|
||||
const paused = await backend.getPauseState();
|
||||
if (paused === false) {
|
||||
reconcileExternalResume();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Backend may have been disposed
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cover art for system Now Playing ─────────────────────────────────────────
|
||||
// macOS shows the media session's albumart in the audio center; mpv reads it
|
||||
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
|
||||
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
|
||||
|
||||
/** Resolve cover art to a local path for mpv's --cover-art-files, per the
|
||||
* call site's latency budget:
|
||||
* "cache" — disk cache only (sync): resume paths must never wait on the
|
||||
* network, so a miss plays artless and warms for next time.
|
||||
* "bounded" — disk hit, else fetch capped at 1.2s: cold play needs the art
|
||||
* at file LOAD, but a slow cover server must not stall audio.
|
||||
* "await" — disk hit, else full (8s-bounded) fetch: boot restore preloads
|
||||
* while feeds/progress load anyway, so the wait is free and the
|
||||
* cover must be present when the file loads.
|
||||
* fetchCoverArt already short-circuits on the disk cache, so "await" costs
|
||||
* nothing on a warm cache. */
|
||||
async function resolveCoverArt(
|
||||
coverUrl: string | undefined,
|
||||
mode: "cache" | "bounded" | "await",
|
||||
): Promise<string | null> {
|
||||
if (!coverUrl) return null;
|
||||
if (mode === "cache") return cachedCoverPath(coverUrl);
|
||||
if (mode === "bounded") {
|
||||
const cached = cachedCoverPath(coverUrl);
|
||||
if (cached) return cached;
|
||||
return Promise.race([
|
||||
fetchCoverArt(coverUrl),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
|
||||
]);
|
||||
}
|
||||
return fetchCoverArt(coverUrl);
|
||||
}
|
||||
|
||||
async function play(episode: Episode): Promise<void> {
|
||||
const b = ensureBackend();
|
||||
setError(null);
|
||||
|
||||
if (!episode.audioUrl) {
|
||||
setError("No audio URL for this episode");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const appStore = useAppStore();
|
||||
const progressStore = useProgressStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
const vol = volume();
|
||||
const spd = storeSpeed || speed();
|
||||
|
||||
const feedStore = useFeedStore();
|
||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
// Play the downloaded file when present (offline + no network stalls);
|
||||
// otherwise stream. Cover resolves to the feed art, falling back to the
|
||||
// episode's own image (feeds added by URL may lack a channel cover).
|
||||
const downloadStore = useDownloadStore();
|
||||
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||
// Cover art only applies at file LOAD (the runtime video-add fallback
|
||||
// never becomes an albumart track), so a cold-cache play must wait for
|
||||
// the fetch or play artless. Serve the disk cache synchronously; on a
|
||||
// miss, await the bounded fetch (covers fetch in ~300ms typically) —
|
||||
// past the 1.2s cap, play bare and let the fetch warm the cache.
|
||||
const coverArtPath = await resolveCoverArt(
|
||||
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||
"bounded",
|
||||
);
|
||||
|
||||
// Resume from saved progress if available and not completed
|
||||
const savedProgress = progressStore.get(episode.id);
|
||||
let startPos = 0;
|
||||
if (savedProgress && !progressStore.isCompleted(episode.id)) {
|
||||
startPos = savedProgress.position;
|
||||
}
|
||||
|
||||
await b.play(url, {
|
||||
volume: vol,
|
||||
speed: spd,
|
||||
startPosition: startPos > 0 ? startPos : undefined,
|
||||
mediaTitle: episode.title,
|
||||
coverArtPath: coverArtPath ?? undefined,
|
||||
});
|
||||
|
||||
setCurrentEpisode(episode);
|
||||
setIsPlaying(true);
|
||||
setPosition(startPos);
|
||||
setSpeed(spd);
|
||||
if (episode.duration) setDuration(episode.duration);
|
||||
startedPlayback = true;
|
||||
|
||||
// Remember this episode as "loaded in the player" so the next launch
|
||||
// can restore it paused (cleared by stop()).
|
||||
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||
|
||||
// Register with platform media controls
|
||||
const media = useMediaRegistry();
|
||||
media.setNowPlaying({
|
||||
title: episode.title,
|
||||
artist: podcastTitle || episode.podcastId,
|
||||
duration: episode.duration,
|
||||
});
|
||||
media.setPlaybackState(true);
|
||||
if (startPos > 0) media.setPosition(startPos);
|
||||
|
||||
startPolling();
|
||||
emit("player.play", { episodeId: episode.id });
|
||||
// Distinct from "player.play" (which also fires on resume): signals a
|
||||
// fresh episode start so Shell can honor the auto-jump-to-player pref.
|
||||
emit("player.started", { episodeId: episode.id });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Playback failed");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an episode into the player WITHOUT starting playback. The player tab
|
||||
* renders it paused at its saved position; the first play action starts the
|
||||
* backend from there (see togglePlayback). Used to restore the last player
|
||||
* session at boot.
|
||||
*/
|
||||
async function load(episode: Episode): Promise<void> {
|
||||
ensureBackend();
|
||||
setError(null);
|
||||
|
||||
setCurrentEpisode(episode);
|
||||
setIsPlaying(false);
|
||||
startedPlayback = false;
|
||||
|
||||
// Show the saved position so the player tab reflects where playback
|
||||
// will resume; episodes at/above the completion threshold start from 0.
|
||||
const progressStore = useProgressStore();
|
||||
const saved = progressStore.get(episode.id);
|
||||
const pos = saved && isRestoreEligible(saved) ? saved.position : 0;
|
||||
setPosition(pos);
|
||||
if (episode.duration) setDuration(episode.duration);
|
||||
|
||||
const appStore = useAppStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
setSpeed(storeSpeed || speed());
|
||||
|
||||
// Surface the loaded-but-paused track to the OS media controls.
|
||||
const feedStore = useFeedStore();
|
||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
const media = useMediaRegistry();
|
||||
media.setNowPlaying({
|
||||
title: episode.title,
|
||||
artist: podcastTitle || episode.podcastId,
|
||||
duration: episode.duration,
|
||||
});
|
||||
media.setPlaybackState(false);
|
||||
if (pos > 0) media.setPosition(pos);
|
||||
|
||||
// Preload the episode into the backend PAUSED: mpv opens the stream and
|
||||
// fills its demuxer cache while parked, so the user's first Play flips
|
||||
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
|
||||
// — a failed preload just makes the first play take the cold path.
|
||||
const downloadStore = useDownloadStore();
|
||||
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||
if (episode.audioUrl && backend) {
|
||||
// The preload must carry the cover AT LOAD: cover-art-files only
|
||||
// applies when the file loads, and the runtime video-add fallback
|
||||
// never becomes an albumart track (verified). Restore already waits
|
||||
// on feeds/progress at boot, so the bounded fetch (~300ms typical,
|
||||
// 8s worst case) is free. Falls back to the episode's own image when
|
||||
// the feed has no channel cover.
|
||||
const coverArtPath = await resolveCoverArt(
|
||||
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||
"await",
|
||||
);
|
||||
const backendSnap = backend;
|
||||
backendSnap
|
||||
.preload(url, {
|
||||
volume: volume(),
|
||||
speed: storeSpeed || speed(),
|
||||
startPosition: pos > 0 ? pos : undefined,
|
||||
mediaTitle: episode.title,
|
||||
coverArtPath: coverArtPath ?? undefined,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||
}
|
||||
|
||||
async function pause(): Promise<void> {
|
||||
if (!backend) return;
|
||||
try {
|
||||
await backend.pause();
|
||||
setIsPlaying(false);
|
||||
// Polling stays armed (paused-watch mode): playback can be resumed
|
||||
// from OUTSIDE PodTUI — AirPods, lock-screen/media-center play —
|
||||
// and the poll must be live to catch it.
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
// Save progress on pause
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
emit("player.pause", { episodeId: ep.id });
|
||||
|
||||
// Update platform media controls
|
||||
const media = useMediaRegistry();
|
||||
media.setPlaybackState(false);
|
||||
media.setPosition(position());
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Pause failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** mpv was killed/crashed: respawn it and restart playback from the saved
|
||||
* position via the full play path (fresh loadfile, cover art, media
|
||||
* registry). A bare unpause would target a dead — or freshly-idle —
|
||||
* daemon and silently do nothing. */
|
||||
async function recoverPlayback(): Promise<void> {
|
||||
const ep = currentEpisode();
|
||||
if (ep && ep.audioUrl) {
|
||||
await play(ep);
|
||||
} else {
|
||||
setError("Player is not running");
|
||||
}
|
||||
}
|
||||
|
||||
async function resume(): Promise<void> {
|
||||
if (!backend) return;
|
||||
if (!backend.isAlive()) {
|
||||
await recoverPlayback();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await backend.resume();
|
||||
setIsPlaying(true);
|
||||
startPolling();
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
emit("player.play", { episodeId: ep.id });
|
||||
const media = useMediaRegistry();
|
||||
media.setPlaybackState(true);
|
||||
}
|
||||
} catch (err) {
|
||||
// Race: the daemon died between the liveness check above and the
|
||||
// unpause — backend.resume() respawned it and threw
|
||||
// PlayerRestartedError (the fresh daemon has no file loaded).
|
||||
if (err instanceof PlayerRestartedError) {
|
||||
await recoverPlayback();
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Resume failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlayback(): Promise<void> {
|
||||
if (isPlaying()) {
|
||||
await pause();
|
||||
} else if (currentEpisode()) {
|
||||
if (startedPlayback) {
|
||||
await resume();
|
||||
} else {
|
||||
// Episode is only LOADED (e.g. restored at boot) — the backend
|
||||
// was never started, so unpausing a dead player would fail
|
||||
// silently. Start playback from the saved position instead.
|
||||
const ep = currentEpisode();
|
||||
if (ep) await play(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
if (!backend) return;
|
||||
try {
|
||||
// Save progress before stopping
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
}
|
||||
await backend.stop();
|
||||
setIsPlaying(false);
|
||||
setPosition(0);
|
||||
setCurrentEpisode(null);
|
||||
startedPlayback = false;
|
||||
stopPolling();
|
||||
emit("player.stop", {});
|
||||
|
||||
// Player is empty again — nothing to restore on the next launch.
|
||||
saveLastPlayerToFile({ episodeId: null, timestamp: null });
|
||||
|
||||
const media = useMediaRegistry();
|
||||
media.clearNowPlaying();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Stop failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function seek(seconds: number): Promise<void> {
|
||||
if (!backend) return;
|
||||
const clamped = Math.max(0, Math.min(seconds, duration()));
|
||||
try {
|
||||
await backend.seek(clamped);
|
||||
setPosition(clamped);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Seek failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function seekRelative(delta: number): Promise<void> {
|
||||
await seek(position() + delta);
|
||||
}
|
||||
|
||||
async function doSetVolume(vol: number): Promise<void> {
|
||||
const clamped = Math.max(0, Math.min(1, vol));
|
||||
if (backend) {
|
||||
try {
|
||||
await backend.setVolume(clamped);
|
||||
} catch {
|
||||
// Some backends can't change volume at runtime
|
||||
}
|
||||
}
|
||||
setVolume(clamped);
|
||||
|
||||
// Sync back to app store (persisted to config.json for the next launch).
|
||||
const appStore = useAppStore();
|
||||
appStore.updateSettings({ volume: clamped });
|
||||
}
|
||||
|
||||
async function doSetSpeed(spd: number): Promise<void> {
|
||||
const clamped = Math.max(0.25, Math.min(3, spd));
|
||||
if (backend) {
|
||||
try {
|
||||
await backend.setSpeed(clamped);
|
||||
} catch {
|
||||
// Some backends can't change speed at runtime
|
||||
}
|
||||
}
|
||||
setSpeed(clamped);
|
||||
|
||||
// Sync back to app store
|
||||
const appStore = useAppStore();
|
||||
appStore.updateSettings({ playbackSpeed: clamped });
|
||||
}
|
||||
|
||||
async function switchBackend(name: BackendName): Promise<void> {
|
||||
const wasPlaying = isPlaying();
|
||||
const ep = currentEpisode();
|
||||
const pos = position();
|
||||
const vol = volume();
|
||||
const spd = speed();
|
||||
|
||||
if (backend) {
|
||||
stopPolling();
|
||||
backend.dispose();
|
||||
backend = null;
|
||||
}
|
||||
|
||||
backend = createAudioBackend(name);
|
||||
setBackendName(backend.name);
|
||||
setAvailablePlayers(detectPlayers());
|
||||
|
||||
// Resume playback if we were playing
|
||||
if (wasPlaying && ep && ep.audioUrl) {
|
||||
try {
|
||||
const feedStore = useFeedStore();
|
||||
const feed = feedStore
|
||||
.feeds()
|
||||
.find((f) => f.podcast.id === ep.podcastId);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
const url =
|
||||
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
|
||||
const coverArtPath = await resolveCoverArt(
|
||||
feed?.podcast.coverUrl ?? ep.imageUrl,
|
||||
"cache",
|
||||
);
|
||||
await backend.play(url, {
|
||||
startPosition: pos,
|
||||
volume: vol,
|
||||
speed: spd,
|
||||
mediaTitle: ep.title,
|
||||
coverArtPath: coverArtPath ?? undefined,
|
||||
});
|
||||
setIsPlaying(true);
|
||||
startedPlayback = true;
|
||||
startPolling();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Backend switch failed");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialized restore chain: the boot-triggered restore and any explicit
|
||||
* call run one after another, so a late-finishing earlier restore can never
|
||||
* overwrite state changed by a later one (and callers can await the latest
|
||||
* attempt deterministically). */
|
||||
let restoreChain: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Boot-time session restore: reload the episode that was loaded in the
|
||||
* player when the previous run ended (persisted on play/load and at exit),
|
||||
* paused at its saved position — never autostarted. Episodes at/above the
|
||||
* completion threshold are skipped. Silently no-ops when there is nothing
|
||||
* to restore (empty player, unsubscribed show, or completed episode).
|
||||
*/
|
||||
export async function restoreLastSession(): Promise<void> {
|
||||
const attempt = restoreChain.then(async () => {
|
||||
const marker = await loadLastPlayerFromFile();
|
||||
if (!marker?.episodeId) return;
|
||||
|
||||
// Feeds and progress load asynchronously at boot; wait for both
|
||||
// before looking the episode up.
|
||||
await Promise.all([
|
||||
useProgressStore().whenReady(),
|
||||
useFeedStore().whenReady(),
|
||||
]);
|
||||
|
||||
const episode = useFeedStore().findEpisode(marker.episodeId);
|
||||
if (!episode) return;
|
||||
|
||||
// Only restore episodes below the completion threshold.
|
||||
const saved = useProgressStore().get(episode.id);
|
||||
if (!isRestoreEligible(saved)) return;
|
||||
|
||||
await load(episode);
|
||||
});
|
||||
// Keep the chain alive even when an attempt fails; the caller awaiting
|
||||
// this attempt still observes its own outcome.
|
||||
restoreChain = attempt.catch(() => {});
|
||||
await attempt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive audio controls hook.
|
||||
*
|
||||
* Returns a singleton — all components share the same playback state.
|
||||
* Registers event bus listeners and cleans them up with onCleanup.
|
||||
* Returns the shared audio engine wrapped with the two extra controls, so
|
||||
* all components observe the same playback state. The first useAudio()
|
||||
* owner creates the backend, runs the one-time boot (volume/speed sync +
|
||||
* session restore) and registers the process-exit teardown; the last
|
||||
* owner disposes the backend.
|
||||
*/
|
||||
export function useAudio(): AudioControls {
|
||||
// Initialize backend on first use
|
||||
ensureBackend();
|
||||
const engine = createAudioEngine();
|
||||
ensureEngineBackend();
|
||||
registerExitTeardown();
|
||||
|
||||
// Sync initial speed/volume from app store (reuse the previous session's
|
||||
// playback levels; defaults are 1x and 100%).
|
||||
// First owner: sync speed/volume from the persisted settings and restore
|
||||
// the last player session once (loaded, not playing). Raw signal
|
||||
// accessors are used here on purpose — this is boot-only, not a user
|
||||
// volume/speed change, so it must not re-persist to the app store.
|
||||
if (refCount === 0) {
|
||||
const appStore = useAppStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
@@ -759,160 +143,13 @@ export function useAudio(): AudioControls {
|
||||
|
||||
refCount++;
|
||||
|
||||
// Listen for event bus commands (e.g. from other components)
|
||||
const unsubPlay = on("player.play", async (data) => {
|
||||
// External play requests — currently just tracks episodeId.
|
||||
// Episode lookup would require feed store integration.
|
||||
});
|
||||
|
||||
const unsubStop = on("player.stop", async () => {
|
||||
if (backend && isPlaying()) {
|
||||
await backend.stop();
|
||||
setIsPlaying(false);
|
||||
setPosition(0);
|
||||
setCurrentEpisode(null);
|
||||
stopPolling();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for global multimedia key events (from useMultimediaKeys)
|
||||
const unsubMediaToggle = on("media.toggle", async () => {
|
||||
await togglePlayback();
|
||||
});
|
||||
|
||||
const unsubMediaVolUp = on("media.volumeUp", async () => {
|
||||
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
|
||||
});
|
||||
|
||||
const unsubMediaVolDown = on("media.volumeDown", async () => {
|
||||
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
|
||||
});
|
||||
|
||||
const unsubMediaSpeed = on("media.speedCycle", async () => {
|
||||
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
|
||||
await doSetSpeed(next);
|
||||
});
|
||||
|
||||
const audioNav = useAudioNavStore();
|
||||
const feedStore = useFeedStore();
|
||||
|
||||
async function prev(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
|
||||
const currentPos = position();
|
||||
const currentDur = duration();
|
||||
|
||||
const NAV_START_THRESHOLD = 30;
|
||||
|
||||
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
|
||||
await seek(NAV_START_THRESHOLD);
|
||||
} else {
|
||||
const source = audioNav.getSource();
|
||||
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
|
||||
if (source === AudioSource.FEED) {
|
||||
episodes = feedStore.getAllEpisodesChronological();
|
||||
} else if (source === AudioSource.MY_SHOWS) {
|
||||
const podcastId = audioNav.getPodcastId();
|
||||
if (!podcastId) return;
|
||||
|
||||
const feed = feedStore
|
||||
.getFilteredFeeds()
|
||||
.find((f) => f.podcast.id === podcastId);
|
||||
if (!feed) return;
|
||||
|
||||
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
|
||||
}
|
||||
|
||||
const currentIndex = audioNav.getCurrentIndex();
|
||||
const newIndex = Math.max(0, currentIndex - 1);
|
||||
|
||||
if (newIndex < episodes.length && episodes[newIndex]) {
|
||||
const { episode } = episodes[newIndex];
|
||||
await play(episode);
|
||||
audioNav.prev(newIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function next(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
|
||||
const source = audioNav.getSource();
|
||||
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
|
||||
|
||||
if (source === AudioSource.FEED) {
|
||||
episodes = feedStore.getAllEpisodesChronological();
|
||||
} else if (source === AudioSource.MY_SHOWS) {
|
||||
const podcastId = audioNav.getPodcastId();
|
||||
if (!podcastId) return;
|
||||
|
||||
const feed = feedStore
|
||||
.getFilteredFeeds()
|
||||
.find((f) => f.podcast.id === podcastId);
|
||||
if (!feed) return;
|
||||
|
||||
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
|
||||
}
|
||||
|
||||
const currentIndex = audioNav.getCurrentIndex();
|
||||
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
|
||||
|
||||
if (newIndex >= 0 && episodes[newIndex]) {
|
||||
const { episode } = episodes[newIndex];
|
||||
await play(episode);
|
||||
audioNav.next(newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
refCount--;
|
||||
unsubPlay();
|
||||
unsubStop();
|
||||
unsubMediaToggle();
|
||||
unsubMediaVolUp();
|
||||
unsubMediaVolDown();
|
||||
unsubMediaSpeed();
|
||||
|
||||
if (refCount <= 0) {
|
||||
stopPolling();
|
||||
if (backend) {
|
||||
backend.dispose();
|
||||
backend = null;
|
||||
}
|
||||
// Clear media registry on full teardown
|
||||
const media = useMediaRegistry();
|
||||
media.clearNowPlaying();
|
||||
|
||||
disposeEngineBackend();
|
||||
refCount = 0;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
position,
|
||||
duration,
|
||||
volume,
|
||||
speed,
|
||||
backendName,
|
||||
error,
|
||||
currentEpisode,
|
||||
availablePlayers,
|
||||
|
||||
play,
|
||||
load,
|
||||
pause,
|
||||
resume,
|
||||
togglePlayback,
|
||||
stop,
|
||||
seek,
|
||||
seekRelative,
|
||||
setVolume: doSetVolume,
|
||||
setSpeed: doSetSpeed,
|
||||
switchBackend,
|
||||
prev,
|
||||
next,
|
||||
};
|
||||
return { ...engine, availablePlayers, switchBackend };
|
||||
}
|
||||
|
||||
@@ -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.9.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
|
||||
@@ -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,6 +658,21 @@ 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 */}
|
||||
@@ -738,8 +759,18 @@ export function MyShowsPage() {
|
||||
|
||||
const previewContent = () =>
|
||||
depth() === 0 ? (
|
||||
// depth 0 preview: hovered unsubscribed-show download, else the
|
||||
// hovered show.
|
||||
// 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={
|
||||
@@ -769,13 +800,14 @@ export function MyShowsPage() {
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
) : (
|
||||
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
||||
<>
|
||||
<Show when={focusedOnMore()}>
|
||||
<FetchMorePreview
|
||||
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||
fetchMoreMode={fetchMoreMode}
|
||||
manualText={() =>
|
||||
"Load the next batch of older episodes for this show (Enter)."
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -7,25 +7,31 @@ import { createSignal } from "solid-js";
|
||||
import { Effect } from "effect";
|
||||
import { refreshFeedsBatch } from "../effects/feed-refresh";
|
||||
import { FeedVisibility } from "../types/feed";
|
||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
||||
import type { Feed } from "../types/feed";
|
||||
import type { Podcast } from "../types/podcast";
|
||||
import type { Episode } from "../types/episode";
|
||||
import type { PodcastSource } from "../types/source";
|
||||
import { DEFAULT_SOURCES } from "../types/source";
|
||||
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
||||
import { FETCH_TIMEOUT_MS, fetchFeedXml } from "../utils/rss-client";
|
||||
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
||||
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
||||
import { mergeEpisodesBounded } from "../utils/episode-merge";
|
||||
import {
|
||||
episodeSignature,
|
||||
mergeEpisodesBounded,
|
||||
} from "../utils/episode-merge";
|
||||
episodeKeepFn,
|
||||
episodeTs,
|
||||
dateFetchMoreCutoff,
|
||||
dateBandCount,
|
||||
sameRefreshWindow,
|
||||
} from "../utils/episode-windows";
|
||||
import { createSourceRegistry } from "../utils/source-registry";
|
||||
import { createPersistScheduler } from "./persist";
|
||||
import {
|
||||
DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
episodeInWindow,
|
||||
loadFeedsFromFile,
|
||||
saveFeedsToFile,
|
||||
loadSourcesFromFile,
|
||||
saveSourcesToFile,
|
||||
loadSourcesFromFile,
|
||||
} from "../utils/feeds-persistence";
|
||||
import { useActivityStore } from "./activity";
|
||||
import { useDownloadStore } from "./download";
|
||||
@@ -33,20 +39,12 @@ import { useAppStore } from "./app";
|
||||
import { DownloadStatus } from "../types/episode";
|
||||
|
||||
/** Max episodes to load per page/chunk (count mode only — date mode steps
|
||||
* by FETCH_MORE_WINDOW_DAYS instead). */
|
||||
* by episode-windows' fetch-more band instead). */
|
||||
const MAX_EPISODES_REFRESH = 50;
|
||||
|
||||
/** Max episodes to fetch on initial subscribe */
|
||||
const MAX_EPISODES_SUBSCRIBE = 20;
|
||||
|
||||
/** Fetch-more step in date mode: each press reveals the next two weeks of
|
||||
* episodes past the oldest loaded one, instead of a fixed episode count. */
|
||||
const FETCH_MORE_WINDOW_DAYS = 14;
|
||||
|
||||
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
|
||||
* the background refresh loop. */
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** Bounds simultaneous RSS requests during a refresh batch — a hung feed
|
||||
* burns at most one slot for FETCH_TIMEOUT_MS instead of pinning the whole
|
||||
* batch. */
|
||||
@@ -122,68 +120,21 @@ const fullEpisodeCache = new Map<string, Episode[]>();
|
||||
* holds — when it reaches the cache length, hasMoreEpisodes flips false. */
|
||||
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. */
|
||||
function episodeKeepFn(prefs: {
|
||||
episodeCacheMode: "date" | "count";
|
||||
episodeCacheCount: number;
|
||||
episodeCacheDays: number;
|
||||
}): (ep: Episode, index: number) => boolean {
|
||||
const now = new Date();
|
||||
if (prefs.episodeCacheMode === "count") {
|
||||
const count = Math.max(1, prefs.episodeCacheCount);
|
||||
return (_ep: Episode, index: number) => index < count;
|
||||
}
|
||||
const days = Math.max(1, prefs.episodeCacheDays);
|
||||
return (ep: Episode) => episodeInWindow(ep, now, days);
|
||||
}
|
||||
/** Write closure for the persist scheduler — reads the live feed signal
|
||||
* (wired by createFeedStore) so a flush always lands the latest value. */
|
||||
let readFeeds: () => Feed[] = () => [];
|
||||
|
||||
/** Timestamp for window math — undated episodes sort/compare as NEWEST
|
||||
* (Infinity) so they can never be excluded by a date cutoff. */
|
||||
const epTs = (ep: Episode): number => {
|
||||
const t = ep.pubDate?.getTime();
|
||||
return t === undefined || Number.isNaN(t) ? Infinity : t;
|
||||
};
|
||||
|
||||
/** Date-mode fetch-more cutoff: the oldest loaded episode's pubDate minus the
|
||||
* 2-week band. With nothing loaded (a show whose episodes all fall outside
|
||||
* the cache window), the band anchors at the cache-window edge (now minus
|
||||
* the configured days) — a dormant show can't drag in arbitrarily old
|
||||
* episodes just because the button is pressed. */
|
||||
const dateFetchMoreCutoff = (
|
||||
cached: Episode[],
|
||||
loaded: number,
|
||||
windowDays: number,
|
||||
): number => {
|
||||
if (loaded > 0) {
|
||||
const t = epTs(cached[loaded - 1]);
|
||||
if (Number.isFinite(t)) {
|
||||
return t - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000;
|
||||
}
|
||||
}
|
||||
// Nothing loaded: the band extends FETCH_MORE_WINDOW_DAYS before the
|
||||
// cache-window edge (e.g. 60d → reveals the 60–74d slice).
|
||||
return (
|
||||
Date.now() -
|
||||
Math.max(1, windowDays) * 24 * 3600 * 1000 -
|
||||
FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000
|
||||
);
|
||||
};
|
||||
|
||||
/** Save feeds to file (async, fire-and-forget). */
|
||||
function saveFeeds(feeds: Feed[]): void {
|
||||
/** Shared trailing-edge debouncer for config.json writes ("feeds" domain);
|
||||
* sources persist immediately instead. */
|
||||
const persistScheduler = createPersistScheduler(() => {
|
||||
const prefs = useAppStore().state().preferences;
|
||||
const days =
|
||||
saveFeedsToFile(
|
||||
readFeeds(),
|
||||
prefs.episodeCacheMode === "date"
|
||||
? Math.max(1, prefs.episodeCacheDays)
|
||||
: undefined;
|
||||
saveFeedsToFile(feeds, days);
|
||||
}
|
||||
|
||||
/** Save sources to file (async, fire-and-forget) */
|
||||
function saveSources(sources: PodcastSource[]): void {
|
||||
saveSourcesToFile(sources);
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
});
|
||||
|
||||
/** Move plaintext apiKey/apiSecret (pre-keychain persistence) into the macOS
|
||||
* keychain, marking the source hasCredentials and stripping the plaintext.
|
||||
@@ -229,39 +180,10 @@ async function migratePlaintextCredentials(
|
||||
return changed ? migrated : sources;
|
||||
}
|
||||
|
||||
/** True when the freshly fetched window matches the corresponding PREFIX of
|
||||
* the existing episode list (id-set equality, order-insensitive). With
|
||||
* union semantics the merged list legitimately contains episodes BEYOND the
|
||||
* fetched window, so unchanged-detection must compare the fetched window
|
||||
* against the existing list's prefix — comparing full lists would bump
|
||||
* `lastUpdated` on every refresh. When ids drifted between refreshes (the
|
||||
* one-time positional-id migration, or a feed that rotates enclosure URLs)
|
||||
* the id sets differ for the SAME content, so a content-signature
|
||||
* comparison decides: an unchanged feed stays unchanged. */
|
||||
export function sameRefreshWindow(
|
||||
existing: Episode[],
|
||||
fetched: Episode[],
|
||||
): boolean {
|
||||
if (fetched.length === 0) return true;
|
||||
const prefix = existing.slice(0, fetched.length);
|
||||
const ids = new Set(prefix.map((e) => e.id));
|
||||
if (fetched.every((e) => ids.has(e.id))) return true;
|
||||
if (prefix.length !== fetched.length) return false;
|
||||
const signatures = new Set(prefix.map(episodeSignature));
|
||||
return fetched.every((e) => signatures.has(episodeSignature(e)));
|
||||
}
|
||||
|
||||
function createFeedStore() {
|
||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||
...DEFAULT_SOURCES,
|
||||
]);
|
||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
||||
visibility: "all",
|
||||
sortBy: "updated" as FeedSortField,
|
||||
sortDirection: "desc",
|
||||
});
|
||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
||||
readFeeds = () => feeds();
|
||||
const registry = createSourceRegistry(DEFAULT_SOURCES);
|
||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||
/** Feed-page fetch-more presses in COUNT mode: the global list is capped
|
||||
@@ -270,93 +192,26 @@ function createFeedStore() {
|
||||
* dump deep history (see getAllEpisodesChronological). */
|
||||
const [countFetchMorePresses, setCountFetchMorePresses] = createSignal(0);
|
||||
|
||||
// ── Debounced persistence ───────────────────────────────────────────────
|
||||
/** Trailing-edge debounce window for config.json writes. */
|
||||
const SAVE_DEBOUNCE_MS = 250;
|
||||
/** True when a save is scheduled but has not flushed yet. */
|
||||
let savePending = false;
|
||||
let pendingSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Schedule a config.json write (trailing edge) — rapid state changes
|
||||
* (a refresh batch landing feed-by-feed, pin toggles, load-more pages)
|
||||
* collapse into one final write instead of one file rewrite per step. */
|
||||
const scheduleSaveFeeds = (): void => {
|
||||
savePending = true;
|
||||
if (pendingSaveTimer) clearTimeout(pendingSaveTimer);
|
||||
pendingSaveTimer = setTimeout(() => {
|
||||
pendingSaveTimer = null;
|
||||
flushPendingSave();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
persistScheduler.schedule("feeds");
|
||||
};
|
||||
|
||||
/** Persist immediately when anything is dirty; exported for tests and
|
||||
* quit hooks. Cancels a pending debounced save — the state it would
|
||||
* have written is already reflected in feeds(), so writing now is
|
||||
* strictly more current. */
|
||||
const flushPendingSave = (): void => {
|
||||
if (pendingSaveTimer) {
|
||||
clearTimeout(pendingSaveTimer);
|
||||
pendingSaveTimer = null;
|
||||
}
|
||||
if (!savePending) return;
|
||||
savePending = false;
|
||||
saveFeeds(feeds());
|
||||
persistScheduler.flush("feeds");
|
||||
};
|
||||
|
||||
const getFilteredFeeds = (): Feed[] => {
|
||||
let result = [...feeds()];
|
||||
const f = filter();
|
||||
|
||||
if (f.visibility && f.visibility !== "all") {
|
||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||
}
|
||||
|
||||
if (f.sourceId) {
|
||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
||||
}
|
||||
|
||||
if (f.pinnedOnly) {
|
||||
result = result.filter((feed) => feed.isPinned);
|
||||
}
|
||||
|
||||
if (f.searchQuery) {
|
||||
const query = f.searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(feed) =>
|
||||
feed.podcast.title.toLowerCase().includes(query) ||
|
||||
feed.customName?.toLowerCase().includes(query) ||
|
||||
feed.podcast.description?.toLowerCase().includes(query),
|
||||
// The filter signal is write-only (no caller mutates it), so every
|
||||
// caller observes the defaults: "all" visibility and the stable
|
||||
// "updated desc" sort with pinned feeds first.
|
||||
const result = [...feeds()];
|
||||
result.sort(
|
||||
(a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
||||
result.sort((a, b) => {
|
||||
switch (f.sortBy) {
|
||||
case "title":
|
||||
return (
|
||||
sortDir *
|
||||
(a.customName || a.podcast.title).localeCompare(
|
||||
b.customName || b.podcast.title,
|
||||
)
|
||||
);
|
||||
case "episodeCount":
|
||||
return sortDir * (a.episodes.length - b.episodes.length);
|
||||
case "latestEpisode":
|
||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
|
||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
|
||||
return sortDir * (aLatest - bLatest);
|
||||
case "updated":
|
||||
default:
|
||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
result.sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -415,17 +270,8 @@ function createFeedStore() {
|
||||
feedId?: string,
|
||||
): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => {
|
||||
try {
|
||||
const response = await fetch(feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
// Hung feeds must not stall a refresh batch (or the
|
||||
// background refresh loop) indefinitely.
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) return { episodes: null, coverUrl: undefined };
|
||||
const xml = await response.text();
|
||||
const xml = await fetchFeedXml(feedUrl);
|
||||
if (xml === null) return { episodes: null, coverUrl: undefined };
|
||||
// Yield after the network read so the renderer gets a turn
|
||||
// before the sync regex + parse work begins.
|
||||
await yieldToUI();
|
||||
@@ -702,8 +548,8 @@ function createFeedStore() {
|
||||
// apiKey/apiSecret (pre-keychain builds) move into the macOS
|
||||
// keychain and are stripped from config.json.
|
||||
const secured = await migratePlaintextCredentials(mergedSources);
|
||||
setSources(secured);
|
||||
if (secured !== mergedSources) saveSources(secured);
|
||||
registry.replaceAll(secured);
|
||||
if (secured !== mergedSources) saveSourcesToFile(secured);
|
||||
}
|
||||
await refreshAllFeeds();
|
||||
})();
|
||||
@@ -762,71 +608,6 @@ function createFeedStore() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
||||
);
|
||||
scheduleSaveFeeds();
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const togglePinned = (feedId: string) => {
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
||||
);
|
||||
scheduleSaveFeeds();
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||
const newSource: PodcastSource = {
|
||||
...source,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
setSources((prev) => {
|
||||
const updated = [...prev, newSource];
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
return newSource;
|
||||
};
|
||||
|
||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((source) =>
|
||||
source.id === sourceId ? { ...source, ...updates } : source,
|
||||
);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const removeSource = (sourceId: string) => {
|
||||
// Don't remove default sources
|
||||
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
|
||||
|
||||
setSources((prev) => {
|
||||
const updated = prev.filter((s) => s.id !== sourceId);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const toggleSource = (sourceId: string) => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((s) =>
|
||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
||||
);
|
||||
saveSources(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const getFeed = (feedId: string): Feed | undefined => {
|
||||
return feeds().find((f) => f.id === feedId);
|
||||
};
|
||||
@@ -841,11 +622,6 @@ function createFeedStore() {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getSelectedFeed = (): Feed | undefined => {
|
||||
const id = selectedFeedId();
|
||||
return id ? getFeed(id) : undefined;
|
||||
};
|
||||
|
||||
/** Check if a feed has more episodes available beyond what's currently
|
||||
* loaded. The full parse cache holds ALL episodes (including beyond the
|
||||
* cache bound), so fetch-more can page deeper — but in DATE mode only
|
||||
@@ -866,7 +642,7 @@ function createFeedStore() {
|
||||
loaded,
|
||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
);
|
||||
return epTs(cached[loaded]) >= cutoff;
|
||||
return episodeTs(cached[loaded]) >= cutoff;
|
||||
};
|
||||
|
||||
/** Load the next chunk of episodes for one feed from the full parse
|
||||
@@ -888,17 +664,8 @@ function createFeedStore() {
|
||||
// restart). The cache holds the FULL parse — no bound applied here.
|
||||
if (!cached) {
|
||||
try {
|
||||
const response = await fetch(feed.podcast.feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
// A hung feed must not stall the load-more path forever —
|
||||
// mirror fetchEpisodes' per-feed timeout.
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const xml = await response.text();
|
||||
const xml = await fetchFeedXml(feed.podcast.feedUrl);
|
||||
if (xml === null) return;
|
||||
cached = await parseEpisodesIncremental(xml, feed.podcast.feedUrl);
|
||||
} catch {
|
||||
// Failed/hung refetch: leave the feed's loaded episodes
|
||||
@@ -936,13 +703,7 @@ function createFeedStore() {
|
||||
currentCount,
|
||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
);
|
||||
newCount = currentCount;
|
||||
while (
|
||||
newCount < cached.length &&
|
||||
epTs(cached[newCount]) >= cutoff
|
||||
) {
|
||||
newCount++;
|
||||
}
|
||||
newCount = dateBandCount(cached, currentCount, cutoff);
|
||||
} else {
|
||||
newCount = currentCount + MAX_EPISODES_REFRESH;
|
||||
}
|
||||
@@ -1029,14 +790,7 @@ function createFeedStore() {
|
||||
currentCount,
|
||||
windowDays,
|
||||
);
|
||||
if (epTs(cached[currentCount]) < cutoff) continue;
|
||||
newCount = currentCount;
|
||||
while (
|
||||
newCount < cached.length &&
|
||||
epTs(cached[newCount]) >= cutoff
|
||||
) {
|
||||
newCount++;
|
||||
}
|
||||
newCount = dateBandCount(cached, currentCount, cutoff);
|
||||
}
|
||||
if (newCount <= currentCount) continue;
|
||||
episodeLoadCount.set(feed.id, newCount);
|
||||
@@ -1073,9 +827,7 @@ function createFeedStore() {
|
||||
return {
|
||||
// State
|
||||
feeds,
|
||||
sources,
|
||||
filter,
|
||||
selectedFeedId,
|
||||
sources: registry.sources,
|
||||
isLoadingMore,
|
||||
|
||||
/** Resolves once persisted feeds are loaded from disk (before the
|
||||
@@ -1087,40 +839,36 @@ function createFeedStore() {
|
||||
getAllEpisodesChronological,
|
||||
getFeed,
|
||||
findEpisode,
|
||||
getSelectedFeed,
|
||||
hasMoreEpisodes,
|
||||
isLoadingFeeds,
|
||||
|
||||
// Actions
|
||||
setFilter,
|
||||
setSelectedFeedId,
|
||||
/** Fetch + parse an RSS feed WITHOUT subscribing or touching any feed
|
||||
* record (Discover's episode preview). Pass no feedId to skip the
|
||||
* full-parse cache; the visible window is bounded by the user's
|
||||
* cache preference and `limit`. */
|
||||
fetchEpisodes,
|
||||
addFeed,
|
||||
hasFeedByUrl,
|
||||
removeFeed,
|
||||
removeFeedByUrl,
|
||||
updateFeed,
|
||||
togglePinned,
|
||||
refreshFeed,
|
||||
refreshAllFeeds,
|
||||
loadMoreEpisodes,
|
||||
loadMoreAllFeeds,
|
||||
hasMoreAcrossAll,
|
||||
flushPendingSave,
|
||||
addSource,
|
||||
removeSource,
|
||||
toggleSource,
|
||||
updateSource,
|
||||
addSource: registry.addSource,
|
||||
toggleSource: registry.toggleSource,
|
||||
updateSource: registry.updateSource,
|
||||
runAutoDownload: runAutoDownloadNow,
|
||||
};
|
||||
}
|
||||
|
||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
||||
|
||||
/** Re-exported: refresh-merge tests import it from the store module. */
|
||||
export { sameRefreshWindow } from "../utils/episode-windows";
|
||||
|
||||
export function useFeedStore() {
|
||||
if (!feedStoreInstance) {
|
||||
feedStoreInstance = createFeedStore();
|
||||
|
||||
144
src/stores/pane-layout.ts
Normal file
144
src/stores/pane-layout.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* pane-layout — global split positions for the yazi-style pane rows.
|
||||
*
|
||||
* Every tab renders its parent|current|preview columns through `PaneRow`
|
||||
* sharing one layout: the two borders of the CENTER (current) column are
|
||||
* draggable, and their positions are stored here as fractions of the row
|
||||
* width (so a terminal resize re-derives pixel positions proportionally).
|
||||
*
|
||||
* parent (15+) | current (30+) | preview (15+)
|
||||
* ── left ───────── right ──────── <-- draggable borders
|
||||
*
|
||||
* Defaults mirror the old fixed 2:5:3 ratio (20% / 50% / 30%). Minimum
|
||||
* pane widths are enforced in `splitPixels` whenever a border is dragged
|
||||
* or the row is re-derived. The positions persist to the app preferences
|
||||
* on `commit()` (drag end) — never per drag event, so drags don't thrash
|
||||
* config.json.
|
||||
*/
|
||||
|
||||
import type { PaneSplits } from "@/types/settings";
|
||||
import { createSignal } from "solid-js";
|
||||
import { useAppStore } from "./app";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Public surface of the shared pane-layout store. */
|
||||
export interface PaneLayoutStore {
|
||||
/** Current split positions (fractions of the row width). */
|
||||
splits(): PaneSplits;
|
||||
/** Move the left border of the current pane to column `x`. */
|
||||
setLeft(x: number, width: number): void;
|
||||
/** Move the right border of the current pane to column `x`. */
|
||||
setRight(x: number, width: number): void;
|
||||
/** Persist the current splits (called on drag end, not per drag event). */
|
||||
commit(): void;
|
||||
}
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Default split — the historical 2:5:3 ratio (parent 20 / current 50 / preview 30). */
|
||||
export const DEFAULT_PANE_SPLITS: PaneSplits = { left: 0.2, right: 0.7 };
|
||||
|
||||
/** Per-pane minimum widths (columns) enforced while a border is being
|
||||
* dragged. Static rendering maps stored fractions 1:1 to pixels — the
|
||||
* minimums never distort the user's chosen layout on narrow terminals. */
|
||||
export const MIN_PANE_WIDTH = {
|
||||
parent: 15,
|
||||
current: 30,
|
||||
preview: 15,
|
||||
} as const;
|
||||
|
||||
/** Clamp `v` into [`lo`, `hi`] (bounds may invert on degenerate widths). */
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
/** Minimum row width that can hold all three panes at their drag minimums. */
|
||||
const MIN_TOTAL_WIDTH =
|
||||
MIN_PANE_WIDTH.parent + MIN_PANE_WIDTH.current + MIN_PANE_WIDTH.preview;
|
||||
|
||||
/** Resolve stored splits into concrete pixel columns for a row `width`.
|
||||
* A pure 1:1 fraction→pixel mapping (keeping the ratio exact for every
|
||||
* terminal size); min-width enforcement lives in the drag setters only. */
|
||||
export function splitPixels(
|
||||
width: number,
|
||||
splits: PaneSplits,
|
||||
): { leftPx: number; rightPx: number } {
|
||||
if (width <= 0) return { leftPx: 0, rightPx: 0 };
|
||||
const leftPx = Math.round(width * splits.left);
|
||||
const rightPx = Math.max(leftPx + 1, Math.round(width * splits.right));
|
||||
return { leftPx, rightPx };
|
||||
}
|
||||
|
||||
export function createPaneLayoutStore(): PaneLayoutStore {
|
||||
const app = useAppStore();
|
||||
|
||||
// Seeded from persisted preferences; `saved` is always defined because
|
||||
// the app store backfills the default when the config predates it.
|
||||
const [splits, setSplits] = createSignal<PaneSplits>(
|
||||
app.state().preferences.paneSplit ?? DEFAULT_PANE_SPLITS,
|
||||
);
|
||||
|
||||
/** Store the normalized pixel positions as fractions of `width`. */
|
||||
const applyPixels = (leftPx: number, rightPx: number, width: number) => {
|
||||
if (width <= 0) return;
|
||||
setSplits({ left: leftPx / width, right: rightPx / width });
|
||||
};
|
||||
|
||||
/** Clamp a dragged border to the per-pane minimums (only on terminals
|
||||
* wide enough to hold them; smaller rows just follow the cursor). */
|
||||
const dragPixels = (
|
||||
leftPx: number,
|
||||
rightPx: number,
|
||||
width: number,
|
||||
): { leftPx: number; rightPx: number } => {
|
||||
if (width < MIN_TOTAL_WIDTH)
|
||||
return {
|
||||
leftPx: clamp(leftPx, 1, width - 2),
|
||||
rightPx: Math.max(rightPx, leftPx + 1),
|
||||
};
|
||||
const minLeft = MIN_PANE_WIDTH.parent;
|
||||
const maxLeft = width - MIN_PANE_WIDTH.current - MIN_PANE_WIDTH.preview;
|
||||
const maxRight = width - MIN_PANE_WIDTH.preview;
|
||||
const newLeft = clamp(leftPx, minLeft, maxLeft);
|
||||
// The dragged border follows the cursor; the other border is pushed
|
||||
// only as far as needed to keep the current pane at its minimum.
|
||||
return {
|
||||
leftPx: newLeft,
|
||||
rightPx: clamp(rightPx, newLeft + MIN_PANE_WIDTH.current, maxRight),
|
||||
};
|
||||
};
|
||||
|
||||
/** Move the left border to column `x` (the current pane's left edge). */
|
||||
const setLeft = (x: number, width: number) => {
|
||||
if (width <= 0) return;
|
||||
const { rightPx: curRight } = splitPixels(width, splits());
|
||||
const { leftPx, rightPx } = dragPixels(Math.round(x), curRight, width);
|
||||
applyPixels(leftPx, rightPx, width);
|
||||
};
|
||||
|
||||
/** Move the right border to column `x` (the current pane's right edge). */
|
||||
const setRight = (x: number, width: number) => {
|
||||
if (width <= 0) return;
|
||||
const { leftPx: curLeft, rightPx: curRight } = splitPixels(width, splits());
|
||||
const { leftPx, rightPx } = dragPixels(curLeft, Math.round(x), width);
|
||||
applyPixels(leftPx, rightPx, width);
|
||||
};
|
||||
|
||||
/** Persist the current splits (called on drag end, not per drag event). */
|
||||
const commit = () => {
|
||||
app.updatePreferences({ paneSplit: splits() });
|
||||
};
|
||||
|
||||
return { splits, setLeft, setRight, commit };
|
||||
}
|
||||
|
||||
// ── Singleton ───────────────────────────────────────────────────────────
|
||||
|
||||
let paneLayoutInstance: PaneLayoutStore | null = null;
|
||||
|
||||
/** Accessor for the shared pane-layout store (all tabs share one split). */
|
||||
export function usePaneLayout(): PaneLayoutStore {
|
||||
if (!paneLayoutInstance) paneLayoutInstance = createPaneLayoutStore();
|
||||
return paneLayoutInstance;
|
||||
}
|
||||
57
src/stores/persist.ts
Normal file
57
src/stores/persist.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Persistence scheduler for PodTUI
|
||||
* Per-domain trailing-edge debounced writes
|
||||
*/
|
||||
|
||||
/** Debounced writer: rapid schedules collapse into one write per domain. */
|
||||
export interface PersistScheduler {
|
||||
/** Mark a domain dirty and (re)arm its trailing-edge write timer. */
|
||||
schedule(domain: string): void;
|
||||
/** Write the domain immediately if dirty; cancels any pending timer. */
|
||||
flush(domain: string): void;
|
||||
/** Write every dirty domain immediately. */
|
||||
flushAll(): void;
|
||||
}
|
||||
|
||||
/** Timer handle as returned by setTimeout in this runtime. */
|
||||
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
/** Build a per-domain trailing-edge debouncer. `write` is invoked with the
|
||||
* domain name; callers read current state inside it, so a flush always
|
||||
* lands the latest value. Rapid schedule() calls share one timer. */
|
||||
export function createPersistScheduler(
|
||||
write: (domain: string) => void,
|
||||
debounceMs = 250,
|
||||
): PersistScheduler {
|
||||
const dirty = new Set<string>();
|
||||
const timers = new Map<string, TimerHandle>();
|
||||
|
||||
const flush = (domain: string): void => {
|
||||
const timer = timers.get(domain);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(domain);
|
||||
}
|
||||
if (!dirty.has(domain)) return;
|
||||
dirty.delete(domain);
|
||||
write(domain);
|
||||
};
|
||||
|
||||
const schedule = (domain: string): void => {
|
||||
dirty.add(domain);
|
||||
clearTimeout(timers.get(domain));
|
||||
timers.set(
|
||||
domain,
|
||||
setTimeout(() => {
|
||||
timers.delete(domain);
|
||||
flush(domain);
|
||||
}, debounceMs),
|
||||
);
|
||||
};
|
||||
|
||||
const flushAll = (): void => {
|
||||
for (const domain of [...dirty]) flush(domain);
|
||||
};
|
||||
|
||||
return { schedule, flush, flushAll };
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "../utils/app-persistence";
|
||||
import { useFeedStore } from "./feed";
|
||||
import type { SearchResult, SearchScope } from "../types/source";
|
||||
import { createPersistScheduler } from "./persist";
|
||||
|
||||
const STORAGE_SCOPE_KEY = "podtui_search_scope";
|
||||
const MAX_HISTORY = 10;
|
||||
@@ -70,6 +71,15 @@ export function createSearchStore() {
|
||||
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
||||
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
|
||||
|
||||
/** History persistence: rapid mutations collapse into one debounced
|
||||
* write; the closure reads the live signal so a flush lands the
|
||||
* latest list. */
|
||||
const persistHistory = createPersistScheduler((domain: string) => {
|
||||
if (domain === "search-history") {
|
||||
saveSearchHistoryToFile(history());
|
||||
}
|
||||
});
|
||||
|
||||
/** Load search history from file (fire-and-forget; recents appear as
|
||||
* soon as the file is read). */
|
||||
async function init(): Promise<void> {
|
||||
@@ -167,24 +177,18 @@ export function createSearchStore() {
|
||||
};
|
||||
|
||||
const addToHistory = (q: string) => {
|
||||
setHistory((prev) => {
|
||||
const updated = sanitizeHistory([q, ...prev]);
|
||||
saveSearchHistoryToFile(updated);
|
||||
return updated;
|
||||
});
|
||||
setHistory((prev) => sanitizeHistory([q, ...prev]));
|
||||
persistHistory.schedule("search-history");
|
||||
};
|
||||
|
||||
const clearHistory = () => {
|
||||
setHistory([]);
|
||||
saveSearchHistoryToFile([]);
|
||||
persistHistory.schedule("search-history");
|
||||
};
|
||||
|
||||
const removeFromHistory = (q: string) => {
|
||||
setHistory((prev) => {
|
||||
const updated = prev.filter((h) => h !== q);
|
||||
saveSearchHistoryToFile(updated);
|
||||
return updated;
|
||||
});
|
||||
setHistory((prev) => prev.filter((h) => h !== q));
|
||||
persistHistory.schedule("search-history");
|
||||
};
|
||||
|
||||
const clearResults = () => {
|
||||
|
||||
@@ -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,12 +324,27 @@ 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;
|
||||
};
|
||||
|
||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
||||
|
||||
let lastBarWriteAt = 0;
|
||||
const renderFrame = () => {
|
||||
if (!cava?.isReady || !sampleBuffer || !pcm) return;
|
||||
|
||||
@@ -282,15 +352,50 @@ 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;
|
||||
|
||||
const output = cava.execute(sampleBuffer);
|
||||
|
||||
// Write the UI signal at ~10fps, not 30: cava already smooths
|
||||
// (noise reduction + peak release), and each Solid write costs a
|
||||
// renderer diff pass. 3 of every 4 frames update only the pipeline.
|
||||
const nowMs = performance.now();
|
||||
if (nowMs - lastBarWriteAt >= 95) {
|
||||
lastBarWriteAt = nowMs;
|
||||
// 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 +530,7 @@ function createVisualizerStore(): VisualizerStore {
|
||||
// state
|
||||
barData,
|
||||
isLoading,
|
||||
isStalled,
|
||||
isRunning: () => frameTimer !== null,
|
||||
// inputs
|
||||
setFocused,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
874
src/utils/audio-engine.ts
Normal file
874
src/utils/audio-engine.ts
Normal file
@@ -0,0 +1,874 @@
|
||||
/**
|
||||
* Module-level audio engine — owns the AudioBackend lifecycle, the 150ms
|
||||
* playback poll (progress save + external pause/resume reconciliation),
|
||||
* cover-art resolution, session restore, and the event-bus playback
|
||||
* commands.
|
||||
*
|
||||
* `createAudioEngine()` is the only factory. It builds a lazily-booting
|
||||
* engine (the backend is created on the first play/load, not here) and
|
||||
* returns the SAME instance for the life of the process, so every
|
||||
* useAudio() call shares one engine. The Solid-lifecycle parts that can't
|
||||
* live at module scope — the ref-counted last-owner dispose and the
|
||||
* process-exit teardown — stay in hooks/useAudio, the thin wrapper.
|
||||
*/
|
||||
|
||||
import {
|
||||
cachedCoverPath,
|
||||
fetchCoverArt,
|
||||
} from "./cover-art";
|
||||
import {
|
||||
createAudioBackend,
|
||||
detectPlayers,
|
||||
PlayerRestartedError,
|
||||
type AudioBackend,
|
||||
type BackendName,
|
||||
type DetectedPlayer,
|
||||
} from "./audio-player";
|
||||
import {
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
position,
|
||||
setPosition,
|
||||
duration,
|
||||
setDuration,
|
||||
volume,
|
||||
setVolume,
|
||||
speed,
|
||||
setSpeed,
|
||||
backendName,
|
||||
setBackendName,
|
||||
error,
|
||||
setError,
|
||||
currentEpisode,
|
||||
setCurrentEpisode,
|
||||
availablePlayers,
|
||||
setAvailablePlayers,
|
||||
} from "./audio-signals";
|
||||
import { emit, on } from "./event-bus";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useProgressStore } from "../stores/progress";
|
||||
import { useMediaRegistry } from "./media-registry";
|
||||
import {
|
||||
loadLastPlayerFromFile,
|
||||
saveLastPlayerToFile,
|
||||
} from "./app-persistence";
|
||||
import type { Episode, Progress } from "../types/episode";
|
||||
import { feedForEpisode } from "./feed-resolve";
|
||||
import { useAudioNavStore } from "../stores/audio-nav";
|
||||
import { useDownloadStore } from "../stores/download";
|
||||
import { useFeedStore } from "../stores/feed";
|
||||
import { useSearchStore } from "../stores/search";
|
||||
import {
|
||||
nextStep,
|
||||
prevStep,
|
||||
queueForSource,
|
||||
} from "./audio-queue";
|
||||
|
||||
// Singleton state — shared by every useAudio() owner through the one engine
|
||||
let backend: AudioBackend | null = null;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
||||
|
||||
// Playback signals are declared in utils/audio-signals.ts (imported above)
|
||||
// so non-component consumers (the visualizer store) can subscribe without
|
||||
// mounting a useAudio() owner.
|
||||
|
||||
/** True once the current episode has been handed to the backend (play
|
||||
* started). `false` means the episode is only LOADED in the player (e.g.
|
||||
* restored at boot) and the first play action must start the backend
|
||||
* instead of unpausing it. */
|
||||
let startedPlayback = false;
|
||||
|
||||
/** Completion fraction at/above which an episode is NOT restored at boot. */
|
||||
const RESTORE_COMPLETION_THRESHOLD = 0.98;
|
||||
|
||||
/** The engine surface useAudio() wraps. Deliberately omits
|
||||
* availablePlayers and switchBackend — the hook re-exposes those from
|
||||
* audio-signals / this module on top of the engine. */
|
||||
export interface AudioEngine {
|
||||
// Signals (reactive getters)
|
||||
isPlaying: () => boolean;
|
||||
position: () => number;
|
||||
duration: () => number;
|
||||
volume: () => number;
|
||||
speed: () => number;
|
||||
backendName: () => BackendName;
|
||||
error: () => string | null;
|
||||
currentEpisode: () => Episode | null;
|
||||
|
||||
// Actions
|
||||
play: (episode: Episode) => Promise<void>;
|
||||
/** Load an episode into the player WITHOUT starting playback. */
|
||||
load: (episode: Episode) => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
resume: () => Promise<void>;
|
||||
togglePlayback: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
seek: (seconds: number) => Promise<void>;
|
||||
seekRelative: (delta: number) => Promise<void>;
|
||||
setVolume: (volume: number) => Promise<void>;
|
||||
setSpeed: (speed: number) => Promise<void>;
|
||||
prev: () => Promise<void>;
|
||||
next: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** True when saved progress is below the restore cutoff. Episodes with no
|
||||
* progress (never reached the persist threshold) or unknown duration count
|
||||
* as eligible — they restore from the start. */
|
||||
function isRestoreEligible(progress: Progress | undefined): boolean {
|
||||
if (!progress || progress.duration <= 0) return true;
|
||||
return progress.position / progress.duration < RESTORE_COMPLETION_THRESHOLD;
|
||||
}
|
||||
|
||||
/** Lazily create the shared backend on first use. The process-exit
|
||||
* teardown lives in useAudio (it must survive last-owner dispose), so it is
|
||||
* registered there, not here. */
|
||||
function ensureBackend(): AudioBackend {
|
||||
if (!backend) {
|
||||
const detected = detectPlayers();
|
||||
setAvailablePlayers(detected);
|
||||
backend = createAudioBackend();
|
||||
setBackendName(backend.name);
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
/** Poll ticks between paused-state checks (~1s at 150ms/tick). While the
|
||||
* UI believes playback is paused we only need to catch an external
|
||||
* resume (AirPod play tap, lock-screen/media-center play); checking every
|
||||
* tick would just hammer mpv IPC for nothing. */
|
||||
const PAUSE_WATCH_TICKS = 7;
|
||||
|
||||
/** The player process died while we believed playback was live — track
|
||||
* ended (mpv quits at EOF) or the process crashed. Persist the final
|
||||
* position and stop polling. `autoAdvance` is true only when the track
|
||||
* reached its natural end with the player still alive and no stream error
|
||||
* — the signal to keep the queue going. */
|
||||
function finalizeTrackEnd(autoAdvance: boolean): void {
|
||||
setIsPlaying(false);
|
||||
stopPolling();
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
}
|
||||
if (autoAdvance) {
|
||||
// The episode finished: play the next one from the source that
|
||||
// started it (search results / show / feed). No-op at the end of
|
||||
// the list or when the episode isn't in the source list anymore.
|
||||
void next().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal,
|
||||
* device swap, OS media keys, the Now Playing center. Bring the UI in
|
||||
* sync; the poll stays armed so an external resume is caught too. */
|
||||
function reconcileExternalPause(): void {
|
||||
setIsPlaying(false);
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
emit("player.pause", { episodeId: ep.id });
|
||||
const media = useMediaRegistry();
|
||||
media.setPlaybackState(false);
|
||||
media.setPosition(position());
|
||||
}
|
||||
}
|
||||
|
||||
/** Playback was restarted from outside PodTUI (AirPods, lock-screen or
|
||||
* media-center play, OS media keys). Bring the UI back to "playing". */
|
||||
function reconcileExternalResume(): void {
|
||||
setIsPlaying(true);
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
emit("player.play", { episodeId: ep.id });
|
||||
useMediaRegistry().setPlaybackState(true);
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
stopPolling();
|
||||
pollCount = 0;
|
||||
// Guard against overlapping ticks if a socket read ever outlives the
|
||||
// interval (getPosition opens a fresh mpv IPC connection per call).
|
||||
let pollInFlight = false;
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!backend || pollInFlight) return;
|
||||
pollInFlight = true;
|
||||
try {
|
||||
pollCount++;
|
||||
if (isPlaying()) {
|
||||
// Track ended (eof-reached observed) or process died. Check
|
||||
// BEFORE pause reconciliation: mpv keeps the file open at EOF
|
||||
// and reports pause=true there, which would otherwise be
|
||||
// mistaken for an external pause and never finalize.
|
||||
if (!backend.isPlaying()) {
|
||||
// Natural EOF (player alive, no stream error) auto-advances
|
||||
// to the next episode; a crashed/killed daemon or a failed
|
||||
// stream must not start the next episode on its own.
|
||||
finalizeTrackEnd(
|
||||
backend.isAlive() && !backend.getPlaybackError(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// mpv can pause itself outside PodTUI. Reconcile instead of
|
||||
// staying stuck on "playing" with a frozen waveform
|
||||
// (getPosition would just re-read the same frozen time-pos).
|
||||
const paused = await backend.getPauseState();
|
||||
if (paused === true) {
|
||||
reconcileExternalPause();
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = await backend.getPosition();
|
||||
const dur = await backend.getDuration();
|
||||
setPosition(pos);
|
||||
if (dur > 0) setDuration(dur);
|
||||
|
||||
// Save progress every ~5 seconds (33 ticks * 150ms)
|
||||
if (pollCount % 33 === 0) {
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||
|
||||
const media = useMediaRegistry();
|
||||
media.setPosition(pos);
|
||||
}
|
||||
}
|
||||
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
|
||||
// Paused — watch for playback restarted from outside (AirPods,
|
||||
// lock-screen/media-center play). Only while the player is
|
||||
// still alive: a dead player while we thought we were paused
|
||||
// means the track ended (mpv quits at EOF) or it crashed.
|
||||
if (!backend.isAlive()) {
|
||||
finalizeTrackEnd(false);
|
||||
return;
|
||||
}
|
||||
const paused = await backend.getPauseState();
|
||||
if (paused === false) {
|
||||
reconcileExternalResume();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Backend may have been disposed
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cover art for system Now Playing ─────────────────────────────────────────
|
||||
// macOS shows the media session's albumart in the audio center; mpv reads it
|
||||
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
|
||||
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
|
||||
|
||||
/** Resolve cover art to a local path for mpv's --cover-art-files, per the
|
||||
* call site's latency budget:
|
||||
* "cache" — disk cache only (sync): resume paths must never wait on the
|
||||
* network, so a miss plays artless and warms for next time.
|
||||
* "bounded" — disk hit, else fetch capped at 1.2s: cold play needs the art
|
||||
* at file LOAD, but a slow cover server must not stall audio.
|
||||
* "await" — disk hit, else full (8s-bounded) fetch: boot restore preloads
|
||||
* while feeds/progress load anyway, so the wait is free and the
|
||||
* cover must be present when the file loads.
|
||||
* fetchCoverArt already short-circuits on the disk cache, so "await" costs
|
||||
* nothing on a warm cache. */
|
||||
async function resolveCoverArt(
|
||||
coverUrl: string | undefined,
|
||||
mode: "cache" | "bounded" | "await",
|
||||
): Promise<string | null> {
|
||||
if (!coverUrl) return null;
|
||||
if (mode === "cache") return cachedCoverPath(coverUrl);
|
||||
if (mode === "bounded") {
|
||||
const cached = cachedCoverPath(coverUrl);
|
||||
if (cached) return cached;
|
||||
return Promise.race([
|
||||
fetchCoverArt(coverUrl),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
|
||||
]);
|
||||
}
|
||||
return fetchCoverArt(coverUrl);
|
||||
}
|
||||
|
||||
async function play(episode: Episode): Promise<void> {
|
||||
const b = ensureBackend();
|
||||
setError(null);
|
||||
|
||||
if (!episode.audioUrl) {
|
||||
setError("No audio URL for this episode");
|
||||
return;
|
||||
}
|
||||
|
||||
const appStore = useAppStore();
|
||||
const progressStore = useProgressStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
const vol = volume();
|
||||
const spd = storeSpeed || speed();
|
||||
|
||||
const feed = feedForEpisode(useFeedStore().feeds(), episode);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
// Play the downloaded file when present (offline + no network stalls);
|
||||
// otherwise stream. Cover resolves to the feed art, falling back to the
|
||||
// episode's own image (feeds added by URL may lack a channel cover).
|
||||
const downloadStore = useDownloadStore();
|
||||
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||
|
||||
// Resume from saved progress if available and not completed
|
||||
const savedProgress = progressStore.get(episode.id);
|
||||
let startPos = 0;
|
||||
if (savedProgress && !progressStore.isCompleted(episode.id)) {
|
||||
startPos = savedProgress.position;
|
||||
}
|
||||
|
||||
// Present the new episode in the UI IMMEDIATELY, before the backend load
|
||||
// (cover fetch + loadfile can take a few hundred ms): the player tab,
|
||||
// status bar, and OS Now Playing must not keep showing the previous
|
||||
// episode during the swap. The previous track's poll is stopped so it
|
||||
// can't attribute its position/progress to the new episode; polling
|
||||
// restarts once the backend is actually playing. Mirrors load()'s
|
||||
// synchronous presentation.
|
||||
stopPolling();
|
||||
setCurrentEpisode(episode);
|
||||
setIsPlaying(false);
|
||||
startedPlayback = false;
|
||||
setPosition(startPos);
|
||||
setSpeed(spd);
|
||||
if (episode.duration) setDuration(episode.duration);
|
||||
const media = useMediaRegistry();
|
||||
media.setNowPlaying({
|
||||
title: episode.title,
|
||||
artist: podcastTitle || episode.podcastId,
|
||||
duration: episode.duration,
|
||||
});
|
||||
media.setPlaybackState(false);
|
||||
if (startPos > 0) media.setPosition(startPos);
|
||||
|
||||
try {
|
||||
// Cover art only applies at file LOAD (the runtime video-add fallback
|
||||
// never becomes an albumart track), so a cold-cache play must wait for
|
||||
// the fetch or play artless. Serve the disk cache synchronously; on a
|
||||
// miss, await the bounded fetch (covers fetch in ~300ms typically) —
|
||||
// past the 1.2s cap, play bare and let the fetch warm the cache.
|
||||
const coverArtPath = await resolveCoverArt(
|
||||
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||
"bounded",
|
||||
);
|
||||
|
||||
await b.play(url, {
|
||||
volume: vol,
|
||||
speed: spd,
|
||||
startPosition: startPos > 0 ? startPos : undefined,
|
||||
mediaTitle: episode.title,
|
||||
coverArtPath: coverArtPath ?? undefined,
|
||||
});
|
||||
|
||||
setIsPlaying(true);
|
||||
setPosition(startPos);
|
||||
if (episode.duration) setDuration(episode.duration);
|
||||
startedPlayback = true;
|
||||
|
||||
// Remember this episode as "loaded in the player" so the next launch
|
||||
// can restore it paused (cleared by stop()).
|
||||
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||
|
||||
// Register with platform media controls
|
||||
media.setPlaybackState(true);
|
||||
if (startPos > 0) media.setPosition(startPos);
|
||||
|
||||
startPolling();
|
||||
emit("player.play", { episodeId: episode.id });
|
||||
// Distinct from "player.play" (which also fires on resume): signals a
|
||||
// fresh episode start so Shell can honor the auto-jump-to-player pref.
|
||||
emit("player.started", { episodeId: episode.id });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Playback failed");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an episode into the player WITHOUT starting playback. The player tab
|
||||
* renders it paused at its saved position; the first play action starts the
|
||||
* backend from there (see togglePlayback). Used to restore the last player
|
||||
* session at boot.
|
||||
*/
|
||||
async function load(episode: Episode): Promise<void> {
|
||||
ensureBackend();
|
||||
setError(null);
|
||||
|
||||
setCurrentEpisode(episode);
|
||||
setIsPlaying(false);
|
||||
startedPlayback = false;
|
||||
|
||||
// Show the saved position so the player tab reflects where playback
|
||||
// will resume; episodes at/above the completion threshold start from 0.
|
||||
const progressStore = useProgressStore();
|
||||
const saved = progressStore.get(episode.id);
|
||||
const pos = saved && isRestoreEligible(saved) ? saved.position : 0;
|
||||
setPosition(pos);
|
||||
if (episode.duration) setDuration(episode.duration);
|
||||
|
||||
const appStore = useAppStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
setSpeed(storeSpeed || speed());
|
||||
|
||||
// Surface the loaded-but-paused track to the OS media controls.
|
||||
const feed = feedForEpisode(useFeedStore().feeds(), episode);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
const media = useMediaRegistry();
|
||||
media.setNowPlaying({
|
||||
title: episode.title,
|
||||
artist: podcastTitle || episode.podcastId,
|
||||
duration: episode.duration,
|
||||
});
|
||||
media.setPlaybackState(false);
|
||||
if (pos > 0) media.setPosition(pos);
|
||||
|
||||
// Preload the episode into the backend PAUSED: mpv opens the stream and
|
||||
// fills its demuxer cache while parked, so the user's first Play flips
|
||||
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
|
||||
// — a failed preload just makes the first play take the cold path.
|
||||
const downloadStore = useDownloadStore();
|
||||
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||
if (episode.audioUrl && backend) {
|
||||
// The preload must carry the cover AT LOAD: cover-art-files only
|
||||
// applies when the file loads, and the runtime video-add fallback
|
||||
// never becomes an albumart track (verified). Restore already waits
|
||||
// on feeds/progress at boot, so the bounded fetch (~300ms typical,
|
||||
// 8s worst case) is free. Falls back to the episode's own image when
|
||||
// the feed has no channel cover.
|
||||
const coverArtPath = await resolveCoverArt(
|
||||
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||
"await",
|
||||
);
|
||||
const backendSnap = backend;
|
||||
backendSnap
|
||||
.preload(url, {
|
||||
volume: volume(),
|
||||
speed: storeSpeed || speed(),
|
||||
startPosition: pos > 0 ? pos : undefined,
|
||||
mediaTitle: episode.title,
|
||||
coverArtPath: coverArtPath ?? undefined,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||
}
|
||||
|
||||
async function pause(): Promise<void> {
|
||||
if (!backend) return;
|
||||
try {
|
||||
await backend.pause();
|
||||
setIsPlaying(false);
|
||||
// Polling stays armed (paused-watch mode): playback can be resumed
|
||||
// from OUTSIDE PodTUI — AirPods, lock-screen/media-center play —
|
||||
// and the poll must be live to catch it.
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
// Save progress on pause
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
emit("player.pause", { episodeId: ep.id });
|
||||
|
||||
// Update platform media controls
|
||||
const media = useMediaRegistry();
|
||||
media.setPlaybackState(false);
|
||||
media.setPosition(position());
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Pause failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** mpv was killed/crashed: respawn it and restart playback from the saved
|
||||
* position via the full play path (fresh loadfile, cover art, media
|
||||
* registry). A bare unpause would target a dead — or freshly-idle —
|
||||
* daemon and silently do nothing. */
|
||||
async function recoverPlayback(): Promise<void> {
|
||||
const ep = currentEpisode();
|
||||
if (ep && ep.audioUrl) {
|
||||
await play(ep);
|
||||
} else {
|
||||
setError("Player is not running");
|
||||
}
|
||||
}
|
||||
|
||||
async function resume(): Promise<void> {
|
||||
if (!backend) return;
|
||||
if (!backend.isAlive()) {
|
||||
await recoverPlayback();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await backend.resume();
|
||||
setIsPlaying(true);
|
||||
startPolling();
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
emit("player.play", { episodeId: ep.id });
|
||||
const media = useMediaRegistry();
|
||||
media.setPlaybackState(true);
|
||||
}
|
||||
} catch (err) {
|
||||
// Race: the daemon died between the liveness check above and the
|
||||
// unpause — backend.resume() respawned it and threw
|
||||
// PlayerRestartedError (the fresh daemon has no file loaded).
|
||||
if (err instanceof PlayerRestartedError) {
|
||||
await recoverPlayback();
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Resume failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlayback(): Promise<void> {
|
||||
if (isPlaying()) {
|
||||
await pause();
|
||||
} else if (currentEpisode()) {
|
||||
if (startedPlayback) {
|
||||
await resume();
|
||||
} else {
|
||||
// Episode is only LOADED (e.g. restored at boot) — the backend
|
||||
// was never started, so unpausing a dead player would fail
|
||||
// silently. Start playback from the saved position instead.
|
||||
const ep = currentEpisode();
|
||||
if (ep) await play(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
if (!backend) return;
|
||||
try {
|
||||
// Save progress before stopping
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
}
|
||||
await backend.stop();
|
||||
setIsPlaying(false);
|
||||
setPosition(0);
|
||||
setCurrentEpisode(null);
|
||||
startedPlayback = false;
|
||||
stopPolling();
|
||||
emit("player.stop", {});
|
||||
|
||||
// Player is empty again — nothing to restore on the next launch.
|
||||
saveLastPlayerToFile({ episodeId: null, timestamp: null });
|
||||
|
||||
const media = useMediaRegistry();
|
||||
media.clearNowPlaying();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Stop failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function seek(seconds: number): Promise<void> {
|
||||
if (!backend) return;
|
||||
const clamped = Math.max(0, Math.min(seconds, duration()));
|
||||
try {
|
||||
await backend.seek(clamped);
|
||||
setPosition(clamped);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Seek failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function seekRelative(delta: number): Promise<void> {
|
||||
await seek(position() + delta);
|
||||
}
|
||||
|
||||
async function doSetVolume(vol: number): Promise<void> {
|
||||
const clamped = Math.max(0, Math.min(1, vol));
|
||||
if (backend) {
|
||||
try {
|
||||
await backend.setVolume(clamped);
|
||||
} catch {
|
||||
// Some backends can't change volume at runtime
|
||||
}
|
||||
}
|
||||
setVolume(clamped);
|
||||
|
||||
// Sync back to app store (persisted to config.json for the next launch).
|
||||
const appStore = useAppStore();
|
||||
appStore.updateSettings({ volume: clamped });
|
||||
}
|
||||
|
||||
async function doSetSpeed(spd: number): Promise<void> {
|
||||
const clamped = Math.max(0.25, Math.min(3, spd));
|
||||
if (backend) {
|
||||
try {
|
||||
await backend.setSpeed(clamped);
|
||||
} catch {
|
||||
// Some backends can't change speed at runtime
|
||||
}
|
||||
}
|
||||
setSpeed(clamped);
|
||||
|
||||
// Sync back to app store
|
||||
const appStore = useAppStore();
|
||||
appStore.updateSettings({ playbackSpeed: clamped });
|
||||
}
|
||||
|
||||
/** Switch the active player backend (mpv / afplay / ...). Off the
|
||||
* AudioEngine interface by contract, but kept here (module-scoped) so the
|
||||
* engine owns backend teardown/creation; useAudio re-exposes it. */
|
||||
export async function switchBackend(name: BackendName): Promise<void> {
|
||||
const wasPlaying = isPlaying();
|
||||
const ep = currentEpisode();
|
||||
const pos = position();
|
||||
const vol = volume();
|
||||
const spd = speed();
|
||||
|
||||
if (backend) {
|
||||
stopPolling();
|
||||
backend.dispose();
|
||||
backend = null;
|
||||
}
|
||||
|
||||
backend = createAudioBackend(name);
|
||||
setBackendName(backend.name);
|
||||
setAvailablePlayers(detectPlayers());
|
||||
|
||||
// Resume playback if we were playing
|
||||
if (wasPlaying && ep && ep.audioUrl) {
|
||||
try {
|
||||
const feed = feedForEpisode(useFeedStore().feeds(), ep);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
const url =
|
||||
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
|
||||
const coverArtPath = await resolveCoverArt(
|
||||
feed?.podcast.coverUrl ?? ep.imageUrl,
|
||||
"cache",
|
||||
);
|
||||
await backend.play(url, {
|
||||
startPosition: pos,
|
||||
volume: vol,
|
||||
speed: spd,
|
||||
mediaTitle: ep.title,
|
||||
coverArtPath: coverArtPath ?? undefined,
|
||||
});
|
||||
setIsPlaying(true);
|
||||
startedPlayback = true;
|
||||
startPolling();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Backend switch failed");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialized restore chain: the boot-triggered restore and any explicit
|
||||
* call run one after another, so a late-finishing earlier restore can never
|
||||
* overwrite state changed by a later one (and callers can await the latest
|
||||
* attempt deterministically). */
|
||||
let restoreChain: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Boot-time session restore: reload the episode that was loaded in the
|
||||
* player when the previous run ended (persisted on play/load and at exit),
|
||||
* paused at its saved position — never autostarted. Episodes at/above the
|
||||
* completion threshold are skipped. Silently no-ops when there is nothing
|
||||
* to restore (empty player, unsubscribed show, or completed episode).
|
||||
*/
|
||||
export async function restoreLastSession(): Promise<void> {
|
||||
const attempt = restoreChain.then(async () => {
|
||||
const marker = await loadLastPlayerFromFile();
|
||||
if (!marker?.episodeId) return;
|
||||
|
||||
// Feeds and progress load asynchronously at boot; wait for both
|
||||
// before looking the episode up.
|
||||
await Promise.all([
|
||||
useProgressStore().whenReady(),
|
||||
useFeedStore().whenReady(),
|
||||
]);
|
||||
|
||||
const episode = useFeedStore().findEpisode(marker.episodeId);
|
||||
if (!episode) return;
|
||||
|
||||
// Only restore episodes below the completion threshold.
|
||||
const saved = useProgressStore().get(episode.id);
|
||||
if (!isRestoreEligible(saved)) return;
|
||||
|
||||
await load(episode);
|
||||
});
|
||||
// Keep the chain alive even when an attempt fails; the caller awaiting
|
||||
// this attempt still observes its own outcome.
|
||||
restoreChain = attempt.catch(() => {});
|
||||
await attempt;
|
||||
}
|
||||
|
||||
// ── Episode queue navigation ──────────────────────────────────────────────
|
||||
// `next`/`prev` (and the end-of-episode auto-advance in finalizeTrackEnd)
|
||||
// move within the ordered list of the source that STARTED the current
|
||||
// episode: the Feed's chronological list, the current show's episodes, or
|
||||
// the search results (see utils/audio-queue). Module-level so
|
||||
// finalizeTrackEnd can auto-advance without a mounted hook owner.
|
||||
|
||||
const audioNav = useAudioNavStore();
|
||||
|
||||
/** The ordered playable episodes for the source that started playback. */
|
||||
function queueForCurrentSource(): Episode[] {
|
||||
const feedStore = useFeedStore();
|
||||
return queueForSource(
|
||||
audioNav.getSource(),
|
||||
audioNav.getPodcastId(),
|
||||
feedStore.feeds(),
|
||||
feedStore.getAllEpisodesChronological(),
|
||||
useSearchStore().results(),
|
||||
);
|
||||
}
|
||||
|
||||
async function next(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
const step = nextStep(queueForCurrentSource(), current.id);
|
||||
// A duplicated queue entry (same episode id twice) must not make
|
||||
// "next" replay the CURRENT episode — that would reload it from
|
||||
// saved progress and audibly repeat already-played audio.
|
||||
if (!step || step.episode.id === current.id) return;
|
||||
await play(step.episode);
|
||||
audioNav.next(step.index);
|
||||
}
|
||||
|
||||
async function prev(): Promise<void> {
|
||||
const current = currentEpisode();
|
||||
if (!current) return;
|
||||
|
||||
// Standard transport behavior: past 30s in, "prev" restarts the current
|
||||
// episode; before that it steps back within the source queue.
|
||||
const NAV_START_THRESHOLD = 30;
|
||||
const currentPos = position();
|
||||
const currentDur = duration();
|
||||
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
|
||||
await seek(NAV_START_THRESHOLD);
|
||||
return;
|
||||
}
|
||||
|
||||
const step = prevStep(queueForCurrentSource(), current.id);
|
||||
if (!step) return;
|
||||
await play(step.episode);
|
||||
audioNav.prev(step.index);
|
||||
}
|
||||
|
||||
// ── Event bus commands ────────────────────────────────────────────────────
|
||||
// Registered once per process (in createAudioEngine), not per hook owner.
|
||||
// Every handler is no-op-safe when the backend is absent (e.g. after the
|
||||
// last owner disposed it).
|
||||
|
||||
let eventListenersRegistered = false;
|
||||
function registerEventListeners(): void {
|
||||
if (eventListenersRegistered) return;
|
||||
eventListenersRegistered = true;
|
||||
|
||||
on("player.play", async (data) => {
|
||||
// External play requests — currently just tracks episodeId.
|
||||
// Episode lookup would require feed store integration.
|
||||
});
|
||||
|
||||
on("player.stop", async () => {
|
||||
if (backend && isPlaying()) {
|
||||
await backend.stop();
|
||||
setIsPlaying(false);
|
||||
setPosition(0);
|
||||
setCurrentEpisode(null);
|
||||
stopPolling();
|
||||
}
|
||||
});
|
||||
|
||||
// Global multimedia key events (from useMultimediaKeys)
|
||||
on("media.toggle", async () => {
|
||||
await togglePlayback();
|
||||
});
|
||||
|
||||
on("media.volumeUp", async () => {
|
||||
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
|
||||
});
|
||||
|
||||
on("media.volumeDown", async () => {
|
||||
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
|
||||
});
|
||||
|
||||
on("media.speedCycle", async () => {
|
||||
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
|
||||
await doSetSpeed(next);
|
||||
});
|
||||
}
|
||||
|
||||
/** Lazily create the shared backend on first use. Called from useAudio's
|
||||
* boot path (the old hook created it eagerly; tests and the mpv IPC test
|
||||
* rely on the backend existing before the first play). */
|
||||
export function ensureEngineBackend(): AudioBackend {
|
||||
return ensureBackend();
|
||||
}
|
||||
|
||||
/** Full engine teardown for when the last hook owner unmounts: stop the
|
||||
* poll, dispose the backend, and clear the OS media session. (The
|
||||
* process-exit teardown in useAudio does the same minus the media clear,
|
||||
* since the process is ending.) */
|
||||
export function disposeEngineBackend(): void {
|
||||
stopPolling();
|
||||
if (backend) {
|
||||
backend.dispose();
|
||||
backend = null;
|
||||
}
|
||||
// Clear media registry on full teardown
|
||||
useMediaRegistry().clearNowPlaying();
|
||||
}
|
||||
|
||||
/** Stop the poll — one-line wrapper so the process-exit teardown in
|
||||
* useAudio doesn't reach into engine internals. */
|
||||
export function stopEnginePolling(): void {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
/** The current backend (or null), for useAudio's exit-time dispose. */
|
||||
export function getEngineBackend(): AudioBackend | null {
|
||||
return backend;
|
||||
}
|
||||
|
||||
let engineInstance: AudioEngine | null = null;
|
||||
|
||||
/** Build (once) and return the process-wide audio engine. Side-effect-free:
|
||||
* the backend is created lazily on the first play/load, and the event-bus
|
||||
* listeners are registered here. */
|
||||
export function createAudioEngine(): AudioEngine {
|
||||
if (engineInstance) return engineInstance;
|
||||
registerEventListeners();
|
||||
engineInstance = {
|
||||
isPlaying,
|
||||
position,
|
||||
duration,
|
||||
volume,
|
||||
speed,
|
||||
backendName,
|
||||
error,
|
||||
currentEpisode,
|
||||
|
||||
play,
|
||||
load,
|
||||
pause,
|
||||
resume,
|
||||
togglePlayback,
|
||||
stop,
|
||||
seek,
|
||||
seekRelative,
|
||||
setVolume: doSetVolume,
|
||||
setSpeed: doSetSpeed,
|
||||
prev,
|
||||
next,
|
||||
};
|
||||
return engineInstance;
|
||||
}
|
||||
@@ -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
|
||||
@@ -568,8 +579,12 @@ export class MpvBackend implements AudioBackend {
|
||||
if (pausedSeek) {
|
||||
// time-pos sent before file-loaded is silently dropped by mpv
|
||||
// (no file yet) — the preload then parked at 0 and the restore
|
||||
// position was lost. Wait for the open, then seek.
|
||||
await fileLoaded;
|
||||
// position was lost. Wait for the open, then seek. A dead URL
|
||||
// never fires file-loaded at all (mpv keeps retrying the
|
||||
// open), so end-file (the open-failure notification) races it
|
||||
// and the wait folds to "not loaded" instead of stalling the
|
||||
// load mutex for the full 5s timeout.
|
||||
await Promise.race([fileLoaded, this.conn?.waitEvent("end-file", 5000)]);
|
||||
await this.send(["set_property", "time-pos", pausedSeek]);
|
||||
this._position = pausedSeek;
|
||||
}
|
||||
@@ -591,13 +606,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 +806,9 @@ class NoopBackend implements AudioBackend {
|
||||
isAlive(): boolean {
|
||||
return false;
|
||||
}
|
||||
getPlaybackError(): string | null {
|
||||
return null;
|
||||
}
|
||||
dispose(): void {}
|
||||
}
|
||||
|
||||
|
||||
88
src/utils/audio-queue.ts
Normal file
88
src/utils/audio-queue.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* audio-queue — ordered episode queue for "what plays next" navigation.
|
||||
*
|
||||
* Pure selection logic for source-based auto-advance (and manual next/prev):
|
||||
* given the navigation source that STARTED the current episode, which
|
||||
* episodes come after it?
|
||||
*
|
||||
* FEED — the global chronological Feed list (newest first), so "next"
|
||||
* walks toward older episodes — further down the list.
|
||||
* MY_SHOWS — the current show's episode list (newest first), scoped to the
|
||||
* podcast that started playback.
|
||||
* SEARCH — the current search results, in display order (episode-kind
|
||||
* results only — a show result has nothing to play).
|
||||
*
|
||||
* Kept dependency-light (pure functions over plain data) so the ordering and
|
||||
* bounds contract is unit-testable without stores or audio.
|
||||
*/
|
||||
|
||||
import type { Episode } from "../types/episode";
|
||||
import type { Feed } from "../types/feed";
|
||||
import type { SearchResult } from "../types/source";
|
||||
import { AudioSource } from "../stores/audio-nav";
|
||||
|
||||
/** The ordered playable queue for a navigation source. Empty when the
|
||||
* source's context is missing (no podcastId, no search results, no feeds). */
|
||||
export function queueForSource(
|
||||
source: AudioSource,
|
||||
podcastId: string | undefined,
|
||||
feeds: Feed[],
|
||||
allEpisodes: Array<{ episode: Episode; feed: Feed }>,
|
||||
searchResults: SearchResult[],
|
||||
): Episode[] {
|
||||
if (source === AudioSource.FEED) {
|
||||
// Dedupe by episode id: the same episode can appear twice after a
|
||||
// refresh merge or when two feeds list it — a duplicate would make
|
||||
// next/auto-advance step onto the CURRENT episode and replay it.
|
||||
const seen = new Set<string>();
|
||||
const unique: Episode[] = [];
|
||||
for (const e of allEpisodes) {
|
||||
if (seen.has(e.episode.id)) continue;
|
||||
seen.add(e.episode.id);
|
||||
unique.push(e.episode);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
if (source === AudioSource.MY_SHOWS) {
|
||||
const feed = feeds.find((f) => f.podcast.id === podcastId);
|
||||
return feed ? feed.episodes : [];
|
||||
}
|
||||
if (source === AudioSource.SEARCH) {
|
||||
return searchResults
|
||||
.filter((r) => r.kind === "episode")
|
||||
.map((r) => r.episode);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Index of an episode in the queue, or -1 when the episode isn't in it. */
|
||||
export function queueIndex(queue: Episode[], episodeId: string): number {
|
||||
return queue.findIndex((e) => e.id === episodeId);
|
||||
}
|
||||
|
||||
export interface QueueStep {
|
||||
episode: Episode;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/** The episode after `episodeId` in the queue, with its index. Null when
|
||||
* the episode isn't in the queue or is already the last one. */
|
||||
export function nextStep(
|
||||
queue: Episode[],
|
||||
episodeId: string,
|
||||
): QueueStep | null {
|
||||
const idx = queueIndex(queue, episodeId);
|
||||
if (idx < 0 || idx + 1 >= queue.length) return null;
|
||||
return { episode: queue[idx + 1], index: idx + 1 };
|
||||
}
|
||||
|
||||
/** The episode before `episodeId` in the queue, with its index. Null when
|
||||
* the episode isn't in the queue or is already the first one. */
|
||||
export function prevStep(
|
||||
queue: Episode[],
|
||||
episodeId: string,
|
||||
): QueueStep | null {
|
||||
const idx = queueIndex(queue, episodeId);
|
||||
if (idx <= 0) return null;
|
||||
return { episode: queue[idx - 1], index: idx - 1 };
|
||||
}
|
||||
@@ -95,6 +95,8 @@ export class CavaCore {
|
||||
private _bars = 0;
|
||||
private _channels = 1;
|
||||
private _destroyed = false;
|
||||
/** Serialized last init config — identical init() calls are no-ops. */
|
||||
private lastConfigKey = "";
|
||||
|
||||
/** Use loadCavaCore() instead of constructing directly. */
|
||||
constructor(lib: CavaLib) {
|
||||
@@ -112,15 +114,25 @@ export class CavaCore {
|
||||
|
||||
/**
|
||||
* Initialize the cavacore engine with the given configuration.
|
||||
* Must be called before execute(). Can be called again after destroy()
|
||||
* to reinitialize with different parameters.
|
||||
* Must be called before execute(). Identical configs are a no-op:
|
||||
* cava_init/destroy churn leaks the old plan's FFTW work buffers
|
||||
* (upstream frees only its own struct), so a pipeline restart with
|
||||
* unchanged bars/rate/cutoffs must re-USE the live plan.
|
||||
*/
|
||||
init(config: CavaCoreConfig = {}): void {
|
||||
const cfg = { ...DEFAULTS, ...config };
|
||||
if (
|
||||
this.plan !== null &&
|
||||
!this._destroyed &&
|
||||
this.lastConfigKey === JSON.stringify(cfg)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.lastConfigKey = JSON.stringify(cfg);
|
||||
if (this.plan) {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
const cfg = { ...DEFAULTS, ...config };
|
||||
this._bars = cfg.bars;
|
||||
this._channels = cfg.channels;
|
||||
|
||||
|
||||
@@ -124,17 +124,17 @@ export async function downloadEpisode(
|
||||
}
|
||||
}
|
||||
|
||||
const reader = body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
const fileWriter = Bun.file(filePath).writer()
|
||||
let bytesDownloaded = 0
|
||||
let lastProgressTime = Date.now()
|
||||
let lastProgressBytes = 0
|
||||
|
||||
const reader = body.getReader()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
chunks.push(value)
|
||||
fileWriter.write(value)
|
||||
bytesDownloaded += value.length
|
||||
|
||||
// Report progress roughly every 250ms
|
||||
@@ -152,22 +152,14 @@ export async function downloadEpisode(
|
||||
}
|
||||
}
|
||||
|
||||
// Concatenate chunks and write to file
|
||||
const totalSize = bytesDownloaded
|
||||
const buffer = new Uint8Array(totalSize)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
buffer.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
await Bun.write(filePath, buffer)
|
||||
// Finalize the streamed file
|
||||
await fileWriter.end()
|
||||
|
||||
// Final progress report
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
bytesDownloaded: totalSize,
|
||||
totalBytes: contentLength || totalSize,
|
||||
bytesDownloaded,
|
||||
totalBytes: contentLength || bytesDownloaded,
|
||||
percent: 100,
|
||||
speed: 0,
|
||||
})
|
||||
@@ -176,7 +168,7 @@ export async function downloadEpisode(
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
fileSize: totalSize,
|
||||
fileSize: bytesDownloaded,
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
|
||||
111
src/utils/episode-windows.ts
Normal file
111
src/utils/episode-windows.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Episode cache-window math — shared by the feed store's refresh, retention,
|
||||
* and fetch-more paging paths. Pure module: no Solid, no store imports.
|
||||
*/
|
||||
|
||||
import type { Episode } from "../types/episode";
|
||||
import { episodeSignature } from "./episode-merge";
|
||||
import { episodeInWindow } from "./feeds-persistence";
|
||||
|
||||
/** Floor on the visible episode window for a subscribed show: at least this
|
||||
* many most-recent episodes always load, regardless of a stricter count or
|
||||
* date cache bound. */
|
||||
const MIN_EPISODES_PER_SHOW = 5;
|
||||
|
||||
/** Fetch-more step in date mode: each press reveals the next two weeks of
|
||||
* episodes past the oldest loaded one, instead of a fixed episode count. */
|
||||
const FETCH_MORE_WINDOW_DAYS = 14;
|
||||
|
||||
/** Timestamp for window math — undated episodes sort/compare as NEWEST
|
||||
* (Infinity) so they can never be excluded by a date cutoff. */
|
||||
export const episodeTs = (ep: Episode): number => {
|
||||
const t = ep.pubDate?.getTime();
|
||||
return t === undefined || Number.isNaN(t) ? Infinity : t;
|
||||
};
|
||||
|
||||
/** Read the episode cache bound from preferences: a closure that decides
|
||||
* whether the episode at `index` (0 = newest, after sort) is kept. The five
|
||||
* most-recent episodes of a subscribed show always stay (MIN_EPISODES_PER_SHOW),
|
||||
* overriding a stricter count or date bound so every show surfaces at least
|
||||
* five episodes. */
|
||||
export function episodeKeepFn(
|
||||
prefs: {
|
||||
episodeCacheMode: "date" | "count";
|
||||
episodeCacheCount: number;
|
||||
episodeCacheDays: number;
|
||||
},
|
||||
now?: Date,
|
||||
): (ep: Episode, index: number) => boolean {
|
||||
const at = now ?? new Date();
|
||||
if (prefs.episodeCacheMode === "count") {
|
||||
const count = Math.max(1, prefs.episodeCacheCount);
|
||||
return (_ep: Episode, index: number) =>
|
||||
index < Math.max(count, MIN_EPISODES_PER_SHOW);
|
||||
}
|
||||
const days = Math.max(1, prefs.episodeCacheDays);
|
||||
return (ep: Episode, index: number) =>
|
||||
index < MIN_EPISODES_PER_SHOW || episodeInWindow(ep, at, days);
|
||||
}
|
||||
|
||||
/** Date-mode fetch-more cutoff: the oldest loaded episode's pubDate minus the
|
||||
* 2-week band. With nothing loaded (a show whose episodes all fall outside
|
||||
* the cache window), the band anchors at the cache-window edge (now minus
|
||||
* the configured days) — a dormant show can't drag in arbitrarily old
|
||||
* episodes just because the button is pressed. */
|
||||
export const dateFetchMoreCutoff = (
|
||||
cached: Episode[],
|
||||
loaded: number,
|
||||
windowDays: number,
|
||||
): number => {
|
||||
if (loaded > 0) {
|
||||
const t = episodeTs(cached[loaded - 1]);
|
||||
if (Number.isFinite(t)) {
|
||||
return t - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000;
|
||||
}
|
||||
}
|
||||
// Nothing loaded: the band extends FETCH_MORE_WINDOW_DAYS before the
|
||||
// cache-window edge (e.g. 60d → reveals the 60–74d slice).
|
||||
return (
|
||||
Date.now() -
|
||||
Math.max(1, windowDays) * 24 * 3600 * 1000 -
|
||||
FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000
|
||||
);
|
||||
};
|
||||
|
||||
/** Episodes the date band adds past the loaded window: count forward while
|
||||
* each next cached episode still falls on/after the cutoff. The single
|
||||
* implementation behind both fetch-more paths (one feed / all feeds) — an
|
||||
* empty band adds nothing, which doubles as the "has more" guard. */
|
||||
export function dateBandCount(
|
||||
cached: Episode[],
|
||||
loaded: number,
|
||||
cutoff: number,
|
||||
): number {
|
||||
let count = loaded;
|
||||
while (count < cached.length && episodeTs(cached[count]) >= cutoff) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** True when the freshly fetched window matches the corresponding PREFIX of
|
||||
* the existing episode list (id-set equality, order-insensitive). With
|
||||
* union semantics the merged list legitimately contains episodes BEYOND the
|
||||
* fetched window, so unchanged-detection must compare the fetched window
|
||||
* against the existing list's prefix — comparing full lists would bump
|
||||
* `lastUpdated` on every refresh. When ids drifted between refreshes (the
|
||||
* one-time positional-id migration, or a feed that rotates enclosure URLs)
|
||||
* the id sets differ for the SAME content, so a content-signature
|
||||
* comparison decides: an unchanged feed stays unchanged. */
|
||||
export function sameRefreshWindow(
|
||||
existing: Episode[],
|
||||
fetched: Episode[],
|
||||
): boolean {
|
||||
if (fetched.length === 0) return true;
|
||||
const prefix = existing.slice(0, fetched.length);
|
||||
const ids = new Set(prefix.map((e) => e.id));
|
||||
if (fetched.every((e) => ids.has(e.id))) return true;
|
||||
if (prefix.length !== fetched.length) return false;
|
||||
const signatures = new Set(prefix.map(episodeSignature));
|
||||
return fetched.every((e) => signatures.has(episodeSignature(e)));
|
||||
}
|
||||
22
src/utils/feed-resolve.ts
Normal file
22
src/utils/feed-resolve.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,18 @@ function reviveDates(feed: Feed): Feed {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Config-legacy baggage: search-time parseRSSFeed once embedded the full
|
||||
* episode history inside podcast.episodes (2,100+ stale copies, 3.8 MB of
|
||||
* config). Nothing reads them — feed.episodes is the source of truth — so
|
||||
* every load/save drops them. */
|
||||
function stripLegacyPodcastEpisodes(feed: Feed): Feed {
|
||||
if (!("episodes" in feed.podcast)) return feed;
|
||||
const { episodes: _legacy, ...podcast } = feed.podcast;
|
||||
void _legacy;
|
||||
return { ...feed, podcast: podcast as Feed["podcast"] };
|
||||
}
|
||||
|
||||
/** Load feeds from config.json, pruning episodes outside the retention
|
||||
* window (completed downloads always kept). When anything was pruned, the
|
||||
* pruned list is rewritten to config.json (startup cleanup for legacy
|
||||
@@ -93,7 +105,9 @@ export async function loadFeedsFromFile(
|
||||
try {
|
||||
const cfg = await loadConfig();
|
||||
if (!Array.isArray(cfg.feeds)) return [];
|
||||
const feeds = cfg.feeds.map(reviveDates);
|
||||
const feeds = cfg.feeds
|
||||
.map(reviveDates)
|
||||
.map(stripLegacyPodcastEpisodes);
|
||||
const downloadedIds = await readDownloadedEpisodeIds();
|
||||
const now = new Date();
|
||||
let prunedAny = false;
|
||||
@@ -122,7 +136,9 @@ export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
|
||||
(async () => {
|
||||
try {
|
||||
const downloadedIds = await readDownloadedEpisodeIds();
|
||||
const pruned = feeds.map((f) => ({
|
||||
const pruned = feeds
|
||||
.map(stripLegacyPodcastEpisodes)
|
||||
.map((f) => ({
|
||||
...f,
|
||||
episodes: f.episodes.filter((ep) =>
|
||||
episodeIsPersistable(ep, downloadedIds, new Date(), windowDays),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
74
src/utils/nested-scroll.ts
Normal file
74
src/utils/nested-scroll.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Nested scroll sections favor the innermost one under the cursor.
|
||||
*
|
||||
* opentui bubbles a wheel event up the renderable tree, so every ancestor
|
||||
* `ScrollBoxRenderable` that has room to move scrolls — nested sections (e.g.
|
||||
* the episode-description scrollbox inside a page's list pane) scroll in
|
||||
* lockstep. This patches the scrollbox's wheel handler so the innermost
|
||||
* scrollbox under the cursor wins instead:
|
||||
*
|
||||
* • The first scrollbox that can move in the wheel's direction scrolls and
|
||||
* stops propagation, so its ancestors don't also scroll.
|
||||
* • When it is already at its boundary it lets the next outer scrollbox
|
||||
* take over (wheel chaining), matching typical nested-scroll UX.
|
||||
*/
|
||||
|
||||
import { ScrollBoxRenderable } from "@opentui/core";
|
||||
import type { MouseEvent } from "@opentui/core";
|
||||
|
||||
type ScrollDir = "up" | "down" | "left" | "right";
|
||||
|
||||
// The scrollbox's own wheel handler (scrolls, then bubbles to its parent).
|
||||
const original = (ScrollBoxRenderable.prototype as unknown as {
|
||||
onMouseEvent: (event: MouseEvent) => void;
|
||||
}).onMouseEvent;
|
||||
|
||||
let installed = false;
|
||||
|
||||
/** True when `sb` has room to move in `dir` from its current position. */
|
||||
function canScroll(sb: ScrollBoxRenderable, dir: ScrollDir): boolean {
|
||||
const maxTop = Math.max(0, sb.scrollHeight - sb.viewport.height);
|
||||
const maxLeft = Math.max(0, sb.scrollWidth - sb.viewport.width);
|
||||
switch (dir) {
|
||||
case "up":
|
||||
return sb.scrollTop > 0;
|
||||
case "down":
|
||||
return sb.scrollTop < maxTop;
|
||||
case "left":
|
||||
return sb.scrollLeft > 0;
|
||||
case "right":
|
||||
return sb.scrollLeft < maxLeft;
|
||||
}
|
||||
}
|
||||
|
||||
const handleWheel = function (
|
||||
this: ScrollBoxRenderable,
|
||||
event: MouseEvent,
|
||||
): void {
|
||||
if (event.type !== "scroll" || !event.scroll?.direction) {
|
||||
original.call(this, event);
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = event.scroll.direction;
|
||||
const effective: ScrollDir = event.modifiers.shift
|
||||
? (dir === "up" ? "left" : dir === "down" ? "right" : dir === "right" ? "down" : "up")
|
||||
: dir;
|
||||
|
||||
const moves = canScroll(this, effective);
|
||||
original.call(this, event);
|
||||
// Only claim the wheel when this box actually moved; otherwise let the
|
||||
// next outer scrollbox (also under the cursor) take over.
|
||||
if (moves) event.stopPropagation();
|
||||
};
|
||||
|
||||
export function installNestedScrollBehavior(): void {
|
||||
if (installed || typeof original !== "function") return;
|
||||
installed = true;
|
||||
// `onMouseEvent` is a well-known protected method; the cast only bypasses
|
||||
// TypeScript's protected-access check and trusts the shipped class shape.
|
||||
const scrollboxProto = ScrollBoxRenderable.prototype as unknown as {
|
||||
onMouseEvent: typeof handleWheel;
|
||||
};
|
||||
scrollboxProto.onMouseEvent = handleWheel;
|
||||
}
|
||||
31
src/utils/rss-client.ts
Normal file
31
src/utils/rss-client.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* RSS feed client — single owner of feed XML fetches: headers, timeout,
|
||||
* and failure folding to null.
|
||||
*/
|
||||
|
||||
/** Default per-feed fetch timeout (ms). */
|
||||
export const FETCH_TIMEOUT_MS = 20_000;
|
||||
|
||||
/**
|
||||
* Fetch a feed's raw XML. Identity encoding keeps the response raw; the
|
||||
* Accept list matches what podcast servers send. Any failure (network,
|
||||
* non-ok, timeout) resolves to null — callers must leave data untouched.
|
||||
*/
|
||||
export const fetchFeedXml = async (
|
||||
url: string,
|
||||
opts?: { timeoutMs?: number },
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
signal: AbortSignal.timeout(opts?.timeoutMs ?? FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return await response.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { searchSourceByType, searchEpisodesByType } from "./source-searcher";
|
||||
import { parseRSSFeed } from "../api/rss-parser";
|
||||
import { fetchFeedXml } from "./rss-client";
|
||||
import { SourceType } from "../types/source";
|
||||
import type { PodcastSource, SearchResult } from "../types/source";
|
||||
|
||||
@@ -81,16 +82,13 @@ export const searchByFeedUrl = async (
|
||||
if (!FEED_URL_RE.test(trimmed)) return [];
|
||||
|
||||
try {
|
||||
const response = await fetch(trimmed, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
|
||||
const xml = await response.text();
|
||||
const podcast = parseRSSFeed(xml, trimmed);
|
||||
const xml = await fetchFeedXml(trimmed);
|
||||
if (xml === null) return [];
|
||||
// Full parse's episodes are dead weight here (2,100+ stale copies were
|
||||
// previously persisted inside Feed.podcast): addFeed refetches through
|
||||
// fetchEpisodes and nothing reads Podcast.episodes off a search result.
|
||||
const { episodes: _episodes, ...podcast } = parseRSSFeed(xml, trimmed);
|
||||
void _episodes;
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
59
src/utils/source-registry.ts
Normal file
59
src/utils/source-registry.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Source registry — owns the podcast source list and its immediate file
|
||||
* persistence. The feed store seeds it at boot and wires the loaded list in.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import type { PodcastSource } from "../types/source";
|
||||
import { saveSourcesToFile } from "./feeds-persistence";
|
||||
|
||||
/** Create a source registry around the given initial list. Every mutation
|
||||
* persists immediately (async, fire-and-forget) — source edits are rare
|
||||
* and must not sit in a debounce window across a process exit. */
|
||||
export function createSourceRegistry(initial: PodcastSource[]) {
|
||||
const [sources, setSources] = createSignal<PodcastSource[]>([...initial]);
|
||||
|
||||
/** Swap in a fully rebuilt list WITHOUT persisting — the boot-time
|
||||
* loader saves only when its migration actually changed data. */
|
||||
const replaceAll = (list: PodcastSource[]): void => {
|
||||
setSources(list);
|
||||
};
|
||||
|
||||
const addSource = (source: Omit<PodcastSource, "id">): PodcastSource => {
|
||||
const newSource: PodcastSource = {
|
||||
...source,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
setSources((prev) => {
|
||||
const updated = [...prev, newSource];
|
||||
saveSourcesToFile(updated);
|
||||
return updated;
|
||||
});
|
||||
return newSource;
|
||||
};
|
||||
|
||||
const updateSource = (
|
||||
sourceId: string,
|
||||
updates: Partial<PodcastSource>,
|
||||
): void => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((source) =>
|
||||
source.id === sourceId ? { ...source, ...updates } : source,
|
||||
);
|
||||
saveSourcesToFile(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSource = (sourceId: string): void => {
|
||||
setSources((prev) => {
|
||||
const updated = prev.map((s) =>
|
||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
||||
);
|
||||
saveSourcesToFile(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
return { sources, replaceAll, addSource, updateSource, toggleSource };
|
||||
}
|
||||
47
src/utils/terminal-recovery.ts
Normal file
47
src/utils/terminal-recovery.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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
155
tests/audio-queue.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* audio-queue unit tests — pure selection logic for next/prev navigation
|
||||
* and source-based auto-advance. Covers ordering, bounds, and the
|
||||
* deduplication that prevents "next" from replaying the current episode.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
queueForSource,
|
||||
queueIndex,
|
||||
nextStep,
|
||||
prevStep,
|
||||
} from "../src/utils/audio-queue";
|
||||
import { AudioSource } from "../src/stores/audio-nav";
|
||||
import type { Episode } from "../src/types/episode";
|
||||
import type { Feed } from "../src/types/feed";
|
||||
import { FeedVisibility } from "../src/types/feed";
|
||||
import type { SearchResult } from "../src/types/source";
|
||||
|
||||
function ep(id: string, n: number): Episode {
|
||||
return {
|
||||
id,
|
||||
podcastId: "pod-" + id,
|
||||
title: `Episode ${n}`,
|
||||
description: "",
|
||||
audioUrl: `https://example.com/${id}.mp3`,
|
||||
duration: 600,
|
||||
pubDate: new Date(2026, 0, n),
|
||||
};
|
||||
}
|
||||
|
||||
function feed(id: string, episodes: Episode[]): Feed {
|
||||
return {
|
||||
id,
|
||||
podcast: {
|
||||
id,
|
||||
title: "Feed " + id,
|
||||
description: "",
|
||||
feedUrl: `https://example.com/${id}.xml`,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes,
|
||||
visibility: FeedVisibility.PUBLIC,
|
||||
sourceId: "rss",
|
||||
lastUpdated: new Date(),
|
||||
isPinned: false,
|
||||
};
|
||||
}
|
||||
|
||||
function episodeResult(episode: Episode): SearchResult {
|
||||
return {
|
||||
sourceId: "itunes",
|
||||
kind: "episode",
|
||||
podcast: {
|
||||
id: episode.podcastId,
|
||||
title: "Show " + episode.podcastId,
|
||||
description: "",
|
||||
feedUrl: `https://example.com/${episode.podcastId}.xml`,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
},
|
||||
episode,
|
||||
};
|
||||
}
|
||||
|
||||
const e1 = ep("e1", 1);
|
||||
const e2 = ep("e2", 2);
|
||||
const e3 = ep("e3", 3);
|
||||
|
||||
test("FEED queue is the chronological global list, newest first", () => {
|
||||
const f1 = feed("f1", [e3, e2]);
|
||||
const f2 = feed("f2", [e1]);
|
||||
const queue = queueForSource(
|
||||
AudioSource.FEED,
|
||||
undefined,
|
||||
[f1, f2],
|
||||
[
|
||||
{ episode: e3, feed: f1 },
|
||||
{ episode: e2, feed: f1 },
|
||||
{ episode: e1, feed: f2 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(queue.map((e) => e.id)).toEqual(["e3", "e2", "e1"]);
|
||||
expect(queueIndex(queue, "e2")).toBe(1);
|
||||
expect(nextStep(queue, "e2")?.episode.id).toBe("e1");
|
||||
expect(prevStep(queue, "e2")?.episode.id).toBe("e3");
|
||||
expect(nextStep(queue, "e1")).toBeNull();
|
||||
expect(prevStep(queue, "e3")).toBeNull();
|
||||
});
|
||||
|
||||
test("FEED queue dedupes repeated episode ids (same episode listed twice)", () => {
|
||||
// The same episode appears twice in the global list (e.g. a refresh
|
||||
// merge duplicated a feed's entries). Without dedupe, nextStep after
|
||||
// e2 would step onto e2 AGAIN — replaying the current episode.
|
||||
const f1 = feed("f1", [e3, e2, e2, e1]);
|
||||
const queue = queueForSource(
|
||||
AudioSource.FEED,
|
||||
undefined,
|
||||
[f1],
|
||||
[
|
||||
{ episode: e3, feed: f1 },
|
||||
{ episode: e2, feed: f1 },
|
||||
{ episode: e2, feed: f1 },
|
||||
{ episode: e1, feed: f1 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(queue.map((e) => e.id)).toEqual(["e3", "e2", "e1"]);
|
||||
// Distinct objects sharing an id dedupe too.
|
||||
const e2clone = { ...e2 };
|
||||
const queue2 = queueForSource(
|
||||
AudioSource.FEED,
|
||||
undefined,
|
||||
[f1],
|
||||
[
|
||||
{ episode: e3, feed: f1 },
|
||||
{ episode: e2, feed: f1 },
|
||||
{ episode: e2clone, feed: f1 },
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(queue2.map((e) => e.id)).toEqual(["e3", "e2"]);
|
||||
expect(nextStep(queue2, "e2")).toBeNull(); // no self-step
|
||||
});
|
||||
|
||||
test("MY_SHOWS queue scopes to the podcast that started playback", () => {
|
||||
const fA = feed("podA", [e3, e2]);
|
||||
const fB = feed("podB", [e1]);
|
||||
const queue = queueForSource(
|
||||
AudioSource.MY_SHOWS,
|
||||
"podA",
|
||||
[fA, fB],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
expect(queue.map((e) => e.id)).toEqual(["e3", "e2"]);
|
||||
// Unknown podcastId → empty queue (nothing to play next).
|
||||
expect(
|
||||
queueForSource(AudioSource.MY_SHOWS, "podX", [fA, fB], [], []),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("SEARCH queue filters to episode-kind results in display order", () => {
|
||||
const queue = queueForSource(
|
||||
AudioSource.SEARCH,
|
||||
undefined,
|
||||
[],
|
||||
[],
|
||||
[episodeResult(e1), episodeResult(e2)],
|
||||
);
|
||||
expect(queue.map((e) => e.id)).toEqual(["e1", "e2"]);
|
||||
expect(queueIndex(queue, "e1")).toBe(0);
|
||||
expect(queueIndex(queue, "e3")).toBe(-1);
|
||||
});
|
||||
197
tests/auto-advance.test.ts
Normal file
197
tests/auto-advance.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* auto-advance.test.ts — "at the end of episodes play the next one, from
|
||||
* the source that started it" feature.
|
||||
*
|
||||
* When a track reaches its natural end (mpv eof-reached), useAudio must
|
||||
* advance to the next episode in the source queue — the current show's
|
||||
* episode list (MY_SHOWS), the Feed's chronological list, or the search
|
||||
* results — and must STOP at the end of the list (no wrap-around). A
|
||||
* crashed/killed daemon must NOT auto-advance (that path is pinned by
|
||||
* external-pause-reconcile.test.ts).
|
||||
*
|
||||
* Integration style (like external-pause-reconcile.test.ts): real stores,
|
||||
* real persistence sandbox, and the REAL mpv backend driven by real audio
|
||||
* files — two short local WAVs served over HTTP, so EOF happens on a
|
||||
* deterministic timer. The show is subscribed through the real feed store's
|
||||
* addFeed() API (no config seeding — works on whatever singleton state this
|
||||
* worker holds), and the audio-nav source is pinned to MY_SHOWS for that
|
||||
* podcast so the queue is scoped and deterministic. Skipped when mpv isn't
|
||||
* installed.
|
||||
*/
|
||||
import { test, expect, afterAll } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const hasMpv = !!Bun.which("mpv");
|
||||
|
||||
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
|
||||
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-autoadv-"));
|
||||
const DATA = mkdtempSync(join(tmpdir(), "podtui-autoadv-data-"));
|
||||
process.env.XDG_CONFIG_HOME = CONFIG;
|
||||
process.env.XDG_DATA_HOME = DATA;
|
||||
process.env.PODTUI_AUDIO_BACKEND = "mpv"; // real backend; EOF is the signal under test
|
||||
|
||||
/** 2s mono 16-bit WAV with a sine tone — short enough to EOF fast,
|
||||
* distinct per episode so playback is unambiguous. */
|
||||
function makeWav(freq: number): Buffer {
|
||||
const SAMPLE_RATE = 44100;
|
||||
const DURATION = 2;
|
||||
const dataLen = SAMPLE_RATE * DURATION;
|
||||
const buf = Buffer.alloc(44 + dataLen * 2);
|
||||
buf.write("RIFF", 0);
|
||||
buf.writeUInt32LE(36 + dataLen * 2, 4);
|
||||
buf.write("WAVE", 8);
|
||||
buf.write("fmt ", 12);
|
||||
buf.writeUInt32LE(16, 16); // fmt chunk size
|
||||
buf.writeUInt16LE(1, 20); // PCM
|
||||
buf.writeUInt16LE(1, 22); // mono
|
||||
buf.writeUInt32LE(SAMPLE_RATE, 24);
|
||||
buf.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate
|
||||
buf.writeUInt16LE(2, 32); // block align
|
||||
buf.writeUInt16LE(16, 34); // bits per sample
|
||||
buf.write("data", 36);
|
||||
buf.writeUInt32LE(dataLen * 2, 40);
|
||||
for (let i = 0; i < dataLen; i++) {
|
||||
const sample = Math.round(
|
||||
Math.sin((2 * Math.PI * freq * i) / SAMPLE_RATE) * 8000,
|
||||
);
|
||||
buf.writeInt16LE(sample, 44 + i * 2);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
const wav1 = makeWav(440);
|
||||
const wav2 = makeWav(880);
|
||||
|
||||
// ── Local HTTP server: the RSS feed + both audio files ────────────────────
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
function feedXml(origin: string): string {
|
||||
// Distinct pubDates so ep1 (newest) is episodes[0], ep2 older — "next"
|
||||
// must step DOWN the list toward the older episode.
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Auto Advance Show</title>
|
||||
<description>auto-advance test feed</description>
|
||||
<item>
|
||||
<title>Episode One</title>
|
||||
<pubDate>2026-08-10T00:00:00Z</pubDate>
|
||||
<enclosure url="${origin}/e1.wav" length="${wav1.length}" type="audio/wav"/>
|
||||
</item>
|
||||
<item>
|
||||
<title>Episode Two</title>
|
||||
<pubDate>2026-08-01T00:00:00Z</pubDate>
|
||||
<enclosure url="${origin}/e2.wav" length="${wav2.length}" type="audio/wav"/>
|
||||
</item>
|
||||
</channel></rss>`;
|
||||
}
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml(url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
}
|
||||
if (url.pathname.endsWith("e1.wav")) {
|
||||
return new Response(wav1.buffer as ArrayBuffer, {
|
||||
headers: { "Content-Type": "audio/wav" },
|
||||
});
|
||||
}
|
||||
if (url.pathname.endsWith("e2.wav")) {
|
||||
return new Response(wav2.buffer as ArrayBuffer, {
|
||||
headers: { "Content-Type": "audio/wav" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
// ── Real modules (loaded after env + server are up) ───────────────────────
|
||||
// @ts-expect-error — bun-only query suffix: distinct module identity that
|
||||
// loads the real file instead of a leaked mock.module from another test file.
|
||||
const { useAudio } = await import("../src/hooks/useAudio?auto-advance-test");
|
||||
const { useFeedStore } = await import("../src/stores/feed");
|
||||
const { useAudioNavStore, AudioSource } = await import(
|
||||
"../src/stores/audio-nav"
|
||||
);
|
||||
|
||||
const feedStore = useFeedStore();
|
||||
const audioNav = useAudioNavStore();
|
||||
|
||||
/** Poll `check` every 25ms until truthy; throw after `timeoutMs`. */
|
||||
async function waitFor(
|
||||
check: () => boolean,
|
||||
timeoutMs = 15000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!check()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("condition not met in time");
|
||||
}
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to the local feed through the real store API; unique podcast id
|
||||
// so the MY_SHOWS queue lookup is deterministic whatever else this worker's
|
||||
// shared feed store holds.
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
|
||||
const PODCAST_ID = `auto-advance-pod-${process.pid}`;
|
||||
const feed = await feedStore.addFeed(
|
||||
{
|
||||
id: PODCAST_ID,
|
||||
title: "Auto Advance Show",
|
||||
description: "auto-advance test feed",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
"test-source",
|
||||
);
|
||||
if (!feed || feed.episodes.length < 2) {
|
||||
throw new Error("test feed did not load two episodes");
|
||||
}
|
||||
const ep1 = feed.episodes[0]; // newest — plays first
|
||||
const ep2 = feed.episodes[1]; // older — must follow automatically
|
||||
if (ep1.title !== "Episode One") {
|
||||
throw new Error("episode order unexpected — ep1 is not the newest");
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
audioNav.reset(); // don't leak nav state into shared-worker tests
|
||||
server?.stop(true);
|
||||
rmSync(CONFIG, { recursive: true, force: true });
|
||||
rmSync(DATA, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.skipIf(!hasMpv)(
|
||||
"episode ending auto-plays the next in the show; the last episode stops",
|
||||
async () => {
|
||||
const audio = useAudio();
|
||||
audioNav.setSource(AudioSource.MY_SHOWS, PODCAST_ID);
|
||||
|
||||
// Start the newest episode.
|
||||
await audio.play(ep1);
|
||||
expect(audio.isPlaying()).toBe(true);
|
||||
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
||||
|
||||
// EOF → the next (older) episode starts automatically, and the nav
|
||||
// index moves with it.
|
||||
await waitFor(
|
||||
() =>
|
||||
audio.currentEpisode()?.id === ep2.id && audio.isPlaying(),
|
||||
);
|
||||
expect(audioNav.getCurrentIndex()).toBe(1);
|
||||
|
||||
// The last episode ends → playback stops; no wrap-around to ep1.
|
||||
await waitFor(() => !audio.isPlaying());
|
||||
expect(audio.currentEpisode()?.id).toBe(ep2.id);
|
||||
await Bun.sleep(600); // give any (wrong) auto-advance time to fire
|
||||
expect(audio.currentEpisode()?.id).toBe(ep2.id);
|
||||
expect(audio.isPlaying()).toBe(false);
|
||||
|
||||
await audio.stop();
|
||||
},
|
||||
{ timeout: 45000 },
|
||||
);
|
||||
58
tests/cavacore-init-reuse.test.ts
Normal file
58
tests/cavacore-init-reuse.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* CavaCore.init() must be a no-op for an identical config: re-initializing
|
||||
* the same plan leaks the old plan's native FFTW work buffers, so pipeline
|
||||
* restarts (focus/episode churn) have to re-USE the live plan.
|
||||
*/
|
||||
import { test, expect } from "bun:test"
|
||||
import { CavaCore } from "../src/utils/cavacore"
|
||||
|
||||
function stubLib() {
|
||||
const calls = { init: 0, destroy: 0 }
|
||||
let plans = 0
|
||||
const lib = {
|
||||
symbols: {
|
||||
cava_init: () => {
|
||||
calls.init++
|
||||
return { p: ++plans }
|
||||
},
|
||||
cava_execute: () => {},
|
||||
cava_destroy: () => {
|
||||
calls.destroy++
|
||||
},
|
||||
},
|
||||
close: () => {},
|
||||
}
|
||||
return { lib, calls }
|
||||
}
|
||||
|
||||
test("identical init config reuses the plan", () => {
|
||||
const { lib, calls } = stubLib()
|
||||
const cava = new CavaCore(lib as never)
|
||||
const cfg = { bars: 64, sampleRate: 22050, channels: 1, autosens: 0 }
|
||||
cava.init(cfg)
|
||||
cava.init(cfg)
|
||||
cava.init(cfg)
|
||||
expect(calls.init).toBe(1)
|
||||
expect(cava.isReady).toBe(true)
|
||||
})
|
||||
|
||||
test("changed config re-inits, destroying the old plan", () => {
|
||||
const { lib, calls } = stubLib()
|
||||
const cava = new CavaCore(lib as never)
|
||||
cava.init({ bars: 64, sampleRate: 22050, channels: 1, autosens: 0 })
|
||||
cava.init({ bars: 32, sampleRate: 22050, channels: 1, autosens: 0 })
|
||||
expect(calls.init).toBe(2)
|
||||
expect(calls.destroy).toBe(1)
|
||||
expect(cava.bars).toBe(32)
|
||||
})
|
||||
|
||||
test("init after destroy creates a fresh plan", () => {
|
||||
const { lib, calls } = stubLib()
|
||||
const cava = new CavaCore(lib as never)
|
||||
const cfg = { bars: 64, sampleRate: 22050, channels: 1, autosens: 0 }
|
||||
cava.init(cfg)
|
||||
cava.destroy()
|
||||
cava.init(cfg)
|
||||
expect(calls.init).toBe(2)
|
||||
expect(cava.isReady).toBe(true)
|
||||
})
|
||||
72
tests/feed-for-episode.test.ts
Normal file
72
tests/feed-for-episode.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ import { whenConfigIdle } from "../src/utils/config";
|
||||
import { FeedVisibility } from "../src/types/feed";
|
||||
import type { Feed } from "../src/types/feed";
|
||||
import type { Episode } from "../src/types/episode";
|
||||
import type { PodcastWithEpisodes } from "../src/types/podcast";
|
||||
|
||||
const configJsonPath = join(configHome, "podtui", "config.json");
|
||||
const downloadsJsonPath = join(configHome, "podtui", "downloads.json");
|
||||
@@ -169,6 +170,73 @@ test("DEFAULT_EPISODE_WINDOW_DAYS is 60", () => {
|
||||
expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60);
|
||||
});
|
||||
|
||||
// ── Legacy podcast.episodes baggage ────────────────────────────────────────
|
||||
|
||||
test("saveFeedsToFile strips legacy podcast.episodes from the persisted feed", async () => {
|
||||
const feed = makeFeed([
|
||||
makeEpisode({ id: "recent-id", pubDate: new Date(Date.now() - 5 * DAY) }),
|
||||
]);
|
||||
// Simulate the pre-fix shape: parseRSSFeed's full history embedded on
|
||||
// the podcast object (841 stale copies were persisted this way).
|
||||
const podcastWithLegacy = feed.podcast as PodcastWithEpisodes;
|
||||
podcastWithLegacy.episodes = [
|
||||
makeEpisode({ id: "stale-history-1" }),
|
||||
makeEpisode({ id: "stale-history-2" }),
|
||||
];
|
||||
|
||||
saveFeedsToFile([feed]);
|
||||
await settleWrites();
|
||||
const raw = await Bun.file(configJsonPath).json();
|
||||
expect("episodes" in raw.feeds[0].podcast).toBe(false);
|
||||
});
|
||||
|
||||
test("loadFeedsFromFile drops legacy podcast.episodes from a seeded config", async () => {
|
||||
await Bun.write(
|
||||
configJsonPath,
|
||||
JSON.stringify({
|
||||
feeds: [
|
||||
{
|
||||
id: "feed-1",
|
||||
podcast: {
|
||||
id: "feed-1",
|
||||
title: "Baggage Show",
|
||||
description: "",
|
||||
author: "tester",
|
||||
feedUrl: "https://example.com/baggage.xml",
|
||||
lastUpdated: new Date().toISOString(),
|
||||
isSubscribed: true,
|
||||
episodes: [
|
||||
{ id: "huge-stale-1", title: "archived copy" },
|
||||
{ id: "huge-stale-2", title: "archived copy" },
|
||||
],
|
||||
},
|
||||
episodes: [
|
||||
{
|
||||
id: "recent-id",
|
||||
podcastId: "feed-1",
|
||||
title: "Recent",
|
||||
description: "",
|
||||
audioUrl: "https://example.com/audio/recent.mp3",
|
||||
duration: 60,
|
||||
pubDate: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
visibility: "public",
|
||||
sourceId: "source-1",
|
||||
lastUpdated: new Date().toISOString(),
|
||||
isPinned: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const feeds = await loadFeedsFromFile();
|
||||
|
||||
expect(feeds).toHaveLength(1);
|
||||
expect(feeds[0].episodes.map((e) => e.id)).toEqual(["recent-id"]);
|
||||
expect("episodes" in feeds[0].podcast).toBe(false);
|
||||
});
|
||||
|
||||
// ── Save path: retention window applied with completed-download exemption ──
|
||||
|
||||
test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
128
tests/nested-scroll.test.tsx
Normal file
128
tests/nested-scroll.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
101
tests/pane-layout-store.test.ts
Normal file
101
tests/pane-layout-store.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* pane-layout-store.test.ts — the shared pane-split store: splitPixels
|
||||
* clamping, border moves that respect per-pane minimum widths, and
|
||||
* commit() persistence into the app preferences.
|
||||
*/
|
||||
import { test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Point the config dir at a throwaway directory BEFORE importing the store
|
||||
// (its module-level init reads it).
|
||||
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "podtui-panecfg-"));
|
||||
|
||||
// The config dir must be set before the module is evaluated, so the app
|
||||
// store is loaded dynamically here rather than statically at the top.
|
||||
const { splitPixels, createPaneLayoutStore, DEFAULT_PANE_SPLITS } = await import(
|
||||
"../src/stores/pane-layout"
|
||||
);
|
||||
const { useAppStore } = await import("../src/stores/app");
|
||||
|
||||
beforeAll(async () => {
|
||||
await useAppStore().whenReady();
|
||||
});
|
||||
afterAll(() => {
|
||||
// Restore pristine preferences so a later file sharing this process
|
||||
// (bun test reuses the module registry) renders the default split.
|
||||
useAppStore().updatePreferences({ paneSplit: DEFAULT_PANE_SPLITS });
|
||||
});
|
||||
|
||||
test("default splits mirror the historical 2:5:3 ratio at width 100", () => {
|
||||
expect(DEFAULT_PANE_SPLITS).toEqual({ left: 0.2, right: 0.7 });
|
||||
const { leftPx, rightPx } = splitPixels(100, DEFAULT_PANE_SPLITS);
|
||||
expect(leftPx).toBe(20);
|
||||
expect(rightPx).toBe(70);
|
||||
});
|
||||
|
||||
test("splitPixels maps splits 1:1 to pixels (ratio exact at every width)", () => {
|
||||
// Pure fraction→pixel mapping — no minimum enforcement in rendering.
|
||||
expect(splitPixels(100, { left: 0.6, right: 0.7 })).toEqual({
|
||||
leftPx: 60,
|
||||
rightPx: 70,
|
||||
});
|
||||
expect(splitPixels(70, { left: 0.2, right: 0.7 })).toEqual({
|
||||
leftPx: 14,
|
||||
rightPx: 49,
|
||||
});
|
||||
});
|
||||
|
||||
test("splitPixels handles zero-width and degenerate terminals", () => {
|
||||
expect(splitPixels(0, DEFAULT_PANE_SPLITS)).toEqual({ leftPx: 0, rightPx: 0 });
|
||||
const tiny = splitPixels(40, { left: 0.2, right: 0.7 });
|
||||
expect(tiny.leftPx).toBe(8);
|
||||
expect(tiny.rightPx).toBe(28);
|
||||
});
|
||||
|
||||
test("setRight clamps the preview to its minimum", () => {
|
||||
const store = createPaneLayoutStore();
|
||||
store.setRight(97, 100); // preview can't shrink below 15
|
||||
expect(store.splits().right).toBeCloseTo(0.85, 5);
|
||||
// The parent minimum also holds when the left border is dragged.
|
||||
store.setLeft(1, 100);
|
||||
expect(store.splits().left).toBeCloseTo(0.15, 5);
|
||||
});
|
||||
|
||||
test("setLeft moves the border and normalizes stored fractions", () => {
|
||||
const store = createPaneLayoutStore();
|
||||
store.setLeft(40, 100);
|
||||
expect(store.splits().left).toBeCloseTo(0.4, 5);
|
||||
expect(store.splits().right).toBeCloseTo(0.7, 5);
|
||||
});
|
||||
|
||||
test("setLeft below the parent minimum clamps up", () => {
|
||||
const store = createPaneLayoutStore();
|
||||
store.setLeft(5, 100);
|
||||
expect(store.splits().left).toBeCloseTo(0.15, 5);
|
||||
});
|
||||
|
||||
test("setLeft beyond the current minimum forces the right border right", () => {
|
||||
const store = createPaneLayoutStore();
|
||||
store.setLeft(60, 100);
|
||||
expect(store.splits().left).toBeCloseTo(0.55, 5);
|
||||
expect(store.splits().right).toBeCloseTo(0.85, 5);
|
||||
});
|
||||
|
||||
test("setRight respects the current and preview minimums", () => {
|
||||
const store = createPaneLayoutStore();
|
||||
store.setRight(80, 100);
|
||||
expect(store.splits().right).toBeCloseTo(0.8, 5);
|
||||
store.setRight(40, 100); // must not cross below leftPx(20) + minCurrent(30)
|
||||
expect(store.splits().right).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
|
||||
test("commit persists the split into the app preferences", async () => {
|
||||
const store = createPaneLayoutStore();
|
||||
store.setLeft(35, 100);
|
||||
store.commit();
|
||||
await useAppStore().whenReady();
|
||||
expect(useAppStore().state().preferences.paneSplit.left).toBeCloseTo(0.35, 5);
|
||||
expect(useAppStore().state().preferences.paneSplit.right).toBeCloseTo(0.7, 5);
|
||||
});
|
||||
209
tests/pane-resize.test.tsx
Normal file
209
tests/pane-resize.test.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* pane-resize.test.tsx — dragging the center column's borders actually
|
||||
* resizes the panes in a rendered PaneRow.
|
||||
*
|
||||
* Each pane renders a long run of a unique character (P / C / V). A line
|
||||
* where all three meet encodes the boundary columns directly: the current
|
||||
* pane carries the only borders (cols `leftPx` and `rightPx - 1`), so its
|
||||
* content starts one column in — `leftPx + 1`. Hence
|
||||
* `leftPx = firstC - 1`, `rightPx = firstV`.
|
||||
*
|
||||
* The grab zones overlay each border: 3 columns wide, the border plus one
|
||||
* help-padded column each side (left zone at [left-1, left+1], right zone
|
||||
* at [right-2, right)). The test presses inside a zone and drags across
|
||||
* the row — the drag bubbles to the row container which moves the split,
|
||||
* so the panes must re-render at the new columns.
|
||||
*/
|
||||
import { test, expect, afterAll } from "bun:test";
|
||||
import { testRender } from "@opentui/solid";
|
||||
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||
import { PaneRow } from "../src/components/PaneRow";
|
||||
import { usePaneLayout } from "../src/stores/pane-layout";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Point the config dir at a throwaway directory BEFORE importing the store
|
||||
// (module-level init reads it).
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-paneresize-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
type Span = { text: string };
|
||||
type Frame = { cols: number; lines: { spans: Span[] }[] };
|
||||
|
||||
interface BoundCols {
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
function readBounds(frame: Frame): BoundCols {
|
||||
const line = frame.lines
|
||||
.map((l) => l.spans.map((s) => s.text).join(""))
|
||||
.find((l) => l.includes("C"));
|
||||
if (!line) throw new Error("pane row did not render");
|
||||
return { left: line.indexOf("C") - 1, right: line.indexOf("V") };
|
||||
}
|
||||
|
||||
async function renderRow(panes: 2 | 3 = 3) {
|
||||
const setup = (await testRender(
|
||||
() => (
|
||||
<ThemeProvider mode="dark">
|
||||
<PaneRow
|
||||
parent={<text selectable={false}>{"P".repeat(300)}</text>}
|
||||
current={<text selectable={false}>{"C".repeat(600)}</text>}
|
||||
preview={<text selectable={false}>{"V".repeat(300)}</text>}
|
||||
currentLabel=""
|
||||
panes={panes}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: 100, height: 10, useThread: false },
|
||||
)) as unknown as {
|
||||
renderOnce: () => Promise<void>;
|
||||
captureSpans: () => Frame;
|
||||
mockMouse: {
|
||||
drag: (a: number, b: number, c: number, d: number) => Promise<void>;
|
||||
click: (a: number, b: number) => Promise<void>;
|
||||
};
|
||||
renderer: { destroy: () => void };
|
||||
};
|
||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
||||
return setup;
|
||||
}
|
||||
|
||||
/** Reset the shared store to the default split for a deterministic start. */
|
||||
function resetSplits() {
|
||||
usePaneLayout().setLeft(20, 100);
|
||||
usePaneLayout().setRight(70, 100);
|
||||
}
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
afterAll(() => {
|
||||
for (const c of cleanups) c();
|
||||
// Restore the default split so a later file sharing this process (bun
|
||||
// test reuses the module registry) renders the default layout.
|
||||
usePaneLayout().setLeft(20, 100);
|
||||
usePaneLayout().setRight(70, 100);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("panes render at the default 20/70 split", async () => {
|
||||
const setup = await renderRow(3);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
await setup.renderOnce();
|
||||
const { left, right } = readBounds(setup.captureSpans());
|
||||
expect(left).toBe(20);
|
||||
expect(right).toBe(70);
|
||||
});
|
||||
|
||||
test("dragging the left border resizes parent vs current", async () => {
|
||||
const setup = await renderRow(3);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
await setup.renderOnce();
|
||||
|
||||
// Press on the left zone (border at 20 → zone covers 19-21) and drag
|
||||
// toward the middle of the row.
|
||||
await setup.mockMouse.drag(20, 5, 45, 5);
|
||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
||||
const after = readBounds(setup.captureSpans());
|
||||
expect(after.left).toBeGreaterThanOrEqual(44);
|
||||
expect(after.left).toBeLessThanOrEqual(46);
|
||||
// Pushing the left border to 45 would shrink the current pane below its
|
||||
// 30-col minimum (45..70 = 25), so the right border is forced right to
|
||||
// 75, keeping the current pane at exactly 30 and absorbing the overflow
|
||||
// in the preview.
|
||||
expect(after.right).toBe(75);
|
||||
});
|
||||
|
||||
test("dragging the right border resizes current vs preview", async () => {
|
||||
const setup = await renderRow(3);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
await setup.renderOnce();
|
||||
|
||||
// Press on the right zone (border at 69 → zone covers 68-70) and drag
|
||||
// toward the right edge of the row.
|
||||
await setup.mockMouse.drag(69, 5, 90, 5);
|
||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
||||
const after = readBounds(setup.captureSpans());
|
||||
expect(after.right).toBeGreaterThanOrEqual(84);
|
||||
expect(after.right).toBeLessThanOrEqual(85); // clamped at preview min 15
|
||||
expect(after.left).toBe(20);
|
||||
});
|
||||
|
||||
test("grabbing the left zone from its far edge does not jump the border", async () => {
|
||||
const setup = await renderRow(3);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
await setup.renderOnce();
|
||||
|
||||
// Press one column LEFT of the border (x=19, border at 20 → offset -1)
|
||||
// and drag to 37. The border must track the grab, landing at 38 (37 + 1),
|
||||
// not at 37. Without the grab offset it would jump one column.
|
||||
await setup.mockMouse.drag(19, 5, 37, 5);
|
||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
||||
const after = readBounds(setup.captureSpans());
|
||||
expect(after.left).toBe(38);
|
||||
expect(after.right).toBe(70);
|
||||
});
|
||||
|
||||
test("grabbing the left zone from its inner edge does not jump the border", async () => {
|
||||
const setup = await renderRow(3);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
await setup.renderOnce();
|
||||
|
||||
// Press one column RIGHT of the border (x=21, border at 20 → offset +1)
|
||||
// and drag to 37. The border lands at 36 (37 - 1), not 37.
|
||||
await setup.mockMouse.drag(21, 5, 37, 5);
|
||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
||||
const after = readBounds(setup.captureSpans());
|
||||
expect(after.left).toBe(36);
|
||||
expect(after.right).toBe(70);
|
||||
});
|
||||
|
||||
test("a click inside a padded grab zone (off the border) does not resize", async () => {
|
||||
const setup = await renderRow(3);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
await setup.renderOnce();
|
||||
|
||||
// A bare click (no drag) on the help-padded column beside the border
|
||||
// must not move the split — only an actual drag does.
|
||||
await setup.mockMouse.click(19, 5);
|
||||
await setup.renderOnce();
|
||||
const { left, right } = readBounds(setup.captureSpans());
|
||||
expect(left).toBe(20);
|
||||
expect(right).toBe(70);
|
||||
});
|
||||
|
||||
test("a plain click away from the borders does not resize", async () => {
|
||||
const setup = await renderRow(3);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
await setup.renderOnce();
|
||||
|
||||
await setup.mockMouse.click(5, 5);
|
||||
await setup.renderOnce();
|
||||
const { left, right } = readBounds(setup.captureSpans());
|
||||
expect(left).toBe(20);
|
||||
expect(right).toBe(70);
|
||||
});
|
||||
|
||||
test("2-pane rows offer no right border (current fills the row)", async () => {
|
||||
const setup = await renderRow(2);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
||||
|
||||
// No 'V' pane: the line runs to the screen edge.
|
||||
const frame = setup.captureSpans();
|
||||
const { left } = readBounds(frame);
|
||||
expect(left).toBe(20);
|
||||
const line = frame.lines
|
||||
.map((l) => l.spans.map((s) => s.text).join(""))
|
||||
.find((l) => l.includes("C"));
|
||||
expect(line?.includes("V")).toBe(false);
|
||||
});
|
||||
23
tests/scratch-cava-reinit.test.ts
Normal file
23
tests/scratch-cava-reinit.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Scratch: cava init/destroy cycles leak native fftw buffers? */
|
||||
import { test, expect } from "bun:test"
|
||||
import { loadCavaCore } from "../src/utils/cavacore"
|
||||
|
||||
const cava = loadCavaCore()
|
||||
const skip = !cava
|
||||
|
||||
test.skipIf(skip)("init/destroy x50: RSS bounded", () => {
|
||||
const cfg = { bars: 64, sampleRate: 22050, channels: 1, autosens: 0 }
|
||||
const samples = new Float64Array(8192)
|
||||
Bun.gc(true)
|
||||
const startRss = process.memoryUsage.rss()
|
||||
for (let i = 0; i < 50; i++) {
|
||||
cava!.init(cfg)
|
||||
cava!.execute(samples)
|
||||
cava!.destroy()
|
||||
}
|
||||
Bun.gc(true)
|
||||
const endRss = process.memoryUsage.rss()
|
||||
const grown = (endRss - startRss) / 1048576
|
||||
console.log(`init/destroy x50: rss delta=${grown.toFixed(1)}MB`)
|
||||
expect(grown).toBeLessThan(100)
|
||||
}, 60_000)
|
||||
59
tests/scratch-hover.test.tsx
Normal file
59
tests/scratch-hover.test.tsx
Normal 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();
|
||||
});
|
||||
34
tests/scratch-region-count.ts
Normal file
34
tests/scratch-region-count.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/** Mach VM region walker via FFI (self-process). */
|
||||
import { dlopen, FFIType, ptr } from "bun:ffi"
|
||||
|
||||
const k = dlopen("/usr/lib/system/libsystem_kernel.dylib", {
|
||||
mach_task_self: { args: [], returns: FFIType.u64 },
|
||||
mach_vm_region: {
|
||||
args: [FFIType.u64, FFIType.ptr, FFIType.ptr, FFIType.u32, FFIType.ptr, FFIType.ptr, FFIType.ptr],
|
||||
returns: FFIType.i32,
|
||||
},
|
||||
})
|
||||
|
||||
/** Walk own VM map; count total regions and ~128K ones. */
|
||||
export function countRegions(): { total: number; r128k: number } {
|
||||
const task = (k.symbols.mach_task_self as any)() as number
|
||||
const addr = new BigUint64Array(1)
|
||||
const size = new BigUint64Array(1)
|
||||
const info = new Uint32Array(16)
|
||||
const infoCnt = new Uint32Array(1)
|
||||
const objectName = new Uint32Array(1)
|
||||
let total = 0
|
||||
let r128k = 0
|
||||
addr[0] = 1n
|
||||
const walk = k.symbols.mach_vm_region as any
|
||||
for (;;) {
|
||||
infoCnt[0] = 16
|
||||
const kr = walk(BigInt(task), ptr(addr), ptr(size), 9, ptr(info), ptr(infoCnt), ptr(objectName))
|
||||
if (kr !== 0) break
|
||||
total++
|
||||
if (size[0] >= 131072n && size[0] <= 139264n) r128k++
|
||||
addr[0] = addr[0] + size[0]
|
||||
if (addr[0] === 0n || total > 500000) break
|
||||
}
|
||||
return { total, r128k }
|
||||
}
|
||||
27
tests/scratch-render-leak.tsx
Normal file
27
tests/scratch-render-leak.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/** Minimal opentui app: VM region growth from render loop and/or text churn. */
|
||||
import { appendFileSync } from "node:fs"
|
||||
import { createSignal } from "solid-js"
|
||||
import { render } from "@opentui/solid"
|
||||
import { countRegions } from "./scratch-region-count"
|
||||
|
||||
const CHURN = Bun.argv.includes("--churn")
|
||||
const OUT = Bun.argv.find((a) => a.startsWith("--out="))?.slice(6) ?? "/tmp/render-leak.log"
|
||||
const log = (m: string) => Bun.write(Bun.stderr, m + "\n") // stderr may be hijacked too; use fd via file:
|
||||
const append = (m: string) => appendFileSync(OUT, m + "\n")
|
||||
|
||||
const [s, setS] = createSignal("hello")
|
||||
if (CHURN) {
|
||||
let i = 0
|
||||
setInterval(() => setS(`hello ${++i} ${"x".repeat(i % 50)}`), 33)
|
||||
}
|
||||
|
||||
render(() => <text>{s()}</text>)
|
||||
await Bun.sleep(1000)
|
||||
const c0 = countRegions()
|
||||
append(`start churn=${CHURN}: total=${c0.total}`)
|
||||
for (let w = 1; w <= 4; w++) {
|
||||
await Bun.sleep(15_000)
|
||||
const c = countRegions()
|
||||
append(`t=${w * 15}s total=${c.total} (delta ${c.total - c0.total})`)
|
||||
}
|
||||
process.exit(0)
|
||||
36
tests/scratch-stream-leak.test.ts
Normal file
36
tests/scratch-stream-leak.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** Scratch: does the ffmpeg decode stream leak VM regions per chunk? */
|
||||
import { test, expect } from "bun:test"
|
||||
import { EpisodePcmCache } from "../src/utils/audio-pcm-cache"
|
||||
import { spawnSync } from "child_process"
|
||||
|
||||
const wav = "/tmp/podtui-stream.wav"
|
||||
if (!(await Bun.file(wav).exists()) && (await Bun.$`which ffmpeg`.nothrow())) {
|
||||
await Bun.$`ffmpeg -f lavfi -i "sine=frequency=440:duration=600" -ar 22050 -ac 1 -sample_fmt s16 ${wav}`.quiet().nothrow()
|
||||
}
|
||||
|
||||
function regions(): number {
|
||||
const out = spawnSync("vmmap", [String(process.pid)], { timeout: 20000 }).stdout?.toString() ?? ""
|
||||
return out.split("\n").filter((l) => l.includes("VM_ALLOCATE")).length
|
||||
}
|
||||
function rss(): number {
|
||||
return Number(spawnSync("ps", ["-o", "rss=", "-p", String(process.pid)]).stdout?.toString().trim() || 0)
|
||||
}
|
||||
|
||||
test("decode stream 90s: regions and rss bounded", async () => {
|
||||
Bun.gc(true)
|
||||
await Bun.sleep(200)
|
||||
const r0 = regions(), m0 = rss()
|
||||
const pcm = new EpisodePcmCache({ url: wav })
|
||||
pcm.startDecode(0)
|
||||
const t0 = Date.now()
|
||||
while (Date.now() - t0 < 90_000) {
|
||||
await Bun.sleep(5_000)
|
||||
const pos = ((Date.now() - t0) / 1000) * 4
|
||||
pcm.readWindow(new Float64Array(512), pos)
|
||||
}
|
||||
Bun.gc(true)
|
||||
const r1 = regions(), m1 = rss()
|
||||
console.log(`stream 90s: regions ${r0}->${r1} (delta ${r1 - r0}), rss ${(m0 / 1048576) | 0}->${(m1 / 1048576) | 0}MB`)
|
||||
pcm.stop()
|
||||
expect(r1 - r0).toBeLessThan(100)
|
||||
}, 140_000)
|
||||
22
tests/scratch-stream-run.ts
Normal file
22
tests/scratch-stream-run.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Scratch runner: decode stream, self-measure VM regions over time. */
|
||||
import { EpisodePcmCache } from "../src/utils/audio-pcm-cache"
|
||||
import { countRegions } from "./scratch-region-count"
|
||||
const wav = "/tmp/podtui-stream.wav"
|
||||
if (!(await Bun.file(wav).exists())) {
|
||||
await Bun.$`ffmpeg -f lavfi -i "sine=frequency=440:duration=600" -ar 22050 -ac 1 -sample_fmt s16 ${wav}`.quiet().nothrow()
|
||||
}
|
||||
const pcm = new EpisodePcmCache({ url: wav })
|
||||
Bun.gc(true)
|
||||
console.log(`start: ${JSON.stringify(countRegions())}`)
|
||||
pcm.startDecode(0)
|
||||
const t0 = Date.now()
|
||||
const buf = new Float64Array(512)
|
||||
while (Date.now() - t0 < 90_000) {
|
||||
await Bun.sleep(15_000)
|
||||
const pos = ((Date.now() - t0) / 1000) * 4
|
||||
pcm.readWindow(buf, pos)
|
||||
const c = countRegions()
|
||||
console.log(`t=${((Date.now() - t0) / 1000) | 0}s total=${c.total} r128k=${c.r128k} rss=${(process.memoryUsage.rss() / 1048576) | 0}MB`)
|
||||
}
|
||||
pcm.stop()
|
||||
console.log("done")
|
||||
@@ -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(() => {
|
||||
|
||||
41
tests/visualizer-throttle.test.ts
Normal file
41
tests/visualizer-throttle.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/** bars signal writes are throttled to ~10fps; the render loop still runs 30fps. */
|
||||
import { test, expect } from "bun:test"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
process.env.XDG_CONFIG_HOME = join(tmpdir(), `podtui-th-${process.pid}`)
|
||||
process.env.XDG_DATA_HOME = join(tmpdir(), `podtui-th-data-${process.pid}`)
|
||||
process.env.PODTUI_AUDIO_BACKEND = "none"
|
||||
|
||||
const { useVisualizer } = await import("../src/stores/visualizer")
|
||||
const { setCurrentEpisode, setIsPlaying, setPosition } = await import("../src/utils/audio-signals")
|
||||
import type { Episode } from "../src/types/episode"
|
||||
|
||||
const wavPath = "/tmp/podtui-pause-cycle.wav"
|
||||
const skip = !(Bun.which("ffmpeg") && Bun.file(wavPath).exists())
|
||||
|
||||
test.skipIf(skip)("barData updates at ~10fps while the loop runs at 30fps", async () => {
|
||||
const viz = useVisualizer()
|
||||
viz.setBarCount(64)
|
||||
viz.setFocused(true)
|
||||
setCurrentEpisode({ audioUrl: wavPath } as unknown as Episode)
|
||||
setIsPlaying(true)
|
||||
setPosition(5)
|
||||
for (let i = 0; i < 200 && !(viz.barData().length > 0); i++) await Bun.sleep(25)
|
||||
|
||||
let writes = 0
|
||||
let prev = viz.barData()
|
||||
// count distinct array references the signal produced over 1s
|
||||
const check = setInterval(() => {
|
||||
const cur = viz.barData()
|
||||
if (cur !== prev) {
|
||||
writes++
|
||||
prev = cur
|
||||
}
|
||||
}, 16)
|
||||
await Bun.sleep(1000)
|
||||
clearInterval(check)
|
||||
console.log(`barData writes in 1s: ${writes} (30fps loop would be ~15-20 distinct seen at 16ms sampling)`)
|
||||
expect(writes).toBeGreaterThan(3)
|
||||
expect(writes).toBeLessThanOrEqual(14)
|
||||
}, 30_000)
|
||||
Reference in New Issue
Block a user