17 Commits

Author SHA1 Message Date
3d6d4918bc bump VERSION to 0.5.1
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m14s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-11 21:13:01 -04:00
35ad858d0d Default fetch-more mode to auto 2026-08-11 21:12:27 -04:00
af827a9a96 fix(visualizer): bars recover after far-forward seeks into undecoded audio
ensureDecodeAround let a running decode pass close ANY forward gap in
place — at 4x pacing, skipping 30 min ahead meant ~7.5 min for the
frontier to arrive: bars held their last frame indefinitely. Now a gap
beyond 15s restarts the pass at the seek target (network coverage there
in ~1.6s, verified); smaller gaps close in place (cheaper than a
reconnect + range request). Seek-key holds are debounced (400ms) so
rapid re-seeks don't reconnect-spam the stream's server, and pending
seek-decode cancels on pause/stop so no ffmpeg restarts while paused.

Also fixes a pre-existing config-write race that intermittently failed
visualizer-toggle.test.ts: updateConfig re-resolved the config path and
re-read the patch state when the deferred write-chain drained, so a
queued save could land in a directory XDG_CONFIG_HOME had since been
pointed at (or carry state mutated after queueing). Path and patch
snapshot are now captured eagerly at call time.
2026-08-11 21:08:09 -04:00
2cf9559b0b bump VERSION to 0.5.0
Some checks failed
release / build (ubuntu-latest / x64) (push) Failing after 51m25s
release / build (macos-14 / arm64) (push) Has been cancelled
release / build (ubuntu-24.04-arm / arm64) (push) Has been cancelled
release / build (macos-15-intel / x64) (push) Has been cancelled
release / Attach to GitHub Release (push) Has been cancelled
2026-08-11 19:54:36 -04:00
20336ea716 feat(audio): rebuild playback + visualization on resident daemon and PCM cache
Two fragility points, rebuilt at the root:

Playback: one resident mpv daemon (--idle --keep-open) with a persistent
IPC connection and observe_property state instead of spawn-per-episode and
connect-per-poll. Play/pause/seek are sub-ms commands; time-pos pushes at
~20Hz; external pauses arrive as events. Boot session restore preloads the
episode paused (loadfile + paused time-pos seek, since mpv defers --start
stream work until playback) so first Play is a ~400ms unpause instead of a
cold 4.3s open+seek. Load ops are mutex-serialized so a raced preload
cannot clobber an in-flight play.

Data throttling: mpv demuxer cache capped (cache-secs=90, max-bytes=40MiB)
so a paused preload no longer races to its 150MiB default (measured
45.7MB/12s); decoder paced at 4x realtime instead of 84x so playback start
isn't starved by the visualizer ripping the whole episode.

Visualization: replaced the paced-ring reader (AudioStreamReader) with a
position-indexed PCM cache (audio-pcm-cache). ffmpeg fills a cache indexed
by absolute playback time; reads at the player position are always exact.
Pause freezes the render loop, resume re-arms it — no coverage guessing,
no clamped-buffer freeze (the pause->broken-waveform->freeze bug). Seeks
and speed changes need no pipeline restarts; uncovered reads return empty
and the last frame holds.

Cover art: persistent per-URL disk cache under XDG cache dir; play() no
longer awaits a curl subprocess (up to 8s). Cache hit = one stat; misses
apply late via mpv video-add.

Test suite: 161 pass. New tests pin the position-index contract (sample-
exact window reads, hold-on-uncovered, pause-keeps-cache, seek segments),
the daemon contract (play/pause/resume/seek/stop, preload fast path,
EOF->replay), and cover cache/single-flight/404.
2026-08-11 19:54:05 -04:00
8b7b38276e feat(player): toggleable waveform visualizer in settings, default on 2026-08-11 14:15:48 -04:00
8496922aaf feat(player): waveform pipeline survives tab switches — 30s unload grace + braille spinner loading
The waveform's ffmpeg decode + cavacore FFT pipeline lived inside
RealtimeWaveform, so switching away from the Player tab unmounted it and
killed the pipeline instantly — respawning ffmpeg on every return.

Move the pipeline into a module-level store (stores/visualizer.ts) that
outlives the page:
- losing Player-tab focus keeps the pipeline warm for
  VISUALIZER_UNLOAD_DELAY_MS (30s), then tears it down (kills ffmpeg,
  destroys the cava plan); regaining focus within the delay resumes the
  warm pipeline with no restart churn; after an unload it restarts from
  the current playback position.
- playback signals move to utils/audio-signals.ts (module-level, no
  useAudio() owner needed) so the store reacts to play/pause/seek/speed
  while no Player page is mounted; useAudio re-imports them.
- render a braille spinner as the loading state for the visualizer
  (first play / after unload); stale bars stay on screen during warmup
  restarts so the waveform never blanks out for the network-bound cold
  start.
- seed the smooth position clock at pipeline start: with the position
  still frozen at 0 while mpv opens the stream, the reader sampled a
  1-sample window that could never fill, starving the bars until mpv's
  first poll.

Pins the store contract in tests/visualizer-store.test.ts: loading→bars,
warm resume without restart, 30s unload, and bars while position is
frozen at 0.
2026-08-11 14:07:58 -04:00
5e3ad48a2d feat(audio): reconcile externally-initiated pause/resume via live mpv pause state
mpv can pause or resume OUTSIDE PodTUI — system sleep/lock, AirPod
removal, device swap, OS media keys, the Now Playing center. The poll
previously only reflected commands PodTUI sent, so the UI stayed stuck
on "playing" (or "paused") with a frozen position clock.

The poll now reads mpv's live pause state each tick: an external pause
reconciles the UI to paused (persisting progress, syncing media
controls) while keeping the poll armed; an external resume brings the
UI back to playing. A paused player is polled at a throttled rate
(PAUSE_WATCH_TICKS) so the watch costs ~1 IPC read per second instead
of hammering mpv; a dead process (track end / crash) finalizes the
track.
2026-08-11 14:07:43 -04:00
005ac8fde3 feat(search): stream unsubscribed episodes directly; a subscribes in place 2026-08-11 14:01:46 -04:00
1f0b9de456 fix(macos): derive bundle Info.plist version from VERSION constant
The release tag bumps VERSION in src/index.tsx, but build.ts hardcoded
0.3.1 in the app bundle's plist — shipped bundles reported a stale version
(lsappinfo/System Settings). Extract VERSION at bundle-assembly time and
interpolate into CFBundleShortVersionString/CFBundleVersion.
2026-08-11 13:48:23 -04:00
3388757185 fix(macos): hard-fail bundle without mpv, PATH fallback, CI mpv install
- build.ts: darwin compile now exits 1 when mpv is absent — CI can no
  longer ship a PodTui.app without its bundled player (0.4.0 did, killing
  Now Playing attribution).
- audio-player.ts: the bundled mpv is probed (--version) at first resolve;
  it links against brew's dylibs, and a Homebrew ffmpeg major upgrade can
  break it — fall back to PATH mpv so audio survives (icon degrades to
  blank instead of playback dying). Probed once per process.
- release.yml: brew install mpv on darwin runners; smoke test now asserts
  the tarball's PodTui.app has a launchable mpv signed with the
  com.mikefreno.podtui identifier.
2026-08-11 13:37:43 -04:00
8049d02457 feat(player): persist volume across sessions, default to 100%
Store the playback volume in app settings (config.json) whenever it
changes and re-apply the previous session's level at boot, instead of
always starting at the old 70% fallback.

- AppSettings gains volume (default 1 = 100%); both default-settings
  copies and the volume signal default are raised from 0.7 to 1.
- doSetVolume persists via the app store (mirrors playbackSpeed).
- The boot sync awaits the app store's async config load (new
  whenReady()) so a persisted level is applied even when settings load
  finishes after useAudio mounts.
- tests/volume-persistence.test.ts: default, clamp, and cross-session
  reuse (fresh module instance simulates the next launch).
2026-08-11 13:30:01 -04:00
1b55b7117c feat(player): restore the last player session at boot
Reload the episode that was loaded in the player when the previous run
ended (persisted on play/load and synchronously at exit) into the Player
tab paused at its saved position — never autostarted. Episodes at or
above 98% completion are skipped, as are empty-player and unsubscribed
episodes.

- useAudio gains load(episode) (sets currentEpisode/position/Now Playing
  without starting the backend) and restoreLastSession(), triggered once
  at boot and serialized through a chain so a late-finishing boot restore
  can't clobber later state.
- togglePlayback branches on a startedPlayback flag: a restored episode
  starts the backend from the saved position; a paused one resumes.
- stop() clears the marker; the exit teardown writes it synchronously
  (process.exit bypasses async writes).
- feed/progress stores expose whenReady() so restore waits for the async
  boot loads; feed store gains findEpisode().
- app-persistence serializes last-player marker writes and exposes
  waitForLastPlayerWrite() for deterministic tests.
- tests/restore-session.test.ts: real modules + local RSS feed server;
  the real useAudio is imported via a ?restore-test query suffix to
  bypass the suite's mock.module leak across shared bun workers.
2026-08-11 13:18:00 -04:00
15f8a098b5 feat(download): unsubscribed-show downloads from search
Episode search results gain d (download), D (delete), x (unsubscribe)
and enter (play for subscribed shows); downloads of unsubscribed shows
are recorded with the show's metadata under a deterministic synthetic
feed id and listed under an "Unsubscribed Show Downloads" section in
My Shows and the settings Download Manager. Classified at render time
by feed id or feed URL, so subscribing re-classifies the downloads and
unsubscribing purges them.
2026-08-11 13:11:32 -04:00
2d7d49b91c feat(feed): periodic background refresh with failed-fetch guard
Self-rescheduling refresh timer (default 30 min, configurable via a
Preferences item, re-read on every tick, skips in-flight refreshes).
fetchEpisodes returns null on network failure/timeout so a failed
refresh can never wipe a feed's episodes (addFeed/refreshFeed/
refreshAllFeeds all treat null as unchanged); feeds still refresh on
launch.
2026-08-11 13:11:20 -04:00
df9c519439 feat(search): persist recent searches between sessions
History used localStorage, which never exists in the Bun TUI, so nothing
survived a restart. Store the 10 most recent queries in config-dir
search-history.json (same fire-and-forget file pattern as audio-nav.json),
loaded asynchronously at store init. Dedupe case-insensitively, cap at 10.
2026-08-11 12:45:43 -04:00
2bf1c229c7 Require shift for speed cycle keybind (s -> S) 2026-08-11 12:22:44 -04:00
48 changed files with 4851 additions and 1186 deletions

View File

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

View File

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

View File

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

View File

@@ -68,6 +68,7 @@ export type KeybindActionName =
| "sort"
| "toggle-hidden"
| "refresh"
| "subscribe"
| "unsubscribe"
| "download"
| "delete-download"

View File

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

View File

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

View File

@@ -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.1";
interface CliArgs {
version: boolean;

View File

@@ -68,7 +68,7 @@ function FeedPage() {
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
// reaching the bottom row fetches automatically (see the effect below).
const app = useAppStore();
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
const showFetchMore = () => feedStore.hasMoreAcrossAll();
// Total navigable rows: episodes + the optional Fetch More row.
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);

View File

@@ -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 ?? "";
@@ -83,7 +102,7 @@ export function MyShowsPage() {
// counterpart to the Feed page's row (which loads every feed). manual
// mode: Enter on the row. auto mode: reaching the bottom row fetches
// automatically (see the effect below).
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
const showFetchMore = () =>
depth() >= 1 &&
!!drilledShowId() &&
@@ -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>
)}

View File

@@ -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 */}

View File

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

View File

@@ -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.01.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 (

View File

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

View File

@@ -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: () => {

View File

@@ -211,14 +211,40 @@ export function usePreferencesItems(): SettingItem[] {
kind: "select",
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
help: () =>
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: auto\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
const idx = modes.indexOf(prefs().fetchMoreMode ?? "auto");
const next = modes[(idx + dir + modes.length) % modes.length];
app.updatePreferences({ fetchMoreMode: next });
},
},
{
id: "refreshInterval",
label: "Feed Refresh Interval",
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 (1120 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".

View File

@@ -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,
},
{

View File

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

View File

@@ -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,
@@ -41,7 +43,8 @@ const defaultPreferences: UserPreferences = {
autoDownloadScope: "all",
autoDownloadWhitelist: [],
autoJumpToPlayer: true,
fetchMoreMode: "manual",
fetchMoreMode: "auto",
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,

View File

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

View File

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

View File

@@ -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.
*/

View File

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

450
src/stores/visualizer.ts Normal file
View File

@@ -0,0 +1,450 @@
/**
* 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;
/** Timer handle as returned by setTimeout/setInterval in this runtime. */
type TimerHandle = ReturnType<typeof setTimeout>;
// ── Types ────────────────────────────────────────────────────────────────
export interface VisualizerStore {
/** Frequency bar values (0.01.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.01.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: TimerHandle | null = null;
let sampleBuffer: Float64Array | null = null;
let unloadTimer: TimerHandle | 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;
}
clearTimeout(seekDecodeTimer);
seekDecodeTimer = undefined;
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;
}
// Cancel any debounced seek-decode: it would restart ffmpeg while
// paused, defeating the "no background CPU while paused" contract.
clearTimeout(seekDecodeTimer);
seekDecodeTimer = undefined;
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;
let seekDecodeTimer: TimerHandle | undefined;
createEffect(
on(audioPlaybackSignals.position, (pos) => {
if (!audioPlaybackSignals.isPlaying() || !pcm) {
lastSyncPosition = pos;
return;
}
const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos;
if (delta > 2) {
// Debounce: holding the seek key fires a jump per poll tick —
// without debounce each one restarts ffmpeg, spamming network
// reconnects against the stream's server. Wait for the user to
// settle, then decode at the final position.
clearTimeout(seekDecodeTimer);
const target = pcm; // capture for the timer
seekDecodeTimer = setTimeout(() => {
seekDecodeTimer = undefined;
target.ensureDecodeAround(untrack(audioPlaybackSignals.position));
}, 400);
}
}),
);
// ── 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;
}

View File

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

View File

@@ -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 (8128, 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 01 (default: 1 = 100%). */
volume: number;
downloadPath: string;
/** Render the app background transparent (let the terminal's own bg show). */
transparentBackground: boolean;
@@ -86,7 +90,7 @@ export type AppSettings = {
visualizer: VisualizerSettings;
};
/** How the Feed and per-show episode lists load older episodes (default: manual "[Fetch More]"). */
/** How the Feed and per-show episode lists load older episodes (default: auto). */
export type FetchMoreMode = "manual" | "auto";
/** Which shows the auto-download setting applies to (default: all). */
@@ -103,8 +107,10 @@ export type UserPreferences = {
autoDownloadWhitelist: string[];
/** Jump to the Player view automatically when playback starts (default: true) */
autoJumpToPlayer: boolean;
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: auto). */
fetchMoreMode: FetchMoreMode;
/** Minutes between automatic background feed refreshes (default: 30). */
refreshIntervalMinutes: number;
};
export type AppState = {

View File

@@ -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,
@@ -44,7 +47,8 @@ const defaultPreferences: UserPreferences = {
autoDownloadScope: "all",
autoDownloadWhitelist: [],
autoJumpToPlayer: true,
fetchMoreMode: "manual",
fetchMoreMode: "auto",
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
}
}

View File

@@ -0,0 +1,385 @@
/**
* 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;
/**
* Gap (seconds) a running decode pass may close on its own before a restart
* at the seek target is cheaper than waiting: at 4x pacing, 15s of undecoded
* audio closes in ~4s — about the cost of a network reconnect + range
* request for a fresh ffmpeg pass. Beyond the gap, restart at the target.
*/
const CLOSE_IN_PLACE_GAP_SEC = 15;
/**
* 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;
/** The running pass's segment (base + frontier); null when idle. */
private activeSegment: Segment | 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;
}
/** Base (playback seconds) of the running decode pass; null when idle. */
get activeDecodeBaseSec(): number | null {
return this._decoding && this.activeSegment
? this.activeSegment.baseSec
: null;
}
/** 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.activeSegment = segment;
this.readLoop(myGeneration, segment);
this.proc.exited
.then((code) => {
if (this.generation === myGeneration) {
this._decoding = false;
this.activeSegment = null;
// Exit 0 == decoded to stream EOF.
if (code === 0) segment.finished = true;
}
})
.catch(() => {
if (this.generation === myGeneration) {
this._decoding = false;
this.activeSegment = 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 {
// Data already on hand: nothing needed here; only keep the tail
// filling if the decode is idle and the episode is unfinished.
if (this.covers(sec)) {
if (this._decoding || this.decodeFinished) return;
this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec);
return;
}
if (this._decoding && this.activeSegment !== null) {
// A decode pass fills monotonically FORWARD from its base. Targets
// behind the base are unreachable — restart at the target.
if (sec < this.activeSegment.baseSec) {
this.startDecode(Math.max(0, sec));
return;
}
// Target past the pass's frontier: a SMALL gap closes on its own
// (4x pacing covers 15s in ~4s — about what a cold restart costs
// to reconnect + range-request a network stream), but a FAR-FORWARD
// seek would otherwise mean minutes of frozen bars while the pass
// chews through the skipped region. Restart at the target.
const frontier =
this.activeSegment.baseSec + this.activeSegment.written / this.sampleRate;
if (sec - frontier <= CLOSE_IN_PLACE_GAP_SEC) return;
}
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.activeSegment = 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 */
}
}
}
}

View File

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

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

View File

@@ -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.53s,
* 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 */
}
}
}
}

View File

@@ -13,6 +13,7 @@
* always overwrite — no backup files are created.
*/
import { mkdir } from "fs/promises";
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
import type {
AppSettings,
@@ -58,15 +59,34 @@ let writeChain: Promise<void> = Promise.resolve();
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
export function updateConfig(patch: Partial<PodTuiConfig>): void {
// Capture the target path AND the patch data eagerly, at call time:
// the write chain defers execution, and both the config dir (tests
// re-point XDG_CONFIG_HOME between ops) and the state object (stores
// mutate in place) move under a pending write. Without the capture, a
// queued save writes the LATEST state into whatever directory is
// current when the chain drains — a cross-directory misdelivery that
// was the source of a flaky "enabled:false survives reload" test.
const configPath = getConfigFilePath(CONFIG_FILE);
const configDir = getConfigDir();
const snapshot = JSON.parse(JSON.stringify(patch)) as Partial<PodTuiConfig>;
writeChain = writeChain.then(async () => {
try {
await ensureConfigDir();
const current = await loadConfig();
const next = { ...current, ...patch };
await Bun.write(
getConfigFilePath(CONFIG_FILE),
JSON.stringify(next, null, 2),
);
await migrateOnce();
await mkdir(configDir, { recursive: true });
let current: PodTuiConfig = {};
try {
const file = Bun.file(configPath);
if (await file.exists()) {
const raw = await file.json();
if (raw && typeof raw === "object") {
current = raw as PodTuiConfig;
}
}
} catch {
/* unreadable existing config — treat as empty */
}
const next = { ...current, ...snapshot };
await Bun.write(configPath, JSON.stringify(next, null, 2));
} catch {
// Fire-and-forget persistence — silently ignore write errors.
}

View File

@@ -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(() => {});
}

View File

@@ -76,6 +76,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
"sort",
"toggle-hidden",
"refresh",
"subscribe",
"unsubscribe",
"download",
"delete-download",

View File

@@ -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
View 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 },
);

View File

@@ -0,0 +1,280 @@
/**
* 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");
test.skipIf(!hasFfmpeg)(
"far-forward seek into undecoded territory restarts decode AT the target (bars recover in seconds, not minutes)",
async () => {
const wav = tmpWav();
writeSineWav(wav, 60);
const cache = new EpisodePcmCache({ url: wav });
try {
cache.startDecode(0);
await waitForCoverage(cache, 1);
// Skipping 45s ahead while the pass still crawls at 4x must restart
// the segment at the target — waiting for the frontier to chew
// through the skipped region is minutes of frozen bars.
cache.ensureDecodeAround(45);
expect(cache.decoding).toBe(true);
expect(cache.activeDecodeBaseSec).toBe(45);
await waitForCoverage(cache, 45.1);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);
test.skipIf(!hasFfmpeg)(
"small forward gap closes in place — no needless reconnect",
async () => {
const wav = tmpWav();
writeSineWav(wav, 60);
const cache = new EpisodePcmCache({ url: wav });
try {
cache.startDecode(0);
await waitForCoverage(cache, 2);
// ~5s past the running frontier: at 4x pacing this closes in ~1.5s,
// cheaper than a reconnect — the pass must NOT restart.
const target = cache.coverageEndSec + 5;
cache.ensureDecodeAround(target);
expect(cache.activeDecodeBaseSec).toBe(0);
await waitForCoverage(cache, target);
} finally {
cache.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 20000 },
);
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 },
);

View File

@@ -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
View 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 */
}
}
}
});

View File

@@ -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 }) => {

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

View 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 */
}
});

View File

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

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

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

View File

@@ -51,6 +51,7 @@ const stubAudio: AudioControls = {
currentEpisode: () => null,
availablePlayers: () => [],
play: async () => {},
load: async () => {},
pause: async () => {},
resume: async () => {},
togglePlayback: async () => {},

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

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

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