mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
Add browser playback diagnostics and MPV/VLC fallback (#921)
* feat(playback): add codec diagnostics fallback Entire-Checkpoint: f957cd9849e0 * fix(playback): address diagnostics review feedback Entire-Checkpoint: f957cd9849e0 * fix(playback): ignore recoverable hls diagnostics Entire-Checkpoint: f957cd9849e0 * fix(playback): polish diagnostic fallback banner Entire-Checkpoint: f957cd9849e0 * fix(xtream): preserve live category search scope Entire-Checkpoint: f957cd9849e0
This commit is contained in:
1 parent
5f4cf01467
commit
6305dcf00e
66 files changed
+2857
-206
No files matched your search
@@ -108,7 +108,8 @@ export type LaunchedElectronApp = {
|
||||
};
|
||||
|
||||
export const test = base.extend<ElectronFixtures>({
|
||||
dataDir: async ({}, use) => {
|
||||
dataDir: async ({ browserName }, use) => {
|
||||
void browserName;
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-electron-e2e-'));
|
||||
|
||||
await use(dataDir);
|
||||
@@ -948,24 +949,22 @@ export async function clickCategoryByNameExact(
|
||||
const category = await pickPreferredCategory(categories);
|
||||
|
||||
await expect(category).toBeVisible();
|
||||
await expect(category).toBeEnabled();
|
||||
await category.scrollIntoViewIfNeeded();
|
||||
const categoryId =
|
||||
(await category.getAttribute('data-category-id'))?.trim() ?? '';
|
||||
await category.click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const pathname = new URL(page.url()).pathname;
|
||||
const isSelected =
|
||||
(await category.getAttribute('aria-current')) === 'true';
|
||||
|
||||
return (
|
||||
isSelected ||
|
||||
(categoryId.length > 0 &&
|
||||
(pathname.endsWith(`/${categoryId}`) ||
|
||||
pathname.includes(`/${categoryId}/`)))
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
const selectedCategory =
|
||||
categoryId.length > 0
|
||||
? page
|
||||
.locator(
|
||||
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible`
|
||||
)
|
||||
.first()
|
||||
: category;
|
||||
await expect(selectedCategory).toHaveAttribute('aria-current', 'true', {
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export function sourceRowByTitle(page: Page, title: string): Locator {
|
||||
|
||||
@@ -519,7 +519,12 @@ test.describe('Electron Workspace Search', () => {
|
||||
).toHaveClass(/(^|\s)active(\s|$)/);
|
||||
|
||||
await fillWorkspaceSearch(app.mainWindow, '');
|
||||
await expectQueryParamAbsent(app.mainWindow, 'q');
|
||||
await clickCategoryByNameExact(app.mainWindow, sample.categoryName);
|
||||
await expectWorkspaceSearchScope(
|
||||
app.mainWindow,
|
||||
`Live TV / ${sample.categoryName}`
|
||||
);
|
||||
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
|
||||
|
||||
await expectPathname(
|
||||
@@ -1352,24 +1357,22 @@ async function clickCategoryByNameExact(
|
||||
const category = await pickPreferredCategory(categories);
|
||||
|
||||
await expect(category).toBeVisible();
|
||||
await expect(category).toBeEnabled();
|
||||
await category.scrollIntoViewIfNeeded();
|
||||
const categoryId =
|
||||
(await category.getAttribute('data-category-id'))?.trim() ?? '';
|
||||
await category.click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const pathname = new URL(page.url()).pathname;
|
||||
const isSelected =
|
||||
(await category.getAttribute('aria-current')) === 'true';
|
||||
|
||||
return (
|
||||
isSelected ||
|
||||
(categoryId.length > 0 &&
|
||||
(pathname.endsWith(`/${categoryId}`) ||
|
||||
pathname.includes(`/${categoryId}/`)))
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
const selectedCategory =
|
||||
categoryId.length > 0
|
||||
? page
|
||||
.locator(
|
||||
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible`
|
||||
)
|
||||
.first()
|
||||
: category;
|
||||
await expect(selectedCategory).toHaveAttribute('aria-current', 'true', {
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
async function openWorkspaceSection(page: Page, label: string): Promise<void> {
|
||||
@@ -1770,3 +1773,9 @@ async function expectQueryParam(
|
||||
.poll(() => new URL(page.url()).searchParams.get(name))
|
||||
.toBe(value);
|
||||
}
|
||||
|
||||
async function expectQueryParamAbsent(page: Page, name: string): Promise<void> {
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.has(name))
|
||||
.toBe(false);
|
||||
}
|
||||
@@ -102,4 +102,43 @@ describe('PlayerService', () => {
|
||||
);
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it('forces external playback without changing the selected embedded player', async () => {
|
||||
const session: ExternalPlayerSession = {
|
||||
id: 'session-2',
|
||||
player: 'vlc',
|
||||
status: 'opened',
|
||||
title: 'Example Live',
|
||||
streamUrl: 'https://example.com/live.m3u8',
|
||||
startedAt: '2026-03-07T10:00:00.000Z',
|
||||
updatedAt: '2026-03-07T10:00:00.000Z',
|
||||
canClose: true,
|
||||
};
|
||||
settingsStore.player.mockReturnValue(VideoPlayer.VideoJs);
|
||||
dataService.sendIpcEvent.mockResolvedValue(session);
|
||||
|
||||
const result = await service.openExternalPlayback(
|
||||
{
|
||||
streamUrl: 'https://example.com/live.m3u8',
|
||||
title: 'Example Live',
|
||||
userAgent: 'IPTVnator Test',
|
||||
referer: 'https://referrer.example.com',
|
||||
origin: 'https://origin.example.com',
|
||||
},
|
||||
'vlc'
|
||||
);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
'OPEN_VLC_PLAYER',
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com/live.m3u8',
|
||||
title: 'Example Live',
|
||||
'user-agent': 'IPTVnator Test',
|
||||
referer: 'https://referrer.example.com',
|
||||
origin: 'https://origin.example.com',
|
||||
})
|
||||
);
|
||||
expect(dialog.open).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ResolvedPortalPlayback,
|
||||
VideoPlayer,
|
||||
} from 'shared-interfaces';
|
||||
import type { ExternalPlayerName } from 'shared-interfaces';
|
||||
import { SettingsStore } from './settings-store.service';
|
||||
|
||||
@Injectable({
|
||||
@@ -66,54 +67,18 @@ export class PlayerService {
|
||||
hideExternalInfoDialog = true
|
||||
): Promise<ExternalPlayerSession | void> {
|
||||
const player = this.settingsStore.player() ?? VideoPlayer.VideoJs;
|
||||
const {
|
||||
streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
userAgent,
|
||||
referer,
|
||||
origin,
|
||||
headers,
|
||||
contentInfo,
|
||||
startTime,
|
||||
} = playback;
|
||||
const { streamUrl, title, contentInfo, startTime } = playback;
|
||||
|
||||
if (player === VideoPlayer.MPV) {
|
||||
if (!hideExternalInfoDialog) {
|
||||
this.dialog.open(ExternalPlayerInfoDialogComponent);
|
||||
}
|
||||
return await this.dataService.sendIpcEvent<ExternalPlayerSession>(
|
||||
OPEN_MPV_PLAYER,
|
||||
{
|
||||
url: streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
'user-agent': userAgent,
|
||||
referer: referer,
|
||||
origin: origin,
|
||||
headers,
|
||||
contentInfo,
|
||||
startTime,
|
||||
}
|
||||
);
|
||||
return await this.openExternalPlayback(playback, 'mpv');
|
||||
} else if (player === VideoPlayer.VLC) {
|
||||
if (!hideExternalInfoDialog) {
|
||||
this.dialog.open(ExternalPlayerInfoDialogComponent);
|
||||
}
|
||||
return await this.dataService.sendIpcEvent<ExternalPlayerSession>(
|
||||
OPEN_VLC_PLAYER,
|
||||
{
|
||||
url: streamUrl,
|
||||
title,
|
||||
thumbnail,
|
||||
'user-agent': userAgent,
|
||||
referer: referer,
|
||||
origin: origin,
|
||||
headers,
|
||||
contentInfo,
|
||||
startTime,
|
||||
}
|
||||
);
|
||||
return await this.openExternalPlayback(playback, 'vlc');
|
||||
}
|
||||
|
||||
return import('@iptvnator/portal/xtream/feature').then(
|
||||
@@ -133,4 +98,27 @@ export class PlayerService {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async openExternalPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
player: ExternalPlayerName
|
||||
): Promise<ExternalPlayerSession | void> {
|
||||
const ipcEvent =
|
||||
player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER;
|
||||
|
||||
return await this.dataService.sendIpcEvent<ExternalPlayerSession>(
|
||||
ipcEvent,
|
||||
{
|
||||
url: playback.streamUrl,
|
||||
title: playback.title,
|
||||
thumbnail: playback.thumbnail,
|
||||
'user-agent': playback.userAgent,
|
||||
referer: playback.referer,
|
||||
origin: playback.origin,
|
||||
headers: playback.headers,
|
||||
contentInfo: playback.contentInfo,
|
||||
startTime: playback.startTime,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "السمة الفاتحة",
|
||||
"SYSTEM_THEME": "سمة النظام"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات",
|
||||
"SHOW_CHANNELS_LIST": "إظهار قائمة القنوات",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "المظهر الفاتح",
|
||||
"SYSTEM_THEME": "مظهر النظام"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات",
|
||||
"SHOW_CHANNELS_LIST": "إظهار قائمة القنوات",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "Светлая тэма",
|
||||
"SYSTEM_THEME": "Сістэмная тэма"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Схаваць спіс каналаў",
|
||||
"SHOW_CHANNELS_LIST": "Паказаць спіс каналаў",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "Hell",
|
||||
"SYSTEM_THEME": "System"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Senderliste ausblenden",
|
||||
"SHOW_CHANNELS_LIST": "Senderliste einblenden",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "Ανοιχτόχρωμο θέμα",
|
||||
"SYSTEM_THEME": "Θέμα συστήματος"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Απόκρυψη λίστας καναλιών",
|
||||
"SHOW_CHANNELS_LIST": "Εμφάνιση λίστας καναλιών",
|
||||
|
||||
@@ -358,6 +358,35 @@
|
||||
"LIGHT_THEME": "Light theme",
|
||||
"SYSTEM_THEME": "System theme"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Hide channels list",
|
||||
"SHOW_CHANNELS_LIST": "Show channels list",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "Tema claro",
|
||||
"SYSTEM_THEME": "Tema del sistema"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Ocultar lista de canales",
|
||||
"SHOW_CHANNELS_LIST": "Mostrar lista de canales",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "Thème clair",
|
||||
"SYSTEM_THEME": "Thème du système"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Masquer la liste des chaînes",
|
||||
"SHOW_CHANNELS_LIST": "Afficher la liste des chaînes",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "Tema chiaro",
|
||||
"SYSTEM_THEME": "Tema di sistema"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Nascondi elenco canali",
|
||||
"SHOW_CHANNELS_LIST": "Mostra elenco canali",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "ライトテーマ",
|
||||
"SYSTEM_THEME": "システムテーマ"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "チャンネル一覧を非表示",
|
||||
"SHOW_CHANNELS_LIST": "チャンネル一覧を表示",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "밝은 테마",
|
||||
"SYSTEM_THEME": "시스템 테마"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "채널 목록 숨기기",
|
||||
"SHOW_CHANNELS_LIST": "채널 목록 표시",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "Licht thema",
|
||||
"SYSTEM_THEME": "Systeemthema"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Zenderlijst verbergen",
|
||||
"SHOW_CHANNELS_LIST": "Zenderlijst tonen",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "Jasny motyw",
|
||||
"SYSTEM_THEME": "Motyw systemowy"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Ukryj listę kanałów",
|
||||
"SHOW_CHANNELS_LIST": "Pokaż listę kanałów",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "Tema claro",
|
||||
"SYSTEM_THEME": "Tema do sistema"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Ocultar lista de canais",
|
||||
"SHOW_CHANNELS_LIST": "Mostrar lista de canais",
|
||||
|
||||
@@ -356,6 +356,35 @@
|
||||
"LIGHT_THEME": "Светлая тема",
|
||||
"SYSTEM_THEME": "Системная тема"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "Контейнер потока, вероятно, не поддерживается встроенным браузерным плеером",
|
||||
"DESCRIPTION": "Chromium/Electron может не воспроизводить этот контейнер внутри приложения. Попробуйте MPV или VLC с более широкой поддержкой кодеков."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "Кодек потока, вероятно, не поддерживается встроенным браузерным плеером",
|
||||
"DESCRIPTION": "Поток сообщает кодеки, которые встроенный Chromium-плеер может не декодировать. MPV или VLC часто справляются с такими потоками."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "Браузерный плеер не смог декодировать этот поток",
|
||||
"DESCRIPTION": "Воспроизведение началось, но медиапайплайн завершился ошибкой декодирования. Внешний плеер может обработать этот поток лучше."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "Не удалось загрузить поток",
|
||||
"DESCRIPTION": "Плеер сообщил сетевую ошибку или ошибку загрузки у провайдера. Проверьте URL потока, заголовки или доступность провайдера."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "Поток может быть зашифрован или защищен DRM",
|
||||
"DESCRIPTION": "Браузерный плеер не может продолжить из-за ошибки ключа или шифрования. Внешний плеер тоже может не помочь, если требуется DRM провайдера."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "Браузерный плеер сообщил ошибку воспроизведения",
|
||||
"DESCRIPTION": "Поток не воспроизвелся во встроенном плеере, но ошибка не указала конкретную причину в кодеке, контейнере или сети."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Открыть в MPV",
|
||||
"ACTION_OPEN_VLC": "Открыть в VLC",
|
||||
"ACTION_COPY_URL": "Скопировать URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Скрыть список каналов",
|
||||
"SHOW_CHANNELS_LIST": "Показать список каналов",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "Açık tema",
|
||||
"SYSTEM_THEME": "Sistem teması"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Kanal listesini gizle",
|
||||
"SHOW_CHANNELS_LIST": "Kanal listesini göster",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "明亮模式",
|
||||
"SYSTEM_THEME": "跟随系统"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "隐藏频道列表",
|
||||
"SHOW_CHANNELS_LIST": "显示频道列表",
|
||||
|
||||
@@ -352,6 +352,35 @@
|
||||
"LIGHT_THEME": "亮色主題",
|
||||
"SYSTEM_THEME": "跟隨系統"
|
||||
},
|
||||
"PLAYBACK_DIAGNOSTICS": {
|
||||
"UNSUPPORTED_CONTAINER": {
|
||||
"TITLE": "This stream container is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "Chromium/Electron may not decode this container inline. Try MPV or VLC for broader codec support."
|
||||
},
|
||||
"UNSUPPORTED_CODEC": {
|
||||
"TITLE": "This stream codec is likely unsupported by the browser player",
|
||||
"DESCRIPTION": "The stream advertises codecs that the built-in Chromium player may not decode. MPV or VLC can often play it."
|
||||
},
|
||||
"MEDIA_DECODE_ERROR": {
|
||||
"TITLE": "The browser player could not decode this stream",
|
||||
"DESCRIPTION": "Playback started but the media pipeline failed while decoding. An external player may handle this stream better."
|
||||
},
|
||||
"NETWORK_ERROR": {
|
||||
"TITLE": "The stream could not be loaded",
|
||||
"DESCRIPTION": "The player reported a network or provider loading error. Check the stream URL, headers, or provider availability."
|
||||
},
|
||||
"DRM_OR_ENCRYPTION": {
|
||||
"TITLE": "This stream may be encrypted or DRM-protected",
|
||||
"DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required."
|
||||
},
|
||||
"UNKNOWN_PLAYBACK_ERROR": {
|
||||
"TITLE": "The browser player reported a playback error",
|
||||
"DESCRIPTION": "The stream failed in the built-in player, but the error did not identify a specific codec, container, or network cause."
|
||||
},
|
||||
"ACTION_OPEN_MPV": "Open in MPV",
|
||||
"ACTION_OPEN_VLC": "Open in VLC",
|
||||
"ACTION_COPY_URL": "Copy URL"
|
||||
},
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "隱藏頻道列表",
|
||||
"SHOW_CHANNELS_LIST": "顯示頻道列表",
|
||||
|
||||
@@ -11,6 +11,7 @@ This document records the current contract for embedded playback in portal detai
|
||||
- Live playback stays inline in dedicated live layouts.
|
||||
- VOD and series detail playback now also stays inline on canonical detail surfaces.
|
||||
- Material dialog playback remains only as a fallback for older non-detail callers.
|
||||
- Browser-player failures are diagnosed client-side and can offer explicit MPV/VLC fallback actions without changing the saved player setting.
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -84,6 +85,11 @@ Fallback dialog path:
|
||||
- `/Users/4gray/Code/iptvnator/apps/web/src/app/services/player.service.ts`
|
||||
- `/Users/4gray/Code/iptvnator/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts`
|
||||
|
||||
Diagnostics and fallback UI:
|
||||
|
||||
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts`
|
||||
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
|
||||
|
||||
## Playback Decision Rule
|
||||
|
||||
When a detail view starts playback:
|
||||
@@ -95,6 +101,30 @@ When a detail view starts playback:
|
||||
|
||||
The detail host owns inline state. `PlayerService` is no longer the primary owner of UI playback state for canonical VOD/series detail screens.
|
||||
|
||||
## Codec And Container Diagnostics
|
||||
|
||||
The shared `WebPlayerViewComponent` is the central browser-player viewport for M3U, Xtream, and Stalker inline playback. Video.js, HTML5, and ArtPlayer report native media errors, HLS.js errors, mpegts.js errors, and HLS manifest codec metadata into the shared diagnostics classifier.
|
||||
|
||||
The diagnostics remain client-only:
|
||||
|
||||
- no ffprobe or server-side probing
|
||||
- no extra manifest fetch beyond the active player
|
||||
- no automatic failover to an external player
|
||||
- no embedded MPV macOS diagnostics
|
||||
|
||||
Supported diagnostic codes are:
|
||||
|
||||
- `unsupported-container`
|
||||
- `unsupported-codec`
|
||||
- `media-decode-error`
|
||||
- `network-error`
|
||||
- `drm-or-encryption`
|
||||
- `unknown-playback-error`
|
||||
|
||||
When a diagnostic is actionable in Electron, the inline banner may offer `Open in MPV`, `Open in VLC`, and `Copy URL`. Web builds only expose copy/help text. MPV/VLC fallback requests carry the original `ResolvedPortalPlayback` payload so headers, referer, origin, user-agent, content metadata, and resume offset stay intact.
|
||||
|
||||
`PortalPlayer.openExternalPlayback(playback, player)` is the forced external launch API. It sends the playback payload to MPV or VLC regardless of the current saved player setting, so fallback buttons do not mutate preferences.
|
||||
|
||||
## Flatpak External Players
|
||||
|
||||
Flatpak cannot execute host-installed `mpv` or `vlc` binaries directly from the sandbox.
|
||||
|
||||
@@ -6,6 +6,21 @@
|
||||
"projectType": "library",
|
||||
"tags": ["scope:playlist", "domain:m3u", "type:feature"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "nx:run-commands",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"command": [
|
||||
"node",
|
||||
"./tools/testing/run-web-esm-lib-tests.mjs",
|
||||
"libs/playlist/m3u/feature-player/src"
|
||||
],
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--experimental-vm-modules"
|
||||
},
|
||||
"forwardAllArgs": true
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
</button>
|
||||
}
|
||||
@if (activeChannel(); as activeChannel) {
|
||||
@let playbackTarget = playbackChannel();
|
||||
@if (
|
||||
activeChannel.radio === 'true' ||
|
||||
shouldShowInlinePlayer(activeChannel)
|
||||
@@ -73,43 +72,17 @@
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
@if (playerSettings.player === 'videojs') {
|
||||
<app-vjs-player
|
||||
[options]="{
|
||||
sources: [
|
||||
{
|
||||
src:
|
||||
playbackTarget?.url +
|
||||
playbackTarget?.epgParams,
|
||||
type: 'application/x-mpegURL',
|
||||
},
|
||||
],
|
||||
}"
|
||||
[volume]="volume"
|
||||
[class.hide-captions]="!playerSettings.showCaptions"
|
||||
/>
|
||||
}
|
||||
@if (playerSettings.player === 'html5') {
|
||||
<app-html-video-player
|
||||
[channel]="playbackTarget!"
|
||||
[volume]="volume"
|
||||
[showCaptions]="playerSettings.showCaptions!"
|
||||
/>
|
||||
}
|
||||
@if (playerSettings.player === 'artplayer') {
|
||||
<app-art-player
|
||||
[channel]="playbackTarget!"
|
||||
[volume]="volume"
|
||||
[showCaptions]="playerSettings.showCaptions!"
|
||||
/>
|
||||
}
|
||||
@if (playerSettings.player === 'embedded-mpv') {
|
||||
<app-web-player-view
|
||||
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
|
||||
[title]="embeddedPlayback()?.title ?? ''"
|
||||
[playback]="embeddedPlayback()"
|
||||
/>
|
||||
}
|
||||
<app-web-player-view
|
||||
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
|
||||
[title]="embeddedPlayback()?.title ?? ''"
|
||||
[playback]="embeddedPlayback()"
|
||||
[playerOverride]="playerSettings.player ?? null"
|
||||
[volume]="volume"
|
||||
[showCaptions]="!!playerSettings.showCaptions"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
+157
-8
@@ -1,6 +1,7 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
Directive,
|
||||
NO_ERRORS_SCHEMA,
|
||||
input,
|
||||
output,
|
||||
@@ -31,7 +32,16 @@ import { DataService, PlaylistsService, SettingsStore } from 'services';
|
||||
import { Channel, EpgProgram, Settings, VideoPlayer } from 'shared-interfaces';
|
||||
import { LiveEpgPanelSummary } from 'shared-portals';
|
||||
import { Overlay } from '@angular/cdk/overlay';
|
||||
import { VideoPlayerComponent } from './video-player.component';
|
||||
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
|
||||
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', () => ({}));
|
||||
|
||||
@Component({
|
||||
selector: 'app-live-epg-panel',
|
||||
@@ -51,9 +61,96 @@ class StubLiveEpgPanelComponent {
|
||||
readonly dateNavigation = output<'next' | 'prev'>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list-loading-state',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubChannelListLoadingStateComponent {
|
||||
readonly view = input<string | null>(null);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubSidebarComponent {
|
||||
readonly channels = input<Channel[]>([]);
|
||||
readonly channelsLoading = input(false);
|
||||
readonly showPlaylistHeader = input(false);
|
||||
readonly activeView = input('');
|
||||
readonly sidebarWidth = input(0);
|
||||
readonly sidebarWidthRequested = output<number>();
|
||||
readonly sidebarWidthRequestEnded = output<number>();
|
||||
readonly sidebarToggleRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-empty-state',
|
||||
standalone: true,
|
||||
template: '<div class="stub-empty-state">{{ message() }}</div>',
|
||||
})
|
||||
class StubPortalEmptyStateComponent {
|
||||
readonly icon = input('');
|
||||
readonly message = input('');
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-audio-player',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubAudioPlayerComponent {
|
||||
readonly url = input('');
|
||||
readonly icon = input('');
|
||||
readonly channelName = input('');
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-web-player-view',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly streamUrl = input('');
|
||||
readonly title = input('');
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly playerOverride = input<VideoPlayer | null>(null);
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-list',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubEpgListComponent {
|
||||
readonly selectedDate = input<string | null>(null);
|
||||
readonly showDateNavigator = input(false);
|
||||
readonly archivePlaybackAvailable = input(false);
|
||||
readonly selectedDateChange = output<string>();
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[appResizable]',
|
||||
standalone: true,
|
||||
})
|
||||
class StubResizableDirective {
|
||||
readonly minWidth = input(0);
|
||||
readonly maxWidth = input(0);
|
||||
readonly defaultWidth = input(0);
|
||||
readonly storageKey = input('');
|
||||
readonly widthChange = output<number>();
|
||||
readonly resizeEnd = output<number>();
|
||||
}
|
||||
|
||||
describe('VideoPlayerComponent', () => {
|
||||
let fixture: ComponentFixture<VideoPlayerComponent>;
|
||||
let component: VideoPlayerComponent;
|
||||
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
|
||||
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
|
||||
let component: VideoPlayerComponentInstance;
|
||||
let headerContext: WorkspaceHeaderContextService;
|
||||
|
||||
const playlistId = signal('playlist-1');
|
||||
@@ -147,6 +244,9 @@ describe('VideoPlayerComponent', () => {
|
||||
})
|
||||
),
|
||||
};
|
||||
const dataServiceMock = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
|
||||
const sampleChannel: Channel = {
|
||||
id: 'channel-1',
|
||||
@@ -161,6 +261,10 @@ describe('VideoPlayerComponent', () => {
|
||||
},
|
||||
} as Channel;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ VideoPlayerComponent } = await import('./video-player.component'));
|
||||
});
|
||||
|
||||
function syncStoreState(channel: Channel | null): void {
|
||||
activeChannel.set(channel);
|
||||
activeChannel$.next(channel);
|
||||
@@ -182,6 +286,7 @@ describe('VideoPlayerComponent', () => {
|
||||
overlayRef.attach.mockClear();
|
||||
overlayRef.dispose.mockClear();
|
||||
storeMock.dispatch.mockClear();
|
||||
dataServiceMock.sendIpcEvent.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VideoPlayerComponent],
|
||||
@@ -212,9 +317,7 @@ describe('VideoPlayerComponent', () => {
|
||||
},
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: {
|
||||
sendIpcEvent: jest.fn(),
|
||||
},
|
||||
useValue: dataServiceMock,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
@@ -256,7 +359,14 @@ describe('VideoPlayerComponent', () => {
|
||||
set: {
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
StubAudioPlayerComponent,
|
||||
StubChannelListLoadingStateComponent,
|
||||
StubEpgListComponent,
|
||||
StubLiveEpgPanelComponent,
|
||||
StubPortalEmptyStateComponent,
|
||||
StubResizableDirective,
|
||||
StubSidebarComponent,
|
||||
StubWebPlayerViewComponent,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
@@ -272,7 +382,7 @@ describe('VideoPlayerComponent', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
fixture?.destroy();
|
||||
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
|
||||
});
|
||||
|
||||
@@ -300,13 +410,52 @@ describe('VideoPlayerComponent', () => {
|
||||
fixture.nativeElement.querySelector('.video-player')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-vjs-player')
|
||||
fixture.nativeElement.querySelector('app-web-player-view')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-list')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens MPV fallback with the active channel headers preserved', () => {
|
||||
syncStoreState({
|
||||
...sampleChannel,
|
||||
http: {
|
||||
'user-agent': 'IPTVnator Test',
|
||||
referrer: 'https://referrer.example.com',
|
||||
origin: 'https://origin.example.com',
|
||||
},
|
||||
} as Channel);
|
||||
|
||||
component.handleExternalFallbackRequest({
|
||||
player: 'mpv',
|
||||
playback: {
|
||||
streamUrl: 'https://archive.example.com/live.m3u8?utc=1',
|
||||
title: 'Archive Sample',
|
||||
},
|
||||
diagnostic: {
|
||||
code: 'unsupported-codec',
|
||||
source: 'hls',
|
||||
sourceUrl: 'https://archive.example.com/live.m3u8?utc=1',
|
||||
container: 'm3u8',
|
||||
audioCodecs: ['ac-3'],
|
||||
videoCodecs: ['avc1.64001f'],
|
||||
externalFallbackRecommended: true,
|
||||
},
|
||||
} satisfies PlaybackFallbackRequest);
|
||||
|
||||
expect(dataServiceMock.sendIpcEvent).toHaveBeenCalledWith(
|
||||
'OPEN_MPV_PLAYER',
|
||||
expect.objectContaining({
|
||||
url: 'https://archive.example.com/live.m3u8?utc=1',
|
||||
title: 'Sample TV',
|
||||
'user-agent': 'IPTVnator Test',
|
||||
referer: 'https://referrer.example.com',
|
||||
origin: 'https://origin.example.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the embedded mpv inline player with the EPG panel', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.EmbeddedMpv);
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import {
|
||||
ChannelActions,
|
||||
PlaylistActions,
|
||||
buildExternalPlayerPayload,
|
||||
selectActive,
|
||||
selectActivePlaybackUrl,
|
||||
selectChannels,
|
||||
@@ -66,11 +67,9 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
ArtPlayerComponent,
|
||||
AudioPlayerComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
type PlaybackFallbackRequest,
|
||||
SidebarComponent,
|
||||
VjsPlayerComponent,
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { LiveEpgPanelComponent, LiveEpgPanelSummary } from 'shared-portals';
|
||||
@@ -80,6 +79,8 @@ import {
|
||||
Channel,
|
||||
EpgProgram,
|
||||
ExternalPlayerSession,
|
||||
OPEN_MPV_PLAYER,
|
||||
OPEN_VLC_PLAYER,
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
M3uRecentlyViewedItem,
|
||||
PlaylistMeta,
|
||||
@@ -100,13 +101,11 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
|
||||
@Component({
|
||||
selector: 'app-video-player',
|
||||
imports: [
|
||||
ArtPlayerComponent,
|
||||
AsyncPipe,
|
||||
AudioPlayerComponent,
|
||||
ChannelListLoadingStateComponent,
|
||||
CommonModule,
|
||||
EpgListComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
LiveEpgPanelComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
@@ -115,7 +114,6 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
|
||||
ResizableDirective,
|
||||
SidebarComponent,
|
||||
TranslatePipe,
|
||||
VjsPlayerComponent,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
templateUrl: './video-player.component.html',
|
||||
@@ -172,15 +170,16 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
return null;
|
||||
}
|
||||
|
||||
const http: Partial<Channel['http']> = playbackTarget.http ?? {};
|
||||
const headers: Record<string, string> = {};
|
||||
if (playbackTarget.http['user-agent']) {
|
||||
headers['User-Agent'] = playbackTarget.http['user-agent'];
|
||||
if (http['user-agent']) {
|
||||
headers['User-Agent'] = http['user-agent'];
|
||||
}
|
||||
if (playbackTarget.http.referrer) {
|
||||
headers['Referer'] = playbackTarget.http.referrer;
|
||||
if (http.referrer) {
|
||||
headers['Referer'] = http.referrer;
|
||||
}
|
||||
if (playbackTarget.http.origin) {
|
||||
headers['Origin'] = playbackTarget.http.origin;
|
||||
if (http.origin) {
|
||||
headers['Origin'] = http.origin;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -192,9 +191,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
thumbnail: activeChannel.tvg?.logo ?? null,
|
||||
isLive: true,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
userAgent: playbackTarget.http['user-agent'] || undefined,
|
||||
referer: playbackTarget.http.referrer || undefined,
|
||||
origin: playbackTarget.http.origin || undefined,
|
||||
userAgent: http['user-agent'] || undefined,
|
||||
referer: http.referrer || undefined,
|
||||
origin: http.origin || undefined,
|
||||
};
|
||||
});
|
||||
readonly sidebarStorageKey = computed(() =>
|
||||
@@ -811,6 +810,21 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
return !this.isExternalPlayer(this.playerSettings.player);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
const payload = buildExternalPlayerPayload(
|
||||
this.activeChannel(),
|
||||
request.playback.streamUrl
|
||||
);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dataService.sendIpcEvent(
|
||||
request.player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER,
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
private toLiveEpgPanelSummary(
|
||||
program: EpgProgram | null | undefined
|
||||
): LiveEpgPanelSummary | null {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ExternalPlayerSession,
|
||||
ResolvedPortalPlayback,
|
||||
} from 'shared-interfaces';
|
||||
import type { ExternalPlayerName } from 'shared-interfaces';
|
||||
|
||||
export interface PortalPlayer {
|
||||
isEmbeddedPlayer(): boolean;
|
||||
@@ -23,6 +24,10 @@ export interface PortalPlayer {
|
||||
playback: ResolvedPortalPlayback,
|
||||
hideExternalInfoDialog?: boolean
|
||||
): Promise<ExternalPlayerSession | void>;
|
||||
openExternalPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
player: ExternalPlayerName
|
||||
): Promise<ExternalPlayerSession | void>;
|
||||
}
|
||||
|
||||
export const PORTAL_PLAYER = new InjectionToken<PortalPlayer>(
|
||||
|
||||
+3
@@ -18,6 +18,9 @@
|
||||
(inlineTimeUpdated)="handleInlineTimeUpdate($event)"
|
||||
(inlinePlaybackClosed)="closeInlinePlayer()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(inlineExternalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
} @else if (contentType() === 'series') {
|
||||
|
||||
+11
-1
@@ -25,7 +25,10 @@ import {
|
||||
StalkerSelectedVodItem,
|
||||
toggleStalkerVodFavorite,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { VodDetailsComponent } from '@iptvnator/ui/playback';
|
||||
import {
|
||||
type PlaybackFallbackRequest,
|
||||
VodDetailsComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { DownloadsService, PlaylistsService } from 'services';
|
||||
import {
|
||||
createStalkerVodItem,
|
||||
@@ -261,6 +264,13 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
void this.portalPlayer.openExternalPlayback(
|
||||
request.playback,
|
||||
request.player
|
||||
);
|
||||
}
|
||||
|
||||
async onVodDownload(item: VodDetailsItem): Promise<void> {
|
||||
if (item.type !== 'stalker') {
|
||||
return;
|
||||
|
||||
+3
@@ -125,6 +125,9 @@
|
||||
[streamUrl]="streamUrl()"
|
||||
[title]="activePlayback()?.title ?? ''"
|
||||
[playback]="activePlayback()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ class StubWebPlayerViewComponent {
|
||||
readonly streamUrl = input('');
|
||||
readonly title = input('');
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
+8
@@ -42,6 +42,7 @@ import { AudioPlayerComponent } from '@iptvnator/ui/playback';
|
||||
import {
|
||||
LiveEpgPanelComponent,
|
||||
LiveEpgPanelSummary,
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from 'shared-portals';
|
||||
import {
|
||||
@@ -813,4 +814,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
|
||||
void this.playChannel(channel, true);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
void this.portalPlayer.openExternalPlayback(
|
||||
request.playback,
|
||||
request.player
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
@@ -63,6 +63,9 @@
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -44,7 +44,10 @@ import {
|
||||
StalkerStore,
|
||||
StalkerVodSource,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import {
|
||||
type PlaybackFallbackRequest,
|
||||
PortalInlinePlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { DownloadsService } from 'services';
|
||||
|
||||
/**
|
||||
@@ -474,6 +477,13 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
void this.portalPlayer.openExternalPlayback(
|
||||
request.playback,
|
||||
request.player
|
||||
);
|
||||
}
|
||||
|
||||
private async startPlayback(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
|
||||
+3
@@ -99,6 +99,9 @@
|
||||
[streamUrl]="streamUrl"
|
||||
[title]="activePlayback()?.title ?? ''"
|
||||
[playback]="activePlayback()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ class StubWebPlayerViewComponent {
|
||||
readonly streamUrl = input('');
|
||||
readonly title = input('');
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
EpgViewComponent,
|
||||
LiveEpgPanelComponent,
|
||||
LiveEpgPanelSummary,
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from 'shared-portals';
|
||||
import { EpgItem, EpgProgram, ResolvedPortalPlayback } from 'shared-interfaces';
|
||||
@@ -452,6 +453,13 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
this.playLive(channel, true);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
void this.portalPlayer.openExternalPlayback(
|
||||
request.playback,
|
||||
request.player
|
||||
);
|
||||
}
|
||||
|
||||
private getVisibleChannels(): XtreamLiveChannelItem[] {
|
||||
return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
[playback]="data.playback ?? null"
|
||||
[startTime]="data.startTime || 0"
|
||||
(timeUpdate)="handleTimeUpdate($event)"
|
||||
(externalFallbackRequested)="handleExternalFallbackRequest($event)"
|
||||
/>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions class="align-actions">
|
||||
|
||||
@@ -6,9 +6,15 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlayerContentInfo, ResolvedPortalPlayback } from 'shared-interfaces';
|
||||
import { WebPlayerViewComponent } from 'shared-portals';
|
||||
import {
|
||||
type PlaybackFallbackRequest,
|
||||
WebPlayerViewComponent,
|
||||
} from 'shared-portals';
|
||||
|
||||
export interface PlayerDialogData {
|
||||
streamUrl: string;
|
||||
@@ -37,6 +43,7 @@ export class PlayerDialogComponent {
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private translateService = inject(TranslateService);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
|
||||
readonly title: string;
|
||||
readonly streamUrl: string;
|
||||
@@ -75,4 +82,11 @@ export class PlayerDialogComponent {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
void this.portalPlayer.openExternalPlayback(
|
||||
request.playback,
|
||||
request.player
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,9 @@
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ class StubPortalInlinePlayerComponent {
|
||||
readonly timeUpdate = output<unknown>();
|
||||
readonly closed = output<void>();
|
||||
readonly streamUrlCopied = output<void>();
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
PORTAL_PLAYER,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import {
|
||||
type PlaybackFallbackRequest,
|
||||
PortalInlinePlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
PlayerContentInfo,
|
||||
@@ -309,6 +312,13 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
void this.portalPlayer.openExternalPlayback(
|
||||
request.playback,
|
||||
request.player
|
||||
);
|
||||
}
|
||||
|
||||
private addToRecentlyViewed(xtreamId: number): void {
|
||||
this.xtreamStore.addRecentItem({
|
||||
xtreamId,
|
||||
|
||||
@@ -154,6 +154,9 @@
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
(streamUrlCopied)="showCopyNotification()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
getPortalPlaybackProgressPercent,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import {
|
||||
type PlaybackFallbackRequest,
|
||||
PortalInlinePlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { DownloadsService, SettingsStore } from 'services';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
@@ -466,6 +469,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
void this.portalPlayer.openExternalPlayback(
|
||||
request.playback,
|
||||
request.player
|
||||
);
|
||||
}
|
||||
|
||||
async downloadVod(vodItem: XtreamVodDetails): Promise<void> {
|
||||
const info = getXtreamVodInfo(vodItem);
|
||||
const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem);
|
||||
|
||||
@@ -52,7 +52,7 @@ async function flushEffects(): Promise<void> {
|
||||
|
||||
function getXtreamSectionFromUrl(url: string): string | null {
|
||||
const match = url.match(
|
||||
/^\/workspace\/xtreams\/[^/]+\/([^/?]+)(?:\/|$)/
|
||||
/^\/workspace\/xtreams\/[^/]+\/([^/?]+)(?:[/?]|$)/
|
||||
);
|
||||
|
||||
return match?.[1] ?? null;
|
||||
@@ -452,6 +452,31 @@ describe('XtreamWorkspaceRouteSession', () => {
|
||||
expect(xtreamStore.initializeContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the selected live category during query-only search navigation', async () => {
|
||||
router.url = `/workspace/xtreams/${PLAYLIST_ID}/live`;
|
||||
currentPlaylist.set(XTREAM_PLAYLIST);
|
||||
playlistId.set(PLAYLIST_ID);
|
||||
isContentInitialized.set(true);
|
||||
contentLoadStateByType.set({
|
||||
live: 'ready',
|
||||
vod: 'ready',
|
||||
series: 'ready',
|
||||
});
|
||||
|
||||
TestBed.inject(XtreamWorkspaceRouteSession);
|
||||
await flushEffects();
|
||||
|
||||
selectedCategoryId.set(101);
|
||||
xtreamStore.setSelectedCategory.mockClear();
|
||||
|
||||
router.url = `/workspace/xtreams/${PLAYLIST_ID}/live?q=world`;
|
||||
routerEvents.next(new NavigationEnd(1, router.url, router.url));
|
||||
await flushEffects();
|
||||
|
||||
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalledWith(null);
|
||||
expect(selectedCategoryId()).toBe(101);
|
||||
});
|
||||
|
||||
it('does not rehydrate cached offline content when switching categories in a ready section', async () => {
|
||||
router.url = `/workspace/xtreams/${PLAYLIST_ID}/vod/101`;
|
||||
currentPlaylist.set(XTREAM_PLAYLIST);
|
||||
|
||||
@@ -184,6 +184,10 @@ function getXtreamRouteCategoryId(
|
||||
return Number.isNaN(categoryId) ? null : categoryId;
|
||||
}
|
||||
|
||||
function getRoutePath(url: string): string {
|
||||
return url.split('?')[0] ?? url;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class XtreamWorkspaceRouteSession {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
@@ -193,6 +197,7 @@ export class XtreamWorkspaceRouteSession {
|
||||
|
||||
private syncInFlight = false;
|
||||
private syncPending = false;
|
||||
private lastSyncedRoutePath: string | null = null;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -357,13 +362,27 @@ export class XtreamWorkspaceRouteSession {
|
||||
return null;
|
||||
}
|
||||
|
||||
const routePath = getRoutePath(this.router.url);
|
||||
const isQueryOnlyNavigation = this.lastSyncedRoutePath === routePath;
|
||||
this.lastSyncedRoutePath = routePath;
|
||||
|
||||
if (section === 'vod' || section === 'live' || section === 'series') {
|
||||
this.xtreamStore.setSelectedContentType(section);
|
||||
}
|
||||
|
||||
this.xtreamStore.setSelectedCategory(
|
||||
getXtreamRouteCategoryId(this.router.url, section)
|
||||
const routeCategoryId = getXtreamRouteCategoryId(
|
||||
this.router.url,
|
||||
section
|
||||
);
|
||||
if (
|
||||
section === 'live' &&
|
||||
routeCategoryId === null &&
|
||||
isQueryOnlyNavigation
|
||||
) {
|
||||
return section;
|
||||
}
|
||||
|
||||
this.xtreamStore.setSelectedCategory(routeCategoryId);
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from './lib/audio-player/audio-player.component';
|
||||
export * from './lib/embedded-mpv-player/embedded-mpv-player.component';
|
||||
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
|
||||
export * from './lib/html-video-player/html-video-player.component';
|
||||
export * from './lib/playback-diagnostics/playback-diagnostics.util';
|
||||
export * from './lib/portal-inline-player/portal-inline-player.component';
|
||||
export * from './lib/vjs-player/vjs-player.component';
|
||||
export * from './lib/video-player/sidebar/sidebar.component';
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Channel } from 'shared-interfaces';
|
||||
import type { ArtPlayerComponent as ArtPlayerComponentInstance } from './art-player.component';
|
||||
|
||||
const artPlayerInstances: MockArtplayer[] = [];
|
||||
const hlsInstances: MockHls[] = [];
|
||||
const mpegTsInstances: MockMpegTsPlayer[] = [];
|
||||
|
||||
class MockArtplayer {
|
||||
static AUTO_PLAYBACK_TIMEOUT = 0;
|
||||
|
||||
readonly video = document.createElement('video');
|
||||
readonly setting = { add: jest.fn() };
|
||||
readonly on = jest.fn();
|
||||
readonly destroy = jest.fn();
|
||||
readonly currentTime = 0;
|
||||
readonly duration = 0;
|
||||
|
||||
constructor(readonly options: Record<string, unknown>) {
|
||||
artPlayerInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
class MockHls {
|
||||
static Events = {
|
||||
MANIFEST_PARSED: 'manifestParsed',
|
||||
ERROR: 'error',
|
||||
AUDIO_TRACKS_UPDATED: 'audioTracksUpdated',
|
||||
};
|
||||
|
||||
static isSupported = jest.fn(() => true);
|
||||
|
||||
readonly handlers = new Map<string, (...args: unknown[]) => void>();
|
||||
readonly on = jest.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
this.handlers.set(event, handler);
|
||||
});
|
||||
readonly loadSource = jest.fn();
|
||||
readonly attachMedia = jest.fn();
|
||||
readonly destroy = jest.fn();
|
||||
readonly audioTracks: unknown[] = [];
|
||||
|
||||
constructor() {
|
||||
hlsInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
class MockMpegTsPlayer {
|
||||
readonly handlers = new Map<string, (...args: unknown[]) => void>();
|
||||
readonly attachMediaElement = jest.fn();
|
||||
readonly on = jest.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
this.handlers.set(event, handler);
|
||||
});
|
||||
readonly load = jest.fn();
|
||||
readonly play = jest.fn();
|
||||
readonly pause = jest.fn();
|
||||
readonly unload = jest.fn();
|
||||
readonly detachMediaElement = jest.fn();
|
||||
readonly destroy = jest.fn();
|
||||
|
||||
constructor() {
|
||||
mpegTsInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
jest.unstable_mockModule('artplayer', () => ({
|
||||
default: MockArtplayer,
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('hls.js', () => ({
|
||||
default: MockHls,
|
||||
}));
|
||||
|
||||
jest.unstable_mockModule('mpegts.js', () => ({
|
||||
default: {
|
||||
Events: {
|
||||
ERROR: 'error',
|
||||
},
|
||||
createPlayer: jest.fn(() => new MockMpegTsPlayer()),
|
||||
isSupported: jest.fn(() => true),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ArtPlayerComponent', () => {
|
||||
let ArtPlayerComponent: typeof import('./art-player.component').ArtPlayerComponent;
|
||||
let fixture: ComponentFixture<ArtPlayerComponentInstance>;
|
||||
let component: ArtPlayerComponentInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ ArtPlayerComponent } = await import('./art-player.component'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
artPlayerInstances.length = 0;
|
||||
hlsInstances.length = 0;
|
||||
mpegTsInstances.length = 0;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ArtPlayerComponent],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('emits a playback issue when the native video element reports an unsupported source', () => {
|
||||
createComponent({
|
||||
url: 'https://example.com/archive/movie.mkv',
|
||||
name: 'MKV Movie',
|
||||
});
|
||||
const issues: unknown[] = [];
|
||||
component.playbackIssue.subscribe((issue) => {
|
||||
if (issue) issues.push(issue);
|
||||
});
|
||||
|
||||
Object.defineProperty(artPlayerInstances[0].video, 'error', {
|
||||
configurable: true,
|
||||
value: {
|
||||
code: 4,
|
||||
message: 'No compatible source was found',
|
||||
},
|
||||
});
|
||||
|
||||
getCustomType('mkv')(
|
||||
artPlayerInstances[0].video,
|
||||
'https://example.com/archive/movie.mkv'
|
||||
);
|
||||
expect(artPlayerInstances[0].video.onerror).toBeNull();
|
||||
artPlayerInstances[0].video.dispatchEvent(new Event('error'));
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: 'unsupported-container',
|
||||
source: 'native',
|
||||
sourceUrl: 'https://example.com/archive/movie.mkv',
|
||||
externalFallbackRecommended: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits a playback issue when HLS.js reports an unsupported codec', () => {
|
||||
createComponent({
|
||||
url: 'https://example.com/live/playlist.m3u8',
|
||||
name: 'HLS Live',
|
||||
});
|
||||
const issues: unknown[] = [];
|
||||
component.playbackIssue.subscribe((issue) => {
|
||||
if (issue) issues.push(issue);
|
||||
});
|
||||
|
||||
getCustomType('m3u8')(
|
||||
artPlayerInstances[0].video,
|
||||
'https://example.com/live/playlist.m3u8'
|
||||
);
|
||||
hlsInstances[0].handlers.get(MockHls.Events.ERROR)?.(null, {
|
||||
type: 'mediaError',
|
||||
details: 'bufferAddCodecError',
|
||||
fatal: true,
|
||||
error: new Error('codec unsupported'),
|
||||
});
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: 'unsupported-codec',
|
||||
source: 'hls',
|
||||
sourceUrl: 'https://example.com/live/playlist.m3u8',
|
||||
externalFallbackRecommended: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not emit a playback issue when HLS.js reports a recoverable error', () => {
|
||||
createComponent({
|
||||
url: 'https://example.com/live/playlist.m3u8',
|
||||
name: 'HLS Live',
|
||||
});
|
||||
const issues: unknown[] = [];
|
||||
component.playbackIssue.subscribe((issue) => {
|
||||
if (issue) issues.push(issue);
|
||||
});
|
||||
|
||||
getCustomType('m3u8')(
|
||||
artPlayerInstances[0].video,
|
||||
'https://example.com/live/playlist.m3u8'
|
||||
);
|
||||
hlsInstances[0].handlers.get(MockHls.Events.ERROR)?.(null, {
|
||||
type: 'networkError',
|
||||
details: 'fragLoadError',
|
||||
fatal: false,
|
||||
error: new Error('segment retry'),
|
||||
});
|
||||
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits a playback issue when mpegts.js reports an unsupported codec', () => {
|
||||
createComponent({
|
||||
url: 'https://example.com/live/channel.ts',
|
||||
name: 'TS Live',
|
||||
});
|
||||
const issues: unknown[] = [];
|
||||
component.playbackIssue.subscribe((issue) => {
|
||||
if (issue) issues.push(issue);
|
||||
});
|
||||
|
||||
getCustomType('ts')(
|
||||
artPlayerInstances[0].video,
|
||||
'https://example.com/live/channel.ts'
|
||||
);
|
||||
mpegTsInstances[0].handlers
|
||||
.get('error')
|
||||
?.('mediaError', 'unsupported codec', {});
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: 'unsupported-codec',
|
||||
source: 'mpegts',
|
||||
sourceUrl: 'https://example.com/live/channel.ts',
|
||||
externalFallbackRecommended: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
function createComponent(channel: Pick<Channel, 'url' | 'name'>): void {
|
||||
fixture = TestBed.createComponent(ArtPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('channel', channel);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function getCustomType(
|
||||
type: 'm3u8' | 'ts' | 'mkv'
|
||||
): (video: HTMLVideoElement, url: string) => void {
|
||||
return (
|
||||
artPlayerInstances[0].options['customType'] as Record<
|
||||
string,
|
||||
(video: HTMLVideoElement, url: string) => void
|
||||
>
|
||||
)[type];
|
||||
}
|
||||
});
|
||||
@@ -11,10 +11,19 @@ import {
|
||||
SimpleChanges,
|
||||
} from '@angular/core';
|
||||
import Artplayer from 'artplayer';
|
||||
import Hls from 'hls.js';
|
||||
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
|
||||
import { getExtensionFromUrl } from 'm3u-utils';
|
||||
import mpegts from 'mpegts.js';
|
||||
import { Channel } from 'shared-interfaces';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnostic,
|
||||
classifyHlsPlaybackIssue,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
classifyNativePlaybackIssue,
|
||||
classifyUnsupportedHlsManifestCodecs,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
|
||||
type AudioTrackSelector = {
|
||||
html: string | HTMLElement;
|
||||
@@ -50,6 +59,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
|
||||
|
||||
private player!: Artplayer;
|
||||
private hls: Hls | null = null;
|
||||
@@ -57,6 +67,21 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
|
||||
private readonly elementRef = inject(ElementRef);
|
||||
|
||||
private readonly handleNativePlaybackError = () => {
|
||||
this.playbackIssue.emit(
|
||||
classifyNativePlaybackIssue(
|
||||
this.player?.video?.error,
|
||||
this.createSourceMetadata(
|
||||
this.channel?.url ?? this.player?.video?.currentSrc ?? ''
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly clearPlaybackIssue = () => {
|
||||
this.playbackIssue.emit(null);
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initPlayer();
|
||||
}
|
||||
@@ -85,11 +110,24 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
this.hls = null;
|
||||
}
|
||||
if (this.player) {
|
||||
this.player.video?.removeEventListener(
|
||||
'error',
|
||||
this.handleNativePlaybackError
|
||||
);
|
||||
this.player.video?.removeEventListener(
|
||||
'loadeddata',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
this.player.video?.removeEventListener(
|
||||
'playing',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
this.player.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private initPlayer(): void {
|
||||
this.playbackIssue.emit(null);
|
||||
const el = this.elementRef.nativeElement.querySelector(
|
||||
'.artplayer-container'
|
||||
);
|
||||
@@ -125,6 +163,12 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
this.hls.destroy();
|
||||
}
|
||||
this.hls = new Hls();
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
this.handleHlsManifestParsed(url, data);
|
||||
});
|
||||
this.hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
this.handleHlsError(url, data);
|
||||
});
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(video);
|
||||
this.setupHlsAudioTracks();
|
||||
@@ -145,20 +189,45 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
url: url,
|
||||
});
|
||||
this.mpegtsPlayer.attachMediaElement(video);
|
||||
this.mpegtsPlayer.on(
|
||||
mpegts.Events.ERROR,
|
||||
(
|
||||
type: string,
|
||||
details: string,
|
||||
info: unknown
|
||||
): void => {
|
||||
this.playbackIssue.emit(
|
||||
classifyMpegTsPlaybackIssue(
|
||||
{
|
||||
type,
|
||||
details,
|
||||
info,
|
||||
},
|
||||
this.createSourceMetadata(
|
||||
url,
|
||||
'video/mp2t'
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
this.mpegtsPlayer.load();
|
||||
this.mpegtsPlayer.play();
|
||||
}
|
||||
},
|
||||
mkv: function (video: HTMLVideoElement, url: string) {
|
||||
mkv: (video: HTMLVideoElement, url: string) => {
|
||||
video.src = url;
|
||||
video.onerror = () => {
|
||||
console.error('Error loading MKV file:', video.error);
|
||||
video.src = url;
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.player.video.addEventListener(
|
||||
'error',
|
||||
this.handleNativePlaybackError
|
||||
);
|
||||
this.player.video.addEventListener('loadeddata', this.clearPlaybackIssue);
|
||||
this.player.video.addEventListener('playing', this.clearPlaybackIssue);
|
||||
|
||||
if (this.startTime > 0) {
|
||||
this.player.on('ready', () => {
|
||||
this.player.seek = this.startTime;
|
||||
@@ -173,6 +242,44 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
});
|
||||
}
|
||||
|
||||
private handleHlsManifestParsed(
|
||||
url: string,
|
||||
data: ManifestParsedData
|
||||
): void {
|
||||
const metadata = this.createSourceMetadata(
|
||||
url,
|
||||
'application/x-mpegURL',
|
||||
data.levels
|
||||
.map((level) => level.audioCodec)
|
||||
.filter((codec): codec is string => Boolean(codec)),
|
||||
data.levels
|
||||
.map((level) => level.videoCodec)
|
||||
.filter((codec): codec is string => Boolean(codec))
|
||||
);
|
||||
const issue = classifyUnsupportedHlsManifestCodecs(metadata);
|
||||
if (issue) {
|
||||
this.playbackIssue.emit(issue);
|
||||
}
|
||||
}
|
||||
|
||||
private handleHlsError(url: string, data: ErrorData): void {
|
||||
if (!data.fatal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playbackIssue.emit(
|
||||
classifyHlsPlaybackIssue(
|
||||
{
|
||||
type: data.type,
|
||||
details: data.details,
|
||||
fatal: data.fatal,
|
||||
message: data.error?.message,
|
||||
},
|
||||
this.createSourceMetadata(url, 'application/x-mpegURL')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for HLS.js audio tracks and adds a settings menu entry
|
||||
* to ArtPlayer for switching between available audio tracks.
|
||||
@@ -232,4 +339,19 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
return 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
private createSourceMetadata(
|
||||
url: string,
|
||||
mimeType?: string,
|
||||
audioCodecs: readonly string[] = [],
|
||||
videoCodecs: readonly string[] = []
|
||||
) {
|
||||
return createPlaybackSourceMetadata({
|
||||
url,
|
||||
mimeType,
|
||||
player: InlinePlaybackPlayer.ArtPlayer,
|
||||
audioCodecs,
|
||||
videoCodecs,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -56,4 +56,57 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
|
||||
expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL);
|
||||
});
|
||||
|
||||
it('emits a playback issue when the native video element reports an unsupported source', () => {
|
||||
const issues: unknown[] = [];
|
||||
component.channel = TEST_CHANNEL as never;
|
||||
component.playbackIssue.subscribe((issue) => issues.push(issue));
|
||||
|
||||
Object.defineProperty(component.videoPlayer.nativeElement, 'error', {
|
||||
configurable: true,
|
||||
value: {
|
||||
code: 4,
|
||||
message: 'No compatible source was found',
|
||||
},
|
||||
});
|
||||
|
||||
component.videoPlayer.nativeElement.dispatchEvent(new Event('error'));
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: 'unsupported-container',
|
||||
source: 'native',
|
||||
sourceUrl: 'http://test.ts',
|
||||
externalFallbackRecommended: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not emit a playback issue when HLS.js reports a recoverable error', () => {
|
||||
const issues: unknown[] = [];
|
||||
component.playbackIssue.subscribe((issue) => {
|
||||
if (issue) issues.push(issue);
|
||||
});
|
||||
|
||||
(
|
||||
component as unknown as {
|
||||
handleHlsError: (
|
||||
url: string,
|
||||
data: {
|
||||
type: string;
|
||||
details: string;
|
||||
fatal: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
).handleHlsError('https://example.com/live/playlist.m3u8', {
|
||||
type: 'networkError',
|
||||
details: 'fragLoadError',
|
||||
fatal: false,
|
||||
error: new Error('segment retry'),
|
||||
});
|
||||
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,20 @@ import {
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import Hls from 'hls.js';
|
||||
import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js';
|
||||
import mpegts from 'mpegts.js';
|
||||
import { getExtensionFromUrl } from 'm3u-utils';
|
||||
import { DataService } from 'services';
|
||||
import { Channel } from 'shared-interfaces';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnostic,
|
||||
classifyHlsPlaybackIssue,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
classifyNativePlaybackIssue,
|
||||
classifyUnsupportedHlsManifestCodecs,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
|
||||
/**
|
||||
* This component contains the implementation of HTML5 based video player
|
||||
@@ -35,6 +44,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
|
||||
|
||||
private readonly dataService = inject(DataService);
|
||||
|
||||
@@ -50,6 +60,23 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** Captions/subtitles indicator */
|
||||
@Input() showCaptions!: boolean;
|
||||
|
||||
private readonly handleNativePlaybackError = () => {
|
||||
const metadata = this.createSourceMetadata(
|
||||
this.channel?.url ?? this.videoPlayer.nativeElement.currentSrc
|
||||
);
|
||||
|
||||
this.playbackIssue.emit(
|
||||
classifyNativePlaybackIssue(
|
||||
this.videoPlayer.nativeElement.error,
|
||||
metadata
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
private readonly clearPlaybackIssue = () => {
|
||||
this.playbackIssue.emit(null);
|
||||
};
|
||||
|
||||
ngOnInit() {
|
||||
this.videoPlayer.nativeElement.addEventListener('volumechange', () => {
|
||||
this.onVolumeChange();
|
||||
@@ -67,6 +94,19 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
duration: this.videoPlayer.nativeElement.duration,
|
||||
});
|
||||
});
|
||||
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'error',
|
||||
this.handleNativePlaybackError
|
||||
);
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'loadeddata',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
this.videoPlayer.nativeElement.addEventListener(
|
||||
'playing',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,6 +141,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
if (this.hls) this.hls.destroy();
|
||||
if (channel.url) {
|
||||
this.playbackIssue.emit(null);
|
||||
const url = channel.url + (channel.epgParams ?? '');
|
||||
const extension = getExtensionFromUrl(channel.url);
|
||||
|
||||
@@ -122,6 +163,25 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.mpegtsPlayer.attachMediaElement(
|
||||
this.videoPlayer.nativeElement
|
||||
);
|
||||
this.mpegtsPlayer.on(
|
||||
mpegts.Events.ERROR,
|
||||
(
|
||||
type: string,
|
||||
details: string,
|
||||
info: unknown
|
||||
): void => {
|
||||
this.playbackIssue.emit(
|
||||
classifyMpegTsPlaybackIssue(
|
||||
{
|
||||
type,
|
||||
details,
|
||||
info,
|
||||
},
|
||||
this.createSourceMetadata(url, 'video/mp2t')
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
this.mpegtsPlayer.load();
|
||||
this.handlePlayOperation();
|
||||
} else if (
|
||||
@@ -132,6 +192,12 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
) {
|
||||
console.log('... switching channel to ', channel.name, url);
|
||||
this.hls = new Hls();
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||
this.handleHlsManifestParsed(url, data);
|
||||
});
|
||||
this.hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
this.handleHlsError(url, data);
|
||||
});
|
||||
this.hls.attachMedia(this.videoPlayer.nativeElement);
|
||||
this.hls.loadSource(url);
|
||||
this.handlePlayOperation();
|
||||
@@ -187,6 +253,59 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private handleHlsManifestParsed(
|
||||
url: string,
|
||||
data: ManifestParsedData
|
||||
): void {
|
||||
const metadata = this.createSourceMetadata(
|
||||
url,
|
||||
'application/x-mpegURL',
|
||||
data.levels
|
||||
.map((level) => level.audioCodec)
|
||||
.filter((codec): codec is string => Boolean(codec)),
|
||||
data.levels
|
||||
.map((level) => level.videoCodec)
|
||||
.filter((codec): codec is string => Boolean(codec))
|
||||
);
|
||||
const issue = classifyUnsupportedHlsManifestCodecs(metadata);
|
||||
if (issue) {
|
||||
this.playbackIssue.emit(issue);
|
||||
}
|
||||
}
|
||||
|
||||
private handleHlsError(url: string, data: ErrorData): void {
|
||||
if (!data.fatal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playbackIssue.emit(
|
||||
classifyHlsPlaybackIssue(
|
||||
{
|
||||
type: data.type,
|
||||
details: data.details,
|
||||
fatal: data.fatal,
|
||||
message: data.error?.message,
|
||||
},
|
||||
this.createSourceMetadata(url, 'application/x-mpegURL')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private createSourceMetadata(
|
||||
url: string,
|
||||
mimeType?: string,
|
||||
audioCodecs: readonly string[] = [],
|
||||
videoCodecs: readonly string[] = []
|
||||
) {
|
||||
return createPlaybackSourceMetadata({
|
||||
url,
|
||||
mimeType,
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
audioCodecs,
|
||||
videoCodecs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save volume when user changes it
|
||||
*/
|
||||
@@ -204,6 +323,18 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
'volumechange',
|
||||
this.onVolumeChange
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'error',
|
||||
this.handleNativePlaybackError
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'loadeddata',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
this.videoPlayer.nativeElement.removeEventListener(
|
||||
'playing',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
if (this.mpegtsPlayer) {
|
||||
this.mpegtsPlayer.pause();
|
||||
this.mpegtsPlayer.unload();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
PlaybackDiagnosticCode,
|
||||
classifyHlsPlaybackIssue,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
classifyNativePlaybackIssue,
|
||||
createPlaybackSourceMetadata,
|
||||
} from './playback-diagnostics.util';
|
||||
|
||||
describe('playback diagnostics', () => {
|
||||
it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
{
|
||||
type: 'mediaError',
|
||||
details: 'manifestIncompatibleCodecsError',
|
||||
fatal: true,
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
mimeType: 'application/x-mpegURL',
|
||||
player: 'videojs',
|
||||
audioCodecs: ['ac-3'],
|
||||
videoCodecs: ['avc1.64001f'],
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
expect(issue.externalFallbackRecommended).toBe(true);
|
||||
expect(issue.audioCodecs).toEqual(['ac-3']);
|
||||
expect(issue.videoCodecs).toEqual(['avc1.64001f']);
|
||||
});
|
||||
|
||||
it('classifies HLS buffer codec errors as unsupported codec fallbacks', () => {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
{
|
||||
type: 'mediaError',
|
||||
details: 'bufferIncompatibleCodecsError',
|
||||
fatal: true,
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
player: 'html5',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
expect(issue.externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies native decode and unsupported source errors without using network wording', () => {
|
||||
const decodeIssue = classifyNativePlaybackIssue(
|
||||
{ code: 3, message: 'decode failed' },
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/movie.mp4',
|
||||
mimeType: 'video/mp4',
|
||||
player: 'artplayer',
|
||||
})
|
||||
);
|
||||
const unsupportedSourceIssue = classifyNativePlaybackIssue(
|
||||
{ code: 4, message: 'source not supported' },
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/movie.mkv',
|
||||
mimeType: 'video/matroska',
|
||||
player: 'artplayer',
|
||||
})
|
||||
);
|
||||
|
||||
expect(decodeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
expect(decodeIssue.externalFallbackRecommended).toBe(true);
|
||||
expect(unsupportedSourceIssue.code).toBe(
|
||||
PlaybackDiagnosticCode.UnsupportedContainer
|
||||
);
|
||||
expect(unsupportedSourceIssue.externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies HLS network errors without claiming codec incompatibility', () => {
|
||||
const issue = classifyHlsPlaybackIssue(
|
||||
{
|
||||
type: 'networkError',
|
||||
details: 'manifestLoadError',
|
||||
fatal: true,
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/index.m3u8',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies mpegts codec errors as unsupported codec fallbacks', () => {
|
||||
const issue = classifyMpegTsPlaybackIssue(
|
||||
{
|
||||
type: 'MediaError',
|
||||
details: 'MediaCodecUnsupported',
|
||||
},
|
||||
createPlaybackSourceMetadata({
|
||||
url: 'https://example.com/live/channel.ts',
|
||||
mimeType: 'video/mp2t',
|
||||
player: 'videojs',
|
||||
})
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
expect(issue.externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers stream extension query metadata over web script path extensions', () => {
|
||||
const metadata = createPlaybackSourceMetadata({
|
||||
url: 'http://portal.example/play/live.php?stream=123&extension=ts',
|
||||
player: 'html5',
|
||||
});
|
||||
|
||||
expect(metadata.extension).toBe('ts');
|
||||
expect(metadata.container).toBe('ts');
|
||||
});
|
||||
|
||||
it('does not expose web script extensions as media containers', () => {
|
||||
const metadata = createPlaybackSourceMetadata({
|
||||
url: 'http://portal.example/play/live.php?stream=123',
|
||||
player: 'html5',
|
||||
});
|
||||
|
||||
expect(metadata.extension).toBe('');
|
||||
expect(metadata.container).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
import { getExtensionFromUrl } from 'm3u-utils';
|
||||
import type { ExternalPlayerName, ResolvedPortalPlayback } from 'shared-interfaces';
|
||||
|
||||
export const PlaybackDiagnosticCode = {
|
||||
UnsupportedContainer: 'unsupported-container',
|
||||
UnsupportedCodec: 'unsupported-codec',
|
||||
MediaDecodeError: 'media-decode-error',
|
||||
NetworkError: 'network-error',
|
||||
DrmOrEncryption: 'drm-or-encryption',
|
||||
UnknownPlaybackError: 'unknown-playback-error',
|
||||
} as const;
|
||||
|
||||
export type PlaybackDiagnosticCode =
|
||||
(typeof PlaybackDiagnosticCode)[keyof typeof PlaybackDiagnosticCode];
|
||||
|
||||
export const PlaybackDiagnosticSource = {
|
||||
Source: 'source',
|
||||
Native: 'native',
|
||||
Hls: 'hls',
|
||||
MpegTs: 'mpegts',
|
||||
} as const;
|
||||
|
||||
export type PlaybackDiagnosticSource =
|
||||
(typeof PlaybackDiagnosticSource)[keyof typeof PlaybackDiagnosticSource];
|
||||
|
||||
export const InlinePlaybackPlayer = {
|
||||
VideoJs: 'videojs',
|
||||
Html5: 'html5',
|
||||
ArtPlayer: 'artplayer',
|
||||
} as const;
|
||||
|
||||
export type InlinePlaybackPlayer =
|
||||
(typeof InlinePlaybackPlayer)[keyof typeof InlinePlaybackPlayer];
|
||||
|
||||
export interface PlaybackSourceMetadataInput {
|
||||
readonly url: string;
|
||||
readonly mimeType?: string;
|
||||
readonly player?: InlinePlaybackPlayer;
|
||||
readonly audioCodecs?: readonly string[];
|
||||
readonly videoCodecs?: readonly string[];
|
||||
}
|
||||
|
||||
export interface PlaybackSourceMetadata {
|
||||
readonly url: string;
|
||||
readonly extension: string;
|
||||
readonly container: string;
|
||||
readonly mimeType?: string;
|
||||
readonly player?: InlinePlaybackPlayer;
|
||||
readonly audioCodecs: readonly string[];
|
||||
readonly videoCodecs: readonly string[];
|
||||
}
|
||||
|
||||
export interface NativePlaybackErrorInput {
|
||||
readonly code?: number;
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
export interface HlsPlaybackErrorInput {
|
||||
readonly type?: string;
|
||||
readonly details?: string;
|
||||
readonly fatal?: boolean;
|
||||
readonly message?: string;
|
||||
readonly error?: unknown;
|
||||
readonly audioCodecs?: readonly string[];
|
||||
readonly videoCodecs?: readonly string[];
|
||||
}
|
||||
|
||||
export interface MpegTsPlaybackErrorInput {
|
||||
readonly type?: string;
|
||||
readonly details?: string;
|
||||
readonly message?: string;
|
||||
readonly info?: unknown;
|
||||
}
|
||||
|
||||
export interface PlaybackDiagnostic {
|
||||
readonly code: PlaybackDiagnosticCode;
|
||||
readonly source: PlaybackDiagnosticSource;
|
||||
readonly sourceUrl: string;
|
||||
readonly container: string;
|
||||
readonly mimeType?: string;
|
||||
readonly player?: InlinePlaybackPlayer;
|
||||
readonly audioCodecs: readonly string[];
|
||||
readonly videoCodecs: readonly string[];
|
||||
readonly details?: string;
|
||||
readonly nativeErrorCode?: number;
|
||||
readonly nativeErrorMessage?: string;
|
||||
readonly externalFallbackRecommended: boolean;
|
||||
}
|
||||
|
||||
export interface PlaybackFallbackRequest {
|
||||
readonly player: ExternalPlayerName;
|
||||
readonly playback: ResolvedPortalPlayback;
|
||||
readonly diagnostic: PlaybackDiagnostic;
|
||||
}
|
||||
|
||||
const UNSUPPORTED_CONTAINER_EXTENSIONS = new Set([
|
||||
'avi',
|
||||
'asf',
|
||||
'divx',
|
||||
'flv',
|
||||
'm2ts',
|
||||
'm4v',
|
||||
'mkv',
|
||||
'mov',
|
||||
'mpeg',
|
||||
'mpg',
|
||||
'rm',
|
||||
'rmvb',
|
||||
'ts',
|
||||
'vob',
|
||||
'wmv',
|
||||
]);
|
||||
|
||||
const NON_MEDIA_URL_EXTENSIONS = new Set([
|
||||
'asp',
|
||||
'aspx',
|
||||
'cgi',
|
||||
'jsp',
|
||||
'php',
|
||||
'pl',
|
||||
]);
|
||||
|
||||
const SOURCE_NOT_SUPPORTED_CODE = 4;
|
||||
const DECODE_ERROR_CODE = 3;
|
||||
const NETWORK_ERROR_CODE = 2;
|
||||
|
||||
export function createPlaybackSourceMetadata(
|
||||
input: PlaybackSourceMetadataInput
|
||||
): PlaybackSourceMetadata {
|
||||
const extension = getMediaExtensionFromUrl(input.url);
|
||||
const mimeType = input.mimeType?.trim() || undefined;
|
||||
|
||||
return {
|
||||
url: input.url,
|
||||
extension,
|
||||
container: extension || inferContainerFromMimeType(mimeType),
|
||||
mimeType,
|
||||
player: input.player,
|
||||
audioCodecs: normalizeCodecs(input.audioCodecs),
|
||||
videoCodecs: normalizeCodecs(input.videoCodecs),
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyNativePlaybackIssue(
|
||||
error: NativePlaybackErrorInput | MediaError | null | undefined,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
const nativeErrorCode = error?.code;
|
||||
const nativeErrorMessage = error?.message || undefined;
|
||||
|
||||
if (nativeErrorCode === NETWORK_ERROR_CODE) {
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
source: PlaybackDiagnosticSource.Native,
|
||||
metadata,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (nativeErrorCode === DECODE_ERROR_CODE) {
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.MediaDecodeError,
|
||||
source: PlaybackDiagnosticSource.Native,
|
||||
metadata,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (nativeErrorCode === SOURCE_NOT_SUPPORTED_CODE) {
|
||||
return createDiagnostic({
|
||||
code: isLikelyContainerIssue(metadata)
|
||||
? PlaybackDiagnosticCode.UnsupportedContainer
|
||||
: PlaybackDiagnosticCode.UnsupportedCodec,
|
||||
source: PlaybackDiagnosticSource.Native,
|
||||
metadata,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.UnknownPlaybackError,
|
||||
source: PlaybackDiagnosticSource.Native,
|
||||
metadata,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
export function classifyHlsPlaybackIssue(
|
||||
error: HlsPlaybackErrorInput,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
const details = normalizeErrorDetails(error);
|
||||
const lowerDetails = details.toLowerCase();
|
||||
const lowerType = (error.type ?? '').toLowerCase();
|
||||
const mergedMetadata = mergeCodecMetadata(metadata, {
|
||||
audioCodecs: error.audioCodecs,
|
||||
videoCodecs: error.videoCodecs,
|
||||
});
|
||||
|
||||
if (isNetworkFailure(lowerType, lowerDetails)) {
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
source: PlaybackDiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDrmOrEncryptionFailure(lowerDetails)) {
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.DrmOrEncryption,
|
||||
source: PlaybackDiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCodecFailure(lowerDetails)) {
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.UnsupportedCodec,
|
||||
source: PlaybackDiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (lowerType.includes('media') || lowerType.includes('mux')) {
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.MediaDecodeError,
|
||||
source: PlaybackDiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.UnknownPlaybackError,
|
||||
source: PlaybackDiagnosticSource.Hls,
|
||||
metadata: mergedMetadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
export function classifyMpegTsPlaybackIssue(
|
||||
error: MpegTsPlaybackErrorInput,
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic {
|
||||
const details = normalizeErrorDetails(error);
|
||||
const lowerDetails = details.toLowerCase();
|
||||
const lowerType = (error.type ?? '').toLowerCase();
|
||||
|
||||
if (isNetworkFailure(lowerType, lowerDetails)) {
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.NetworkError,
|
||||
source: PlaybackDiagnosticSource.MpegTs,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (lowerDetails.includes('codec')) {
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.UnsupportedCodec,
|
||||
source: PlaybackDiagnosticSource.MpegTs,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (lowerDetails.includes('format') || lowerDetails.includes('mse')) {
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.UnsupportedContainer,
|
||||
source: PlaybackDiagnosticSource.MpegTs,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
if (lowerType.includes('media')) {
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.MediaDecodeError,
|
||||
source: PlaybackDiagnosticSource.MpegTs,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.UnknownPlaybackError,
|
||||
source: PlaybackDiagnosticSource.MpegTs,
|
||||
metadata,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
export function classifyUnsupportedHlsManifestCodecs(
|
||||
metadata: PlaybackSourceMetadata
|
||||
): PlaybackDiagnostic | null {
|
||||
if (!hasCodecs(metadata) || typeof MediaSource === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codecList = [...metadata.videoCodecs, ...metadata.audioCodecs];
|
||||
const mimeType = `video/mp4; codecs="${codecList.join(',')}"`;
|
||||
|
||||
if (MediaSource.isTypeSupported(mimeType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createDiagnostic({
|
||||
code: PlaybackDiagnosticCode.UnsupportedCodec,
|
||||
source: PlaybackDiagnosticSource.Source,
|
||||
metadata,
|
||||
details: mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
function createDiagnostic(options: {
|
||||
readonly code: PlaybackDiagnosticCode;
|
||||
readonly source: PlaybackDiagnosticSource;
|
||||
readonly metadata: PlaybackSourceMetadata;
|
||||
readonly details?: string;
|
||||
readonly nativeErrorCode?: number;
|
||||
readonly nativeErrorMessage?: string;
|
||||
}): PlaybackDiagnostic {
|
||||
const {
|
||||
code,
|
||||
source,
|
||||
metadata,
|
||||
details,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
} = options;
|
||||
|
||||
return {
|
||||
code,
|
||||
source,
|
||||
sourceUrl: metadata.url,
|
||||
container: metadata.container,
|
||||
mimeType: metadata.mimeType,
|
||||
player: metadata.player,
|
||||
audioCodecs: metadata.audioCodecs,
|
||||
videoCodecs: metadata.videoCodecs,
|
||||
details: details || undefined,
|
||||
nativeErrorCode,
|
||||
nativeErrorMessage,
|
||||
externalFallbackRecommended: isExternalFallbackRecommended(code),
|
||||
};
|
||||
}
|
||||
|
||||
function isExternalFallbackRecommended(code: PlaybackDiagnosticCode): boolean {
|
||||
return (
|
||||
code === PlaybackDiagnosticCode.UnsupportedContainer ||
|
||||
code === PlaybackDiagnosticCode.UnsupportedCodec ||
|
||||
code === PlaybackDiagnosticCode.MediaDecodeError ||
|
||||
code === PlaybackDiagnosticCode.DrmOrEncryption
|
||||
);
|
||||
}
|
||||
|
||||
function mergeCodecMetadata(
|
||||
metadata: PlaybackSourceMetadata,
|
||||
codecs: Pick<PlaybackSourceMetadataInput, 'audioCodecs' | 'videoCodecs'>
|
||||
): PlaybackSourceMetadata {
|
||||
const audioCodecs = normalizeCodecs([
|
||||
...metadata.audioCodecs,
|
||||
...(codecs.audioCodecs ?? []),
|
||||
]);
|
||||
const videoCodecs = normalizeCodecs([
|
||||
...metadata.videoCodecs,
|
||||
...(codecs.videoCodecs ?? []),
|
||||
]);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
audioCodecs,
|
||||
videoCodecs,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCodecs(codecs: readonly string[] | undefined): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(codecs ?? [])
|
||||
.map((codec) => codec.trim())
|
||||
.filter((codec) => codec.length > 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeToken(value: string | undefined): string {
|
||||
return value?.trim().toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
function normalizeExtensionToken(value: string | undefined): string {
|
||||
return normalizeToken(value).replace(/^\.+/, '');
|
||||
}
|
||||
|
||||
function getMediaExtensionFromUrl(url: string): string {
|
||||
const queryExtension = getMediaExtensionFromQuery(url);
|
||||
if (queryExtension) {
|
||||
return queryExtension;
|
||||
}
|
||||
|
||||
const pathExtension = normalizeExtensionToken(getExtensionFromUrl(url));
|
||||
if (NON_MEDIA_URL_EXTENSIONS.has(pathExtension)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return pathExtension;
|
||||
}
|
||||
|
||||
function getMediaExtensionFromQuery(url: string): string {
|
||||
try {
|
||||
const parsedUrl = new URL(url, 'http://iptvnator.local');
|
||||
const declaredExtension = normalizeExtensionToken(
|
||||
parsedUrl.searchParams.get('extension') ?? undefined
|
||||
);
|
||||
|
||||
if (!declaredExtension) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return NON_MEDIA_URL_EXTENSIONS.has(declaredExtension)
|
||||
? ''
|
||||
: declaredExtension;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function inferContainerFromMimeType(mimeType: string | undefined): string {
|
||||
if (!mimeType) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const subtype = mimeType.split(';')[0]?.split('/')[1] ?? '';
|
||||
return subtype.toLowerCase();
|
||||
}
|
||||
|
||||
function isLikelyContainerIssue(metadata: PlaybackSourceMetadata): boolean {
|
||||
return (
|
||||
UNSUPPORTED_CONTAINER_EXTENSIONS.has(metadata.extension) ||
|
||||
metadata.mimeType === 'video/matroska'
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeErrorDetails(
|
||||
error: HlsPlaybackErrorInput | MpegTsPlaybackErrorInput
|
||||
): string {
|
||||
const hlsError = 'error' in error ? error.error : undefined;
|
||||
const mpegTsInfo = 'info' in error ? error.info : undefined;
|
||||
const errorMessage =
|
||||
hlsError instanceof Error
|
||||
? hlsError.message
|
||||
: typeof hlsError === 'string'
|
||||
? hlsError
|
||||
: '';
|
||||
const info =
|
||||
typeof mpegTsInfo === 'string'
|
||||
? mpegTsInfo
|
||||
: mpegTsInfo
|
||||
? JSON.stringify(mpegTsInfo)
|
||||
: '';
|
||||
|
||||
return [error.details, error.message, errorMessage, info]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function isNetworkFailure(type: string, details: string): boolean {
|
||||
return (
|
||||
type.includes('network') ||
|
||||
details.includes('network') ||
|
||||
details.includes('loaderror') ||
|
||||
details.includes('timeout') ||
|
||||
details.includes('status')
|
||||
);
|
||||
}
|
||||
|
||||
function isCodecFailure(details: string): boolean {
|
||||
return (
|
||||
details.includes('codec') ||
|
||||
details.includes('incompatiblecodecs') ||
|
||||
details.includes('addcodec')
|
||||
);
|
||||
}
|
||||
|
||||
function isDrmOrEncryptionFailure(details: string): boolean {
|
||||
return (
|
||||
details.includes('decrypt') ||
|
||||
details.includes('keysystem') ||
|
||||
details.includes('keyload') ||
|
||||
details.includes('license') ||
|
||||
details.includes('drm')
|
||||
);
|
||||
}
|
||||
|
||||
function hasCodecs(metadata: PlaybackSourceMetadata): boolean {
|
||||
return metadata.audioCodecs.length > 0 || metadata.videoCodecs.length > 0;
|
||||
}
|
||||
@@ -36,6 +36,9 @@
|
||||
[playback]="playback()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="onTimeUpdate($event)"
|
||||
(externalFallbackRequested)="
|
||||
onExternalFallbackRequested($event)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PlayerContentInfo,
|
||||
ResolvedPortalPlayback,
|
||||
} from 'shared-interfaces';
|
||||
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import { WebPlayerViewComponent } from '../web-player-view/web-player-view.component';
|
||||
|
||||
@Component({
|
||||
@@ -48,6 +49,7 @@ export class PortalInlinePlayerComponent {
|
||||
duration: number;
|
||||
}>();
|
||||
readonly streamUrlCopied = output<void>();
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
|
||||
onClose(): void {
|
||||
this.closed.emit();
|
||||
@@ -60,4 +62,8 @@ export class PortalInlinePlayerComponent {
|
||||
onCopied(): void {
|
||||
this.streamUrlCopied.emit();
|
||||
}
|
||||
|
||||
onExternalFallbackRequested(request: PlaybackFallbackRequest): void {
|
||||
this.externalFallbackRequested.emit(request);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ jest.unstable_mockModule('mpegts.js', () => ({
|
||||
default: {
|
||||
createPlayer: jest.fn(),
|
||||
isSupported: mpegTsIsSupportedMock,
|
||||
Events: {
|
||||
ERROR: 'error',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -24,11 +27,18 @@ describe('VjsPlayerComponent', () => {
|
||||
let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent;
|
||||
let component: VjsPlayerComponentInstance;
|
||||
let player: VjsPlayerComponentInstance['player'];
|
||||
|
||||
type SignalApiShape = {
|
||||
options: () => unknown;
|
||||
volume: () => number;
|
||||
startTime: () => number;
|
||||
timeUpdate: { emit: (event: unknown) => void };
|
||||
playbackIssue: {
|
||||
emit: (event: unknown) => void;
|
||||
subscribe: (callback: (event: unknown) => void) => {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -40,8 +50,10 @@ describe('VjsPlayerComponent', () => {
|
||||
imports: [VjsPlayerComponent],
|
||||
}).compileComponents();
|
||||
|
||||
component = TestBed.createComponent(VjsPlayerComponent).componentInstance;
|
||||
component =
|
||||
TestBed.createComponent(VjsPlayerComponent).componentInstance;
|
||||
player = {
|
||||
error: jest.fn(),
|
||||
src: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
volume: jest.fn(),
|
||||
@@ -57,6 +69,7 @@ describe('VjsPlayerComponent', () => {
|
||||
expect(signalComponent.volume()).toBe(1);
|
||||
expect(signalComponent.startTime()).toBe(0);
|
||||
expect(typeof signalComponent.timeUpdate.emit).toBe('function');
|
||||
expect(typeof signalComponent.playbackIssue.emit).toBe('function');
|
||||
});
|
||||
|
||||
it('does not reset VideoJS when options change without changing the source', () => {
|
||||
@@ -136,6 +149,46 @@ describe('VjsPlayerComponent', () => {
|
||||
expect(player.volume).toHaveBeenCalledWith(0.75);
|
||||
});
|
||||
|
||||
it('emits a playback issue when VideoJS reports an unsupported source', () => {
|
||||
const issues: unknown[] = [];
|
||||
const videoElement = document.createElement('video');
|
||||
const testComponent = component as unknown as SignalApiShape & {
|
||||
options: () => {
|
||||
sources: Array<{ src: string; type: string }>;
|
||||
};
|
||||
target: () => { nativeElement: HTMLVideoElement };
|
||||
handleVideoJsPlaybackError: () => void;
|
||||
};
|
||||
testComponent.options = () => ({
|
||||
sources: [
|
||||
{
|
||||
src: 'https://example.com/archive/movie.mkv',
|
||||
type: 'video/matroska',
|
||||
},
|
||||
],
|
||||
});
|
||||
testComponent.target = () => ({ nativeElement: videoElement });
|
||||
jest.mocked(player.error).mockReturnValue({
|
||||
code: 4,
|
||||
message: 'No compatible source was found',
|
||||
});
|
||||
|
||||
const subscription = component.playbackIssue.subscribe((issue) =>
|
||||
issues.push(issue)
|
||||
);
|
||||
testComponent.handleVideoJsPlaybackError();
|
||||
subscription.unsubscribe();
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: 'unsupported-container',
|
||||
source: 'native',
|
||||
sourceUrl: 'https://example.com/archive/movie.mkv',
|
||||
externalFallbackRecommended: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('tears down mpegts playback when options clear the source', () => {
|
||||
const mpegtsPlayer = {
|
||||
pause: jest.fn(),
|
||||
|
||||
@@ -16,6 +16,13 @@ import mpegts from 'mpegts.js';
|
||||
import videoJs from 'video.js';
|
||||
import 'videojs-contrib-quality-levels';
|
||||
import 'videojs-quality-selector-hls';
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
PlaybackDiagnostic,
|
||||
classifyMpegTsPlaybackIssue,
|
||||
classifyNativePlaybackIssue,
|
||||
createPlaybackSourceMetadata,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
|
||||
/**
|
||||
* This component contains the implementation of video player that is based on video.js library
|
||||
@@ -71,11 +78,14 @@ type VideoJsPlayer = Omit<
|
||||
ReturnType<typeof videoJs>,
|
||||
'audioTracks' | 'tech' | 'getChild'
|
||||
> & {
|
||||
qualitySelectorHls?: (options?: { displayCurrentQuality?: boolean }) => void;
|
||||
qualitySelectorHls?: (options?: {
|
||||
displayCurrentQuality?: boolean;
|
||||
}) => void;
|
||||
aspectRatioPanel?: () => void;
|
||||
audioTracks: () => VideoJsAudioTrackList | null;
|
||||
tech: (options?: unknown) => VideoJsTech | null;
|
||||
getChild: (name: string) => VideoJsControlChild | null;
|
||||
error: () => { code?: number; message?: string } | null;
|
||||
};
|
||||
|
||||
@Component({
|
||||
@@ -100,6 +110,11 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
|
||||
private readonly clearPlaybackIssue = () => {
|
||||
this.playbackIssue.emit(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Instantiate Video.js on component init
|
||||
@@ -107,72 +122,76 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
ngOnInit(): void {
|
||||
const source = this.options().sources?.[0];
|
||||
const isMpegTs = this.isMpegTsSource(source?.src);
|
||||
const targetVideo = this.target().nativeElement as HTMLVideoElement;
|
||||
targetVideo.addEventListener('loadeddata', this.clearPlaybackIssue);
|
||||
targetVideo.addEventListener('playing', this.clearPlaybackIssue);
|
||||
|
||||
// For raw MPEG-TS streams, init Video.js without a source (UI/controls only)
|
||||
const vjsOptions = isMpegTs
|
||||
? { ...this.options(), sources: [], autoplay: false }
|
||||
: { ...this.options(), autoplay: true };
|
||||
|
||||
this.player = videoJs(
|
||||
this.target().nativeElement,
|
||||
vjsOptions,
|
||||
() => {
|
||||
console.log(
|
||||
'Setting VideoJS player initial volume to:',
|
||||
this.volume()
|
||||
);
|
||||
this.player.volume(this.volume());
|
||||
this.player = videoJs(this.target().nativeElement, vjsOptions, () => {
|
||||
console.log(
|
||||
'Setting VideoJS player initial volume to:',
|
||||
this.volume()
|
||||
);
|
||||
this.player.volume(this.volume());
|
||||
|
||||
this.player.on('loadedmetadata', () => {
|
||||
if (this.startTime() > 0) {
|
||||
this.player.currentTime(this.startTime());
|
||||
}
|
||||
this.player.on('loadedmetadata', () => {
|
||||
if (this.startTime() > 0) {
|
||||
this.player.currentTime(this.startTime());
|
||||
}
|
||||
this.playbackIssue.emit(null);
|
||||
this.logAudioTracks();
|
||||
this.setupAudioTrackMenu();
|
||||
});
|
||||
|
||||
this.player.on('error', () => {
|
||||
this.handleVideoJsPlaybackError();
|
||||
});
|
||||
|
||||
// Audio tracks may be added after loadedmetadata (e.g. HLS alternate audio)
|
||||
const audioTracks = this.player.audioTracks();
|
||||
if (audioTracks) {
|
||||
audioTracks.addEventListener('addtrack', () => {
|
||||
console.log(
|
||||
'[AudioTrack] addtrack event fired, total tracks:',
|
||||
this.player.audioTracks().length
|
||||
);
|
||||
this.logAudioTracks();
|
||||
this.setupAudioTrackMenu();
|
||||
});
|
||||
|
||||
// Audio tracks may be added after loadedmetadata (e.g. HLS alternate audio)
|
||||
const audioTracks = this.player.audioTracks();
|
||||
if (audioTracks) {
|
||||
audioTracks.addEventListener('addtrack', () => {
|
||||
console.log(
|
||||
'[AudioTrack] addtrack event fired, total tracks:',
|
||||
this.player.audioTracks().length
|
||||
);
|
||||
this.logAudioTracks();
|
||||
this.setupAudioTrackMenu();
|
||||
});
|
||||
audioTracks.addEventListener('removetrack', () => {
|
||||
console.log(
|
||||
'[AudioTrack] removetrack event fired, total tracks:',
|
||||
this.player.audioTracks().length
|
||||
);
|
||||
this.logAudioTracks();
|
||||
this.setupAudioTrackMenu();
|
||||
});
|
||||
audioTracks.addEventListener('change', () => {
|
||||
this.logAudioTracks();
|
||||
});
|
||||
}
|
||||
|
||||
this.player.on('volumechange', () => {
|
||||
const currentVolume = this.player.volume();
|
||||
localStorage.setItem('volume', currentVolume.toString());
|
||||
audioTracks.addEventListener('removetrack', () => {
|
||||
console.log(
|
||||
'[AudioTrack] removetrack event fired, total tracks:',
|
||||
this.player.audioTracks().length
|
||||
);
|
||||
this.logAudioTracks();
|
||||
this.setupAudioTrackMenu();
|
||||
});
|
||||
|
||||
this.player.on('timeupdate', () => {
|
||||
this.timeUpdate.emit({
|
||||
currentTime: this.player.currentTime(),
|
||||
duration: this.player.duration(),
|
||||
});
|
||||
audioTracks.addEventListener('change', () => {
|
||||
this.logAudioTracks();
|
||||
});
|
||||
|
||||
// Attach mpegts.js after Video.js is ready
|
||||
if (isMpegTs) {
|
||||
this.initMpegTs(source.src);
|
||||
}
|
||||
}
|
||||
) as unknown as VideoJsPlayer;
|
||||
|
||||
this.player.on('volumechange', () => {
|
||||
const currentVolume = this.player.volume();
|
||||
localStorage.setItem('volume', currentVolume.toString());
|
||||
});
|
||||
|
||||
this.player.on('timeupdate', () => {
|
||||
this.timeUpdate.emit({
|
||||
currentTime: this.player.currentTime(),
|
||||
duration: this.player.duration(),
|
||||
});
|
||||
});
|
||||
|
||||
// Attach mpegts.js after Video.js is ready
|
||||
if (isMpegTs) {
|
||||
this.initMpegTs(source.src);
|
||||
}
|
||||
}) as unknown as VideoJsPlayer;
|
||||
try {
|
||||
if (typeof this.player.qualitySelectorHls === 'function') {
|
||||
this.player.qualitySelectorHls({
|
||||
@@ -201,6 +220,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
changes['options'].previousValue.sources?.[0];
|
||||
const newSource = changes['options'].currentValue.sources?.[0];
|
||||
if (this.hasSourceChanged(previousSource, newSource)) {
|
||||
this.playbackIssue.emit(null);
|
||||
this.destroyMpegTs();
|
||||
if (!newSource) {
|
||||
this.player.reset();
|
||||
@@ -226,6 +246,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.destroyMpegTs();
|
||||
this.removeNativePlaybackListeners();
|
||||
if (this.player) {
|
||||
this.player.dispose();
|
||||
}
|
||||
@@ -246,8 +267,23 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
private removeNativePlaybackListeners(): void {
|
||||
try {
|
||||
const targetVideo = this.target().nativeElement as HTMLVideoElement;
|
||||
targetVideo.removeEventListener(
|
||||
'loadeddata',
|
||||
this.clearPlaybackIssue
|
||||
);
|
||||
targetVideo.removeEventListener('playing', this.clearPlaybackIssue);
|
||||
} catch {
|
||||
// Required viewChild can be unavailable when a shallow unit test destroys an unrendered component.
|
||||
}
|
||||
}
|
||||
|
||||
private initMpegTs(url: string): void {
|
||||
const videoEl = this.player.tech({ IWillNotUseThisInPlugins: true })?.el();
|
||||
const videoEl = this.player
|
||||
.tech({ IWillNotUseThisInPlugins: true })
|
||||
?.el();
|
||||
if (!videoEl) return;
|
||||
|
||||
console.log('Using mpegts.js for TS stream:', url);
|
||||
@@ -257,10 +293,53 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
url: url,
|
||||
});
|
||||
this.mpegtsPlayer.attachMediaElement(videoEl as HTMLVideoElement);
|
||||
this.mpegtsPlayer.on(
|
||||
mpegts.Events.ERROR,
|
||||
(type: string, details: string, info: unknown): void => {
|
||||
this.playbackIssue.emit(
|
||||
classifyMpegTsPlaybackIssue(
|
||||
{
|
||||
type,
|
||||
details,
|
||||
info,
|
||||
},
|
||||
this.createSourceMetadata(url, 'video/mp2t')
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
this.mpegtsPlayer.load();
|
||||
this.mpegtsPlayer.play();
|
||||
}
|
||||
|
||||
private handleVideoJsPlaybackError(): void {
|
||||
const source = this.options().sources?.[0];
|
||||
const targetVideo = this.target().nativeElement as HTMLVideoElement;
|
||||
const videoJsError =
|
||||
typeof this.player?.error === 'function'
|
||||
? this.player.error()
|
||||
: null;
|
||||
const nativeError = targetVideo?.error ?? null;
|
||||
|
||||
this.playbackIssue.emit(
|
||||
classifyNativePlaybackIssue(
|
||||
videoJsError ?? nativeError,
|
||||
this.createSourceMetadata(
|
||||
source?.src ?? targetVideo?.currentSrc ?? '',
|
||||
source?.type
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private createSourceMetadata(url: string, mimeType?: string) {
|
||||
return createPlaybackSourceMetadata({
|
||||
url,
|
||||
mimeType,
|
||||
player: InlinePlaybackPlayer.VideoJs,
|
||||
});
|
||||
}
|
||||
|
||||
private destroyMpegTs(): void {
|
||||
if (this.mpegtsPlayer) {
|
||||
this.mpegtsPlayer.pause();
|
||||
@@ -276,7 +355,10 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
*/
|
||||
private logAudioTracks(): void {
|
||||
const audioTracks = this.player.audioTracks();
|
||||
console.log('[AudioTrack] Audio tracks count:', audioTracks?.length ?? 0);
|
||||
console.log(
|
||||
'[AudioTrack] Audio tracks count:',
|
||||
audioTracks?.length ?? 0
|
||||
);
|
||||
for (let i = 0; i < (audioTracks?.length ?? 0); i++) {
|
||||
const t = audioTracks[i];
|
||||
console.log(
|
||||
|
||||
@@ -143,6 +143,9 @@
|
||||
(timeUpdate)="onInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayback()"
|
||||
(streamUrlCopied)="onStreamUrlCopied()"
|
||||
(externalFallbackRequested)="
|
||||
onInlineExternalFallbackRequested($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
normalizeVodDetails,
|
||||
} from 'shared-interfaces';
|
||||
import { DownloadsService } from 'services';
|
||||
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
|
||||
|
||||
/**
|
||||
@@ -98,6 +99,10 @@ export class VodDetailsComponent {
|
||||
/** Emitted when the stream url is copied */
|
||||
readonly streamUrlCopied = output<void>();
|
||||
|
||||
/** Emitted when the inline player requests MPV/VLC fallback */
|
||||
readonly inlineExternalFallbackRequested =
|
||||
output<PlaybackFallbackRequest>();
|
||||
|
||||
// ============ Services ============
|
||||
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
@@ -288,6 +293,12 @@ export class VodDetailsComponent {
|
||||
this.streamUrlCopied.emit();
|
||||
}
|
||||
|
||||
onInlineExternalFallbackRequested(
|
||||
request: PlaybackFallbackRequest
|
||||
): void {
|
||||
this.inlineExternalFallbackRequested.emit(request);
|
||||
}
|
||||
|
||||
async stopExternalPlayback(): Promise<void> {
|
||||
await this.externalPlaybackActions.closeSession(
|
||||
this.matchedExternalPlayback()
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
@if (player === 'videojs') {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[volume]="volume()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
/>
|
||||
} @else if (player === 'html5') {
|
||||
<app-html-video-player
|
||||
[channel]="$any(channel)"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
/>
|
||||
} @else if (player === 'artplayer') {
|
||||
<app-art-player
|
||||
[channel]="$any(channel)"
|
||||
[volume]="1"
|
||||
[showCaptions]="false"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
/>
|
||||
} @else if (player === 'embedded-mpv') {
|
||||
<app-embedded-mpv-player
|
||||
@@ -27,7 +33,66 @@
|
||||
} @else {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[volume]="volume()"
|
||||
[startTime]="startTime()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (playbackDiagnostic(); as issue) {
|
||||
<section
|
||||
class="web-player-diagnostic"
|
||||
data-test-id="playback-diagnostic-banner"
|
||||
role="status"
|
||||
>
|
||||
<div class="web-player-diagnostic__icon" aria-hidden="true">
|
||||
<mat-icon>warning</mat-icon>
|
||||
</div>
|
||||
<div class="web-player-diagnostic__body">
|
||||
<div class="web-player-diagnostic__title">
|
||||
{{ getDiagnosticTitleKey(issue) | translate }}
|
||||
</div>
|
||||
<div class="web-player-diagnostic__description">
|
||||
{{ getDiagnosticDescriptionKey(issue) | translate }}
|
||||
</div>
|
||||
@if (getDiagnosticMeta(issue); as meta) {
|
||||
<div class="web-player-diagnostic__meta">
|
||||
{{ meta }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="web-player-diagnostic__actions">
|
||||
@if (canShowExternalFallbackActions()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="playback-fallback-mpv"
|
||||
(click)="requestExternalFallback('mpv')"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="playback-fallback-vlc"
|
||||
(click)="requestExternalFallback('vlc')"
|
||||
>
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[cdkCopyToClipboard]="resolvedPlayback().streamUrl"
|
||||
[matTooltip]="resolvedPlayback().streamUrl"
|
||||
data-test-id="playback-copy-url"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
app-art-player,
|
||||
@@ -10,3 +11,84 @@ app-html-video-player {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.web-player-diagnostic {
|
||||
position: absolute;
|
||||
inset-inline: 16px;
|
||||
bottom: var(--web-player-diagnostic-bottom, 16px);
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'icon body'
|
||||
'icon actions';
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid color-mix(in srgb, var(--mat-sys-error) 36%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error-container) 92%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mat-sys-on-error-container);
|
||||
box-shadow: 0 16px 42px rgb(0 0 0 / 28%);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__icon {
|
||||
grid-area: icon;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__body {
|
||||
grid-area: body;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__description,
|
||||
.web-player-diagnostic__meta {
|
||||
margin-top: 3px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__meta {
|
||||
opacity: 0.78;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__actions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__actions button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.web-player-diagnostic {
|
||||
inset-inline: 8px;
|
||||
bottom: var(--web-player-diagnostic-bottom, 8px);
|
||||
grid-template-areas:
|
||||
'icon body'
|
||||
'actions actions';
|
||||
}
|
||||
|
||||
.web-player-diagnostic__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
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 { of } from 'rxjs';
|
||||
import { VideoPlayer } from 'shared-interfaces';
|
||||
import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component';
|
||||
import {
|
||||
PlaybackDiagnostic,
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
|
||||
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', () => ({}));
|
||||
|
||||
@Component({
|
||||
selector: 'app-vjs-player',
|
||||
template: '<div data-test-id="stub-vjs-player"></div>',
|
||||
})
|
||||
class StubVjsPlayerComponent {
|
||||
readonly options = input<unknown>();
|
||||
readonly volume = input(1);
|
||||
readonly startTime = input(0);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-html-video-player',
|
||||
template: '<div data-test-id="stub-html-player"></div>',
|
||||
})
|
||||
class StubHtmlVideoPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly startTime = input(0);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-art-player',
|
||||
template: '<div data-test-id="stub-art-player"></div>',
|
||||
})
|
||||
class StubArtPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
readonly startTime = input(0);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-embedded-mpv-player',
|
||||
template: '<div data-test-id="stub-embedded-mpv-player"></div>',
|
||||
})
|
||||
class StubEmbeddedMpvPlayerComponent {
|
||||
readonly playback = input.required<unknown>();
|
||||
readonly recordingFolder = input('');
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
}
|
||||
|
||||
describe('WebPlayerViewComponent', () => {
|
||||
let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent;
|
||||
let fixture: ComponentFixture<WebPlayerViewComponentInstance>;
|
||||
let component: WebPlayerViewComponentInstance;
|
||||
const storageMap = {
|
||||
get: jest.fn(() => of({ player: VideoPlayer.VideoJs })),
|
||||
};
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ WebPlayerViewComponent } = await import(
|
||||
'./web-player-view.component'
|
||||
));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WebPlayerViewComponent, TranslateModule.forRoot()],
|
||||
providers: [{ provide: StorageMap, useValue: storageMap }],
|
||||
})
|
||||
.overrideComponent(WebPlayerViewComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
StubArtPlayerComponent,
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
ClipboardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
TranslateModule,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
storageMap.get.mockReturnValue(of({ player: VideoPlayer.VideoJs }));
|
||||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput(
|
||||
'streamUrl',
|
||||
'https://example.com/archive/movie.mkv'
|
||||
);
|
||||
fixture.componentRef.setInput('title', 'Example Movie');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.electron = originalElectron;
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('renders diagnostics and emits MPV fallback requests on desktop', () => {
|
||||
const requests: unknown[] = [];
|
||||
fixture.destroy();
|
||||
window.electron = {} as typeof window.electron;
|
||||
fixture = TestBed.createComponent(WebPlayerViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput(
|
||||
'streamUrl',
|
||||
'https://example.com/archive/movie.mkv'
|
||||
);
|
||||
fixture.componentRef.setInput('title', 'Example Movie');
|
||||
component.externalFallbackRequested.subscribe((request) =>
|
||||
requests.push(request)
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = fixture.debugElement.query(
|
||||
By.css('[data-test-id="playback-diagnostic-banner"]')
|
||||
);
|
||||
const mpvButton = fixture.debugElement.query(
|
||||
By.css('[data-test-id="playback-fallback-mpv"]')
|
||||
);
|
||||
|
||||
expect(banner.nativeElement.textContent).toContain(
|
||||
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.TITLE'
|
||||
);
|
||||
mpvButton.nativeElement.click();
|
||||
|
||||
expect(requests).toEqual([
|
||||
expect.objectContaining({
|
||||
player: 'mpv',
|
||||
playback: expect.objectContaining({
|
||||
streamUrl: 'https://example.com/archive/movie.mkv',
|
||||
title: 'Example Movie',
|
||||
}),
|
||||
diagnostic: expect.objectContaining({
|
||||
code: PlaybackDiagnosticCode.UnsupportedContainer,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
|
||||
return {
|
||||
code: PlaybackDiagnosticCode.UnsupportedContainer,
|
||||
source: PlaybackDiagnosticSource.Native,
|
||||
sourceUrl: 'https://example.com/archive/movie.mkv',
|
||||
container: 'mkv',
|
||||
mimeType: 'video/matroska',
|
||||
player: 'videojs',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
externalFallbackRecommended: true,
|
||||
};
|
||||
}
|
||||
@@ -8,9 +8,15 @@ import {
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { getExtensionFromUrl } from 'm3u-utils';
|
||||
import {
|
||||
ResolvedPortalPlayback,
|
||||
@@ -18,9 +24,15 @@ import {
|
||||
STORE_KEY,
|
||||
VideoPlayer,
|
||||
} from 'shared-interfaces';
|
||||
import type { ExternalPlayerName } from 'shared-interfaces';
|
||||
import { ArtPlayerComponent } from '../art-player/art-player.component';
|
||||
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
|
||||
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
|
||||
import {
|
||||
type PlaybackDiagnostic,
|
||||
PlaybackDiagnosticCode,
|
||||
type PlaybackFallbackRequest,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
||||
|
||||
@Component({
|
||||
@@ -29,8 +41,13 @@ import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
||||
styleUrls: ['./web-player-view.component.scss'],
|
||||
imports: [
|
||||
ArtPlayerComponent,
|
||||
ClipboardModule,
|
||||
EmbeddedMpvPlayerComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
TranslatePipe,
|
||||
VjsPlayerComponent,
|
||||
],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
@@ -42,10 +59,15 @@ export class WebPlayerViewComponent {
|
||||
title = input('');
|
||||
playback = input<ResolvedPortalPlayback | null>(null);
|
||||
startTime = input<number>(0);
|
||||
volume = input<number>(1);
|
||||
showCaptions = input<boolean>(false);
|
||||
playerOverride = input<VideoPlayer | null>(null);
|
||||
@Output() timeUpdate = new EventEmitter<{
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}>();
|
||||
@Output() externalFallbackRequested =
|
||||
new EventEmitter<PlaybackFallbackRequest>();
|
||||
|
||||
settings = toSignal(
|
||||
this.storage.get(STORE_KEY.Settings)
|
||||
@@ -54,6 +76,14 @@ export class WebPlayerViewComponent {
|
||||
channel!: { url: string };
|
||||
player!: VideoPlayer;
|
||||
vjsOptions!: { sources: { src: string; type: string }[] };
|
||||
readonly isDesktop = signal(this.detectDesktop());
|
||||
readonly playbackDiagnostic = signal<PlaybackDiagnostic | null>(null);
|
||||
readonly canShowExternalFallbackActions = computed(
|
||||
() =>
|
||||
this.isDesktop() &&
|
||||
!!this.playbackDiagnostic()?.externalFallbackRecommended
|
||||
);
|
||||
|
||||
readonly resolvedPlayback = computed<ResolvedPortalPlayback>(() => {
|
||||
const playback = this.playback();
|
||||
if (playback) {
|
||||
@@ -66,12 +96,19 @@ export class WebPlayerViewComponent {
|
||||
startTime: this.startTime(),
|
||||
};
|
||||
});
|
||||
readonly selectedPlayer = computed(
|
||||
() =>
|
||||
this.playerOverride() ??
|
||||
this.settings()?.player ??
|
||||
VideoPlayer.VideoJs
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.player = this.settings()?.player ?? VideoPlayer.VideoJs;
|
||||
this.player = this.selectedPlayer();
|
||||
|
||||
const playback = this.resolvedPlayback();
|
||||
this.playbackDiagnostic.set(null);
|
||||
this.setChannel(playback.streamUrl);
|
||||
this.setVjsOptions(playback.streamUrl);
|
||||
});
|
||||
@@ -96,4 +133,60 @@ export class WebPlayerViewComponent {
|
||||
url: streamUrl,
|
||||
};
|
||||
}
|
||||
|
||||
handlePlaybackIssue(issue: PlaybackDiagnostic | null): void {
|
||||
this.playbackDiagnostic.set(issue);
|
||||
}
|
||||
|
||||
requestExternalFallback(player: ExternalPlayerName): void {
|
||||
const diagnostic = this.playbackDiagnostic();
|
||||
if (!diagnostic) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.externalFallbackRequested.emit({
|
||||
player,
|
||||
playback: this.resolvedPlayback(),
|
||||
diagnostic,
|
||||
});
|
||||
}
|
||||
|
||||
getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
|
||||
return `${this.getDiagnosticTranslationBase(issue)}.TITLE`;
|
||||
}
|
||||
|
||||
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
|
||||
return `${this.getDiagnosticTranslationBase(issue)}.DESCRIPTION`;
|
||||
}
|
||||
|
||||
getDiagnosticMeta(issue: PlaybackDiagnostic): string {
|
||||
const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', ');
|
||||
if (codecs) {
|
||||
return codecs;
|
||||
}
|
||||
|
||||
return issue.container || issue.mimeType || issue.details || '';
|
||||
}
|
||||
|
||||
private getDiagnosticTranslationBase(issue: PlaybackDiagnostic): string {
|
||||
switch (issue.code) {
|
||||
case PlaybackDiagnosticCode.UnsupportedContainer:
|
||||
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER';
|
||||
case PlaybackDiagnosticCode.UnsupportedCodec:
|
||||
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC';
|
||||
case PlaybackDiagnosticCode.MediaDecodeError:
|
||||
return 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR';
|
||||
case PlaybackDiagnosticCode.NetworkError:
|
||||
return 'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR';
|
||||
case PlaybackDiagnosticCode.DrmOrEncryption:
|
||||
return 'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION';
|
||||
case PlaybackDiagnosticCode.UnknownPlaybackError:
|
||||
default:
|
||||
return 'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR';
|
||||
}
|
||||
}
|
||||
|
||||
private detectDesktop(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.electron;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export * from './lib/epg-view/epg-view.component';
|
||||
export * from './lib/live-epg-panel/live-epg-panel.component';
|
||||
export * from './lib/live-stream-layout/live-stream-layout.component';
|
||||
export { WebPlayerViewComponent } from '@iptvnator/ui/playback';
|
||||
export {
|
||||
WebPlayerViewComponent,
|
||||
type PlaybackFallbackRequest,
|
||||
} from '@iptvnator/ui/playback';
|
||||
@@ -116,6 +116,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.content-container:has(> .epg.epg-collapsed)
|
||||
.video-player
|
||||
> app-web-player-view {
|
||||
--web-player-diagnostic-bottom: 72px;
|
||||
}
|
||||
|
||||
.epg {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
|
||||
Reference in new issue
Block a user