mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
fix(playback): clarify external launch feedback
This commit is contained in:
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "إجراءات القسم",
|
||||
|
||||
@@ -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": "إجراءات القسم",
|
||||
|
||||
@@ -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": "Дзеянні раздзела",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ενέργειες ενότητας",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "セクションのアクション",
|
||||
|
||||
@@ -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": "섹션 작업",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Действия раздела",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "章节操作",
|
||||
|
||||
@@ -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": "區段操作",
|
||||
|
||||
@@ -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
|
||||
|
||||
+28
-18
@@ -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>
|
||||
}
|
||||
|
||||
+54
-1
@@ -17,6 +17,11 @@ class FakeTranslateLoader implements TranslateLoader {
|
||||
WORKSPACE: {
|
||||
SHELL: {
|
||||
EXTERNAL_PLAYBACK_CLOSE: 'Close player',
|
||||
EXTERNAL_PLAYBACK_DISMISS: 'Dismiss',
|
||||
EXTERNAL_PLAYBACK_OPENING: 'Opening player…',
|
||||
EXTERNAL_PLAYBACK_STARTED: 'Player started',
|
||||
EXTERNAL_PLAYBACK_PLAYING: 'Playing',
|
||||
EXTERNAL_PLAYBACK_FAILED: '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')
|
||||
|
||||
+26
-7
@@ -35,6 +35,7 @@ export class ExternalPlaybackDockComponent {
|
||||
readonly compact = input(false);
|
||||
|
||||
readonly closeClicked = output<void>();
|
||||
readonly dismissClicked = output<void>();
|
||||
readonly artworkClicked = output<void>();
|
||||
private readonly artworkFailed = signal(false);
|
||||
|
||||
@@ -55,25 +56,27 @@ export class ExternalPlaybackDockComponent {
|
||||
readonly artworkUrl = computed(
|
||||
() => this.session().thumbnail?.trim() ?? ''
|
||||
);
|
||||
readonly statusLabel = computed(() => {
|
||||
readonly statusLabelKey = computed(() => {
|
||||
const session = this.session();
|
||||
|
||||
switch (session.status) {
|
||||
case 'launching':
|
||||
return 'Launching…';
|
||||
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_OPENING';
|
||||
case 'opened':
|
||||
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_STARTED';
|
||||
case 'playing':
|
||||
return 'Opened';
|
||||
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_PLAYING';
|
||||
case 'error':
|
||||
return session.error || 'Playback failed';
|
||||
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_FAILED';
|
||||
default:
|
||||
return 'Closed';
|
||||
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSED';
|
||||
}
|
||||
});
|
||||
|
||||
readonly statusIcon = computed(() => {
|
||||
const status = this.session().status;
|
||||
if (status === 'error') return 'error_outline';
|
||||
if (status === 'playing') return 'play_circle';
|
||||
return 'open_in_new';
|
||||
});
|
||||
|
||||
@@ -101,8 +104,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();
|
||||
}
|
||||
}
|
||||
+33
-4
@@ -101,12 +101,30 @@
|
||||
[attr.data-test-id]="
|
||||
getRecommendationTestId(recommendation)
|
||||
"
|
||||
[disabled]="pending()"
|
||||
[class.web-player-diagnostic__player-card--disabled]="
|
||||
pending()
|
||||
"
|
||||
[disabled]="
|
||||
pending() &&
|
||||
!isExternalPlayerRecommendation(recommendation)
|
||||
"
|
||||
[attr.aria-disabled]="pending() ? 'true' : null"
|
||||
[attr.aria-busy]="
|
||||
isExternalLaunching(recommendation) ? 'true' : null
|
||||
"
|
||||
(click)="activate(recommendation)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
getRecommendationIcon(recommendation)
|
||||
}}</mat-icon>
|
||||
@if (isExternalLaunching(recommendation)) {
|
||||
<mat-spinner
|
||||
aria-hidden="true"
|
||||
diameter="18"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">{{
|
||||
getRecommendationIcon(recommendation)
|
||||
}}</mat-icon>
|
||||
}
|
||||
<span class="web-player-diagnostic__player-copy">
|
||||
<span class="web-player-diagnostic__player-label">
|
||||
{{
|
||||
@@ -124,6 +142,17 @@
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
@if (
|
||||
getExternalStatusKey(recommendation);
|
||||
as statusKey
|
||||
) {
|
||||
<span
|
||||
class="web-player-diagnostic__player-status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ statusKey | translate }}
|
||||
</span>
|
||||
}
|
||||
@if (
|
||||
isTemporaryBuiltInRecommendation(recommendation)
|
||||
) {
|
||||
|
||||
+24
-1
@@ -221,12 +221,17 @@
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card:disabled {
|
||||
.web-player-diagnostic__player-card:disabled,
|
||||
.web-player-diagnostic__player-card--disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.56;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card mat-spinner {
|
||||
--mdc-circular-progress-active-indicator-color: currentColor;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card:focus-visible,
|
||||
.web-player-diagnostic__utility-button:focus-visible,
|
||||
.web-player-diagnostic__details summary:focus-visible {
|
||||
@@ -278,6 +283,24 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-status {
|
||||
min-width: 0;
|
||||
color: currentColor;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 680;
|
||||
line-height: 1.1;
|
||||
opacity: 0.82;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.web-player-diagnostic__player-card,
|
||||
.web-player-diagnostic__player-card mat-spinner {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
+86
-4
@@ -16,6 +16,7 @@ import type {
|
||||
VodSourceDescriptor,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PlaybackDiagnosticPanelComponent } from './playback-diagnostic-panel.component';
|
||||
import type { ExternalRecoveryStates } from '../web-player-view/external-playback-recovery';
|
||||
|
||||
const DIAGNOSTIC: PlaybackDiagnostic = {
|
||||
code: PlaybackDiagnosticCode.UnsupportedContainer,
|
||||
@@ -106,6 +107,16 @@ function source(index: number): VodSourceDescriptor {
|
||||
};
|
||||
}
|
||||
|
||||
function externalStates(
|
||||
overrides: Partial<ExternalRecoveryStates> = {}
|
||||
): ExternalRecoveryStates {
|
||||
return {
|
||||
mpv: { attempts: 0, sessionId: null, status: 'idle' },
|
||||
vlc: { attempts: 0, sessionId: null, status: 'idle' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlaybackDiagnosticPanelComponent', () => {
|
||||
let fixture: ComponentFixture<PlaybackDiagnosticPanelComponent>;
|
||||
let component: PlaybackDiagnosticPanelComponent;
|
||||
@@ -131,6 +142,12 @@ describe('PlaybackDiagnosticPanelComponent', () => {
|
||||
REASON_DIFFERENT_ENGINE_FAMILY:
|
||||
'This player uses a different playback engine that may support the stream format more reliably.',
|
||||
RECOMMENDATIONS_LABEL: 'Recommended recovery actions',
|
||||
ACTION_OPEN_MPV: 'Open in MPV',
|
||||
ACTION_OPEN_VLC: 'Open in VLC',
|
||||
ACTION_OPENING_MPV: 'Opening MPV…',
|
||||
ACTION_REOPEN_MPV: 'Open MPV again',
|
||||
EXTERNAL_OPENING: 'Opening player…',
|
||||
EXTERNAL_STARTED: 'Player started',
|
||||
},
|
||||
PORTALS: {
|
||||
MULTI_SOURCE: {
|
||||
@@ -148,6 +165,74 @@ describe('PlaybackDiagnosticPanelComponent', () => {
|
||||
fixture.componentRef.setInput('playback', PLAYBACK);
|
||||
fixture.componentRef.setInput('supportsManagedExternalPlayers', false);
|
||||
fixture.componentRef.setInput('playbackExternallyTransferable', true);
|
||||
fixture.componentRef.setInput('externalStates', externalStates());
|
||||
});
|
||||
|
||||
it('keeps the focused external button mounted through launch and started states', () => {
|
||||
const players: PlaybackRecommendationTarget[] = [];
|
||||
component.playerRequested.subscribe((target) => players.push(target));
|
||||
fixture.componentRef.setInput('recommendations', [
|
||||
recommendation('player', {
|
||||
target: 'mpv',
|
||||
priority: 'primary',
|
||||
}),
|
||||
recommendation('player', { target: 'vlc' }),
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
const mpv = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="playback-fallback-mpv"]'
|
||||
) as HTMLButtonElement;
|
||||
mpv.focus();
|
||||
|
||||
fixture.componentRef.setInput(
|
||||
'externalStates',
|
||||
externalStates({
|
||||
mpv: { attempts: 1, sessionId: null, status: 'launching' },
|
||||
})
|
||||
);
|
||||
fixture.componentRef.setInput('pending', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const launchingMpv = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="playback-fallback-mpv"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(launchingMpv).toBe(mpv);
|
||||
expect(document.activeElement).toBe(mpv);
|
||||
expect(mpv.disabled).toBe(false);
|
||||
expect(mpv.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(mpv.getAttribute('aria-busy')).toBe('true');
|
||||
expect(mpv.querySelector('mat-spinner')).not.toBeNull();
|
||||
expect(mpv.textContent).toContain('Opening MPV');
|
||||
expect(mpv.textContent).toContain('Opening player');
|
||||
expect(
|
||||
mpv
|
||||
.querySelector('.web-player-diagnostic__player-status')
|
||||
?.getAttribute('aria-live')
|
||||
).toBe('polite');
|
||||
mpv.click();
|
||||
expect(players).toEqual([]);
|
||||
|
||||
fixture.componentRef.setInput(
|
||||
'externalStates',
|
||||
externalStates({
|
||||
mpv: {
|
||||
attempts: 1,
|
||||
sessionId: 'mpv-session',
|
||||
status: 'started',
|
||||
},
|
||||
})
|
||||
);
|
||||
fixture.componentRef.setInput('pending', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-test-id="playback-fallback-mpv"]'
|
||||
)
|
||||
).toBe(mpv);
|
||||
expect(mpv.textContent).toContain('Open MPV again');
|
||||
expect(mpv.textContent).toContain('Player started');
|
||||
expect(mpv.getAttribute('aria-busy')).toBeNull();
|
||||
});
|
||||
|
||||
it('applies the component host style from the actual panel stylesheet', () => {
|
||||
@@ -503,10 +588,7 @@ describe('PlaybackDiagnosticPanelComponent', () => {
|
||||
code: PlaybackDiagnosticCode.DrmOrEncryption,
|
||||
});
|
||||
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
|
||||
fixture.componentRef.setInput(
|
||||
'playbackExternallyTransferable',
|
||||
false
|
||||
);
|
||||
fixture.componentRef.setInput('playbackExternallyTransferable', false);
|
||||
fixture.componentRef.setInput('recommendations', [
|
||||
recommendation('alternative-source', { priority: 'primary' }),
|
||||
]);
|
||||
|
||||
+45
-2
@@ -7,6 +7,7 @@ import {
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
@@ -19,6 +20,10 @@ import type {
|
||||
VodSourceDescriptor,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { VodSourceRowComponent } from '@iptvnator/ui/components';
|
||||
import type {
|
||||
ExternalRecoveryStates,
|
||||
ExternalRecoveryTargetState,
|
||||
} from '../web-player-view/external-playback-recovery';
|
||||
import {
|
||||
getDiagnosticCodecHint,
|
||||
getDiagnosticDescriptionKey,
|
||||
@@ -29,11 +34,13 @@ import {
|
||||
import {
|
||||
getRecommendationIcon,
|
||||
getRecommendationKey,
|
||||
getRecommendationLabelKey,
|
||||
getExternalRecommendationStatusKey,
|
||||
getRecommendationLabelKey as resolveRecommendationLabelKey,
|
||||
getRecommendationParams,
|
||||
getRecommendationReasonKey,
|
||||
getRecommendationTestId,
|
||||
isExternalPlayerRecommendation,
|
||||
isExternalRecommendationLaunching,
|
||||
isTemporaryBuiltInRecommendation,
|
||||
} from './playback-recommendation-view.util';
|
||||
|
||||
@@ -48,6 +55,7 @@ const ERROR_SCREEN_ALTERNATIVES = 5;
|
||||
imports: [
|
||||
ClipboardModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
TranslateModule,
|
||||
VodSourceRowComponent,
|
||||
@@ -62,6 +70,7 @@ export class PlaybackDiagnosticPanelComponent {
|
||||
readonly supportsManagedExternalPlayers = input.required<boolean>();
|
||||
readonly playbackExternallyTransferable = input.required<boolean>();
|
||||
readonly alternativeSources = input<readonly VodSourceDescriptor[]>([]);
|
||||
readonly externalStates = input.required<ExternalRecoveryStates>();
|
||||
readonly pending = input(false);
|
||||
|
||||
readonly retryRequested = output<void>();
|
||||
@@ -97,11 +106,11 @@ export class PlaybackDiagnosticPanelComponent {
|
||||
readonly getRecommendationKey = getRecommendationKey;
|
||||
readonly getRecommendationTestId = getRecommendationTestId;
|
||||
readonly getRecommendationIcon = getRecommendationIcon;
|
||||
readonly getRecommendationLabelKey = getRecommendationLabelKey;
|
||||
readonly getRecommendationParams = getRecommendationParams;
|
||||
readonly getRecommendationReasonKey = getRecommendationReasonKey;
|
||||
readonly isTemporaryBuiltInRecommendation =
|
||||
isTemporaryBuiltInRecommendation;
|
||||
readonly isExternalPlayerRecommendation = isExternalPlayerRecommendation;
|
||||
|
||||
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
|
||||
return getDiagnosticDescriptionKey(
|
||||
@@ -112,6 +121,9 @@ export class PlaybackDiagnosticPanelComponent {
|
||||
}
|
||||
|
||||
activate(recommendation: PlaybackRecommendation): void {
|
||||
if (this.pending()) {
|
||||
return;
|
||||
}
|
||||
switch (recommendation.action) {
|
||||
case 'retry':
|
||||
this.retryRequested.emit();
|
||||
@@ -123,4 +135,35 @@ export class PlaybackDiagnosticPanelComponent {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getRecommendationLabelKey(recommendation: PlaybackRecommendation): string {
|
||||
return resolveRecommendationLabelKey(
|
||||
recommendation,
|
||||
this.getExternalState(recommendation)
|
||||
);
|
||||
}
|
||||
|
||||
getExternalStatusKey(
|
||||
recommendation: PlaybackRecommendation
|
||||
): string | null {
|
||||
return getExternalRecommendationStatusKey(
|
||||
recommendation,
|
||||
this.getExternalState(recommendation)
|
||||
);
|
||||
}
|
||||
|
||||
isExternalLaunching(recommendation: PlaybackRecommendation): boolean {
|
||||
return isExternalRecommendationLaunching(
|
||||
recommendation,
|
||||
this.getExternalState(recommendation)
|
||||
);
|
||||
}
|
||||
|
||||
private getExternalState(
|
||||
recommendation: PlaybackRecommendation
|
||||
): ExternalRecoveryTargetState | undefined {
|
||||
return isExternalPlayerRecommendation(recommendation)
|
||||
? this.externalStates()[recommendation.target]
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
+70
@@ -11,8 +11,11 @@ import {
|
||||
getRecommendationParams,
|
||||
getRecommendationReasonKey,
|
||||
getRecommendationTestId,
|
||||
getExternalRecommendationStatusKey,
|
||||
isExternalRecommendationLaunching,
|
||||
isTemporaryBuiltInRecommendation,
|
||||
} from './playback-recommendation-view.util';
|
||||
import type { ExternalRecoveryTargetState } from '../web-player-view/external-playback-recovery';
|
||||
|
||||
function player(
|
||||
target: PlaybackRecommendationTarget,
|
||||
@@ -43,6 +46,73 @@ function alternative(): PlaybackRecommendation {
|
||||
}
|
||||
|
||||
describe('playback recommendation view mappings', () => {
|
||||
it.each([
|
||||
[
|
||||
'mpv',
|
||||
{ attempts: 1, sessionId: null, status: 'launching' },
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_OPENING_MPV',
|
||||
'PLAYBACK_DIAGNOSTICS.EXTERNAL_OPENING',
|
||||
],
|
||||
[
|
||||
'vlc',
|
||||
{ attempts: 1, sessionId: 'vlc-1', status: 'started' },
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_VLC',
|
||||
'PLAYBACK_DIAGNOSTICS.EXTERNAL_STARTED',
|
||||
],
|
||||
[
|
||||
'mpv',
|
||||
{ attempts: 1, sessionId: 'mpv-1', status: 'playing' },
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_MPV',
|
||||
'PLAYBACK_DIAGNOSTICS.EXTERNAL_PLAYING',
|
||||
],
|
||||
[
|
||||
'vlc',
|
||||
{ attempts: 2, sessionId: null, status: 'error' },
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_RETRY_VLC',
|
||||
'PLAYBACK_DIAGNOSTICS.EXTERNAL_FAILED',
|
||||
],
|
||||
] as const)(
|
||||
'maps %s %s state to exact action and status copy',
|
||||
(target, state, actionKey, statusKey) => {
|
||||
const recommendation = player(target);
|
||||
|
||||
expect(
|
||||
getRecommendationLabelKey(
|
||||
recommendation,
|
||||
state as ExternalRecoveryTargetState
|
||||
)
|
||||
).toBe(actionKey);
|
||||
expect(
|
||||
getExternalRecommendationStatusKey(
|
||||
recommendation,
|
||||
state as ExternalRecoveryTargetState
|
||||
)
|
||||
).toBe(statusKey);
|
||||
expect(
|
||||
isExternalRecommendationLaunching(
|
||||
recommendation,
|
||||
state as ExternalRecoveryTargetState
|
||||
)
|
||||
).toBe(state.status === 'launching');
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps an attempted closed target actionable without a stale status', () => {
|
||||
const recommendation = player('mpv');
|
||||
const state: ExternalRecoveryTargetState = {
|
||||
attempts: 1,
|
||||
sessionId: null,
|
||||
status: 'idle',
|
||||
};
|
||||
|
||||
expect(getRecommendationLabelKey(recommendation, state)).toBe(
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_MPV'
|
||||
);
|
||||
expect(
|
||||
getExternalRecommendationStatusKey(recommendation, state)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['videojs', 'playback-recommendation-videojs'],
|
||||
['html5', 'playback-recommendation-html5'],
|
||||
|
||||
+65
-7
@@ -3,6 +3,8 @@ import {
|
||||
type PlaybackRecommendationReason,
|
||||
type PlaybackRecommendationTarget,
|
||||
} from '@iptvnator/playback/util';
|
||||
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
|
||||
import type { ExternalRecoveryTargetState } from '../web-player-view/external-playback-recovery';
|
||||
|
||||
export function getRecommendationKey(
|
||||
recommendation: PlaybackRecommendation
|
||||
@@ -26,7 +28,8 @@ export function getRecommendationTestId(
|
||||
}
|
||||
|
||||
export function getRecommendationLabelKey(
|
||||
recommendation: PlaybackRecommendation
|
||||
recommendation: PlaybackRecommendation,
|
||||
externalState?: ExternalRecoveryTargetState
|
||||
): string {
|
||||
switch (recommendation.action) {
|
||||
case 'retry':
|
||||
@@ -34,10 +37,41 @@ export function getRecommendationLabelKey(
|
||||
case 'alternative-source':
|
||||
return 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE';
|
||||
case 'player':
|
||||
return getPlayerLabelKey(recommendation.target);
|
||||
return getPlayerLabelKey(recommendation.target, externalState);
|
||||
}
|
||||
}
|
||||
|
||||
export function getExternalRecommendationStatusKey(
|
||||
recommendation: PlaybackRecommendation,
|
||||
state?: ExternalRecoveryTargetState
|
||||
): string | null {
|
||||
if (!isExternalPlayerRecommendation(recommendation) || !state) {
|
||||
return null;
|
||||
}
|
||||
switch (state.status) {
|
||||
case 'launching':
|
||||
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_OPENING';
|
||||
case 'started':
|
||||
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_STARTED';
|
||||
case 'playing':
|
||||
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_PLAYING';
|
||||
case 'error':
|
||||
return 'PLAYBACK_DIAGNOSTICS.EXTERNAL_FAILED';
|
||||
case 'idle':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isExternalRecommendationLaunching(
|
||||
recommendation: PlaybackRecommendation,
|
||||
state?: ExternalRecoveryTargetState
|
||||
): boolean {
|
||||
return (
|
||||
isExternalPlayerRecommendation(recommendation) &&
|
||||
state?.status === 'launching'
|
||||
);
|
||||
}
|
||||
|
||||
export function getRecommendationParams(
|
||||
recommendation: PlaybackRecommendation
|
||||
): Readonly<Record<string, string>> {
|
||||
@@ -93,7 +127,10 @@ export function isTemporaryBuiltInRecommendation(
|
||||
|
||||
export function isExternalPlayerRecommendation(
|
||||
recommendation: PlaybackRecommendation
|
||||
): boolean {
|
||||
): recommendation is Extract<
|
||||
PlaybackRecommendation,
|
||||
{ readonly action: 'player' }
|
||||
> & { readonly target: ExternalPlayerName } {
|
||||
return (
|
||||
recommendation.action === 'player' &&
|
||||
isExternalTarget(recommendation.target)
|
||||
@@ -115,19 +152,38 @@ function getPlayerTestId(target: PlaybackRecommendationTarget): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayerLabelKey(target: PlaybackRecommendationTarget): string {
|
||||
function getPlayerLabelKey(
|
||||
target: PlaybackRecommendationTarget,
|
||||
state?: ExternalRecoveryTargetState
|
||||
): string {
|
||||
switch (target) {
|
||||
case 'videojs':
|
||||
case 'html5':
|
||||
case 'artplayer':
|
||||
return 'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER';
|
||||
case 'mpv':
|
||||
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV';
|
||||
return getExternalPlayerLabelKey('MPV', state);
|
||||
case 'vlc':
|
||||
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC';
|
||||
return getExternalPlayerLabelKey('VLC', state);
|
||||
}
|
||||
}
|
||||
|
||||
function getExternalPlayerLabelKey(
|
||||
player: 'MPV' | 'VLC',
|
||||
state?: ExternalRecoveryTargetState
|
||||
): string {
|
||||
if (state?.status === 'launching') {
|
||||
return `PLAYBACK_DIAGNOSTICS.ACTION_OPENING_${player}`;
|
||||
}
|
||||
if (state?.status === 'error') {
|
||||
return `PLAYBACK_DIAGNOSTICS.ACTION_RETRY_${player}`;
|
||||
}
|
||||
if (state && state.attempts > 0) {
|
||||
return `PLAYBACK_DIAGNOSTICS.ACTION_REOPEN_${player}`;
|
||||
}
|
||||
return `PLAYBACK_DIAGNOSTICS.ACTION_OPEN_${player}`;
|
||||
}
|
||||
|
||||
function getPlayerName(target: PlaybackRecommendationTarget): string {
|
||||
switch (target) {
|
||||
case 'videojs':
|
||||
@@ -143,6 +199,8 @@ function getPlayerName(target: PlaybackRecommendationTarget): string {
|
||||
}
|
||||
}
|
||||
|
||||
function isExternalTarget(target: PlaybackRecommendationTarget): boolean {
|
||||
function isExternalTarget(
|
||||
target: PlaybackRecommendationTarget
|
||||
): target is ExternalPlayerName {
|
||||
return target === 'mpv' || target === 'vlc';
|
||||
}
|
||||
+54
@@ -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(
|
||||
|
||||
+369
@@ -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)"
|
||||
|
||||
+64
-63
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
@@ -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;
|
||||
|
||||
+10
-3
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user