fix(portals): the movie menu's MPV/VLC launch honours the pinned copy

The menu resolved the route's copy while reading the resume flag of the
copy the primary button acts on, so with a pin it could launch a
different source from Play and Start over and apply the wrong resume
decision. The launch is host-owned now: a pinned copy plays through the
pinned-source orchestration with the forced player and its own resume
point, otherwise the route copy starts from its own position.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 17:26:59 +02:00
1 parent d47b172852
commit bc052804d9
10 files changed
+149 -49

No files matched your search

@@ -22,6 +22,7 @@ describe('VodDetailsMenuService', () => {
const playbackStartPending = signal(false);
const isExternalLaunchPending = signal(false);
const hasPlaybackPosition = signal(true);
const openExternal = jest.fn().mockResolvedValue(undefined);
let service: VodDetailsMenuService;
beforeEach(() => {
@@ -75,6 +76,7 @@ describe('VodDetailsMenuService', () => {
vodId: signal(7),
category: signal(null),
restart: jest.fn(),
openExternal,
});
});
@@ -90,6 +92,13 @@ describe('VodDetailsMenuService', () => {
expect(row(VOD_MENU_ACTION.StartOver)?.disabled).toBeFalsy();
});
it('hands the external launch to the host with the configured player', async () => {
// The host honours a pinned copy and its resume point; the menu
// must not resolve the route copy on its own.
await service.run(VOD_MENU_ACTION.ExternalPlayer);
expect(openExternal).toHaveBeenCalledWith('mpv');
});
it('holds both rows while a start resolves or a launch awaits the player', () => {
// Another start would be refused while one is in flight: an enabled
// row would close the menu and do nothing.
@@ -34,6 +34,8 @@ interface VodDetailsMenuBindings {
readonly category: Signal<Partial<XtreamCategory> | null>;
/** Start over honours a pinned copy, so the host owns it. */
readonly restart: () => Promise<void>;
/** The MPV/VLC launch honours the pinned copy and its resume point too. */
readonly openExternal: (player: ExternalPlayerName) => Promise<void>;
}
/**
@@ -142,15 +144,7 @@ export class VodDetailsMenuService {
const item = this.bindings()?.item() ?? null;
switch (actionId) {
case VOD_MENU_ACTION.ExternalPlayer:
await this.playback
.openInExternalPlayer(
item,
this.msUi.hasPlaybackPosition(),
this.externalPlayer()
)
.catch((error) =>
this.logger.warn('External launch failed', error)
);
await this.bindings()?.openExternal(this.externalPlayer());
return;
case VOD_MENU_ACTION.CopyUrl:
await this.copyStreamUrl(item);
@@ -172,7 +172,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
container_extension: 'mkv',
},
} as never;
const launch = service.openInExternalPlayer(movie, false, 'vlc');
const launch = service.playVod(movie, 'vlc');
expect(launch).not.toBeNull();
await launch;
@@ -205,7 +205,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
closeSession.mockClear();
openExternalPlayback.mockClear();
await service.openInExternalPlayer(
await service.playVod(
{
info: {},
movie_data: {
@@ -214,7 +214,6 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
container_extension: 'mkv',
},
} as never,
false,
'mpv'
);
@@ -241,14 +241,21 @@ export class VodDetailsPlaybackService {
};
}
async playVod(vodItem: XtreamVodDetails | null): Promise<boolean> {
/** `player` forces MPV/VLC (the "…" menu); the start is route-owned either way. */
async playVod(
vodItem: XtreamVodDetails | null,
player?: ExternalPlayerName
): Promise<boolean> {
const playback = this.buildVodPlayback(vodItem, false);
return playback ? await this.startPlayback(playback) : false;
return playback ? await this.startPlayback(playback, player) : false;
}
async resumeVod(vodItem: XtreamVodDetails | null): Promise<boolean> {
async resumeVod(
vodItem: XtreamVodDetails | null,
player?: ExternalPlayerName
): Promise<boolean> {
const playback = this.buildVodPlayback(vodItem, true);
return playback ? await this.startPlayback(playback) : false;
return playback ? await this.startPlayback(playback, player) : false;
}
onPrimaryAction(vodItem: XtreamVodDetails | null): void {
@@ -308,22 +315,6 @@ export class VodDetailsPlaybackService {
);
}
/**
* 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<boolean> {
const playback = this.buildVodPlayback(vodItem, resume);
return playback
? this.startPlayback(playback, player)
: Promise.resolve(false);
}
/**
* Hands the playback to MPV or VLC regardless of the configured player
* (the inline player's fallback), owned and settled like a regular
@@ -727,6 +727,40 @@ describe('VodDetailsRouteComponent — playback actions', () => {
).resolves.toBe(2538);
});
it('launches the pinned copy, from its own resume point, in the forced player', async () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
withActiveSource('playlist-1', 650020);
Object.defineProperty(component['msUi'], 'primaryIsPinnedCopy', {
configurable: true,
value: () => true,
});
const pinnedPlay = jest
.spyOn(component.multiSource, 'playPinnedSource')
.mockResolvedValue('played');
const routePlay = jest.spyOn(component, 'playVod');
await component.openInExternalPlayer(
{
movie_data: {
stream_id: 650020,
name: 'Example',
container_extension: 'mp4',
},
} as never,
'mpv'
);
// The menu's launch and the primary button must agree on the copy
// and on where it resumes: the route copy's own position says
// nothing about the pinned one.
expect(pinnedPlay).toHaveBeenCalledWith(
component['msUi'].resumeSecondsFor,
'mpv'
);
expect(routePlay).not.toHaveBeenCalled();
});
it('restarts the pinned copy, not the route copy', async () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
@@ -65,6 +65,7 @@ import {
XtreamVodInfo,
XtreamVodStream,
youtubeEmbedUrl,
type ExternalPlayerName,
type PlaybackPositionData,
type VodSourceCandidate,
type VodSourceDescriptor,
@@ -454,10 +455,11 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.multiSource.bind({
// Route every switch through the same inline-vs-external fork a
// normal Play uses, so the two paths cannot drift apart.
startPlayback: async (playback, isCurrent) => {
startPlayback: async (playback, isCurrent, player) => {
const started = await this.playback.startResolvedPlayback(
playback,
isCurrent
isCurrent,
player
);
if (started) {
// A switch mounts a DIFFERENT stream in the same host, so
@@ -511,6 +513,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
vodId: this.selectedVodId,
category: this.selectedCategory,
restart: () => this.restartVod(this.playableVodItem()),
openExternal: (player) =>
this.openInExternalPlayer(this.playableVodItem(), player),
});
registerContentMetadataBackfill({
@@ -606,9 +610,12 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.xtreamStore.setSelectedItem(null);
}
async playVod(vodItem: XtreamVodDetails | null): Promise<boolean> {
async playVod(
vodItem: XtreamVodDetails | null,
player?: ExternalPlayerName
): Promise<boolean> {
this.multiSource.supersedePendingSwitch();
const started = await this.playback.playVod(vodItem);
const started = await this.playback.playVod(vodItem, player);
if (!started) {
return false;
}
@@ -646,9 +653,12 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
await this.playVod(vodItem);
}
async resumeVod(vodItem: XtreamVodDetails | null): Promise<boolean> {
async resumeVod(
vodItem: XtreamVodDetails | null,
player?: ExternalPlayerName
): Promise<boolean> {
this.multiSource.supersedePendingSwitch();
const started = await this.playback.resumeVod(vodItem);
const started = await this.playback.resumeVod(vodItem, player);
if (!started) {
return false;
}
@@ -689,6 +699,35 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
await this.playFromProviderSource(vodItem);
}
/**
* The "…" menu's MPV/VLC launch: the copy the primary button acts on,
* from where it would resume. A pinned copy outranks the route's, as it
* does for Play and Restart, so the launch and the button never disagree
* about the source or the position.
*/
async openInExternalPlayer(
vodItem: XtreamVodDetails | null,
player: ExternalPlayerName
): Promise<void> {
if (this.isExternalLaunchPending()) {
return;
}
if (this.msUi.primaryIsPinnedCopy()) {
const outcome = await this.multiSource.playPinnedSource(
this.msUi.resumeSecondsFor,
player
);
if (outcome !== 'unavailable') {
return;
}
}
if (this.playback.hasPlaybackPosition()) {
await this.resumeVod(vodItem, player);
return;
}
await this.playVod(vodItem, player);
}
async playFromProviderSource(
vodItem: XtreamVodDetails | null
): Promise<void> {
@@ -48,6 +48,27 @@ describe('VodMultiSourceHostService — pinning', () => {
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('launches the pinned copy in the forced player', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
// 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'
);
expect(startPlayback).toHaveBeenCalledWith(
expect.anything(),
expect.any(Function),
'vlc'
);
});
it('resumes the pinned source from the stored position', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
@@ -138,7 +138,8 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
expect.objectContaining({
streamUrl: expect.stringContaining(String(ALT_THREE.contentId)),
}),
expect.any(Function)
expect.any(Function),
undefined
);
});
@@ -175,9 +175,11 @@ describe('VodMultiSourceHostService', () => {
{ startTime: 2538 }
);
expect(startPlayback).toHaveBeenCalledTimes(1);
// No forced player: the host picks inline or external itself.
expect(startPlayback).toHaveBeenCalledWith(
expect.objectContaining({ startTime: 2538 }),
expect.any(Function)
expect.any(Function),
undefined
);
});
@@ -50,6 +50,7 @@ import {
type PinKeySets,
} from './vod-multi-source-pin';
import {
type ExternalPlayerName,
type ResolvedPortalPlayback,
type VodSourceCandidate,
type VodSourceDescriptor,
@@ -66,12 +67,14 @@ import {
export interface VodMultiSourceBindings {
/**
* Applies a playback — inline swap or external launch, host's choice.
* False leaves the controller on its current source.
* Applies a playback — inline swap or external launch, host's choice,
* unless `player` forces MPV/VLC. False leaves the controller on its
* current source.
*/
startPlayback: (
playback: ResolvedPortalPlayback,
isCurrent: () => boolean
isCurrent: () => boolean,
player?: ExternalPlayerName
) => Promise<boolean>;
/** The movie on screen, or null while its identity is not yet knowable. */
movie: Signal<VodMultiSourceMovie | null>;
@@ -382,7 +385,8 @@ export class VodMultiSourceHostService {
* charge; a superseded attempt must NOT fall through that way.
*/
playPinnedSource(
resumeFor?: (source: VodSourceCandidate) => Promise<number | null>
resumeFor?: (source: VodSourceCandidate) => Promise<number | null>,
player?: ExternalPlayerName
): Promise<PinnedPlayOutcome> {
const session = this.sessionToken;
// Claim a switch generation up front. The discovery wait and the
@@ -396,7 +400,7 @@ export class VodMultiSourceHostService {
pinnedSourceId: () => this.pendingPinnedSourceId(),
resumeFor,
isCurrent: () => this.isCurrentSwitch(session, attempt),
play: (sourceId) => this.runPlay(sourceId),
play: (sourceId) => this.runPlay(sourceId, player),
});
}
@@ -406,7 +410,10 @@ export class VodMultiSourceHostService {
}
/** As `play`, but keeping the distinction the pinned path needs. */
private async runPlay(sourceId: string): Promise<PinnedPlayOutcome> {
private async runPlay(
sourceId: string,
player?: ExternalPlayerName
): Promise<PinnedPlayOutcome> {
const candidate = this.controller.findSource(sourceId);
if (!candidate || !this.bindings) {
return 'unavailable';
@@ -414,7 +421,7 @@ export class VodMultiSourceHostService {
this._busySourceId.set(sourceId);
try {
return toPinnedOutcome(await this.switchTo(candidate));
return toPinnedOutcome(await this.switchTo(candidate, player));
} finally {
// Only while this attempt still owns the spinner, or a slower
// pick would clear the row that is still resolving.
@@ -559,7 +566,10 @@ export class VodMultiSourceHostService {
this.controller.seedResumeSeconds(seconds);
}
private switchTo(candidate: VodSourceCandidate): Promise<SwitchOutcome> {
private switchTo(
candidate: VodSourceCandidate,
player?: ExternalPlayerName
): Promise<SwitchOutcome> {
const bindings = this.bindings;
if (!bindings || bindings.playbackStartBlocked()) {
return Promise.resolve('superseded');
@@ -573,7 +583,7 @@ export class VodMultiSourceHostService {
resolve: (target, options) =>
this.resolver.resolve(target, options),
startPlayback: (playback, isCurrent) =>
bindings.startPlayback(playback, isCurrent),
bindings.startPlayback(playback, isCurrent, player),
isCurrent: () => this.isCurrentSwitch(session, attempt),
setPreviousSource: (id) => this._previousSourceId.set(id),
setNotice: (notice) => this._lastSwitch.set(notice),