fix(playback): clarify external player launch feedback (#1388)

This commit is contained in:
4gray authored and GitHub committed 2026-08-09 13:33:07 +02:00
1 parent 8442747c37
commit d73acd6bfc
101 files changed
+8819 -850

No files matched your search

@@ -1,5 +1,6 @@
import type {
ExternalPlayerName,
ExternalPlayerSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { ErrorDetails, ErrorTypes } from 'hls.js';
@@ -278,4 +279,7 @@ export interface PlaybackFallbackRequest {
readonly player: ExternalPlayerName;
readonly playback: ResolvedPortalPlayback;
readonly diagnostic: PlaybackDiagnostic;
readonly trackLaunch: (
launch: Promise<ExternalPlayerSession | void>
) => void;
}
@@ -41,6 +41,7 @@ import {
import {
Channel,
EpgProgram,
ExternalPlayerSession,
ResolvedPortalPlayback,
Settings,
VideoPlayer,
@@ -197,6 +198,7 @@ describe('VideoPlayerComponent', () => {
const stripCountryPrefix = signal(false);
const epgViewMode = signal<'timeline' | 'list'>('timeline');
const epgUrlSetting = signal<string[]>([]);
const externalSession = signal<ExternalPlayerSession | null>(null);
const originalElectron = window.electron;
const overlayRef = {
@@ -325,6 +327,7 @@ describe('VideoPlayerComponent', () => {
channelsLoading.set(false);
currentEpgProgram.set(null);
activeEpgProgram.set(null);
externalSession.set(null);
currentEpgProgram$.next(null);
epgPrograms$.next([]);
overlayMock.create.mockClear();
@@ -415,7 +418,7 @@ describe('VideoPlayerComponent', () => {
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
activeSession: externalSession,
},
},
],
@@ -582,9 +585,13 @@ describe('VideoPlayerComponent', () => {
Origin: 'https://origin.example.com',
},
};
const launch = Promise.resolve();
const trackLaunch = jest.fn();
dataServiceMock.sendIpcEvent.mockReturnValueOnce(launch);
component.handleExternalFallbackRequest({
player: 'mpv',
playback,
trackLaunch,
diagnostic: {
code: 'unsupported-codec',
source: 'hls',
@@ -605,6 +612,7 @@ describe('VideoPlayerComponent', () => {
origin: 'https://origin.example.com',
}
);
expect(trackLaunch).toHaveBeenCalledWith(launch);
});
it('renders the embedded mpv inline player with the EPG panel', () => {
@@ -641,6 +649,38 @@ describe('VideoPlayerComponent', () => {
).toBe(false);
});
it('clears the channel when a closable error becomes terminal', () => {
syncStoreState(sampleChannel);
player.set(VideoPlayer.MPV);
const closableError: ExternalPlayerSession = {
id: 'external-1',
player: 'mpv',
status: 'error',
title: sampleChannel.name,
streamUrl: sampleChannel.url,
startedAt: '2026-08-08T00:00:00.000Z',
updatedAt: '2026-08-08T00:00:00.000Z',
error: 'Process exit was not confirmed',
canClose: true,
};
externalSession.set(closableError);
fixture.detectChanges();
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
ChannelActions.resetActiveChannel()
);
externalSession.set({
...closableError,
canClose: false,
updatedAt: '2026-08-08T00:00:01.000Z',
});
fixture.detectChanges();
expect(storeMock.dispatch).toHaveBeenCalledWith(
ChannelActions.resetActiveChannel()
);
});
it('keeps DASH channels inline on the HTML5 player even when MPV is configured', () => {
syncStoreState({
...sampleChannel,
@@ -80,6 +80,7 @@ import {
persistLiveEpgPanelState,
persistLiveSidebarState,
PORTAL_EXTERNAL_PLAYBACK,
isLiveExternalPlayerSession,
restoreLiveEpgPanelState,
restoreLiveSidebarState,
WorkspaceHeaderContextService,
@@ -1083,10 +1084,14 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
return;
}
this.dataService.sendIpcEvent(
request.player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER,
payload
const launch = Promise.resolve(
this.dataService.sendIpcEvent<ExternalPlayerSession>(
request.player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER,
payload
)
);
request.trackLaunch(launch);
void launch;
}
private toLiveEpgPanelSummary(
@@ -1110,7 +1115,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
return null;
}
return `${session.id}:${session.status}`;
const lifecycle = isLiveExternalPlayerSession(session)
? 'live'
: 'terminal';
return `${session.id}:${session.status}:${lifecycle}`;
}
private isExternalPlayer(
@@ -1122,7 +1130,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
private isTerminalExternalSession(
session: ExternalPlayerSession | null | undefined
): boolean {
return session?.status === 'closed' || session?.status === 'error';
return !!session && !isLiveExternalPlayerSession(session);
}
private registerHeaderShortcut(): void {
@@ -611,6 +611,7 @@ describe('UnifiedLiveTabComponent', () => {
webPlayer.externalFallbackRequested.emit({
player: 'mpv',
playback,
trackLaunch: jest.fn(),
diagnostic: {
code: 'network-error',
player: 'videojs',
@@ -537,10 +537,12 @@ export class UnifiedLiveTabComponent {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
onClose(): void {
@@ -3,7 +3,10 @@ import type {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { createExternalPlaybackButtonState } from './external-playback-button-state';
import {
createExternalPlaybackButtonState,
isLiveExternalPlayerSession,
} from './external-playback-button-state';
function session(
overrides: Partial<ExternalPlayerSession> = {}
@@ -105,6 +108,21 @@ describe('createExternalPlaybackButtonState', () => {
}
);
it('offers Stop for an errored session whose process may still be alive', () => {
const { api } = setup(
session({
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
})
);
expect(api.matchedSession()).not.toBeNull();
expect(api.buttonState()).toBe('stop');
expect(api.primaryLabel()).toBe('Stop MPV');
expect(api.isStopAction()).toBe(true);
});
it('reports a launching session', () => {
const { api } = setup(session({ status: 'launching' }));
@@ -208,3 +226,30 @@ describe('createExternalPlaybackButtonState', () => {
expect(api.matchedSession()).toBeNull();
});
});
describe('isLiveExternalPlayerSession', () => {
it('keeps ownership while a teardown error can still be stopped', () => {
expect(
isLiveExternalPlayerSession(
session({
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
})
)
).toBe(true);
});
it('treats an unclosable error and a confirmed close as terminal', () => {
expect(
isLiveExternalPlayerSession(
session({ status: 'error', canClose: false })
)
).toBe(false);
expect(
isLiveExternalPlayerSession(
session({ status: 'closed', canClose: false })
)
).toBe(false);
});
});
@@ -48,6 +48,21 @@ export interface ExternalPlaybackButtonStateApi {
buttonState: Signal<ExternalPlaybackButtonState>;
}
/**
* An error can still own a real process when bounded teardown could not
* confirm its exit. Keep that session live until Stop succeeds; only a
* confirmed close or an unclosable failure is terminal.
*/
export function isLiveExternalPlayerSession(
session: ExternalPlayerSession | null | undefined
): session is ExternalPlayerSession {
return (
!!session &&
session.status !== 'closed' &&
(session.status !== 'error' || session.canClose)
);
}
export function createExternalPlaybackButtonState(
config: ExternalPlaybackButtonStateConfig
): ExternalPlaybackButtonStateApi {
@@ -56,11 +71,7 @@ export function createExternalPlaybackButtonState(
const matchedSession = computed(() => {
const session = config.session();
// A closed or errored session says nothing about what is playing now.
if (
!session?.contentInfo ||
session.status === 'closed' ||
session.status === 'error'
) {
if (!session?.contentInfo || !isLiveExternalPlayerSession(session)) {
return null;
}
@@ -92,6 +103,7 @@ export function createExternalPlaybackButtonState(
return `Opening in ${player}...`;
case 'opened':
case 'playing':
case 'error':
return `Stop ${player}`;
default:
return null;
@@ -104,6 +116,7 @@ export function createExternalPlaybackButtonState(
return 'hourglass_top';
case 'opened':
case 'playing':
case 'error':
return 'stop_circle';
default:
return 'play_arrow';
@@ -116,7 +129,9 @@ export function createExternalPlaybackButtonState(
const isStopAction = computed(() => {
const status = matchedSession()?.status;
return status === 'opened' || status === 'playing';
return (
status === 'opened' || status === 'playing' || status === 'error'
);
});
const buttonState = computed<ExternalPlaybackButtonState>(() => {
@@ -267,10 +267,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
async onVodDownload(item: VodDetailsItem): Promise<void> {
@@ -543,6 +543,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
const request: PlaybackFallbackRequest = {
player: 'vlc',
playback,
trackLaunch: jest.fn(),
diagnostic: {
code: 'network-error',
player: 'html5',
@@ -560,8 +561,8 @@ describe('StalkerLiveStreamLayoutComponent', () => {
playback,
'vlc'
);
expect(portalPlayer.openExternalPlayback.mock.calls[0][0]).toBe(
playback
expect(request.trackLaunch).toHaveBeenCalledWith(
portalPlayer.openExternalPlayback.mock.results[0].value
);
});
@@ -1394,9 +1394,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
}
@@ -33,6 +33,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { SafePipe } from '@iptvnator/pipes';
import {
isLiveExternalPlayerSession,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
@@ -438,7 +439,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
return;
}
if (session.status === 'opened' || session.status === 'playing') {
if (isLiveExternalPlayerSession(session)) {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(session.contentInfo.contentXtreamId);
return;
@@ -900,10 +901,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
playPreviousEpisode(): void {
@@ -153,9 +153,11 @@ export class StalkerVodPlaybackController {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.config.portalPlayer.openExternalPlayback(
const launch = this.config.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
}
@@ -625,6 +625,7 @@ describe('LiveStreamLayoutComponent', () => {
component.handleExternalFallbackRequest({
player: 'mpv',
playback,
trackLaunch: jest.fn(),
} as PlaybackFallbackRequest);
const [forwardedPlayback, forwardedPlayer] =
@@ -565,10 +565,12 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
private getAllLiveStreams(): XtreamLiveChannelItem[] {
@@ -10,6 +10,7 @@ import {
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
isLiveExternalPlayerSession,
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
@@ -125,7 +126,7 @@ export class SerialDetailsPlaybackService {
return;
}
if (session.status === 'opened' || session.status === 'playing') {
if (isLiveExternalPlayerSession(session)) {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(session.contentInfo.contentXtreamId);
return;
@@ -295,12 +296,14 @@ export class SerialDetailsPlaybackService {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void this.playbackPositionState.recordExternalLaunch(
request.playback,
this.portalPlayer.openExternalPlayback(
request.playback,
request.player
),
launch,
(playlistId, position) =>
this.playbackPositions.savePlaybackPosition(
playlistId,
@@ -776,8 +776,10 @@ describe('SerialDetailsComponent', () => {
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
const trackLaunch = jest.fn();
playbackService.handleExternalFallbackRequest({
player: 'mpv',
trackLaunch,
playback: {
streamUrl: 'http://xtream.example/series/2001.mp4',
title: 'Season 2 Episode 1',
@@ -795,6 +797,9 @@ describe('SerialDetailsComponent', () => {
await fixture.whenStable();
expect(openExternalPlayback).toHaveBeenCalledTimes(1);
expect(trackLaunch).toHaveBeenCalledWith(
openExternalPlayback.mock.results[0].value
);
expect(savePlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({
@@ -0,0 +1,88 @@
import { Signal, computed, signal } from '@angular/core';
import type { PlayerContentInfo } from '@iptvnator/shared/interfaces';
interface RouteScopedExternalLaunch {
readonly routePlaylistId: string;
readonly routeContentId: number;
readonly content: PlayerContentInfo;
}
export interface ExternalLaunchRouteIdentity {
readonly playlistId: string;
readonly contentId: number;
}
export interface ExternalLaunchOwner {
readonly current: Signal<PlayerContentInfo | null>;
/** Exact last destination retained for close-before-replacement. */
readonly retained: Signal<PlayerContentInfo | null>;
captureRoute(): ExternalLaunchRouteIdentity | null;
ownsRoute(identity: ExternalLaunchRouteIdentity | null): boolean;
set(content: PlayerContentInfo | null | undefined): void;
clear(): void;
}
/** Retains an external destination only while its initiating VOD route owns it. */
export function createExternalLaunchOwner(
routePlaylistId: () => string | undefined,
routeContentId: () => number | undefined
): ExternalLaunchOwner {
const launch = signal<RouteScopedExternalLaunch | null>(null);
const captureRoute = (): ExternalLaunchRouteIdentity | null => {
const playlistId = routePlaylistId();
const contentId = routeContentId();
return playlistId &&
contentId !== undefined &&
Number.isSafeInteger(contentId) &&
contentId > 0
? { playlistId, contentId }
: null;
};
return {
current: computed(() => {
const value = launch();
return value &&
value.routePlaylistId === routePlaylistId() &&
value.routeContentId === routeContentId()
? value.content
: null;
}),
retained: computed(() => launch()?.content ?? null),
captureRoute,
ownsRoute: (identity) => {
const current = captureRoute();
return (
!!identity &&
current?.playlistId === identity.playlistId &&
current.contentId === identity.contentId
);
},
set: (content) => {
const route = captureRoute();
launch.set(
content && route
? {
routePlaylistId: route.playlistId,
routeContentId: route.contentId,
content,
}
: null
);
},
clear: () => launch.set(null),
};
}
/** Runs a Play/Resume only while its initiating VOD route still owns it. */
export async function startRouteOwnedPlayback(
owner: ExternalLaunchOwner,
start: (isCurrent: () => boolean) => Promise<boolean>
): Promise<boolean> {
const route = owner.captureRoute();
if (!route) return false;
try {
return await start(() => owner.ownsRoute(route));
} catch {
return false;
}
}
@@ -0,0 +1,60 @@
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
interface OwnedExternalLaunchOptions {
launch: Promise<ExternalPlayerSession | void>;
owns: () => boolean;
close: (session: ExternalPlayerSession) => Promise<void>;
warnCloseFailure: (error: unknown) => void;
clearPending: () => void;
clearOwnership: () => void;
}
/** Settles an exact MPV/VLC launch without letting stale results take ownership. */
export async function settleOwnedExternalLaunch(
options: OwnedExternalLaunchOptions
): Promise<boolean> {
try {
const launched = await options.launch;
const accepted =
launched?.status === 'opened' || launched?.status === 'playing';
if (accepted && options.owns()) {
options.clearPending();
return true;
}
if (isClosableExternalLaunch(launched)) {
try {
await options.close(launched);
} catch (error) {
options.warnCloseFailure(error);
// The exact child is still potentially live. Keep the
// credential-free destination owner so the next source start
// can retry its close instead of depending on the global dock.
options.clearPending();
return false;
}
}
options.clearPending();
options.clearOwnership();
return false;
} catch {
// A partial reuse can reject while Electron retains a matching
// closable error session. Keep its credential-free identity so the
// next source start can still find and close that exact process.
options.clearPending();
return false;
}
}
function isClosableExternalLaunch(
session: ExternalPlayerSession | void
): session is ExternalPlayerSession {
return (
!!session &&
(session.status === 'launching' ||
session.status === 'opened' ||
session.status === 'playing' ||
(session.status === 'error' && session.canClose))
);
}
@@ -7,10 +7,7 @@ import {
} 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 type { PlayerContentInfo } from '@iptvnator/shared/interfaces';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
/**
@@ -30,18 +27,24 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
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);
const currentPlaylist = signal({ id: ROUTE_PLAYLIST });
const routeVodId = signal(ROUTE_VOD_ID);
function sessionFor(playlistId: string, contentXtreamId: number) {
return {
id: `${playlistId}:${contentXtreamId}`,
player: 'mpv',
status: 'playing',
title: 'Example Movie',
streamUrl: 'https://example.com/movie.mkv',
startedAt: '2026-08-08T00:00:00.000Z',
updatedAt: '2026-08-08T00:00:00.000Z',
canClose: true,
contentInfo: {
playlistId,
contentXtreamId,
@@ -53,8 +56,13 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
beforeEach(() => {
activeSession.set(null);
activeSource.set(null);
positionListener = undefined;
currentPlaylist.set({ id: ROUTE_PLAYLIST });
routeVodId.set(ROUTE_VOD_ID);
addRecentItem.mockClear();
closeSession.mockReset().mockResolvedValue(undefined);
openResolvedPlayback
.mockReset()
.mockResolvedValue(sessionFor('playlist-2', 991));
TestBed.configureTestingModule({
providers: [
@@ -62,7 +70,7 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
{
provide: XtreamStore,
useValue: {
currentPlaylist: signal({ id: ROUTE_PLAYLIST }),
currentPlaylist,
addRecentItem,
constructVodStreamUrl: jest
.fn()
@@ -90,12 +98,7 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: {
onPlaybackPositionUpdate: (
listener: (data: PlaybackPositionData) => void
) => {
positionListener = listener;
return () => undefined;
},
onPlaybackPositionUpdate: () => () => undefined,
},
},
],
@@ -103,17 +106,15 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
service = TestBed.inject(VodDetailsPlaybackService);
service.bind({
vodId: signal(ROUTE_VOD_ID),
vodId: routeVodId,
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.
// Ownership survives controller refreshes or overlapping UI state:
// the exact process this page launched still has to close first.
activeSession.set(sessionFor('playlist-2', 991));
activeSource.set({
playlistId: 'playlist-3',
@@ -153,22 +154,79 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
);
});
it('still starts the replacement when closing the old player fails', async () => {
it('cancels 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 expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
})
).resolves.toBe(false);
expect(openResolvedPlayback).not.toHaveBeenCalled();
closeSession.mockResolvedValue(undefined);
});
it('cancels the replacement while a live session cannot be closed', async () => {
activeSession.set({
...sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID),
status: 'launching',
canClose: false,
});
openResolvedPlayback.mockClear();
await expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
})
).resolves.toBe(false);
expect(closeSession).not.toHaveBeenCalled();
expect(openResolvedPlayback).not.toHaveBeenCalled();
});
it('rejects a handoff when the external player launch fails', async () => {
openResolvedPlayback.mockRejectedValueOnce(
new Error('previous player is still shutting down')
);
await expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
})
).resolves.toBe(false);
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
});
it('closes a closable error before starting a replacement source', async () => {
activeSession.set({
...sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID),
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
});
closeSession.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);
expect(closeSession).toHaveBeenCalledWith(
expect.objectContaining({ status: 'error', canClose: true })
);
});
it('launches only the newest source when two switches overlap', async () => {
@@ -209,6 +267,232 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
closeSession.mockResolvedValue(undefined);
});
it('checks host ownership after teardown before applying playback', 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();
let ownsSwitch = true;
const switching = service.startResolvedPlayback(
{
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
},
() => ownsSwitch
);
ownsSwitch = false;
releaseClose?.();
await expect(switching).resolves.toBe(false);
expect(openResolvedPlayback).not.toHaveBeenCalled();
closeSession.mockResolvedValue(undefined);
});
it('closes a launch that loses host ownership while IPC is pending', async () => {
let releaseLaunch: ((value: unknown) => void) | undefined;
const launched = sessionFor('playlist-2', 991);
openResolvedPlayback.mockReturnValueOnce(
new Promise((resolve) => {
releaseLaunch = resolve;
})
);
let ownsSwitch = true;
const switching = service.startResolvedPlayback(
{
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: launched.contentInfo,
},
() => ownsSwitch
);
while (openResolvedPlayback.mock.calls.length === 0) {
await Promise.resolve();
}
ownsSwitch = false;
releaseLaunch?.(launched);
await expect(switching).resolves.toBe(false);
expect(closeSession).toHaveBeenCalledWith(launched);
});
it('retains a stale launch when its exact close fails', async () => {
let releaseLaunch: ((value: unknown) => void) | undefined;
const launched = sessionFor('playlist-2', 991);
const failed = {
...launched,
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
};
openResolvedPlayback.mockReturnValueOnce(
new Promise((resolve) => {
releaseLaunch = resolve;
})
);
closeSession.mockRejectedValueOnce(new Error('close ipc failed'));
let ownsSwitch = true;
const switching = service.startResolvedPlayback(
{
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: launched.contentInfo,
},
() => ownsSwitch
);
while (openResolvedPlayback.mock.calls.length === 0) {
await Promise.resolve();
}
ownsSwitch = false;
activeSession.set(failed);
releaseLaunch?.(launched);
await expect(switching).resolves.toBe(false);
expect(service.matchedExternalPlayback()?.id).toBe(failed.id);
await service.startResolvedPlayback({
streamUrl: 'https://example.com/third.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-3',
contentXtreamId: 992,
contentType: 'vod',
},
});
expect(closeSession).toHaveBeenLastCalledWith(failed);
});
it('does not commit a session stopped while launch IPC is pending', async () => {
openResolvedPlayback.mockResolvedValueOnce({
...sessionFor('playlist-2', 991),
status: 'closed',
canClose: false,
});
await expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
})
).resolves.toBe(false);
});
it('owns an alternative immediately while its external launch is pending', async () => {
let releaseLaunch: ((value: unknown) => void) | undefined;
const launching = {
...sessionFor('playlist-2', 991),
status: 'launching',
};
openResolvedPlayback.mockReturnValueOnce(
new Promise((resolve) => {
releaseLaunch = resolve;
})
);
const switching = service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: launching.contentInfo,
});
while (openResolvedPlayback.mock.calls.length === 0) {
await Promise.resolve();
}
expect(service.isExternalLaunchPending()).toBe(true);
activeSession.set(launching);
expect(service.matchedExternalPlayback()?.id).toBe(launching.id);
const opened = { ...launching, status: 'opened' };
activeSession.set(opened);
releaseLaunch?.(opened);
await expect(switching).resolves.toBe(true);
expect(service.isExternalLaunchPending()).toBe(false);
});
it('keeps the pending launch when an unclosable source replacement is denied', async () => {
let releaseLaunch: ((value: unknown) => void) | undefined;
const launching = {
...sessionFor('playlist-2', 991),
status: 'launching',
canClose: false,
};
openResolvedPlayback.mockReturnValueOnce(
new Promise((resolve) => {
releaseLaunch = resolve;
})
);
const first = service.startResolvedPlayback({
streamUrl: 'https://example.com/first.mkv',
title: 'Example Movie',
contentInfo: launching.contentInfo,
});
while (openResolvedPlayback.mock.calls.length === 0) {
await Promise.resolve();
}
activeSession.set(launching);
await expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/second.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-3',
contentXtreamId: 77,
contentType: 'vod',
},
})
).resolves.toBe(false);
const opened = { ...launching, status: 'opened' };
activeSession.set(opened);
releaseLaunch?.(opened);
await expect(first).resolves.toBe(true);
expect(openResolvedPlayback).toHaveBeenCalledTimes(1);
expect(closeSession).not.toHaveBeenCalled();
});
it('retains a closable failed alternative for the next exact close', async () => {
const failed = {
...sessionFor('playlist-2', 991),
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
};
openResolvedPlayback.mockImplementationOnce(async () => {
activeSession.set(failed);
throw new Error('External player process did not exit');
});
await expect(
service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: failed.contentInfo,
})
).resolves.toBe(false);
expect(service.matchedExternalPlayback()?.id).toBe(failed.id);
await service.startResolvedPlayback({
streamUrl: 'https://example.com/third.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-3',
contentXtreamId: 992,
contentType: 'vod',
},
});
expect(closeSession).toHaveBeenCalledWith(failed);
});
it('drops a switch that a plain Play overtook', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
let releaseClose: (() => void) | undefined;
@@ -246,6 +530,36 @@ describe('VodDetailsPlaybackService — external playback handoff', () => {
closeSession.mockResolvedValue(undefined);
});
it('drops a plain Play when its initiating route changes during close', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
let releaseClose: (() => void) | undefined;
closeSession.mockReturnValue(
new Promise<void>((resolve) => {
releaseClose = resolve;
})
);
openResolvedPlayback.mockClear();
addRecentItem.mockClear();
const playing = service.playVod({
movie_data: {
stream_id: ROUTE_VOD_ID,
name: 'First movie',
container_extension: 'mkv',
},
} as never);
while (closeSession.mock.calls.length === 0) {
await Promise.resolve();
}
routeVodId.set(ROUTE_VOD_ID + 1);
releaseClose?.();
await expect(playing).resolves.toBe(false);
expect(openResolvedPlayback).not.toHaveBeenCalled();
expect(addRecentItem).not.toHaveBeenCalled();
});
it('stops the running external player before switching sources', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
@@ -2,14 +2,15 @@ import type {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { isLiveExternalPlayerSession } from '@iptvnator/portal/shared/util';
/**
* Which external player process, and which position rows, belong to this page.
*
* Multi-source makes both questions harder than they look: playback can be on
* a copy of the film in ANOTHER playlist, whose ids the session and the
* position rows then carry, and during a switch the controller has already
* moved "active" to the destination before playback is handed over.
* position rows then carry. A source handoff also waits for exact process
* teardown before the destination can become active.
*/
/** Ids this page owns, beyond the route's own copy. */
@@ -53,10 +54,9 @@ export function ownsContent(
/**
* The external process this page started, if it is still up.
*
* Matched on the ids we LAUNCHED with rather than on what is active now: a
* switch marks the destination active before handing playback over, so asking
* "is this session ours?" at that moment answers no and leaves the running
* process playing beside its replacement.
* Matched on the ids we LAUNCHED with rather than only on what is active now:
* refreshes and overlapping handoffs may update controller state while exact
* process teardown is still in flight.
*/
export function runningExternalSession(
session: ExternalPlayerSession | null,
@@ -66,7 +66,7 @@ export function runningExternalSession(
if (
!session?.contentInfo ||
session.status === 'closed' ||
session.status === 'error'
(session.status === 'error' && !session.canClose)
) {
return null;
}
@@ -83,26 +83,29 @@ export function runningExternalSession(
/**
* 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.
* A failed close leaves teardown unconfirmed. Report false so the caller can
* cancel the replacement instead of starting a second external process.
*/
export async function closeRunningExternalSession(
session: ExternalPlayerSession | null,
close: (session: ExternalPlayerSession) => Promise<void>,
warn: (message: string, error: unknown) => void
): Promise<void> {
): Promise<boolean> {
if (!session) {
return;
return true;
}
if (isLiveExternalPlayerSession(session) && !session.canClose) {
return false;
}
try {
await close(session);
return true;
} catch (error) {
warn(
'Closing the previous external player failed; starting the replacement anyway.',
'Closing the previous external player failed; cancelling the replacement.',
error
);
return false;
}
}
@@ -31,6 +31,8 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
const activeSession = signal<unknown>(null);
const closeSession = jest.fn().mockResolvedValue(undefined);
const openResolvedPlayback = jest.fn();
const openExternalPlayback = jest.fn();
const supersedePendingSwitch = jest.fn();
const activeSource = signal<PlayerContentInfo | null>(null);
const currentPlaylist = signal({ id: ROUTE_PLAYLIST });
const routeVodId = signal(ROUTE_VOD_ID);
@@ -63,6 +65,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
return {
player: 'mpv',
status: 'playing',
canClose: true,
contentInfo: {
playlistId,
contentXtreamId,
@@ -78,6 +81,14 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
routeVodId.set(ROUTE_VOD_ID);
positionListener = undefined;
addRecentItem.mockClear();
closeSession.mockReset().mockResolvedValue(undefined);
openResolvedPlayback
.mockReset()
.mockResolvedValue(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
openExternalPlayback
.mockReset()
.mockResolvedValue(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
supersedePendingSwitch.mockClear();
getPlaybackPosition.mockReset();
getPlaybackPosition.mockResolvedValue(null);
@@ -110,6 +121,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback,
openExternalPlayback,
},
},
{
@@ -131,6 +143,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
vodId: routeVodId,
vodInfo: signal(null),
activeSource,
supersedePendingSwitch,
});
});
@@ -176,6 +189,142 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
expect(service.matchedExternalPlayback()).toBeNull();
});
it('disowns its previous external launch when the routed movie changes', async () => {
const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
await service.startResolvedPlayback({
streamUrl: 'https://example.com/route.mkv',
title: 'First movie',
contentInfo: launched.contentInfo,
});
activeSession.set(launched);
routeVodId.set(ROUTE_VOD_ID + 1);
expect(service.matchedExternalPlayback()).toBeNull();
expect(service.isExternalStopAction()).toBe(false);
});
it('closes the previous route launch before playing the new movie', async () => {
const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
await service.startResolvedPlayback({
streamUrl: 'https://example.com/first.mkv',
title: 'First movie',
contentInfo: launched.contentInfo,
});
activeSession.set(launched);
routeVodId.set(ROUTE_VOD_ID + 1);
closeSession.mockClear();
openResolvedPlayback.mockClear();
service.playVod({
movie_data: {
stream_id: ROUTE_VOD_ID + 1,
name: 'Second movie',
container_extension: 'mkv',
},
} as never);
for (let attempt = 0; attempt < 10; attempt += 1) {
if (openResolvedPlayback.mock.calls.length > 0) break;
await Promise.resolve();
}
expect(closeSession).toHaveBeenCalledWith(launched);
expect(closeSession.mock.invocationCallOrder[0]).toBeLessThan(
openResolvedPlayback.mock.invocationCallOrder[0]
);
});
it('closes a diagnostic fallback before playing the next routed movie', async () => {
const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
const launch = Promise.resolve(launched);
const trackLaunch = jest.fn();
openExternalPlayback.mockImplementationOnce(() => {
activeSession.set(launched);
expect(service.isExternalLaunchPending()).toBe(true);
expect(service.matchedExternalPlayback()).toBe(launched);
return launch;
});
service.handleExternalFallbackRequest({
player: 'mpv',
playback: {
streamUrl: 'https://example.com/first.mkv',
title: 'First movie',
contentInfo: launched.contentInfo,
},
diagnostic: {},
trackLaunch,
} as never);
expect(trackLaunch).toHaveBeenCalledWith(launch);
await launch;
await Promise.resolve();
routeVodId.set(ROUTE_VOD_ID + 1);
closeSession.mockClear();
openResolvedPlayback.mockClear();
service.playVod({
movie_data: {
stream_id: ROUTE_VOD_ID + 1,
name: 'Second movie',
container_extension: 'mkv',
},
} as never);
for (let attempt = 0; attempt < 10; attempt += 1) {
if (openResolvedPlayback.mock.calls.length > 0) break;
await Promise.resolve();
}
expect(closeSession).toHaveBeenCalledWith(launched);
expect(closeSession.mock.invocationCallOrder[0]).toBeLessThan(
openResolvedPlayback.mock.invocationCallOrder[0]
);
});
it('supersedes a pending source switch before starting a diagnostic fallback', () => {
const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
service.handleExternalFallbackRequest({
player: 'mpv',
playback: {
streamUrl: 'https://example.com/first.mkv',
title: 'First movie',
contentInfo: launched.contentInfo,
},
diagnostic: {},
trackLaunch: jest.fn(),
} as never);
expect(supersedePendingSwitch).toHaveBeenCalledTimes(1);
expect(supersedePendingSwitch.mock.invocationCallOrder[0]).toBeLessThan(
openExternalPlayback.mock.invocationCallOrder[0]
);
});
it('closes a diagnostic fallback that resolves after its route changed', async () => {
const launched = sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID);
const pending = deferred<typeof launched>();
const trackLaunch = jest.fn();
openExternalPlayback.mockReturnValueOnce(pending.promise);
service.handleExternalFallbackRequest({
player: 'mpv',
playback: {
streamUrl: 'https://example.com/first.mkv',
title: 'First movie',
contentInfo: launched.contentInfo,
},
diagnostic: {},
trackLaunch,
} as never);
routeVodId.set(ROUTE_VOD_ID + 1);
pending.resolve(launched);
await pending.promise;
await Promise.resolve();
expect(trackLaunch).toHaveBeenCalledWith(pending.promise);
expect(closeSession).toHaveBeenCalledWith(launched);
});
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
@@ -21,6 +21,7 @@ import {
} from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import {
ExternalPlayerSession,
PlaybackPositionData,
PlayerContentInfo,
ResolvedPortalPlayback,
@@ -33,6 +34,11 @@ import {
ownsContent,
runningExternalSession,
} from './vod-details-external-session';
import {
createExternalLaunchOwner,
startRouteOwnedPlayback,
} from './vod-details-external-launch-owner';
import { settleOwnedExternalLaunch } from './vod-details-external-launch';
import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation';
import { formatPlaybackPosition } from './vod-primary-action-position';
@@ -47,6 +53,8 @@ export interface VodDetailsPlaybackBindings {
* this the session belongs to no page and never offers Stop.
*/
activeSource?: Signal<PlayerContentInfo | null>;
/** Retires a source resolution that the accepted fallback now supersedes. */
supersedePendingSwitch: () => void;
}
/**
@@ -69,6 +77,10 @@ export class VodDetailsPlaybackService {
/** Signals bound from the host component via `bind()` */
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(null);
private readonly externalLaunchOwner = createExternalLaunchOwner(
() => this.xtreamStore.currentPlaylist()?.id,
() => this.bindings()?.vodId()
);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
/**
@@ -95,13 +107,22 @@ export class VodDetailsPlaybackService {
session: this.externalPlayback.activeSession,
playlistId: computed(() => this.xtreamStore.currentPlaylist()?.id),
contentId: computed(() => this.bindings()?.vodId()),
alsoOwns: computed(() => this.bindings()?.activeSource?.() ?? null),
alsoOwns: computed(
() =>
this.externalLaunchOwner.current() ??
this.bindings()?.activeSource?.() ??
null
),
});
readonly matchedExternalPlayback = this.externalButton.matchedSession;
readonly externalPrimaryLabel = this.externalButton.primaryLabel;
readonly externalPrimaryIcon = this.externalButton.primaryIcon;
readonly isExternalLaunchPending = this.externalButton.isLaunchPending;
readonly isExternalLaunchPending = computed(
() =>
this.externalLaunchGeneration() !== null ||
this.externalButton.isLaunchPending()
);
readonly isExternalStopAction = this.externalButton.isStopAction;
readonly externalPrimaryButtonState = this.externalButton.buttonState;
readonly vodPlaybackProgress = computed(() =>
@@ -177,23 +198,22 @@ export class VodDetailsPlaybackService {
this.bindings.set(bindings);
}
playVod(vodItem: XtreamVodDetails | null): void {
async playVod(vodItem: XtreamVodDetails | null): Promise<boolean> {
if (!vodItem) {
return;
return false;
}
const source = resolveXtreamVodPlaybackSource(vodItem);
if (!source) {
return;
return false;
}
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
return false;
}
const presentation = resolveXtreamVodPlaybackPresentation(vodItem);
this.addToRecentlyViewed();
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
const routeVodId = this.bindings()?.vodId();
const id =
@@ -217,26 +237,25 @@ export class VodDetailsPlaybackService {
contentInfo,
};
this.startPlayback(playback);
return await this.startPlayback(playback);
}
resumeVod(vodItem: XtreamVodDetails | null): void {
async resumeVod(vodItem: XtreamVodDetails | null): Promise<boolean> {
if (!vodItem) {
return;
return false;
}
const source = resolveXtreamVodPlaybackSource(vodItem);
if (!source) {
return;
return false;
}
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
return false;
}
const presentation = resolveXtreamVodPlaybackPresentation(vodItem);
this.addToRecentlyViewed();
// Master's sparse-details fallback: a provider that omits the route
// id still has the stream id on the resolved source.
const routeVodId = this.bindings()?.vodId();
@@ -265,7 +284,7 @@ export class VodDetailsPlaybackService {
contentInfo,
};
this.startPlayback(playback);
return await this.startPlayback(playback);
}
onPrimaryAction(vodItem: XtreamVodDetails | null): void {
@@ -274,22 +293,20 @@ export class VodDetailsPlaybackService {
}
if (this.isExternalStopAction()) {
void this.stopExternalPlayback();
void this.stopExternalPlayback().catch(() => undefined);
return;
}
if (this.hasPlaybackPosition()) {
this.resumeVod(vodItem);
void this.resumeVod(vodItem);
return;
}
this.playVod(vodItem);
void this.playVod(vodItem);
}
async stopExternalPlayback(): Promise<void> {
await this.externalPlayback.closeSession(
this.matchedExternalPlayback()
);
stopExternalPlayback(): Promise<void> {
return this.externalPlayback.closeSession(this.matchedExternalPlayback());
}
formatPosition(): string {
@@ -324,10 +341,20 @@ export class VodDetailsPlaybackService {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const routeIdentity = this.externalLaunchOwner.captureRoute();
this.bindings()?.supersedePendingSwitch();
const generation = ++this.startGeneration;
this.claimExternalLaunch(request.playback, generation);
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void this.settleExternalLaunch(
generation,
() => this.externalLaunchOwner.ownsRoute(routeIdentity),
launch
);
}
async loadPosition(playlistId: string, vodId: number): Promise<void> {
@@ -375,27 +402,40 @@ export class VodDetailsPlaybackService {
* What we last launched externally, remembered independently of the
* controller's active source.
*
* `matchedExternalPlayback` cannot answer this during a switch: the
* controller marks the DESTINATION active before playback is handed over,
* so by the time we get here the running process no longer looks like
* ours and would be left playing beside its replacement.
* Kept independently of the controller so a refresh or an overlapping
* handoff cannot make the exact process this page launched look foreign
* before its teardown has been confirmed. Its route owner prevents a
* reused component from attributing that process to a different movie.
*/
private launchedExternally: PlayerContentInfo | null = null;
private launchedExternallyGeneration = 0;
private readonly externalLaunchGeneration = signal<number | null>(null);
async startResolvedPlayback(
playback: ResolvedPortalPlayback
): Promise<void> {
playback: ResolvedPortalPlayback,
isCurrent: () => boolean = () => true
): Promise<boolean> {
if (this.externalLaunchGeneration() !== null) {
return false;
}
const runningSession = runningExternalSession(
this.externalPlayback.activeSession(),
this.externalLaunchOwner.retained(),
this.matchedExternalPlayback()
);
// A still-opening process has no exact closer yet. Denying this
// replacement must not supersede that launch generation, or its late
// successful result would be closed even though nothing replaced it.
if (runningSession && !runningSession.canClose) {
return false;
}
const generation = ++this.startGeneration;
// 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.
await closeRunningExternalSession(
runningExternalSession(
this.externalPlayback.activeSession(),
this.launchedExternally,
this.matchedExternalPlayback()
),
const previousPlayerClosed = await closeRunningExternalSession(
runningSession,
(session) => this.externalPlayback.closeSession(session),
(message, error) => this.logger.warn(message, error)
);
@@ -404,30 +444,90 @@ export class VodDetailsPlaybackService {
// reach this line too: both would have seen the same session, closed
// it once, and then launched independently — two detached players
// again, the older one holding a source the user has moved on from.
if (generation !== this.startGeneration) {
return;
if (
!previousPlayerClosed ||
generation !== this.startGeneration ||
!isCurrent()
) {
return false;
}
// Same movie, different source: still a view.
this.addToRecentlyViewed();
this.startPlayback(playback);
return await this.applyPlayback(playback, isCurrent);
}
private startPlayback(playback: ResolvedPortalPlayback): void {
private startPlayback(playback: ResolvedPortalPlayback): Promise<boolean> {
return startRouteOwnedPlayback(this.externalLaunchOwner, (isCurrent) =>
this.startResolvedPlayback(playback, isCurrent)
);
}
private async applyPlayback(
playback: ResolvedPortalPlayback,
isCurrent: () => boolean = () => true
): Promise<boolean> {
// EVERY start claims the generation, not just the switch path. Play,
// Resume and Restart reach here directly, and a switch still waiting
// on its `closeSession` would otherwise pass the check afterwards and
// launch on top of what the user just chose.
this.startGeneration++;
const generation = ++this.startGeneration;
this.positionWriter.reset();
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
this.launchedExternally = null;
return;
this.externalLaunchOwner.clear();
this.externalLaunchGeneration.set(null);
return true;
}
this.closeInlinePlayer();
this.launchedExternally = playback.contentInfo ?? null;
void this.portalPlayer.openResolvedPlayback(playback, true);
this.claimExternalLaunch(playback, generation);
const launch = this.portalPlayer.openResolvedPlayback(playback, true);
return await this.settleExternalLaunch(
generation,
isCurrent,
launch
);
}
private claimExternalLaunch(
playback: ResolvedPortalPlayback,
generation: number
): void {
this.externalLaunchOwner.set(playback.contentInfo);
this.launchedExternallyGeneration = generation;
this.externalLaunchGeneration.set(generation);
}
private async settleExternalLaunch(
generation: number,
isCurrent: () => boolean,
launch: Promise<ExternalPlayerSession | void>
): Promise<boolean> {
return await settleOwnedExternalLaunch({
launch,
owns: () => generation === this.startGeneration && isCurrent(),
close: (session) => this.externalPlayback.closeSession(session),
warnCloseFailure: (error) =>
this.logger.warn(
'Closing a superseded external player failed.',
error
),
clearPending: () => this.clearExternalLaunchPending(generation),
clearOwnership: () =>
this.clearExternalLaunchOwnership(generation),
});
}
private clearExternalLaunchOwnership(generation: number): void {
if (this.launchedExternallyGeneration === generation) {
this.externalLaunchOwner.clear();
}
}
private clearExternalLaunchPending(generation: number): void {
if (this.externalLaunchGeneration() === generation) {
this.externalLaunchGeneration.set(null);
}
}
}
@@ -112,7 +112,7 @@ describe('VodDetailsRouteComponent — source caption', () => {
expect(component.activeSourceCaption()).toBeNull();
});
it('waits for the next stream to start after Play is pressed again', () => {
it('waits for the next stream to start after Play is pressed again', async () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
@@ -131,8 +131,12 @@ describe('VodDetailsRouteComponent — source caption', () => {
// Restart keeps the same host mounted, so without clearing the latch
// the caption would carry the previous stream's claim into the new one.
component.playVod({
movie_data: { stream_id: 650020, name: 'Example' },
await component.playVod({
movie_data: {
stream_id: 650020,
name: 'Example',
container_extension: 'mkv',
},
} as never);
expect(playback.inlinePlayback()).not.toBeNull();
expect(component.activeSourceCaption()).toBeNull();
@@ -159,10 +163,12 @@ describe('VodDetailsRouteComponent — source caption', () => {
// The exact callback `switchToSource` invokes once it has resolved a
// new source. Standing up the resolver here would test the host, not
// the route's half of the seam.
(
await (
component.multiSource as unknown as {
bindings: {
startPlayback: (playback: ResolvedPortalPlayback) => void;
startPlayback: (
playback: ResolvedPortalPlayback
) => Promise<boolean>;
};
}
).bindings.startPlayback({
@@ -170,9 +176,6 @@ 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'
@@ -100,11 +100,61 @@ describe('VodDetailsRouteComponent — playback actions', () => {
expect(component.isExternalStopAction()).toBe(true);
});
it('drops the carried position when Restart starts from the beginning', () => {
it('wires diagnostic fallback acceptance to source-switch supersession', () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
const supersede = jest.spyOn(
component.multiSource,
'supersedePendingSwitch'
);
const launched = {
id: 'diagnostic-session',
player: 'mpv' as const,
status: 'opened' as const,
title: 'Example',
streamUrl: 'https://example.com/movie.mkv',
startedAt: '2026-08-09T00:00:00.000Z',
updatedAt: '2026-08-09T00:00:01.000Z',
canClose: true,
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod' as const,
},
};
const launch = Promise.resolve(launched);
stubs.openExternalPlayback.mockReturnValueOnce(launch);
component.handleExternalFallbackRequest({
player: 'mpv',
playback: {
streamUrl: launched.streamUrl,
title: launched.title,
contentInfo: launched.contentInfo,
},
diagnostic: {},
trackLaunch: jest.fn(),
} as never);
expect(supersede).toHaveBeenCalledTimes(1);
expect(supersede.mock.invocationCallOrder[0]).toBeLessThan(
stubs.openExternalPlayback.mock.invocationCallOrder[0]
);
});
it('drops the carried position when Restart starts from the beginning', async () => {
currentPlaylist.set({ id: 'playlist-1' });
stubs.isEmbeddedPlayer.mockReturnValue(true);
const component = fixture.componentInstance;
const reported = jest.spyOn(component.multiSource, 'reportPosition');
component.playVod({} as XtreamVodDetails);
await component.playVod({
movie_data: {
stream_id: 650020,
name: 'Example',
container_extension: 'mkv',
},
} as never);
// The controller still holds whatever this page was seeded with, and
// a failure before the first timeupdate would resolve the next source
@@ -112,6 +162,44 @@ describe('VodDetailsRouteComponent — playback actions', () => {
expect(reported).toHaveBeenCalledWith(0);
});
it('keeps the previous source state when its external close fails', async () => {
currentPlaylist.set({ id: 'playlist-1' });
activeSession.set({
id: 'previous-session',
player: 'mpv',
status: 'playing',
title: 'Previous source',
streamUrl: 'https://example.com/previous.mkv',
startedAt: '2026-08-08T00:00:00.000Z',
updatedAt: '2026-08-08T00:00:00.000Z',
canClose: true,
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
},
});
closeSession.mockRejectedValueOnce(new Error('close ipc failed'));
const component = fixture.componentInstance;
const markRouteSourceActive = jest.spyOn(
component.multiSource,
'markRouteSourceActive'
);
const beginPlayback = jest.spyOn(component.msUi, 'beginPlayback');
await component.playVod({
movie_data: {
stream_id: 650020,
name: 'Example',
container_extension: 'mkv',
},
} as never);
expect(markRouteSourceActive).not.toHaveBeenCalled();
expect(beginPlayback).not.toHaveBeenCalled();
expect(stubs.openResolvedPlayback).not.toHaveBeenCalled();
});
it('stops the external player when the button says Stop', async () => {
currentPlaylist.set({ id: 'playlist-1' });
downloadsAvailable.set(true);
@@ -235,6 +323,34 @@ describe('VodDetailsRouteComponent — playback actions', () => {
expect(stubs.openResolvedPlayback).not.toHaveBeenCalled();
});
it('guards Restart and provider actions before external IPC settles', async () => {
currentPlaylist.set({ id: 'playlist-1' });
stubs.openResolvedPlayback.mockReturnValue(new Promise(() => undefined));
const component = fixture.componentInstance;
const item = {
movie_data: {
stream_id: 650020,
name: 'Example',
container_extension: 'mp4',
},
} as never;
void component.restartVod(item);
for (
let attempt = 0;
attempt < 20 && stubs.openResolvedPlayback.mock.calls.length === 0;
attempt += 1
) {
await new Promise<void>((resolve) => setTimeout(resolve));
}
expect(component.isExternalLaunchPending()).toBe(true);
await component.restartVod(item);
await component.playFromProviderSource(item);
expect(component.isExternalLaunchPending()).toBe(true);
expect(stubs.openResolvedPlayback).toHaveBeenCalledTimes(1);
});
it('does not let the provider secondary bypass a pending external launch', async () => {
currentPlaylist.set({ id: 'playlist-1' });
activeSession.set({
@@ -492,6 +608,7 @@ describe('VodDetailsRouteComponent — playback actions', () => {
it('replaces an alternative’s timecode when Resume starts the route copy', async () => {
currentPlaylist.set({ id: 'playlist-1' });
stubs.isEmbeddedPlayer.mockReturnValue(true);
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
@@ -510,7 +627,7 @@ describe('VodDetailsRouteComponent — playback actions', () => {
// ROUTE copy has to overwrite it, or a failure before the first
// timeupdate resolves the next source at a position from another copy.
component.multiSource.reportPosition(4200);
component.resumeVod({
await component.resumeVod({
movie_data: {
stream_id: 650020,
name: 'Example',
@@ -544,8 +661,9 @@ describe('VodDetailsRouteComponent — playback actions', () => {
expect(reported).not.toHaveBeenCalled();
});
it('takes the route wrappers when the primary button falls through', () => {
it('takes the route wrappers when the primary button falls through', async () => {
currentPlaylist.set({ id: 'playlist-1' });
stubs.isEmbeddedPlayer.mockReturnValue(true);
const component = fixture.componentInstance;
withActiveSource('playlist-1', 650020);
jest.spyOn(component.multiSource, 'playPinnedSource').mockResolvedValue(
@@ -555,7 +673,7 @@ describe('VodDetailsRouteComponent — playback actions', () => {
// Reaching the service directly would skip the bookkeeping a route
// start needs — the controller would keep an alternative's timecode.
void component.onPrimaryAction({
await component.onPrimaryAction({
movie_data: {
stream_id: 650020,
name: 'Example',
@@ -563,9 +681,7 @@ describe('VodDetailsRouteComponent — playback actions', () => {
},
} as never);
return Promise.resolve().then(() => {
expect(reported).toHaveBeenCalledWith(0);
});
expect(reported).toHaveBeenCalledWith(0);
});
it('offers no resume point for a pinned copy watched through', async () => {
@@ -37,6 +37,7 @@
<button
class="play-btn play-btn--restart"
(click)="restartVod(playableItem)"
[disabled]="isExternalLaunchPending()"
>
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
@@ -119,6 +120,7 @@
type="button"
class="icon-action-btn"
(click)="playFromProviderSource(playableItem)"
[disabled]="isExternalLaunchPending()"
[matTooltip]="
'PORTALS.MULTI_SOURCE.PLAY_FROM_SOURCE'
| translate
@@ -370,6 +370,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
vodId: this.selectedVodId,
vodInfo: this.selectedVodInfo,
activeSource: this.msUi.activeAlternativeSource,
supersedePendingSwitch: () =>
this.multiSource.supersedePendingSwitch(),
});
effect(() => {
@@ -396,16 +398,21 @@ 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: (playback) => {
// A switch mounts a DIFFERENT stream in the same host, so the
// evidence that the previous one was playing says nothing
// about this one — without clearing it the caption and the
// badge would claim the new source while it is still opening.
this.msUi.reset();
void this.playback.startResolvedPlayback(playback);
startPlayback: async (playback, isCurrent) => {
const started = await this.playback.startResolvedPlayback(
playback,
isCurrent
);
if (started) {
// A switch mounts a DIFFERENT stream in the same host, so
// evidence from the previous one says nothing about it.
this.msUi.reset();
}
return started;
},
movie: this.multiSourceMovie,
playbackLive: this.playbackLive,
playbackStartBlocked: this.playback.isExternalLaunchPending,
});
// Initializes on first render and RE-initializes when the route
@@ -500,14 +507,20 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.xtreamStore.setSelectedItem(null);
}
playVod(vodItem: XtreamVodDetails | null): void {
async playVod(vodItem: XtreamVodDetails | null): Promise<boolean> {
this.multiSource.supersedePendingSwitch();
const started = await this.playback.playVod(vodItem);
if (!started) {
return false;
}
// Restart means from the beginning. The controller still holds the
// position this page was seeded with, and a failure before the first
// timeupdate would otherwise resolve the next source back at it.
this.multiSource.reportPosition(0);
this.multiSource.markRouteSourceActive();
this.msUi.beginPlayback();
this.playback.playVod(vodItem);
return true;
}
/**
@@ -518,6 +531,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
* user to the route's playlist.
*/
async restartVod(vodItem: XtreamVodDetails | null): Promise<void> {
if (this.isExternalLaunchPending()) {
return;
}
if (this.msUi.primaryIsPinnedCopy()) {
const outcome = await this.multiSource.playPinnedSource(async () =>
Promise.resolve(0)
@@ -527,19 +544,25 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
}
this.playVod(vodItem);
await this.playVod(vodItem);
}
resumeVod(vodItem: XtreamVodDetails | null): void {
this.multiSource.markRouteSourceActive();
this.msUi.beginPlayback();
async resumeVod(vodItem: XtreamVodDetails | null): Promise<boolean> {
this.multiSource.supersedePendingSwitch();
const started = await this.playback.resumeVod(vodItem);
if (!started) {
return false;
}
// The controller can still hold an ALTERNATIVE's timecode. A failure
// before the first timeupdate would otherwise resolve the next source
// at a position that belongs to a different copy.
this.multiSource.reportPosition(
this.playback.routePlaybackPosition()?.positionSeconds ?? 0
);
this.playback.resumeVod(vodItem);
this.multiSource.markRouteSourceActive();
this.msUi.beginPlayback();
return true;
}
async onPrimaryAction(vodItem: XtreamVodDetails | null): Promise<void> {
@@ -547,7 +570,11 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
// make the control do the opposite of what it says — launching a
// second player while the first keeps running.
if (this.playback.isExternalStopAction()) {
await this.playback.stopExternalPlayback();
try {
await this.playback.stopExternalPlayback();
} catch {
// The dock stays visible when process teardown is unconfirmed.
}
return;
}
@@ -566,7 +593,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
async playFromProviderSource(
vodItem: XtreamVodDetails | null
): Promise<void> {
if (this.externalPrimaryButtonState() !== 'idle') {
if (
this.isExternalLaunchPending() ||
this.externalPrimaryButtonState() !== 'idle'
) {
return;
}
@@ -587,11 +617,11 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
// bookkeeping a route start needs — clearing the playback evidence and
// replacing whatever timecode an alternative left in the controller.
if (this.playback.hasPlaybackPosition()) {
this.resumeVod(vodItem);
await this.resumeVod(vodItem);
return;
}
this.playVod(vodItem);
await this.playVod(vodItem);
}
stopExternalPlayback(): Promise<void> {
@@ -72,6 +72,7 @@ export function createVodDetailsRouteStubs() {
closeSession: jest.fn(),
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
snackBarOpen: jest.fn(),
startDownload: jest.fn().mockResolvedValue(undefined),
locationBack: jest.fn(),
@@ -109,6 +110,7 @@ export function resetVodDetailsRouteStubs(stubs: VodDetailsRouteStubs): void {
stubs.isDownloading.mockReturnValue(false);
stubs.isPausedDownload.mockReturnValue(false);
stubs.getDownloadedFilePath.mockReturnValue(undefined);
stubs.isEmbeddedPlayer.mockReturnValue(false);
}
/**
@@ -245,6 +247,7 @@ export async function configureVodDetailsRouteTestBed(
useValue: {
isEmbeddedPlayer: stubs.isEmbeddedPlayer,
openResolvedPlayback: stubs.openResolvedPlayback,
openExternalPlayback: stubs.openExternalPlayback,
},
},
{ provide: MatSnackBar, useValue: { open: stubs.snackBarOpen } },
@@ -39,6 +39,7 @@ describe('VodMultiSourceHostService — pin persistence', () => {
// Whatever is on screen; the pin path distinguishes it from selection.
const playbackLive = signal(false);
const playbackStartBlocked = signal(false);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
@@ -63,7 +64,9 @@ describe('VodMultiSourceHostService — pin persistence', () => {
beforeEach(() => {
jest.resetAllMocks();
startPlayback.mockResolvedValue(true);
movie.set(null);
playbackStartBlocked.set(false);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
@@ -89,7 +92,12 @@ describe('VodMultiSourceHostService — pin persistence', () => {
service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
service.bind({ startPlayback, movie, playbackLive })
service.bind({
startPlayback,
movie,
playbackLive,
playbackStartBlocked,
})
);
});
@@ -15,6 +15,7 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
const {
discovery,
movie,
playbackStartBlocked,
pins,
probes,
resolver,
@@ -135,10 +136,39 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
expect(startPlayback).toHaveBeenLastCalledWith(
expect.objectContaining({
streamUrl: expect.stringContaining(String(ALT_THREE.contentId)),
})
}),
expect.any(Function)
);
});
it('does not supersede a pending external launch with a blocked source pick', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
const firstLaunch = createDeferred<boolean>();
startPlayback
.mockImplementationOnce((_playback, isCurrent) => {
playbackStartBlocked.set(true);
return firstLaunch.promise.then((opened) => {
playbackStartBlocked.set(false);
return opened && isCurrent();
});
})
.mockResolvedValueOnce(false);
const first = service.play(ALT_TWO.id);
while (startPlayback.mock.calls.length === 0) {
await Promise.resolve();
}
await expect(service.play(ALT_THREE.id)).resolves.toBe(false);
expect(startPlayback).toHaveBeenCalledTimes(1);
firstLaunch.resolve(true);
await expect(first).resolves.toBe(true);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
expect(rowFor(ALT_THREE.id)?.isActive).toBe(false);
});
it('drops a switch whose movie was navigated away from', async () => {
await loadMovie([ALT_TWO]);
service.reportPosition(2538);
@@ -40,6 +40,7 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
// Whatever is on screen; the pin path distinguishes it from selection.
const playbackLive = signal(false);
const playbackStartBlocked = signal(false);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
@@ -72,7 +73,9 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
beforeEach(() => {
jest.resetAllMocks();
startPlayback.mockResolvedValue(true);
movie.set(null);
playbackStartBlocked.set(false);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
@@ -98,7 +101,12 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
service.bind({ startPlayback, movie, playbackLive })
service.bind({
startPlayback,
movie,
playbackLive,
playbackStartBlocked,
})
);
});
@@ -122,6 +122,7 @@ export function setupVodMultiSourceHost() {
const movie = signal<VodMultiSourceMovie | null>(null);
/** Whatever is on screen; the pin path distinguishes it from selection. */
const playbackLive = signal(false);
const playbackStartBlocked = signal(false);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
@@ -132,6 +133,7 @@ export function setupVodMultiSourceHost() {
const harness = {
movie,
playbackLive,
playbackStartBlocked,
vodAutoFailover,
startPlayback,
discovery,
@@ -143,8 +145,10 @@ export function setupVodMultiSourceHost() {
/** Call from `beforeEach`; returns the freshly bound service. */
reset(): VodMultiSourceHostService {
jest.resetAllMocks();
startPlayback.mockResolvedValue(true);
movie.set(null);
playbackLive.set(false);
playbackStartBlocked.set(false);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
@@ -170,7 +174,12 @@ export function setupVodMultiSourceHost() {
harness.service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
harness.service.bind({ startPlayback, movie, playbackLive })
harness.service.bind({
startPlayback,
movie,
playbackLive,
playbackStartBlocked,
})
);
return harness.service;
},
@@ -9,10 +9,7 @@ import {
StreamProbeService,
VodSourcePinService,
} from '@iptvnator/services';
import type {
VodSourceCandidate,
VodSourceField,
} from '@iptvnator/shared/interfaces';
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
@@ -43,6 +40,7 @@ describe('VodMultiSourceHostService', () => {
// Whatever is on screen; the pin path distinguishes it from selection.
const playbackLive = signal(false);
const playbackStartBlocked = signal(false);
const vodAutoFailover = signal(false);
const startPlayback = jest.fn();
const discovery = { isAvailable: true, discover: jest.fn() };
@@ -89,7 +87,9 @@ describe('VodMultiSourceHostService', () => {
beforeEach(() => {
jest.resetAllMocks();
startPlayback.mockResolvedValue(true);
movie.set(null);
playbackStartBlocked.set(false);
vodAutoFailover.set(false);
discovery.isAvailable = true;
discovery.discover.mockResolvedValue({
@@ -115,7 +115,12 @@ describe('VodMultiSourceHostService', () => {
service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
service.bind({ startPlayback, movie, playbackLive })
service.bind({
startPlayback,
movie,
playbackLive,
playbackStartBlocked,
})
);
});
@@ -164,10 +169,57 @@ describe('VodMultiSourceHostService', () => {
);
expect(startPlayback).toHaveBeenCalledTimes(1);
expect(startPlayback).toHaveBeenCalledWith(
expect.objectContaining({ startTime: 2538 })
expect.objectContaining({ startTime: 2538 }),
expect.any(Function)
);
});
it('keeps the current source when playback rejects the handoff', async () => {
await loadMovie([ALT_TWO]);
startPlayback.mockResolvedValueOnce(false);
await expect(service.play(ALT_TWO.id)).resolves.toBe(false);
expect(rowFor(CURRENT_A_ID)?.isActive).toBe(true);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(false);
expect(service.lastSwitch()).toBeNull();
expect(service.previousSourceId()).toBeNull();
});
it('does not launch a resolved source after a newer unresolvable pick', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
const teardown = createDeferred<void>();
const launched: string[] = [];
startPlayback.mockImplementationOnce(
async (
playback: { streamUrl: string },
isCurrent?: () => boolean
) => {
await teardown.promise;
if (isCurrent && !isCurrent()) {
return false;
}
launched.push(playback.streamUrl);
return true;
}
);
const first = service.play(ALT_TWO.id);
while (startPlayback.mock.calls.length === 0) {
await Promise.resolve();
}
resolver.resolve.mockResolvedValueOnce(null);
await expect(service.play(ALT_THREE.id)).resolves.toBe(false);
teardown.resolve();
await expect(first).resolves.toBe(false);
expect(launched).toEqual([]);
expect(rowFor(CURRENT_A_ID)?.isActive).toBe(true);
expect(service.lastSwitch()).toBeNull();
});
it('never puts a credential-bearing playlist name in the notice', async () => {
// Users routinely name a playlist after the URL they pasted, and this
// string goes straight into a toast over the video.
@@ -61,8 +61,14 @@ import {
*/
export interface VodMultiSourceBindings {
/** Applies a playback — inline swap or external launch, host's choice. */
startPlayback: (playback: ResolvedPortalPlayback) => void;
/**
* Applies a playback — inline swap or external launch, host's choice.
* False leaves the controller on its current source.
*/
startPlayback: (
playback: ResolvedPortalPlayback,
isCurrent: () => boolean
) => Promise<boolean>;
/** The movie on screen, or null while its identity is not yet knowable. */
movie: Signal<VodMultiSourceMovie | null>;
/**
@@ -70,6 +76,12 @@ export interface VodMultiSourceBindings {
* A pinned row stays selected after its player is closed.
*/
playbackLive: Signal<boolean>;
/**
* True while an external launch has no exact closer yet. A source pick in
* that interval cannot safely replace the launch and must not supersede
* the switch token that still owns it.
*/
playbackStartBlocked: Signal<boolean>;
}
export type { VodMultiSourceSwitchNotice };
@@ -483,13 +495,18 @@ export class VodMultiSourceHostService {
* so an older resolution would replace what the user just asked for.
*/
markRouteSourceActive(): void {
this.switchToken++;
this.supersedePendingSwitch();
if (this.routeSourceId) {
this.controller.markPlaying(this.routeSourceId);
this.publish();
}
}
/** Cancel an older source resolution without claiming that route playback started. */
supersedePendingSwitch(): void {
this.switchToken++;
}
/** The live position, fed ahead of the persist throttle. */
reportPosition(seconds: number): void {
this.controller.setResumeSeconds(seconds);
@@ -501,7 +518,7 @@ export class VodMultiSourceHostService {
private switchTo(candidate: VodSourceCandidate): Promise<SwitchOutcome> {
const bindings = this.bindings;
if (!bindings) {
if (!bindings || bindings.playbackStartBlocked()) {
return Promise.resolve('superseded');
}
@@ -512,7 +529,8 @@ export class VodMultiSourceHostService {
controller: this.controller,
resolve: (target, options) =>
this.resolver.resolve(target, options),
startPlayback: (playback) => bindings.startPlayback(playback),
startPlayback: (playback, isCurrent) =>
bindings.startPlayback(playback, isCurrent),
isCurrent: () => this.isCurrentSwitch(session, attempt),
setPreviousSource: (id) => this._previousSourceId.set(id),
setNotice: (notice) => this._lastSwitch.set(notice),
@@ -111,7 +111,11 @@ export interface SwitchDeps {
playback: ResolvedPortalPlayback;
candidate: VodSourceCandidate;
} | null>;
startPlayback: (playback: ResolvedPortalPlayback) => void;
/** True only when the host accepted and applied this exact handoff. */
startPlayback: (
playback: ResolvedPortalPlayback,
isCurrent: () => boolean
) => Promise<boolean>;
/** False once a newer switch, or another movie, owns the screen. */
isCurrent: () => boolean;
setPreviousSource: (sourceId: string | null) => void;
@@ -154,11 +158,21 @@ export async function switchToSource(
return 'unresolvable';
}
// Ownership must be rechecked inside the playback seam after any external
// teardown wait but before it applies the new source. A post-launch check
// alone can reject a stale handoff only after its player already exists.
const started = await deps.startPlayback(resolved.playback, deps.isCurrent);
if (!started || !deps.isCurrent()) {
// Teardown may fail before the host can apply the replacement, or a
// newer start may take ownership while that teardown is in flight.
// In either case the old source remains the only truthful selection.
return 'superseded';
}
controller.updateSource(resolved.candidate);
deps.setPreviousSource(previous?.id ?? null);
controller.markPlaying(candidate.id);
controller.setResumeSeconds(resumeSeconds);
deps.startPlayback(resolved.playback);
deps.setNotice(
buildSwitchNotice(
@@ -3,11 +3,7 @@ import { PlayerContentInfo } from './portal-playback.interface';
export type ExternalPlayerName = 'mpv' | 'vlc';
export type ExternalPlayerSessionStatus =
| 'launching'
| 'opened'
| 'playing'
| 'error'
| 'closed';
'launching' | 'opened' | 'playing' | 'error' | 'closed';
export interface ExternalPlayerSession {
id: string;
@@ -21,4 +17,6 @@ export interface ExternalPlayerSession {
updatedAt: string;
error?: string;
canClose: boolean;
/** Exact failed replacement displaced by this restored live session. */
restoredFromSessionId?: string;
}
@@ -2,6 +2,7 @@
class="external-playback-dock"
[class.external-playback-dock--compact]="compact()"
[class.external-playback-dock--error]="session().status === 'error'"
[attr.aria-busy]="session().status === 'launching' ? 'true' : null"
>
<button
type="button"
@@ -10,9 +11,7 @@
artworkInteractive()
"
[disabled]="!artworkInteractive()"
[attr.aria-label]="
artworkInteractive() ? 'Open in playlist' : null
"
[attr.aria-label]="artworkInteractive() ? 'Open in playlist' : null"
(click)="onArtworkClick()"
>
@if (showArtwork()) {
@@ -31,35 +30,50 @@
<div class="external-playback-dock__meta">
<div class="external-playback-dock__title">{{ displayTitle() }}</div>
<div class="external-playback-dock__status">
<span class="external-playback-dock__chip">{{ playerLabel() }}</span>
<div class="external-playback-dock__status" aria-live="polite">
<span class="external-playback-dock__chip">{{
playerLabel()
}}</span>
<span
class="external-playback-dock__status-separator"
aria-hidden="true"
>·</span
>
@if (showSpinner()) {
<mat-spinner diameter="12" strokeWidth="2"></mat-spinner>
<mat-spinner
aria-hidden="true"
diameter="12"
strokeWidth="2"
></mat-spinner>
} @else if (showStatusIcon()) {
<mat-icon class="external-playback-dock__status-icon">{{
statusIcon()
}}</mat-icon>
<mat-icon
aria-hidden="true"
class="external-playback-dock__status-icon"
>{{ statusIcon() }}</mat-icon
>
}
<span class="external-playback-dock__status-text">{{
statusLabel()
}}</span>
<span class="external-playback-dock__status-text">
@if (session().status === 'error' && session().error) {
{{ session().error }}
} @else {
{{ statusLabelKey() | translate }}
}
</span>
</div>
</div>
<div class="external-playback-dock__actions">
@if (showCloseButton()) {
@if (showCloseAction()) {
<button
type="button"
mat-button
class="external-playback-dock__button"
(click)="closeClicked.emit()"
[attr.aria-label]="
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSE' | translate
"
(click)="onCloseClick()"
>
<mat-icon>stop_circle</mat-icon>
<mat-icon aria-hidden="true">stop_circle</mat-icon>
@if (!compact()) {
<span>{{
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSE' | translate
@@ -67,5 +81,38 @@
}
</button>
}
@if (showDismissAction()) {
@if (compact()) {
<button
type="button"
mat-icon-button
class="external-playback-dock__button external-playback-dock__dismiss"
[attr.aria-label]="
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_DISMISS' | translate
"
(click)="onDismissClick()"
>
<mat-icon aria-hidden="true">close</mat-icon>
</button>
} @else {
<button
type="button"
mat-button
class="external-playback-dock__button external-playback-dock__dismiss"
[attr.aria-label]="
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_DISMISS' | translate
"
(click)="onDismissClick()"
>
<mat-icon aria-hidden="true">close</mat-icon>
@if (!compact()) {
<span>{{
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_DISMISS'
| translate
}}</span>
}
</button>
}
}
</div>
</section>
@@ -9,27 +9,27 @@
gap: 14px;
min-height: 72px;
padding: 2px 14px;
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 60%, transparent);
border: 1px solid var(--app-widget-header-border, rgba(0, 0, 0, 0.08));
border-radius: 15px 15px 0 0;
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--mat-sys-surface-container-high) 88%, white 12%),
color-mix(in srgb, var(--mat-sys-surface-container) 92%, transparent)
);
box-shadow:
0 18px 42px rgba(15, 23, 42, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--app-widget-bg, #fff) 88%, white 12%),
var(--app-widget-header-bg, #fafbfc)
);
box-shadow: var(--app-widget-shadow, 0 18px 42px rgba(15, 23, 42, 0.12));
}
.external-playback-dock--error {
border-color: color-mix(in srgb, var(--mat-sys-error) 50%, transparent);
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--mat-sys-error-container) 72%, transparent),
color-mix(in srgb, var(--mat-sys-surface-container) 90%, transparent)
);
border-color: color-mix(
in srgb,
var(--app-expiry-expired-text, #b3261e) 50%,
transparent
);
background: linear-gradient(
135deg,
var(--app-expiry-expired-surface, rgba(244, 67, 54, 0.14)),
var(--app-widget-bg, #fff)
);
}
.external-playback-dock--compact {
@@ -37,9 +37,7 @@
padding: 8px 10px;
gap: 10px;
border-radius: 16px;
box-shadow:
0 12px 28px rgba(15, 23, 42, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
box-shadow: var(--app-widget-shadow, 0 12px 28px rgba(15, 23, 42, 0.16));
.external-playback-dock__artwork {
width: 38px;
@@ -63,7 +61,7 @@
border: 0;
border-radius: 14px;
overflow: hidden;
background: color-mix(in srgb, var(--mat-sys-surface-container-highest) 92%, transparent);
background: var(--app-widget-header-bg, #fafbfc);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
color: inherit;
cursor: default;
@@ -93,7 +91,7 @@
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 2px;
}
}
@@ -102,7 +100,7 @@
.external-playback-dock__placeholder {
display: grid;
place-items: center;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color, #4a5060);
}
.external-playback-dock__meta {
@@ -114,14 +112,9 @@
align-items: center;
height: 18px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-highest) 80%,
transparent
);
color: var(--mat-sys-on-surface-variant);
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent);
background: var(--app-card-hover-bg, #f0f2f5);
color: var(--app-body-color, #4a5060);
border: 1px solid var(--app-widget-header-border, rgba(0, 0, 0, 0.08));
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.08em;
@@ -133,7 +126,7 @@
white-space: nowrap;
font-size: 0.95rem;
font-weight: 700;
color: var(--mat-sys-on-surface);
color: var(--app-heading-color, #1a1d23);
margin-bottom: 2px;
}
@@ -141,7 +134,7 @@
display: flex;
align-items: center;
gap: 6px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color, #4a5060);
font-size: 0.78rem;
min-width: 0;
@@ -179,6 +172,10 @@
white-space: nowrap;
}
.external-playback-dock__dismiss {
flex-shrink: 0;
}
@media (max-width: 720px) {
.external-playback-dock {
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -17,6 +17,11 @@ class FakeTranslateLoader implements TranslateLoader {
WORKSPACE: {
SHELL: {
EXTERNAL_PLAYBACK_CLOSE: 'Close player',
EXTERNAL_PLAYBACK_DISMISS: 'Dismiss',
EXTERNAL_PLAYBACK_OPENING: 'Opening player…',
EXTERNAL_PLAYBACK_STARTED: 'Player started',
EXTERNAL_PLAYBACK_PLAYING: 'Playing',
EXTERNAL_PLAYBACK_FAILED: 'External player error',
},
},
});
@@ -65,8 +70,18 @@ describe('ExternalPlaybackDockComponent', () => {
const text = fixture.nativeElement.textContent;
expect(text).toContain('Example Video');
expect(text).toContain('MPV');
expect(text).toContain('Launching');
expect(text).toContain('Opening player');
expect(text).toContain('Close player');
expect(
fixture.nativeElement
.querySelector('.external-playback-dock')
.getAttribute('aria-busy')
).toBe('true');
expect(
fixture.nativeElement
.querySelector('.external-playback-dock__status')
.getAttribute('aria-live')
).toBe('polite');
});
it('emits a single close action when the close button is clicked', () => {
@@ -96,6 +111,78 @@ describe('ExternalPlaybackDockComponent', () => {
).toBe(true);
});
it.each([
['opened', 'Player started'],
['playing', 'Playing'],
] as const)('renders exact %s status copy', (status, expected) => {
fixture.componentRef.setInput('session', { ...session, status });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(expected);
expect(
fixture.nativeElement
.querySelector('.external-playback-dock')
.getAttribute('aria-busy')
).toBeNull();
});
it('keeps a failed session visible with a safe dismiss action and no retry', () => {
const dismissSpy = jest.fn();
component.dismissClicked.subscribe(dismissSpy);
fixture.componentRef.setInput('session', {
...session,
status: 'error',
error: '',
canClose: false,
});
fixture.detectChanges();
const action = fixture.debugElement.query(
By.css('.external-playback-dock__button')
);
expect(fixture.nativeElement.textContent).toContain(
'External player error'
);
expect(action.nativeElement.textContent).toContain('Dismiss');
expect(action.nativeElement.textContent).not.toContain('Retry');
expect(action.nativeElement.getAttribute('aria-label')).toBe('Dismiss');
expect(action.nativeElement.classList).toContain('mat-mdc-button');
expect(action.nativeElement.classList).not.toContain(
'mat-mdc-icon-button'
);
action.nativeElement.click();
expect(dismissSpy).toHaveBeenCalledTimes(1);
});
it('preserves the only stop affordance when an errored process may still be alive', () => {
const closeSpy = jest.fn();
const dismissSpy = jest.fn();
component.closeClicked.subscribe(closeSpy);
component.dismissClicked.subscribe(dismissSpy);
fixture.componentRef.setInput('session', {
...session,
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
});
fixture.detectChanges();
const stop = fixture.debugElement.query(
By.css('.external-playback-dock__button')
);
const dismiss = fixture.debugElement.query(
By.css('.external-playback-dock__dismiss')
);
expect(stop.nativeElement.textContent).toContain('Close player');
expect(dismiss).toBeNull();
stop.nativeElement.click();
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(dismissSpy).not.toHaveBeenCalled();
});
it('disables the artwork button when the session has no playlist target', () => {
const artwork = fixture.debugElement.query(
By.css('.external-playback-dock__artwork')
@@ -35,6 +35,7 @@ export class ExternalPlaybackDockComponent {
readonly compact = input(false);
readonly closeClicked = output<void>();
readonly dismissClicked = output<void>();
readonly artworkClicked = output<void>();
private readonly artworkFailed = signal(false);
@@ -55,25 +56,27 @@ export class ExternalPlaybackDockComponent {
readonly artworkUrl = computed(
() => this.session().thumbnail?.trim() ?? ''
);
readonly statusLabel = computed(() => {
readonly statusLabelKey = computed(() => {
const session = this.session();
switch (session.status) {
case 'launching':
return 'Launching…';
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_OPENING';
case 'opened':
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_STARTED';
case 'playing':
return 'Opened';
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_PLAYING';
case 'error':
return session.error || 'Playback failed';
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_FAILED';
default:
return 'Closed';
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSED';
}
});
readonly statusIcon = computed(() => {
const status = this.session().status;
if (status === 'error') return 'error_outline';
if (status === 'playing') return 'play_circle';
return 'open_in_new';
});
@@ -101,8 +104,9 @@ export class ExternalPlaybackDockComponent {
readonly artworkInteractive = computed(
() => !!this.session().contentInfo?.playlistId
);
readonly showCloseButton = computed(
() => this.session().canClose && this.session().status !== 'error'
readonly showCloseAction = computed(() => this.session().canClose);
readonly showDismissAction = computed(
() => this.session().status === 'error' && !this.session().canClose
);
constructor() {
@@ -120,4 +124,12 @@ export class ExternalPlaybackDockComponent {
if (!this.artworkInteractive()) return;
this.artworkClicked.emit();
}
onCloseClick(): void {
this.closeClicked.emit();
}
onDismissClick(): void {
this.dismissClicked.emit();
}
}
@@ -101,12 +101,30 @@
[attr.data-test-id]="
getRecommendationTestId(recommendation)
"
[disabled]="pending()"
[class.web-player-diagnostic__player-card--disabled]="
pending()
"
[disabled]="
pending() &&
!isExternalPlayerRecommendation(recommendation)
"
[attr.aria-disabled]="pending() ? 'true' : null"
[attr.aria-busy]="
isExternalLaunching(recommendation) ? 'true' : null
"
(click)="activate(recommendation)"
>
<mat-icon aria-hidden="true">{{
getRecommendationIcon(recommendation)
}}</mat-icon>
@if (isExternalLaunching(recommendation)) {
<mat-spinner
aria-hidden="true"
diameter="18"
strokeWidth="2"
/>
} @else {
<mat-icon aria-hidden="true">{{
getRecommendationIcon(recommendation)
}}</mat-icon>
}
<span class="web-player-diagnostic__player-copy">
<span class="web-player-diagnostic__player-label">
{{
@@ -124,6 +142,17 @@
) | translate
}}
</span>
@if (
getExternalStatusKey(recommendation);
as statusKey
) {
<span
class="web-player-diagnostic__player-status"
aria-live="polite"
>
{{ statusKey | translate }}
</span>
}
@if (
isTemporaryBuiltInRecommendation(recommendation)
) {
@@ -221,12 +221,17 @@
transform: translateY(1px);
}
.web-player-diagnostic__player-card:disabled {
.web-player-diagnostic__player-card:disabled,
.web-player-diagnostic__player-card--disabled {
cursor: wait;
opacity: 0.56;
transform: none;
}
.web-player-diagnostic__player-card mat-spinner {
--mdc-circular-progress-active-indicator-color: currentColor;
}
.web-player-diagnostic__player-card:focus-visible,
.web-player-diagnostic__utility-button:focus-visible,
.web-player-diagnostic__details summary:focus-visible {
@@ -278,6 +283,24 @@
white-space: normal;
}
.web-player-diagnostic__player-status {
min-width: 0;
color: currentColor;
font-size: 0.78rem;
font-weight: 680;
line-height: 1.1;
opacity: 0.82;
overflow-wrap: anywhere;
}
@media (prefers-reduced-motion: reduce) {
.web-player-diagnostic__player-card,
.web-player-diagnostic__player-card mat-spinner {
transition: none;
animation: none;
}
}
.web-player-diagnostic__utilities {
display: flex;
flex-wrap: wrap;
@@ -16,6 +16,7 @@ import type {
VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { PlaybackDiagnosticPanelComponent } from './playback-diagnostic-panel.component';
import type { ExternalRecoveryStates } from '../web-player-view/external-playback-recovery';
const DIAGNOSTIC: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedContainer,
@@ -106,6 +107,16 @@ function source(index: number): VodSourceDescriptor {
};
}
function externalStates(
overrides: Partial<ExternalRecoveryStates> = {}
): ExternalRecoveryStates {
return {
mpv: { attempts: 0, sessionId: null, status: 'idle' },
vlc: { attempts: 0, sessionId: null, status: 'idle' },
...overrides,
};
}
describe('PlaybackDiagnosticPanelComponent', () => {
let fixture: ComponentFixture<PlaybackDiagnosticPanelComponent>;
let component: PlaybackDiagnosticPanelComponent;
@@ -131,6 +142,12 @@ describe('PlaybackDiagnosticPanelComponent', () => {
REASON_DIFFERENT_ENGINE_FAMILY:
'This player uses a different playback engine that may support the stream format more reliably.',
RECOMMENDATIONS_LABEL: 'Recommended recovery actions',
ACTION_OPEN_MPV: 'Open in MPV',
ACTION_OPEN_VLC: 'Open in VLC',
ACTION_OPENING_MPV: 'Opening MPV…',
ACTION_REOPEN_MPV: 'Open MPV again',
EXTERNAL_OPENING: 'Opening player…',
EXTERNAL_STARTED: 'Player started',
},
PORTALS: {
MULTI_SOURCE: {
@@ -148,6 +165,74 @@ describe('PlaybackDiagnosticPanelComponent', () => {
fixture.componentRef.setInput('playback', PLAYBACK);
fixture.componentRef.setInput('supportsManagedExternalPlayers', false);
fixture.componentRef.setInput('playbackExternallyTransferable', true);
fixture.componentRef.setInput('externalStates', externalStates());
});
it('keeps the focused external button mounted through launch and started states', () => {
const players: PlaybackRecommendationTarget[] = [];
component.playerRequested.subscribe((target) => players.push(target));
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'mpv',
priority: 'primary',
}),
recommendation('player', { target: 'vlc' }),
]);
fixture.detectChanges();
const mpv = fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"]'
) as HTMLButtonElement;
mpv.focus();
fixture.componentRef.setInput(
'externalStates',
externalStates({
mpv: { attempts: 1, sessionId: null, status: 'launching' },
})
);
fixture.componentRef.setInput('pending', true);
fixture.detectChanges();
const launchingMpv = fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"]'
) as HTMLButtonElement;
expect(launchingMpv).toBe(mpv);
expect(document.activeElement).toBe(mpv);
expect(mpv.disabled).toBe(false);
expect(mpv.getAttribute('aria-disabled')).toBe('true');
expect(mpv.getAttribute('aria-busy')).toBe('true');
expect(mpv.querySelector('mat-spinner')).not.toBeNull();
expect(mpv.textContent).toContain('Opening MPV');
expect(mpv.textContent).toContain('Opening player');
expect(
mpv
.querySelector('.web-player-diagnostic__player-status')
?.getAttribute('aria-live')
).toBe('polite');
mpv.click();
expect(players).toEqual([]);
fixture.componentRef.setInput(
'externalStates',
externalStates({
mpv: {
attempts: 1,
sessionId: 'mpv-session',
status: 'started',
},
})
);
fixture.componentRef.setInput('pending', false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"]'
)
).toBe(mpv);
expect(mpv.textContent).toContain('Open MPV again');
expect(mpv.textContent).toContain('Player started');
expect(mpv.getAttribute('aria-busy')).toBeNull();
});
it('applies the component host style from the actual panel stylesheet', () => {
@@ -503,10 +588,7 @@ describe('PlaybackDiagnosticPanelComponent', () => {
code: PlaybackDiagnosticCode.DrmOrEncryption,
});
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.componentRef.setInput(
'playbackExternallyTransferable',
false
);
fixture.componentRef.setInput('playbackExternallyTransferable', false);
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
@@ -7,6 +7,7 @@ import {
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import {
@@ -19,6 +20,10 @@ import type {
VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import type {
ExternalRecoveryStates,
ExternalRecoveryTargetState,
} from '../web-player-view/external-playback-recovery';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
@@ -29,11 +34,13 @@ import {
import {
getRecommendationIcon,
getRecommendationKey,
getRecommendationLabelKey,
getExternalRecommendationStatusKey,
getRecommendationLabelKey as resolveRecommendationLabelKey,
getRecommendationParams,
getRecommendationReasonKey,
getRecommendationTestId,
isExternalPlayerRecommendation,
isExternalRecommendationLaunching,
isTemporaryBuiltInRecommendation,
} from './playback-recommendation-view.util';
@@ -48,6 +55,7 @@ const ERROR_SCREEN_ALTERNATIVES = 5;
imports: [
ClipboardModule,
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
TranslateModule,
VodSourceRowComponent,
@@ -62,6 +70,7 @@ export class PlaybackDiagnosticPanelComponent {
readonly supportsManagedExternalPlayers = input.required<boolean>();
readonly playbackExternallyTransferable = input.required<boolean>();
readonly alternativeSources = input<readonly VodSourceDescriptor[]>([]);
readonly externalStates = input.required<ExternalRecoveryStates>();
readonly pending = input(false);
readonly retryRequested = output<void>();
@@ -97,11 +106,11 @@ export class PlaybackDiagnosticPanelComponent {
readonly getRecommendationKey = getRecommendationKey;
readonly getRecommendationTestId = getRecommendationTestId;
readonly getRecommendationIcon = getRecommendationIcon;
readonly getRecommendationLabelKey = getRecommendationLabelKey;
readonly getRecommendationParams = getRecommendationParams;
readonly getRecommendationReasonKey = getRecommendationReasonKey;
readonly isTemporaryBuiltInRecommendation =
isTemporaryBuiltInRecommendation;
readonly isExternalPlayerRecommendation = isExternalPlayerRecommendation;
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
return getDiagnosticDescriptionKey(
@@ -112,6 +121,9 @@ export class PlaybackDiagnosticPanelComponent {
}
activate(recommendation: PlaybackRecommendation): void {
if (this.pending()) {
return;
}
switch (recommendation.action) {
case 'retry':
this.retryRequested.emit();
@@ -123,4 +135,35 @@ export class PlaybackDiagnosticPanelComponent {
return;
}
}
getRecommendationLabelKey(recommendation: PlaybackRecommendation): string {
return resolveRecommendationLabelKey(
recommendation,
this.getExternalState(recommendation)
);
}
getExternalStatusKey(
recommendation: PlaybackRecommendation
): string | null {
return getExternalRecommendationStatusKey(
recommendation,
this.getExternalState(recommendation)
);
}
isExternalLaunching(recommendation: PlaybackRecommendation): boolean {
return isExternalRecommendationLaunching(
recommendation,
this.getExternalState(recommendation)
);
}
private getExternalState(
recommendation: PlaybackRecommendation
): ExternalRecoveryTargetState | undefined {
return isExternalPlayerRecommendation(recommendation)
? this.externalStates()[recommendation.target]
: undefined;
}
}
@@ -11,8 +11,11 @@ import {
getRecommendationParams,
getRecommendationReasonKey,
getRecommendationTestId,
getExternalRecommendationStatusKey,
isExternalRecommendationLaunching,
isTemporaryBuiltInRecommendation,
} from './playback-recommendation-view.util';
import type { ExternalRecoveryTargetState } from '../web-player-view/external-playback-recovery';
function player(
target: PlaybackRecommendationTarget,
@@ -43,6 +46,73 @@ function alternative(): PlaybackRecommendation {
}
describe('playback recommendation view mappings', () => {
it.each([
[
'mpv',
{ attempts: 1, sessionId: null, status: 'launching' },
'PLAYBACK_DIAGNOSTICS.ACTION_OPENING_MPV',
'PLAYBACK_DIAGNOSTICS.EXTERNAL_OPENING',
],
[
'vlc',
{ attempts: 1, sessionId: 'vlc-1', status: 'started' },
'PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_VLC',
'PLAYBACK_DIAGNOSTICS.EXTERNAL_STARTED',
],
[
'mpv',
{ attempts: 1, sessionId: 'mpv-1', status: 'playing' },
'PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_MPV',
'PLAYBACK_DIAGNOSTICS.EXTERNAL_PLAYING',
],
[
'vlc',
{ attempts: 2, sessionId: null, status: 'error' },
'PLAYBACK_DIAGNOSTICS.ACTION_RETRY_VLC',
'PLAYBACK_DIAGNOSTICS.EXTERNAL_FAILED',
],
] as const)(
'maps %s %s state to exact action and status copy',
(target, state, actionKey, statusKey) => {
const recommendation = player(target);
expect(
getRecommendationLabelKey(
recommendation,
state as ExternalRecoveryTargetState
)
).toBe(actionKey);
expect(
getExternalRecommendationStatusKey(
recommendation,
state as ExternalRecoveryTargetState
)
).toBe(statusKey);
expect(
isExternalRecommendationLaunching(
recommendation,
state as ExternalRecoveryTargetState
)
).toBe(state.status === 'launching');
}
);
it('keeps an attempted closed target actionable without a stale status', () => {
const recommendation = player('mpv');
const state: ExternalRecoveryTargetState = {
attempts: 1,
sessionId: null,
status: 'idle',
};
expect(getRecommendationLabelKey(recommendation, state)).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_MPV'
);
expect(
getExternalRecommendationStatusKey(recommendation, state)
).toBeNull();
});
it.each([
['videojs', 'playback-recommendation-videojs'],
['html5', 'playback-recommendation-html5'],
@@ -3,6 +3,8 @@ import {
type PlaybackRecommendationReason,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
import type { ExternalRecoveryTargetState } from '../web-player-view/external-playback-recovery';
export function getRecommendationKey(
recommendation: PlaybackRecommendation
@@ -26,7 +28,8 @@ export function getRecommendationTestId(
}
export function getRecommendationLabelKey(
recommendation: PlaybackRecommendation
recommendation: PlaybackRecommendation,
externalState?: ExternalRecoveryTargetState
): string {
switch (recommendation.action) {
case 'retry':
@@ -34,10 +37,41 @@ export function getRecommendationLabelKey(
case 'alternative-source':
return 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE';
case 'player':
return getPlayerLabelKey(recommendation.target);
return getPlayerLabelKey(recommendation.target, externalState);
}
}
export function getExternalRecommendationStatusKey(
recommendation: PlaybackRecommendation,
state?: ExternalRecoveryTargetState
): string | null {
if (!isExternalPlayerRecommendation(recommendation) || !state) {
return null;
}
switch (state.status) {
case 'launching':
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_OPENING';
case 'started':
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_STARTED';
case 'playing':
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_PLAYING';
case 'error':
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_FAILED';
case 'idle':
return null;
}
}
export function isExternalRecommendationLaunching(
recommendation: PlaybackRecommendation,
state?: ExternalRecoveryTargetState
): boolean {
return (
isExternalPlayerRecommendation(recommendation) &&
state?.status === 'launching'
);
}
export function getRecommendationParams(
recommendation: PlaybackRecommendation
): Readonly<Record<string, string>> {
@@ -93,7 +127,10 @@ export function isTemporaryBuiltInRecommendation(
export function isExternalPlayerRecommendation(
recommendation: PlaybackRecommendation
): boolean {
): recommendation is Extract<
PlaybackRecommendation,
{ readonly action: 'player' }
> & { readonly target: ExternalPlayerName } {
return (
recommendation.action === 'player' &&
isExternalTarget(recommendation.target)
@@ -115,19 +152,38 @@ function getPlayerTestId(target: PlaybackRecommendationTarget): string {
}
}
function getPlayerLabelKey(target: PlaybackRecommendationTarget): string {
function getPlayerLabelKey(
target: PlaybackRecommendationTarget,
state?: ExternalRecoveryTargetState
): string {
switch (target) {
case 'videojs':
case 'html5':
case 'artplayer':
return 'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER';
case 'mpv':
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV';
return getExternalPlayerLabelKey('MPV', state);
case 'vlc':
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC';
return getExternalPlayerLabelKey('VLC', state);
}
}
function getExternalPlayerLabelKey(
player: 'MPV' | 'VLC',
state?: ExternalRecoveryTargetState
): string {
if (state?.status === 'launching') {
return `PLAYBACK_DIAGNOSTICS.ACTION_OPENING_${player}`;
}
if (state?.status === 'error') {
return `PLAYBACK_DIAGNOSTICS.ACTION_RETRY_${player}`;
}
if (state && state.attempts > 0) {
return `PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_${player}`;
}
return `PLAYBACK_DIAGNOSTICS.ACTION_OPEN_${player}`;
}
function getPlayerName(target: PlaybackRecommendationTarget): string {
switch (target) {
case 'videojs':
@@ -143,6 +199,8 @@ function getPlayerName(target: PlaybackRecommendationTarget): string {
}
}
function isExternalTarget(target: PlaybackRecommendationTarget): boolean {
function isExternalTarget(
target: PlaybackRecommendationTarget
): target is ExternalPlayerName {
return target === 'mpv' || target === 'vlc';
}
@@ -251,7 +251,11 @@ export class VodDetailsComponent {
async onPrimaryAction(): Promise<void> {
if (this.isExternalStopAction()) {
await this.stopExternalPlayback();
try {
await this.stopExternalPlayback();
} catch {
// The dock stays visible when process teardown is unconfirmed.
}
return;
}
@@ -0,0 +1,205 @@
import { signal } from '@angular/core';
import type { PortalExternalPlayback } from '@iptvnator/portal/shared/util';
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { ExternalPlaybackRecoveryCoordinator } from './external-playback-recovery-coordinator';
describe('ExternalPlaybackRecoveryCoordinator', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('does not launch beside a replacement live session that appears during close', async () => {
const previous = session({ id: 'previous', player: 'mpv' });
const activeSession = signal<ExternalPlayerSession | null>(previous);
let releaseClose: (() => void) | undefined;
const closeSession = jest.fn(
() =>
new Promise<void>((resolve) => {
releaseClose = resolve;
})
);
const externalPlayback: PortalExternalPlayback = {
activeSession,
visibleSession: activeSession,
closeSession,
dismissActiveSession: jest.fn(),
};
const coordinator = new ExternalPlaybackRecoveryCoordinator(
externalPlayback
);
coordinator.syncSession('content-a');
const ready = jest.fn(() => true);
coordinator.request('vlc', jest.fn(), ready);
activeSession.set(session({ id: 'replacement', player: 'mpv' }));
releaseClose?.();
await Promise.resolve();
expect(ready).not.toHaveBeenCalled();
expect(coordinator.states().vlc.status).toBe('error');
coordinator.destroy();
});
it('applies the confirmed old-session close before launching a replacement', async () => {
const previous = session({ id: 'previous', player: 'mpv' });
const activeSession = signal<ExternalPlayerSession | null>(null);
const closeSession = jest.fn(async () => {
activeSession.set(
session({
id: 'previous',
player: 'mpv',
status: 'closed',
canClose: false,
})
);
});
const coordinator = new ExternalPlaybackRecoveryCoordinator({
activeSession,
visibleSession: activeSession,
closeSession,
dismissActiveSession: jest.fn(),
});
coordinator.syncSession('content-a');
let trackFirst:
| ((launch: Promise<ExternalPlayerSession | void>) => void)
| undefined;
coordinator.request('mpv', jest.fn(), (trackLaunch) => {
trackFirst = trackLaunch;
return true;
});
trackFirst?.(Promise.resolve(previous));
await Promise.resolve();
activeSession.set(previous);
let stateAtReady: string | undefined;
coordinator.request('vlc', jest.fn(), () => {
stateAtReady = coordinator.states().mpv.status;
return true;
});
await Promise.resolve();
expect(stateAtReady).toBe('idle');
coordinator.destroy();
});
it('closes a closable error before retrying another external target', async () => {
const previous = session({
id: 'uncertain-process',
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
});
const activeSession = signal<ExternalPlayerSession | null>(previous);
const closeSession = jest.fn(async () => {
activeSession.set({
...previous,
status: 'closed',
canClose: false,
});
});
const coordinator = new ExternalPlaybackRecoveryCoordinator({
activeSession,
visibleSession: activeSession,
closeSession,
dismissActiveSession: jest.fn(),
});
coordinator.syncSession('content-a');
const ready = jest.fn(() => true);
coordinator.request('vlc', jest.fn(), ready);
await Promise.resolve();
expect(closeSession).toHaveBeenCalledWith(previous);
expect(ready).toHaveBeenCalledTimes(1);
coordinator.destroy();
});
it('cancels an intent when launch ownership disappears during close', async () => {
const previous = session({ id: 'previous', player: 'mpv' });
const activeSession = signal<ExternalPlayerSession | null>(previous);
let releaseClose: (() => void) | undefined;
const coordinator = new ExternalPlaybackRecoveryCoordinator({
activeSession,
visibleSession: activeSession,
closeSession: jest.fn(
() =>
new Promise<void>((resolve) => {
releaseClose = () => {
activeSession.set(
session({
...previous,
status: 'closed',
canClose: false,
})
);
resolve();
};
})
),
dismissActiveSession: jest.fn(),
});
coordinator.syncSession('content-a');
coordinator.request('vlc', jest.fn(), () => false);
releaseClose?.();
await Promise.resolve();
expect(coordinator.pending()).toBe(false);
expect(coordinator.states().vlc.status).toBe('idle');
coordinator.destroy();
});
it('correlates only the exact promise bound to the current attempt', async () => {
const activeSession = signal<ExternalPlayerSession | null>(null);
const coordinator = new ExternalPlaybackRecoveryCoordinator({
activeSession,
visibleSession: activeSession,
closeSession: jest.fn(),
dismissActiveSession: jest.fn(),
});
coordinator.syncSession('content-a');
let firstTracker:
| ((launch: Promise<ExternalPlayerSession | void>) => void)
| undefined;
coordinator.request('mpv', jest.fn(), (trackLaunch) => {
firstTracker = trackLaunch;
return true;
});
jest.advanceTimersByTime(10_000);
let retryTracker:
| ((launch: Promise<ExternalPlayerSession | void>) => void)
| undefined;
coordinator.request('mpv', jest.fn(), (trackLaunch) => {
retryTracker = trackLaunch;
return true;
});
firstTracker?.(
Promise.resolve(session({ id: 'late-first', player: 'mpv' }))
);
retryTracker?.(
Promise.resolve(session({ id: 'exact-retry', player: 'mpv' }))
);
await Promise.resolve();
expect(coordinator.states().mpv.sessionId).toBe('exact-retry');
coordinator.destroy();
});
});
function session(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'session',
player: 'mpv',
status: 'opened',
title: 'Example',
streamUrl: 'https://example.com/video.mkv',
startedAt: '2026-08-08T10:00:00.000Z',
updatedAt: '2026-08-08T10:00:01.000Z',
canClose: true,
...overrides,
};
}
@@ -0,0 +1,119 @@
import type { PortalExternalPlayback } from '@iptvnator/portal/shared/util';
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import {
type ExternalRecoveryIntent,
ExternalPlaybackRecovery,
} from './external-playback-recovery';
export type ExternalRecoveryTarget = 'mpv' | 'vlc';
export type ExternalPlaybackLaunchTracker = (
launch: Promise<ExternalPlayerSession | void>
) => void;
/**
* Serializes managed external-player recovery without retaining playback data.
* The callbacks are owned by the player view and run only for the current
* credential-free intent.
*/
export class ExternalPlaybackRecoveryCoordinator {
private readonly recovery = new ExternalPlaybackRecovery();
readonly states = this.recovery.states;
readonly pending = this.recovery.pending;
constructor(
private readonly externalPlayback: PortalExternalPlayback | null
) {}
observe(session: ExternalPlayerSession | null): void {
this.recovery.observe(session);
}
syncSession(sessionKey: string): void {
this.recovery.syncSession(sessionKey);
}
request(
target: ExternalRecoveryTarget,
onBegin: () => void,
onReady: (trackLaunch: ExternalPlaybackLaunchTracker) => boolean
): void {
const activeSession = this.externalPlayback?.activeSession() ?? null;
const intent = this.recovery.begin(target);
if (!intent) {
return;
}
onBegin();
if (!isLiveExternalSession(activeSession)) {
this.runReady(intent, onReady);
return;
}
if (!activeSession.canClose || !this.externalPlayback) {
this.recovery.fail(intent);
return;
}
void this.closeThenRun(activeSession, intent, onReady);
}
destroy(): void {
this.recovery.destroy();
}
private async closeThenRun(
session: ExternalPlayerSession,
intent: ExternalRecoveryIntent,
onReady: (trackLaunch: ExternalPlaybackLaunchTracker) => boolean
): Promise<void> {
try {
await this.externalPlayback?.closeSession(session);
} catch {
this.recovery.fail(intent);
return;
}
this.recovery.close(session.player, session.id);
const current = this.externalPlayback?.activeSession() ?? null;
if (isLiveExternalSession(current)) {
this.recovery.fail(intent);
return;
}
this.runReady(intent, onReady);
}
private runReady(
intent: ExternalRecoveryIntent,
onReady: (trackLaunch: ExternalPlaybackLaunchTracker) => boolean
): void {
if (!this.recovery.owns(intent)) {
return;
}
const accepted = onReady((launch) => {
void launch.then(
(session) => {
if (session) {
this.recovery.confirm(intent, session);
} else {
this.recovery.fail(intent);
}
},
() => this.recovery.fail(intent)
);
});
if (!accepted) {
this.recovery.cancel(intent);
}
}
}
function isLiveExternalSession(
session: ExternalPlayerSession | null
): session is ExternalPlayerSession {
return (
session?.status === 'launching' ||
session?.status === 'opened' ||
session?.status === 'playing' ||
(session?.status === 'error' && session.canClose)
);
}
@@ -0,0 +1,345 @@
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import {
EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS,
ExternalPlaybackRecovery,
} from './external-playback-recovery';
function session(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'session-1',
player: 'mpv',
status: 'opened',
title: 'Example',
streamUrl: 'https://user:secret@example.com/stream.mkv?token=private',
startedAt: '2026-08-08T10:00:00.000Z',
updatedAt: '2026-08-08T10:00:01.000Z',
canClose: true,
...overrides,
};
}
describe('ExternalPlaybackRecovery', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('starts one external launch intent and rejects a competing target', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = recovery.begin('mpv');
expect(intent).not.toBeNull();
expect(recovery.pending()).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
sessionId: null,
status: 'launching',
});
expect(recovery.begin('vlc')).toBeNull();
recovery.destroy();
});
it('requires the exact owned launch result before accepting session updates', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('mpv'));
expect(
recovery.observe(session({ id: 'old-session', status: 'playing' }))
).toBe(false);
expect(
recovery.observe(
session({ id: 'vlc-session', player: 'vlc', status: 'opened' })
)
).toBe(false);
expect(
recovery.observe(
session({ id: 'mpv-session', player: 'mpv', status: 'opened' })
)
).toBe(false);
expect(
recovery.confirm(
intent,
session({ id: 'mpv-session', player: 'mpv', status: 'opened' })
)
).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
sessionId: 'mpv-session',
status: 'started',
});
expect(recovery.pending()).toBe(false);
expect(
recovery.observe(
session({ id: 'other-mpv', player: 'mpv', status: 'error' })
)
).toBe(false);
expect(recovery.target('mpv').status).toBe('started');
recovery.destroy();
});
it.each([
['playing', 'playing'],
['error', 'error'],
] as const)('maps exact session %s to %s', (sessionStatus, stateStatus) => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
intent,
session({ id: 'mpv-session', status: 'opened' })
);
expect(
recovery.observe(
session({ id: 'mpv-session', status: sessionStatus })
)
).toBe(true);
expect(recovery.target('mpv').status).toBe(stateStatus);
recovery.destroy();
});
it('returns a closed exact session to idle while retaining attempt history', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
intent,
session({ id: 'mpv-session', status: 'opened' })
);
expect(
recovery.observe(
session({
id: 'mpv-session',
status: 'closed',
canClose: false,
})
)
).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
sessionId: null,
status: 'idle',
});
recovery.destroy();
});
it('times out a missing launch handoff without accepting its later session', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv');
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
expect(recovery.pending()).toBe(false);
expect(recovery.target('mpv').status).toBe('error');
expect(
recovery.observe(session({ id: 'late-session', status: 'opened' }))
).toBe(false);
recovery.destroy();
});
it('keeps an exact launching session correlated after the local timeout', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
intent,
session({ id: 'slow-mpv', player: 'mpv', status: 'launching' })
);
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
sessionId: 'slow-mpv',
status: 'error',
});
expect(
recovery.observe(
session({ id: 'slow-mpv', player: 'mpv', status: 'playing' })
)
).toBe(true);
expect(recovery.target('mpv').status).toBe('playing');
recovery.destroy();
});
it('invalidates an old intent and timer when the content session changes', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = recovery.begin('mpv');
expect(intent).not.toBeNull();
recovery.syncSession('content-b');
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
expect(recovery.owns(requireIntent(intent))).toBe(false);
expect(recovery.pending()).toBe(false);
expect(recovery.target('mpv')).toEqual({
attempts: 0,
sessionId: null,
status: 'idle',
});
expect(
recovery.observe(session({ id: 'stale-session', status: 'error' }))
).toBe(false);
recovery.destroy();
});
it('clears the launch timer and ownership when destroyed', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv');
recovery.destroy();
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
expect(jest.getTimerCount()).toBe(0);
expect(recovery.pending()).toBe(false);
expect(recovery.target('mpv').status).toBe('launching');
});
it('fails only the current intent and counts a later retry', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const first = requireIntent(recovery.begin('mpv'));
expect(recovery.fail(first)).toBe(true);
expect(recovery.fail(first)).toBe(false);
const second = requireIntent(recovery.begin('mpv'));
expect(recovery.target('mpv')).toEqual({
attempts: 2,
sessionId: null,
status: 'launching',
});
expect(recovery.owns(second)).toBe(true);
recovery.destroy();
});
it('rejects a late result from a timed-out attempt while accepting the retry result', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const first = requireIntent(recovery.begin('mpv'));
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
const retry = requireIntent(recovery.begin('mpv'));
expect(
recovery.confirm(
first,
session({ id: 'late-first', status: 'opened' })
)
).toBe(false);
expect(
recovery.observe(session({ id: 'late-first', status: 'playing' }))
).toBe(false);
expect(
recovery.confirm(
retry,
session({ id: 'exact-retry', status: 'opened' })
)
).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 2,
sessionId: 'exact-retry',
status: 'started',
});
recovery.destroy();
});
it('cancels an unlaunched current intent without reporting an error', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('vlc'));
expect(recovery.cancel(intent)).toBe(true);
expect(recovery.pending()).toBe(false);
expect(recovery.target('vlc')).toEqual({
attempts: 1,
sessionId: null,
status: 'idle',
});
recovery.destroy();
});
it('updates the previous exact session without cancelling a different target intent', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const mpvIntent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
mpvIntent,
session({ id: 'mpv-session', status: 'opened' })
);
const vlcIntent = requireIntent(recovery.begin('vlc'));
expect(
recovery.observe(
session({
id: 'mpv-session',
status: 'closed',
canClose: false,
})
)
).toBe(true);
expect(recovery.target('mpv').status).toBe('idle');
expect(recovery.owns(vlcIntent)).toBe(true);
expect(recovery.pending()).toBe(true);
recovery.destroy();
});
it('applies a confirmed exact close even when a reactive observer coalesces it', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const mpvIntent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
mpvIntent,
session({ id: 'mpv-session', status: 'opened' })
);
expect(recovery.close('mpv', 'mpv-session')).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
sessionId: null,
status: 'idle',
});
expect(recovery.close('mpv', 'other-session')).toBe(false);
recovery.destroy();
});
it('stores no stream URL, headers, credentials, title, or error text', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
intent,
session({
error: 'token=private at /Users/example/player',
headers: undefined,
id: 'safe-id',
status: 'error',
} as Partial<ExternalPlayerSession>)
);
const serialized = JSON.stringify(recovery.states());
expect(serialized).toContain('safe-id');
expect(serialized).not.toContain('example.com');
expect(serialized).not.toContain('private');
expect(serialized).not.toContain('/Users/example');
expect(serialized).not.toContain('Example');
recovery.destroy();
});
});
function requireIntent<T>(intent: T | null): T {
if (intent === null) {
throw new Error('Expected an external recovery intent');
}
return intent;
}
@@ -0,0 +1,249 @@
import { computed, signal, type Signal } from '@angular/core';
import type {
ExternalPlayerName,
ExternalPlayerSession,
} from '@iptvnator/shared/interfaces';
export const EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS = 10_000;
export type ExternalRecoveryStatus =
'idle' | 'launching' | 'started' | 'playing' | 'error';
export interface ExternalRecoveryTargetState {
readonly attempts: number;
readonly sessionId: string | null;
readonly status: ExternalRecoveryStatus;
}
export interface ExternalRecoveryIntent {
readonly target: ExternalPlayerName;
readonly token: symbol;
}
export type ExternalRecoveryStates = Readonly<
Record<ExternalPlayerName, ExternalRecoveryTargetState>
>;
interface ActiveIntent extends ExternalRecoveryIntent {
readonly sessionId: string | null;
}
function idleState(): ExternalRecoveryTargetState {
return Object.freeze({ attempts: 0, sessionId: null, status: 'idle' });
}
function initialStates(): ExternalRecoveryStates {
return Object.freeze({ mpv: idleState(), vlc: idleState() });
}
export class ExternalPlaybackRecovery {
private readonly statesState =
signal<ExternalRecoveryStates>(initialStates());
private readonly activeIntentState = signal<ActiveIntent | null>(null);
private contentSessionKey: string | null = null;
private launchTimer: ReturnType<typeof setTimeout> | null = null;
readonly states: Signal<ExternalRecoveryStates> =
this.statesState.asReadonly();
readonly pending = computed(() => this.activeIntentState() !== null);
syncSession(key: string): boolean {
if (this.contentSessionKey === key) {
return false;
}
this.contentSessionKey = key;
this.clearActiveIntent();
this.statesState.set(initialStates());
return true;
}
begin(target: ExternalPlayerName): ExternalRecoveryIntent | null {
if (this.activeIntentState() !== null) {
return null;
}
const token = Symbol();
const intent: ActiveIntent = Object.freeze({
sessionId: null,
target,
token,
});
const previous = this.target(target);
this.setTarget(target, {
attempts: previous.attempts + 1,
sessionId: null,
status: 'launching',
});
this.activeIntentState.set(intent);
this.launchTimer = setTimeout(
() => this.fail(intent),
EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS
);
return intent;
}
owns(intent: ExternalRecoveryIntent): boolean {
const active = this.activeIntentState();
return (
active?.token === intent.token && active.target === intent.target
);
}
observe(session: ExternalPlayerSession | null): boolean {
if (!session) {
return false;
}
const correlated = this.target(session.player);
if (correlated.sessionId === session.id) {
return this.applySession(session.player, session);
}
const active = this.activeIntentState();
if (active) {
if (active.sessionId === null) {
return false;
} else if (session.id !== active.sessionId) {
return false;
}
return this.applySession(active.target, session);
}
const target = session.player;
return this.target(target).sessionId === session.id
? this.applySession(target, session)
: false;
}
confirm(
intent: ExternalRecoveryIntent,
session: ExternalPlayerSession
): boolean {
if (!this.owns(intent) || session.player !== intent.target) {
return false;
}
const active = this.activeIntentState();
if (!active) {
return false;
}
this.activeIntentState.set(
Object.freeze({ ...active, sessionId: session.id })
);
return this.applySession(intent.target, session);
}
cancel(intent: ExternalRecoveryIntent): boolean {
if (!this.owns(intent)) {
return false;
}
const current = this.target(intent.target);
if (current.status === 'launching') {
this.setTarget(intent.target, {
attempts: current.attempts,
sessionId: null,
status: 'idle',
});
}
this.clearActiveIntent();
return true;
}
close(target: ExternalPlayerName, sessionId: string): boolean {
const current = this.target(target);
if (current.sessionId !== sessionId) {
return false;
}
this.setTarget(target, {
attempts: current.attempts,
sessionId: null,
status: 'idle',
});
return true;
}
fail(intent: ExternalRecoveryIntent): boolean {
if (!this.owns(intent)) {
return false;
}
const current = this.target(intent.target);
this.setTarget(intent.target, {
attempts: current.attempts,
sessionId: current.sessionId,
status: 'error',
});
this.clearActiveIntent();
return true;
}
target(target: ExternalPlayerName): ExternalRecoveryTargetState {
return this.statesState()[target];
}
destroy(): void {
this.clearActiveIntent();
}
private applySession(
target: ExternalPlayerName,
session: ExternalPlayerSession
): boolean {
const previous = this.target(target);
const status = this.toRecoveryStatus(session.status);
this.setTarget(target, {
attempts: previous.attempts,
sessionId: status === 'idle' ? null : session.id,
status,
});
const active = this.activeIntentState();
if (
session.status !== 'launching' &&
active?.target === target &&
active.sessionId === session.id
) {
this.clearActiveIntent();
}
return true;
}
private toRecoveryStatus(
status: ExternalPlayerSession['status']
): ExternalRecoveryStatus {
switch (status) {
case 'launching':
return 'launching';
case 'opened':
return 'started';
case 'playing':
return 'playing';
case 'error':
return 'error';
case 'closed':
return 'idle';
}
}
private setTarget(
target: ExternalPlayerName,
state: ExternalRecoveryTargetState
): void {
this.statesState.update((states) =>
Object.freeze({
...states,
[target]: Object.freeze(state),
})
);
}
private clearActiveIntent(): void {
if (this.launchTimer !== null) {
clearTimeout(this.launchTimer);
this.launchTimer = null;
}
this.activeIntentState.set(null);
}
}
@@ -0,0 +1,33 @@
import type { PlaybackBinding } from './playback-recovery-session';
import type {
WebPlayerApplicationToken,
WebPlayerSourceRevisionToken,
} from './web-player-application-state';
export interface PlaybackApplicationOwnership {
readonly binding: PlaybackBinding | null;
readonly embeddedMpv: boolean;
readonly isLive: boolean;
readonly sourceRevision: WebPlayerSourceRevisionToken;
readonly token: WebPlayerApplicationToken;
}
export function ownsPlaybackApplication(options: {
readonly ownership: PlaybackApplicationOwnership;
readonly currentToken: WebPlayerApplicationToken;
readonly currentSourceRevision: WebPlayerSourceRevisionToken;
readonly bindingOwned: boolean;
readonly embeddedMpvSelected: boolean;
}): boolean {
const { ownership } = options;
if (
ownership.token !== options.currentToken ||
ownership.sourceRevision !== options.currentSourceRevision
) {
return false;
}
if (ownership.binding) {
return !ownership.embeddedMpv && options.bindingOwned;
}
return ownership.embeddedMpv && options.embeddedMpvSelected;
}
@@ -0,0 +1,169 @@
import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type { ExternalRecoveryStates } from './external-playback-recovery';
import { createWebPlayerRecommendations } from './web-player-recovery-policy';
const DIAGNOSTIC: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedContainer,
source: PlaybackDiagnosticSource.Native,
sourceUrl: 'https://example.com/movie.mkv',
container: 'matroska',
mimeType: 'video/matroska',
player: InlinePlaybackPlayer.VideoJs,
audioCodecs: [],
videoCodecs: [],
};
function externalStates(
overrides: Partial<ExternalRecoveryStates> = {}
): ExternalRecoveryStates {
return {
mpv: { attempts: 0, sessionId: null, status: 'idle' },
vlc: { attempts: 0, sessionId: null, status: 'idle' },
...overrides,
};
}
function recommendations(options: {
attemptedTargets?: ReadonlySet<PlaybackRecommendationTarget>;
diagnostic?: PlaybackDiagnostic;
externalStates?: ExternalRecoveryStates;
}) {
return createWebPlayerRecommendations({
diagnostic: options.diagnostic ?? DIAGNOSTIC,
binding: { generation: 1, target: InlinePlaybackPlayer.VideoJs },
attemptedTargets: options.attemptedTargets ?? new Set(),
externalStates: options.externalStates ?? externalStates(),
managedExternalPlayersAvailable: true,
playbackExternallyTransferable: true,
isLive: false,
alternativeSourceCount: 1,
});
}
function targets(
result: ReturnType<typeof recommendations>
): readonly string[] {
return result.map((item) =>
item.action === 'player' ? item.target : item.action
);
}
describe('createWebPlayerRecommendations external outcomes', () => {
it('keeps an attempted failed external target and promotes its untried sibling', () => {
const result = recommendations({
attemptedTargets: new Set(['mpv']),
externalStates: externalStates({
mpv: {
attempts: 1,
sessionId: 'mpv-session',
status: 'error',
},
}),
});
expect(targets(result)).toEqual(['vlc', 'mpv', 'alternative-source']);
expect(result.map((item) => item.priority)).toEqual([
'primary',
'secondary',
'secondary',
]);
});
it('retains inline family exclusion while reranking only external siblings', () => {
const result = recommendations({
diagnostic: {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.MediaDecodeError,
source: PlaybackDiagnosticSource.Hls,
container: 'm3u8',
mimeType: 'application/vnd.apple.mpegurl',
},
attemptedTargets: new Set([InlinePlaybackPlayer.Html5, 'mpv']),
externalStates: externalStates({
mpv: { attempts: 1, sessionId: null, status: 'error' },
}),
});
expect(targets(result)).toEqual(['vlc', 'mpv', 'alternative-source']);
expect(targets(result)).not.toContain(InlinePlaybackPlayer.Html5);
});
it('keeps a failed external target and reveals its capped sibling', () => {
const result = recommendations({
diagnostic: {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.DrmOrEncryption,
source: PlaybackDiagnosticSource.Hls,
container: 'm3u8',
mimeType: 'application/vnd.apple.mpegurl',
},
attemptedTargets: new Set(['mpv']),
externalStates: externalStates({
mpv: { attempts: 1, sessionId: null, status: 'error' },
}),
});
expect(targets(result)).toEqual([
InlinePlaybackPlayer.Html5,
'vlc',
'mpv',
]);
expect(result).toHaveLength(3);
});
it('retains a previously attempted target that the initial cap omitted', () => {
const result = recommendations({
diagnostic: {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.DrmOrEncryption,
source: PlaybackDiagnosticSource.Hls,
container: 'm3u8',
mimeType: 'application/vnd.apple.mpegurl',
},
attemptedTargets: new Set(['vlc']),
externalStates: externalStates({
vlc: { attempts: 1, sessionId: null, status: 'error' },
}),
});
expect(targets(result)).toEqual([
InlinePlaybackPlayer.Html5,
'mpv',
'vlc',
]);
expect(result).toHaveLength(3);
});
it('preserves policy order for equal attempt counts and stays capped at three', () => {
const result = recommendations({
attemptedTargets: new Set(['mpv', 'vlc']),
externalStates: externalStates({
mpv: { attempts: 2, sessionId: null, status: 'error' },
vlc: { attempts: 2, sessionId: null, status: 'error' },
}),
});
expect(targets(result)).toEqual(['mpv', 'vlc', 'alternative-source']);
expect(result).toHaveLength(3);
});
it('does not mutate attempts or external state inputs', () => {
const attemptedTargets = new Set<PlaybackRecommendationTarget>(['mpv']);
const states = externalStates({
mpv: { attempts: 1, sessionId: 'mpv-1', status: 'started' },
});
const attemptedBefore = [...attemptedTargets];
const statesBefore = JSON.stringify(states);
recommendations({ attemptedTargets, externalStates: states });
expect([...attemptedTargets]).toEqual(attemptedBefore);
expect(JSON.stringify(states)).toBe(statesBefore);
});
});
@@ -5,18 +5,22 @@ import {
resolvePlaybackSourceKind,
type PlaybackDiagnostic,
type PlaybackRecommendation,
type PlaybackRecommendationContext,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import {
VideoPlayer,
type ExternalPlayerName,
type ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { PlaybackBinding } from './playback-recovery-session';
import type { ExternalRecoveryStates } from './external-playback-recovery';
export function createWebPlayerRecommendations(options: {
readonly diagnostic: PlaybackDiagnostic | null;
readonly binding: PlaybackBinding | null;
readonly attemptedTargets: ReadonlySet<PlaybackRecommendationTarget>;
readonly externalStates: ExternalRecoveryStates;
readonly managedExternalPlayersAvailable: boolean;
readonly playbackExternallyTransferable: boolean;
readonly isLive: boolean;
@@ -26,10 +30,15 @@ export function createWebPlayerRecommendations(options: {
return [];
}
const sourceKind = resolvePlaybackSourceKind(options.diagnostic);
return recommendPlaybackRecovery({
const attemptedInlineTargets = new Set(
[...options.attemptedTargets].filter(
(target) => target !== 'mpv' && target !== 'vlc'
)
);
const context: PlaybackRecommendationContext = {
diagnostic: options.diagnostic,
activeTarget: options.binding.target,
attemptedTargets: options.attemptedTargets,
attemptedTargets: attemptedInlineTargets,
targetCapabilities: createPlaybackTargetCapabilities({
sourceKind,
managedExternalPlayersAvailable:
@@ -44,9 +53,128 @@ export function createWebPlayerRecommendations(options: {
externalTransferable: options.playbackExternallyTransferable,
},
alternativeSourceCount: options.alternativeSourceCount,
};
const recommendations = revealCappedExternalSiblings(
recommendPlaybackRecovery(context),
context,
options.externalStates
);
return rerankExternalRecommendations(
recommendations,
options.externalStates
);
}
function revealCappedExternalSiblings(
recommendations: readonly PlaybackRecommendation[],
context: PlaybackRecommendationContext,
states: ExternalRecoveryStates
): readonly PlaybackRecommendation[] {
const externalTargets: readonly ExternalPlayerName[] = ['mpv', 'vlc'];
if (!externalTargets.some((target) => states[target].attempts > 0)) {
return recommendations;
}
const retained = [...recommendations];
for (const target of externalTargets) {
if (
retained.some(
(recommendation) =>
isExternalRecommendation(recommendation) &&
recommendation.target === target
)
) {
continue;
}
const siblingTarget: ExternalPlayerName =
target === 'mpv' ? 'vlc' : 'mpv';
const candidate = recommendPlaybackRecovery({
...context,
attemptedTargets: new Set([
...context.attemptedTargets,
siblingTarget,
]),
}).find(
(recommendation) =>
isExternalRecommendation(recommendation) &&
recommendation.target === target
);
if (!candidate) {
continue;
}
if (retained.length >= 3) {
const replaceIndex = findLastReplaceableRecommendation(retained);
if (replaceIndex < 0) {
continue;
}
retained.splice(replaceIndex, 1);
}
retained.push(candidate);
}
return retained;
}
function findLastReplaceableRecommendation(
recommendations: readonly PlaybackRecommendation[]
): number {
for (let index = recommendations.length - 1; index > 0; index -= 1) {
if (!isExternalRecommendation(recommendations[index])) {
return index;
}
}
return -1;
}
function rerankExternalRecommendations(
recommendations: readonly PlaybackRecommendation[],
states: ExternalRecoveryStates
): readonly PlaybackRecommendation[] {
const external = recommendations
.map((recommendation, index) => ({ index, recommendation }))
.filter(
(
item
): item is {
readonly index: number;
readonly recommendation: Extract<
PlaybackRecommendation,
{ readonly action: 'player' }
> & { readonly target: ExternalPlayerName };
} =>
item.recommendation.action === 'player' &&
(item.recommendation.target === 'mpv' ||
item.recommendation.target === 'vlc')
)
.sort((left, right) => {
const attemptDifference =
states[left.recommendation.target].attempts -
states[right.recommendation.target].attempts;
return attemptDifference || left.index - right.index;
});
let externalIndex = 0;
return recommendations.map((recommendation, index) => {
const ranked =
recommendation.action === 'player' &&
(recommendation.target === 'mpv' || recommendation.target === 'vlc')
? external[externalIndex++].recommendation
: recommendation;
return {
...ranked,
priority: index === 0 ? 'primary' : 'secondary',
};
});
}
function isExternalRecommendation(
recommendation: PlaybackRecommendation
): recommendation is Extract<PlaybackRecommendation, { readonly action: 'player' }> & {
readonly target: ExternalPlayerName;
} {
return (
recommendation.action === 'player' &&
(recommendation.target === 'mpv' || recommendation.target === 'vlc')
);
}
export function isPlaybackExternallyTransferable(
playback: ResolvedPortalPlayback
): boolean {
@@ -0,0 +1,415 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { signal } from '@angular/core';
import {
ComponentFixture,
DeferBlockBehavior,
TestBed,
} from '@angular/core/testing';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { By } from '@angular/platform-browser';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule } from '@ngx-translate/core';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
type PlaybackFallbackRequest,
} from '@iptvnator/playback/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
type ExternalPlayerSession,
type ResolvedPortalPlayback,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import { of } from 'rxjs';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
import {
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
} from './web-player-view.spec-stubs';
jest.unstable_mockModule('video.js', () => ({ default: jest.fn() }));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
describe('WebPlayerViewComponent external recovery integration', () => {
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
let component: WebPlayerViewComponentInstance;
let activeSession: ReturnType<typeof signal<ExternalPlayerSession | null>>;
let closeSession: jest.Mock<Promise<void>, [ExternalPlayerSession]>;
beforeAll(async () => {
({ WebPlayerViewComponent } =
await import('./web-player-view.component'));
});
beforeEach(async () => {
activeSession = signal<ExternalPlayerSession | null>(null);
closeSession = jest.fn(async (session) => {
activeSession.set({
...session,
status: 'closed',
canClose: false,
updatedAt: '2026-08-08T10:00:02.000Z',
});
});
await TestBed.configureTestingModule({
deferBlockBehavior: DeferBlockBehavior.Playthrough,
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
providers: [
{
provide: StorageMap,
useValue: {
get: jest.fn(() => of({ player: VideoPlayer.VideoJs })),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: { supportsManagedExternalPlayers: true },
},
{
provide: ElectronStreamHeadersService,
useValue: {
apply: jest.fn(() => null),
clear: jest.fn(),
},
},
{
provide: SettingsStore,
useValue: {
showCaptions: () => false,
webPlayerSharedControls: () => false,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession,
visibleSession: activeSession,
dismissActiveSession: jest.fn(),
closeSession,
},
},
],
})
.overrideComponent(WebPlayerViewComponent, {
set: {
imports: [
ClipboardModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
PlaybackDiagnosticPanelComponent,
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
TranslateModule,
VodSourceRowComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(WebPlayerViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
'https://example.com/live.m3u8'
);
fixture.componentRef.setInput('title', 'Recovery stream');
fixture.componentRef.setInput('playbackSessionKey', 'content-a');
});
afterEach(() => fixture.destroy());
it('keeps both external actions mounted while MPV launches and after it starts', async () => {
const requests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
const mpvButton = requiredButton('playback-fallback-mpv');
const vlcButton = requiredButton('playback-fallback-vlc');
mpvButton.click();
mpvButton.click();
fixture.detectChanges();
expect(requests).toHaveLength(1);
expect(component.externalRecoveryPending()).toBe(true);
expect(component.externalRecoveryState().mpv).toEqual({
attempts: 1,
sessionId: null,
status: 'launching',
});
expect(requiredButton('playback-fallback-mpv')).toBe(mpvButton);
expect(requiredButton('playback-fallback-vlc')).toBe(vlcButton);
const opened = externalSession({ id: 'mpv-session', status: 'opened' });
activeSession.set(opened);
requests[0].trackLaunch(Promise.resolve(opened));
await Promise.resolve();
fixture.detectChanges();
expect(component.externalRecoveryPending()).toBe(false);
expect(component.externalRecoveryState().mpv).toEqual({
attempts: 1,
sessionId: 'mpv-session',
status: 'started',
});
expect(playerActionIds()).toEqual([
'playback-recommendation-html5',
'playback-fallback-vlc',
'playback-fallback-mpv',
]);
});
it('closes an active external player before emitting a different target', async () => {
const requests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
click('playback-fallback-mpv');
const opened = externalSession({ id: 'mpv-session', status: 'opened' });
activeSession.set(opened);
requests[0].trackLaunch(Promise.resolve(opened));
await Promise.resolve();
fixture.detectChanges();
click('playback-fallback-vlc');
await fixture.whenStable();
expect(closeSession).toHaveBeenCalledWith(
expect.objectContaining({ id: 'mpv-session' })
);
expect(requests.map(({ player }) => player)).toEqual(['mpv', 'vlc']);
});
it('cancels the pending replacement when the diagnostic clears during close', async () => {
const requests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
click('playback-fallback-mpv');
const opened = externalSession({ id: 'mpv-session', status: 'opened' });
activeSession.set(opened);
requests[0].trackLaunch(Promise.resolve(opened));
await Promise.resolve();
fixture.detectChanges();
let releaseClose: (() => void) | undefined;
closeSession.mockImplementationOnce(
(session) =>
new Promise<void>((resolve) => {
releaseClose = () => {
activeSession.set({
...session,
status: 'closed',
canClose: false,
});
resolve();
};
})
);
click('playback-fallback-vlc');
vjs().playbackIssue.emit(null);
fixture.detectChanges();
releaseClose?.();
await fixture.whenStable();
expect(requests.map(({ player }) => player)).toEqual(['mpv']);
expect(component.externalRecoveryPending()).toBe(false);
expect(component.externalRecoveryState().vlc).toEqual({
attempts: 1,
sessionId: null,
status: 'idle',
});
});
it('reports a local failure instead of launching beside an unclosable session', async () => {
activeSession.set(
externalSession({
id: 'unclosable',
player: 'vlc',
canClose: false,
})
);
const requests: unknown[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
click('playback-fallback-mpv');
await fixture.whenStable();
expect(requests).toEqual([]);
expect(closeSession).not.toHaveBeenCalled();
expect(component.externalRecoveryState().mpv.status).toBe('error');
expect(playerActionIds()).toContain('playback-fallback-mpv');
});
it('rejects a stale external session after the content key changes', async () => {
await showDiagnostic();
click('playback-fallback-mpv');
fixture.componentRef.setInput('playbackSessionKey', 'content-b');
setPlayback({ streamUrl: 'https://example.com/next.m3u8' });
fixture.detectChanges();
activeSession.set(
externalSession({ id: 'stale-mpv', status: 'error' })
);
fixture.detectChanges();
expect(component.externalRecoveryState().mpv).toEqual({
attempts: 0,
sessionId: null,
status: 'idle',
});
});
it.each([
{ target: 'mpv' as const, testId: 'playback-fallback-mpv' },
{ target: 'vlc' as const, testId: 'playback-fallback-vlc' },
])(
'emits one exact $target request for a same-tick double activation',
async ({ target, testId }) => {
const requests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
const issue = await showDiagnostic();
const button = requiredButton(testId);
button.click();
button.click();
expect(requests).toEqual([
{
player: target,
playback: component.resolvedPlayback(),
diagnostic: issue,
trackLaunch: expect.any(Function),
},
]);
}
);
it.each([
{ target: 'mpv' as const, testId: 'playback-fallback-mpv' },
{ target: 'vlc' as const, testId: 'playback-fallback-vlc' },
])(
'rejects a stale $target activation after playback becomes DRM protected',
async ({ testId }) => {
const requests: unknown[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
const staleButton = requiredButton(testId);
setPlayback({
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: {
'00112233445566778899aabbccddeeff':
'ffeeddccbbaa99887766554433221100',
},
},
});
staleButton.click();
expect(requests).toEqual([]);
}
);
async function showDiagnostic(): Promise<PlaybackDiagnostic> {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const issue = mediaIssue();
vjs().playbackIssue.emit(issue);
fixture.detectChanges();
return issue;
}
function setPlayback(overrides: Partial<ResolvedPortalPlayback>): void {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'Recovery stream',
isLive: false,
...overrides,
});
}
function vjs(): StubVjsPlayerComponent {
return fixture.debugElement.query(By.directive(StubVjsPlayerComponent))
.componentInstance as StubVjsPlayerComponent;
}
function click(testId: string): void {
requiredButton(testId).click();
}
function requiredButton(testId: string): HTMLButtonElement {
const button = fixture.nativeElement.querySelector(
`[data-test-id="${testId}"]`
);
expect(button).toBeInstanceOf(HTMLButtonElement);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Expected ${testId} button`);
}
return button;
}
function playerActionIds(): string[] {
return Array.from(
fixture.nativeElement.querySelectorAll(
'.web-player-diagnostic__player-card'
) as NodeListOf<HTMLElement>
).map((element) => element.dataset['testId'] ?? '');
}
});
function externalSession(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'external-session',
player: 'mpv',
status: 'opened',
title: 'Recovery stream',
streamUrl: 'https://example.com/live.m3u8',
startedAt: '2026-08-08T10:00:00.000Z',
updatedAt: '2026-08-08T10:00:01.000Z',
canClose: true,
...overrides,
};
}
function mediaIssue(): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.MediaDecodeError,
source: PlaybackDiagnosticSource.Vhs,
sourceUrl: 'https://example.com/live.m3u8',
container: 'm3u8',
mimeType: 'application/x-mpegURL',
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
};
}
@@ -103,6 +103,7 @@
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[playbackExternallyTransferable]="playbackExternallyTransferable()"
[alternativeSources]="alternativeSources()"
[externalStates]="externalRecoveryState()"
[pending]="recoveryPending()"
(retryRequested)="retryPlayback()"
(playerRequested)="requestRecommendedPlayer($event)"
@@ -4,6 +4,7 @@ import {
DeferBlockBehavior,
TestBed,
} from '@angular/core/testing';
import { signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
@@ -15,13 +16,16 @@ import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
type PlaybackFallbackRequest,
} from '@iptvnator/playback/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
type ExternalPlayerSession,
STORE_KEY,
VideoPlayer,
type ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import { of } from 'rxjs';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
@@ -50,6 +54,10 @@ describe('WebPlayerViewComponent recovery integration', () => {
),
};
let runtime: { supportsManagedExternalPlayers: boolean };
let activeExternalSession: ReturnType<
typeof signal<ExternalPlayerSession | null>
>;
let closeExternalSession: jest.Mock<Promise<void>, [ExternalPlayerSession]>;
let holdHeaderHandoff: boolean;
let headerResolvers: Array<(stillCurrent: boolean) => void>;
let headerRejectors: Array<(reason?: unknown) => void>;
@@ -73,6 +81,15 @@ describe('WebPlayerViewComponent recovery integration', () => {
beforeEach(async () => {
runtime = { supportsManagedExternalPlayers: true };
activeExternalSession = signal<ExternalPlayerSession | null>(null);
closeExternalSession = jest.fn(async (session) => {
activeExternalSession.set({
...session,
status: 'closed',
canClose: false,
updatedAt: '2026-08-08T10:00:02.000Z',
});
});
holdHeaderHandoff = false;
headerResolvers = [];
headerRejectors = [];
@@ -95,6 +112,15 @@ describe('WebPlayerViewComponent recovery integration', () => {
webPlayerSharedControls: () => false,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: activeExternalSession,
visibleSession: activeExternalSession,
dismissActiveSession: jest.fn(),
closeSession: closeExternalSession,
},
},
],
})
.overrideComponent(WebPlayerViewComponent, {
@@ -175,7 +201,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
});
it('retries the active target with a reload while preserving attempts', async () => {
const fallbackRequests: unknown[] = [];
const fallbackRequests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
fallbackRequests.push(request)
);
@@ -184,6 +210,14 @@ describe('WebPlayerViewComponent recovery integration', () => {
fixture.detectChanges();
click('playback-fallback-mpv');
expect(fallbackRequests).toHaveLength(1);
const opened = externalSession({
id: 'mpv-retry-session',
status: 'opened',
});
activeExternalSession.set(opened);
fallbackRequests[0].trackLaunch(Promise.resolve(opened));
await Promise.resolve();
fixture.detectChanges();
vjs().playbackIssue.emit(networkIssue('videojs'));
fixture.detectChanges();
@@ -200,22 +234,43 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(playerActionIds()).toEqual([
'playback-recommendation-html5',
'playback-fallback-vlc',
'playback-fallback-mpv',
]);
});
it('keeps desktop browser-access guidance after external recommendations are exhausted', async () => {
it('keeps desktop browser-access guidance and both actions after both attempts', async () => {
const fallbackRequests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
fallbackRequests.push(request)
);
await render();
vjs().playbackIssue.emit(browserAccessIssue('videojs'));
fixture.detectChanges();
click('playback-fallback-mpv');
const openedMpv = externalSession({
id: 'mpv-browser-session',
status: 'opened',
});
activeExternalSession.set(openedMpv);
fallbackRequests[0].trackLaunch(Promise.resolve(openedMpv));
await Promise.resolve();
fixture.detectChanges();
click('playback-fallback-vlc');
await fixture.whenStable();
const openedVlc = externalSession({
id: 'vlc-browser-session',
player: 'vlc',
status: 'opened',
});
activeExternalSession.set(openedVlc);
fallbackRequests[1].trackLaunch(Promise.resolve(openedVlc));
await Promise.resolve();
fixture.detectChanges();
const banner = query('playback-diagnostic-banner');
expect(banner?.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
);
expect(banner?.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
@@ -223,7 +278,10 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(banner?.textContent).not.toContain(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
);
expect(playerActionIds()).toEqual([]);
expect(playerActionIds()).toEqual([
'playback-fallback-mpv',
'playback-fallback-vlc',
]);
});
it('preserves attempts when the URL changes under the same content key', async () => {
@@ -243,6 +301,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(playerActionIds()).toEqual([
'playback-recommendation-html5',
'playback-fallback-vlc',
'playback-fallback-mpv',
]);
});
@@ -1079,66 +1138,6 @@ describe('WebPlayerViewComponent recovery integration', () => {
);
});
it.each([
{ target: 'mpv' as const, testId: 'playback-fallback-mpv' },
{ target: 'vlc' as const, testId: 'playback-fallback-vlc' },
])(
'emits one exact $target request for a same-tick double activation',
async ({ target, testId }) => {
const requests: unknown[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await render();
const issue = mediaIssue('videojs');
vjs().playbackIssue.emit(issue);
fixture.detectChanges();
const button = requiredButton(testId);
button.click();
button.click();
expect(requests).toEqual([
{
player: target,
playback: component.resolvedPlayback(),
diagnostic: issue,
},
]);
}
);
it.each([
{ target: 'mpv' as const, testId: 'playback-fallback-mpv' },
{ target: 'vlc' as const, testId: 'playback-fallback-vlc' },
])(
'rejects a stale $target activation after playback becomes DRM protected',
async ({ testId }) => {
const requests: unknown[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await render();
vjs().playbackIssue.emit(mediaIssue('videojs'));
fixture.detectChanges();
const staleButton = requiredButton(testId);
setPlayback({
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: {
'00112233445566778899aabbccddeeff':
'ffeeddccbbaa99887766554433221100',
},
},
});
staleButton.click();
expect(requests).toEqual([]);
}
);
it('keeps a replaced-source diagnostic detached across every handoff outcome', async () => {
await render();
vjs().playbackIssue.emit(mediaIssue('videojs'));
@@ -1281,6 +1280,22 @@ describe('WebPlayerViewComponent recovery integration', () => {
}
});
function externalSession(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'external-session',
player: 'mpv',
status: 'opened',
title: 'Recovery stream',
streamUrl: 'https://example.com/live.m3u8',
startedAt: '2026-08-08T10:00:00.000Z',
updatedAt: '2026-08-08T10:00:01.000Z',
canClose: true,
...overrides,
};
}
function mediaIssue(
player: 'videojs' | 'html5',
path = 'live.m3u8'
@@ -19,6 +19,7 @@ import {
type PlaybackFallbackRequest,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
STORE_KEY,
@@ -41,15 +42,19 @@ import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-pl
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
import type { VideoPlayerOptions } from '../vjs-player/vjs-player.types';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import { ExternalPlaybackRecoveryCoordinator } from './external-playback-recovery-coordinator';
import {
type PlaybackBinding,
PlaybackRecoverySession,
} from './playback-recovery-session';
import { WebPlayerApplicationHandoffCoordinator } from './web-player-application-handoff';
import {
ownsPlaybackApplication,
type PlaybackApplicationOwnership,
} from './web-player-application-ownership';
import {
createWebPlayerApplicationState,
type WebPlayerApplicationToken,
type WebPlayerSourceRevisionToken,
} from './web-player-application-state';
import { resolveWebPlayerMediaTitle } from './web-player-playback-state';
import {
@@ -61,17 +66,8 @@ import {
function resolveWebPlayerSharedControls(): boolean {
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
return typeof storedValue === 'boolean'
? storedValue
: WEB_PLAYER_SHARED_CONTROLS_ENABLED;
}
interface PlaybackApplicationOwnership {
readonly binding: PlaybackBinding | null;
readonly embeddedMpv: boolean;
readonly isLive: boolean;
readonly sourceRevision: WebPlayerSourceRevisionToken;
readonly token: WebPlayerApplicationToken;
const fallback = WEB_PLAYER_SHARED_CONTROLS_ENABLED;
return typeof storedValue === 'boolean' ? storedValue : fallback;
}
@Component({
@@ -98,7 +94,13 @@ export class WebPlayerViewComponent implements OnDestroy {
private readonly storage = inject(StorageMap);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK, {
optional: true,
});
private readonly recoverySession = new PlaybackRecoverySession();
private readonly externalRecovery = new ExternalPlaybackRecoveryCoordinator(
this.externalPlayback
);
private readonly applicationHandoff =
new WebPlayerApplicationHandoffCoordinator(
inject(ElectronStreamHeadersService),
@@ -136,7 +138,13 @@ export class WebPlayerViewComponent implements OnDestroy {
);
readonly reloadToken = signal(0);
readonly playbackDiagnostic = signal<PlaybackDiagnostic | null>(null);
readonly recoveryPending = this.recoverySession.switchPending;
readonly externalRecoveryState = this.externalRecovery.states;
readonly externalRecoveryPending = this.externalRecovery.pending;
readonly recoveryPending = computed(
() =>
this.recoverySession.switchPending() ||
this.externalRecoveryPending()
);
readonly activeBinding = this.recoverySession.activeBinding;
channel: Channel | undefined;
@@ -215,6 +223,7 @@ export class WebPlayerViewComponent implements OnDestroy {
? binding
: null,
attemptedTargets: this.recoverySession.attemptedTargets(),
externalStates: this.externalRecoveryState(),
managedExternalPlayersAvailable:
this.runtime.supportsManagedExternalPlayers,
playbackExternallyTransferable:
@@ -245,6 +254,10 @@ export class WebPlayerViewComponent implements OnDestroy {
});
constructor() {
effect(() => {
const session = this.externalPlayback?.activeSession() ?? null;
untracked(() => this.externalRecovery.observe(session));
});
effect(() => {
// Session sync may clear a temporary player override. Run it before
// tracking intent so that reset is folded into this application.
@@ -287,6 +300,7 @@ export class WebPlayerViewComponent implements OnDestroy {
}
ngOnDestroy(): void {
this.externalRecovery.destroy();
this.applicationHandoff.destroy();
}
@@ -328,7 +342,21 @@ export class WebPlayerViewComponent implements OnDestroy {
event: { currentTime: number; duration: number },
ownership: PlaybackApplicationOwnership
): void {
if (!this.ownsPlaybackApplication(ownership)) {
if (
!ownsPlaybackApplication({
ownership,
currentToken: this.playbackApplicationToken(),
currentSourceRevision: this.playbackSourceRevisionToken(),
bindingOwned: ownership.binding
? this.applicationHandoff.owns(
ownership.binding,
ownership.token
)
: false,
embeddedMpvSelected:
this.selectedPlayer() === VideoPlayer.EmbeddedMpv,
})
) {
return;
}
@@ -351,12 +379,22 @@ export class WebPlayerViewComponent implements OnDestroy {
return;
}
if (target === 'mpv' || target === 'vlc') {
this.recoverySession.recordExternalAttempt(target);
this.externalFallbackRequested.emit({
player: target,
playback: this.resolvedPlayback(),
diagnostic,
});
this.externalRecovery.request(
target,
() => this.recoverySession.recordExternalAttempt(target),
(trackLaunch) => {
if (this.visiblePlaybackDiagnostic() !== diagnostic) {
return false;
}
this.externalFallbackRequested.emit({
player: target,
playback: this.resolvedPlayback(),
diagnostic,
trackLaunch,
});
return true;
}
);
return;
}
this.recoverySession.beginPlayerSwitch(target, this.resolvedIsLive());
@@ -371,6 +409,7 @@ export class WebPlayerViewComponent implements OnDestroy {
private syncRecoverySession(): void {
const sessionKey = this.playbackSessionKey();
this.externalRecovery.syncSession(sessionKey);
if (untracked(() => this.recoverySession.syncSession(sessionKey))) {
this.clearPlaybackDiagnostic();
}
@@ -380,25 +419,4 @@ export class WebPlayerViewComponent implements OnDestroy {
this.playbackDiagnosticOwnerToken.set(null);
this.playbackDiagnostic.set(null);
}
private ownsPlaybackApplication(
ownership: PlaybackApplicationOwnership
): boolean {
if (
ownership.token !== this.playbackApplicationToken() ||
ownership.sourceRevision !== this.playbackSourceRevisionToken()
) {
return false;
}
if (ownership.binding) {
return (
!ownership.embeddedMpv &&
this.applicationHandoff.owns(ownership.binding, ownership.token)
);
}
return (
ownership.embeddedMpv &&
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
);
}
}
@@ -271,6 +271,7 @@ describe('WorkspaceShellFacade', () => {
activeSession: signal(null),
visibleSession: signal(null),
closeSession: jest.fn(),
dismissActiveSession: jest.fn(),
},
},
{
@@ -1075,6 +1076,26 @@ describe('WorkspaceShellFacade', () => {
expect(recentCommands.record).toHaveBeenCalledWith('open-settings');
});
it('delegates failed external-session dismissal without closing a player', () => {
const externalPlayback = TestBed.inject(PORTAL_EXTERNAL_PLAYBACK);
facade.dismissActiveExternalSession();
expect(externalPlayback.dismissActiveSession).toHaveBeenCalledTimes(1);
expect(externalPlayback.closeSession).not.toHaveBeenCalled();
});
it('handles a rejected external-session close', async () => {
const externalPlayback = TestBed.inject(PORTAL_EXTERNAL_PLAYBACK);
const closeSession = externalPlayback.closeSession as jest.Mock;
closeSession.mockRejectedValueOnce(new Error('close failed'));
facade.closeActiveExternalSession();
await Promise.resolve();
expect(closeSession).toHaveBeenCalledTimes(1);
});
it('waits for embedded MPV support preload before opening the palette', async () => {
const dialog = TestBed.inject(MatDialog) as unknown as {
open: jest.Mock;
@@ -52,9 +52,12 @@ export class WorkspaceShellFacade {
);
// Root-provided; optional keeps standalone unit tests light. While the phone context drawer is modal, opening the command
// palette over it would stack two competing focus-trapped surfaces.
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
optional: true,
});
private readonly contextDrawer = inject(
WorkspaceShellContextDrawerService,
{
optional: true,
}
);
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
if (!(event.ctrlKey || event.metaKey)) {
return;
@@ -153,9 +156,13 @@ export class WorkspaceShellFacade {
}
closeActiveExternalSession(): void {
void this.externalPlayback.closeSession(
this.externalPlayback.activeSession()
);
void this.externalPlayback
.closeSession(this.externalPlayback.activeSession())
.catch(() => undefined);
}
dismissActiveExternalSession(): void {
this.externalPlayback.dismissActiveSession();
}
openActiveExternalSessionTarget(): void {
@@ -128,6 +128,7 @@
<app-external-playback-dock
[session]="session"
(closeClicked)="facade.closeActiveExternalSession()"
(dismissClicked)="facade.dismissActiveExternalSession()"
(artworkClicked)="facade.openActiveExternalSessionTarget()"
/>
</footer>
@@ -102,6 +102,7 @@ class MockWorkspaceShellContextSidebarComponent {
class MockExternalPlaybackDockComponent {
readonly session = input<unknown>(null);
readonly closeClicked = output<void>();
readonly dismissClicked = output<void>();
}
@Component({
@@ -210,6 +211,7 @@ class MockWorkspaceShellFacade {
openAccountInfo = jest.fn();
openAccountInfoFor = jest.fn();
closeActiveExternalSession = jest.fn();
dismissActiveExternalSession = jest.fn();
cancelXtreamImport = jest.fn();
}
@@ -294,6 +296,11 @@ describe('WorkspaceShellComponent', () => {
expect(
fixture.nativeElement.querySelector('app-external-playback-dock')
).not.toBeNull();
const externalDock = fixture.debugElement.query(
By.directive(MockExternalPlaybackDockComponent)
).componentInstance as MockExternalPlaybackDockComponent;
externalDock.dismissClicked.emit();
expect(facade.dismissActiveExternalSession).toHaveBeenCalledTimes(1);
});
it('renders the xtream import overlay child only when the facade flag is true', async () => {