fix(portals): a choice made while the opened player closes is the one that starts

The queued entry stays in place while the player the launch opened
closes, so a choice made meanwhile replaces it instead of being stranded
(Stalker) or registering a second replay that starts beside the first
(Xtream). The series launch helpers log through the shared redacting
logger.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 16:15:54 +02:00
1 parent 22fd53f6cf
commit cf191eb1a0
5 files changed
+89 -17

No files matched your search

@@ -72,6 +72,32 @@ describe('StalkerSeriesLaunchQueue', () => {
expect(queue.isLaunching(A)).toBe(false);
});
it('starts the choice held while the player closed, not the earlier one', async () => {
const queue = new StalkerSeriesLaunchQueue();
const replace = deferred<boolean>();
const run = queue.run(
A,
() => Promise.resolve(),
release({ replacePlayer: () => replace.promise })
);
const earlier = jest.fn();
const latest = jest.fn();
queue.hold(A, earlier);
await Promise.resolve();
await Promise.resolve();
// Still launching while the player closes: the viewer's newer pick
// must not be stranded behind the one that triggered the close.
expect(queue.isLaunching(A)).toBe(true);
queue.hold(A, latest);
replace.settle(true);
await run;
expect(earlier).not.toHaveBeenCalled();
expect(latest).toHaveBeenCalledTimes(1);
expect(queue.isLaunching(A)).toBe(false);
});
it('keeps the player and drops the choice when it cannot be replaced', async () => {
const queue = new StalkerSeriesLaunchQueue();
const choice = jest.fn();
@@ -59,10 +59,16 @@ export class StalkerSeriesLaunchQueue {
seriesKey: string,
release: StalkerSeriesLaunchRelease
): Promise<void> {
if (!this.choices.has(seriesKey)) return;
if (!release.stillShown()) {
this.choices.delete(seriesKey);
return;
}
const replaced = await release.replacePlayer();
// The latest choice wins, including one held while the player closed.
const choice = this.choices.get(seriesKey);
this.choices.delete(seriesKey);
if (!choice || !release.stillShown()) return;
if ((await release.replacePlayer()) && release.stillShown()) choice();
if (replaced && release.stillShown()) choice?.();
}
private count(seriesKey: string, delta: number): void {
@@ -287,6 +287,38 @@ describe('openEpisodeExternally', () => {
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false);
});
it('starts only the choice made while the opened player closed', async () => {
const t = host(null);
const opened = session({ id: 'mpv-2' });
t.openExternalPlayback.mockImplementation(async () => {
t.host.externalPlayback.activeSession.set(opened);
return opened;
});
let settleClose: () => void = () => undefined;
t.closeSession.mockImplementation(
() => new Promise<void>((resolve) => (settleClose = resolve))
);
const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
const earlier = jest.fn();
const latest = jest.fn();
queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-3', earlier);
await launch;
await flush();
expect(t.closeSession).toHaveBeenCalledTimes(1);
// Still pending while the player closes: the newer choice replaces
// the queued one instead of starting beside it afterwards.
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(true);
queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-4', latest);
settleClose();
await flush();
expect(earlier).not.toHaveBeenCalled();
expect(latest).toHaveBeenCalledTimes(1);
expect(latest).toHaveBeenCalledWith('episode-4');
expect(t.closeSession).toHaveBeenCalledTimes(1);
});
it('drops the queued choice when the opened player cannot be closed', async () => {
const t = host(null);
const opened = session({ id: 'mpv-2' });
@@ -8,9 +8,14 @@ import type {
ExternalPlayerSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { isLiveExternalPlayerSession } from '@iptvnator/portal/shared/util';
import {
createLogger,
isLiveExternalPlayerSession,
} from '@iptvnator/portal/shared/util';
import { closeRunningExternalSession } from '../vod-details/vod-details-external-session';
const logger = createLogger('SerialDetailsPlayback');
/** The episode ids an external session contributes to the series page. */
export interface ExternalEpisodeSessionIds {
readonly opening: number | null;
@@ -139,8 +144,11 @@ export function queueEpisodeChoice<TEpisode>(
*/
async function replayQueuedChoice(owner: string): Promise<void> {
const queued = queuedChoices.get(owner);
queuedChoices.delete(owner);
if (!queued || queued.host.launchOwner() !== owner) {
if (!queued) {
return;
}
if (queued.host.launchOwner() !== owner) {
queuedChoices.delete(owner);
return;
}
countPending(owner, 1);
@@ -150,8 +158,13 @@ async function replayQueuedChoice(owner: string): Promise<void> {
} finally {
countPending(owner, -1);
}
if (replaced && queued.host.launchOwner() === owner) {
queued.start(queued.episode as never);
// The entry stayed in the map while the player closed, so a choice made
// meanwhile replaced it instead of registering a second replay: the
// latest choice wins.
const latest = queuedChoices.get(owner);
queuedChoices.delete(owner);
if (latest && replaced && latest.host.launchOwner() === owner) {
latest.start(latest.episode as never);
}
}
@@ -176,8 +189,7 @@ function closeOwnedEpisodeSession(
return closeRunningExternalSession(
ownSession,
(running) => host.externalPlayback.closeSession(running),
(message, error) =>
console.warn(`[SerialDetailsPlayback] ${message}`, error)
(message, error) => logger.warn(message, error)
);
}
@@ -13,6 +13,7 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
getSeriesQuickStartAction,
inlineProgressPosition,
} from '@iptvnator/portal/shared/util';
@@ -82,6 +83,7 @@ export class SerialDetailsPlaybackService {
private readonly recordRecentItem = injectXtreamRecentHistory();
private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET);
private readonly seasonWatch = inject(SerialDetailsSeasonWatchService);
private readonly logger = createLogger('SerialDetailsPlayback');
private readonly savePosition = (
playlistId: string,
position: PlaybackPositionData
@@ -380,10 +382,7 @@ export class SerialDetailsPlaybackService {
} catch (error) {
// The toggle itself succeeded; a failed refresh keeps the store
// populated-but-stale, which beats wiping it with a bad read.
console.warn(
'[SerialDetailsPlayback] Store position refresh failed',
error
);
this.logger.warn('Store position refresh failed', error);
}
}
@@ -449,10 +448,7 @@ export class SerialDetailsPlaybackService {
const token = this.pageToken();
const stillShown = () => this.pageToken() === token;
const settled = launch.catch((error) => {
console.warn(
'[SerialDetailsPlayback] External launch failed',
error
);
this.logger.warn('External launch failed', error);
return undefined;
});
void this.playbackPositionState.recordExternalLaunch(