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.
This commit is contained in:
@@ -26,7 +26,12 @@ 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 +50,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>;
|
||||
@@ -76,6 +83,23 @@ const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
/** True once the current episode has been handed to the backend (play
|
||||
* started). `false` means the episode is only LOADED in the player (e.g.
|
||||
* restored at boot) and the first play action must start the backend
|
||||
* instead of unpausing it. */
|
||||
let startedPlayback = false;
|
||||
|
||||
/** Completion fraction at/above which an episode is NOT restored at boot. */
|
||||
const RESTORE_COMPLETION_THRESHOLD = 0.98;
|
||||
|
||||
/** True when saved progress is below the restore cutoff. Episodes with no
|
||||
* progress (never reached the persist threshold) or unknown duration count
|
||||
* as eligible — they restore from the start. */
|
||||
function isRestoreEligible(progress: Progress | undefined): boolean {
|
||||
if (!progress || progress.duration <= 0) return true;
|
||||
return progress.position / progress.duration < RESTORE_COMPLETION_THRESHOLD;
|
||||
}
|
||||
|
||||
function ensureBackend(): AudioBackend {
|
||||
if (!backend) {
|
||||
const detected = detectPlayers();
|
||||
@@ -101,6 +125,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 {
|
||||
@@ -228,6 +263,11 @@ async function play(episode: Episode): Promise<void> {
|
||||
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,6 +290,48 @@ 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);
|
||||
|
||||
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||
}
|
||||
|
||||
async function pause(): Promise<void> {
|
||||
if (!backend) return;
|
||||
try {
|
||||
@@ -294,7 +376,15 @@ async function togglePlayback(): Promise<void> {
|
||||
if (isPlaying()) {
|
||||
await pause();
|
||||
} else if (currentEpisode()) {
|
||||
if (startedPlayback) {
|
||||
await resume();
|
||||
} else {
|
||||
// Episode is only LOADED (e.g. restored at boot) — the backend
|
||||
// was never started, so unpausing a dead player would fail
|
||||
// silently. Start playback from the saved position instead.
|
||||
const ep = currentEpisode();
|
||||
if (ep) await play(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,9 +401,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) {
|
||||
@@ -402,6 +496,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 +505,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.
|
||||
*
|
||||
@@ -427,6 +562,9 @@ export function useAudio(): AudioControls {
|
||||
if (storeSpeed && storeSpeed !== speed()) {
|
||||
setSpeed(storeSpeed);
|
||||
}
|
||||
|
||||
// Restore the last player session once at boot (loaded, not playing).
|
||||
restoreLastSession().catch(() => {});
|
||||
}
|
||||
|
||||
refCount++;
|
||||
@@ -574,6 +712,7 @@ export function useAudio(): AudioControls {
|
||||
availablePlayers,
|
||||
|
||||
play,
|
||||
load,
|
||||
pause,
|
||||
resume,
|
||||
togglePlayback,
|
||||
|
||||
@@ -409,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
|
||||
@@ -565,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();
|
||||
@@ -670,10 +686,15 @@ function createFeedStore() {
|
||||
selectedFeedId,
|
||||
isLoadingMore,
|
||||
|
||||
/** Resolves once persisted feeds are loaded from disk (before the
|
||||
* background refresh). */
|
||||
whenReady: () => feedsReady,
|
||||
|
||||
// Computed
|
||||
getFilteredFeeds,
|
||||
getAllEpisodesChronological,
|
||||
getFeed,
|
||||
findEpisode,
|
||||
getSelectedFeed,
|
||||
hasMoreEpisodes,
|
||||
isLoadingFeeds,
|
||||
|
||||
@@ -53,11 +53,17 @@ async function initProgress(): Promise<void> {
|
||||
setProgressMap(parsed);
|
||||
}
|
||||
|
||||
// Fire-and-forget init
|
||||
initProgress();
|
||||
// Fire-and-forget init; the promise is exposed via whenReady() so boot-time
|
||||
// consumers (e.g. player-session restore) can await the file load.
|
||||
const progressInit = initProgress();
|
||||
|
||||
function createProgressStore() {
|
||||
return {
|
||||
/**
|
||||
* Resolves once the persisted progress map has been loaded from disk.
|
||||
*/
|
||||
whenReady: () => progressInit,
|
||||
|
||||
/**
|
||||
* Get progress for a specific episode.
|
||||
*/
|
||||
|
||||
@@ -219,9 +219,14 @@ export async function loadLastPlayerFromFile(): Promise<LastPlayerState | null>
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the last-loaded-player marker (overwrite, fire-and-forget) */
|
||||
/** 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 {
|
||||
(async () => {
|
||||
lastPlayerWriteChain = lastPlayerWriteChain.then(async () => {
|
||||
try {
|
||||
await ensureConfigDir();
|
||||
await Bun.write(
|
||||
@@ -231,7 +236,12 @@ export function saveLastPlayerToFile(state: LastPlayerState): void {
|
||||
} 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
|
||||
|
||||
195
tests/restore-session.test.ts
Normal file
195
tests/restore-session.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* restore-session.test.ts — "load the last player session at boot" feature.
|
||||
*
|
||||
* useAudio persists which episode is loaded in the player (on play/load/stop
|
||||
* and synchronously at exit), and `restoreLastSession()` reloads it at boot
|
||||
* PAUSED at its saved position — never autostarted — skipping episodes at or
|
||||
* above 98% completion. The first play action must START the backend (a
|
||||
* restored episode was never handed to it), not unpause a dead player.
|
||||
*
|
||||
* Strategy — the suite shares bun test workers across files, so OTHER test
|
||||
* files' `mock.module("../src/hooks/useAudio")` leaks into this file's module
|
||||
* registry, and store singletons may already exist. Hence:
|
||||
* - the real useAudio is imported via a `?restore-test` query suffix, which
|
||||
* bun treats as a distinct module identity and loads from disk, bypassing
|
||||
* the suite's useAudio mock (verified: query-suffixed imports are not
|
||||
* intercepted by mock.module);
|
||||
* - feeds are injected through the REAL feed store's public addFeed() API
|
||||
* against a local RSS server (no config seeding — works on whatever
|
||||
* singleton state this worker holds), and progress through update().
|
||||
* Audio is the no-op backend via PODTUI_AUDIO_BACKEND=none.
|
||||
*/
|
||||
import { test, expect, afterAll } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
|
||||
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-restore-"));
|
||||
const DATA = mkdtempSync(join(tmpdir(), "podtui-restore-data-"));
|
||||
process.env.XDG_CONFIG_HOME = CONFIG;
|
||||
process.env.XDG_DATA_HOME = DATA;
|
||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||
|
||||
// ── Local RSS feed server (episode ids = feedUrl#index) ────────────────────
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
function feedXml(origin: string): string {
|
||||
const items = ["Episode One", "Episode Two"]
|
||||
.map(
|
||||
(title) => `<item>
|
||||
<title>${title}</title>
|
||||
<pubDate>2026-08-10T00:00:00Z</pubDate>
|
||||
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Restore Test Show</title>
|
||||
<description>restore-session test feed</description>
|
||||
${items}
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml(url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
// ── Real modules (loaded after env + server are up) ───────────────────────
|
||||
// @ts-expect-error — bun-only query suffix: distinct module identity that
|
||||
// loads the real file instead of a leaked mock.module from another test file.
|
||||
const { useAudio, restoreLastSession } = await import("../src/hooks/useAudio?restore-test");
|
||||
const { useFeedStore } = await import("../src/stores/feed");
|
||||
const { useProgressStore } = await import("../src/stores/progress");
|
||||
const { saveLastPlayerToFile, waitForLastPlayerWrite } = await import(
|
||||
"../src/utils/app-persistence"
|
||||
);
|
||||
|
||||
const feedStore = useFeedStore();
|
||||
const progressStore = useProgressStore();
|
||||
|
||||
// Subscribe to the local feed through the real store API.
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
|
||||
const feed = await feedStore.addFeed(
|
||||
{
|
||||
id: feedUrl,
|
||||
title: "Restore Test Show",
|
||||
description: "restore-session test feed",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
},
|
||||
"test-source",
|
||||
);
|
||||
if (!feed || feed.episodes.length < 2) {
|
||||
throw new Error("test feed did not load two episodes");
|
||||
}
|
||||
const ep1 = feed.episodes[0];
|
||||
const ep2 = feed.episodes[1];
|
||||
|
||||
// Simulate a previous session: ep1 loaded, 20% through, marker persisted.
|
||||
progressStore.update(ep1.id, 120, 600);
|
||||
saveLastPlayerToFile({ episodeId: ep1.id, timestamp: new Date() });
|
||||
await waitForLastPlayerWrite();
|
||||
|
||||
/** Rewrite the marker as if a previous session had ended this way. */
|
||||
async function writeMarker(episodeId: string | null): Promise<void> {
|
||||
saveLastPlayerToFile({ episodeId, timestamp: new Date() });
|
||||
await waitForLastPlayerWrite();
|
||||
}
|
||||
|
||||
/** Read the current last-player marker from disk. */
|
||||
async function readMarker(): Promise<{ episodeId: string | null } | null> {
|
||||
const file = Bun.file(join(CONFIG, "podtui", "last-player.json"));
|
||||
if (!(await file.exists())) return null;
|
||||
return (await file.json()) as { episodeId: string | null };
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
server?.stop(true);
|
||||
rmSync(CONFIG, { recursive: true, force: true });
|
||||
rmSync(DATA, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("restore loads the last player episode paused, without autostart", async () => {
|
||||
const audio = useAudio(); // boot trigger also fires restoreLastSession()
|
||||
await restoreLastSession();
|
||||
|
||||
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
||||
expect(audio.isPlaying()).toBe(false);
|
||||
// Position reflects where playback will resume — the player tab shows it.
|
||||
expect(audio.position()).toBe(120);
|
||||
});
|
||||
|
||||
test("first play on a restored episode starts playback from saved progress", async () => {
|
||||
const audio = useAudio();
|
||||
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
||||
|
||||
await audio.togglePlayback();
|
||||
|
||||
expect(audio.isPlaying()).toBe(true);
|
||||
expect(audio.position()).toBe(120);
|
||||
|
||||
// A second toggle pauses (normal pause path) — no backend restart.
|
||||
await audio.togglePlayback();
|
||||
expect(audio.isPlaying()).toBe(false);
|
||||
});
|
||||
|
||||
test("restore skips episodes at or above 98% completion", async () => {
|
||||
const audio = useAudio();
|
||||
await audio.stop(); // clear the previously restored episode
|
||||
await waitForLastPlayerWrite(); // stop()'s null marker has landed
|
||||
await writeMarker(ep1.id); // stop() cleared the marker; restore needs it
|
||||
|
||||
// Exactly 98% — boundary: NOT restored.
|
||||
progressStore.update(ep1.id, 588, 600);
|
||||
await restoreLastSession();
|
||||
expect(audio.currentEpisode()).toBeNull();
|
||||
|
||||
// Just under 98% — restored.
|
||||
progressStore.update(ep1.id, 587, 600);
|
||||
await restoreLastSession();
|
||||
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
||||
expect(audio.position()).toBe(587);
|
||||
|
||||
await audio.stop();
|
||||
});
|
||||
|
||||
test("restore no-ops when the player was empty at last quit", async () => {
|
||||
const audio = useAudio();
|
||||
await writeMarker(null);
|
||||
|
||||
await restoreLastSession();
|
||||
expect(audio.currentEpisode()).toBeNull();
|
||||
});
|
||||
|
||||
test("restore no-ops when the episode is no longer in any feed", async () => {
|
||||
const audio = useAudio();
|
||||
await writeMarker("gone-ep");
|
||||
|
||||
await restoreLastSession();
|
||||
expect(audio.currentEpisode()).toBeNull();
|
||||
});
|
||||
|
||||
test("play persists the marker; stop clears it", async () => {
|
||||
const audio = useAudio();
|
||||
|
||||
await audio.play(ep2);
|
||||
await waitForLastPlayerWrite();
|
||||
expect((await readMarker())?.episodeId).toBe(ep2.id);
|
||||
|
||||
await audio.stop();
|
||||
await waitForLastPlayerWrite();
|
||||
expect((await readMarker())?.episodeId).toBeNull();
|
||||
});
|
||||
@@ -51,6 +51,7 @@ const stubAudio: AudioControls = {
|
||||
currentEpisode: () => null,
|
||||
availablePlayers: () => [],
|
||||
play: async () => {},
|
||||
load: async () => {},
|
||||
pause: async () => {},
|
||||
resume: async () => {},
|
||||
togglePlayback: async () => {},
|
||||
|
||||
Reference in New Issue
Block a user