mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
* feat(workspace): route platform Back gestures to the header Back The mouse Back button, Alt+Left (Cmd+[ on macOS) and, in Electron, the window's app-command browser-backward (Windows/Linux) and trackpad swipe (macOS) now run the workspace header's Back, one step each. - WorkspaceBackGesturesService (shell-provided) dispatches every gesture through WorkspaceBackNavigationService.goBack(). A gesture is swallowed while a dialog, menu, the phone drawer or a fullscreen player is up, and left to the browser only where the header has no Back. - The chord is a navigate-back entry in the shortcuts dialog, handled in a capture-phase listener of WorkspaceKeyboardShortcutsService; ignored while typing, repeats swallowed. - Electron forwards app-command and swipe over the push-only channel WINDOW:BACK_REQUESTED (preload onWindowBackRequested), gated in the renderer on RuntimeCapabilitiesService.supportsWindowBackGestures. - A mouse press that also arrives as app-command goes back once, however long the button is held; Electron's own Chromium history step on the mouse button is always prevented. - No Forward binding. Docs: workspace-shell (Platform gestures), portal-detail-navigation, electron-security, UI guidelines, README shortcuts table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(workspace): drop NgZone from the Back gestures service Master runs zoneless change detection and no longer uses NgZone; the bridge callback calls goBack() directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): keep Back gestures where the Fullscreen API is missing iPhone Safari has no unprefixed document.fullscreenElement; the undefined value read as "a player is fullscreen" and blocked every gesture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): keep a held Back chord's repeats swallowed after Back Repeats asked for the current result, so once the first press reached a page with no Back left they were released to the browser, which could take another history step. Repeats now follow the chord's first press until a key is released. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(electron): one Back per press of the Linux Browser Back key On Linux Electron raises app-command browser-backward for every unhandled event of the Browser Back key (NativeWindowViews:: HandleKeyboardEvent), keydown and keyup alike, so one press went back twice. The main process now consumes that key in before-input-event and requests Back once per press; mouse Back buttons still arrive as app-command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
265 lines
9.7 KiB
TypeScript
265 lines
9.7 KiB
TypeScript
import type { CDPSession, Page } from '@playwright/test';
|
|
import {
|
|
addXtreamPortal,
|
|
clickFirstGridListCard,
|
|
closeElectronApp,
|
|
expect,
|
|
launchElectronApp,
|
|
resetMockServers,
|
|
test,
|
|
waitForXtreamWorkspaceReady,
|
|
type LaunchedElectronApp,
|
|
} from './electron-test-fixtures';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Platform Back gestures run the workspace header's Back: the mouse Back
|
|
// button, Alt+← (Cmd+[ on macOS), and the window's `app-command`
|
|
// (`browser-backward`, Windows/Linux) and trackpad `swipe` (macOS), which the
|
|
// main process forwards to the renderer. Each goes back exactly one step. A
|
|
// mouse Back press that reaches the app both as a renderer `mouseup` and as
|
|
// an `app-command` (Windows and Linux) still goes back once. There is no
|
|
// Forward. Gestures are ignored while typing (the chord) and while a dialog
|
|
// is open.
|
|
//
|
|
// The window events are emitted on the main process's BrowserWindow, so the
|
|
// specs run on every platform even though Electron raises each event only
|
|
// on its own.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const detailUrlPattern = /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/;
|
|
const backChord =
|
|
process.platform === 'darwin' ? 'Meta+BracketLeft' : 'Alt+ArrowLeft';
|
|
|
|
type WindowGesture =
|
|
| { event: 'app-command'; value: 'browser-backward' | 'browser-forward' }
|
|
| { event: 'swipe'; value: 'left' | 'right' };
|
|
|
|
async function emitWindowGesture(
|
|
app: LaunchedElectronApp,
|
|
gesture: WindowGesture
|
|
): Promise<void> {
|
|
await app.electronApp.evaluate(({ BrowserWindow }, { event, value }) => {
|
|
const [win] = BrowserWindow.getAllWindows();
|
|
win.emit(event, {}, value);
|
|
}, gesture);
|
|
}
|
|
|
|
/** Half of a mouse Back press through Chromium's input pipeline. */
|
|
async function mouseBack(
|
|
cdp: CDPSession,
|
|
page: Page,
|
|
type: 'mousePressed' | 'mouseReleased'
|
|
): Promise<void> {
|
|
const box = await page.locator('main').first().boundingBox();
|
|
await cdp.send('Input.dispatchMouseEvent', {
|
|
type,
|
|
x: Math.round((box?.x ?? 0) + (box?.width ?? 400) / 2),
|
|
y: Math.round((box?.y ?? 0) + (box?.height ?? 400) / 2),
|
|
button: 'back',
|
|
buttons: type === 'mousePressed' ? 8 : 0,
|
|
clickCount: 1,
|
|
});
|
|
}
|
|
|
|
async function pressMouseBack(cdp: CDPSession, page: Page): Promise<void> {
|
|
await mouseBack(cdp, page, 'mousePressed');
|
|
await mouseBack(cdp, page, 'mouseReleased');
|
|
}
|
|
|
|
async function openFirstSeries(page: Page): Promise<string> {
|
|
await clickFirstGridListCard(page);
|
|
await page.waitForURL(detailUrlPattern);
|
|
await expect(page.locator('app-portal-detail-shell')).toBeVisible({
|
|
timeout: 20_000,
|
|
});
|
|
return page.url();
|
|
}
|
|
|
|
/** The history index Back gestures must land on, and nothing below it. */
|
|
function historyIndex(page: Page): Promise<number> {
|
|
return page.evaluate(() => window.navigation?.currentEntry?.index ?? -1);
|
|
}
|
|
|
|
/** Time for a late second step (a duplicated press) to surface. */
|
|
function settle(page: Page): Promise<unknown> {
|
|
return page.evaluate(
|
|
() => new Promise((resolve) => setTimeout(resolve, 400))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Waits for a gesture to return to the list, then for any late second step
|
|
* to surface before checking the history index.
|
|
*/
|
|
async function expectBackToList(
|
|
page: Page,
|
|
listUrl: string,
|
|
listIndex: number
|
|
): Promise<void> {
|
|
await expect(page).toHaveURL(listUrl);
|
|
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
|
|
await settle(page);
|
|
await expect(page).toHaveURL(listUrl);
|
|
expect(await historyIndex(page)).toBe(listIndex);
|
|
}
|
|
|
|
/** The Xtream series list, reached by navigation from the start page. */
|
|
async function openSeriesList(
|
|
page: Page
|
|
): Promise<{ listUrl: string; listIndex: number }> {
|
|
await addXtreamPortal(page);
|
|
await waitForXtreamWorkspaceReady(page);
|
|
await page.getByRole('link', { name: 'Series', exact: true }).click();
|
|
await page.waitForURL(/\/series$/);
|
|
await expect(
|
|
page.locator('.category-content-layout mat-card').first()
|
|
).toBeVisible({ timeout: 20_000 });
|
|
return { listUrl: page.url(), listIndex: await historyIndex(page) };
|
|
}
|
|
|
|
test.describe('Platform Back gestures', () => {
|
|
test('@xtream @electron mouse Back, the Back chord and the window gestures each go back one step', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
const page = app.mainWindow;
|
|
const cdp = await app.electronApp.context().newCDPSession(page);
|
|
const { listUrl, listIndex } = await openSeriesList(page);
|
|
|
|
await openFirstSeries(page);
|
|
await pressMouseBack(cdp, page);
|
|
await expectBackToList(page, listUrl, listIndex);
|
|
|
|
await openFirstSeries(page);
|
|
await page.keyboard.press(backChord);
|
|
await expectBackToList(page, listUrl, listIndex);
|
|
|
|
await openFirstSeries(page);
|
|
await emitWindowGesture(app, {
|
|
event: 'app-command',
|
|
value: 'browser-backward',
|
|
});
|
|
await expectBackToList(page, listUrl, listIndex);
|
|
|
|
await openFirstSeries(page);
|
|
await emitWindowGesture(app, { event: 'swipe', value: 'left' });
|
|
await expectBackToList(page, listUrl, listIndex);
|
|
|
|
// No Forward: Stalker inline details are not history entries.
|
|
await emitWindowGesture(app, {
|
|
event: 'app-command',
|
|
value: 'browser-forward',
|
|
});
|
|
await emitWindowGesture(app, { event: 'swipe', value: 'right' });
|
|
await expectBackToList(page, listUrl, listIndex);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('@xtream @electron a mouse Back press that also arrives as an app command goes back once', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
const page = app.mainWindow;
|
|
const cdp = await app.electronApp.context().newCDPSession(page);
|
|
const { listUrl, listIndex } = await openSeriesList(page);
|
|
|
|
await openFirstSeries(page);
|
|
await Promise.all([
|
|
pressMouseBack(cdp, page),
|
|
emitWindowGesture(app, {
|
|
event: 'app-command',
|
|
value: 'browser-backward',
|
|
}),
|
|
]);
|
|
await expectBackToList(page, listUrl, listIndex);
|
|
|
|
// The pairing is per press: the next one goes back again.
|
|
await openFirstSeries(page);
|
|
await emitWindowGesture(app, {
|
|
event: 'app-command',
|
|
value: 'browser-backward',
|
|
});
|
|
await pressMouseBack(cdp, page);
|
|
await expectBackToList(page, listUrl, listIndex);
|
|
|
|
// Linux sends the command on press: a long hold is one press.
|
|
await openFirstSeries(page);
|
|
await mouseBack(cdp, page, 'mousePressed');
|
|
await emitWindowGesture(app, {
|
|
event: 'app-command',
|
|
value: 'browser-backward',
|
|
});
|
|
await expect(page).toHaveURL(listUrl);
|
|
await settle(page);
|
|
await settle(page);
|
|
await mouseBack(cdp, page, 'mouseReleased');
|
|
await expectBackToList(page, listUrl, listIndex);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('@xtream @electron gestures wait while typing or while a dialog is open', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
const page = app.mainWindow;
|
|
const cdp = await app.electronApp.context().newCDPSession(page);
|
|
const { listUrl, listIndex } = await openSeriesList(page);
|
|
|
|
// Alt/Option arrows edit text in a field. The list has the
|
|
// history fallback, so the chord would otherwise go back.
|
|
const search = page.locator(
|
|
'app-workspace-shell-header input[type="search"]'
|
|
);
|
|
await expect(search).toBeEnabled();
|
|
await search.focus();
|
|
await page.keyboard.press(backChord);
|
|
await settle(page);
|
|
await expect(page).toHaveURL(listUrl);
|
|
await search.blur();
|
|
|
|
const detailUrl = await openFirstSeries(page);
|
|
|
|
// One at a time: a gesture that slipped through would navigate,
|
|
// and the dialog would close with the navigation.
|
|
await page.locator('app-portal-detail-shell').focus();
|
|
await page.keyboard.press('?');
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible();
|
|
for (const gesture of [
|
|
() => page.keyboard.press(backChord),
|
|
// Electron's Chromium would step back on its own.
|
|
() => pressMouseBack(cdp, page),
|
|
() => emitWindowGesture(app, { event: 'swipe', value: 'left' }),
|
|
]) {
|
|
await gesture();
|
|
await settle(page);
|
|
await expect(page).toHaveURL(detailUrl);
|
|
await expect(dialog).toBeVisible();
|
|
}
|
|
|
|
await page.keyboard.press('Escape');
|
|
await expect(dialog).toHaveCount(0);
|
|
await page.keyboard.press(backChord);
|
|
await expectBackToList(page, listUrl, listIndex);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
});
|