mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
* feat(settings): grouped navigation, cards and switches from the settings concept Implements the Claude Design "Settings concept" handoff. Navigation: the sidebar is a 20px "Settings" title over App, Library, Devices and Data groups, with About pinned to the footer beside the installed version and an update badge. The active item is a soft fill without a border. Esc leaves settings like the header Back. Reset is no longer a page: its one destructive action is the last card of "Backup & data". Pages: a title and one-line subtitle, then rows grouped into titled cards with one right-aligned control column. Selects are compact without floating labels, checkboxes are switches, segmented controls are pills, and descriptions are capped at 56ch. "Show subtitles" moves to Playback, the Embedded MPV note becomes a callout under the player select (only while it is selected), and the TMDB attribution becomes the About footer. EPG: Add and Refresh all sit in the Sources header, the empty state has its own Add, the format examples are one line, Clear EPG data is its own row and the offset is a stepper. About: version, update state and channel share one card; the nightly warning is a callout shown only while Nightly is selected, with a shortcut to the backup page; the support buttons are neutral with coloured icons. Save model: the save bar stays (design option B) and now floats over the content column, counts the staged changes, answers Cmd/Ctrl+S, and marks pages with staged edits in the sidebar. A saved change that waits for a restart shows a dismissible notice. Rows stack under 600px of pane width (the page is a size container, so the persistent sidebar is accounted for). Page-specific styles moved into the About, EPG, Backup and Remote control section stylesheets to stay within the component style budget. New SETTINGS keys are translated in all 19 locales and NAV_RESET is removed; brand names and loanwords that stay English are in the identical-value baseline. E2E: Material slide toggles report aria-checked after the next render, so both suites toggle through a setSwitch helper; the settings nav is a navigation landmark, so the Dashboard rail link is scoped to the rail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): compare restart notices against the running app, fix CI fallout The restart notice now lists only the restart controls whose saved value differs from what the running app uses: the stored values at first load, or what the embedded engine reports it actually runs for the frame-copy opt-in. Saving the launch value back withdraws the notice instead of leaving it up with no chip to explain it. The refresh reads the current list untracked and writes only a changed one, because the engine probe effect calls it. CI: the zoneless checklist dropped the deleted Reset section component, and the theme-tokens Electron E2E floats the recording folder label on the Playback page now that the settings selects carry no floating label. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(settings): keep a dismissed restart reminder away until the setting changes "Later" now records the saved value it dismissed, for the rest of the app run, so an unrelated save (or reopening Settings) does not bring the same reminder back. It returns once a restart setting is saved with another value. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
/**
|
||
* Setup actions that stage a dialog or a settings page for a shot: the
|
||
* add-playlist forms, the settings sections, the remote-control switch.
|
||
* Actions here call each other directly rather than through `runAction`,
|
||
* which keeps this module free of a dependency on the dispatcher.
|
||
*/
|
||
|
||
import type { Page } from '@playwright/test';
|
||
|
||
import {
|
||
AUTO_DETECT_FIXTURE_MESSAGE,
|
||
CAPTURE_REMOTE_CONTROL_PORT,
|
||
CAPTURE_REMOTE_CONTROL_URL,
|
||
EPG_FIXTURE_URL,
|
||
M3U_FIXTURE_PLAYLIST_TITLE,
|
||
M3U_FIXTURE_PLAYLIST_URL,
|
||
STALKER_FIXTURE_MAC,
|
||
STALKER_FIXTURE_PORTAL_URL,
|
||
STALKER_FIXTURE_TITLE,
|
||
XTREAM_FIXTURE_CREDENTIALS,
|
||
XTREAM_FIXTURE_TITLE,
|
||
XTREAM_MOCK_ORIGIN,
|
||
} from './capture-fixtures';
|
||
import {
|
||
type CaptureAction,
|
||
clickDialogOption,
|
||
goHome,
|
||
openAddPlaylistDialog,
|
||
settleUi,
|
||
} from './capture-navigation-helpers';
|
||
|
||
export async function openSettings(page: Page): Promise<void> {
|
||
await page.locator('a[href$="/workspace/settings"]').first().click();
|
||
await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 });
|
||
await page
|
||
.locator('[data-test-id="settings-container"]')
|
||
.waitFor({ state: 'visible', timeout: 15_000 });
|
||
}
|
||
|
||
async function openDashboard(page: Page): Promise<void> {
|
||
await page
|
||
.locator('app-workspace-shell-rail a[href$="/workspace/dashboard"]')
|
||
.first()
|
||
.click();
|
||
await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 });
|
||
await page
|
||
.locator('[data-test-id="dashboard-hero"]')
|
||
.waitFor({ state: 'visible', timeout: 30_000 });
|
||
await settleUi(page);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Add-playlist dialog */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
async function openAddPlaylistXtream(page: Page): Promise<void> {
|
||
await goHome(page);
|
||
await openAddPlaylistDialog(page);
|
||
const dialog = page.locator('mat-dialog-container').last();
|
||
|
||
await clickDialogOption(dialog, /xtream credentials/i);
|
||
await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE);
|
||
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN);
|
||
await dialog.locator('#username').fill(XTREAM_FIXTURE_CREDENTIALS.username);
|
||
await dialog.locator('#password').fill(XTREAM_FIXTURE_CREDENTIALS.password);
|
||
// The server URL is plain http://, so the probe never tries HTTPS and
|
||
// only talks to the local mock; the frame can show the successful
|
||
// "portal is active" verdict the guide explains.
|
||
await dialog
|
||
.getByRole('button', { name: 'Test HTTPS and HTTP', exact: true })
|
||
.click();
|
||
// The status line first reads "Testing connection…"; wait for the
|
||
// verdict itself, so a refused account fails the shot instead of
|
||
// publishing a frame that contradicts the guide.
|
||
const status = dialog.getByRole('status');
|
||
await status
|
||
.filter({ hasText: /portal is active/i })
|
||
.waitFor({ state: 'visible', timeout: 30_000 })
|
||
.catch(async () => {
|
||
const shown = await status
|
||
.textContent({ timeout: 1_000 })
|
||
.catch(() => null);
|
||
throw new Error(
|
||
`Xtream connection test did not report an active portal within 30s (status: ${shown?.trim() || 'none'})`
|
||
);
|
||
});
|
||
// The dialog body scrolls. The verdict sits under the server URL,
|
||
// above the credentials, so scrolling to the last field frames the
|
||
// whole filled form together with the verdict the guide explains.
|
||
await dialog.locator('#password').scrollIntoViewIfNeeded();
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
async function openAddPlaylistAuto(page: Page): Promise<void> {
|
||
await goHome(page);
|
||
await openAddPlaylistDialog(page);
|
||
const dialog = page.locator('mat-dialog-container').last();
|
||
|
||
await clickDialogOption(dialog, /auto-detect/i);
|
||
await dialog
|
||
.locator('[data-test-id="auto-detect-textarea"]')
|
||
.fill(AUTO_DETECT_FIXTURE_MESSAGE);
|
||
const candidate = dialog
|
||
.locator('[data-test-id="auto-detect-candidate"]')
|
||
.first();
|
||
await candidate.waitFor({ state: 'visible', timeout: 15_000 });
|
||
await candidate.scrollIntoViewIfNeeded();
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
async function openAddPlaylistStalker(page: Page): Promise<void> {
|
||
await goHome(page);
|
||
await openAddPlaylistDialog(page);
|
||
const dialog = page.locator('mat-dialog-container').last();
|
||
|
||
await clickDialogOption(dialog, /stalker portal/i);
|
||
await dialog.locator('#title').fill(STALKER_FIXTURE_TITLE);
|
||
await dialog.locator('#portalUrl').fill(STALKER_FIXTURE_PORTAL_URL);
|
||
await dialog.locator('#macAddress').fill(STALKER_FIXTURE_MAC);
|
||
// Blur runs the MAC normalization the guide describes.
|
||
await dialog.locator('#serialNumber').focus();
|
||
// The form is long; frame the identity fields and the derive
|
||
// toggle rather than the signature fields at the bottom.
|
||
await dialog.locator('.derive-device-ids').scrollIntoViewIfNeeded();
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
async function openAddPlaylistM3uUrl(page: Page): Promise<void> {
|
||
await goHome(page);
|
||
await openAddPlaylistDialog(page);
|
||
const dialog = page.locator('mat-dialog-container').last();
|
||
|
||
await clickDialogOption(dialog, /m3u url/i);
|
||
// Typed only: the dialog fetches nothing until Add is clicked,
|
||
// and the address points at the local mock anyway.
|
||
await dialog
|
||
.locator('input[formcontrolname="playlistUrl"]')
|
||
.fill(M3U_FIXTURE_PLAYLIST_URL);
|
||
await dialog
|
||
.locator('input[formcontrolname="playlistName"]')
|
||
.fill(M3U_FIXTURE_PLAYLIST_TITLE);
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Settings sections */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/** Navigates to Settings › EPG and waits for the section, without staging anything. */
|
||
export async function openEpgSettingsSection(page: Page): Promise<void> {
|
||
await openSettings(page);
|
||
const sectionLink = page
|
||
.locator('[data-test-id="settings-section-epg"]')
|
||
.first();
|
||
|
||
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
|
||
await sectionLink.click({ timeout: 10_000 });
|
||
await page.waitForURL(/\/workspace\/settings\/epg/, { timeout: 15_000 });
|
||
await page.locator('#epg').waitFor({ state: 'visible', timeout: 15_000 });
|
||
}
|
||
|
||
async function openSettingsEpg(page: Page): Promise<void> {
|
||
await openEpgSettingsSection(page);
|
||
const section = page.locator('#epg');
|
||
|
||
// Show a filled source row instead of the empty state. The value
|
||
// is staged in the form only; nothing is saved or fetched. The
|
||
// dirty form is discarded by `discardUnsavedSettings` before the
|
||
// next action or the app teardown — the settings close guard
|
||
// would otherwise hold `app.close()` open forever. A row a previous
|
||
// step already saved (`load-demo-epg`) fills the frame on its own.
|
||
if ((await section.locator('.epg-source-row').count()) === 0) {
|
||
await section
|
||
.getByRole('button', { name: /add epg source/i })
|
||
.click({ timeout: 10_000 });
|
||
const field = section.locator('input[type="url"]').last();
|
||
await field.waitFor({ state: 'visible', timeout: 10_000 });
|
||
await field.fill(EPG_FIXTURE_URL, { timeout: 10_000 });
|
||
}
|
||
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
/**
|
||
* Settings with a term typed into the header search, so the page shows the
|
||
* ranked results list and the per-section match counts instead of a section.
|
||
* The term lives only in the `q` query param: the next action's navigation
|
||
* clears it, and nothing is staged in the settings form.
|
||
*/
|
||
async function openSettingsSearch(
|
||
page: Page,
|
||
term: string | null
|
||
): Promise<void> {
|
||
if (!term) {
|
||
throw new Error('open-settings-search needs a term: open-settings-search=<term>');
|
||
}
|
||
|
||
await openSettings(page);
|
||
await page
|
||
.locator('app-workspace-shell-header input[type="search"]')
|
||
.fill(term, { timeout: 10_000 });
|
||
await page.waitForURL(/\/workspace\/settings\/general\?q=/, {
|
||
timeout: 15_000,
|
||
});
|
||
const results = page.locator('[data-test-id="settings-search-results"]');
|
||
await results.waitFor({ state: 'visible', timeout: 15_000 });
|
||
await results
|
||
.locator('[data-test-id^="settings-search-result-"]')
|
||
.first()
|
||
.waitFor({ state: 'visible', timeout: 10_000 });
|
||
await settleUi(page);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Remote control (guide shots) */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* Opens Settings › Remote control with the feature switched on and the
|
||
* capture port in the field. The form is left dirty unless a later
|
||
* `enable-remote-control` step saves it; `discardUnsavedSettings` clears it
|
||
* before the next action.
|
||
*/
|
||
async function openRemoteControlSettings(page: Page): Promise<void> {
|
||
await openSettings(page);
|
||
const sectionLink = page
|
||
.locator('[data-test-id="settings-section-remote-control"]')
|
||
.first();
|
||
|
||
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
|
||
await sectionLink.click({ timeout: 10_000 });
|
||
await page.waitForURL(/\/workspace\/settings\/remote-control/, {
|
||
timeout: 15_000,
|
||
});
|
||
|
||
const section = page.locator('#remote-control');
|
||
await section.waitFor({ state: 'visible', timeout: 15_000 });
|
||
|
||
const toggle = section.locator(
|
||
'[data-test-id="remote-control-enabled"] [role="switch"]'
|
||
);
|
||
|
||
if (!(await toggle.isChecked())) {
|
||
await section.locator('[data-test-id="remote-control-enabled"]').click();
|
||
}
|
||
|
||
const port = section.locator('[data-test-id="remote-control-port"]');
|
||
await port.waitFor({ state: 'visible', timeout: 10_000 });
|
||
|
||
if ((await port.inputValue()) !== String(CAPTURE_REMOTE_CONTROL_PORT)) {
|
||
await port.fill(String(CAPTURE_REMOTE_CONTROL_PORT));
|
||
}
|
||
|
||
await section
|
||
.locator('.remote-control-url')
|
||
.first()
|
||
.waitFor({ state: 'visible', timeout: 15_000 });
|
||
}
|
||
|
||
async function openSettingsRemoteControl(page: Page): Promise<void> {
|
||
await openRemoteControlSettings(page);
|
||
const section = page.locator('#remote-control');
|
||
const qrButton = section.locator('.url-row button').first();
|
||
|
||
await qrButton.waitFor({ state: 'visible', timeout: 15_000 });
|
||
await qrButton.click();
|
||
await section
|
||
.locator('qrcode canvas, qrcode img')
|
||
.first()
|
||
.waitFor({ state: 'visible', timeout: 15_000 });
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
async function enableRemoteControl(page: Page): Promise<void> {
|
||
await openRemoteControlSettings(page);
|
||
const save = page.locator('[data-test-id="save-settings"]').first();
|
||
|
||
if (await save.isEnabled().catch(() => false)) {
|
||
await save.click();
|
||
await settleUi(page);
|
||
}
|
||
|
||
await waitForRemoteControlServer();
|
||
}
|
||
|
||
/** Polls the status endpoint the phone view reads until the app's server answers. */
|
||
async function waitForRemoteControlServer(): Promise<void> {
|
||
const statusUrl = `${CAPTURE_REMOTE_CONTROL_URL}api/remote-control/status`;
|
||
const deadline = Date.now() + 15_000;
|
||
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
const response = await fetch(statusUrl);
|
||
|
||
if (response.ok) {
|
||
return;
|
||
}
|
||
} catch {
|
||
// not up yet
|
||
}
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||
}
|
||
|
||
throw new Error(
|
||
`Remote control server did not answer at ${statusUrl} — is port ${CAPTURE_REMOTE_CONTROL_PORT} held by another IPTVnator instance?`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Settings › Metadata (TMDB) with the switch staged ON, which is what reveals
|
||
* the API-key field, the cache panel and the required TMDB attribution. The
|
||
* value is never saved: enrichment must stay off for the whole capture (G5),
|
||
* and `discardUnsavedSettings` drops the form before the next action.
|
||
*/
|
||
async function openSettingsTmdb(page: Page): Promise<void> {
|
||
await openSettings(page);
|
||
const sectionLink = page
|
||
.locator('[data-test-id="settings-section-tmdb"]')
|
||
.first();
|
||
|
||
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
|
||
await sectionLink.click({ timeout: 10_000 });
|
||
await page.waitForURL(/\/workspace\/settings\/tmdb/, { timeout: 15_000 });
|
||
|
||
const section = page.locator('#tmdb');
|
||
await section.waitFor({ state: 'visible', timeout: 15_000 });
|
||
|
||
const toggle = section.locator(
|
||
'[data-test-id="tmdb-enabled"] [role="switch"]'
|
||
);
|
||
|
||
if (!(await toggle.isChecked())) {
|
||
await section.locator('[data-test-id="tmdb-enabled"]').click();
|
||
}
|
||
|
||
await section
|
||
.locator('[data-test-id="tmdb-api-key"]')
|
||
.waitFor({ state: 'visible', timeout: 10_000 });
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
export const SETUP_ACTIONS: Readonly<Record<string, CaptureAction>> = {
|
||
'open-settings': openSettings,
|
||
'open-dashboard': openDashboard,
|
||
'open-add-playlist-xtream': openAddPlaylistXtream,
|
||
'open-add-playlist-auto': openAddPlaylistAuto,
|
||
'open-add-playlist-stalker': openAddPlaylistStalker,
|
||
'open-add-playlist-m3u-url': openAddPlaylistM3uUrl,
|
||
'open-settings-epg': openSettingsEpg,
|
||
'open-settings-search': openSettingsSearch,
|
||
'open-settings-tmdb': openSettingsTmdb,
|
||
'open-settings-remote-control': openSettingsRemoteControl,
|
||
'enable-remote-control': enableRemoteControl,
|
||
};
|