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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 17:38:51 +02:00
1 parent bc052804d9
commit e9e12f8f0b
5 files changed
+75 -14

No files matched your search

@@ -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());
@@ -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 });
});
});
@@ -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;
@@ -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',
@@ -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<number | null>,
player?: ExternalPlayerName
options: PinnedPlayOptions = {}
): Promise<PinnedPlayOutcome> {
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),
});
}