fix(playback): clarify external launch feedback

This commit is contained in:
4gray committed 2026-08-09 12:52:05 +02:00
1 parent c189acf147
commit 7ed1ccfd8e
50 files changed
+2254 -202

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: playback
---
MPV and VLC recovery actions now stay available after launch attempts, show precise opening, started, playing, and failure feedback, and prevent overlapping external-player launches. Failed launches remain visible in the playback dock until dismissed.
+7 -1
View File
@@ -288,7 +288,13 @@ Key files:
exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP
DRM suppresses external targets because its payload is not transferable. Raw
engine messages, arbitrary data, and credentials never enter recommendation
evidence or ownership state.
evidence or ownership state. MPV/VLC actions remain mounted after an attempt
and expose credential-free per-target launching/started/playing/error state;
only an exact Electron `playing` update is labelled Playing. One handshake is
allowed at a time, switching players first closes the tracked live external
session, and stale updates cannot take ownership. The global dock mirrors
those statuses, keeps errors visible until dismissal, and intentionally has
no retry because it does not own the original launch headers or credentials.
- The built-in HTML5/hls.js player is the second guarded consumer.
`HtmlVideoPlayerComponent` provides a component-scoped
`WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared
+7 -1
View File
@@ -824,7 +824,13 @@ app as a real argument, so it is not an option.
exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP
DRM suppresses external targets because its payload is not transferable. Raw
engine messages, arbitrary data, and credentials never enter recommendation
evidence or ownership state.
evidence or ownership state. MPV/VLC actions remain mounted after an attempt
and expose credential-free per-target launching/started/playing/error state;
only an exact Electron `playing` update is labelled Playing. One handshake is
allowed at a time, switching players first closes the tracked live external
session, and stale updates cannot take ownership. The global dock mirrors
those statuses, keeps errors visible until dismissal, and intentionally has
no retry because it does not own the original launch headers or credentials.
- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded
Shaka Player source engine inside the HTML5 and ArtPlayer components (no new
player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`
@@ -3,6 +3,7 @@ import { readFileSync } from 'fs';
import { basename, join } from 'path';
import type {
ExternalPlayerName,
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import {
@@ -189,6 +190,7 @@ type CapturedExternalPlayerLaunch = {
};
type PlaybackRecommendationCaptureSnapshot = {
closed: string[];
completed: number;
launches: CapturedExternalPlayerLaunch[];
released: boolean;
@@ -197,6 +199,7 @@ type PlaybackRecommendationCaptureSnapshot = {
type PlaybackRecommendationCaptureState =
PlaybackRecommendationCaptureSnapshot & {
releaseFirstResponse: (() => void) | undefined;
sessions: Record<string, ExternalPlayerSession>;
waitForFirstResponse: Promise<void>;
};
@@ -209,10 +212,12 @@ async function installPlaybackRecommendationLaunchCapture(
releaseFirstResponse = resolve;
});
const state: PlaybackRecommendationCaptureState = {
closed: [],
completed: 0,
launches: [],
released: false,
releaseFirstResponse,
sessions: {},
waitForFirstResponse,
};
const globalRef = globalThis as typeof globalThis & {
@@ -223,7 +228,7 @@ async function installPlaybackRecommendationLaunchCapture(
const captureLaunch =
(player: ExternalPlayerName) =>
async (
_event: unknown,
rawEvent: unknown,
url: string,
title: string,
thumbnail?: string,
@@ -248,30 +253,81 @@ async function installPlaybackRecommendationLaunchCapture(
],
player,
});
const now = new Date().toISOString();
const id = `e2e-recommended-${player}-${invocation}`;
const event = rawEvent as {
sender: {
send: (
channel: string,
session: ExternalPlayerSession
) => void;
};
};
const launching: ExternalPlayerSession = {
canClose: true,
id,
player,
startedAt: now,
status: 'launching',
streamUrl: url,
thumbnail: thumbnail ?? null,
title,
updatedAt: now,
};
state.sessions[id] = launching;
event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', launching);
if (invocation === 1 && !state.released) {
await state.waitForFirstResponse;
}
state.completed += 1;
const now = new Date().toISOString();
return {
canClose: false,
id: `e2e-recommended-${player}-${invocation}`,
const completed: ExternalPlayerSession = {
...launching,
canClose: player === 'mpv',
error:
player === 'vlc'
? 'E2E player launch failed'
: undefined,
player,
startedAt: now,
status: 'opened',
streamUrl: url,
thumbnail: thumbnail ?? null,
title,
updatedAt: now,
status: player === 'mpv' ? 'opened' : 'error',
updatedAt: new Date().toISOString(),
};
state.sessions[id] = completed;
event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', completed);
return completed;
};
ipcMain.removeHandler('OPEN_MPV_PLAYER');
ipcMain.removeHandler('OPEN_VLC_PLAYER');
ipcMain.handle('OPEN_MPV_PLAYER', captureLaunch('mpv'));
ipcMain.handle('OPEN_VLC_PLAYER', captureLaunch('vlc'));
ipcMain.removeHandler('CLOSE_EXTERNAL_PLAYER_SESSION');
ipcMain.handle(
'CLOSE_EXTERNAL_PLAYER_SESSION',
(rawEvent: unknown, sessionId: string) => {
const session = state.sessions[sessionId];
if (!session) return null;
const closed: ExternalPlayerSession = {
...session,
canClose: false,
status: 'closed',
updatedAt: new Date().toISOString(),
};
state.closed.push(sessionId);
state.sessions[sessionId] = closed;
const event = rawEvent as {
sender: {
send: (
channel: string,
update: ExternalPlayerSession
) => void;
};
};
event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', closed);
return closed;
}
);
});
}
@@ -289,6 +345,7 @@ async function getPlaybackRecommendationCapture(
);
}
return {
closed: capture.closed,
completed: capture.completed,
launches: capture.launches,
released: capture.released,
@@ -313,7 +370,7 @@ async function releasePlaybackRecommendationCapture(
});
}
test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens in MPV', async ({
test('@electron @dash ClearKey DASH filters DRM fallback and reports external launch states', async ({
dataDir,
}) => {
const fixtureServer = await startDashFixtureServer();
@@ -439,6 +496,7 @@ test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens
},
] satisfies CapturedExternalPlayerLaunch[];
expect(await getPlaybackRecommendationCapture(app)).toEqual({
closed: [],
completed: 0,
launches: [],
released: false,
@@ -450,18 +508,33 @@ test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens
timeout: 10_000,
})
.toEqual({
closed: [],
completed: 0,
launches: expectedLaunches,
released: false,
});
expect(
(await getPlaybackRecommendationCapture(app)).launches.filter(
({ player }) => player === 'vlc'
)
).toHaveLength(0);
await expect(mpvFallback).toHaveCount(0);
await expect(vlcFallback).toHaveClass(
/web-player-diagnostic__player-card--primary/
await expect(mpvFallback).toBeVisible();
await expect(vlcFallback).toBeVisible();
await expect(mpvFallback).toContainText('Opening MPV');
await expect(mpvFallback).toContainText('Opening player');
await expect(mpvFallback).toHaveAttribute('aria-busy', 'true');
await expect(vlcFallback).toHaveAttribute('aria-disabled', 'true');
await vlcFallback.evaluate((button: HTMLButtonElement) =>
button.click()
);
await expect
.poll(() => getPlaybackRecommendationCapture(app))
.toEqual({
closed: [],
completed: 0,
launches: expectedLaunches,
released: false,
});
const dock = app.mainWindow.locator('app-external-playback-dock');
await expect(dock).toContainText('Opening player');
await expect(dock.locator('.external-playback-dock')).toHaveAttribute(
'aria-busy',
'true'
);
await releasePlaybackRecommendationCapture(app);
@@ -470,6 +543,7 @@ test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens
timeout: 10_000,
})
.toEqual({
closed: [],
completed: 1,
launches: expectedLaunches,
released: true,
@@ -483,14 +557,47 @@ test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens
})
);
expect(await getPlaybackRecommendationCapture(app)).toEqual({
closed: [],
completed: 1,
launches: expectedLaunches,
released: true,
});
await expect(mpvFallback).toHaveCount(0);
await expect(mpvFallback).toBeVisible();
await expect(mpvFallback).toContainText('Open MPV again');
await expect(mpvFallback).toContainText('Player started');
await expect(vlcFallback).toHaveClass(
/web-player-diagnostic__player-card--primary/
);
await expect(dock).toContainText('Player started');
await vlcFallback.click();
const expectedBothLaunches = [
...expectedLaunches,
{ ...expectedLaunches[0], player: 'vlc' as const },
];
await expect
.poll(() => getPlaybackRecommendationCapture(app), {
timeout: 10_000,
})
.toEqual({
closed: ['e2e-recommended-mpv-1'],
completed: 2,
launches: expectedBothLaunches,
released: true,
});
await expect(mpvFallback).toBeVisible();
await expect(vlcFallback).toBeVisible();
await expect(vlcFallback).toContainText('Try VLC again');
await expect(vlcFallback).toContainText('Player launch failed');
await expect(mpvFallback).toHaveClass(
/web-player-diagnostic__player-card--primary/
);
await expect(dock).toContainText('E2E player launch failed');
await expect(
dock.getByRole('button', { name: 'Dismiss' })
).toBeVisible();
await dock.getByRole('button', { name: 'Dismiss' }).click();
await expect(dock).toBeHidden();
} finally {
await releasePlaybackRecommendationCapture(app).catch(() => undefined);
await closeElectronApp(app);
@@ -2,9 +2,7 @@ import { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { ExternalPlaybackService } from './external-playback.service';
describe('ExternalPlaybackService', () => {
let listener:
| ((session: ExternalPlayerSession) => void)
| undefined;
let listener: ((session: ExternalPlayerSession) => void) | undefined;
let closeExternalPlayerSession: jest.Mock;
let service: ExternalPlaybackService;
@@ -94,7 +92,9 @@ describe('ExternalPlaybackService', () => {
contentType: 'vod',
})
).toBeNull();
expect(service.visibleSession()).toBeNull();
expect(service.visibleSession()).toEqual(
expect.objectContaining({ status: 'error' })
);
});
it('delegates close requests for closable sessions', async () => {
@@ -133,18 +133,22 @@ describe('ExternalPlaybackService', () => {
expect(closeExternalPlayerSession).toHaveBeenCalledWith('session-2');
});
it('hides terminal sessions from the dock', () => {
it('hides closed sessions but keeps errors visible until dismissed', () => {
listener?.(createSession({ status: 'closed', canClose: false }));
expect(service.visibleSession()).toBeNull();
listener?.(
createSession({
id: 'session-3',
status: 'error',
error: 'Launch failed',
canClose: false,
})
);
const failed = createSession({
id: 'session-3',
status: 'error',
error: 'Launch failed',
canClose: false,
});
listener?.(createSession({ id: failed.id }));
listener?.(failed);
expect(service.visibleSession()).toEqual(failed);
service.dismissActiveSession();
expect(service.visibleSession()).toBeNull();
});
});
@@ -1,5 +1,8 @@
import { computed, Injectable, signal } from '@angular/core';
import { ExternalPlayerSession, PlayerContentInfo } from '@iptvnator/shared/interfaces';
import {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
@Injectable({
providedIn: 'root',
@@ -10,11 +13,7 @@ export class ExternalPlaybackService {
readonly visibleSession = computed(() => {
const session = this.activeSession();
if (
!session ||
session.status === 'closed' ||
session.status === 'error'
) {
if (!session || session.status === 'closed') {
return null;
}
@@ -60,9 +59,8 @@ export class ExternalPlaybackService {
this.dismissedSessionId.set(session.id);
try {
const updatedSession = await window.electron.closeExternalPlayerSession(
session.id
);
const updatedSession =
await window.electron.closeExternalPlayerSession(session.id);
if (updatedSession) {
this.handleSessionUpdate(updatedSession);
return;
@@ -106,7 +104,11 @@ export class ExternalPlaybackService {
private handleSessionUpdate(session: ExternalPlayerSession): void {
const current = this.activeSession();
if (!current || current.id === session.id || session.status === 'launching') {
if (
!current ||
current.id === session.id ||
session.status === 'launching'
) {
this.activeSession.set(session);
}
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "هذا الهدف متوافق مع بيانات التشفير المتاحة",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "المفضلة",
"RAIL_DOWNLOADS": "التنزيلات",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "جميع القنوات",
"RAIL_GROUPS": "المجموعات",
"RAIL_CONTEXT_ACTIONS": "إجراءات القسم",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "هاد الهدف متوافق مع بيانات التشفير المتوفرة",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "المفضلة",
"RAIL_DOWNLOADS": "التحميلات",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "جميع القنوات",
"RAIL_GROUPS": "المجموعات",
"RAIL_CONTEXT_ACTIONS": "إجراءات القسم",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Гэтая мэта сумяшчальная з даступнымі данымі шыфравання",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Выбранае",
"RAIL_DOWNLOADS": "Спампаванні",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Усе каналы",
"RAIL_GROUPS": "Групы",
"RAIL_CONTEXT_ACTIONS": "Дзеянні раздзела",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Dieses Ziel ist mit den verfügbaren Verschlüsselungsdaten kompatibel",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "MPV wird geöffnet…",
"ACTION_OPENING_VLC": "VLC wird geöffnet…",
"ACTION_REOPEN_MPV": "MPV erneut öffnen",
"ACTION_REOPEN_VLC": "VLC erneut öffnen",
"ACTION_RETRY_MPV": "MPV erneut versuchen",
"ACTION_RETRY_VLC": "VLC erneut versuchen",
"EXTERNAL_OPENING": "Player wird geöffnet…",
"EXTERNAL_STARTED": "Player gestartet",
"EXTERNAL_PLAYING": "Wiedergabe läuft",
"EXTERNAL_FAILED": "Player konnte nicht gestartet werden",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Favoriten",
"RAIL_DOWNLOADS": "Downloads",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Ausblenden",
"EXTERNAL_PLAYBACK_OPENING": "Player wird geöffnet…",
"EXTERNAL_PLAYBACK_STARTED": "Player gestartet",
"EXTERNAL_PLAYBACK_PLAYING": "Wiedergabe läuft",
"EXTERNAL_PLAYBACK_FAILED": "Wiedergabe fehlgeschlagen",
"EXTERNAL_PLAYBACK_CLOSED": "Geschlossen",
"RAIL_ALL_CHANNELS": "Alle Sender",
"RAIL_GROUPS": "Gruppen",
"RAIL_CONTEXT_ACTIONS": "Bereichsaktionen",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Αυτός ο στόχος είναι συμβατός με τα διαθέσιμα δεδομένα κρυπτογράφησης",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Αγαπημένα",
"RAIL_DOWNLOADS": "Λήψεις",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Όλα τα κανάλια",
"RAIL_GROUPS": "Ομάδες",
"RAIL_CONTEXT_ACTIONS": "Ενέργειες ενότητας",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "This target is compatible with the available encryption data",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy stream URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Favorites",
"RAIL_DOWNLOADS": "Downloads",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "All channels",
"RAIL_GROUPS": "Groups",
"RAIL_CONTEXT_ACTIONS": "Section actions",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Este destino es compatible con los datos de cifrado disponibles",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Favoritos",
"RAIL_DOWNLOADS": "Descargas",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Todos los canales",
"RAIL_GROUPS": "Grupos",
"RAIL_CONTEXT_ACTIONS": "Acciones de la sección",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Cette cible est compatible avec les données de chiffrement disponibles",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Favoris",
"RAIL_DOWNLOADS": "Téléchargements",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Toutes les chaînes",
"RAIL_GROUPS": "Groupes",
"RAIL_CONTEXT_ACTIONS": "Actions de section",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Ez a cél kompatibilis az elérhető titkosítási adatokkal",
"ACTION_OPEN_MPV": "Megnyitás MPV-ben",
"ACTION_OPEN_VLC": "Megnyitás VLC-ben",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "beállított lejátszó",
"ACTION_OPEN_VLC_HINT": "beállított lejátszó",
"ACTION_COPY_URL": "Adatfolyam URL-címének másolása",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Kedvencek",
"RAIL_DOWNLOADS": "Letöltések",
"EXTERNAL_PLAYBACK_CLOSE": "Lejátszó bezárása",
"EXTERNAL_PLAYBACK_DISMISS": "Dismiss",
"EXTERNAL_PLAYBACK_OPENING": "Opening player…",
"EXTERNAL_PLAYBACK_STARTED": "Player started",
"EXTERNAL_PLAYBACK_PLAYING": "Playing",
"EXTERNAL_PLAYBACK_FAILED": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Összes csatorna",
"RAIL_GROUPS": "Csoportok",
"RAIL_CONTEXT_ACTIONS": "A szakasz műveletei",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Questa destinazione è compatibile con i dati di crittografia disponibili",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Preferiti",
"RAIL_DOWNLOADS": "Download",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Tutti i canali",
"RAIL_GROUPS": "Gruppi",
"RAIL_CONTEXT_ACTIONS": "Azioni di sezione",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "この対象は利用可能な暗号化データに対応しています",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "お気に入り",
"RAIL_DOWNLOADS": "ダウンロード",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "すべてのチャンネル",
"RAIL_GROUPS": "グループ",
"RAIL_CONTEXT_ACTIONS": "セクションのアクション",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "이 대상은 사용 가능한 암호화 데이터와 호환됩니다",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "즐겨찾기",
"RAIL_DOWNLOADS": "다운로드",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "모든 채널",
"RAIL_GROUPS": "그룹",
"RAIL_CONTEXT_ACTIONS": "섹션 작업",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Dit doel is compatibel met de beschikbare versleutelingsgegevens",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Favorieten",
"RAIL_DOWNLOADS": "Downloads",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Alle kanalen",
"RAIL_GROUPS": "Groepen",
"RAIL_CONTEXT_ACTIONS": "Sectieacties",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Ten cel jest zgodny z dostępnymi danymi szyfrowania",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Ulubione",
"RAIL_DOWNLOADS": "Pobrane",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Wszystkie kanały",
"RAIL_GROUPS": "Grupy",
"RAIL_CONTEXT_ACTIONS": "Akcje sekcji",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Este destino é compatível com os dados de criptografia disponíveis",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Favoritos",
"RAIL_DOWNLOADS": "Downloads",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Todos os canais",
"RAIL_GROUPS": "Grupos",
"RAIL_CONTEXT_ACTIONS": "Ações da seção",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Эта цель совместима с доступными данными шифрования",
"ACTION_OPEN_MPV": "Открыть в MPV",
"ACTION_OPEN_VLC": "Открыть в VLC",
"ACTION_OPENING_MPV": "MPV открывается…",
"ACTION_OPENING_VLC": "VLC открывается…",
"ACTION_REOPEN_MPV": "Открыть MPV снова",
"ACTION_REOPEN_VLC": "Открыть VLC снова",
"ACTION_RETRY_MPV": "Повторить запуск MPV",
"ACTION_RETRY_VLC": "Повторить запуск VLC",
"EXTERNAL_OPENING": "Плеер открывается…",
"EXTERNAL_STARTED": "Плеер запущен",
"EXTERNAL_PLAYING": "Воспроизведение",
"EXTERNAL_FAILED": "Не удалось запустить плеер",
"ACTION_OPEN_MPV_HINT": "настроенный плеер",
"ACTION_OPEN_VLC_HINT": "настроенный плеер",
"ACTION_COPY_URL": "Скопировать URL потока",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Избранное",
"RAIL_DOWNLOADS": "Загрузки",
"EXTERNAL_PLAYBACK_CLOSE": "Close player",
"EXTERNAL_PLAYBACK_DISMISS": "Скрыть",
"EXTERNAL_PLAYBACK_OPENING": "Плеер открывается…",
"EXTERNAL_PLAYBACK_STARTED": "Плеер запущен",
"EXTERNAL_PLAYBACK_PLAYING": "Воспроизведение",
"EXTERNAL_PLAYBACK_FAILED": "Не удалось запустить плеер",
"EXTERNAL_PLAYBACK_CLOSED": "Закрыт",
"RAIL_ALL_CHANNELS": "Все каналы",
"RAIL_GROUPS": "Группы",
"RAIL_CONTEXT_ACTIONS": "Действия раздела",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "Bu hedef kullanılabilir şifreleme verileriyle uyumludur",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "Favoriler",
"RAIL_DOWNLOADS": "İndirilenler",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "Tüm kanallar",
"RAIL_GROUPS": "Gruplar",
"RAIL_CONTEXT_ACTIONS": "Bölüm işlemleri",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "此目标与可用的加密数据兼容",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "收藏",
"RAIL_DOWNLOADS": "下载",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "所有频道",
"RAIL_GROUPS": "分组",
"RAIL_CONTEXT_ACTIONS": "章节操作",
+16
View File
@@ -583,6 +583,16 @@
"REASON_COMPATIBLE_DRM_PATH": "此目標與可用的加密資料相容",
"ACTION_OPEN_MPV": "Open in MPV",
"ACTION_OPEN_VLC": "Open in VLC",
"ACTION_OPENING_MPV": "Opening MPV…",
"ACTION_OPENING_VLC": "Opening VLC…",
"ACTION_REOPEN_MPV": "Open MPV again",
"ACTION_REOPEN_VLC": "Open VLC again",
"ACTION_RETRY_MPV": "Try MPV again",
"ACTION_RETRY_VLC": "Try VLC again",
"EXTERNAL_OPENING": "Opening player…",
"EXTERNAL_STARTED": "Player started",
"EXTERNAL_PLAYING": "Playing",
"EXTERNAL_FAILED": "Player launch failed",
"ACTION_OPEN_MPV_HINT": "configured player",
"ACTION_OPEN_VLC_HINT": "configured player",
"ACTION_COPY_URL": "Copy URL",
@@ -1298,6 +1308,12 @@
"RAIL_FAVORITES": "我的最愛",
"RAIL_DOWNLOADS": "下載",
"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": "Playback failed",
"EXTERNAL_PLAYBACK_CLOSED": "Closed",
"RAIL_ALL_CHANNELS": "所有頻道",
"RAIL_GROUPS": "群組",
"RAIL_CONTEXT_ACTIONS": "區段操作",
+18 -6
View File
@@ -582,12 +582,13 @@ offer a same-family built-in alternative. Unknown source or engine-family facts
also suppress built-in recommendations.
The pure policy builds the following order, filters the current, unavailable,
incompatible, and already attempted targets, and then returns at most three
actions. It also projects attempted inline target IDs through the validated
incompatible, and already attempted inline targets, and then returns at most
three actions. It projects attempted inline target IDs through the validated
canonical source/target capability matrix and filters every engine family that
has already been attempted. HTML5 and ArtPlayer therefore cannot be offered as
separate hls.js recovery attempts. The first surviving action is primary and
later actions are secondary.
separate hls.js recovery attempts. External MPV/VLC attempts remain eligible;
the view reranks them by per-target attempt count instead of removing them. The
first surviving action is primary and later actions are secondary.
| Sanitized evidence | Candidate order |
| ----------------------------------------- | ------------------------------------------------------------ |
@@ -654,8 +655,19 @@ override and saved player setting. The new engine receives the latest finite
VOD position as a best-effort resume point; live playback starts at the live
edge. Retry reloads the active target without clearing attempts. Selecting MPV
or VLC records the external target before emitting the existing fallback
request. The system does not infer whether the external process ultimately
played the stream.
request. Both external actions remain mounted while their per-target state
moves through `launching`, `started`, `playing`, or `error`; an attempted idle
target becomes an explicit reopen action and a failed target becomes Try again.
Only an exact correlated Electron `playing` session update earns the Playing
label. A single external launch handshake owns the session: duplicate actions
are ignored, other external actions wait, and an existing live external session
must close before a different player can start.
The global external-playback dock uses the same Electron session status. It
shows Opening player with progress during launch, Player started for `opened`,
and Playing only for `playing`. Launch errors remain visible until dismissed;
the dock deliberately has no retry because it does not own the original headers
or credentials required to reconstruct a safe launch request.
No recommendation mutates `Settings.player` or another persisted setting.
Recovery recommendations never auto-switch a player or source and do not
@@ -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,39 +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 (showActionButton()) {
<button
type="button"
mat-button
class="external-playback-dock__button"
(click)="closeClicked.emit()"
[attr.aria-label]="actionLabelKey() | translate"
(click)="onActionClick()"
>
<mat-icon>stop_circle</mat-icon>
<mat-icon aria-hidden="true">{{ actionIcon() }}</mat-icon>
@if (!compact()) {
<span>{{
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSE' | translate
}}</span>
<span>{{ actionLabelKey() | translate }}</span>
}
</button>
}
@@ -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: 'Playback failed',
},
},
});
@@ -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,44 @@ 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('Playback failed');
expect(action.nativeElement.textContent).toContain('Dismiss');
expect(action.nativeElement.textContent).not.toContain('Retry');
expect(action.nativeElement.getAttribute('aria-label')).toBe('Dismiss');
action.nativeElement.click();
expect(dismissSpy).toHaveBeenCalledTimes(1);
});
it('disables the artwork button when the session has no playlist target', () => {
const artwork = fixture.debugElement.query(
By.css('.external-playback-dock__artwork')
@@ -35,6 +35,7 @@ export class ExternalPlaybackDockComponent {
readonly compact = input(false);
readonly closeClicked = output<void>();
readonly dismissClicked = output<void>();
readonly artworkClicked = output<void>();
private readonly artworkFailed = signal(false);
@@ -55,25 +56,27 @@ export class ExternalPlaybackDockComponent {
readonly artworkUrl = computed(
() => this.session().thumbnail?.trim() ?? ''
);
readonly statusLabel = computed(() => {
readonly statusLabelKey = computed(() => {
const session = this.session();
switch (session.status) {
case 'launching':
return 'Launching…';
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_OPENING';
case 'opened':
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_STARTED';
case 'playing':
return 'Opened';
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_PLAYING';
case 'error':
return session.error || 'Playback failed';
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_FAILED';
default:
return 'Closed';
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSED';
}
});
readonly statusIcon = computed(() => {
const status = this.session().status;
if (status === 'error') return 'error_outline';
if (status === 'playing') return 'play_circle';
return 'open_in_new';
});
@@ -101,8 +104,16 @@ export class ExternalPlaybackDockComponent {
readonly artworkInteractive = computed(
() => !!this.session().contentInfo?.playlistId
);
readonly showCloseButton = computed(
() => this.session().canClose && this.session().status !== 'error'
readonly showActionButton = computed(
() => this.session().status === 'error' || this.session().canClose
);
readonly actionLabelKey = computed(() =>
this.session().status === 'error'
? 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_DISMISS'
: 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSE'
);
readonly actionIcon = computed(() =>
this.session().status === 'error' ? 'close' : 'stop_circle'
);
constructor() {
@@ -120,4 +131,12 @@ export class ExternalPlaybackDockComponent {
if (!this.artworkInteractive()) return;
this.artworkClicked.emit();
}
onActionClick(): void {
if (this.session().status === 'error') {
this.dismissClicked.emit();
return;
}
this.closeClicked.emit();
}
}
@@ -101,12 +101,30 @@
[attr.data-test-id]="
getRecommendationTestId(recommendation)
"
[disabled]="pending()"
[class.web-player-diagnostic__player-card--disabled]="
pending()
"
[disabled]="
pending() &&
!isExternalPlayerRecommendation(recommendation)
"
[attr.aria-disabled]="pending() ? 'true' : null"
[attr.aria-busy]="
isExternalLaunching(recommendation) ? 'true' : null
"
(click)="activate(recommendation)"
>
<mat-icon aria-hidden="true">{{
getRecommendationIcon(recommendation)
}}</mat-icon>
@if (isExternalLaunching(recommendation)) {
<mat-spinner
aria-hidden="true"
diameter="18"
strokeWidth="2"
/>
} @else {
<mat-icon aria-hidden="true">{{
getRecommendationIcon(recommendation)
}}</mat-icon>
}
<span class="web-player-diagnostic__player-copy">
<span class="web-player-diagnostic__player-label">
{{
@@ -124,6 +142,17 @@
) | translate
}}
</span>
@if (
getExternalStatusKey(recommendation);
as statusKey
) {
<span
class="web-player-diagnostic__player-status"
aria-live="polite"
>
{{ statusKey | translate }}
</span>
}
@if (
isTemporaryBuiltInRecommendation(recommendation)
) {
@@ -221,12 +221,17 @@
transform: translateY(1px);
}
.web-player-diagnostic__player-card:disabled {
.web-player-diagnostic__player-card:disabled,
.web-player-diagnostic__player-card--disabled {
cursor: wait;
opacity: 0.56;
transform: none;
}
.web-player-diagnostic__player-card mat-spinner {
--mdc-circular-progress-active-indicator-color: currentColor;
}
.web-player-diagnostic__player-card:focus-visible,
.web-player-diagnostic__utility-button:focus-visible,
.web-player-diagnostic__details summary:focus-visible {
@@ -278,6 +283,24 @@
white-space: normal;
}
.web-player-diagnostic__player-status {
min-width: 0;
color: currentColor;
font-size: 0.78rem;
font-weight: 680;
line-height: 1.1;
opacity: 0.82;
overflow-wrap: anywhere;
}
@media (prefers-reduced-motion: reduce) {
.web-player-diagnostic__player-card,
.web-player-diagnostic__player-card mat-spinner {
transition: none;
animation: none;
}
}
.web-player-diagnostic__utilities {
display: flex;
flex-wrap: wrap;
@@ -16,6 +16,7 @@ import type {
VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { PlaybackDiagnosticPanelComponent } from './playback-diagnostic-panel.component';
import type { ExternalRecoveryStates } from '../web-player-view/external-playback-recovery';
const DIAGNOSTIC: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedContainer,
@@ -106,6 +107,16 @@ function source(index: number): VodSourceDescriptor {
};
}
function externalStates(
overrides: Partial<ExternalRecoveryStates> = {}
): ExternalRecoveryStates {
return {
mpv: { attempts: 0, sessionId: null, status: 'idle' },
vlc: { attempts: 0, sessionId: null, status: 'idle' },
...overrides,
};
}
describe('PlaybackDiagnosticPanelComponent', () => {
let fixture: ComponentFixture<PlaybackDiagnosticPanelComponent>;
let component: PlaybackDiagnosticPanelComponent;
@@ -131,6 +142,12 @@ describe('PlaybackDiagnosticPanelComponent', () => {
REASON_DIFFERENT_ENGINE_FAMILY:
'This player uses a different playback engine that may support the stream format more reliably.',
RECOMMENDATIONS_LABEL: 'Recommended recovery actions',
ACTION_OPEN_MPV: 'Open in MPV',
ACTION_OPEN_VLC: 'Open in VLC',
ACTION_OPENING_MPV: 'Opening MPV…',
ACTION_REOPEN_MPV: 'Open MPV again',
EXTERNAL_OPENING: 'Opening player…',
EXTERNAL_STARTED: 'Player started',
},
PORTALS: {
MULTI_SOURCE: {
@@ -148,6 +165,74 @@ describe('PlaybackDiagnosticPanelComponent', () => {
fixture.componentRef.setInput('playback', PLAYBACK);
fixture.componentRef.setInput('supportsManagedExternalPlayers', false);
fixture.componentRef.setInput('playbackExternallyTransferable', true);
fixture.componentRef.setInput('externalStates', externalStates());
});
it('keeps the focused external button mounted through launch and started states', () => {
const players: PlaybackRecommendationTarget[] = [];
component.playerRequested.subscribe((target) => players.push(target));
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'mpv',
priority: 'primary',
}),
recommendation('player', { target: 'vlc' }),
]);
fixture.detectChanges();
const mpv = fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"]'
) as HTMLButtonElement;
mpv.focus();
fixture.componentRef.setInput(
'externalStates',
externalStates({
mpv: { attempts: 1, sessionId: null, status: 'launching' },
})
);
fixture.componentRef.setInput('pending', true);
fixture.detectChanges();
const launchingMpv = fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"]'
) as HTMLButtonElement;
expect(launchingMpv).toBe(mpv);
expect(document.activeElement).toBe(mpv);
expect(mpv.disabled).toBe(false);
expect(mpv.getAttribute('aria-disabled')).toBe('true');
expect(mpv.getAttribute('aria-busy')).toBe('true');
expect(mpv.querySelector('mat-spinner')).not.toBeNull();
expect(mpv.textContent).toContain('Opening MPV');
expect(mpv.textContent).toContain('Opening player');
expect(
mpv
.querySelector('.web-player-diagnostic__player-status')
?.getAttribute('aria-live')
).toBe('polite');
mpv.click();
expect(players).toEqual([]);
fixture.componentRef.setInput(
'externalStates',
externalStates({
mpv: {
attempts: 1,
sessionId: 'mpv-session',
status: 'started',
},
})
);
fixture.componentRef.setInput('pending', false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"]'
)
).toBe(mpv);
expect(mpv.textContent).toContain('Open MPV again');
expect(mpv.textContent).toContain('Player started');
expect(mpv.getAttribute('aria-busy')).toBeNull();
});
it('applies the component host style from the actual panel stylesheet', () => {
@@ -503,10 +588,7 @@ describe('PlaybackDiagnosticPanelComponent', () => {
code: PlaybackDiagnosticCode.DrmOrEncryption,
});
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.componentRef.setInput(
'playbackExternallyTransferable',
false
);
fixture.componentRef.setInput('playbackExternallyTransferable', false);
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
@@ -7,6 +7,7 @@ import {
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import {
@@ -19,6 +20,10 @@ import type {
VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import type {
ExternalRecoveryStates,
ExternalRecoveryTargetState,
} from '../web-player-view/external-playback-recovery';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
@@ -29,11 +34,13 @@ import {
import {
getRecommendationIcon,
getRecommendationKey,
getRecommendationLabelKey,
getExternalRecommendationStatusKey,
getRecommendationLabelKey as resolveRecommendationLabelKey,
getRecommendationParams,
getRecommendationReasonKey,
getRecommendationTestId,
isExternalPlayerRecommendation,
isExternalRecommendationLaunching,
isTemporaryBuiltInRecommendation,
} from './playback-recommendation-view.util';
@@ -48,6 +55,7 @@ const ERROR_SCREEN_ALTERNATIVES = 5;
imports: [
ClipboardModule,
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
TranslateModule,
VodSourceRowComponent,
@@ -62,6 +70,7 @@ export class PlaybackDiagnosticPanelComponent {
readonly supportsManagedExternalPlayers = input.required<boolean>();
readonly playbackExternallyTransferable = input.required<boolean>();
readonly alternativeSources = input<readonly VodSourceDescriptor[]>([]);
readonly externalStates = input.required<ExternalRecoveryStates>();
readonly pending = input(false);
readonly retryRequested = output<void>();
@@ -97,11 +106,11 @@ export class PlaybackDiagnosticPanelComponent {
readonly getRecommendationKey = getRecommendationKey;
readonly getRecommendationTestId = getRecommendationTestId;
readonly getRecommendationIcon = getRecommendationIcon;
readonly getRecommendationLabelKey = getRecommendationLabelKey;
readonly getRecommendationParams = getRecommendationParams;
readonly getRecommendationReasonKey = getRecommendationReasonKey;
readonly isTemporaryBuiltInRecommendation =
isTemporaryBuiltInRecommendation;
readonly isExternalPlayerRecommendation = isExternalPlayerRecommendation;
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
return getDiagnosticDescriptionKey(
@@ -112,6 +121,9 @@ export class PlaybackDiagnosticPanelComponent {
}
activate(recommendation: PlaybackRecommendation): void {
if (this.pending()) {
return;
}
switch (recommendation.action) {
case 'retry':
this.retryRequested.emit();
@@ -123,4 +135,35 @@ export class PlaybackDiagnosticPanelComponent {
return;
}
}
getRecommendationLabelKey(recommendation: PlaybackRecommendation): string {
return resolveRecommendationLabelKey(
recommendation,
this.getExternalState(recommendation)
);
}
getExternalStatusKey(
recommendation: PlaybackRecommendation
): string | null {
return getExternalRecommendationStatusKey(
recommendation,
this.getExternalState(recommendation)
);
}
isExternalLaunching(recommendation: PlaybackRecommendation): boolean {
return isExternalRecommendationLaunching(
recommendation,
this.getExternalState(recommendation)
);
}
private getExternalState(
recommendation: PlaybackRecommendation
): ExternalRecoveryTargetState | undefined {
return isExternalPlayerRecommendation(recommendation)
? this.externalStates()[recommendation.target]
: undefined;
}
}
@@ -11,8 +11,11 @@ import {
getRecommendationParams,
getRecommendationReasonKey,
getRecommendationTestId,
getExternalRecommendationStatusKey,
isExternalRecommendationLaunching,
isTemporaryBuiltInRecommendation,
} from './playback-recommendation-view.util';
import type { ExternalRecoveryTargetState } from '../web-player-view/external-playback-recovery';
function player(
target: PlaybackRecommendationTarget,
@@ -43,6 +46,73 @@ function alternative(): PlaybackRecommendation {
}
describe('playback recommendation view mappings', () => {
it.each([
[
'mpv',
{ attempts: 1, sessionId: null, status: 'launching' },
'PLAYBACK_DIAGNOSTICS.ACTION_OPENING_MPV',
'PLAYBACK_DIAGNOSTICS.EXTERNAL_OPENING',
],
[
'vlc',
{ attempts: 1, sessionId: 'vlc-1', status: 'started' },
'PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_VLC',
'PLAYBACK_DIAGNOSTICS.EXTERNAL_STARTED',
],
[
'mpv',
{ attempts: 1, sessionId: 'mpv-1', status: 'playing' },
'PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_MPV',
'PLAYBACK_DIAGNOSTICS.EXTERNAL_PLAYING',
],
[
'vlc',
{ attempts: 2, sessionId: null, status: 'error' },
'PLAYBACK_DIAGNOSTICS.ACTION_RETRY_VLC',
'PLAYBACK_DIAGNOSTICS.EXTERNAL_FAILED',
],
] as const)(
'maps %s %s state to exact action and status copy',
(target, state, actionKey, statusKey) => {
const recommendation = player(target);
expect(
getRecommendationLabelKey(
recommendation,
state as ExternalRecoveryTargetState
)
).toBe(actionKey);
expect(
getExternalRecommendationStatusKey(
recommendation,
state as ExternalRecoveryTargetState
)
).toBe(statusKey);
expect(
isExternalRecommendationLaunching(
recommendation,
state as ExternalRecoveryTargetState
)
).toBe(state.status === 'launching');
}
);
it('keeps an attempted closed target actionable without a stale status', () => {
const recommendation = player('mpv');
const state: ExternalRecoveryTargetState = {
attempts: 1,
sessionId: null,
status: 'idle',
};
expect(getRecommendationLabelKey(recommendation, state)).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_MPV'
);
expect(
getExternalRecommendationStatusKey(recommendation, state)
).toBeNull();
});
it.each([
['videojs', 'playback-recommendation-videojs'],
['html5', 'playback-recommendation-html5'],
@@ -3,6 +3,8 @@ import {
type PlaybackRecommendationReason,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
import type { ExternalRecoveryTargetState } from '../web-player-view/external-playback-recovery';
export function getRecommendationKey(
recommendation: PlaybackRecommendation
@@ -26,7 +28,8 @@ export function getRecommendationTestId(
}
export function getRecommendationLabelKey(
recommendation: PlaybackRecommendation
recommendation: PlaybackRecommendation,
externalState?: ExternalRecoveryTargetState
): string {
switch (recommendation.action) {
case 'retry':
@@ -34,10 +37,41 @@ export function getRecommendationLabelKey(
case 'alternative-source':
return 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE';
case 'player':
return getPlayerLabelKey(recommendation.target);
return getPlayerLabelKey(recommendation.target, externalState);
}
}
export function getExternalRecommendationStatusKey(
recommendation: PlaybackRecommendation,
state?: ExternalRecoveryTargetState
): string | null {
if (!isExternalPlayerRecommendation(recommendation) || !state) {
return null;
}
switch (state.status) {
case 'launching':
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_OPENING';
case 'started':
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_STARTED';
case 'playing':
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_PLAYING';
case 'error':
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_FAILED';
case 'idle':
return null;
}
}
export function isExternalRecommendationLaunching(
recommendation: PlaybackRecommendation,
state?: ExternalRecoveryTargetState
): boolean {
return (
isExternalPlayerRecommendation(recommendation) &&
state?.status === 'launching'
);
}
export function getRecommendationParams(
recommendation: PlaybackRecommendation
): Readonly<Record<string, string>> {
@@ -93,7 +127,10 @@ export function isTemporaryBuiltInRecommendation(
export function isExternalPlayerRecommendation(
recommendation: PlaybackRecommendation
): boolean {
): recommendation is Extract<
PlaybackRecommendation,
{ readonly action: 'player' }
> & { readonly target: ExternalPlayerName } {
return (
recommendation.action === 'player' &&
isExternalTarget(recommendation.target)
@@ -115,19 +152,38 @@ function getPlayerTestId(target: PlaybackRecommendationTarget): string {
}
}
function getPlayerLabelKey(target: PlaybackRecommendationTarget): string {
function getPlayerLabelKey(
target: PlaybackRecommendationTarget,
state?: ExternalRecoveryTargetState
): string {
switch (target) {
case 'videojs':
case 'html5':
case 'artplayer':
return 'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER';
case 'mpv':
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV';
return getExternalPlayerLabelKey('MPV', state);
case 'vlc':
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC';
return getExternalPlayerLabelKey('VLC', state);
}
}
function getExternalPlayerLabelKey(
player: 'MPV' | 'VLC',
state?: ExternalRecoveryTargetState
): string {
if (state?.status === 'launching') {
return `PLAYBACK_DIAGNOSTICS.ACTION_OPENING_${player}`;
}
if (state?.status === 'error') {
return `PLAYBACK_DIAGNOSTICS.ACTION_RETRY_${player}`;
}
if (state && state.attempts > 0) {
return `PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_${player}`;
}
return `PLAYBACK_DIAGNOSTICS.ACTION_OPEN_${player}`;
}
function getPlayerName(target: PlaybackRecommendationTarget): string {
switch (target) {
case 'videojs':
@@ -143,6 +199,8 @@ function getPlayerName(target: PlaybackRecommendationTarget): string {
}
}
function isExternalTarget(target: PlaybackRecommendationTarget): boolean {
function isExternalTarget(
target: PlaybackRecommendationTarget
): target is ExternalPlayerName {
return target === 'mpv' || target === 'vlc';
}
@@ -0,0 +1,54 @@
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', () => {
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();
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();
});
});
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,91 @@
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';
/**
* 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: () => void
): void {
const activeSession = this.externalPlayback?.activeSession() ?? null;
const intent = this.recovery.begin(target, activeSession?.id ?? null);
if (!intent) {
return;
}
onBegin();
if (!isLiveExternalSession(activeSession)) {
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: () => void
): Promise<void> {
try {
await this.externalPlayback?.closeSession(session);
} catch {
this.recovery.fail(intent);
return;
}
const current = this.externalPlayback?.activeSession() ?? null;
if (isLiveExternalSession(current)) {
this.recovery.fail(intent);
return;
}
if (this.recovery.owns(intent)) {
onReady();
}
}
}
function isLiveExternalSession(
session: ExternalPlayerSession | null
): session is ExternalPlayerSession {
return (
session?.status === 'launching' ||
session?.status === 'opened' ||
session?.status === 'playing'
);
}
@@ -0,0 +1,240 @@
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', 'old-session');
expect(intent).not.toBeNull();
expect(recovery.pending()).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
sessionId: null,
status: 'launching',
});
expect(recovery.begin('vlc', 'old-session')).toBeNull();
recovery.destroy();
});
it('correlates only the next matching target and then requires its exact id', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv', 'old-session');
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(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');
recovery.begin('mpv', null);
recovery.observe(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');
recovery.begin('mpv', null);
recovery.observe(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', null);
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('invalidates an old intent and timer when the content session changes', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = recovery.begin('mpv', null);
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', null);
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', null));
expect(recovery.fail(first)).toBe(true);
expect(recovery.fail(first)).toBe(false);
const second = requireIntent(recovery.begin('mpv', null));
expect(recovery.target('mpv')).toEqual({
attempts: 2,
sessionId: null,
status: 'launching',
});
expect(recovery.owns(second)).toBe(true);
recovery.destroy();
});
it('updates the previous exact session without cancelling a different target intent', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv', null);
recovery.observe(session({ id: 'mpv-session', status: 'opened' }));
const vlcIntent = requireIntent(recovery.begin('vlc', 'mpv-session'));
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('stores no stream URL, headers, credentials, title, or error text', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv', null);
recovery.observe(
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,214 @@
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 ignoredSessionId: string | null;
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,
previousSessionId: string | null
): ExternalRecoveryIntent | null {
if (this.activeIntentState() !== null) {
return null;
}
const token = Symbol();
const intent: ActiveIntent = Object.freeze({
ignoredSessionId: previousSessionId,
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) {
if (
session.player !== active.target ||
session.id === active.ignoredSessionId
) {
return false;
}
this.activeIntentState.set(
Object.freeze({ ...active, sessionId: session.id })
);
} 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;
}
fail(intent: ExternalRecoveryIntent): boolean {
if (!this.owns(intent)) {
return false;
}
const current = this.target(intent.target);
this.setTarget(intent.target, {
attempts: current.attempts,
sessionId: null,
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,123 @@
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('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);
});
});
@@ -9,14 +9,17 @@ import {
} 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 +29,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 recommendations = recommendPlaybackRecovery({
diagnostic: options.diagnostic,
activeTarget: options.binding.target,
attemptedTargets: options.attemptedTargets,
attemptedTargets: attemptedInlineTargets,
targetCapabilities: createPlaybackTargetCapabilities({
sourceKind,
managedExternalPlayersAvailable:
@@ -45,6 +53,50 @@ export function createWebPlayerRecommendations(options: {
},
alternativeSourceCount: options.alternativeSourceCount,
});
return rerankExternalRecommendations(
recommendations,
options.externalStates
);
}
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',
};
});
}
export function isPlaybackExternallyTransferable(
@@ -0,0 +1,369 @@
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,
} 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: unknown[] = [];
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);
activeSession.set(
externalSession({ id: 'mpv-session', status: 'opened' })
);
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: Array<{ player: string }> = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
click('playback-fallback-mpv');
activeSession.set(
externalSession({ id: 'mpv-session', status: 'opened' })
);
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('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: unknown[] = [];
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,
},
]);
}
);
it.each([
{ target: 'mpv' as const, testId: 'playback-fallback-mpv' },
{ target: 'vlc' as const, testId: 'playback-fallback-vlc' },
])(
'rejects a stale $target activation after playback becomes DRM protected',
async ({ testId }) => {
const requests: unknown[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
const staleButton = requiredButton(testId);
setPlayback({
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: {
'00112233445566778899aabbccddeeff':
'ffeeddccbbaa99887766554433221100',
},
},
});
staleButton.click();
expect(requests).toEqual([]);
}
);
async function showDiagnostic(): Promise<PlaybackDiagnostic> {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const issue = mediaIssue();
vjs().playbackIssue.emit(issue);
fixture.detectChanges();
return issue;
}
function setPlayback(overrides: Partial<ResolvedPortalPlayback>): void {
fixture.componentRef.setInput('playback', {
streamUrl: 'https://example.com/live.m3u8',
title: 'Recovery stream',
isLive: false,
...overrides,
});
}
function vjs(): StubVjsPlayerComponent {
return fixture.debugElement.query(By.directive(StubVjsPlayerComponent))
.componentInstance as StubVjsPlayerComponent;
}
function click(testId: string): void {
requiredButton(testId).click();
}
function requiredButton(testId: string): HTMLButtonElement {
const button = fixture.nativeElement.querySelector(
`[data-test-id="${testId}"]`
);
expect(button).toBeInstanceOf(HTMLButtonElement);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`Expected ${testId} button`);
}
return button;
}
function playerActionIds(): string[] {
return Array.from(
fixture.nativeElement.querySelectorAll(
'.web-player-diagnostic__player-card'
) as NodeListOf<HTMLElement>
).map((element) => element.dataset['testId'] ?? '');
}
});
function externalSession(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
id: 'external-session',
player: 'mpv',
status: 'opened',
title: 'Recovery stream',
streamUrl: 'https://example.com/live.m3u8',
startedAt: '2026-08-08T10:00:00.000Z',
updatedAt: '2026-08-08T10:00:01.000Z',
canClose: true,
...overrides,
};
}
function mediaIssue(): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.MediaDecodeError,
source: PlaybackDiagnosticSource.Vhs,
sourceUrl: 'https://example.com/live.m3u8',
container: 'm3u8',
mimeType: 'application/x-mpegURL',
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
};
}
@@ -103,6 +103,7 @@
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[playbackExternallyTransferable]="playbackExternallyTransferable()"
[alternativeSources]="alternativeSources()"
[externalStates]="externalRecoveryState()"
[pending]="recoveryPending()"
(retryRequested)="retryPlayback()"
(playerRequested)="requestRecommendedPlayer($event)"
@@ -4,6 +4,7 @@ import {
DeferBlockBehavior,
TestBed,
} from '@angular/core/testing';
import { signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
@@ -18,10 +19,12 @@ import {
} 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 +53,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 +80,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 +111,15 @@ describe('WebPlayerViewComponent recovery integration', () => {
webPlayerSharedControls: () => false,
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: activeExternalSession,
visibleSession: activeExternalSession,
dismissActiveSession: jest.fn(),
closeSession: closeExternalSession,
},
},
],
})
.overrideComponent(WebPlayerViewComponent, {
@@ -184,6 +209,10 @@ describe('WebPlayerViewComponent recovery integration', () => {
fixture.detectChanges();
click('playback-fallback-mpv');
expect(fallbackRequests).toHaveLength(1);
activeExternalSession.set(
externalSession({ id: 'mpv-retry-session', status: 'opened' })
);
fixture.detectChanges();
vjs().playbackIssue.emit(networkIssue('videojs'));
fixture.detectChanges();
@@ -200,22 +229,34 @@ 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 () => {
await render();
vjs().playbackIssue.emit(browserAccessIssue('videojs'));
fixture.detectChanges();
click('playback-fallback-mpv');
activeExternalSession.set(
externalSession({ id: 'mpv-browser-session', status: 'opened' })
);
fixture.detectChanges();
click('playback-fallback-vlc');
await fixture.whenStable();
activeExternalSession.set(
externalSession({
id: 'vlc-browser-session',
player: 'vlc',
status: 'opened',
})
);
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 +264,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 +287,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
expect(playerActionIds()).toEqual([
'playback-recommendation-html5',
'playback-fallback-vlc',
'playback-fallback-mpv',
]);
});
@@ -1079,66 +1124,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 +1266,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 {
@@ -66,14 +71,6 @@ function resolveWebPlayerSharedControls(): boolean {
: WEB_PLAYER_SHARED_CONTROLS_ENABLED;
}
interface PlaybackApplicationOwnership {
readonly binding: PlaybackBinding | null;
readonly embeddedMpv: boolean;
readonly isLive: boolean;
readonly sourceRevision: WebPlayerSourceRevisionToken;
readonly token: WebPlayerApplicationToken;
}
@Component({
selector: 'app-web-player-view',
templateUrl: './web-player-view.component.html',
@@ -98,7 +95,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 +139,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 +224,7 @@ export class WebPlayerViewComponent implements OnDestroy {
? binding
: null,
attemptedTargets: this.recoverySession.attemptedTargets(),
externalStates: this.externalRecoveryState(),
managedExternalPlayersAvailable:
this.runtime.supportsManagedExternalPlayers,
playbackExternallyTransferable:
@@ -245,6 +255,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 +301,7 @@ export class WebPlayerViewComponent implements OnDestroy {
}
ngOnDestroy(): void {
this.externalRecovery.destroy();
this.applicationHandoff.destroy();
}
@@ -328,7 +343,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 +380,18 @@ 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),
() => {
if (this.visiblePlaybackDiagnostic() !== diagnostic) return;
this.externalFallbackRequested.emit({
player: target,
playback: this.resolvedPlayback(),
diagnostic,
});
}
);
return;
}
this.recoverySession.beginPlayerSwitch(target, this.resolvedIsLive());
@@ -371,6 +406,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 +416,4 @@ export class WebPlayerViewComponent implements OnDestroy {
this.playbackDiagnosticOwnerToken.set(null);
this.playbackDiagnostic.set(null);
}
private ownsPlaybackApplication(
ownership: PlaybackApplicationOwnership
): boolean {
if (
ownership.token !== this.playbackApplicationToken() ||
ownership.sourceRevision !== this.playbackSourceRevisionToken()
) {
return false;
}
if (ownership.binding) {
return (
!ownership.embeddedMpv &&
this.applicationHandoff.owns(ownership.binding, ownership.token)
);
}
return (
ownership.embeddedMpv &&
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
);
}
}
@@ -271,6 +271,7 @@ describe('WorkspaceShellFacade', () => {
activeSession: signal(null),
visibleSession: signal(null),
closeSession: jest.fn(),
dismissActiveSession: jest.fn(),
},
},
{
@@ -1075,6 +1076,15 @@ describe('WorkspaceShellFacade', () => {
expect(recentCommands.record).toHaveBeenCalledWith('open-settings');
});
it('delegates failed external-session dismissal without closing a player', () => {
const externalPlayback = TestBed.inject(PORTAL_EXTERNAL_PLAYBACK);
facade.dismissActiveExternalSession();
expect(externalPlayback.dismissActiveSession).toHaveBeenCalledTimes(1);
expect(externalPlayback.closeSession).not.toHaveBeenCalled();
});
it('waits for embedded MPV support preload before opening the palette', async () => {
const dialog = TestBed.inject(MatDialog) as unknown as {
open: jest.Mock;
@@ -52,9 +52,12 @@ export class WorkspaceShellFacade {
);
// Root-provided; optional keeps standalone unit tests light. While the phone context drawer is modal, opening the command
// palette over it would stack two competing focus-trapped surfaces.
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
optional: true,
});
private readonly contextDrawer = inject(
WorkspaceShellContextDrawerService,
{
optional: true,
}
);
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
if (!(event.ctrlKey || event.metaKey)) {
return;
@@ -158,6 +161,10 @@ export class WorkspaceShellFacade {
);
}
dismissActiveExternalSession(): void {
this.externalPlayback.dismissActiveSession();
}
openActiveExternalSessionTarget(): void {
const playlistId =
this.externalPlaybackSession()?.contentInfo?.playlistId;
@@ -128,6 +128,7 @@
<app-external-playback-dock
[session]="session"
(closeClicked)="facade.closeActiveExternalSession()"
(dismissClicked)="facade.dismissActiveExternalSession()"
(artworkClicked)="facade.openActiveExternalSessionTarget()"
/>
</footer>
@@ -102,6 +102,7 @@ class MockWorkspaceShellContextSidebarComponent {
class MockExternalPlaybackDockComponent {
readonly session = input<unknown>(null);
readonly closeClicked = output<void>();
readonly dismissClicked = output<void>();
}
@Component({
@@ -210,6 +211,7 @@ class MockWorkspaceShellFacade {
openAccountInfo = jest.fn();
openAccountInfoFor = jest.fn();
closeActiveExternalSession = jest.fn();
dismissActiveExternalSession = jest.fn();
cancelXtreamImport = jest.fn();
}
@@ -294,6 +296,11 @@ describe('WorkspaceShellComponent', () => {
expect(
fixture.nativeElement.querySelector('app-external-playback-dock')
).not.toBeNull();
const externalDock = fixture.debugElement.query(
By.directive(MockExternalPlaybackDockComponent)
).componentInstance as MockExternalPlaybackDockComponent;
externalDock.dismissClicked.emit();
expect(facade.dismissActiveExternalSession).toHaveBeenCalledTimes(1);
});
it('renders the xtream import overlay child only when the facade flag is true', async () => {