Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aac138629 | |||
| 3f0001b0d5 | |||
| 74158d75d4 | |||
| 4ff8aabb51 | |||
| 895138357f | |||
| 02c957f584 |
@@ -63,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
|
||||
|
||||
9
scripts/_hv.ts
Normal file
9
scripts/_hv.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { testRender } from "@opentui/solid";
|
||||
const { ThemeProvider } = await import("../src/context/ThemeContext");
|
||||
const { PaneRow } = await import("../src/components/PaneRow");
|
||||
process.env.XDG_CONFIG_HOME = import.meta.dir + "/../.harness/config-home";
|
||||
import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path";
|
||||
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "hv-"));
|
||||
const setup = (await testRender(
|
||||
() => React.createElement...
|
||||
));
|
||||
@@ -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();
|
||||
|
||||
@@ -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 drag strips sit
|
||||
* exactly on the drawn borders), and renders two invisible grab handles over
|
||||
* the border cells.
|
||||
*
|
||||
* Column semantics (per the yazi depth model):
|
||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||
* KEEPS its 20% slot when blank (never collapses to width 0).
|
||||
* Borderless (no left/right/top/bottom edge). Carries the single
|
||||
* header row: the CURRENT column's title renders top-left in the
|
||||
* parent's slot (the panes above current/preview were removed).
|
||||
* current — the current-depth list. The only focusable content column; it
|
||||
* is the ONLY bordered column — left/right edges only, always
|
||||
* muted (no active-border highlight, focused or not).
|
||||
* preview — detail of the hovered item in `current`. Borderless, no header.
|
||||
* keeps a minimum 15-col slot. Borderless.
|
||||
* current — the current-depth list. The only focusable content column; the
|
||||
* ONLY bordered column — left/right edges only, always muted.
|
||||
* preview — detail of the hovered item in `current`. Borderless.
|
||||
*
|
||||
* The primitive is purely structural: callers pass their own JSX per column
|
||||
* (static elements or accessors) plus the current-column title. Theme colors
|
||||
* are resolved internally via `useTheme()`. Only the current column's
|
||||
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor (j/k
|
||||
* stay in the current pane).
|
||||
* `<scrollbox>` receives `focused`, so scroll focus follows the cursor.
|
||||
*
|
||||
* Example:
|
||||
* <PaneRow
|
||||
@@ -34,11 +31,16 @@
|
||||
* />
|
||||
*/
|
||||
|
||||
import { createMemo, Show } from "solid-js";
|
||||
import { createMemo, createSignal, Show } from "solid-js";
|
||||
import type { JSX } from "solid-js";
|
||||
import { useTerminalDimensions } from "@opentui/solid";
|
||||
import type { RGBA, BorderSides } from "@opentui/core";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
import {
|
||||
MIN_PANE_WIDTH,
|
||||
splitPixels,
|
||||
usePaneLayout,
|
||||
} from "@/stores/pane-layout";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
type PaneContent = JSX.Element | (() => JSX.Element);
|
||||
@@ -46,7 +48,7 @@ type PaneLabel = string | (() => string);
|
||||
|
||||
export type PaneRowProps = {
|
||||
/** Parent column content (previous-depth list, or null for a muted
|
||||
* placeholder — the 1/5 slot is always preserved). */
|
||||
* placeholder — a minimum slot is always preserved). */
|
||||
parent?: PaneContent;
|
||||
/** Current column content (the focused list). */
|
||||
current?: PaneContent;
|
||||
@@ -99,7 +101,7 @@ function Placeholder(props: { color: () => RGBA }) {
|
||||
|
||||
// ── Pane column ─────────────────────────────────────────────────────────────
|
||||
function Pane(props: {
|
||||
grow: number;
|
||||
width: number;
|
||||
label: () => string;
|
||||
content: () => JSX.Element | undefined;
|
||||
border: boolean | BorderSides[];
|
||||
@@ -116,8 +118,8 @@ function Pane(props: {
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={props.grow}
|
||||
flexBasis={0}
|
||||
width={props.width}
|
||||
flexShrink={0}
|
||||
height="100%"
|
||||
>
|
||||
{/* ── title row: rendered only when the pane carries a label ────────── */}
|
||||
@@ -158,6 +160,45 @@ function Pane(props: {
|
||||
);
|
||||
}
|
||||
|
||||
/** A 1-column invisible grab handle covering exactly one border of the
|
||||
* current pane. `onBegin` is called on mousedown; subsequent drag/drag-end
|
||||
* events bubble up the row and drive `usePaneLayout` there. On hover or
|
||||
* while dragging it overdraws the border with a full-height accent `│`
|
||||
* line (a bordered box would render as a blocky rectangle instead). */
|
||||
function Splitter(props: {
|
||||
left: number;
|
||||
active: boolean;
|
||||
onBegin: () => void;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const dims = useTerminalDimensions();
|
||||
const [hovered, setHovered] = createSignal(false);
|
||||
const highlighted = () => props.active || hovered();
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
left={props.left}
|
||||
top={0}
|
||||
width={1}
|
||||
height="100%"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault?.();
|
||||
props.onBegin();
|
||||
}}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
>
|
||||
<Show when={highlighted()}>
|
||||
{/* Draw the accent edge down the full pane height; the box clips
|
||||
* any excess rows below the row's bottom edge. */}
|
||||
<text fg={theme.primary} selectable={false}>
|
||||
{"│\n".repeat(dims().height)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Row primitive ───────────────────────────────────────────────────────────
|
||||
export function PaneRow(props: PaneRowProps) {
|
||||
/** true → the current column's scrollbox is focused (scroll follows cursor). */
|
||||
@@ -179,20 +220,60 @@ export function PaneRow(props: PaneRowProps) {
|
||||
// 2-pane mode (parent|current) grows the current column to fill the
|
||||
// preview slot. Defaults to 3 (parent|current|preview).
|
||||
const panes = createMemo(() => props.panes ?? 3);
|
||||
const currentGrow = createMemo(() =>
|
||||
panes() === 2
|
||||
? PANE_RATIO.current + PANE_RATIO.preview
|
||||
: PANE_RATIO.current,
|
||||
);
|
||||
const currentBorder = createMemo<boolean | BorderSides[]>(
|
||||
() => props.currentBorder ?? ["left", "right"],
|
||||
);
|
||||
|
||||
// Shared split state + terminal width drive explicit column widths so the
|
||||
// drag strips sit exactly on the drawn borders.
|
||||
const layout = usePaneLayout();
|
||||
const dims = useTerminalDimensions();
|
||||
const width = () => dims().width;
|
||||
const pixels = createMemo(() => splitPixels(width(), layout.splits()));
|
||||
const hasRoom = () =>
|
||||
width() >=
|
||||
MIN_PANE_WIDTH.parent + MIN_PANE_WIDTH.current + MIN_PANE_WIDTH.preview;
|
||||
|
||||
// Column widths in pixels (sum to the row width).
|
||||
const parentWidth = () => pixels().leftPx;
|
||||
const currentWidth = () =>
|
||||
panes() === 2
|
||||
? width() - pixels().leftPx
|
||||
: pixels().rightPx - pixels().leftPx;
|
||||
const previewWidth = () => width() - pixels().rightPx;
|
||||
|
||||
// ── Drag state ──────────────────────────────────────────────────────────
|
||||
// onMouseDown on a Splitter records which border is being dragged; the
|
||||
// row then lives-updates the split from the absolute drag x (bubbled up
|
||||
// from whatever renderable the cursor captures) and commits on release.
|
||||
const [activeSplit, setActiveSplit] = createSignal<"left" | "right" | null>(
|
||||
null,
|
||||
);
|
||||
const beginDrag = (which: "left" | "right") => () => setActiveSplit(which);
|
||||
const handleDrag = (e: { x: number }) => {
|
||||
const which = activeSplit();
|
||||
if (!which) return;
|
||||
if (which === "left") layout.setLeft(e.x, width());
|
||||
else layout.setRight(e.x, width());
|
||||
};
|
||||
const handleDragEnd = () => {
|
||||
if (activeSplit()) layout.commit();
|
||||
setActiveSplit(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── parent (20%) — previous-depth list; title row top-left ────────── */}
|
||||
<box
|
||||
flexDirection="row"
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexGrow={1}
|
||||
onMouseDrag={handleDrag}
|
||||
onMouseDragEnd={handleDragEnd}
|
||||
onMouseUp={handleDragEnd}
|
||||
>
|
||||
{/* ── parent — previous-depth list; title row top-left ─────────────── */}
|
||||
<Pane
|
||||
grow={PANE_RATIO.parent}
|
||||
width={parentWidth()}
|
||||
label={currentLabel}
|
||||
content={parentContent}
|
||||
border={false}
|
||||
@@ -200,22 +281,37 @@ export function PaneRow(props: PaneRowProps) {
|
||||
/>
|
||||
{/* ── current — the focused list; left/right borders only ─────────── */}
|
||||
<Pane
|
||||
grow={currentGrow()}
|
||||
width={currentWidth()}
|
||||
label={() => ""}
|
||||
content={currentContent}
|
||||
border={currentBorder()}
|
||||
scrollFocused={() => focused()}
|
||||
/>
|
||||
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
|
||||
{/* ── preview (optional) — hovered-item detail; no border ─────────── */}
|
||||
<Show when={panes() === 3}>
|
||||
<Pane
|
||||
grow={PANE_RATIO.preview}
|
||||
width={previewWidth()}
|
||||
label={() => ""}
|
||||
content={previewContent}
|
||||
border={false}
|
||||
scrollFocused={() => false}
|
||||
/>
|
||||
</Show>
|
||||
{/* ── drag handles over the current pane's borders ───────────────── */}
|
||||
<Show when={hasRoom()}>
|
||||
<Splitter
|
||||
left={pixels().leftPx}
|
||||
active={activeSplit() === "left"}
|
||||
onBegin={beginDrag("left")}
|
||||
/>
|
||||
<Show when={panes() === 3}>
|
||||
<Splitter
|
||||
left={pixels().rightPx - 1}
|
||||
active={activeSplit() === "right"}
|
||||
onBegin={beginDrag("right")}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
saveLastPlayerSync,
|
||||
} from "../utils/app-persistence";
|
||||
import type { Episode, Progress } from "../types/episode";
|
||||
import { feedForEpisode } from "../utils/feed-resolve";
|
||||
import { useAudioNavStore } from "../stores/audio-nav";
|
||||
import { useDownloadStore } from "../stores/download";
|
||||
import { useFeedStore } from "../stores/feed";
|
||||
@@ -359,8 +360,7 @@ async function play(episode: Episode): Promise<void> {
|
||||
const vol = volume();
|
||||
const spd = storeSpeed || speed();
|
||||
|
||||
const feedStore = useFeedStore();
|
||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||
const feed = feedForEpisode(useFeedStore().feeds(), episode);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
// Play the downloaded file when present (offline + no network stalls);
|
||||
// otherwise stream. Cover resolves to the feed art, falling back to the
|
||||
@@ -468,8 +468,7 @@ async function load(episode: Episode): Promise<void> {
|
||||
setSpeed(storeSpeed || speed());
|
||||
|
||||
// Surface the loaded-but-paused track to the OS media controls.
|
||||
const feedStore = useFeedStore();
|
||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||
const feed = feedForEpisode(useFeedStore().feeds(), episode);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
const media = useMediaRegistry();
|
||||
media.setNowPlaying({
|
||||
@@ -688,10 +687,7 @@ async function switchBackend(name: BackendName): Promise<void> {
|
||||
// Resume playback if we were playing
|
||||
if (wasPlaying && ep && ep.audioUrl) {
|
||||
try {
|
||||
const feedStore = useFeedStore();
|
||||
const feed = feedStore
|
||||
.feeds()
|
||||
.find((f) => f.podcast.id === ep.podcastId);
|
||||
const feed = feedForEpisode(useFeedStore().feeds(), ep);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
const url =
|
||||
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
|
||||
|
||||
@@ -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.1";
|
||||
const VERSION = "0.8.0";
|
||||
|
||||
interface CliArgs {
|
||||
version: boolean;
|
||||
@@ -234,10 +237,13 @@ if (cliArgs.query !== null || cliArgs.play !== null) {
|
||||
const { NavigationProvider } = await import("./context/NavigationContext");
|
||||
const { DialogProvider } = await import("./ui/dialog");
|
||||
const { CommandProvider } = await import("./ui/command");
|
||||
// Nested scroll sections favor the innermost one under the cursor.
|
||||
installNestedScrollBehavior();
|
||||
|
||||
function RendererSetup(props: { children: unknown }) {
|
||||
const renderer = useRenderer();
|
||||
renderer.disableStdoutInterception();
|
||||
onCleanup(setupTerminalRecovery(renderer));
|
||||
return props.children;
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -19,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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,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),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -39,6 +39,11 @@ const MAX_EPISODES_REFRESH = 50;
|
||||
/** Max episodes to fetch on initial subscribe */
|
||||
const MAX_EPISODES_SUBSCRIBE = 20;
|
||||
|
||||
/** Floor on the visible episode window for a subscribed show: at least this
|
||||
* many most-recent episodes always load, regardless of a stricter count or
|
||||
* date cache bound. Overridden by episodeKeepFn. */
|
||||
const MIN_EPISODES_PER_SHOW = 5;
|
||||
|
||||
/** Fetch-more step in date mode: each press reveals the next two weeks of
|
||||
* episodes past the oldest loaded one, instead of a fixed episode count. */
|
||||
const FETCH_MORE_WINDOW_DAYS = 14;
|
||||
@@ -123,7 +128,10 @@ const fullEpisodeCache = new Map<string, Episode[]>();
|
||||
const episodeLoadCount = new Map<string, number>();
|
||||
|
||||
/** Read the episode cache bound from preferences: a closure that decides
|
||||
* whether the episode at `index` (0 = newest, after sort) is kept. */
|
||||
* whether the episode at `index` (0 = newest, after sort) is kept. The five
|
||||
* most-recent episodes of a subscribed show always stay (MIN_EPISODES_PER_SHOW),
|
||||
* overriding a stricter count or date bound so every show surfaces at least
|
||||
* five episodes. */
|
||||
function episodeKeepFn(prefs: {
|
||||
episodeCacheMode: "date" | "count";
|
||||
episodeCacheCount: number;
|
||||
@@ -132,10 +140,12 @@ function episodeKeepFn(prefs: {
|
||||
const now = new Date();
|
||||
if (prefs.episodeCacheMode === "count") {
|
||||
const count = Math.max(1, prefs.episodeCacheCount);
|
||||
return (_ep: Episode, index: number) => index < count;
|
||||
return (_ep: Episode, index: number) =>
|
||||
index < Math.max(count, MIN_EPISODES_PER_SHOW);
|
||||
}
|
||||
const days = Math.max(1, prefs.episodeCacheDays);
|
||||
return (ep: Episode) => episodeInWindow(ep, now, days);
|
||||
return (ep: Episode, index: number) =>
|
||||
index < MIN_EPISODES_PER_SHOW || episodeInWindow(ep, now, days);
|
||||
}
|
||||
|
||||
/** Timestamp for window math — undated episodes sort/compare as NEWEST
|
||||
|
||||
144
src/stores/pane-layout.ts
Normal file
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;
|
||||
}
|
||||
@@ -132,11 +132,11 @@ function createVisualizerStore(): VisualizerStore {
|
||||
let lastPosMoveAt = 0;
|
||||
|
||||
// Resume point: the position a paused pipeline was re-armed at. The
|
||||
// loading state set by resume only clears once the position clock has
|
||||
// advanced PAST this — while the player is still re-buffering, the
|
||||
// cache can serve the same window forever and the stale pre-pause bars
|
||||
// must not masquerade as live data. -1 = cold start (clear on the
|
||||
// first produced frame, regardless of the clock).
|
||||
// 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
|
||||
@@ -379,12 +379,14 @@ function createVisualizerStore(): VisualizerStore {
|
||||
|
||||
// Normalize against the running peak and copy to a new array
|
||||
setBarData(scaler(output));
|
||||
// Fresh frames only count once the position clock has moved past
|
||||
// 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. Cold starts (resumePos < 0)
|
||||
// clear on the first frame as before.
|
||||
if (isLoading() && (resumePos < 0 || rawPos > resumePos)) {
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -383,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
|
||||
|
||||
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))
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
72
src/utils/nested-scroll.ts
Normal file
72
src/utils/nested-scroll.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Nested scroll sections favor the innermost one under the cursor.
|
||||
*
|
||||
* opentui bubbles a wheel event up the renderable tree, so every ancestor
|
||||
* `ScrollBoxRenderable` that has room to move scrolls — nested sections (e.g.
|
||||
* the episode-description scrollbox inside a page's list pane) scroll in
|
||||
* lockstep. This patches the scrollbox's wheel handler so the innermost
|
||||
* scrollbox under the cursor wins instead:
|
||||
*
|
||||
* • The first scrollbox that can move in the wheel's direction scrolls and
|
||||
* stops propagation, so its ancestors don't also scroll.
|
||||
* • When it is already at its boundary it lets the next outer scrollbox
|
||||
* take over (wheel chaining), matching typical nested-scroll UX.
|
||||
*/
|
||||
|
||||
import { ScrollBoxRenderable } from "@opentui/core";
|
||||
import type { MouseEvent } from "@opentui/core";
|
||||
|
||||
type ScrollDir = "up" | "down" | "left" | "right";
|
||||
|
||||
// The scrollbox's own wheel handler (scrolls, then bubbles to its parent).
|
||||
const original = ScrollBoxRenderable.prototype.onMouseEvent;
|
||||
|
||||
let installed = false;
|
||||
|
||||
/** True when `sb` has room to move in `dir` from its current position. */
|
||||
function canScroll(sb: ScrollBoxRenderable, dir: ScrollDir): boolean {
|
||||
const maxTop = Math.max(0, sb.scrollHeight - sb.viewport.height);
|
||||
const maxLeft = Math.max(0, sb.scrollWidth - sb.viewport.width);
|
||||
switch (dir) {
|
||||
case "up":
|
||||
return sb.scrollTop > 0;
|
||||
case "down":
|
||||
return sb.scrollTop < maxTop;
|
||||
case "left":
|
||||
return sb.scrollLeft > 0;
|
||||
case "right":
|
||||
return sb.scrollLeft < maxLeft;
|
||||
}
|
||||
}
|
||||
|
||||
const handleWheel = function (
|
||||
this: ScrollBoxRenderable,
|
||||
event: MouseEvent,
|
||||
): void {
|
||||
if (event.type !== "scroll" || !event.scroll?.direction) {
|
||||
original.call(this, event);
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = event.scroll.direction;
|
||||
const effective: ScrollDir = event.modifiers.shift
|
||||
? (dir === "up" ? "left" : dir === "down" ? "right" : dir === "right" ? "down" : "up")
|
||||
: dir;
|
||||
|
||||
const moves = canScroll(this, effective);
|
||||
original.call(this, event);
|
||||
// Only claim the wheel when this box actually moved; otherwise let the
|
||||
// next outer scrollbox (also under the cursor) take over.
|
||||
if (moves) event.stopPropagation();
|
||||
};
|
||||
|
||||
export function installNestedScrollBehavior(): void {
|
||||
if (installed || typeof original !== "function") return;
|
||||
installed = true;
|
||||
// `onMouseEvent` is a well-known protected method; the cast only bypasses
|
||||
// TypeScript's protected-access check and trusts the shipped class shape.
|
||||
const scrollboxProto = ScrollBoxRenderable.prototype as unknown as {
|
||||
onMouseEvent: typeof handleWheel;
|
||||
};
|
||||
scrollboxProto.onMouseEvent = handleWheel;
|
||||
}
|
||||
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);
|
||||
};
|
||||
}
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
162
tests/pane-resize.test.tsx
Normal file
162
tests/pane-resize.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* pane-resize.test.tsx — dragging the center column's borders actually
|
||||
* resizes the panes in a rendered PaneRow.
|
||||
*
|
||||
* Each pane renders a long run of a unique character (P / C / V). A line
|
||||
* where all three meet encodes the boundary columns directly: the current
|
||||
* pane carries the only borders (cols `leftPx` and `rightPx - 1`), so its
|
||||
* content starts one column in — `leftPx + 1`. Hence
|
||||
* `leftPx = firstC - 1`, `rightPx = firstV`.
|
||||
*
|
||||
* The drag strips overlay the border cells (left strip at [left, left+2),
|
||||
* right strip at [right-2, right)). The test presses inside a strip and
|
||||
* drags across the row — the drag bubbles to the row container which moves
|
||||
* the split, so the panes must re-render at the new columns.
|
||||
*/
|
||||
import { test, expect, afterAll } from "bun:test";
|
||||
import { testRender } from "@opentui/solid";
|
||||
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||
import { PaneRow } from "../src/components/PaneRow";
|
||||
import { usePaneLayout } from "../src/stores/pane-layout";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Point the config dir at a throwaway directory BEFORE importing the store
|
||||
// (module-level init reads it).
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-paneresize-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
type Span = { text: string };
|
||||
type Frame = { cols: number; lines: { spans: Span[] }[] };
|
||||
|
||||
interface BoundCols {
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
function readBounds(frame: Frame): BoundCols {
|
||||
const line = frame.lines
|
||||
.map((l) => l.spans.map((s) => s.text).join(""))
|
||||
.find((l) => l.includes("C"));
|
||||
if (!line) throw new Error("pane row did not render");
|
||||
return { left: line.indexOf("C") - 1, right: line.indexOf("V") };
|
||||
}
|
||||
|
||||
async function renderRow(panes: 2 | 3 = 3) {
|
||||
const setup = (await testRender(
|
||||
() => (
|
||||
<ThemeProvider mode="dark">
|
||||
<PaneRow
|
||||
parent={<text selectable={false}>{"P".repeat(300)}</text>}
|
||||
current={<text selectable={false}>{"C".repeat(600)}</text>}
|
||||
preview={<text selectable={false}>{"V".repeat(300)}</text>}
|
||||
currentLabel=""
|
||||
panes={panes}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: 100, height: 10, useThread: false },
|
||||
)) as unknown as {
|
||||
renderOnce: () => Promise<void>;
|
||||
captureSpans: () => Frame;
|
||||
mockMouse: {
|
||||
drag: (a: number, b: number, c: number, d: number) => Promise<void>;
|
||||
click: (a: number, b: number) => Promise<void>;
|
||||
};
|
||||
renderer: { destroy: () => void };
|
||||
};
|
||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
||||
return setup;
|
||||
}
|
||||
|
||||
/** Reset the shared store to the default split for a deterministic start. */
|
||||
function resetSplits() {
|
||||
usePaneLayout().setLeft(20, 100);
|
||||
usePaneLayout().setRight(70, 100);
|
||||
}
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
afterAll(() => {
|
||||
for (const c of cleanups) c();
|
||||
// Restore the default split so a later file sharing this process (bun
|
||||
// test reuses the module registry) renders the default layout.
|
||||
usePaneLayout().setLeft(20, 100);
|
||||
usePaneLayout().setRight(70, 100);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("panes render at the default 20/70 split", async () => {
|
||||
const setup = await renderRow(3);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
await setup.renderOnce();
|
||||
const { left, right } = readBounds(setup.captureSpans());
|
||||
expect(left).toBe(20);
|
||||
expect(right).toBe(70);
|
||||
});
|
||||
|
||||
test("dragging the left border resizes parent vs current", async () => {
|
||||
const setup = await renderRow(3);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
await setup.renderOnce();
|
||||
|
||||
// Press on the left strip (border at 20 → strip covers 20) and drag
|
||||
// toward the middle of the row.
|
||||
await setup.mockMouse.drag(20, 5, 45, 5);
|
||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
||||
const after = readBounds(setup.captureSpans());
|
||||
expect(after.left).toBeGreaterThanOrEqual(44);
|
||||
expect(after.left).toBeLessThanOrEqual(46);
|
||||
// Pushing the left border to 45 would shrink the current pane below its
|
||||
// 30-col minimum (45..70 = 25), so the right border is forced right to
|
||||
// 75, keeping the current pane at exactly 30 and absorbing the overflow
|
||||
// in the preview.
|
||||
expect(after.right).toBe(75);
|
||||
});
|
||||
|
||||
test("dragging the right border resizes current vs preview", async () => {
|
||||
const setup = await renderRow(3);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
await setup.renderOnce();
|
||||
|
||||
// Press on the right strip (border at 69 → strip covers 69) and drag
|
||||
// toward the right edge of the row.
|
||||
await setup.mockMouse.drag(69, 5, 90, 5);
|
||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
||||
const after = readBounds(setup.captureSpans());
|
||||
expect(after.right).toBeGreaterThanOrEqual(84);
|
||||
expect(after.right).toBeLessThanOrEqual(85); // clamped at preview min 15
|
||||
expect(after.left).toBe(20);
|
||||
});
|
||||
|
||||
test("a plain click away from the borders does not resize", async () => {
|
||||
const setup = await renderRow(3);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
await setup.renderOnce();
|
||||
|
||||
await setup.mockMouse.click(5, 5);
|
||||
await setup.renderOnce();
|
||||
const { left, right } = readBounds(setup.captureSpans());
|
||||
expect(left).toBe(20);
|
||||
expect(right).toBe(70);
|
||||
});
|
||||
|
||||
test("2-pane rows offer no right border (current fills the row)", async () => {
|
||||
const setup = await renderRow(2);
|
||||
cleanups.push(() => setup.renderer.destroy());
|
||||
resetSplits();
|
||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
||||
|
||||
// No 'V' pane: the line runs to the screen edge.
|
||||
const frame = setup.captureSpans();
|
||||
const { left } = readBounds(frame);
|
||||
expect(left).toBe(20);
|
||||
const line = frame.lines
|
||||
.map((l) => l.spans.map((s) => s.text).join(""))
|
||||
.find((l) => l.includes("C"));
|
||||
expect(line?.includes("V")).toBe(false);
|
||||
});
|
||||
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();
|
||||
});
|
||||
@@ -342,6 +342,34 @@ test.skipIf(skip)(
|
||||
{ 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(() => {
|
||||
|
||||
Reference in New Issue
Block a user