mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(playback): keep Video.js vendor-chrome shortcuts after a mouse click on a control (#1523)
Follow-up to #1516 for the vendor-chrome path (shared controls opted out). With Video.js's own controls, Chromium leaves a clicked control-bar button focused, and a focused Video.js component captures the keyboard entirely, so after clicking fullscreen Space left fullscreen instead of pausing and the seek, volume and mute keys did nothing until the user clicked the video. ArtPlayer and the native HTML5 controls were verified unaffected. The legacy Video.js chrome now releases the focus a pointer interaction leaves on a control (vjs-pointer-focus-release.ts). The release is scoped to the .vjs-control-bar and pointer-attributed, and runs on both focusin (focus landing on a control, e.g. a menu handing focus to its button) and click (a control clicked while already focused, which fires no focusin); keyboard Tab focus and modal-dialog focus traps are preserved. The eligibility helper is shared with ControlsSurface via pointer-focus-release.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
1 parent
5a11b82eaf
commit
fe3c86394c
13 files changed
+902
-145
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: playback
|
||||
---
|
||||
|
||||
With IPTVnator's shared controls turned off, the Video.js player's keyboard
|
||||
shortcuts now work again right after you click a button in its control bar.
|
||||
Previously the clicked button kept the keyboard: after clicking fullscreen,
|
||||
Space left fullscreen instead of pausing, and the seek, volume and mute keys
|
||||
did nothing until you clicked the video.
|
||||
@@ -457,7 +457,27 @@ Key files:
|
||||
path keeps the existing Video.js skin and legacy series navigation unchanged
|
||||
(still without `userActions.hotkeys`), while the playback keyboard shortcuts
|
||||
attach through `LegacyPlayerShortcuts` and drive the player API so the
|
||||
vendor control bar stays in sync (`vjs-legacy-shortcuts.ts`).
|
||||
vendor control bar stays in sync (`vjs-legacy-shortcuts.ts`). That chrome
|
||||
also releases the focus a pointer interaction leaves on a control
|
||||
(`vjs-pointer-focus-release.ts`, sharing `pointer-focus-release.ts`'s
|
||||
`blurFocusedControl` with `ControlsSurface`): a focused Video.js component
|
||||
stops every key before the document and turns Space/Enter into a click, so
|
||||
after a click on fullscreen Space left fullscreen instead of pausing. It is
|
||||
driven mainly by `focusin`, not the click, because choosing a menu item
|
||||
moves focus to the menu button a tick later and that click never bubbles to
|
||||
the shell: an eligible control (button/`role=button`/slider, never a menu
|
||||
item) is released when its focus is attributable to a recent shell
|
||||
`pointerdown` not yet ended by a document `keydown`, so `Tab` focus is kept.
|
||||
A `click` runs the same release for a control clicked while already focused
|
||||
(Tab, then a mouse click), which fires no `focusin`. The release is scoped
|
||||
to `.vjs-control-bar`, so the caption-settings dialog (a modal sibling of
|
||||
the bar) keeps its focus trap. Menu buttons live in the bar and are not
|
||||
exempt: a popup is navigated through its focused item, so releasing the
|
||||
button never disturbs an open menu, and the button focus a pointer moves
|
||||
through (open, item selection, toggling an open menu shut) is released so
|
||||
Space works again after the menu closes. ArtPlayer
|
||||
(non-focusable divs) and the native HTML5 controls (focus lands on the
|
||||
`<video>`) need no counterpart.
|
||||
- ArtPlayer is the fourth guarded consumer. `ArtPlayerComponent` provides a
|
||||
component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns
|
||||
HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and
|
||||
|
||||
@@ -1130,7 +1130,7 @@ engine` (restart required) or
|
||||
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
|
||||
contracts: `docs/architecture/embedded-mpv-native.md` and
|
||||
`tools/embedded-mpv/README.md`.
|
||||
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Only keyboard-originated focus pins the bar open: Chromium also focuses a clicked `<button>`, so `ControlsSurface.wasPointerInteraction` attributes a `focusin` to a recent `pointerdown` inside the focused element and such focus reveals without blocking auto-hide (otherwise the fullscreen button left the controls on screen until a click-to-pause on the viewport); the press record is discarded on the first bar focus event or any `keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a `keydown` bubbling out of a bar control re-pins it, since operating a focused control produces no focus event. A completed pointer click then releases the focus it left on the control (`onBarClick` → `ControlsSurface.releasePointerFocus`, attributed by `wasPointerClick`: non-empty click `pointerType`, else a recent press inside the clicked element): a focused control captures the keyboard — Space and Enter re-activated the clicked button and `ControlsShortcuts` yields to any interactive element in the key's path, so after a click on fullscreen Space left fullscreen instead of pausing. Keyboard activation (empty `pointerType`) keeps focus, only buttons and range sliders are released, Chromium keeps its sequential-focus starting point at the blurred control so Tab continues from it, and the volume popover ignores the release's `focusout` (`wasPointerFocusRelease`). Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the Line truncated
|
||||
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Only keyboard-originated focus pins the bar open: Chromium also focuses a clicked `<button>`, so `ControlsSurface.wasPointerInteraction` attributes a `focusin` to a recent `pointerdown` inside the focused element and such focus reveals without blocking auto-hide (otherwise the fullscreen button left the controls on screen until a click-to-pause on the viewport); the press record is discarded on the first bar focus event or any `keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a `keydown` bubbling out of a bar control re-pins it, since operating a focused control produces no focus event. A completed pointer click then releases the focus it left on the control (`onBarClick` → `ControlsSurface.releasePointerFocus`, attributed by `wasPointerClick`: non-empty click `pointerType`, else a recent press inside the clicked element): a focused control captures the keyboard — Space and Enter re-activated the clicked button and `ControlsShortcuts` yields to any interactive element in the key's path, so after a click on fullscreen Space left fullscreen instead of pausing. Keyboard activation (empty `pointerType`) keeps focus, only buttons and range sliders are released, Chromium keeps its sequential-focus starting point at the blurred control so Tab continues from it, and the volume popover ignores the release's `focusout` (`wasPointerFocusRelease`). Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the Line truncated
|
||||
- Shared web picture-in-picture stays inside that default-on rollout.
|
||||
`PlayerController` exposes capability `pictureInPicture`, state
|
||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||
|
||||
+208
-121
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { expect, test } from './fixtures';
|
||||
import { setInputValue } from './e2e-helpers';
|
||||
import {
|
||||
@@ -929,6 +929,159 @@ function formatXtreamDateTime(timestampSeconds: number): string {
|
||||
// now the app-web-player-view host, which spans all applications of one mount.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline series playback helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serve a tiny VP8 clip for every episode stream. Chromium ships no
|
||||
* proprietary codecs, so the clip stands in for the mock's external HLS
|
||||
* redirect; the browser sniffs the WebM container from the bytes. Registered
|
||||
* after the beforeEach proxy route, so it runs first and fetches from the
|
||||
* mock itself (the mock ignores the url parameter).
|
||||
*/
|
||||
async function routeEpisodeClip(page: Page): Promise<void> {
|
||||
const episodeClip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === MOCK_SERVER && url.pathname.startsWith('/series/'),
|
||||
async (route) => {
|
||||
// Chromium's media pipeline seeks through byte ranges; a plain
|
||||
// 200 to a Range request clamps every seek to the start, which
|
||||
// would defeat end-of-episode steps.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = episodeClip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, episodeClip.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: {
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
'content-type': 'video/webm',
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${episodeClip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: episodeClip.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mock's episodes are .mkv, which the HTML5 player would hand to hls.js;
|
||||
* get_series_info is rewritten to .mp4 — the extension the player gives to
|
||||
* the native source path.
|
||||
*/
|
||||
async function rewriteSeriesEpisodesToMp4(page: Page): Promise<void> {
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const original = new URL(route.request().url());
|
||||
if (original.searchParams.get('action') !== 'get_series_info') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
original.searchParams.forEach((value, key) => {
|
||||
if (key !== 'targetId') {
|
||||
mockUrl.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
const response = await route.fetch({ url: mockUrl.toString() });
|
||||
const body = (await response.json()) as {
|
||||
payload: {
|
||||
episodes?: Record<
|
||||
string,
|
||||
Array<{ container_extension: string }>
|
||||
>;
|
||||
};
|
||||
};
|
||||
for (const episodes of Object.values(body.payload.episodes ?? {})) {
|
||||
for (const episode of episodes) {
|
||||
episode.container_extension = 'mp4';
|
||||
}
|
||||
}
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist the web player engine and the shared-controls preference. */
|
||||
async function selectWebPlayer(
|
||||
page: Page,
|
||||
engine: string,
|
||||
sharedControls = true
|
||||
): Promise<void> {
|
||||
await page.goto('/workspace/settings/playback');
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page.getByRole('option', { name: engine, exact: true }).click();
|
||||
const toggle = page.locator(
|
||||
'[data-test-id="web-player-shared-controls-toggle"]'
|
||||
);
|
||||
if ((await toggle.locator('input').isChecked()) !== sharedControls) {
|
||||
await toggle.click();
|
||||
}
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
|
||||
/** Add the mock portal and start the first episode of the first series. */
|
||||
async function playFirstSeriesEpisode(
|
||||
page: Page,
|
||||
request: APIRequestContext
|
||||
): Promise<{ playerView: Locator; video: Locator }> {
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
const category = categories[0];
|
||||
const seriesItems = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
|
||||
)
|
||||
).json()) as Array<{ name: string; series_id: number }>;
|
||||
const targetSeries = seriesItems[0];
|
||||
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
|
||||
const categoryItem = page
|
||||
.locator('.context-panel .category-item')
|
||||
.filter({ hasText: category.category_name })
|
||||
.first();
|
||||
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
|
||||
await categoryItem.click();
|
||||
const seriesCard = page
|
||||
.locator('app-grid-list mat-card')
|
||||
.filter({ hasText: targetSeries.name })
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
const episodeCards = page.locator('.episode-card');
|
||||
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
|
||||
await episodeCards.first().click();
|
||||
|
||||
const playerView = page.locator(
|
||||
'app-portal-inline-player app-web-player-view'
|
||||
);
|
||||
return { playerView, video: playerView.locator('video') };
|
||||
}
|
||||
|
||||
test.describe('@xtream inline series fullscreen', () => {
|
||||
// No autoplay-policy flag is needed: the episode click is a user
|
||||
// activation and the fixture clip carries no audio track.
|
||||
@@ -941,133 +1094,19 @@ test.describe('@xtream inline series fullscreen', () => {
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
// Chromium ships no proprietary codecs, so a tiny VP8 clip stands in
|
||||
// for the mock's external HLS redirect; the browser sniffs the WebM
|
||||
// container from the bytes. The mock's episodes are .mkv, which the
|
||||
// HTML5 player would hand to hls.js, so get_series_info is rewritten
|
||||
// to .mp4 — the extension the player gives to the native source path.
|
||||
// Registered after the beforeEach proxy route, so it runs first and
|
||||
// fetches from the mock itself (the mock ignores the url parameter).
|
||||
const episodeClip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.origin === MOCK_SERVER &&
|
||||
url.pathname.startsWith('/series/'),
|
||||
async (route) => {
|
||||
// Chromium's media pipeline seeks through byte ranges; a
|
||||
// plain 200 to a Range request clamps every seek to the
|
||||
// start, which would defeat the end-of-episode step below.
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = episodeClip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, episodeClip.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: {
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
'content-type': 'video/webm',
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${episodeClip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: episodeClip.subarray(start, end + 1),
|
||||
});
|
||||
}
|
||||
);
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const original = new URL(route.request().url());
|
||||
if (original.searchParams.get('action') !== 'get_series_info') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
|
||||
original.searchParams.forEach((value, key) => {
|
||||
if (key !== 'targetId') {
|
||||
mockUrl.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
const response = await route.fetch({ url: mockUrl.toString() });
|
||||
const body = (await response.json()) as {
|
||||
payload: {
|
||||
episodes?: Record<
|
||||
string,
|
||||
Array<{ container_extension: string }>
|
||||
>;
|
||||
};
|
||||
};
|
||||
for (const episodes of Object.values(body.payload.episodes ?? {})) {
|
||||
for (const episode of episodes) {
|
||||
episode.container_extension = 'mp4';
|
||||
}
|
||||
}
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
|
||||
// Persist the HTML5 engine first: Video.js rejects the mock's
|
||||
// video/matroska source type before any bytes are sniffed.
|
||||
await page.goto('/workspace/settings/playback');
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page
|
||||
.getByRole('option', { name: 'HTML5 video player', exact: true })
|
||||
.click();
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
const category = categories[0];
|
||||
const seriesItems = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
|
||||
)
|
||||
).json()) as Array<{ name: string; series_id: number }>;
|
||||
const targetSeries = seriesItems[0];
|
||||
|
||||
await page.goto('/');
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
|
||||
const categoryItem = page
|
||||
.locator('.context-panel .category-item')
|
||||
.filter({ hasText: category.category_name })
|
||||
.first();
|
||||
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
|
||||
await categoryItem.click();
|
||||
const seriesCard = page
|
||||
.locator('app-grid-list mat-card')
|
||||
.filter({ hasText: targetSeries.name })
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
const episodeCards = page.locator('.episode-card');
|
||||
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
|
||||
await episodeCards.first().click();
|
||||
|
||||
const playerView = page.locator(
|
||||
'app-portal-inline-player app-web-player-view'
|
||||
await selectWebPlayer(page, 'HTML5 video player');
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-html-video-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const video = playerView.locator('video');
|
||||
const waitForMetadata = () =>
|
||||
expect
|
||||
.poll(() =>
|
||||
@@ -1132,3 +1171,51 @@ test.describe('@xtream inline series fullscreen', () => {
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'DOM fullscreen assertions target Chromium'
|
||||
);
|
||||
|
||||
test('keep working after a mouse click on a control-bar button', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeEpisodeClip(page);
|
||||
await rewriteSeriesEpisodesToMp4(page);
|
||||
// Video.js with the shared controls opted out: the vendor control bar.
|
||||
await selectWebPlayer(page, 'Video.js player', false);
|
||||
const { playerView, video } = await playFirstSeriesEpisode(
|
||||
page,
|
||||
request
|
||||
);
|
||||
await expect(playerView.locator('app-vjs-player')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(playerView.locator('app-player-controls')).toHaveCount(0);
|
||||
const paused = () =>
|
||||
video.evaluate((el) => (el as HTMLVideoElement).paused);
|
||||
const fullscreenOwner = () =>
|
||||
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
|
||||
await expect.poll(paused).toBe(false);
|
||||
|
||||
// Chromium leaves the clicked Video.js button focused, and a focused
|
||||
// Video.js component swallows every key; the legacy chrome releases
|
||||
// that focus so Space and M reach the playback shortcuts instead of
|
||||
// Space activating the button again (which left fullscreen while the
|
||||
// video kept playing).
|
||||
await playerView.hover();
|
||||
await playerView.locator('.vjs-fullscreen-control').click();
|
||||
await expect.poll(fullscreenOwner).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(true);
|
||||
expect(await fullscreenOwner()).not.toBeNull();
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(paused).toBe(false);
|
||||
await page.keyboard.press('m');
|
||||
await expect
|
||||
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).muted))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -429,7 +429,41 @@ straight to the engine:
|
||||
- **Video.js** (`vjs-legacy-shortcuts.ts`) goes through the player API so the
|
||||
vendor control bar stays in sync; F uses the player's
|
||||
`requestFullscreen`/`exitFullscreen`. The legacy configuration still never
|
||||
enables `userActions.hotkeys`.
|
||||
enables `userActions.hotkeys`. The chrome also releases the focus a pointer
|
||||
interaction leaves on a control (`vjs-pointer-focus-release.ts`, the vendor
|
||||
counterpart of `ControlsSurface.releasePointerFocus`, sharing
|
||||
`pointer-focus-release.ts`'s `blurFocusedControl`): Chromium focuses a
|
||||
clicked control-bar `<button>` or slider, and a focused Video.js component
|
||||
captures the keyboard entirely — `Component.handleKeyDown` stops the
|
||||
propagation of every key and `ClickableComponent` turns Space and Enter into
|
||||
a click — so after a click on the fullscreen button Space left fullscreen
|
||||
instead of pausing and the document-level shortcuts never saw a key. The
|
||||
release is driven mainly by the focus landing, not the click: choosing a
|
||||
menu item moves focus to the menu button a tick after the click
|
||||
(`MenuItem.handleTapClick`) and that selection click never bubbles to the
|
||||
shell, so a click handler alone would be both too early and unreached. A
|
||||
`focusin` on an eligible control (a `<button>`, `role="button"` clickable, or
|
||||
slider; never a `role="menuitem*"`) is released when it is attributable to a
|
||||
recent `pointerdown` inside the shell not yet ended by a document `keydown`,
|
||||
so keyboard `Tab` focus is preserved. A `click` runs the same release,
|
||||
because clicking a control that was already focused (Tab, then a mouse click
|
||||
on it) moves no focus and fires no `focusin`; a keyboard-activation click
|
||||
carries no `pointerdown`, so attribution keeps that focus. The release is
|
||||
scoped to the `.vjs-control-bar`, the persistent chrome that hands keys back
|
||||
to the document; the player's other focusable surfaces manage their own
|
||||
focus and keep it — in particular the caption-settings dialog
|
||||
(`.vjs-text-track-settings`, a modal sibling of the control bar under
|
||||
`.video-js`) traps focus for its Escape/Tab handling, so its Reset button is
|
||||
left alone. Menu buttons live in the control bar and are not exempt: a
|
||||
Video.js popup is navigated through its focused item, not its button, so
|
||||
releasing the button never disturbs an open menu — opening focuses the item,
|
||||
and the button focus a pointer moves through (the transient press on open,
|
||||
item selection, and toggling an open menu shut) is released, which is what
|
||||
lets Space work again after a menu is dismissed by clicking its button a
|
||||
second time. ArtPlayer needs no
|
||||
counterpart (its controls are non-focusable divs), nor do the native HTML5
|
||||
controls (a click focuses the `<video>`, which the shortcuts do not treat as
|
||||
interactive).
|
||||
- **ArtPlayer** (`art-player-legacy-shortcuts.ts`) uses the vendor setters its
|
||||
own hotkeys used (`toggle`, `forward`/`backward`, `volume`, `muted`,
|
||||
`fullscreen`), so ArtPlayer's notices and UI stay in sync. The legacy chrome
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
blurFocusedControl,
|
||||
clickPointerOrigin,
|
||||
} from './pointer-focus-release';
|
||||
|
||||
export interface ControlsSurfaceHandlers {
|
||||
/** Reveal the controls (pointer move / enter / click on the surface). */
|
||||
reveal: () => void;
|
||||
@@ -36,14 +41,6 @@ const POINTER_ATTRIBUTION_WINDOW_MS = 1000;
|
||||
|
||||
const INTERACTIVE_SELECTOR = 'button, input, [role="slider"]';
|
||||
|
||||
/**
|
||||
* Controls whose pointer-originated focus is released once the click that
|
||||
* produced it completes. Text entry is deliberately absent: a click into a
|
||||
* field must not end typing. The bar holds none today.
|
||||
*/
|
||||
const POINTER_FOCUS_RELEASE_SELECTOR =
|
||||
'button, input[type="range"], [role="slider"]';
|
||||
|
||||
interface PointerPress {
|
||||
at: number;
|
||||
target: EventTarget | null;
|
||||
@@ -221,9 +218,9 @@ export class ControlsSurface {
|
||||
* key press (`onDocumentKeyDown`).
|
||||
*/
|
||||
wasPointerClick(event: MouseEvent): boolean {
|
||||
const pointerType = (event as Partial<PointerEvent>).pointerType;
|
||||
if (typeof pointerType === 'string') {
|
||||
return pointerType !== '';
|
||||
const origin = clickPointerOrigin(event);
|
||||
if (origin !== null) {
|
||||
return origin;
|
||||
}
|
||||
const press = this.lastPointerDownClick;
|
||||
this.lastPointerDownClick = null;
|
||||
@@ -252,23 +249,16 @@ export class ControlsSurface {
|
||||
* a later Tab continues from it exactly as if it were still focused.
|
||||
* Handlers of the resulting `focusout` can recognize the release through
|
||||
* {@link wasPointerFocusRelease}. Returns whether focus was released.
|
||||
* The eligibility rules live in `pointer-focus-release.ts`, shared with
|
||||
* the vendor-chrome Video.js player.
|
||||
*/
|
||||
releasePointerFocus(root: HTMLElement): boolean {
|
||||
const active = root.ownerDocument.activeElement;
|
||||
if (
|
||||
!(active instanceof HTMLElement) ||
|
||||
!root.contains(active) ||
|
||||
!active.matches(POINTER_FOCUS_RELEASE_SELECTOR)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.pointerFocusRelease = true;
|
||||
try {
|
||||
active.blur();
|
||||
return blurFocusedControl(root);
|
||||
} finally {
|
||||
this.pointerFocusRelease = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from './controls-fullscreen';
|
||||
export * from './controls-shortcuts';
|
||||
export * from './legacy-player-shortcuts';
|
||||
export * from './controls-surface';
|
||||
export * from './pointer-focus-release';
|
||||
export * from './controls-timeline';
|
||||
export * from './controls-view-model';
|
||||
export * from './controls-visibility';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
blurFocusedControl,
|
||||
clickPointerOrigin,
|
||||
} from './pointer-focus-release';
|
||||
|
||||
/** A click as an engine dispatches it; `pointerType` only when given. */
|
||||
function click(pointerType?: string): MouseEvent {
|
||||
const event = new MouseEvent('click', { bubbles: true });
|
||||
if (pointerType !== undefined) {
|
||||
Object.defineProperty(event, 'pointerType', { value: pointerType });
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('clickPointerOrigin', () => {
|
||||
it('reports a pointer for mouse, touch, and pen clicks', () => {
|
||||
expect(clickPointerOrigin(click('mouse'))).toBe(true);
|
||||
expect(clickPointerOrigin(click('touch'))).toBe(true);
|
||||
expect(clickPointerOrigin(click('pen'))).toBe(true);
|
||||
});
|
||||
|
||||
it('reports keyboard or script activation for an empty pointer type', () => {
|
||||
expect(clickPointerOrigin(click(''))).toBe(false);
|
||||
});
|
||||
|
||||
it('reports unknown for a legacy click without a pointer type', () => {
|
||||
expect(clickPointerOrigin(click())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('blurFocusedControl', () => {
|
||||
let root: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
root = document.createElement('div');
|
||||
document.body.appendChild(root);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.remove();
|
||||
});
|
||||
|
||||
it('blurs a focused button inside the root', () => {
|
||||
const button = document.createElement('button');
|
||||
root.appendChild(button);
|
||||
button.focus();
|
||||
|
||||
expect(blurFocusedControl(root)).toBe(true);
|
||||
expect(document.activeElement).not.toBe(button);
|
||||
});
|
||||
|
||||
it('blurs a focused range input and ARIA slider by default', () => {
|
||||
const range = document.createElement('input');
|
||||
range.type = 'range';
|
||||
const slider = document.createElement('div');
|
||||
slider.setAttribute('role', 'slider');
|
||||
slider.tabIndex = 0;
|
||||
root.append(range, slider);
|
||||
|
||||
range.focus();
|
||||
expect(blurFocusedControl(root)).toBe(true);
|
||||
slider.focus();
|
||||
expect(blurFocusedControl(root)).toBe(true);
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it('leaves focus outside the root alone', () => {
|
||||
const outside = document.createElement('button');
|
||||
document.body.appendChild(outside);
|
||||
outside.focus();
|
||||
|
||||
expect(blurFocusedControl(root)).toBe(false);
|
||||
expect(document.activeElement).toBe(outside);
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
it('does nothing when nothing is focused', () => {
|
||||
expect(blurFocusedControl(root)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps focus in text entry', () => {
|
||||
const field = document.createElement('input');
|
||||
field.type = 'text';
|
||||
root.appendChild(field);
|
||||
field.focus();
|
||||
|
||||
expect(blurFocusedControl(root)).toBe(false);
|
||||
expect(document.activeElement).toBe(field);
|
||||
});
|
||||
|
||||
it('honors a custom selector', () => {
|
||||
const clickable = document.createElement('div');
|
||||
clickable.setAttribute('role', 'button');
|
||||
clickable.tabIndex = 0;
|
||||
root.appendChild(clickable);
|
||||
clickable.focus();
|
||||
|
||||
expect(blurFocusedControl(root)).toBe(false);
|
||||
expect(document.activeElement).toBe(clickable);
|
||||
expect(blurFocusedControl(root, { selector: '[role="button"]' })).toBe(
|
||||
true
|
||||
);
|
||||
expect(document.activeElement).not.toBe(clickable);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Controls whose pointer-originated focus is released once the click that
|
||||
* produced it completes. Text entry is deliberately absent: a click into a
|
||||
* field must not end typing. Neither the shared controls bar nor the vendor
|
||||
* chrome holds one today.
|
||||
*/
|
||||
export const POINTER_FOCUS_RELEASE_SELECTOR =
|
||||
'button, input[type="range"], [role="slider"]';
|
||||
|
||||
export interface BlurFocusedControlOptions {
|
||||
/** Controls eligible for release; defaults to the shared selector. */
|
||||
selector?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a click's own `pointerType` says about its origin: `true` for a
|
||||
* pointer (mouse, touch, pen); `false` for keyboard activation or script,
|
||||
* since current engines dispatch Enter/Space on a focused button and
|
||||
* `element.click()` as a PointerEvent with an empty `pointerType`; `null`
|
||||
* for a legacy MouseEvent click that carries no pointer type at all, which
|
||||
* the caller must attribute some other way or leave alone.
|
||||
*/
|
||||
export function clickPointerOrigin(event: MouseEvent): boolean | null {
|
||||
const pointerType = (event as Partial<PointerEvent>).pointerType;
|
||||
return typeof pointerType === 'string' ? pointerType !== '' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blur the focused control inside `root` when it is eligible. Chromium
|
||||
* focuses a clicked `<button>`, and a focused control captures the keyboard:
|
||||
* Space and Enter activate it again and the playback shortcuts yield to it.
|
||||
* Focus a pointer click left behind was never the keyboard's, so it is
|
||||
* dropped once the click completes; Chromium keeps its sequential focus
|
||||
* navigation starting point at the blurred control, so a later Tab
|
||||
* continues from it exactly as if it were still focused. Returns whether
|
||||
* focus was released.
|
||||
*/
|
||||
export function blurFocusedControl(
|
||||
root: HTMLElement,
|
||||
options: BlurFocusedControlOptions = {}
|
||||
): boolean {
|
||||
const active = root.ownerDocument.activeElement;
|
||||
if (
|
||||
!(active instanceof HTMLElement) ||
|
||||
!root.contains(active) ||
|
||||
!active.matches(options.selector ?? POINTER_FOCUS_RELEASE_SELECTOR)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
active.blur();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { WEB_PLAYER_SHARED_CONTROLS } from '../player-controls/web-player-controls.flag';
|
||||
import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component';
|
||||
import type { VideoJsPlayer } from './vjs-player.types';
|
||||
|
||||
const videoJsMock = jest.fn();
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({ default: videoJsMock }));
|
||||
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
|
||||
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
|
||||
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
jest.unstable_mockModule('mpegts.js', () => ({
|
||||
default: {
|
||||
Events: { ERROR: 'error' },
|
||||
createPlayer: jest.fn(),
|
||||
isSupported: jest.fn(() => false),
|
||||
},
|
||||
}));
|
||||
|
||||
// Real `attachVjsPointerFocusRelease` is exercised through the shell so the
|
||||
// test proves both the ngAfterViewInit wiring (the view child must be
|
||||
// resolved) and that the shared-controls guard and teardown behave.
|
||||
describe('VjsPlayerComponent pointer focus release wiring', () => {
|
||||
let VjsPlayerComponent: typeof import('./vjs-player.component').VjsPlayerComponent;
|
||||
let fixture: ComponentFixture<VjsPlayerComponentInstance>;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ VjsPlayerComponent } = await import('./vjs-player.component'));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
videoJsMock.mockReset().mockImplementation(() => createPlayerMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
const mount = async (sharedControls: boolean) => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VjsPlayerComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: WEB_PLAYER_SHARED_CONTROLS,
|
||||
useValue: sharedControls,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(VjsPlayerComponent);
|
||||
fixture.componentRef.setInput('options', {
|
||||
sources: [{ src: 'https://example.test/movie.mp4' }],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement.querySelector(
|
||||
'.vjs-player-shell'
|
||||
) as HTMLElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* A control bar with a button — the mocked player renders neither, and
|
||||
* the release is scoped to `.vjs-control-bar`, so the test owns both.
|
||||
*/
|
||||
const addControl = (shell: HTMLElement) => {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'vjs-control-bar';
|
||||
const button = document.createElement('button');
|
||||
bar.appendChild(button);
|
||||
shell.appendChild(bar);
|
||||
return button;
|
||||
};
|
||||
|
||||
const pressAndFocus = (shell: HTMLElement, control: HTMLElement) => {
|
||||
shell.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
|
||||
control.focus();
|
||||
};
|
||||
|
||||
it('releases the focus a pointer press leaves on a control (attach reached the resolved shell)', async () => {
|
||||
const shell = await mount(false);
|
||||
const control = addControl(shell);
|
||||
|
||||
pressAndFocus(shell, control);
|
||||
|
||||
expect(document.activeElement).not.toBe(control);
|
||||
});
|
||||
|
||||
it('leaves the control focused after the component is destroyed', async () => {
|
||||
const shell = await mount(false);
|
||||
const control = addControl(shell);
|
||||
fixture.destroy();
|
||||
|
||||
pressAndFocus(shell, control);
|
||||
|
||||
expect(document.activeElement).toBe(control);
|
||||
});
|
||||
|
||||
it('does not attach when shared controls own the surface', async () => {
|
||||
const shell = await mount(true);
|
||||
const control = addControl(shell);
|
||||
|
||||
pressAndFocus(shell, control);
|
||||
|
||||
expect(document.activeElement).toBe(control);
|
||||
});
|
||||
});
|
||||
|
||||
function createPlayerMock(): VideoJsPlayer {
|
||||
const player = {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
ready: jest.fn(),
|
||||
getChild: jest.fn(() => null),
|
||||
audioTracks: jest.fn(() => null),
|
||||
textTracks: jest.fn(() => null),
|
||||
currentTime: jest.fn(() => 0),
|
||||
duration: jest.fn(() => 0),
|
||||
error: jest.fn(() => null),
|
||||
paused: jest.fn(() => true),
|
||||
play: jest.fn(() => Promise.resolve()),
|
||||
pause: jest.fn(),
|
||||
volume: jest.fn(() => 1),
|
||||
muted: jest.fn(() => false),
|
||||
src: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
dispose: jest.fn(),
|
||||
isFullscreen: jest.fn(() => false),
|
||||
requestFullscreen: jest.fn(),
|
||||
exitFullscreen: jest.fn(),
|
||||
controlBar: { getChild: jest.fn(() => null) },
|
||||
};
|
||||
return player as unknown as VideoJsPlayer;
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import { logVjsAudioTracks, setupVjsAudioTrackMenu } from './vjs-audio-tracks';
|
||||
import { VjsLegacyTracks } from './vjs-legacy-tracks';
|
||||
import { VjsMpegTsSession } from './vjs-mpegts-session';
|
||||
import { attachVjsLegacyShortcuts } from './vjs-legacy-shortcuts';
|
||||
import { attachVjsPointerFocusRelease } from './vjs-pointer-focus-release';
|
||||
import { VjsPlayerControlsBridge } from './vjs-player-controls.bridge';
|
||||
import {
|
||||
createVjsPlayerOptions,
|
||||
@@ -119,6 +120,8 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
private legacyTracks: VjsLegacyTracks | null = null;
|
||||
/** Keyboard shortcuts for the legacy chrome; null with shared controls. */
|
||||
private legacyShortcuts: LegacyPlayerShortcuts | null = null;
|
||||
/** Pointer focus release for the legacy chrome; null with shared controls. */
|
||||
private detachPointerFocusRelease: (() => void) | null = null;
|
||||
private desiredSource: VideoPlayerSource | null = null;
|
||||
private readyHandled = false;
|
||||
private destroyed = false;
|
||||
@@ -151,6 +154,11 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
isAvailable: () => this.interactionEnabled(),
|
||||
isLive: () => this.options().isLive !== false,
|
||||
});
|
||||
const shell = this.playerRoot()?.nativeElement;
|
||||
if (shell) {
|
||||
this.detachPointerFocusRelease =
|
||||
attachVjsPointerFocusRelease(shell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +209,8 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.destroyed = true;
|
||||
this.legacyShortcuts?.detach();
|
||||
this.legacyShortcuts = null;
|
||||
this.detachPointerFocusRelease?.();
|
||||
this.detachPointerFocusRelease = null;
|
||||
this.resetCoordinator.destroy();
|
||||
this.controlsBridge?.destroy();
|
||||
this.controlsBridge = null;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { attachVjsPointerFocusRelease } from './vjs-pointer-focus-release';
|
||||
|
||||
describe('attachVjsPointerFocusRelease', () => {
|
||||
let root: HTMLElement;
|
||||
let fullscreen: HTMLButtonElement;
|
||||
let seekBar: HTMLElement;
|
||||
let menuButton: HTMLButtonElement;
|
||||
let menuItem: HTMLElement;
|
||||
let modalResetButton: HTMLButtonElement;
|
||||
let detach: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
root = document.createElement('div');
|
||||
root.className = 'vjs-player-shell';
|
||||
// The Video.js 8 layout that matters here: the control bar holds the
|
||||
// focusable chrome (buttons, sliders, a menu button whose popup items
|
||||
// are focused by `MenuButton.pressButton()`); the caption-settings
|
||||
// dialog is a modal *sibling* of the control bar, under `.video-js`.
|
||||
root.innerHTML = `
|
||||
<div class="video-js">
|
||||
<div class="vjs-control-bar">
|
||||
<div class="vjs-progress-control vjs-control">
|
||||
<div class="vjs-progress-holder vjs-slider" role="slider" tabindex="0"></div>
|
||||
</div>
|
||||
<div class="vjs-playback-rate vjs-menu-button-popup vjs-control">
|
||||
<button class="vjs-playback-rate vjs-menu-button" type="button" aria-expanded="false"></button>
|
||||
<div class="vjs-menu">
|
||||
<ul class="vjs-menu-content" role="menu">
|
||||
<li class="vjs-menu-item" role="menuitemradio" tabindex="-1"></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button class="vjs-fullscreen-control vjs-control vjs-button" type="button"></button>
|
||||
</div>
|
||||
<div class="vjs-text-track-settings vjs-modal-dialog" role="dialog">
|
||||
<button class="vjs-default-button" type="button"></button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(root);
|
||||
fullscreen = root.querySelector(
|
||||
'.vjs-fullscreen-control'
|
||||
) as HTMLButtonElement;
|
||||
seekBar = root.querySelector('.vjs-progress-holder') as HTMLElement;
|
||||
menuButton = root.querySelector(
|
||||
'button.vjs-menu-button'
|
||||
) as HTMLButtonElement;
|
||||
menuItem = root.querySelector('.vjs-menu-item') as HTMLElement;
|
||||
modalResetButton = root.querySelector(
|
||||
'.vjs-text-track-settings .vjs-default-button'
|
||||
) as HTMLButtonElement;
|
||||
detach = attachVjsPointerFocusRelease(root);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
detach();
|
||||
root.remove();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
/** A pointer press as the pane sees it (jsdom lacks PointerEvent). */
|
||||
const pointerDown = (target: EventTarget = root) => {
|
||||
target.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
|
||||
};
|
||||
|
||||
const keyDown = (target: EventTarget = document.body) => {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
|
||||
);
|
||||
};
|
||||
|
||||
const click = (target: EventTarget) => {
|
||||
target.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
);
|
||||
};
|
||||
|
||||
it('releases the focus that lands on a control-bar button after a pointer press', () => {
|
||||
pointerDown();
|
||||
fullscreen.focus();
|
||||
|
||||
expect(document.activeElement).not.toBe(fullscreen);
|
||||
});
|
||||
|
||||
it('releases a control-bar control clicked while it was already focused', () => {
|
||||
// Tab focuses it (kept — no pointer press yet), then a mouse click on
|
||||
// the same control moves no focus and fires no focusin, so the click
|
||||
// is the only signal.
|
||||
fullscreen.focus();
|
||||
expect(document.activeElement).toBe(fullscreen);
|
||||
|
||||
pointerDown();
|
||||
click(fullscreen);
|
||||
|
||||
expect(document.activeElement).not.toBe(fullscreen);
|
||||
});
|
||||
|
||||
it('keeps an already-focused control the keyboard activates', () => {
|
||||
// Space/Enter on a focused button fires a click with no preceding
|
||||
// pointerdown, so the focus is kept.
|
||||
fullscreen.focus();
|
||||
|
||||
click(fullscreen);
|
||||
|
||||
expect(document.activeElement).toBe(fullscreen);
|
||||
});
|
||||
|
||||
it('leaves a modal dialog button focused when it is clicked', () => {
|
||||
pointerDown(modalResetButton);
|
||||
modalResetButton.focus();
|
||||
// Even the click path is scoped to the control bar.
|
||||
click(modalResetButton);
|
||||
|
||||
expect(document.activeElement).toBe(modalResetButton);
|
||||
});
|
||||
|
||||
it('releases a control-bar slider', () => {
|
||||
pointerDown();
|
||||
seekBar.focus();
|
||||
|
||||
expect(document.activeElement).not.toBe(seekBar);
|
||||
});
|
||||
|
||||
it('leaves a modal dialog button focused so its focus trap survives', () => {
|
||||
// The caption-settings dialog keeps its own Escape/Tab handling; its
|
||||
// Reset button sits outside the control bar and must not be released.
|
||||
pointerDown(modalResetButton);
|
||||
modalResetButton.focus();
|
||||
|
||||
expect(document.activeElement).toBe(modalResetButton);
|
||||
});
|
||||
|
||||
it('keeps focus that no pointer press preceded (keyboard navigation)', () => {
|
||||
fullscreen.focus();
|
||||
|
||||
expect(document.activeElement).toBe(fullscreen);
|
||||
});
|
||||
|
||||
it('keeps focus once a key press has ended the pointer attribution', () => {
|
||||
// The key that follows a release is pressed while focus rests on
|
||||
// body, outside the shell, so the clear must not depend on the shell.
|
||||
pointerDown();
|
||||
keyDown(document);
|
||||
fullscreen.focus();
|
||||
|
||||
expect(document.activeElement).toBe(fullscreen);
|
||||
});
|
||||
|
||||
it('releases the menu button Video.js focuses after an item is chosen', () => {
|
||||
// Choosing an item: the press lands on the item, which is not
|
||||
// eligible, then Video.js moves focus to the now-collapsed button.
|
||||
pointerDown(menuItem);
|
||||
menuItem.focus();
|
||||
expect(document.activeElement).toBe(menuItem);
|
||||
|
||||
menuButton.setAttribute('aria-expanded', 'false');
|
||||
menuButton.focus();
|
||||
expect(document.activeElement).not.toBe(menuButton);
|
||||
});
|
||||
|
||||
it('releases an expanded menu button so Space works after a click closes it', () => {
|
||||
// Toggling an open menu shut with a pointer moves focus item -> button
|
||||
// while the button is still expanded; the button then swallows keys
|
||||
// until released. It is not exempt: the popup is navigated through its
|
||||
// item, so dropping the button focus never disturbs an open menu.
|
||||
menuButton.setAttribute('aria-expanded', 'true');
|
||||
pointerDown();
|
||||
menuButton.focus();
|
||||
|
||||
expect(document.activeElement).not.toBe(menuButton);
|
||||
});
|
||||
|
||||
it('keeps a focused menu item so arrow navigation survives', () => {
|
||||
pointerDown();
|
||||
menuItem.focus();
|
||||
|
||||
expect(document.activeElement).toBe(menuItem);
|
||||
});
|
||||
|
||||
it('forgets a pointer press after the attribution window', () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
pointerDown();
|
||||
jest.setSystemTime(new Date('2026-01-01T00:00:01.500Z'));
|
||||
fullscreen.focus();
|
||||
|
||||
expect(document.activeElement).toBe(fullscreen);
|
||||
});
|
||||
|
||||
it('stops releasing after detach', () => {
|
||||
detach();
|
||||
pointerDown();
|
||||
fullscreen.focus();
|
||||
|
||||
expect(document.activeElement).toBe(fullscreen);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { blurFocusedControl } from '../player-controls';
|
||||
|
||||
/**
|
||||
* How long after a pointer press a focus that lands on a control is still
|
||||
* attributed to that press. A menu selection moves focus to the menu button
|
||||
* a tick or two after the click, so the window covers that gap while a later
|
||||
* keydown ends it (Tab focus must not be attributed to a stale press).
|
||||
*/
|
||||
const POINTER_ATTRIBUTION_WINDOW_MS = 1000;
|
||||
|
||||
/** The persistent chrome the release is scoped to (see the doc comment). */
|
||||
const VJS_CONTROL_BAR_SELECTOR = '.vjs-control-bar';
|
||||
|
||||
/**
|
||||
* Control-bar focusables that capture the keyboard: `<button>`s (including
|
||||
* menu buttons), `ClickableComponent` divs (`role="button"`) and sliders
|
||||
* (`role="slider"`). Menu items are `role="menuitem*"` and are deliberately
|
||||
* absent — while a menu is open its item, not its button, owns arrow-key
|
||||
* navigation, so releasing the button never disturbs it.
|
||||
*/
|
||||
const VJS_RELEASE_SELECTOR = 'button, [role="button"], [role="slider"]';
|
||||
|
||||
/**
|
||||
* Vendor-chrome counterpart of the shared controls' pointer focus release
|
||||
* (`ControlsSurface.releasePointerFocus`), attached to the player shell of
|
||||
* the preference-off Video.js player. Chromium focuses a clicked control, and
|
||||
* a focused Video.js component captures the keyboard entirely:
|
||||
* `Component.handleKeyDown` stops the propagation of every key and
|
||||
* `ClickableComponent` turns Space and Enter into a click, so after a mouse
|
||||
* click on the fullscreen button Space left fullscreen instead of pausing and
|
||||
* `LegacyPlayerShortcuts` on the document never saw a key.
|
||||
*
|
||||
* The release is driven mainly by the focus landing, not the click: choosing
|
||||
* a menu item moves focus to the menu button a tick after the click (Video.js
|
||||
* `MenuItem.handleTapClick`), and the selection click never bubbles to this
|
||||
* root, so a click handler alone would be both too early and unreached.
|
||||
* A `focusin` on an eligible control is released when it is attributable to a
|
||||
* recent pointer press inside the shell — a `pointerdown` within the window,
|
||||
* not yet ended by a keydown anywhere — so keyboard `Tab` focus is preserved
|
||||
* (the keydown listener is on the document because the key that follows a
|
||||
* release is pressed while focus sits on `body`, outside the shell). A
|
||||
* `click` runs the same release, because clicking a control that was already
|
||||
* focused (Tab, then a mouse click on it) moves no focus and fires no
|
||||
* `focusin`; a keyboard-activation click carries no `pointerdown`, so
|
||||
* attribution keeps that focus.
|
||||
*
|
||||
* The release is scoped to the `.vjs-control-bar`: that persistent chrome is
|
||||
* what hands keys back to the document, while the player's other focusable
|
||||
* surfaces manage their own focus and must keep it. In particular the
|
||||
* caption-settings dialog (`.vjs-text-track-settings`, a modal sibling of the
|
||||
* control bar) traps focus for its Escape/Tab handling — blurring its Reset
|
||||
* button would let keys leak to the document behind the open dialog. Menu
|
||||
* buttons live in the control bar and are not exempt: a Video.js popup is
|
||||
* navigated through its focused item, not its button, so releasing the button
|
||||
* never breaks it. Opening a menu focuses the item (not eligible), and the
|
||||
* button focus a pointer press moves through — the transient press on open,
|
||||
* item selection, and toggling an open menu shut — is released, which is what
|
||||
* lets Space work again after a menu is dismissed by clicking its button a
|
||||
* second time. Returns the detach function.
|
||||
*/
|
||||
export function attachVjsPointerFocusRelease(root: HTMLElement): () => void {
|
||||
const doc = root.ownerDocument;
|
||||
let lastPointerDownAt: number | null = null;
|
||||
|
||||
// Only presses inside the shell count: a click elsewhere on the page must
|
||||
// not attribute a later `Tab` into the controls.
|
||||
const onPointerDown = () => {
|
||||
lastPointerDownAt = Date.now();
|
||||
};
|
||||
// A key press ends pointer attribution: focus that moves after it was
|
||||
// moved by the keyboard, whatever the last press hit (mirrors
|
||||
// `ControlsSurface`). It is on the document because the key that follows
|
||||
// a release is pressed while focus rests on `body`, outside the shell.
|
||||
const onKeyDown = () => {
|
||||
lastPointerDownAt = null;
|
||||
};
|
||||
|
||||
const attributed = () =>
|
||||
lastPointerDownAt !== null &&
|
||||
Date.now() - lastPointerDownAt <= POINTER_ATTRIBUTION_WINDOW_MS;
|
||||
|
||||
// Passing the control bar (not the shell) as the root scopes the release
|
||||
// to it, so focus inside a modal dialog is left alone.
|
||||
const releaseControlBarFocus = () => {
|
||||
const controlBar = root.querySelector(VJS_CONTROL_BAR_SELECTOR);
|
||||
if (controlBar instanceof HTMLElement) {
|
||||
blurFocusedControl(controlBar, { selector: VJS_RELEASE_SELECTOR });
|
||||
}
|
||||
};
|
||||
|
||||
// Focus that moves onto a control (a pointer focusing it, or a menu
|
||||
// handing focus to its button) is the common path.
|
||||
const onFocusIn = () => {
|
||||
if (attributed()) {
|
||||
releaseControlBarFocus();
|
||||
}
|
||||
};
|
||||
// Clicking a control that was already focused (Tab, then a mouse click on
|
||||
// the same control) moves no focus and fires no `focusin`, so the click
|
||||
// is the only signal; a keyboard-activation click carries no preceding
|
||||
// `pointerdown`, so `attributed()` keeps that focus.
|
||||
const onClick = () => {
|
||||
if (attributed()) {
|
||||
releaseControlBarFocus();
|
||||
}
|
||||
};
|
||||
|
||||
root.addEventListener('pointerdown', onPointerDown, { capture: true });
|
||||
doc.addEventListener('keydown', onKeyDown, { capture: true });
|
||||
root.addEventListener('focusin', onFocusIn);
|
||||
root.addEventListener('click', onClick);
|
||||
return () => {
|
||||
root.removeEventListener('pointerdown', onPointerDown, {
|
||||
capture: true,
|
||||
});
|
||||
doc.removeEventListener('keydown', onKeyDown, { capture: true });
|
||||
root.removeEventListener('focusin', onFocusIn);
|
||||
root.removeEventListener('click', onClick);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user