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:
@@ -55,10 +55,15 @@ import {
|
|||||||
saveLastPlayerSync,
|
saveLastPlayerSync,
|
||||||
} from "../utils/app-persistence";
|
} from "../utils/app-persistence";
|
||||||
import type { Episode, Progress } from "../types/episode";
|
import type { Episode, Progress } from "../types/episode";
|
||||||
import type { Feed } from "../types/feed";
|
import { useAudioNavStore } from "../stores/audio-nav";
|
||||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
|
||||||
import { useDownloadStore } from "../stores/download";
|
import { useDownloadStore } from "../stores/download";
|
||||||
import { useFeedStore } from "../stores/feed";
|
import { useFeedStore } from "../stores/feed";
|
||||||
|
import { useSearchStore } from "../stores/search";
|
||||||
|
import {
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
queueForSource,
|
||||||
|
} from "../utils/audio-queue";
|
||||||
|
|
||||||
export interface AudioControls {
|
export interface AudioControls {
|
||||||
// Signals (reactive getters)
|
// Signals (reactive getters)
|
||||||
@@ -180,8 +185,10 @@ const PAUSE_WATCH_TICKS = 7;
|
|||||||
|
|
||||||
/** The player process died while we believed playback was live — track
|
/** The player process died while we believed playback was live — track
|
||||||
* ended (mpv quits at EOF) or the process crashed. Persist the final
|
* ended (mpv quits at EOF) or the process crashed. Persist the final
|
||||||
* position and stop polling. */
|
* position and stop polling. `autoAdvance` is true only when the track
|
||||||
function finalizeTrackEnd(): void {
|
* 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);
|
setIsPlaying(false);
|
||||||
stopPolling();
|
stopPolling();
|
||||||
const ep = currentEpisode();
|
const ep = currentEpisode();
|
||||||
@@ -189,6 +196,12 @@ function finalizeTrackEnd(): void {
|
|||||||
const progressStore = useProgressStore();
|
const progressStore = useProgressStore();
|
||||||
progressStore.update(ep.id, position(), duration(), speed());
|
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,
|
/** 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
|
// and reports pause=true there, which would otherwise be
|
||||||
// mistaken for an external pause and never finalize.
|
// mistaken for an external pause and never finalize.
|
||||||
if (!backend.isPlaying()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +288,7 @@ function startPolling(): void {
|
|||||||
// still alive: a dead player while we thought we were paused
|
// still alive: a dead player while we thought we were paused
|
||||||
// means the track ended (mpv quits at EOF) or it crashed.
|
// means the track ended (mpv quits at EOF) or it crashed.
|
||||||
if (!backend.isAlive()) {
|
if (!backend.isAlive()) {
|
||||||
finalizeTrackEnd();
|
finalizeTrackEnd(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const paused = await backend.getPauseState();
|
const paused = await backend.getPauseState();
|
||||||
@@ -335,21 +353,52 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const appStore = useAppStore();
|
||||||
const appStore = useAppStore();
|
const progressStore = useProgressStore();
|
||||||
const progressStore = useProgressStore();
|
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
const vol = volume();
|
||||||
const vol = volume();
|
const spd = storeSpeed || speed();
|
||||||
const spd = storeSpeed || speed();
|
|
||||||
|
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
// Play the downloaded file when present (offline + no network stalls);
|
// Play the downloaded file when present (offline + no network stalls);
|
||||||
// otherwise stream. Cover resolves to the feed art, falling back to the
|
// 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).
|
// episode's own image (feeds added by URL may lack a channel cover).
|
||||||
const downloadStore = useDownloadStore();
|
const downloadStore = useDownloadStore();
|
||||||
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
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
|
// 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
|
// 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
|
// the fetch or play artless. Serve the disk cache synchronously; on a
|
||||||
@@ -360,13 +409,6 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
"bounded",
|
"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, {
|
await b.play(url, {
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
@@ -375,10 +417,8 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
coverArtPath: coverArtPath ?? undefined,
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCurrentEpisode(episode);
|
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
setPosition(startPos);
|
setPosition(startPos);
|
||||||
setSpeed(spd);
|
|
||||||
if (episode.duration) setDuration(episode.duration);
|
if (episode.duration) setDuration(episode.duration);
|
||||||
startedPlayback = true;
|
startedPlayback = true;
|
||||||
|
|
||||||
@@ -387,12 +427,6 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||||
|
|
||||||
// Register with platform media controls
|
// Register with platform media controls
|
||||||
const media = useMediaRegistry();
|
|
||||||
media.setNowPlaying({
|
|
||||||
title: episode.title,
|
|
||||||
artist: podcastTitle || episode.podcastId,
|
|
||||||
duration: episode.duration,
|
|
||||||
});
|
|
||||||
media.setPlaybackState(true);
|
media.setPlaybackState(true);
|
||||||
if (startPos > 0) media.setPosition(startPos);
|
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.
|
* Returns a singleton — all components share the same playback state.
|
||||||
* Registers event bus listeners and cleans them up with onCleanup.
|
* 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 {
|
export function useAudio(): AudioControls {
|
||||||
// Initialize backend on first use
|
// Initialize backend on first use
|
||||||
ensureBackend();
|
ensureBackend();
|
||||||
@@ -793,80 +881,6 @@ export function useAudio(): AudioControls {
|
|||||||
await doSetSpeed(next);
|
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(() => {
|
onCleanup(() => {
|
||||||
refCount--;
|
refCount--;
|
||||||
unsubPlay();
|
unsubPlay();
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ export interface AudioBackend {
|
|||||||
getPauseState(): Promise<boolean | undefined>;
|
getPauseState(): Promise<boolean | undefined>;
|
||||||
/** True while the player process is running (regardless of pause). */
|
/** True while the player process is running (regardless of pause). */
|
||||||
isAlive(): boolean;
|
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;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,13 +596,22 @@ export class MpvBackend implements AudioBackend {
|
|||||||
// play checks it and skips its own stale paused-load.
|
// play checks it and skips its own stale paused-load.
|
||||||
this._intentPlaying = true;
|
this._intentPlaying = true;
|
||||||
await this.runLoadExclusive(async () => {
|
await this.runLoadExclusive(async () => {
|
||||||
// Fast path: this exact URL was PRELOADED paused (boot restore) —
|
// Same episode re-selected (Enter in a list, key-repeat, a
|
||||||
// mpv has been buffering it since boot, so flipping pause off starts
|
// second tap on the playing row): the file is ALREADY in the
|
||||||
// audio ~instantly. Re-acquire the start position only when it
|
// player. Reloading with start=<saved progress> would audibly
|
||||||
// moved meaningfully since the preload (progress saved meanwhile).
|
// skip BACK and repeat already-played audio (saved progress
|
||||||
if (this._loadedUrl === url && this._loadedPaused && !this._ended) {
|
// 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;
|
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]);
|
await this.send(["set_property", "time-pos", target]);
|
||||||
this._position = target;
|
this._position = target;
|
||||||
}
|
}
|
||||||
@@ -782,6 +796,9 @@ class NoopBackend implements AudioBackend {
|
|||||||
isAlive(): boolean {
|
isAlive(): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
getPlaybackError(): string | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
dispose(): void {}
|
dispose(): void {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
src/utils/audio-queue.ts
Normal file
88
src/utils/audio-queue.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -172,6 +172,76 @@ test.skipIf(!hasMpv)(
|
|||||||
{ timeout: 20000 },
|
{ 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)(
|
test.skipIf(!hasMpv)(
|
||||||
"daemon killed mid-play: resume() rejects on the fresh idle daemon; play() recovers a new one",
|
"daemon killed mid-play: resume() rejects on the fresh idle daemon; play() recovers a new one",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
155
tests/audio-queue.test.ts
Normal file
155
tests/audio-queue.test.ts
Normal 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
197
tests/auto-advance.test.ts
Normal 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 },
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user