Merge origin/master into the programme guide branch

Adopts the compact icon-only timeline toolbar (#1557): the Guide button is
now an icon-only `epg-timeline__iconbtn` with its label in the tooltip and
aria-label, so the unused EPG.TIMELINE.GUIDE key is dropped from all locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 13:59:04 +02:00
commit 49fdd40c9d
123 files changed
+5976 -474

No files matched your search

@@ -78,25 +78,60 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
.first();
await expect(nowBlock).toBeVisible();
// Zooming re-renders the ribbon: block widths grow with px/minute.
const zoomInput = timeline.locator(
'.epg-timeline__zoom input[type="range"]'
// The zoom button cycles hours → detail → day; block widths follow
// px/minute, so "detail" must render the same block wider than "day".
const zoomButton = timeline.locator('.epg-timeline__zoom');
await expect(zoomButton).toBeVisible();
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'hours');
const blockWidth = async () =>
(await nowBlock.boundingBox())?.width ?? 0;
await zoomButton.click();
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'detail');
const detailZoomWidth = await blockWidth();
await zoomButton.click();
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'day');
const dayZoomWidth = await blockWidth();
expect(detailZoomWidth).toBeGreaterThan(dayZoomWidth);
// Ctrl + wheel over the ribbon fine-tunes the zoom (and must not
// trigger Chromium's page zoom, which would scale the whole window).
const ribbon = timeline.locator('.epg-timeline__ribbon');
await ribbon.hover({ position: { x: 200, y: 40 } });
const pageZoomBefore = await app.mainWindow.evaluate(
() => window.devicePixelRatio
);
await expect(zoomInput).toBeVisible();
await app.mainWindow.keyboard.down('Control');
await app.mainWindow.mouse.wheel(0, -300);
await app.mainWindow.keyboard.up('Control');
await expect
.poll(blockWidth, { timeout: 5000 })
.toBeGreaterThan(dayZoomWidth);
// Chromium page zoom scales devicePixelRatio; the ribbon must have
// swallowed the gesture instead.
expect(
await app.mainWindow.evaluate(() => window.devicePixelRatio)
).toBe(pageZoomBefore);
const blockWidthAt = async (zoom: 'min' | 'max') => {
await zoomInput.evaluate((element, target) => {
const input = element as HTMLInputElement;
input.value = target === 'min' ? input.min : input.max;
input.dispatchEvent(new Event('input', { bubbles: true }));
}, zoom);
const box = await nowBlock.boundingBox();
return box?.width ?? 0;
// The contrast checks below read the block's time line, which the
// narrower tiers hide, so settle on the "detail" preset. The
// wheel-tuned scale may sit in any band (Chromium scales the delivered
// delta), so cycle from wherever it landed rather than assuming a
// fixed number of clicks.
const clicksToDetail: Record<string, number> = {
detail: 0,
day: 2,
hours: 1,
};
const minZoomWidth = await blockWidthAt('min');
const maxZoomWidth = await blockWidthAt('max');
expect(maxZoomWidth).toBeGreaterThan(minZoomWidth);
const landedLevel =
(await zoomButton.getAttribute('data-zoom-level')) ?? '';
expect(Object.keys(clicksToDetail)).toContain(landedLevel);
for (let i = 0; i < clicksToDetail[landedLevel]; i += 1) {
await zoomButton.click();
}
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'detail');
for (const theme of ['light', 'dark', 'light'] as const) {
await applyTheme(app.mainWindow, theme);
@@ -0,0 +1,386 @@
import { Page } from '@playwright/test';
import {
addXtreamPortal,
channelItemByTitle,
clickCategoryByNameExact,
closeElectronApp,
expect,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
LaunchedElectronApp,
openPlaylistRecent,
openSources,
openWorkspaceSection,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
test,
waitForM3uCatalog,
waitForXtreamWorkspaceReady,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
import { fetchXtreamLiveFixture } from './portal-mock-fixtures';
/**
* Issue #1458, second report: "all channels disappear after clearing the
* playback history; a reset does not bring them back". The history write
* never touched the channels — the reporter's screenshot shows a collapsed
* channel rail, a state that used to be persisted under one key shared by
* every live surface and that survived restart, "Remove all playlists" and
* re-import. These scenarios pin both halves: the data path stays intact,
* and the collapsed rail is discoverable, scoped, and restorable.
*/
const CHANNELS = [
{
groupTitle: 'News',
name: 'Channel Alpha',
url: 'https://streams.example.test/alpha.m3u8',
},
{
groupTitle: 'News',
name: 'Channel Beta',
url: 'https://streams.example.test/beta.m3u8',
},
{
groupTitle: 'Sports',
name: 'Channel Gamma',
url: 'https://streams.example.test/gamma.m3u8',
},
];
const M3U_STATE_KEY = 'live-sidebar-state:m3u';
const PORTAL_STATE_KEY = 'live-sidebar-state:portal';
const LEGACY_STATE_KEY = 'live-sidebar-state';
function playlistIdFromUrl(page: Page): string {
const match = page.url().match(/\/workspace\/playlists\/([^/?#]+)\//);
if (!match) {
throw new Error(`Not on a playlist route: ${page.url()}`);
}
return match[1];
}
async function storedItemCount(
page: Page,
playlistId: string
): Promise<number> {
return page.evaluate(async (id) => {
const bridge = (
window as unknown as {
electron: {
dbGetAppPlaylist: (
playlistId: string
) => Promise<{ playlist?: { items?: unknown[] } } | null>;
};
}
).electron;
const playlist = await bridge.dbGetAppPlaylist(id);
return playlist?.playlist?.items?.length ?? -1;
}, playlistId);
}
async function reopenPlaylist(page: Page, sourceTitle: string): Promise<void> {
await openSources(page);
await sourceRowByTitle(page, sourceTitle).first().click();
await page.waitForURL(/\/workspace\/playlists\/.+\/all$/);
}
function readStorage(page: Page, key: string): Promise<string | null> {
return page.evaluate((storageKey) => localStorage.getItem(storageKey), key);
}
function headerToggle(page: Page) {
return page.locator('app-workspace-shell-header .header-sidebar-toggle');
}
function hiddenStateRestoreButton(page: Page) {
return page.locator(
'app-channel-list-hidden-state button.empty-state-action'
);
}
/**
* The collapsed rail keeps its children in the DOM (overflow-clipped inside
* a zero-width container), so Playwright still reports them "visible";
* assert on the container and its restore affordances instead.
*/
async function expectRailCollapsed(page: Page): Promise<void> {
const sidebar = page.locator('.sidebar').first();
await expect(sidebar).toHaveClass(/sidebar-collapsed/);
// width: 0 !important, but the 1px (transparent) border still counts.
await expect
.poll(async () => (await sidebar.boundingBox())?.width ?? -1)
.toBeLessThanOrEqual(1);
await expect(page.locator('.sidebar-restore')).toBeVisible();
await expect(headerToggle(page)).toHaveAttribute('aria-pressed', 'false');
}
async function expectRailExpanded(page: Page): Promise<void> {
await expect(page.locator('.sidebar').first()).not.toHaveClass(
/sidebar-collapsed/
);
await expect(page.locator('.sidebar-restore')).toHaveCount(0);
await expect(page.getByTestId('channel-item').first()).toBeVisible();
await expect(headerToggle(page)).toHaveAttribute('aria-pressed', 'true');
}
test.describe('Live channel rail collapse (#1458)', () => {
test('clearing the playlist history keeps every channel in SQLite and in the All channels view, also after restart', async ({
dataDir,
}) => {
const sourceTitle = 'rail-history';
const filePath = writeTemporaryM3uFile(
dataDir,
`${sourceTitle}.m3u`,
CHANNELS
);
const app: LaunchedElectronApp = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
const playlistId = playlistIdFromUrl(app.mainWindow);
expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3);
await channelItemByTitle(app.mainWindow, 'Channel Alpha')
.first()
.click();
await openPlaylistRecent(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, 'Channel Alpha').first()
).toBeVisible({ timeout: 20000 });
await app.mainWindow
.getByRole('button', { name: 'Clear recently viewed Live TV' })
.click();
await app.mainWindow.getByRole('button', { name: 'Yes' }).click();
await expect(
channelItemByTitle(app.mainWindow, 'Channel Alpha')
).toHaveCount(0);
await reopenPlaylist(app.mainWindow, sourceTitle);
await expect(
app.mainWindow.getByTestId('channel-item')
).toHaveCount(3);
await expectRailExpanded(app.mainWindow);
expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await reopenPlaylist(app.mainWindow, sourceTitle);
await expect(
app.mainWindow.getByTestId('channel-item')
).toHaveCount(3);
await expectRailExpanded(app.mainWindow);
expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3);
} finally {
await closeElectronApp(app);
}
});
test('a collapsed rail stays discoverable and restorable: hidden-list state, header toggle, restart, and a fresh import', async ({
dataDir,
}) => {
const firstTitle = 'rail-collapse-a';
const secondTitle = 'rail-collapse-b';
const firstPath = writeTemporaryM3uFile(
dataDir,
`${firstTitle}.m3u`,
CHANNELS
);
const secondPath = writeTemporaryM3uFile(
dataDir,
`${secondTitle}.m3u`,
CHANNELS
);
const app: LaunchedElectronApp = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, firstPath);
await waitForM3uCatalog(app.mainWindow);
await expectRailExpanded(app.mainWindow);
expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).not.toBe(
'collapsed'
);
// The chevron in the "All channels" header hides the rail. With
// nothing playing, the content area must say so instead of
// asking the user to pick from a list they cannot see.
await app.mainWindow
.locator('.all-channels-sidebar-toggle')
.first()
.click();
await expectRailCollapsed(app.mainWindow);
await expect(
app.mainWindow.locator('app-channel-list-hidden-state')
).toBeVisible();
await expect(
hiddenStateRestoreButton(app.mainWindow)
).toBeVisible();
await expect(
app.mainWindow.locator('app-portal-empty-state', {
hasText: 'select a channel',
})
).toHaveCount(0);
expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe(
'collapsed'
);
// The full-size action restores the rail.
await hiddenStateRestoreButton(app.mainWindow).click();
await expectRailExpanded(app.mainWindow);
expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe(
'expanded'
);
// The header toggle exists in both states and drives the same
// rail.
await headerToggle(app.mainWindow).click();
await expectRailCollapsed(app.mainWindow);
await headerToggle(app.mainWindow).click();
await expectRailExpanded(app.mainWindow);
// A hidden rail is a preference the user made, so it survives a
// restart and applies to a second playlist — but the way back
// survives with it.
await headerToggle(app.mainWindow).click();
await expectRailCollapsed(app.mainWindow);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await reopenPlaylist(app.mainWindow, firstTitle);
const playlistId = playlistIdFromUrl(app.mainWindow);
expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3);
await expectRailCollapsed(app.mainWindow);
await expect(
hiddenStateRestoreButton(app.mainWindow)
).toBeVisible();
await importM3uPlaylistFromNativeDialog(app, secondPath);
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+\/all$/);
await expectRailCollapsed(app.mainWindow);
// Ctrl/Cmd+B still works and is reflected by the header toggle.
await app.mainWindow.keyboard.press('Control+b');
await expectRailExpanded(app.mainWindow);
expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe(
'expanded'
);
} finally {
await closeElectronApp(app);
}
});
test('the collapsed state is scoped per surface and the legacy shared key is forgotten', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const liveFixture = await fetchXtreamLiveFixture(request, {
username: 'user1',
password: 'pass1',
});
const m3uTitle = 'rail-scope';
const m3uPath = writeTemporaryM3uFile(
dataDir,
`${m3uTitle}.m3u`,
CHANNELS
);
const app: LaunchedElectronApp = await launchElectronApp(dataDir);
try {
// A leftover pre-split value must not hide anything after the
// update, and it must be cleaned up.
await app.mainWindow.evaluate((key) => {
localStorage.setItem(key, 'collapsed');
}, LEGACY_STATE_KEY);
await importM3uPlaylistFromNativeDialog(app, m3uPath);
await waitForM3uCatalog(app.mainWindow);
await expectRailExpanded(app.mainWindow);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await reopenPlaylist(app.mainWindow, m3uTitle);
await expectRailExpanded(app.mainWindow);
expect(await readStorage(app.mainWindow, LEGACY_STATE_KEY)).toBe(
null
);
// Hide the M3U rail, then open an Xtream portal's Live TV: its
// rail (and the header toggle for it) must still be expanded.
await headerToggle(app.mainWindow).click();
await expectRailCollapsed(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
name: 'Rail Scope Portal',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(
app.mainWindow,
liveFixture.categoryName
);
await expect(headerToggle(app.mainWindow)).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(
app.mainWindow.locator('.sidebar-restore')
).toHaveCount(0);
expect(
await readStorage(app.mainWindow, PORTAL_STATE_KEY)
).not.toBe('collapsed');
expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe(
'collapsed'
);
// And the other way round: hiding the portal rail leaves the
// M3U preference untouched. With a category open and nothing
// playing, the portal host shows the same hidden-list state and
// its button restores the rail.
await headerToggle(app.mainWindow).click();
await expect(headerToggle(app.mainWindow)).toHaveAttribute(
'aria-pressed',
'false'
);
await expect(hiddenStateRestoreButton(app.mainWindow)).toBeVisible();
expect(await readStorage(app.mainWindow, PORTAL_STATE_KEY)).toBe(
'collapsed'
);
await hiddenStateRestoreButton(app.mainWindow).click();
await expect(headerToggle(app.mainWindow)).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(
app.mainWindow.locator('app-channel-list-hidden-state')
).toHaveCount(0);
await headerToggle(app.mainWindow).click();
expect(await readStorage(app.mainWindow, PORTAL_STATE_KEY)).toBe(
'collapsed'
);
await reopenPlaylist(app.mainWindow, m3uTitle);
await expectRailCollapsed(app.mainWindow);
await headerToggle(app.mainWindow).click();
await expectRailExpanded(app.mainWindow);
expect(await readStorage(app.mainWindow, PORTAL_STATE_KEY)).toBe(
'collapsed'
);
} finally {
await closeElectronApp(app);
}
});
});
@@ -2,23 +2,30 @@ import { APIRequestContext, Page } from '@playwright/test';
import { AddressInfo, createServer as createNetServer } from 'net';
import {
addStalkerPortal,
addXtreamPortal,
channelItemByTitle,
closeElectronApp,
enableRemoteControl,
expect,
fillWorkspaceSearch,
goToDashboard,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openSettings,
openSettingsSection,
saveSettings,
resetMockServers,
test,
waitForM3uCatalog,
waitForStalkerCatalog,
waitForXtreamCatalog,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
type RemoteControlStatus = {
channelName?: string;
channelNumber?: number;
isLiveView: boolean;
muted?: boolean;
portal: 'm3u' | 'xtream' | 'stalker' | 'unknown';
@@ -27,6 +34,235 @@ type RemoteControlStatus = {
};
test.describe('Electron Remote Control', () => {
for (const mode of ['xtream', 'stalker', 'radio'] as const) {
test(`@remote-control @electron ${mode} keeps playback order while browsing and reveals the playing channel`, async ({
dataDir,
request,
}) => {
test.setTimeout(120000);
const provider = mode === 'xtream' ? 'xtream' : 'stalker';
await resetMockServers(request, [provider]);
const remotePort = await reserveFreePort();
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await openSettings(page);
await openSettingsSection(page, 'playback');
await selectSettingsOption(
page,
'select-video-player',
'artplayer'
);
await enableRemoteControl(page, remotePort);
await saveSettings(page);
await waitForRemoteControlServer(request, remotePort);
await goToDashboard(page);
if (provider === 'xtream') {
await addXtreamPortal(page);
await waitForXtreamCatalog(page);
} else {
await addStalkerPortal(page);
await waitForStalkerCatalog(page);
}
await page
.getByRole('link', {
name: mode === 'radio' ? 'Radio' : 'Live TV',
exact: true,
})
.click();
const categories = page.locator(
'.context-panel .category-item:not([data-category-id="*"])'
);
await expect(categories.nth(1)).toBeVisible();
const originalCategory = categories.first();
const browsedCategory = categories.nth(1);
await originalCategory.click();
const rows = page
.locator('#live-channels')
.getByTestId('channel-item');
await expect(rows.nth(2)).toBeVisible();
if (provider === 'xtream') {
await page
.getByRole('button', {
name: 'Sort channels',
exact: true,
})
.click();
await page
.getByRole('menuitem', { name: 'Name Z-A' })
.click();
}
const titles = await rows
.locator('.channel-name')
.allTextContents();
const [first, second, third] = titles.map((title) =>
title.trim()
);
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(third).toBeTruthy();
await rows.first().click();
await waitForRemoteStatus(
request,
remotePort,
(status) =>
status.portal === provider &&
status.channelName === first &&
status.channelNumber === 1
);
await browsedCategory.click();
await expect(browsedCategory).toHaveAttribute(
'aria-current',
'true'
);
if (provider === 'xtream') {
await page
.getByRole('button', {
name: 'Sort channels',
exact: true,
})
.click();
await page
.getByRole('menuitem', { name: 'Name A-Z' })
.click();
}
await fillWorkspaceSearch(page, '__no_playing_channel__');
await page.waitForURL(/q=__no_playing_channel__/);
await expect(rows).toHaveCount(0);
await waitForRemoteStatus(
request,
remotePort,
(status) =>
status.portal === provider &&
status.channelName === first &&
status.channelNumber === 1
);
await postRemoteCommand(
request,
remotePort,
'/channel/select-number',
{ number: 2 }
);
await waitForRemoteStatus(
request,
remotePort,
(status) =>
status.channelName === second &&
status.channelNumber === 2
);
await expect(browsedCategory).toHaveAttribute(
'aria-current',
'true'
);
await postRemoteCommand(request, remotePort, '/channel/down');
await waitForRemoteStatus(
request,
remotePort,
(status) =>
status.channelName === third &&
status.channelNumber === 3
);
await postRemoteCommand(request, remotePort, '/channel/up');
await waitForRemoteStatus(
request,
remotePort,
(status) =>
status.channelName === second &&
status.channelNumber === 2
);
await expect(browsedCategory).toHaveAttribute(
'aria-current',
'true'
);
const media = page
.locator(
mode === 'radio'
? 'app-audio-player audio'
: 'app-web-player-view video'
)
.first();
await expect(media).toBeAttached();
const originalMedia = await media.elementHandle();
const originalSource = await media.evaluate(
(element: HTMLMediaElement) => element.src
);
const reveal = page.getByRole('button', {
name: 'Show playing channel',
exact: true,
});
await expect(reveal).toBeVisible();
for (const theme of ['light', 'dark'] as const) {
await page.emulateMedia({ colorScheme: theme });
if (theme === 'dark') {
await expect(page.locator('body')).toHaveClass(
/dark-theme/
);
} else {
await expect(page.locator('body')).not.toHaveClass(
/dark-theme/
);
}
await page.screenshot({
path: test
.info()
.outputPath(`${mode}-show-playing-${theme}.png`),
});
}
await reveal.click();
await expect(originalCategory).toHaveAttribute(
'aria-current',
'true'
);
await expect(
page.locator(
'app-workspace-shell-header input[type="search"]'
)
).toHaveValue('');
await expect(page.locator('#live-channels')).toBeFocused();
const activeRow = channelItemByTitle(page, second).first();
await expect(activeRow).toBeVisible();
await expect(activeRow).toHaveClass(/active/);
await expect(reveal).toHaveCount(0);
expect(
await media.evaluate(
(element, previous) => element === previous,
originalMedia
)
).toBe(true);
await expect(media).toHaveJSProperty('src', originalSource);
await waitForRemoteStatus(
request,
remotePort,
(status) =>
status.channelName === second &&
status.channelNumber === 2
);
await page.screenshot({
path: test.info().outputPath(`${mode}-revealed-dark.png`),
});
// Category-only root browsing does not change the URL; a
// router no-op must still reveal without restarting playback.
await browsedCategory.click();
await expect(reveal).toBeVisible();
await reveal.click();
await expect(originalCategory).toHaveAttribute(
'aria-current',
'true'
);
await expect(activeRow).toBeVisible();
expect(
await media.evaluate(
(element, previous) => element === previous,
originalMedia
)
).toBe(true);
await originalMedia?.dispose();
} finally {
await closeElectronApp(app);
}
});
}
test('@remote-control @m3u @electron applies remote volume commands to the selected built-in video player', async ({
dataDir,
request,
@@ -247,10 +483,11 @@ async function getRemoteStatus(
async function postRemoteCommand(
request: APIRequestContext,
port: number,
path: string
path: string,
data: Record<string, unknown> = {}
): Promise<void> {
const response = await request.post(remoteControlUrl(port, path), {
data: {},
data,
});
expect(response.ok()).toBe(true);
@@ -261,9 +498,12 @@ function remoteControlUrl(port: number, path: string): string {
}
async function readMediaVolume(page: Page, selector: string): Promise<number> {
return page.locator(selector).first().evaluate((element) => {
return Number((element as HTMLMediaElement).volume.toFixed(2));
});
return page
.locator(selector)
.first()
.evaluate((element) => {
return Number((element as HTMLMediaElement).volume.toFixed(2));
});
}
function roundVolume(volume: number | undefined): number | null {
@@ -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;
}