mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user