From e9e12f8f0b6a92210f856f6bc3d5bb6628e29e24 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 3 Oct 2026 17:38:51 +0200 Subject: [PATCH] fix(portals): a pinned copy that already plays is restarted or relaunched, never swapped Restart and the menu's MPV/VLC launch fell through to the route's copy while the pinned copy was playing, since a playing pin counts as nothing to honour for plain Play. Both now ask the pinned play to replace the running copy. The hero also reads the position the primary button acts on, so its progress and remaining time describe the pinned copy rather than the route's row. Co-Authored-By: Claude Fable 5.1 --- .../vod-details-multi-source-ui.service.ts | 5 +++ .../vod-details-route-playback.spec.ts | 8 ++-- .../vod-details-route.component.ts | 13 ++++-- .../vod-multi-source-host-pin.spec.ts | 42 +++++++++++++++++-- .../vod-multi-source-host.service.ts | 21 ++++++++-- 5 files changed, 75 insertions(+), 14 deletions(-) diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts index 63f07236a..36a18b220 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts @@ -126,6 +126,11 @@ export class VodDetailsMultiSourceUiService { }); readonly hasPlaybackPosition = this.primaryAction.hasPosition; + /** + * The position the primary button acts on: the pinned copy's own row, or + * the live one while that copy plays, otherwise the route copy's. + */ + readonly primaryPosition = this.primaryAction.position; formatPosition(): string { return formatPlaybackPosition(this.primaryAction.position()); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts index 2126817e1..42c9d7e4e 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -756,7 +756,7 @@ describe('VodDetailsRouteComponent — playback actions', () => { // nothing about the pinned one. expect(pinnedPlay).toHaveBeenCalledWith( component['msUi'].resumeSecondsFor, - 'mpv' + { player: 'mpv', replacePlaying: true } ); expect(routePlay).not.toHaveBeenCalled(); }); @@ -784,8 +784,10 @@ describe('VodDetailsRouteComponent — playback actions', () => { } as never); expect(pinnedPlay).toHaveBeenCalled(); - // Restart means zero, whichever copy it starts. - const resumeFor = pinnedPlay.mock.calls[0][0]; + // Restart means zero, whichever copy it starts — including the + // pinned copy that is playing right now. + const [resumeFor, options] = pinnedPlay.mock.calls[0]; await expect(resumeFor?.({} as never)).resolves.toBe(0); + expect(options).toEqual({ replacePlaying: true }); }); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index ff8c228d2..4447647b6 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -498,7 +498,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.watched.bind(this.selectedVodId); this.hero.bind({ info: this.selectedVodInfo, - position: this.routePlaybackPosition, + // The copy the button starts, so the progress bar and the + // remaining time describe that copy, not the route's row. + position: this.msUi.primaryPosition, hasPlaybackPosition: this.hasPlaybackPosition, isOfflinePrimary: this.isOfflinePrimary, externalLabel: this.externalPrimaryLabel, @@ -642,8 +644,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { } if (this.msUi.primaryIsPinnedCopy()) { - const outcome = await this.multiSource.playPinnedSource(async () => - Promise.resolve(0) + const outcome = await this.multiSource.playPinnedSource( + async () => Promise.resolve(0), + { replacePlaying: true } ); if (outcome !== 'unavailable') { return; @@ -713,9 +716,11 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { return; } if (this.msUi.primaryIsPinnedCopy()) { + // Also while that copy already plays: the viewer's chosen source + // is relaunched, never swapped for the route's copy. const outcome = await this.multiSource.playPinnedSource( this.msUi.resumeSecondsFor, - player + { player, replacePlaying: true } ); if (outcome !== 'unavailable') { return; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts index d629311b5..704ec4a91 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts @@ -59,9 +59,9 @@ describe('VodMultiSourceHostService — pinning', () => { // The "…" menu's MPV/VLC launch honours the pin like Play does, so // the two never start different copies of the film. - await expect(service.playPinnedSource(undefined, 'vlc')).resolves.toBe( - 'played' - ); + await expect( + service.playPinnedSource(undefined, { player: 'vlc' }) + ).resolves.toBe('played'); expect(startPlayback).toHaveBeenCalledWith( expect.anything(), expect.any(Function), @@ -69,6 +69,42 @@ describe('VodMultiSourceHostService — pinning', () => { ); }); + it('relaunches a pinned copy that already plays only when asked to replace it', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + await expect(service.playPinnedSource()).resolves.toBe('played'); + playbackLive.set(true); + startPlayback.mockClear(); + + // Plain Play has nothing to honour while the pinned copy plays... + await expect( + service.playPinnedSource(undefined, { player: 'mpv' }) + ).resolves.toBe('unavailable'); + expect(startPlayback).not.toHaveBeenCalled(); + + // ...but Restart and the menu's MPV/VLC launch mean THAT copy, never + // the route's, so they replace the running player with it. + await expect( + service.playPinnedSource(undefined, { + player: 'mpv', + replacePlaying: true, + }) + ).resolves.toBe('played'); + expect(startPlayback).toHaveBeenCalledWith( + expect.objectContaining({ + streamUrl: expect.stringContaining(String(ALT_TWO.contentId)), + }), + expect.any(Function), + 'mpv' + ); + playbackLive.set(false); + }); + it('resumes the pinned source from the stored position', async () => { pins.get.mockResolvedValue({ matchKey: 'title:the matrix:1999', diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts index ab914a8a7..4cbdf2936 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts @@ -65,6 +65,14 @@ import { * reaches into the route component. */ +/** How a pinned play starts. */ +export interface PinnedPlayOptions { + /** Forces MPV/VLC instead of the host's inline-or-external choice. */ + readonly player?: ExternalPlayerName; + /** Honour the pin even while its copy already plays: a restart or a forced relaunch replaces it. */ + readonly replacePlaying?: boolean; +} + export interface VodMultiSourceBindings { /** * Applies a playback — inline swap or external launch, host's choice, @@ -382,11 +390,13 @@ export class VodMultiSourceHostService { /** * Start from the pinned source if there is one. `unavailable` means there * is nothing pinned to honour, leaving the caller's own Play path in - * charge; a superseded attempt must NOT fall through that way. + * charge; a superseded attempt must NOT fall through that way. A pin + * whose copy already plays counts as nothing to honour, unless + * `replacePlaying` says the caller means to restart or relaunch it. */ playPinnedSource( resumeFor?: (source: VodSourceCandidate) => Promise, - player?: ExternalPlayerName + options: PinnedPlayOptions = {} ): Promise { const session = this.sessionToken; // Claim a switch generation up front. The discovery wait and the @@ -397,10 +407,13 @@ export class VodMultiSourceHostService { return startPinnedSource({ controller: this.controller, loadInFlight: this.loadInFlight, - pinnedSourceId: () => this.pendingPinnedSourceId(), + pinnedSourceId: () => + options.replacePlaying + ? pinnedSourceAwaitingPlay(this._sources(), false) + : this.pendingPinnedSourceId(), resumeFor, isCurrent: () => this.isCurrentSwitch(session, attempt), - play: (sourceId) => this.runPlay(sourceId, player), + play: (sourceId) => this.runPlay(sourceId, options.player), }); }