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), }); }