Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cf9559b0b | |||
| 20336ea716 | |||
| 8b7b38276e | |||
| 8496922aaf | |||
| 5e3ad48a2d | |||
| 005ac8fde3 | |||
| 1f0b9de456 | |||
| 3388757185 | |||
| 8049d02457 | |||
| 1b55b7117c | |||
| 15f8a098b5 | |||
| 2d7d49b91c | |||
| df9c519439 | |||
| 2bf1c229c7 |
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -50,7 +50,10 @@ jobs:
|
||||
- name: Install fftw (cavacore build dependency)
|
||||
run: |
|
||||
if uname -s | grep -qi darwin; then
|
||||
brew install fftw
|
||||
# mpv is required for the release bundle: build.ts copies it into
|
||||
# PodTui.app (signed with the podtui bundle identifier) so macOS
|
||||
# Now Playing shows the PodTui icon instead of a blank placeholder.
|
||||
brew install fftw mpv
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libfftw3-dev
|
||||
@@ -76,6 +79,16 @@ jobs:
|
||||
printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml"
|
||||
cd "$SMOKE_DIR"
|
||||
./podtui-*/podtui --version
|
||||
# macOS tarballs must ship PodTui.app with a working bundled mpv
|
||||
# carrying the podtui bundle identifier — otherwise Now Playing
|
||||
# attribution silently regresses to a blank icon.
|
||||
if [ "${{ matrix.plat }}" = "darwin" ]; then
|
||||
MPV=./podtui-*/PodTui.app/Contents/MacOS/mpv
|
||||
test -x $MPV || { echo "PodTui.app missing bundled mpv"; exit 1; }
|
||||
$MPV --version >/dev/null || { echo "bundled mpv does not launch"; exit 1; }
|
||||
codesign -dvv $MPV 2>&1 | grep -q "Identifier=com.mikefreno.podtui" \
|
||||
|| { echo "bundled mpv lacks podtui signing identifier"; exit 1; }
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
23
build.ts
23
build.ts
@@ -155,9 +155,13 @@ if (COMPILE) {
|
||||
if (mpvPath) {
|
||||
copyFileSync(mpvPath, join(macosDir, "mpv"));
|
||||
} else {
|
||||
console.warn(
|
||||
"Warning: mpv not found in PATH — skipping bundle mpv (Now Playing attribution won't work)",
|
||||
// A darwin release tarball without a bundled mpv silently ships
|
||||
// without Now Playing attribution (blank icon). Fail loudly so CI
|
||||
// can't produce it — the runner must have mpv installed.
|
||||
console.error(
|
||||
"Error: mpv not found in PATH — PodTui.app requires a bundled mpv for macOS Now Playing attribution (brew install mpv on the build machine)",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const icnsSrc = join("assets", "App Icon", "AppIcon.icns");
|
||||
@@ -169,7 +173,16 @@ if (COMPILE) {
|
||||
);
|
||||
}
|
||||
|
||||
// Keep CFBundleShortVersionString in sync with src/index.tsx VERSION.
|
||||
// Version for the bundle comes from src/index.tsx (single source of
|
||||
// truth — release.yml requires bumping it in the tag commit).
|
||||
const srcIndex = await Bun.file(join("src", "index.tsx")).text();
|
||||
const versionMatch = srcIndex.match(/const VERSION = "([^"]+)"/);
|
||||
const bundleVersion = versionMatch?.[1];
|
||||
if (!bundleVersion) {
|
||||
console.error("Error: could not read VERSION from src/index.tsx");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
Bun.write(
|
||||
join(appRoot, "Contents", "Info.plist"),
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -189,9 +202,9 @@ if (COMPILE) {
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.1</string>
|
||||
<string>${bundleVersion}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.3.1</string>
|
||||
<string>${bundleVersion}</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
</dict>
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"sort": [","],
|
||||
"toggle-hidden": ["."],
|
||||
"refresh": ["r"],
|
||||
"subscribe": ["a"], // subscribe focused show/episode result in place (Search)
|
||||
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
||||
|
||||
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
||||
|
||||
@@ -68,6 +68,7 @@ export type KeybindActionName =
|
||||
| "sort"
|
||||
| "toggle-hidden"
|
||||
| "refresh"
|
||||
| "subscribe"
|
||||
| "unsubscribe"
|
||||
| "download"
|
||||
| "delete-download"
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createSignal, onCleanup } from "solid-js";
|
||||
import { unlinkSync } from "fs";
|
||||
import { fetchCoverArt, coverTempPath } from "../utils/cover-art";
|
||||
import { onCleanup } from "solid-js";
|
||||
import {
|
||||
cachedCoverPath,
|
||||
fetchCoverArt,
|
||||
prefetchCoverArt,
|
||||
} from "../utils/cover-art";
|
||||
import {
|
||||
createAudioBackend,
|
||||
detectPlayers,
|
||||
@@ -22,11 +25,36 @@ import {
|
||||
type BackendName,
|
||||
type DetectedPlayer,
|
||||
} from "../utils/audio-player";
|
||||
import {
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
position,
|
||||
setPosition,
|
||||
duration,
|
||||
setDuration,
|
||||
volume,
|
||||
setVolume,
|
||||
speed,
|
||||
setSpeed,
|
||||
backendName,
|
||||
setBackendName,
|
||||
error,
|
||||
setError,
|
||||
currentEpisode,
|
||||
setCurrentEpisode,
|
||||
availablePlayers,
|
||||
setAvailablePlayers,
|
||||
} from "../utils/audio-signals";
|
||||
import { emit, on } from "../utils/event-bus";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useProgressStore } from "../stores/progress";
|
||||
import { useMediaRegistry } from "../utils/media-registry";
|
||||
import type { Episode } from "../types/episode";
|
||||
import {
|
||||
loadLastPlayerFromFile,
|
||||
saveLastPlayerToFile,
|
||||
saveLastPlayerSync,
|
||||
} from "../utils/app-persistence";
|
||||
import type { Episode, Progress } from "../types/episode";
|
||||
import type { Feed } from "../types/feed";
|
||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
||||
import { useFeedStore } from "../stores/feed";
|
||||
@@ -45,6 +73,8 @@ export interface AudioControls {
|
||||
|
||||
// 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>;
|
||||
@@ -64,17 +94,26 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let refCount = 0;
|
||||
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
||||
|
||||
const [isPlaying, setIsPlaying] = createSignal(false);
|
||||
const [position, setPosition] = createSignal(0);
|
||||
const [duration, setDuration] = createSignal(0);
|
||||
const [volume, setVolume] = createSignal(0.7);
|
||||
const [speed, setSpeed] = createSignal(1);
|
||||
const [backendName, setBackendName] = createSignal<BackendName>("none");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
|
||||
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
|
||||
[],
|
||||
);
|
||||
// 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) {
|
||||
@@ -101,6 +140,17 @@ function registerExitTeardown(): void {
|
||||
exitTeardownRegistered = true;
|
||||
const teardown = (): void => {
|
||||
stopPolling();
|
||||
// Persist "what's loaded in the player right now" synchronously —
|
||||
// process.exit(0) runs this handler synchronously and an async write
|
||||
// would never land. The next launch restores this episode paused.
|
||||
try {
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
saveLastPlayerSync({ episodeId: ep.id, timestamp: new Date() });
|
||||
}
|
||||
} catch {
|
||||
/* best-effort at exit */
|
||||
}
|
||||
try {
|
||||
backend?.dispose();
|
||||
} catch {
|
||||
@@ -111,11 +161,6 @@ function registerExitTeardown(): void {
|
||||
} catch {
|
||||
/* best-effort at exit */
|
||||
}
|
||||
try {
|
||||
unlinkSync(coverTempPath());
|
||||
} catch {
|
||||
/* best-effort at exit */
|
||||
}
|
||||
};
|
||||
process.on("exit", teardown);
|
||||
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||||
@@ -126,6 +171,52 @@ function registerExitTeardown(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll ticks between paused-state checks (~1s at 150ms/tick). While the
|
||||
* UI believes playback is paused we only need to catch an external
|
||||
* resume (AirPod play tap, lock-screen/media-center play); checking every
|
||||
* tick would just hammer mpv IPC for nothing. */
|
||||
const PAUSE_WATCH_TICKS = 7;
|
||||
|
||||
/** The player process died while we believed playback was live — track
|
||||
* ended (mpv quits at EOF) or the process crashed. Persist the final
|
||||
* position and stop polling. */
|
||||
function finalizeTrackEnd(): void {
|
||||
setIsPlaying(false);
|
||||
stopPolling();
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
}
|
||||
}
|
||||
|
||||
/** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal,
|
||||
* device swap, OS media keys, the Now Playing center. Bring the UI in
|
||||
* sync; the poll stays armed so an external resume is caught too. */
|
||||
function reconcileExternalPause(): void {
|
||||
setIsPlaying(false);
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, position(), duration(), speed());
|
||||
emit("player.pause", { episodeId: ep.id });
|
||||
const media = useMediaRegistry();
|
||||
media.setPlaybackState(false);
|
||||
media.setPosition(position());
|
||||
}
|
||||
}
|
||||
|
||||
/** Playback was restarted from outside PodTUI (AirPods, lock-screen or
|
||||
* media-center play, OS media keys). Bring the UI back to "playing". */
|
||||
function reconcileExternalResume(): void {
|
||||
setIsPlaying(true);
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
emit("player.play", { episodeId: ep.id });
|
||||
useMediaRegistry().setPlaybackState(true);
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
stopPolling();
|
||||
pollCount = 0;
|
||||
@@ -133,36 +224,57 @@ function startPolling(): void {
|
||||
// interval (getPosition opens a fresh mpv IPC connection per call).
|
||||
let pollInFlight = false;
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!backend || !isPlaying() || pollInFlight) return;
|
||||
if (!backend || pollInFlight) return;
|
||||
pollInFlight = true;
|
||||
try {
|
||||
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)
|
||||
pollCount++;
|
||||
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);
|
||||
if (isPlaying()) {
|
||||
// Track ended (eof-reached observed) or process died. Check
|
||||
// BEFORE pause reconciliation: mpv keeps the file open at EOF
|
||||
// and reports pause=true there, which would otherwise be
|
||||
// mistaken for an external pause and never finalize.
|
||||
if (!backend.isPlaying()) {
|
||||
finalizeTrackEnd();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if backend stopped playing (track ended)
|
||||
if (!backend.isPlaying() && isPlaying()) {
|
||||
setIsPlaying(false);
|
||||
stopPolling();
|
||||
// Save final position on track end
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||
// mpv can pause itself outside PodTUI. Reconcile instead of
|
||||
// staying stuck on "playing" with a frozen waveform
|
||||
// (getPosition would just re-read the same frozen time-pos).
|
||||
const paused = await backend.getPauseState();
|
||||
if (paused === true) {
|
||||
reconcileExternalPause();
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = await backend.getPosition();
|
||||
const dur = await backend.getDuration();
|
||||
setPosition(pos);
|
||||
if (dur > 0) setDuration(dur);
|
||||
|
||||
// Save progress every ~5 seconds (33 ticks * 150ms)
|
||||
if (pollCount % 33 === 0) {
|
||||
const ep = currentEpisode();
|
||||
if (ep) {
|
||||
const progressStore = useProgressStore();
|
||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||
|
||||
const media = useMediaRegistry();
|
||||
media.setPosition(pos);
|
||||
}
|
||||
}
|
||||
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
|
||||
// Paused — watch for playback restarted from outside (AirPods,
|
||||
// lock-screen/media-center play). Only while the player is
|
||||
// still alive: a dead player while we thought we were paused
|
||||
// means the track ended (mpv quits at EOF) or it crashed.
|
||||
if (!backend.isAlive()) {
|
||||
finalizeTrackEnd();
|
||||
return;
|
||||
}
|
||||
const paused = await backend.getPauseState();
|
||||
if (paused === false) {
|
||||
reconcileExternalResume();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -204,9 +316,12 @@ async function play(episode: Episode): Promise<void> {
|
||||
const feedStore = useFeedStore();
|
||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
const coverArtPath = feed?.podcast.coverUrl
|
||||
? await fetchCoverArt(feed.podcast.coverUrl)
|
||||
: null;
|
||||
// Cover art must NEVER gate playback (it was a curl subprocess blocking
|
||||
// play() by up to 8s). Serve the disk-cached file synchronously when it
|
||||
// exists; on a miss, start playback bare and fetch in the background —
|
||||
// the backend applies late art at runtime (mpv video-add).
|
||||
const coverUrl = feed?.podcast.coverUrl;
|
||||
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
||||
|
||||
// Resume from saved progress if available and not completed
|
||||
const savedProgress = progressStore.get(episode.id);
|
||||
@@ -223,11 +338,26 @@ async function play(episode: Episode): Promise<void> {
|
||||
coverArtPath: coverArtPath ?? undefined,
|
||||
});
|
||||
|
||||
if (coverUrl && !coverArtPath) {
|
||||
fetchCoverArt(coverUrl)
|
||||
.then((path) => {
|
||||
if (path && currentEpisode()?.id === episode.id) {
|
||||
b.addCoverArt(path).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
setCurrentEpisode(episode);
|
||||
setIsPlaying(true);
|
||||
setPosition(startPos);
|
||||
setSpeed(spd);
|
||||
if (episode.duration) setDuration(episode.duration);
|
||||
startedPlayback = true;
|
||||
|
||||
// Remember this episode as "loaded in the player" so the next launch
|
||||
// can restore it paused (cleared by stop()).
|
||||
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||
|
||||
// Register with platform media controls
|
||||
const media = useMediaRegistry();
|
||||
@@ -250,12 +380,79 @@ async function play(episode: Episode): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an episode into the player WITHOUT starting playback. The player tab
|
||||
* renders it paused at its saved position; the first play action starts the
|
||||
* backend from there (see togglePlayback). Used to restore the last player
|
||||
* session at boot.
|
||||
*/
|
||||
async function load(episode: Episode): Promise<void> {
|
||||
ensureBackend();
|
||||
setError(null);
|
||||
|
||||
setCurrentEpisode(episode);
|
||||
setIsPlaying(false);
|
||||
startedPlayback = false;
|
||||
|
||||
// Show the saved position so the player tab reflects where playback
|
||||
// will resume; episodes at/above the completion threshold start from 0.
|
||||
const progressStore = useProgressStore();
|
||||
const saved = progressStore.get(episode.id);
|
||||
const pos = saved && isRestoreEligible(saved) ? saved.position : 0;
|
||||
setPosition(pos);
|
||||
if (episode.duration) setDuration(episode.duration);
|
||||
|
||||
const appStore = useAppStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
setSpeed(storeSpeed || speed());
|
||||
|
||||
// Surface the loaded-but-paused track to the OS media controls.
|
||||
const feedStore = useFeedStore();
|
||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
const media = useMediaRegistry();
|
||||
media.setNowPlaying({
|
||||
title: episode.title,
|
||||
artist: podcastTitle || episode.podcastId,
|
||||
duration: episode.duration,
|
||||
});
|
||||
media.setPlaybackState(false);
|
||||
if (pos > 0) media.setPosition(pos);
|
||||
|
||||
// Preload the episode into the backend PAUSED: mpv opens the stream and
|
||||
// fills its demuxer cache while parked, so the user's first Play flips
|
||||
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
|
||||
// — a failed preload just makes the first play take the cold path.
|
||||
if (episode.audioUrl && backend) {
|
||||
const coverUrl = feed?.podcast.coverUrl;
|
||||
if (coverUrl) prefetchCoverArt(coverUrl);
|
||||
const backendSnap = backend;
|
||||
backendSnap
|
||||
.preload(episode.audioUrl, {
|
||||
volume: volume(),
|
||||
speed: storeSpeed || speed(),
|
||||
startPosition: pos > 0 ? pos : undefined,
|
||||
mediaTitle: podcastTitle
|
||||
? `${podcastTitle} — ${episode.title}`
|
||||
: episode.title,
|
||||
coverArtPath: coverUrl
|
||||
? (cachedCoverPath(coverUrl) ?? undefined)
|
||||
: undefined,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||
}
|
||||
|
||||
async function pause(): Promise<void> {
|
||||
if (!backend) return;
|
||||
try {
|
||||
await backend.pause();
|
||||
setIsPlaying(false);
|
||||
stopPolling();
|
||||
// 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
|
||||
@@ -294,7 +491,15 @@ async function togglePlayback(): Promise<void> {
|
||||
if (isPlaying()) {
|
||||
await pause();
|
||||
} else if (currentEpisode()) {
|
||||
await resume();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,9 +516,13 @@ async function stop(): Promise<void> {
|
||||
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) {
|
||||
@@ -346,6 +555,10 @@ async function doSetVolume(vol: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
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> {
|
||||
@@ -389,9 +602,8 @@ async function switchBackend(name: BackendName): Promise<void> {
|
||||
.feeds()
|
||||
.find((f) => f.podcast.id === ep.podcastId);
|
||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||
const coverArtPath = feed?.podcast.coverUrl
|
||||
? await fetchCoverArt(feed.podcast.coverUrl)
|
||||
: null;
|
||||
const coverUrl = feed?.podcast.coverUrl;
|
||||
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
||||
await backend.play(ep.audioUrl, {
|
||||
startPosition: pos,
|
||||
volume: vol,
|
||||
@@ -402,6 +614,7 @@ async function switchBackend(name: BackendName): Promise<void> {
|
||||
coverArtPath: coverArtPath ?? undefined,
|
||||
});
|
||||
setIsPlaying(true);
|
||||
startedPlayback = true;
|
||||
startPolling();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Backend switch failed");
|
||||
@@ -410,6 +623,46 @@ async function switchBackend(name: BackendName): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*
|
||||
@@ -420,13 +673,29 @@ export function useAudio(): AudioControls {
|
||||
// Initialize backend on first use
|
||||
ensureBackend();
|
||||
|
||||
// Sync initial speed from app store
|
||||
// Sync initial speed/volume from app store (reuse the previous session's
|
||||
// playback levels; defaults are 1x and 100%).
|
||||
if (refCount === 0) {
|
||||
const appStore = useAppStore();
|
||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||
if (storeSpeed && storeSpeed !== speed()) {
|
||||
setSpeed(storeSpeed);
|
||||
}
|
||||
|
||||
// Volume re-syncs once settings finish loading (async config read)
|
||||
// so a level persisted last session is applied at boot.
|
||||
appStore
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
const storeVolume = appStore.state().settings.volume;
|
||||
if (storeVolume !== undefined && storeVolume !== volume()) {
|
||||
setVolume(storeVolume);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Restore the last player session once at boot (loaded, not playing).
|
||||
restoreLastSession().catch(() => {});
|
||||
}
|
||||
|
||||
refCount++;
|
||||
@@ -574,6 +843,7 @@ export function useAudio(): AudioControls {
|
||||
availablePlayers,
|
||||
|
||||
play,
|
||||
load,
|
||||
pause,
|
||||
resume,
|
||||
togglePlayback,
|
||||
|
||||
@@ -57,6 +57,8 @@ export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
||||
break;
|
||||
|
||||
case "s":
|
||||
// Speed is shift+s (S) so plain `s` stays free for search.
|
||||
if (!key.shift) return;
|
||||
emit("media.speedCycle", {});
|
||||
break;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Feed } from "./types/feed"
|
||||
import type { Episode } from "./types/episode"
|
||||
|
||||
const VERSION = "0.4.0";
|
||||
const VERSION = "0.5.0";
|
||||
|
||||
interface CliArgs {
|
||||
version: boolean;
|
||||
|
||||
@@ -33,7 +33,7 @@ import { useAudio } from "@/hooks/useAudio";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { Episode } from "@/types/episode";
|
||||
import type { Episode, DownloadedEpisode } from "@/types/episode";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { PaneRow } from "@/components/PaneRow";
|
||||
@@ -62,9 +62,28 @@ export function MyShowsPage() {
|
||||
|
||||
const shows = () => feedStore.getFilteredFeeds();
|
||||
|
||||
// Downloads of shows that are NOT subscribed (made from episode search) —
|
||||
// listed as their own section under the shows list. Reads feeds() so an
|
||||
// entry drops out the moment the user subscribes to its show.
|
||||
const unsubs = () => downloadStore.getUnsubscribedDownloads();
|
||||
|
||||
// Total depth-0 rows: subscribed shows + unsubscribed-show downloads.
|
||||
const depth0Count = () => shows().length + unsubs().length;
|
||||
|
||||
const focusedShowIdx = () =>
|
||||
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
||||
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
|
||||
/** True when the depth-0 cursor sits on an unsubscribed-show download
|
||||
* row (past the shows list). */
|
||||
const focusedOnUnsub = () =>
|
||||
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0;
|
||||
const focusedUnsub = (): DownloadedEpisode | undefined => {
|
||||
if (!focusedOnUnsub()) return undefined;
|
||||
return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)];
|
||||
};
|
||||
const selectedShow = (): Feed | undefined => {
|
||||
if (focusedOnUnsub()) return undefined;
|
||||
return shows()[focusedShowIdx()];
|
||||
};
|
||||
|
||||
// depth-1 frame ctx = the drilled feed id
|
||||
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
||||
@@ -104,11 +123,11 @@ export function MyShowsPage() {
|
||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||
const moreRef = useScrollIntoView(() => focusedOnMore());
|
||||
|
||||
const curLen = () => (depth() === 0 ? shows().length : rowCount());
|
||||
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (shows().length > 0 && focus(0) >= shows().length)
|
||||
nav.setDepthFocus(shows().length - 1, 0);
|
||||
if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
|
||||
nav.setDepthFocus(depth0Count() - 1, 0);
|
||||
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
|
||||
nav.setDepthFocus(rowCount() - 1, 1);
|
||||
};
|
||||
@@ -116,7 +135,10 @@ export function MyShowsPage() {
|
||||
|
||||
onMount(() => {
|
||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||
if (depth() === 0) return shows()[i]?.id;
|
||||
if (depth() === 0) {
|
||||
if (i < shows().length) return shows()[i]?.id;
|
||||
return unsubs()[i - shows().length]?.episodeId;
|
||||
}
|
||||
return episodes()[i]?.id;
|
||||
});
|
||||
});
|
||||
@@ -172,9 +194,31 @@ export function MyShowsPage() {
|
||||
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
||||
};
|
||||
|
||||
/** Stream an unsubscribed-show download. The record carries only what was
|
||||
* persisted at download time, so a minimal Episode is reconstructed. */
|
||||
const playUnsubscribedDownload = (d: DownloadedEpisode) => {
|
||||
audio
|
||||
.play({
|
||||
id: d.episodeId,
|
||||
podcastId: d.feedId,
|
||||
title: d.episodeTitle ?? d.episodeId,
|
||||
description: "",
|
||||
audioUrl: d.audioUrl ?? "",
|
||||
duration: 0,
|
||||
pubDate: d.pubDate ? new Date(d.pubDate) : new Date(),
|
||||
})
|
||||
.catch(() => {});
|
||||
audioNav.setSource(AudioSource.SEARCH, d.feedId);
|
||||
};
|
||||
|
||||
// ── drill / open ───────────────────────────────────────────────────────────
|
||||
function open() {
|
||||
if (depth() === 0) {
|
||||
const d = focusedUnsub();
|
||||
if (d) {
|
||||
playUnsubscribedDownload(d);
|
||||
return;
|
||||
}
|
||||
const show = selectedShow();
|
||||
if (!show) return;
|
||||
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
||||
@@ -215,6 +259,14 @@ export function MyShowsPage() {
|
||||
if (ep) downloadStore.startDownload(ep, drilledShowId());
|
||||
},
|
||||
"delete-download": () => {
|
||||
if (depth() === 0) {
|
||||
const d = focusedUnsub();
|
||||
if (d) {
|
||||
downloadStore.cancelDownload(d.episodeId);
|
||||
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (depth() < 1) return;
|
||||
const ep = focusedEpisode();
|
||||
if (!ep) return;
|
||||
@@ -283,7 +335,9 @@ export function MyShowsPage() {
|
||||
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? `Shows (${shows().length})`
|
||||
? `Shows (${shows().length})${
|
||||
unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : ""
|
||||
}`
|
||||
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
||||
|
||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||
@@ -321,7 +375,7 @@ export function MyShowsPage() {
|
||||
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
||||
<Show when={depth() === 0}>
|
||||
<Show
|
||||
when={shows().length > 0}
|
||||
when={depth0Count() > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>
|
||||
@@ -377,6 +431,71 @@ export function MyShowsPage() {
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={unsubs().length > 0}>
|
||||
<box paddingLeft={1} paddingTop={1}>
|
||||
<text fg={theme.textSecondary}>
|
||||
Unsubscribed Show Downloads
|
||||
</text>
|
||||
</box>
|
||||
<For each={unsubs()}>
|
||||
{(d, index) => {
|
||||
// Rows continue after the shows list.
|
||||
const rowIdx = () => shows().length + index();
|
||||
const lf = () => nav.depthFocus(0);
|
||||
const ref = useScrollIntoView(() => rowIdx() === lf());
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(rowIdx(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(rowIdx(), 0);
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={focusFg(rowIdx(), lf(), isActive())}
|
||||
>
|
||||
{rowIdx() === lf() ? marker() : " "}
|
||||
</text>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={focusFg(rowIdx(), lf(), isActive())}
|
||||
>
|
||||
{d.episodeTitle ?? d.episodeId}
|
||||
</text>
|
||||
<Show when={downloadLabel(d.episodeId)}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={downloadColor(d.episodeId)}
|
||||
>
|
||||
{downloadLabel(d.episodeId)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={
|
||||
rowIdx() === lf()
|
||||
? theme.surface
|
||||
: theme.textSecondary
|
||||
}
|
||||
>
|
||||
{d.podcastTitle ?? d.feedId}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
{/* depth ≥1: episodes */}
|
||||
@@ -491,39 +610,78 @@ export function MyShowsPage() {
|
||||
// ── preview pane ───────────────────────────────────────────────────────────
|
||||
const previewContent = () =>
|
||||
depth() === 0 ? (
|
||||
// depth 0 preview: hovered show
|
||||
// depth 0 preview: hovered unsubscribed-show download, else the
|
||||
// hovered show.
|
||||
<Show
|
||||
when={selectedShow()}
|
||||
when={focusedUnsub()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No show focused</text>
|
||||
</box>
|
||||
<Show
|
||||
when={selectedShow()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No show focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(show) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{showTitle(show())}</strong>
|
||||
</text>
|
||||
<Show when={show().podcast.author}>
|
||||
<text fg={muted()}>by {show().podcast.author}</text>
|
||||
</Show>
|
||||
<text fg={theme.textSecondary}>
|
||||
{show().episodes.length} episodes
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{show().podcast.description?.slice(0, 400) ??
|
||||
"No description."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter/l: open · h: back · x: unsubscribe
|
||||
{app.state().preferences.autoDownloadScope ===
|
||||
"whitelist"
|
||||
? (app.state().preferences.autoDownloadWhitelist ??
|
||||
[]
|
||||
).includes(show().id)
|
||||
? " · w: un-whitelist"
|
||||
: " · w: whitelist"
|
||||
: ""}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(show) => (
|
||||
{(d) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>{showTitle(show())}</strong>
|
||||
<strong>{d().episodeTitle ?? d().episodeId}</strong>
|
||||
</text>
|
||||
<Show when={show().podcast.author}>
|
||||
<text fg={muted()}>by {show().podcast.author}</text>
|
||||
</Show>
|
||||
<text fg={theme.textSecondary}>
|
||||
{show().episodes.length} episodes
|
||||
{d().podcastTitle ?? d().feedId}
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={d().pubDate}>
|
||||
<text fg={theme.info}>
|
||||
{formatDate(new Date(d().pubDate!))}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(d().episodeId)}>
|
||||
<text fg={downloadColor(d().episodeId)}>
|
||||
{downloadLabel(d().episodeId)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={muted()}>
|
||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||
Downloaded from episode search — the show is not
|
||||
subscribed.
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter/l: open · h: back · x: unsubscribe
|
||||
{app.state().preferences.autoDownloadScope === "whitelist"
|
||||
? (app.state().preferences.autoDownloadWhitelist ??
|
||||
[]
|
||||
).includes(show().id)
|
||||
? " · w: un-whitelist"
|
||||
: " · w: whitelist"
|
||||
: ""}
|
||||
enter: play · D: delete download · h: back
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function PlaybackControls(props: PlaybackControlsProps) {
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg={theme.textMuted}>Speed</text>
|
||||
<text fg={theme.text}>{props.speed}x</text>
|
||||
<text fg={theme.textMuted}>s</text>
|
||||
<text fg={theme.textMuted}>S</text>
|
||||
</box>
|
||||
</box>
|
||||
{/* audio warnings — wrap to their own (3rd) line when the row is tight */}
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
* tab root.
|
||||
*/
|
||||
|
||||
import { Show } from "solid-js";
|
||||
import { Show, onMount, onCleanup } from "solid-js";
|
||||
import { PlaybackControls } from "./PlaybackControls";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useVisualizer } from "@/stores/visualizer";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
||||
@@ -27,7 +28,19 @@ export function PlayerPage() {
|
||||
const audio = useAudio();
|
||||
const { theme } = useTheme();
|
||||
const nav = useNavigation();
|
||||
const viz = useVisualizer();
|
||||
const app = useAppStore();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
// Settings master switch: off hides the waveform entirely (the store
|
||||
// also stops the decode+FFT pipeline, see stores/visualizer.ts).
|
||||
const vizEnabled = () => app.state().settings.visualizer.enabled;
|
||||
|
||||
// The page is mounted exactly while the Player tab is in focus (Shell
|
||||
// renders only the active tab), so mount ⇔ focused. Report it to the
|
||||
// visualizer store: losing focus starts the unload grace timer instead
|
||||
// of killing the pipeline with the page; regaining focus restarts it.
|
||||
onMount(() => viz.setFocused(true));
|
||||
onCleanup(() => viz.setFocused(false));
|
||||
|
||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||
|
||||
@@ -82,18 +95,9 @@ export function PlayerPage() {
|
||||
|
||||
<ProgressBar />
|
||||
|
||||
<RealtimeWaveform
|
||||
visualizerConfig={(() => {
|
||||
const viz = useAppStore().state().settings.visualizer;
|
||||
// bars is width-derived in RealtimeWaveform; pass only the
|
||||
// audio-processing params here.
|
||||
return {
|
||||
noiseReduction: viz.noiseReduction,
|
||||
lowCutOff: viz.lowCutOff,
|
||||
highCutOff: viz.highCutOff,
|
||||
};
|
||||
})()}
|
||||
/>
|
||||
<Show when={vizEnabled()}>
|
||||
<RealtimeWaveform />
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -1,55 +1,30 @@
|
||||
/**
|
||||
* RealtimeWaveform — live audio frequency visualization using cavacore.
|
||||
* RealtimeWaveform — renders the shared visualizer pipeline state.
|
||||
*
|
||||
* Spawns an independent ffmpeg
|
||||
* process to decode the audio stream, feeds PCM samples through cavacore
|
||||
* for FFT analysis, and renders frequency bars as colored terminal
|
||||
* characters at ~30fps.
|
||||
* The pipeline (ffmpeg decode + cavacore FFT) lives in the module-level
|
||||
* visualizer store (`@/stores/visualizer`), not in this component, so it
|
||||
* survives PlayerPage unmounts: leaving the Player tab keeps the waveform
|
||||
* warm for VISUALIZER_UNLOAD_DELAY_MS, then the store tears it down.
|
||||
*
|
||||
* This component only subscribes to store state, reports the width-derived
|
||||
* bar count (terminal resize re-inits the running pipeline), and renders:
|
||||
* a braille spinner while the pipeline is loading its first frames, the
|
||||
* frequency bars once frames arrive, and a dotted placeholder when idle.
|
||||
*/
|
||||
|
||||
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
||||
import { createEffect, on } from "solid-js";
|
||||
import { useTerminalDimensions } from "@opentui/solid";
|
||||
import {
|
||||
loadCavaCore,
|
||||
type CavaCore,
|
||||
type CavaCoreConfig,
|
||||
} from "@/utils/cavacore";
|
||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
||||
import { BAR_LEVELS, barChars, createBarScaler } from "@/utils/bar-mapping";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useVisualizer } from "@/stores/visualizer";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
|
||||
import { PANE_RATIO } from "@/utils/navigation";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type RealtimeWaveformProps = {
|
||||
visualizerConfig?: Partial<CavaCoreConfig>;
|
||||
};
|
||||
|
||||
/** Target frame interval in ms (~30 fps) */
|
||||
const FRAME_INTERVAL = 33;
|
||||
|
||||
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
||||
const SAMPLES_PER_FRAME = 512;
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
|
||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
export function RealtimeWaveform() {
|
||||
const { theme } = useTheme();
|
||||
const audio = useAudio();
|
||||
|
||||
// Frequency bar values (0.0–1.0 per bar)
|
||||
const [barData, setBarData] = createSignal<number[]>([]);
|
||||
|
||||
// Peak-follower scaler replaces cava's autosens: normalizes each FFT
|
||||
// frame against the running peak so a loud start can't pin every bar
|
||||
// at full height and quiet content still gets normalized up.
|
||||
const scaler = createBarScaler();
|
||||
|
||||
let cava: CavaCore | null = null;
|
||||
let reader: AudioStreamReader | null = null;
|
||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let sampleBuffer: Float64Array | null = null;
|
||||
const viz = useVisualizer();
|
||||
|
||||
// Bar count scales with terminal width so the waveform fills its pane.
|
||||
// The player is a 2-pane row: current column = (current+preview) of
|
||||
@@ -68,181 +43,25 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
);
|
||||
};
|
||||
|
||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
||||
|
||||
const initCava = () => {
|
||||
if (cava) return true;
|
||||
|
||||
cava = loadCavaCore();
|
||||
if (!cava) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── Smooth position clock ──────────────────────────────────────────
|
||||
//
|
||||
// audio.position() updates at the useAudio poll rate (~150ms). Between
|
||||
// polls, interpolate the position from wall time so the FFT window
|
||||
// tracks the audio continuously instead of stepping. The 0.5s cap
|
||||
// prevents extrapolating far beyond reality when the player stalls
|
||||
// (e.g. network re-buffering).
|
||||
|
||||
let lastPolledPosition = 0;
|
||||
let lastPolledAt = 0;
|
||||
const smoothPosition = () => {
|
||||
const pos = audio.position();
|
||||
const now = performance.now();
|
||||
if (pos !== lastPolledPosition) {
|
||||
lastPolledPosition = pos;
|
||||
lastPolledAt = now;
|
||||
return pos;
|
||||
}
|
||||
if (lastPolledAt === 0) return pos;
|
||||
const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5);
|
||||
return lastPolledPosition + elapsed * (audio.speed() ?? 1);
|
||||
};
|
||||
|
||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||
|
||||
const startVisualization = (url: string, position: number, speed: number) => {
|
||||
stopVisualization();
|
||||
|
||||
if (!url || !initCava() || !cava) return;
|
||||
|
||||
// Initialize cavacore with current resolution + any overrides.
|
||||
// bars is width-derived (see numBars); visualizerConfig supplies the
|
||||
// audio-processing params (noise reduction, cutoffs, etc.).
|
||||
// autosens is disabled (after the spread so it always wins): cava's
|
||||
// autosens gain-ramps during silence then clips everything to 1.0
|
||||
// when audio arrives — the JS peak scaler handles dynamics instead.
|
||||
const config: CavaCoreConfig = {
|
||||
bars: numBars(),
|
||||
sampleRate: 44100,
|
||||
channels: 1,
|
||||
...props.visualizerConfig,
|
||||
autosens: 0,
|
||||
};
|
||||
cava.init(config);
|
||||
|
||||
// Pre-warm the FFT window: libcavacore's window is malloc'd
|
||||
// uninitialized, so the first real frame would FFT garbage and
|
||||
// render full-scale bars. One zero frame the size of the whole
|
||||
// input buffer clears it (at 44.1kHz mono the window is 8192
|
||||
// samples — FFTbassbufferSize × channels; a 512-sample frame would
|
||||
// leave the tail garbage).
|
||||
cava.execute(new Float64Array(8192));
|
||||
|
||||
// Pre-allocate sample read buffer
|
||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
||||
|
||||
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
|
||||
if (!reader || reader.url !== url) {
|
||||
if (reader) reader.stop();
|
||||
reader = new AudioStreamReader({ url });
|
||||
}
|
||||
reader.start(position, speed);
|
||||
|
||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||
};
|
||||
|
||||
const stopVisualization = () => {
|
||||
if (frameTimer) {
|
||||
clearInterval(frameTimer);
|
||||
frameTimer = null;
|
||||
}
|
||||
if (reader) {
|
||||
reader.stop();
|
||||
// Don't null reader — we reuse it across start/stop cycles
|
||||
}
|
||||
if (cava?.isReady) {
|
||||
cava.destroy();
|
||||
}
|
||||
sampleBuffer = null;
|
||||
};
|
||||
|
||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
||||
|
||||
const renderFrame = () => {
|
||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
||||
|
||||
// Sample the FFT window at the player's position, not the decode
|
||||
// head — the reader decodes independently (paced at the player's
|
||||
// clock rate with a LEAD_SECONDS burst head start) and only the
|
||||
// position clock ties the bars to what's actually playing.
|
||||
const target = smoothPosition();
|
||||
const count = reader.read(sampleBuffer, target);
|
||||
// Never feed a partial FFT window to cava.
|
||||
if (count < sampleBuffer.length) return;
|
||||
|
||||
const output = cava.execute(sampleBuffer);
|
||||
|
||||
// Normalize against the running peak and copy to a new array
|
||||
setBarData(scaler(output));
|
||||
};
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
audio.isPlaying,
|
||||
() => audio.currentEpisode()?.audioUrl ?? "",
|
||||
audio.speed,
|
||||
numBars,
|
||||
],
|
||||
([playing, url, speed]) => {
|
||||
if (playing && url) {
|
||||
const pos = untrack(audio.position);
|
||||
startVisualization(url, pos, speed);
|
||||
} else {
|
||||
stopVisualization();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ── Seek detection: lightweight effect for position jumps ──────────
|
||||
//
|
||||
// Watches position and restarts the reader (not the whole pipeline)
|
||||
// only on significant jumps (>2s), which indicate a user seek.
|
||||
// This is intentionally a separate effect — it should NOT trigger a
|
||||
// full pipeline restart, just restart the ffmpeg stream at the new pos.
|
||||
|
||||
let lastSyncPosition = 0;
|
||||
createEffect(
|
||||
on(audio.position, (pos) => {
|
||||
if (!audio.isPlaying || !reader?.running) {
|
||||
lastSyncPosition = pos;
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = Math.abs(pos - lastSyncPosition);
|
||||
lastSyncPosition = pos;
|
||||
|
||||
if (delta > 2) {
|
||||
reader.restart(pos, audio.speed() ?? 1);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
onCleanup(() => {
|
||||
stopVisualization();
|
||||
if (reader) {
|
||||
reader.stop();
|
||||
reader = null;
|
||||
}
|
||||
// Don't null cava itself — it can be reused. But do destroy its plan.
|
||||
if (cava?.isReady) {
|
||||
cava.destroy();
|
||||
}
|
||||
});
|
||||
// Keep the store's bar count in sync with the terminal width; the store
|
||||
// re-inits the running pipeline when it changes (terminal resize).
|
||||
createEffect(on(numBars, (n) => viz.setBarCount(n)));
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────
|
||||
|
||||
const renderLine = () => {
|
||||
const bars = barData();
|
||||
const bars = viz.barData();
|
||||
const count = numBars();
|
||||
|
||||
// Loading state: the braille spinner shows while the pipeline warms
|
||||
// up — but only when there are no bars to render yet (first play /
|
||||
// after an unload). On resume/seek the last bars stay on screen
|
||||
// until fresh frames arrive, so the waveform never blanks out for
|
||||
// the (multi-second, network-bound) cold start.
|
||||
if (bars.length === 0 && viz.isLoading()) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (bars.length === 0) {
|
||||
const placeholder = ".".repeat(count);
|
||||
return (
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
} from "solid-js";
|
||||
import { useSearchStore } from "@/stores/search";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useDownloadStore } from "@/stores/download";
|
||||
import { useAudio } from "@/hooks/useAudio";
|
||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||
import { DownloadStatus } from "@/types/episode";
|
||||
import { useToast } from "@/ui/toast";
|
||||
import { format } from "date-fns";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
@@ -55,6 +59,9 @@ export const SearchPaneCount = 1;
|
||||
function SearchPage() {
|
||||
const searchStore = useSearchStore();
|
||||
const feedStore = useFeedStore();
|
||||
const downloadStore = useDownloadStore();
|
||||
const audio = useAudio();
|
||||
const audioNav = useAudioNavStore();
|
||||
const toast = useToast();
|
||||
const [inputValue, setInputValue] = createSignal("");
|
||||
const { theme } = useTheme();
|
||||
@@ -134,6 +141,35 @@ function SearchPage() {
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
|
||||
const downloadLabel = (id: string) => {
|
||||
switch (downloadStore.getDownloadStatus(id)) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return "[Q]";
|
||||
case DownloadStatus.DOWNLOADING:
|
||||
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||
case DownloadStatus.COMPLETED:
|
||||
return "[DL]";
|
||||
case DownloadStatus.FAILED:
|
||||
return "[ERR]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
const downloadColor = (id: string) => {
|
||||
switch (downloadStore.getDownloadStatus(id)) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return theme.warning;
|
||||
case DownloadStatus.DOWNLOADING:
|
||||
return theme.primary;
|
||||
case DownloadStatus.COMPLETED:
|
||||
return theme.success;
|
||||
case DownloadStatus.FAILED:
|
||||
return theme.error;
|
||||
default:
|
||||
return muted();
|
||||
}
|
||||
};
|
||||
|
||||
const runSearch = (query: string) => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
@@ -185,6 +221,59 @@ function SearchPage() {
|
||||
if (feed) searchStore.markSubscribed(result.podcast.id);
|
||||
};
|
||||
|
||||
/** The subscribed feed backing a search result, if any (matched by
|
||||
* directory id or feed URL). */
|
||||
const feedForResult = (r: SearchResult) =>
|
||||
feedStore.feeds().find(
|
||||
(f) =>
|
||||
f.podcast.id === r.podcast.id ||
|
||||
(!!r.podcast.feedUrl && f.podcast.feedUrl === r.podcast.feedUrl),
|
||||
);
|
||||
|
||||
/** Download the focused episode: under its subscribed feed when the show
|
||||
* is subscribed, otherwise as an "unsubscribed show" download (listed
|
||||
* under Unsubscribed Show Downloads in My Shows / the download manager). */
|
||||
const downloadFocusedEpisode = () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || r.kind !== "episode") return;
|
||||
const feed = feedForResult(r);
|
||||
if (feed) downloadStore.startDownload(r.episode, feed.id);
|
||||
else downloadStore.startUnsubscribedDownload(r.episode, r.podcast);
|
||||
};
|
||||
|
||||
const playFocusedEpisode = () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || r.kind !== "episode") return;
|
||||
audio.play(r.episode).catch(() => {});
|
||||
audioNav.setSource(AudioSource.SEARCH, r.podcast.id);
|
||||
};
|
||||
|
||||
const unsubscribeFocused = () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || !r.podcast.isSubscribed) return;
|
||||
const feed = feedForResult(r);
|
||||
if (feed) {
|
||||
feedStore.removeFeed(feed.id);
|
||||
downloadStore
|
||||
.removeDownloadsForFeed(feed.id, feed.podcast.feedUrl || undefined)
|
||||
.catch(() => {});
|
||||
searchStore.markUnsubscribed(r.podcast.id, r.podcast.feedUrl);
|
||||
}
|
||||
};
|
||||
|
||||
/** Subscribe the focused result's show in place (episode or podcast
|
||||
* result). `enter` plays episodes regardless of subscription, so an
|
||||
* unsubscribed show's episode needs this explicit path. */
|
||||
const subscribeFocused = () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || r.podcast.isSubscribed) return;
|
||||
handleSubscribe(r);
|
||||
};
|
||||
|
||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||
"move-down": () => step(1),
|
||||
@@ -205,6 +294,18 @@ function SearchPage() {
|
||||
);
|
||||
}
|
||||
},
|
||||
download: () => downloadFocusedEpisode(),
|
||||
"delete-download": () => {
|
||||
if (depth() !== 1) return;
|
||||
const r = focusedResult();
|
||||
if (!r || r.kind !== "episode") return;
|
||||
const id = r.episode.id;
|
||||
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||
downloadStore.cancelDownload(id);
|
||||
downloadStore.removeDownload(id).catch(() => {});
|
||||
},
|
||||
unsubscribe: () => unsubscribeFocused(),
|
||||
subscribe: () => subscribeFocused(),
|
||||
search: () => {
|
||||
// `s` refocuses the query input (typing mode) when on the query depth.
|
||||
if (depth() === 0) nav.setInputFocused(true);
|
||||
@@ -230,7 +331,15 @@ function SearchPage() {
|
||||
}
|
||||
if (depth() === 1) {
|
||||
const r = focusedResult();
|
||||
if (r) handleSubscribe(r);
|
||||
if (!r) return;
|
||||
if (r.kind === "episode") {
|
||||
// Any episode result streams directly — subscribed or not
|
||||
// (matches Feed/My Shows). `a` subscribes an unsubscribed
|
||||
// show's episode in place.
|
||||
playFocusedEpisode();
|
||||
return;
|
||||
}
|
||||
handleSubscribe(r);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,6 +583,13 @@ function SearchPage() {
|
||||
{(result, index) => {
|
||||
const fi = () => focusedResultIdx();
|
||||
const ref = useScrollIntoView(() => index() === fi());
|
||||
// Episode download status badge ("" when absent).
|
||||
const dlLabel = () =>
|
||||
result.kind === "episode"
|
||||
? downloadLabel(result.episode.id)
|
||||
: "";
|
||||
const dlEpId = () =>
|
||||
result.kind === "episode" ? result.episode.id : "";
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
@@ -495,6 +611,11 @@ function SearchPage() {
|
||||
? result.episode.title
|
||||
: result.podcast.title}
|
||||
</text>
|
||||
<Show when={dlLabel()}>
|
||||
<text fg={downloadColor(dlEpId())}>
|
||||
{dlLabel()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={result.podcast.isSubscribed}>
|
||||
<text
|
||||
fg={index() === fi() ? theme.surface : theme.success}
|
||||
@@ -576,9 +697,16 @@ function SearchPage() {
|
||||
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={muted()}>
|
||||
Published: {formatDate(r.episode.pubDate)}
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={muted()}>
|
||||
Published: {formatDate(r.episode.pubDate)}
|
||||
</text>
|
||||
<Show when={downloadLabel(r.episode.id)}>
|
||||
<text fg={downloadColor(r.episode.id)}>
|
||||
{downloadLabel(r.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={(r.podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
|
||||
@@ -591,15 +719,31 @@ function SearchPage() {
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<Show when={!r.podcast.isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
<text fg={theme.primary}>[+] Subscribe (a)</text>
|
||||
</Show>
|
||||
<Show when={r.podcast.isSubscribed}>
|
||||
<text fg={theme.success}>Already subscribed</text>
|
||||
<text fg={theme.success}>
|
||||
Subscribed · x: unsubscribe
|
||||
</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: subscribe to show · h: back to query
|
||||
</text>
|
||||
<Show
|
||||
when={r.podcast.isSubscribed}
|
||||
fallback={
|
||||
<text fg={muted()}>
|
||||
enter: play · a: subscribe · d: download · h: back to query
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<text fg={muted()}>
|
||||
enter: play · d: download · x: unsubscribe
|
||||
{downloadStore.getDownloadStatus(r.episode.id) !==
|
||||
DownloadStatus.NONE
|
||||
? " · D: delete"
|
||||
: ""}{" "}
|
||||
· h: back to query
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -640,10 +784,16 @@ function SearchPage() {
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<Show when={r.podcast.isSubscribed}>
|
||||
<text fg={theme.success}>Already subscribed</text>
|
||||
<text fg={theme.success}>
|
||||
Subscribed · x: unsubscribe
|
||||
</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: subscribe · h: back to query</text>
|
||||
<text fg={muted()}>
|
||||
enter: subscribe
|
||||
{r.podcast.isSubscribed ? " · x: unsubscribe" : ""}{" "}
|
||||
· h: back to query
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
/**
|
||||
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
|
||||
*
|
||||
* • "Delete All Downloads" — action item; Enter wipes every download.
|
||||
* • one item per show — action item; Enter deletes all that show's
|
||||
* downloads (file + metadata, aborts in-flight).
|
||||
* • one item per episode — action item; Enter deletes a single download.
|
||||
* • "Delete All Downloads" — action item; Enter wipes every download.
|
||||
* • one item per subscribed show — action item; Enter deletes all that
|
||||
* show's downloads (file + metadata, aborts
|
||||
* in-flight).
|
||||
* • "Unsubscribed Show Downloads" — downloads made from episode search for
|
||||
* shows that aren't subscribed, grouped
|
||||
* under their own header.
|
||||
* • one item per episode — action item; Enter deletes a single download.
|
||||
*
|
||||
* Titles resolve from the feed store at render time (reactive), falling back
|
||||
* to the episode id when the feed is no longer loaded. Movement flows through
|
||||
* nav.action — no own useKeyboard (matches the other panels).
|
||||
* to the persisted episode/show titles for unsubscribed-show downloads.
|
||||
* Movement flows through nav.action — no own useKeyboard (matches the other
|
||||
* panels).
|
||||
*/
|
||||
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
@@ -40,23 +45,26 @@ function statusLabel(s: DownloadStatus): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Episode title for a download, resolved from the feed store (reactive). */
|
||||
/** Episode title for a download, resolved from the feed store (reactive);
|
||||
* falls back to the persisted title (kept for unsubscribed-show downloads). */
|
||||
function episodeTitle(
|
||||
feedStore: ReturnType<typeof useFeedStore>,
|
||||
d: DownloadedEpisode,
|
||||
): string {
|
||||
const feed = feedStore.getFeed(d.feedId);
|
||||
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
|
||||
return ep?.title ?? d.episodeId;
|
||||
return ep?.title ?? d.episodeTitle ?? d.episodeId;
|
||||
}
|
||||
|
||||
/** Show title for a download's feed id. */
|
||||
/** Show title for a download's feed id; falls back to the persisted show
|
||||
* title (unsubscribed-show downloads have no feed to resolve from). */
|
||||
function feedTitle(
|
||||
feedStore: ReturnType<typeof useFeedStore>,
|
||||
feedId: string,
|
||||
d: DownloadedEpisode,
|
||||
): string {
|
||||
const feed = feedStore.getFeed(feedId);
|
||||
return feed ? feed.customName || feed.podcast.title : feedId;
|
||||
const feed = feedStore.getFeed(d.feedId);
|
||||
if (feed) return feed.customName || feed.podcast.title;
|
||||
return d.podcastTitle ?? d.feedId;
|
||||
}
|
||||
|
||||
export function useDownloadItems(): SettingItem[] {
|
||||
@@ -82,9 +90,15 @@ export function useDownloadItems(): SettingItem[] {
|
||||
},
|
||||
];
|
||||
|
||||
// Group downloads by feed so each show gets a delete-by-show item.
|
||||
// Group downloads by feed so each subscribed show gets a delete-by-show
|
||||
// item. Unsubscribed-show downloads (search downloads, synthetic feed
|
||||
// ids) are kept out of these groups and listed under their own section
|
||||
// below.
|
||||
const unsubscribed = downloadStore.getUnsubscribedDownloads();
|
||||
const unsubscribedIds = new Set(unsubscribed.map((d) => d.episodeId));
|
||||
const byFeed = new Map<string, DownloadedEpisode[]>();
|
||||
for (const d of downloads()) {
|
||||
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||
const arr = byFeed.get(d.feedId) ?? [];
|
||||
arr.push(d);
|
||||
byFeed.set(d.feedId, arr);
|
||||
@@ -93,25 +107,54 @@ export function useDownloadItems(): SettingItem[] {
|
||||
const size = eps.reduce((s, e) => s + e.fileSize, 0);
|
||||
items.push({
|
||||
id: `feed:${feedId}`,
|
||||
label: `Show: ${feedTitle(feedStore, feedId)}`,
|
||||
label: `Show: ${feedTitle(feedStore, eps[0])}`,
|
||||
kind: "action",
|
||||
display: () => `${eps.length} · ${fmtBytes(size)}`,
|
||||
help: () =>
|
||||
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
|
||||
run: () => {
|
||||
downloadStore.removeDownloadsForFeed(feedId).catch(() => {});
|
||||
downloadStore
|
||||
.removeDownloadsForFeed(feedId, eps[0].podcastFeedUrl)
|
||||
.catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// One item per individual episode download.
|
||||
// Unsubscribed-show downloads: a section header + one item per episode.
|
||||
if (unsubscribed.length > 0) {
|
||||
items.push({
|
||||
id: "unsubscribed-header",
|
||||
label: "Unsubscribed Show Downloads",
|
||||
kind: "info",
|
||||
display: () => `${unsubscribed.length} files`,
|
||||
help: () =>
|
||||
`Downloads made from episode search for shows that are not\nsubscribed. Subscribe to a show and these move into its group.`,
|
||||
});
|
||||
}
|
||||
for (const d of unsubscribed) {
|
||||
items.push({
|
||||
id: `unsub:${d.episodeId}`,
|
||||
label: episodeTitle(feedStore, d),
|
||||
kind: "action",
|
||||
display: () =>
|
||||
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||
help: () =>
|
||||
`Delete this single download (file + metadata). Enter to run.`,
|
||||
run: () => {
|
||||
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// One item per individual (subscribed-show) episode download.
|
||||
for (const d of downloads()) {
|
||||
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||
items.push({
|
||||
id: `ep:${d.episodeId}`,
|
||||
label: episodeTitle(feedStore, d),
|
||||
kind: "action",
|
||||
display: () =>
|
||||
`${feedTitle(feedStore, d.feedId)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||
help: () =>
|
||||
`Delete this single download (file + metadata). Enter to run.`,
|
||||
run: () => {
|
||||
|
||||
@@ -219,6 +219,32 @@ export function usePreferencesItems(): SettingItem[] {
|
||||
app.updatePreferences({ fetchMoreMode: next });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "refreshInterval",
|
||||
label: "Feed Refresh Interval",
|
||||
kind: "number",
|
||||
display: () => `${prefs().refreshIntervalMinutes} min`,
|
||||
help: () =>
|
||||
`How often subscribed feeds are re-fetched in the background, so new episodes appear without a restart or manual refresh (r).\nType: number (1–120 minutes)\nDefault: 30\nCurrent: ${prefs().refreshIntervalMinutes} min\nj/k to −/+5 · Enter to type a value.`,
|
||||
cycle: (dir) => {
|
||||
const next = Math.min(
|
||||
120,
|
||||
Math.max(1, prefs().refreshIntervalMinutes + dir * 5),
|
||||
);
|
||||
app.updatePreferences({ refreshIntervalMinutes: next });
|
||||
},
|
||||
renderEditor: () => (
|
||||
<NumberInputEditor
|
||||
label="Feed Refresh Interval (minutes)"
|
||||
value={() => prefs().refreshIntervalMinutes}
|
||||
commit={(n) => {
|
||||
app.updatePreferences({
|
||||
refreshIntervalMinutes: Math.min(120, n),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Whitelist management only appears while scope is set to "whitelist".
|
||||
|
||||
@@ -64,7 +64,7 @@ const SECTIONS: SettingsSectionDef[] = [
|
||||
{
|
||||
id: 3,
|
||||
label: "Visualizer",
|
||||
description: "Audio visualizer: bars, sensitivity, cutoffs.",
|
||||
description: "Audio visualizer: on/off, bars, sensitivity, cutoffs.",
|
||||
icon: NF_ICONS.visualizer,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,6 +11,15 @@ export function useVisualizerItems(): SettingItem[] {
|
||||
const viz = () => app.state().settings.visualizer;
|
||||
|
||||
return [
|
||||
{
|
||||
id: "enabled",
|
||||
label: "Waveform",
|
||||
kind: "toggle",
|
||||
display: () => (viz().enabled ? "On" : "Off"),
|
||||
help: () =>
|
||||
`Realtime waveform visualizer in the player.\nType: toggle\nDefault: on\nCurrent: ${viz().enabled ? "on" : "off"}\nSpace/Enter to toggle.`,
|
||||
toggle: () => app.updateVisualizer({ enabled: !viz().enabled }),
|
||||
},
|
||||
{
|
||||
id: "bars",
|
||||
label: "Bars",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../utils/app-persistence";
|
||||
|
||||
const defaultVisualizerSettings: VisualizerSettings = {
|
||||
enabled: true,
|
||||
bars: 64,
|
||||
sensitivity: 1,
|
||||
noiseReduction: 0.77,
|
||||
@@ -28,6 +29,7 @@ const defaultSettings: AppSettings = {
|
||||
theme: "system",
|
||||
fontSize: 14,
|
||||
playbackSpeed: 1,
|
||||
volume: 1,
|
||||
downloadPath: "",
|
||||
transparentBackground: false,
|
||||
showSelectionMarker: false,
|
||||
@@ -42,6 +44,7 @@ const defaultPreferences: UserPreferences = {
|
||||
autoDownloadWhitelist: [],
|
||||
autoJumpToPlayer: true,
|
||||
fetchMoreMode: "manual",
|
||||
refreshIntervalMinutes: 30,
|
||||
};
|
||||
|
||||
const defaultState: AppState = {
|
||||
@@ -54,12 +57,14 @@ function createAppStore() {
|
||||
// Start with defaults; async load will update once ready
|
||||
const [state, setState] = createSignal<AppState>(defaultState);
|
||||
|
||||
// Fire-and-forget async initialisation
|
||||
// Fire-and-forget async initialisation; the promise is exposed via
|
||||
// whenReady() so boot-time consumers (audio-level restore) can await
|
||||
// the config read before reading settings.
|
||||
const init = async () => {
|
||||
const loaded = await loadAppStateFromFile();
|
||||
setState(loaded);
|
||||
};
|
||||
init();
|
||||
const appInit = init();
|
||||
|
||||
const saveState = (next: AppState) => {
|
||||
saveAppStateToFile(next);
|
||||
@@ -118,6 +123,8 @@ function createAppStore() {
|
||||
|
||||
return {
|
||||
state,
|
||||
/** Resolves once persisted settings are loaded from disk. */
|
||||
whenReady: () => appInit,
|
||||
updateSettings,
|
||||
updatePreferences,
|
||||
updateCustomTheme,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createSignal } from "solid-js";
|
||||
import { DownloadStatus } from "../types/episode";
|
||||
import type { DownloadedEpisode } from "../types/episode";
|
||||
import type { Episode } from "../types/episode";
|
||||
import type { Podcast } from "../types/podcast";
|
||||
import { downloadEpisode } from "../utils/episode-downloader";
|
||||
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
|
||||
import { useFeedStore } from "./feed";
|
||||
@@ -17,6 +18,24 @@ import { useFeedStore } from "./feed";
|
||||
const DOWNLOADS_FILE = "downloads.json";
|
||||
const MAX_CONCURRENT = 2;
|
||||
|
||||
/** Prefix for synthetic feed ids of unsubscribed-show downloads (search
|
||||
* downloads). The id doubles as the file subdirectory name, so it must be
|
||||
* filesystem-safe. */
|
||||
const UNSUBSCRIBED_FEED_PREFIX = "unsub-";
|
||||
|
||||
/** Deterministic synthetic feed id for a show that isn't subscribed: groups
|
||||
* its search downloads together (and names their file subdirectory) without
|
||||
* colliding with real feed ids (UUIDs). */
|
||||
function unsubscribedFeedId(podcast: Pick<Podcast, "feedUrl" | "title">): string {
|
||||
const base = podcast.feedUrl || podcast.title;
|
||||
const slug = base
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
return `${UNSUBSCRIBED_FEED_PREFIX}${slug || "podcast"}`;
|
||||
}
|
||||
|
||||
/** Serializable download record for persistence */
|
||||
interface DownloadRecord {
|
||||
episodeId: string;
|
||||
@@ -28,6 +47,12 @@ interface DownloadRecord {
|
||||
error: string | null;
|
||||
audioUrl: string;
|
||||
episodeTitle: string;
|
||||
/** ISO publication date, for unsubscribed-show downloads. */
|
||||
pubDate?: string;
|
||||
/** Show title, for downloads whose show isn't subscribed. */
|
||||
podcastTitle?: string;
|
||||
/** The show's RSS feed URL (re-classifies the download once subscribed). */
|
||||
podcastFeedUrl?: string;
|
||||
}
|
||||
|
||||
/** Queue item for pending downloads */
|
||||
@@ -81,6 +106,11 @@ function createDownloadStore() {
|
||||
speed: 0,
|
||||
fileSize: rec.fileSize,
|
||||
error: rec.error,
|
||||
episodeTitle: rec.episodeTitle || undefined,
|
||||
audioUrl: rec.audioUrl || undefined,
|
||||
pubDate: rec.pubDate || undefined,
|
||||
podcastTitle: rec.podcastTitle || undefined,
|
||||
podcastFeedUrl: rec.podcastFeedUrl || undefined,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
@@ -106,8 +136,11 @@ function createDownloadStore() {
|
||||
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
|
||||
fileSize: dl.fileSize,
|
||||
error: dl.error,
|
||||
audioUrl: qItem?.audioUrl ?? "",
|
||||
episodeTitle: qItem?.episodeTitle ?? "",
|
||||
audioUrl: dl.audioUrl ?? qItem?.audioUrl ?? "",
|
||||
episodeTitle: dl.episodeTitle ?? qItem?.episodeTitle ?? "",
|
||||
pubDate: dl.pubDate,
|
||||
podcastTitle: dl.podcastTitle,
|
||||
podcastFeedUrl: dl.podcastFeedUrl,
|
||||
});
|
||||
}
|
||||
const filePath = getConfigFilePath(DOWNLOADS_FILE);
|
||||
@@ -260,8 +293,20 @@ function createDownloadStore() {
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Optional metadata for a download whose show isn't subscribed (search
|
||||
* downloads) — without it the record cannot render a title or be
|
||||
* re-classified once the show is subscribed. */
|
||||
interface UnsubscribedMeta {
|
||||
podcastTitle: string;
|
||||
podcastFeedUrl?: string;
|
||||
}
|
||||
|
||||
/** Start downloading an episode */
|
||||
const startDownload = (episode: Episode, feedId: string): void => {
|
||||
const startDownload = (
|
||||
episode: Episode,
|
||||
feedId: string,
|
||||
meta?: UnsubscribedMeta,
|
||||
): void => {
|
||||
const existing = downloads().get(episode.id);
|
||||
if (
|
||||
existing?.status === DownloadStatus.DOWNLOADING ||
|
||||
@@ -280,6 +325,11 @@ function createDownloadStore() {
|
||||
speed: 0,
|
||||
fileSize: episode.fileSize ?? 0,
|
||||
error: null,
|
||||
episodeTitle: episode.title,
|
||||
audioUrl: episode.audioUrl,
|
||||
pubDate: episode.pubDate.toISOString(),
|
||||
podcastTitle: meta?.podcastTitle,
|
||||
podcastFeedUrl: meta?.podcastFeedUrl,
|
||||
};
|
||||
|
||||
setDownloads((prev) => {
|
||||
@@ -300,6 +350,21 @@ function createDownloadStore() {
|
||||
processQueue();
|
||||
};
|
||||
|
||||
/** Start downloading an episode of a show that is NOT subscribed. The
|
||||
* download gets a deterministic synthetic feed id (also its file
|
||||
* subdirectory) plus the show's metadata so it can render under
|
||||
* "Unsubscribed Show Downloads" and re-classify if the user later
|
||||
* subscribes to the show. */
|
||||
const startUnsubscribedDownload = (
|
||||
episode: Episode,
|
||||
podcast: Podcast,
|
||||
): void => {
|
||||
startDownload(episode, unsubscribedFeedId(podcast), {
|
||||
podcastTitle: podcast.title,
|
||||
podcastFeedUrl: podcast.feedUrl || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
/** Cancel a download */
|
||||
const cancelDownload = (episodeId: string): void => {
|
||||
// Abort active download
|
||||
@@ -348,10 +413,18 @@ function createDownloadStore() {
|
||||
};
|
||||
|
||||
/** Remove every download (active/queued/completed) belonging to a feed —
|
||||
* abort in-flight transfers, drop queued items, delete files + metadata. */
|
||||
const removeDownloadsForFeed = async (feedId: string): Promise<void> => {
|
||||
* abort in-flight transfers, drop queued items, delete files + metadata.
|
||||
* Also removes downloads of the same show made while it was unsubscribed
|
||||
* (matched by podcastFeedUrl) so unsubscribing purges search downloads
|
||||
* of that show too. */
|
||||
const removeDownloadsForFeed = async (
|
||||
feedId: string,
|
||||
podcastFeedUrl?: string,
|
||||
): Promise<void> => {
|
||||
const eps = Array.from(downloads().values()).filter(
|
||||
(d) => d.feedId === feedId,
|
||||
(d) =>
|
||||
d.feedId === feedId ||
|
||||
(podcastFeedUrl && d.podcastFeedUrl === podcastFeedUrl),
|
||||
);
|
||||
for (const d of eps) {
|
||||
cancelDownload(d.episodeId);
|
||||
@@ -364,6 +437,24 @@ function createDownloadStore() {
|
||||
return Array.from(downloads().values());
|
||||
};
|
||||
|
||||
/** Downloads whose show is not subscribed — the "Unsubscribed Show
|
||||
* Downloads" list shown in My Shows and the settings download manager.
|
||||
* Reads feeds() so the list re-classifies (drops out) the moment the
|
||||
* user subscribes to the show. Matched by feed id, or by the show's
|
||||
* feed URL (covers downloads made before the show was subscribed). */
|
||||
const getUnsubscribedDownloads = (): DownloadedEpisode[] => {
|
||||
const feeds = useFeedStore().feeds();
|
||||
return Array.from(downloads().values()).filter((d) => {
|
||||
if (feeds.some((f) => f.id === d.feedId)) return false;
|
||||
if (d.podcastFeedUrl) {
|
||||
return !feeds.some(
|
||||
(f) => f.podcast.feedUrl === d.podcastFeedUrl,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/** Get the current queue */
|
||||
const getQueue = (): QueueItem[] => {
|
||||
return queue();
|
||||
@@ -381,11 +472,13 @@ function createDownloadStore() {
|
||||
getDownload,
|
||||
getDownloadedFilePath,
|
||||
getAllDownloads,
|
||||
getUnsubscribedDownloads,
|
||||
getQueue,
|
||||
getActiveCount,
|
||||
|
||||
// Actions
|
||||
startDownload,
|
||||
startUnsubscribedDownload,
|
||||
cancelDownload,
|
||||
removeDownload,
|
||||
removeDownloadsForFeed,
|
||||
|
||||
@@ -29,6 +29,13 @@ const MAX_EPISODES_REFRESH = 50;
|
||||
/** Max episodes to fetch on initial subscribe */
|
||||
const MAX_EPISODES_SUBSCRIBE = 20;
|
||||
|
||||
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
|
||||
* the background refresh loop. */
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** Default minutes between automatic background feed refreshes. */
|
||||
const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
|
||||
|
||||
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
|
||||
const fullEpisodeCache = new Map<string, Episode[]>();
|
||||
|
||||
@@ -201,20 +208,27 @@ function createFeedStore() {
|
||||
);
|
||||
};
|
||||
|
||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes.
|
||||
* Returns NULL when the feed could not be fetched (network error, non-OK
|
||||
* response, timeout) — callers must treat null as "unchanged" and keep
|
||||
* the previously loaded episodes. A failed refresh must never look like
|
||||
* an empty feed, or the store would wipe a subscribed show's episodes. */
|
||||
const fetchEpisodes = async (
|
||||
feedUrl: string,
|
||||
limit: number,
|
||||
feedId?: string,
|
||||
): Promise<Episode[]> => {
|
||||
): Promise<Episode[] | null> => {
|
||||
try {
|
||||
const response = await fetch(feedUrl, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||
},
|
||||
// Hung feeds must not stall a refresh batch (or the background
|
||||
// refresh loop) indefinitely.
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
if (!response.ok) return null;
|
||||
const xml = await response.text();
|
||||
const parsed = parseRSSFeed(xml, feedUrl);
|
||||
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
|
||||
@@ -227,7 +241,7 @@ function createFeedStore() {
|
||||
|
||||
return allEpisodes.slice(0, limit);
|
||||
} catch {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -267,7 +281,7 @@ function createFeedStore() {
|
||||
const newFeed: Feed = {
|
||||
id: feedId,
|
||||
podcast,
|
||||
episodes,
|
||||
episodes: episodes ?? [],
|
||||
visibility,
|
||||
sourceId,
|
||||
lastUpdated: new Date(),
|
||||
@@ -346,6 +360,8 @@ function createFeedStore() {
|
||||
MAX_EPISODES_REFRESH,
|
||||
feedId,
|
||||
);
|
||||
// Fetch failed (null): keep the currently loaded episodes untouched.
|
||||
if (!episodes) return;
|
||||
setFeeds((prev) => {
|
||||
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
|
||||
if (updated !== prev) saveFeeds(updated);
|
||||
@@ -379,6 +395,8 @@ function createFeedStore() {
|
||||
setFeeds((prev) => {
|
||||
let updated = prev;
|
||||
for (const [feedId, episodes] of results) {
|
||||
// A failed fetch (null) leaves that feed untouched.
|
||||
if (!episodes) continue;
|
||||
updated = applyRefreshedEpisodes(updated, feedId, episodes);
|
||||
}
|
||||
if (updated !== prev) saveFeeds(updated);
|
||||
@@ -391,9 +409,15 @@ function createFeedStore() {
|
||||
}
|
||||
};
|
||||
|
||||
// Resolves once the persisted feeds are loaded and visible to feeds() —
|
||||
// before the background refresh so boot-time consumers (player-session
|
||||
// restore) don't wait on the network.
|
||||
const { promise: feedsReady, resolve: resolveFeedsReady } =
|
||||
Promise.withResolvers<void>();
|
||||
(async () => {
|
||||
const loadedFeeds = await loadFeedsFromFile();
|
||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||
resolveFeedsReady();
|
||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||
// The default "rss" placeholder source fabricated fake search results
|
||||
// and was removed from DEFAULT_SOURCES; drop it from persisted configs
|
||||
@@ -422,6 +446,30 @@ function createFeedStore() {
|
||||
await refreshAllFeeds();
|
||||
})();
|
||||
|
||||
// ── Background refresh ──────────────────────────────────────────────────
|
||||
// New episodes only reach the app while it runs if feeds are re-fetched
|
||||
// on a schedule: startup and manual `r` alone leave a subscribed show's
|
||||
// latest episode invisible until the user restarts (or presses r). A
|
||||
// self-rescheduling timer re-reads the interval preference on every tick
|
||||
// so a settings change takes effect without a restart, and skips a tick
|
||||
// that would overlap an in-flight refresh (manual or background).
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const scheduleNextRefresh = () => {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
const minutes = Math.max(
|
||||
1,
|
||||
useAppStore().state().preferences.refreshIntervalMinutes ??
|
||||
DEFAULT_REFRESH_INTERVAL_MINUTES,
|
||||
);
|
||||
refreshTimer = setTimeout(() => {
|
||||
if (!isLoadingFeeds()) {
|
||||
refreshAllFeeds().catch(() => {});
|
||||
}
|
||||
scheduleNextRefresh();
|
||||
}, minutes * 60_000);
|
||||
};
|
||||
scheduleNextRefresh();
|
||||
|
||||
/** Remove a feed */
|
||||
const removeFeed = (feedId: string) => {
|
||||
fullEpisodeCache.delete(feedId);
|
||||
@@ -523,6 +571,16 @@ function createFeedStore() {
|
||||
return feeds().find((f) => f.id === feedId);
|
||||
};
|
||||
|
||||
/** Find an episode by ID across all loaded feeds (undefined when the
|
||||
* episode isn't in any loaded window, e.g. an unsubscribed show). */
|
||||
const findEpisode = (episodeId: string): Episode | undefined => {
|
||||
for (const feed of feeds()) {
|
||||
const ep = feed.episodes.find((e) => e.id === episodeId);
|
||||
if (ep) return ep;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Get selected feed */
|
||||
const getSelectedFeed = (): Feed | undefined => {
|
||||
const id = selectedFeedId();
|
||||
@@ -628,10 +686,15 @@ function createFeedStore() {
|
||||
selectedFeedId,
|
||||
isLoadingMore,
|
||||
|
||||
/** Resolves once persisted feeds are loaded from disk (before the
|
||||
* background refresh). */
|
||||
whenReady: () => feedsReady,
|
||||
|
||||
// Computed
|
||||
getFilteredFeeds,
|
||||
getAllEpisodesChronological,
|
||||
getFeed,
|
||||
findEpisode,
|
||||
getSelectedFeed,
|
||||
hasMoreEpisodes,
|
||||
isLoadingFeeds,
|
||||
|
||||
@@ -53,11 +53,17 @@ async function initProgress(): Promise<void> {
|
||||
setProgressMap(parsed);
|
||||
}
|
||||
|
||||
// Fire-and-forget init
|
||||
initProgress();
|
||||
// Fire-and-forget init; the promise is exposed via whenReady() so boot-time
|
||||
// consumers (e.g. player-session restore) can await the file load.
|
||||
const progressInit = initProgress();
|
||||
|
||||
function createProgressStore() {
|
||||
return {
|
||||
/**
|
||||
* Resolves once the persisted progress map has been loaded from disk.
|
||||
*/
|
||||
whenReady: () => progressInit,
|
||||
|
||||
/**
|
||||
* Get progress for a specific episode.
|
||||
*/
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
|
||||
import {
|
||||
loadSearchHistoryFromFile,
|
||||
saveSearchHistoryToFile,
|
||||
} from "../utils/app-persistence";
|
||||
import { useFeedStore } from "./feed";
|
||||
import type { SearchResult, SearchScope } from "../types/source";
|
||||
|
||||
const STORAGE_KEY = "podtui_search_history";
|
||||
const STORAGE_SCOPE_KEY = "podtui_search_scope";
|
||||
const MAX_HISTORY = 20;
|
||||
const MAX_HISTORY = 10;
|
||||
|
||||
export interface SearchState {
|
||||
query: string;
|
||||
@@ -21,25 +24,19 @@ export interface SearchState {
|
||||
|
||||
const CACHE_TTL = 1000 * 60 * 5;
|
||||
|
||||
/** Load search history from localStorage */
|
||||
function loadHistory(): string[] {
|
||||
if (typeof localStorage === "undefined") return [];
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Save search history to localStorage */
|
||||
function saveHistory(history: string[]): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
|
||||
} catch {
|
||||
// Ignore errors
|
||||
/** Normalize raw history: drop blanks, dedupe case-insensitively (newest
|
||||
* wins), cap at MAX_HISTORY. */
|
||||
function sanitizeHistory(items: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const cleaned: string[] = [];
|
||||
for (const item of items) {
|
||||
const trimmed = item.trim();
|
||||
const key = trimmed.toLowerCase();
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
cleaned.push(trimmed);
|
||||
}
|
||||
return cleaned.slice(0, MAX_HISTORY);
|
||||
}
|
||||
|
||||
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
|
||||
@@ -70,10 +67,19 @@ export function createSearchStore() {
|
||||
const [isSearching, setIsSearching] = createSignal(false);
|
||||
const [results, setResults] = createSignal<SearchResult[]>([]);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [history, setHistory] = createSignal<string[]>(loadHistory());
|
||||
const [history, setHistory] = createSignal<string[]>([]);
|
||||
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
||||
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
|
||||
|
||||
/** Load search history from file (fire-and-forget; recents appear as
|
||||
* soon as the file is read). */
|
||||
async function init(): Promise<void> {
|
||||
const loaded = await loadSearchHistoryFromFile();
|
||||
if (loaded.length > 0) setHistory(sanitizeHistory(loaded));
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
/** Set the search scope (shows vs episodes) and persist it. */
|
||||
const setScope = (next: SearchScope) => {
|
||||
setScopeState(next);
|
||||
@@ -164,9 +170,8 @@ export function createSearchStore() {
|
||||
/** Add query to history */
|
||||
const addToHistory = (q: string) => {
|
||||
setHistory((prev) => {
|
||||
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
||||
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
||||
saveHistory(updated);
|
||||
const updated = sanitizeHistory([q, ...prev]);
|
||||
saveSearchHistoryToFile(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
@@ -174,14 +179,14 @@ export function createSearchStore() {
|
||||
/** Clear search history */
|
||||
const clearHistory = () => {
|
||||
setHistory([]);
|
||||
saveHistory([]);
|
||||
saveSearchHistoryToFile([]);
|
||||
};
|
||||
|
||||
/** Remove single history item */
|
||||
const removeFromHistory = (q: string) => {
|
||||
setHistory((prev) => {
|
||||
const updated = prev.filter((h) => h !== q);
|
||||
saveHistory(updated);
|
||||
saveSearchHistoryToFile(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
@@ -213,6 +218,27 @@ export function createSearchStore() {
|
||||
);
|
||||
};
|
||||
|
||||
/** Mark a podcast as unsubscribed in results (after an in-place
|
||||
* unsubscribe from the results list). */
|
||||
const markUnsubscribed = (podcastId: string, feedUrl?: string) => {
|
||||
setResults((prev) =>
|
||||
prev.map((result) => {
|
||||
const matchesId = result.podcast.id === podcastId;
|
||||
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false;
|
||||
if (matchesId || matchesUrl) {
|
||||
return {
|
||||
...result,
|
||||
podcast: {
|
||||
...result.podcast,
|
||||
isSubscribed: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
query,
|
||||
@@ -232,6 +258,7 @@ export function createSearchStore() {
|
||||
setSelectedSources,
|
||||
setScope,
|
||||
markSubscribed,
|
||||
markUnsubscribed,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
431
src/stores/visualizer.ts
Normal file
431
src/stores/visualizer.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* visualizer-store — module-level singleton owning the realtime waveform
|
||||
* pipeline (ffmpeg decode + cavacore FFT), shared across PlayerPage mounts.
|
||||
*
|
||||
* Pipeline shape (see utils/audio-pcm-cache.ts for the rationale):
|
||||
* an ffmpeg process decodes the episode at full speed into a
|
||||
* position-indexed PCM cache; the render loop reads the window ending at
|
||||
* the player's current position from that cache. Because reads are
|
||||
* indexed by playback time, PAUSE/RESUME/SEEK/SPEED need no pipeline
|
||||
* choreography at all — and cannot desync:
|
||||
*
|
||||
* - Pause: stop the render loop and the decode pass; the PCM cache stays
|
||||
* resident. Bars freeze on the last rendered frame.
|
||||
* - Resume: re-arm the render loop — bars render instantly from the cache
|
||||
* — and continue the tail decode in the background. No cold start, no
|
||||
* coverage guessing, no clamped-buffer freeze (the old bug: resume
|
||||
* re-armed the loop over a DEAD ffmpeg and the bars exhausted the ring
|
||||
* buffer, then froze on a repeated stale window forever).
|
||||
* - Seek into decoded audio: nothing to do. Seek into a hole: kick off a
|
||||
* decode segment there; the last frame holds until data arrives.
|
||||
* - Speed changes: nothing. The cache is position-indexed raw PCM.
|
||||
*
|
||||
* Focus lifecycle: Shell unmounts a tab's page when it loses focus, but the
|
||||
* pipeline outlives the page so playback keeps visualizing; UNLOAD_DELAY_MS
|
||||
* after the Player tab stops being focused it tears down. Reads outside
|
||||
* decoded coverage return empty — the renderer simply holds the last frame
|
||||
* until the decode frontier arrives.
|
||||
*/
|
||||
|
||||
import {
|
||||
createSignal,
|
||||
createEffect,
|
||||
createRoot,
|
||||
on,
|
||||
untrack,
|
||||
} from "solid-js";
|
||||
import {
|
||||
loadCavaCore,
|
||||
type CavaCore,
|
||||
type CavaCoreConfig,
|
||||
} from "@/utils/cavacore";
|
||||
import { EpisodePcmCache, PCM_SAMPLE_RATE } from "@/utils/audio-pcm-cache";
|
||||
import { createBarScaler } from "@/utils/bar-mapping";
|
||||
import { audioPlaybackSignals } from "@/utils/audio-signals";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
/** How long the pipeline keeps running after the Player tab loses focus. */
|
||||
export const VISUALIZER_UNLOAD_DELAY_MS = 30_000;
|
||||
|
||||
/** Target frame interval in ms (~30 fps) */
|
||||
const FRAME_INTERVAL = 33;
|
||||
|
||||
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
||||
const SAMPLES_PER_FRAME = 512;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface VisualizerStore {
|
||||
/** Frequency bar values (0.0–1.0 per bar), empty until the first frame. */
|
||||
barData: () => number[];
|
||||
/** True from pipeline start until the first complete FFT frame renders. */
|
||||
isLoading: () => boolean;
|
||||
/** True while the ~30fps render loop is armed. */
|
||||
isRunning: () => boolean;
|
||||
/** Report whether the Player tab is the visible tab. */
|
||||
setFocused: (focused: boolean) => void;
|
||||
/** Report the terminal-width-derived bar count (resize re-inits). */
|
||||
setBarCount: (count: number) => void;
|
||||
}
|
||||
|
||||
// ── Store factory ────────────────────────────────────────────────────────
|
||||
|
||||
function createVisualizerStore(): VisualizerStore {
|
||||
// Frequency bar values (0.0–1.0 per bar)
|
||||
const [barData, setBarData] = createSignal<number[]>([]);
|
||||
|
||||
// True from pipeline start until the first complete FFT frame renders.
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
// Whether the Player tab is the visible tab (fed by PlayerPage).
|
||||
const [focused, setFocused] = createSignal(false);
|
||||
|
||||
// Width-derived bar count (fed by RealtimeWaveform; default before the
|
||||
// renderer reports a real size).
|
||||
const [barCount, setBarCount] = createSignal(64);
|
||||
|
||||
// Peak-follower scaler replaces cava's autosens: normalizes each FFT
|
||||
// frame against the running peak so a loud start can't pin every bar
|
||||
// at full height and quiet content still gets normalized up.
|
||||
const scaler = createBarScaler();
|
||||
|
||||
let cava: CavaCore | null = null;
|
||||
// Position-indexed PCM cache for the current episode. Kept across
|
||||
// pause/resume (segments survive; only the ffmpeg pass is killed) and
|
||||
// dropped only on episode change, stop, disable, or unload.
|
||||
let pcm: EpisodePcmCache | null = null;
|
||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let sampleBuffer: Float64Array | null = null;
|
||||
let unloadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// What the running pipeline was started with — lets the playback effect
|
||||
// tell "nothing changed, stay warm" from "must restart".
|
||||
let activeUrl = "";
|
||||
let activeBars = 64;
|
||||
|
||||
// ── Lifecycle helpers ──────────────────────────────────────────────
|
||||
|
||||
const clearUnloadTimer = () => {
|
||||
if (unloadTimer) {
|
||||
clearTimeout(unloadTimer);
|
||||
unloadTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const initCava = () => {
|
||||
if (cava) return true;
|
||||
|
||||
cava = loadCavaCore();
|
||||
if (!cava) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── Smooth position clock ──────────────────────────────────────────
|
||||
//
|
||||
// audio.position() updates at the useAudio poll rate (~150ms). Between
|
||||
// polls, interpolate the position from wall time so the FFT window
|
||||
// tracks the audio continuously instead of stepping. The 0.5s cap
|
||||
// prevents extrapolating far beyond reality when the player stalls
|
||||
// (e.g. network re-buffering).
|
||||
|
||||
let lastPolledPosition = 0;
|
||||
let lastPolledAt = 0;
|
||||
const smoothPosition = () => {
|
||||
const pos = audioPlaybackSignals.position();
|
||||
const now = performance.now();
|
||||
if (pos !== lastPolledPosition) {
|
||||
lastPolledPosition = pos;
|
||||
lastPolledAt = now;
|
||||
return pos;
|
||||
}
|
||||
if (lastPolledAt === 0) return pos;
|
||||
const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5);
|
||||
return lastPolledPosition + elapsed * (audioPlaybackSignals.speed() ?? 1);
|
||||
};
|
||||
|
||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||
|
||||
const startVisualization = (url: string, position: number) => {
|
||||
stopVisualization();
|
||||
|
||||
if (!url || !initCava() || !cava) return;
|
||||
|
||||
// Initialize cavacore with current resolution + the user's
|
||||
// audio-processing params (noise reduction, cutoffs, etc.).
|
||||
// autosens is disabled (after the spread so it always wins): cava's
|
||||
// autosens gain-ramps during silence then clips everything to 1.0
|
||||
// when audio arrives — the JS peak scaler handles dynamics instead.
|
||||
const viz = useAppStore().state().settings.visualizer;
|
||||
const config: CavaCoreConfig = {
|
||||
bars: barCount(),
|
||||
sampleRate: PCM_SAMPLE_RATE,
|
||||
channels: 1,
|
||||
noiseReduction: viz.noiseReduction,
|
||||
lowCutOff: viz.lowCutOff,
|
||||
highCutOff: viz.highCutOff,
|
||||
autosens: 0,
|
||||
};
|
||||
cava.init(config);
|
||||
|
||||
// Pre-warm the FFT window: libcavacore's window is malloc'd
|
||||
// uninitialized, so the first real frame would FFT garbage and
|
||||
// render full-scale bars. One zero frame the size of the whole
|
||||
// input buffer clears it.
|
||||
cava.execute(new Float64Array(8192));
|
||||
|
||||
// Pre-allocate sample read buffer
|
||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
||||
|
||||
// PCM cache per episode (reuse when the episode is unchanged)
|
||||
if (!pcm || pcm.url !== url) {
|
||||
if (pcm) pcm.stop();
|
||||
pcm = new EpisodePcmCache({ url });
|
||||
}
|
||||
// Decode from 1s before the position so the window ENDING at the
|
||||
// position is covered as soon as the first PCM lands.
|
||||
pcm.startDecode(Math.max(0, position - 1));
|
||||
|
||||
// Seed the smooth position clock with the start position. Without
|
||||
// this, a fresh play at position 0 would sample the window ending at
|
||||
// exactly 0 — a 1-sample slice — so bars would be starved until the
|
||||
// first mpv poll advanced the position clock.
|
||||
lastPolledPosition = position;
|
||||
lastPolledAt = performance.now();
|
||||
|
||||
activeUrl = url;
|
||||
activeBars = barCount();
|
||||
setIsLoading(true);
|
||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||
};
|
||||
|
||||
const stopVisualization = () => {
|
||||
clearUnloadTimer();
|
||||
if (frameTimer) {
|
||||
clearInterval(frameTimer);
|
||||
frameTimer = null;
|
||||
}
|
||||
if (pcm) {
|
||||
pcm.stop();
|
||||
// Keep the (now cache-less, url-tagged) object: a re-start of the
|
||||
// same episode reuses it; segments re-decode in seconds at 80x.
|
||||
}
|
||||
if (cava?.isReady) {
|
||||
cava.destroy();
|
||||
}
|
||||
sampleBuffer = null;
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// ── Pause: freeze the loop, keep the cache ──────────────────────────
|
||||
//
|
||||
// The render loop stops (bars hold their last frame) and the ffmpeg
|
||||
// pass dies (no background CPU), but the decoded PCM stays: resume
|
||||
// serves it instantly.
|
||||
|
||||
const suspendVisualization = () => {
|
||||
clearUnloadTimer();
|
||||
if (frameTimer) {
|
||||
clearInterval(frameTimer);
|
||||
frameTimer = null;
|
||||
}
|
||||
if (pcm) pcm.pauseDecode();
|
||||
// Cava plan + sampleBuffer stay alive — cheap to reuse on resume.
|
||||
// Clear the loading spinner: if the pipeline never produced bars
|
||||
// (still cold-starting when paused), the component should fall back
|
||||
// to the placeholder, not freeze on a spinner.
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// ── Resume: re-arm the render loop, top up the cache ───────────────
|
||||
//
|
||||
// Returns true if the pipeline resumed, false if there was nothing to
|
||||
// resume (no prior pipeline).
|
||||
|
||||
const resumeVisualization = (): boolean => {
|
||||
// Already running — nothing to do.
|
||||
if (frameTimer !== null) return true;
|
||||
if (!pcm || !cava?.isReady || !sampleBuffer) return false;
|
||||
|
||||
const pos = untrack(audioPlaybackSignals.position);
|
||||
|
||||
// Bars come from the cache on the next frame tick (~33ms) whenever
|
||||
// the position is covered; any gap (uncached region) restarts the
|
||||
// decode pass in the background with the last frame holding.
|
||||
pcm.ensureDecodeAround(pos);
|
||||
|
||||
lastPolledPosition = pos;
|
||||
lastPolledAt = performance.now();
|
||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
||||
|
||||
const renderFrame = () => {
|
||||
if (!cava?.isReady || !sampleBuffer || !pcm) return;
|
||||
|
||||
// Sample the FFT window at the player's position. Outside decoded
|
||||
// coverage (decode cold start, seek into a hole) the read is empty
|
||||
// and the LAST FRAME simply holds — never clamped/repeated junk.
|
||||
const target = smoothPosition();
|
||||
const count = pcm.readWindow(sampleBuffer, target);
|
||||
// Never feed a partial FFT window to cava.
|
||||
if (count < sampleBuffer.length) return;
|
||||
|
||||
const output = cava.execute(sampleBuffer);
|
||||
|
||||
// Normalize against the running peak and copy to a new array
|
||||
setBarData(scaler(output));
|
||||
if (isLoading()) setIsLoading(false);
|
||||
};
|
||||
|
||||
// ── Playback subscription ──────────────────────────────────────────
|
||||
//
|
||||
// Keeps the pipeline matched to playback. Pause suspends (render loop +
|
||||
// decode pass die, cache survives) so resume is instant. Stop/track-end/
|
||||
// disable fully tears down. `focused` is a dep so focus regain
|
||||
// re-evaluates; the guards make a focus flip on an already-correct warm
|
||||
// pipeline a no-op. Speed is deliberately NOT a dep — the PCM cache is
|
||||
// position-indexed, so playback-rate changes need no pipeline restart.
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
audioPlaybackSignals.isPlaying,
|
||||
() => audioPlaybackSignals.currentEpisode()?.audioUrl ?? "",
|
||||
barCount,
|
||||
focused,
|
||||
() => useAppStore().state().settings.visualizer.enabled,
|
||||
],
|
||||
([playing, url, , , enabled]) => {
|
||||
if (!url || !enabled) {
|
||||
stopVisualization();
|
||||
return;
|
||||
}
|
||||
if (!playing) {
|
||||
// Pause: freeze the loop, keep the cache. Only if the
|
||||
// pipeline is actually running — otherwise no-op.
|
||||
if (frameTimer !== null) suspendVisualization();
|
||||
return;
|
||||
}
|
||||
|
||||
// Playing — try a fast resume first. If it succeeds and the
|
||||
// pipeline matches, done.
|
||||
if (
|
||||
frameTimer === null &&
|
||||
pcm &&
|
||||
cava?.isReady &&
|
||||
url === activeUrl &&
|
||||
barCount() === activeBars
|
||||
) {
|
||||
if (resumeVisualization()) return;
|
||||
}
|
||||
|
||||
// Warm and already correct — nothing to do (e.g. focus
|
||||
// regained within the unload delay while still playing).
|
||||
if (frameTimer !== null && url === activeUrl && barCount() === activeBars) {
|
||||
return;
|
||||
}
|
||||
if (!focused()) return; // playing away: stay warm; unload timer decides
|
||||
startVisualization(url, untrack(audioPlaybackSignals.position));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ── Focus subscription: unload after the grace delay ───────────────
|
||||
|
||||
createEffect(
|
||||
on(focused, (f) => {
|
||||
clearUnloadTimer();
|
||||
if (f) {
|
||||
// Pipeline was unloaded (or never started) but playback is
|
||||
// still going — restart from the current position. When the
|
||||
// pipeline is warm the playback effect above is the one that
|
||||
// acts (guard: no-op for an unchanged warm pipeline).
|
||||
if (
|
||||
audioPlaybackSignals.isPlaying() &&
|
||||
audioPlaybackSignals.currentEpisode()?.audioUrl &&
|
||||
useAppStore().state().settings.visualizer.enabled &&
|
||||
frameTimer === null
|
||||
) {
|
||||
startVisualization(
|
||||
audioPlaybackSignals.currentEpisode()!.audioUrl,
|
||||
untrack(audioPlaybackSignals.position),
|
||||
);
|
||||
}
|
||||
} else if (frameTimer !== null) {
|
||||
unloadTimer = setTimeout(() => {
|
||||
unloadTimer = null;
|
||||
stopVisualization();
|
||||
}, VISUALIZER_UNLOAD_DELAY_MS);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Seek detection: jump coverage, not pipeline restarts ───────────
|
||||
//
|
||||
// Watches position for significant jumps (>2s = user seek). Decoded
|
||||
// audio at the new position is served instantly with zero action; a
|
||||
// jump into an undecoded hole kicks a background segment decode there
|
||||
// while the last frame holds.
|
||||
|
||||
let lastSyncPosition = 0;
|
||||
createEffect(
|
||||
on(audioPlaybackSignals.position, (pos) => {
|
||||
if (!audioPlaybackSignals.isPlaying() || !pcm) {
|
||||
lastSyncPosition = pos;
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = Math.abs(pos - lastSyncPosition);
|
||||
lastSyncPosition = pos;
|
||||
|
||||
if (delta > 2) {
|
||||
pcm.ensureDecodeAround(pos);
|
||||
}
|
||||
}),
|
||||
);
|
||||
// ── Process-exit teardown ──────────────────────────────────────────
|
||||
//
|
||||
// The pipeline lives in a detached createRoot that is never disposed,
|
||||
// so Solid's onCleanup never runs. `q`/`:quit` call process.exit(0)
|
||||
// (bypassing onCleanup); SIGINT/TERM/HUP are caught by useAudio's
|
||||
// handler. This handler runs synchronously on `exit` and kills the
|
||||
// ffmpeg child + destroys the cava plan so they don't outlive the host.
|
||||
// Without it, a warm pipeline leaks an orphaned ffmpeg process on quit.
|
||||
process.on("exit", () => {
|
||||
stopVisualization();
|
||||
});
|
||||
|
||||
return {
|
||||
// state
|
||||
barData,
|
||||
isLoading,
|
||||
isRunning: () => frameTimer !== null,
|
||||
// inputs
|
||||
setFocused,
|
||||
setBarCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Singleton ─────────────────────────────────────────────────────────────
|
||||
|
||||
let visualizerStoreInstance: VisualizerStore | null = null;
|
||||
|
||||
/**
|
||||
* Accessor for the shared visualizer store. Created once inside a
|
||||
* `createRoot` so its effects are owned by a detached root — not by
|
||||
* whichever component happens to call first (PlayerPage unmounts would
|
||||
* otherwise dispose the pipeline effects with it).
|
||||
*/
|
||||
export function useVisualizer(): VisualizerStore {
|
||||
if (!visualizerStoreInstance) {
|
||||
visualizerStoreInstance = createRoot(() => createVisualizerStore());
|
||||
}
|
||||
return visualizerStoreInstance;
|
||||
}
|
||||
@@ -98,7 +98,9 @@ export enum DownloadStatus {
|
||||
export interface DownloadedEpisode {
|
||||
/** Episode ID */
|
||||
episodeId: string
|
||||
/** Feed ID the episode belongs to */
|
||||
/** Feed ID the episode belongs to. For downloads of shows that aren't
|
||||
* subscribed (search downloads) this is a deterministic synthetic id
|
||||
* ("unsub-<slug>") that also names the file subdirectory. */
|
||||
feedId: string
|
||||
/** Current download status */
|
||||
status: DownloadStatus
|
||||
@@ -114,4 +116,16 @@ export interface DownloadedEpisode {
|
||||
fileSize: number
|
||||
/** Error message if failed */
|
||||
error: string | null
|
||||
/** Episode title, persisted so unsubscribed-show downloads render without
|
||||
* a loaded feed. */
|
||||
episodeTitle?: string
|
||||
/** Audio URL, persisted so queued downloads survive a restart. */
|
||||
audioUrl?: string
|
||||
/** Publication date (ISO), for display of unsubscribed-show downloads. */
|
||||
pubDate?: string
|
||||
/** Show title, kept for downloads whose show isn't subscribed. */
|
||||
podcastTitle?: string
|
||||
/** The show's RSS feed URL, used to re-classify a download as subscribed
|
||||
* once the user subscribes to its show. */
|
||||
podcastFeedUrl?: string
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ export type DesktopTheme = {
|
||||
};
|
||||
|
||||
export type VisualizerSettings = {
|
||||
/** Master on/off switch for the player's realtime waveform (default: on). */
|
||||
enabled: boolean;
|
||||
/** Number of frequency bars (8–128, default: 64) */
|
||||
bars: number;
|
||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||
@@ -78,6 +80,8 @@ export type AppSettings = {
|
||||
theme: ThemeName;
|
||||
fontSize: number;
|
||||
playbackSpeed: number;
|
||||
/** Playback volume 0–1 (default: 1 = 100%). */
|
||||
volume: number;
|
||||
downloadPath: string;
|
||||
/** Render the app background transparent (let the terminal's own bg show). */
|
||||
transparentBackground: boolean;
|
||||
@@ -105,6 +109,8 @@ export type UserPreferences = {
|
||||
autoJumpToPlayer: boolean;
|
||||
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
|
||||
fetchMoreMode: FetchMoreMode;
|
||||
/** Minutes between automatic background feed refreshes (default: 30). */
|
||||
refreshIntervalMinutes: number;
|
||||
};
|
||||
|
||||
export type AppState = {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* No backups — writes always overwrite.
|
||||
*/
|
||||
|
||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
||||
import { mkdirSync, writeFileSync } from "fs";
|
||||
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
|
||||
import { loadConfig, updateConfig } from "./config";
|
||||
import type {
|
||||
AppState,
|
||||
@@ -20,6 +21,7 @@ import { DEFAULT_THEME } from "../constants/themes";
|
||||
// --- Defaults ---
|
||||
|
||||
const defaultVisualizerSettings: VisualizerSettings = {
|
||||
enabled: true,
|
||||
bars: 32,
|
||||
sensitivity: 1,
|
||||
noiseReduction: 0.77,
|
||||
@@ -31,6 +33,7 @@ const defaultSettings: AppSettings = {
|
||||
theme: "system",
|
||||
fontSize: 14,
|
||||
playbackSpeed: 1,
|
||||
volume: 1,
|
||||
downloadPath: "",
|
||||
transparentBackground: false,
|
||||
showSelectionMarker: false,
|
||||
@@ -45,6 +48,7 @@ const defaultPreferences: UserPreferences = {
|
||||
autoDownloadWhitelist: [],
|
||||
autoJumpToPlayer: true,
|
||||
fetchMoreMode: "manual",
|
||||
refreshIntervalMinutes: 30,
|
||||
};
|
||||
|
||||
const defaultState: AppState = {
|
||||
@@ -61,7 +65,18 @@ export async function loadAppStateFromFile(): Promise<AppState> {
|
||||
const cfg = await loadConfig();
|
||||
if (!cfg || typeof cfg !== "object") return defaultState;
|
||||
return {
|
||||
settings: { ...defaultSettings, ...cfg.settings },
|
||||
settings: {
|
||||
...defaultSettings,
|
||||
...cfg.settings,
|
||||
// Visualizer is nested: a plain spread would let a config
|
||||
// saved before a field was added (e.g. `enabled`) clobber
|
||||
// the whole object and leave the new field undefined.
|
||||
// Deep-merge so defaults backfill missing nested keys.
|
||||
visualizer: {
|
||||
...defaultVisualizerSettings,
|
||||
...cfg.settings?.visualizer,
|
||||
},
|
||||
},
|
||||
preferences: { ...defaultPreferences, ...cfg.preferences },
|
||||
customTheme: { ...DEFAULT_THEME, ...cfg.customTheme },
|
||||
};
|
||||
@@ -123,6 +138,39 @@ export function saveProgressToFile(data: Record<string, unknown>): void {
|
||||
})();
|
||||
}
|
||||
|
||||
// ── Search History (separate file — changes on every search) ────────────────
|
||||
|
||||
const SEARCH_HISTORY_FILE = "search-history.json";
|
||||
|
||||
/** Load search history from JSON file */
|
||||
export async function loadSearchHistoryFromFile(): Promise<string[]> {
|
||||
try {
|
||||
const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE));
|
||||
if (!(await file.exists())) return [];
|
||||
|
||||
const raw = await file.json();
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((item): item is string => typeof item === "string");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Save search history to JSON file (overwrite, no backup) */
|
||||
export function saveSearchHistoryToFile(history: string[]): void {
|
||||
(async () => {
|
||||
try {
|
||||
await ensureConfigDir();
|
||||
await Bun.write(
|
||||
getConfigFilePath(SEARCH_HISTORY_FILE),
|
||||
JSON.stringify(history, null, 2),
|
||||
);
|
||||
} catch {
|
||||
// Silently ignore write errors
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// ── Audio Nav State (separate file — changes on every track change) ──────────
|
||||
|
||||
const AUDIO_NAV_FILE = "audio-nav.json";
|
||||
@@ -156,3 +204,70 @@ export function saveAudioNavToFile<T>(data: T): void {
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// ── Last Player State (separate file — written on every load/stop) ──────────
|
||||
|
||||
const LAST_PLAYER_FILE = "last-player.json";
|
||||
|
||||
/** Which episode is currently loaded in the player, persisted so the next
|
||||
* launch can restore it paused. `episodeId: null` means the player is empty
|
||||
* (e.g. after Stop). */
|
||||
export interface LastPlayerState {
|
||||
episodeId: string | null;
|
||||
timestamp: string | Date | null;
|
||||
}
|
||||
|
||||
/** Load the last-loaded-player marker (null when absent or unreadable) */
|
||||
export async function loadLastPlayerFromFile(): Promise<LastPlayerState | null> {
|
||||
try {
|
||||
const file = Bun.file(getConfigFilePath(LAST_PLAYER_FILE));
|
||||
if (!(await file.exists())) return null;
|
||||
|
||||
const raw = await file.json();
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
return raw as LastPlayerState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialized marker-write chain: concurrent writes land in submission
|
||||
* order, and callers can await the last one (tests read the file back
|
||||
* deterministically). Mirrors updateConfig's write serialization. */
|
||||
let lastPlayerWriteChain: Promise<void> = Promise.resolve();
|
||||
|
||||
/** Save the last-loaded-player marker (fire-and-forget) */
|
||||
export function saveLastPlayerToFile(state: LastPlayerState): void {
|
||||
lastPlayerWriteChain = lastPlayerWriteChain.then(async () => {
|
||||
try {
|
||||
await ensureConfigDir();
|
||||
await Bun.write(
|
||||
getConfigFilePath(LAST_PLAYER_FILE),
|
||||
JSON.stringify(state, null, 2),
|
||||
);
|
||||
} catch {
|
||||
// Silently ignore write errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves once every marker write submitted so far has landed on disk. */
|
||||
export function waitForLastPlayerWrite(): Promise<void> {
|
||||
return lastPlayerWriteChain;
|
||||
}
|
||||
|
||||
/** Synchronous variant for the process-exit teardown. `q` quits through
|
||||
* `process.exit(0)`, which runs exit listeners synchronously — an async
|
||||
* write would never land. */
|
||||
export function saveLastPlayerSync(state: LastPlayerState): void {
|
||||
try {
|
||||
mkdirSync(getConfigDir(), { recursive: true });
|
||||
writeFileSync(
|
||||
getConfigFilePath(LAST_PLAYER_FILE),
|
||||
JSON.stringify(state, null, 2),
|
||||
);
|
||||
} catch {
|
||||
// Silently ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
363
src/utils/audio-pcm-cache.ts
Normal file
363
src/utils/audio-pcm-cache.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Position-indexed PCM cache for visualization.
|
||||
*
|
||||
* One ffmpeg process decodes the episode's audio at 4x realtime (with an
|
||||
* 8s initial burst — fast enough to serve bars and seeks instantly, throttled
|
||||
* enough that a remote episode isn't ripped at 84x while mpv is trying to
|
||||
* start playback) into an in-memory cache indexed by ABSOLUTE playback time.
|
||||
* The renderer then reads the PCM
|
||||
* window ending at the player's current position with zero sync machinery:
|
||||
* there is no pacing (-readrate), no lead-burst, no decode-head/player
|
||||
* drift math, no ring wrap, and nothing that knows or cares about pause,
|
||||
* resume, seek, or playback speed — those all collapse to "read at a
|
||||
* different position in the cache".
|
||||
*
|
||||
* Pause/resume contract (the failure mode of the old design):
|
||||
* - pauseDecode() kills ffmpeg but KEEPS the cache. Resume reads from it
|
||||
* instantly and resumes the tail decode in the background.
|
||||
* - Reads outside decoded coverage (startup, seek into an undecoded hole)
|
||||
* return 0 — the renderer HOLDS the last rendered frame rather than
|
||||
* freezing on a clamped buffer or decaying into junk bars.
|
||||
*
|
||||
* Seeks into undecoded territory start a fresh SEGMENT (a second decode
|
||||
* pass over just that region) — earlier segments stay valid, mp3 decode of
|
||||
* the same file is deterministic so abutting segments agree.
|
||||
*
|
||||
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min (~80 MB per 30 min),
|
||||
* freed on stop(). 22050 Hz covers Nyquist 11 kHz, above the default 10 kHz
|
||||
* high-cutoff of the visualizer's FFT config.
|
||||
*
|
||||
* Downloads via ffmpeg's own http stack with reconnect flags, matching the
|
||||
* old reader; local files skip them (ffmpeg rejects http-only options for
|
||||
* file inputs).
|
||||
*/
|
||||
|
||||
import type { Subprocess } from "bun";
|
||||
|
||||
/** PCM output format constants */
|
||||
export const PCM_SAMPLE_RATE = 22050;
|
||||
const BYTES_PER_SAMPLE = 2; // s16le
|
||||
|
||||
/** Initial segment capacity: 4 Mi samples ≈ 190 s of audio (8 MB). */
|
||||
const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Monotonically increasing generation counter.
|
||||
* Each startDecode() increments this; the read loop checks it to know
|
||||
* if it's been superseded and should bail out.
|
||||
*/
|
||||
let globalGeneration = 0;
|
||||
|
||||
interface Segment {
|
||||
/** Playback seconds where this segment's first sample sits. */
|
||||
baseSec: number;
|
||||
/** Sample buffer; capacity >= written, doubled on overflow. */
|
||||
samples: Int16Array;
|
||||
/** Samples written so far (== decoded length of the segment). */
|
||||
written: number;
|
||||
/** ffmpeg reached stream EOF while writing this segment — nothing more
|
||||
* will ever arrive after its end. */
|
||||
finished: boolean;
|
||||
}
|
||||
|
||||
export interface EpisodePcmCacheOptions {
|
||||
/** Audio URL or file path to decode */
|
||||
url: string;
|
||||
/** Sample rate (default: 22050) */
|
||||
sampleRate?: number;
|
||||
}
|
||||
|
||||
export class EpisodePcmCache {
|
||||
private proc: Subprocess | null = null;
|
||||
private segments: Segment[] = [];
|
||||
private generation = 0;
|
||||
private _decoding = false;
|
||||
/** Base offset (playback seconds) of the running decode pass; null when idle. */
|
||||
private activeBaseSec: number | null = null;
|
||||
readonly url: string;
|
||||
readonly sampleRate: number;
|
||||
|
||||
constructor(options: EpisodePcmCacheOptions) {
|
||||
this.url = options.url;
|
||||
this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE;
|
||||
}
|
||||
|
||||
/** Whether an ffmpeg decode pass is currently running. */
|
||||
get decoding(): boolean {
|
||||
return this._decoding;
|
||||
}
|
||||
|
||||
/** End (playback seconds) of the furthest-decoded segment. */
|
||||
get coverageEndSec(): number {
|
||||
let end = 0;
|
||||
for (const seg of this.segments) {
|
||||
const segEnd = seg.baseSec + seg.written / this.sampleRate;
|
||||
if (segEnd > end) end = segEnd;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
/** Whether the furthest segment finished at stream EOF. */
|
||||
get decodeFinished(): boolean {
|
||||
let maxEnd = -1;
|
||||
let finished = false;
|
||||
for (const seg of this.segments) {
|
||||
const segEnd = seg.baseSec + seg.written / this.sampleRate;
|
||||
if (segEnd > maxEnd) {
|
||||
maxEnd = segEnd;
|
||||
finished = seg.finished;
|
||||
}
|
||||
}
|
||||
return finished;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start decoding at `fromSec` of playback time into a fresh segment.
|
||||
* Kills any in-flight pass first; existing segments stay readable.
|
||||
*/
|
||||
startDecode(fromSec: number): void {
|
||||
this.killProcess();
|
||||
|
||||
if (!Bun.which("ffmpeg")) {
|
||||
throw new Error("ffmpeg not found — required for audio visualization");
|
||||
}
|
||||
|
||||
this.generation = ++globalGeneration;
|
||||
const myGeneration = this.generation;
|
||||
|
||||
const segment: Segment = {
|
||||
baseSec: Math.max(0, fromSec),
|
||||
samples: new Int16Array(INITIAL_CAPACITY_SAMPLES),
|
||||
written: 0,
|
||||
finished: false,
|
||||
};
|
||||
this.segments.push(segment);
|
||||
|
||||
const args = ["ffmpeg", "-loglevel", "quiet"];
|
||||
|
||||
// Pace the decode at 4x realtime (with an 8s initial burst) instead of
|
||||
// flat-out: unthrottled decode measures ~84x realtime, which pulls the
|
||||
// ENTIRE episode from the network within the first minute of playback
|
||||
// (~160MB/hr) and starves mpv's own buffering right at startup. 4x
|
||||
// still fills the cache 4x faster than playback consumes it, lands a
|
||||
// 75-min episode in ~19 min of background work, and the burst makes
|
||||
// the first bars available immediately.
|
||||
args.push("-readrate", "4", "-readrate_initial_burst", "8");
|
||||
|
||||
// `-reconnect*` are http-protocol options: ffmpeg rejects them at
|
||||
// input-open when the input is a local file, killing the process
|
||||
// before any PCM is produced. Only pass them for network URLs.
|
||||
if (/^https?:\/\//i.test(this.url)) {
|
||||
args.push(
|
||||
"-reconnect",
|
||||
"1",
|
||||
"-reconnect_streamed",
|
||||
"1",
|
||||
"-reconnect_delay_max",
|
||||
"5",
|
||||
);
|
||||
}
|
||||
|
||||
// Seek before input for network efficiency (container-level skip is
|
||||
// near-instant for mp3/aac; no pre-position decode burn).
|
||||
if (fromSec > 0) {
|
||||
args.push("-ss", String(Math.max(0, fromSec)));
|
||||
}
|
||||
|
||||
args.push(
|
||||
"-i",
|
||||
this.url,
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
String(this.sampleRate),
|
||||
"-f",
|
||||
"s16le",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-",
|
||||
);
|
||||
|
||||
this.proc = Bun.spawn(args, {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
});
|
||||
this._decoding = true;
|
||||
this.activeBaseSec = segment.baseSec;
|
||||
this.readLoop(myGeneration, segment);
|
||||
|
||||
this.proc.exited
|
||||
.then((code) => {
|
||||
if (this.generation === myGeneration) {
|
||||
this._decoding = false;
|
||||
this.activeBaseSec = null;
|
||||
// Exit 0 == decoded to stream EOF.
|
||||
if (code === 0) segment.finished = true;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.generation === myGeneration) {
|
||||
this._decoding = false;
|
||||
this.activeBaseSec = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `sec` of playback time has decoded PCM on hand.
|
||||
*/
|
||||
covers(sec: number): boolean {
|
||||
const idx = Math.round(sec * this.sampleRate);
|
||||
for (const seg of this.segments) {
|
||||
const base = Math.round(seg.baseSec * this.sampleRate);
|
||||
if (idx >= base && idx < base + seg.written) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure decode is progressing toward `sec`: no-op while a pass is
|
||||
* running or the episode is fully decoded; otherwise resumes the tail
|
||||
* decode from the frontier (when `sec` is inside coverage) or starts a
|
||||
* new segment at `sec` (seek into a hole / resume past cached audio).
|
||||
*/
|
||||
ensureDecodeAround(sec: number): void {
|
||||
if (this._decoding) {
|
||||
// A decode pass fills monotonically FORWARD from its base. Only a
|
||||
// target at/after the active base is eventually covered by it —
|
||||
// a target BEHIND the base (seek into an undecoded hole ahead of
|
||||
// the active pass) never is: kill the pass and restart at sec.
|
||||
if (this.activeBaseSec !== null && sec >= this.activeBaseSec) return;
|
||||
this.startDecode(Math.max(0, sec));
|
||||
return;
|
||||
}
|
||||
if (this.covers(sec)) {
|
||||
// Covered here: continue the tail so the cache keeps filling
|
||||
// past the position (unless the whole episode is decoded).
|
||||
if (this.decodeFinished) return;
|
||||
this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec);
|
||||
return;
|
||||
}
|
||||
// Seek into an undecoded region: start a fresh segment there.
|
||||
this.startDecode(Math.max(0, sec));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the PCM window ENDING at `atSec` of playback into `out`
|
||||
* (Int16 magnitudes widened to f64, the scale cavacore expects).
|
||||
*
|
||||
* Returns the number of samples written: `out.length` on a full hit, 0
|
||||
* when the window is not (fully) decoded yet — the caller HOLDS the
|
||||
* last rendered frame instead of rendering partial/stale data.
|
||||
*/
|
||||
readWindow(out: Float64Array, atSec: number): number {
|
||||
if (out.length === 0) return 0;
|
||||
const endIdx = Math.round(atSec * this.sampleRate);
|
||||
const startIdx = endIdx - out.length + 1;
|
||||
for (const seg of this.segments) {
|
||||
const base = Math.round(seg.baseSec * this.sampleRate);
|
||||
if (startIdx < base || endIdx >= base + seg.written) continue;
|
||||
const rel = startIdx - base;
|
||||
const src = seg.samples;
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
out[i] = src[rel + i];
|
||||
}
|
||||
return out.length;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause contract: kill the ffmpeg pass but KEEP every decoded segment.
|
||||
* Resume later serves bars from the cache instantly.
|
||||
*/
|
||||
pauseDecode(): void {
|
||||
this.generation = ++globalGeneration;
|
||||
this._decoding = false;
|
||||
this.activeBaseSec = null;
|
||||
this.killProcess();
|
||||
}
|
||||
|
||||
/** Kill the decode pass AND drop all cached audio. */
|
||||
stop(): void {
|
||||
this.pauseDecode();
|
||||
this.segments = [];
|
||||
}
|
||||
|
||||
/** Kill the ffmpeg process without touching generation/state. */
|
||||
private killProcess(): void {
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.proc = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal: continuously reads stdout from ffmpeg and appends samples
|
||||
* to the segment at their absolute playback-time offsets. */
|
||||
private async readLoop(myGeneration: number, segment: Segment): Promise<void> {
|
||||
const stdout = this.proc?.stdout;
|
||||
if (!stdout || typeof stdout === "number") return;
|
||||
|
||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
|
||||
// s16 sample pairs can straddle pipe chunk boundaries: carry a lone
|
||||
// trailing byte into the next chunk (dropping it would byte-flip
|
||||
// every sample that follows).
|
||||
let carry: number | null = null;
|
||||
try {
|
||||
while (this.generation === myGeneration) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done || this.generation !== myGeneration) break;
|
||||
if (!value || value.byteLength === 0) continue;
|
||||
|
||||
let view: Uint8Array = value;
|
||||
if (carry !== null) {
|
||||
const merged = new Uint8Array(1 + value.byteLength);
|
||||
merged[0] = carry;
|
||||
merged.set(value, 1);
|
||||
view = merged;
|
||||
carry = null;
|
||||
}
|
||||
if (view.byteLength % BYTES_PER_SAMPLE !== 0) {
|
||||
carry = view[view.byteLength - 1];
|
||||
view = view.subarray(0, view.byteLength - 1);
|
||||
}
|
||||
|
||||
const sampleCount = view.byteLength / BYTES_PER_SAMPLE;
|
||||
if (sampleCount === 0) continue;
|
||||
|
||||
if (segment.written + sampleCount > segment.samples.length) {
|
||||
const grown = new Int16Array(
|
||||
Math.max(
|
||||
segment.samples.length * 2,
|
||||
segment.written + sampleCount,
|
||||
),
|
||||
);
|
||||
grown.set(segment.samples.subarray(0, segment.written));
|
||||
segment.samples = grown;
|
||||
}
|
||||
// Int16Array view over the byte buffer: s16le is the platform's
|
||||
// native endianness on every supported target (arm64/x64 are LE).
|
||||
const src = new Int16Array(
|
||||
view.buffer,
|
||||
view.byteOffset,
|
||||
sampleCount,
|
||||
);
|
||||
segment.samples.set(src, segment.written);
|
||||
segment.written += sampleCount;
|
||||
}
|
||||
} catch {
|
||||
// Stream ended or process killed — expected during stop()
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,28 @@
|
||||
* restart. When mpv isn't installed there is no fallback: the no-op backend
|
||||
* surfaces "No audio player found" honestly rather than degrading through
|
||||
* players that can't change speed/volume without restarting.
|
||||
*
|
||||
* The backend owns ONE RESIDENT mpv daemon (`--idle=yes --keep-open=yes`)
|
||||
* for the app's lifetime instead of spawning a fresh player per episode:
|
||||
*
|
||||
* - Play/pause/seek are IPC commands on a persistent Unix-socket
|
||||
* connection — no process spawn, no socket connect/disconnect churn per
|
||||
* poll, no `waitForSocket` on the play path. Measured command latency is
|
||||
* single-digit ms; a mid-episode resume after pause takes ~300ms on a
|
||||
* network stream.
|
||||
* - State (time-pos, pause, duration) is OBSERVED (`observe_property`):
|
||||
* mpv pushes time-pos at ~20Hz while playing, so `getPosition()` /
|
||||
* `getPauseState()` read a cache instead of round-tripping the socket on
|
||||
* every 150ms UI tick. External pauses (AirPod removal, system sleep,
|
||||
* Now Playing center) arrive as pause property events with zero polling.
|
||||
* - A restored session can PRELOAD: the episode is loaded paused so mpv
|
||||
* fills its demuxer cache ahead of time; the first real play just flips
|
||||
* `pause` to false — the ~2s network open is paid at boot, not on the
|
||||
* user's first Play.
|
||||
*/
|
||||
|
||||
import { platform } from "os";
|
||||
import { existsSync } from "fs";
|
||||
import { existsSync, unlinkSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { dirname, join } from "path";
|
||||
import type { Socket, Subprocess } from "bun";
|
||||
@@ -31,6 +49,18 @@ export interface AudioState {
|
||||
export interface AudioBackend {
|
||||
readonly name: BackendName;
|
||||
play(url: string, opts?: PlayOptions): Promise<void>;
|
||||
/**
|
||||
* Load the URL paused WITHOUT starting playback, so the player buffers
|
||||
* ahead of the user's first Play (used for boot session restore).
|
||||
* A subsequent play() of the SAME url flips pause off — near-instant.
|
||||
*/
|
||||
preload(url: string, opts?: PlayOptions): Promise<void>;
|
||||
/**
|
||||
* Attach a cover-art image to the currently-loaded file at runtime
|
||||
* (mpv `video-add`). Lets play() start without waiting on art; the
|
||||
* Now Playing artwork pops in when the download lands.
|
||||
*/
|
||||
addCoverArt(path: string): Promise<void>;
|
||||
pause(): Promise<void>;
|
||||
resume(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
@@ -40,6 +70,15 @@ export interface AudioBackend {
|
||||
getPosition(): Promise<number>;
|
||||
getDuration(): Promise<number>;
|
||||
isPlaying(): boolean;
|
||||
/** Live pause state: `true` paused, `false` playing, `undefined` when
|
||||
* unknown (player unreachable / not yet loaded). Unlike `isPlaying()` —
|
||||
* which reflects only commands PodTUI sent — this reflects the player's
|
||||
* real state, including pauses initiated OUTSIDE PodTUI (system
|
||||
* sleep/lock, AirPod removal, device swap, OS media keys, the Now
|
||||
* Playing center). */
|
||||
getPauseState(): Promise<boolean | undefined>;
|
||||
/** True while the player process is running (regardless of pause). */
|
||||
isAlive(): boolean;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -71,8 +110,16 @@ function which(cmd: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
let mpvInstance = 0;
|
||||
function mpvSocketPath(): string {
|
||||
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`);
|
||||
// Per-instance, not just per-pid: tests (and backend switching) create
|
||||
// several MpvBackend objects in ONE bun process — a pid-only path makes
|
||||
// every daemon bind the same socket, so later daemons unlink the path
|
||||
// out from under earlier ones and IPC cross-talks between backends.
|
||||
return join(
|
||||
tmpdir(),
|
||||
`podtui-mpv-${process.pid}-${mpvInstance++}.sock`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,240 +127,558 @@ function mpvSocketPath(): string {
|
||||
* (macOS PodTui.app/Contents/MacOS/mpv): running mpv from inside the bundle
|
||||
* makes macOS attribute its Now Playing session to PodTui — source-app icon
|
||||
* and name in Control Center — instead of a blank placeholder for an
|
||||
* unbundled binary. Falls back to PATH so dev runs and Linux keep working.
|
||||
* unbundled binary.
|
||||
*
|
||||
* The bundled copy is verified to actually launch: it links against brew's
|
||||
* dylibs by absolute path, and a Homebrew ffmpeg major upgrade can break it
|
||||
* (dylib gone → immediate non-zero exit). If the bundled binary can't run,
|
||||
* fall back to PATH mpv so audio keeps working — the icon degrades to blank
|
||||
* rather than playback dying. Probed once per process.
|
||||
*/
|
||||
let resolvedMpv: string | null | undefined; // undefined = not yet probed
|
||||
|
||||
function mpvLaunches(binary: string): boolean {
|
||||
try {
|
||||
const proc = Bun.spawnSync([binary, "--version"], { timeout: 3000 });
|
||||
return proc.exitCode === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMpvBinary(): string | null {
|
||||
if (resolvedMpv !== undefined) return resolvedMpv;
|
||||
let resolved: string | null = null;
|
||||
try {
|
||||
const bundled = join(dirname(process.execPath), "mpv");
|
||||
if (existsSync(bundled)) return bundled;
|
||||
if (existsSync(bundled) && mpvLaunches(bundled)) {
|
||||
resolved = bundled;
|
||||
}
|
||||
} catch {
|
||||
/* process.execPath unusable — fall through to PATH */
|
||||
}
|
||||
return which("mpv");
|
||||
if (!resolved) resolved = which("mpv");
|
||||
resolvedMpv = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// ── mpv JSON IPC connection ─────────────────────────────────────────
|
||||
//
|
||||
// One persistent Unix-socket connection to the resident mpv daemon. Lines
|
||||
// from mpv are either command responses (`request_id` present — correlated
|
||||
// to the pending promise) or unsolicited traffic (property-change events
|
||||
// from `observe_property`, end-file, ...), dispatched to the event handler.
|
||||
|
||||
interface MpvResponse {
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
request_id?: number;
|
||||
}
|
||||
|
||||
interface MpvEvent {
|
||||
event: string;
|
||||
/** Observation id for property-change events. */
|
||||
id?: number;
|
||||
name?: string;
|
||||
data?: unknown;
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type MpvEventHandler = (msg: MpvEvent) => void;
|
||||
|
||||
class MpvConnection {
|
||||
private sock: Socket | null = null;
|
||||
private buf = "";
|
||||
private nextId = 1;
|
||||
private pending = new Map<number, (msg: MpvResponse) => void>();
|
||||
private eventWaiters = new Map<string, Array<(msg: MpvEvent) => void>>();
|
||||
onEvent: MpvEventHandler = () => {};
|
||||
|
||||
async connect(path: string): Promise<void> {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
let settled = false;
|
||||
Bun.connect({
|
||||
unix: path,
|
||||
socket: {
|
||||
open: (socket) => {
|
||||
this.sock = socket;
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
data: (_socket, data) => this.onData(data),
|
||||
error: (_socket, err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(err);
|
||||
}
|
||||
this.handleTeardown();
|
||||
},
|
||||
close: () => this.handleTeardown(),
|
||||
},
|
||||
}).catch((err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
await promise;
|
||||
}
|
||||
|
||||
private onData(data: Uint8Array): void {
|
||||
this.buf += Buffer.from(data).toString();
|
||||
let nl = this.buf.indexOf("\n");
|
||||
while (nl !== -1) {
|
||||
const line = this.buf.slice(0, nl);
|
||||
this.buf = this.buf.slice(nl + 1);
|
||||
nl = this.buf.indexOf("\n");
|
||||
if (!line.trim()) continue;
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue; // skip malformed lines
|
||||
}
|
||||
if (msg.request_id !== undefined) {
|
||||
const resolve = this.pending.get(msg.request_id as number);
|
||||
if (resolve) {
|
||||
this.pending.delete(msg.request_id as number);
|
||||
resolve(msg as MpvResponse);
|
||||
}
|
||||
} else if (typeof msg.event === "string") {
|
||||
const event = msg as unknown as MpvEvent;
|
||||
this.onEvent(event);
|
||||
const waiters = this.eventWaiters.get(event.event);
|
||||
if (waiters) {
|
||||
this.eventWaiters.delete(event.event);
|
||||
for (const w of waiters) w(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Socket died / daemon gone: fail all pending commands so no caller
|
||||
* hangs on a dead connection. */
|
||||
private handleTeardown(): void {
|
||||
for (const resolve of this.pending.values()) {
|
||||
resolve({ error: "connection-lost" });
|
||||
}
|
||||
this.pending.clear();
|
||||
this.sock = null;
|
||||
}
|
||||
|
||||
/** Send a command and await mpv's response (correlated by request_id).
|
||||
* Resolves `{ error: "timeout" }` instead of hanging when mpv stalls. */
|
||||
send(command: unknown[], timeoutMs = 2000): Promise<MpvResponse> {
|
||||
const sock = this.sock;
|
||||
if (!sock) return Promise.resolve({ error: "not-connected" });
|
||||
const id = this.nextId++;
|
||||
const { promise, resolve } = Promise.withResolvers<MpvResponse>();
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.pending.delete(id)) resolve({ error: "timeout" });
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, (msg) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(msg);
|
||||
});
|
||||
sock.write(JSON.stringify({ command, request_id: id }) + "\n");
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** One-shot wait for an mpv event by name. Register BEFORE the command
|
||||
* that triggers it. Resolves null on timeout instead of hanging. */
|
||||
waitEvent(name: string, timeoutMs = 5000): Promise<MpvEvent | null> {
|
||||
const { promise, resolve } = Promise.withResolvers<MpvEvent | null>();
|
||||
const list = this.eventWaiters.get(name) ?? [];
|
||||
list.push(resolve);
|
||||
this.eventWaiters.set(name, list);
|
||||
setTimeout(() => {
|
||||
const current = this.eventWaiters.get(name);
|
||||
if (current) {
|
||||
this.eventWaiters.set(
|
||||
name,
|
||||
current.filter((w) => w !== resolve),
|
||||
);
|
||||
}
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
return promise;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.sock?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.handleTeardown();
|
||||
}
|
||||
}
|
||||
|
||||
// ── mpv Backend ──────────────────────────────────────────────────────
|
||||
// Uses JSON IPC over a Unix socket for full bidirectional control.
|
||||
// One resident daemon for the app's lifetime, controlled over a single
|
||||
// persistent JSON IPC connection with property observation.
|
||||
|
||||
/** Property observation ids (correlate property-change events). */
|
||||
const OBS_TIME_POS = 1;
|
||||
const OBS_PAUSE = 2;
|
||||
const OBS_DURATION = 3;
|
||||
const OBS_EOF = 4;
|
||||
|
||||
export class MpvBackend implements AudioBackend {
|
||||
readonly name: BackendName = "mpv";
|
||||
private proc: Subprocess | null = null;
|
||||
private socketPath = mpvSocketPath();
|
||||
private _playing = false;
|
||||
private conn: MpvConnection | null = null;
|
||||
/** Guarantee daemon startup runs once (concurrent play/preload). */
|
||||
private startPromise: Promise<void> | null = null;
|
||||
|
||||
// Command intent: what PodTUI asked the player to do.
|
||||
private _intentPlaying = false;
|
||||
/** The file currently loaded via loadfile (null = idle). */
|
||||
private _loadedUrl: string | null = null;
|
||||
/** The current file was loadfile'd paused (preload) and not yet played. */
|
||||
private _loadedPaused = false;
|
||||
/** Set on end-file reason "eof"/"error"; cleared by the next loadfile. */
|
||||
private _ended = false;
|
||||
|
||||
// Observed (player-reported) state, pushed by mpv property-change events.
|
||||
private _position = 0;
|
||||
private _duration = 0;
|
||||
/** null until the first pause observation arrives. */
|
||||
private _paused: boolean | null = null;
|
||||
|
||||
private _volume = 100;
|
||||
private _speed = 1;
|
||||
private _exited = false;
|
||||
/** Last playback error reported via end-file reason "error". */
|
||||
private _playbackError: string | null = null;
|
||||
|
||||
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||
await this.stop();
|
||||
// ── Daemon lifecycle ─────────────────────────────────────────────
|
||||
|
||||
private async ensureDaemon(): Promise<void> {
|
||||
if (this.proc && !this._exited && this.conn) return;
|
||||
if (this.startPromise) return this.startPromise;
|
||||
this.startPromise = this.spawnDaemon().finally(() => {
|
||||
this.startPromise = null;
|
||||
});
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
private async spawnDaemon(): Promise<void> {
|
||||
// Clean up stale socket
|
||||
try {
|
||||
if (existsSync(this.socketPath)) {
|
||||
const { unlinkSync } = await import("fs");
|
||||
unlinkSync(this.socketPath);
|
||||
}
|
||||
unlinkSync(this.socketPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const args = [
|
||||
resolveMpvBinary() ?? "mpv",
|
||||
"--no-video",
|
||||
"--no-terminal",
|
||||
"--really-quiet",
|
||||
`--input-ipc-server=${this.socketPath}`,
|
||||
`--volume=${Math.round((opts?.volume ?? 1) * 100)}`,
|
||||
`--speed=${opts?.speed ?? 1}`,
|
||||
];
|
||||
|
||||
if (opts?.mediaTitle) {
|
||||
args.push(`--force-media-title=${opts.mediaTitle}`);
|
||||
}
|
||||
|
||||
if (opts?.coverArtPath) {
|
||||
// Explicit cover file → albumart track → macOS Now Playing artwork
|
||||
// (works for remote streams, not just local downloads).
|
||||
args.push(`--cover-art-files=${opts.coverArtPath}`);
|
||||
}
|
||||
|
||||
if (opts?.startPosition && opts.startPosition > 0) {
|
||||
args.push(`--start=${opts.startPosition}`);
|
||||
}
|
||||
|
||||
args.push(url);
|
||||
|
||||
this.proc = Bun.spawn(args, {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
});
|
||||
|
||||
this._playing = true;
|
||||
this._position = opts?.startPosition ?? 0;
|
||||
this._volume = Math.round((opts?.volume ?? 1) * 100);
|
||||
this._speed = opts?.speed ?? 1;
|
||||
|
||||
// Wait for socket to appear (mpv creates it async)
|
||||
await this.waitForSocket(2000);
|
||||
|
||||
// Position is fetched live from mpv on each getPosition() call (see
|
||||
// below) — the UI polls it, so no internal poll timer is needed.
|
||||
|
||||
// Detect process exit
|
||||
this.proc = Bun.spawn(
|
||||
[
|
||||
resolveMpvBinary() ?? "mpv",
|
||||
"--no-video",
|
||||
"--no-terminal",
|
||||
"--really-quiet",
|
||||
// Stay alive after finishing/unloading files; PodTUI owns one mpv
|
||||
// for its whole session and switches episodes via loadfile.
|
||||
"--idle=yes",
|
||||
"--keep-open=yes",
|
||||
// Cap the demuxer cache. mpv's defaults (150MiB) make it race
|
||||
// to fill while a preload sits paused — measured 45MB pulled
|
||||
// within 12s of a boot-restore preload, saturating the link
|
||||
// exactly when everything else is starting up. ~90s forward
|
||||
// target / 40MiB hard cap is a few MB at podcast bitrates:
|
||||
// plenty for instant resume + stall resilience.
|
||||
"--cache-secs=90",
|
||||
"--demuxer-max-bytes=40MiB",
|
||||
"--demuxer-max-back-bytes=20MiB",
|
||||
`--input-ipc-server=${this.socketPath}`,
|
||||
],
|
||||
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
|
||||
);
|
||||
this._exited = false;
|
||||
this.proc.exited
|
||||
.then(() => {
|
||||
this._playing = false;
|
||||
this._exited = true;
|
||||
this._intentPlaying = false;
|
||||
this._loadedUrl = null;
|
||||
this._paused = null;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
private async waitForSocket(timeoutMs: number): Promise<void> {
|
||||
// mpv creates the socket asynchronously (measured ~600ms cold spawn).
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (existsSync(this.socketPath)) return;
|
||||
while (Date.now() - start < 3000) {
|
||||
if (this._exited) break;
|
||||
if (existsSync(this.socketPath)) break;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
const conn = new MpvConnection();
|
||||
conn.onEvent = (msg) => this.handleEvent(msg);
|
||||
await conn.connect(this.socketPath);
|
||||
this.conn = conn;
|
||||
|
||||
// Observe the state the UI polls: mpv then pushes changes at ~20Hz
|
||||
// while playing and broadcasts external changes (AirPods pull, OS
|
||||
// media keys) with zero polling from our side.
|
||||
await this.send(["observe_property", OBS_TIME_POS, "time-pos"]);
|
||||
await this.send(["observe_property", OBS_PAUSE, "pause"]);
|
||||
await this.send(["observe_property", OBS_DURATION, "duration"]);
|
||||
// With --keep-open=yes mpv does NOT emit end-file at natural EOF — it
|
||||
// sets eof-reached=true (and pauses at the last frame) instead. That
|
||||
// property is the track-end signal; end-file only covers unload/error.
|
||||
await this.send(["observe_property", OBS_EOF, "eof-reached"]);
|
||||
}
|
||||
|
||||
/** Send a fire-and-forget command (no response needed) */
|
||||
private async send(command: unknown[]): Promise<void> {
|
||||
try {
|
||||
const conn = await Bun.connect({
|
||||
unix: this.socketPath,
|
||||
socket: {
|
||||
data() {},
|
||||
error() {},
|
||||
close() {},
|
||||
open() {},
|
||||
},
|
||||
});
|
||||
conn.write(JSON.stringify({ command }) + "\n");
|
||||
// Don't wait, just schedule a close
|
||||
setTimeout(() => {
|
||||
try {
|
||||
conn.end();
|
||||
} catch {}
|
||||
}, 50);
|
||||
} catch {
|
||||
/* ignore */
|
||||
private async send(
|
||||
command: unknown[],
|
||||
): Promise<MpvResponse> {
|
||||
if (!this.conn) return { error: "not-connected" };
|
||||
return this.conn.send(command);
|
||||
}
|
||||
|
||||
private handleEvent(msg: MpvEvent): void {
|
||||
if (msg.event === "property-change") {
|
||||
if (msg.id === OBS_TIME_POS) {
|
||||
// `data` is number while playing; unavailable → undefined while
|
||||
// idle. Keep last known on transient gaps, reset on idle.
|
||||
if (typeof msg.data === "number") this._position = msg.data;
|
||||
} else if (msg.id === OBS_PAUSE) {
|
||||
if (typeof msg.data === "boolean") this._paused = msg.data;
|
||||
} else if (msg.id === OBS_DURATION) {
|
||||
if (typeof msg.data === "number" && msg.data > 0) {
|
||||
this._duration = msg.data;
|
||||
}
|
||||
} else if (msg.id === OBS_EOF) {
|
||||
// Natural end-of-file (or a brand-new load reporting false).
|
||||
this._ended = msg.data === true;
|
||||
if (this._ended) this._intentPlaying = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.event === "end-file") {
|
||||
if (msg.reason === "eof") {
|
||||
this._ended = true;
|
||||
this._intentPlaying = false;
|
||||
} else if (msg.reason === "error") {
|
||||
this._ended = true;
|
||||
this._intentPlaying = false;
|
||||
this._playbackError = msg.error ?? "mpv failed to play the stream";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.event === "file-loaded") {
|
||||
this._ended = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── File presentation options ────────────────────────────────────
|
||||
//
|
||||
// force-media-title and cover-art-files are set as global properties
|
||||
// BEFORE loadfile (verified: runtime-settable; values containing commas
|
||||
// would corrupt the per-file options string). Numbers (volume, speed,
|
||||
// start, pause) ride as per-file options on loadfile itself so each
|
||||
// loadfile is self-contained.
|
||||
|
||||
private async applyPresentation(opts?: PlayOptions): Promise<void> {
|
||||
await this.send([
|
||||
"set_property",
|
||||
"force-media-title",
|
||||
opts?.mediaTitle ?? "",
|
||||
]);
|
||||
await this.send([
|
||||
"set_property",
|
||||
"cover-art-files",
|
||||
opts?.coverArtPath ?? "",
|
||||
]);
|
||||
}
|
||||
|
||||
private loadfileOptions(opts: PlayOptions | undefined, paused: boolean): string {
|
||||
const parts: string[] = [`pause=${paused ? "yes" : "no"}`];
|
||||
if (opts?.startPosition && opts.startPosition > 0) {
|
||||
parts.push(`start=${Math.max(0, opts.startPosition)}`);
|
||||
}
|
||||
const vol = Math.round((opts?.volume ?? 1) * 100);
|
||||
if (Number.isFinite(vol)) parts.push(`volume=${vol}`);
|
||||
const speed = opts?.speed ?? 1;
|
||||
if (Number.isFinite(speed) && speed > 0) parts.push(`speed=${speed}`);
|
||||
return parts.join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a property value from mpv via IPC.
|
||||
*
|
||||
* Resolves the parsed numeric value, or `undefined` when the read fails
|
||||
* (socket error, timeout, unparseable response, or the property being
|
||||
* unavailable — e.g. `time-pos` before playback starts). Failure is
|
||||
* distinct from a legitimate `0` so callers can keep the last known
|
||||
* value instead of snapping the position clock to zero on a transient
|
||||
* error; the next poll retries.
|
||||
*
|
||||
* mpv multiplexes unsolicited events (audio-reconfig, file-loaded, ...)
|
||||
* onto the same connection, so we line-buffer and only settle on the
|
||||
* line that carries the command response (`request_id` set). The socket
|
||||
* is closed once the response is handled — leaving it open leaks an fd
|
||||
* per poll, while closing it before mpv processes the request drops the
|
||||
* reply.
|
||||
* Every loadfile (play, preload, replay) runs under this mutex: useAudio
|
||||
* fires the boot preload unawaited, so without serialization a user
|
||||
* pressing Play mid-preload would send loadfile(no-pause) followed by the
|
||||
* in-flight preload's loadfile(pause=yes) — and the stale preload would
|
||||
* pause the file the user just started. The mutex also prevents
|
||||
* presentation options (title/cover) of one episode from interleaving
|
||||
* with the loadfile of another.
|
||||
*/
|
||||
private async getProperty(name: string): Promise<number | undefined> {
|
||||
try {
|
||||
return await new Promise<number | undefined>((resolve) => {
|
||||
let settled = false;
|
||||
let sock: Socket | null = null;
|
||||
let buf = "";
|
||||
const done = (value: number | undefined) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
sock?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
const timeout = setTimeout(() => done(undefined), 300);
|
||||
private loadMutex: Promise<unknown> = Promise.resolve();
|
||||
|
||||
Bun.connect({
|
||||
unix: this.socketPath,
|
||||
socket: {
|
||||
open(socket) {
|
||||
sock = socket;
|
||||
socket.write(
|
||||
JSON.stringify({ command: ["get_property", name] }) + "\n",
|
||||
);
|
||||
},
|
||||
data(_socket, data) {
|
||||
buf += Buffer.from(data).toString();
|
||||
let nl = buf.indexOf("\n");
|
||||
while (nl !== -1) {
|
||||
const line = buf.slice(0, nl);
|
||||
buf = buf.slice(nl + 1);
|
||||
nl = buf.indexOf("\n");
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
// Events carry no request_id; only settle on
|
||||
// the actual command response.
|
||||
if (parsed?.request_id === undefined) continue;
|
||||
if (parsed?.data !== undefined) {
|
||||
done(Number(parsed.data) || 0);
|
||||
} else {
|
||||
done(undefined);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
/* skip malformed lines */
|
||||
}
|
||||
}
|
||||
},
|
||||
error() {
|
||||
done(undefined);
|
||||
},
|
||||
close() {
|
||||
done(undefined);
|
||||
},
|
||||
},
|
||||
}).catch(() => done(undefined));
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
private runLoadExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const result = this.loadMutex.then(fn);
|
||||
this.loadMutex = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async loadFileLocked(
|
||||
url: string,
|
||||
opts: PlayOptions | undefined,
|
||||
paused: boolean,
|
||||
): Promise<void> {
|
||||
await this.applyPresentation(opts);
|
||||
// Paused preload of a mid-episode restore: pass NO start= option and
|
||||
// seek while paused instead. mpv defers --start stream work (open,
|
||||
// header probe, demuxer seek) until playback begins — measured: the
|
||||
// demuxer cache stays EMPTY during the whole preload and the eventual
|
||||
// unpause pays 4.3s. A time-pos seek while paused executes at once,
|
||||
// so the stream opens and buffers during the preload, and the first
|
||||
// real Play is a sub-second unpause.
|
||||
const pausedSeek =
|
||||
paused && opts?.startPosition && opts.startPosition > 0
|
||||
? opts.startPosition
|
||||
: null;
|
||||
const loadOpts =
|
||||
pausedSeek && opts ? { ...opts, startPosition: undefined } : opts;
|
||||
// Register the file-loaded waiter BEFORE loadfile: the event can
|
||||
// arrive between the command response and listener setup otherwise.
|
||||
const fileLoaded = pausedSeek && this.conn ? this.conn.waitEvent("file-loaded") : null;
|
||||
const resp = await this.send([
|
||||
"loadfile",
|
||||
url,
|
||||
"replace",
|
||||
-1,
|
||||
this.loadfileOptions(loadOpts, paused),
|
||||
]);
|
||||
if (resp.error && resp.error !== "success") {
|
||||
throw new Error(`mpv loadfile failed: ${resp.error}`);
|
||||
}
|
||||
if (pausedSeek) {
|
||||
// time-pos sent before file-loaded is silently dropped by mpv
|
||||
// (no file yet) — the preload then parked at 0 and the restore
|
||||
// position was lost. Wait for the open, then seek.
|
||||
await fileLoaded;
|
||||
await this.send(["set_property", "time-pos", pausedSeek]);
|
||||
this._position = pausedSeek;
|
||||
}
|
||||
this._loadedUrl = url;
|
||||
this._loadedPaused = paused;
|
||||
this._ended = false;
|
||||
this._playbackError = null;
|
||||
this._position = opts?.startPosition ?? 0;
|
||||
this._duration = 0;
|
||||
this._volume = Math.round((opts?.volume ?? 1) * 100);
|
||||
this._speed = opts?.speed ?? 1;
|
||||
}
|
||||
|
||||
// ── AudioBackend ─────────────────────────────────────────────────
|
||||
|
||||
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||
await this.ensureDaemon();
|
||||
// Mark intent before the mutex: a boot preload queued behind this
|
||||
// play checks it and skips its own stale paused-load.
|
||||
this._intentPlaying = true;
|
||||
await this.runLoadExclusive(async () => {
|
||||
// Fast path: this exact URL was PRELOADED paused (boot restore) —
|
||||
// mpv has been buffering it since boot, so flipping pause off starts
|
||||
// audio ~instantly. Re-acquire the start position only when it
|
||||
// moved meaningfully since the preload (progress saved meanwhile).
|
||||
if (this._loadedUrl === url && this._loadedPaused && !this._ended) {
|
||||
const target = opts?.startPosition ?? this._position;
|
||||
if (Math.abs(target - this._position) > 2) {
|
||||
await this.send(["set_property", "time-pos", target]);
|
||||
this._position = target;
|
||||
}
|
||||
await this.send([
|
||||
"set_property",
|
||||
"volume",
|
||||
Math.round((opts?.volume ?? 1) * 100),
|
||||
]);
|
||||
await this.send(["set_property", "speed", opts?.speed ?? 1]);
|
||||
if (opts?.coverArtPath) {
|
||||
// File is already loaded: cover-art-files only applies at
|
||||
// load, so add the art as a runtime albumart track instead.
|
||||
await this.send(["set_property", "cover-art-files", opts.coverArtPath]);
|
||||
await this.send(["video-add", opts.coverArtPath]);
|
||||
}
|
||||
if (opts?.mediaTitle) {
|
||||
await this.send(["set_property", "force-media-title", opts.mediaTitle]);
|
||||
}
|
||||
await this.send(["set_property", "pause", false]);
|
||||
this._loadedPaused = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadFileLocked(url, opts, false);
|
||||
});
|
||||
}
|
||||
|
||||
async preload(url: string, opts?: PlayOptions): Promise<void> {
|
||||
await this.ensureDaemon();
|
||||
await this.runLoadExclusive(async () => {
|
||||
// Already loaded (paused park, or actively playing because the
|
||||
// user pressed Play while this preload was queued — either way
|
||||
// the file is in the player and must not be clobbered).
|
||||
if (this._loadedUrl === url) return;
|
||||
await this.loadFileLocked(url, opts, true);
|
||||
this._intentPlaying = false;
|
||||
});
|
||||
}
|
||||
|
||||
async addCoverArt(path: string): Promise<void> {
|
||||
if (!this._loadedUrl) return;
|
||||
// Keep the property pointing at the latest art too, so a subsequent
|
||||
// loadfile of the same episode carries it.
|
||||
await this.send(["set_property", "cover-art-files", path]);
|
||||
await this.send(["video-add", path]);
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
await this.send(["set_property", "pause", true]);
|
||||
this._playing = false;
|
||||
this._intentPlaying = false;
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
if (this._ended && this._loadedUrl) {
|
||||
// Play pressed on a finished episode: replay from the top.
|
||||
this._ended = false;
|
||||
const url = this._loadedUrl;
|
||||
await this.runLoadExclusive(async () => {
|
||||
await this.loadFileLocked(
|
||||
url,
|
||||
{ volume: this._volume / 100, speed: this._speed },
|
||||
false,
|
||||
);
|
||||
});
|
||||
this._intentPlaying = true;
|
||||
return;
|
||||
}
|
||||
if (this._loadedPaused && this._loadedUrl) {
|
||||
// Deferred first play of a preloaded file.
|
||||
this._loadedPaused = false;
|
||||
}
|
||||
this._ended = false;
|
||||
await this.send(["set_property", "pause", false]);
|
||||
this._playing = true;
|
||||
this._intentPlaying = true;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.proc = null;
|
||||
if (this.conn && this._loadedUrl) {
|
||||
await this.send(["stop"]);
|
||||
}
|
||||
this._playing = false;
|
||||
this._intentPlaying = false;
|
||||
this._loadedUrl = null;
|
||||
this._loadedPaused = false;
|
||||
this._ended = false;
|
||||
this._position = 0;
|
||||
|
||||
// Clean up socket
|
||||
try {
|
||||
if (existsSync(this.socketPath)) {
|
||||
const { unlinkSync } = await import("fs");
|
||||
unlinkSync(this.socketPath);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this._duration = 0;
|
||||
await this.send(["set_property", "cover-art-files", ""]);
|
||||
}
|
||||
|
||||
async seek(seconds: number): Promise<void> {
|
||||
@@ -333,30 +698,55 @@ export class MpvBackend implements AudioBackend {
|
||||
}
|
||||
|
||||
async getPosition(): Promise<number> {
|
||||
// Live-fetch `time-pos` so the position clock is as fresh as the
|
||||
// UI's poll rate (the hook polls this at ~150ms). On a transient IPC
|
||||
// failure, keep the last known value rather than returning 0.
|
||||
if (this._playing && this.proc) {
|
||||
const pos = await this.getProperty("time-pos");
|
||||
if (pos !== undefined) this._position = pos;
|
||||
}
|
||||
// Observed at ~20Hz by mpv — no socket roundtrip on the UI poll.
|
||||
return this._position;
|
||||
}
|
||||
|
||||
async getDuration(): Promise<number> {
|
||||
if (this._duration <= 0) {
|
||||
const dur = await this.getProperty("duration");
|
||||
if (dur !== undefined && dur > 0) this._duration = dur;
|
||||
}
|
||||
return this._duration;
|
||||
}
|
||||
|
||||
isPlaying(): boolean {
|
||||
return this._playing;
|
||||
return this._intentPlaying && this.isAlive() && !this._ended;
|
||||
}
|
||||
|
||||
async getPauseState(): Promise<boolean | undefined> {
|
||||
if (!this.isAlive() || this._paused === null) return undefined;
|
||||
return this._paused;
|
||||
}
|
||||
|
||||
isAlive(): boolean {
|
||||
return this.proc !== null && !this._exited;
|
||||
}
|
||||
|
||||
/** Last mpv playback failure (end-file reason "error"), if any. */
|
||||
getPlaybackError(): string | null {
|
||||
return this._playbackError;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
const conn = this.conn;
|
||||
this.conn = null;
|
||||
if (conn) {
|
||||
// Ask nicely, then force: dispose runs inside process-exit
|
||||
// handlers where awaiting is not guaranteed to complete.
|
||||
conn.send(["quit"], 500).catch(() => {});
|
||||
}
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.proc = null;
|
||||
}
|
||||
this._exited = true;
|
||||
this._intentPlaying = false;
|
||||
try {
|
||||
unlinkSync(this.socketPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,6 +755,8 @@ export class MpvBackend implements AudioBackend {
|
||||
class NoopBackend implements AudioBackend {
|
||||
readonly name: BackendName = "none";
|
||||
async play(): Promise<void> {}
|
||||
async preload(): Promise<void> {}
|
||||
async addCoverArt(): Promise<void> {}
|
||||
async pause(): Promise<void> {}
|
||||
async resume(): Promise<void> {}
|
||||
async stop(): Promise<void> {}
|
||||
@@ -380,6 +772,13 @@ class NoopBackend implements AudioBackend {
|
||||
isPlaying(): boolean {
|
||||
return false;
|
||||
}
|
||||
async getPauseState(): Promise<boolean | undefined> {
|
||||
// Nothing plays on the no-op backend — never externally paused.
|
||||
return false;
|
||||
}
|
||||
isAlive(): boolean {
|
||||
return false;
|
||||
}
|
||||
dispose(): void {}
|
||||
}
|
||||
|
||||
|
||||
42
src/utils/audio-signals.ts
Normal file
42
src/utils/audio-signals.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* audio-signals — module-level playback state shared by useAudio and
|
||||
* non-component consumers.
|
||||
*
|
||||
* useAudio's playback state is a module-level singleton (signals live at
|
||||
* module scope, every `useAudio()` call shares them). Those signals are
|
||||
* declared here so components that must react to playback WITHOUT mounting
|
||||
* a `useAudio()` owner — the visualizer store — can subscribe directly via
|
||||
* `audioPlaybackSignals` (or the individual accessors/setters), instead of
|
||||
* going through the hook. `useAudio()` re-exports nothing from this module
|
||||
* for callers; it imports the accessors and setters for its own use.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import type { Episode } from "../types/episode";
|
||||
import type { BackendName, DetectedPlayer } from "./audio-player";
|
||||
|
||||
export const [isPlaying, setIsPlaying] = createSignal(false);
|
||||
export const [position, setPosition] = createSignal(0);
|
||||
export const [duration, setDuration] = createSignal(0);
|
||||
export const [volume, setVolume] = createSignal(1);
|
||||
export const [speed, setSpeed] = createSignal(1);
|
||||
export const [backendName, setBackendName] = createSignal<BackendName>("none");
|
||||
export const [error, setError] = createSignal<string | null>(null);
|
||||
export const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(
|
||||
null,
|
||||
);
|
||||
export const [availablePlayers, setAvailablePlayers] = createSignal<
|
||||
DetectedPlayer[]
|
||||
>([]);
|
||||
|
||||
/**
|
||||
* The playback signals the visualizer pipeline reacts to. `useAudio()`
|
||||
* itself remains the component-facing surface; this is for module-level
|
||||
* consumers that must track playback without a component owner.
|
||||
*/
|
||||
export const audioPlaybackSignals = {
|
||||
isPlaying,
|
||||
position,
|
||||
speed,
|
||||
currentEpisode,
|
||||
} as const;
|
||||
@@ -1,324 +0,0 @@
|
||||
/**
|
||||
* Real-time audio stream reader for visualization.
|
||||
*
|
||||
* Spawns a separate ffmpeg process that decodes the same audio URL
|
||||
* the player is using and outputs raw PCM data (signed 16-bit LE, mono,
|
||||
* 44100 Hz) to a pipe. The reader accumulates samples in a ring buffer
|
||||
* and serves windows *at a requested playback position* to the caller.
|
||||
*
|
||||
* This is independent from the actual playback backend — it's a
|
||||
* read-only "tap" on the audio for FFT analysis purposes. Sync with the
|
||||
* player is maintained by pacing decode at the player's clock rate
|
||||
* (`-readrate <speed>`) while front-loading a burst of LEAD_SECONDS
|
||||
* (`-readrate_initial_burst`) so the decode head leads the player
|
||||
* position by a stable lead — read() samples at the exact position the
|
||||
* player reports, never at the decode head.
|
||||
*/
|
||||
|
||||
/** PCM output format constants */
|
||||
const SAMPLE_RATE = 44100;
|
||||
const CHANNELS = 1;
|
||||
const BYTES_PER_SAMPLE = 2; // s16le
|
||||
|
||||
/**
|
||||
* How many samples to buffer (~10 seconds).
|
||||
* Large enough to absorb the gap between mpv's startup latency (0.5–3s,
|
||||
* more for network streams at speed) and the reader's decode head, plus
|
||||
* short player stalls. Samples older than the ring window are never needed
|
||||
* again — the renderer only samples at the current playback position.
|
||||
*/
|
||||
const RING_BUFFER_SAMPLES = SAMPLE_RATE * 10;
|
||||
|
||||
/**
|
||||
* Decode-head lead over the player position, in seconds.
|
||||
*
|
||||
* `-readrate_initial_burst LEAD_SECONDS` makes ffmpeg emit this much audio
|
||||
* immediately on start, then pace at realtime (`-readrate speed`) after.
|
||||
* The decode head thus leads the player by ~LEAD_SECONDS from the very
|
||||
* first frame. read() samples at the player's current position, which is
|
||||
* always behind the head — so it finds freshly decoded samples there
|
||||
* instead of clamping to stale data.
|
||||
*
|
||||
* Bare `-readrate speed` (no burst) starts ffmpeg ε behind mpv (input-open
|
||||
* + first-packet latency) and, since both advance at the same rate, never
|
||||
* catches up — the bars lag by ε (up to several seconds on network
|
||||
* streams). The burst eliminates that constant offset.
|
||||
*
|
||||
* Must stay within the ring window (RING_BUFFER_SAMPLES ~10s) so the
|
||||
* lead audio hasn't wrapped out by the time the player reaches it.
|
||||
*/
|
||||
const LEAD_SECONDS = 3;
|
||||
|
||||
export interface AudioStreamReaderOptions {
|
||||
/** Audio URL or file path to decode */
|
||||
url: string;
|
||||
/** Sample rate (default: 44100) */
|
||||
sampleRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonically increasing generation counter.
|
||||
* Each start() increments this; the read loop checks it to know
|
||||
* if it's been superseded and should bail out.
|
||||
*/
|
||||
let globalGeneration = 0;
|
||||
|
||||
import type { Subprocess } from "bun";
|
||||
|
||||
export class AudioStreamReader {
|
||||
private proc: Subprocess | null = null;
|
||||
private ringBuffer: Float64Array;
|
||||
private writePos = 0;
|
||||
private totalSamplesWritten = 0;
|
||||
private startPosition = 0;
|
||||
private _running = false;
|
||||
private generation = 0;
|
||||
readonly url: string;
|
||||
private sampleRate: number;
|
||||
|
||||
constructor(options: AudioStreamReaderOptions) {
|
||||
this.url = options.url;
|
||||
this.sampleRate = options.sampleRate ?? SAMPLE_RATE;
|
||||
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES);
|
||||
}
|
||||
|
||||
/** Whether the reader is actively reading samples. */
|
||||
get running(): boolean {
|
||||
return this._running;
|
||||
}
|
||||
|
||||
/** Total number of samples written since start(). */
|
||||
get samplesWritten(): number {
|
||||
return this.totalSamplesWritten;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the ffmpeg decode process and begin reading PCM data.
|
||||
*
|
||||
* If already running, the previous process is killed first.
|
||||
* Uses a generation counter to guarantee that only one read loop
|
||||
* is ever active — stale loops from killed processes bail out
|
||||
* immediately.
|
||||
*
|
||||
* @param startPosition Seek position in seconds (default: 0).
|
||||
* @param speed Playback speed multiplier (default: 1). Paces ffmpeg
|
||||
* at the player's advance rate so decode tracks the
|
||||
* player clock; `-readrate_initial_burst` front-loads
|
||||
* a LEAD_SECONDS head start.
|
||||
*/
|
||||
start(startPosition = 0, speed = 1): void {
|
||||
// Always kill the previous process first — no early return on _running
|
||||
this.killProcess();
|
||||
|
||||
if (!Bun.which("ffmpeg")) {
|
||||
throw new Error("ffmpeg not found — required for audio visualization");
|
||||
}
|
||||
|
||||
// Increment generation so any lingering read loop from a previous
|
||||
// start() will see a mismatch and exit.
|
||||
this.generation = ++globalGeneration;
|
||||
this.startPosition = Math.max(0, startPosition);
|
||||
|
||||
const readRate = Math.max(0.25, speed > 0 ? speed : 1);
|
||||
|
||||
const args = [
|
||||
"ffmpeg",
|
||||
"-loglevel",
|
||||
"quiet",
|
||||
// Pace input at the player's advance rate (speed× native). Combined
|
||||
// with -readrate_initial_burst below, the decode head starts
|
||||
// LEAD_SECONDS ahead of the player and advances at the same rate —
|
||||
// read() samples at the player position and always finds fresh data.
|
||||
"-readrate",
|
||||
String(readRate),
|
||||
// Front-load LEAD_SECONDS of audio immediately so the decode head
|
||||
// leads the player from the very first frame. Without this, ffmpeg
|
||||
// starts ε behind mpv (input-open + first-packet latency) and,
|
||||
// pacing at the same rate, never catches up — bars lag by ε.
|
||||
"-readrate_initial_burst",
|
||||
String(LEAD_SECONDS),
|
||||
];
|
||||
|
||||
// `-reconnect*` are http-protocol options: ffmpeg rejects them at
|
||||
// input-open when the input is a local file, killing the process
|
||||
// before any PCM is produced. Only pass them for network URLs.
|
||||
if (/^https?:\/\//i.test(this.url)) {
|
||||
args.push(
|
||||
"-reconnect",
|
||||
"1",
|
||||
"-reconnect_streamed",
|
||||
"1",
|
||||
"-reconnect_delay_max",
|
||||
"5",
|
||||
);
|
||||
}
|
||||
|
||||
// Seek before input for network efficiency
|
||||
if (startPosition > 0) {
|
||||
args.push("-ss", String(startPosition));
|
||||
}
|
||||
|
||||
args.push("-i", this.url);
|
||||
|
||||
// No atempo filter: the renderer samples the *source* audio at the
|
||||
// player's current position, so output samples map 1:1 to input time
|
||||
// (stream index = (targetSeconds - startPosition) * sampleRate).
|
||||
args.push(
|
||||
"-ac",
|
||||
String(CHANNELS),
|
||||
"-ar",
|
||||
String(this.sampleRate),
|
||||
"-f",
|
||||
"s16le",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-",
|
||||
);
|
||||
|
||||
this.proc = Bun.spawn(args, {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
stdin: "ignore",
|
||||
});
|
||||
|
||||
this._running = true;
|
||||
this.writePos = 0;
|
||||
this.totalSamplesWritten = 0;
|
||||
|
||||
const myGeneration = this.generation;
|
||||
|
||||
this.readLoop(myGeneration);
|
||||
|
||||
// Detect process exit
|
||||
this.proc.exited
|
||||
.then(() => {
|
||||
// Only clear _running if this is still the current generation
|
||||
if (this.generation === myGeneration) {
|
||||
this._running = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (this.generation === myGeneration) {
|
||||
this._running = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the visualization window ending at `targetSeconds` of playback.
|
||||
*
|
||||
* The player (mpv) and this decoder are independent processes, so the
|
||||
* decode head and the actual playback position drift apart (startup skew,
|
||||
* stalls, speed changes). Instead of sampling the decode head, we select
|
||||
* the window *at* the position the player reports, clamped to the nearest
|
||||
* available samples when the target hasn't been decoded yet (decode head
|
||||
* behind) or has already wrapped out of the ring (long stall).
|
||||
*
|
||||
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
|
||||
* @param targetSeconds - Playback position (input seconds) to sample.
|
||||
* @returns Number of samples written to `out`.
|
||||
*/
|
||||
read(out: Float64Array, targetSeconds: number): number {
|
||||
if (this.totalSamplesWritten <= 0 || out.length === 0) return 0;
|
||||
|
||||
const headSample = this.totalSamplesWritten - 1;
|
||||
const coveredStart = Math.max(
|
||||
0,
|
||||
this.totalSamplesWritten - this.ringBuffer.length,
|
||||
);
|
||||
|
||||
const targetSample = Math.max(
|
||||
0,
|
||||
Math.round((targetSeconds - this.startPosition) * this.sampleRate),
|
||||
);
|
||||
|
||||
// Window end: the target, clamped to what's been decoded so far.
|
||||
const endSample = Math.min(targetSample, headSample);
|
||||
// Window start: at most out.length samples back, clamped to what the
|
||||
// ring still holds (target older than the ring -> serve the oldest
|
||||
// available window, which is the closest to the target).
|
||||
const startSample = Math.max(
|
||||
coveredStart,
|
||||
Math.min(endSample, endSample - out.length + 1),
|
||||
);
|
||||
const available = endSample - startSample + 1;
|
||||
if (available <= 0) return 0;
|
||||
|
||||
const ringLen = this.ringBuffer.length;
|
||||
for (let i = 0; i < available; i++) {
|
||||
out[i] = this.ringBuffer[(startSample + i) % ringLen];
|
||||
}
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the ffmpeg process and clean up.
|
||||
* Safe to call multiple times. Guarantees the read loop exits.
|
||||
*/
|
||||
stop(): void {
|
||||
// Bump generation to invalidate any running read loop
|
||||
this.generation = ++globalGeneration;
|
||||
this._running = false;
|
||||
this.killProcess();
|
||||
this.writePos = 0;
|
||||
this.totalSamplesWritten = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the reader at a new position and/or speed.
|
||||
*/
|
||||
restart(startPosition = 0, speed = 1): void {
|
||||
this.start(startPosition, speed);
|
||||
}
|
||||
|
||||
/** Kill the ffmpeg process without touching generation/state. */
|
||||
private killProcess(): void {
|
||||
if (this.proc) {
|
||||
try {
|
||||
this.proc.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.proc = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
|
||||
private async readLoop(myGeneration: number): Promise<void> {
|
||||
const stdout = this.proc?.stdout;
|
||||
if (!stdout || typeof stdout === "number") return;
|
||||
|
||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
|
||||
try {
|
||||
while (this.generation === myGeneration) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done || this.generation !== myGeneration) break;
|
||||
if (!value || value.byteLength === 0) continue;
|
||||
|
||||
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE);
|
||||
if (sampleCount === 0) continue;
|
||||
|
||||
const int16View = new Int16Array(
|
||||
value.buffer,
|
||||
value.byteOffset,
|
||||
sampleCount,
|
||||
);
|
||||
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
this.ringBuffer[this.writePos] = int16View[i];
|
||||
this.writePos = (this.writePos + 1) % this.ringBuffer.length;
|
||||
this.totalSamplesWritten++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stream ended or process killed — expected during stop()
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,34 +2,97 @@
|
||||
* Cover-art staging for the system Now Playing session.
|
||||
*
|
||||
* macOS shows the media session's albumart in the audio center (Control
|
||||
* Center / lock screen). mpv reads it from `--cover-art-files` (loads the
|
||||
* file as an albumart video track), so the podcast cover is staged to a temp
|
||||
* file BEFORE playback starts and passed to mpv.
|
||||
* Center / lock screen). mpv reads artwork from `--cover-art-files` (loads
|
||||
* the file as an albumart video track), so the podcast cover must exist on
|
||||
* disk before (cover-art-files) or right after (video-add) playback starts.
|
||||
*
|
||||
* Covers are cached persistently under `$XDG_CACHE_HOME/podtui/covers`
|
||||
* (~/.cache/podtui/covers by default), keyed by the URL hash, so the
|
||||
* download happens ONCE per feed — subsequent plays (including the
|
||||
* boot-restored episode) hit the disk cache and never wait on the network.
|
||||
* The play path must never block on art: `cachedCoverPath` is the sync
|
||||
* fast path; `fetchCoverArt` is awaited only by flows where latency does
|
||||
* not matter (CLI play) or fired in the background with the result
|
||||
* applied to a live mpv via `video-add`.
|
||||
*
|
||||
* Downloaded via `curl` (not `fetch`): Bun's `fetch` hangs in compiled
|
||||
* `bun build --compile` binaries (Bun 1.3.8), timing out on any host —
|
||||
* which would silently drop every cover in shipped builds. curl is present
|
||||
* on macOS and Linux. Bounded: a slow cover server must never stall audio,
|
||||
* so an 8s cap drops the art.
|
||||
* on macOS and Linux. Bounded: a slow cover server must never stall audio.
|
||||
*/
|
||||
|
||||
import { tmpdir } from "os";
|
||||
import { existsSync, mkdirSync, renameSync, statSync } from "fs";
|
||||
import { createHash } from "crypto";
|
||||
import { join } from "path";
|
||||
import { unlinkSync, statSync } from "fs";
|
||||
|
||||
export const coverTempPath = () => join(tmpdir(), "podtui-cover.jpg");
|
||||
/** Resolved once per process; null when no home directory is detectable. */
|
||||
let cacheDir: string | null | undefined;
|
||||
|
||||
export async function fetchCoverArt(url: string): Promise<string | null> {
|
||||
const path = coverTempPath();
|
||||
function coversDir(): string | null {
|
||||
if (cacheDir !== undefined) return cacheDir;
|
||||
let dir: string | null = null;
|
||||
try {
|
||||
unlinkSync(path);
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||
if (home) {
|
||||
dir = join(process.env.XDG_CACHE_HOME ?? join(home, ".cache"), "podtui", "covers");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
} catch {
|
||||
/* no stale cover */
|
||||
dir = null;
|
||||
}
|
||||
cacheDir = dir;
|
||||
return dir;
|
||||
}
|
||||
|
||||
function cachePathFor(url: string): string | null {
|
||||
const dir = coversDir();
|
||||
if (!dir) return null;
|
||||
return join(dir, `${createHash("sha1").update(url).digest("hex")}.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync fast path: the cached cover file for `url`, or null when it has not
|
||||
* been downloaded yet. This is what keeps cover art off the play() critical
|
||||
* path — a cache hit costs one stat() and a miss simply plays without art
|
||||
* (or applies it late via video-add).
|
||||
*/
|
||||
export function cachedCoverPath(url: string): string | null {
|
||||
const path = cachePathFor(url);
|
||||
if (!path) return null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
(async () => {
|
||||
const proc = Bun.spawn([
|
||||
return existsSync(path) && statSync(path).size > 0 ? path : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** In-flight downloads keyed by URL — a burst of plays of the same show
|
||||
* shares one curl instead of racing ephemeral files. */
|
||||
const inflight = new Map<string, Promise<string | null>>();
|
||||
|
||||
/**
|
||||
* Fetch the cover for `url`, returns its cache path. Cache hits return
|
||||
* immediately. Downloads are single-flight per URL and time-bounded (8s);
|
||||
* failure resolves null and retries on the next call. The file is written
|
||||
* to a temp name and renamed into place so a killed process can never
|
||||
* poison the cache with a truncated file.
|
||||
*/
|
||||
export function fetchCoverArt(url: string): Promise<string | null> {
|
||||
const cached = cachedCoverPath(url);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
|
||||
const dest = cachePathFor(url);
|
||||
if (!dest) return Promise.resolve(null);
|
||||
|
||||
const pending = inflight.get(url);
|
||||
if (pending) return pending;
|
||||
|
||||
const task = (async (): Promise<string | null> => {
|
||||
const staging = `${dest}.${process.pid}.tmp`;
|
||||
try {
|
||||
const { promise, resolve } = Promise.withResolvers<string | null>();
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"curl",
|
||||
"-sS",
|
||||
"--fail",
|
||||
@@ -38,20 +101,41 @@ export async function fetchCoverArt(url: string): Promise<string | null> {
|
||||
"--max-filesize",
|
||||
"2097152",
|
||||
"-o",
|
||||
path,
|
||||
staging,
|
||||
url,
|
||||
]);
|
||||
const code = await proc.exited;
|
||||
if (code !== 0) return null;
|
||||
try {
|
||||
return statSync(path).size > 0 ? path : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8000)),
|
||||
]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
],
|
||||
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
|
||||
);
|
||||
proc.exited
|
||||
.then((code) => {
|
||||
if (code !== 0) return resolve(null);
|
||||
try {
|
||||
if (statSync(staging).size <= 0) return resolve(null);
|
||||
renameSync(staging, dest);
|
||||
resolve(dest);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
})
|
||||
.catch(() => resolve(null));
|
||||
setTimeout(() => resolve(null), 8000);
|
||||
return await promise;
|
||||
} finally {
|
||||
inflight.delete(url);
|
||||
// Best-effort staging cleanup (no-op after a successful rename).
|
||||
try {
|
||||
Bun.spawn(["rm", "-f", staging], { stdout: "ignore", stderr: "ignore" });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
inflight.set(url, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/** Fire-and-forget warm-up used by the boot/restore path. */
|
||||
export function prefetchCoverArt(url: string): void {
|
||||
fetchCoverArt(url).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
||||
"sort",
|
||||
"toggle-hidden",
|
||||
"refresh",
|
||||
"subscribe",
|
||||
"unsubscribe",
|
||||
"download",
|
||||
"delete-download",
|
||||
|
||||
@@ -65,6 +65,8 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
|
||||
sort: [","],
|
||||
"toggle-hidden": ["."],
|
||||
refresh: ["r"],
|
||||
// a subscribes the focused show/episode result in place (x unsubscribes)
|
||||
subscribe: ["a"],
|
||||
unsubscribe: ["x"],
|
||||
// downloads
|
||||
download: ["d"],
|
||||
|
||||
192
tests/audio-backend.test.ts
Normal file
192
tests/audio-backend.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* MpvBackend resident-daemon contract tests (real mpv process).
|
||||
*
|
||||
* Pins the IPC contract the app's playback depends on:
|
||||
*
|
||||
* 1. play() loads a file and position advances (observed, no polling).
|
||||
* 2. pause()/resume() flip the player-reported pause state through IPC.
|
||||
* 3. seek() lands where asked.
|
||||
* 4. stop() unloads the file but keeps the daemon alive (isAlive stays
|
||||
* true — the daemon model's whole point: no process churn per episode).
|
||||
* 5. preload() parks an episode paused; play() of the SAME url then starts
|
||||
* it by unpausing — the boot-restore fast path with no second load.
|
||||
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
|
||||
* resume() afterwards replays from the top.
|
||||
*
|
||||
* All playback runs silent (volume 0). Requires a real mpv on PATH;
|
||||
* tests skip where it is missing.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { MpvBackend } from "../src/utils/audio-player";
|
||||
|
||||
const SAMPLE_RATE = 22050;
|
||||
const FREQ = 440;
|
||||
const AMP = 20000;
|
||||
|
||||
/** Write a WAV file containing `seconds` of a sine at AMP amplitude. */
|
||||
function writeSineWav(path: string, seconds: number): void {
|
||||
const total = Math.round(seconds * SAMPLE_RATE);
|
||||
const dataSize = total * 2;
|
||||
const buf = new Uint8Array(44 + dataSize);
|
||||
const dv = new DataView(buf.buffer);
|
||||
const ascii = (off: number, s: string) => {
|
||||
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
|
||||
};
|
||||
ascii(0, "RIFF");
|
||||
dv.setUint32(4, 36 + dataSize, true);
|
||||
ascii(8, "WAVE");
|
||||
ascii(12, "fmt ");
|
||||
dv.setUint32(16, 16, true);
|
||||
dv.setUint16(20, 1, true);
|
||||
dv.setUint16(22, 1, true);
|
||||
dv.setUint32(24, SAMPLE_RATE, true);
|
||||
dv.setUint32(28, SAMPLE_RATE * 2, true);
|
||||
dv.setUint16(32, 2, true);
|
||||
dv.setUint16(34, 16, true);
|
||||
ascii(36, "data");
|
||||
dv.setUint32(40, dataSize, true);
|
||||
for (let i = 0; i < total; i++) {
|
||||
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
|
||||
dv.setInt16(44 + i * 2, v, true);
|
||||
}
|
||||
Bun.write(path, buf);
|
||||
}
|
||||
|
||||
/** Poll a predicate until true or the deadline expires. */
|
||||
async function waitFor(
|
||||
label: string,
|
||||
pred: () => boolean | Promise<boolean>,
|
||||
timeoutMs = 8000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
if (await pred()) return;
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`${label}: not true within ${timeoutMs}ms`);
|
||||
}
|
||||
await Bun.sleep(50);
|
||||
}
|
||||
}
|
||||
|
||||
const hasMpv = !!Bun.which("mpv");
|
||||
const wavA = join(tmpdir(), `podtui-backend-${process.pid}-a.wav`);
|
||||
const wavB = join(tmpdir(), `podtui-backend-${process.pid}-b.wav`);
|
||||
|
||||
function fixtureWavs(): void {
|
||||
writeSineWav(wavA, 8);
|
||||
writeSineWav(wavB, 8);
|
||||
}
|
||||
|
||||
async function cleanup(backend: MpvBackend): Promise<void> {
|
||||
backend.dispose();
|
||||
await Bun.$`rm -f ${wavA} ${wavB}`.quiet();
|
||||
}
|
||||
|
||||
test.skipIf(!hasMpv)(
|
||||
"play / pause / resume / seek over the resident daemon",
|
||||
async () => {
|
||||
fixtureWavs();
|
||||
const backend = new MpvBackend();
|
||||
try {
|
||||
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
|
||||
expect(backend.isAlive()).toBe(true);
|
||||
expect(backend.isPlaying()).toBe(true);
|
||||
|
||||
// Observed position advances without any polling from us.
|
||||
await waitFor("position advances", async () => (await backend.getPosition()) > 1.3);
|
||||
expect(await backend.getPauseState()).toBe(false);
|
||||
expect(await backend.getDuration()).toBeGreaterThan(7.5);
|
||||
|
||||
// Pause: reported by the player's own state, position stalls.
|
||||
await backend.pause();
|
||||
await waitFor("paused state observed", async () => (await backend.getPauseState()) === true);
|
||||
const posAtPause = await backend.getPosition();
|
||||
await Bun.sleep(400);
|
||||
expect(Math.abs((await backend.getPosition()) - posAtPause)).toBeLessThan(0.3);
|
||||
|
||||
// Resume: clock advances again.
|
||||
await backend.resume();
|
||||
await waitFor("resumed state observed", async () => (await backend.getPauseState()) === false);
|
||||
await waitFor(
|
||||
"position advances after resume",
|
||||
async () => (await backend.getPosition()) > posAtPause + 0.3,
|
||||
);
|
||||
|
||||
// Seek lands where asked.
|
||||
await backend.seek(6);
|
||||
await waitFor(
|
||||
"seek observed",
|
||||
async () => Math.abs((await backend.getPosition()) - 6) < 0.5,
|
||||
);
|
||||
|
||||
// Stop unloads the file — but the daemon stays resident.
|
||||
await backend.stop();
|
||||
expect(backend.isPlaying()).toBe(false);
|
||||
expect(backend.isAlive()).toBe(true);
|
||||
expect(await backend.getPosition()).toBe(0);
|
||||
} finally {
|
||||
await cleanup(backend);
|
||||
}
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
test.skipIf(!hasMpv)(
|
||||
"preload parks the episode paused; play() of the same url starts it by unpausing",
|
||||
async () => {
|
||||
fixtureWavs();
|
||||
const backend = new MpvBackend();
|
||||
try {
|
||||
await backend.preload(wavB, { volume: 0, speed: 1, startPosition: 2 });
|
||||
// Parked: paused, at the requested offset, nothing advancing.
|
||||
await waitFor(
|
||||
"preload observed paused",
|
||||
async () => (await backend.getPauseState()) === true,
|
||||
);
|
||||
const parkedPos = await backend.getPosition();
|
||||
expect(parkedPos).toBeGreaterThan(1.5);
|
||||
expect(backend.isPlaying()).toBe(false);
|
||||
await Bun.sleep(400);
|
||||
expect(Math.abs((await backend.getPosition()) - parkedPos)).toBeLessThan(0.3);
|
||||
|
||||
// The boot-restore fast path: play() unpauses instead of re-loading.
|
||||
await backend.play(wavB, { volume: 0, speed: 1, startPosition: parkedPos });
|
||||
expect(backend.isPlaying()).toBe(true);
|
||||
await waitFor(
|
||||
"preload fast path plays",
|
||||
async () => (await backend.getPosition()) > parkedPos + 0.3,
|
||||
);
|
||||
} finally {
|
||||
await cleanup(backend);
|
||||
}
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
test.skipIf(!hasMpv)(
|
||||
"EOF marks playback ended; resume() then replays from the top",
|
||||
async () => {
|
||||
const wavShort = join(tmpdir(), `podtui-backend-${process.pid}-short.wav`);
|
||||
writeSineWav(wavShort, 2);
|
||||
const backend = new MpvBackend();
|
||||
try {
|
||||
await backend.play(wavShort, { volume: 0, speed: 2 });
|
||||
// 2s at 2x ends in ~1s+startup. isPlaying() must drop on its own.
|
||||
await waitFor("episode ended", async () => !backend.isPlaying());
|
||||
|
||||
// Play pressed on a finished episode replays from the top.
|
||||
await backend.resume();
|
||||
await waitFor("replay started", async () => backend.isPlaying());
|
||||
await waitFor(
|
||||
"replay position near start",
|
||||
async () => (await backend.getPosition()) < 3 && backend.isPlaying(),
|
||||
);
|
||||
} finally {
|
||||
backend.dispose();
|
||||
await Bun.$`rm -f ${wavShort}`.quiet();
|
||||
}
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
231
tests/audio-pcm-cache.test.ts
Normal file
231
tests/audio-pcm-cache.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* EpisodePcmCache position-index contract tests.
|
||||
*
|
||||
* The visualizer's bars are served from a position-indexed PCM cache that
|
||||
* ffmpeg fills at full speed. These tests pin the observable contracts the
|
||||
* fragile paced-ring design kept breaking:
|
||||
*
|
||||
* 1. readWindow(out, at) serves the EXACT window ending at playback time
|
||||
* `at` — position mapping is sample-precise, independent of how fast or
|
||||
* far the decode has run.
|
||||
* 2. Reads outside decoded coverage return 0 — the renderer HOLDS the last
|
||||
* frame. (The old reader CLAMPED to a stale buffer; re-rendering the
|
||||
* same window decayed cava into a frozen junk pattern after pause.)
|
||||
* 3. pauseDecode kills ffmpeg but keeps the cache: resume serves bars
|
||||
* instantly, ensureDecodeAround restarts the tail decode.
|
||||
* 4. Seeking into an undecoded region starts a new segment there WITHOUT
|
||||
* invalidating the previously decoded coverage.
|
||||
*
|
||||
* Uses a self-generated WAV (440Hz sine, mono, 22050Hz s16le — the cache's
|
||||
* native rate) so expected samples are computed analytically with no
|
||||
* resampler tolerance.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { EpisodePcmCache } from "../src/utils/audio-pcm-cache";
|
||||
|
||||
const SAMPLE_RATE = 22050;
|
||||
const FREQ = 440;
|
||||
const AMP = 30000;
|
||||
|
||||
/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
|
||||
function writeSineWav(path: string, seconds: number): void {
|
||||
const total = Math.round(seconds * SAMPLE_RATE);
|
||||
const dataSize = total * 2;
|
||||
const buf = new Uint8Array(44 + dataSize);
|
||||
const dv = new DataView(buf.buffer);
|
||||
const ascii = (off: number, s: string) => {
|
||||
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
|
||||
};
|
||||
ascii(0, "RIFF");
|
||||
dv.setUint32(4, 36 + dataSize, true);
|
||||
ascii(8, "WAVE");
|
||||
ascii(12, "fmt ");
|
||||
dv.setUint32(16, 16, true);
|
||||
dv.setUint16(20, 1, true); // PCM
|
||||
dv.setUint16(22, 1, true); // mono
|
||||
dv.setUint32(24, SAMPLE_RATE, true);
|
||||
dv.setUint32(28, SAMPLE_RATE * 2, true);
|
||||
dv.setUint16(32, 2, true);
|
||||
dv.setUint16(34, 16, true);
|
||||
ascii(36, "data");
|
||||
dv.setUint32(40, dataSize, true);
|
||||
for (let i = 0; i < total; i++) {
|
||||
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
|
||||
dv.setInt16(44 + i * 2, v, true);
|
||||
}
|
||||
Bun.write(path, buf);
|
||||
}
|
||||
|
||||
/** Analytic sample value at a file index, matching the writer's formula. */
|
||||
function expectedAt(fileIndex: number): number {
|
||||
return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
|
||||
}
|
||||
|
||||
/** Block until the cache covers playback time `sec`. */
|
||||
async function waitForCoverage(
|
||||
cache: EpisodePcmCache,
|
||||
sec: number,
|
||||
timeoutMs = 10000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!cache.covers(sec)) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`cache did not cover ${sec}s in time`);
|
||||
}
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
}
|
||||
|
||||
/** Block until the furthest decode pass has hit stream EOF. */
|
||||
async function waitForFinished(
|
||||
cache: EpisodePcmCache,
|
||||
timeoutMs = 10000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!cache.decodeFinished) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("decode did not finish in time");
|
||||
}
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
}
|
||||
|
||||
function tmpWav(): string {
|
||||
return join(tmpdir(), `podtui-pcm-${process.pid}-${Math.floor(Math.random() * 1e9)}.wav`);
|
||||
}
|
||||
|
||||
const hasFfmpeg = !!Bun.which("ffmpeg");
|
||||
const FIVE_SEC_BASE = 5 * SAMPLE_RATE; // decode offset for position-mapping tests
|
||||
|
||||
test.skipIf(!hasFfmpeg)(
|
||||
"readWindow serves the exact window ending at the requested position",
|
||||
async () => {
|
||||
const wav = tmpWav();
|
||||
writeSineWav(wav, 30);
|
||||
const cache = new EpisodePcmCache({ url: wav });
|
||||
try {
|
||||
cache.startDecode(5);
|
||||
await waitForCoverage(cache, 6.5);
|
||||
|
||||
const out = new Float64Array(512);
|
||||
expect(cache.readWindow(out, 5.1)).toBe(512);
|
||||
// Window ENDS at the target: out[i] is the sample at
|
||||
// round(5.1*SR) - (len-1) + i (5s offset + 0.1s).
|
||||
const endIdx = Math.round(5.1 * SAMPLE_RATE);
|
||||
for (let i = 0; i < 512; i++) {
|
||||
const idx = endIdx - (out.length - 1) + i;
|
||||
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
// A 5ms later window is the same stream shifted by exactly
|
||||
// round(0.005*SR)=110 samples — pins position mapping precision.
|
||||
const later = new Float64Array(512);
|
||||
expect(cache.readWindow(later, 5.105)).toBe(512);
|
||||
for (let i = 0; i <= 512 - 111; i++) {
|
||||
expect(later[i]).toBe(out[i + 110]);
|
||||
}
|
||||
} finally {
|
||||
cache.stop();
|
||||
await Bun.$`rm -f ${wav}`.quiet();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(!hasFfmpeg)(
|
||||
"reads outside decoded coverage return 0 (renderer holds last frame, never stale junk)",
|
||||
async () => {
|
||||
const wav = tmpWav();
|
||||
writeSineWav(wav, 30);
|
||||
const cache = new EpisodePcmCache({ url: wav });
|
||||
try {
|
||||
cache.startDecode(5);
|
||||
await waitForCoverage(cache, 5.5);
|
||||
|
||||
const out = new Float64Array(512);
|
||||
out.fill(-999);
|
||||
|
||||
// Beyond the decode frontier.
|
||||
expect(cache.readWindow(out, 999)).toBe(0);
|
||||
// Before the segment base (decode started at 5s).
|
||||
expect(cache.readWindow(out, 4.0)).toBe(0);
|
||||
// Buffer untouched — no partial/stale samples leak through.
|
||||
for (let i = 0; i < 16; i++) expect(out[i]).toBe(-999);
|
||||
} finally {
|
||||
cache.stop();
|
||||
await Bun.$`rm -f ${wav}`.quiet();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(!hasFfmpeg)(
|
||||
"pauseDecode keeps the cache: resume serves instantly, tail decode continues",
|
||||
async () => {
|
||||
const wav = tmpWav();
|
||||
writeSineWav(wav, 12); // short: full tail decode lands well under a second
|
||||
const cache = new EpisodePcmCache({ url: wav });
|
||||
try {
|
||||
cache.startDecode(0);
|
||||
await waitForCoverage(cache, 1.5);
|
||||
|
||||
// Pause: decode dies, cache must survive.
|
||||
cache.pauseDecode();
|
||||
expect(cache.decoding).toBe(false);
|
||||
expect(cache.covers(1)).toBe(true);
|
||||
|
||||
// Serve from cache immediately after pause — this is the resume
|
||||
// fast path: zero ffmpeg cold start.
|
||||
const out = new Float64Array(512);
|
||||
expect(cache.readWindow(out, 1.0)).toBe(512);
|
||||
const endIdx = Math.round(1.0 * SAMPLE_RATE);
|
||||
for (let i = 0; i < 512; i++) {
|
||||
const idx = endIdx - (out.length - 1) + i;
|
||||
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Resume: tail decode restarts and eventually covers the file.
|
||||
cache.ensureDecodeAround(1.0);
|
||||
await waitForFinished(cache);
|
||||
expect(cache.coverageEndSec).toBeGreaterThanOrEqual(11.9);
|
||||
expect(cache.readWindow(out, 11.5)).toBe(512);
|
||||
} finally {
|
||||
cache.stop();
|
||||
await Bun.$`rm -f ${wav}`.quiet();
|
||||
}
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
test.skipIf(!hasFfmpeg)(
|
||||
"seek into an undecoded region starts a new segment without losing earlier coverage",
|
||||
async () => {
|
||||
const wav = tmpWav();
|
||||
writeSineWav(wav, 30);
|
||||
const cache = new EpisodePcmCache({ url: wav });
|
||||
try {
|
||||
// Decoded the back half only...
|
||||
cache.startDecode(10);
|
||||
await waitForCoverage(cache, 11);
|
||||
expect(cache.covers(2)).toBe(false);
|
||||
|
||||
// ...then the user seeks to 2s: a new segment decodes the front,
|
||||
// and the back-half coverage stays valid throughout.
|
||||
cache.ensureDecodeAround(2);
|
||||
await waitForCoverage(cache, 2.2);
|
||||
expect(cache.covers(10.5)).toBe(true);
|
||||
|
||||
const out = new Float64Array(512);
|
||||
expect(cache.readWindow(out, 10.5)).toBe(512);
|
||||
const endIdx = Math.round(10.5 * SAMPLE_RATE);
|
||||
for (let i = 0; i < 512; i++) {
|
||||
const idx = endIdx - (out.length - 1) + i;
|
||||
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||
}
|
||||
} finally {
|
||||
cache.stop();
|
||||
await Bun.$`rm -f ${wav}`.quiet();
|
||||
}
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* AudioStreamReader sync contract tests.
|
||||
*
|
||||
* The visualizer's bars must track the player's position in real time even
|
||||
* though the reader is an independent ffmpeg process. These tests pin the
|
||||
* two mechanisms that make that true:
|
||||
*
|
||||
* 1. `read(out, target)` serves the FFT window *at* the requested playback
|
||||
* position — not at the decode head, which drifts from the player
|
||||
* (startup skew, stalls).
|
||||
* 2. Decode is paced at the player's clock rate (`-readrate <speed>`), so
|
||||
* the decode head keeps up with the position at any playback speed —
|
||||
* native-rate pacing falls behind by (speed-1)s per second.
|
||||
*
|
||||
* Uses a self-generated WAV (440Hz sine, mono, 44.1kHz s16le) so the
|
||||
* expected samples can be computed analytically and compared exactly.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { AudioStreamReader } from "../src/utils/audio-stream-reader";
|
||||
|
||||
const SAMPLE_RATE = 44100;
|
||||
const FREQ = 440;
|
||||
const AMP = 30000;
|
||||
|
||||
/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
|
||||
function writeSineWav(path: string, seconds: number): void {
|
||||
const total = Math.round(seconds * SAMPLE_RATE);
|
||||
const dataSize = total * 2;
|
||||
const buf = new Uint8Array(44 + dataSize);
|
||||
const dv = new DataView(buf.buffer);
|
||||
const ascii = (off: number, s: string) => {
|
||||
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
|
||||
};
|
||||
ascii(0, "RIFF");
|
||||
dv.setUint32(4, 36 + dataSize, true);
|
||||
ascii(8, "WAVE");
|
||||
ascii(12, "fmt ");
|
||||
dv.setUint32(16, 16, true);
|
||||
dv.setUint16(20, 1, true); // PCM
|
||||
dv.setUint16(22, 1, true); // mono
|
||||
dv.setUint32(24, SAMPLE_RATE, true);
|
||||
dv.setUint32(28, SAMPLE_RATE * 2, true);
|
||||
dv.setUint16(32, 2, true);
|
||||
dv.setUint16(34, 16, true);
|
||||
ascii(36, "data");
|
||||
dv.setUint32(40, dataSize, true);
|
||||
for (let i = 0; i < total; i++) {
|
||||
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
|
||||
dv.setInt16(44 + i * 2, v, true);
|
||||
}
|
||||
Bun.write(path, buf);
|
||||
}
|
||||
|
||||
/** Analytic sample value at a file index, matching the writer's formula. */
|
||||
function expectedAt(fileIndex: number): number {
|
||||
return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Block until the reader's decode head has advanced past `samples` samples.
|
||||
* The head advances at readrate × real time, so this bounds how long we wait.
|
||||
*/
|
||||
async function waitForHead(
|
||||
reader: AudioStreamReader,
|
||||
samples: number,
|
||||
timeoutMs = 8000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (reader.samplesWritten < samples) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("reader decode head did not advance in time");
|
||||
}
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
}
|
||||
|
||||
const hasFfmpeg = !!Bun.which("ffmpeg");
|
||||
|
||||
test.skipIf(!hasFfmpeg)(
|
||||
"read() serves the exact window at the requested position",
|
||||
async () => {
|
||||
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
|
||||
writeSineWav(wav, 20);
|
||||
const reader = new AudioStreamReader({ url: wav });
|
||||
try {
|
||||
reader.start(5, 1);
|
||||
// Cover targets up to ~5.6s (head must pass the read target).
|
||||
await waitForHead(reader, Math.round(0.6 * SAMPLE_RATE));
|
||||
|
||||
const out = new Float64Array(512);
|
||||
|
||||
// Window at 5.1s: the window ENDS at the target, so out[i] is at
|
||||
// file index 5*SR + round((5.1-5)*SR) - (len-1) + i.
|
||||
expect(reader.read(out, 5.1)).toBe(512);
|
||||
for (let i = 0; i < 512; i++) {
|
||||
const idx =
|
||||
Math.round(5 * SAMPLE_RATE) +
|
||||
Math.round((5.1 - 5) * SAMPLE_RATE) -
|
||||
(out.length - 1) +
|
||||
i;
|
||||
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Window at 5.105s is the same stream shifted by exactly
|
||||
// round(0.005*SR)=221 samples — pins that the target maps to a
|
||||
// precise offset, not "whatever the decode head is at".
|
||||
const later = new Float64Array(512);
|
||||
expect(reader.read(later, 5.105)).toBe(512);
|
||||
for (let i = 0; i <= 512 - 222; i++) {
|
||||
expect(later[i]).toBe(out[i + 221]);
|
||||
}
|
||||
} finally {
|
||||
reader.stop();
|
||||
await Bun.$`rm -f ${wav}`.quiet();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(!hasFfmpeg)(
|
||||
"decode keeps up with the player clock at 2x speed",
|
||||
async () => {
|
||||
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
|
||||
writeSineWav(wav, 20);
|
||||
const reader = new AudioStreamReader({ url: wav });
|
||||
try {
|
||||
reader.start(0, 2);
|
||||
// At 2x pacing the head reaches 2.5s after ~1.25s of wall time.
|
||||
// With native-rate pacing it would only be at ~1.25s, and the
|
||||
// window at 2.5s would clamp to the head — content mismatch.
|
||||
await waitForHead(reader, Math.round(2.5 * SAMPLE_RATE));
|
||||
|
||||
const out = new Float64Array(512);
|
||||
expect(reader.read(out, 2.5)).toBe(512);
|
||||
for (let i = 0; i < 512; i++) {
|
||||
const idx =
|
||||
Math.round(2.5 * SAMPLE_RATE) - (out.length - 1) + i;
|
||||
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||
}
|
||||
} finally {
|
||||
reader.stop();
|
||||
await Bun.$`rm -f ${wav}`.quiet();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(!hasFfmpeg)(
|
||||
"read() clamps to the nearest samples when the target is beyond the head",
|
||||
async () => {
|
||||
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
|
||||
writeSineWav(wav, 20);
|
||||
const reader = new AudioStreamReader({ url: wav });
|
||||
try {
|
||||
reader.start(0, 1);
|
||||
await waitForHead(reader, Math.round(0.3 * SAMPLE_RATE));
|
||||
|
||||
// Target far beyond the decode head: serve the newest available
|
||||
// window (real sine samples, never zeros or garbage).
|
||||
const out = new Float64Array(512);
|
||||
expect(reader.read(out, 999)).toBe(512);
|
||||
const maxAbs = Math.max(...Array.from(out, Math.abs));
|
||||
expect(maxAbs).toBeGreaterThan(10000);
|
||||
for (const v of out) {
|
||||
expect(Math.abs(v)).toBeLessThanOrEqual(AMP + 1);
|
||||
}
|
||||
} finally {
|
||||
reader.stop();
|
||||
await Bun.$`rm -f ${wav}`.quiet();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(!hasFfmpeg)(
|
||||
"sustained render loop: ffmpeg stays alive and decode head maintains a lead over the player",
|
||||
async () => {
|
||||
// Real wall-clock time is required here: this test validates ffmpeg's
|
||||
// actual decode pacing (-readrate + -readrate_initial_burst) against
|
||||
// the platform clock. Deterministic time control cannot reproduce the
|
||||
// race where ffmpeg exits early and the bars freeze — that only
|
||||
// surfaces when a real process writes to a real pipe.
|
||||
//
|
||||
// Simulates the actual render loop: for ~5s of wall time, advance a
|
||||
// simulated player position at 1× realtime and call read() each frame.
|
||||
// The decode head must stay ahead of the player position so read()
|
||||
// always returns 512 samples, and ffmpeg must not exit early (which
|
||||
// would freeze the bars). This test would have caught the
|
||||
// backpressure-pacing failure where ffmpeg decoded all data into the
|
||||
// pipe buffer instantly, exited, and the readLoop stopped.
|
||||
const wav = join(
|
||||
tmpdir(),
|
||||
`podtui-reader-${process.pid}-${Date.now()}.wav`,
|
||||
);
|
||||
writeSineWav(wav, 30);
|
||||
const reader = new AudioStreamReader({ url: wav });
|
||||
try {
|
||||
reader.start(0, 1);
|
||||
|
||||
const FRAME_MS = 33;
|
||||
const DURATION_MS = 5000;
|
||||
const out = new Float64Array(512);
|
||||
let successes = 0;
|
||||
let failures = 0;
|
||||
let minLead = Infinity;
|
||||
|
||||
const start = Date.now();
|
||||
for (let frame = 0; Date.now() - start < DURATION_MS; frame++) {
|
||||
const playerPos = (Date.now() - start) / 1000;
|
||||
const count = reader.read(out, playerPos);
|
||||
if (count === 512) successes++;
|
||||
else failures++;
|
||||
|
||||
// The decode head should stay ahead of the player position.
|
||||
const headPos = reader.samplesWritten / SAMPLE_RATE;
|
||||
const lead = headPos - playerPos;
|
||||
if (frame > 3) minLead = Math.min(minLead, lead);
|
||||
|
||||
await Bun.sleep(FRAME_MS);
|
||||
}
|
||||
|
||||
// ffmpeg must still be running — it must not have exited early.
|
||||
expect(reader.running).toBe(true);
|
||||
|
||||
// The vast majority of frames should return a full window.
|
||||
// A few early failures during ffmpeg startup are acceptable.
|
||||
expect(failures).toBeLessThan(5);
|
||||
expect(successes).toBeGreaterThan(100);
|
||||
|
||||
// The decode head must maintain a positive lead over the player.
|
||||
// Without -readrate_initial_burst, the head would lag behind by
|
||||
// the ffmpeg startup latency and never catch up.
|
||||
expect(minLead).toBeGreaterThan(0);
|
||||
} finally {
|
||||
reader.stop();
|
||||
await Bun.$`rm -f ${wav}`.quiet();
|
||||
}
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
);
|
||||
77
tests/cover-art.test.ts
Normal file
77
tests/cover-art.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Cover-art disk-cache contract tests.
|
||||
*
|
||||
* fetchCoverArt downloads each cover ONCE into a persistent per-URL cache;
|
||||
* playback never waits on the network for art it has already fetched. Pins:
|
||||
*
|
||||
* 1. A fetch stores the bytes on disk and returns the cache path.
|
||||
* 2. A second fetch of the same URL returns the cached path WITHOUT hitting
|
||||
* the server again (request count stays 1).
|
||||
* 3. Concurrent fetches of the same URL share one download (single-flight).
|
||||
* 4. A failed fetch (404) resolves null instead of throwing.
|
||||
*
|
||||
* Served from a local Bun server — no external network dependence. Cache
|
||||
* entries created here are removed afterwards.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { unlinkSync } from "fs";
|
||||
import { cachedCoverPath, fetchCoverArt } from "../src/utils/cover-art";
|
||||
|
||||
const FAKE_JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, ...new Array(256).fill(7)]);
|
||||
|
||||
test("cover art is fetched once, cached on disk, and shared", async () => {
|
||||
let requests = 0;
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
requests++;
|
||||
if (new URL(req.url).pathname === "/missing.jpg") {
|
||||
return new Response("nope", { status: 404 });
|
||||
}
|
||||
return new Response(FAKE_JPEG, {
|
||||
headers: { "content-type": "image/jpeg" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const url = `http://127.0.0.1:${server.port}/cover.jpg`;
|
||||
const missing = `http://127.0.0.1:${server.port}/missing.jpg`;
|
||||
let cachedPath: string | null = null;
|
||||
try {
|
||||
expect(cachedCoverPath(url)).toBeNull();
|
||||
|
||||
// First fetch: downloads and caches.
|
||||
cachedPath = await fetchCoverArt(url);
|
||||
expect(cachedPath).not.toBeNull();
|
||||
expect(requests).toBe(1);
|
||||
expect(Bun.file(cachedPath!).size).toBe(FAKE_JPEG.byteLength);
|
||||
|
||||
// Second fetch: disk hit, server untouched.
|
||||
expect(await fetchCoverArt(url)).toBe(cachedPath);
|
||||
expect(requests).toBe(1);
|
||||
|
||||
// Single-flight: parallel misses of a fresh URL make ONE request.
|
||||
const shared = `http://127.0.0.1:${server.port}/shared.jpg`;
|
||||
const [a, b, c] = await Promise.all([
|
||||
fetchCoverArt(shared),
|
||||
fetchCoverArt(shared),
|
||||
fetchCoverArt(shared),
|
||||
]);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a).toBe(b);
|
||||
expect(b).toBe(c);
|
||||
if (a) unlinkSync(a);
|
||||
|
||||
// 404 resolves null, never throws.
|
||||
expect(await fetchCoverArt(missing)).toBeNull();
|
||||
} finally {
|
||||
server.stop(true);
|
||||
if (cachedPath) {
|
||||
try {
|
||||
unlinkSync(cachedPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -171,6 +171,20 @@ test("dispatch('move-up') emits nav.action on the current pane only (j/k never c
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch('subscribe') on a depth-tab current pane emits nav.action (page-local, like unsubscribe)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
nav.setActiveTab(TABS.SEARCH);
|
||||
nav.enterTabContent();
|
||||
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||
|
||||
const events = captureNavActions(() => dispatch("subscribe"));
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].action).toBe("subscribe");
|
||||
expect(events[0].tab).toBe(TABS.SEARCH);
|
||||
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration: l drills (open emit), h pops, h@0 → tab root ────────────────
|
||||
test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => {
|
||||
withHarness(({ nav, dispatch }) => {
|
||||
|
||||
182
tests/download-unsubscribed.test.ts
Normal file
182
tests/download-unsubscribed.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Unsubscribed-show download tests — the download store contract behind the
|
||||
* "Unsubscribed Show Downloads" list (My Shows depth 0 and the settings
|
||||
* Download Manager):
|
||||
*
|
||||
* 1. startUnsubscribedDownload records the episode under a deterministic
|
||||
* synthetic feed id with the show's metadata, and
|
||||
* getUnsubscribedDownloads lists it.
|
||||
* 2. A download made under a real (subscribed) feed id is NOT listed as
|
||||
* unsubscribed.
|
||||
* 3. Subscribing to the show re-classifies its unsubscribed download into
|
||||
* the subscribed group — it drops out of getUnsubscribedDownloads.
|
||||
* 4. removeDownloadsForFeed with the show's feed URL removes that show's
|
||||
* unsubscribed downloads too (unsubscribing purges search downloads).
|
||||
*
|
||||
* Served over a real local HTTP server, mirroring how the app's other store
|
||||
* tests exercise the network path. The store singleton is shared with other
|
||||
* test files, so every added feed/download is removed in afterAll.
|
||||
*/
|
||||
|
||||
import { test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
// Point the config/data dirs at throwaway directories BEFORE importing the
|
||||
// stores (their module-level init reads them).
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-unsubdl-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
const dataHome = mkdtempSync(join(tmpdir(), "podtui-unsubdl-data-"));
|
||||
process.env.XDG_DATA_HOME = dataHome;
|
||||
|
||||
import { useDownloadStore } from "../src/stores/download";
|
||||
import { useFeedStore } from "../src/stores/feed";
|
||||
import type { Episode } from "../src/types/episode";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let audioUrl = "";
|
||||
const addedFeedIds: string[] = [];
|
||||
const addedEpisodeIds: string[] = [];
|
||||
|
||||
/** Minimal RSS feed for one show. */
|
||||
function feedXml(title: string, origin: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>${title}</title>
|
||||
<description>Test feed</description>
|
||||
<item>
|
||||
<title>Ep 1</title>
|
||||
<pubDate>2026-08-01T00:00:00Z</pubDate>
|
||||
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
const makeEpisode = (id: string, title: string): Episode => ({
|
||||
id,
|
||||
podcastId: "pod",
|
||||
title,
|
||||
description: "",
|
||||
audioUrl,
|
||||
duration: 0,
|
||||
pubDate: new Date("2026-08-01T00:00:00Z"),
|
||||
});
|
||||
|
||||
const makePodcast = (feedUrl: string, title: string): Podcast => ({
|
||||
id: `dir-${title}`,
|
||||
title,
|
||||
description: "Test feed",
|
||||
author: "tester",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml("Test Show", url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
}
|
||||
return new Response("audio bytes", {
|
||||
headers: { "Content-Type": "audio/mpeg" },
|
||||
});
|
||||
},
|
||||
});
|
||||
audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const id of addedEpisodeIds) {
|
||||
const dl = useDownloadStore();
|
||||
dl.cancelDownload(id);
|
||||
dl.removeDownload(id).catch(() => {});
|
||||
}
|
||||
for (const id of addedFeedIds) {
|
||||
useFeedStore().removeFeed(id);
|
||||
}
|
||||
server?.stop(true);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
rmSync(dataHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("startUnsubscribedDownload records a synthetic-feed download with show metadata", () => {
|
||||
const dl = useDownloadStore();
|
||||
const episode = makeEpisode("unsub-ep-1", "Ep 1");
|
||||
const podcast = makePodcast("https://example.com/feed.xml", "Unsub Show");
|
||||
addedEpisodeIds.push(episode.id);
|
||||
|
||||
dl.startUnsubscribedDownload(episode, podcast);
|
||||
|
||||
const listed = dl.getUnsubscribedDownloads();
|
||||
const mine = listed.find((d) => d.episodeId === episode.id);
|
||||
expect(mine).toBeDefined();
|
||||
expect(mine!.feedId).toBe("unsub-https-example-com-feed-xml");
|
||||
expect(mine!.podcastTitle).toBe("Unsub Show");
|
||||
expect(mine!.podcastFeedUrl).toBe("https://example.com/feed.xml");
|
||||
expect(mine!.episodeTitle).toBe("Ep 1");
|
||||
});
|
||||
|
||||
test("downloads under a real feed id are not listed as unsubscribed", async () => {
|
||||
const feedStore = useFeedStore();
|
||||
const dl = useDownloadStore();
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/subbed.xml`;
|
||||
const feed = await feedStore.addFeed(makePodcast(feedUrl, "Subbed"), "test");
|
||||
expect(feed).not.toBeNull();
|
||||
addedFeedIds.push(feed!.id);
|
||||
|
||||
const episode = makeEpisode("subbed-ep-1", "Ep 1");
|
||||
addedEpisodeIds.push(episode.id);
|
||||
dl.startDownload(episode, feed!.id);
|
||||
|
||||
expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("subscribing to the show re-classifies its unsubscribed download", async () => {
|
||||
const feedStore = useFeedStore();
|
||||
const dl = useDownloadStore();
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/later.xml`;
|
||||
const episode = makeEpisode("unsub-ep-later", "Ep 1");
|
||||
const podcast = makePodcast(feedUrl, "Later Show");
|
||||
addedEpisodeIds.push(episode.id);
|
||||
|
||||
// Downloaded while unsubscribed.
|
||||
dl.startUnsubscribedDownload(episode, podcast);
|
||||
expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// Subscribing later (same feed URL) moves it into the subscribed group.
|
||||
const feed = await feedStore.addFeed(podcast, "test");
|
||||
expect(feed).not.toBeNull();
|
||||
addedFeedIds.push(feed!.id);
|
||||
expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("removeDownloadsForFeed purges the show's unsubscribed downloads by feed URL", async () => {
|
||||
const feedStore = useFeedStore();
|
||||
const dl = useDownloadStore();
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/purge.xml`;
|
||||
const episode = makeEpisode("unsub-ep-purge", "Ep 1");
|
||||
addedEpisodeIds.push(episode.id);
|
||||
|
||||
dl.startUnsubscribedDownload(episode, makePodcast(feedUrl, "Purge Show"));
|
||||
expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// Unsubscribe the show: the feed is gone, but its URL still identifies
|
||||
// the search downloads made while it was unsubscribed.
|
||||
await dl.removeDownloadsForFeed("no-such-feed-id", feedUrl);
|
||||
expect(dl.getAllDownloads().some((d) => d.episodeId === episode.id)).toBe(false);
|
||||
});
|
||||
264
tests/external-pause-reconcile.test.ts
Normal file
264
tests/external-pause-reconcile.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* external-pause-reconcile.test.ts — "audio paused outside PodTUI must not
|
||||
* freeze the player tab" regression test.
|
||||
*
|
||||
* The OS can pause the player without PodTUI knowing: system sleep/lock,
|
||||
* AirPod removal / device swap, OS media keys, the Now Playing center. mpv
|
||||
* flips its own `pause` property and keeps it there. Before the fix,
|
||||
* useAudio's signals stayed on "playing" — [Pause] button shown while
|
||||
* silent, a poll that only re-read the same frozen `time-pos` (stuck
|
||||
* waveform), and no way to catch an external RESUME either (the poll was
|
||||
* stopped whenever the UI thought it was paused).
|
||||
*
|
||||
* Integration style (like restore-session.test.ts): real stores and real
|
||||
* persistence files in a temp XDG_CONFIG_HOME — but with the REAL mpv
|
||||
* backend driven over its actual IPC socket. The test flips mpv's pause
|
||||
* property the same way the OS does and asserts useAudio reconciles in
|
||||
* both directions. Skipped when mpv isn't installed.
|
||||
*
|
||||
* Real-timer note: the reconcile path runs on useAudio's real 150ms poll
|
||||
* interval against a real mpv process, with no injectable clock — the
|
||||
* deliberate-exception case from the no-real-timers rule (same as
|
||||
* visualizer-store.test.ts). `waitFor` polls with Bun.sleep.
|
||||
*
|
||||
* Shared-worker note (same as restore-session.test.ts): the suite reuses
|
||||
* bun test workers, so other files' `mock.module("../src/hooks/useAudio")`
|
||||
* leaks into this file's module registry. The REAL useAudio is therefore
|
||||
* imported via a `?external-pause-test` query suffix — a distinct module
|
||||
* identity bun loads from disk, bypassing the leaked mock.
|
||||
*/
|
||||
import { test, expect, afterAll } from "bun:test";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const hasMpv = !!Bun.which("mpv");
|
||||
|
||||
// ── Sandbox BEFORE any app module evaluates (mirrors restore-session) ────
|
||||
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-extpause-"));
|
||||
process.env.XDG_CONFIG_HOME = CONFIG;
|
||||
process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-extpause-data-"));
|
||||
process.env.PODTUI_AUDIO_BACKEND = "mpv"; // real backend; the test drives mpv's IPC
|
||||
const APP_CONFIG = join(CONFIG, "podtui");
|
||||
mkdirSync(APP_CONFIG, { recursive: true });
|
||||
|
||||
// Seed one feed so the app store boots cleanly. No coverUrl — the play()
|
||||
// path skips cover-art fetching. The RSS URL is unreachable so the
|
||||
// background refresh fails fast and leaves the seeded data untouched.
|
||||
const ISO = "2026-08-10T00:00:00.000Z";
|
||||
const feed = {
|
||||
id: "feed1",
|
||||
podcast: {
|
||||
id: "pod1",
|
||||
title: "Pod One",
|
||||
description: "",
|
||||
feedUrl: "http://127.0.0.1:1/show.xml",
|
||||
lastUpdated: ISO,
|
||||
isSubscribed: true,
|
||||
},
|
||||
episodes: [],
|
||||
visibility: "public",
|
||||
sourceId: "test",
|
||||
lastUpdated: ISO,
|
||||
isPinned: false,
|
||||
};
|
||||
|
||||
await Bun.write(
|
||||
join(APP_CONFIG, "config.json"),
|
||||
JSON.stringify({ feeds: [feed] }, null, 2),
|
||||
);
|
||||
|
||||
// ── Local 60s WAV so playback is hermetic (no network, no early EOF) ─────
|
||||
const wavPath = join(tmpdir(), `podtui-extpause-${process.pid}.wav`);
|
||||
{
|
||||
const SAMPLE_RATE = 44100;
|
||||
const DURATION = 60;
|
||||
const dataLen = SAMPLE_RATE * DURATION; // mono 16-bit
|
||||
const buf = Buffer.alloc(44 + dataLen * 2);
|
||||
buf.write("RIFF", 0);
|
||||
buf.writeUInt32LE(36 + dataLen * 2, 4);
|
||||
buf.write("WAVE", 8);
|
||||
buf.write("fmt ", 12);
|
||||
buf.writeUInt32LE(16, 16); // fmt chunk size
|
||||
buf.writeUInt16LE(1, 20); // PCM
|
||||
buf.writeUInt16LE(1, 22); // mono
|
||||
buf.writeUInt32LE(SAMPLE_RATE, 24);
|
||||
buf.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate
|
||||
buf.writeUInt16LE(2, 32); // block align
|
||||
buf.writeUInt16LE(16, 34); // bits per sample
|
||||
buf.write("data", 36);
|
||||
buf.writeUInt32LE(dataLen * 2, 40);
|
||||
for (let i = 0; i < dataLen; i++) {
|
||||
const sample = Math.round(
|
||||
Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE) * 8000,
|
||||
);
|
||||
buf.writeInt16LE(sample, 44 + i * 2);
|
||||
}
|
||||
writeFileSync(wavPath, buf);
|
||||
}
|
||||
|
||||
// ── Real modules (loaded after env + sandbox are set up) ──────────────────
|
||||
// @ts-expect-error — bun-only query suffix: distinct module identity that
|
||||
// loads the real file instead of a leaked mock.module from another test file.
|
||||
const { useAudio } = await import("../src/hooks/useAudio?external-pause-test");
|
||||
|
||||
/**
|
||||
* The socket path of the LIVE backend daemon in this process. The backend
|
||||
* names sockets per-instance (`podtui-mpv-<pid>-<instance>.sock`), so scan
|
||||
* tmpdir for this pid's sockets and take the newest (the one mpv actually
|
||||
* bound — earlier instances may have been orphaned by a re-spawn).
|
||||
*/
|
||||
function mpvSocket(): string | null {
|
||||
let newest: string | null = null;
|
||||
let newestMtime = 0;
|
||||
for (const name of readdirSync(tmpdir())) {
|
||||
if (
|
||||
!name.startsWith(`podtui-mpv-${process.pid}-`) ||
|
||||
!name.endsWith(".sock")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const candidate = join(tmpdir(), name);
|
||||
const mtime = statSync(candidate).mtimeMs;
|
||||
if (mtime > newestMtime) {
|
||||
newest = candidate;
|
||||
newestMtime = mtime;
|
||||
}
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a raw mpv IPC command over the unix socket — exactly how the OS
|
||||
* media session pauses/resumes mpv without PodTUI's involvement.
|
||||
*/
|
||||
async function mpvCommand(command: unknown[]): Promise<void> {
|
||||
const socket = mpvSocket();
|
||||
if (!socket) throw new Error("backend mpv socket not found");
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
let settled = false;
|
||||
const settle = (err: Error | null): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
Bun.connect({
|
||||
unix: socket,
|
||||
socket: {
|
||||
open(s) {
|
||||
s.write(JSON.stringify({ command }) + "\n");
|
||||
},
|
||||
data() {},
|
||||
error() {
|
||||
settle(new Error("mpv IPC connect failed"));
|
||||
},
|
||||
close() {
|
||||
settle(null);
|
||||
},
|
||||
},
|
||||
}).then((s) =>
|
||||
setTimeout(() => {
|
||||
try {
|
||||
s.end();
|
||||
} catch {}
|
||||
}, 150),
|
||||
);
|
||||
// Never hang the test on a vanished socket.
|
||||
setTimeout(() => settle(null), 1000);
|
||||
await promise;
|
||||
}
|
||||
|
||||
/** Poll `check` every 25ms until truthy; throw after `timeoutMs`. */
|
||||
async function waitFor(
|
||||
check: () => boolean,
|
||||
timeoutMs = 10000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!check()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("condition not met in time");
|
||||
}
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
}
|
||||
|
||||
const episode = {
|
||||
id: "ep1",
|
||||
podcastId: "pod1",
|
||||
title: "Episode One",
|
||||
description: "desc",
|
||||
audioUrl: wavPath,
|
||||
duration: 60,
|
||||
pubDate: new Date(),
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
test.skipIf(!hasMpv)(
|
||||
"external pause flips the UI to paused; external resume recovers",
|
||||
async () => {
|
||||
const audio = useAudio();
|
||||
await audio.play(episode);
|
||||
expect(audio.isPlaying()).toBe(true);
|
||||
|
||||
// Simulate the OS pausing the session (lock/sleep, AirPod removal,
|
||||
// device swap, media-center pause): flip mpv's own pause property
|
||||
// over IPC. PodTUI is never told.
|
||||
await mpvCommand(["set_property", "pause", true]);
|
||||
await waitFor(() => !audio.isPlaying());
|
||||
expect(audio.isPlaying()).toBe(false);
|
||||
// The episode stays loaded — nothing was torn down.
|
||||
expect(audio.currentEpisode()?.id).toBe("ep1");
|
||||
|
||||
// Simulate an external resume (AirPod play tap, media-center play).
|
||||
await mpvCommand(["set_property", "pause", false]);
|
||||
await waitFor(() => audio.isPlaying());
|
||||
expect(audio.isPlaying()).toBe(true);
|
||||
|
||||
// The TUI transport still works from the reconciled state.
|
||||
await audio.togglePlayback();
|
||||
expect(audio.isPlaying()).toBe(false);
|
||||
await audio.togglePlayback();
|
||||
expect(audio.isPlaying()).toBe(true);
|
||||
|
||||
await audio.stop();
|
||||
expect(audio.isPlaying()).toBe(false);
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
// ── Teardown ──────────────────────────────────────────────────────────────
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
useAudio().stop();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
// The resident daemon survives stop() by design — quit it so test
|
||||
// workers don't leak idle mpv processes.
|
||||
try {
|
||||
await mpvCommand(["quit"]);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
try {
|
||||
const socket = mpvSocket();
|
||||
if (socket) rmSync(socket, { force: true });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
try {
|
||||
rmSync(wavPath, { force: true });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
});
|
||||
@@ -37,6 +37,8 @@ interface ServedEpisode {
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let servedEpisodes: ServedEpisode[] = [];
|
||||
let feedAId = "";
|
||||
/** When set, the server 503s this path — simulates a feed going down. */
|
||||
let failPath: string | null = null;
|
||||
|
||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||
@@ -72,6 +74,9 @@ beforeAll(() => {
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (failPath && url.pathname === failPath) {
|
||||
return new Response("feed unavailable", { status: 503 });
|
||||
}
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
@@ -130,6 +135,31 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => {
|
||||
expect(after.episodes.length).toBe(4);
|
||||
});
|
||||
|
||||
test("a failed refresh does not wipe the feed's episodes", async () => {
|
||||
const store = useFeedStore();
|
||||
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }];
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
const feedId = feed!.id;
|
||||
expect(store.getFeed(feedId)!.episodes.length).toBe(1);
|
||||
|
||||
// The feed now 503s. fetchEpisodes returns null, and both refresh paths
|
||||
// must leave the loaded episodes untouched — a failed refresh must never
|
||||
// look like an empty feed (which would wipe the show's episodes).
|
||||
failPath = "/flaky.xml";
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await store.refreshFeed(feedId);
|
||||
expect(store.getFeed(feedId)!.episodes.length).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await store.refreshAllFeeds();
|
||||
expect(store.getFeed(feedId)!.episodes.length).toBe(1);
|
||||
|
||||
failPath = null;
|
||||
store.removeFeed(feedId);
|
||||
});
|
||||
|
||||
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
||||
const store = useFeedStore();
|
||||
// Feed B: distinct URL, identical served content, so refreshing it is a
|
||||
|
||||
195
tests/restore-session.test.ts
Normal file
195
tests/restore-session.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* restore-session.test.ts — "load the last player session at boot" feature.
|
||||
*
|
||||
* useAudio persists which episode is loaded in the player (on play/load/stop
|
||||
* and synchronously at exit), and `restoreLastSession()` reloads it at boot
|
||||
* PAUSED at its saved position — never autostarted — skipping episodes at or
|
||||
* above 98% completion. The first play action must START the backend (a
|
||||
* restored episode was never handed to it), not unpause a dead player.
|
||||
*
|
||||
* Strategy — the suite shares bun test workers across files, so OTHER test
|
||||
* files' `mock.module("../src/hooks/useAudio")` leaks into this file's module
|
||||
* registry, and store singletons may already exist. Hence:
|
||||
* - the real useAudio is imported via a `?restore-test` query suffix, which
|
||||
* bun treats as a distinct module identity and loads from disk, bypassing
|
||||
* the suite's useAudio mock (verified: query-suffixed imports are not
|
||||
* intercepted by mock.module);
|
||||
* - feeds are injected through the REAL feed store's public addFeed() API
|
||||
* against a local RSS server (no config seeding — works on whatever
|
||||
* singleton state this worker holds), and progress through update().
|
||||
* Audio is the no-op backend via PODTUI_AUDIO_BACKEND=none.
|
||||
*/
|
||||
import { test, expect, afterAll } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
|
||||
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-restore-"));
|
||||
const DATA = mkdtempSync(join(tmpdir(), "podtui-restore-data-"));
|
||||
process.env.XDG_CONFIG_HOME = CONFIG;
|
||||
process.env.XDG_DATA_HOME = DATA;
|
||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||
|
||||
// ── Local RSS feed server (episode ids = feedUrl#index) ────────────────────
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
function feedXml(origin: string): string {
|
||||
const items = ["Episode One", "Episode Two"]
|
||||
.map(
|
||||
(title) => `<item>
|
||||
<title>${title}</title>
|
||||
<pubDate>2026-08-10T00:00:00Z</pubDate>
|
||||
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Restore Test Show</title>
|
||||
<description>restore-session test feed</description>
|
||||
${items}
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml(url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
// ── Real modules (loaded after env + server are up) ───────────────────────
|
||||
// @ts-expect-error — bun-only query suffix: distinct module identity that
|
||||
// loads the real file instead of a leaked mock.module from another test file.
|
||||
const { useAudio, restoreLastSession } = await import("../src/hooks/useAudio?restore-test");
|
||||
const { useFeedStore } = await import("../src/stores/feed");
|
||||
const { useProgressStore } = await import("../src/stores/progress");
|
||||
const { saveLastPlayerToFile, waitForLastPlayerWrite } = await import(
|
||||
"../src/utils/app-persistence"
|
||||
);
|
||||
|
||||
const feedStore = useFeedStore();
|
||||
const progressStore = useProgressStore();
|
||||
|
||||
// Subscribe to the local feed through the real store API.
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
|
||||
const feed = await feedStore.addFeed(
|
||||
{
|
||||
id: feedUrl,
|
||||
title: "Restore Test Show",
|
||||
description: "restore-session test feed",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
"test-source",
|
||||
);
|
||||
if (!feed || feed.episodes.length < 2) {
|
||||
throw new Error("test feed did not load two episodes");
|
||||
}
|
||||
const ep1 = feed.episodes[0];
|
||||
const ep2 = feed.episodes[1];
|
||||
|
||||
// Simulate a previous session: ep1 loaded, 20% through, marker persisted.
|
||||
progressStore.update(ep1.id, 120, 600);
|
||||
saveLastPlayerToFile({ episodeId: ep1.id, timestamp: new Date() });
|
||||
await waitForLastPlayerWrite();
|
||||
|
||||
/** Rewrite the marker as if a previous session had ended this way. */
|
||||
async function writeMarker(episodeId: string | null): Promise<void> {
|
||||
saveLastPlayerToFile({ episodeId, timestamp: new Date() });
|
||||
await waitForLastPlayerWrite();
|
||||
}
|
||||
|
||||
/** Read the current last-player marker from disk. */
|
||||
async function readMarker(): Promise<{ episodeId: string | null } | null> {
|
||||
const file = Bun.file(join(CONFIG, "podtui", "last-player.json"));
|
||||
if (!(await file.exists())) return null;
|
||||
return (await file.json()) as { episodeId: string | null };
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
server?.stop(true);
|
||||
rmSync(CONFIG, { recursive: true, force: true });
|
||||
rmSync(DATA, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("restore loads the last player episode paused, without autostart", async () => {
|
||||
const audio = useAudio(); // boot trigger also fires restoreLastSession()
|
||||
await restoreLastSession();
|
||||
|
||||
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
||||
expect(audio.isPlaying()).toBe(false);
|
||||
// Position reflects where playback will resume — the player tab shows it.
|
||||
expect(audio.position()).toBe(120);
|
||||
});
|
||||
|
||||
test("first play on a restored episode starts playback from saved progress", async () => {
|
||||
const audio = useAudio();
|
||||
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
||||
|
||||
await audio.togglePlayback();
|
||||
|
||||
expect(audio.isPlaying()).toBe(true);
|
||||
expect(audio.position()).toBe(120);
|
||||
|
||||
// A second toggle pauses (normal pause path) — no backend restart.
|
||||
await audio.togglePlayback();
|
||||
expect(audio.isPlaying()).toBe(false);
|
||||
});
|
||||
|
||||
test("restore skips episodes at or above 98% completion", async () => {
|
||||
const audio = useAudio();
|
||||
await audio.stop(); // clear the previously restored episode
|
||||
await waitForLastPlayerWrite(); // stop()'s null marker has landed
|
||||
await writeMarker(ep1.id); // stop() cleared the marker; restore needs it
|
||||
|
||||
// Exactly 98% — boundary: NOT restored.
|
||||
progressStore.update(ep1.id, 588, 600);
|
||||
await restoreLastSession();
|
||||
expect(audio.currentEpisode()).toBeNull();
|
||||
|
||||
// Just under 98% — restored.
|
||||
progressStore.update(ep1.id, 587, 600);
|
||||
await restoreLastSession();
|
||||
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
||||
expect(audio.position()).toBe(587);
|
||||
|
||||
await audio.stop();
|
||||
});
|
||||
|
||||
test("restore no-ops when the player was empty at last quit", async () => {
|
||||
const audio = useAudio();
|
||||
await writeMarker(null);
|
||||
|
||||
await restoreLastSession();
|
||||
expect(audio.currentEpisode()).toBeNull();
|
||||
});
|
||||
|
||||
test("restore no-ops when the episode is no longer in any feed", async () => {
|
||||
const audio = useAudio();
|
||||
await writeMarker("gone-ep");
|
||||
|
||||
await restoreLastSession();
|
||||
expect(audio.currentEpisode()).toBeNull();
|
||||
});
|
||||
|
||||
test("play persists the marker; stop clears it", async () => {
|
||||
const audio = useAudio();
|
||||
|
||||
await audio.play(ep2);
|
||||
await waitForLastPlayerWrite();
|
||||
expect((await readMarker())?.episodeId).toBe(ep2.id);
|
||||
|
||||
await audio.stop();
|
||||
await waitForLastPlayerWrite();
|
||||
expect((await readMarker())?.episodeId).toBeNull();
|
||||
});
|
||||
344
tests/search-episode-actions.test.tsx
Normal file
344
tests/search-episode-actions.test.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* search-episode-actions.test.tsx — episode search results must stream
|
||||
* directly, subscribed or not, and `a` must subscribe an unsubscribed show
|
||||
* in place.
|
||||
*
|
||||
* Regression: `enter` on an unsubscribed show's episode result used to
|
||||
* subscribe instead of play — there was no direct "stream unsubscribed
|
||||
* episode" path (subscribing first was the only way to hear it). `enter` now
|
||||
* plays every episode result (matching Feed/My Shows), and the new `subscribe`
|
||||
* action (`a`, sibling of `x` unsubscribe) subscribes the focused result.
|
||||
*
|
||||
* Mounts the real app (sandboxed, silent audio, mocked search store) and
|
||||
* drives the Search tab with the test renderer's mock keys: enter on an
|
||||
* unsubscribed episode plays it without subscribing; `a` subscribes (feed
|
||||
* fetched from a local server); enter then plays the now-subscribed episode.
|
||||
* The search store is mocked so results are deterministic (no directory
|
||||
* network calls); the feed store is real and served from a local HTTP server.
|
||||
*
|
||||
* App modules are loaded dynamically (never statically) because the sandbox
|
||||
* config/data dirs must be set BEFORE they evaluate — their module-level init
|
||||
* reads those env vars at import time.
|
||||
*/
|
||||
|
||||
import { test, expect, afterAll, beforeAll, mock } from "bun:test";
|
||||
import type { Server } from "bun";
|
||||
import { testRender } from "@opentui/solid";
|
||||
import { createSignal } from "solid-js";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { AudioControls } from "../src/hooks/useAudio";
|
||||
import type { SearchResult, SearchScope } from "../src/types/source";
|
||||
import type { Episode } from "../src/types/episode";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
import type { DepthFrame, NavigationState } from "../src/context/navigation-store";
|
||||
|
||||
// Recording audio stub: `play` pushes what was streamed. Registered FIRST so
|
||||
// a leaked partial useAudio mock from another file in this worker can't break
|
||||
// the app mount (see tests/search-focus.test.tsx for the same hazard).
|
||||
const played: Episode[] = [];
|
||||
const stubAudio: AudioControls = {
|
||||
isPlaying: () => false,
|
||||
position: () => 0,
|
||||
duration: () => 0,
|
||||
volume: () => 1,
|
||||
speed: () => 1,
|
||||
backendName: () => "none",
|
||||
error: () => null,
|
||||
currentEpisode: () => null,
|
||||
availablePlayers: () => [],
|
||||
play: async (episode: Episode) => {
|
||||
played.push(episode);
|
||||
},
|
||||
load: async () => {},
|
||||
pause: async () => {},
|
||||
resume: async () => {},
|
||||
togglePlayback: async () => {},
|
||||
stop: async () => {},
|
||||
seek: async () => {},
|
||||
seekRelative: async () => {},
|
||||
setVolume: async () => {},
|
||||
setSpeed: async () => {},
|
||||
switchBackend: async () => {},
|
||||
prev: async () => {},
|
||||
next: async () => {},
|
||||
};
|
||||
mock.module("../src/hooks/useAudio", () => ({
|
||||
useAudio: () => stubAudio,
|
||||
}));
|
||||
|
||||
// Deterministic search store: `search` feeds the seeded results synchronously;
|
||||
// markSubscribed/markUnsubscribed flip the result's flag (SearchPage renders
|
||||
// it, and the real feed store still owns the actual subscription).
|
||||
const [scope, setScope] = createSignal<SearchScope>("episode");
|
||||
const [results, setResults] = createSignal<SearchResult[]>([]);
|
||||
const [query, setQuery] = createSignal("");
|
||||
const [isSearching, setIsSearching] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [history, setHistory] = createSignal<string[]>([]);
|
||||
const flip = (id: string, feedUrl: string | undefined, subscribed: boolean) =>
|
||||
setResults((prev) =>
|
||||
prev.map((r) =>
|
||||
r.podcast.id === id ||
|
||||
(feedUrl && r.podcast.feedUrl === feedUrl)
|
||||
? { ...r, podcast: { ...r.podcast, isSubscribed: subscribed } }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
const mockSearchStore = {
|
||||
query,
|
||||
isSearching,
|
||||
results,
|
||||
error,
|
||||
history,
|
||||
selectedSources: () => [] as string[],
|
||||
scope,
|
||||
search: async () => {},
|
||||
setQuery,
|
||||
clearResults: () => setResults([]),
|
||||
clearHistory: () => setHistory([]),
|
||||
removeFromHistory: () => {},
|
||||
setSelectedSources: () => {},
|
||||
setScope,
|
||||
markSubscribed: (id: string, feedUrl?: string) => flip(id, feedUrl, true),
|
||||
markUnsubscribed: (id: string, feedUrl?: string) => flip(id, feedUrl, false),
|
||||
};
|
||||
mock.module("../src/stores/search", () => ({
|
||||
useSearchStore: () => mockSearchStore,
|
||||
}));
|
||||
|
||||
// Sandbox BEFORE any app module evaluates — config-dir/persistence read these
|
||||
// env vars at import time, so the app modules are loaded dynamically.
|
||||
const SANDBOX = join(process.cwd(), ".harness", "test-episode-actions");
|
||||
mkdirSync(join(SANDBOX, "config-home"), { recursive: true });
|
||||
mkdirSync(join(SANDBOX, "data-home"), { recursive: true });
|
||||
process.env.XDG_CONFIG_HOME = join(SANDBOX, "config-home");
|
||||
process.env.XDG_DATA_HOME = join(SANDBOX, "data-home");
|
||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||
|
||||
const { App } = await import("../src/App");
|
||||
const { ThemeProvider } = await import("../src/context/ThemeContext");
|
||||
const toast = await import("../src/ui/toast");
|
||||
const { KeybindProvider, useKeybinds } = await import(
|
||||
"../src/context/KeybindContext"
|
||||
);
|
||||
const { NavigationProvider, useNavigation } = await import(
|
||||
"../src/context/NavigationContext"
|
||||
);
|
||||
const { DialogProvider } = await import("../src/ui/dialog");
|
||||
const { CommandProvider } = await import("../src/ui/command");
|
||||
const { TABS } = await import("../src/utils/navigation");
|
||||
const { useFeedStore } = await import("../src/stores/feed");
|
||||
|
||||
// Local HTTP server serving one show's RSS feed (the feed store fetches it
|
||||
// when subscribing).
|
||||
let server: Server<unknown> | null = null;
|
||||
let feedUrl = "";
|
||||
let audioUrl = "";
|
||||
|
||||
function feedXml(origin: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Stream Me Show</title>
|
||||
<description>Test feed</description>
|
||||
<item>
|
||||
<title>Ep 1</title>
|
||||
<pubDate>2026-08-01T00:00:00Z</pubDate>
|
||||
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
function makeResult(): SearchResult {
|
||||
const episode: Episode = {
|
||||
id: "stream-ep-1",
|
||||
podcastId: "dir-stream-me",
|
||||
title: "Ep 1",
|
||||
description: "",
|
||||
audioUrl,
|
||||
duration: 0,
|
||||
pubDate: new Date("2026-08-01T00:00:00Z"),
|
||||
};
|
||||
const podcast: Podcast = {
|
||||
id: "dir-stream-me",
|
||||
title: "Stream Me Show",
|
||||
description: "Test feed",
|
||||
author: "tester",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
};
|
||||
return {
|
||||
kind: "episode",
|
||||
sourceId: "test",
|
||||
sourceName: "Test",
|
||||
podcast,
|
||||
episode,
|
||||
};
|
||||
}
|
||||
|
||||
type MockInput = { pressKey: (key: string) => void; pressEnter: () => void };
|
||||
type Mounted = {
|
||||
renderer: { destroy: () => void };
|
||||
renderOnce: () => Promise<void>;
|
||||
mockInput: MockInput;
|
||||
nav: () => NavigationState;
|
||||
keybindsReady: () => boolean;
|
||||
};
|
||||
|
||||
async function mountApp(): Promise<Mounted> {
|
||||
let navRef: NavigationState | null = null;
|
||||
let keybindsRef: { ready: boolean } | null = null;
|
||||
const StateProbe = () => {
|
||||
navRef = useNavigation();
|
||||
keybindsRef = useKeybinds();
|
||||
return null;
|
||||
};
|
||||
const HarnessRoot = () => (
|
||||
<toast.ToastProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
<KeybindProvider>
|
||||
<NavigationProvider>
|
||||
<StateProbe />
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<App />
|
||||
<toast.Toast />
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</NavigationProvider>
|
||||
</KeybindProvider>
|
||||
</ThemeProvider>
|
||||
</toast.ToastProvider>
|
||||
);
|
||||
const setup = await testRender(() => <HarnessRoot />, {
|
||||
width: 100,
|
||||
height: 30,
|
||||
useThread: false,
|
||||
});
|
||||
// The test renderer intercepts stdout; the app is a TUI that writes frames
|
||||
// asynchronously, so silence that interception (same as search-focus).
|
||||
(
|
||||
setup.renderer as unknown as {
|
||||
disableStdoutInterception?: () => void;
|
||||
}
|
||||
).disableStdoutInterception?.();
|
||||
await setup.renderOnce();
|
||||
await sleep(60);
|
||||
return {
|
||||
renderer: setup.renderer,
|
||||
renderOnce: setup.renderOnce,
|
||||
mockInput: setup.mockInput,
|
||||
nav: () => navRef!,
|
||||
keybindsReady: () => keybindsRef?.ready ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
setTimeout(resolve, ms);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function settleReady(m: Mounted): Promise<void> {
|
||||
for (let i = 0; i < 80; i++) {
|
||||
await m.renderOnce();
|
||||
await sleep(60);
|
||||
if (m.keybindsReady()) return;
|
||||
}
|
||||
throw new Error("keybinds never became ready");
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
m: Mounted,
|
||||
cond: () => boolean,
|
||||
what: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (cond()) return;
|
||||
await m.renderOnce();
|
||||
await sleep(25);
|
||||
}
|
||||
throw new Error(`timed out waiting for: ${what}`);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml(url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
}
|
||||
return new Response("audio bytes", {
|
||||
headers: { "Content-Type": "audio/mpeg" },
|
||||
});
|
||||
},
|
||||
});
|
||||
feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
|
||||
audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server?.stop(true);
|
||||
rmSync(SANDBOX, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("enter streams an unsubscribed show's episode; a subscribes it in place; enter then still plays", async () => {
|
||||
const m = await mountApp();
|
||||
try {
|
||||
await settleReady(m);
|
||||
|
||||
// Open the Search tab (digit press retried until the router attaches).
|
||||
for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.SEARCH; i++) {
|
||||
m.mockInput.pressKey("4");
|
||||
await m.renderOnce();
|
||||
await sleep(40);
|
||||
}
|
||||
expect(m.nav().activeTab()).toBe(TABS.SEARCH);
|
||||
m.mockInput.pressEnter(); // open the tab's content (query depth)
|
||||
await waitFor(m, () => m.nav().inputFocused(), "search tab mounted");
|
||||
|
||||
// Seed one unsubscribed-show episode result and drill to results
|
||||
// (mirrors SearchPage.runSearch: set results, push the frame).
|
||||
setResults([makeResult()]);
|
||||
const frame: DepthFrame = { kind: "search:results", ctx: "test", focus: 0 };
|
||||
m.nav().pushDepth(frame);
|
||||
m.nav().setActivePane(1);
|
||||
await waitFor(m, () => m.nav().currentDepth() === 1, "results depth");
|
||||
|
||||
// enter → the episode streams; the show is NOT subscribed.
|
||||
m.mockInput.pressEnter();
|
||||
await waitFor(m, () => played.length === 1, "play called on enter");
|
||||
expect(played[0].id).toBe("stream-ep-1");
|
||||
expect(
|
||||
useFeedStore()
|
||||
.feeds()
|
||||
.some((f) => f.podcast.feedUrl === feedUrl),
|
||||
).toBe(false);
|
||||
expect(mockSearchStore.results()[0].podcast.isSubscribed).toBe(false);
|
||||
|
||||
// a → subscribes in place (real feed fetch against the local server).
|
||||
m.mockInput.pressKey("a");
|
||||
await waitFor(
|
||||
m,
|
||||
() =>
|
||||
useFeedStore()
|
||||
.feeds()
|
||||
.some((f) => f.podcast.feedUrl === feedUrl),
|
||||
"a subscribes the show",
|
||||
);
|
||||
expect(mockSearchStore.results()[0].podcast.isSubscribed).toBe(true);
|
||||
|
||||
// enter again → still streams (now under the subscribed show).
|
||||
m.mockInput.pressEnter();
|
||||
await waitFor(m, () => played.length === 2, "play called after subscribe");
|
||||
expect(played[1].id).toBe("stream-ep-1");
|
||||
} finally {
|
||||
m.renderer.destroy();
|
||||
}
|
||||
});
|
||||
@@ -51,6 +51,7 @@ const stubAudio: AudioControls = {
|
||||
currentEpisode: () => null,
|
||||
availablePlayers: () => [],
|
||||
play: async () => {},
|
||||
load: async () => {},
|
||||
pause: async () => {},
|
||||
resume: async () => {},
|
||||
togglePlayback: async () => {},
|
||||
|
||||
247
tests/visualizer-store.test.ts
Normal file
247
tests/visualizer-store.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Visualizer store lifecycle tests — pin the waveform pipeline contract:
|
||||
*
|
||||
* - playback starts the pipeline and exposes a loading state until the
|
||||
* first FFT frame renders (the braille-spinner window);
|
||||
* - losing Player-tab focus does NOT kill a warm pipeline — it keeps
|
||||
* rendering for the grace period, and regaining focus within the delay
|
||||
* resumes it without a restart;
|
||||
* - after VISUALIZER_UNLOAD_DELAY_MS unfocused the pipeline tears down
|
||||
* (ffmpeg process + cava plan released).
|
||||
*
|
||||
* The store subscribes to the module-level signals in utils/audio-signals.ts
|
||||
* (no useAudio mock — the signals are exported and driven directly, so this
|
||||
* file can never leak a module mock into another test's worker).
|
||||
*
|
||||
* Uses a self-generated local WAV (a frequency chirp, so different playback
|
||||
* positions produce measurably different bar output) and the real ffmpeg +
|
||||
* native cavacore pipeline, mirroring audio-pcm-cache.test.ts.
|
||||
*
|
||||
* Timing note: this is an integration test of the store's real timers — the
|
||||
* unload path is a genuine `setTimeout` in the store, and bun 1.3.8 ships no
|
||||
* fake-timer API (no `mock.timer`, no `vi.useFakeTimers`), so the grace
|
||||
* period must be exercised against the platform clock. Delays are kept to
|
||||
* the minimum that observes the contract (see the 30s unload test).
|
||||
*/
|
||||
import { test, expect, afterAll } from "bun:test";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { setIsPlaying, setPosition, setCurrentEpisode } from "../src/utils/audio-signals";
|
||||
import { useAppStore } from "../src/stores/app";
|
||||
import type { Episode } from "../src/types/episode";
|
||||
|
||||
// ── Sandbox (the app store reads config from XDG_CONFIG_HOME at first
|
||||
// use; set before importing the store) ─────────────────────────────────
|
||||
|
||||
process.env.XDG_CONFIG_HOME = join(tmpdir(), `podtui-viz-test-${process.pid}`);
|
||||
process.env.XDG_DATA_HOME = join(tmpdir(), `podtui-viz-data-${process.pid}`);
|
||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||
|
||||
const { useVisualizer, VISUALIZER_UNLOAD_DELAY_MS } = await import(
|
||||
"../src/stores/visualizer"
|
||||
);
|
||||
|
||||
// ── Local chirp WAV (200Hz → 2kHz over 45s) ─────────────────────────────
|
||||
|
||||
const SAMPLE_RATE = 44100;
|
||||
const F0 = 200;
|
||||
const F1 = 2000;
|
||||
const DURATION = 45;
|
||||
const AMP = 30000;
|
||||
const hasFfmpeg = !!Bun.which("ffmpeg");
|
||||
const hasNativeLib = Bun.file(
|
||||
join(process.cwd(), "src", "native", "libcavacore.dylib"),
|
||||
).exists();
|
||||
|
||||
async function writeChirpWav(path: string): Promise<void> {
|
||||
const total = Math.round(DURATION * SAMPLE_RATE);
|
||||
const dataSize = total * 2;
|
||||
const buf = new Uint8Array(44 + dataSize);
|
||||
const dv = new DataView(buf.buffer);
|
||||
const ascii = (off: number, s: string) => {
|
||||
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
|
||||
};
|
||||
ascii(0, "RIFF");
|
||||
dv.setUint32(4, 36 + dataSize, true);
|
||||
ascii(8, "WAVE");
|
||||
ascii(12, "fmt ");
|
||||
dv.setUint32(16, 16, true);
|
||||
dv.setUint16(20, 1, true); // PCM
|
||||
dv.setUint16(22, 1, true); // mono
|
||||
dv.setUint32(24, SAMPLE_RATE, true);
|
||||
dv.setUint32(28, SAMPLE_RATE * 2, true);
|
||||
dv.setUint16(32, 2, true);
|
||||
dv.setUint16(34, 16, true);
|
||||
ascii(36, "data");
|
||||
dv.setUint32(40, dataSize, true);
|
||||
// Linear chirp: instantaneous frequency sweeps F0 → F1 over DURATION.
|
||||
const sweep = (F1 - F0) / DURATION;
|
||||
for (let i = 0; i < total; i++) {
|
||||
const t = i / SAMPLE_RATE;
|
||||
const phase = 2 * Math.PI * (F0 * t + 0.5 * sweep * t * t);
|
||||
dv.setInt16(44 + i * 2, Math.round(AMP * Math.sin(phase)), true);
|
||||
}
|
||||
await Bun.write(path, buf);
|
||||
}
|
||||
|
||||
const wavPath = join(tmpdir(), `podtui-viz-${process.pid}-${Date.now()}.wav`);
|
||||
await writeChirpWav(wavPath); // long enough to outlast the unload delay at readrate 1
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Poll `check` every 5ms until truthy; throw after `timeoutMs`. */
|
||||
async function waitFor(
|
||||
check: () => boolean,
|
||||
timeoutMs = 10000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!check()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("condition not met in time");
|
||||
}
|
||||
await Bun.sleep(5);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start playback against the local WAV and wait for the first frame.
|
||||
*
|
||||
* The reader samples the window ENDING at the playback position, so a
|
||||
* frozen position clock would serve a 1-sample window at position 0 and
|
||||
* never produce a full frame (in production mpv advances the clock every
|
||||
* poll). Drive the clock to 2s right after play — inside the 3s decode-head
|
||||
* burst — so complete windows are available immediately.
|
||||
*/
|
||||
async function startPlaying(): Promise<void> {
|
||||
const viz = useVisualizer();
|
||||
viz.setBarCount(64);
|
||||
viz.setFocused(true);
|
||||
setCurrentEpisode({ audioUrl: wavPath } as unknown as Episode);
|
||||
setIsPlaying(true);
|
||||
setPosition(2);
|
||||
await waitFor(() => viz.isRunning(), 10000);
|
||||
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
|
||||
}
|
||||
|
||||
const skip = !(hasFfmpeg && hasNativeLib);
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
test.skipIf(skip)(
|
||||
"starts on playback: loading state first, then frequency bars",
|
||||
async () => {
|
||||
const viz = useVisualizer();
|
||||
await startPlaying();
|
||||
expect(viz.barData().length).toBe(64);
|
||||
expect(viz.isLoading()).toBe(false);
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
// Regression: with the position clock frozen at the start position (mpv
|
||||
// still opening the stream), the reader samples a window ending at the
|
||||
// start — a 1-sample slice it can never fill. The smooth clock must be
|
||||
// seeded at pipeline start so the interpolated target advances and bars
|
||||
// render as soon as ffmpeg has ANY audio, not after the first position poll.
|
||||
test.skipIf(skip)(
|
||||
"renders bars while the position clock is still frozen at 0",
|
||||
async () => {
|
||||
const viz = useVisualizer();
|
||||
viz.setBarCount(64);
|
||||
viz.setFocused(true);
|
||||
setCurrentEpisode({ audioUrl: wavPath } as unknown as Episode);
|
||||
setIsPlaying(true);
|
||||
// Deliberately do NOT advance the mock position: the clock stays at 0.
|
||||
await waitFor(() => viz.isRunning(), 10000);
|
||||
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
|
||||
expect(viz.barData().length).toBe(64);
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
test.skipIf(skip)(
|
||||
"losing focus keeps the warm pipeline alive; refocus within the delay resumes without restart",
|
||||
async () => {
|
||||
const viz = useVisualizer();
|
||||
await startPlaying();
|
||||
|
||||
viz.setFocused(false);
|
||||
// Not an instant teardown: observe the pipeline well inside the 30s
|
||||
// grace window.
|
||||
await Bun.sleep(500);
|
||||
expect(viz.isRunning()).toBe(true);
|
||||
expect(viz.isLoading()).toBe(false);
|
||||
|
||||
// Still live: moving the position clock changes the bars (chirp →
|
||||
// different spectrum at 3s than at the 2s start position).
|
||||
setPosition(3);
|
||||
const barsBefore = viz.barData();
|
||||
await waitFor(() => viz.barData() !== barsBefore, 3000);
|
||||
|
||||
// Refocus within the delay: warm pipeline, no restart — a restart
|
||||
// would respawn ffmpeg and flash the loading state. Watch for that
|
||||
// flash over a short observation window.
|
||||
viz.setFocused(true);
|
||||
let sawRestartLoading = false;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 250) {
|
||||
if (viz.isLoading()) sawRestartLoading = true;
|
||||
await Bun.sleep(5);
|
||||
}
|
||||
expect(sawRestartLoading).toBe(false);
|
||||
expect(viz.isRunning()).toBe(true);
|
||||
expect(viz.barData().length).toBe(64);
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
// The store's unload is a real `setTimeout(VISUALIZER_UNLOAD_DELAY_MS)` with
|
||||
// no injectable clock (bun 1.3.8 has no fake timers), so the grace period is
|
||||
// exercised against the platform clock — this is the deliberate-exception
|
||||
// case from the no-real-timers rule.
|
||||
test.skipIf(skip)(
|
||||
"unloads the pipeline after the unfocused grace delay",
|
||||
async () => {
|
||||
const viz = useVisualizer();
|
||||
await startPlaying();
|
||||
expect(viz.isRunning()).toBe(true);
|
||||
|
||||
viz.setFocused(false);
|
||||
await Bun.sleep(VISUALIZER_UNLOAD_DELAY_MS + 1500);
|
||||
|
||||
expect(viz.isRunning()).toBe(false);
|
||||
expect(viz.isLoading()).toBe(false);
|
||||
},
|
||||
{ timeout: 45000 },
|
||||
);
|
||||
|
||||
// Settings master switch: turning the visualizer off must tear the running
|
||||
// pipeline down (not just hide the component), and re-enabling restarts it
|
||||
// from the current position.
|
||||
test.skipIf(skip)(
|
||||
"disabling the visualizer stops a running pipeline; re-enabling restarts it",
|
||||
async () => {
|
||||
const viz = useVisualizer();
|
||||
const app = useAppStore();
|
||||
await app.whenReady();
|
||||
await startPlaying();
|
||||
expect(viz.isRunning()).toBe(true);
|
||||
|
||||
app.updateVisualizer({ enabled: false });
|
||||
await waitFor(() => !viz.isRunning(), 10000);
|
||||
expect(viz.isLoading()).toBe(false);
|
||||
|
||||
app.updateVisualizer({ enabled: true });
|
||||
await waitFor(() => viz.isRunning(), 10000);
|
||||
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
|
||||
expect(viz.barData().length).toBe(64);
|
||||
},
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
// ── Teardown ─────────────────────────────────────────────────────────────
|
||||
|
||||
afterAll(() => {
|
||||
// Release any pipeline still running (e.g. if a test failed midway).
|
||||
setIsPlaying(false);
|
||||
});
|
||||
94
tests/visualizer-toggle.test.ts
Normal file
94
tests/visualizer-toggle.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* visualizer-toggle.test.ts — "waveform visualizer toggleable on/off in
|
||||
* settings, default on".
|
||||
*
|
||||
* Pins three contracts:
|
||||
* 1. The default is ON (fresh config, before any user change).
|
||||
* 2. The Settings → Visualizer "Waveform" item flips it via updateVisualizer.
|
||||
* 3. Persistence: a config saved BEFORE `enabled` existed (no key) still
|
||||
* loads as ON with its other visualizer fields intact (deep-merge
|
||||
* backfill), and an explicit `enabled: false` survives a reload.
|
||||
*
|
||||
* The config dir is derived from XDG_CONFIG_HOME at call time, so each
|
||||
* persistence test points it at a fresh tmpdir and writes its own
|
||||
* config.json before calling loadAppStateFromFile directly.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
|
||||
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-viz-toggle-"));
|
||||
process.env.XDG_CONFIG_HOME = CONFIG;
|
||||
process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-viz-toggle-data-"));
|
||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||
|
||||
const { useAppStore } = await import("../src/stores/app");
|
||||
const { useVisualizerItems } = await import(
|
||||
"../src/pages/Settings/VisualizerSettings"
|
||||
);
|
||||
const { loadAppStateFromFile } = await import("../src/utils/app-persistence");
|
||||
|
||||
/** Write a config.json into a fresh XDG_CONFIG_HOME and load app state. */
|
||||
async function loadWithConfig(settings: unknown): Promise<{
|
||||
state: ReturnType<typeof loadAppStateFromFile> extends Promise<infer T>
|
||||
? T
|
||||
: never;
|
||||
}> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "podtui-viz-toggle-cfg-"));
|
||||
process.env.XDG_CONFIG_HOME = dir;
|
||||
mkdirSync(join(dir, "podtui"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, "podtui", "config.json"),
|
||||
JSON.stringify({ settings }, null, 2),
|
||||
);
|
||||
return { state: await loadAppStateFromFile() };
|
||||
}
|
||||
|
||||
test("waveform visualizer defaults to ON with a fresh config", async () => {
|
||||
const app = useAppStore();
|
||||
await app.whenReady(); // empty sandbox config → defaults
|
||||
expect(app.state().settings.visualizer.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("Settings → Visualizer exposes a Waveform toggle that flips the setting", async () => {
|
||||
const app = useAppStore();
|
||||
await app.whenReady();
|
||||
const items = useVisualizerItems();
|
||||
const item = items.find((it) => it.id === "enabled");
|
||||
expect(item).toBeDefined();
|
||||
expect(item!.kind).toBe("toggle");
|
||||
expect(item!.display()).toBe("On");
|
||||
|
||||
item!.toggle!();
|
||||
expect(app.state().settings.visualizer.enabled).toBe(false);
|
||||
expect(item!.display()).toBe("Off");
|
||||
|
||||
item!.toggle!();
|
||||
expect(app.state().settings.visualizer.enabled).toBe(true);
|
||||
expect(item!.display()).toBe("On");
|
||||
});
|
||||
|
||||
test("a config saved before `enabled` existed loads as ON with other fields intact", async () => {
|
||||
const { state } = await loadWithConfig({
|
||||
visualizer: { bars: 16, lowCutOff: 80 },
|
||||
});
|
||||
expect(state.settings.visualizer.enabled).toBe(true); // backfilled
|
||||
expect(state.settings.visualizer.bars).toBe(16); // preserved, not clobbered
|
||||
expect(state.settings.visualizer.lowCutOff).toBe(80);
|
||||
});
|
||||
|
||||
test("an explicit enabled:false survives a reload", async () => {
|
||||
const { state } = await loadWithConfig({
|
||||
visualizer: { enabled: false, bars: 128 },
|
||||
});
|
||||
expect(state.settings.visualizer.enabled).toBe(false);
|
||||
expect(state.settings.visualizer.bars).toBe(128);
|
||||
});
|
||||
|
||||
test("an empty visualizer object in config falls back to full defaults", async () => {
|
||||
const { state } = await loadWithConfig({ visualizer: {} });
|
||||
expect(state.settings.visualizer.enabled).toBe(true);
|
||||
expect(state.settings.visualizer.bars).toBeGreaterThan(0);
|
||||
});
|
||||
85
tests/volume-persistence.test.ts
Normal file
85
tests/volume-persistence.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* volume-persistence.test.ts — "store and reuse the previous session's
|
||||
* audio level" feature.
|
||||
*
|
||||
* useAudio's volume starts at 100% (default, before any user change);
|
||||
* setVolume() persists the new level to app settings (config.json); and
|
||||
* at boot the volume signal re-syncs from the persisted settings — so the
|
||||
* next session resumes at the previous level.
|
||||
*
|
||||
* Same worker-leak defenses as restore-session.test.ts: other test files
|
||||
* mock.module("../src/hooks/useAudio") and bun reuses workers, so the real
|
||||
* module is imported via a query-suffixed specifier (distinct module
|
||||
* identity, loads from disk). A fresh module instance simulates the next
|
||||
* launch: its refCount starts at 0, so its boot sync re-reads settings.
|
||||
* The boot sync awaits the app store's async config load (whenReady), so
|
||||
* tests await it too and flush microtasks — no wall-clock sleeps.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
|
||||
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-volume-"));
|
||||
process.env.XDG_CONFIG_HOME = CONFIG;
|
||||
process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-volume-data-"));
|
||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||
|
||||
// ── Real modules ──────────────────────────────────────────────────────────
|
||||
// @ts-expect-error — bun-only query suffix: distinct module identity that
|
||||
// loads the real file instead of a leaked mock.module from another test file.
|
||||
const { useAudio } = await import("../src/hooks/useAudio?restore-test");
|
||||
const { useAppStore } = await import("../src/stores/app");
|
||||
|
||||
/** Flush the boot sync's promise chain (whenReady.then(...)) — microtasks
|
||||
* only, no timers. */
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
test("volume defaults to 100% and is persisted and reused across sessions", async () => {
|
||||
const appStore = useAppStore();
|
||||
await appStore.whenReady(); // empty sandbox config → defaults
|
||||
|
||||
// Boot 1: no persisted volume — the default is 100% (not the old 70%).
|
||||
const audio = useAudio();
|
||||
await flushMicrotasks();
|
||||
expect(audio.volume()).toBe(1);
|
||||
expect(appStore.state().settings.volume).toBe(1);
|
||||
|
||||
// User change: signal updates and the level lands in app settings.
|
||||
await audio.setVolume(0.35);
|
||||
expect(audio.volume()).toBe(0.35);
|
||||
expect(appStore.state().settings.volume).toBe(0.35);
|
||||
|
||||
// Boot 2 (fresh module instance — refCount starts at 0, so the boot
|
||||
// sync re-reads settings): the previous session's level is restored.
|
||||
// @ts-expect-error — same bun-only query-suffix mechanism as above.
|
||||
const { useAudio: useAudioNext } = await import("../src/hooks/useAudio?restore-test-vol");
|
||||
const nextAudio = useAudioNext();
|
||||
await flushMicrotasks();
|
||||
expect(nextAudio.volume()).toBe(0.35);
|
||||
});
|
||||
|
||||
test("setVolume clamps to the 0–1 range before persisting", async () => {
|
||||
const audio = useAudio();
|
||||
await audio.setVolume(1.7);
|
||||
expect(audio.volume()).toBe(1);
|
||||
expect(useAppStore().state().settings.volume).toBe(1);
|
||||
|
||||
await audio.setVolume(-0.3);
|
||||
expect(audio.volume()).toBe(0);
|
||||
expect(useAppStore().state().settings.volume).toBe(0);
|
||||
});
|
||||
|
||||
test("a persisted volume wins over the default at boot", async () => {
|
||||
// Persist a non-default level, then simulate a fresh launch that has no
|
||||
// prior signal state (new module instance).
|
||||
useAppStore().updateSettings({ volume: 0.6 });
|
||||
// @ts-expect-error — same bun-only query-suffix mechanism as above.
|
||||
const { useAudio: useAudioNext } = await import("../src/hooks/useAudio?restore-test-vol2");
|
||||
const nextAudio = useAudioNext();
|
||||
await flushMicrotasks();
|
||||
expect(nextAudio.volume()).toBe(0.6);
|
||||
});
|
||||
Reference in New Issue
Block a user