From 444ec06e9439805b85e696a057e568076c09dbb1 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:51:17 +0200 Subject: [PATCH 1/5] fix(detail): keep the Back button from covering detail content (#1763) --- .changes/detail-back-button-lane.md | 6 + .../src/detail-back-lane.e2e.ts | 336 ++++++++++++++++++ apps/web-e2e/src/m3u-movie-details.e2e.ts | 7 + docs/architecture/iptvnator-ui-guidelines.md | 7 + docs/architecture/portal-detail-navigation.md | 16 + .../content-hero/content-hero.component.scss | 4 + .../content-about.component.scss | 3 + .../portal-detail-shell.component.scss | 48 ++- .../portal-detail-shell.component.spec.ts | 64 ++++ .../portal-detail-shell.component.ts | 33 ++ .../season-header.component.scss | 11 +- .../portal-inline-player.component.scss | 3 + 12 files changed, 531 insertions(+), 7 deletions(-) create mode 100644 .changes/detail-back-button-lane.md create mode 100644 apps/electron-backend-e2e/src/detail-back-lane.e2e.ts diff --git a/.changes/detail-back-button-lane.md b/.changes/detail-back-button-lane.md new file mode 100644 index 000000000..21a1e6da9 --- /dev/null +++ b/.changes/detail-back-button-lane.md @@ -0,0 +1,6 @@ +--- +type: fix +area: detail +--- + +On movie and series pages, the Back button no longer covers the start of the "Seasons and Episodes" heading or the corner of the player while you scroll, and in narrow windows the heading no longer gets squeezed onto several lines beside its buttons. diff --git a/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts b/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts new file mode 100644 index 000000000..607303622 --- /dev/null +++ b/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts @@ -0,0 +1,336 @@ +import type { Locator, Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + addXtreamPortal, + clickFirstGridListCard, + closeElectronApp, + expect, + launchElectronApp, + openSettings, + resetMockServers, + saveSettings, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +// --------------------------------------------------------------------------- +// The detail shell's sticky Back control floats over its scroll owner. While +// it is shown, every content column reserves the control's lane, so no +// scroll position may put it over the "Seasons and Episodes" heading or the +// inline player's top-left corner. Tested at a wide and a narrow window; the +// workspace rail and category panel leave the detail pane far narrower than +// either, and the heading used to wrap beside its actions (two lines at +// 1280px, three at 780px) with its first word under the arrow. +// +// A pane under 400px cannot spare the lane: a 700px window beside the +// category panel, or a phone. There the control sits in an opaque sticky bar, +// and whatever reaches the arrow's band must be hidden beneath the bar, never +// visible beside the arrow. +// --------------------------------------------------------------------------- + +const widths = [1280, 780]; +const compactWidths = [700, 375]; +const playerCorner = 56; +/** The widest translation of the heading; it must fit wherever English does. */ +const widestLocale = 'nl'; +const widestHeading = ( + JSON.parse( + readFileSync( + join(__dirname, `../../web/src/assets/i18n/${widestLocale}.json`), + 'utf8' + ) + ) as { PORTALS: { SEASONS_AND_EPISODES: string } } +).PORTALS.SEASONS_AND_EPISODES; + +type Sweep = { + overlaps: string[]; + /** Scroll positions where the target shared the arrow's vertical band. */ + beside: { player: number; title: number }; +}; + +/** + * Scrolls the shell from top to bottom in steps shorter than the arrow and + * records every position where the arrow's box intersects the heading or the + * player's top-left corner. Reads layout synchronously after each scroll + * write, so a hidden or throttled window cannot skip frames. + */ +function sweepDetail(shell: Locator): Promise { + return shell.evaluate((element, corner) => { + const back = element.querySelector('.shell__back-button'); + if (!back) throw new Error('The detail shows no Back control.'); + const sweep = { + overlaps: [] as string[], + beside: { player: 0, title: 0 }, + }; + const max = element.scrollHeight - element.clientHeight; + for (let top = 0; ; top = Math.min(max, top + 24)) { + element.scrollTop = top; + const arrow = back.getBoundingClientRect(); + const title = element + .querySelector('.section-title') + ?.getBoundingClientRect(); + const player = element + .querySelector('app-portal-inline-player') + ?.getBoundingClientRect(); + const targets = { + title, + player: player && { + left: player.left, + top: player.top, + right: player.left + corner, + bottom: player.top + corner, + }, + }; + for (const [name, box] of Object.entries(targets)) { + if (!box) continue; + const sameBand = + box.top < arrow.bottom && arrow.top < box.bottom; + if (!sameBand) continue; + sweep.beside[name as keyof Sweep['beside']] += 1; + if (box.left < arrow.right && arrow.left < box.right) { + sweep.overlaps.push(`${name} at scrollTop ${top}`); + } + } + if (top >= max) break; + } + element.scrollTop = 0; + return sweep; + }, playerCorner); +} + +type BarSweep = { + background: string; + /** Scroll positions where a target reached the bar's band. */ + beneath: number; + /** Points of a target in the bar's band that hit content, not the bar. */ + exposed: string[]; +}; + +/** + * Compact counterpart of {@link sweepDetail}: the bar sits in flow and + * content scrolls beneath it, so boxes do intersect. Instead, every sampled + * point of the heading or the player's corner that is inside the bar's band + * must hit-test to the bar: hidden beneath it, and unreachable by a tap. + */ +function sweepBar(shell: Locator): Promise { + return shell.evaluate((element, corner) => { + const bar = element.querySelector('.shell__navigation'); + if (!bar) throw new Error('The detail shows no Back control.'); + const sweep = { + background: getComputedStyle(bar).backgroundColor, + beneath: 0, + exposed: [] as string[], + }; + const max = element.scrollHeight - element.clientHeight; + for (let top = 0; ; top = Math.min(max, top + 24)) { + element.scrollTop = top; + const band = bar.getBoundingClientRect(); + const title = element + .querySelector('.section-title') + ?.getBoundingClientRect(); + const player = element + .querySelector('app-portal-inline-player') + ?.getBoundingClientRect(); + const targets = { + title, + player: player && { + left: player.left, + top: player.top, + right: player.left + corner, + bottom: player.top + corner, + }, + }; + for (const [name, box] of Object.entries(targets)) { + if (!box) continue; + const from = Math.max(box.top, band.top); + const to = Math.min(box.bottom, band.bottom); + if (to <= from) continue; + sweep.beneath += 1; + const y = (from + to) / 2; + for (const x of [box.left + 2, box.right - 2]) { + const hit = element.ownerDocument.elementFromPoint(x, y); + if (!hit || !bar.contains(hit)) { + sweep.exposed.push( + `${name} at scrollTop ${top} hits ${hit?.tagName}.${String(hit?.className)}` + ); + } + } + } + if (top >= max) break; + } + element.scrollTop = 0; + return sweep; + }, playerCorner); +} + +/** Line boxes of the heading's text; 1 means it did not wrap. */ +function headingLineCount(page: Page): Promise { + return page.locator('.section-title').evaluate((heading) => { + const range = document.createRange(); + range.selectNodeContents(heading); + const lines = [...range.getClientRects()] + .filter((rect) => rect.width > 0) + .map((rect) => Math.round(rect.top)); + return new Set(lines).size; + }); +} + +/** + * Lets the browse↔watch morph, the player's fade-in and the workspace's own + * transitions settle — crossing into the phone layout slides the category + * drawer out over the page for 200ms. Bounded, so a paused animation + * elsewhere cannot stall the test. + */ +async function settle(shell: Locator): Promise { + await shell.evaluate((element) => + Promise.race([ + Promise.all( + element.ownerDocument + .getAnimations() + .filter( + (animation) => + animation.effect?.getTiming().iterations !== + Infinity + ) + .map((animation) => + animation.finished.catch(() => undefined) + ) + ), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]) + ); +} + +async function expectBackClearOfContent( + page: Page, + state: 'browse' | 'watch' +): Promise { + const shell = page.locator('app-portal-detail-shell'); + for (const width of widths) { + await page.setViewportSize({ width, height: 800 }); + // Fail on the mode first: a lane assertion against the bar would + // only report confusing intersections. + await expect(shell, `${state} at ${width}px`).not.toHaveClass( + /shell-host--compact/ + ); + await settle(shell); + const sweep = await sweepDetail(shell); + + expect(sweep.overlaps, `${state} at ${width}px`).toEqual([]); + // The sweep has to carry the heading (and the player) past the arrow, + // or the empty overlap list proves nothing. + expect(sweep.beside.title, `${state} at ${width}px`).toBeGreaterThan(0); + if (state === 'watch') { + expect( + sweep.beside.player, + `${state} at ${width}px` + ).toBeGreaterThan(0); + } + expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1); + } + + for (const width of compactWidths) { + await page.setViewportSize({ width, height: 800 }); + await expect(shell, `${state} at ${width}px`).toHaveClass( + /shell-host--compact/ + ); + await settle(shell); + const sweep = await sweepBar(shell); + + expect(sweep.exposed, `${state} at ${width}px`).toEqual([]); + expect(sweep.beneath, `${state} at ${width}px`).toBeGreaterThan(0); + // Opaque, or what scrolls beneath would show through. + expect(sweep.background, `${state} at ${width}px`).toMatch(/^rgb\(/); + expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1); + } +} + +/** + * Re-checks the heading in the widest translation at the lane and bar widths. + * Below them (a ~220px header beside the category panel) a translation wider + * than the pane itself wraps by design rather than losing words to an + * ellipsis. + */ +async function expectWidestHeadingOnOneLine( + page: Page, + detailUrl: string +): Promise { + await page.setViewportSize({ width: widths[0], height: 800 }); + await openSettings(page); + await page.getByTestId('select-language').click(); + await page.getByTestId(widestLocale).click(); + await saveSettings(page); + await page.goBack(); + await page.waitForURL(detailUrl); + await expect(page.locator('.section-title')).toHaveText(widestHeading, { + timeout: 20_000, + }); + for (const width of [...widths, ...compactWidths]) { + await page.setViewportSize({ width, height: 800 }); + expect( + await headingLineCount(page), + `${widestLocale} at ${width}px` + ).toBe(1); + } +} + +/** + * The actions move onto their own row before the heading wraps, at every + * pane width that can hold the heading at all — including the widths where + * the lane gives way to the bar. + */ +async function expectHeadingOnOneLine(page: Page): Promise { + const wrapped: number[] = []; + for (let width = 680; width <= 1600; width += 20) { + await page.setViewportSize({ width, height: 800 }); + if ((await headingLineCount(page)) !== 1) wrapped.push(width); + } + expect(wrapped).toEqual([]); +} + +test.describe('Portal detail Back lane', () => { + test('@xtream @electron keeps the Back arrow off the heading and the player while scrolling', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await page + .getByRole('link', { name: 'Series', exact: true }) + .click(); + await clickFirstGridListCard(page); + await page.waitForURL( + /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/ + ); + + const detailUrl = page.url(); + const shell = page.locator('app-portal-detail-shell'); + const episodes = page.locator('.episode-card'); + await expect(page.locator('.section-title')).toBeVisible({ + timeout: 20_000, + }); + await expect(episodes.first()).toBeVisible({ timeout: 20_000 }); + + await expectBackClearOfContent(page, 'browse'); + await expectHeadingOnOneLine(page); + + await page.setViewportSize({ width: widths[0], height: 800 }); + await episodes.first().click(); + await expect(shell).toHaveClass(/shell-host--watch/); + await expect( + shell.locator('app-portal-inline-player app-web-player-view') + ).toBeVisible({ timeout: 20_000 }); + + await expectBackClearOfContent(page, 'watch'); + await expectWidestHeadingOnOneLine(page, detailUrl); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index b14be957d..7c45b82ed 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -416,6 +416,13 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({ .click(); await expect(inlineVideo(page)).toHaveCount(0); await expect(shell.locator('.shell__back-button')).toHaveCount(0); + // Without the arrow there is no lane to reserve: the hero keeps its own + // inset instead of the 72px column portal details give the Back control. + expect( + await shell + .locator('.hero__content') + .evaluate((el) => getComputedStyle(el).paddingInlineStart) + ).toBe('40px'); await shell.focus(); await page.keyboard.press('Escape'); await expect(playButton).toBeVisible(); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 0305755da..c1b0b53a3 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -627,6 +627,13 @@ and `--app-selection-color`; hover uses the app's neutral surface treatment. Keep these treatments in the shared season components and detail-action partial so Xtream and Stalker share the same behavior. +The season header's actions wrap onto their own row, starting under the +"Seasons and Episodes" heading, before the heading itself would wrap. The +detail pane is narrower than the window (context panel, the sticky Back lane), +so the header's own width decides, not a viewport breakpoint. A translation +wider than the pane itself wraps rather than ellipsizing: unlike a fixed-height +panel title, a content heading has room to wrap and should not lose words. + Browser regression coverage measures the composited neutral edges and selected toggle fill, in addition to capturing light/dark grid and list screenshots. Hero action edges use matching pixels from rendered screenshots with the border diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index 1bc555910..8cc2da1d7 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -30,6 +30,22 @@ The button has an opaque app-themed surface, visible keyboard focus, an Escape shortcut hint via native `title` and Electron `no-drag` hit testing. The hint does not create an overlay that could consume the first Escape press. +The control floats, so the content makes room for it: while it is shown, every +content column (hero content, watch-state player, episodes, About and extras) +starts after a 72 px lane — 16 px inset, 40 px button, 16 px gap — and no scroll +position puts the arrow over a heading or the player. The shell derives the lane +from the button's geometry and publishes it as `--detail-back-lane`, which +`content-hero` and `content-about` read too; hosts with `backAvailable=false` +keep their own inset. The lane is horizontal because watch state is short of +height, not width. A pane narrower than 400 px is the exception — a phone, or a +desktop pane beside the context panel — because a 72 px column there would clip +the player's controls. The pane decides, not the viewport: a `ResizeObserver` +on the shell's border box (scrollbar-independent) sets `shell-host--compact`, +and the wrapper becomes an in-flow 56 px sticky bar on `--app-header-bg` that +content scrolls beneath and that takes the taps over it. Electron E2E +(`detail-back-lane.e2e.ts`) sweeps both states with the lane at 1280 and +780 px and with the bar at 700 and 375 px. + The sticky control is route-level Back in both states: it emits `backClicked` whether or not inline playback is active, so the arrow keeps one meaning and the list is one click away while watching. Only Escape unwinds one level: watch diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index 4525cc4f4..ffbb78355 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -144,6 +144,8 @@ gap: 32px; align-items: flex-start; padding: 140px 40px 60px; + // Reserved by the detail shell while its sticky Back control shows. + padding-inline-start: max(40px, var(--detail-back-lane, 0px)); max-width: var(--content-max-width); margin: 0 auto; box-sizing: border-box; @@ -511,6 +513,7 @@ &__content { padding: 60px 20px 40px; + padding-inline-start: max(20px, var(--detail-back-lane, 0px)); gap: 16px; } } @@ -552,6 +555,7 @@ flex-direction: column; align-items: stretch; padding-inline: 16px; + padding-inline-start: max(16px, var(--detail-back-lane, 0px)); } .poster { diff --git a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss index 070f9d06f..ba11eeaa3 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss +++ b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.scss @@ -6,6 +6,8 @@ max-width: var(--content-max-width, 1400px); margin: 0 auto; padding: 24px 40px 40px; + // The detail shell's sticky Back lane (0 when there is no Back). + padding-inline-start: max(40px, var(--detail-back-lane, 0px)); border-top: 1px solid var(--tag-border, rgba(255, 255, 255, 0.12)); &__heading { @@ -127,6 +129,7 @@ @media (max-width: 800px) { .about { padding: 20px 20px 32px; + padding-inline-start: max(20px, var(--detail-back-lane, 0px)); &__poster { width: 92px; 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 e089e4769..c60c3ada5 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 @@ -13,6 +13,13 @@ --tag-border: rgba(0, 0, 0, 0.12); --poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15); --content-max-width: 1400px; + // The sticky Back control floats over the scroll owner. While it is shown, + // every content column (hero, player, episodes, About, extras) starts + // after its lane — inset + button + the same inset again — so nothing + // scrolls beneath it. content-hero and content-about read the lane too. + --shell-back-inset: 16px; + --shell-back-size: 40px; + --detail-back-lane: 0px; display: block; height: 100%; @@ -30,6 +37,12 @@ } } +:host(.shell-host--back) { + --detail-back-lane: calc( + 2 * var(--shell-back-inset) + var(--shell-back-size) + ); +} + :host-context(.dark-theme) { --surface-bg: #141414; --text-primary: #fff; @@ -43,7 +56,7 @@ .shell__navigation { // Direct child of the scroll owner: sticky lasts through the entire page. position: sticky; - top: 16px; + top: var(--shell-back-inset); height: 0; z-index: 10; pointer-events: none; @@ -51,15 +64,15 @@ .shell__back-button { position: absolute; - left: 16px; + inset-inline-start: var(--shell-back-inset); pointer-events: auto; app-region: no-drag; -webkit-app-region: no-drag; display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; + width: var(--shell-back-size); + height: var(--shell-back-size); border-radius: 50%; background: var(--app-widget-bg, var(--surface-bg)); color: var(--app-on-surface, var(--text-primary)); @@ -122,6 +135,7 @@ &--active { padding: 16px 40px 0; + padding-inline-start: max(40px, var(--detail-back-lane)); animation: shell-fade-in 300ms ease 50ms both; } } @@ -130,6 +144,7 @@ max-width: var(--content-max-width); margin: 0 auto; padding: 0 40px; + padding-inline-start: max(40px, var(--detail-back-lane)); } .shell__about { @@ -143,6 +158,7 @@ max-width: var(--content-max-width); margin: 0 auto; padding: 0 40px 40px; + padding-inline-start: max(40px, var(--detail-back-lane)); } @keyframes shell-fade-in { @@ -159,14 +175,38 @@ @media (max-width: 800px) { .shell__player--active { padding: 8px 12px 0; + padding-inline-start: max(12px, var(--detail-back-lane)); } .shell__episodes { padding: 0 20px; + padding-inline-start: max(20px, var(--detail-back-lane)); } .shell__extras { padding: 0 20px 32px; + padding-inline-start: max(20px, var(--detail-back-lane)); + } +} + +// A pane too narrow to spare a 72px column (a phone, or a desktop pane beside +// the context panel; the component sets the class from the pane's width) +// would clip the player's controls. There the control takes a row instead — +// an opaque sticky bar in flow that content scrolls beneath, never alongside. +:host(.shell-host--compact) { + --detail-back-lane: 0px; + + .shell__navigation { + top: 0; + height: calc(var(--shell-back-size) + 16px); + background: var(--app-header-bg); + // The bar hides what scrolls beneath it, so it must not let taps + // through to that hidden content either. + pointer-events: auto; + } + + .shell__back-button { + top: 8px; } } 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 0173f234a..55e51eeda 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 @@ -451,3 +451,67 @@ describe('PortalDetailShellComponent', () => { expect(host.closeRequests).toBe(0); }); }); + +describe('PortalDetailShellComponent pane width', () => { + const original = globalThis.ResizeObserver; + const disconnect = jest.fn(); + // The hero observes itself too, so keep each callback with its target. + let observers: { callback: ResizeObserverCallback; target?: Element }[]; + + beforeEach(() => { + observers = []; + disconnect.mockClear(); + globalThis.ResizeObserver = class { + private readonly entry: (typeof observers)[number]; + constructor(callback: ResizeObserverCallback) { + this.entry = { callback }; + observers.push(this.entry); + } + observe(target: Element): void { + this.entry.target = target; + } + unobserve = jest.fn(); + disconnect = disconnect; + } as unknown as typeof ResizeObserver; + TestBed.configureTestingModule({ + imports: [HostComponent, TranslateModule.forRoot()], + }); + }); + + afterEach(() => { + globalThis.ResizeObserver = original; + }); + + const resize = (shell: HTMLElement, inlineSize: number): void => { + const observer = observers.find(({ target }) => target === shell); + if (!observer) throw new Error('The shell does not observe itself.'); + observer.callback( + [ + { + borderBoxSize: [{ inlineSize, blockSize: 800 }], + } as unknown as ResizeObserverEntry, + ], + {} as ResizeObserver + ); + }; + + it('trades the Back lane for a bar when the pane itself is narrow', () => { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + const shell = (fixture.nativeElement as HTMLElement).querySelector( + 'app-portal-detail-shell' + ) as HTMLElement; + + // A 780px window leaves the pane ~402px beside the context panel. + resize(shell, 402); + expect(shell.classList).not.toContain('shell-host--compact'); + // A 641px window leaves it ~260px: too narrow for the lane. + resize(shell, 260); + expect(shell.classList).toContain('shell-host--compact'); + resize(shell, 1280); + expect(shell.classList).not.toContain('shell-host--compact'); + + fixture.destroy(); + expect(disconnect).toHaveBeenCalled(); + }); +}); 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 2adf29067..eac7d90cf 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 @@ -2,6 +2,7 @@ import { NgTemplateOutlet } from '@angular/common'; import { afterNextRender, Component, + DestroyRef, ElementRef, Injector, computed, @@ -23,6 +24,14 @@ import { DetailTagsTemplateDirective, } from './detail-template.directives'; +/** + * Below this pane width the Back lane (16 + 40 + 16px) would leave the player + * card under ~316px, where its control row clips, so the control takes a + * sticky bar instead. The pane decides, not the viewport: beside the context + * panel a desktop pane can be narrower than a phone. + */ +const COMPACT_SHELL_WIDTH = 400; + /** * Two-state layout shell for portal VOD/series detail pages. * @@ -61,12 +70,15 @@ import { // on body, while preserving already-handled events and overlay guards. '(keydown.escape)': 'onEscape($event)', '[class.shell-host--watch]': 'isWatch()', + // Content columns reserve the sticky Back control's lane. + '[class.shell-host--back]': 'backAvailable()', '(document:keydown.escape)': 'onEscape($event)', }, }) export class PortalDetailShellComponent { private readonly host = inject>(ElementRef); private readonly injector = inject(Injector); + private readonly destroyRef = inject(DestroyRef); private readonly backButton = viewChild>('backButton'); @@ -108,6 +120,7 @@ export class PortalDetailShellComponent { element.focus({ preventScroll: true }); } }); + this.observeCompactWidth(); let wasWatch = false; effect(() => { const watch = this.isWatch(); @@ -241,4 +254,24 @@ export class PortalDetailShellComponent { } element.scrollTo({ top: 0, behavior: 'auto' }); } + + /** + * Toggles `shell-host--compact` straight on the host, so the class lands + * in the same frame as the resize instead of after a change-detection + * pass. The border box keeps the threshold independent of scrollbar width. + */ + private observeCompactWidth(): void { + if (typeof ResizeObserver === 'undefined') return; + const element = this.host.nativeElement; + const observer = new ResizeObserver(([entry]) => { + const width = + entry?.borderBoxSize?.[0]?.inlineSize ?? element.offsetWidth; + element.classList.toggle( + 'shell-host--compact', + width < COMPACT_SHELL_WIDTH + ); + }); + observer.observe(element); + this.destroyRef.onDestroy(() => observer.disconnect()); + } } diff --git a/libs/ui/components/src/lib/season-container/season-header.component.scss b/libs/ui/components/src/lib/season-container/season-header.component.scss index 2f84ae0fb..880648db2 100644 --- a/libs/ui/components/src/lib/season-container/season-header.component.scss +++ b/libs/ui/components/src/lib/season-container/season-header.component.scss @@ -4,6 +4,10 @@ .section-header { display: flex; + // The actions move onto their own row before the title has to wrap. The + // pane is narrower than the window (context panel, Back lane), so a + // viewport breakpoint cannot tell when the two stop fitting side by side. + flex-wrap: wrap; align-items: center; justify-content: space-between; margin-bottom: 20px; @@ -14,14 +18,16 @@ display: flex; align-items: center; gap: 16px; - flex: 1; + flex: 1 1 auto; } + // Beside the title the header's space-between keeps the actions at the + // end; once they move to their own row they start under the title. &__actions { display: flex; flex-wrap: wrap; align-items: center; - justify-content: flex-end; + justify-content: flex-start; gap: 8px; } @@ -117,7 +123,6 @@ @media (max-width: 560px) { .section-header { align-items: flex-start; - flex-wrap: wrap; &__actions { width: 100%; diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.scss b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.scss index e228e028c..3a3965f64 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.scss +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.scss @@ -203,6 +203,9 @@ .player-shell__title-row { flex-direction: column; + // Centered, the nowrap heading took its full text width and spilled + // past both card edges; stretched, the title ellipsizes in the card. + align-items: stretch; } .player-shell__actions { From 1dba9598523659812257b1b7ad3a1aafe5dce26e Mon Sep 17 00:00:00 2001 From: Mahmut YUM Date: Thu, 1 Oct 2026 07:24:20 +0300 Subject: [PATCH 2/5] fix(portals): treat an undetermined audio language as unknown (#1689) * fix(portals): treat an undetermined audio language as unknown ICU 78, which Electron 43 and Node 26 ship, canonicalizes the ffprobe marker `und` to the subtag `und` instead of an empty one. The source metadata then recorded it as a stated language, so a switch between a copy tagged `und` and one tagged English raised the "dub may differ" warning. CI's older ICU hid this: the existing spec only fails on the newer runtime. Decline `und` explicitly, and cover `und-US` plus the dub comparison. * test(portals): cover undetermined language across runtimes --------- Co-authored-by: 4gray --- .../portals-undetermined-audio-language.md | 7 +++ docs/architecture/vod-multi-source.md | 5 +++ .../vod-source-metadata.util.spec.ts | 43 ++++++++++++++++++- .../multi-source/vod-source-metadata.util.ts | 16 +++++-- 4 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 .changes/portals-undetermined-audio-language.md diff --git a/.changes/portals-undetermined-audio-language.md b/.changes/portals-undetermined-audio-language.md new file mode 100644 index 000000000..e1808943b --- /dev/null +++ b/.changes/portals-undetermined-audio-language.md @@ -0,0 +1,7 @@ +--- +type: fix +area: portals +--- + +Switching to another playlist's copy of a movie no longer warns that the dub +may differ when one copy's audio language is simply unknown (`und`). diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 018852008..ebd6e1cd2 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -776,6 +776,11 @@ tag a language at all, so in practice it is usually silent — which is the honest state, and the same one the rest of this feature takes when it does not know. +Undetermined language tags (`und`, including regional forms such as `und-US`) +are explicitly treated as unknown after normalization. This must hold whether +the runtime's `Intl.Locale` returns no language or preserves `und`: neither +case establishes a factual dub difference against a known language. + Switching sources through `startResolvedPlayback` closes the external session it LAUNCHED first — tracked separately so refreshes and overlapping handoffs cannot disown it within the same route session. The retained identity is ignored diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts index c6d3a6d86..b02719588 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts @@ -180,8 +180,10 @@ describe('applyApiMetadata', () => { }); it.each([ - // ffprobe's marker for "we do not know". + // ffprobe's marker for "we do not know", regardless of whether + // this runtime's Intl.Locale preserves the language subtag. ['und'], + ['und-US'], ['Russian'], ['en_US'], [''], @@ -360,6 +362,45 @@ describe('audioDiffersFactually', () => { expect(audioDiffersFactually(from, to)).toBe(false); }); + it('stays silent when one side reports an undetermined language', () => { + // `und` is ffprobe admitting it does not know; measured against a + // stated English it is not a dub change. + const from = candidate( + applyApiMetadata(candidate(), { audioLanguage: 'und' }) + ); + const to = candidate( + applyApiMetadata(candidate(), { audioLanguage: 'eng' }) + ); + + expect(audioDiffersFactually(from, to)).toBe(false); + }); + + it.each(['und', 'und-US', 'UND'])( + 'stays silent for %s when Intl.Locale preserves und', + (raw) => { + const known = applyApiMetadata(candidate(), { + audioLanguage: 'eng', + }); + // Exercise the newer runtime behavior even on Node 22. The + // captured Locale constructor still reads this prototype getter. + const language = jest + .spyOn(Intl.Locale.prototype, 'language', 'get') + .mockReturnValue('und'); + + try { + const unknown = applyApiMetadata(candidate(), { + audioLanguage: raw, + }); + + expect(unknown.audioLanguage).toBeUndefined(); + expect(audioDiffersFactually(unknown, known)).toBe(false); + expect(audioDiffersFactually(known, unknown)).toBe(false); + } finally { + language.mockRestore(); + } + } + ); + it('stays silent when both sides state the same track', () => { const from = candidate({ audioLanguage: { value: 'rus', provenance: 'api' }, diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts index f3ec6ed5e..a0ddad91a 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts @@ -217,8 +217,11 @@ function keepFactual(field?: VodSourceField): VodSourceField | undefined { * * `Intl.Locale` does the canonicalizing: 639-2 collapses to 639-1 where one * exists, both German forms land on `de`, and regions drop away. `und` — - * ffprobe's marker for undetermined — canonicalizes to nothing, which is - * exactly right: it means the provider does not know either. + * ffprobe's marker for undetermined — is declined explicitly: it means the + * provider does not know either. Some runtimes return no language for this + * tag; others (including Electron 43 and Node 26) preserve `und`. Declining + * it in either case prevents "unknown vs. English" from becoming a dub + * change, independently of the runtime's Intl implementation. * * Anything that survives with more than three characters is not a language * code (`Russian` parses as the subtag `russian`), so the comparison is @@ -232,7 +235,11 @@ function canonicalLanguage(raw: string | null | undefined): string | null { try { const language = new LocaleCtor(value).language; - return language && language.length <= 3 ? language : null; + return language && + language.length <= 3 && + language !== UNDETERMINED_LANGUAGE + ? language + : null; } catch { // Not a well-formed tag at all; saying nothing beats comparing junk. return null; @@ -253,6 +260,9 @@ const LocaleCtor = ( } ).Locale; +/** ISO 639-2 "undetermined"; see `canonicalLanguage`. */ +const UNDETERMINED_LANGUAGE = 'und'; + function cleanString(raw: string | null | undefined): string | null { const trimmed = typeof raw === 'string' ? raw.trim() : ''; return trimmed === '' ? null : trimmed; From 5d50995c306521b2b56a529435312a0285a03692 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:27:08 +0200 Subject: [PATCH 3/5] fix(e2e): let per-file E2E targets run without mock serve dependencies (#1772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(e2e): let per-file E2E targets run without mock serve dependencies Since #1710 the Playwright configs start the Stalker and Xtream mocks themselves (`node --import tsx …`), so @nx/playwright can no longer map those webServers to Nx tasks and infers the atomized `e2e-ci--*` targets as non-parallel. The `e2e-ci--src/*.e2e.ts` target default still made them depend on the continuous `stalker-mock-server:serve` and `xtream-mock-server:serve` targets, and Nx refuses to run a non-parallel task with continuous dependencies, so every per-file target failed before running anything. Drop the redundant mock dependencies and keep the Electron build. The mock-launch guard spec now also rejects any nx.json target default that depends on a mock-server task. Co-Authored-By: Claude Opus 5.5 * test(e2e): scope the mock dependency guard to E2E targets Check only the `e2e*` target defaults in nx.json, so an unrelated default may still depend on a mock, and also check every target in the `apps/*-e2e` project.json files, where a mock `serve` dependency would break the same targets. Co-Authored-By: Claude Opus 5.5 * fix(e2e): build Electron only before Electron per-file E2E targets The `e2e-ci--src/*.e2e.ts` target default also matched web-e2e, so every browser-only per-file target built electron-backend first. Split it into project-filtered entries: Electron targets keep `build-e2e`, web targets get an empty dependency list (which also keeps the inferred `web:serve` dependency, rejected by Nx on a non-parallel target, out of them). The mock dependency guard now also catches `^serve`-style dependencies, which schedule the mocks through the E2E projects' implicit dependencies. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../src/app/project-config.spec.ts | 96 +++++++++++++++++++ docs/architecture/xtream-mock-server.md | 6 +- nx.json | 27 +++--- 3 files changed, 116 insertions(+), 13 deletions(-) diff --git a/apps/xtream-mock-server/src/app/project-config.spec.ts b/apps/xtream-mock-server/src/app/project-config.spec.ts index 9c1895228..740a1ee5c 100644 --- a/apps/xtream-mock-server/src/app/project-config.spec.ts +++ b/apps/xtream-mock-server/src/app/project-config.spec.ts @@ -87,3 +87,99 @@ describe('Playwright mock-server launch', () => { } ); }); + +describe('Nx E2E task dependencies', () => { + type Dependency = + | string + | { + projects?: string | string[]; + dependencies?: boolean; + target?: string; + }; + type TargetConfig = { + dependsOn?: Dependency[]; + continuous?: boolean; + filter?: { projects?: string[] }; + }; + type ProjectJson = { targets: Record }; + const readJson = (path: string) => + JSON.parse(readFileSync(join(process.cwd(), path), 'utf8')) as T; + const nxJson = readJson<{ + targetDefaults: Record; + }>('nx.json'); + const appNames = readdirSync(join(process.cwd(), 'apps')); + const e2eProjectFiles = appNames + .filter((name) => name.endsWith('-e2e')) + .map((name) => `apps/${name}/project.json`); + // The E2E projects list the mocks as implicit dependencies, so a `^serve` + // dependency schedules the mock serve tasks as well. + const mockContinuousTargets = new Set( + appNames + .filter((name) => name.endsWith('-mock-server')) + .flatMap((name) => + Object.entries( + readJson(`apps/${name}/project.json`).targets + ) + .filter(([, target]) => target.continuous) + .map(([targetName]) => targetName) + ) + ); + const dependsOnMock = (dependency: Dependency) => + typeof dependency === 'string' + ? dependency.includes('-mock-server:') || + (dependency.startsWith('^') && + mockContinuousTargets.has(dependency.slice(1))) + : [dependency.projects ?? []] + .flat() + .some((project) => project.includes('-mock-server')) || + (dependency.dependencies === true && + mockContinuousTargets.has(dependency.target ?? '')); + const perFileDefaultFor = (project: string) => + [nxJson.targetDefaults['e2e-ci--src/*.e2e.ts'] ?? []] + .flat() + .find((entry) => entry.filter?.projects?.includes(project)); + + // The Playwright configs start the mocks themselves, so @nx/playwright + // infers their E2E targets as non-parallel, and Nx refuses to run a + // non-parallel task that depends on a continuous `serve` task. + it('never makes an E2E target depend on a mock-server task', () => { + const e2eDefaults = Object.entries(nxJson.targetDefaults) + .filter(([targetName]) => targetName.startsWith('e2e')) + .flatMap(([targetName, config]) => + [config].flat().map((entry) => ({ + source: `nx.json ${targetName}`, + config: entry, + })) + ); + const e2eProjectTargets = e2eProjectFiles.flatMap((path) => + Object.entries(readJson(path).targets).map( + ([targetName, config]) => ({ + source: `${path} ${targetName}`, + config, + }) + ) + ); + const mockDependencies = [...e2eDefaults, ...e2eProjectTargets] + .map(({ source, config }) => ({ + source, + dependencies: (config.dependsOn ?? []).filter(dependsOnMock), + })) + .filter(({ dependencies }) => dependencies.length > 0); + + expect(e2eProjectFiles).toEqual( + expect.arrayContaining([ + 'apps/electron-backend-e2e/project.json', + 'apps/web-e2e/project.json', + ]) + ); + expect([...mockContinuousTargets]).toContain('serve'); + expect(mockDependencies).toEqual([]); + }); + + it('builds the Electron app only before Electron per-file E2E targets', () => { + expect(perFileDefaultFor('electron-backend-e2e')?.dependsOn).toEqual([ + { projects: ['electron-backend'], target: 'build-e2e' }, + ]); + expect(perFileDefaultFor('web-e2e')?.dependsOn).toEqual([]); + }); +}); diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 571c2f461..a82e1618f 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -579,7 +579,11 @@ for the serve target's `--tsconfig` flag and is required for the `@iptvnator/*` path aliases. `project-config.spec.ts` pins which configs start which mock, and fails if any of them launches a mock through Nx or a new config starts one without being listed there. The `serve` targets remain the entry point -for starting a mock by hand. The web-e2e `web-backend` entry uses the same +for starting a mock by hand, and no E2E target may depend on them: with no +Nx-launched server, `@nx/playwright` infers the atomized `e2e-ci--*` targets +as non-parallel, and Nx refuses to run a non-parallel task that depends on a +continuous `serve` task. The same spec guards the `e2e*` entries of `nx.json` +`targetDefaults` and every target in the `apps/*-e2e` `project.json` files. The web-e2e `web-backend` entry uses the same launch form; see [PWA web backend](pwa-self-hosted.md#web-backend). ### Request Interception diff --git a/nx.json b/nx.json index ff0a58753..2f36ffe99 100644 --- a/nx.json +++ b/nx.json @@ -68,18 +68,21 @@ "dependsOn": ["^build"], "inputs": ["production", "^production"] }, - "e2e-ci--src/*.e2e.ts": { - "dependsOn": [ - { - "projects": ["electron-backend"], - "target": "build-e2e" - }, - { - "projects": ["stalker-mock-server", "xtream-mock-server"], - "target": "serve" - } - ] - } + "e2e-ci--src/*.e2e.ts": [ + { + "filter": { "projects": ["electron-backend-e2e"] }, + "dependsOn": [ + { + "projects": ["electron-backend"], + "target": "build-e2e" + } + ] + }, + { + "filter": { "projects": ["web-e2e"] }, + "dependsOn": [] + } + ] }, "plugins": [ { From adb4889b0f1b6d7e5e63749af2ce84d27cf7addf Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 1 Oct 2026 06:37:02 +0200 Subject: [PATCH 4/5] fix(player): keyboard focus, contrast and ARIA for controls and settings (#1769) * fix(player): keyboard focus, contrast and ARIA for controls and settings Dock and settings-panel icon buttons draw a 2px --pc-text ring on :focus-visible, and Material's theme-coloured focus layer is off, so keyboard focus shows on video in the light theme too. A focused selected subtitle swatch now differs from one that is only selected. Settings headings read --pc-text-secondary on denser glass (--pc-glass-bg-dense, 0.86): 4.5:1 or more over mid-grey and white frames. They wrap (overflow-wrap: anywhere, hyphens: auto), so long German and Russian headings stay inside the sheet's heading column. The settings panel is now radio groups only (SettingsRadioGroupDirective over a CDK FocusKeyManager): one Tab stop per group on the checked option, arrows, Home and End move focus without applying, and Space/Enter checks. The dialog and its groups are named by real h2/h3/h4 headings, the load-file action sits outside the subtitle radio group, the subtitle and speed chips carry their value in their name ("Subtitles: English"), and tune has aria-haspopup="dialog". Adds a web E2E for the keyboard path in both themes with an axe check on the open panel, and de/ru sheet heading wrapping; axe-core is a new dev dependency for it. Co-Authored-By: Claude Opus 5.5 * fix(player): arrows check settings radios; subtitle chip reads On Review follow-up: - Arrow keys, Home and End now check the settings radio they reach, as a native radio group does (the directive clicks it, so the template's handler applies the choice); an option the engine already reports as checked is not applied again. - With subtitles on but no track marked selected yet (the engine can report the switch before the track list), the subtitle chip reads and announces "On" (new SUBTITLES_ON key, 19 locales) instead of "Off". - The swatch row has 4px padding on every side, so the outer focus ring is not clipped at the scroll edge of the panel body. Co-Authored-By: Claude Opus 5.5 * fix(player): re-apply a settings radio while a switch is pending The arrow-key check skipped any option the engine still reported as checked. Arrowing from audio track A to B and back to A before the engine confirmed B therefore sent no command for A, and playback ended on B with focus on A. An arrow move always lands on an option other than the last one applied, so it now applies unconditionally. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changes/player-controls-keyboard-access.md | 9 + .../src/player-controls-keyboard.e2e.ts | 410 ++++++++++++++++++ apps/web-e2e/src/player-settings-panel.e2e.ts | 105 +---- .../src/player-settings-panel.fixture.ts | 114 +++++ apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/hu.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/iptvnator-ui-guidelines.md | 4 +- docs/architecture/player-controls-contract.md | 49 ++- .../player-controls.component.html | 25 +- .../player-controls.component.scss | 13 + ...-controls.component.settings-panel.spec.ts | 4 +- ...ntrols.component.subtitle-settings.spec.ts | 6 +- .../player-controls.palette.spec.ts | 81 ++++ .../player-settings-panel.a11y.spec.ts | 355 +++++++++++++++ .../player-settings-panel.component.html | 235 +++++----- .../player-settings-panel.component.scss | 82 +++- .../player-settings-panel.component.ts | 24 +- .../settings-radio-group.directive.ts | 143 ++++++ package.json | 1 + pnpm-lock.yaml | 9 + 37 files changed, 1450 insertions(+), 257 deletions(-) create mode 100644 .changes/player-controls-keyboard-access.md create mode 100644 apps/web-e2e/src/player-controls-keyboard.e2e.ts create mode 100644 apps/web-e2e/src/player-settings-panel.fixture.ts create mode 100644 libs/ui/playback/src/lib/player-controls/player-settings-panel.a11y.spec.ts create mode 100644 libs/ui/playback/src/lib/player-controls/settings-radio-group.directive.ts diff --git a/.changes/player-controls-keyboard-access.md b/.changes/player-controls-keyboard-access.md new file mode 100644 index 000000000..745fe8489 --- /dev/null +++ b/.changes/player-controls-keyboard-access.md @@ -0,0 +1,9 @@ +--- +type: fix +area: player +--- + +Player controls now show a clear focus ring in the light theme too. The +settings panel works with arrow keys and screen readers, and the subtitle and +speed buttons announce their current value. Settings headings are easier to +read over bright video and wrap in long translations instead of overflowing. diff --git a/apps/web-e2e/src/player-controls-keyboard.e2e.ts b/apps/web-e2e/src/player-controls-keyboard.e2e.ts new file mode 100644 index 000000000..15a441f36 --- /dev/null +++ b/apps/web-e2e/src/player-controls-keyboard.e2e.ts @@ -0,0 +1,410 @@ +import type { Locator, Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { pressTab } from './e2e-helpers'; +import { expect, test } from './fixtures'; +import { + importPlaylist, + selectPlayer, + serveClip, + startClip, +} from './player-settings-panel.fixture'; + +/** + * Keyboard and assistive-technology access to the shared controls in both + * app themes: every dock control and every settings choice shows a focus + * ring in the overlay's own colour, each settings radio group is one Tab + * stop with arrow-key navigation, the open panel passes axe, its headings + * stay readable over bright frames, and long German and Russian headings + * wrap inside the compact sheet. ArtPlayer is used because it offers the + * fullest panel (subtitle file, size and colour, speed). + */ + +test.use({ serviceWorkers: 'block' }); + +/** `--pc-text`, the overlay's text colour, as computed styles report it. */ +const RING_COLOR = 'rgb(231, 236, 243)'; +const AXE_SOURCE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8'); + +interface FocusStop { + label: string; + role: string | null; + group: string | null; + checked: string | null; + iconButton: boolean; + outline: string; + outlineColor: string; +} + +/** Describes the focused element and the ring it draws. */ +function focusStop(page: Page): Promise { + return page.evaluate(() => { + const element = document.activeElement; + if (!(element instanceof HTMLElement)) return null; + const style = getComputedStyle(element); + const group = element.closest('[role="radiogroup"]'); + return { + label: + element.getAttribute('aria-label') ?? + element.textContent?.trim() ?? + '', + role: element.getAttribute('role'), + group: group?.getAttribute('aria-labelledby') ?? null, + checked: element.getAttribute('aria-checked'), + iconButton: element.matches('button[mat-icon-button]'), + outline: `${style.outlineStyle} ${style.outlineWidth}`, + outlineColor: style.outlineColor, + }; + }); +} + +function focusIsInside(page: Page, selector: string): Promise { + return page.evaluate( + (within) => !!document.activeElement?.closest(within), + selector + ); +} + +/** + * Presses Tab (or Shift+Tab) until focus lies inside `target`, or leaves + * `within` when that is given, and returns every stop on the way — the + * last one included. + */ +async function tabUntil( + page: Page, + browserName: string, + options: { + target?: string; + within?: string; + direction?: 'forward' | 'backward'; + max?: number; + } +): Promise { + const stops: FocusStop[] = []; + for (let step = 0; step < (options.max ?? 20); step++) { + await pressTab(page, browserName, options.direction); + if (options.within && !(await focusIsInside(page, options.within))) { + return stops; + } + const stop = await focusStop(page); + if (stop) stops.push(stop); + if (options.target && (await focusIsInside(page, options.target))) { + return stops; + } + } + return stops; +} + +async function applyTheme(page: Page, theme: 'light' | 'dark') { + await page.evaluate((dark) => { + document.body.classList.toggle('dark-theme', dark); + }, theme === 'dark'); +} + +async function expectNoAxeViolations(panel: Locator) { + await panel.page().evaluate(AXE_SOURCE); + const violations = await panel.evaluate(async (element) => { + const axe = (window as unknown as { axe: typeof import('axe-core') }) + .axe; + const results = await axe.run(element, { resultTypes: ['violations'] }); + return results.violations.map((violation) => ({ + id: violation.id, + nodes: violation.nodes.map((node) => node.target.join(' ')), + })); + }); + expect(violations).toEqual([]); +} + +/** + * Heading contrast on the panel glass composited over a mid-grey and a white + * frame — the video behind the panel can be anything. + */ +function headingContrast(panel: Locator) { + return panel.evaluate((host) => { + type Color = [number, number, number, number]; + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + const parse = (css: string): Color => { + ctx.clearRect(0, 0, 1, 1); + ctx.fillStyle = css; + ctx.fillRect(0, 0, 1, 1); + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + return [r, g, b, a / 255]; + }; + // `front` over an opaque `back`: the result is opaque too. + const over = (front: Color, back: Color): Color => [ + front[0] * front[3] + back[0] * (1 - front[3]), + front[1] * front[3] + back[1] * (1 - front[3]), + front[2] * front[3] + back[2] * (1 - front[3]), + 1, + ]; + const luminance = (color: Color) => { + const [r, g, b] = color.slice(0, 3).map((value) => { + const s = value / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return r * 0.2126 + g * 0.7152 + b * 0.0722; + }; + const ratio = (a: Color, b: Color) => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); + }; + const glass = parse(getComputedStyle(host).backgroundColor); + return Array.from(host.querySelectorAll('h3, h4'), (heading) => { + const text = parse(getComputedStyle(heading).color); + const on = (frame: Color) => { + const surface = over(glass, frame); + return ratio(over(text, surface), surface); + }; + return { + heading: heading.textContent?.trim(), + midGrey: on([128, 128, 128, 1]), + white: on([255, 255, 255, 1]), + }; + }); + }); +} + +test('@web @playback keyboard reaches the dock and the settings radios with a visible ring in both themes', async ({ + page, + browserName, +}) => { + test.setTimeout(120_000); + // Chips and the side panel need a player of at least 960px. + await page.setViewportSize({ width: 1600, height: 1000 }); + await serveClip(page); + await selectPlayer(page, 'ArtPlayer'); + await importPlaylist(page); + const { view, video, controls } = await startClip(page); + // Park the pointer on the video away from the dock and the panel: a + // control that slides in under a resting pointer shows its hover + // tooltip, and a visible tooltip spends the next Escape on itself. + await view.hover({ position: { x: 24, y: 24 } }); + const tune = controls.locator( + '[data-test-id="player-controls-settings-button"]' + ); + const panel = controls.getByRole('dialog', { name: 'Settings' }); + + for (const theme of ['light', 'dark'] as const) { + await applyTheme(page, theme); + + // --- The dock, from the timeline to the last control. ------------- + await controls + .getByRole('slider', { name: 'Playback position' }) + .focus(); + const dockButtons = ( + await tabUntil(page, browserName, { within: 'app-player-controls' }) + ).filter((stop) => stop.iconButton); + // Every icon button wears the ring, whatever the app theme. + expect(dockButtons.map((stop) => stop.outline)).toEqual( + dockButtons.map(() => 'solid 2px') + ); + expect(dockButtons.map((stop) => stop.outlineColor)).toEqual( + dockButtons.map(() => RING_COLOR) + ); + expect(dockButtons.map((stop) => stop.label)).toEqual( + expect.arrayContaining([ + 'Mute', + 'Back 10 seconds', + 'Forward 10 seconds', + 'Settings', + 'Enter fullscreen', + ]) + ); + + // --- Open the panel from the keyboard: focus moves into it. ------- + await tabUntil(page, browserName, { + target: '[data-test-id="player-controls-settings-button"]', + direction: 'backward', + max: 10, + }); + await expect(tune).toBeFocused(); + await expect(tune).toHaveAttribute('aria-haspopup', 'dialog'); + await page.screenshot({ + path: test.info().outputPath(`${theme}-dock-focus.png`), + }); + await page.keyboard.press('Enter'); + await expect(panel).toBeVisible(); + await expect(panel).toBeFocused(); + + // Tab enters every radio group once, on its checked option, and + // every stop wears the ring. + const panelStops = await tabUntil(page, browserName, { + target: '[data-test-id="player-settings-speed"]', + }); + expect(panelStops.map((stop) => stop.outline)).toEqual( + panelStops.map(() => 'solid 2px') + ); + expect(panelStops.map((stop) => stop.outlineColor)).toEqual( + panelStops.map(() => RING_COLOR) + ); + const radioStops = panelStops.filter((stop) => stop.role === 'radio'); + // Subtitle size, subtitle colour and speed: one stop each, checked. + expect(radioStops.map((stop) => stop.checked)).toEqual([ + 'true', + 'true', + 'true', + ]); + expect(new Set(radioStops.map((stop) => stop.group)).size).toBe(3); + + // --- Arrows move focus and apply the option they reach. ---------- + const speedGroup = panel.locator( + '[data-test-id="player-settings-speed"] [role="radiogroup"]' + ); + const rate = () => + video.evaluate((el: HTMLVideoElement) => el.playbackRate); + await page.keyboard.press('ArrowRight'); + const moved = await focusStop(page); + expect(moved?.role).toBe('radio'); + await expect.poll(rate).toBe(Number.parseFloat(moved?.label ?? '')); + await expect( + speedGroup.locator('[role="radio"][aria-checked="true"]') + ).toHaveText(moved?.label ?? ''); + await page.keyboard.press('Home'); + expect((await focusStop(page))?.label).toBe('0.5×'); + await expect.poll(rate).toBe(0.5); + await expect( + speedGroup.locator('[role="radio"][aria-checked="true"]') + ).toHaveText('0.5×'); + // The ends wrap, as in a native radio group. + await page.keyboard.press('ArrowLeft'); + expect((await focusStop(page))?.label).toBe('2×'); + await expect.poll(rate).toBe(2); + await expect(speedGroup.locator('[tabindex="0"]')).toHaveText('2×'); + // Leaving and re-entering lands on the checked option — the one the + // engine reports, so wait for it to confirm the switch first. + await expect( + speedGroup.locator('[role="radio"][aria-checked="true"]') + ).toHaveText('2×'); + await pressTab(page, browserName, 'backward'); + await pressTab(page, browserName); + expect((await focusStop(page))?.label).toBe('2×'); + + // --- A focused selected swatch differs from a selected one. ------- + await tabUntil(page, browserName, { + target: '.player-settings__swatches', + direction: 'backward', + max: 5, + }); + const focusedSwatch = panel.locator('.player-settings__swatch:focus'); + await expect(focusedSwatch).toHaveAttribute('aria-checked', 'true'); + await expect(focusedSwatch).toHaveCSS('outline-style', 'solid'); + // The arrow checks the next swatch, which takes focus and the ring. + await page.keyboard.press('ArrowRight'); + await expect(focusedSwatch).toHaveAttribute('aria-checked', 'true'); + await expect(focusedSwatch).toHaveCSS('outline-style', 'solid'); + // Tab moves on to the speed group: the checked swatch keeps its + // selected border but loses the ring. + await pressTab(page, browserName); + expect( + await focusIsInside(page, '[data-test-id="player-settings-speed"]') + ).toBe(true); + const selectedOnly = panel.locator( + '.player-settings__swatch[aria-checked="true"]' + ); + await expect(selectedOnly).toHaveCount(1); + await expect(selectedOnly).not.toBeFocused(); + await expect(selectedOnly).toHaveCSS('outline-style', 'none'); + await expect(selectedOnly).toHaveCSS( + 'border-top-color', + 'rgb(255, 255, 255)' + ); + + // --- Headings, axe and closing. ---------------------------------- + const contrast = await headingContrast(panel); + expect(contrast.length).toBeGreaterThan(0); + expect( + contrast.filter( + (heading) => heading.midGrey < 4.5 || heading.white < 4.5 + ) + ).toEqual([]); + await expectNoAxeViolations(panel); + await page.screenshot({ + path: test.info().outputPath(`${theme}-panel-focus.png`), + }); + + // Focus is on a speed radio, which has no tooltip. Material spends + // an Escape on any tooltip still showing (the swatch's fades out), + // so wait for it to go before closing. + await expect(page.locator('.mat-mdc-tooltip')).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(panel).toHaveCount(0); + await expect(tune).toBeFocused(); + } +}); + +for (const [locale, speedHeading] of [ + ['de', 'Wiedergabegeschwindigkeit'], + ['ru', 'Скорость воспроизведения'], +] as const) { + test(`@web @playback settings sheet headings wrap in the ${locale} locale`, async ({ + page, + }) => { + test.setTimeout(120_000); + await page.setViewportSize({ width: 900, height: 700 }); + await serveClip(page); + await selectPlayer(page, 'ArtPlayer'); + const playlistUrl = await importPlaylist(page); + + await page.goto('/workspace/settings/general'); + await page.locator('[data-test-id="select-language"]').click(); + await page.locator(`mat-option[data-test-id="${locale}"]`).click(); + const save = page.locator('[data-test-id="save-settings"]'); + await save.click(); + await expect(save).toBeHidden(); + + await page.goto(playlistUrl); + const { controls } = await startClip(page); + await controls + .locator('[data-test-id="player-controls-settings-button"]') + .click(); + const sheet = controls.locator( + '[data-test-id="player-controls-settings-panel"]' + ); + await expect(sheet).toHaveClass(/player-controls__settings--sheet/); + await expect( + sheet.locator('[data-test-id="player-settings-speed"] h3') + ).toHaveText(speedHeading); + + for (const theme of ['light', 'dark'] as const) { + await applyTheme(page, theme); + const headings = await sheet.evaluate((host) => + Array.from(host.querySelectorAll('h3, h4'), (heading) => { + const box = heading.getBoundingClientRect(); + const control = heading.nextElementSibling; + const style = getComputedStyle(heading); + return { + text: heading.textContent?.trim(), + overflow: heading.scrollWidth - heading.clientWidth, + // A group heading sits in the sheet's left column, + // beside its control. + intoControl: + heading.tagName === 'H3' && control + ? box.right - + control.getBoundingClientRect().left + : 0, + lines: Math.round( + (box.height - + Number.parseFloat(style.paddingTop) - + Number.parseFloat(style.paddingBottom)) / + Number.parseFloat(style.lineHeight) + ), + }; + }) + ); + for (const heading of headings) { + expect(heading.overflow, heading.text).toBeLessThanOrEqual(0); + expect(heading.intoControl, heading.text).toBeLessThanOrEqual( + 0 + ); + } + expect( + headings.find((heading) => heading.text === speedHeading)?.lines + ).toBeGreaterThan(1); + await expectNoAxeViolations(sheet); + } + await page.screenshot({ + path: test.info().outputPath(`${locale}-sheet.png`), + }); + }); +} diff --git a/apps/web-e2e/src/player-settings-panel.e2e.ts b/apps/web-e2e/src/player-settings-panel.e2e.ts index 0f399a397..dca258724 100644 --- a/apps/web-e2e/src/player-settings-panel.e2e.ts +++ b/apps/web-e2e/src/player-settings-panel.e2e.ts @@ -1,7 +1,10 @@ -import type { Page } from '@playwright/test'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { expect, test } from './fixtures'; +import { + importPlaylist, + selectPlayer, + serveClip, + startClip, +} from './player-settings-panel.fixture'; /** * The shared controls' settings panel: on a wide player the speed chip opens @@ -11,100 +14,8 @@ import { expect, test } from './fixtures'; * remote media is involved. */ -const FIXTURE_HOST = 'https://player-settings-fixture.local'; -const PLAYLIST = [ - '#EXTM3U', - '#EXTINF:-1 group-title="Movies",Settings Clip', - `${FIXTURE_HOST}/clip.mp4`, -].join('\n'); - test.use({ serviceWorkers: 'block' }); -async function serveClip(page: Page): Promise { - const clip = readFileSync( - join(__dirname, 'fixtures/playback/episode.webm') - ); - await page.route(`${FIXTURE_HOST}/**`, async (route) => { - const range = /^bytes=(\d*)-(\d*)$/.exec( - route.request().headers()['range'] ?? '' - ); - const last = clip.length - 1; - const start = range?.[1] - ? Number(range[1]) - : range?.[2] - ? Math.max(0, clip.length - Number(range[2])) - : 0; - const end = - range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last; - await route.fulfill({ - status: range ? 206 : 200, - headers: { - 'content-type': 'video/webm', - 'accept-ranges': 'bytes', - 'content-length': String(end - start + 1), - ...(range - ? { - 'content-range': `bytes ${start}-${end}/${clip.length}`, - } - : {}), - }, - body: clip.subarray(start, end + 1), - }); - }); -} - -async function selectHtml5Player(page: Page): Promise { - await page.goto('/workspace/settings/playback'); - const select = page.locator('[data-test-id="select-video-player"]'); - await expect(select).toBeVisible(); - const previous = await select.innerText(); - await select.click(); - await page - .getByRole('option', { name: 'HTML5 video player', exact: true }) - .click(); - if (!previous.includes('HTML5 video player')) { - const saveButton = page.getByRole('button', { name: 'Save changes' }); - await saveButton.click(); - await expect(saveButton).toBeHidden(); - } -} - -async function importPlaylist(page: Page): Promise { - 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(PLAYLIST); - await Promise.all([ - page.waitForURL(/\/workspace\/playlists\/.+\/all$/), - dialog.getByRole('button', { name: 'Import', exact: true }).click(), - ]); - await expect(page.getByText('1 channels')).toBeVisible(); -} - -async function startClip(page: Page) { - await page - .locator('[data-test-id="channel-item"]') - .filter({ hasText: 'Settings Clip' }) - .click(); - const view = page.locator('app-web-player-view'); - const video = view.locator('video'); - await expect - .poll(() => - video.evaluate( - (el: HTMLVideoElement) => - Number.isFinite(el.duration) && el.duration > 5 - ) - ) - .toBe(true); - // Reveal the dock and keep it revealed for the assertions below. - await view.hover(); - const controls = view.locator('app-player-controls'); - await controls.getByRole('button', { name: 'Pause', exact: true }).click(); - return { view, video, controls }; -} - test('@web @playback settings panel opens from the speed chip and applies in place', async ({ page, }) => { @@ -112,7 +23,7 @@ test('@web @playback settings panel opens from the speed chip and applies in pla // Chips and the side panel need a player of at least 960px. await page.setViewportSize({ width: 1600, height: 1000 }); await serveClip(page); - await selectHtml5Player(page); + await selectPlayer(page); await importPlaylist(page); const { video, controls } = await startClip(page); @@ -164,7 +75,7 @@ test('@web @playback compact player folds the chips into a tune button with a bo test.setTimeout(90_000); await page.setViewportSize({ width: 900, height: 700 }); await serveClip(page); - await selectHtml5Player(page); + await selectPlayer(page); await importPlaylist(page); const { view, controls } = await startClip(page); diff --git a/apps/web-e2e/src/player-settings-panel.fixture.ts b/apps/web-e2e/src/player-settings-panel.fixture.ts new file mode 100644 index 000000000..e2879718e --- /dev/null +++ b/apps/web-e2e/src/player-settings-panel.fixture.ts @@ -0,0 +1,114 @@ +import type { Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect } from './fixtures'; + +/** + * A one-channel M3U playlist whose only stream is the local test clip, served + * through route interception: the shared-controls specs play it in a web + * engine without any remote media. + */ +const FIXTURE_HOST = 'https://player-settings-fixture.local'; +export const CLIP_TITLE = 'Settings Clip'; +const PLAYLIST = [ + '#EXTM3U', + `#EXTINF:-1 group-title="Movies",${CLIP_TITLE}`, + `${FIXTURE_HOST}/clip.mp4`, +].join('\n'); + +export async function serveClip(page: Page): Promise { + const clip = readFileSync( + join(__dirname, 'fixtures/playback/episode.webm') + ); + await page.route(`${FIXTURE_HOST}/**`, async (route) => { + const range = /^bytes=(\d*)-(\d*)$/.exec( + route.request().headers()['range'] ?? '' + ); + const last = clip.length - 1; + const start = range?.[1] + ? Number(range[1]) + : range?.[2] + ? Math.max(0, clip.length - Number(range[2])) + : 0; + const end = + range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last; + await route.fulfill({ + status: range ? 206 : 200, + headers: { + 'content-type': 'video/webm', + 'accept-ranges': 'bytes', + 'content-length': String(end - start + 1), + ...(range + ? { + 'content-range': `bytes ${start}-${end}/${clip.length}`, + } + : {}), + }, + body: clip.subarray(start, end + 1), + }); + }); +} + +export async function selectPlayer( + page: Page, + player = 'HTML5 video player' +): Promise { + await page.goto('/workspace/settings/playback'); + const select = page.locator('[data-test-id="select-video-player"]'); + await expect(select).toBeVisible(); + const previous = await select.innerText(); + await select.click(); + await page.getByRole('option', { name: player, exact: true }).click(); + if (!previous.includes(player)) { + const saveButton = page.getByRole('button', { name: 'Save changes' }); + await saveButton.click(); + await expect(saveButton).toBeHidden(); + } +} + +/** Imports the playlist and returns the URL of its channel list. */ +export async function importPlaylist(page: Page): Promise { + 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(PLAYLIST); + await Promise.all([ + page.waitForURL(/\/workspace\/playlists\/.+\/all$/), + dialog.getByRole('button', { name: 'Import', exact: true }).click(), + ]); + await expect(page.getByText('1 channels')).toBeVisible(); + return page.url(); +} + +/** + * Starts the clip and pauses it with the dock revealed. The play button is + * found by test id, so this works in every app language. + */ +export async function startClip(page: Page) { + await page + .locator('[data-test-id="channel-item"]') + .filter({ hasText: CLIP_TITLE }) + .click(); + const view = page.locator('app-web-player-view'); + const video = view.locator('video'); + await expect + .poll(() => + video.evaluate( + (el: HTMLVideoElement) => + Number.isFinite(el.duration) && + el.duration > 5 && + !el.paused + ) + ) + .toBe(true); + // Reveal the dock and keep it revealed for the assertions below. + await view.hover(); + const controls = view.locator('app-player-controls'); + await controls.locator('[data-test-id="player-controls-play"]').click(); + await expect + .poll(() => video.evaluate((el: HTMLVideoElement) => el.paused)) + .toBe(true); + return { view, video, controls }; +} diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 2f73604da..e4dff9e22 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "مسارات الصوت", "SUBTITLES": "الترجمات", "SUBTITLES_OFF": "إيقاف", + "SUBTITLES_ON": "تشغيل", "QUALITY": "الجودة", "QUALITY_AUTO": "تلقائي", "TRACK_DEFAULT": "افتراضي", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}", "PLAYBACK_SPEED": "سرعة التشغيل", "SPEED_TOOLTIP": "السرعة: {{speed}}", + "SUBTITLES_TOOLTIP": "الترجمات: {{subtitles}}", "ASPECT_RATIO": "نسبة العرض إلى الارتفاع", "ASPECT_TOOLTIP": "النسبة: {{aspect}}", "ASPECT_DEFAULT": "افتراضي", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index e23c291bc..56f24be99 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "مسارات الصوت", "SUBTITLES": "الترجمة", "SUBTITLES_OFF": "معطلة", + "SUBTITLES_ON": "مفعلة", "QUALITY": "الجودة", "QUALITY_AUTO": "تلقائي", "TRACK_DEFAULT": "افتراضي", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}", "PLAYBACK_SPEED": "سرعة التشغيل", "SPEED_TOOLTIP": "السرعة: {{speed}}", + "SUBTITLES_TOOLTIP": "الترجمة: {{subtitles}}", "ASPECT_RATIO": "نسبة الأبعاد", "ASPECT_TOOLTIP": "الأبعاد: {{aspect}}", "ASPECT_DEFAULT": "افتراضي", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 91f53d7b0..1a5583ef1 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Аўдыядарожкі", "SUBTITLES": "Субтытры", "SUBTITLES_OFF": "Выкл.", + "SUBTITLES_ON": "Укл.", "QUALITY": "Якасць", "QUALITY_AUTO": "Аўта", "TRACK_DEFAULT": "Па змаўчанні", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}", "PLAYBACK_SPEED": "Хуткасць прайгравання", "SPEED_TOOLTIP": "Хуткасць: {{speed}}", + "SUBTITLES_TOOLTIP": "Субтытры: {{subtitles}}", "ASPECT_RATIO": "Суадносіны бакоў", "ASPECT_TOOLTIP": "Суадносіны: {{aspect}}", "ASPECT_DEFAULT": "Па змаўчанні", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 9f05ac68c..9c7170c7e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Tonspuren", "SUBTITLES": "Untertitel", "SUBTITLES_OFF": "Aus", + "SUBTITLES_ON": "An", "QUALITY": "Qualität", "QUALITY_AUTO": "Automatisch", "TRACK_DEFAULT": "Standard", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Untertitel {{index}}", "PLAYBACK_SPEED": "Wiedergabegeschwindigkeit", "SPEED_TOOLTIP": "Geschwindigkeit: {{speed}}", + "SUBTITLES_TOOLTIP": "Untertitel: {{subtitles}}", "ASPECT_RATIO": "Seitenverhältnis", "ASPECT_TOOLTIP": "Seitenverhältnis: {{aspect}}", "ASPECT_DEFAULT": "Standard", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index f40ec13cf..5a7d89dc5 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Κομμάτια ήχου", "SUBTITLES": "Υπότιτλοι", "SUBTITLES_OFF": "Ανενεργοί", + "SUBTITLES_ON": "Ενεργοί", "QUALITY": "Ποιότητα", "QUALITY_AUTO": "Αυτόματη", "TRACK_DEFAULT": "Προεπιλογή", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}", "PLAYBACK_SPEED": "Ταχύτητα αναπαραγωγής", "SPEED_TOOLTIP": "Ταχύτητα: {{speed}}", + "SUBTITLES_TOOLTIP": "Υπότιτλοι: {{subtitles}}", "ASPECT_RATIO": "Αναλογία εικόνας", "ASPECT_TOOLTIP": "Αναλογία: {{aspect}}", "ASPECT_DEFAULT": "Προεπιλογή", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 4e00a95c5..6544f35ee 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Audio tracks", "SUBTITLES": "Subtitles", "SUBTITLES_OFF": "Off", + "SUBTITLES_ON": "On", "QUALITY": "Quality", "QUALITY_AUTO": "Auto", "TRACK_DEFAULT": "Default", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Subtitle {{index}}", "PLAYBACK_SPEED": "Playback speed", "SPEED_TOOLTIP": "Speed: {{speed}}", + "SUBTITLES_TOOLTIP": "Subtitles: {{subtitles}}", "ASPECT_RATIO": "Aspect ratio", "ASPECT_TOOLTIP": "Aspect: {{aspect}}", "ASPECT_DEFAULT": "Default", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 3fe7f484d..4c340b517 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Pistas de audio", "SUBTITLES": "Subtítulos", "SUBTITLES_OFF": "Desactivados", + "SUBTITLES_ON": "Activados", "QUALITY": "Calidad", "QUALITY_AUTO": "Automática", "TRACK_DEFAULT": "Predeterminada", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Subtítulo {{index}}", "PLAYBACK_SPEED": "Velocidad de reproducción", "SPEED_TOOLTIP": "Velocidad: {{speed}}", + "SUBTITLES_TOOLTIP": "Subtítulos: {{subtitles}}", "ASPECT_RATIO": "Relación de aspecto", "ASPECT_TOOLTIP": "Aspecto: {{aspect}}", "ASPECT_DEFAULT": "Predeterminada", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 1c432081e..812cb01dd 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Pistes audio", "SUBTITLES": "Sous-titres", "SUBTITLES_OFF": "Désactivés", + "SUBTITLES_ON": "Activés", "QUALITY": "Qualité", "QUALITY_AUTO": "Auto", "TRACK_DEFAULT": "Par défaut", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Sous-titre {{index}}", "PLAYBACK_SPEED": "Vitesse de lecture", "SPEED_TOOLTIP": "Vitesse : {{speed}}", + "SUBTITLES_TOOLTIP": "Sous-titres : {{subtitles}}", "ASPECT_RATIO": "Format d'image", "ASPECT_TOOLTIP": "Format : {{aspect}}", "ASPECT_DEFAULT": "Par défaut", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index f62ce758d..6027cebbf 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Hangsávok", "SUBTITLES": "Feliratok", "SUBTITLES_OFF": "Kikapcsolva", + "SUBTITLES_ON": "Bekapcsolva", "QUALITY": "Minőség", "QUALITY_AUTO": "Automatikus", "TRACK_DEFAULT": "Alapértelmezett", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "{{index}}. feliratsáv", "PLAYBACK_SPEED": "Lejátszási sebesség", "SPEED_TOOLTIP": "Sebesség: {{speed}}", + "SUBTITLES_TOOLTIP": "Feliratok: {{subtitles}}", "ASPECT_RATIO": "Képarány", "ASPECT_TOOLTIP": "Képarány: {{aspect}}", "ASPECT_DEFAULT": "Alapértelmezett", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 78aee97ab..a978c7560 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Tracce audio", "SUBTITLES": "Sottotitoli", "SUBTITLES_OFF": "Disattivati", + "SUBTITLES_ON": "Attivati", "QUALITY": "Qualità", "QUALITY_AUTO": "Automatica", "TRACK_DEFAULT": "Predefinita", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Sottotitoli {{index}}", "PLAYBACK_SPEED": "Velocità di riproduzione", "SPEED_TOOLTIP": "Velocità: {{speed}}", + "SUBTITLES_TOOLTIP": "Sottotitoli: {{subtitles}}", "ASPECT_RATIO": "Proporzioni", "ASPECT_TOOLTIP": "Proporzioni: {{aspect}}", "ASPECT_DEFAULT": "Predefinite", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index ff22ff6ad..e0ea48c88 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "音声トラック", "SUBTITLES": "字幕", "SUBTITLES_OFF": "オフ", + "SUBTITLES_ON": "オン", "QUALITY": "画質", "QUALITY_AUTO": "自動", "TRACK_DEFAULT": "デフォルト", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}", "PLAYBACK_SPEED": "再生速度", "SPEED_TOOLTIP": "速度:{{speed}}", + "SUBTITLES_TOOLTIP": "字幕:{{subtitles}}", "ASPECT_RATIO": "アスペクト比", "ASPECT_TOOLTIP": "アスペクト比:{{aspect}}", "ASPECT_DEFAULT": "デフォルト", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 5065701fa..5c2adf2b7 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "오디오 트랙", "SUBTITLES": "자막", "SUBTITLES_OFF": "끄기", + "SUBTITLES_ON": "켜기", "QUALITY": "화질", "QUALITY_AUTO": "자동", "TRACK_DEFAULT": "기본", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "자막 {{index}}", "PLAYBACK_SPEED": "재생 속도", "SPEED_TOOLTIP": "속도: {{speed}}", + "SUBTITLES_TOOLTIP": "자막: {{subtitles}}", "ASPECT_RATIO": "화면 비율", "ASPECT_TOOLTIP": "비율: {{aspect}}", "ASPECT_DEFAULT": "기본", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 60f8a51c1..aebe8cc35 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Audiosporen", "SUBTITLES": "Ondertiteling", "SUBTITLES_OFF": "Uit", + "SUBTITLES_ON": "Aan", "QUALITY": "Kwaliteit", "QUALITY_AUTO": "Automatisch", "TRACK_DEFAULT": "Standaard", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Ondertitel {{index}}", "PLAYBACK_SPEED": "Afspeelsnelheid", "SPEED_TOOLTIP": "Snelheid: {{speed}}", + "SUBTITLES_TOOLTIP": "Ondertiteling: {{subtitles}}", "ASPECT_RATIO": "Beeldverhouding", "ASPECT_TOOLTIP": "Beeldverhouding: {{aspect}}", "ASPECT_DEFAULT": "Standaard", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 692a3df29..dfa56de3d 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Ścieżki audio", "SUBTITLES": "Napisy", "SUBTITLES_OFF": "Wyłączone", + "SUBTITLES_ON": "Włączone", "QUALITY": "Jakość", "QUALITY_AUTO": "Automatycznie", "TRACK_DEFAULT": "Domyślna", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Napisy {{index}}", "PLAYBACK_SPEED": "Prędkość odtwarzania", "SPEED_TOOLTIP": "Prędkość: {{speed}}", + "SUBTITLES_TOOLTIP": "Napisy: {{subtitles}}", "ASPECT_RATIO": "Proporcje obrazu", "ASPECT_TOOLTIP": "Proporcje: {{aspect}}", "ASPECT_DEFAULT": "Domyślne", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index aa718cf75..9c3fac933 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Faixas de áudio", "SUBTITLES": "Legendas", "SUBTITLES_OFF": "Desativadas", + "SUBTITLES_ON": "Ativadas", "QUALITY": "Qualidade", "QUALITY_AUTO": "Automática", "TRACK_DEFAULT": "Padrão", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Legenda {{index}}", "PLAYBACK_SPEED": "Velocidade de reprodução", "SPEED_TOOLTIP": "Velocidade: {{speed}}", + "SUBTITLES_TOOLTIP": "Legendas: {{subtitles}}", "ASPECT_RATIO": "Proporção da imagem", "ASPECT_TOOLTIP": "Proporção: {{aspect}}", "ASPECT_DEFAULT": "Padrão", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 0f6705621..1e6d37dc4 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Аудиодорожки", "SUBTITLES": "Субтитры", "SUBTITLES_OFF": "Выкл.", + "SUBTITLES_ON": "Вкл.", "QUALITY": "Качество", "QUALITY_AUTO": "Авто", "TRACK_DEFAULT": "По умолчанию", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}", "PLAYBACK_SPEED": "Скорость воспроизведения", "SPEED_TOOLTIP": "Скорость: {{speed}}", + "SUBTITLES_TOOLTIP": "Субтитры: {{subtitles}}", "ASPECT_RATIO": "Соотношение сторон", "ASPECT_TOOLTIP": "Соотношение: {{aspect}}", "ASPECT_DEFAULT": "По умолчанию", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index ab028b62c..863c33469 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "Ses parçaları", "SUBTITLES": "Altyazılar", "SUBTITLES_OFF": "Kapalı", + "SUBTITLES_ON": "Açık", "QUALITY": "Kalite", "QUALITY_AUTO": "Otomatik", "TRACK_DEFAULT": "Varsayılan", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "Altyazı {{index}}", "PLAYBACK_SPEED": "Oynatma hızı", "SPEED_TOOLTIP": "Hız: {{speed}}", + "SUBTITLES_TOOLTIP": "Altyazılar: {{subtitles}}", "ASPECT_RATIO": "En boy oranı", "ASPECT_TOOLTIP": "En boy oranı: {{aspect}}", "ASPECT_DEFAULT": "Varsayılan", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 355bc1d2e..f860f0818 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "音轨", "SUBTITLES": "字幕", "SUBTITLES_OFF": "关闭", + "SUBTITLES_ON": "开启", "QUALITY": "画质", "QUALITY_AUTO": "自动", "TRACK_DEFAULT": "默认", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}", "PLAYBACK_SPEED": "播放速度", "SPEED_TOOLTIP": "速度:{{speed}}", + "SUBTITLES_TOOLTIP": "字幕:{{subtitles}}", "ASPECT_RATIO": "宽高比", "ASPECT_TOOLTIP": "宽高比:{{aspect}}", "ASPECT_DEFAULT": "默认", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index c04e86a2f..ef606aab3 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -620,6 +620,7 @@ "AUDIO_TRACKS": "音軌", "SUBTITLES": "字幕", "SUBTITLES_OFF": "關閉", + "SUBTITLES_ON": "開啟", "QUALITY": "畫質", "QUALITY_AUTO": "自動", "TRACK_DEFAULT": "預設", @@ -627,6 +628,7 @@ "SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}", "PLAYBACK_SPEED": "播放速度", "SPEED_TOOLTIP": "速度:{{speed}}", + "SUBTITLES_TOOLTIP": "字幕:{{subtitles}}", "ASPECT_RATIO": "長寬比", "ASPECT_TOOLTIP": "長寬比:{{aspect}}", "ASPECT_DEFAULT": "預設", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index c1b0b53a3..20ebe310c 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -91,7 +91,9 @@ themes and fullscreen; frame-copy and built-in shared controls keep their light-on-dark overlay palette — the fixed `--pc-*` token set of the shared dock (accent blue, cyan, violet, the `--pc-live` / `--pc-danger` reds and a light text ramp), never the app theme. The overlay styles in -`player-controls/` never read a `--mat-sys-*` token. +`player-controls/` never read a `--mat-sys-*` token, and their keyboard focus +is a 2px `--pc-text` outline rather than Material's theme-coloured focus layer +(`player-controls-keyboard.e2e.ts` checks it in both themes). EPG timeline, list, empty states and programme details use the library-local `libs/ui/epg/src/lib/_epg-theme.scss` palette, based on app surfaces, separators, diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index fc4e4c615..4bbdd20fd 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -297,7 +297,8 @@ It owns only transient presentation behavior: - `ControlsSettings` — the settings panel's groups, on/modified state and open/close transitions (`controls-settings-groups.ts` holds the pure group-availability rule); -- `app-player-settings-panel` — the panel / bottom sheet presentation; +- `app-player-settings-panel` — the panel / bottom sheet presentation, + whose radio groups use `settings-radio-group.directive.ts`; - `ControlsUpNext` and `app-player-up-next-card` — the "Up next" card's gate and presentation; and - `controls-view-model.ts` — derived display state. @@ -346,7 +347,11 @@ app's `--app-selection-color` is a different blue that would fight the video. measured below 3:1 against the lighter fills. - **Icon buttons** are 40px with a 12px radius (32px / 9px compact) through Material's `--mat-icon-button-*` tokens; their hover is a flat - `rgba(255,255,255,.1)` layer. + `rgba(255,255,255,.1)` layer. Keyboard focus draws a 2px `--pc-text` + outline (`:focus-visible`), and Material's focus state layer is switched + off: its colour comes from the app theme, and the light theme's dark layer + left no visible focus on video. The settings panel's icon buttons use the + same ring, inset by 2px because the panel body clips at its edge. ### Timeline segments @@ -458,7 +463,10 @@ the panel the moment the last group disappears. - **Roomy wide dock** (≥ 960px): two **value chips** precede `tune` — subtitles (`closed_caption` + the selected track's label, or "Off") and speed - (`speed` + `1.25×`). Audio and aspect ratio have no chip: they are + (`speed` + `1.25×`). A chip's accessible name and tooltip carry its + value (`SUBTITLES_TOOLTIP` "Subtitles: English", `SPEED_TOOLTIP` + "Speed: 1.25×"); `tune` has `aria-haspopup="dialog"` and + `aria-expanded`. Audio and aspect ratio have no chip: they are panel-only. A chip click opens the panel **focused on its group** (`settingsFocus`; the group scrolls into view and wears a brief ring); right-click or long-press on the subtitle chip toggles subtitles without @@ -476,12 +484,34 @@ the panel the moment the last group disappears. picture and recording stay in the compact dock — the mock shows only `tune` + fullscreen there, but those two are engine features a viewer needs without opening anything. -- **Inside**: list groups (audio, subtitles, quality) use `menuitemradio` - rows with a check mark and a cyan selection; segmented groups (speed, - aspect) use `radio` items with a violet selection, and a selected default - (`1×`, the first aspect preset) stays neutral. The subtitle group carries - the load-file action and the delay / size / color sections that the - popover used to hold (same `player-controls-load-subtitle`, +- **Inside**: every choice is a `radio` in a `radiogroup` + (`SettingsRadioGroupDirective` / `SettingsRadioDirective`). List groups + (audio, subtitles, quality) are rows with a check mark and a cyan + selection; segmented groups (speed, aspect, subtitle size) have a violet + selection, and a selected default (`1×`, the first aspect preset) stays + neutral. Each group is one Tab stop — the checked option as the engine + reports it, else the first — and arrows, Home and End move focus with a + CDK `FocusKeyManager` (wrapping; the horizontal arrows follow `direction`) + and check the option they reach, as a native radio group does: the + directive clicks it, so the template's handler applies the choice, even + onto an option the engine still reports as checked: returning to it must + cancel a switch that is still pending. Focus + changes also write the roving `tabindex` immediately, because a quick + Shift+Tab, Tab can arrive before change detection updates the bindings. The dialog is named by its `h2` title (`aria-labelledby`), each + radio group by its `h3` group heading or `h4` subheading, and the delay + buttons form a labelled `group`. Headings read `--pc-text-secondary` on + `--pc-glass-bg-dense` (`rgba(12,16,23,.86)`), 4.5:1 or more even over a + white frame. They wrap with `overflow-wrap: anywhere` and `hyphens: auto`, + so one long word (German "Wiedergabegeschwindigkeit") breaks inside the + sheet's 84px heading column. Hyphenation needs a `lang` on ``. + With subtitles on but no track marked selected yet (the engine can report + the switch before the track list), the subtitle chip reads "On" + (`SUBTITLES_ON`), never "Off". + Every focused option shows the `--pc-text` ring; a selected swatch has a + white border with a dark inner gap, and focus adds an outer ring. The + subtitle group carries the load-file action (a plain button outside the + radio group) and the delay / size / color sections that the popover used + to hold (same `player-controls-load-subtitle`, `player-controls-subtitle-delay`, `player-controls-subtitle-style` test ids). The panel is a `role="dialog"` with `tabindex="-1"`: opened from the keyboard (the opener is `:focus-visible`) it takes focus, a pointer @@ -1639,6 +1669,7 @@ libs/ui/playback/src/lib/player-controls/ ├── player-settings-panel.component.ts ├── player-settings-panel.component.html ├── player-settings-panel.component.scss +├── settings-radio-group.directive.ts ├── controls-fullscreen.ts ├── controls-menu-selection.ts ├── controls-menu-state.ts diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html index f695c4453..fcfc38f57 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -366,6 +366,16 @@ settings.available() && layout.roomy() && !settings.isOpen() ) { @if (settings.groups().subtitles) { + + @let subtitleValue = + settings.subtitleLabel() ?? + ((settings.subtitlesOn() + ? 'EMBEDDED_MPV.PLAYER.SUBTITLES_ON' + : 'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' + ) | translate); } @@ -407,7 +417,9 @@ data-test-id="player-controls-speed-chip" (click)="settings.open('speed')" [attr.aria-label]=" - 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate + 'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP' + | translate + : { speed: settings.speedLabel() } " [matTooltip]=" 'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP' @@ -434,6 +446,7 @@ " data-test-id="player-controls-settings-button" (click)="settings.toggle()" + aria-haspopup="dialog" [attr.aria-expanded]="settings.isOpen()" [attr.aria-label]=" 'EMBEDDED_MPV.PLAYER.SETTINGS' | translate diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss index e721e8735..7bd311009 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -27,6 +27,9 @@ --pc-track: rgba(255, 255, 255, 0.18); --pc-hover: rgba(255, 255, 255, 0.1); --pc-glass-bg: rgba(12, 16, 23, 0.72); + // The settings panel carries small secondary text over whatever frame is + // playing: denser glass keeps it at 4.5:1 even over a white frame. + --pc-glass-bg-dense: rgba(12, 16, 23, 0.86); --pc-glass-border: rgba(255, 255, 255, 0.09); --pc-font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace; @@ -493,10 +496,20 @@ --mat-icon-button-state-layer-size: 40px; --mat-icon-button-container-shape: 12px; --mat-icon-button-hover-state-layer-opacity: 0; + // The ring below is the focus indicator; Material's focus layer would + // take its colour from the app theme. + --mat-icon-button-focus-state-layer-opacity: 0; border-radius: 12px; transition: background 120ms ease-out; } +// Keyboard focus in the overlay's own text colour, so it shows on video in +// both app themes. The active `tune` button keeps its accent fill under it. +:host :is(button[mat-icon-button]:focus-visible) { + outline: 2px solid var(--pc-text); + outline-offset: 2px; +} + :host :is(button[mat-icon-button]) mat-icon { color: var(--pc-text); } diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts index aafb3ff6b..7aebb3175 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts @@ -219,7 +219,9 @@ describe('PlayerControlsComponent settings panel', () => { 'player-controls__settings--sheet' ); expect(panel?.getAttribute('role')).toBe('dialog'); - expect(panel?.getAttribute('aria-label')).toBe('Settings'); + const title = panel?.querySelector('h2'); + expect(panel?.getAttribute('aria-labelledby')).toBe(title?.id); + expect(title?.textContent?.trim()).toBe('Settings'); expect( query('[data-test-id="player-settings-speed"]')?.classList ).toContain('player-settings__group--focused'); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts index 09585bc5e..c33b77337 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts @@ -99,12 +99,14 @@ describe('PlayerControlsComponent subtitle settings', () => { }); it('renders the subtitle chip with zero tracks when external loading exists', () => { - expect(query('[aria-label="Subtitles"]')).toBeNull(); + const chip = () => + query('[data-test-id="player-controls-subtitle-chip"]'); + expect(chip()).toBeNull(); setCapabilities({ externalSubtitles: true }); fixture.detectChanges(); - expect(query('[aria-label="Subtitles"]')).not.toBeNull(); + expect(chip()).not.toBeNull(); openSubtitleMenu(); // No track list entries: no Off row without a selectable track… expect(query('.player-settings__option--selected')).toBeNull(); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts index 62bee3157..ac40ad085 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.palette.spec.ts @@ -30,6 +30,8 @@ const STYLE_SOURCES = new Map( const HOST_STYLES = STYLE_SOURCES.get('player-controls.component.scss') ?? ''; const TIMELINE_STYLES = STYLE_SOURCES.get('player-timeline.component.scss') ?? ''; +const SETTINGS_STYLES = + STYLE_SOURCES.get('player-settings-panel.component.scss') ?? ''; type Rgb = [number, number, number]; @@ -52,6 +54,24 @@ function paletteColor(token: string): { rgb: Rgb; alpha: number } { throw new Error(`${token} is not a literal palette colour: "${value}"`); } +/** A translucent palette colour composited over an opaque frame. */ +function over(color: { rgb: Rgb; alpha: number }, frame: Rgb): Rgb { + return color.rgb.map( + (channel, index) => + channel * color.alpha + frame[index] * (1 - color.alpha) + ) as Rgb; +} + +/** The declarations of the first rule whose selector list is exactly `selector`. */ +function ruleBody(source: string, selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return ( + source.match( + new RegExp(`(?:^|[}\\s])${escaped}\\s*\\{([^}]*)\\}`) + )?.[1] ?? '' + ); +} + function contrastRatio(foreground: Rgb, background: Rgb): number { const luminance = (rgb: Rgb): number => { const [red, green, blue] = rgb.map((channel) => { @@ -130,6 +150,67 @@ describe('player controls overlay palette', () => { ); }); + it('keeps the settings headings at 4.5:1 on the panel glass over bright frames', () => { + const midGrey: Rgb = [128, 128, 128]; + const white: Rgb = [255, 255, 255]; + const glass = paletteColor('--pc-glass-bg-dense'); + const secondary = paletteColor('--pc-text-secondary').rgb; + + expect(ruleBody(SETTINGS_STYLES, ':host')).toMatch( + /background:\s*var\(--pc-glass-bg-dense\b/ + ); + expect( + ruleBody( + SETTINGS_STYLES, + '.player-settings__heading,\n.player-settings__subheading' + ) + ).toMatch(/color:\s*var\(--pc-text-secondary\b/); + expect(SETTINGS_STYLES).not.toMatch( + /__(sub)?heading\s*\{[^}]*--pc-text-tertiary/ + ); + for (const frame of [midGrey, white]) { + expect( + contrastRatio(secondary, over(glass, frame)) + ).toBeGreaterThanOrEqual(4.5); + } + // The tertiary step the headings used to read failed on mid-grey. + expect( + contrastRatio( + paletteColor('--pc-text-tertiary').rgb, + over(paletteColor('--pc-glass-bg'), midGrey) + ) + ).toBeLessThan(4.5); + }); + + it('draws keyboard focus on icon buttons in the overlay text colour', () => { + const ring = /outline:\s*2px solid var\(--pc-text[,)]/; + for (const source of [HOST_STYLES, SETTINGS_STYLES]) { + expect( + ruleBody( + source, + ':host :is(button[mat-icon-button]:focus-visible)' + ) + ).toMatch(ring); + // Material's focus layer takes the app theme's colour. + expect( + ruleBody(source, ':host :is(button[mat-icon-button])') + ).toMatch(/--mat-icon-button-focus-state-layer-opacity:\s*0;/); + } + }); + + it('tells a focused selected swatch from one that is only selected', () => { + const selected = ruleBody( + SETTINGS_STYLES, + '.player-settings__swatch--selected' + ); + const focused = ruleBody( + SETTINGS_STYLES, + '.player-settings__swatch:focus-visible' + ); + expect(selected).not.toMatch(/outline/); + expect(focused).toMatch(/outline:\s*2px solid var\(--pc-text[,)]/); + }); + it('keeps the palette reds readable on video', () => { const white: Rgb = [255, 255, 255]; const glass = paletteColor('--pc-glass-bg'); diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.a11y.spec.ts b/libs/ui/playback/src/lib/player-controls/player-settings-panel.a11y.spec.ts new file mode 100644 index 000000000..1e2e25b98 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.a11y.spec.ts @@ -0,0 +1,355 @@ +import { WritableSignal, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + DEFAULT_PLAYER_CAPABILITIES, + createEmptyControlsState, +} from './player-controls-defaults'; +import { PlayerControlsComponent } from './player-controls.component'; +import type { + PlayerControlsCapabilities, + PlayerControlsCommands, + PlayerControlsState, + PlayerController, +} from './player-controls.model'; + +function createFakeController() { + const capabilities: WritableSignal = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + audioTracks: true, + subtitles: true, + externalSubtitles: true, + subtitleDelay: true, + subtitleStyle: true, + qualityLevels: true, + playbackSpeed: true, + aspectRatio: true, + }); + const state: WritableSignal = signal({ + ...createEmptyControlsState(), + audioTracks: [ + { id: 1, label: 'English', selected: true }, + { id: 2, label: 'German', selected: false }, + ], + subtitleTracks: [{ id: 5, label: 'Russian', selected: false }], + qualityLevels: [ + { id: 0, label: '720p', selected: false }, + { id: 1, label: '1080p', selected: false }, + ], + }); + const commands = { + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + } as unknown as jest.Mocked; + const controller: PlayerController = { capabilities, state, commands }; + return { controller, capabilities, state, commands }; +} + +const KEY_CODES: Record = { + ArrowLeft: 37, + ArrowUp: 38, + ArrowRight: 39, + ArrowDown: 40, + Home: 36, + End: 35, +}; + +/** A real key press carries its legacy `keyCode`, which CDK key managers read. */ +function press(target: HTMLElement, key: string): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'keyCode', { value: KEY_CODES[key] }); + target.dispatchEvent(event); + return event; +} + +/** + * The settings panel as assistive technology and the keyboard meet it: a + * dialog named by its heading, radio groups named by theirs with one Tab + * stop each, arrow keys that move focus without applying, and dock chips + * whose names carry their values. + */ +describe('PlayerSettingsPanelComponent accessibility', () => { + const originalResizeObserver = globalThis.ResizeObserver; + let fixture: ComponentFixture; + let component: PlayerControlsComponent; + let fake: ReturnType; + + const query = (selector: string) => + fixture.nativeElement.querySelector(selector) as HTMLElement | null; + const radios = (group: string) => + Array.from( + fixture.nativeElement.querySelectorAll( + `[data-test-id="player-settings-${group}"] [role="radio"]` + ) as NodeListOf + ); + // List rows carry a check icon beside their label, segments are text + // and colour swatches are named by `aria-label`. + const label = (radio: Element) => + radio.getAttribute('aria-label') ?? + (radio.querySelector('span') ?? radio).textContent?.trim(); + const tabStops = (group: string) => + radios(group) + .filter((radio) => radio.getAttribute('tabindex') === '0') + .map(label); + const openPanel = () => { + component.settings.open(); + fixture.detectChanges(); + }; + + beforeEach(async () => { + globalThis.ResizeObserver = class { + observe(): void { + /* noop */ + } + unobserve(): void { + /* noop */ + } + disconnect(): void { + /* noop */ + } + } as unknown as typeof ResizeObserver; + localStorage.removeItem('volume'); + await TestBed.configureTestingModule({ + imports: [PlayerControlsComponent, TranslateModule.forRoot()], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + EMBEDDED_MPV: { + PLAYER: { + SETTINGS: 'Settings', + AUDIO_TRACKS: 'Audio tracks', + SUBTITLES: 'Subtitles', + SUBTITLES_OFF: 'Off', + SUBTITLES_ON: 'On', + SUBTITLES_TOOLTIP: 'Subtitles: {{subtitles}}', + SUBTITLE_DELAY: 'Subtitle delay', + SUBTITLE_SIZE: 'Subtitle size', + SUBTITLE_COLOR: 'Subtitle color', + SUBTITLE_COLOR_DEFAULT: 'Default', + QUALITY: 'Quality', + QUALITY_AUTO: 'Auto', + PLAYBACK_SPEED: 'Playback speed', + SPEED_TOOLTIP: 'Speed: {{speed}}', + ASPECT_RATIO: 'Aspect ratio', + ASPECT_DEFAULT: 'Default', + }, + }, + }); + translate.use('en'); + + fake = createFakeController(); + fixture = TestBed.createComponent(PlayerControlsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('controller', fake.controller); + fixture.detectChanges(); + document.body.appendChild(fixture.nativeElement); + }); + + afterEach(() => { + fixture.nativeElement.remove(); + fixture.destroy(); + globalThis.ResizeObserver = originalResizeObserver; + }); + + it('names the dialog and every radio group by a real heading', () => { + openPanel(); + const panel = query('[data-test-id="player-controls-settings-panel"]'); + const title = panel?.querySelector('h2'); + expect(panel?.getAttribute('aria-labelledby')).toBe(title?.id); + expect(title?.textContent?.trim()).toBe('Settings'); + + const groups = Array.from( + panel?.querySelectorAll('[role="radiogroup"]') ?? [] + ).map((group) => { + const heading = document.getElementById( + group.getAttribute('aria-labelledby') ?? '' + ); + return `${heading?.tagName} ${heading?.textContent?.trim()}`; + }); + expect(groups).toEqual([ + 'H3 Audio tracks', + 'H3 Subtitles', + 'H4 Subtitle size', + 'H4 Subtitle color', + 'H3 Quality', + 'H3 Playback speed', + 'H3 Aspect ratio', + ]); + expect( + document.getElementById( + query( + '[data-test-id="player-controls-subtitle-delay"] [role="group"]' + )?.getAttribute('aria-labelledby') ?? '' + )?.textContent + ).toContain('Subtitle delay'); + }); + + it('offers radios only, with the load-file action outside the group', () => { + openPanel(); + const panel = query( + '[data-test-id="player-controls-settings-panel"]' + ) as HTMLElement; + + expect(panel.querySelectorAll('[role^="menuitem"]')).toHaveLength(0); + const radioParents = Array.from( + panel.querySelectorAll('[role="radio"]') + ).map((radio) => radio.parentElement?.getAttribute('role')); + expect(radioParents.length).toBeGreaterThan(0); + expect(new Set(radioParents)).toEqual(new Set(['radiogroup'])); + + const load = query('[data-test-id="player-controls-load-subtitle"]'); + expect(load?.hasAttribute('role')).toBe(false); + expect(load?.closest('[role="radiogroup"]')).toBeNull(); + }); + + it('gives each group one Tab stop on its checked option, else the first', () => { + fake.state.update((state) => ({ ...state, playbackSpeed: 1.1 })); + openPanel(); + + expect(tabStops('audio')).toEqual(['English']); + // Tracks, then the subtitle size and colour groups of the section. + expect(tabStops('subtitles')).toEqual(['Off', '100%', 'Default']); + expect(tabStops('quality')).toEqual(['Auto']); + // No preset matches 1.1×: the first option keeps the group reachable. + expect(tabStops('speed')).toEqual(['0.5×']); + expect(tabStops('aspect')).toEqual(['Default']); + }); + + it('moves focus with the arrow keys, Home and End and checks the option reached', () => { + openPanel(); + const checked = radios('speed').find( + (radio) => radio.getAttribute('aria-checked') === 'true' + ) as HTMLButtonElement; + checked.focus(); + expect(checked.textContent?.trim()).toBe('1×'); + + expect(press(checked, 'ArrowRight').defaultPrevented).toBe(true); + fixture.detectChanges(); + expect(document.activeElement?.textContent?.trim()).toBe('1.25×'); + expect(tabStops('speed')).toEqual(['1.25×']); + + // The ends wrap, as in a native radio group. + const reached = [ + 'ArrowDown', + 'ArrowUp', + 'ArrowLeft', + 'End', + 'ArrowRight', + 'ArrowLeft', + 'Home', + ].map((key) => { + press(document.activeElement as HTMLElement, key); + return document.activeElement?.textContent?.trim(); + }); + expect(reached).toEqual([ + '1.5×', + '1.25×', + '1×', + '2×', + '0.5×', + '2×', + '0.5×', + ]); + // Every move applies its option, 1× included: the (fake) engine + // still reports 1× checked, but 1.25× is the pending request. + expect( + fake.commands.setPlaybackSpeed.mock.calls.map(([speed]) => speed) + ).toEqual([1.25, 1.5, 1.25, 1, 2, 0.5, 2, 0.5]); + }); + + it('keeps arrow keys inside their own group', () => { + openPanel(); + const [english, german] = radios('audio'); + english.focus(); + press(english, 'ArrowDown'); + expect(document.activeElement).toBe(german); + press(german, 'ArrowDown'); + expect(document.activeElement).toBe(english); + // Returning before the engine confirms the first switch applies + // English again; otherwise the pending switch would win. + expect(fake.commands.setAudioTrack.mock.calls).toEqual([[2], [1]]); + expect(fake.commands.setPlaybackSpeed).not.toHaveBeenCalled(); + }); + + it('hands the Tab stop back to the reported checked option when focus leaves', () => { + // The fake engine never confirms the switch the arrow key requested, + // so English stays the checked track. + openPanel(); + const [english, german] = radios('audio'); + english.focus(); + press(english, 'ArrowDown'); + fixture.detectChanges(); + expect(tabStops('audio')).toEqual(['German']); + expect(german.tabIndex).toBe(0); + + query('[data-test-id="player-settings-close"]')?.focus(); + fixture.detectChanges(); + expect(tabStops('audio')).toEqual(['English']); + }); + + it('reverses the horizontal arrows in a right-to-left layout', () => { + openPanel(); + const speed = radios('speed'); + (speed[0].parentElement as HTMLElement).style.direction = 'rtl'; + speed[2].focus(); + + press(speed[2], 'ArrowLeft'); + expect(document.activeElement).toBe(speed[3]); + press(speed[3], 'ArrowRight'); + expect(document.activeElement).toBe(speed[2]); + }); + + it('names the chips with their current values', () => { + const subtitleChip = () => + query('[data-test-id="player-controls-subtitle-chip"]'); + const speedChip = () => + query('[data-test-id="player-controls-speed-chip"]'); + expect(subtitleChip()?.getAttribute('aria-label')).toBe( + 'Subtitles: Off' + ); + expect(speedChip()?.getAttribute('aria-label')).toBe('Speed: 1×'); + + fake.state.update((state) => ({ + ...state, + subtitleTracks: [{ id: 5, label: 'Russian', selected: true }], + subtitlesEnabled: true, + playbackSpeed: 1.25, + })); + fixture.detectChanges(); + + expect(subtitleChip()?.getAttribute('aria-label')).toBe( + 'Subtitles: Russian' + ); + expect(speedChip()?.getAttribute('aria-label')).toBe('Speed: 1.25×'); + }); + + it('names the subtitle chip "On" while no enabled track is marked selected', () => { + // The engine can report subtitles on before the track list marks the + // selected track; the chip must not claim they are off. + fake.state.update((state) => ({ + ...state, + subtitleTracks: [{ id: 5, label: 'Russian', selected: false }], + subtitlesEnabled: true, + })); + fixture.detectChanges(); + + const chip = query('[data-test-id="player-controls-subtitle-chip"]'); + expect(chip?.getAttribute('aria-label')).toBe('Subtitles: On'); + expect( + chip?.querySelector('.player-controls__chip-label')?.textContent + ).toContain('On'); + }); + + it('announces that the tune button opens a dialog', () => { + const tune = query('[data-test-id="player-controls-settings-button"]'); + expect(tune?.getAttribute('aria-haspopup')).toBe('dialog'); + expect(tune?.getAttribute('aria-expanded')).toBe('false'); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html index f58ac738b..f6692d6ce 100644 --- a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html @@ -2,7 +2,9 @@ }
-
{{ title() }}
+

+ {{ title() }} +

- } - @for (track of state().subtitleTracks; track track.id) { - - } - @if (capabilities().externalSubtitles) { - - } -
+ + @if (state().subtitleTracks.length > 0) { +
+ @if (capabilities().subtitles) { + + } + @for (track of state().subtitleTracks; track track.id) { + + } +
+ } + @if (capabilities().externalSubtitles) { + + + } @if (capabilities().subtitleDelay) {
-
+

{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY' | translate }} -

-
+ +
-
+

{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate }} -

+
@for ( preset of subtitleColorPresets; @@ -262,8 +266,10 @@ ) {