{{ displayTitle() }}
-
-
{{ playerLabel() }}
+
+ {{
+ playerLabel()
+ }}
·
@if (showSpinner()) {
-
+
} @else if (showStatusIcon()) {
- {{
- statusIcon()
- }}
+ {{ statusIcon() }}
}
- {{
- statusLabel()
- }}
+
+ @if (session().status === 'error' && session().error) {
+ {{ session().error }}
+ } @else {
+ {{ statusLabelKey() | translate }}
+ }
+
- @if (showCloseButton()) {
+ @if (showCloseAction()) {
}
+ @if (showDismissAction()) {
+ @if (compact()) {
+
+ } @else {
+
+ }
+ }
diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss
index 29158fd71..2a121a3d1 100644
--- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss
+++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss
@@ -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;
diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts
index 45a39d7a7..ab1c30176 100644
--- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts
+++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts
@@ -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')
diff --git a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts
index 5e5713c83..8b3ed9795 100644
--- a/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts
+++ b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts
@@ -35,6 +35,7 @@ export class ExternalPlaybackDockComponent {
readonly compact = input(false);
readonly closeClicked = output
();
+ readonly dismissClicked = output();
readonly artworkClicked = output();
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();
+ }
}
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html
index a3930a3c8..012bae4e5 100644
--- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html
@@ -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)"
>
- {{
- getRecommendationIcon(recommendation)
- }}
+ @if (isExternalLaunching(recommendation)) {
+
+ } @else {
+ {{
+ getRecommendationIcon(recommendation)
+ }}
+ }
{{
@@ -124,6 +142,17 @@
) | translate
}}
+ @if (
+ getExternalStatusKey(recommendation);
+ as statusKey
+ ) {
+
+ {{ statusKey | translate }}
+
+ }
@if (
isTemporaryBuiltInRecommendation(recommendation)
) {
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss
index 3503ec392..d8633cfc7 100644
--- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss
@@ -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;
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts
index 7ca962fff..aa866cb82 100644
--- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts
@@ -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 {
+ return {
+ mpv: { attempts: 0, sessionId: null, status: 'idle' },
+ vlc: { attempts: 0, sessionId: null, status: 'idle' },
+ ...overrides,
+ };
+}
+
describe('PlaybackDiagnosticPanelComponent', () => {
let fixture: ComponentFixture;
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' }),
]);
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts
index 8a176a8f3..a67075a03 100644
--- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts
@@ -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();
readonly playbackExternallyTransferable = input.required();
readonly alternativeSources = input([]);
+ readonly externalStates = input.required();
readonly pending = input(false);
readonly retryRequested = output();
@@ -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;
+ }
}
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts
index 0bbad462a..aa77c9e68 100644
--- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts
@@ -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'],
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts
index 79285ad2c..4154ec4b4 100644
--- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts
+++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts
@@ -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> {
@@ -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';
}
diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts
index ab505e0b8..4e21f977d 100644
--- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts
+++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts
@@ -251,7 +251,11 @@ export class VodDetailsComponent {
async onPrimaryAction(): Promise {
if (this.isExternalStopAction()) {
- await this.stopExternalPlayback();
+ try {
+ await this.stopExternalPlayback();
+ } catch {
+ // The dock stays visible when process teardown is unconfirmed.
+ }
return;
}
diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts
new file mode 100644
index 000000000..ab005aa84
--- /dev/null
+++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts
@@ -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(previous);
+ let releaseClose: (() => void) | undefined;
+ const closeSession = jest.fn(
+ () =>
+ new Promise((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(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) => 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(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(previous);
+ let releaseClose: (() => void) | undefined;
+ const coordinator = new ExternalPlaybackRecoveryCoordinator({
+ activeSession,
+ visibleSession: activeSession,
+ closeSession: jest.fn(
+ () =>
+ new Promise((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(null);
+ const coordinator = new ExternalPlaybackRecoveryCoordinator({
+ activeSession,
+ visibleSession: activeSession,
+ closeSession: jest.fn(),
+ dismissActiveSession: jest.fn(),
+ });
+ coordinator.syncSession('content-a');
+ let firstTracker:
+ | ((launch: Promise) => void)
+ | undefined;
+ coordinator.request('mpv', jest.fn(), (trackLaunch) => {
+ firstTracker = trackLaunch;
+ return true;
+ });
+ jest.advanceTimersByTime(10_000);
+ let retryTracker:
+ | ((launch: Promise) => 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 {
+ 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,
+ };
+}
diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts
new file mode 100644
index 000000000..ad9ab6f14
--- /dev/null
+++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts
@@ -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
+) => 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 {
+ 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)
+ );
+}
diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts
new file mode 100644
index 000000000..b5de7bfd0
--- /dev/null
+++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.spec.ts
@@ -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 {
+ 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)
+ );
+
+ 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(intent: T | null): T {
+ if (intent === null) {
+ throw new Error('Expected an external recovery intent');
+ }
+ return intent;
+}
diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts
new file mode 100644
index 000000000..d62a64812
--- /dev/null
+++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery.ts
@@ -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
+>;
+
+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(initialStates());
+ private readonly activeIntentState = signal(null);
+ private contentSessionKey: string | null = null;
+ private launchTimer: ReturnType | null = null;
+
+ readonly states: Signal =
+ 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);
+ }
+}
diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-application-ownership.ts b/libs/ui/playback/src/lib/web-player-view/web-player-application-ownership.ts
new file mode 100644
index 000000000..be862b170
--- /dev/null
+++ b/libs/ui/playback/src/lib/web-player-view/web-player-application-ownership.ts
@@ -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;
+}
diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts
new file mode 100644
index 000000000..1512996e6
--- /dev/null
+++ b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.spec.ts
@@ -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 {
+ return {
+ mpv: { attempts: 0, sessionId: null, status: 'idle' },
+ vlc: { attempts: 0, sessionId: null, status: 'idle' },
+ ...overrides,
+ };
+}
+
+function recommendations(options: {
+ attemptedTargets?: ReadonlySet;
+ 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
+): 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(['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);
+ });
+});
diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts
index c97ecd3ce..b5aba38fa 100644
--- a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts
+++ b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts
@@ -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;
+ 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 & {
+ readonly target: ExternalPlayerName;
+} {
+ return (
+ recommendation.action === 'player' &&
+ (recommendation.target === 'mpv' || recommendation.target === 'vlc')
+ );
+}
+
export function isPlaybackExternallyTransferable(
playback: ResolvedPortalPlayback
): boolean {
diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.external-recovery.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.external-recovery.spec.ts
new file mode 100644
index 000000000..5e0187b5a
--- /dev/null
+++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.external-recovery.spec.ts
@@ -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;
+ let component: WebPlayerViewComponentInstance;
+ let activeSession: ReturnType>;
+ let closeSession: jest.Mock, [ExternalPlayerSession]>;
+
+ beforeAll(async () => {
+ ({ WebPlayerViewComponent } =
+ await import('./web-player-view.component'));
+ });
+
+ beforeEach(async () => {
+ activeSession = signal(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((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 {
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ const issue = mediaIssue();
+ vjs().playbackIssue.emit(issue);
+ fixture.detectChanges();
+ return issue;
+ }
+
+ function setPlayback(overrides: Partial): 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
+ ).map((element) => element.dataset['testId'] ?? '');
+ }
+});
+
+function externalSession(
+ overrides: Partial = {}
+): 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: [],
+ };
+}
diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html
index b55da54d0..bb1cf277d 100644
--- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html
+++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html
@@ -103,6 +103,7 @@
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[playbackExternallyTransferable]="playbackExternallyTransferable()"
[alternativeSources]="alternativeSources()"
+ [externalStates]="externalRecoveryState()"
[pending]="recoveryPending()"
(retryRequested)="retryPlayback()"
(playerRequested)="requestRecommendedPlayer($event)"
diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts
index 2a1450c7e..1679a54f9 100644
--- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts
+++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts
@@ -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
+ >;
+ let closeExternalSession: jest.Mock, [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(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 {
+ 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'
diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
index 859d6caae..693ca8f83 100644
--- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
+++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
@@ -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(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
- );
- }
}
diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts
index 504d0f989..e3f8cede4 100644
--- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts
+++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts
@@ -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;
diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts
index e68d22867..5f191049e 100644
--- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts
+++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts
@@ -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 {
diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html
index 52f8c28f5..4a6dac447 100644
--- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html
+++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html
@@ -128,6 +128,7 @@
diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts
index f4ca86406..df39cd9c5 100644
--- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts
+++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts
@@ -102,6 +102,7 @@ class MockWorkspaceShellContextSidebarComponent {
class MockExternalPlaybackDockComponent {
readonly session = input(null);
readonly closeClicked = output();
+ readonly dismissClicked = output();
}
@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 () => {