feat(audio): auto-advance to next episode in source queue on track end

When a track reaches natural EOF (player alive, no stream error), play the
next episode from the source that started it — search results, show, or
Feed — and stop at the end of the list. A crashed/killed daemon or failed
stream never auto-advances.

- add audio-queue.ts: pure next/prev selection from the navigation source
- audio-player: expose getPlaybackError() to distinguish EOF from failure
- useAudio: finalizeTrackEnd(autoAdvance) wiring, re-selecting the current
  episode no longer reloads from stale saved progress
- tests: audio-queue units, auto-advance integration (real mpv + local
  WAVs over HTTP), backend re-select no-reload test
This commit is contained in:
2026-08-17 20:50:05 -04:00
parent 22059c24ca
commit 9ddfd21685
6 changed files with 656 additions and 115 deletions

View File

@@ -55,10 +55,15 @@ import {
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 { useAudioNavStore } from "../stores/audio-nav";
import { useDownloadStore } from "../stores/download";
import { useFeedStore } from "../stores/feed";
import { useSearchStore } from "../stores/search";
import {
nextStep,
prevStep,
queueForSource,
} from "../utils/audio-queue";
export interface AudioControls {
// Signals (reactive getters)
@@ -180,8 +185,10 @@ 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 {
* position and stop polling. `autoAdvance` is true only when the track
* reached its natural end with the player still alive and no stream error
* — the signal to keep the queue going. */
function finalizeTrackEnd(autoAdvance: boolean): void {
setIsPlaying(false);
stopPolling();
const ep = currentEpisode();
@@ -189,6 +196,12 @@ function finalizeTrackEnd(): void {
const progressStore = useProgressStore();
progressStore.update(ep.id, position(), duration(), speed());
}
if (autoAdvance) {
// The episode finished: play the next one from the source that
// started it (search results / show / feed). No-op at the end of
// the list or when the episode isn't in the source list anymore.
void next().catch(() => {});
}
}
/** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal,
@@ -235,7 +248,12 @@ function startPolling(): void {
// and reports pause=true there, which would otherwise be
// mistaken for an external pause and never finalize.
if (!backend.isPlaying()) {
finalizeTrackEnd();
// Natural EOF (player alive, no stream error) auto-advances
// to the next episode; a crashed/killed daemon or a failed
// stream must not start the next episode on its own.
finalizeTrackEnd(
backend.isAlive() && !backend.getPlaybackError(),
);
return;
}
@@ -270,7 +288,7 @@ function startPolling(): void {
// 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();
finalizeTrackEnd(false);
return;
}
const paused = await backend.getPauseState();
@@ -335,21 +353,52 @@ async function play(episode: Episode): Promise<void> {
return;
}
try {
const appStore = useAppStore();
const progressStore = useProgressStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
const vol = volume();
const spd = storeSpeed || speed();
const appStore = useAppStore();
const progressStore = useProgressStore();
const storeSpeed = appStore.state().settings.playbackSpeed;
const vol = volume();
const spd = storeSpeed || speed();
const feedStore = useFeedStore();
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
// Play the downloaded file when present (offline + no network stalls);
// otherwise stream. Cover resolves to the feed art, falling back to the
// episode's own image (feeds added by URL may lack a channel cover).
const downloadStore = useDownloadStore();
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
const feedStore = useFeedStore();
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
const podcastTitle = feed?.customName || feed?.podcast.title || "";
// Play the downloaded file when present (offline + no network stalls);
// otherwise stream. Cover resolves to the feed art, falling back to the
// episode's own image (feeds added by URL may lack a channel cover).
const downloadStore = useDownloadStore();
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
let startPos = 0;
if (savedProgress && !progressStore.isCompleted(episode.id)) {
startPos = savedProgress.position;
}
// Present the new episode in the UI IMMEDIATELY, before the backend load
// (cover fetch + loadfile can take a few hundred ms): the player tab,
// status bar, and OS Now Playing must not keep showing the previous
// episode during the swap. The previous track's poll is stopped so it
// can't attribute its position/progress to the new episode; polling
// restarts once the backend is actually playing. Mirrors load()'s
// synchronous presentation.
stopPolling();
setCurrentEpisode(episode);
setIsPlaying(false);
startedPlayback = false;
setPosition(startPos);
setSpeed(spd);
if (episode.duration) setDuration(episode.duration);
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(false);
if (startPos > 0) media.setPosition(startPos);
try {
// Cover art only applies at file LOAD (the runtime video-add fallback
// never becomes an albumart track), so a cold-cache play must wait for
// the fetch or play artless. Serve the disk cache synchronously; on a
@@ -360,13 +409,6 @@ async function play(episode: Episode): Promise<void> {
"bounded",
);
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
let startPos = 0;
if (savedProgress && !progressStore.isCompleted(episode.id)) {
startPos = savedProgress.position;
}
await b.play(url, {
volume: vol,
speed: spd,
@@ -375,10 +417,8 @@ async function play(episode: Episode): Promise<void> {
coverArtPath: coverArtPath ?? undefined,
});
setCurrentEpisode(episode);
setIsPlaying(true);
setPosition(startPos);
setSpeed(spd);
if (episode.duration) setDuration(episode.duration);
startedPlayback = true;
@@ -387,12 +427,6 @@ async function play(episode: Episode): Promise<void> {
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
// Register with platform media controls
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(true);
if (startPos > 0) media.setPosition(startPos);
@@ -728,6 +762,60 @@ export async function restoreLastSession(): Promise<void> {
* Returns a singleton — all components share the same playback state.
* Registers event bus listeners and cleans them up with onCleanup.
*/
// ── Episode queue navigation ──────────────────────────────────────────────
// `next`/`prev` (and the end-of-episode auto-advance in finalizeTrackEnd)
// move within the ordered list of the source that STARTED the current
// episode: the Feed's chronological list, the current show's episodes, or
// the search results (see utils/audio-queue). Module-level so
// finalizeTrackEnd can auto-advance without a mounted hook owner.
const audioNav = useAudioNavStore();
/** The ordered playable episodes for the source that started playback. */
function queueForCurrentSource(): Episode[] {
const feedStore = useFeedStore();
return queueForSource(
audioNav.getSource(),
audioNav.getPodcastId(),
feedStore.feeds(),
feedStore.getAllEpisodesChronological(),
useSearchStore().results(),
);
}
async function next(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const step = nextStep(queueForCurrentSource(), current.id);
// A duplicated queue entry (same episode id twice) must not make
// "next" replay the CURRENT episode — that would reload it from
// saved progress and audibly repeat already-played audio.
if (!step || step.episode.id === current.id) return;
await play(step.episode);
audioNav.next(step.index);
}
async function prev(): Promise<void> {
const current = currentEpisode();
if (!current) return;
// Standard transport behavior: past 30s in, "prev" restarts the current
// episode; before that it steps back within the source queue.
const NAV_START_THRESHOLD = 30;
const currentPos = position();
const currentDur = duration();
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
await seek(NAV_START_THRESHOLD);
return;
}
const step = prevStep(queueForCurrentSource(), current.id);
if (!step) return;
await play(step.episode);
audioNav.prev(step.index);
}
export function useAudio(): AudioControls {
// Initialize backend on first use
ensureBackend();
@@ -793,80 +881,6 @@ export function useAudio(): AudioControls {
await doSetSpeed(next);
});
const audioNav = useAudioNavStore();
const feedStore = useFeedStore();
async function prev(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const currentPos = position();
const currentDur = duration();
const NAV_START_THRESHOLD = 30;
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
await seek(NAV_START_THRESHOLD);
} else {
const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore
.getFilteredFeeds()
.find((f) => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.max(0, currentIndex - 1);
if (newIndex < episodes.length && episodes[newIndex]) {
const { episode } = episodes[newIndex];
await play(episode);
audioNav.prev(newIndex);
}
}
}
async function next(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore
.getFilteredFeeds()
.find((f) => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map((ep) => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
if (newIndex >= 0 && episodes[newIndex]) {
const { episode } = episodes[newIndex];
await play(episode);
audioNav.next(newIndex);
}
}
onCleanup(() => {
refCount--;
unsubPlay();

View File

@@ -82,6 +82,11 @@ export interface AudioBackend {
getPauseState(): Promise<boolean | undefined>;
/** True while the player process is running (regardless of pause). */
isAlive(): boolean;
/** Last playback error (end-file reason "error"), or null when the last
* track ended cleanly (or nothing has failed yet). Lets callers
* distinguish a natural end-of-file from a stream failure — a failed
* episode must not auto-advance the queue. */
getPlaybackError(): string | null;
dispose(): void;
}
@@ -591,13 +596,22 @@ export class MpvBackend implements AudioBackend {
// play checks it and skips its own stale paused-load.
this._intentPlaying = true;
await this.runLoadExclusive(async () => {
// Fast path: this exact URL was PRELOADED paused (boot restore) —
// mpv has been buffering it since boot, so flipping pause off starts
// audio ~instantly. Re-acquire the start position only when it
// moved meaningfully since the preload (progress saved meanwhile).
if (this._loadedUrl === url && this._loadedPaused && !this._ended) {
// Same episode re-selected (Enter in a list, key-repeat, a
// second tap on the playing row): the file is ALREADY in the
// player. Reloading with start=<saved progress> would audibly
// skip BACK and repeat already-played audio (saved progress
// lags the live position by up to the 5s persist interval), so
// align in place instead:
// - preload park (loaded paused at boot restore): seek only
// when the caller's target moved materially since load;
// - user-paused: unpause at the CURRENT position (saved
// progress is stale and must not become a backward seek);
// - already playing: unpause is a no-op — nothing to do.
// A genuinely finished episode (_ended) still falls through to
// a fresh load, which replays from the top via isCompleted.
if (this._loadedUrl === url && !this._ended) {
const target = opts?.startPosition ?? this._position;
if (Math.abs(target - this._position) > 2) {
if (this._loadedPaused && Math.abs(target - this._position) > 2) {
await this.send(["set_property", "time-pos", target]);
this._position = target;
}
@@ -782,6 +796,9 @@ class NoopBackend implements AudioBackend {
isAlive(): boolean {
return false;
}
getPlaybackError(): string | null {
return null;
}
dispose(): void {}
}

88
src/utils/audio-queue.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* audio-queue — ordered episode queue for "what plays next" navigation.
*
* Pure selection logic for source-based auto-advance (and manual next/prev):
* given the navigation source that STARTED the current episode, which
* episodes come after it?
*
* FEED — the global chronological Feed list (newest first), so "next"
* walks toward older episodes — further down the list.
* MY_SHOWS — the current show's episode list (newest first), scoped to the
* podcast that started playback.
* SEARCH — the current search results, in display order (episode-kind
* results only — a show result has nothing to play).
*
* Kept dependency-light (pure functions over plain data) so the ordering and
* bounds contract is unit-testable without stores or audio.
*/
import type { Episode } from "../types/episode";
import type { Feed } from "../types/feed";
import type { SearchResult } from "../types/source";
import { AudioSource } from "../stores/audio-nav";
/** The ordered playable queue for a navigation source. Empty when the
* source's context is missing (no podcastId, no search results, no feeds). */
export function queueForSource(
source: AudioSource,
podcastId: string | undefined,
feeds: Feed[],
allEpisodes: Array<{ episode: Episode; feed: Feed }>,
searchResults: SearchResult[],
): Episode[] {
if (source === AudioSource.FEED) {
// Dedupe by episode id: the same episode can appear twice after a
// refresh merge or when two feeds list it — a duplicate would make
// next/auto-advance step onto the CURRENT episode and replay it.
const seen = new Set<string>();
const unique: Episode[] = [];
for (const e of allEpisodes) {
if (seen.has(e.episode.id)) continue;
seen.add(e.episode.id);
unique.push(e.episode);
}
return unique;
}
if (source === AudioSource.MY_SHOWS) {
const feed = feeds.find((f) => f.podcast.id === podcastId);
return feed ? feed.episodes : [];
}
if (source === AudioSource.SEARCH) {
return searchResults
.filter((r) => r.kind === "episode")
.map((r) => r.episode);
}
return [];
}
/** Index of an episode in the queue, or -1 when the episode isn't in it. */
export function queueIndex(queue: Episode[], episodeId: string): number {
return queue.findIndex((e) => e.id === episodeId);
}
export interface QueueStep {
episode: Episode;
index: number;
}
/** The episode after `episodeId` in the queue, with its index. Null when
* the episode isn't in the queue or is already the last one. */
export function nextStep(
queue: Episode[],
episodeId: string,
): QueueStep | null {
const idx = queueIndex(queue, episodeId);
if (idx < 0 || idx + 1 >= queue.length) return null;
return { episode: queue[idx + 1], index: idx + 1 };
}
/** The episode before `episodeId` in the queue, with its index. Null when
* the episode isn't in the queue or is already the first one. */
export function prevStep(
queue: Episode[],
episodeId: string,
): QueueStep | null {
const idx = queueIndex(queue, episodeId);
if (idx <= 0) return null;
return { episode: queue[idx - 1], index: idx - 1 };
}

View File

@@ -172,6 +172,76 @@ test.skipIf(!hasMpv)(
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"play() of the already-playing url does NOT reload (no audible skip-back)",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
// Start mid-episode (as a resume would) and let it advance.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
await waitFor(
"position advances past the start offset",
async () => (await backend.getPosition()) > 1.8,
);
const before = await backend.getPosition();
// Re-selecting the SAME episode (Enter in a list, key-repeat)
// calls play() with the STALE saved progress. The file is
// already loaded — this must not reload from that earlier
// position, or the listener hears already-played audio again.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
// A reload would drop the position back to ~1; a correct no-op
// keeps advancing from where it was.
await waitFor(
"playback continues past the pre-play position",
async () => (await backend.getPosition()) > before + 0.3,
);
expect(backend.isPlaying()).toBe(true);
// And the position never fell back toward the stale offset.
expect(await backend.getPosition()).toBeGreaterThan(1.8);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"play() of the same url while user-paused resumes at the current position",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
await backend.play(wavB, { volume: 0, speed: 1, startPosition: 1 });
await waitFor(
"position advances",
async () => (await backend.getPosition()) > 2,
);
await backend.pause();
await waitFor(
"paused observed",
async () => (await backend.getPauseState()) === true,
);
const pausedAt = await backend.getPosition();
// Re-selecting the paused episode resumes where it PAUSED — the
// stale saved progress must not become a backward seek target.
await backend.play(wavB, { volume: 0, speed: 1, startPosition: 1 });
expect(backend.isPlaying()).toBe(true);
await waitFor(
"resumed at the paused position",
async () => (await backend.getPosition()) > pausedAt + 0.3,
);
expect(await backend.getPosition()).toBeGreaterThan(1.5);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"daemon killed mid-play: resume() rejects on the fresh idle daemon; play() recovers a new one",
async () => {

155
tests/audio-queue.test.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* audio-queue unit tests — pure selection logic for next/prev navigation
* and source-based auto-advance. Covers ordering, bounds, and the
* deduplication that prevents "next" from replaying the current episode.
*/
import { test, expect } from "bun:test";
import {
queueForSource,
queueIndex,
nextStep,
prevStep,
} from "../src/utils/audio-queue";
import { AudioSource } from "../src/stores/audio-nav";
import type { Episode } from "../src/types/episode";
import type { Feed } from "../src/types/feed";
import { FeedVisibility } from "../src/types/feed";
import type { SearchResult } from "../src/types/source";
function ep(id: string, n: number): Episode {
return {
id,
podcastId: "pod-" + id,
title: `Episode ${n}`,
description: "",
audioUrl: `https://example.com/${id}.mp3`,
duration: 600,
pubDate: new Date(2026, 0, n),
};
}
function feed(id: string, episodes: Episode[]): Feed {
return {
id,
podcast: {
id,
title: "Feed " + id,
description: "",
feedUrl: `https://example.com/${id}.xml`,
lastUpdated: new Date(),
isSubscribed: true,
},
episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: "rss",
lastUpdated: new Date(),
isPinned: false,
};
}
function episodeResult(episode: Episode): SearchResult {
return {
sourceId: "itunes",
kind: "episode",
podcast: {
id: episode.podcastId,
title: "Show " + episode.podcastId,
description: "",
feedUrl: `https://example.com/${episode.podcastId}.xml`,
lastUpdated: new Date(),
isSubscribed: false,
},
episode,
};
}
const e1 = ep("e1", 1);
const e2 = ep("e2", 2);
const e3 = ep("e3", 3);
test("FEED queue is the chronological global list, newest first", () => {
const f1 = feed("f1", [e3, e2]);
const f2 = feed("f2", [e1]);
const queue = queueForSource(
AudioSource.FEED,
undefined,
[f1, f2],
[
{ episode: e3, feed: f1 },
{ episode: e2, feed: f1 },
{ episode: e1, feed: f2 },
],
[],
);
expect(queue.map((e) => e.id)).toEqual(["e3", "e2", "e1"]);
expect(queueIndex(queue, "e2")).toBe(1);
expect(nextStep(queue, "e2")?.episode.id).toBe("e1");
expect(prevStep(queue, "e2")?.episode.id).toBe("e3");
expect(nextStep(queue, "e1")).toBeNull();
expect(prevStep(queue, "e3")).toBeNull();
});
test("FEED queue dedupes repeated episode ids (same episode listed twice)", () => {
// The same episode appears twice in the global list (e.g. a refresh
// merge duplicated a feed's entries). Without dedupe, nextStep after
// e2 would step onto e2 AGAIN — replaying the current episode.
const f1 = feed("f1", [e3, e2, e2, e1]);
const queue = queueForSource(
AudioSource.FEED,
undefined,
[f1],
[
{ episode: e3, feed: f1 },
{ episode: e2, feed: f1 },
{ episode: e2, feed: f1 },
{ episode: e1, feed: f1 },
],
[],
);
expect(queue.map((e) => e.id)).toEqual(["e3", "e2", "e1"]);
// Distinct objects sharing an id dedupe too.
const e2clone = { ...e2 };
const queue2 = queueForSource(
AudioSource.FEED,
undefined,
[f1],
[
{ episode: e3, feed: f1 },
{ episode: e2, feed: f1 },
{ episode: e2clone, feed: f1 },
],
[],
);
expect(queue2.map((e) => e.id)).toEqual(["e3", "e2"]);
expect(nextStep(queue2, "e2")).toBeNull(); // no self-step
});
test("MY_SHOWS queue scopes to the podcast that started playback", () => {
const fA = feed("podA", [e3, e2]);
const fB = feed("podB", [e1]);
const queue = queueForSource(
AudioSource.MY_SHOWS,
"podA",
[fA, fB],
[],
[],
);
expect(queue.map((e) => e.id)).toEqual(["e3", "e2"]);
// Unknown podcastId → empty queue (nothing to play next).
expect(
queueForSource(AudioSource.MY_SHOWS, "podX", [fA, fB], [], []),
).toEqual([]);
});
test("SEARCH queue filters to episode-kind results in display order", () => {
const queue = queueForSource(
AudioSource.SEARCH,
undefined,
[],
[],
[episodeResult(e1), episodeResult(e2)],
);
expect(queue.map((e) => e.id)).toEqual(["e1", "e2"]);
expect(queueIndex(queue, "e1")).toBe(0);
expect(queueIndex(queue, "e3")).toBe(-1);
});

197
tests/auto-advance.test.ts Normal file
View File

@@ -0,0 +1,197 @@
/**
* auto-advance.test.ts — "at the end of episodes play the next one, from
* the source that started it" feature.
*
* When a track reaches its natural end (mpv eof-reached), useAudio must
* advance to the next episode in the source queue — the current show's
* episode list (MY_SHOWS), the Feed's chronological list, or the search
* results — and must STOP at the end of the list (no wrap-around). A
* crashed/killed daemon must NOT auto-advance (that path is pinned by
* external-pause-reconcile.test.ts).
*
* Integration style (like external-pause-reconcile.test.ts): real stores,
* real persistence sandbox, and the REAL mpv backend driven by real audio
* files — two short local WAVs served over HTTP, so EOF happens on a
* deterministic timer. The show is subscribed through the real feed store's
* addFeed() API (no config seeding — works on whatever singleton state this
* worker holds), and the audio-nav source is pinned to MY_SHOWS for that
* podcast so the queue is scoped and deterministic. Skipped when mpv isn't
* installed.
*/
import { test, expect, afterAll } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const hasMpv = !!Bun.which("mpv");
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-autoadv-"));
const DATA = mkdtempSync(join(tmpdir(), "podtui-autoadv-data-"));
process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = DATA;
process.env.PODTUI_AUDIO_BACKEND = "mpv"; // real backend; EOF is the signal under test
/** 2s mono 16-bit WAV with a sine tone — short enough to EOF fast,
* distinct per episode so playback is unambiguous. */
function makeWav(freq: number): Buffer {
const SAMPLE_RATE = 44100;
const DURATION = 2;
const dataLen = SAMPLE_RATE * DURATION;
const buf = Buffer.alloc(44 + dataLen * 2);
buf.write("RIFF", 0);
buf.writeUInt32LE(36 + dataLen * 2, 4);
buf.write("WAVE", 8);
buf.write("fmt ", 12);
buf.writeUInt32LE(16, 16); // fmt chunk size
buf.writeUInt16LE(1, 20); // PCM
buf.writeUInt16LE(1, 22); // mono
buf.writeUInt32LE(SAMPLE_RATE, 24);
buf.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate
buf.writeUInt16LE(2, 32); // block align
buf.writeUInt16LE(16, 34); // bits per sample
buf.write("data", 36);
buf.writeUInt32LE(dataLen * 2, 40);
for (let i = 0; i < dataLen; i++) {
const sample = Math.round(
Math.sin((2 * Math.PI * freq * i) / SAMPLE_RATE) * 8000,
);
buf.writeInt16LE(sample, 44 + i * 2);
}
return buf;
}
const wav1 = makeWav(440);
const wav2 = makeWav(880);
// ── Local HTTP server: the RSS feed + both audio files ────────────────────
let server: ReturnType<typeof Bun.serve> | null = null;
function feedXml(origin: string): string {
// Distinct pubDates so ep1 (newest) is episodes[0], ep2 older — "next"
// must step DOWN the list toward the older episode.
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Auto Advance Show</title>
<description>auto-advance test feed</description>
<item>
<title>Episode One</title>
<pubDate>2026-08-10T00:00:00Z</pubDate>
<enclosure url="${origin}/e1.wav" length="${wav1.length}" type="audio/wav"/>
</item>
<item>
<title>Episode Two</title>
<pubDate>2026-08-01T00:00:00Z</pubDate>
<enclosure url="${origin}/e2.wav" length="${wav2.length}" type="audio/wav"/>
</item>
</channel></rss>`;
}
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.endsWith(".xml")) {
return new Response(feedXml(url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
}
if (url.pathname.endsWith("e1.wav")) {
return new Response(wav1.buffer as ArrayBuffer, {
headers: { "Content-Type": "audio/wav" },
});
}
if (url.pathname.endsWith("e2.wav")) {
return new Response(wav2.buffer as ArrayBuffer, {
headers: { "Content-Type": "audio/wav" },
});
}
return new Response("not found", { status: 404 });
},
});
// ── Real modules (loaded after env + server are up) ───────────────────────
// @ts-expect-error — bun-only query suffix: distinct module identity that
// loads the real file instead of a leaked mock.module from another test file.
const { useAudio } = await import("../src/hooks/useAudio?auto-advance-test");
const { useFeedStore } = await import("../src/stores/feed");
const { useAudioNavStore, AudioSource } = await import(
"../src/stores/audio-nav"
);
const feedStore = useFeedStore();
const audioNav = useAudioNavStore();
/** Poll `check` every 25ms until truthy; throw after `timeoutMs`. */
async function waitFor(
check: () => boolean,
timeoutMs = 15000,
): Promise<void> {
const start = Date.now();
while (!check()) {
if (Date.now() - start > timeoutMs) {
throw new Error("condition not met in time");
}
await Bun.sleep(25);
}
}
// Subscribe to the local feed through the real store API; unique podcast id
// so the MY_SHOWS queue lookup is deterministic whatever else this worker's
// shared feed store holds.
const feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
const PODCAST_ID = `auto-advance-pod-${process.pid}`;
const feed = await feedStore.addFeed(
{
id: PODCAST_ID,
title: "Auto Advance Show",
description: "auto-advance test feed",
feedUrl,
lastUpdated: new Date(),
isSubscribed: true,
},
"test-source",
);
if (!feed || feed.episodes.length < 2) {
throw new Error("test feed did not load two episodes");
}
const ep1 = feed.episodes[0]; // newest — plays first
const ep2 = feed.episodes[1]; // older — must follow automatically
if (ep1.title !== "Episode One") {
throw new Error("episode order unexpected — ep1 is not the newest");
}
afterAll(() => {
audioNav.reset(); // don't leak nav state into shared-worker tests
server?.stop(true);
rmSync(CONFIG, { recursive: true, force: true });
rmSync(DATA, { recursive: true, force: true });
});
test.skipIf(!hasMpv)(
"episode ending auto-plays the next in the show; the last episode stops",
async () => {
const audio = useAudio();
audioNav.setSource(AudioSource.MY_SHOWS, PODCAST_ID);
// Start the newest episode.
await audio.play(ep1);
expect(audio.isPlaying()).toBe(true);
expect(audio.currentEpisode()?.id).toBe(ep1.id);
// EOF → the next (older) episode starts automatically, and the nav
// index moves with it.
await waitFor(
() =>
audio.currentEpisode()?.id === ep2.id && audio.isPlaying(),
);
expect(audioNav.getCurrentIndex()).toBe(1);
// The last episode ends → playback stops; no wrap-around to ep1.
await waitFor(() => !audio.isPlaying());
expect(audio.currentEpisode()?.id).toBe(ep2.id);
await Bun.sleep(600); // give any (wrong) auto-advance time to fire
expect(audio.currentEpisode()?.id).toBe(ep2.id);
expect(audio.isPlaying()).toBe(false);
await audio.stop();
},
{ timeout: 45000 },
);