fix(portals): a settling launch keeps the choices other series hold

The held episode choices live in a per-series queue: the series view is
reused across series, so a launch of one series settling no longer drops
what a viewer chose for another series whose launch is still in flight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 15:39:33 +02:00
1 parent 36079b89c1
commit ac23230fe8
3 files changed
+141 -29

No files matched your search

@@ -0,0 +1,79 @@
import { StalkerSeriesLaunchQueue } from './stalker-series-launch-queue';
function deferred(): { promise: Promise<void>; settle: () => void } {
let settle!: () => void;
const promise = new Promise<void>((resolve) => (settle = resolve));
return { promise, settle };
}
describe('StalkerSeriesLaunchQueue', () => {
const A = 'playlist-1:100';
const B = 'playlist-1:200';
it('plays the last choice held during a launch once it settles', async () => {
const queue = new StalkerSeriesLaunchQueue();
const launch = deferred();
const run = queue.run(
A,
() => launch.promise,
() => true
);
const first = jest.fn();
const second = jest.fn();
expect(queue.isLaunching(A)).toBe(true);
queue.hold(A, first);
queue.hold(A, second);
expect(second).not.toHaveBeenCalled();
launch.settle();
await run;
expect(queue.isLaunching(A)).toBe(false);
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
it('keeps a choice for one series when a launch of another settles first', async () => {
const queue = new StalkerSeriesLaunchQueue();
const launchA = deferred();
const launchB = deferred();
const runA = queue.run(
A,
() => launchA.promise,
() => false
);
const runB = queue.run(
B,
() => launchB.promise,
() => true
);
const choiceB = jest.fn();
queue.hold(B, choiceB);
// The page shows B now: A settling must not touch B's choice.
launchA.settle();
await runA;
expect(choiceB).not.toHaveBeenCalled();
expect(queue.isLaunching(B)).toBe(true);
launchB.settle();
await runB;
expect(choiceB).toHaveBeenCalledTimes(1);
});
it('drops the choice when the page moved on, even if the launch failed', async () => {
const queue = new StalkerSeriesLaunchQueue();
const choice = jest.fn();
const run = queue.run(
A,
() => Promise.reject(new Error('launch failed')),
() => false
);
queue.hold(A, choice);
await expect(run).rejects.toThrow('launch failed');
expect(choice).not.toHaveBeenCalled();
expect(queue.isLaunching(A)).toBe(false);
});
});
@@ -0,0 +1,43 @@
/**
* Episode choices made while a forced MPV/VLC launch of their series is
* mid-flight. The launch IPC cannot be cancelled by a later request, so the
* choice waits and plays once the launch settles instead of opening a second
* player; the last choice per series wins.
*
* Keyed by `playlist:series`: the series view is reused across series, so a
* launch of one series settling must not drop what another series holds.
*/
export class StalkerSeriesLaunchQueue {
private readonly launching = new Set<string>();
private readonly choices = new Map<string, () => void>();
/** True while a launch of `seriesKey` is mid-flight. */
isLaunching(seriesKey: string): boolean {
return this.launching.has(seriesKey);
}
/** Holds `choice` until the launch of `seriesKey` settles, replacing an earlier one. */
hold(seriesKey: string, choice: () => void): void {
this.choices.set(seriesKey, choice);
}
/**
* Runs `launch`, then the choice held for `seriesKey` meanwhile, unless
* `stillShown` reports that the page moved on to another series.
*/
async run(
seriesKey: string,
launch: () => Promise<void>,
stillShown: () => boolean
): Promise<void> {
this.launching.add(seriesKey);
try {
await launch();
} finally {
this.launching.delete(seriesKey);
const choice = this.choices.get(seriesKey);
this.choices.delete(seriesKey);
if (choice && stillShown()) choice();
}
}
}
@@ -100,6 +100,7 @@ import {
} from '@iptvnator/services';
import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service';
import { StalkerSeriesHeroPresenter } from './stalker-series-hero.presenter';
import { StalkerSeriesLaunchQueue } from './stalker-series-launch-queue';
import { StalkerSeriesMenuService } from './stalker-series-menu.service';
import {
getStalkerSeriesQuickStartButton,
@@ -289,13 +290,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
* starting. Provider series ids are playlist-scoped.
*/
private readonly pendingStartSeriesIds = signal<readonly string[]>([]);
/**
* `playlist:series` keys whose forced MPV/VLC launch is mid-flight. The
* launch IPC cannot be cancelled, so an episode chosen meanwhile waits
* in `queuedEpisodeChoice` instead of opening a second player.
*/
private readonly launchingSeriesIds = new Set<string>();
private queuedEpisodeChoice: (() => void) | null = null;
/** Episode choices made while a forced MPV/VLC launch is mid-flight. */
private readonly launchQueue = new StalkerSeriesLaunchQueue();
/** `playlist:series` of the series on screen; provider ids collide across playlists. */
readonly currentSeriesKey = computed(
() =>
@@ -1035,10 +1031,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
startTimeOverride?: number,
forcePlayer?: ExternalPlayerName
) {
if (this.launchingSeriesIds.has(this.currentSeriesKey())) {
// The last choice made during the launch plays once it settles.
this.queuedEpisodeChoice = () =>
this.onEpisodeClicked(episode, startTimeOverride, forcePlayer);
const seriesKey = this.currentSeriesKey();
if (this.launchQueue.isLaunching(seriesKey)) {
// The launch cannot be cancelled: the choice plays once it settles.
this.launchQueue.hold(seriesKey, () =>
this.onEpisodeClicked(episode, startTimeOverride, forcePlayer)
);
return;
}
const item = this.displayItem();
@@ -1350,17 +1348,16 @@ export class StalkerSeriesViewComponent implements OnDestroy {
if (forcePlayer) {
// Awaited so the start stays pending through the close of the
// previous player and the launch itself.
this.launchingSeriesIds.add(pendingSeriesId);
try {
await this.openEpisodeExternally(
resolvedPlayback,
forcePlayer,
request
);
} finally {
this.launchingSeriesIds.delete(pendingSeriesId);
this.playQueuedEpisodeChoice(pendingSeriesId);
}
await this.launchQueue.run(
pendingSeriesId,
() =>
this.openEpisodeExternally(
resolvedPlayback,
forcePlayer,
request
),
() => this.currentSeriesKey() === pendingSeriesId
);
} else {
void this.portalPlayer.openResolvedPlayback(
resolvedPlayback,
@@ -1389,13 +1386,6 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
}
/** Plays the choice held during a launch, unless the page moved on meanwhile. */
private playQueuedEpisodeChoice(seriesKey: string): void {
const queued = this.queuedEpisodeChoice;
this.queuedEpisodeChoice = null;
if (queued && this.currentSeriesKey() === seriesKey) queued();
}
private isPlaybackRequestCurrent(
request: StalkerSeriesPlaybackRequestContext
): boolean {