mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(ui): restore channel and detail keyboard scrolling (#1542)
* fix(ui): restore channel and detail keyboard scrolling * test(ui): drag below the Windows scrollbar arrow
This commit is contained in:
1 parent
0ba5107561
commit
eb602db5fc
44 files changed
+950
-49
No files matched your search
@@ -329,3 +329,88 @@ test.describe('Electron Xtream Responsiveness', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps Live TV scrolling, row actions and resizing independently accessible', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamCatalog(page);
|
||||
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
|
||||
const categories = page.locator('.context-panel .category-item');
|
||||
const category = categories.first();
|
||||
await category.click();
|
||||
const viewport = page.locator('.scroll-viewport-portals');
|
||||
await expect(viewport).toBeVisible();
|
||||
await category.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(viewport).toBeFocused();
|
||||
await expect(page.locator('app-web-player-view')).toHaveCount(0);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(
|
||||
viewport.locator('button.channel-content').first()
|
||||
).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(viewport).toBeFocused();
|
||||
await viewport.locator('.channel-name').first().click();
|
||||
await expect(viewport).toBeFocused();
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(100);
|
||||
const position = await viewport.evaluate((el) => el.scrollTop);
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(position);
|
||||
await page.keyboard.press('Home');
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBe(0);
|
||||
const before = (await viewport.boundingBox())!;
|
||||
const handle = page.locator(
|
||||
'app-live-stream-layout .sidebar > .resize-handle'
|
||||
);
|
||||
const resize = (await handle.boundingBox())!;
|
||||
expect(before.x + before.width).toBeLessThanOrEqual(resize.x);
|
||||
expect(
|
||||
await handle.evaluate((el) =>
|
||||
getComputedStyle(el).getPropertyValue('app-region')
|
||||
)
|
||||
).toBe('no-drag');
|
||||
// Start inside the thumb, below the native Windows up-arrow button.
|
||||
// The 40-channel mock leaves a thumb taller than 100 px here.
|
||||
await page.mouse.move(before.x + before.width - 3, before.y + 40);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(before.x + before.width - 3, before.y + 160, {
|
||||
steps: 10,
|
||||
});
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(100);
|
||||
expect((await viewport.boundingBox())!.width).toBe(before.width);
|
||||
await page.mouse.move(resize.x + 2, resize.y + 100);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(resize.x + 62, resize.y + 100, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => (await viewport.boundingBox())!.width)
|
||||
.toBeGreaterThan(before.width + 30);
|
||||
await viewport.focus();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(category).toBeFocused();
|
||||
await categories.nth(1).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(categories.nth(1)).toBeFocused();
|
||||
await expect(categories.nth(1)).toHaveAttribute('aria-current', 'true');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(viewport).toBeFocused();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -133,3 +133,67 @@ test.describe('Xtream VOD Details', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
for (const theme of ['light', 'dark']) {
|
||||
test(`supports keyboard and mouse scrolling in portal details (${theme})`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await page.setViewportSize({ width: 1200, height: 540 });
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
for (const section of ['Movies', 'Series']) {
|
||||
await page
|
||||
.getByRole('link', { name: section, exact: true })
|
||||
.click();
|
||||
await page.locator('app-grid-list mat-card').first().click();
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
await expect(shell).toBeFocused();
|
||||
await page.evaluate(
|
||||
(dark) =>
|
||||
document.body.classList.toggle('dark-theme', dark),
|
||||
theme === 'dark'
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
shell.evaluate(
|
||||
(el) => el.scrollHeight - el.clientHeight
|
||||
)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
expect(
|
||||
await shell.evaluate(
|
||||
(el) => getComputedStyle(el).scrollbarWidth
|
||||
)
|
||||
).not.toBe('none');
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => shell.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
await page.keyboard.press('Home');
|
||||
await expect
|
||||
.poll(() => shell.evaluate((el) => el.scrollTop))
|
||||
.toBe(0);
|
||||
const box = (await shell.boundingBox())!;
|
||||
await page.mouse.move(
|
||||
box.x + box.width / 2,
|
||||
box.y + box.height / 2
|
||||
);
|
||||
await page.mouse.wheel(0, 300);
|
||||
await expect
|
||||
.poll(() => shell.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(shell.locator('.hero__back-button')).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(shell).toHaveCount(0);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -52,3 +52,22 @@ export async function postWithRetry(
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/** Wait for native scrolling to settle before the next discrete keyboard action. */
|
||||
export async function waitForScrollIdle(scrollOwner: Locator): Promise<void> {
|
||||
await scrollOwner.evaluate(
|
||||
(element) =>
|
||||
new Promise<void>((resolve) => {
|
||||
let last = element.scrollTop;
|
||||
let stableFrames = 0;
|
||||
const frame = () => {
|
||||
stableFrames =
|
||||
element.scrollTop === last ? stableFrames + 1 : 0;
|
||||
last = element.scrollTop;
|
||||
if (stableFrames === 3) resolve();
|
||||
else requestAnimationFrame(frame);
|
||||
};
|
||||
requestAnimationFrame(frame);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from './fixtures';
|
||||
import { waitForScrollIdle } from './e2e-helpers';
|
||||
|
||||
/**
|
||||
* The M3U movie-recognition workflow end to end: a playlist entry whose URL
|
||||
@@ -112,20 +113,22 @@ async function enableTmdb(page: Page): Promise<void> {
|
||||
await saveSettings(page);
|
||||
}
|
||||
|
||||
async function importPlaylist(page: Page): Promise<void> {
|
||||
async function importPlaylist(
|
||||
page: Page,
|
||||
content = MOVIE_PLAYLIST,
|
||||
count = 2
|
||||
): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
|
||||
await dialog
|
||||
.getByLabel('Insert m3u(8) playlist as text')
|
||||
.fill(MOVIE_PLAYLIST);
|
||||
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(content);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
|
||||
]);
|
||||
await expect(page.getByText('2 channels')).toBeVisible();
|
||||
await expect(page.getByText(`${count} channels`)).toBeVisible();
|
||||
}
|
||||
|
||||
const detail = (page: Page) => page.locator('app-m3u-vod-detail');
|
||||
@@ -248,3 +251,68 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
|
||||
)
|
||||
.toBe(0.25);
|
||||
});
|
||||
|
||||
for (const theme of ['light', 'dark']) {
|
||||
test(`@web @m3u channel scrolling keeps focus after selection (${theme})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await serveStreams(page);
|
||||
await selectHtml5Player(page);
|
||||
const channels = Array.from(
|
||||
{ length: 60 },
|
||||
(_, index) =>
|
||||
`#EXTINF:-1 group-title="News",Station ${index + 1}\n${FIXTURE_HOST}/live-${index}.m3u8`
|
||||
);
|
||||
await importPlaylist(page, ['#EXTM3U', ...channels].join('\n'), 60);
|
||||
await page.evaluate(
|
||||
(dark) => document.body.classList.toggle('dark-theme', dark),
|
||||
theme === 'dark'
|
||||
);
|
||||
const viewport = page.locator(
|
||||
'app-all-channels-view cdk-virtual-scroll-viewport'
|
||||
);
|
||||
await viewport.locator('.channel-name').first().click();
|
||||
await expect(viewport).toBeFocused();
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(100);
|
||||
await expect(viewport).toBeFocused();
|
||||
await waitForScrollIdle(viewport);
|
||||
await page.keyboard.press('Home');
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBe(0);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(
|
||||
viewport.locator('button.channel-content').first()
|
||||
).toBeFocused();
|
||||
await viewport.locator('button.channel-content').nth(1).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(viewport.locator('.channel-list-item').nth(1)).toHaveClass(
|
||||
/active/
|
||||
);
|
||||
await page.keyboard.press('Tab');
|
||||
const favorite = viewport.locator('.favorite-button').nth(1);
|
||||
await expect(favorite).toBeFocused();
|
||||
await page.keyboard.press('Space');
|
||||
await expect(favorite).toBeFocused();
|
||||
await page
|
||||
.getByRole('link', { name: 'Global favorites', exact: true })
|
||||
.click();
|
||||
const favorites = page.locator(
|
||||
'app-global-favorites-list [appChannelScrollFocus]'
|
||||
);
|
||||
await favorites.locator('.channel-name').first().click();
|
||||
await expect(favorites).toBeFocused();
|
||||
await page
|
||||
.getByRole('link', { name: 'Recently viewed', exact: true })
|
||||
.first()
|
||||
.click();
|
||||
const recent = page.locator(
|
||||
'app-global-favorites-list [appChannelScrollFocus]'
|
||||
);
|
||||
await recent.locator('.channel-name').first().click();
|
||||
await expect(recent).toBeFocused();
|
||||
});
|
||||
}
|
||||
@@ -480,10 +480,22 @@ test('@stalker ITV playback survives a category switch', async ({ page }) => {
|
||||
const sidebarTitle = sidebar.locator('.category-title');
|
||||
const channels = page.locator('[data-test-id="channel-item"]');
|
||||
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
|
||||
const scrollPane = sidebar.locator('#live-channels');
|
||||
await categories.nth(1).focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(scrollPane).toBeFocused();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(categories.nth(1)).toBeFocused();
|
||||
const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? '';
|
||||
expect(firstCategoryTitle).not.toBe('');
|
||||
|
||||
await channels.first().click();
|
||||
await expect(scrollPane).toBeFocused();
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => scrollPane.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
|
||||
const player = page.locator('app-web-player-view');
|
||||
await expect(player).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { expect, test } from './fixtures';
|
||||
import { setInputValue } from './e2e-helpers';
|
||||
import { setInputValue, waitForScrollIdle } from './e2e-helpers';
|
||||
import {
|
||||
getRegisteredProviderUrl,
|
||||
interceptProviderTargetRegistration,
|
||||
@@ -1219,3 +1219,107 @@ test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
for (const theme of ['light', 'dark']) {
|
||||
test(`@xtream navigation: channel focus and separate scrollbar (${theme})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await addXtreamPortal(page);
|
||||
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
|
||||
await page.evaluate(
|
||||
(dark) => document.body.classList.toggle('dark-theme', dark),
|
||||
theme === 'dark'
|
||||
);
|
||||
const category = page.locator('.context-panel .category-item').first();
|
||||
await category.click();
|
||||
const viewport = page.locator('.scroll-viewport-portals');
|
||||
await expect(viewport).toBeVisible();
|
||||
await category.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(viewport).toBeFocused();
|
||||
await expect(page.locator('app-web-player-view')).toHaveCount(0);
|
||||
await page.keyboard.press('Tab');
|
||||
const firstAction = viewport.locator('button.channel-content').first();
|
||||
await expect(firstAction).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(viewport).toBeFocused();
|
||||
|
||||
const row = viewport.locator('.channel-name').first();
|
||||
await row.click();
|
||||
await expect(viewport).toBeFocused();
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(100);
|
||||
await waitForScrollIdle(viewport);
|
||||
const position = await viewport.evaluate((el) => el.scrollTop);
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(position);
|
||||
await waitForScrollIdle(viewport);
|
||||
await page.keyboard.press('Home');
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el) => el.scrollTop))
|
||||
.toBe(0);
|
||||
await firstAction.focus();
|
||||
await page.keyboard.press('Space');
|
||||
await expect(page.locator('app-web-player-view')).toBeVisible();
|
||||
await expect(firstAction).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(
|
||||
viewport.locator('.favorite-button').first()
|
||||
).toBeFocused();
|
||||
await page.keyboard.press('Space');
|
||||
await expect(
|
||||
viewport.locator('.favorite-button').first()
|
||||
).toBeFocused();
|
||||
await viewport.focus();
|
||||
const overlap = await viewport.evaluate((el) => {
|
||||
const handle = el
|
||||
.closest('.sidebar')!
|
||||
.querySelector('.resize-handle')!;
|
||||
return (
|
||||
el.getBoundingClientRect().right -
|
||||
handle.getBoundingClientRect().left
|
||||
);
|
||||
});
|
||||
expect(overlap).toBeLessThanOrEqual(0);
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(category).toBeFocused();
|
||||
});
|
||||
|
||||
for (const section of ['Movies', 'Series']) {
|
||||
test(`@xtream navigation: detail scroll ${section} (${theme})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1200, height: 540 });
|
||||
await addXtreamPortal(page);
|
||||
await page
|
||||
.getByRole('link', { name: section, exact: true })
|
||||
.click();
|
||||
await page.locator('app-grid-list mat-card').first().click();
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
await expect(shell).toBeVisible();
|
||||
await page.evaluate(
|
||||
(dark) => document.body.classList.toggle('dark-theme', dark),
|
||||
theme === 'dark'
|
||||
);
|
||||
await expect(shell).toBeFocused();
|
||||
await expect
|
||||
.poll(() =>
|
||||
shell.evaluate((el) => el.scrollHeight - el.clientHeight)
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
expect(
|
||||
await shell.evaluate(
|
||||
(el) => getComputedStyle(el).scrollbarWidth
|
||||
)
|
||||
).not.toBe('none');
|
||||
await page.keyboard.press('PageDown');
|
||||
await expect
|
||||
.poll(() => shell.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -261,3 +261,8 @@ textarea,
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.channel-scroll-focus:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
Reference in new issue
Block a user