Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 649baf40ab | |||
| 9702af640b | |||
| 48076fcef5 | |||
| 06c5cc9184 | |||
| a3641d100e | |||
| 8a173a5180 | |||
| 132d2079f7 | |||
| b53d4add29 | |||
| 6c3ad5d925 | |||
| 09d5732b55 | |||
| 9e2f232d27 | |||
| 9143078b12 | |||
| ca46de4d70 | |||
| 3e90f9e783 | |||
| c2ec356a5f |
@@ -1,9 +0,0 @@
|
|||||||
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...
|
|
||||||
));
|
|
||||||
@@ -595,9 +595,7 @@ async function snapshotState(audioControls: any): Promise<Record<string, unknown
|
|||||||
const feeds = fs_.feeds ? fs_.feeds() : [];
|
const feeds = fs_.feeds ? fs_.feeds() : [];
|
||||||
state.feed = {
|
state.feed = {
|
||||||
count: feeds?.length ?? 0,
|
count: feeds?.length ?? 0,
|
||||||
sel: fs_.selectedFeedId ? fs_.selectedFeedId() : null,
|
|
||||||
loading: fs_.isLoadingFeeds ? fs_.isLoadingFeeds() : null,
|
loading: fs_.isLoadingFeeds ? fs_.isLoadingFeeds() : null,
|
||||||
titles: (feeds ?? []).slice(0, 8).map((f: any) => f?.podcast?.title),
|
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state.feed = "ERR: " + String(e);
|
state.feed = "ERR: " + String(e);
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
* CENTER (current) column are draggable and resize the neighboring panes.
|
* CENTER (current) column are draggable and resize the neighboring panes.
|
||||||
* Split positions live in the shared pane-layout store (`@/stores/pane-layout`)
|
* Split positions live in the shared pane-layout store (`@/stores/pane-layout`)
|
||||||
* as fractions of the row width; this component resolves them to pixel
|
* as fractions of the row width; this component resolves them to pixel
|
||||||
* columns, gives each column an explicit width (so the drag strips sit
|
* columns, gives each column an explicit width (so the grab zones sit
|
||||||
* exactly on the drawn borders), and renders two invisible grab handles over
|
* exactly on the drawn borders), and renders two 3-column invisible grab
|
||||||
* the border cells.
|
* zones over the borders.
|
||||||
*
|
*
|
||||||
* Column semantics (per the yazi depth model):
|
* Column semantics (per the yazi depth model):
|
||||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||||
@@ -160,15 +160,22 @@ function Pane(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A 1-column invisible grab handle covering exactly one border of the
|
/** A 3-column invisible grab zone centered on one border of the current
|
||||||
* current pane. `onBegin` is called on mousedown; subsequent drag/drag-end
|
* pane: the border column plus one column of help padding on each side,
|
||||||
* events bubble up the row and drive `usePaneLayout` there. On hover or
|
* so the thin border is easy to target with a mouse. `onBegin` is called
|
||||||
* while dragging it overdraws the border with a full-height accent `│`
|
* on mousedown with the cursor's x; the row records that grab offset so
|
||||||
* line (a bordered box would render as a blocky rectangle instead). */
|
* 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: {
|
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;
|
left: number;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onBegin: () => void;
|
onBegin: (x: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const dims = useTerminalDimensions();
|
const dims = useTerminalDimensions();
|
||||||
@@ -177,13 +184,13 @@ function Splitter(props: {
|
|||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
left={props.left}
|
left={props.left - 1}
|
||||||
top={0}
|
top={0}
|
||||||
width={1}
|
width={3}
|
||||||
height="100%"
|
height="100%"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault?.();
|
e.preventDefault?.();
|
||||||
props.onBegin();
|
props.onBegin(e.x);
|
||||||
}}
|
}}
|
||||||
onMouseOver={() => setHovered(true)}
|
onMouseOver={() => setHovered(true)}
|
||||||
onMouseOut={() => setHovered(false)}
|
onMouseOut={() => setHovered(false)}
|
||||||
@@ -192,7 +199,7 @@ function Splitter(props: {
|
|||||||
{/* Draw the accent edge down the full pane height; the box clips
|
{/* Draw the accent edge down the full pane height; the box clips
|
||||||
* any excess rows below the row's bottom edge. */}
|
* any excess rows below the row's bottom edge. */}
|
||||||
<text fg={theme.primary} selectable={false}>
|
<text fg={theme.primary} selectable={false}>
|
||||||
{"│\n".repeat(dims().height)}
|
{" │\n".repeat(dims().height)}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
@@ -243,22 +250,34 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
const previewWidth = () => width() - pixels().rightPx;
|
const previewWidth = () => width() - pixels().rightPx;
|
||||||
|
|
||||||
// ── Drag state ──────────────────────────────────────────────────────────
|
// ── Drag state ──────────────────────────────────────────────────────────
|
||||||
// onMouseDown on a Splitter records which border is being dragged; the
|
// onMouseDown on a Splitter records which border is being dragged and
|
||||||
// row then lives-updates the split from the absolute drag x (bubbled up
|
// the cursor's grab offset from that border's column; the row then
|
||||||
// from whatever renderable the cursor captures) and commits on release.
|
// 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>(
|
const [activeSplit, setActiveSplit] = createSignal<"left" | "right" | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const beginDrag = (which: "left" | "right") => () => setActiveSplit(which);
|
// 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 handleDrag = (e: { x: number }) => {
|
||||||
const which = activeSplit();
|
const which = activeSplit();
|
||||||
if (!which) return;
|
if (!which) return;
|
||||||
if (which === "left") layout.setLeft(e.x, width());
|
if (which === "left") layout.setLeft(e.x - grabOffset, width());
|
||||||
else layout.setRight(e.x, width());
|
else layout.setRight(e.x - grabOffset, width());
|
||||||
};
|
};
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
if (activeSplit()) layout.commit();
|
if (activeSplit()) layout.commit();
|
||||||
setActiveSplit(null);
|
setActiveSplit(null);
|
||||||
|
grabOffset = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -300,13 +319,13 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
{/* ── drag handles over the current pane's borders ───────────────── */}
|
{/* ── drag handles over the current pane's borders ───────────────── */}
|
||||||
<Show when={hasRoom()}>
|
<Show when={hasRoom()}>
|
||||||
<Splitter
|
<Splitter
|
||||||
left={pixels().leftPx}
|
left={borderCol("left")}
|
||||||
active={activeSplit() === "left"}
|
active={activeSplit() === "left"}
|
||||||
onBegin={beginDrag("left")}
|
onBegin={beginDrag("left")}
|
||||||
/>
|
/>
|
||||||
<Show when={panes() === 3}>
|
<Show when={panes() === 3}>
|
||||||
<Splitter
|
<Splitter
|
||||||
left={pixels().rightPx - 1}
|
left={borderCol("right")}
|
||||||
active={activeSplit() === "right"}
|
active={activeSplit() === "right"}
|
||||||
onBegin={beginDrag("right")}
|
onBegin={beginDrag("right")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Reactive SolidJS hook wrapping the AudioBackend.
|
* Reactive SolidJS hook over the module-level audio engine.
|
||||||
*
|
*
|
||||||
* Provides signals for playback state and methods for controlling
|
* Wraps utils/audio-engine: every useAudio() call shares ONE engine (all
|
||||||
* audio. Integrates with the event bus and app store.
|
* playback logic, the 150ms poll, session restore, and the event-bus
|
||||||
|
* commands live there). This hook keeps only what is tied to the Solid
|
||||||
|
* lifecycle — the ref-counted last-owner dispose and the process-exit
|
||||||
|
* teardown — and re-exposes the two controls the engine deliberately omits
|
||||||
|
* (availablePlayers, switchBackend).
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* ```tsx
|
* ```tsx
|
||||||
@@ -14,124 +18,42 @@
|
|||||||
|
|
||||||
import { onCleanup } from "solid-js";
|
import { onCleanup } from "solid-js";
|
||||||
import {
|
import {
|
||||||
cachedCoverPath,
|
availablePlayers,
|
||||||
fetchCoverArt,
|
currentEpisode,
|
||||||
} from "../utils/cover-art";
|
|
||||||
import {
|
|
||||||
createAudioBackend,
|
|
||||||
detectPlayers,
|
|
||||||
PlayerRestartedError,
|
|
||||||
type AudioBackend,
|
|
||||||
type BackendName,
|
|
||||||
type DetectedPlayer,
|
|
||||||
} from "../utils/audio-player";
|
|
||||||
import {
|
|
||||||
isPlaying,
|
|
||||||
setIsPlaying,
|
|
||||||
position,
|
|
||||||
setPosition,
|
|
||||||
duration,
|
|
||||||
setDuration,
|
|
||||||
volume,
|
|
||||||
setVolume,
|
|
||||||
speed,
|
speed,
|
||||||
setSpeed,
|
setSpeed,
|
||||||
backendName,
|
volume,
|
||||||
setBackendName,
|
setVolume,
|
||||||
error,
|
|
||||||
setError,
|
|
||||||
currentEpisode,
|
|
||||||
setCurrentEpisode,
|
|
||||||
availablePlayers,
|
|
||||||
setAvailablePlayers,
|
|
||||||
} from "../utils/audio-signals";
|
} from "../utils/audio-signals";
|
||||||
import { emit, on } from "../utils/event-bus";
|
|
||||||
import { useAppStore } from "../stores/app";
|
import { useAppStore } from "../stores/app";
|
||||||
import { useProgressStore } from "../stores/progress";
|
|
||||||
import { useMediaRegistry } from "../utils/media-registry";
|
import { useMediaRegistry } from "../utils/media-registry";
|
||||||
|
import { saveLastPlayerSync } from "../utils/app-persistence";
|
||||||
|
import type { BackendName, DetectedPlayer } from "../utils/audio-player";
|
||||||
import {
|
import {
|
||||||
loadLastPlayerFromFile,
|
createAudioEngine,
|
||||||
saveLastPlayerToFile,
|
ensureEngineBackend,
|
||||||
saveLastPlayerSync,
|
disposeEngineBackend,
|
||||||
} from "../utils/app-persistence";
|
stopEnginePolling,
|
||||||
import type { Episode, Progress } from "../types/episode";
|
getEngineBackend,
|
||||||
import { feedForEpisode } from "../utils/feed-resolve";
|
switchBackend,
|
||||||
import { useAudioNavStore } from "../stores/audio-nav";
|
restoreLastSession,
|
||||||
import { useDownloadStore } from "../stores/download";
|
type AudioEngine,
|
||||||
import { useFeedStore } from "../stores/feed";
|
} from "../utils/audio-engine";
|
||||||
import { useSearchStore } from "../stores/search";
|
|
||||||
import {
|
|
||||||
nextStep,
|
|
||||||
prevStep,
|
|
||||||
queueForSource,
|
|
||||||
} from "../utils/audio-queue";
|
|
||||||
|
|
||||||
export interface AudioControls {
|
// Re-exported so the session-restore test can pull it from this module.
|
||||||
// Signals (reactive getters)
|
export { restoreLastSession };
|
||||||
isPlaying: () => boolean;
|
|
||||||
position: () => number;
|
// useAudio() surface: the engine plus the two controls it doesn't expose.
|
||||||
duration: () => number;
|
export type AudioControls = AudioEngine & {
|
||||||
volume: () => number;
|
|
||||||
speed: () => number;
|
|
||||||
backendName: () => BackendName;
|
|
||||||
error: () => string | null;
|
|
||||||
currentEpisode: () => Episode | null;
|
|
||||||
availablePlayers: () => DetectedPlayer[];
|
availablePlayers: () => DetectedPlayer[];
|
||||||
|
|
||||||
// Actions
|
|
||||||
play: (episode: Episode) => Promise<void>;
|
|
||||||
/** Load an episode into the player WITHOUT starting playback. */
|
|
||||||
load: (episode: Episode) => Promise<void>;
|
|
||||||
pause: () => Promise<void>;
|
|
||||||
resume: () => Promise<void>;
|
|
||||||
togglePlayback: () => Promise<void>;
|
|
||||||
stop: () => Promise<void>;
|
|
||||||
seek: (seconds: number) => Promise<void>;
|
|
||||||
seekRelative: (delta: number) => Promise<void>;
|
|
||||||
setVolume: (volume: number) => Promise<void>;
|
|
||||||
setSpeed: (speed: number) => Promise<void>;
|
|
||||||
switchBackend: (name: BackendName) => Promise<void>;
|
switchBackend: (name: BackendName) => Promise<void>;
|
||||||
prev: () => Promise<void>;
|
};
|
||||||
next: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton state — shared across all components that call useAudio()
|
const engine = createAudioEngine();
|
||||||
let backend: AudioBackend | null = null;
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
// Singleton ref count — how many live useAudio() owners there are. The engine
|
||||||
|
// is shared; the last owner to unmount disposes the backend.
|
||||||
let refCount = 0;
|
let refCount = 0;
|
||||||
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
|
||||||
|
|
||||||
// Playback signals are declared in utils/audio-signals.ts (imported above)
|
|
||||||
// so non-component consumers (the visualizer store) can subscribe without
|
|
||||||
// mounting a useAudio() owner.
|
|
||||||
|
|
||||||
/** True once the current episode has been handed to the backend (play
|
|
||||||
* started). `false` means the episode is only LOADED in the player (e.g.
|
|
||||||
* restored at boot) and the first play action must start the backend
|
|
||||||
* instead of unpausing it. */
|
|
||||||
let startedPlayback = false;
|
|
||||||
|
|
||||||
/** Completion fraction at/above which an episode is NOT restored at boot. */
|
|
||||||
const RESTORE_COMPLETION_THRESHOLD = 0.98;
|
|
||||||
|
|
||||||
/** True when saved progress is below the restore cutoff. Episodes with no
|
|
||||||
* progress (never reached the persist threshold) or unknown duration count
|
|
||||||
* as eligible — they restore from the start. */
|
|
||||||
function isRestoreEligible(progress: Progress | undefined): boolean {
|
|
||||||
if (!progress || progress.duration <= 0) return true;
|
|
||||||
return progress.position / progress.duration < RESTORE_COMPLETION_THRESHOLD;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureBackend(): AudioBackend {
|
|
||||||
if (!backend) {
|
|
||||||
const detected = detectPlayers();
|
|
||||||
setAvailablePlayers(detected);
|
|
||||||
backend = createAudioBackend();
|
|
||||||
setBackendName(backend.name);
|
|
||||||
registerExitTeardown();
|
|
||||||
}
|
|
||||||
return backend;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Process-exit teardown ─────────────────────────────────────────────
|
// ── Process-exit teardown ─────────────────────────────────────────────
|
||||||
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
|
// `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's
|
||||||
@@ -146,7 +68,7 @@ function registerExitTeardown(): void {
|
|||||||
if (exitTeardownRegistered) return;
|
if (exitTeardownRegistered) return;
|
||||||
exitTeardownRegistered = true;
|
exitTeardownRegistered = true;
|
||||||
const teardown = (): void => {
|
const teardown = (): void => {
|
||||||
stopPolling();
|
stopEnginePolling();
|
||||||
// Persist "what's loaded in the player right now" synchronously —
|
// Persist "what's loaded in the player right now" synchronously —
|
||||||
// process.exit(0) runs this handler synchronously and an async write
|
// process.exit(0) runs this handler synchronously and an async write
|
||||||
// would never land. The next launch restores this episode paused.
|
// would never land. The next launch restores this episode paused.
|
||||||
@@ -159,7 +81,7 @@ function registerExitTeardown(): void {
|
|||||||
/* best-effort at exit */
|
/* best-effort at exit */
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
backend?.dispose();
|
getEngineBackend()?.dispose();
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort at exit */
|
/* best-effort at exit */
|
||||||
}
|
}
|
||||||
@@ -178,646 +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. `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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive audio controls hook.
|
* Reactive audio controls hook.
|
||||||
*
|
*
|
||||||
* Returns a singleton — all components share the same playback state.
|
* Returns the shared audio engine wrapped with the two extra controls, so
|
||||||
* Registers event bus listeners and cleans them up with onCleanup.
|
* all components observe the same playback state. The first useAudio()
|
||||||
|
* owner creates the backend, runs the one-time boot (volume/speed sync +
|
||||||
|
* session restore) and registers the process-exit teardown; the last
|
||||||
|
* owner disposes the backend.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ── Episode queue navigation ──────────────────────────────────────────────
|
|
||||||
// `next`/`prev` (and the end-of-episode auto-advance in finalizeTrackEnd)
|
|
||||||
// move within the ordered list of the source that STARTED the current
|
|
||||||
// episode: the Feed's chronological list, the current show's episodes, or
|
|
||||||
// the search results (see utils/audio-queue). Module-level so
|
|
||||||
// finalizeTrackEnd can auto-advance without a mounted hook owner.
|
|
||||||
|
|
||||||
const audioNav = useAudioNavStore();
|
|
||||||
|
|
||||||
/** The ordered playable episodes for the source that started playback. */
|
|
||||||
function queueForCurrentSource(): Episode[] {
|
|
||||||
const feedStore = useFeedStore();
|
|
||||||
return queueForSource(
|
|
||||||
audioNav.getSource(),
|
|
||||||
audioNav.getPodcastId(),
|
|
||||||
feedStore.feeds(),
|
|
||||||
feedStore.getAllEpisodesChronological(),
|
|
||||||
useSearchStore().results(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function next(): Promise<void> {
|
|
||||||
const current = currentEpisode();
|
|
||||||
if (!current) return;
|
|
||||||
const step = nextStep(queueForCurrentSource(), current.id);
|
|
||||||
// A duplicated queue entry (same episode id twice) must not make
|
|
||||||
// "next" replay the CURRENT episode — that would reload it from
|
|
||||||
// saved progress and audibly repeat already-played audio.
|
|
||||||
if (!step || step.episode.id === current.id) return;
|
|
||||||
await play(step.episode);
|
|
||||||
audioNav.next(step.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prev(): Promise<void> {
|
|
||||||
const current = currentEpisode();
|
|
||||||
if (!current) return;
|
|
||||||
|
|
||||||
// Standard transport behavior: past 30s in, "prev" restarts the current
|
|
||||||
// episode; before that it steps back within the source queue.
|
|
||||||
const NAV_START_THRESHOLD = 30;
|
|
||||||
const currentPos = position();
|
|
||||||
const currentDur = duration();
|
|
||||||
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
|
|
||||||
await seek(NAV_START_THRESHOLD);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const step = prevStep(queueForCurrentSource(), current.id);
|
|
||||||
if (!step) return;
|
|
||||||
await play(step.episode);
|
|
||||||
audioNav.prev(step.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAudio(): AudioControls {
|
export function useAudio(): AudioControls {
|
||||||
// Initialize backend on first use
|
const engine = createAudioEngine();
|
||||||
ensureBackend();
|
ensureEngineBackend();
|
||||||
|
registerExitTeardown();
|
||||||
|
|
||||||
// Sync initial speed/volume from app store (reuse the previous session's
|
// First owner: sync speed/volume from the persisted settings and restore
|
||||||
// playback levels; defaults are 1x and 100%).
|
// the last player session once (loaded, not playing). Raw signal
|
||||||
|
// accessors are used here on purpose — this is boot-only, not a user
|
||||||
|
// volume/speed change, so it must not re-persist to the app store.
|
||||||
if (refCount === 0) {
|
if (refCount === 0) {
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||||
@@ -843,86 +143,13 @@ export function useAudio(): AudioControls {
|
|||||||
|
|
||||||
refCount++;
|
refCount++;
|
||||||
|
|
||||||
// Listen for event bus commands (e.g. from other components)
|
|
||||||
const unsubPlay = on("player.play", async (data) => {
|
|
||||||
// External play requests — currently just tracks episodeId.
|
|
||||||
// Episode lookup would require feed store integration.
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubStop = on("player.stop", async () => {
|
|
||||||
if (backend && isPlaying()) {
|
|
||||||
await backend.stop();
|
|
||||||
setIsPlaying(false);
|
|
||||||
setPosition(0);
|
|
||||||
setCurrentEpisode(null);
|
|
||||||
stopPolling();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for global multimedia key events (from useMultimediaKeys)
|
|
||||||
const unsubMediaToggle = on("media.toggle", async () => {
|
|
||||||
await togglePlayback();
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubMediaVolUp = on("media.volumeUp", async () => {
|
|
||||||
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))));
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubMediaVolDown = on("media.volumeDown", async () => {
|
|
||||||
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubMediaSpeed = on("media.speedCycle", async () => {
|
|
||||||
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
|
|
||||||
await doSetSpeed(next);
|
|
||||||
});
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
refCount--;
|
refCount--;
|
||||||
unsubPlay();
|
|
||||||
unsubStop();
|
|
||||||
unsubMediaToggle();
|
|
||||||
unsubMediaVolUp();
|
|
||||||
unsubMediaVolDown();
|
|
||||||
unsubMediaSpeed();
|
|
||||||
|
|
||||||
if (refCount <= 0) {
|
if (refCount <= 0) {
|
||||||
stopPolling();
|
disposeEngineBackend();
|
||||||
if (backend) {
|
|
||||||
backend.dispose();
|
|
||||||
backend = null;
|
|
||||||
}
|
|
||||||
// Clear media registry on full teardown
|
|
||||||
const media = useMediaRegistry();
|
|
||||||
media.clearNowPlaying();
|
|
||||||
|
|
||||||
refCount = 0;
|
refCount = 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { ...engine, availablePlayers, switchBackend };
|
||||||
isPlaying,
|
|
||||||
position,
|
|
||||||
duration,
|
|
||||||
volume,
|
|
||||||
speed,
|
|
||||||
backendName,
|
|
||||||
error,
|
|
||||||
currentEpisode,
|
|
||||||
availablePlayers,
|
|
||||||
|
|
||||||
play,
|
|
||||||
load,
|
|
||||||
pause,
|
|
||||||
resume,
|
|
||||||
togglePlayback,
|
|
||||||
stop,
|
|
||||||
seek,
|
|
||||||
seekRelative,
|
|
||||||
setVolume: doSetVolume,
|
|
||||||
setSpeed: doSetSpeed,
|
|
||||||
switchBackend,
|
|
||||||
prev,
|
|
||||||
next,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { installNestedScrollBehavior } from "./utils/nested-scroll";
|
|||||||
import type { Feed } from "./types/feed"
|
import type { Feed } from "./types/feed"
|
||||||
import type { Episode } from "./types/episode"
|
import type { Episode } from "./types/episode"
|
||||||
|
|
||||||
const VERSION = "0.8.0";
|
const VERSION = "0.9.1";
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
version: boolean;
|
version: boolean;
|
||||||
|
|||||||
@@ -7,25 +7,31 @@ import { createSignal } from "solid-js";
|
|||||||
import { Effect } from "effect";
|
import { Effect } from "effect";
|
||||||
import { refreshFeedsBatch } from "../effects/feed-refresh";
|
import { refreshFeedsBatch } from "../effects/feed-refresh";
|
||||||
import { FeedVisibility } from "../types/feed";
|
import { FeedVisibility } from "../types/feed";
|
||||||
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
|
import type { Feed } from "../types/feed";
|
||||||
import type { Podcast } from "../types/podcast";
|
import type { Podcast } from "../types/podcast";
|
||||||
import type { Episode } from "../types/episode";
|
import type { Episode } from "../types/episode";
|
||||||
import type { PodcastSource } from "../types/source";
|
import type { PodcastSource } from "../types/source";
|
||||||
import { DEFAULT_SOURCES } from "../types/source";
|
import { DEFAULT_SOURCES } from "../types/source";
|
||||||
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
||||||
|
import { FETCH_TIMEOUT_MS, fetchFeedXml } from "../utils/rss-client";
|
||||||
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
||||||
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
||||||
|
import { mergeEpisodesBounded } from "../utils/episode-merge";
|
||||||
import {
|
import {
|
||||||
episodeSignature,
|
episodeKeepFn,
|
||||||
mergeEpisodesBounded,
|
episodeTs,
|
||||||
} from "../utils/episode-merge";
|
dateFetchMoreCutoff,
|
||||||
|
dateBandCount,
|
||||||
|
sameRefreshWindow,
|
||||||
|
} from "../utils/episode-windows";
|
||||||
|
import { createSourceRegistry } from "../utils/source-registry";
|
||||||
|
import { createPersistScheduler } from "./persist";
|
||||||
import {
|
import {
|
||||||
DEFAULT_EPISODE_WINDOW_DAYS,
|
DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
episodeInWindow,
|
|
||||||
loadFeedsFromFile,
|
loadFeedsFromFile,
|
||||||
saveFeedsToFile,
|
saveFeedsToFile,
|
||||||
loadSourcesFromFile,
|
|
||||||
saveSourcesToFile,
|
saveSourcesToFile,
|
||||||
|
loadSourcesFromFile,
|
||||||
} from "../utils/feeds-persistence";
|
} from "../utils/feeds-persistence";
|
||||||
import { useActivityStore } from "./activity";
|
import { useActivityStore } from "./activity";
|
||||||
import { useDownloadStore } from "./download";
|
import { useDownloadStore } from "./download";
|
||||||
@@ -33,25 +39,12 @@ import { useAppStore } from "./app";
|
|||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
|
|
||||||
/** Max episodes to load per page/chunk (count mode only — date mode steps
|
/** 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;
|
const MAX_EPISODES_REFRESH = 50;
|
||||||
|
|
||||||
/** Max episodes to fetch on initial subscribe */
|
/** Max episodes to fetch on initial subscribe */
|
||||||
const MAX_EPISODES_SUBSCRIBE = 20;
|
const MAX_EPISODES_SUBSCRIBE = 20;
|
||||||
|
|
||||||
/** 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;
|
|
||||||
|
|
||||||
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
|
|
||||||
* the background refresh loop. */
|
|
||||||
const FETCH_TIMEOUT_MS = 20_000;
|
|
||||||
|
|
||||||
/** Bounds simultaneous RSS requests during a refresh batch — a hung feed
|
/** Bounds simultaneous RSS requests during a refresh batch — a hung feed
|
||||||
* burns at most one slot for FETCH_TIMEOUT_MS instead of pinning the whole
|
* burns at most one slot for FETCH_TIMEOUT_MS instead of pinning the whole
|
||||||
* batch. */
|
* batch. */
|
||||||
@@ -127,73 +120,21 @@ const fullEpisodeCache = new Map<string, Episode[]>();
|
|||||||
* holds — when it reaches the cache length, hasMoreEpisodes flips false. */
|
* holds — when it reaches the cache length, hasMoreEpisodes flips false. */
|
||||||
const episodeLoadCount = new Map<string, number>();
|
const episodeLoadCount = new Map<string, number>();
|
||||||
|
|
||||||
/** Read the episode cache bound from preferences: a closure that decides
|
/** Write closure for the persist scheduler — reads the live feed signal
|
||||||
* whether the episode at `index` (0 = newest, after sort) is kept. The five
|
* (wired by createFeedStore) so a flush always lands the latest value. */
|
||||||
* most-recent episodes of a subscribed show always stay (MIN_EPISODES_PER_SHOW),
|
let readFeeds: () => Feed[] = () => [];
|
||||||
* overriding a stricter count or date bound so every show surfaces at least
|
|
||||||
* five episodes. */
|
|
||||||
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 < 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, now, days);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Timestamp for window math — undated episodes sort/compare as NEWEST
|
/** Shared trailing-edge debouncer for config.json writes ("feeds" domain);
|
||||||
* (Infinity) so they can never be excluded by a date cutoff. */
|
* sources persist immediately instead. */
|
||||||
const epTs = (ep: Episode): number => {
|
const persistScheduler = createPersistScheduler(() => {
|
||||||
const t = ep.pubDate?.getTime();
|
|
||||||
return t === undefined || Number.isNaN(t) ? Infinity : t;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Date-mode fetch-more cutoff: the oldest loaded episode's pubDate minus the
|
|
||||||
* 2-week band. With nothing loaded (a show whose episodes all fall outside
|
|
||||||
* the cache window), the band anchors at the cache-window edge (now minus
|
|
||||||
* the configured days) — a dormant show can't drag in arbitrarily old
|
|
||||||
* episodes just because the button is pressed. */
|
|
||||||
const dateFetchMoreCutoff = (
|
|
||||||
cached: Episode[],
|
|
||||||
loaded: number,
|
|
||||||
windowDays: number,
|
|
||||||
): number => {
|
|
||||||
if (loaded > 0) {
|
|
||||||
const t = epTs(cached[loaded - 1]);
|
|
||||||
if (Number.isFinite(t)) {
|
|
||||||
return t - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Nothing loaded: the band extends FETCH_MORE_WINDOW_DAYS before the
|
|
||||||
// cache-window edge (e.g. 60d → reveals the 60–74d slice).
|
|
||||||
return (
|
|
||||||
Date.now() -
|
|
||||||
Math.max(1, windowDays) * 24 * 3600 * 1000 -
|
|
||||||
FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Save feeds to file (async, fire-and-forget). */
|
|
||||||
function saveFeeds(feeds: Feed[]): void {
|
|
||||||
const prefs = useAppStore().state().preferences;
|
const prefs = useAppStore().state().preferences;
|
||||||
const days =
|
saveFeedsToFile(
|
||||||
|
readFeeds(),
|
||||||
prefs.episodeCacheMode === "date"
|
prefs.episodeCacheMode === "date"
|
||||||
? Math.max(1, prefs.episodeCacheDays)
|
? Math.max(1, prefs.episodeCacheDays)
|
||||||
: undefined;
|
: undefined,
|
||||||
saveFeedsToFile(feeds, days);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
/** Save sources to file (async, fire-and-forget) */
|
|
||||||
function saveSources(sources: PodcastSource[]): void {
|
|
||||||
saveSourcesToFile(sources);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Move plaintext apiKey/apiSecret (pre-keychain persistence) into the macOS
|
/** Move plaintext apiKey/apiSecret (pre-keychain persistence) into the macOS
|
||||||
* keychain, marking the source hasCredentials and stripping the plaintext.
|
* keychain, marking the source hasCredentials and stripping the plaintext.
|
||||||
@@ -239,39 +180,10 @@ async function migratePlaintextCredentials(
|
|||||||
return changed ? migrated : sources;
|
return changed ? migrated : sources;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the freshly fetched window matches the corresponding PREFIX of
|
|
||||||
* the existing episode list (id-set equality, order-insensitive). With
|
|
||||||
* union semantics the merged list legitimately contains episodes BEYOND the
|
|
||||||
* fetched window, so unchanged-detection must compare the fetched window
|
|
||||||
* against the existing list's prefix — comparing full lists would bump
|
|
||||||
* `lastUpdated` on every refresh. 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() {
|
function createFeedStore() {
|
||||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
readFeeds = () => feeds();
|
||||||
...DEFAULT_SOURCES,
|
const registry = createSourceRegistry(DEFAULT_SOURCES);
|
||||||
]);
|
|
||||||
const [filter, setFilter] = createSignal<FeedFilter>({
|
|
||||||
visibility: "all",
|
|
||||||
sortBy: "updated" as FeedSortField,
|
|
||||||
sortDirection: "desc",
|
|
||||||
});
|
|
||||||
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
|
|
||||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||||
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||||
/** Feed-page fetch-more presses in COUNT mode: the global list is capped
|
/** Feed-page fetch-more presses in COUNT mode: the global list is capped
|
||||||
@@ -280,93 +192,26 @@ function createFeedStore() {
|
|||||||
* dump deep history (see getAllEpisodesChronological). */
|
* dump deep history (see getAllEpisodesChronological). */
|
||||||
const [countFetchMorePresses, setCountFetchMorePresses] = createSignal(0);
|
const [countFetchMorePresses, setCountFetchMorePresses] = createSignal(0);
|
||||||
|
|
||||||
// ── Debounced persistence ───────────────────────────────────────────────
|
|
||||||
/** Trailing-edge debounce window for config.json writes. */
|
|
||||||
const SAVE_DEBOUNCE_MS = 250;
|
|
||||||
/** True when a save is scheduled but has not flushed yet. */
|
|
||||||
let savePending = false;
|
|
||||||
let pendingSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
/** Schedule a config.json write (trailing edge) — rapid state changes
|
|
||||||
* (a refresh batch landing feed-by-feed, pin toggles, load-more pages)
|
|
||||||
* collapse into one final write instead of one file rewrite per step. */
|
|
||||||
const scheduleSaveFeeds = (): void => {
|
const scheduleSaveFeeds = (): void => {
|
||||||
savePending = true;
|
persistScheduler.schedule("feeds");
|
||||||
if (pendingSaveTimer) clearTimeout(pendingSaveTimer);
|
|
||||||
pendingSaveTimer = setTimeout(() => {
|
|
||||||
pendingSaveTimer = null;
|
|
||||||
flushPendingSave();
|
|
||||||
}, SAVE_DEBOUNCE_MS);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Persist immediately when anything is dirty; exported for tests and
|
|
||||||
* quit hooks. Cancels a pending debounced save — the state it would
|
|
||||||
* have written is already reflected in feeds(), so writing now is
|
|
||||||
* strictly more current. */
|
|
||||||
const flushPendingSave = (): void => {
|
const flushPendingSave = (): void => {
|
||||||
if (pendingSaveTimer) {
|
persistScheduler.flush("feeds");
|
||||||
clearTimeout(pendingSaveTimer);
|
|
||||||
pendingSaveTimer = null;
|
|
||||||
}
|
|
||||||
if (!savePending) return;
|
|
||||||
savePending = false;
|
|
||||||
saveFeeds(feeds());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFilteredFeeds = (): Feed[] => {
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
let result = [...feeds()];
|
// The filter signal is write-only (no caller mutates it), so every
|
||||||
const f = filter();
|
// caller observes the defaults: "all" visibility and the stable
|
||||||
|
// "updated desc" sort with pinned feeds first.
|
||||||
if (f.visibility && f.visibility !== "all") {
|
const result = [...feeds()];
|
||||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
result.sort(
|
||||||
}
|
(a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime(),
|
||||||
|
|
||||||
if (f.sourceId) {
|
|
||||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (f.pinnedOnly) {
|
|
||||||
result = result.filter((feed) => feed.isPinned);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (f.searchQuery) {
|
|
||||||
const query = f.searchQuery.toLowerCase();
|
|
||||||
result = result.filter(
|
|
||||||
(feed) =>
|
|
||||||
feed.podcast.title.toLowerCase().includes(query) ||
|
|
||||||
feed.customName?.toLowerCase().includes(query) ||
|
|
||||||
feed.podcast.description?.toLowerCase().includes(query),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
|
||||||
result.sort((a, b) => {
|
|
||||||
switch (f.sortBy) {
|
|
||||||
case "title":
|
|
||||||
return (
|
|
||||||
sortDir *
|
|
||||||
(a.customName || a.podcast.title).localeCompare(
|
|
||||||
b.customName || b.podcast.title,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
case "episodeCount":
|
|
||||||
return sortDir * (a.episodes.length - b.episodes.length);
|
|
||||||
case "latestEpisode":
|
|
||||||
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
|
|
||||||
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
|
|
||||||
return sortDir * (aLatest - bLatest);
|
|
||||||
case "updated":
|
|
||||||
default:
|
|
||||||
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
if (a.isPinned && !b.isPinned) return -1;
|
if (a.isPinned && !b.isPinned) return -1;
|
||||||
if (!a.isPinned && b.isPinned) return 1;
|
if (!a.isPinned && b.isPinned) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -425,17 +270,8 @@ function createFeedStore() {
|
|||||||
feedId?: string,
|
feedId?: string,
|
||||||
): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => {
|
): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(feedUrl, {
|
const xml = await fetchFeedXml(feedUrl);
|
||||||
headers: {
|
if (xml === null) return { episodes: null, coverUrl: undefined };
|
||||||
"Accept-Encoding": "identity",
|
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
|
||||||
},
|
|
||||||
// Hung feeds must not stall a refresh batch (or the
|
|
||||||
// background refresh loop) indefinitely.
|
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
if (!response.ok) return { episodes: null, coverUrl: undefined };
|
|
||||||
const xml = await response.text();
|
|
||||||
// Yield after the network read so the renderer gets a turn
|
// Yield after the network read so the renderer gets a turn
|
||||||
// before the sync regex + parse work begins.
|
// before the sync regex + parse work begins.
|
||||||
await yieldToUI();
|
await yieldToUI();
|
||||||
@@ -712,8 +548,8 @@ function createFeedStore() {
|
|||||||
// apiKey/apiSecret (pre-keychain builds) move into the macOS
|
// apiKey/apiSecret (pre-keychain builds) move into the macOS
|
||||||
// keychain and are stripped from config.json.
|
// keychain and are stripped from config.json.
|
||||||
const secured = await migratePlaintextCredentials(mergedSources);
|
const secured = await migratePlaintextCredentials(mergedSources);
|
||||||
setSources(secured);
|
registry.replaceAll(secured);
|
||||||
if (secured !== mergedSources) saveSources(secured);
|
if (secured !== mergedSources) saveSourcesToFile(secured);
|
||||||
}
|
}
|
||||||
await refreshAllFeeds();
|
await refreshAllFeeds();
|
||||||
})();
|
})();
|
||||||
@@ -772,71 +608,6 @@ function createFeedStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
|
||||||
setFeeds((prev) => {
|
|
||||||
const updated = prev.map((f) =>
|
|
||||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
|
||||||
);
|
|
||||||
scheduleSaveFeeds();
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePinned = (feedId: string) => {
|
|
||||||
setFeeds((prev) => {
|
|
||||||
const updated = prev.map((f) =>
|
|
||||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
|
||||||
);
|
|
||||||
scheduleSaveFeeds();
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
|
||||||
const newSource: PodcastSource = {
|
|
||||||
...source,
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
};
|
|
||||||
setSources((prev) => {
|
|
||||||
const updated = [...prev, newSource];
|
|
||||||
saveSources(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
return newSource;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
|
||||||
setSources((prev) => {
|
|
||||||
const updated = prev.map((source) =>
|
|
||||||
source.id === sourceId ? { ...source, ...updates } : source,
|
|
||||||
);
|
|
||||||
saveSources(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeSource = (sourceId: string) => {
|
|
||||||
// Don't remove default sources
|
|
||||||
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
|
|
||||||
|
|
||||||
setSources((prev) => {
|
|
||||||
const updated = prev.filter((s) => s.id !== sourceId);
|
|
||||||
saveSources(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSource = (sourceId: string) => {
|
|
||||||
setSources((prev) => {
|
|
||||||
const updated = prev.map((s) =>
|
|
||||||
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
|
|
||||||
);
|
|
||||||
saveSources(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFeed = (feedId: string): Feed | undefined => {
|
const getFeed = (feedId: string): Feed | undefined => {
|
||||||
return feeds().find((f) => f.id === feedId);
|
return feeds().find((f) => f.id === feedId);
|
||||||
};
|
};
|
||||||
@@ -851,11 +622,6 @@ function createFeedStore() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedFeed = (): Feed | undefined => {
|
|
||||||
const id = selectedFeedId();
|
|
||||||
return id ? getFeed(id) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Check if a feed has more episodes available beyond what's currently
|
/** Check if a feed has more episodes available beyond what's currently
|
||||||
* loaded. The full parse cache holds ALL episodes (including beyond the
|
* loaded. The full parse cache holds ALL episodes (including beyond the
|
||||||
* cache bound), so fetch-more can page deeper — but in DATE mode only
|
* cache bound), so fetch-more can page deeper — but in DATE mode only
|
||||||
@@ -876,7 +642,7 @@ function createFeedStore() {
|
|||||||
loaded,
|
loaded,
|
||||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
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
|
/** Load the next chunk of episodes for one feed from the full parse
|
||||||
@@ -898,17 +664,8 @@ function createFeedStore() {
|
|||||||
// restart). The cache holds the FULL parse — no bound applied here.
|
// restart). The cache holds the FULL parse — no bound applied here.
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(feed.podcast.feedUrl, {
|
const xml = await fetchFeedXml(feed.podcast.feedUrl);
|
||||||
headers: {
|
if (xml === null) return;
|
||||||
"Accept-Encoding": "identity",
|
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
|
||||||
},
|
|
||||||
// A hung feed must not stall the load-more path forever —
|
|
||||||
// mirror fetchEpisodes' per-feed timeout.
|
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
if (!response.ok) return;
|
|
||||||
const xml = await response.text();
|
|
||||||
cached = await parseEpisodesIncremental(xml, feed.podcast.feedUrl);
|
cached = await parseEpisodesIncremental(xml, feed.podcast.feedUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// Failed/hung refetch: leave the feed's loaded episodes
|
// Failed/hung refetch: leave the feed's loaded episodes
|
||||||
@@ -946,13 +703,7 @@ function createFeedStore() {
|
|||||||
currentCount,
|
currentCount,
|
||||||
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
);
|
);
|
||||||
newCount = currentCount;
|
newCount = dateBandCount(cached, currentCount, cutoff);
|
||||||
while (
|
|
||||||
newCount < cached.length &&
|
|
||||||
epTs(cached[newCount]) >= cutoff
|
|
||||||
) {
|
|
||||||
newCount++;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
newCount = currentCount + MAX_EPISODES_REFRESH;
|
newCount = currentCount + MAX_EPISODES_REFRESH;
|
||||||
}
|
}
|
||||||
@@ -1039,14 +790,7 @@ function createFeedStore() {
|
|||||||
currentCount,
|
currentCount,
|
||||||
windowDays,
|
windowDays,
|
||||||
);
|
);
|
||||||
if (epTs(cached[currentCount]) < cutoff) continue;
|
newCount = dateBandCount(cached, currentCount, cutoff);
|
||||||
newCount = currentCount;
|
|
||||||
while (
|
|
||||||
newCount < cached.length &&
|
|
||||||
epTs(cached[newCount]) >= cutoff
|
|
||||||
) {
|
|
||||||
newCount++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (newCount <= currentCount) continue;
|
if (newCount <= currentCount) continue;
|
||||||
episodeLoadCount.set(feed.id, newCount);
|
episodeLoadCount.set(feed.id, newCount);
|
||||||
@@ -1083,9 +827,7 @@ function createFeedStore() {
|
|||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
feeds,
|
feeds,
|
||||||
sources,
|
sources: registry.sources,
|
||||||
filter,
|
|
||||||
selectedFeedId,
|
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
|
|
||||||
/** Resolves once persisted feeds are loaded from disk (before the
|
/** Resolves once persisted feeds are loaded from disk (before the
|
||||||
@@ -1097,40 +839,36 @@ function createFeedStore() {
|
|||||||
getAllEpisodesChronological,
|
getAllEpisodesChronological,
|
||||||
getFeed,
|
getFeed,
|
||||||
findEpisode,
|
findEpisode,
|
||||||
getSelectedFeed,
|
|
||||||
hasMoreEpisodes,
|
hasMoreEpisodes,
|
||||||
isLoadingFeeds,
|
isLoadingFeeds,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setFilter,
|
|
||||||
setSelectedFeedId,
|
|
||||||
/** Fetch + parse an RSS feed WITHOUT subscribing or touching any feed
|
/** Fetch + parse an RSS feed WITHOUT subscribing or touching any feed
|
||||||
* record (Discover's episode preview). Pass no feedId to skip the
|
* record (Discover's episode preview). Pass no feedId to skip the
|
||||||
* full-parse cache; the visible window is bounded by the user's
|
* full-parse cache; the visible window is bounded by the user's
|
||||||
* cache preference and `limit`. */
|
* cache preference and `limit`. */
|
||||||
fetchEpisodes,
|
fetchEpisodes,
|
||||||
addFeed,
|
addFeed,
|
||||||
hasFeedByUrl,
|
|
||||||
removeFeed,
|
removeFeed,
|
||||||
removeFeedByUrl,
|
removeFeedByUrl,
|
||||||
updateFeed,
|
|
||||||
togglePinned,
|
|
||||||
refreshFeed,
|
refreshFeed,
|
||||||
refreshAllFeeds,
|
refreshAllFeeds,
|
||||||
loadMoreEpisodes,
|
loadMoreEpisodes,
|
||||||
loadMoreAllFeeds,
|
loadMoreAllFeeds,
|
||||||
hasMoreAcrossAll,
|
hasMoreAcrossAll,
|
||||||
flushPendingSave,
|
flushPendingSave,
|
||||||
addSource,
|
addSource: registry.addSource,
|
||||||
removeSource,
|
toggleSource: registry.toggleSource,
|
||||||
toggleSource,
|
updateSource: registry.updateSource,
|
||||||
updateSource,
|
|
||||||
runAutoDownload: runAutoDownloadNow,
|
runAutoDownload: runAutoDownloadNow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
||||||
|
|
||||||
|
/** Re-exported: refresh-merge tests import it from the store module. */
|
||||||
|
export { sameRefreshWindow } from "../utils/episode-windows";
|
||||||
|
|
||||||
export function useFeedStore() {
|
export function useFeedStore() {
|
||||||
if (!feedStoreInstance) {
|
if (!feedStoreInstance) {
|
||||||
feedStoreInstance = createFeedStore();
|
feedStoreInstance = createFeedStore();
|
||||||
|
|||||||
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";
|
} from "../utils/app-persistence";
|
||||||
import { useFeedStore } from "./feed";
|
import { useFeedStore } from "./feed";
|
||||||
import type { SearchResult, SearchScope } from "../types/source";
|
import type { SearchResult, SearchScope } from "../types/source";
|
||||||
|
import { createPersistScheduler } from "./persist";
|
||||||
|
|
||||||
const STORAGE_SCOPE_KEY = "podtui_search_scope";
|
const STORAGE_SCOPE_KEY = "podtui_search_scope";
|
||||||
const MAX_HISTORY = 10;
|
const MAX_HISTORY = 10;
|
||||||
@@ -70,6 +71,15 @@ export function createSearchStore() {
|
|||||||
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
||||||
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
|
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
|
||||||
|
|
||||||
|
/** History persistence: rapid mutations collapse into one debounced
|
||||||
|
* write; the closure reads the live signal so a flush lands the
|
||||||
|
* latest list. */
|
||||||
|
const persistHistory = createPersistScheduler((domain: string) => {
|
||||||
|
if (domain === "search-history") {
|
||||||
|
saveSearchHistoryToFile(history());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** Load search history from file (fire-and-forget; recents appear as
|
/** Load search history from file (fire-and-forget; recents appear as
|
||||||
* soon as the file is read). */
|
* soon as the file is read). */
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
@@ -167,24 +177,18 @@ export function createSearchStore() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addToHistory = (q: string) => {
|
const addToHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => sanitizeHistory([q, ...prev]));
|
||||||
const updated = sanitizeHistory([q, ...prev]);
|
persistHistory.schedule("search-history");
|
||||||
saveSearchHistoryToFile(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearHistory = () => {
|
const clearHistory = () => {
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
saveSearchHistoryToFile([]);
|
persistHistory.schedule("search-history");
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFromHistory = (q: string) => {
|
const removeFromHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => prev.filter((h) => h !== q));
|
||||||
const updated = prev.filter((h) => h !== q);
|
persistHistory.schedule("search-history");
|
||||||
saveSearchHistoryToFile(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearResults = () => {
|
const clearResults = () => {
|
||||||
|
|||||||
@@ -374,7 +374,6 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
const count = pcm.readWindow(sampleBuffer, target);
|
const count = pcm.readWindow(sampleBuffer, target);
|
||||||
// Never feed a partial FFT window to cava.
|
// Never feed a partial FFT window to cava.
|
||||||
if (count < sampleBuffer.length) return;
|
if (count < sampleBuffer.length) return;
|
||||||
|
|
||||||
const output = cava.execute(sampleBuffer);
|
const output = cava.execute(sampleBuffer);
|
||||||
|
|
||||||
// Normalize against the running peak and copy to a new array
|
// Normalize against the running peak and copy to a new array
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -579,8 +579,12 @@ export class MpvBackend implements AudioBackend {
|
|||||||
if (pausedSeek) {
|
if (pausedSeek) {
|
||||||
// time-pos sent before file-loaded is silently dropped by mpv
|
// time-pos sent before file-loaded is silently dropped by mpv
|
||||||
// (no file yet) — the preload then parked at 0 and the restore
|
// (no file yet) — the preload then parked at 0 and the restore
|
||||||
// position was lost. Wait for the open, then seek.
|
// position was lost. Wait for the open, then seek. A dead URL
|
||||||
await fileLoaded;
|
// never fires file-loaded at all (mpv keeps retrying the
|
||||||
|
// open), so end-file (the open-failure notification) races it
|
||||||
|
// and the wait folds to "not loaded" instead of stalling the
|
||||||
|
// load mutex for the full 5s timeout.
|
||||||
|
await Promise.race([fileLoaded, this.conn?.waitEvent("end-file", 5000)]);
|
||||||
await this.send(["set_property", "time-pos", pausedSeek]);
|
await this.send(["set_property", "time-pos", pausedSeek]);
|
||||||
this._position = pausedSeek;
|
this._position = pausedSeek;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export class CavaCore {
|
|||||||
private _bars = 0;
|
private _bars = 0;
|
||||||
private _channels = 1;
|
private _channels = 1;
|
||||||
private _destroyed = false;
|
private _destroyed = false;
|
||||||
|
/** Serialized last init config — identical init() calls are no-ops. */
|
||||||
|
private lastConfigKey = "";
|
||||||
|
|
||||||
/** Use loadCavaCore() instead of constructing directly. */
|
/** Use loadCavaCore() instead of constructing directly. */
|
||||||
constructor(lib: CavaLib) {
|
constructor(lib: CavaLib) {
|
||||||
@@ -112,15 +114,25 @@ export class CavaCore {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the cavacore engine with the given configuration.
|
* Initialize the cavacore engine with the given configuration.
|
||||||
* Must be called before execute(). Can be called again after destroy()
|
* Must be called before execute(). Identical configs are a no-op:
|
||||||
* to reinitialize with different parameters.
|
* cava_init/destroy churn leaks the old plan's FFTW work buffers
|
||||||
|
* (upstream frees only its own struct), so a pipeline restart with
|
||||||
|
* unchanged bars/rate/cutoffs must re-USE the live plan.
|
||||||
*/
|
*/
|
||||||
init(config: CavaCoreConfig = {}): void {
|
init(config: CavaCoreConfig = {}): void {
|
||||||
|
const cfg = { ...DEFAULTS, ...config };
|
||||||
|
if (
|
||||||
|
this.plan !== null &&
|
||||||
|
!this._destroyed &&
|
||||||
|
this.lastConfigKey === JSON.stringify(cfg)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastConfigKey = JSON.stringify(cfg);
|
||||||
if (this.plan) {
|
if (this.plan) {
|
||||||
this.destroy();
|
this.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = { ...DEFAULTS, ...config };
|
|
||||||
this._bars = cfg.bars;
|
this._bars = cfg.bars;
|
||||||
this._channels = cfg.channels;
|
this._channels = cfg.channels;
|
||||||
|
|
||||||
|
|||||||
@@ -124,17 +124,17 @@ export async function downloadEpisode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = body.getReader()
|
const fileWriter = Bun.file(filePath).writer()
|
||||||
const chunks: Uint8Array[] = []
|
|
||||||
let bytesDownloaded = 0
|
let bytesDownloaded = 0
|
||||||
let lastProgressTime = Date.now()
|
let lastProgressTime = Date.now()
|
||||||
let lastProgressBytes = 0
|
let lastProgressBytes = 0
|
||||||
|
|
||||||
|
const reader = body.getReader()
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
|
|
||||||
chunks.push(value)
|
fileWriter.write(value)
|
||||||
bytesDownloaded += value.length
|
bytesDownloaded += value.length
|
||||||
|
|
||||||
// Report progress roughly every 250ms
|
// Report progress roughly every 250ms
|
||||||
@@ -152,22 +152,14 @@ export async function downloadEpisode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Concatenate chunks and write to file
|
// Finalize the streamed file
|
||||||
const totalSize = bytesDownloaded
|
await fileWriter.end()
|
||||||
const buffer = new Uint8Array(totalSize)
|
|
||||||
let offset = 0
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
buffer.set(chunk, offset)
|
|
||||||
offset += chunk.length
|
|
||||||
}
|
|
||||||
|
|
||||||
await Bun.write(filePath, buffer)
|
|
||||||
|
|
||||||
// Final progress report
|
// Final progress report
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
onProgress({
|
onProgress({
|
||||||
bytesDownloaded: totalSize,
|
bytesDownloaded,
|
||||||
totalBytes: contentLength || totalSize,
|
totalBytes: contentLength || bytesDownloaded,
|
||||||
percent: 100,
|
percent: 100,
|
||||||
speed: 0,
|
speed: 0,
|
||||||
})
|
})
|
||||||
@@ -176,7 +168,7 @@ export async function downloadEpisode(
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
filePath,
|
filePath,
|
||||||
fileSize: totalSize,
|
fileSize: bytesDownloaded,
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof DOMException && err.name === "AbortError") {
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
|||||||
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)));
|
||||||
|
}
|
||||||
@@ -83,6 +83,18 @@ function reviveDates(feed: Feed): Feed {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Config-legacy baggage: search-time parseRSSFeed once embedded the full
|
||||||
|
* episode history inside podcast.episodes (2,100+ stale copies, 3.8 MB of
|
||||||
|
* config). Nothing reads them — feed.episodes is the source of truth — so
|
||||||
|
* every load/save drops them. */
|
||||||
|
function stripLegacyPodcastEpisodes(feed: Feed): Feed {
|
||||||
|
if (!("episodes" in feed.podcast)) return feed;
|
||||||
|
const { episodes: _legacy, ...podcast } = feed.podcast;
|
||||||
|
void _legacy;
|
||||||
|
return { ...feed, podcast: podcast as Feed["podcast"] };
|
||||||
|
}
|
||||||
|
|
||||||
/** Load feeds from config.json, pruning episodes outside the retention
|
/** Load feeds from config.json, pruning episodes outside the retention
|
||||||
* window (completed downloads always kept). When anything was pruned, the
|
* window (completed downloads always kept). When anything was pruned, the
|
||||||
* pruned list is rewritten to config.json (startup cleanup for legacy
|
* pruned list is rewritten to config.json (startup cleanup for legacy
|
||||||
@@ -93,7 +105,9 @@ export async function loadFeedsFromFile(
|
|||||||
try {
|
try {
|
||||||
const cfg = await loadConfig();
|
const cfg = await loadConfig();
|
||||||
if (!Array.isArray(cfg.feeds)) return [];
|
if (!Array.isArray(cfg.feeds)) return [];
|
||||||
const feeds = cfg.feeds.map(reviveDates);
|
const feeds = cfg.feeds
|
||||||
|
.map(reviveDates)
|
||||||
|
.map(stripLegacyPodcastEpisodes);
|
||||||
const downloadedIds = await readDownloadedEpisodeIds();
|
const downloadedIds = await readDownloadedEpisodeIds();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
let prunedAny = false;
|
let prunedAny = false;
|
||||||
@@ -122,7 +136,9 @@ export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const downloadedIds = await readDownloadedEpisodeIds();
|
const downloadedIds = await readDownloadedEpisodeIds();
|
||||||
const pruned = feeds.map((f) => ({
|
const pruned = feeds
|
||||||
|
.map(stripLegacyPodcastEpisodes)
|
||||||
|
.map((f) => ({
|
||||||
...f,
|
...f,
|
||||||
episodes: f.episodes.filter((ep) =>
|
episodes: f.episodes.filter((ep) =>
|
||||||
episodeIsPersistable(ep, downloadedIds, new Date(), windowDays),
|
episodeIsPersistable(ep, downloadedIds, new Date(), windowDays),
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import type { MouseEvent } from "@opentui/core";
|
|||||||
type ScrollDir = "up" | "down" | "left" | "right";
|
type ScrollDir = "up" | "down" | "left" | "right";
|
||||||
|
|
||||||
// The scrollbox's own wheel handler (scrolls, then bubbles to its parent).
|
// The scrollbox's own wheel handler (scrolls, then bubbles to its parent).
|
||||||
const original = ScrollBoxRenderable.prototype.onMouseEvent;
|
const original = (ScrollBoxRenderable.prototype as unknown as {
|
||||||
|
onMouseEvent: (event: MouseEvent) => void;
|
||||||
|
}).onMouseEvent;
|
||||||
|
|
||||||
let installed = false;
|
let installed = false;
|
||||||
|
|
||||||
|
|||||||
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 { searchSourceByType, searchEpisodesByType } from "./source-searcher";
|
||||||
import { parseRSSFeed } from "../api/rss-parser";
|
import { parseRSSFeed } from "../api/rss-parser";
|
||||||
|
import { fetchFeedXml } from "./rss-client";
|
||||||
import { SourceType } from "../types/source";
|
import { SourceType } from "../types/source";
|
||||||
import type { PodcastSource, SearchResult } from "../types/source";
|
import type { PodcastSource, SearchResult } from "../types/source";
|
||||||
|
|
||||||
@@ -81,16 +82,13 @@ export const searchByFeedUrl = async (
|
|||||||
if (!FEED_URL_RE.test(trimmed)) return [];
|
if (!FEED_URL_RE.test(trimmed)) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(trimmed, {
|
const xml = await fetchFeedXml(trimmed);
|
||||||
headers: {
|
if (xml === null) return [];
|
||||||
"Accept-Encoding": "identity",
|
// Full parse's episodes are dead weight here (2,100+ stale copies were
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
// previously persisted inside Feed.podcast): addFeed refetches through
|
||||||
},
|
// fetchEpisodes and nothing reads Podcast.episodes off a search result.
|
||||||
});
|
const { episodes: _episodes, ...podcast } = parseRSSFeed(xml, trimmed);
|
||||||
if (!response.ok) return [];
|
void _episodes;
|
||||||
|
|
||||||
const xml = await response.text();
|
|
||||||
const podcast = parseRSSFeed(xml, trimmed);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
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)
|
||||||
|
})
|
||||||
@@ -36,6 +36,7 @@ import { whenConfigIdle } from "../src/utils/config";
|
|||||||
import { FeedVisibility } from "../src/types/feed";
|
import { FeedVisibility } from "../src/types/feed";
|
||||||
import type { Feed } from "../src/types/feed";
|
import type { Feed } from "../src/types/feed";
|
||||||
import type { Episode } from "../src/types/episode";
|
import type { Episode } from "../src/types/episode";
|
||||||
|
import type { PodcastWithEpisodes } from "../src/types/podcast";
|
||||||
|
|
||||||
const configJsonPath = join(configHome, "podtui", "config.json");
|
const configJsonPath = join(configHome, "podtui", "config.json");
|
||||||
const downloadsJsonPath = join(configHome, "podtui", "downloads.json");
|
const downloadsJsonPath = join(configHome, "podtui", "downloads.json");
|
||||||
@@ -169,6 +170,73 @@ test("DEFAULT_EPISODE_WINDOW_DAYS is 60", () => {
|
|||||||
expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60);
|
expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Legacy podcast.episodes baggage ────────────────────────────────────────
|
||||||
|
|
||||||
|
test("saveFeedsToFile strips legacy podcast.episodes from the persisted feed", async () => {
|
||||||
|
const feed = makeFeed([
|
||||||
|
makeEpisode({ id: "recent-id", pubDate: new Date(Date.now() - 5 * DAY) }),
|
||||||
|
]);
|
||||||
|
// Simulate the pre-fix shape: parseRSSFeed's full history embedded on
|
||||||
|
// the podcast object (841 stale copies were persisted this way).
|
||||||
|
const podcastWithLegacy = feed.podcast as PodcastWithEpisodes;
|
||||||
|
podcastWithLegacy.episodes = [
|
||||||
|
makeEpisode({ id: "stale-history-1" }),
|
||||||
|
makeEpisode({ id: "stale-history-2" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
saveFeedsToFile([feed]);
|
||||||
|
await settleWrites();
|
||||||
|
const raw = await Bun.file(configJsonPath).json();
|
||||||
|
expect("episodes" in raw.feeds[0].podcast).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadFeedsFromFile drops legacy podcast.episodes from a seeded config", async () => {
|
||||||
|
await Bun.write(
|
||||||
|
configJsonPath,
|
||||||
|
JSON.stringify({
|
||||||
|
feeds: [
|
||||||
|
{
|
||||||
|
id: "feed-1",
|
||||||
|
podcast: {
|
||||||
|
id: "feed-1",
|
||||||
|
title: "Baggage Show",
|
||||||
|
description: "",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl: "https://example.com/baggage.xml",
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
isSubscribed: true,
|
||||||
|
episodes: [
|
||||||
|
{ id: "huge-stale-1", title: "archived copy" },
|
||||||
|
{ id: "huge-stale-2", title: "archived copy" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
episodes: [
|
||||||
|
{
|
||||||
|
id: "recent-id",
|
||||||
|
podcastId: "feed-1",
|
||||||
|
title: "Recent",
|
||||||
|
description: "",
|
||||||
|
audioUrl: "https://example.com/audio/recent.mp3",
|
||||||
|
duration: 60,
|
||||||
|
pubDate: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
visibility: "public",
|
||||||
|
sourceId: "source-1",
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const feeds = await loadFeedsFromFile();
|
||||||
|
|
||||||
|
expect(feeds).toHaveLength(1);
|
||||||
|
expect(feeds[0].episodes.map((e) => e.id)).toEqual(["recent-id"]);
|
||||||
|
expect("episodes" in feeds[0].podcast).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Save path: retention window applied with completed-download exemption ──
|
// ── Save path: retention window applied with completed-download exemption ──
|
||||||
|
|
||||||
test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => {
|
test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => {
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
* content starts one column in — `leftPx + 1`. Hence
|
* content starts one column in — `leftPx + 1`. Hence
|
||||||
* `leftPx = firstC - 1`, `rightPx = firstV`.
|
* `leftPx = firstC - 1`, `rightPx = firstV`.
|
||||||
*
|
*
|
||||||
* The drag strips overlay the border cells (left strip at [left, left+2),
|
* The grab zones overlay each border: 3 columns wide, the border plus one
|
||||||
* right strip at [right-2, right)). The test presses inside a strip and
|
* help-padded column each side (left zone at [left-1, left+1], right zone
|
||||||
* drags across the row — the drag bubbles to the row container which moves
|
* at [right-2, right)). The test presses inside a zone and drags across
|
||||||
* the split, so the panes must re-render at the new columns.
|
* 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 { test, expect, afterAll } from "bun:test";
|
||||||
import { testRender } from "@opentui/solid";
|
import { testRender } from "@opentui/solid";
|
||||||
@@ -102,7 +103,7 @@ test("dragging the left border resizes parent vs current", async () => {
|
|||||||
resetSplits();
|
resetSplits();
|
||||||
await setup.renderOnce();
|
await setup.renderOnce();
|
||||||
|
|
||||||
// Press on the left strip (border at 20 → strip covers 20) and drag
|
// Press on the left zone (border at 20 → zone covers 19-21) and drag
|
||||||
// toward the middle of the row.
|
// toward the middle of the row.
|
||||||
await setup.mockMouse.drag(20, 5, 45, 5);
|
await setup.mockMouse.drag(20, 5, 45, 5);
|
||||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
||||||
@@ -122,7 +123,7 @@ test("dragging the right border resizes current vs preview", async () => {
|
|||||||
resetSplits();
|
resetSplits();
|
||||||
await setup.renderOnce();
|
await setup.renderOnce();
|
||||||
|
|
||||||
// Press on the right strip (border at 69 → strip covers 69) and drag
|
// Press on the right zone (border at 69 → zone covers 68-70) and drag
|
||||||
// toward the right edge of the row.
|
// toward the right edge of the row.
|
||||||
await setup.mockMouse.drag(69, 5, 90, 5);
|
await setup.mockMouse.drag(69, 5, 90, 5);
|
||||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
||||||
@@ -132,6 +133,52 @@ test("dragging the right border resizes current vs preview", async () => {
|
|||||||
expect(after.left).toBe(20);
|
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 () => {
|
test("a plain click away from the borders does not resize", async () => {
|
||||||
const setup = await renderRow(3);
|
const setup = await renderRow(3);
|
||||||
cleanups.push(() => setup.renderer.destroy());
|
cleanups.push(() => setup.renderer.destroy());
|
||||||
|
|||||||
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)
|
||||||
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")
|
||||||
Reference in New Issue
Block a user