fix(portals): drop a forced episode launch once the page moved on

Closing the previous external session takes time; a launch whose series
page is gone or shows another series by then no longer starts. The
service clears its bindings on destroy and exposes the page's
playlist:series owner for that check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 10:59:56 +02:00
1 parent d79e6504a5
commit 026a69c14e
3 files changed
+48 -28

No files matched your search

@@ -37,17 +37,18 @@ describe('openEpisodeExternally', () => {
function host(active: ExternalPlayerSession | null) {
const openExternalPlayback = jest.fn().mockResolvedValue(undefined);
const closeSession = jest.fn().mockResolvedValue(undefined);
const launchOwner = jest.fn().mockReturnValue('xtream-1:103');
return {
openExternalPlayback,
closeSession,
launchOwner,
host: {
portalPlayer: { openExternalPlayback },
externalPlayback: {
activeSession: () => active,
closeSession,
},
currentPlaylistId: () => 'xtream-1',
selectedItem: () => ({ series_id: '103' }),
launchOwner,
},
};
}
@@ -79,10 +80,23 @@ describe('openEpisodeExternally', () => {
expect(t.openExternalPlayback).toHaveBeenCalledWith(PLAYBACK, 'mpv');
});
it('drops the launch when the page moved on while the close ran', async () => {
const t = host(session());
t.closeSession.mockImplementation(async () => {
t.launchOwner.mockReturnValue('xtream-1:999');
});
await openEpisodeExternally(t.host, PLAYBACK, 'mpv');
expect(t.closeSession).toHaveBeenCalledTimes(1);
expect(t.openExternalPlayback).not.toHaveBeenCalled();
});
it('keeps the running player when closing it fails', async () => {
const t = host(session());
t.closeSession.mockRejectedValue(new Error('still busy'));
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const warn = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
await expect(
openEpisodeExternally(t.host, PLAYBACK, 'mpv')
).resolves.toBeUndefined();
@@ -16,21 +16,26 @@ export interface SeriesExternalLaunchHost {
PortalExternalPlayback,
'activeSession' | 'closeSession'
>;
currentPlaylistId(): string;
selectedItem(): { series_id?: string | number } | null;
/** `playlist:series` on screen, null once the page is gone. */
launchOwner(): string | null;
}
/**
* The "…" menu's MPV/VLC launch of an episode. An episode of this series
* still running externally is closed first: with instance reuse off a
* second detached player would start beside it. When that close fails the
* running player stays and nothing new launches.
* second detached player would start beside it. When that close fails, or
* the user moved on while it ran, the running player stays and nothing new
* launches.
*/
export async function openEpisodeExternally(
host: SeriesExternalLaunchHost,
playback: ResolvedPortalPlayback,
player: ExternalPlayerName
): Promise<ExternalPlayerSession | void> {
const owner = host.launchOwner();
if (!owner) {
return;
}
const session = host.externalPlayback.activeSession();
const info = session?.contentInfo;
const ownSession =
@@ -38,8 +43,7 @@ export async function openEpisodeExternally(
info &&
session.status !== 'closed' &&
info.contentType === 'episode' &&
info.playlistId === host.currentPlaylistId() &&
info.seriesXtreamId === Number(host.selectedItem()?.series_id ?? 0)
`${info.playlistId}:${info.seriesXtreamId}` === owner
? session
: null;
const replaced = await closeRunningExternalSession(
@@ -48,7 +52,7 @@ export async function openEpisodeExternally(
(message, error) =>
console.warn(`[SerialDetailsPlayback] ${message}`, error)
);
if (!replaced) {
if (!replaced || host.launchOwner() !== owner) {
return;
}
return host.portalPlayer.openExternalPlayback(playback, player);
@@ -72,11 +72,15 @@ export class SerialDetailsPlaybackService {
private readonly recordRecentItem = injectXtreamRecentHistory();
private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET);
private readonly seasonWatch = inject(SerialDetailsSeasonWatchService);
private readonly savePosition = (
playlistId: string,
position: PlaybackPositionData
) => this.playbackPositions.savePlaybackPosition(playlistId, position);
private readonly bindings = signal<SerialDetailsPlaybackBindings | null>(
null
);
readonly currentPlaylistId = computed(
private readonly currentPlaylistId = computed(
() => this.xtreamStore.currentPlaylist()?.id ?? ''
);
private readonly playbackPositionState =
@@ -114,6 +118,8 @@ export class SerialDetailsPlaybackService {
);
constructor() {
// A launch still closing its predecessor must not outlive the page.
inject(DestroyRef).onDestroy(() => this.bindings.set(null));
effect(() => {
const session = this.externalPlayback.activeSession();
const selectedItem = this.selectedItem();
@@ -262,18 +268,16 @@ export class SerialDetailsPlaybackService {
playPreviousEpisode(): void {
const previous = this.inlineEpisodeState()?.previous;
if (!previous) {
return;
if (previous) {
this.playEpisode(previous);
}
this.playEpisode(previous);
}
playNextEpisode(): void {
const next = this.inlineEpisodeState()?.next;
if (!next) {
return;
if (next) {
this.playEpisode(next);
}
this.playEpisode(next);
}
handleInlinePlaybackEnded(): void {
@@ -322,11 +326,7 @@ export class SerialDetailsPlaybackService {
void this.playbackPositionState.recordExternalLaunch(
request.playback,
launch,
(playlistId, position) =>
this.playbackPositions.savePlaybackPosition(
playlistId,
position
)
this.savePosition
);
}
@@ -409,10 +409,16 @@ export class SerialDetailsPlaybackService {
);
}
selectedItem(): XtreamSerieDetailsView | null {
private selectedItem(): XtreamSerieDetailsView | null {
return this.bindings()?.selectedItem() ?? null;
}
/** `playlist:series` of the page, null once it is gone or shows another series. */
launchOwner(): string | null {
const seriesId = this.selectedItem()?.series_id;
return seriesId ? `${this.currentPlaylistId()}:${seriesId}` : null;
}
private startPlayback(
playback: ResolvedPortalPlayback,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null,
@@ -431,11 +437,7 @@ export class SerialDetailsPlaybackService {
player
? openEpisodeExternally(this, playback, player)
: this.portalPlayer.openResolvedPlayback(playback, true),
(playlistId, position) =>
this.playbackPositions.savePlaybackPosition(
playlistId,
position
)
this.savePosition
);
}