mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
## Summary Live TV panels now fold from the outside in, in three nested levels, instead of one toggle that hid the categories rail and the channel list together: 1. **Categories + channels + player** (browse, unchanged). 2. **Channels + player** — a new `chevron_left` in the categories rail header hides only that rail. The channels header then turns its title into a **category dropdown** that opens the same shell panel as a popover (search, sort, counts, selection are one implementation), plus a `chevron_right` that brings the rail back. 3. **Player only** — the channels header chevron, as before. The floating restore handle and `Cmd/Ctrl+B` return to the level the user collapsed from, not always to level 1. Every level is restored as stored, per surface (`live-sidebar-state:<surface>`, from #1555): a hidden rail is discoverable through the workspace header toggle and the hidden-list empty state that #1555 added, so this PR no longer needs its original "player-only never restores" rule. The level `Cmd/Ctrl+B` comes back to is seeded from the restored level and kept for the session. ## Design notes - Nested levels rather than two independent booleans: "channels hidden, categories visible" makes no sense since a category click has to bring the channels back anyway. The model follows the outside-in collapse of three-pane apps (Mail, Slack, Plex). - The categories rail folds at level 2 **only while a category is selected**: the live root ("All Items" grid) has no channels header to host the way back, so folding there would strand the user. Level 3 folds it regardless, because the floating restore handle lives in the content area. - `LIVE_CATEGORIES_POPOVER` (`@iptvnator/portal/shared/util`) is the DI bridge: the workspace shell provides `WorkspaceLiveCategoriesPopoverService` (CDK overlay hosting `WorkspaceContextPanelComponent` in `presentation="popover"`), the Xtream and Stalker live layouts inject it optionally and keep their plain heading without a provider. - M3U and the unified live tab have no categories rail and treat level 2 like level 1; their code is untouched. ## Merged with #1555 (per-surface rail state) #1555 landed while this PR was open and reworked the same service: state per surface (`m3u` / `portal` / `collection`), a workspace header toggle, the hidden-list empty state, and the legacy shared key forgotten on startup. This PR keeps that model and layers the three levels onto the `portal` surface (`areCategoriesHiddenFor`, `hideCategories` / `showCategories` / `collapse` / `expand` per surface; `toggle(surface)` returns to the level the surface collapsed from). "Show playing channel" uses `expand('portal')` so it keeps a deliberately hidden categories rail folded, and the category sort preference moved to `PortalCategorySortStateService` so the popover copy of the context panel and the retained rail agree. ## Also fixed along the way - The channels header showed "Channels" instead of the category name: provider category ids are strings, the selection is numeric. Compared via `String()` now. - A collapsed context panel left a 22px padding strip beside the channels rail. - The panel toggle labels said "Hide channels list" while also hiding categories; labels and tooltips are honest now (8 new i18n keys, all 18 locales). Docs: `docs/architecture/iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar" rewritten), `docs/architecture/workspace-shell.md`. Release note: `.changes/portals-live-panel-collapse-levels.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
537 lines
19 KiB
TypeScript
537 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('menuitem', { 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('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,
|
|
}) => {
|
|
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> {
|
|
let latestStatus: RemoteControlStatus | null = 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 }
|
|
);
|
|
}
|
|
|
|
return latestStatus as RemoteControlStatus;
|
|
}
|
|
|
|
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));
|
|
}
|