mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(portals): a queued episode choice replaces the player its launch opened
Replaying a choice held during a forced MPV/VLC launch closed nothing, so with the embedded player configured the inline player mounted next to the external player that had just opened. Both portals now close the session the launch opened before the choice starts, keep the series pending while that player closes, and drop the choice when the player has to stay or the page moved on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac23230fe8
commit
22fd53f6cf
6 files changed
+268
-71
No files matched your search
+67
-16
@@ -1,11 +1,27 @@
|
||||
import { StalkerSeriesLaunchQueue } from './stalker-series-launch-queue';
|
||||
import {
|
||||
StalkerSeriesLaunchQueue,
|
||||
type StalkerSeriesLaunchRelease,
|
||||
} from './stalker-series-launch-queue';
|
||||
|
||||
function deferred(): { promise: Promise<void>; settle: () => void } {
|
||||
let settle!: () => void;
|
||||
const promise = new Promise<void>((resolve) => (settle = resolve));
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
settle: (value: T) => void;
|
||||
} {
|
||||
let settle!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolve) => (settle = resolve));
|
||||
return { promise, settle };
|
||||
}
|
||||
|
||||
function release(
|
||||
overrides: Partial<StalkerSeriesLaunchRelease> = {}
|
||||
): StalkerSeriesLaunchRelease {
|
||||
return {
|
||||
stillShown: () => true,
|
||||
replacePlayer: () => Promise.resolve(true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('StalkerSeriesLaunchQueue', () => {
|
||||
const A = 'playlist-1:100';
|
||||
const B = 'playlist-1:200';
|
||||
@@ -13,11 +29,7 @@ describe('StalkerSeriesLaunchQueue', () => {
|
||||
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 run = queue.run(A, () => launch.promise, release());
|
||||
const first = jest.fn();
|
||||
const second = jest.fn();
|
||||
|
||||
@@ -34,6 +46,47 @@ describe('StalkerSeriesLaunchQueue', () => {
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('replaces the player the launch opened before the held choice starts', async () => {
|
||||
const queue = new StalkerSeriesLaunchQueue();
|
||||
const replace = deferred<boolean>();
|
||||
const replacePlayer = jest.fn().mockReturnValue(replace.promise);
|
||||
const run = queue.run(
|
||||
A,
|
||||
() => Promise.resolve(),
|
||||
release({ replacePlayer })
|
||||
);
|
||||
const choice = jest.fn();
|
||||
queue.hold(A, choice);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// The choice must not play beside the opened player, and nothing
|
||||
// else may start while that player closes.
|
||||
expect(replacePlayer).toHaveBeenCalledTimes(1);
|
||||
expect(choice).not.toHaveBeenCalled();
|
||||
expect(queue.isLaunching(A)).toBe(true);
|
||||
|
||||
replace.settle(true);
|
||||
await run;
|
||||
expect(choice).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();
|
||||
const run = queue.run(
|
||||
A,
|
||||
() => Promise.resolve(),
|
||||
release({ replacePlayer: () => Promise.resolve(false) })
|
||||
);
|
||||
queue.hold(A, choice);
|
||||
|
||||
await run;
|
||||
expect(choice).not.toHaveBeenCalled();
|
||||
expect(queue.isLaunching(A)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a choice for one series when a launch of another settles first', async () => {
|
||||
const queue = new StalkerSeriesLaunchQueue();
|
||||
const launchA = deferred();
|
||||
@@ -41,13 +94,9 @@ describe('StalkerSeriesLaunchQueue', () => {
|
||||
const runA = queue.run(
|
||||
A,
|
||||
() => launchA.promise,
|
||||
() => false
|
||||
);
|
||||
const runB = queue.run(
|
||||
B,
|
||||
() => launchB.promise,
|
||||
() => true
|
||||
release({ stillShown: () => false })
|
||||
);
|
||||
const runB = queue.run(B, () => launchB.promise, release());
|
||||
const choiceB = jest.fn();
|
||||
queue.hold(B, choiceB);
|
||||
|
||||
@@ -65,14 +114,16 @@ describe('StalkerSeriesLaunchQueue', () => {
|
||||
it('drops the choice when the page moved on, even if the launch failed', async () => {
|
||||
const queue = new StalkerSeriesLaunchQueue();
|
||||
const choice = jest.fn();
|
||||
const replacePlayer = jest.fn();
|
||||
const run = queue.run(
|
||||
A,
|
||||
() => Promise.reject(new Error('launch failed')),
|
||||
() => false
|
||||
release({ stillShown: () => false, replacePlayer })
|
||||
);
|
||||
queue.hold(A, choice);
|
||||
|
||||
await expect(run).rejects.toThrow('launch failed');
|
||||
expect(replacePlayer).not.toHaveBeenCalled();
|
||||
expect(choice).not.toHaveBeenCalled();
|
||||
expect(queue.isLaunching(A)).toBe(false);
|
||||
});
|
||||
|
||||
+46
-13
@@ -1,43 +1,76 @@
|
||||
/** What releasing a held choice needs from the page once its launch settled. */
|
||||
export interface StalkerSeriesLaunchRelease {
|
||||
/** Whether the page still shows the series: a choice the viewer left behind is dropped. */
|
||||
readonly stillShown: () => boolean;
|
||||
/** Closes the player the launch opened; false keeps it and drops the choice. */
|
||||
readonly replacePlayer: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* choice waits and, once the launch settled, replaces the player it opened
|
||||
* instead of playing beside it; 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 launching = new Map<string, number>();
|
||||
private readonly choices = new Map<string, () => void>();
|
||||
|
||||
/** True while a launch of `seriesKey` is mid-flight. */
|
||||
/** True while a launch of `seriesKey`, or the release of its held choice, is mid-flight. */
|
||||
isLaunching(seriesKey: string): boolean {
|
||||
return this.launching.has(seriesKey);
|
||||
return (this.launching.get(seriesKey) ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Holds `choice` until the launch of `seriesKey` settles, replacing an earlier one. */
|
||||
/**
|
||||
* Holds `choice` until the launch of `seriesKey` settles, replacing an
|
||||
* earlier one. The choice must start the episode directly: the series
|
||||
* still counts as launching when it runs.
|
||||
*/
|
||||
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.
|
||||
* Runs `launch`, then the choice held for `seriesKey` meanwhile, after
|
||||
* `replacePlayer` closed what the launch opened. The series counts as
|
||||
* launching until that choice started, so nothing slips in between.
|
||||
*/
|
||||
async run(
|
||||
seriesKey: string,
|
||||
launch: () => Promise<void>,
|
||||
stillShown: () => boolean
|
||||
release: StalkerSeriesLaunchRelease
|
||||
): Promise<void> {
|
||||
this.launching.add(seriesKey);
|
||||
this.count(seriesKey, 1);
|
||||
try {
|
||||
await launch();
|
||||
} finally {
|
||||
try {
|
||||
await this.releaseChoice(seriesKey, release);
|
||||
} finally {
|
||||
this.count(seriesKey, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async releaseChoice(
|
||||
seriesKey: string,
|
||||
release: StalkerSeriesLaunchRelease
|
||||
): Promise<void> {
|
||||
const choice = this.choices.get(seriesKey);
|
||||
this.choices.delete(seriesKey);
|
||||
if (!choice || !release.stillShown()) return;
|
||||
if ((await release.replacePlayer()) && release.stillShown()) choice();
|
||||
}
|
||||
|
||||
private count(seriesKey: string, delta: number): void {
|
||||
const next = (this.launching.get(seriesKey) ?? 0) + delta;
|
||||
if (next > 0) {
|
||||
this.launching.set(seriesKey, next);
|
||||
} else {
|
||||
this.launching.delete(seriesKey);
|
||||
const choice = this.choices.get(seriesKey);
|
||||
this.choices.delete(seriesKey);
|
||||
if (choice && stillShown()) choice();
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-11
@@ -46,6 +46,7 @@ import {
|
||||
import {
|
||||
ExternalPlayerName,
|
||||
PlaybackPositionData,
|
||||
PlayerContentInfo,
|
||||
ResolvedPortalPlayback,
|
||||
TmdbEnrichedCastMember,
|
||||
XtreamSerieEpisode,
|
||||
@@ -1033,12 +1034,21 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
) {
|
||||
const seriesKey = this.currentSeriesKey();
|
||||
if (this.launchQueue.isLaunching(seriesKey)) {
|
||||
// The launch cannot be cancelled: the choice plays once it settles.
|
||||
// The launch cannot be cancelled: the choice replaces its player
|
||||
// once it settled.
|
||||
this.launchQueue.hold(seriesKey, () =>
|
||||
this.onEpisodeClicked(episode, startTimeOverride, forcePlayer)
|
||||
this.startEpisode(episode, startTimeOverride, forcePlayer)
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.startEpisode(episode, startTimeOverride, forcePlayer);
|
||||
}
|
||||
|
||||
private startEpisode(
|
||||
episode: XtreamSerieEpisode,
|
||||
startTimeOverride?: number,
|
||||
forcePlayer?: ExternalPlayerName
|
||||
): void {
|
||||
const item = this.displayItem();
|
||||
const episodeState = resolveSelectedStalkerEpisodeState({
|
||||
episodesBySeason: this.mappedSeasons(),
|
||||
@@ -1259,14 +1269,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
// `closeInlinePlayer()` just retired `request`'s generation; a fresh
|
||||
// one covers the close round trip, the identity check the series.
|
||||
const generation = this.seriesPlaybackRequestGeneration;
|
||||
const own = playback.contentInfo;
|
||||
const replaced = await replaceOwnedExternalSession(
|
||||
this.externalPlayback,
|
||||
(info) =>
|
||||
info.contentType === 'episode' &&
|
||||
info.playlistId === own?.playlistId &&
|
||||
info.seriesXtreamId === own?.seriesXtreamId,
|
||||
(message, error) => this.logger.warn(message, error)
|
||||
const replaced = await this.replaceOwnExternalSession(
|
||||
playback.contentInfo
|
||||
);
|
||||
if (
|
||||
!replaced ||
|
||||
@@ -1289,6 +1293,20 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes an episode of this series still running externally; false keeps it. */
|
||||
private replaceOwnExternalSession(
|
||||
own: PlayerContentInfo | undefined
|
||||
): Promise<boolean> {
|
||||
return replaceOwnedExternalSession(
|
||||
this.externalPlayback,
|
||||
(info) =>
|
||||
info.contentType === 'episode' &&
|
||||
info.playlistId === own?.playlistId &&
|
||||
info.seriesXtreamId === own?.seriesXtreamId,
|
||||
(message, error) => this.logger.warn(message, error)
|
||||
);
|
||||
}
|
||||
|
||||
private async startPlayback(
|
||||
cmd: string | undefined,
|
||||
title: string | undefined,
|
||||
@@ -1356,7 +1374,14 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
forcePlayer,
|
||||
request
|
||||
),
|
||||
() => this.currentSeriesKey() === pendingSeriesId
|
||||
{
|
||||
stillShown: () =>
|
||||
this.currentSeriesKey() === pendingSeriesId,
|
||||
replacePlayer: () =>
|
||||
this.replaceOwnExternalSession(
|
||||
resolvedPlayback.contentInfo
|
||||
),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
void this.portalPlayer.openResolvedPlayback(
|
||||
|
||||
+51
-2
@@ -245,8 +245,8 @@ describe('openEpisodeExternally', () => {
|
||||
// again: the reopened page's start runs, with its episode.
|
||||
const startOld = jest.fn();
|
||||
const startNew = jest.fn();
|
||||
queueEpisodeChoice('xtream-1:103', 'episode-1', startOld);
|
||||
queueEpisodeChoice('xtream-1:103', 'episode-2', startNew);
|
||||
queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-1', startOld);
|
||||
queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-2', startNew);
|
||||
await flush();
|
||||
expect(startOld).not.toHaveBeenCalled();
|
||||
|
||||
@@ -258,6 +258,55 @@ describe('openEpisodeExternally', () => {
|
||||
expect(startNew).toHaveBeenCalledWith('episode-2');
|
||||
});
|
||||
|
||||
it('closes the player the launch opened before the queued choice starts', async () => {
|
||||
const t = host(null);
|
||||
const opened = session({ id: 'mpv-2', status: 'launching' });
|
||||
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 start = jest.fn();
|
||||
queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-3', start);
|
||||
|
||||
await launch;
|
||||
await flush();
|
||||
// The choice must not play beside the player that just opened, and
|
||||
// nothing else may start while that player closes.
|
||||
expect(t.closeSession).toHaveBeenCalledWith(opened);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(true);
|
||||
|
||||
settleClose();
|
||||
await flush();
|
||||
expect(start).toHaveBeenCalledWith('episode-3');
|
||||
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the queued choice when the opened player cannot be closed', async () => {
|
||||
const t = host(null);
|
||||
const opened = session({ id: 'mpv-2' });
|
||||
t.openExternalPlayback.mockImplementation(async () => {
|
||||
t.host.externalPlayback.activeSession.set(opened);
|
||||
return opened;
|
||||
});
|
||||
t.closeSession.mockRejectedValue(new Error('busy'));
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const launch = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
|
||||
const start = jest.fn();
|
||||
queueEpisodeChoice(t.host, 'xtream-1:103', 'episode-3', start);
|
||||
|
||||
await launch;
|
||||
await flush();
|
||||
expect(t.closeSession).toHaveBeenCalledWith(opened);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
expect(isEpisodeLaunchPending('xtream-1:103')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a reopened series launch the episode an earlier visit still settles', async () => {
|
||||
const first = host(session());
|
||||
let settleClose: () => void = () => undefined;
|
||||
|
||||
+64
-25
@@ -92,7 +92,14 @@ export function whenEpisodeLaunchesSettle(owner: string | null): Promise<void> {
|
||||
return tail ? tail.then(() => undefined) : Promise.resolve();
|
||||
}
|
||||
|
||||
/** What replaying a queued choice reads from the page that made it. */
|
||||
type QueuedChoiceHost = Pick<
|
||||
SeriesExternalLaunchHost,
|
||||
'externalPlayback' | 'launchOwner'
|
||||
>;
|
||||
|
||||
interface QueuedEpisodeChoice {
|
||||
readonly host: QueuedChoiceHost;
|
||||
readonly episode: unknown;
|
||||
readonly start: (episode: never) => void;
|
||||
}
|
||||
@@ -102,29 +109,76 @@ const queuedChoices = new Map<string, QueuedEpisodeChoice>();
|
||||
|
||||
/**
|
||||
* Keeps the latest choice made while the owner's forced launch settles and
|
||||
* hands it to the `start` that came with it once that launch settled; the
|
||||
* caller decides whether its page still shows the owner by then. One settle
|
||||
* handler per owner: a page reopened meanwhile queues with its own `start`,
|
||||
* and that one runs, not the handler of the page the viewer left.
|
||||
* hands it to the `start` that came with it once that launch settled. One
|
||||
* settle handler per owner: a page reopened meanwhile queues with its own
|
||||
* `host` and `start`, and those run, not the ones of the page the viewer left.
|
||||
*/
|
||||
export function queueEpisodeChoice<TEpisode>(
|
||||
host: QueuedChoiceHost,
|
||||
owner: string,
|
||||
episode: TEpisode,
|
||||
start: (episode: TEpisode) => void
|
||||
): void {
|
||||
const handlerRegistered = queuedChoices.has(owner);
|
||||
queuedChoices.set(owner, {
|
||||
host,
|
||||
episode,
|
||||
start: start as (episode: never) => void,
|
||||
});
|
||||
if (handlerRegistered) {
|
||||
return;
|
||||
}
|
||||
void whenEpisodeLaunchesSettle(owner).then(() => {
|
||||
const queued = queuedChoices.get(owner);
|
||||
queuedChoices.delete(owner);
|
||||
queued?.start(queued.episode as never);
|
||||
});
|
||||
void whenEpisodeLaunchesSettle(owner).then(() => replayQueuedChoice(owner));
|
||||
}
|
||||
|
||||
/**
|
||||
* The settled launch opened a player for the series; the queued choice
|
||||
* replaces it, never plays beside it. The owner stays pending while that
|
||||
* player closes so no other start slips in between. The choice is dropped
|
||||
* when the page moved on meanwhile or the player has to stay.
|
||||
*/
|
||||
async function replayQueuedChoice(owner: string): Promise<void> {
|
||||
const queued = queuedChoices.get(owner);
|
||||
queuedChoices.delete(owner);
|
||||
if (!queued || queued.host.launchOwner() !== owner) {
|
||||
return;
|
||||
}
|
||||
countPending(owner, 1);
|
||||
let replaced = false;
|
||||
try {
|
||||
replaced = await closeOwnedEpisodeSession(queued.host, owner);
|
||||
} finally {
|
||||
countPending(owner, -1);
|
||||
}
|
||||
if (replaced && queued.host.launchOwner() === owner) {
|
||||
queued.start(queued.episode as never);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the external session when it plays an episode of `owner`; false
|
||||
* when it has to stay (the close failed) and nothing may start beside it.
|
||||
*/
|
||||
function closeOwnedEpisodeSession(
|
||||
host: Pick<SeriesExternalLaunchHost, 'externalPlayback'>,
|
||||
owner: string
|
||||
): Promise<boolean> {
|
||||
const session = host.externalPlayback.activeSession();
|
||||
const info = session?.contentInfo;
|
||||
const ownSession =
|
||||
session &&
|
||||
info &&
|
||||
session.status !== 'closed' &&
|
||||
info.contentType === 'episode' &&
|
||||
`${info.playlistId}:${info.seriesXtreamId}` === owner
|
||||
? session
|
||||
: null;
|
||||
return closeRunningExternalSession(
|
||||
ownSession,
|
||||
(running) => host.externalPlayback.closeSession(running),
|
||||
(message, error) =>
|
||||
console.warn(`[SerialDetailsPlayback] ${message}`, error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,22 +237,7 @@ async function launchEpisode(
|
||||
if (host.launchOwner() !== owner) {
|
||||
return;
|
||||
}
|
||||
const session = host.externalPlayback.activeSession();
|
||||
const info = session?.contentInfo;
|
||||
const ownSession =
|
||||
session &&
|
||||
info &&
|
||||
session.status !== 'closed' &&
|
||||
info.contentType === 'episode' &&
|
||||
`${info.playlistId}:${info.seriesXtreamId}` === owner
|
||||
? session
|
||||
: null;
|
||||
const replaced = await closeRunningExternalSession(
|
||||
ownSession,
|
||||
(running) => host.externalPlayback.closeSession(running),
|
||||
(message, error) =>
|
||||
console.warn(`[SerialDetailsPlayback] ${message}`, error)
|
||||
);
|
||||
const replaced = await closeOwnedEpisodeSession(host, owner);
|
||||
if (!replaced || host.launchOwner() !== owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
+4
-4
@@ -207,12 +207,12 @@ export class SerialDetailsPlaybackService {
|
||||
player?: ExternalPlayerName
|
||||
): Promise<ExternalPlayerSession | void> | void {
|
||||
// A forced launch still settling owns the next start: the latest
|
||||
// choice made meanwhile starts once it settled, never beside it.
|
||||
// choice made meanwhile replaces its player once it settled.
|
||||
const owner = this.launchOwner();
|
||||
if (!player && owner && this.forcedLaunchPending()) {
|
||||
queueEpisodeChoice(owner, episode, (queued) => {
|
||||
if (this.launchOwner() === owner) this.playEpisode(queued);
|
||||
});
|
||||
queueEpisodeChoice(this, owner, episode, (queued) =>
|
||||
this.playEpisode(queued)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const playlist = this.xtreamStore.currentPlaylist();
|
||||
|
||||
Reference in new issue
Block a user