mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
* fix(catalog): short sort chip label and radio sort menus
The catalog sort chip now shows one short label per mode at every width
("Newest", "A-Z", "Top rated") and keeps the full "Sort: ..." text in its
aria-label, so it no longer truncates in long translations. The rating
chip follows the same rule and puts its clear icon after the value. The
1120px container query that swapped full and compact labels is gone.
Single-choice mat-menus get a shared appMenuItemRadio /
appMenuItemRadioCheck pair: rows are menuitemradio with aria-checked, and
every row reserves a leading check slot that is visible only when
checked. Material projects every mat-icon ahead of the label, so the old
check rendered only on the chosen row shifted that row's label. Applied
to the catalog refine menu (sort and rating groups), Xtream live
channels, M3U all channels and groups, unified collection favorites,
workspace categories and workspace sources.
Adds WORKSPACE.SORT_CHIP keys in all locales and drops the unused
WORKSPACE.FILTER_RATING key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(catalog): read the full sort to screen readers from hidden text
A plain div cannot carry an accessible name, so screen readers could skip
the chip's aria-label and read only the short "Newest". The full
"Sort: ..." text now sits in a visually hidden span and the short label
is aria-hidden; the polite live region announces the full text on change.
The spec checks the text outside aria-hidden, and the E2E checks the
chip's accessibility tree in en, de, ru and hu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
542 lines
19 KiB
TypeScript
542 lines
19 KiB
TypeScript
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';
|
|
supportsVolume?: boolean;
|
|
volume?: number;
|
|
};
|
|
|
|
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') {
|
|
// Read the order before sorting: the menu closes before the
|
|
// virtual list re-renders, and reading the titles too early
|
|
// captured the server order while the click below landed
|
|
// on the re-sorted first row (flaky on slow runners).
|
|
const serverOrder = await rows
|
|
.locator('.channel-name')
|
|
.allTextContents();
|
|
await page
|
|
.getByRole('button', {
|
|
name: 'Sort channels',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await page
|
|
.getByRole('menuitemradio', { name: 'Name Z-A' })
|
|
.click();
|
|
await expect
|
|
.poll(() =>
|
|
rows.locator('.channel-name').allTextContents()
|
|
)
|
|
.not.toEqual(serverOrder);
|
|
}
|
|
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('menuitemradio', { 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,
|
|
}) => {
|
|
const remotePort = await reserveFreePort();
|
|
const channelName = 'Remote ArtPlayer Channel';
|
|
const playlistFile = writeTemporaryM3uFile(
|
|
dataDir,
|
|
'remote-control-video.m3u',
|
|
[
|
|
{
|
|
groupTitle: 'Remote',
|
|
name: channelName,
|
|
url: 'https://example.channels/remote-art-player.m3u8',
|
|
},
|
|
]
|
|
);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await openSettings(app.mainWindow);
|
|
await openSettingsSection(app.mainWindow, 'playback');
|
|
await selectSettingsOption(
|
|
app.mainWindow,
|
|
'select-video-player',
|
|
'artplayer'
|
|
);
|
|
await enableRemoteControl(app.mainWindow, remotePort);
|
|
await saveSettings(app.mainWindow);
|
|
await waitForRemoteControlServer(request, remotePort);
|
|
|
|
await goToDashboard(app.mainWindow);
|
|
await importM3uPlaylistFromNativeDialog(app, playlistFile);
|
|
await waitForM3uCatalog(app.mainWindow);
|
|
await channelItemByTitle(app.mainWindow, channelName)
|
|
.first()
|
|
.click();
|
|
|
|
const playerVideo = app.mainWindow
|
|
.locator('app-art-player video')
|
|
.first();
|
|
await expect(playerVideo).toHaveCount(1, { timeout: 20000 });
|
|
await waitForRemoteStatus(request, remotePort, (status) => {
|
|
return (
|
|
status.portal === 'm3u' &&
|
|
status.channelName === channelName &&
|
|
status.supportsVolume === true &&
|
|
roundVolume(status.volume) === 1
|
|
);
|
|
});
|
|
|
|
await postRemoteCommand(request, remotePort, '/volume/down');
|
|
|
|
await waitForRemoteStatus(request, remotePort, (status) => {
|
|
return (
|
|
status.portal === 'm3u' &&
|
|
status.channelName === channelName &&
|
|
status.supportsVolume === true &&
|
|
roundVolume(status.volume) === 0.9 &&
|
|
status.muted === false
|
|
);
|
|
});
|
|
await expect
|
|
.poll(() =>
|
|
readMediaVolume(app.mainWindow, 'app-art-player video')
|
|
)
|
|
.toBe(0.9);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('@remote-control @m3u @electron applies remote volume commands to radio audio playback', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
const remotePort = await reserveFreePort();
|
|
const channelName = 'Remote Radio Channel';
|
|
const playlistFile = writeTemporaryM3uFile(
|
|
dataDir,
|
|
'remote-control-radio.m3u',
|
|
[
|
|
{
|
|
groupTitle: 'Radio',
|
|
name: channelName,
|
|
radio: true,
|
|
url: 'https://example.channels/remote-radio-stream.mp3',
|
|
},
|
|
]
|
|
);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await openSettings(app.mainWindow);
|
|
await enableRemoteControl(app.mainWindow, remotePort);
|
|
await saveSettings(app.mainWindow);
|
|
await waitForRemoteControlServer(request, remotePort);
|
|
|
|
await goToDashboard(app.mainWindow);
|
|
await importM3uPlaylistFromNativeDialog(app, playlistFile);
|
|
await waitForM3uCatalog(app.mainWindow);
|
|
await channelItemByTitle(app.mainWindow, channelName)
|
|
.first()
|
|
.click();
|
|
|
|
const audio = app.mainWindow
|
|
.locator('app-audio-player audio')
|
|
.first();
|
|
await expect(audio).toHaveCount(1, { timeout: 20000 });
|
|
await waitForRemoteStatus(request, remotePort, (status) => {
|
|
return (
|
|
status.portal === 'm3u' &&
|
|
status.channelName === channelName &&
|
|
status.supportsVolume === true &&
|
|
roundVolume(status.volume) === 1
|
|
);
|
|
});
|
|
|
|
await postRemoteCommand(request, remotePort, '/volume/down');
|
|
|
|
await waitForRemoteStatus(request, remotePort, (status) => {
|
|
return (
|
|
status.portal === 'm3u' &&
|
|
status.channelName === channelName &&
|
|
status.supportsVolume === true &&
|
|
roundVolume(status.volume) === 0.9 &&
|
|
status.muted === false
|
|
);
|
|
});
|
|
await expect
|
|
.poll(() =>
|
|
readMediaVolume(app.mainWindow, 'app-audio-player audio')
|
|
)
|
|
.toBe(0.9);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
});
|
|
|
|
async function selectSettingsOption(
|
|
page: Page,
|
|
selectTestId: string,
|
|
optionTestId: string
|
|
): Promise<void> {
|
|
await page.getByTestId(selectTestId).click();
|
|
await page.getByTestId(optionTestId).click();
|
|
}
|
|
|
|
async function reserveFreePort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = createNetServer();
|
|
|
|
server.on('error', reject);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const address = server.address() as AddressInfo | null;
|
|
if (!address) {
|
|
server.close();
|
|
reject(new Error('Could not reserve a remote control port.'));
|
|
return;
|
|
}
|
|
|
|
server.close(() => resolve(address.port));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitForRemoteControlServer(
|
|
request: APIRequestContext,
|
|
port: number
|
|
): Promise<void> {
|
|
await waitForRemoteStatus(
|
|
request,
|
|
port,
|
|
(status) => status.portal === 'unknown' && status.isLiveView === false
|
|
);
|
|
}
|
|
|
|
async function waitForRemoteStatus(
|
|
request: APIRequestContext,
|
|
port: number,
|
|
predicate: (status: RemoteControlStatus) => boolean
|
|
): Promise<RemoteControlStatus> {
|
|
// Typed through an assertion so the poll callback's writes stay visible
|
|
// to the return below instead of narrowing to the initial null.
|
|
let latestStatus = null as RemoteControlStatus | null;
|
|
|
|
try {
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
latestStatus = await getRemoteStatus(request, port);
|
|
return latestStatus ? predicate(latestStatus) : false;
|
|
},
|
|
{ timeout: 20000 }
|
|
)
|
|
.toBe(true);
|
|
} catch (error) {
|
|
// The predicate alone says nothing about how far the status got;
|
|
// surface the last payload so a CI failure is diagnosable.
|
|
throw new Error(
|
|
`Remote status never matched. Last status: ${JSON.stringify(latestStatus)}`,
|
|
{ cause: error }
|
|
);
|
|
}
|
|
|
|
if (!latestStatus) {
|
|
throw new Error('Remote status poll passed without a status payload');
|
|
}
|
|
return latestStatus;
|
|
}
|
|
|
|
async function getRemoteStatus(
|
|
request: APIRequestContext,
|
|
port: number
|
|
): Promise<RemoteControlStatus | null> {
|
|
try {
|
|
const response = await request.get(remoteControlUrl(port, '/status'), {
|
|
timeout: 1000,
|
|
});
|
|
|
|
if (!response.ok()) {
|
|
return null;
|
|
}
|
|
|
|
return (await response.json()) as RemoteControlStatus;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function postRemoteCommand(
|
|
request: APIRequestContext,
|
|
port: number,
|
|
path: string,
|
|
data: Record<string, unknown> = {}
|
|
): Promise<void> {
|
|
const response = await request.post(remoteControlUrl(port, path), {
|
|
data,
|
|
});
|
|
|
|
expect(response.ok()).toBe(true);
|
|
}
|
|
|
|
function remoteControlUrl(port: number, path: string): string {
|
|
return `http://127.0.0.1:${port}/api/remote-control${path}`;
|
|
}
|
|
|
|
async function readMediaVolume(page: Page, selector: string): Promise<number> {
|
|
return page
|
|
.locator(selector)
|
|
.first()
|
|
.evaluate((element) => {
|
|
return Number((element as HTMLMediaElement).volume.toFixed(2));
|
|
});
|
|
}
|
|
|
|
function roundVolume(volume: number | undefined): number | null {
|
|
if (volume === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return Number(volume.toFixed(2));
|
|
}
|