From caa89e3a6ea83a55aa047ff3bef25c364be32265 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 29 Jul 2026 02:41:30 +0200 Subject: [PATCH] fix(portals): a failed close must not leave the page claiming a dead source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `closeSession()` rejected, `startResolvedPlayback` rejected with it and never launched — while `switchToSource` had already marked the destination active and reported the switch as successful. The page then named a source that nothing was playing. The close failure is logged and the replacement starts anyway. A close that rejects usually means the session was already gone, and a possibly-lingering process is the lesser of the two evils: the alternative is a UI that lies about what is on screen. Co-Authored-By: Claude Opus 5 --- .../vod-details-playback.service.spec.ts | 18 ++++++++++++++++++ .../vod-details-playback.service.ts | 14 +++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index 6498e10b9..2ea9153b4 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -242,6 +242,24 @@ describe('VodDetailsPlaybackService — external session ownership', () => { closeSession.mockResolvedValue(undefined); }); + it('still starts the replacement when closing the old player fails', async () => { + activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); + closeSession.mockRejectedValue(new Error('close ipc failed')); + openResolvedPlayback.mockClear(); + + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + }); + + // The switch is already committed — the badge names the new source. + // Bailing out here left the page claiming a source with nothing + // started at all. + expect(openResolvedPlayback).toHaveBeenCalledTimes(1); + + closeSession.mockResolvedValue(undefined); + }); + it('launches only the newest source when two switches overlap', async () => { activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); // One shared promise: both calls see the same running session, so diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index b1343e3d3..b496b7513 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -358,7 +358,19 @@ export class VodDetailsPlaybackService { this.matchedExternalPlayback() ); if (running) { - await this.externalPlayback.closeSession(running); + try { + await this.externalPlayback.closeSession(running); + } catch (error) { + // The switch is already committed — the badge names the new + // source. Giving up here would leave the page claiming a + // source with nothing started, which is worse than the + // pre-existing risk of a lingering process (and a close that + // rejects usually means the session was gone anyway). + this.logger.warn( + 'Closing the previous external player failed; starting the replacement anyway.', + error + ); + } } // Closing is a round-trip, and a second pick across it would otherwise