From efd70b75e6f5c1282bec7292b7771705f71cc7b7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 10:32:22 +0200 Subject: [PATCH] fix(portals): second review round on the cinematic details - The movie menu's MPV/VLC launch is a regular route-owned start with the player forced: a running external session is replaced through the same guard instead of a second player starting beside it. - The trailer-backdrop setting is shown for every player; it is a details-page feature, not a web-player control. - The series menu matches the recent list by content type as well, so a movie sharing the provider id does not stand in for the series row, and the hide action follows the refreshed list instead of a permanent hidden marker. Co-Authored-By: Claude Fable 5.1 --- .../settings-playback-section.component.html | 55 ++++++++++--------- .../serial-details-menu.service.ts | 19 ++++--- .../serial-details.component.spec.ts | 27 ++------- .../vod-details/vod-details-menu.service.ts | 2 +- .../vod-details-playback.service.spec.ts | 37 +++++++++++++ .../vod-details-playback.service.ts | 42 +++++++------- 6 files changed, 104 insertions(+), 78 deletions(-) diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index 810c05b29..1ee3d3b39 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -114,33 +114,6 @@ -
-
-

- {{ 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate }} -

-

- {{ - 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION' - | translate - }} -

-
-
- -
-
-
} + +
+
+

+ {{ 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND' | translate }} +

+

+ {{ + 'SETTINGS.PLAY_TRAILERS_IN_BACKGROUND_DESCRIPTION' + | translate + }} +

+
+
+ +
+
+ @if (supportsUpNextCard()) {
(null); - /** Set once "Hide from Continue Watching" ran for the current series. */ - private readonly hiddenSeriesIds = signal>(new Set()); bind(bindings: SerialDetailsMenuBindings): void { this.bindings.set(bindings); @@ -111,18 +109,20 @@ export class SerialDetailsMenuService { /** Whether the series row sits in this playlist's recently viewed list. */ private readonly inContinueWatching = computed(() => { const seriesId = this.seriesId(); - if ( - !seriesId || - !this.dataSource || - this.hiddenSeriesIds().has(seriesId) - ) { + if (!seriesId || !this.dataSource) { return false; } + // Xtream ids collide across live, movies and series: the row has to + // be the series' own. return ( this.hasProgress() && this.xtreamStore .recentItems() - .some((item) => Number(item.xtream_id) === seriesId) + .some( + (item) => + item.type === 'series' && + Number(item.xtream_id) === seriesId + ) ); }); @@ -281,7 +281,8 @@ export class SerialDetailsMenuService { return; } await this.dataSource.removeRecentItem(content.id, playlistId); - this.hiddenSeriesIds.update((ids) => new Set(ids).add(seriesId)); + // The refreshed list drops the row, and brings it back once the + // series is played again. this.xtreamStore.loadRecentItems({ id: playlistId }); this.notify('PORTALS.DETAIL.HIDDEN_FROM_CONTINUE_WATCHING'); } catch (error) { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 956994ebb..de178297a 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -819,44 +819,29 @@ describe('SerialDetailsComponent', () => { expect(quickStartButton()?.textContent).toContain('S01E01'); }); - it('records history and the launch position when the menu opens the next episode in MPV', async () => { - openExternalPlayback.mockResolvedValue({ - id: 'mpv-session-2', - player: 'mpv', - status: 'opened', - }); + it('records history when the menu opens the next episode in MPV', async () => { + const streamUrl = 'http://xtream.example/series/1001.mp4'; fixture.detectChanges(); await fixture.whenStable(); - const menu = fixture.debugElement.injector.get( SerialDetailsMenuService ); await menu.run('external-player'); await fixture.whenStable(); + // The regular episode start with the player forced: same history + // and launch-position bookkeeping as the Play button. expect(openResolvedPlayback).not.toHaveBeenCalled(); expect(openExternalPlayback).toHaveBeenCalledWith( - expect.objectContaining({ - streamUrl: 'http://xtream.example/series/1001.mp4', - }), + expect.objectContaining({ streamUrl }), 'mpv' ); - // Same bookkeeping as the Play button: the view lands once the - // stream plays, the launch position is written right away. TestBed.inject(PlaybackHistoryGate).confirm({ - streamUrls: ['http://xtream.example/series/1001.mp4'], + streamUrls: [streamUrl], }); expect(addRecentItem).toHaveBeenCalledWith( expect.objectContaining({ xtreamId: '103', contentType: 'series' }) ); - expect(savePlaybackPosition).toHaveBeenCalledWith( - 'xtream-1', - expect.objectContaining({ - contentXtreamId: 1001, - contentType: 'episode', - positionSeconds: 0, - }) - ); }); it('persists the launched episode after an external fallback succeeds', async () => { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts index 2d10ae399..e7902851f 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts @@ -136,7 +136,7 @@ export class VodDetailsMenuService { this.msUi.hasPlaybackPosition(), this.externalPlayer() ) - ?.catch((error) => + .catch((error) => this.logger.warn('External launch failed', error) ); return; 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 1a6ed5bd1..44b11ff66 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 @@ -194,6 +194,43 @@ describe('VodDetailsPlaybackService — external session ownership', () => { ); }); + it('replaces a running external session when the menu relaunches the movie', async () => { + const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID); + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/route.mkv', + title: 'Route movie', + contentInfo: launched.contentInfo, + }); + activeSession.set(launched); + closeSession.mockClear(); + openExternalPlayback.mockClear(); + + await service.openInExternalPlayer( + { + info: {}, + movie_data: { + stream_id: ROUTE_VOD_ID, + name: 'Route movie', + container_extension: 'mkv', + }, + } as never, + false, + 'mpv' + ); + + // Replaced, never doubled: the running player closes first. + expect(closeSession).toHaveBeenCalledWith(launched); + expect(closeSession.mock.invocationCallOrder[0]).toBeLessThan( + openExternalPlayback.mock.invocationCallOrder[0] + ); + expect(openExternalPlayback).toHaveBeenCalledWith( + expect.objectContaining({ + streamUrl: 'https://example.com/route.mkv', + }), + 'mpv' + ); + }); + it('records the movie as recently viewed only once its stream played', async () => { await service.startResolvedPlayback({ streamUrl: 'https://example.com/broken.mkv', 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 98fb7d17a..49f806d41 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 @@ -307,25 +307,19 @@ export class VodDetailsPlaybackService { } /** - * The "…" menu's explicit MPV/VLC launch. It counts as a view like any - * start; `launchExternal` alone serves the inline player's fallback, - * whose start already recorded the item. + * The "…" menu's explicit MPV/VLC launch: a regular route-owned start + * with the player forced, so the view is recorded and a running + * session is replaced rather than doubled. */ openInExternalPlayer( vodItem: XtreamVodDetails | null, resume: boolean, player: ExternalPlayerName - ): Promise | null { + ): Promise { const playback = this.buildVodPlayback(vodItem, resume); - if (!playback) { - return null; - } - this.recordRecentItem(playback.streamUrl, { - xtreamId: this.bindings()?.vodId() ?? NaN, - contentType: 'movie', - backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0], - }); - return this.launchExternal(playback, player); + return playback + ? this.startPlayback(playback, player) + : Promise.resolve(false); } /** @@ -423,7 +417,8 @@ export class VodDetailsPlaybackService { async startResolvedPlayback( playback: ResolvedPortalPlayback, - isCurrent: () => boolean = () => true + isCurrent: () => boolean = () => true, + player?: ExternalPlayerName ): Promise { if (this.externalLaunchGeneration() !== null) { return false; @@ -472,21 +467,26 @@ export class VodDetailsPlaybackService { contentType: 'movie', backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0], }); - return await this.applyPlayback(playback, isCurrent); + return await this.applyPlayback(playback, isCurrent, player); } finally { this.pendingStart.settle(startId); } } - private startPlayback(playback: ResolvedPortalPlayback): Promise { + private startPlayback( + playback: ResolvedPortalPlayback, + player?: ExternalPlayerName + ): Promise { return startRouteOwnedPlayback(this.externalLaunchOwner, (isCurrent) => - this.startResolvedPlayback(playback, isCurrent) + this.startResolvedPlayback(playback, isCurrent, player) ); } + /** `player` forces MPV/VLC regardless of the configured player. */ private async applyPlayback( playback: ResolvedPortalPlayback, - isCurrent: () => boolean = () => true + isCurrent: () => boolean = () => true, + player?: ExternalPlayerName ): Promise { // EVERY start claims the generation, not just the switch path. Play, // Resume and Restart reach here directly, and a switch still waiting @@ -494,7 +494,7 @@ export class VodDetailsPlaybackService { // launch on top of what the user just chose. const generation = ++this.startGeneration; this.positionWriter.reset(); - if (this.portalPlayer.isEmbeddedPlayer()) { + if (!player && this.portalPlayer.isEmbeddedPlayer()) { this.inlinePlayback.set(playback); this.externalLaunchOwner.clear(); this.externalLaunchGeneration.set(null); @@ -503,7 +503,9 @@ export class VodDetailsPlaybackService { this.closeInlinePlayer(); this.claimExternalLaunch(playback, generation); - const launch = this.portalPlayer.openResolvedPlayback(playback, true); + const launch = player + ? this.portalPlayer.openExternalPlayback(playback, player) + : this.portalPlayer.openResolvedPlayback(playback, true); return await this.settleExternalLaunch(generation, isCurrent, launch); }