From 6305dcf00e055945486a8ec842fb46e451bda8cd Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 10 May 2026 17:27:54 +0200 Subject: [PATCH] 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 --- .../src/electron-test-fixtures.ts | 29 +- apps/electron-backend-e2e/src/search.e2e.ts | 37 +- .../src/app/services/player.service.spec.ts | 39 ++ apps/web/src/app/services/player.service.ts | 66 +-- apps/web/src/assets/i18n/ar.json | 29 + apps/web/src/assets/i18n/ary.json | 29 + apps/web/src/assets/i18n/by.json | 29 + apps/web/src/assets/i18n/de.json | 29 + apps/web/src/assets/i18n/el.json | 29 + apps/web/src/assets/i18n/en.json | 29 + apps/web/src/assets/i18n/es.json | 29 + apps/web/src/assets/i18n/fr.json | 29 + apps/web/src/assets/i18n/it.json | 29 + apps/web/src/assets/i18n/ja.json | 29 + apps/web/src/assets/i18n/ko.json | 29 + apps/web/src/assets/i18n/nl.json | 29 + apps/web/src/assets/i18n/pl.json | 29 + apps/web/src/assets/i18n/pt.json | 29 + apps/web/src/assets/i18n/ru.json | 29 + apps/web/src/assets/i18n/tr.json | 29 + apps/web/src/assets/i18n/zh.json | 29 + apps/web/src/assets/i18n/zhtw.json | 29 + docs/architecture/embedded-inline-playback.md | 30 ++ libs/playlist/m3u/feature-player/project.json | 15 + .../video-player/video-player.component.html | 49 +- .../video-player.component.spec.ts | 165 +++++- .../video-player/video-player.component.ts | 44 +- .../shared/util/src/lib/portal-player.ts | 5 + .../stalker-catalog-detail.component.html | 3 + .../stalker-catalog-detail.component.ts | 12 +- .../stalker-live-stream-layout.component.html | 3 + ...alker-live-stream-layout.component.spec.ts | 1 + .../stalker-live-stream-layout.component.ts | 8 + .../stalker-series-view.component.html | 3 + .../stalker-series-view.component.ts | 12 +- .../live-stream-layout.component.html | 3 + .../live-stream-layout.component.spec.ts | 1 + .../live-stream-layout.component.ts | 8 + .../player-dialog.component.html | 1 + .../player-dialog/player-dialog.component.ts | 18 +- .../serial-details.component.html | 3 + .../serial-details.component.spec.ts | 1 + .../serial-details.component.ts | 12 +- .../vod-details-route.component.html | 3 + .../vod-details-route.component.ts | 12 +- ...am-workspace-route-session.service.spec.ts | 27 +- .../xtream-workspace-route-session.service.ts | 23 +- libs/ui/playback/src/index.ts | 1 + .../art-player/art-player.component.spec.ts | 241 +++++++++ .../lib/art-player/art-player.component.ts | 134 ++++- .../html-video-player.component.spec.ts | 53 ++ .../html-video-player.component.ts | 133 ++++- .../playback-diagnostics.util.spec.ts | 128 +++++ .../playback-diagnostics.util.ts | 504 ++++++++++++++++++ .../portal-inline-player.component.html | 3 + .../portal-inline-player.component.ts | 6 + .../vjs-player/vjs-player.component.spec.ts | 55 +- .../lib/vjs-player/vjs-player.component.ts | 194 +++++-- .../vod-details/vod-details.component.html | 3 + .../lib/vod-details/vod-details.component.ts | 11 + .../web-player-view.component.html | 69 ++- .../web-player-view.component.scss | 82 +++ .../web-player-view.component.spec.ts | 185 +++++++ .../web-player-view.component.ts | 95 +++- libs/ui/shared-portals/src/index.ts | 5 +- libs/ui/styles/_portal-layout.scss | 6 + 66 files changed, 2857 insertions(+), 206 deletions(-) create mode 100644 libs/ui/playback/src/lib/art-player/art-player.component.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index d37a9354b..6757825e4 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -108,7 +108,8 @@ export type LaunchedElectronApp = { }; export const test = base.extend({ - 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 { diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index 653acae96..14ebc2914 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -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 { @@ -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 { + await expect + .poll(() => new URL(page.url()).searchParams.has(name)) + .toBe(false); +} diff --git a/apps/web/src/app/services/player.service.spec.ts b/apps/web/src/app/services/player.service.spec.ts index aba7eaaeb..d731743a6 100644 --- a/apps/web/src/app/services/player.service.spec.ts +++ b/apps/web/src/app/services/player.service.spec.ts @@ -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); + }); }); diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index e686f523e..71734fb0b 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -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 { 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( - 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( - 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 { + const ipcEvent = + player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER; + + return await this.dataService.sendIpcEvent( + 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, + } + ); + } } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index e207bc8fd..296463bcc 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "إظهار قائمة القنوات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 8a03c78cf..15e69f71c 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "إظهار قائمة القنوات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 439d7ee7c..5b4bd1a26 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Паказаць спіс каналаў", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 0b182d35f..c974a6b16 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 5f0e3b580..3018d0f98 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Εμφάνιση λίστας καναλιών", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index cf3fe47f8..d280c81e3 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 110aad810..71251fb1d 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 7ccd853d9..4e3072e3d 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 30add1618..5030c8afe 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index d8429c9db..ab979fc7f 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "チャンネル一覧を表示", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 65e3c7b8e..7184acdff 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "채널 목록 표시", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 18fc35b86..5037188d2 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 509b34e30..03cb1f9d0 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index b4a68cc1f..4f4f38509 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 0b26d845c..9c2e41b81 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Показать список каналов", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 0cb1e7173..4d89ad146 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 8799e1dd1..4defd7261 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "显示频道列表", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 3144e02bd..2942169a1 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "顯示頻道列表", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 2739de7be..f75a3ed84 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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. diff --git a/libs/playlist/m3u/feature-player/project.json b/libs/playlist/m3u/feature-player/project.json index ee3e59f10..4a61408e9 100644 --- a/libs/playlist/m3u/feature-player/project.json +++ b/libs/playlist/m3u/feature-player/project.json @@ -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" } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 968868227..82a8ed4d1 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -58,7 +58,6 @@ } @if (activeChannel(); as activeChannel) { - @let playbackTarget = playbackChannel(); @if ( activeChannel.radio === 'true' || shouldShowInlinePlayer(activeChannel) @@ -73,43 +72,17 @@ " /> } @else { - @if (playerSettings.player === 'videojs') { - - } - @if (playerSettings.player === 'html5') { - - } - @if (playerSettings.player === 'artplayer') { - - } - @if (playerSettings.player === 'embedded-mpv') { - - } + } } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 1e988f549..d58e88b55 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -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(null); +} + +@Component({ + selector: 'app-sidebar', + standalone: true, + template: '', +}) +class StubSidebarComponent { + readonly channels = input([]); + readonly channelsLoading = input(false); + readonly showPlaylistHeader = input(false); + readonly activeView = input(''); + readonly sidebarWidth = input(0); + readonly sidebarWidthRequested = output(); + readonly sidebarWidthRequestEnded = output(); + readonly sidebarToggleRequested = output(); +} + +@Component({ + selector: 'app-portal-empty-state', + standalone: true, + template: '
{{ message() }}
', +}) +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(null); + readonly playerOverride = input(null); + readonly volume = input(1); + readonly showCaptions = input(false); + readonly externalFallbackRequested = output(); +} + +@Component({ + selector: 'app-epg-list', + standalone: true, + template: '', +}) +class StubEpgListComponent { + readonly selectedDate = input(null); + readonly showDateNavigator = input(false); + readonly archivePlaybackAvailable = input(false); + readonly selectedDateChange = output(); +} + +@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(); + readonly resizeEnd = output(); +} + describe('VideoPlayerComponent', () => { - let fixture: ComponentFixture; - let component: VideoPlayerComponent; + let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent; + let fixture: ComponentFixture; + 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); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 82cf15edd..e504ded5c 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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 = playbackTarget.http ?? {}; const headers: Record = {}; - 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 { diff --git a/libs/portal/shared/util/src/lib/portal-player.ts b/libs/portal/shared/util/src/lib/portal-player.ts index dab6e8db6..c66a519d9 100644 --- a/libs/portal/shared/util/src/lib/portal-player.ts +++ b/libs/portal/shared/util/src/lib/portal-player.ts @@ -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; + openExternalPlayback( + playback: ResolvedPortalPlayback, + player: ExternalPlayerName + ): Promise; } export const PORTAL_PLAYER = new InjectionToken( diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html index d359dacf8..24ca1636b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html @@ -18,6 +18,9 @@ (inlineTimeUpdated)="handleInlineTimeUpdate($event)" (inlinePlaybackClosed)="closeInlinePlayer()" (streamUrlCopied)="showCopyNotification()" + (inlineExternalFallbackRequested)=" + handleExternalFallbackRequest($event) + " /> } } @else if (contentType() === 'series') { diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 403769572..af930133f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -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 { if (item.type !== 'stalker') { return; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 750d63360..4113d1508 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -125,6 +125,9 @@ [streamUrl]="streamUrl()" [title]="activePlayback()?.title ?? ''" [playback]="activePlayback()" + (externalFallbackRequested)=" + handleExternalFallbackRequest($event) + " /> } diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 210dc61bb..7e268b67a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -53,6 +53,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input(''); readonly title = input(''); readonly playback = input(null); + readonly externalFallbackRequested = output(); } @Component({ diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index ff2b97ff3..bb70d154d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -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 + ); + } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index e10351ce2..70ef5b16f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -63,6 +63,9 @@ (timeUpdate)="handleInlineTimeUpdate($event)" (closed)="closeInlinePlayer()" (streamUrlCopied)="showCopyNotification()" + (externalFallbackRequested)=" + handleExternalFallbackRequest($event) + " /> } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 4fd484c66..b41ec3918 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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, diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 1d1d99120..ed8b3202a 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -99,6 +99,9 @@ [streamUrl]="streamUrl" [title]="activePlayback()?.title ?? ''" [playback]="activePlayback()" + (externalFallbackRequested)=" + handleExternalFallbackRequest($event) + " /> } diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 28ca38289..d44ef25f5 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -53,6 +53,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input(''); readonly title = input(''); readonly playback = input(null); + readonly externalFallbackRequested = output(); } @Component({ diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 0267bfe72..89f08f8e0 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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[]; } diff --git a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html index 6f547a7d5..92910b7b6 100644 --- a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html +++ b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html @@ -6,6 +6,7 @@ [playback]="data.playback ?? null" [startTime]="data.startTime || 0" (timeUpdate)="handleTimeUpdate($event)" + (externalFallbackRequested)="handleExternalFallbackRequest($event)" /> diff --git a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts index 7218c4908..2bb3eb771 100644 --- a/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts +++ b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts @@ -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 + ); + } } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index da237b5bd..0e64b6ab6 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -68,6 +68,9 @@ (timeUpdate)="handleInlineTimeUpdate($event)" (closed)="closeInlinePlayer()" (streamUrlCopied)="showCopyNotification()" + (externalFallbackRequested)=" + handleExternalFallbackRequest($event) + " /> } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 799924041..c35e24fa1 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -64,6 +64,7 @@ class StubPortalInlinePlayerComponent { readonly timeUpdate = output(); readonly closed = output(); readonly streamUrlCopied = output(); + readonly externalFallbackRequested = output(); } @Component({ diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 89ca17a05..75d1c9789 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index eb7f765fb..9a8d1c12b 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -154,6 +154,9 @@ (timeUpdate)="handleInlineTimeUpdate($event)" (closed)="closeInlinePlayer()" (streamUrlCopied)="showCopyNotification()" + (externalFallbackRequested)=" + handleExternalFallbackRequest($event) + " /> } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index ef8fb5a1e..23148e1f1 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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 { const info = getXtreamVodInfo(vodItem); const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts index 840edc39f..1f2781ca4 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.spec.ts @@ -52,7 +52,7 @@ async function flushEffects(): Promise { 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); diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index e35c67f72..bf6b63674 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -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; } diff --git a/libs/ui/playback/src/index.ts b/libs/ui/playback/src/index.ts index 402a88515..64abacb1b 100644 --- a/libs/ui/playback/src/index.ts +++ b/libs/ui/playback/src/index.ts @@ -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'; diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts new file mode 100644 index 000000000..2184fe402 --- /dev/null +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts @@ -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) { + 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 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 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; + 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): 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]; + } +}); diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 8635a2265..6de258f9e 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -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(); 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, + }); + } } diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index e7e0fc103..b95cbcc27 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -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([]); + }); }); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index dd7391e24..2a4cb49a1 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -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(); 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(); diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts new file mode 100644 index 000000000..423d49c75 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts @@ -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(''); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts new file mode 100644 index 000000000..d30008a03 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts @@ -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 +): 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; +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index 55dc36e19..0f9440667 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -36,6 +36,9 @@ [playback]="playback()" [startTime]="startTime()" (timeUpdate)="onTimeUpdate($event)" + (externalFallbackRequested)=" + onExternalFallbackRequested($event) + " /> diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index df2af8ad4..d86b13e39 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -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(); + readonly externalFallbackRequested = output(); 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); + } } diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts index 4aed167b9..4df67a477 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -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(), diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 86fe0bd61..57c625ac8 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -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, '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(); + + 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( diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index 078ec83c7..5ceb81d9e 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -143,6 +143,9 @@ (timeUpdate)="onInlineTimeUpdate($event)" (closed)="closeInlinePlayback()" (streamUrlCopied)="onStreamUrlCopied()" + (externalFallbackRequested)=" + onInlineExternalFallbackRequested($event) + " /> } diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 0efc941b9..33e7e7620 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -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(); + /** Emitted when the inline player requests MPV/VLC fallback */ + readonly inlineExternalFallbackRequested = + output(); + // ============ 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 { await this.externalPlaybackActions.closeSession( this.matchedExternalPlayback() diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index b410d4271..cb5cb8474 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -1,22 +1,28 @@ @if (player === 'videojs') { } @else if (player === 'html5') { } @else if (player === 'artplayer') { } @else if (player === 'embedded-mpv') { } + +@if (playbackDiagnostic(); as issue) { +
+ +
+
+ {{ getDiagnosticTitleKey(issue) | translate }} +
+
+ {{ getDiagnosticDescriptionKey(issue) | translate }} +
+ @if (getDiagnosticMeta(issue); as meta) { +
+ {{ meta }} +
+ } +
+
+ @if (canShowExternalFallbackActions()) { + + + } + +
+
+} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss index 2bef2e382..943f17d92 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss @@ -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; + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts new file mode 100644 index 000000000..b8017fc17 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -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: '
', +}) +class StubVjsPlayerComponent { + readonly options = input(); + readonly volume = input(1); + readonly startTime = input(0); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly playbackIssue = output(); +} + +@Component({ + selector: 'app-html-video-player', + template: '
', +}) +class StubHtmlVideoPlayerComponent { + readonly channel = input(); + readonly volume = input(1); + readonly showCaptions = input(false); + readonly startTime = input(0); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly playbackIssue = output(); +} + +@Component({ + selector: 'app-art-player', + template: '
', +}) +class StubArtPlayerComponent { + readonly channel = input(); + readonly volume = input(1); + readonly showCaptions = input(false); + readonly startTime = input(0); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly playbackIssue = output(); +} + +@Component({ + selector: 'app-embedded-mpv-player', + template: '
', +}) +class StubEmbeddedMpvPlayerComponent { + readonly playback = input.required(); + readonly recordingFolder = input(''); + readonly timeUpdate = output<{ currentTime: number; duration: number }>(); +} + +describe('WebPlayerViewComponent', () => { + let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; + let fixture: ComponentFixture; + 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, + }; +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 70531f6f9..f5999b1f8 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -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(null); startTime = input(0); + volume = input(1); + showCaptions = input(false); + playerOverride = input(null); @Output() timeUpdate = new EventEmitter<{ currentTime: number; duration: number; }>(); + @Output() externalFallbackRequested = + new EventEmitter(); 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(null); + readonly canShowExternalFallbackActions = computed( + () => + this.isDesktop() && + !!this.playbackDiagnostic()?.externalFallbackRecommended + ); + readonly resolvedPlayback = computed(() => { 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; + } } diff --git a/libs/ui/shared-portals/src/index.ts b/libs/ui/shared-portals/src/index.ts index 361627ae0..3b2ad317a 100644 --- a/libs/ui/shared-portals/src/index.ts +++ b/libs/ui/shared-portals/src/index.ts @@ -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'; diff --git a/libs/ui/styles/_portal-layout.scss b/libs/ui/styles/_portal-layout.scss index aef73c0e4..3f2cfd14e 100644 --- a/libs/ui/styles/_portal-layout.scss +++ b/libs/ui/styles/_portal-layout.scss @@ -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;