diff --git a/.changes/xtream-auto-live-ts-fallback.md b/.changes/xtream-auto-live-ts-fallback.md new file mode 100644 index 000000000..097814030 --- /dev/null +++ b/.changes/xtream-auto-live-ts-fallback.md @@ -0,0 +1,7 @@ +--- +type: fix +area: xtream +issues: [1513] +--- + +Xtream Live TV in Auto can try advertised TS once when HLS initially fails with an HTTP error in a web player. The selected player and stream headers are kept. Settings explain the manual TS workaround for external players, Embedded MPV, and HLS retries that never report a terminal failure. diff --git a/AGENTS.md b/AGENTS.md index 4177fc024..87948701d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -272,6 +272,18 @@ scrollbar and guarded initial page focus. Contracts: `docs/architecture/iptvnator-ui-guidelines.md` and `docs/architecture/portal-detail-navigation.md`. +## Xtream Live Auto Format + +The routed Xtream live host supplies `liveAutoTsUrl` only for Auto with explicit +HLS+TS account evidence, using the canonical URL builder and original headers. +The same web player may try TS once after an owned initial terminal HTTP failure, +before `playing`; the old transport unmounts before the guarded render callback +starts TS. No player preference or playlist cache changes. Manual formats, +unknown formats, DRM, VOD/catch-up and stale sessions are excluded. External +MPV/VLC and Embedded MPV retain manual TS; Video.js segment retry cycles without +a terminal diagnostic also need manual TS. Contract and full support matrix: +`docs/architecture/xtream-portal-compatibility.md` (Initial Auto HLS failure). + ## Radio / Audio Player M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected: diff --git a/CLAUDE.md b/CLAUDE.md index 299ff6ab8..52769fda7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -782,6 +782,18 @@ categories by provider ID and type. See `docs/architecture/category-management.m ### Key Features +#### Xtream Live Auto Format + +The routed Xtream live host supplies `liveAutoTsUrl` only for Auto with explicit +HLS+TS account evidence, using the canonical URL builder and original headers. +The same web player may try TS once after an owned initial terminal HTTP failure, +before `playing`; the old transport unmounts before the guarded render callback +starts TS. No player preference or playlist cache changes. Manual formats, +unknown formats, DRM, VOD/catch-up and stale sessions are excluded. External +MPV/VLC and Embedded MPV retain manual TS; Video.js segment retry cycles without +a terminal diagnostic also need manual TS. Contract and full support matrix: +`docs/architecture/xtream-portal-compatibility.md` (Initial Auto HLS failure). + #### M3U URL User-Agent - The URL import form accepts an optional User-Agent and stores it as diff --git a/apps/electron-backend-e2e/src/xtream-live-format.e2e.ts b/apps/electron-backend-e2e/src/xtream-live-format.e2e.ts new file mode 100644 index 000000000..c4087421c --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-live-format.e2e.ts @@ -0,0 +1,88 @@ +import { + closeElectronApp, + expect, + launchElectronApp, + openSources, + openSourceEditor, + updateSourceDialog, + saveSourceDialog, + sourceRowByTitle, + test, +} from './electron-test-fixtures'; +import { + addLiveFormatPortal, + configureLiveFormat, + expectLiveFormatPlaying, + liveChannels, + observeLiveFormatMedia, +} from './xtream-live-format.fixture'; + +for (const player of ['html5', 'artplayer'] as const) { + test(`@electron @xtream Auto live format ${player}: failing segment to playable TS`, async ({ + dataDir, + }) => { + test.setTimeout(120_000); + const app = await launchElectronApp(dataDir, { + args: ['--autoplay-policy=no-user-gesture-required'], + }); + try { + const page = app.mainWindow; + const media = observeLiveFormatMedia(page); + await configureLiveFormat(page, player); + await addLiveFormatPortal(page); + await openSources(page); + const dialog = await openSourceEditor(page, 'Synthetic live'); + await updateSourceDialog(dialog, { + userAgent: 'IPTVnator-1513-fixture', + }); + await saveSourceDialog(page, dialog); + await sourceRowByTitle(page, 'Synthetic live').first().click(); + await page + .getByRole('link', { name: 'Live TV', exact: true }) + .click(); + await page.locator('.context-panel .category-item').first().click(); + const [tsResponse] = await Promise.all([ + page.waitForResponse( + (response) => response.url().endsWith('/10000.ts'), + { timeout: 60_000 } + ), + liveChannels(page).first().click(), + expectLiveFormatPlaying(page, player), + ]); + expect(await tsResponse.headerValue('x-fixture-user-agent')).toBe( + 'IPTVnator-1513-fixture' + ); + + expect(media).toContainEqual({ file: '10000.m3u8', status: 200 }); + expect(media).toContainEqual({ + file: 'denied-segment.ts', + status: 403, + }); + expect(media.filter((r) => r.file === '10000.ts')).toEqual([ + { file: '10000.ts', status: 200 }, + ]); + } finally { + await closeElectronApp(app); + } + }); +} + +test('@electron @xtream Video.js supports the manual TS workaround', async ({ + dataDir, +}) => { + const app = await launchElectronApp(dataDir, { + args: ['--autoplay-policy=no-user-gesture-required'], + }); + try { + const page = app.mainWindow; + const media = observeLiveFormatMedia(page); + await configureLiveFormat(page, 'videojs', 'ts'); + await addLiveFormatPortal(page); + await liveChannels(page).first().click(); + await expectLiveFormatPlaying(page, 'videojs'); + expect(media.some((r) => r.file.endsWith('.m3u8'))).toBe(false); + expect(media.filter((r) => r.file === '10000.ts')).toHaveLength(1); + } finally { + await closeElectronApp(app); + } +}); diff --git a/apps/electron-backend-e2e/src/xtream-live-format.fixture.ts b/apps/electron-backend-e2e/src/xtream-live-format.fixture.ts new file mode 100644 index 000000000..a86937ca7 --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-live-format.fixture.ts @@ -0,0 +1,79 @@ +import { expect, type Page } from '@playwright/test'; + +export const liveFormatMock = `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}`; +export type LiveFormatPlayer = 'html5' | 'videojs' | 'artplayer'; + +export async function configureLiveFormat( + page: Page, + player: LiveFormatPlayer, + format = 'auto' +) { + await page.getByRole('link', { name: 'Open settings' }).click(); + await page.locator('[data-test-id="settings-section-playback"]').click(); + await page.locator('[data-test-id="select-video-player"]').click(); + await page + .getByRole('option', { + name: { + html5: 'HTML5 video player', + videojs: 'Video.js player', + artplayer: 'ArtPlayer', + }[player], + exact: true, + }) + .click(); + await page.locator('[data-test-id="select-stream-format"]').click(); + await page.getByRole('option', { name: format, exact: true }).click(); + if (player !== 'videojs' || format !== 'auto') { + const save = page.getByRole('button', { name: 'Save changes' }); + await expect(save).toBeVisible(); + await save.click(); + await expect(save).toBeHidden(); + } + await page.getByRole('button', { name: 'Back', exact: true }).click(); +} + +export async function addLiveFormatPortal(page: Page) { + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('radio', { name: /Xtream credentials/i }).click(); + await dialog.locator('#title').fill('Synthetic live'); + await dialog.locator('#serverUrl').fill(liveFormatMock); + await dialog.locator('#username').fill('live-fallback'); + await dialog.locator('#password').fill('live-fallback'); + await dialog.getByRole('button', { name: 'Add', exact: true }).click(); + await page.waitForURL(/xtreams.*vod/); + await page.getByRole('link', { name: 'Live TV', exact: true }).click(); + await page.locator('.context-panel .category-item').first().click(); + await expect(liveChannels(page).first()).toBeVisible(); +} + +export const liveChannels = (page: Page) => + page.locator('app-live-stream-layout [data-test-id="channel-item"]'); +export function observeLiveFormatMedia(page: Page) { + const media: { file: string; status: number }[] = []; + page.on('response', (response) => { + if (response.url().startsWith(`${liveFormatMock}/live/`)) + media.push({ + file: new URL(response.url()).pathname.split('/').pop() ?? '', + status: response.status(), + }); + }); + return media; +} +export async function expectLiveFormatPlaying( + page: Page, + player: LiveFormatPlayer +) { + await expect( + page.locator( + `app-${player === 'html5' ? 'html-video' : player === 'videojs' ? 'vjs' : 'art'}-player` + ) + ).toBeVisible(); + const video = page.locator('app-web-player-view video').first(); + await expect + .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime), { + timeout: 50_000, + }) + .toBeGreaterThan(0.5); + return video; +} diff --git a/apps/web-e2e/src/xtream-live-format.e2e.ts b/apps/web-e2e/src/xtream-live-format.e2e.ts new file mode 100644 index 000000000..71fe72182 --- /dev/null +++ b/apps/web-e2e/src/xtream-live-format.e2e.ts @@ -0,0 +1,146 @@ +import { expect, test } from './fixtures'; +import { interceptProviderTargetRegistration } from './provider-target-route'; +import { + addLiveFormatPortal, + configureLiveFormat, + expectLiveFormatPlaying, + liveChannels, + liveFormatMock, + observeLiveFormatMedia, +} from './xtream-live-format.fixture'; + +test.use({ + launchOptions: { args: ['--autoplay-policy=no-user-gesture-required'] }, + serviceWorkers: 'block', +}); +test.skip( + ({ browserName }) => browserName !== 'chromium', + 'Chromium media engines' +); +test.beforeEach(async ({ page }) => { + test.setTimeout(100_000); + await interceptProviderTargetRegistration(page); + await page.route('**/localhost:3000/xtream**', async (route) => { + const url = new URL(route.request().url()); + url.host = new URL(liveFormatMock).host; + await route.continue({ url: url.toString() }); + }); + await page.goto('/'); +}); + +for (const player of ['html5', 'artplayer'] as const) { + for (const channel of [0, 1]) { + test(`@web @xtream Auto live format ${player}: ${channel ? 'manifest' : 'segment'} 403 then playable TS`, async ({ + page, + }) => { + const media = observeLiveFormatMedia(page); + await configureLiveFormat(page, player); + await addLiveFormatPortal(page); + await liveChannels(page).nth(channel).click(); + await expectLiveFormatPlaying(page, player); + if (!channel) + expect(media).toContainEqual({ + file: '10000.m3u8', + status: 200, + }); + expect(media).toContainEqual({ + file: channel ? '10001.m3u8' : 'denied-segment.ts', + status: 403, + }); + expect( + media.filter((r) => r.file === `${10000 + channel}.ts`) + ).toEqual([{ file: `${10000 + channel}.ts`, status: 200 }]); + await expect( + page.locator('[data-test-id="playback-diagnostic-banner"]') + ).toBeHidden(); + }); + } +} + +test('@web @xtream Auto live format: TS failure is terminal without a cycle', async ({ + page, +}) => { + const media = observeLiveFormatMedia(page); + await configureLiveFormat(page, 'html5'); + await addLiveFormatPortal(page); + await liveChannels(page).nth(2).click(); + const banner = page.locator('[data-test-id="playback-diagnostic-banner"]'); + await expect(banner).toBeVisible({ timeout: 50_000 }); + expect(media.filter((r) => r.file === '10002.ts')).toEqual([ + { file: '10002.ts', status: 403 }, + ]); + await expect(banner).toContainText(/MPEG-TS|mpegts/i); +}); + +for (const format of ['ts', 'm3u8']) { + test(`@web @xtream Auto live format: manual ${format} wins`, async ({ + page, + }) => { + const media = observeLiveFormatMedia(page); + await configureLiveFormat(page, 'html5', format); + await addLiveFormatPortal(page); + await liveChannels(page).first().click(); + if (format === 'ts') { + await expectLiveFormatPlaying(page, 'html5'); + expect(media.some((r) => r.file.endsWith('.m3u8'))).toBe(false); + } else { + await expect( + page.locator('[data-test-id="playback-diagnostic-banner"]') + ).toBeVisible({ timeout: 50_000 }); + expect(media.some((r) => r.file === '10000.ts')).toBe(false); + } + }); +} + +test('@web @xtream Auto live format: channel switch cancels delayed HLS failure', async ({ + page, +}) => { + const media = observeLiveFormatMedia(page); + await configureLiveFormat(page, 'html5'); + await addLiveFormatPortal(page); + const pending = page.waitForRequest('**/delayed-segment.ts'); + await liveChannels(page).nth(3).click(); + await pending; + await liveChannels(page).nth(1).click(); + await expectLiveFormatPlaying(page, 'html5'); + expect(media.some((r) => r.file === '10003.ts')).toBe(false); + expect(media.filter((r) => r.file === '10001.ts')).toHaveLength(1); +}); + +test('@web @xtream Auto live format: no TS advertisement means no downgrade', async ({ + page, +}) => { + await page.route('**/localhost:3000/xtream**', async (route) => { + const url = new URL(route.request().url()); + url.host = new URL(liveFormatMock).host; + const response = await route.fetch({ url: url.toString() }); + const json = await response.json(); + if (json.payload?.user_info) + json.payload.user_info.allowed_output_formats = ['m3u8']; + await route.fulfill({ response, json }); + }); + const media = observeLiveFormatMedia(page); + await configureLiveFormat(page, 'html5'); + await addLiveFormatPortal(page); + await liveChannels(page).first().click(); + await expect( + page.locator('[data-test-id="playback-diagnostic-banner"]') + ).toBeVisible({ timeout: 50_000 }); + expect(media.some((r) => r.file === '10000.ts')).toBe(false); +}); + +test('@web @xtream Auto live format: leaving the host aborts pending media without TS', async ({ + page, +}) => { + const media = observeLiveFormatMedia(page); + await configureLiveFormat(page, 'html5'); + await addLiveFormatPortal(page); + const pending = page.waitForRequest('**/delayed-segment.ts'); + await liveChannels(page).nth(3).click(); + const request = await pending; + const aborted = page.waitForEvent('requestfailed', (r) => r === request); + await page.getByRole('link', { name: 'Open settings' }).click(); + await aborted; + expect(media.some((r) => r.file === '10003.ts')).toBe(false); + await expect(page.locator('app-web-player-view')).toHaveCount(0); +}); diff --git a/apps/web-e2e/src/xtream-live-format.fixture.ts b/apps/web-e2e/src/xtream-live-format.fixture.ts new file mode 100644 index 000000000..a86937ca7 --- /dev/null +++ b/apps/web-e2e/src/xtream-live-format.fixture.ts @@ -0,0 +1,79 @@ +import { expect, type Page } from '@playwright/test'; + +export const liveFormatMock = `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}`; +export type LiveFormatPlayer = 'html5' | 'videojs' | 'artplayer'; + +export async function configureLiveFormat( + page: Page, + player: LiveFormatPlayer, + format = 'auto' +) { + await page.getByRole('link', { name: 'Open settings' }).click(); + await page.locator('[data-test-id="settings-section-playback"]').click(); + await page.locator('[data-test-id="select-video-player"]').click(); + await page + .getByRole('option', { + name: { + html5: 'HTML5 video player', + videojs: 'Video.js player', + artplayer: 'ArtPlayer', + }[player], + exact: true, + }) + .click(); + await page.locator('[data-test-id="select-stream-format"]').click(); + await page.getByRole('option', { name: format, exact: true }).click(); + if (player !== 'videojs' || format !== 'auto') { + const save = page.getByRole('button', { name: 'Save changes' }); + await expect(save).toBeVisible(); + await save.click(); + await expect(save).toBeHidden(); + } + await page.getByRole('button', { name: 'Back', exact: true }).click(); +} + +export async function addLiveFormatPortal(page: Page) { + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('radio', { name: /Xtream credentials/i }).click(); + await dialog.locator('#title').fill('Synthetic live'); + await dialog.locator('#serverUrl').fill(liveFormatMock); + await dialog.locator('#username').fill('live-fallback'); + await dialog.locator('#password').fill('live-fallback'); + await dialog.getByRole('button', { name: 'Add', exact: true }).click(); + await page.waitForURL(/xtreams.*vod/); + await page.getByRole('link', { name: 'Live TV', exact: true }).click(); + await page.locator('.context-panel .category-item').first().click(); + await expect(liveChannels(page).first()).toBeVisible(); +} + +export const liveChannels = (page: Page) => + page.locator('app-live-stream-layout [data-test-id="channel-item"]'); +export function observeLiveFormatMedia(page: Page) { + const media: { file: string; status: number }[] = []; + page.on('response', (response) => { + if (response.url().startsWith(`${liveFormatMock}/live/`)) + media.push({ + file: new URL(response.url()).pathname.split('/').pop() ?? '', + status: response.status(), + }); + }); + return media; +} +export async function expectLiveFormatPlaying( + page: Page, + player: LiveFormatPlayer +) { + await expect( + page.locator( + `app-${player === 'html5' ? 'html-video' : player === 'videojs' ? 'vjs' : 'art'}-player` + ) + ).toBeVisible(); + const video = page.locator('app-web-player-view video').first(); + await expect + .poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime), { + timeout: 50_000, + }) + .toBeGreaterThan(0.5); + return video; +} diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index b1dbcbd5c..3cc695184 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -358,7 +358,7 @@ "PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Dockt den Serienplayer in breiten Fenstern links an und zeigt daneben die kommenden Folgen an.", "FULLSCREEN_CHANNEL_PANEL": "Senderliste im Vollbild", "FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Bewege die Maus im Vollbild an den linken Rand oder drücke C, um die Senderliste einzublenden und den Sender zu wechseln, ohne das Vollbild zu verlassen.", - "STREAM_FORMAT_DESCRIPTION": "Standard-Streamformat auswählen (Xtream)", + "STREAM_FORMAT_DESCRIPTION": "Auto bevorzugt HLS. Schlägt Live-TV in MPV, VLC oder Embedded MPV fehl, den Player stoppen, ts wählen, speichern und den Sender erneut öffnen. Webplayer können bei einem anfänglichen HLS-HTTP-Fehler einmal angebotenes TS versuchen.", "LANGUAGE_DESCRIPTION": "Wählen Sie die Sprache der Anwendung aus", "THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus", "COVER_SIZE": "Cover-Größe", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index ec0111811..a2c4f1b68 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -358,7 +358,7 @@ "PLAYER_UP_NEXT_RAIL_DESCRIPTION": "On wide windows, dock the series player to the left and show the upcoming episodes beside it.", "FULLSCREEN_CHANNEL_PANEL": "Channel list in fullscreen", "FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Rest the mouse on the left edge or press C while in fullscreen to slide in the channel list and switch channels without leaving fullscreen.", - "STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)", + "STREAM_FORMAT_DESCRIPTION": "Auto prefers HLS. If live playback fails in MPV, VLC or Embedded MPV, stop the player, select ts, save and reopen the channel. Web players can try advertised TS once after an initial HLS HTTP failure.", "LANGUAGE_DESCRIPTION": "Select language of the application", "THEME_DESCRIPTION": "Select visual theme of the application", "COVER_SIZE": "Cover size", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 9c0550a57..2d871c185 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -358,7 +358,7 @@ "PLAYER_UP_NEXT_RAIL_DESCRIPTION": "В широких окнах закреплять плеер сериала слева и показывать следующие серии рядом с ним.", "FULLSCREEN_CHANNEL_PANEL": "Список каналов в полноэкранном режиме", "FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Подведите мышь к левому краю или нажмите C в полноэкранном режиме, чтобы выдвинуть список каналов и переключить канал, не выходя из полноэкранного режима.", - "STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)", + "STREAM_FORMAT_DESCRIPTION": "Auto предпочитает HLS. При сбое в MPV, VLC или Embedded MPV остановите плеер, выберите ts, сохраните настройки и снова откройте канал. Веб-плееры могут один раз попробовать заявленный TS после первоначальной HTTP-ошибки HLS.", "LANGUAGE_DESCRIPTION": "Выбрать язык приложения", "THEME_DESCRIPTION": "Выбрать визуальную тему оформления", "COVER_SIZE": "Размер обложки", diff --git a/apps/xtream-mock-server/src/app/live-format-fixture.ts b/apps/xtream-mock-server/src/app/live-format-fixture.ts new file mode 100644 index 000000000..863b32ed9 --- /dev/null +++ b/apps/xtream-mock-server/src/app/live-format-fixture.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Express } from 'express'; + +/** Entirely local synthetic media. IDs exercise initial manifest and segment failures. */ +export function installLiveFormatFixture(app: Express): void { + app.get('/live/live-fallback/live-fallback/:file', (request, response) => { + const file = String(request.params['file']); + response.set('Cache-Control', 'no-store'); + response.set('X-Fixture-User-Agent', request.get('user-agent') ?? ''); + if (file.endsWith('.m3u8')) { + if (file === '10001.m3u8') { + response.status(403).end(); + return; + } + response + .type('application/vnd.apple.mpegurl') + .send( + [ + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-TARGETDURATION:6', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXTINF:6,', + file === '10003.m3u8' + ? 'delayed-segment.ts' + : 'denied-segment.ts', + '#EXT-X-ENDLIST', + '', + ].join('\n') + ); + return; + } + if (file === 'delayed-segment.ts') { + const timer = setTimeout(() => response.status(403).end(), 1500); + response.on('close', () => clearTimeout(timer)); + return; + } + if (file === 'denied-segment.ts' || file === '10002.ts') { + response.status(403).end(); + return; + } + response + .type('video/mp2t') + .send( + readFileSync( + join( + process.cwd(), + 'apps/xtream-mock-server/src/fixtures/live.mpegts' + ) + ) + ); + }); +} diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 2452860c6..63b1b2dee 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -31,6 +31,17 @@ export interface ScenarioConfig { * Unknown credential pairs use a hash of "username:password" as seed. */ export const SCENARIOS: Record = { + 'live-fallback:live-fallback': { + name: 'live-format-fallback', + description: 'Local HLS failures and playable TS', + seed: 1513, + categoryCount: { live: 1, vod: 1, series: 1 }, + itemsPerCategory: 4, + seasonsPerSeries: 1, + episodesPerSeason: 1, + accountStatus: 'Active', + expiryDate: '2099-12-31', + }, 'user1:pass1': { name: 'default', description: 'Balanced portal — 8 categories, 40 items each', diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts index 4a67b8194..82b65fd24 100644 --- a/apps/xtream-mock-server/src/app/server.spec.ts +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -51,6 +51,31 @@ describe('Xtream mock server factory', () => { jest.restoreAllMocks(); }); + it('serves the synthetic manifest-success/segment-failure and TS alternatives locally', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ host: '127.0.0.1', port: 0 }) + ); + const base = `${running.origin}/live/live-fallback/live-fallback`; + try { + const manifest = await fetch(`${base}/10000.m3u8`); + expect(manifest.status).toBe(200); + expect(await manifest.text()).toContain('denied-segment.ts'); + expect((await fetch(`${base}/denied-segment.ts`)).status).toBe(403); + expect((await fetch(`${base}/10001.m3u8`)).status).toBe(403); + expect((await fetch(`${base}/10002.ts`)).status).toBe(403); + const ts = await fetch(`${base}/10000.ts`, { + headers: { 'User-Agent': 'synthetic' }, + }); + expect(ts.headers.get('x-fixture-user-agent')).toBe('synthetic'); + const bytes = new Uint8Array(await ts.arrayBuffer()); + expect(bytes.length).toBeGreaterThan(188); + expect(bytes[0]).toBe(0x47); + expect(bytes[188]).toBe(0x47); + } finally { + await running.close(); + } + }); + it('keeps performance controls absent unless explicitly enabled', async () => { const running = await startLoopbackServer( createXtreamMockApp({ host: '127.0.0.1', port: 0 }) diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts index aac33a82d..95d80cd37 100644 --- a/apps/xtream-mock-server/src/app/server.ts +++ b/apps/xtream-mock-server/src/app/server.ts @@ -6,6 +6,7 @@ import express, { type Request, type Response, } from 'express'; +import { installLiveFormatFixture } from './live-format-fixture.js'; import { resetAll } from './data-store.js'; import { renderMarketingAssetSvg } from './generators/marketing.generator.js'; import { installPerformanceControlRoutes } from './performance-control-routes.js'; @@ -107,6 +108,7 @@ export function createXtreamMockApp( dispatchAction(request, response) ); }); + if (!controlEnabled) installLiveFormatFixture(app); installStreamRoutes(app, controlEnabled); app.get('/xtream', (request, response, next) => { dispatchWithControl(controller, request, response, next, 'proxy', () => diff --git a/apps/xtream-mock-server/src/fixtures/README.md b/apps/xtream-mock-server/src/fixtures/README.md new file mode 100644 index 000000000..330cf7f64 --- /dev/null +++ b/apps/xtream-mock-server/src/fixtures/README.md @@ -0,0 +1,15 @@ +# Synthetic playback fixtures + +`live.mpegts` is six seconds of generated color-pattern video and a sine wave, +H.264 baseline + AAC, used by the local Xtream live-format regression account. +No real provider media or credentials are included. + +Regenerate from the repository root with FFmpeg: + +```sh +ffmpeg -f lavfi -i testsrc2=size=160x90:rate=25 -f lavfi -i sine=frequency=440:sample_rate=48000 -t 6 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -g 25 -c:a aac -b:a 48k -f mpegts apps/xtream-mock-server/src/fixtures/live.mpegts +``` + +The mock returns the fixture only for synthetic live-format account media; +manifest and denied-segment routes return controlled 200/403 responses. Web and +Electron E2E tests check advancing playback, not HTTP status alone. diff --git a/apps/xtream-mock-server/src/fixtures/live.mpegts b/apps/xtream-mock-server/src/fixtures/live.mpegts new file mode 100644 index 000000000..22c6149ac Binary files /dev/null and b/apps/xtream-mock-server/src/fixtures/live.mpegts differ diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index ed317b278..a2a4c8279 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -494,7 +494,7 @@ Video.js HTTP error is `network-error` and shows its status. Because an HTTP status is server/network evidence rather than decoding evidence, external decoding is not presented as a likely fix. -Video.js `8.23.9`, the default web player, runs HTTP streaming through bundled +Video.js `8.24.0`, the default web player, runs HTTP streaming through bundled VHS `3.17.5`. Its terminal `Player#error` crosses a separate allowlisted boundary built only from the public Video.js `MediaError` code/status, `metadata.errorType`, and the documented `player.tech().vhs` runtime property. @@ -841,7 +841,12 @@ original headers or credentials required to reconstruct a safe launch request. No recommendation mutates `Settings.player` or another persisted setting. Recovery recommendations never auto-switch a player or source and do not -replace the separate source-owner auto-failover feature. Attempts, overrides, +replace the separate source-owner auto-failover feature. The narrow Xtream +live Auto format contract is separate: a source owner may supply one advertised +TS transport to the same web player on an initial terminal HTTP failure. It +never chooses another engine, and its pending callback waits for the old +transport's render teardown. See [Initial Auto HLS failure](./xtream-portal-compatibility.md#initial-auto-hls-failure) +for eligibility, session ownership and the external/Embedded MPV/VHS limits. Attempts, overrides, resume handoff, and diagnostics are session-local: there is no persistent history, cross-session learning, correlation, or telemetry. diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 71c1ce632..3dfc85bec 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -129,6 +129,58 @@ Manual `ts` and `m3u8` settings remain supported; when a manual setting is not allowed by the portal, URL construction falls back to the first provider-allowed format. +### Initial Auto HLS failure + +The routed Xtream Live TV host attaches `ResolvedPortalPlayback.liveAutoTsUrl` +only when the current user preference is Auto and the current account explicitly +advertises both HLS and TS. `XtreamUrlService.constructAutoLiveTsUrl` uses the +same credential encoding and provider-subpath URL builder as the initial URL; +no URL string replacement, manifest preflight, stream download, or API probe is +introduced. Unknown/empty formats and manual HLS/TS have no alternative. + +`WebPlayerLiveAutoFormat` may consume that alternative once per mounted logical +live session, in the same selected web player, after an owned terminal network +diagnostic with HTTP 4xx/5xx, before the native video element reports `playing`. +A successful manifest is not playback. HLS key/media/unknown-stage failures, +DRM payloads, content-info VOD/episodes, and catch-up are excluded. Generic +network failures, status zero, cancellation, timeouts without an HTTP response, +and decode errors keep the normal explicit recovery surface. + +The old application is first removed from the render tree. The next render +callback starts TS only after the web engine's synchronous loader teardown and +only while source, logical session and user intent still match. The TS payload +keeps the original headers, title and playback metadata; Electron applies them +through its existing scoped header handoff. HTTP-media requests retain the +existing transport/trust rules and do not feed new evidence or admission into +the separate portal-API host-connectivity guard. Recovery recommendations still +require a user action and never auto-switch engines. No settings or playlist +cache is changed. A TS failure displays normal recovery actions; Retry repeats +TS without rearming the automatic attempt. Changing the actual logical channel +or playlist, or mounting a new host, creates a new session; same-session metadata +or provider-source refresh does not rearm it. Same-channel replay and metadata +refresh preserve the chosen TS while the candidate URLs, headers and mode stay +the same; an actual transport/provider change discards that selection. This deliberately avoids a sticky +playlist preference and reevaluates advertised formats on the next channel. + +| Player/path | Initial Auto failure support | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| HTML5 / ArtPlayer, hls.js | One advertised TS attempt after terminal HLS manifest/level/segment HTTP failure; waits for the engine's own bounded retries | +| Video.js / VHS | Only an observable terminal HTTP diagnostic can trigger an attempt. A sole HLS rendition's failing segment can remain in VHS retry/exclusion cycles without such a diagnostic; use manual TS for that case | +| External MPV / VLC | Manual TS only. Process launch/exit and best-effort playing telemetry do not reliably identify the initial HLS media HTTP failure | +| Embedded MPV | Manual TS only; no equivalent structured HTTP diagnostic | +| Unified Favorites/Recent live resolution | Manual TS; it does not own fresh session account-format evidence | + +For manual recovery, stop the current player, choose **Settings > Playback > +Stream Format > ts**, save, and reopen the channel. This keeps the chosen player +and avoids a second uncorrelated external process. The settings description +explains this route. This is partial playback coverage of #1513, not a claim +that all external-player and VHS scenarios automatically recover. + +Synthetic regression media lives in the mock's `live-fallback:live-fallback` +account: HLS manifest 200 plus segment 403, manifest 403, a playable local +H.264/AAC TS alternative, a failing TS alternative, and a delayed HLS segment. +Tests assert actual video progress and the number of concrete TS requests. + If stored Xtream playback credentials contain an invalid server URL or blank username/password, stream URL construction returns an empty URL instead of throwing during playback. diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts index a6599ab54..6dbdf3197 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -120,6 +120,48 @@ describe('XtreamUrlService', () => { expect(url).toBe('http://demo.example/live/demo/secret/101.ts'); }); + it('builds an Auto TS alternative with encoded credentials and the provider subpath', () => { + streamFormat.set(StreamFormat.AutoStreamFormat); + expect( + service.constructAutoLiveTsUrl( + { + serverUrl: + 'https://demo.example/panel/player_api.php?token=unused', + username: 'a/b', + password: 'x?y#z', + allowedOutputFormats: [' M3U8 ', ' TS '], + }, + 101 + ) + ).toBe('https://demo.example/panel/live/a%2Fb/x%3Fy%23z/101.ts'); + }); + + it.each([undefined, [], ['m3u8'], ['rtmp'], ['ts']])( + 'does not guess an HLS-to-TS alternative for %j', + (allowedOutputFormats) => { + streamFormat.set(StreamFormat.AutoStreamFormat); + expect( + service.constructAutoLiveTsUrl( + { ...credentials, allowedOutputFormats }, + 101 + ) + ).toBeUndefined(); + } + ); + + it.each([StreamFormat.TsStreamFormat, StreamFormat.M3u8StreamFormat])( + 'respects manual %s even with both formats advertised', + (format) => { + streamFormat.set(format); + expect( + service.constructAutoLiveTsUrl( + { ...credentials, allowedOutputFormats: ['m3u8', 'ts'] }, + 101 + ) + ).toBeUndefined(); + } + ); + it('constructs a VOD URL from catalog fields when metadata is empty', () => { const vodItem = { info: [], diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index b2c8650b6..9a60f698c 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts @@ -113,6 +113,29 @@ export class XtreamUrlService { return `${normalizedCredentials.serverUrl}/live/${normalizedCredentials.username}/${normalizedCredentials.password}/${xtreamId}.${streamFormat}`; } + /** No probe: eligibility is explicit account evidence plus the user's Auto intent. */ + constructAutoLiveTsUrl( + credentials: XtreamCredentials, + xtreamId: number + ): string | undefined { + const requested = + this.settingsStore.streamFormat() ?? StreamFormat.AutoStreamFormat; + const formats = this.getNormalizedAllowedOutputFormats(credentials); + if ( + requested !== StreamFormat.AutoStreamFormat || + !formats?.includes('m3u8') || + !formats.includes('ts') + ) + return undefined; + return ( + this.constructLiveUrl( + credentials, + xtreamId, + StreamFormat.TsStreamFormat + ) || undefined + ); + } + /** * Construct VOD stream URL * Format: {serverUrl}/movie/{username}/{password}/{streamId}.{extension} 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 0d4f486d5..49ecaa805 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 @@ -196,6 +196,7 @@ describe('LiveStreamLayoutComponent', () => { getFavorites: jest.fn().mockReturnValue(of([])), }; const xtreamUrlService = { + constructAutoLiveTsUrl: jest.fn(() => undefined), resolveCatchupUrl: jest .fn() .mockResolvedValue('https://example.com/timeshift.ts'), @@ -357,6 +358,16 @@ describe('LiveStreamLayoutComponent', () => { window.electron = originalElectron; }); + it('retires committed live playback when the playlist owner changes', () => { + fixture.detectChanges(); + component.playLive({ xtream_id: 10000, title: 'Synthetic' }); + expect(component.activePlayback()).not.toBeNull(); + currentPlaylist.set({ ...playlist, id: 'other-playlist' }); + fixture.detectChanges(); + expect(component.activePlayback()).toBeNull(); + expect(component.playbackSessionKey()).toBe(''); + }); + it('renders the controlled epg list for electron playback', () => { epgItems.set([ buildEpgItem( 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 e79e89b06..ad3d5cce4 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 @@ -372,6 +372,7 @@ export class LiveStreamLayoutComponent private unsubscribeRemoteChannelChange?: () => void; private unsubscribeRemoteCommand?: () => void; private playbackRequestId = 0; + private activePlaylistId: string | null = null; readonly usesEmbeddedPlayer = computed(() => this.portalPlayer.isEmbeddedPlayer() @@ -391,6 +392,16 @@ export class LiveStreamLayoutComponent favorites = new Map(); constructor() { + effect(() => { + const playlistId = this.xtreamStore.currentPlaylist()?.id ?? null; + if (this.activePlaylistId && this.activePlaylistId !== playlistId) { + this.playbackRequestId += 1; + this.activePlaylistId = null; + this.activePlayback.set(null); + this.activeLiveItemId.set(null); + this.activeCatchupProgram.set(null); + } + }); effect((onCleanup) => { const intervalId = window.setInterval(() => { this.currentTimeMs.set(Date.now()); @@ -547,8 +558,19 @@ export class LiveStreamLayoutComponent // store no-op. if (!remote) this.selectLiveItemCategory(item); this.activeLiveItemId.set(item.xtream_id); + const playlist = this.xtreamStore.currentPlaylist(); + this.activePlaylistId = playlist?.id ?? null; this.activePlayback.set({ streamUrl, + liveAutoTsUrl: playlist + ? this.xtreamUrlService.constructAutoLiveTsUrl( + playlist, + item.xtream_id + ) + : undefined, + userAgent: playlist?.userAgent, + referer: playlist?.referrer, + origin: playlist?.origin, title: item.title ?? item.name ?? '', thumbnail: item.poster_url ?? item.stream_icon ?? null, isLive: true, @@ -761,6 +783,7 @@ export class LiveStreamLayoutComponent return; } + this.activePlaylistId = playlist.id; this.activeCatchupProgram.set(program); this.activePlayback.set({ streamUrl: catchupUrl, diff --git a/libs/shared/interfaces/src/lib/portal-playback.interface.ts b/libs/shared/interfaces/src/lib/portal-playback.interface.ts index c06ebb8bc..9fdca6814 100644 --- a/libs/shared/interfaces/src/lib/portal-playback.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-playback.interface.ts @@ -10,6 +10,8 @@ export interface PlayerContentInfo extends Omit< export interface ResolvedPortalPlayback { streamUrl: string; + /** Source-owner supplied, advertised TS alternative for Xtream live Auto only. */ + liveAutoTsUrl?: string; title: string; thumbnail?: string | null; isLive?: boolean; diff --git a/libs/ui/playback/src/lib/art-player/art-player-video-session.ts b/libs/ui/playback/src/lib/art-player/art-player-video-session.ts index c01485bf4..86570eb31 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-video-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-video-session.ts @@ -15,6 +15,7 @@ export interface ArtPlayerVideoSessionConfig { emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void; emitTimeUpdate: (value: { currentTime: number; duration: number }) => void; emitPlaybackEnded: () => void; + emitPlaybackStarted?: () => void; } /** Owns native video and ArtPlayer event listeners for one player instance. */ @@ -35,6 +36,11 @@ export class ArtPlayerVideoSession { ); }; + private readonly handlePlaying = (): void => { + this.clearPlaybackIssue(); + this.config.emitPlaybackStarted?.(); + }; + private readonly clearPlaybackIssue = (): void => { this.config.emitPlaybackIssue(null); }; @@ -73,7 +79,7 @@ export class ArtPlayerVideoSession { > = [ ['error', this.handleNativePlaybackError], ['loadeddata', this.clearPlaybackIssue], - ['playing', this.clearPlaybackIssue], + ['playing', this.handlePlaying], ['volumechange', this.handleVolumeChange], ['ended', this.handlePlaybackEnded], ]; 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 88f7f629a..79903205f 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 @@ -66,6 +66,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { }>(); readonly playbackIssue = output(); readonly playbackEnded = output(); + readonly playbackStarted = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); @@ -187,6 +188,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue), emitTimeUpdate: (value) => this.timeUpdate.emit(value), emitPlaybackEnded: () => this.playbackEnded.emit(), + emitPlaybackStarted: () => this.playbackStarted.emit(), }); this.videoSession = videoSession; videoSession.attach(); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts index ef9593c54..190fce46c 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts @@ -15,6 +15,7 @@ export interface HtmlVideoElementSessionConfig { emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void; emitTimeUpdate: (value: { currentTime: number; duration: number }) => void; emitPlaybackEnded: () => void; + emitPlaybackStarted?: () => void; } export class HtmlVideoElementSession { @@ -34,6 +35,11 @@ export class HtmlVideoElementSession { ); }; + private readonly handlePlaying = (): void => { + this.clearPlaybackIssue(); + this.config.emitPlaybackStarted?.(); + }; + private readonly clearPlaybackIssue = (): void => { this.config.emitPlaybackIssue(null); }; @@ -71,7 +77,7 @@ export class HtmlVideoElementSession { ['timeupdate', this.handleTimeUpdate], ['error', this.handleNativePlaybackError], ['loadeddata', this.clearPlaybackIssue], - ['playing', this.clearPlaybackIssue], + ['playing', this.handlePlaying], ['ended', this.handlePlaybackEnded], ]; 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 13dd005fc..652651186 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 @@ -87,6 +87,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { }>(); @Output() playbackIssue = new EventEmitter(); @Output() playbackEnded = new EventEmitter(); + @Output() playbackStarted = new EventEmitter(); @Output() previousEpisodeRequested = new EventEmitter(); @Output() nextEpisodeRequested = new EventEmitter(); @@ -382,6 +383,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue), emitTimeUpdate: (value) => this.timeUpdate.emit(value), emitPlaybackEnded: () => this.playbackEnded.emit(), + emitPlaybackStarted: () => this.playbackStarted.emit(), }); return this.videoSession; } 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 a63b78131..5756d95cd 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 @@ -90,6 +90,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { }>(); readonly playbackIssue = output(); readonly playbackEnded = output(); + readonly playbackStarted = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); @@ -103,6 +104,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { releasePictureInPicture: !this.sharedControls, clearPlaybackIssue: () => this.playbackIssue.emit(null), emitPlaybackEnded: () => this.playbackEnded.emit(), + emitPlaybackStarted: () => this.playbackStarted.emit(), }); private readonly mpegTsSession = new VjsMpegTsSession({ player: () => this.player, @@ -146,7 +148,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { vjsOptions, this.handlePlayerReady ) as unknown as VideoJsPlayer; - this.bindPlayerEvents(); + this.bindPlayerEvents(true); initializeVjsPlugins(this.player); if (!this.sharedControls) { this.legacyShortcuts = attachVjsLegacyShortcuts({ @@ -220,7 +222,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.mpegTsSession.destroy(); this.videoSession.destroy(); if (this.player) { - this.unbindPlayerEvents(); + this.bindPlayerEvents(false); this.player.dispose(); } } @@ -410,21 +412,18 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.legacyTracks.bind(); } - private bindPlayerEvents(): void { - this.player.on('loadedmetadata', this.handleLoadedMetadata); - this.player.on('error', this.handleVideoJsError); - this.player.on('volumechange', this.handleVolumeChange); - this.player.on('timeupdate', this.handleTimeUpdate); - this.player.on('pause', this.handlePauseForReset); - this.player.on('playerreset', this.handlePlayerReset); - } - - private unbindPlayerEvents(): void { - this.player.off('loadedmetadata', this.handleLoadedMetadata); - this.player.off('error', this.handleVideoJsError); - this.player.off('volumechange', this.handleVolumeChange); - this.player.off('timeupdate', this.handleTimeUpdate); - this.player.off('pause', this.handlePauseForReset); - this.player.off('playerreset', this.handlePlayerReset); + private bindPlayerEvents(bind: boolean): void { + const events = { + loadedmetadata: this.handleLoadedMetadata, + error: this.handleVideoJsError, + volumechange: this.handleVolumeChange, + timeupdate: this.handleTimeUpdate, + pause: this.handlePauseForReset, + playerreset: this.handlePlayerReset, + }; + for (const [event, listener] of Object.entries(events)) { + if (bind) this.player.on(event, listener); + else this.player.off(event, listener); + } } } diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-video-element-session.ts b/libs/ui/playback/src/lib/vjs-player/vjs-video-element-session.ts index 5d2189687..3f9292e59 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-video-element-session.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-video-element-session.ts @@ -4,12 +4,18 @@ export interface VjsVideoElementSessionConfig { releasePictureInPicture?: boolean; clearPlaybackIssue: () => void; emitPlaybackEnded: () => void; + emitPlaybackStarted?: () => void; } export class VjsVideoElementSession { private currentVideo: HTMLVideoElement | null = null; private destroyed = false; + private readonly handlePlaying = (): void => { + this.clearPlaybackIssue(); + this.config.emitPlaybackStarted?.(); + }; + private readonly clearPlaybackIssue = () => { this.config.clearPlaybackIssue(); }; @@ -28,7 +34,7 @@ export class VjsVideoElementSession { this.detach(); this.currentVideo = video; video.addEventListener('loadeddata', this.clearPlaybackIssue); - video.addEventListener('playing', this.clearPlaybackIssue); + video.addEventListener('playing', this.handlePlaying); video.addEventListener('ended', this.emitPlaybackEnded); } @@ -57,10 +63,7 @@ export class VjsVideoElementSession { 'loadeddata', this.clearPlaybackIssue ); - this.currentVideo.removeEventListener( - 'playing', - this.clearPlaybackIssue - ); + this.currentVideo.removeEventListener('playing', this.handlePlaying); this.currentVideo.removeEventListener('ended', this.emitPlaybackEnded); this.currentVideo = null; } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-channel-panel-state.ts b/libs/ui/playback/src/lib/web-player-view/web-player-channel-panel-state.ts new file mode 100644 index 000000000..1c24754be --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-channel-panel-state.ts @@ -0,0 +1,30 @@ +import { linkedSignal } from '@angular/core'; +import type { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces'; + +/** + * The fullscreen channel panel is DOM content over the video. Embedded + * MPV's native-view engine paints a platform view above the page, where + * no DOM layer can show, so the panel exists only while the rendered + * engine is a web player or Embedded MPV's frame-copy canvas. Fails + * closed until frame-copy has been confirmed. Retain that confirmation + * while a replacement component probes support, so channel changes do + * not reset the panel. A confirmed native/unsupported result revokes it. + */ +export function createChannelPanelAvailability( + embedded: () => boolean, + support: () => EmbeddedMpvSupport | null +) { + return linkedSignal< + { embedded: boolean; support: EmbeddedMpvSupport | null }, + boolean + >({ + source: () => ({ embedded: embedded(), support: support() }), + computation: ({ embedded, support }, previous) => { + if (!embedded) return true; + if (support === null) { + return previous?.source.embedded ? previous.value : false; + } + return support.supported && support.engine === 'frame-copy'; + }, + }); +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-live-auto-format.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-live-auto-format.spec.ts new file mode 100644 index 000000000..8e4d9479a --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-live-auto-format.spec.ts @@ -0,0 +1,184 @@ +import { Component, Injector, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, + type PlaybackDiagnostic, +} from '@iptvnator/playback/util'; +import { + VideoPlayer, + type ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { ErrorDetails } from 'hls.js'; +import { WebPlayerLiveAutoFormat } from './web-player-live-auto-format'; + +@Component({ template: '' }) +class Host {} + +const failure: PlaybackDiagnostic = { + code: PlaybackDiagnosticCode.NetworkError, + source: PlaybackDiagnosticSource.Vhs, + sourceUrl: '', + container: 'm3u8', + player: 'videojs', + httpStatus: 403, + audioCodecs: [], + videoCodecs: [], +}; +const source: ResolvedPortalPlayback = { + streamUrl: 'https://fixture.test/stream.php?token=synthetic&extension=m3u8', + liveAutoTsUrl: + 'https://fixture.test/other.php?token=synthetic&extension=ts', + title: 'Fixture', + isLive: true, + userAgent: 'fixture', + headers: { Authorization: 'Bearer synthetic' }, +}; + +describe('Auto live source-format attempt lifecycle', () => { + const playback = signal(source); + const sessionKey = signal('playlist-a/channel-1'); + const player = signal(VideoPlayer.VideoJs); + const intent = signal(0); + const autoEnabled = signal(true); + let controller: WebPlayerLiveAutoFormat; + let fixture: ReturnType>; + beforeEach(() => { + playback.set({ ...source }); + sessionKey.set('playlist-a/channel-1'); + player.set(VideoPlayer.VideoJs); + intent.set(0); + autoEnabled.set(true); + fixture = TestBed.createComponent(Host); + controller = new WebPlayerLiveAutoFormat({ + playback, + sessionKey, + player, + intent, + autoEnabled, + injector: fixture.debugElement.injector.get(Injector), + }); + fixture.detectChanges(); + }); + afterEach(() => { + controller.destroy(); + fixture.destroy(); + }); + async function render() { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + it('waits for the old transport render, preserves the whole payload and consumes only one attempt', async () => { + expect(controller.tryFallback(failure)).toBe(true); + expect(controller.pending()).toBe(true); + expect(controller.playback()).toBe(playback()); + expect(controller.tryFallback(failure)).toBe(false); + await render(); + expect(controller.playback()).toEqual({ + ...source, + streamUrl: source.liveAutoTsUrl, + liveAutoTsUrl: undefined, + }); + const mounted = controller.playback(); + controller.started(); + expect(controller.playback()).toBe(mounted); + expect(controller.tryFallback(failure)).toBe(false); + intent.update((n) => n + 1); + expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl); + player.set(VideoPlayer.Html5Player); + expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl); + }); + it('does not downgrade after actual playing', () => { + controller.started(); + expect(controller.tryFallback(failure)).toBe(false); + }); + it.each([0, 200, undefined])( + 'does not guess from HTTP status %s', + (httpStatus) => { + expect(controller.tryFallback({ ...failure, httpStatus })).toBe( + false + ); + } + ); + it.each([ + PlaybackDiagnosticCode.UnknownPlaybackError, + PlaybackDiagnosticCode.DrmOrEncryption, + PlaybackDiagnosticCode.MediaDecodeError, + ])('does not downgrade %s', (code) => { + expect(controller.tryFallback({ ...failure, code })).toBe(false); + }); + it.each(['key', 'media', 'unknown'] as const)( + 'does not downgrade HLS %s failures even with an HTTP status', + (stage) => { + expect( + controller.tryFallback({ + ...failure, + hls: { + engineType: 'unknown', + engineDetails: ErrorDetails.KEY_LOAD_ERROR, + disposition: 'fatal', + stage, + failure: 'http', + httpStatus: 403, + }, + }) + ).toBe(false); + } + ); + it('respects manual preference changes before the failure and before pending completion', async () => { + autoEnabled.set(false); + expect(controller.tryFallback(failure)).toBe(false); + autoEnabled.set(true); + expect(controller.tryFallback(failure)).toBe(true); + autoEnabled.set(false); + await render(); + expect(controller.playback()).toBe(playback()); + }); + it('ignores a delayed completion after another playlist or channel takes ownership', async () => { + controller.tryFallback(failure); + sessionKey.set('playlist-b/channel-1'); + playback.set({ ...source, title: 'Other playlist' }); + await render(); + expect(controller.playback()).toBe(playback()); + expect(controller.tryFallback(failure)).toBe(true); + await render(); + expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl); + }); + it('cancels on disposal and does not transfer attempts to another mounted session', async () => { + controller.tryFallback(failure); + controller.destroy(); + await render(); + expect(controller.playback()).toBe(playback()); + expect(controller.tryFallback(failure)).toBe(false); + }); + it('does not rearm when same-session source metadata refreshes', async () => { + controller.tryFallback(failure); + await render(); + playback.set({ ...source, title: 'Refreshed' }); + expect(controller.playback()?.streamUrl).toBe(source.liveAutoTsUrl); + expect(controller.playback()?.title).toBe('Refreshed'); + playback.set({ ...source, streamUrl: 'https://other.test/live.m3u8' }); + expect(controller.playback()?.streamUrl).toBe( + 'https://other.test/live.m3u8' + ); + expect(controller.tryFallback(failure)).toBe(false); + }); + it.each([ + { isLive: false }, + { liveAutoTsUrl: undefined }, + { drm: {} }, + { contentInfo: {} }, + ])( + 'excludes VOD, catchup, missing advertisement and DRM: %j', + (overrides) => { + playback.set({ ...source, ...overrides } as ResolvedPortalPlayback); + expect(controller.tryFallback(failure)).toBe(false); + } + ); + it('does not start an Embedded MPV attempt', () => { + player.set(VideoPlayer.EmbeddedMpv); + expect(controller.tryFallback(failure)).toBe(false); + }); +}); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-live-auto-format.ts b/libs/ui/playback/src/lib/web-player-view/web-player-live-auto-format.ts new file mode 100644 index 000000000..9f70bfc81 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-live-auto-format.ts @@ -0,0 +1,171 @@ +import { + afterNextRender, + computed, + linkedSignal, + type AfterRenderRef, + type Injector, + type Signal, +} from '@angular/core'; +import { + type PlaybackDiagnostic, + resolvePlaybackUrlSourceKind, +} from '@iptvnator/playback/util'; +import { + type ResolvedPortalPlayback, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; + +/** One source-format attempt per mounted logical live session, never an engine switch. */ +export class WebPlayerLiveAutoFormat { + private callback?: AfterRenderRef; + private destroyed = false; + // Metadata refreshes reuse the transport revision. The returned ownership + // value is opaque; URLs/headers remain solely in the canonical source input. + private readonly origin = linkedSignal({ + source: () => this.deps.playback(), + computation: ( + source, + previous?: { source: ResolvedPortalPlayback | null; value: symbol } + ) => + previous && sameLiveTransport(source, previous.source) + ? previous.value + : Symbol(), + }); + private readonly intent = computed(() => { + void this.deps.intent(); + void this.deps.player(); + void this.deps.autoEnabled(); + return Symbol(); + }); + private readonly state = linkedSignal({ + source: () => ({ key: this.deps.sessionKey(), origin: this.origin() }), + computation: ( + source, + previous?: { + source: { key: string; origin: symbol }; + value: { + attempted: boolean; + started: boolean; + pending: boolean; + useTs: boolean; + }; + } + ) => ({ + attempted: + previous?.source.key === source.key && + !!previous.value.attempted, + started: + previous?.source.key === source.key && !!previous.value.started, + pending: false, + useTs: false, + }), + }); + readonly pending = computed(() => this.state().pending); + private readonly useTs = computed(() => this.state().useTs); + readonly playback = computed(() => { + const playback = this.deps.playback(); + return this.useTs() && playback?.liveAutoTsUrl + ? { + ...playback, + streamUrl: playback.liveAutoTsUrl, + liveAutoTsUrl: undefined, + } + : playback; + }); + + constructor( + private readonly deps: { + playback: Signal; + sessionKey: Signal; + player: Signal; + intent: () => unknown; + autoEnabled: () => boolean; + injector: Injector; + } + ) {} + + started(): void { + this.state.update((state) => ({ ...state, started: true })); + } + + tryFallback(issue: PlaybackDiagnostic): boolean { + const source = this.deps.playback(); + const state = this.state(); + const status = issue.httpStatus; + if ( + this.destroyed || + !this.deps.autoEnabled() || + state.attempted || + state.started || + !this.deps.sessionKey() || + !source?.liveAutoTsUrl || + source.isLive !== true || + source.contentInfo || + source.drm || + this.deps.player() === VideoPlayer.EmbeddedMpv || + resolvePlaybackUrlSourceKind(source.streamUrl) !== 'hls' || + resolvePlaybackUrlSourceKind(source.liveAutoTsUrl) !== 'mpegts' || + issue.code !== 'network-error' || + status === undefined || + status < 400 || + status > 599 || + // Key/license requests must never cause a format downgrade. + (issue.hls && + !['manifest', 'level', 'segment'].includes(issue.hls.stage)) + ) + return false; + this.state.set({ ...state, attempted: true, pending: true }); + const origin = this.origin(); + const key = this.deps.sessionKey(); + const intent = this.intent(); + this.callback?.destroy(); + // Render an empty application first. All web transports synchronously destroy + // their loaders in ngOnDestroy before the TS header handoff may start. + this.callback = afterNextRender( + () => { + this.callback = undefined; + if ( + this.destroyed || + origin !== this.origin() || + key !== this.deps.sessionKey() + ) + return; + this.state.update((current) => ({ + ...current, + pending: false, + useTs: intent === this.intent(), + })); + }, + { injector: this.deps.injector } + ); + return true; + } + + destroy(): void { + this.destroyed = true; + this.callback?.destroy(); + this.callback = undefined; + } +} + +function sameLiveTransport( + a: ResolvedPortalPlayback | null, + b: ResolvedPortalPlayback | null +): boolean { + if (!a || !b) return a === b; + const headerKeys = new Set([ + ...Object.keys(a.headers ?? {}), + ...Object.keys(b.headers ?? {}), + ]); + return ( + a.streamUrl === b.streamUrl && + a.liveAutoTsUrl === b.liveAutoTsUrl && + a.isLive === b.isLive && + a.drm === b.drm && + a.contentInfo === b.contentInfo && + a.userAgent === b.userAgent && + a.referer === b.referer && + a.origin === b.origin && + [...headerKeys].every((key) => a.headers?.[key] === b.headers?.[key]) + ); +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-controller.ts b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-controller.ts index 02245b692..5114f6ff5 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-controller.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-controller.ts @@ -40,6 +40,7 @@ export interface WebPlayerRecoveryControllerDeps { readonly playbackExternallyTransferable: Signal; readonly alternativeSourceCount: () => number; readonly managedExternalPlayersAvailable: () => boolean; + readonly tryAutoLiveFormat?: (issue: PlaybackDiagnostic) => boolean; readonly emitPlaybackFailed: (code: PlaybackDiagnosticCode) => void; readonly emitExternalFallbackRequested: ( request: PlaybackFallbackRequest @@ -120,6 +121,12 @@ export class WebPlayerRecoveryController { this.clearDiagnostic(); return; } + if (this.deps.tryAutoLiveFormat?.(issue)) { + applicationHandoff.invalidate(); + recoverySession.clearPlaybackBinding(); + this.clearDiagnostic(); + return; + } if (!recoverySession.recordFailure(binding)) { return; } 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 304f8c04e..58c30608f 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 @@ -14,6 +14,7 @@ [seriesNavigation]="seriesNavigation()" (timeUpdate)="handleTimeUpdate($event, application)" (playbackIssue)="handlePlaybackIssue($event, binding)" + (playbackStarted)="handlePlaybackStarted(binding)" (playbackEnded)="playbackEnded.emit()" (previousEpisodeRequested)=" previousEpisodeRequested.emit() @@ -42,6 +43,7 @@ [seriesNavigation]="seriesNavigation()" (timeUpdate)="handleTimeUpdate($event, application)" (playbackIssue)="handlePlaybackIssue($event, binding)" + (playbackStarted)="handlePlaybackStarted(binding)" (playbackEnded)="playbackEnded.emit()" (previousEpisodeRequested)=" previousEpisodeRequested.emit() @@ -70,6 +72,7 @@ [seriesNavigation]="seriesNavigation()" (timeUpdate)="handleTimeUpdate($event, application)" (playbackIssue)="handlePlaybackIssue($event, binding)" + (playbackStarted)="handlePlaybackStarted(binding)" (playbackEnded)="playbackEnded.emit()" (previousEpisodeRequested)=" previousEpisodeRequested.emit() diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.live-format.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.live-format.spec.ts new file mode 100644 index 000000000..ae3cfa5d3 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.live-format.spec.ts @@ -0,0 +1,288 @@ +import { ClipboardModule } from '@angular/cdk/clipboard'; +import { + ComponentFixture, + DeferBlockBehavior, + TestBed, +} from '@angular/core/testing'; +import { DestroyRef, signal } from '@angular/core'; +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 { + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, + type PlaybackDiagnostic, +} from '@iptvnator/playback/util'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { + type ExternalPlayerSession, + STORE_KEY, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util'; +import { VodSourceRowComponent } from '@iptvnator/ui/components'; +import { of } from 'rxjs'; +import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component'; +import { + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubFullscreenChannelPanelComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, +} from './web-player-view.spec-stubs'; +import { ElectronStreamHeadersService } from './electron-stream-headers.service'; +import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.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', () => ({})); + +describe('WebPlayerViewComponent live format integration', () => { + let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; + let fixture: ComponentFixture; + let component: WebPlayerViewComponentInstance; + const storedSettings = { player: VideoPlayer.VideoJs }; + const storage = { + get: jest.fn((key: string) => + of(key === STORE_KEY.Settings ? storedSettings : undefined) + ), + }; + let runtime: { supportsManagedExternalPlayers: boolean }; + let activeExternalSession: ReturnType< + typeof signal + >; + let closeExternalSession: jest.Mock, [ExternalPlayerSession]>; + let holdHeaderHandoff: boolean; + let headerResolvers: Array<(stillCurrent: boolean) => void>; + let headerRejectors: Array<(reason?: unknown) => void>; + const streamHeaders = { + apply: jest.fn( + () => + (holdHeaderHandoff + ? new Promise((resolve, reject) => { + headerResolvers.push(resolve); + headerRejectors.push(reject); + }) + : null) as Promise | null + ), + clear: jest.fn(), + }; + + beforeAll(async () => { + ({ WebPlayerViewComponent } = + await import('./web-player-view.component')); + }); + + beforeEach(async () => { + runtime = { supportsManagedExternalPlayers: true }; + activeExternalSession = signal(null); + closeExternalSession = jest.fn(async (session) => { + activeExternalSession.set({ + ...session, + status: 'closed', + canClose: false, + updatedAt: '2026-08-08T10:00:02.000Z', + }); + }); + holdHeaderHandoff = false; + headerResolvers = []; + headerRejectors = []; + streamHeaders.apply.mockClear(); + streamHeaders.clear.mockClear(); + await TestBed.configureTestingModule({ + deferBlockBehavior: DeferBlockBehavior.Playthrough, + imports: [WebPlayerViewComponent, TranslateModule.forRoot()], + providers: [ + { provide: StorageMap, useValue: storage }, + { provide: RuntimeCapabilitiesService, useValue: runtime }, + { + provide: ElectronStreamHeadersService, + useValue: streamHeaders, + }, + { + provide: SettingsStore, + useValue: { + showCaptions: () => false, + webPlayerSharedControls: () => false, + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: activeExternalSession, + visibleSession: activeExternalSession, + dismissActiveSession: jest.fn(), + closeSession: closeExternalSession, + }, + }, + ], + }) + .overrideComponent(WebPlayerViewComponent, { + set: { + imports: [ + ClipboardModule, + MatButtonModule, + MatIconModule, + MatTooltipModule, + PlaybackDiagnosticPanelComponent, + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubFullscreenChannelPanelComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, + TranslateModule, + VodSourceRowComponent, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(WebPlayerViewComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput( + 'streamUrl', + 'https://example.com/live.m3u8' + ); + fixture.componentRef.setInput('title', 'Recovery stream'); + fixture.componentRef.setInput('playbackSessionKey', 'content-a'); + }); + + afterEach(() => fixture.destroy()); + + it('tries advertised TS once after initial terminal HLS HTTP failure', async () => { + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/live.m3u8', + title: 'Live', + isLive: true, + liveAutoTsUrl: 'https://example.com/live.ts', + headers: { 'User-Agent': 'fixture-agent' }, + }); + await render(); + const oldBinding = currentBinding(); + component.handlePlaybackIssue( + { ...networkIssue('videojs'), httpStatus: 403 }, + oldBinding + ); + await render(); + expect(component.resolvedPlayback().streamUrl).toBe( + 'https://example.com/live.ts' + ); + expect(component.resolvedPlayback().headers).toEqual({ + 'User-Agent': 'fixture-agent', + }); + expect(component.selectedPlayer()).toBe(VideoPlayer.VideoJs); + const tsBinding = currentBinding(); + component.handlePlaybackIssue(networkIssue('videojs'), tsBinding); + await render(); + expect(component.activeBinding()).toBe(tsBinding); + expect(component.visiblePlaybackDiagnostic()).not.toBeNull(); + component.handlePlaybackIssue(networkIssue('videojs'), oldBinding); + expect(component.activeBinding()).toBe(tsBinding); + }); + + it('uses actual playing output to suppress fallback after playback starts', async () => { + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/live.m3u8', + title: 'Live', + isLive: true, + liveAutoTsUrl: 'https://example.com/live.ts', + }); + await render(); + const player = fixture.debugElement.query( + By.directive(StubVjsPlayerComponent) + ).componentInstance as StubVjsPlayerComponent; + player.playbackStarted.emit(); + player.playbackIssue.emit({ + ...networkIssue('videojs'), + httpStatus: 403, + }); + await render(); + expect(component.resolvedPlayback().streamUrl).toBe( + 'https://example.com/live.m3u8' + ); + expect(component.visiblePlaybackDiagnostic()).not.toBeNull(); + }); + + it('destroys the previous transport before the TS header handoff', async () => { + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/live.m3u8', + title: 'Live', + isLive: true, + liveAutoTsUrl: 'https://example.com/live.ts', + }); + await render(); + const player = fixture.debugElement.query( + By.directive(StubVjsPlayerComponent) + ); + const events: string[] = []; + player.injector + .get(DestroyRef) + .onDestroy(() => events.push('destroy-hls')); + streamHeaders.apply.mockImplementationOnce(() => { + events.push('ts-headers'); + return null; + }); + (player.componentInstance as StubVjsPlayerComponent).playbackIssue.emit( + { ...networkIssue('videojs'), httpStatus: 403 } + ); + await render(); + expect(events).toEqual(['destroy-hls', 'ts-headers']); + }); + + it('ignores playing and failure from the old channel before the new render', async () => { + await render(); + const oldBinding = currentBinding(); + fixture.componentRef.setInput( + 'playbackSessionKey', + 'another-playlist/channel' + ); + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/new.m3u8', + title: 'Live', + isLive: true, + liveAutoTsUrl: 'https://example.com/new.ts', + }); + component.handlePlaybackStarted(oldBinding); + component.handlePlaybackIssue( + { ...networkIssue('videojs'), httpStatus: 403 }, + oldBinding + ); + await render(); + const binding = currentBinding(); + component.handlePlaybackIssue( + { ...networkIssue('videojs'), httpStatus: 403 }, + binding + ); + await render(); + expect(component.resolvedPlayback().streamUrl).toBe( + 'https://example.com/new.ts' + ); + }); + + function currentBinding() { + const current = component.activeBinding(); + if (!current) throw new Error('Expected a mounted playback binding'); + return current; + } + + async function render(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } +}); +function networkIssue(player: 'videojs' | 'html5'): PlaybackDiagnostic { + return { + code: PlaybackDiagnosticCode.NetworkError, + source: PlaybackDiagnosticSource.Vhs, + sourceUrl: 'https://example.com/live.m3u8', + container: 'm3u8', + player, + audioCodecs: [], + videoCodecs: [], + }; +} 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 75ac61ce2..362bc114f 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 @@ -1,8 +1,8 @@ import { Component, ElementRef, + Injector, OnDestroy, - type Signal, ViewEncapsulation, computed, effect, @@ -25,7 +25,6 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { VideoPlayer, type Channel, - type EmbeddedMpvSupport, type RecordingStartMetadata, type RecordingStoppedEvent, type ResolvedPortalPlayback, @@ -57,6 +56,8 @@ import { } from './web-player-application-ownership'; import { createWebPlayerApplicationState } from './web-player-application-state'; import { resolveWebPlayerMediaTitle } from './web-player-playback-state'; +import { createChannelPanelAvailability } from './web-player-channel-panel-state'; +import { WebPlayerLiveAutoFormat } from './web-player-live-auto-format'; import { WebPlayerRecoveryController } from './web-player-recovery-controller'; import { isPlaybackExternallyTransferable, @@ -65,11 +66,6 @@ import { toVideoPlayer, } from './web-player-recovery-policy'; -/** What the view needs from a rendered Embedded MPV player: its engine. */ -interface EmbeddedMpvEngineReporter { - readonly support: Signal; -} - function resolveWebPlayerSharedControls(): boolean { const storedValue = inject(SettingsStore).webPlayerSharedControls?.(); const fallback = WEB_PLAYER_SHARED_CONTROLS_ENABLED; @@ -113,34 +109,11 @@ export class WebPlayerViewComponent implements OnDestroy { readonly fullscreenSurface: HTMLElement = inject(ElementRef) .nativeElement; private readonly embeddedMpvPlayer = - viewChild('embeddedMpvPlayer'); - /** - * The fullscreen channel panel is DOM content over the video. Embedded - * MPV's native-view engine paints a platform view above the page, where - * no DOM layer can show, so the panel exists only while the rendered - * engine is a web player or Embedded MPV's frame-copy canvas. Fails - * closed until frame-copy has been confirmed. Retain that confirmation - * while a replacement component probes support, so channel changes do - * not reset the panel. A confirmed native/unsupported result revokes it. - */ - readonly channelPanelAvailable = linkedSignal< - { embedded: boolean; support: EmbeddedMpvSupport | null }, - boolean - >({ - source: () => ({ - embedded: this.renderedApplications().some( - (application) => application.embeddedMpv - ), - support: this.embeddedMpvPlayer()?.support() ?? null, - }), - computation: ({ embedded, support }, previous) => { - if (!embedded) return true; - if (support === null) { - return previous?.source.embedded ? previous.value : false; - } - return support.supported && support.engine === 'frame-copy'; - }, - }); + viewChild('embeddedMpvPlayer'); + readonly channelPanelAvailable = createChannelPanelAvailability( + () => this.renderedApplications().some((app) => app.embeddedMpv), + () => this.embeddedMpvPlayer()?.support() ?? null + ); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK, { @@ -223,8 +196,17 @@ export class WebPlayerViewComponent implements OnDestroy { const temporary = this.recoverySession.temporaryPlayerOverride(); return temporary ? toVideoPlayer(temporary) : this.renderablePlayer(); }); - private readonly applicationState = createWebPlayerApplicationState({ + private readonly liveAutoFormat = new WebPlayerLiveAutoFormat({ playback: this.playback, + sessionKey: this.playbackSessionKey, + player: this.selectedPlayer, + intent: this.reloadToken, + autoEnabled: () => + (this.settingsStore.streamFormat?.() ?? 'auto') === 'auto', + injector: inject(Injector), + }); + private readonly applicationState = createWebPlayerApplicationState({ + playback: this.liveAutoFormat.playback, streamUrl: this.streamUrl, title: this.title, startTime: this.startTime, @@ -243,7 +225,7 @@ export class WebPlayerViewComponent implements OnDestroy { externalRecovery: this.externalRecovery, applicationHandoff: this.applicationHandoff, playbackSessionKey: this.playbackSessionKey, - playback: this.playback, + playback: this.liveAutoFormat.playback, streamUrl: this.streamUrl, startTime: this.startTime, selectedPlayer: this.selectedPlayer, @@ -255,6 +237,7 @@ export class WebPlayerViewComponent implements OnDestroy { alternativeSourceCount: () => this.alternativeSources().length, managedExternalPlayersAvailable: () => this.runtime.supportsManagedExternalPlayers, + tryAutoLiveFormat: (issue) => this.liveAutoFormat.tryFallback(issue), emitPlaybackFailed: (code) => this.playbackFailed.emit(code), emitExternalFallbackRequested: (request) => this.externalFallbackRequested.emit(request), @@ -284,6 +267,7 @@ export class WebPlayerViewComponent implements OnDestroy { readonly renderedApplications = computed< readonly PlaybackApplicationOwnership[] >(() => { + if (this.liveAutoFormat.pending()) return []; const binding = this.activeBinding(); const embeddedMpv = this.selectedPlayer() === VideoPlayer.EmbeddedMpv && !binding; @@ -311,6 +295,7 @@ export class WebPlayerViewComponent implements OnDestroy { // Session sync may clear a temporary player override. Run it before // tracking intent so that reset is folded into this application. this.recovery.syncSession(); + if (this.liveAutoFormat.pending()) return; void this.recovery.diagnosticIntentToken(); const sourceRevision = this.playbackSourceRevisionToken(); untracked(() => @@ -349,6 +334,7 @@ export class WebPlayerViewComponent implements OnDestroy { } ngOnDestroy(): void { + this.liveAutoFormat.destroy(); this.externalRecovery.destroy(); this.applicationHandoff.destroy(); } @@ -360,6 +346,18 @@ export class WebPlayerViewComponent implements OnDestroy { this.recovery.handlePlaybackIssue(issue, binding); } + handlePlaybackStarted(binding: PlaybackBinding): void { + this.recovery.syncSession(); + if ( + this.applicationHandoff.owns( + binding, + this.playbackApplicationToken() + ) && + !this.liveAutoFormat.pending() + ) + this.liveAutoFormat.started(); + } + handleTimeUpdate( event: { currentTime: number; duration: number }, ownership: PlaybackApplicationOwnership diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts index b82185f40..fb57beae0 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -27,6 +27,7 @@ export class StubVjsPlayerComponent { readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackIssue = output(); + readonly playbackStarted = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); @@ -48,6 +49,7 @@ export class StubHtmlVideoPlayerComponent { readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackIssue = output(); + readonly playbackStarted = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); @@ -69,6 +71,7 @@ export class StubArtPlayerComponent { readonly seriesNavigation = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); readonly playbackIssue = output(); + readonly playbackStarted = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output();