fix(portals): a queued episode choice stays with its series

The choice made while a forced launch settles is kept per playlist:series
owner by the launch helper and only starts if the page still shows that
series, so a Similar-rail navigation cannot play it under another title.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 14:28:56 +02:00
1 parent 5b2fbe296b
commit 39ec848110
3 files changed
+50 -16

No files matched your search

@@ -6,6 +6,7 @@ import type {
import {
isEpisodeLaunchPending,
openEpisodeExternally,
queueEpisodeChoice,
} from './serial-details-external-launch';
const PLAYBACK: ResolvedPortalPlayback = {
@@ -195,6 +196,26 @@ describe('openEpisodeExternally', () => {
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
});
it('starts only the latest queued choice once the launch settled', async () => {
const t = host(null);
let settleLaunch: () => void = () => undefined;
t.openExternalPlayback.mockImplementation(
() => new Promise<void>((resolve) => (settleLaunch = resolve))
);
const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
const start = jest.fn();
queueEpisodeChoice('xtream-1:103', 'episode-1', start);
queueEpisodeChoice('xtream-1:103', 'episode-2', start);
await flush();
expect(start).not.toHaveBeenCalled();
settleLaunch();
await launch;
await flush();
expect(start).toHaveBeenCalledTimes(1);
expect(start).toHaveBeenCalledWith('episode-2');
});
it('keeps the running player when closing it fails', async () => {
const t = host(session());
t.closeSession.mockRejectedValue(new Error('still busy'));
@@ -52,6 +52,29 @@ export function whenEpisodeLaunchesSettle(owner: string | null): Promise<void> {
return tail ? tail.then(() => undefined) : Promise.resolve();
}
/** Per owner, the latest Play/episode choice made while its forced launch settled. */
const queuedChoices = new Map<string, unknown>();
/**
* Keeps the latest choice made while the owner's forced launch settles and
* hands it to `start` once that launch settled; the caller decides whether
* the page still shows the owner by then.
*/
export function queueEpisodeChoice<TEpisode>(
owner: string,
episode: TEpisode,
start: (episode: TEpisode) => void
): void {
queuedChoices.set(owner, episode);
void whenEpisodeLaunchesSettle(owner).then(() => {
const queued = queuedChoices.get(owner) as TEpisode | undefined;
if (queued !== undefined) {
queuedChoices.delete(owner);
start(queued);
}
});
}
/**
* The "…" menu's MPV/VLC launch of an episode. An episode of this series
* still running externally is closed first: with instance reuse off a
@@ -44,7 +44,7 @@ import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.toke
import {
isEpisodeLaunchPending,
openEpisodeExternally,
whenEpisodeLaunchesSettle,
queueEpisodeChoice,
} from './serial-details-external-launch';
import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state';
import {
@@ -228,11 +228,11 @@ export class SerialDetailsPlaybackService {
): void {
// A forced launch still settling owns the next start: the latest
// choice made meanwhile starts once it settled, never beside it.
if (!player && this.forcedLaunchPending()) {
this.queuedEpisode = episode;
void whenEpisodeLaunchesSettle(this.launchOwner()).then(() =>
this.playQueuedEpisode()
);
const owner = this.launchOwner();
if (!player && owner && this.forcedLaunchPending()) {
queueEpisodeChoice(owner, episode, (queued) => {
if (this.launchOwner() === owner) this.playEpisode(queued);
});
return;
}
const playlist = this.xtreamStore.currentPlaylist();
@@ -425,16 +425,6 @@ export class SerialDetailsPlaybackService {
readonly forcedLaunchPending = computed(() =>
isEpisodeLaunchPending(this.launchOwner())
);
/** The choice made while a forced launch settled; only the latest one starts. */
private queuedEpisode: XtreamSerieEpisode | null = null;
private playQueuedEpisode(): void {
const episode = this.queuedEpisode;
this.queuedEpisode = null;
if (episode && !this.forcedLaunchPending()) {
this.playEpisode(episode);
}
}
/** `playlist:series` of the page, null once it is gone or shows another series. */
launchOwner(): string | null {