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