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

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

No files matched your search

@@ -101,12 +101,30 @@
[attr.data-test-id]="
getRecommendationTestId(recommendation)
"
[disabled]="pending()"
[class.web-player-diagnostic__player-card--disabled]="
pending()
"
[disabled]="
pending() &&
!isExternalPlayerRecommendation(recommendation)
"
[attr.aria-disabled]="pending() ? 'true' : null"
[attr.aria-busy]="
isExternalLaunching(recommendation) ? 'true' : null
"
(click)="activate(recommendation)"
>
<mat-icon aria-hidden="true">{{
getRecommendationIcon(recommendation)
}}</mat-icon>
@if (isExternalLaunching(recommendation)) {
<mat-spinner
aria-hidden="true"
diameter="18"
strokeWidth="2"
/>
} @else {
<mat-icon aria-hidden="true">{{
getRecommendationIcon(recommendation)
}}</mat-icon>
}
<span class="web-player-diagnostic__player-copy">
<span class="web-player-diagnostic__player-label">
{{
@@ -124,6 +142,17 @@
) | translate
}}
</span>
@if (
getExternalStatusKey(recommendation);
as statusKey
) {
<span
class="web-player-diagnostic__player-status"
aria-live="polite"
>
{{ statusKey | translate }}
</span>
}
@if (
isTemporaryBuiltInRecommendation(recommendation)
) {
@@ -221,12 +221,17 @@
transform: translateY(1px);
}
.web-player-diagnostic__player-card:disabled {
.web-player-diagnostic__player-card:disabled,
.web-player-diagnostic__player-card--disabled {
cursor: wait;
opacity: 0.56;
transform: none;
}
.web-player-diagnostic__player-card mat-spinner {
--mdc-circular-progress-active-indicator-color: currentColor;
}
.web-player-diagnostic__player-card:focus-visible,
.web-player-diagnostic__utility-button:focus-visible,
.web-player-diagnostic__details summary:focus-visible {
@@ -278,6 +283,24 @@
white-space: normal;
}
.web-player-diagnostic__player-status {
min-width: 0;
color: currentColor;
font-size: 0.78rem;
font-weight: 680;
line-height: 1.1;
opacity: 0.82;
overflow-wrap: anywhere;
}
@media (prefers-reduced-motion: reduce) {
.web-player-diagnostic__player-card,
.web-player-diagnostic__player-card mat-spinner {
transition: none;
animation: none;
}
}
.web-player-diagnostic__utilities {
display: flex;
flex-wrap: wrap;
@@ -16,6 +16,7 @@ import type {
VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { PlaybackDiagnosticPanelComponent } from './playback-diagnostic-panel.component';
import type { ExternalRecoveryStates } from '../web-player-view/external-playback-recovery';
const DIAGNOSTIC: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedContainer,
@@ -106,6 +107,16 @@ function source(index: number): VodSourceDescriptor {
};
}
function externalStates(
overrides: Partial<ExternalRecoveryStates> = {}
): ExternalRecoveryStates {
return {
mpv: { attempts: 0, sessionId: null, status: 'idle' },
vlc: { attempts: 0, sessionId: null, status: 'idle' },
...overrides,
};
}
describe('PlaybackDiagnosticPanelComponent', () => {
let fixture: ComponentFixture<PlaybackDiagnosticPanelComponent>;
let component: PlaybackDiagnosticPanelComponent;
@@ -131,6 +142,12 @@ describe('PlaybackDiagnosticPanelComponent', () => {
REASON_DIFFERENT_ENGINE_FAMILY:
'This player uses a different playback engine that may support the stream format more reliably.',
RECOMMENDATIONS_LABEL: 'Recommended recovery actions',
ACTION_OPEN_MPV: 'Open in MPV',
ACTION_OPEN_VLC: 'Open in VLC',
ACTION_OPENING_MPV: 'Opening MPV…',
ACTION_REOPEN_MPV: 'Open MPV again',
EXTERNAL_OPENING: 'Opening player…',
EXTERNAL_STARTED: 'Player started',
},
PORTALS: {
MULTI_SOURCE: {
@@ -148,6 +165,74 @@ describe('PlaybackDiagnosticPanelComponent', () => {
fixture.componentRef.setInput('playback', PLAYBACK);
fixture.componentRef.setInput('supportsManagedExternalPlayers', false);
fixture.componentRef.setInput('playbackExternallyTransferable', true);
fixture.componentRef.setInput('externalStates', externalStates());
});
it('keeps the focused external button mounted through launch and started states', () => {
const players: PlaybackRecommendationTarget[] = [];
component.playerRequested.subscribe((target) => players.push(target));
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'mpv',
priority: 'primary',
}),
recommendation('player', { target: 'vlc' }),
]);
fixture.detectChanges();
const mpv = fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"]'
) as HTMLButtonElement;
mpv.focus();
fixture.componentRef.setInput(
'externalStates',
externalStates({
mpv: { attempts: 1, sessionId: null, status: 'launching' },
})
);
fixture.componentRef.setInput('pending', true);
fixture.detectChanges();
const launchingMpv = fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"]'
) as HTMLButtonElement;
expect(launchingMpv).toBe(mpv);
expect(document.activeElement).toBe(mpv);
expect(mpv.disabled).toBe(false);
expect(mpv.getAttribute('aria-disabled')).toBe('true');
expect(mpv.getAttribute('aria-busy')).toBe('true');
expect(mpv.querySelector('mat-spinner')).not.toBeNull();
expect(mpv.textContent).toContain('Opening MPV');
expect(mpv.textContent).toContain('Opening player');
expect(
mpv
.querySelector('.web-player-diagnostic__player-status')
?.getAttribute('aria-live')
).toBe('polite');
mpv.click();
expect(players).toEqual([]);
fixture.componentRef.setInput(
'externalStates',
externalStates({
mpv: {
attempts: 1,
sessionId: 'mpv-session',
status: 'started',
},
})
);
fixture.componentRef.setInput('pending', false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"]'
)
).toBe(mpv);
expect(mpv.textContent).toContain('Open MPV again');
expect(mpv.textContent).toContain('Player started');
expect(mpv.getAttribute('aria-busy')).toBeNull();
});
it('applies the component host style from the actual panel stylesheet', () => {
@@ -503,10 +588,7 @@ describe('PlaybackDiagnosticPanelComponent', () => {
code: PlaybackDiagnosticCode.DrmOrEncryption,
});
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.componentRef.setInput(
'playbackExternallyTransferable',
false
);
fixture.componentRef.setInput('playbackExternallyTransferable', false);
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
@@ -7,6 +7,7 @@ import {
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import {
@@ -19,6 +20,10 @@ import type {
VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import type {
ExternalRecoveryStates,
ExternalRecoveryTargetState,
} from '../web-player-view/external-playback-recovery';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
@@ -29,11 +34,13 @@ import {
import {
getRecommendationIcon,
getRecommendationKey,
getRecommendationLabelKey,
getExternalRecommendationStatusKey,
getRecommendationLabelKey as resolveRecommendationLabelKey,
getRecommendationParams,
getRecommendationReasonKey,
getRecommendationTestId,
isExternalPlayerRecommendation,
isExternalRecommendationLaunching,
isTemporaryBuiltInRecommendation,
} from './playback-recommendation-view.util';
@@ -48,6 +55,7 @@ const ERROR_SCREEN_ALTERNATIVES = 5;
imports: [
ClipboardModule,
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
TranslateModule,
VodSourceRowComponent,
@@ -62,6 +70,7 @@ export class PlaybackDiagnosticPanelComponent {
readonly supportsManagedExternalPlayers = input.required<boolean>();
readonly playbackExternallyTransferable = input.required<boolean>();
readonly alternativeSources = input<readonly VodSourceDescriptor[]>([]);
readonly externalStates = input.required<ExternalRecoveryStates>();
readonly pending = input(false);
readonly retryRequested = output<void>();
@@ -97,11 +106,11 @@ export class PlaybackDiagnosticPanelComponent {
readonly getRecommendationKey = getRecommendationKey;
readonly getRecommendationTestId = getRecommendationTestId;
readonly getRecommendationIcon = getRecommendationIcon;
readonly getRecommendationLabelKey = getRecommendationLabelKey;
readonly getRecommendationParams = getRecommendationParams;
readonly getRecommendationReasonKey = getRecommendationReasonKey;
readonly isTemporaryBuiltInRecommendation =
isTemporaryBuiltInRecommendation;
readonly isExternalPlayerRecommendation = isExternalPlayerRecommendation;
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
return getDiagnosticDescriptionKey(
@@ -112,6 +121,9 @@ export class PlaybackDiagnosticPanelComponent {
}
activate(recommendation: PlaybackRecommendation): void {
if (this.pending()) {
return;
}
switch (recommendation.action) {
case 'retry':
this.retryRequested.emit();
@@ -123,4 +135,35 @@ export class PlaybackDiagnosticPanelComponent {
return;
}
}
getRecommendationLabelKey(recommendation: PlaybackRecommendation): string {
return resolveRecommendationLabelKey(
recommendation,
this.getExternalState(recommendation)
);
}
getExternalStatusKey(
recommendation: PlaybackRecommendation
): string | null {
return getExternalRecommendationStatusKey(
recommendation,
this.getExternalState(recommendation)
);
}
isExternalLaunching(recommendation: PlaybackRecommendation): boolean {
return isExternalRecommendationLaunching(
recommendation,
this.getExternalState(recommendation)
);
}
private getExternalState(
recommendation: PlaybackRecommendation
): ExternalRecoveryTargetState | undefined {
return isExternalPlayerRecommendation(recommendation)
? this.externalStates()[recommendation.target]
: undefined;
}
}
@@ -11,8 +11,11 @@ import {
getRecommendationParams,
getRecommendationReasonKey,
getRecommendationTestId,
getExternalRecommendationStatusKey,
isExternalRecommendationLaunching,
isTemporaryBuiltInRecommendation,
} from './playback-recommendation-view.util';
import type { ExternalRecoveryTargetState } from '../web-player-view/external-playback-recovery';
function player(
target: PlaybackRecommendationTarget,
@@ -43,6 +46,73 @@ function alternative(): PlaybackRecommendation {
}
describe('playback recommendation view mappings', () => {
it.each([
[
'mpv',
{ attempts: 1, sessionId: null, status: 'launching' },
'PLAYBACK_DIAGNOSTICS.ACTION_OPENING_MPV',
'PLAYBACK_DIAGNOSTICS.EXTERNAL_OPENING',
],
[
'vlc',
{ attempts: 1, sessionId: 'vlc-1', status: 'started' },
'PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_VLC',
'PLAYBACK_DIAGNOSTICS.EXTERNAL_STARTED',
],
[
'mpv',
{ attempts: 1, sessionId: 'mpv-1', status: 'playing' },
'PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_MPV',
'PLAYBACK_DIAGNOSTICS.EXTERNAL_PLAYING',
],
[
'vlc',
{ attempts: 2, sessionId: null, status: 'error' },
'PLAYBACK_DIAGNOSTICS.ACTION_RETRY_VLC',
'PLAYBACK_DIAGNOSTICS.EXTERNAL_FAILED',
],
] as const)(
'maps %s %s state to exact action and status copy',
(target, state, actionKey, statusKey) => {
const recommendation = player(target);
expect(
getRecommendationLabelKey(
recommendation,
state as ExternalRecoveryTargetState
)
).toBe(actionKey);
expect(
getExternalRecommendationStatusKey(
recommendation,
state as ExternalRecoveryTargetState
)
).toBe(statusKey);
expect(
isExternalRecommendationLaunching(
recommendation,
state as ExternalRecoveryTargetState
)
).toBe(state.status === 'launching');
}
);
it('keeps an attempted closed target actionable without a stale status', () => {
const recommendation = player('mpv');
const state: ExternalRecoveryTargetState = {
attempts: 1,
sessionId: null,
status: 'idle',
};
expect(getRecommendationLabelKey(recommendation, state)).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_MPV'
);
expect(
getExternalRecommendationStatusKey(recommendation, state)
).toBeNull();
});
it.each([
['videojs', 'playback-recommendation-videojs'],
['html5', 'playback-recommendation-html5'],
@@ -3,6 +3,8 @@ import {
type PlaybackRecommendationReason,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
import type { ExternalRecoveryTargetState } from '../web-player-view/external-playback-recovery';
export function getRecommendationKey(
recommendation: PlaybackRecommendation
@@ -26,7 +28,8 @@ export function getRecommendationTestId(
}
export function getRecommendationLabelKey(
recommendation: PlaybackRecommendation
recommendation: PlaybackRecommendation,
externalState?: ExternalRecoveryTargetState
): string {
switch (recommendation.action) {
case 'retry':
@@ -34,10 +37,41 @@ export function getRecommendationLabelKey(
case 'alternative-source':
return 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE';
case 'player':
return getPlayerLabelKey(recommendation.target);
return getPlayerLabelKey(recommendation.target, externalState);
}
}
export function getExternalRecommendationStatusKey(
recommendation: PlaybackRecommendation,
state?: ExternalRecoveryTargetState
): string | null {
if (!isExternalPlayerRecommendation(recommendation) || !state) {
return null;
}
switch (state.status) {
case 'launching':
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_OPENING';
case 'started':
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_STARTED';
case 'playing':
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_PLAYING';
case 'error':
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_FAILED';
case 'idle':
return null;
}
}
export function isExternalRecommendationLaunching(
recommendation: PlaybackRecommendation,
state?: ExternalRecoveryTargetState
): boolean {
return (
isExternalPlayerRecommendation(recommendation) &&
state?.status === 'launching'
);
}
export function getRecommendationParams(
recommendation: PlaybackRecommendation
): Readonly<Record<string, string>> {
@@ -93,7 +127,10 @@ export function isTemporaryBuiltInRecommendation(
export function isExternalPlayerRecommendation(
recommendation: PlaybackRecommendation
): boolean {
): recommendation is Extract<
PlaybackRecommendation,
{ readonly action: 'player' }
> & { readonly target: ExternalPlayerName } {
return (
recommendation.action === 'player' &&
isExternalTarget(recommendation.target)
@@ -115,19 +152,38 @@ function getPlayerTestId(target: PlaybackRecommendationTarget): string {
}
}
function getPlayerLabelKey(target: PlaybackRecommendationTarget): string {
function getPlayerLabelKey(
target: PlaybackRecommendationTarget,
state?: ExternalRecoveryTargetState
): string {
switch (target) {
case 'videojs':
case 'html5':
case 'artplayer':
return 'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER';
case 'mpv':
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV';
return getExternalPlayerLabelKey('MPV', state);
case 'vlc':
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC';
return getExternalPlayerLabelKey('VLC', state);
}
}
function getExternalPlayerLabelKey(
player: 'MPV' | 'VLC',
state?: ExternalRecoveryTargetState
): string {
if (state?.status === 'launching') {
return `PLAYBACK_DIAGNOSTICS.ACTION_OPENING_${player}`;
}
if (state?.status === 'error') {
return `PLAYBACK_DIAGNOSTICS.ACTION_RETRY_${player}`;
}
if (state && state.attempts > 0) {
return `PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_${player}`;
}
return `PLAYBACK_DIAGNOSTICS.ACTION_OPEN_${player}`;
}
function getPlayerName(target: PlaybackRecommendationTarget): string {
switch (target) {
case 'videojs':
@@ -143,6 +199,8 @@ function getPlayerName(target: PlaybackRecommendationTarget): string {
}
}
function isExternalTarget(target: PlaybackRecommendationTarget): boolean {
function isExternalTarget(
target: PlaybackRecommendationTarget
): target is ExternalPlayerName {
return target === 'mpv' || target === 'vlc';
}
@@ -251,7 +251,11 @@ export class VodDetailsComponent {
async onPrimaryAction(): Promise<void> {
if (this.isExternalStopAction()) {
await this.stopExternalPlayback();
try {
await this.stopExternalPlayback();
} catch {
// The dock stays visible when process teardown is unconfirmed.
}
return;
}
@@ -0,0 +1,205 @@
import { signal } from '@angular/core';
import type { PortalExternalPlayback } from '@iptvnator/portal/shared/util';
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { ExternalPlaybackRecoveryCoordinator } from './external-playback-recovery-coordinator';
describe('ExternalPlaybackRecoveryCoordinator', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('does not launch beside a replacement live session that appears during close', async () => {
const previous = session({ id: 'previous', player: 'mpv' });
const activeSession = signal<ExternalPlayerSession | null>(previous);
let releaseClose: (() => void) | undefined;
const closeSession = jest.fn(
() =>
new Promise<void>((resolve) => {
releaseClose = resolve;
})
);
const externalPlayback: PortalExternalPlayback = {
activeSession,
visibleSession: activeSession,
closeSession,
dismissActiveSession: jest.fn(),
};
const coordinator = new ExternalPlaybackRecoveryCoordinator(
externalPlayback
);
coordinator.syncSession('content-a');
const ready = jest.fn(() => true);
coordinator.request('vlc', jest.fn(), ready);
activeSession.set(session({ id: 'replacement', player: 'mpv' }));
releaseClose?.();
await Promise.resolve();
expect(ready).not.toHaveBeenCalled();
expect(coordinator.states().vlc.status).toBe('error');
coordinator.destroy();
});
it('applies the confirmed old-session close before launching a replacement', async () => {
const previous = session({ id: 'previous', player: 'mpv' });
const activeSession = signal<ExternalPlayerSession | null>(null);
const closeSession = jest.fn(async () => {
activeSession.set(
session({
id: 'previous',
player: 'mpv',
status: 'closed',
canClose: false,
})
);
});
const coordinator = new ExternalPlaybackRecoveryCoordinator({
activeSession,
visibleSession: activeSession,
closeSession,
dismissActiveSession: jest.fn(),
});
coordinator.syncSession('content-a');
let trackFirst:
| ((launch: Promise<ExternalPlayerSession | void>) => void)
| undefined;
coordinator.request('mpv', jest.fn(), (trackLaunch) => {
trackFirst = trackLaunch;
return true;
});
trackFirst?.(Promise.resolve(previous));
await Promise.resolve();
activeSession.set(previous);
let stateAtReady: string | undefined;
coordinator.request('vlc', jest.fn(), () => {
stateAtReady = coordinator.states().mpv.status;
return true;
});
await Promise.resolve();
expect(stateAtReady).toBe('idle');
coordinator.destroy();
});
it('closes a closable error before retrying another external target', async () => {
const previous = session({
id: 'uncertain-process',
status: 'error',
error: 'Process exit was not confirmed',
canClose: true,
});
const activeSession = signal<ExternalPlayerSession | null>(previous);
const closeSession = jest.fn(async () => {
activeSession.set({
...previous,
status: 'closed',
canClose: false,
});
});
const coordinator = new ExternalPlaybackRecoveryCoordinator({
activeSession,
visibleSession: activeSession,
closeSession,
dismissActiveSession: jest.fn(),
});
coordinator.syncSession('content-a');
const ready = jest.fn(() => true);
coordinator.request('vlc', jest.fn(), ready);
await Promise.resolve();
expect(closeSession).toHaveBeenCalledWith(previous);
expect(ready).toHaveBeenCalledTimes(1);
coordinator.destroy();
});
it('cancels an intent when launch ownership disappears during close', async () => {
const previous = session({ id: 'previous', player: 'mpv' });
const activeSession = signal<ExternalPlayerSession | null>(previous);
let releaseClose: (() => void) | undefined;
const coordinator = new ExternalPlaybackRecoveryCoordinator({
activeSession,
visibleSession: activeSession,
closeSession: jest.fn(
() =>
new Promise<void>((resolve) => {
releaseClose = () => {
activeSession.set(
session({
...previous,
status: 'closed',
canClose: false,
})
);
resolve();
};
})
),
dismissActiveSession: jest.fn(),
});
coordinator.syncSession('content-a');
coordinator.request('vlc', jest.fn(), () => false);
releaseClose?.();
await Promise.resolve();
expect(coordinator.pending()).toBe(false);
expect(coordinator.states().vlc.status).toBe('idle');
coordinator.destroy();
});
it('correlates only the exact promise bound to the current attempt', async () => {
const activeSession = signal<ExternalPlayerSession | null>(null);
const coordinator = new ExternalPlaybackRecoveryCoordinator({
activeSession,
visibleSession: activeSession,
closeSession: jest.fn(),
dismissActiveSession: jest.fn(),
});
coordinator.syncSession('content-a');
let firstTracker:
| ((launch: Promise<ExternalPlayerSession | void>) => void)
| undefined;
coordinator.request('mpv', jest.fn(), (trackLaunch) => {
firstTracker = trackLaunch;
return true;
});
jest.advanceTimersByTime(10_000);
let retryTracker:
| ((launch: Promise<ExternalPlayerSession | void>) => void)
| undefined;
coordinator.request('mpv', jest.fn(), (trackLaunch) => {
retryTracker = trackLaunch;
return true;
});
firstTracker?.(
Promise.resolve(session({ id: 'late-first', player: 'mpv' }))
);
retryTracker?.(
Promise.resolve(session({ id: 'exact-retry', player: 'mpv' }))
);
await Promise.resolve();
expect(coordinator.states().mpv.sessionId).toBe('exact-retry');
coordinator.destroy();
});
});
function session(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'session',
player: 'mpv',
status: 'opened',
title: 'Example',
streamUrl: 'https://example.com/video.mkv',
startedAt: '2026-08-08T10:00:00.000Z',
updatedAt: '2026-08-08T10:00:01.000Z',
canClose: true,
...overrides,
};
}
@@ -0,0 +1,119 @@
import type { PortalExternalPlayback } from '@iptvnator/portal/shared/util';
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import {
type ExternalRecoveryIntent,
ExternalPlaybackRecovery,
} from './external-playback-recovery';
export type ExternalRecoveryTarget = 'mpv' | 'vlc';
export type ExternalPlaybackLaunchTracker = (
launch: Promise<ExternalPlayerSession | void>
) => void;
/**
* Serializes managed external-player recovery without retaining playback data.
* The callbacks are owned by the player view and run only for the current
* credential-free intent.
*/
export class ExternalPlaybackRecoveryCoordinator {
private readonly recovery = new ExternalPlaybackRecovery();
readonly states = this.recovery.states;
readonly pending = this.recovery.pending;
constructor(
private readonly externalPlayback: PortalExternalPlayback | null
) {}
observe(session: ExternalPlayerSession | null): void {
this.recovery.observe(session);
}
syncSession(sessionKey: string): void {
this.recovery.syncSession(sessionKey);
}
request(
target: ExternalRecoveryTarget,
onBegin: () => void,
onReady: (trackLaunch: ExternalPlaybackLaunchTracker) => boolean
): void {
const activeSession = this.externalPlayback?.activeSession() ?? null;
const intent = this.recovery.begin(target);
if (!intent) {
return;
}
onBegin();
if (!isLiveExternalSession(activeSession)) {
this.runReady(intent, onReady);
return;
}
if (!activeSession.canClose || !this.externalPlayback) {
this.recovery.fail(intent);
return;
}
void this.closeThenRun(activeSession, intent, onReady);
}
destroy(): void {
this.recovery.destroy();
}
private async closeThenRun(
session: ExternalPlayerSession,
intent: ExternalRecoveryIntent,
onReady: (trackLaunch: ExternalPlaybackLaunchTracker) => boolean
): Promise<void> {
try {
await this.externalPlayback?.closeSession(session);
} catch {
this.recovery.fail(intent);
return;
}
this.recovery.close(session.player, session.id);
const current = this.externalPlayback?.activeSession() ?? null;
if (isLiveExternalSession(current)) {
this.recovery.fail(intent);
return;
}
this.runReady(intent, onReady);
}
private runReady(
intent: ExternalRecoveryIntent,
onReady: (trackLaunch: ExternalPlaybackLaunchTracker) => boolean
): void {
if (!this.recovery.owns(intent)) {
return;
}
const accepted = onReady((launch) => {
void launch.then(
(session) => {
if (session) {
this.recovery.confirm(intent, session);
} else {
this.recovery.fail(intent);
}
},
() => this.recovery.fail(intent)
);
});
if (!accepted) {
this.recovery.cancel(intent);
}
}
}
function isLiveExternalSession(
session: ExternalPlayerSession | null
): session is ExternalPlayerSession {
return (
session?.status === 'launching' ||
session?.status === 'opened' ||
session?.status === 'playing' ||
(session?.status === 'error' && session.canClose)
);
}
@@ -0,0 +1,345 @@
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import {
EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS,
ExternalPlaybackRecovery,
} from './external-playback-recovery';
function session(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'session-1',
player: 'mpv',
status: 'opened',
title: 'Example',
streamUrl: 'https://user:secret@example.com/stream.mkv?token=private',
startedAt: '2026-08-08T10:00:00.000Z',
updatedAt: '2026-08-08T10:00:01.000Z',
canClose: true,
...overrides,
};
}
describe('ExternalPlaybackRecovery', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('starts one external launch intent and rejects a competing target', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = recovery.begin('mpv');
expect(intent).not.toBeNull();
expect(recovery.pending()).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
sessionId: null,
status: 'launching',
});
expect(recovery.begin('vlc')).toBeNull();
recovery.destroy();
});
it('requires the exact owned launch result before accepting session updates', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('mpv'));
expect(
recovery.observe(session({ id: 'old-session', status: 'playing' }))
).toBe(false);
expect(
recovery.observe(
session({ id: 'vlc-session', player: 'vlc', status: 'opened' })
)
).toBe(false);
expect(
recovery.observe(
session({ id: 'mpv-session', player: 'mpv', status: 'opened' })
)
).toBe(false);
expect(
recovery.confirm(
intent,
session({ id: 'mpv-session', player: 'mpv', status: 'opened' })
)
).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
sessionId: 'mpv-session',
status: 'started',
});
expect(recovery.pending()).toBe(false);
expect(
recovery.observe(
session({ id: 'other-mpv', player: 'mpv', status: 'error' })
)
).toBe(false);
expect(recovery.target('mpv').status).toBe('started');
recovery.destroy();
});
it.each([
['playing', 'playing'],
['error', 'error'],
] as const)('maps exact session %s to %s', (sessionStatus, stateStatus) => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
intent,
session({ id: 'mpv-session', status: 'opened' })
);
expect(
recovery.observe(
session({ id: 'mpv-session', status: sessionStatus })
)
).toBe(true);
expect(recovery.target('mpv').status).toBe(stateStatus);
recovery.destroy();
});
it('returns a closed exact session to idle while retaining attempt history', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
intent,
session({ id: 'mpv-session', status: 'opened' })
);
expect(
recovery.observe(
session({
id: 'mpv-session',
status: 'closed',
canClose: false,
})
)
).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
sessionId: null,
status: 'idle',
});
recovery.destroy();
});
it('times out a missing launch handoff without accepting its later session', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv');
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
expect(recovery.pending()).toBe(false);
expect(recovery.target('mpv').status).toBe('error');
expect(
recovery.observe(session({ id: 'late-session', status: 'opened' }))
).toBe(false);
recovery.destroy();
});
it('keeps an exact launching session correlated after the local timeout', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
intent,
session({ id: 'slow-mpv', player: 'mpv', status: 'launching' })
);
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
sessionId: 'slow-mpv',
status: 'error',
});
expect(
recovery.observe(
session({ id: 'slow-mpv', player: 'mpv', status: 'playing' })
)
).toBe(true);
expect(recovery.target('mpv').status).toBe('playing');
recovery.destroy();
});
it('invalidates an old intent and timer when the content session changes', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = recovery.begin('mpv');
expect(intent).not.toBeNull();
recovery.syncSession('content-b');
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
expect(recovery.owns(requireIntent(intent))).toBe(false);
expect(recovery.pending()).toBe(false);
expect(recovery.target('mpv')).toEqual({
attempts: 0,
sessionId: null,
status: 'idle',
});
expect(
recovery.observe(session({ id: 'stale-session', status: 'error' }))
).toBe(false);
recovery.destroy();
});
it('clears the launch timer and ownership when destroyed', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv');
recovery.destroy();
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
expect(jest.getTimerCount()).toBe(0);
expect(recovery.pending()).toBe(false);
expect(recovery.target('mpv').status).toBe('launching');
});
it('fails only the current intent and counts a later retry', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const first = requireIntent(recovery.begin('mpv'));
expect(recovery.fail(first)).toBe(true);
expect(recovery.fail(first)).toBe(false);
const second = requireIntent(recovery.begin('mpv'));
expect(recovery.target('mpv')).toEqual({
attempts: 2,
sessionId: null,
status: 'launching',
});
expect(recovery.owns(second)).toBe(true);
recovery.destroy();
});
it('rejects a late result from a timed-out attempt while accepting the retry result', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const first = requireIntent(recovery.begin('mpv'));
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
const retry = requireIntent(recovery.begin('mpv'));
expect(
recovery.confirm(
first,
session({ id: 'late-first', status: 'opened' })
)
).toBe(false);
expect(
recovery.observe(session({ id: 'late-first', status: 'playing' }))
).toBe(false);
expect(
recovery.confirm(
retry,
session({ id: 'exact-retry', status: 'opened' })
)
).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 2,
sessionId: 'exact-retry',
status: 'started',
});
recovery.destroy();
});
it('cancels an unlaunched current intent without reporting an error', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('vlc'));
expect(recovery.cancel(intent)).toBe(true);
expect(recovery.pending()).toBe(false);
expect(recovery.target('vlc')).toEqual({
attempts: 1,
sessionId: null,
status: 'idle',
});
recovery.destroy();
});
it('updates the previous exact session without cancelling a different target intent', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const mpvIntent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
mpvIntent,
session({ id: 'mpv-session', status: 'opened' })
);
const vlcIntent = requireIntent(recovery.begin('vlc'));
expect(
recovery.observe(
session({
id: 'mpv-session',
status: 'closed',
canClose: false,
})
)
).toBe(true);
expect(recovery.target('mpv').status).toBe('idle');
expect(recovery.owns(vlcIntent)).toBe(true);
expect(recovery.pending()).toBe(true);
recovery.destroy();
});
it('applies a confirmed exact close even when a reactive observer coalesces it', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const mpvIntent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
mpvIntent,
session({ id: 'mpv-session', status: 'opened' })
);
expect(recovery.close('mpv', 'mpv-session')).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
sessionId: null,
status: 'idle',
});
expect(recovery.close('mpv', 'other-session')).toBe(false);
recovery.destroy();
});
it('stores no stream URL, headers, credentials, title, or error text', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
intent,
session({
error: 'token=private at /Users/example/player',
headers: undefined,
id: 'safe-id',
status: 'error',
} as Partial<ExternalPlayerSession>)
);
const serialized = JSON.stringify(recovery.states());
expect(serialized).toContain('safe-id');
expect(serialized).not.toContain('example.com');
expect(serialized).not.toContain('private');
expect(serialized).not.toContain('/Users/example');
expect(serialized).not.toContain('Example');
recovery.destroy();
});
});
function requireIntent<T>(intent: T | null): T {
if (intent === null) {
throw new Error('Expected an external recovery intent');
}
return intent;
}
@@ -0,0 +1,249 @@
import { computed, signal, type Signal } from '@angular/core';
import type {
ExternalPlayerName,
ExternalPlayerSession,
} from '@iptvnator/shared/interfaces';
export const EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS = 10_000;
export type ExternalRecoveryStatus =
'idle' | 'launching' | 'started' | 'playing' | 'error';
export interface ExternalRecoveryTargetState {
readonly attempts: number;
readonly sessionId: string | null;
readonly status: ExternalRecoveryStatus;
}
export interface ExternalRecoveryIntent {
readonly target: ExternalPlayerName;
readonly token: symbol;
}
export type ExternalRecoveryStates = Readonly<
Record<ExternalPlayerName, ExternalRecoveryTargetState>
>;
interface ActiveIntent extends ExternalRecoveryIntent {
readonly sessionId: string | null;
}
function idleState(): ExternalRecoveryTargetState {
return Object.freeze({ attempts: 0, sessionId: null, status: 'idle' });
}
function initialStates(): ExternalRecoveryStates {
return Object.freeze({ mpv: idleState(), vlc: idleState() });
}
export class ExternalPlaybackRecovery {
private readonly statesState =
signal<ExternalRecoveryStates>(initialStates());
private readonly activeIntentState = signal<ActiveIntent | null>(null);
private contentSessionKey: string | null = null;
private launchTimer: ReturnType<typeof setTimeout> | null = null;
readonly states: Signal<ExternalRecoveryStates> =
this.statesState.asReadonly();
readonly pending = computed(() => this.activeIntentState() !== null);
syncSession(key: string): boolean {
if (this.contentSessionKey === key) {
return false;
}
this.contentSessionKey = key;
this.clearActiveIntent();
this.statesState.set(initialStates());
return true;
}
begin(target: ExternalPlayerName): ExternalRecoveryIntent | null {
if (this.activeIntentState() !== null) {
return null;
}
const token = Symbol();
const intent: ActiveIntent = Object.freeze({
sessionId: null,
target,
token,
});
const previous = this.target(target);
this.setTarget(target, {
attempts: previous.attempts + 1,
sessionId: null,
status: 'launching',
});
this.activeIntentState.set(intent);
this.launchTimer = setTimeout(
() => this.fail(intent),
EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS
);
return intent;
}
owns(intent: ExternalRecoveryIntent): boolean {
const active = this.activeIntentState();
return (
active?.token === intent.token && active.target === intent.target
);
}
observe(session: ExternalPlayerSession | null): boolean {
if (!session) {
return false;
}
const correlated = this.target(session.player);
if (correlated.sessionId === session.id) {
return this.applySession(session.player, session);
}
const active = this.activeIntentState();
if (active) {
if (active.sessionId === null) {
return false;
} else if (session.id !== active.sessionId) {
return false;
}
return this.applySession(active.target, session);
}
const target = session.player;
return this.target(target).sessionId === session.id
? this.applySession(target, session)
: false;
}
confirm(
intent: ExternalRecoveryIntent,
session: ExternalPlayerSession
): boolean {
if (!this.owns(intent) || session.player !== intent.target) {
return false;
}
const active = this.activeIntentState();
if (!active) {
return false;
}
this.activeIntentState.set(
Object.freeze({ ...active, sessionId: session.id })
);
return this.applySession(intent.target, session);
}
cancel(intent: ExternalRecoveryIntent): boolean {
if (!this.owns(intent)) {
return false;
}
const current = this.target(intent.target);
if (current.status === 'launching') {
this.setTarget(intent.target, {
attempts: current.attempts,
sessionId: null,
status: 'idle',
});
}
this.clearActiveIntent();
return true;
}
close(target: ExternalPlayerName, sessionId: string): boolean {
const current = this.target(target);
if (current.sessionId !== sessionId) {
return false;
}
this.setTarget(target, {
attempts: current.attempts,
sessionId: null,
status: 'idle',
});
return true;
}
fail(intent: ExternalRecoveryIntent): boolean {
if (!this.owns(intent)) {
return false;
}
const current = this.target(intent.target);
this.setTarget(intent.target, {
attempts: current.attempts,
sessionId: current.sessionId,
status: 'error',
});
this.clearActiveIntent();
return true;
}
target(target: ExternalPlayerName): ExternalRecoveryTargetState {
return this.statesState()[target];
}
destroy(): void {
this.clearActiveIntent();
}
private applySession(
target: ExternalPlayerName,
session: ExternalPlayerSession
): boolean {
const previous = this.target(target);
const status = this.toRecoveryStatus(session.status);
this.setTarget(target, {
attempts: previous.attempts,
sessionId: status === 'idle' ? null : session.id,
status,
});
const active = this.activeIntentState();
if (
session.status !== 'launching' &&
active?.target === target &&
active.sessionId === session.id
) {
this.clearActiveIntent();
}
return true;
}
private toRecoveryStatus(
status: ExternalPlayerSession['status']
): ExternalRecoveryStatus {
switch (status) {
case 'launching':
return 'launching';
case 'opened':
return 'started';
case 'playing':
return 'playing';
case 'error':
return 'error';
case 'closed':
return 'idle';
}
}
private setTarget(
target: ExternalPlayerName,
state: ExternalRecoveryTargetState
): void {
this.statesState.update((states) =>
Object.freeze({
...states,
[target]: Object.freeze(state),
})
);
}
private clearActiveIntent(): void {
if (this.launchTimer !== null) {
clearTimeout(this.launchTimer);
this.launchTimer = null;
}
this.activeIntentState.set(null);
}
}
@@ -0,0 +1,33 @@
import type { PlaybackBinding } from './playback-recovery-session';
import type {
WebPlayerApplicationToken,
WebPlayerSourceRevisionToken,
} from './web-player-application-state';
export interface PlaybackApplicationOwnership {
readonly binding: PlaybackBinding | null;
readonly embeddedMpv: boolean;
readonly isLive: boolean;
readonly sourceRevision: WebPlayerSourceRevisionToken;
readonly token: WebPlayerApplicationToken;
}
export function ownsPlaybackApplication(options: {
readonly ownership: PlaybackApplicationOwnership;
readonly currentToken: WebPlayerApplicationToken;
readonly currentSourceRevision: WebPlayerSourceRevisionToken;
readonly bindingOwned: boolean;
readonly embeddedMpvSelected: boolean;
}): boolean {
const { ownership } = options;
if (
ownership.token !== options.currentToken ||
ownership.sourceRevision !== options.currentSourceRevision
) {
return false;
}
if (ownership.binding) {
return !ownership.embeddedMpv && options.bindingOwned;
}
return ownership.embeddedMpv && options.embeddedMpvSelected;
}
@@ -0,0 +1,169 @@
import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type { ExternalRecoveryStates } from './external-playback-recovery';
import { createWebPlayerRecommendations } from './web-player-recovery-policy';
const DIAGNOSTIC: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedContainer,
source: PlaybackDiagnosticSource.Native,
sourceUrl: 'https://example.com/movie.mkv',
container: 'matroska',
mimeType: 'video/matroska',
player: InlinePlaybackPlayer.VideoJs,
audioCodecs: [],
videoCodecs: [],
};
function externalStates(
overrides: Partial<ExternalRecoveryStates> = {}
): ExternalRecoveryStates {
return {
mpv: { attempts: 0, sessionId: null, status: 'idle' },
vlc: { attempts: 0, sessionId: null, status: 'idle' },
...overrides,
};
}
function recommendations(options: {
attemptedTargets?: ReadonlySet<PlaybackRecommendationTarget>;
diagnostic?: PlaybackDiagnostic;
externalStates?: ExternalRecoveryStates;
}) {
return createWebPlayerRecommendations({
diagnostic: options.diagnostic ?? DIAGNOSTIC,
binding: { generation: 1, target: InlinePlaybackPlayer.VideoJs },
attemptedTargets: options.attemptedTargets ?? new Set(),
externalStates: options.externalStates ?? externalStates(),
managedExternalPlayersAvailable: true,
playbackExternallyTransferable: true,
isLive: false,
alternativeSourceCount: 1,
});
}
function targets(
result: ReturnType<typeof recommendations>
): readonly string[] {
return result.map((item) =>
item.action === 'player' ? item.target : item.action
);
}
describe('createWebPlayerRecommendations external outcomes', () => {
it('keeps an attempted failed external target and promotes its untried sibling', () => {
const result = recommendations({
attemptedTargets: new Set(['mpv']),
externalStates: externalStates({
mpv: {
attempts: 1,
sessionId: 'mpv-session',
status: 'error',
},
}),
});
expect(targets(result)).toEqual(['vlc', 'mpv', 'alternative-source']);
expect(result.map((item) => item.priority)).toEqual([
'primary',
'secondary',
'secondary',
]);
});
it('retains inline family exclusion while reranking only external siblings', () => {
const result = recommendations({
diagnostic: {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.MediaDecodeError,
source: PlaybackDiagnosticSource.Hls,
container: 'm3u8',
mimeType: 'application/vnd.apple.mpegurl',
},
attemptedTargets: new Set([InlinePlaybackPlayer.Html5, 'mpv']),
externalStates: externalStates({
mpv: { attempts: 1, sessionId: null, status: 'error' },
}),
});
expect(targets(result)).toEqual(['vlc', 'mpv', 'alternative-source']);
expect(targets(result)).not.toContain(InlinePlaybackPlayer.Html5);
});
it('keeps a failed external target and reveals its capped sibling', () => {
const result = recommendations({
diagnostic: {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.DrmOrEncryption,
source: PlaybackDiagnosticSource.Hls,
container: 'm3u8',
mimeType: 'application/vnd.apple.mpegurl',
},
attemptedTargets: new Set(['mpv']),
externalStates: externalStates({
mpv: { attempts: 1, sessionId: null, status: 'error' },
}),
});
expect(targets(result)).toEqual([
InlinePlaybackPlayer.Html5,
'vlc',
'mpv',
]);
expect(result).toHaveLength(3);
});
it('retains a previously attempted target that the initial cap omitted', () => {
const result = recommendations({
diagnostic: {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.DrmOrEncryption,
source: PlaybackDiagnosticSource.Hls,
container: 'm3u8',
mimeType: 'application/vnd.apple.mpegurl',
},
attemptedTargets: new Set(['vlc']),
externalStates: externalStates({
vlc: { attempts: 1, sessionId: null, status: 'error' },
}),
});
expect(targets(result)).toEqual([
InlinePlaybackPlayer.Html5,
'mpv',
'vlc',
]);
expect(result).toHaveLength(3);
});
it('preserves policy order for equal attempt counts and stays capped at three', () => {
const result = recommendations({
attemptedTargets: new Set(['mpv', 'vlc']),
externalStates: externalStates({
mpv: { attempts: 2, sessionId: null, status: 'error' },
vlc: { attempts: 2, sessionId: null, status: 'error' },
}),
});
expect(targets(result)).toEqual(['mpv', 'vlc', 'alternative-source']);
expect(result).toHaveLength(3);
});
it('does not mutate attempts or external state inputs', () => {
const attemptedTargets = new Set<PlaybackRecommendationTarget>(['mpv']);
const states = externalStates({
mpv: { attempts: 1, sessionId: 'mpv-1', status: 'started' },
});
const attemptedBefore = [...attemptedTargets];
const statesBefore = JSON.stringify(states);
recommendations({ attemptedTargets, externalStates: states });
expect([...attemptedTargets]).toEqual(attemptedBefore);
expect(JSON.stringify(states)).toBe(statesBefore);
});
});
@@ -5,18 +5,22 @@ import {
resolvePlaybackSourceKind,
type PlaybackDiagnostic,
type PlaybackRecommendation,
type PlaybackRecommendationContext,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import {
VideoPlayer,
type ExternalPlayerName,
type ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { PlaybackBinding } from './playback-recovery-session';
import type { ExternalRecoveryStates } from './external-playback-recovery';
export function createWebPlayerRecommendations(options: {
readonly diagnostic: PlaybackDiagnostic | null;
readonly binding: PlaybackBinding | null;
readonly attemptedTargets: ReadonlySet<PlaybackRecommendationTarget>;
readonly externalStates: ExternalRecoveryStates;
readonly managedExternalPlayersAvailable: boolean;
readonly playbackExternallyTransferable: boolean;
readonly isLive: boolean;
@@ -26,10 +30,15 @@ export function createWebPlayerRecommendations(options: {
return [];
}
const sourceKind = resolvePlaybackSourceKind(options.diagnostic);
return recommendPlaybackRecovery({
const attemptedInlineTargets = new Set(
[...options.attemptedTargets].filter(
(target) => target !== 'mpv' && target !== 'vlc'
)
);
const context: PlaybackRecommendationContext = {
diagnostic: options.diagnostic,
activeTarget: options.binding.target,
attemptedTargets: options.attemptedTargets,
attemptedTargets: attemptedInlineTargets,
targetCapabilities: createPlaybackTargetCapabilities({
sourceKind,
managedExternalPlayersAvailable:
@@ -44,9 +53,128 @@ export function createWebPlayerRecommendations(options: {
externalTransferable: options.playbackExternallyTransferable,
},
alternativeSourceCount: options.alternativeSourceCount,
};
const recommendations = revealCappedExternalSiblings(
recommendPlaybackRecovery(context),
context,
options.externalStates
);
return rerankExternalRecommendations(
recommendations,
options.externalStates
);
}
function revealCappedExternalSiblings(
recommendations: readonly PlaybackRecommendation[],
context: PlaybackRecommendationContext,
states: ExternalRecoveryStates
): readonly PlaybackRecommendation[] {
const externalTargets: readonly ExternalPlayerName[] = ['mpv', 'vlc'];
if (!externalTargets.some((target) => states[target].attempts > 0)) {
return recommendations;
}
const retained = [...recommendations];
for (const target of externalTargets) {
if (
retained.some(
(recommendation) =>
isExternalRecommendation(recommendation) &&
recommendation.target === target
)
) {
continue;
}
const siblingTarget: ExternalPlayerName =
target === 'mpv' ? 'vlc' : 'mpv';
const candidate = recommendPlaybackRecovery({
...context,
attemptedTargets: new Set([
...context.attemptedTargets,
siblingTarget,
]),
}).find(
(recommendation) =>
isExternalRecommendation(recommendation) &&
recommendation.target === target
);
if (!candidate) {
continue;
}
if (retained.length >= 3) {
const replaceIndex = findLastReplaceableRecommendation(retained);
if (replaceIndex < 0) {
continue;
}
retained.splice(replaceIndex, 1);
}
retained.push(candidate);
}
return retained;
}
function findLastReplaceableRecommendation(
recommendations: readonly PlaybackRecommendation[]
): number {
for (let index = recommendations.length - 1; index > 0; index -= 1) {
if (!isExternalRecommendation(recommendations[index])) {
return index;
}
}
return -1;
}
function rerankExternalRecommendations(
recommendations: readonly PlaybackRecommendation[],
states: ExternalRecoveryStates
): readonly PlaybackRecommendation[] {
const external = recommendations
.map((recommendation, index) => ({ index, recommendation }))
.filter(
(
item
): item is {
readonly index: number;
readonly recommendation: Extract<
PlaybackRecommendation,
{ readonly action: 'player' }
> & { readonly target: ExternalPlayerName };
} =>
item.recommendation.action === 'player' &&
(item.recommendation.target === 'mpv' ||
item.recommendation.target === 'vlc')
)
.sort((left, right) => {
const attemptDifference =
states[left.recommendation.target].attempts -
states[right.recommendation.target].attempts;
return attemptDifference || left.index - right.index;
});
let externalIndex = 0;
return recommendations.map((recommendation, index) => {
const ranked =
recommendation.action === 'player' &&
(recommendation.target === 'mpv' || recommendation.target === 'vlc')
? external[externalIndex++].recommendation
: recommendation;
return {
...ranked,
priority: index === 0 ? 'primary' : 'secondary',
};
});
}
function isExternalRecommendation(
recommendation: PlaybackRecommendation
): recommendation is Extract<PlaybackRecommendation, { readonly action: 'player' }> & {
readonly target: ExternalPlayerName;
} {
return (
recommendation.action === 'player' &&
(recommendation.target === 'mpv' || recommendation.target === 'vlc')
);
}
export function isPlaybackExternallyTransferable(
playback: ResolvedPortalPlayback
): boolean {
@@ -0,0 +1,415 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { signal } from '@angular/core';
import {
ComponentFixture,
DeferBlockBehavior,
TestBed,
} from '@angular/core/testing';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { By } from '@angular/platform-browser';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslateModule } from '@ngx-translate/core';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
type PlaybackFallbackRequest,
} from '@iptvnator/playback/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
type ExternalPlayerSession,
type ResolvedPortalPlayback,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import { of } from 'rxjs';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
import {
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
} from './web-player-view.spec-stubs';
jest.unstable_mockModule('video.js', () => ({ default: jest.fn() }));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
describe('WebPlayerViewComponent external recovery integration', () => {
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
let component: WebPlayerViewComponentInstance;
let activeSession: ReturnType<typeof signal<ExternalPlayerSession | null>>;
let closeSession: jest.Mock<Promise<void>, [ExternalPlayerSession]>;
beforeAll(async () => {
({ WebPlayerViewComponent } =
await import('./web-player-view.component'));
});
beforeEach(async () => {
activeSession = signal<ExternalPlayerSession | null>(null);
closeSession = jest.fn(async (session) => {
activeSession.set({
...session,
status: 'closed',
canClose: false,
updatedAt: '2026-08-08T10:00:02.000Z',
});
});
await TestBed.configureTestingModule({
deferBlockBehavior: DeferBlockBehavior.Playthrough,
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
providers: [
{
provide: StorageMap,
useValue: {
get: jest.fn(() => of({ player: VideoPlayer.VideoJs })),
},
},
{
provide: RuntimeCapabilitiesService,
useValue: { supportsManagedExternalPlayers: true },
},
{
provide: ElectronStreamHeadersService,
useValue: {
apply: jest.fn(() => null),
clear: jest.fn(),
},
},
{
provide: SettingsStore,
useValue: {
showCaptions: () => false,
webPlayerSharedControls: () => false,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession,
visibleSession: activeSession,
dismissActiveSession: jest.fn(),
closeSession,
},
},
],
})
.overrideComponent(WebPlayerViewComponent, {
set: {
imports: [
ClipboardModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
PlaybackDiagnosticPanelComponent,
StubArtPlayerComponent,
StubEmbeddedMpvPlayerComponent,
StubHtmlVideoPlayerComponent,
StubVjsPlayerComponent,
TranslateModule,
VodSourceRowComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(WebPlayerViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput(
'streamUrl',
'https://example.com/live.m3u8'
);
fixture.componentRef.setInput('title', 'Recovery stream');
fixture.componentRef.setInput('playbackSessionKey', 'content-a');
});
afterEach(() => fixture.destroy());
it('keeps both external actions mounted while MPV launches and after it starts', async () => {
const requests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
const mpvButton = requiredButton('playback-fallback-mpv');
const vlcButton = requiredButton('playback-fallback-vlc');
mpvButton.click();
mpvButton.click();
fixture.detectChanges();
expect(requests).toHaveLength(1);
expect(component.externalRecoveryPending()).toBe(true);
expect(component.externalRecoveryState().mpv).toEqual({
attempts: 1,
sessionId: null,
status: 'launching',
});
expect(requiredButton('playback-fallback-mpv')).toBe(mpvButton);
expect(requiredButton('playback-fallback-vlc')).toBe(vlcButton);
const opened = externalSession({ id: 'mpv-session', status: 'opened' });
activeSession.set(opened);
requests[0].trackLaunch(Promise.resolve(opened));
await Promise.resolve();
fixture.detectChanges();
expect(component.externalRecoveryPending()).toBe(false);
expect(component.externalRecoveryState().mpv).toEqual({
attempts: 1,
sessionId: 'mpv-session',
status: 'started',
});
expect(playerActionIds()).toEqual([
'playback-recommendation-html5',
'playback-fallback-vlc',
'playback-fallback-mpv',
]);
});
it('closes an active external player before emitting a different target', async () => {
const requests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
click('playback-fallback-mpv');
const opened = externalSession({ id: 'mpv-session', status: 'opened' });
activeSession.set(opened);
requests[0].trackLaunch(Promise.resolve(opened));
await Promise.resolve();
fixture.detectChanges();
click('playback-fallback-vlc');
await fixture.whenStable();
expect(closeSession).toHaveBeenCalledWith(
expect.objectContaining({ id: 'mpv-session' })
);
expect(requests.map(({ player }) => player)).toEqual(['mpv', 'vlc']);
});
it('cancels the pending replacement when the diagnostic clears during close', async () => {
const requests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
click('playback-fallback-mpv');
const opened = externalSession({ id: 'mpv-session', status: 'opened' });
activeSession.set(opened);
requests[0].trackLaunch(Promise.resolve(opened));
await Promise.resolve();
fixture.detectChanges();
let releaseClose: (() => void) | undefined;
closeSession.mockImplementationOnce(
(session) =>
new Promise<void>((resolve) => {
releaseClose = () => {
activeSession.set({
...session,
status: 'closed',
canClose: false,
});
resolve();
};
})
);
click('playback-fallback-vlc');
vjs().playbackIssue.emit(null);
fixture.detectChanges();
releaseClose?.();
await fixture.whenStable();
expect(requests.map(({ player }) => player)).toEqual(['mpv']);
expect(component.externalRecoveryPending()).toBe(false);
expect(component.externalRecoveryState().vlc).toEqual({
attempts: 1,
sessionId: null,
status: 'idle',
});
});
it('reports a local failure instead of launching beside an unclosable session', async () => {
activeSession.set(
externalSession({
id: 'unclosable',
player: 'vlc',
canClose: false,
})
);
const requests: unknown[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
click('playback-fallback-mpv');
await fixture.whenStable();
expect(requests).toEqual([]);
expect(closeSession).not.toHaveBeenCalled();
expect(component.externalRecoveryState().mpv.status).toBe('error');
expect(playerActionIds()).toContain('playback-fallback-mpv');
});
it('rejects a stale external session after the content key changes', async () => {
await showDiagnostic();
click('playback-fallback-mpv');
fixture.componentRef.setInput('playbackSessionKey', 'content-b');
setPlayback({ streamUrl: 'https://example.com/next.m3u8' });
fixture.detectChanges();
activeSession.set(
externalSession({ id: 'stale-mpv', status: 'error' })
);
fixture.detectChanges();
expect(component.externalRecoveryState().mpv).toEqual({
attempts: 0,
sessionId: null,
status: 'idle',
});
});
it.each([
{ target: 'mpv' as const, testId: 'playback-fallback-mpv' },
{ target: 'vlc' as const, testId: 'playback-fallback-vlc' },
])(
'emits one exact $target request for a same-tick double activation',
async ({ target, testId }) => {
const requests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
const issue = await showDiagnostic();
const button = requiredButton(testId);
button.click();
button.click();
expect(requests).toEqual([
{
player: target,
playback: component.resolvedPlayback(),
diagnostic: issue,
trackLaunch: expect.any(Function),
},
]);
}
);
it.each([
{ target: 'mpv' as const, testId: 'playback-fallback-mpv' },
{ target: 'vlc' as const, testId: 'playback-fallback-vlc' },
])(
'rejects a stale $target activation after playback becomes DRM protected',
async ({ testId }) => {
const requests: unknown[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
const staleButton = requiredButton(testId);
setPlayback({
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: {
'00112233445566778899aabbccddeeff':
'ffeeddccbbaa99887766554433221100',
},
},
});
staleButton.click();
expect(requests).toEqual([]);
}
);
async function showDiagnostic(): Promise<PlaybackDiagnostic> {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const issue = mediaIssue();
vjs().playbackIssue.emit(issue);
fixture.detectChanges();
return issue;
}
function setPlayback(overrides: Partial<ResolvedPortalPlayback>): void {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'Recovery stream',
isLive: false,
...overrides,
});
}
function vjs(): StubVjsPlayerComponent {
return fixture.debugElement.query(By.directive(StubVjsPlayerComponent))
.componentInstance as StubVjsPlayerComponent;
}
function click(testId: string): void {
requiredButton(testId).click();
}
function requiredButton(testId: string): HTMLButtonElement {
const button = fixture.nativeElement.querySelector(
`[data-test-id="${testId}"]`
);
expect(button).toBeInstanceOf(HTMLButtonElement);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Expected ${testId} button`);
}
return button;
}
function playerActionIds(): string[] {
return Array.from(
fixture.nativeElement.querySelectorAll(
'.web-player-diagnostic__player-card'
) as NodeListOf<HTMLElement>
).map((element) => element.dataset['testId'] ?? '');
}
});
function externalSession(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'external-session',
player: 'mpv',
status: 'opened',
title: 'Recovery stream',
streamUrl: 'https://example.com/live.m3u8',
startedAt: '2026-08-08T10:00:00.000Z',
updatedAt: '2026-08-08T10:00:01.000Z',
canClose: true,
...overrides,
};
}
function mediaIssue(): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.MediaDecodeError,
source: PlaybackDiagnosticSource.Vhs,
sourceUrl: 'https://example.com/live.m3u8',
container: 'm3u8',
mimeType: 'application/x-mpegURL',
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
};
}
@@ -103,6 +103,7 @@
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[playbackExternallyTransferable]="playbackExternallyTransferable()"
[alternativeSources]="alternativeSources()"
[externalStates]="externalRecoveryState()"
[pending]="recoveryPending()"
(retryRequested)="retryPlayback()"
(playerRequested)="requestRecommendedPlayer($event)"
@@ -4,6 +4,7 @@ import {
DeferBlockBehavior,
TestBed,
} from '@angular/core/testing';
import { signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
@@ -15,13 +16,16 @@ import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
type PlaybackFallbackRequest,
} from '@iptvnator/playback/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
type ExternalPlayerSession,
STORE_KEY,
VideoPlayer,
type ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import { of } from 'rxjs';
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
@@ -50,6 +54,10 @@ describe('WebPlayerViewComponent recovery integration', () => {
),
};
let runtime: { supportsManagedExternalPlayers: boolean };
let activeExternalSession: ReturnType<
typeof signal<ExternalPlayerSession | null>
>;
let closeExternalSession: jest.Mock<Promise<void>, [ExternalPlayerSession]>;
let holdHeaderHandoff: boolean;
let headerResolvers: Array<(stillCurrent: boolean) => void>;
let headerRejectors: Array<(reason?: unknown) => void>;
@@ -73,6 +81,15 @@ describe('WebPlayerViewComponent recovery integration', () => {
beforeEach(async () => {
runtime = { supportsManagedExternalPlayers: true };
activeExternalSession = signal<ExternalPlayerSession | null>(null);
closeExternalSession = jest.fn(async (session) => {
activeExternalSession.set({
...session,
status: 'closed',
canClose: false,
updatedAt: '2026-08-08T10:00:02.000Z',
});
});
holdHeaderHandoff = false;
headerResolvers = [];
headerRejectors = [];
@@ -95,6 +112,15 @@ describe('WebPlayerViewComponent recovery integration', () => {
webPlayerSharedControls: () => false,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: activeExternalSession,
visibleSession: activeExternalSession,
dismissActiveSession: jest.fn(),
closeSession: closeExternalSession,
},
},
],
})
.overrideComponent(WebPlayerViewComponent, {
@@ -175,7 +201,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
});
it('retries the active target with a reload while preserving attempts', async () => {
const fallbackRequests: unknown[] = [];
const fallbackRequests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
fallbackRequests.push(request)
);
@@ -184,6 +210,14 @@ describe('WebPlayerViewComponent recovery integration', () => {
fixture.detectChanges();
click('playback-fallback-mpv');
expect(fallbackRequests).toHaveLength(1);
const opened = externalSession({
id: 'mpv-retry-session',
status: 'opened',
});
activeExternalSession.set(opened);
fallbackRequests[0].trackLaunch(Promise.resolve(opened));
await Promise.resolve();
fixture.detectChanges();
vjs().playbackIssue.emit(networkIssue('videojs'));
fixture.detectChanges();
@@ -200,22 +234,43 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(playerActionIds()).toEqual([
'playback-recommendation-html5',
'playback-fallback-vlc',
'playback-fallback-mpv',
]);
});
it('keeps desktop browser-access guidance after external recommendations are exhausted', async () => {
it('keeps desktop browser-access guidance and both actions after both attempts', async () => {
const fallbackRequests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
fallbackRequests.push(request)
);
await render();
vjs().playbackIssue.emit(browserAccessIssue('videojs'));
fixture.detectChanges();
click('playback-fallback-mpv');
const openedMpv = externalSession({
id: 'mpv-browser-session',
status: 'opened',
});
activeExternalSession.set(openedMpv);
fallbackRequests[0].trackLaunch(Promise.resolve(openedMpv));
await Promise.resolve();
fixture.detectChanges();
click('playback-fallback-vlc');
await fixture.whenStable();
const openedVlc = externalSession({
id: 'vlc-browser-session',
player: 'vlc',
status: 'opened',
});
activeExternalSession.set(openedVlc);
fallbackRequests[1].trackLaunch(Promise.resolve(openedVlc));
await Promise.resolve();
fixture.detectChanges();
const banner = query('playback-diagnostic-banner');
expect(banner?.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
);
expect(banner?.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
@@ -223,7 +278,10 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(banner?.textContent).not.toContain(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
);
expect(playerActionIds()).toEqual([]);
expect(playerActionIds()).toEqual([
'playback-fallback-mpv',
'playback-fallback-vlc',
]);
});
it('preserves attempts when the URL changes under the same content key', async () => {
@@ -243,6 +301,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(playerActionIds()).toEqual([
'playback-recommendation-html5',
'playback-fallback-vlc',
'playback-fallback-mpv',
]);
});
@@ -1079,66 +1138,6 @@ describe('WebPlayerViewComponent recovery integration', () => {
);
});
it.each([
{ target: 'mpv' as const, testId: 'playback-fallback-mpv' },
{ target: 'vlc' as const, testId: 'playback-fallback-vlc' },
])(
'emits one exact $target request for a same-tick double activation',
async ({ target, testId }) => {
const requests: unknown[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await render();
const issue = mediaIssue('videojs');
vjs().playbackIssue.emit(issue);
fixture.detectChanges();
const button = requiredButton(testId);
button.click();
button.click();
expect(requests).toEqual([
{
player: target,
playback: component.resolvedPlayback(),
diagnostic: issue,
},
]);
}
);
it.each([
{ target: 'mpv' as const, testId: 'playback-fallback-mpv' },
{ target: 'vlc' as const, testId: 'playback-fallback-vlc' },
])(
'rejects a stale $target activation after playback becomes DRM protected',
async ({ testId }) => {
const requests: unknown[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await render();
vjs().playbackIssue.emit(mediaIssue('videojs'));
fixture.detectChanges();
const staleButton = requiredButton(testId);
setPlayback({
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: {
'00112233445566778899aabbccddeeff':
'ffeeddccbbaa99887766554433221100',
},
},
});
staleButton.click();
expect(requests).toEqual([]);
}
);
it('keeps a replaced-source diagnostic detached across every handoff outcome', async () => {
await render();
vjs().playbackIssue.emit(mediaIssue('videojs'));
@@ -1281,6 +1280,22 @@ describe('WebPlayerViewComponent recovery integration', () => {
}
});
function externalSession(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'external-session',
player: 'mpv',
status: 'opened',
title: 'Recovery stream',
streamUrl: 'https://example.com/live.m3u8',
startedAt: '2026-08-08T10:00:00.000Z',
updatedAt: '2026-08-08T10:00:01.000Z',
canClose: true,
...overrides,
};
}
function mediaIssue(
player: 'videojs' | 'html5',
path = 'live.m3u8'
@@ -19,6 +19,7 @@ import {
type PlaybackFallbackRequest,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
STORE_KEY,
@@ -41,15 +42,19 @@ import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-pl
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
import type { VideoPlayerOptions } from '../vjs-player/vjs-player.types';
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
import { ExternalPlaybackRecoveryCoordinator } from './external-playback-recovery-coordinator';
import {
type PlaybackBinding,
PlaybackRecoverySession,
} from './playback-recovery-session';
import { WebPlayerApplicationHandoffCoordinator } from './web-player-application-handoff';
import {
ownsPlaybackApplication,
type PlaybackApplicationOwnership,
} from './web-player-application-ownership';
import {
createWebPlayerApplicationState,
type WebPlayerApplicationToken,
type WebPlayerSourceRevisionToken,
} from './web-player-application-state';
import { resolveWebPlayerMediaTitle } from './web-player-playback-state';
import {
@@ -61,17 +66,8 @@ import {
function resolveWebPlayerSharedControls(): boolean {
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
return typeof storedValue === 'boolean'
? storedValue
: WEB_PLAYER_SHARED_CONTROLS_ENABLED;
}
interface PlaybackApplicationOwnership {
readonly binding: PlaybackBinding | null;
readonly embeddedMpv: boolean;
readonly isLive: boolean;
readonly sourceRevision: WebPlayerSourceRevisionToken;
readonly token: WebPlayerApplicationToken;
const fallback = WEB_PLAYER_SHARED_CONTROLS_ENABLED;
return typeof storedValue === 'boolean' ? storedValue : fallback;
}
@Component({
@@ -98,7 +94,13 @@ export class WebPlayerViewComponent implements OnDestroy {
private readonly storage = inject(StorageMap);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK, {
optional: true,
});
private readonly recoverySession = new PlaybackRecoverySession();
private readonly externalRecovery = new ExternalPlaybackRecoveryCoordinator(
this.externalPlayback
);
private readonly applicationHandoff =
new WebPlayerApplicationHandoffCoordinator(
inject(ElectronStreamHeadersService),
@@ -136,7 +138,13 @@ export class WebPlayerViewComponent implements OnDestroy {
);
readonly reloadToken = signal(0);
readonly playbackDiagnostic = signal<PlaybackDiagnostic | null>(null);
readonly recoveryPending = this.recoverySession.switchPending;
readonly externalRecoveryState = this.externalRecovery.states;
readonly externalRecoveryPending = this.externalRecovery.pending;
readonly recoveryPending = computed(
() =>
this.recoverySession.switchPending() ||
this.externalRecoveryPending()
);
readonly activeBinding = this.recoverySession.activeBinding;
channel: Channel | undefined;
@@ -215,6 +223,7 @@ export class WebPlayerViewComponent implements OnDestroy {
? binding
: null,
attemptedTargets: this.recoverySession.attemptedTargets(),
externalStates: this.externalRecoveryState(),
managedExternalPlayersAvailable:
this.runtime.supportsManagedExternalPlayers,
playbackExternallyTransferable:
@@ -245,6 +254,10 @@ export class WebPlayerViewComponent implements OnDestroy {
});
constructor() {
effect(() => {
const session = this.externalPlayback?.activeSession() ?? null;
untracked(() => this.externalRecovery.observe(session));
});
effect(() => {
// Session sync may clear a temporary player override. Run it before
// tracking intent so that reset is folded into this application.
@@ -287,6 +300,7 @@ export class WebPlayerViewComponent implements OnDestroy {
}
ngOnDestroy(): void {
this.externalRecovery.destroy();
this.applicationHandoff.destroy();
}
@@ -328,7 +342,21 @@ export class WebPlayerViewComponent implements OnDestroy {
event: { currentTime: number; duration: number },
ownership: PlaybackApplicationOwnership
): void {
if (!this.ownsPlaybackApplication(ownership)) {
if (
!ownsPlaybackApplication({
ownership,
currentToken: this.playbackApplicationToken(),
currentSourceRevision: this.playbackSourceRevisionToken(),
bindingOwned: ownership.binding
? this.applicationHandoff.owns(
ownership.binding,
ownership.token
)
: false,
embeddedMpvSelected:
this.selectedPlayer() === VideoPlayer.EmbeddedMpv,
})
) {
return;
}
@@ -351,12 +379,22 @@ export class WebPlayerViewComponent implements OnDestroy {
return;
}
if (target === 'mpv' || target === 'vlc') {
this.recoverySession.recordExternalAttempt(target);
this.externalFallbackRequested.emit({
player: target,
playback: this.resolvedPlayback(),
diagnostic,
});
this.externalRecovery.request(
target,
() => this.recoverySession.recordExternalAttempt(target),
(trackLaunch) => {
if (this.visiblePlaybackDiagnostic() !== diagnostic) {
return false;
}
this.externalFallbackRequested.emit({
player: target,
playback: this.resolvedPlayback(),
diagnostic,
trackLaunch,
});
return true;
}
);
return;
}
this.recoverySession.beginPlayerSwitch(target, this.resolvedIsLive());
@@ -371,6 +409,7 @@ export class WebPlayerViewComponent implements OnDestroy {
private syncRecoverySession(): void {
const sessionKey = this.playbackSessionKey();
this.externalRecovery.syncSession(sessionKey);
if (untracked(() => this.recoverySession.syncSession(sessionKey))) {
this.clearPlaybackDiagnostic();
}
@@ -380,25 +419,4 @@ export class WebPlayerViewComponent implements OnDestroy {
this.playbackDiagnosticOwnerToken.set(null);
this.playbackDiagnostic.set(null);
}
private ownsPlaybackApplication(
ownership: PlaybackApplicationOwnership
): boolean {
if (
ownership.token !== this.playbackApplicationToken() ||
ownership.sourceRevision !== this.playbackSourceRevisionToken()
) {
return false;
}
if (ownership.binding) {
return (
!ownership.embeddedMpv &&
this.applicationHandoff.owns(ownership.binding, ownership.token)
);
}
return (
ownership.embeddedMpv &&
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
);
}
}