mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(xtream): try advertised TS after initial web HLS HTTP failure (#1558)
* fix(xtream): try advertised TS after initial web HLS HTTP failure * refactor(playback): extract fullscreen channel panel state * test(xtream): keep synthetic media within the mock project
This commit is contained in:
1 parent
ef2418a2c5
commit
436825bdec
37 files changed
+1457
-67
No files matched your search
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Размер обложки",
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -31,6 +31,17 @@ export interface ScenarioConfig {
|
||||
* Unknown credential pairs use a hash of "username:password" as seed.
|
||||
*/
|
||||
export const SCENARIOS: Record<string, ScenarioConfig> = {
|
||||
'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',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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', () =>
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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}
|
||||
|
||||
+11
@@ -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(
|
||||
|
||||
@@ -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<number, boolean>();
|
||||
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -66,6 +66,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
}>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly playbackStarted = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}>();
|
||||
@Output() playbackIssue = new EventEmitter<PlaybackDiagnostic | null>();
|
||||
@Output() playbackEnded = new EventEmitter<void>();
|
||||
@Output() playbackStarted = new EventEmitter<void>();
|
||||
@Output() previousEpisodeRequested = new EventEmitter<void>();
|
||||
@Output() nextEpisodeRequested = new EventEmitter<void>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly playbackStarted = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<ResolvedPortalPlayback | null>(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<typeof TestBed.createComponent<Host>>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<ResolvedPortalPlayback | null>;
|
||||
sessionKey: Signal<string>;
|
||||
player: Signal<VideoPlayer>;
|
||||
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])
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export interface WebPlayerRecoveryControllerDeps {
|
||||
readonly playbackExternallyTransferable: Signal<boolean>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+288
@@ -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<WebPlayerViewComponentInstance>;
|
||||
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<ExternalPlayerSession | null>
|
||||
>;
|
||||
let closeExternalSession: jest.Mock<Promise<void>, [ExternalPlayerSession]>;
|
||||
let holdHeaderHandoff: boolean;
|
||||
let headerResolvers: Array<(stillCurrent: boolean) => void>;
|
||||
let headerRejectors: Array<(reason?: unknown) => void>;
|
||||
const streamHeaders = {
|
||||
apply: jest.fn(
|
||||
() =>
|
||||
(holdHeaderHandoff
|
||||
? new Promise<boolean>((resolve, reject) => {
|
||||
headerResolvers.push(resolve);
|
||||
headerRejectors.push(reject);
|
||||
})
|
||||
: null) as Promise<boolean> | null
|
||||
),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ WebPlayerViewComponent } =
|
||||
await import('./web-player-view.component'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
runtime = { supportsManagedExternalPlayers: true };
|
||||
activeExternalSession = signal<ExternalPlayerSession | null>(null);
|
||||
closeExternalSession = jest.fn(async (session) => {
|
||||
activeExternalSession.set({
|
||||
...session,
|
||||
status: 'closed',
|
||||
canClose: false,
|
||||
updatedAt: '2026-08-08T10:00:02.000Z',
|
||||
});
|
||||
});
|
||||
holdHeaderHandoff = false;
|
||||
headerResolvers = [];
|
||||
headerRejectors = [];
|
||||
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<void> {
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
@@ -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<EmbeddedMpvSupport | null>;
|
||||
}
|
||||
|
||||
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<HTMLElement>)
|
||||
.nativeElement;
|
||||
private readonly embeddedMpvPlayer =
|
||||
viewChild<EmbeddedMpvEngineReporter>('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<EmbeddedMpvPlayerComponent>('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
|
||||
|
||||
@@ -27,6 +27,7 @@ export class StubVjsPlayerComponent {
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackStarted = output<void>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
@@ -48,6 +49,7 @@ export class StubHtmlVideoPlayerComponent {
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackStarted = output<void>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
@@ -69,6 +71,7 @@ export class StubArtPlayerComponent {
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackStarted = output<void>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
|
||||
Reference in new issue
Block a user