mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(playback): clarify external player launch feedback (#1388)
This commit is contained in:
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
-1
@@ -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 {
|
||||
|
||||
+1
@@ -611,6 +611,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
webPlayer.externalFallbackRequested.emit({
|
||||
player: 'mpv',
|
||||
playback,
|
||||
trackLaunch: jest.fn(),
|
||||
diagnostic: {
|
||||
code: 'network-error',
|
||||
player: 'videojs',
|
||||
|
||||
+3
-1
@@ -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>(() => {
|
||||
|
||||
+3
-1
@@ -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> {
|
||||
|
||||
+3
-2
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+3
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
@@ -625,6 +625,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
component.handleExternalFallbackRequest({
|
||||
player: 'mpv',
|
||||
playback,
|
||||
trackLaunch: jest.fn(),
|
||||
} as PlaybackFallbackRequest);
|
||||
|
||||
const [forwardedPlayback, forwardedPlayer] =
|
||||
|
||||
+3
-1
@@ -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[] {
|
||||
|
||||
+8
-5
@@ -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))
|
||||
);
|
||||
}
|
||||
+340
-26
@@ -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 } },
|
||||
|
||||
+9
-1
@@ -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);
|
||||
|
||||
+9
-1
@@ -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;
|
||||
},
|
||||
|
||||
+58
-6
@@ -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;
|
||||
}
|
||||
+62
-15
@@ -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>
|
||||
+30
-33
@@ -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;
|
||||
|
||||
+88
-1
@@ -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')
|
||||
|
||||
+19
-7
@@ -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();
|
||||
}
|
||||
}
|
||||
+33
-4
@@ -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)
|
||||
) {
|
||||
|
||||
+24
-1
@@ -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;
|
||||
|
||||
+86
-4
@@ -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' }),
|
||||
]);
|
||||
|
||||
+45
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
+70
@@ -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'],
|
||||
|
||||
+65
-7
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+205
@@ -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 {
|
||||
|
||||
+415
@@ -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)"
|
||||
|
||||
+79
-64
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
|
||||
+13
-6
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user