mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
* fix(ui): visible keyboard focus everywhere via a global focus-visible fallback The global stylesheet removed the outline from every input, button, textarea and `:focus`, so Tab users saw no focus on about 110 raw buttons: detail actions, season tabs, chips, title results, list rows. - Remove the outline only under `:focus:not(:focus-visible)` and draw a fallback ring on every other `:focus-visible` element. Both rules sit in `:where()`, so any component that styles its own focus still wins. - One recipe: `focus-ring-declarations` moves to `libs/ui/styles/_focus-ring.scss` and reads `--app-focus-ring`, declared per theme (light #1d63e0, dark #8cbaff) with at least 3:1 on every app surface and selection tint; `m3-theme.spec.ts` measures it. The card ring, the detail actions, the portal sidebar and both context panels use the mixin (the panels' selection-blue ring fell to 2.97:1 on the active item). - Material Tab stops (buttons, switches, button toggles, checkboxes) take the ring over their 12% focus state layer; menu items and options keep Material's highlight. - Surfaces over video (player controls, vendor chrome, fullscreen panels, Up Next rail) point the ring at the player's text colour. - The selected season tab drew its border with `outline`, which outranks the fallback; it is a spread shadow now. - Guard: `pnpm run styles:focus-visible:validate` (CI) rejects a global `outline: none` on an unscoped selector and a missing fallback. - Electron E2E `keyboard-focus-ring.e2e.ts` tabs through the detail actions and season tabs, a catalog grid with its refinement chips, the Sources list and Settings in both themes: one ring per stop, 3:1 where measurable, none after a click. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ui): keep the player ring in the fullscreen panel; tighten the focus guard and E2E Local review round 1 (Codex P2, Greptile 3×P2): - The fullscreen channel panel carries `dark-theme`, whose context declares the theme ring again, so its own controls ringed in #8cbaff. Point the token back at the player's text colour on `.fullscreen-channel-panel .dark-theme`; the web series-playback E2E checks the close button's ring. - The guard took a container-scoped rule (`.panel :focus-visible`), a mixin body or a media query as the global fallback. The fallback is now the whole selector, outside any at-rule. - The keyboard-focus E2E now fails a ring that an `overflow: hidden` ancestor (up to the scroll container) cuts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ui): show focus where a component removes its outline; catch invisible rings in the guard GitHub review round on #1866 (Codex P2, Greptile P2): - A component rule that sets `outline: none` outranks the zero-specificity fallback. Re-audited every one: two hid a Tab stop with nothing else to show. The EPG list row (`role="button"`) now draws an inset ring, and the command palette underlines its search row while the field has focus. The others already show focus on the field's wrapper, on another element, or as a highlight inside an arrow-key composite (the "…" menu, the guide's search listbox); the guidelines now state the rule. - The guard read only a bare zero or `none` as a removal. It now reads a zero width, a `none`/`hidden` style or a transparent colour anywhere in the shorthand or longhands, so `outline: 0 solid transparent` is reported and `outline: 2px solid transparent` is no fallback. - The keyboard-focus E2E walks the live EPG list and the command palette too, and reads an inset or offset shadow line as a ring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(player): keep --pc-text a literal palette colour Master's palette spec (#1854) reads `--pc-text` from the controls host as a literal; this branch had made it `#{palette.$text}`, failing "sets the timeline label on the dense glass" on the merge ref. The host declares the literal again, and a spec keeps the palette's `$text` (the focus ring token for surfaces beside the host) equal to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(epg): declare the ui-styles dependency of the list row's focus ring The EPG list row now `@use`s `libs/ui/styles/_focus-ring.scss`; Nx cannot infer a Sass import, so `ui-epg` declares `ui-styles` like the other consumers of the shared partials. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(tooling): a styleless outline shorthand hides focus too `outline: 2px` or `outline: red` resets the style to `none`, yet the guard counted either as a visible fallback. A shorthand without a drawn style (or a `var()` that may carry one) now counts as removing the outline, as do `initial` and `unset`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
import { expect, test } from './fixtures';
|
||
import {
|
||
interceptXtreamRequests,
|
||
MOCK_SERVER,
|
||
playFirstSeriesEpisode,
|
||
rewriteSeriesEpisodesToMp4,
|
||
routeEpisodeClip,
|
||
selectWebPlayer,
|
||
} from './xtream-series-playback.fixture';
|
||
|
||
/**
|
||
* Xtream inline series playback in fullscreen: episode switches, the
|
||
* fullscreen episode panel and the vendor-chrome shortcuts. Split out of
|
||
* `xtream.e2e.ts` for size; same mock server and default scenario
|
||
* (3 seasons × 8 episodes per series).
|
||
*
|
||
* Tag: @xtream
|
||
*/
|
||
|
||
test.beforeEach(async ({ page, request }) => {
|
||
await request.post(`${MOCK_SERVER}/reset`);
|
||
await page.goto('/');
|
||
await interceptXtreamRequests(page);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fullscreen survives an episode switch
|
||
//
|
||
// app-web-player-view remounts the engine component for every playback
|
||
// application (next episode, channel, or alternative source). DOM fullscreen
|
||
// used to be owned by that engine's shell, so the Fullscreen API exited the
|
||
// moment the old shell left the document — every "next episode" click and
|
||
// every autoplay hand-off dropped the viewer back to the page. The owner is
|
||
// now the app-web-player-view host, which spans all applications of one mount.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test.describe('@xtream inline series fullscreen', () => {
|
||
// No autoplay-policy flag is needed: the episode click is a user
|
||
// activation and the fixture clip carries no audio track.
|
||
test.skip(
|
||
({ browserName }) => browserName !== 'chromium',
|
||
'DOM fullscreen assertions target Chromium'
|
||
);
|
||
|
||
test('stays in fullscreen across manual and automatic episode switches', async ({
|
||
page,
|
||
request,
|
||
}) => {
|
||
await routeEpisodeClip(page);
|
||
await rewriteSeriesEpisodesToMp4(page);
|
||
|
||
// Persist the HTML5 engine first: Video.js rejects the mock's
|
||
// video/matroska source type before any bytes are sniffed.
|
||
await selectWebPlayer(page, 'HTML5 video player');
|
||
const { playerView, video } = await playFirstSeriesEpisode(
|
||
page,
|
||
request
|
||
);
|
||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
const waitForMetadata = () =>
|
||
expect
|
||
.poll(() =>
|
||
video.evaluate((el) => (el as HTMLVideoElement).readyState)
|
||
)
|
||
.toBeGreaterThanOrEqual(1);
|
||
await waitForMetadata();
|
||
|
||
// The bar auto-hides during playback; a hover reveals it. The
|
||
// overlay title renders only while the controls consider themselves
|
||
// fullscreen, so it doubles as the controls-state assertion.
|
||
// Asserted as "some element is fullscreen" rather than by owner tag,
|
||
// so the test guards the behavior, not the wiring.
|
||
const fullscreenOwner = () =>
|
||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||
const overlayTitle = playerView.locator(
|
||
'[data-test-id="player-controls-media-title"]'
|
||
);
|
||
await playerView.hover();
|
||
await playerView
|
||
.getByRole('button', { name: 'Enter fullscreen' })
|
||
.click();
|
||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||
await expect(overlayTitle).toContainText('S01E01');
|
||
|
||
// Chromium leaves the clicked button focused; the shared controls
|
||
// release that focus so Space reaches the playback shortcut instead
|
||
// of activating the button again (which left fullscreen while the
|
||
// video kept playing).
|
||
const paused = () =>
|
||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||
await expect.poll(paused).toBe(false);
|
||
await page.keyboard.press('Space');
|
||
await expect.poll(paused).toBe(true);
|
||
expect(await fullscreenOwner()).not.toBeNull();
|
||
await page.keyboard.press('Space');
|
||
await expect.poll(paused).toBe(false);
|
||
|
||
// Manual switch from the shared controls' own next-episode button.
|
||
await playerView.hover();
|
||
await playerView
|
||
.locator('[data-test-id="player-controls-next-episode"]')
|
||
.click();
|
||
await expect(overlayTitle).toContainText('S01E02', {
|
||
timeout: 15_000,
|
||
});
|
||
expect(await fullscreenOwner()).not.toBeNull();
|
||
|
||
// Automatic switch: the clip reaching its end autoplays the next
|
||
// episode with no user activation anywhere near it.
|
||
await waitForMetadata();
|
||
await video.evaluate(async (el) => {
|
||
const media = el as HTMLVideoElement;
|
||
media.currentTime = Math.max(0, media.duration - 0.2);
|
||
// Headless autoplay is not guaranteed for a remounted element;
|
||
// the document already carries user activation from the clicks.
|
||
await media.play();
|
||
});
|
||
await expect(overlayTitle).toContainText('S01E03', {
|
||
timeout: 15_000,
|
||
});
|
||
expect(await fullscreenOwner()).not.toBeNull();
|
||
});
|
||
|
||
test('shows the Up next card near the end and plays the next episode from it', async ({
|
||
page,
|
||
request,
|
||
}) => {
|
||
await routeEpisodeClip(page);
|
||
await rewriteSeriesEpisodesToMp4(page);
|
||
await selectWebPlayer(page, 'HTML5 video player');
|
||
const { playerView, video } = await playFirstSeriesEpisode(
|
||
page,
|
||
request
|
||
);
|
||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
await expect
|
||
.poll(() =>
|
||
video.evaluate((el) => (el as HTMLVideoElement).readyState)
|
||
)
|
||
.toBeGreaterThanOrEqual(1);
|
||
|
||
// The 30 s fixture clip is shorter than the card's 40 s minimum
|
||
// lead, so the card is up as soon as the duration is known.
|
||
const card = playerView.locator(
|
||
'[data-test-id="player-controls-up-next"]'
|
||
);
|
||
await expect(card).toBeVisible();
|
||
await expect(card).toContainText('Up next');
|
||
await expect(card).toHaveAttribute('aria-label', /S01E02/);
|
||
|
||
// The settings panel takes the same corner: the card yields to it.
|
||
await playerView.hover();
|
||
await playerView
|
||
.locator('[data-test-id="player-controls-settings-button"]')
|
||
.click();
|
||
await expect(card).toHaveCount(0);
|
||
await page.keyboard.press('Escape');
|
||
await expect(card).toBeVisible();
|
||
|
||
// A click plays the next episode through the host's own path.
|
||
await card.click();
|
||
await expect(
|
||
page.locator('app-portal-inline-player .player-shell__episode-meta')
|
||
).toContainText('S01E02', { timeout: 15_000 });
|
||
await expect(card).toHaveAttribute('aria-label', /S01E03/, {
|
||
timeout: 15_000,
|
||
});
|
||
|
||
// The corner button hides the card for this episode; bringing the
|
||
// controls back does not bring it back.
|
||
await playerView
|
||
.locator('[data-test-id="player-controls-up-next-close"]')
|
||
.click();
|
||
await expect(card).toHaveCount(0);
|
||
await playerView.hover({ position: { x: 40, y: 40 } });
|
||
await expect(
|
||
playerView.locator('[data-test-id="player-controls-next-episode"]')
|
||
).toBeVisible();
|
||
await expect(card).toHaveCount(0);
|
||
});
|
||
|
||
test('switches episodes from the fullscreen episode panel without leaving fullscreen', async ({
|
||
page,
|
||
request,
|
||
}) => {
|
||
await routeEpisodeClip(page);
|
||
await rewriteSeriesEpisodesToMp4(page);
|
||
await selectWebPlayer(page, 'HTML5 video player');
|
||
const { playerView } = await playFirstSeriesEpisode(page, request);
|
||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
|
||
const fullscreenOwner = () =>
|
||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||
const overlayTitle = playerView.locator(
|
||
'[data-test-id="player-controls-media-title"]'
|
||
);
|
||
await playerView.hover();
|
||
await playerView
|
||
.getByRole('button', { name: 'Enter fullscreen' })
|
||
.click();
|
||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||
await expect(overlayTitle).toContainText('S01E01');
|
||
|
||
// C slides in the episode panel: season tabs instead of a search
|
||
// field, the season's eight rows, the playing one marked.
|
||
await page.keyboard.press('c');
|
||
const panel = page.locator('[data-test-id="fullscreen-channel-panel"]');
|
||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||
await expect(panel).toHaveAttribute('data-panel-kind', 'episodes');
|
||
await expect(
|
||
panel.locator('[data-test-id="fullscreen-channel-panel-search"]')
|
||
).toHaveCount(0);
|
||
await expect(panel.locator('.season-tabs__pill')).toHaveCount(3);
|
||
const rows = panel.locator(
|
||
'[data-test-id="fullscreen-episode-panel-episode"]'
|
||
);
|
||
await expect(rows).toHaveCount(8);
|
||
await expect(rows.first()).toHaveAttribute('aria-current', 'true');
|
||
|
||
// Keyboard focus in the panel wears the player's ring, not the
|
||
// theme's: the panel carries `dark-theme` for its tokens, and that
|
||
// context declares the app's ring colour again.
|
||
await page.keyboard.press('Tab');
|
||
expect(
|
||
await page.evaluate(() => {
|
||
const focused = document.activeElement as HTMLElement;
|
||
const style = getComputedStyle(focused);
|
||
return {
|
||
control: focused.dataset['testId'],
|
||
ring: `${style.outlineStyle} ${style.outlineColor}`,
|
||
};
|
||
})
|
||
).toEqual({
|
||
control: 'fullscreen-channel-panel-close',
|
||
ring: 'solid rgb(231, 236, 243)',
|
||
});
|
||
|
||
// Picking an episode plays it inline through the same path as the Up
|
||
// Next rail: fullscreen survives the engine remount, the panel closes.
|
||
await rows.nth(2).click();
|
||
await expect(panel).toHaveAttribute('aria-hidden', 'true');
|
||
await playerView.hover();
|
||
await expect(overlayTitle).toContainText('S01E03', {
|
||
timeout: 15_000,
|
||
});
|
||
expect(await fullscreenOwner()).not.toBeNull();
|
||
|
||
// Reopened, the panel marks the new episode; another season's tab
|
||
// lists that season, offers the way back, and plays from there too.
|
||
await page.keyboard.press('c');
|
||
await expect(panel).toHaveAttribute('aria-hidden', 'false');
|
||
await expect(rows.nth(2)).toHaveAttribute('aria-current', 'true');
|
||
await panel.locator('.season-tabs__pill').nth(1).click();
|
||
await expect(rows).toHaveCount(8);
|
||
await expect(panel.locator('[aria-current="true"]')).toHaveCount(0);
|
||
await expect(
|
||
panel.locator('[data-testid="back-to-playing"]')
|
||
).toBeVisible();
|
||
await rows.first().click();
|
||
await playerView.hover();
|
||
await expect(overlayTitle).toContainText('S02E01', {
|
||
timeout: 15_000,
|
||
});
|
||
expect(await fullscreenOwner()).not.toBeNull();
|
||
});
|
||
});
|
||
|
||
test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
|
||
test.skip(
|
||
({ browserName }) => browserName !== 'chromium',
|
||
'DOM fullscreen assertions target Chromium'
|
||
);
|
||
|
||
test('keep working after a mouse click on a control-bar button', async ({
|
||
page,
|
||
request,
|
||
}) => {
|
||
await routeEpisodeClip(page);
|
||
await rewriteSeriesEpisodesToMp4(page);
|
||
// Video.js with the shared controls opted out: the vendor control bar.
|
||
await selectWebPlayer(page, 'Video.js player', false);
|
||
const { playerView, video } = await playFirstSeriesEpisode(
|
||
page,
|
||
request
|
||
);
|
||
await expect(playerView.locator('app-vjs-player')).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
await expect(playerView.locator('app-player-controls')).toHaveCount(0);
|
||
const paused = () =>
|
||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||
const fullscreenOwner = () =>
|
||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||
await expect.poll(paused).toBe(false);
|
||
|
||
// Chromium leaves the clicked Video.js button focused, and a focused
|
||
// Video.js component swallows every key; the legacy chrome releases
|
||
// that focus so Space and M reach the playback shortcuts instead of
|
||
// Space activating the button again (which left fullscreen while the
|
||
// video kept playing).
|
||
await playerView.hover();
|
||
await playerView.locator('.vjs-fullscreen-control').click();
|
||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||
await page.keyboard.press('Space');
|
||
await expect.poll(paused).toBe(true);
|
||
expect(await fullscreenOwner()).not.toBeNull();
|
||
await page.keyboard.press('Space');
|
||
await expect.poll(paused).toBe(false);
|
||
await page.keyboard.press('m');
|
||
await expect
|
||
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).muted))
|
||
.toBe(true);
|
||
});
|
||
});
|