mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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:
1 parent
22fd53f6cf
commit
cf191eb1a0
5 files changed
+89
-17
No files matched your search
+26
@@ -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();
|
||||
|
||||
+8
-2
@@ -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 {
|
||||
|
||||
+32
@@ -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' });
|
||||
|
||||
+19
-7
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+4
-8
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user