From 6f9c2b8de3df1260002e3e21bfe6149c8d663ac4 Mon Sep 17 00:00:00 2001 From: Marin <182462619+marinionroids@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:19:24 +0000 Subject: [PATCH 1/4] fix(details): keep the page focus ring off after pointer focus Clicking into a detail page focuses its scroll region (the shell host, tabindex 0). Chromium promotes that pointer focus to :focus-visible on the next keypress, so arrow-key seeking during inline playback outlined the whole page in windowed mode. Record whether focus arrived by pointer and suppress the ring for that focus; keyboard (Tab) focus keeps it. A window blur/refocus keeps the verdict, since the shell stays the active element across it. Closes #2170. Co-Authored-By: Claude Opus 5.5 --- .changes/details-player-focus-ring.md | 7 ++++ .../portal-detail-shell.component.scss | 6 +++ .../portal-detail-shell.component.spec.ts | 38 +++++++++++++++++++ .../portal-detail-shell.component.ts | 29 ++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 .changes/details-player-focus-ring.md diff --git a/.changes/details-player-focus-ring.md b/.changes/details-player-focus-ring.md new file mode 100644 index 0000000000..fb8d2293be --- /dev/null +++ b/.changes/details-player-focus-ring.md @@ -0,0 +1,7 @@ +--- +type: fix +area: details +issues: [2170] +--- + +Seeking with the arrow keys on a movie or series page no longer draws a bright outline around the whole page after you click into it. Tabbing to the page still shows the keyboard focus ring. diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss index ba751584a5..99ef9c218e 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss @@ -40,6 +40,12 @@ &:focus-visible { @include focus-ring.focus-ring-declarations($offset: -2px); } + + // Clicked into, not tabbed to: no ring when a later key (arrow-key + // seeking) promotes the focus to :focus-visible. + &.shell-host--pointer-focus:focus-visible { + outline: none; + } } :host-context(.dark-theme) { diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts index 3b540b449d..4ef1eb818c 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts @@ -103,6 +103,44 @@ describe('PortalDetailShellComponent', () => { expect(document.activeElement).not.toBe(shell); }); + it('keeps a pointer-focused shell ringless after later keypresses (#2170)', () => { + const shell = requiredQuery('app-portal-detail-shell'); + let focusVisible = false; + jest.spyOn(shell, 'matches').mockImplementation((selector: string) => + selector === ':focus-visible' ? focusVisible : false + ); + shell.blur(); + // Mouse focus: Chromium does not match :focus-visible yet. + shell.dispatchEvent(new FocusEvent('focus')); + fixture.detectChanges(); + expect(shell.classList).toContain('shell-host--pointer-focus'); + // Keyboard focus (Tab) keeps the ring. + focusVisible = true; + shell.dispatchEvent(new FocusEvent('focus')); + fixture.detectChanges(); + expect(shell.classList).not.toContain('shell-host--pointer-focus'); + }); + + it('keeps the pointer verdict when the window loses and regains focus', () => { + const shell = requiredQuery('app-portal-detail-shell'); + let focusVisible = false; + jest.spyOn(shell, 'matches').mockImplementation((selector: string) => + selector === ':focus-visible' ? focusVisible : false + ); + shell.focus(); + shell.dispatchEvent(new FocusEvent('focus')); + fixture.detectChanges(); + expect(shell.classList).toContain('shell-host--pointer-focus'); + // Window blur: the shell stays the document's active element, and + // the key pressed before it has promoted the focus to :focus-visible. + expect(document.activeElement).toBe(shell); + shell.dispatchEvent(new FocusEvent('blur')); + focusVisible = true; + shell.dispatchEvent(new FocusEvent('focus')); + fixture.detectChanges(); + expect(shell.classList).toContain('shell-host--pointer-focus'); + }); + it('keeps native scrolling on the shell without forwarding keys to the player', () => { const shell = requiredQuery('app-portal-detail-shell'); const globalKey = jest.fn(); diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts index 4b368c6090..f635491a1f 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts @@ -10,6 +10,7 @@ import { inject, input, output, + signal, ChangeDetectionStrategy, } from '@angular/core'; import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access'; @@ -53,6 +54,9 @@ import { // on body, while preserving already-handled events and overlay guards. '(keydown.escape)': 'onEscape($event)', '[class.shell-host--watch]': 'isWatch()', + '[class.shell-host--pointer-focus]': 'pointerFocus()', + '(focus)': 'onFocus()', + '(blur)': 'onBlur()', '(document:keydown.escape)': 'onEscape($event)', }, }) @@ -93,6 +97,16 @@ export class PortalDetailShellComponent { readonly isWatch = computed(() => this.playbackActive()); + /** + * Whether the shell's current focus came from a pointer. Chromium only + * withholds `:focus-visible` from a clicked element until the next + * keypress, so arrow-key seeking would ring the whole page (#2170). The + * verdict is frozen when focus arrives; Tab focus still draws the ring. + */ + protected readonly pointerFocus = signal(false); + /** The window, not the shell, lost focus: its return keeps the verdict. */ + private windowBlurred = false; + constructor() { registerWorkspaceBack({ available: this.backAvailable, @@ -122,6 +136,21 @@ export class PortalDetailShellComponent { }); } + onFocus(): void { + if (this.windowBlurred) { + this.windowBlurred = false; + return; + } + this.pointerFocus.set( + !this.host.nativeElement.matches(':focus-visible') + ); + } + + onBlur(): void { + const element = this.host.nativeElement; + this.windowBlurred = element.ownerDocument.activeElement === element; + } + onScrollKey(event: KeyboardEvent): void { if ( event.target === this.host.nativeElement && From 12449710a5564a82db0692e5e7471ffef584963e Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 11 Nov 2026 20:57:24 +0100 Subject: [PATCH 2/4] fix(details): clear the outline when clicking the focused page --- .changes/details-player-focus-ring.md | 2 +- .../src/keyboard-focus-ring.e2e.ts | 80 ++++++++++++++++++- docs/architecture/iptvnator-ui-guidelines.md | 3 + .../portal-detail-shell.component.ts | 18 ++++- 4 files changed, 97 insertions(+), 6 deletions(-) diff --git a/.changes/details-player-focus-ring.md b/.changes/details-player-focus-ring.md index fb8d2293be..2691579b0a 100644 --- a/.changes/details-player-focus-ring.md +++ b/.changes/details-player-focus-ring.md @@ -4,4 +4,4 @@ area: details issues: [2170] --- -Seeking with the arrow keys on a movie or series page no longer draws a bright outline around the whole page after you click into it. Tabbing to the page still shows the keyboard focus ring. +Clicking into a movie or series page hides the page outline while you seek with the arrow keys, even if you previously tabbed to the page. Tabbing to the page still shows the keyboard focus ring. diff --git a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts index e9e844b52f..a2b35b67f5 100644 --- a/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts +++ b/apps/electron-backend-e2e/src/keyboard-focus-ring.e2e.ts @@ -1,7 +1,9 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test'; import { + addStalkerPortal, addXtreamPortal, channelItemByTitle, + clickCategoryById, clickCategoryByNameExact, clickFirstGridListCard, closeElectronApp, @@ -14,9 +16,18 @@ import { resetMockServers, saveSettings, test, + waitForStalkerCatalog, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; -import { fetchXtreamLiveFixture, getXtreamTitle } from './portal-mock-fixtures'; +import { + routePlayableStreams, + startAndConfirmPlayback, +} from './playable-stream-fixture'; +import { + fetchStalkerCategoryFixture, + fetchXtreamLiveFixture, + getXtreamTitle, +} from './portal-mock-fixtures'; import { applyTheme } from './theme-contrast'; // Fictional metadata and local artwork keep the attached focus report synthetic. @@ -478,6 +489,73 @@ async function openCommandPalette(page: Page): Promise { } test.describe('Keyboard focus ring', () => { + test('@electron @stalker seeking keeps the detail shell ring off after a click', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['stalker']); + const series = await fetchStalkerCategoryFixture(request, 'series'); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + try { + await routePlayableStreams(page); + await openSettings(page); + await openSettingsSection(page, 'playback'); + await page.getByTestId('select-video-player').click(); + await page + .getByRole('option', { + name: 'HTML5 video player', + exact: true, + }) + .click(); + await saveSettings(page); + await page.keyboard.press('Escape'); + await addStalkerPortal(page); + await waitForStalkerCatalog(page); + await openWorkspaceSection(page, 'Series'); + await clickCategoryById(page, series.categoryId); + await clickFirstGridListCard(page); + + const shell = page.locator('app-portal-detail-shell'); + const episode = shell + .locator('.episode-card, .episode-list-item') + .first(); + await startAndConfirmPlayback(page, () => episode.click(), 0.25); + const video = shell.locator('app-html-video-player video'); + await expect(video).toBeVisible(); + await video.evaluate((element: HTMLVideoElement) => + element.pause() + ); + const time = () => + video.evaluate( + (element: HTMLVideoElement) => element.currentTime + ); + const clickShell = () => shell.click({ position: { x: 8, y: 28 } }); + + await clickShell(); + await expect(shell).toBeFocused(); + const beforeSeek = await time(); + await page.keyboard.press('ArrowRight'); + await expect.poll(time).toBeGreaterThan(beforeSeek + 4); + await expect(shell).toHaveCSS('outline-style', 'none'); + + await page.keyboard.press('Tab'); + await expect(shell).not.toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(shell).toBeFocused(); + await expect(shell).toHaveCSS('outline-style', 'solid'); + + await clickShell(); + await expect(shell).toBeFocused(); + await expect(shell).toHaveCSS('outline-style', 'none'); + await page.keyboard.press('ArrowLeft'); + await expect.poll(time).toBeLessThan(beforeSeek + 1); + await expect(shell).toHaveCSS('outline-style', 'none'); + } finally { + await closeElectronApp(app); + } + }); + test('@electron @theme every Tab stop shows one visible ring in both themes, a click none', async ({ dataDir, request, diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index fb9abf997f..cd8d9b162b 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -553,6 +553,9 @@ host by its padding and border on the first move. themes. Each stop must draw exactly one ring in the token's colour, uncut by an ancestor's `overflow: hidden`, at 3:1 where it sits on a flat colour, and a click must draw none. + It also checks that the detail page stays ringless when seeking after a + click, including a click on the already focused page. Tabbing back to the + page shows its focus ring. Catalog result counts use an atomic `status` region so search/filter changes are announced without moving focus from the search field. Opening a catalog diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts index f635491a1f..72c3421738 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts @@ -56,6 +56,7 @@ import { '[class.shell-host--watch]': 'isWatch()', '[class.shell-host--pointer-focus]': 'pointerFocus()', '(focus)': 'onFocus()', + '(pointerdown)': 'onPointerDown($event)', '(blur)': 'onBlur()', '(document:keydown.escape)': 'onEscape($event)', }, @@ -98,10 +99,8 @@ export class PortalDetailShellComponent { readonly isWatch = computed(() => this.playbackActive()); /** - * Whether the shell's current focus came from a pointer. Chromium only - * withholds `:focus-visible` from a clicked element until the next - * keypress, so arrow-key seeking would ring the whole page (#2170). The - * verdict is frozen when focus arrives; Tab focus still draws the ring. + * Chromium can match `:focus-visible` after a seek keypress on a clicked + * shell. Keep that outline hidden until focus arrives again (#2170). */ protected readonly pointerFocus = signal(false); /** The window, not the shell, lost focus: its return keeps the verdict. */ @@ -146,6 +145,17 @@ export class PortalDetailShellComponent { ); } + onPointerDown(event: PointerEvent): void { + const element = this.host.nativeElement; + // Clicking the already focused shell does not fire another focus event. + if ( + event.button === 0 && + element.ownerDocument.activeElement === element + ) { + this.pointerFocus.set(true); + } + } + onBlur(): void { const element = this.host.nativeElement; this.windowBlurred = element.ownerDocument.activeElement === element; From f2cc494a7c5b197bb4e02d292d20c1e00a1e9493 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 11 Nov 2026 22:08:00 +0100 Subject: [PATCH 3/4] test(details): cover pointer focus on the active page --- .../portal-detail-shell.component.spec.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts index 4ef1eb818c..a91f790ccb 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts @@ -121,6 +121,29 @@ describe('PortalDetailShellComponent', () => { expect(shell.classList).not.toContain('shell-host--pointer-focus'); }); + it('switches an already keyboard-focused shell to pointer focus on a primary press', () => { + const shell = requiredQuery('app-portal-detail-shell'); + jest.spyOn(shell, 'matches').mockImplementation( + (selector) => selector === ':focus-visible' + ); + const pressPointer = (button: number) => { + shell.dispatchEvent(new MouseEvent('pointerdown', { button })); + fixture.detectChanges(); + }; + + requiredQuery('.play-btn').focus(); + pressPointer(0); + expect(shell.classList).not.toContain('shell-host--pointer-focus'); + + shell.focus(); + pressPointer(2); + expect(shell.classList).not.toContain('shell-host--pointer-focus'); + + pressPointer(0); + expect(document.activeElement).toBe(shell); + expect(shell.classList).toContain('shell-host--pointer-focus'); + }); + it('keeps the pointer verdict when the window loses and regains focus', () => { const shell = requiredQuery('app-portal-detail-shell'); let focusVisible = false; From 35808525f168f0413dfc2699f50c4a1c0584c68c Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 11 Nov 2026 23:02:26 +0100 Subject: [PATCH 4/4] fix(build): keep clipboard fallback out of renderer startup Expose the clipboard helper through a dedicated services entry point so only the lazy Xtream details and download actions import it. On the PR merge tree, renderer.initialBytes drops from 1,200,590 to 1,200,062 bytes against the unchanged 1,200,301-byte limit. --- docs/architecture/nx-workspace-boundaries.md | 2 ++ .../feature/src/lib/download-manager-actions.service.ts | 2 +- .../src/lib/serial-details/serial-details-menu.service.ts | 2 +- .../feature/src/lib/vod-details/vod-details-menu.service.ts | 2 +- libs/services/src/index.ts | 1 - tsconfig.base.json | 3 +++ 6 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index ba5a485a8f..0b829ec642 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -329,6 +329,8 @@ context panel use, out of that barrel for the same reason, and `@iptvnator/services/playlist-backup` keeps `PlaylistBackupService` (about 22 KB, used only by the lazy settings page) out of the `@iptvnator/services` barrel. +`@iptvnator/services/clipboard-copy` keeps the clipboard fallback with +the lazy Xtream details and download actions. `@iptvnator/services/xtream-catalog-refresh` keeps the edit-time catalog scanner with the lazy source editor. Do not re-export it from the eager services barrel; diff --git a/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts index 2b841d9e1d..81d3787385 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts @@ -2,10 +2,10 @@ import { Injectable, inject, signal } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; import { - copyTextToClipboard, type DownloadItem, DownloadsService, } from '@iptvnator/services'; +import { copyTextToClipboard } from '@iptvnator/services/clipboard-copy'; import { DialogService } from '@iptvnator/ui/components'; import type { DownloadActionResult, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts index 0f6ac8df1a..0e5228c269 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts @@ -13,10 +13,10 @@ import { XtreamStore, } from '@iptvnator/portal/xtream/data-access'; import { - copyTextToClipboard, RuntimeCapabilitiesService, SettingsStore, } from '@iptvnator/services'; +import { copyTextToClipboard } from '@iptvnator/services/clipboard-copy'; import { captureHistoryOwner } from '@iptvnator/services/history-events'; import { sourceConnection } from '@iptvnator/services/source-sync/connection'; import { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts index 3b41ae6df0..654af76ae1 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-menu.service.ts @@ -8,10 +8,10 @@ import { } from '@iptvnator/portal/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { - copyTextToClipboard, RuntimeCapabilitiesService, SettingsStore, } from '@iptvnator/services'; +import { copyTextToClipboard } from '@iptvnator/services/clipboard-copy'; import { VideoPlayer, type ExternalPlayerName, diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 922c7cdb67..8316859eb2 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -1,5 +1,4 @@ export * from './lib/catalog-title-match.service'; -export * from './lib/clipboard-copy.util'; export * from './lib/cross-portal-similar.service'; export * from './lib/data.service'; export * from './lib/host-connectivity-reset'; diff --git a/tsconfig.base.json b/tsconfig.base.json index 1ee77057ec..a1c5af4207 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -171,6 +171,9 @@ "libs/shared/host-health/src/index.ts" ], "@iptvnator/services": ["libs/services/src/index.ts"], + "@iptvnator/services/clipboard-copy": [ + "libs/services/src/lib/clipboard-copy.util.ts" + ], "@iptvnator/services/tmdb-match": [ "libs/services/src/lib/tmdb/tmdb-match.service.ts" ],