mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
1 parent
36079b89c1
commit
ac23230fe8
3 files changed
+141
-29
No files matched your search
+79
@@ -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);
|
||||
});
|
||||
});
|
||||
+43
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-29
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user