refactor(portals): split the external-playback handoff out of the service

Both the service and its spec crossed the 400-line cap with the close-failure
handling, so the handoff — deciding which process is ours, closing it, and
surviving a close that rejects — now lives in
`vod-details-external-session.ts` with its own spec file.

Two tests had to start awaiting: replacing a running external player is a
round-trip, and the handoff now yields once even when there is nothing to
close, so the new playback is mounted a microtask later than before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-29 02:44:41 +02:00
1 parent caa89e3a6e
commit b24732b86e
5 files changed
+308 -169

No files matched your search

@@ -0,0 +1,263 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import type {
PlaybackPositionData,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
/**
* Which external session this page owns.
*
* Multi-source can launch MPV/VLC for a movie in ANOTHER playlist, and the
* session then carries that playlist's ids — so the matcher decides whether
* the primary button can stop it or silently launches a second player.
*/
/**
* Which external process this page owns, and what replacing it entails.
* Split from the position/ownership spec to keep both inside the file-size
* rule.
*/
describe('VodDetailsPlaybackService — external playback handoff', () => {
const ROUTE_PLAYLIST = 'playlist-1';
const ROUTE_VOD_ID = 650020;
let service: VodDetailsPlaybackService;
/** The bridge callback the service registers at construction. */
let positionListener: ((data: PlaybackPositionData) => void) | undefined;
const addRecentItem = jest.fn();
const activeSession = signal<unknown>(null);
const closeSession = jest.fn().mockResolvedValue(undefined);
const openResolvedPlayback = jest.fn();
const activeSource = signal<PlayerContentInfo | null>(null);
function sessionFor(playlistId: string, contentXtreamId: number) {
return {
player: 'mpv',
status: 'playing',
contentInfo: {
playlistId,
contentXtreamId,
contentType: 'vod' as const,
},
};
}
beforeEach(() => {
activeSession.set(null);
activeSource.set(null);
positionListener = undefined;
addRecentItem.mockClear();
TestBed.configureTestingModule({
providers: [
VodDetailsPlaybackService,
{
provide: XtreamStore,
useValue: {
currentPlaylist: signal({ id: ROUTE_PLAYLIST }),
addRecentItem,
constructVodStreamUrl: jest
.fn()
.mockReturnValue('https://example.com/route.mkv'),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession, closeSession },
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getPlaybackPosition: jest.fn(),
savePlaybackPosition: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback,
},
},
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: {
onPlaybackPositionUpdate: (
listener: (data: PlaybackPositionData) => void
) => {
positionListener = listener;
return () => undefined;
},
},
},
],
});
service = TestBed.inject(VodDetailsPlaybackService);
service.bind({
vodId: signal(ROUTE_VOD_ID),
vodInfo: signal(null),
activeSource,
});
});
it('closes the alternative it launched, once the badge has moved on', async () => {
// The controller marks the DESTINATION active before playback is
// handed over, so by the time the switch reaches the service the
// running process no longer looks like "ours" — and was left playing
// beside its replacement.
activeSession.set(sessionFor('playlist-2', 991));
activeSource.set({
playlistId: 'playlist-3',
contentXtreamId: 77,
contentType: 'vod',
});
closeSession.mockClear();
// Pretend the alternative we are replacing is the one we started.
await service.startResolvedPlayback({
streamUrl: 'https://example.com/first.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
});
closeSession.mockClear();
await service.startResolvedPlayback({
streamUrl: 'https://example.com/second.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-3',
contentXtreamId: 77,
contentType: 'vod',
},
});
expect(closeSession).toHaveBeenCalledWith(
expect.objectContaining({
contentInfo: expect.objectContaining({
contentXtreamId: 991,
}),
})
);
});
it('still starts the replacement when closing the old player fails', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
closeSession.mockRejectedValue(new Error('close ipc failed'));
openResolvedPlayback.mockClear();
await service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
});
// The switch is already committed — the badge names the new source.
// Bailing out here left the page claiming a source with nothing
// started at all.
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
closeSession.mockResolvedValue(undefined);
});
it('launches only the newest source when two switches overlap', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
// One shared promise: both calls see the same running session, so
// both await the same close rather than each making its own.
let releaseClose: (() => void) | undefined;
const closing = new Promise<void>((resolve) => {
releaseClose = () => resolve();
});
closeSession.mockReturnValue(closing);
// These spies are module-level and never cleared between cases.
openResolvedPlayback.mockClear();
const first = service.startResolvedPlayback({
streamUrl: 'https://example.com/one.mkv',
title: 'Example Movie',
});
const second = service.startResolvedPlayback({
streamUrl: 'https://example.com/two.mkv',
title: 'Example Movie',
});
releaseClose?.();
await Promise.all([first, second]);
// Both saw the same running session and awaited its close. Launching
// both afterwards is how two detached players appear again, with the
// older one holding a source the user has already moved on from.
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'https://example.com/two.mkv',
}),
true
);
closeSession.mockResolvedValue(undefined);
});
it('drops a switch that a plain Play overtook', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
let releaseClose: (() => void) | undefined;
const closing = new Promise<void>((resolve) => {
releaseClose = () => resolve();
});
closeSession.mockReturnValue(closing);
openResolvedPlayback.mockClear();
const switching = service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
});
// The user presses Play on the route copy while that close is pending.
service.playVod({
movie_data: { stream_id: ROUTE_VOD_ID, name: 'Example Movie' },
} as never);
releaseClose?.();
await switching;
// Only the route copy may be playing; the switch was overtaken.
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
expect(openResolvedPlayback).not.toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'https://example.com/alt.mkv',
}),
true
);
closeSession.mockResolvedValue(undefined);
});
it('stops the running external player before switching sources', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
await service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
});
// A switch REPLACES what is playing. With MPV/VLC and instance reuse
// off the backend spawns a second detached player otherwise: both
// sources keep running, and Stop owns only the newer one.
expect(closeSession).toHaveBeenCalled();
});
});
@@ -75,3 +75,30 @@ export function runningExternalSession(
return isLaunchedOne ? session : matched;
}
/**
* Close the running external player before its replacement starts.
*
* A failure is logged rather than propagated: the caller has already
* committed the switch, so refusing to launch would leave the page naming a
* source with nothing playing — worse than a possibly-lingering process, and
* a close that rejects usually means the session was gone already.
*/
export async function closeRunningExternalSession(
session: ExternalPlayerSession | null,
close: (session: ExternalPlayerSession) => Promise<void>,
warn: (message: string, error: unknown) => void
): Promise<void> {
if (!session) {
return;
}
try {
await close(session);
} catch (error) {
warn(
'Closing the previous external player failed; starting the replacement anyway.',
error
);
}
}
@@ -146,163 +146,19 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
expect(service.matchedExternalPlayback()).toBeNull();
});
it('stops the running external player before switching sources', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
await service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
});
// A switch REPLACES what is playing. With MPV/VLC and instance reuse
// off the backend spawns a second detached player otherwise: both
// sources keep running, and Stop owns only the newer one.
expect(closeSession).toHaveBeenCalled();
});
it('closes the alternative it launched, once the badge has moved on', async () => {
// The controller marks the DESTINATION active before playback is
// handed over, so by the time the switch reaches the service the
// running process no longer looks like "ours" — and was left playing
// beside its replacement.
activeSession.set(sessionFor('playlist-2', 991));
activeSource.set({
playlistId: 'playlist-3',
contentXtreamId: 77,
contentType: 'vod',
});
closeSession.mockClear();
// Pretend the alternative we are replacing is the one we started.
await service.startResolvedPlayback({
streamUrl: 'https://example.com/first.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
});
closeSession.mockClear();
await service.startResolvedPlayback({
streamUrl: 'https://example.com/second.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-3',
contentXtreamId: 77,
contentType: 'vod',
},
});
expect(closeSession).toHaveBeenCalledWith(
expect.objectContaining({
contentInfo: expect.objectContaining({
contentXtreamId: 991,
}),
})
);
});
it('drops a switch that a plain Play overtook', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
let releaseClose: (() => void) | undefined;
const closing = new Promise<void>((resolve) => {
releaseClose = () => resolve();
});
closeSession.mockReturnValue(closing);
openResolvedPlayback.mockClear();
const switching = service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
});
// The user presses Play on the route copy while that close is pending.
service.playVod({
movie_data: { stream_id: ROUTE_VOD_ID, name: 'Example Movie' },
} as never);
releaseClose?.();
await switching;
// Only the route copy may be playing; the switch was overtaken.
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
expect(openResolvedPlayback).not.toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'https://example.com/alt.mkv',
}),
true
);
closeSession.mockResolvedValue(undefined);
});
it('still starts the replacement when closing the old player fails', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
closeSession.mockRejectedValue(new Error('close ipc failed'));
openResolvedPlayback.mockClear();
await service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
});
// The switch is already committed — the badge names the new source.
// Bailing out here left the page claiming a source with nothing
// started at all.
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
closeSession.mockResolvedValue(undefined);
});
it('launches only the newest source when two switches overlap', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
// One shared promise: both calls see the same running session, so
// both await the same close rather than each making its own.
let releaseClose: (() => void) | undefined;
const closing = new Promise<void>((resolve) => {
releaseClose = () => resolve();
});
closeSession.mockReturnValue(closing);
// These spies are module-level and never cleared between cases.
openResolvedPlayback.mockClear();
const first = service.startResolvedPlayback({
streamUrl: 'https://example.com/one.mkv',
title: 'Example Movie',
});
const second = service.startResolvedPlayback({
streamUrl: 'https://example.com/two.mkv',
title: 'Example Movie',
});
releaseClose?.();
await Promise.all([first, second]);
// Both saw the same running session and awaited its close. Launching
// both afterwards is how two detached players appear again, with the
// older one holding a source the user has already moved on from.
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
expect(openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'https://example.com/two.mkv',
}),
true
);
closeSession.mockResolvedValue(undefined);
});
it('records a source started through multi-source as recently viewed', () => {
it('records a source started through multi-source as recently viewed', async () => {
// Playing an alternative from the picker, or letting a pin decide the
// primary Play, is still watching the movie — it belongs in Recently
// Viewed exactly as an ordinary Play does.
service.startResolvedPlayback({
//
// Awaited: the handoff always yields once now, because replacing a
// running external player is a round-trip even when there is none.
await service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: {
@@ -27,6 +27,7 @@ import {
} from '@iptvnator/shared/interfaces';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import {
closeRunningExternalSession,
ownsContent,
runningExternalSession,
} from './vod-details-external-session';
@@ -352,26 +353,15 @@ export class VodDetailsPlaybackService {
// A switch REPLACES what is playing. With MPV or VLC and instance
// reuse off, the backend spawns a second detached player otherwise —
// both sources keep running and Stop owns only the newer one.
const running = runningExternalSession(
this.externalPlayback.activeSession(),
this.launchedExternally,
this.matchedExternalPlayback()
await closeRunningExternalSession(
runningExternalSession(
this.externalPlayback.activeSession(),
this.launchedExternally,
this.matchedExternalPlayback()
),
(session) => this.externalPlayback.closeSession(session),
(message, error) => this.logger.warn(message, error)
);
if (running) {
try {
await this.externalPlayback.closeSession(running);
} catch (error) {
// The switch is already committed — the badge names the new
// source. Giving up here would leave the page claiming a
// source with nothing started, which is worse than the
// pre-existing risk of a lingering process (and a close that
// rejects usually means the session was gone anyway).
this.logger.warn(
'Closing the previous external player failed; starting the replacement anyway.',
error
);
}
}
// Closing is a round-trip, and a second pick across it would otherwise
// reach this line too: both would have seen the same session, closed
@@ -138,7 +138,7 @@ describe('VodDetailsRouteComponent — source caption', () => {
expect(component.activeSourceCaption()).toBeNull();
});
it('waits again after a manual source switch', () => {
it('waits again after a manual source switch', async () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
@@ -170,6 +170,9 @@ describe('VodDetailsRouteComponent — source caption', () => {
title: 'Example',
startTime: 3,
});
// Replacing a running external player is a round-trip, so the handoff
// yields once before the new playback is mounted.
await Promise.resolve();
expect(playback.inlinePlayback()?.streamUrl).toBe(
'http://example.com/alt.mkv'