From 026a69c14e05e9b720d120d4f7fe28ae4238c55f Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 10:59:56 +0200 Subject: [PATCH] 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 --- .../serial-details-external-launch.spec.ts | 20 ++++++++-- .../serial-details-external-launch.ts | 18 +++++---- .../serial-details-playback.service.ts | 38 ++++++++++--------- 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts index a15d20836..f113274a7 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.spec.ts @@ -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(); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts index 1ac9d9494..4108a23c8 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts @@ -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 { + 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); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 42c08bab6..bae59a29c 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -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( 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 | 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 ); }