fix(playback): keep playback shortcuts after a mouse click on a bar button (#1516)

Chromium focuses a clicked <button>, and a focused control captures the
keyboard: Space and Enter activate it again, and ControlsShortcuts yields
to any interactive element in the key's path. After a click on the
fullscreen button, Space left fullscreen instead of pausing and the seek,
volume and mute keys did nothing until a click on the video took focus
away. Follow-up to #1512, which stopped that focus from pinning the bar
but left it on the button.

A completed pointer click now releases the focus it left on the control
(onBarClick -> ControlsSurface.releasePointerFocus). The click is
attributed by its pointerType (empty for Enter/Space activation and
element.click()), with the legacy MouseEvent fallback answered once per
recorded press, so keyboard activation keeps focus where Tab put it.
Only buttons and range sliders are released. Chromium keeps its
sequential-focus starting point at the blurred control, so a later Tab
continues from it. The release dispatches a focusout while the pointer
still rests on the control, so the volume anchor ignores it instead of
closing the popover under the hovering mouse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-03 21:42:08 +02:00
1 parent 90d26d499f
commit 308ed9cb41
12 files changed
+548 -26

No files matched your search

@@ -0,0 +1,10 @@
---
type: fix
area: playback
---
Keyboard shortcuts work again right after you click a button in the built-in
player controls. 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. Applies to the
shared controls in the web players and the Embedded MPV frame-copy engine.
+12 -1
View File
@@ -213,7 +213,18 @@ Key files:
discarded on the first bar focus event it is asked about or on 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. Contract:
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), because 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`). Contract:
`docs/architecture/player-controls-contract.md` (auto-hide paragraph).
- Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored
values coerce with `!== false`; only an explicit false opts out to the legacy
+1 -1
View File
@@ -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. 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 next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync — its owner is the `app-web-player-view` host (`WebPlayerViewComponent.fullscreenSurface`, passed to every engine as `fullscreenTarget`), not the engine shell, because the view remounts the engine componeLine 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
+13
View File
@@ -1093,6 +1093,19 @@ test.describe('@xtream inline series fullscreen', () => {
await expect.poll(fullscreenOwner).not.toBeNull();
await expect(overlayTitle).toContainText('S01E01');
// Chromium leaves the clicked button focused; the shared controls
// release that focus so Space reaches the playback shortcut instead
// of activating the button again (which left fullscreen while the
// video kept playing).
const paused = () =>
video.evaluate((el) => (el as HTMLVideoElement).paused);
await expect.poll(paused).toBe(false);
await page.keyboard.press('Space');
await expect.poll(paused).toBe(true);
expect(await fullscreenOwner()).not.toBeNull();
await page.keyboard.press('Space');
await expect.poll(paused).toBe(false);
// Manual switch from the shared controls' own next-episode button.
await playerView.hover();
await playerView
+24 -1
View File
@@ -356,6 +356,28 @@ focus) or only a transfer inside the bar, which `focusout` ignores by design,
so the pin cannot be cleared from focus events alone. Without
this distinction the fullscreen button kept the controls on screen until a
click on the viewport took focus away — and that click also paused playback.
The focus a pointer click leaves on a control is released once the click
completes (`onBarClick` → `ControlsSurface.releasePointerFocus`). A focused
control captures the keyboard: Space and Enter activate it again, and
`ControlsShortcuts` yields to any interactive element in the key's path, so
after a click on the fullscreen button Space left fullscreen instead of
pausing and the seek, volume, and mute keys did nothing.
`ControlsSurface.wasPointerClick` attributes the click by its `pointerType`
(non-empty for a pointer; empty for Enter/Space activation and
`element.click()`), and a legacy `MouseEvent` click by a recent press inside
the clicked element, answered once per press and discarded on any key press.
Keyboard activation therefore keeps focus where Tab put it. Only buttons and
range sliders are released; text entry would keep its focus, and the bar
holds none. 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; the clicked button's tooltip hides with the focus. The release
dispatches a `focusout` while the pointer still rests on the control, so the
volume anchor's `focusout` handler skips its popover close for it
(`wasPointerFocusRelease`), while focus leaving by keyboard still closes the
popover. A press that never completes into a click (released off the
control) is the one case that still leaves pointer-originated focus behind,
which is why the key-press re-pin above remains.
In fullscreen playback, hiding the controls also hides the pointer
over both the controls host and the supplied player surface; revealing controls
or destroying the component restores the surface's previous inline cursor.
@@ -463,7 +485,8 @@ the last second (`wasTouchInteraction`). Three behaviors diverge from mouse:
the hover-open path is suppressed and the first tap on the volume button
opens the popover instead of muting; a tap while it is open toggles mute as
the button's label says. Touch-attributed `focusout` does not schedule the
popover close (outside taps and other menu buttons dismiss it).
popover close (outside taps and other menu buttons dismiss it), and neither
does the `focusout` of a pointer focus release.
- **Coarse-pointer scrub sizing.** Under `@media (pointer: coarse)` the
timeline/volume sliders grow their input hit strip to 28px and the thumb to
18px; the 4px visual track is unchanged.
@@ -180,6 +180,37 @@ describe('ControlsShortcuts', () => {
menuItem.remove();
});
it('yields to a focused button only while it holds focus', () => {
const button = document.createElement('button');
document.body.append(button);
button.focus();
// The key reaches the focused button first: its activation owns
// Space, whatever the playback state.
button.dispatchEvent(
new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
})
);
expect(handlers.togglePaused).not.toHaveBeenCalled();
// Releasing the focus a pointer click left behind hands Space back
// to playback (the controls bar does this on a completed click).
button.blur();
const event = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(handlers.togglePaused).toHaveBeenCalledTimes(1);
button.remove();
});
it('ignores modified playback shortcuts without preventing their defaults', () => {
expect(dispatchKey('k', { metaKey: true })).toBe(false);
expect(dispatchKey('f', { ctrlKey: true })).toBe(false);
@@ -352,4 +352,144 @@ describe('ControlsSurface', () => {
expect(surface.wasPointerInteraction(focusOn(button))).toBe(false);
});
});
describe('pointer-originated click attribution', () => {
let button: HTMLButtonElement;
let icon: HTMLElement;
beforeEach(() => {
button = document.createElement('button');
icon = document.createElement('span');
button.appendChild(icon);
element.appendChild(button);
});
/** A click as an engine dispatches it; `pointerType` only when given. */
const clickOn = (target: EventTarget, pointerType?: string) => {
const event = new MouseEvent('click', { bubbles: true });
if (pointerType !== undefined) {
Object.defineProperty(event, 'pointerType', {
value: pointerType,
});
}
Object.defineProperty(event, 'target', { value: target });
return event;
};
it('trusts a non-empty pointer type on the click', () => {
expect(surface.wasPointerClick(clickOn(button, 'mouse'))).toBe(
true
);
expect(surface.wasPointerClick(clickOn(button, 'touch'))).toBe(
true
);
expect(surface.wasPointerClick(clickOn(button, 'pen'))).toBe(true);
});
it('reads an empty pointer type as keyboard or script activation, even after a press', () => {
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
expect(surface.wasPointerClick(clickOn(button, ''))).toBe(false);
});
it('attributes a legacy click to a recent press inside the clicked element', () => {
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
expect(surface.wasPointerClick(clickOn(button))).toBe(true);
});
it('reports keyboard activation for a legacy click without a press', () => {
expect(surface.wasPointerClick(clickOn(button))).toBe(false);
});
it('reports keyboard activation when the press landed outside the clicked element', () => {
element.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
expect(surface.wasPointerClick(clickOn(button))).toBe(false);
});
it('consumes the press on the first legacy click it is asked about', () => {
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
expect(surface.wasPointerClick(clickOn(button))).toBe(true);
expect(surface.wasPointerClick(clickOn(button))).toBe(false);
});
it('answers the focus and the click of one press independently', () => {
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
const focus = new FocusEvent('focusin');
Object.defineProperty(focus, 'target', { value: button });
expect(surface.wasPointerInteraction(focus)).toBe(true);
expect(surface.wasPointerClick(clickOn(button))).toBe(true);
});
it('discards the press on any key press', () => {
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
);
expect(surface.wasPointerClick(clickOn(button))).toBe(false);
});
it('forgets a press after the attribution window', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-01T00:00:00Z'));
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
jest.setSystemTime(new Date('2026-01-01T00:00:01.500Z'));
expect(surface.wasPointerClick(clickOn(button))).toBe(false);
});
});
describe('pointer focus release', () => {
let button: HTMLButtonElement;
beforeEach(() => {
button = document.createElement('button');
element.appendChild(button);
});
it('blurs the focused control inside the root and flags its focusout', () => {
button.focus();
let flagged: boolean | null = null;
element.addEventListener('focusout', () => {
flagged = surface.wasPointerFocusRelease();
});
expect(surface.releasePointerFocus(element)).toBe(true);
expect(document.activeElement).not.toBe(button);
expect(flagged).toBe(true);
expect(surface.wasPointerFocusRelease()).toBe(false);
});
it('releases a focused range slider', () => {
const slider = document.createElement('input');
slider.type = 'range';
element.appendChild(slider);
slider.focus();
expect(surface.releasePointerFocus(element)).toBe(true);
expect(document.activeElement).not.toBe(slider);
});
it('leaves focus outside the root alone', () => {
const outside = document.createElement('button');
document.body.appendChild(outside);
outside.focus();
expect(surface.releasePointerFocus(element)).toBe(false);
expect(document.activeElement).toBe(outside);
outside.remove();
});
it('does nothing when nothing is focused', () => {
expect(surface.releasePointerFocus(element)).toBe(false);
});
it('keeps focus in text entry', () => {
const field = document.createElement('input');
field.type = 'text';
element.appendChild(field);
field.focus();
expect(surface.releasePointerFocus(element)).toBe(false);
expect(document.activeElement).toBe(field);
});
});
});
@@ -36,6 +36,19 @@ 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;
}
/**
* Owns the surface + document interaction wiring for the controls: reveal on
* pointer activity, click-to-pause on the viewport, fullscreen on double-click,
@@ -48,14 +61,20 @@ export class ControlsSurface {
private surfaceCleanup: (() => void) | null = null;
private clickPauseTimer: ReturnType<typeof setTimeout> | null = null;
private lastTouchPointerDownAt: number | null = null;
private lastPointerDown: { at: number; target: EventTarget | null } | null =
null;
/** The most recent press, answered once by the focus event it moved. */
private lastPointerDown: PointerPress | null = null;
/** The same press, answered once by the click it completes into. */
private lastPointerDownClick: PointerPress | null = null;
/** Set while {@link releasePointerFocus} dispatches its `focusout`. */
private pointerFocusRelease = false;
private readonly onDocumentPointerDown = (event: PointerEvent) => {
const now = Date.now();
this.lastTouchPointerDownAt =
event.pointerType === 'touch' ? now : null;
this.lastPointerDown = { at: now, target: event.target };
const press: PointerPress = { at: now, target: event.target };
this.lastPointerDown = press;
this.lastPointerDownClick = press;
const path = event.composedPath();
if (
!this.surface ||
@@ -68,11 +87,13 @@ export class ControlsSurface {
};
/**
* Keyboard input ends pointer attribution: focus that moves after a key
* press was moved by the keyboard, whatever the last press hit.
* Keyboard input ends pointer attribution: focus that moves, or a click
* that fires, after a key press was produced by the keyboard, whatever
* the last press hit.
*/
private readonly onDocumentKeyDown = () => {
this.lastPointerDown = null;
this.lastPointerDownClick = null;
};
constructor(
@@ -189,6 +210,77 @@ export class ControlsSurface {
);
}
/**
* Whether a click completes a pointer press rather than keyboard
* activation or script. Click events are PointerEvents in current
* engines and carry an empty `pointerType` when nothing pointed
* (Enter/Space on a focused button, `element.click()`); a legacy
* MouseEvent click is attributed to a recent press that landed inside
* the clicked element. A press completes into at most one click, so the
* record is discarded on the first click it is asked about and on any
* key press (`onDocumentKeyDown`).
*/
wasPointerClick(event: MouseEvent): boolean {
const pointerType = (event as Partial<PointerEvent>).pointerType;
if (typeof pointerType === 'string') {
return pointerType !== '';
}
const press = this.lastPointerDownClick;
this.lastPointerDownClick = null;
if (
press === null ||
Date.now() - press.at > POINTER_ATTRIBUTION_WINDOW_MS
) {
return false;
}
const clicked = event.target;
return (
clicked instanceof Node &&
press.target instanceof Node &&
clicked.contains(press.target)
);
}
/**
* Release the focus a pointer click left on a control inside `root`.
* 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 (`ControlsShortcuts` ignores keys whose path
* holds an interactive element). That focus 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.
* Handlers of the resulting `focusout` can recognize the release through
* {@link wasPointerFocusRelease}. Returns whether focus was released.
*/
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();
} finally {
this.pointerFocusRelease = false;
}
return true;
}
/**
* Whether the `focusout` being dispatched right now comes from
* {@link releasePointerFocus}: the pointer is still where it clicked, so
* hover-scoped state (the volume popover) must not read it as focus
* leaving by keyboard.
*/
wasPointerFocusRelease(): boolean {
return this.pointerFocusRelease;
}
private onClick(event: MouseEvent): void {
const touch = this.wasTouchInteraction(event);
// Capture before revealing: a touch tap's semantics depend on whether
@@ -6,6 +6,12 @@ export interface ControlsVolumeInteractionsDeps {
menus: ControlsMenuState;
/** Pointer-type attribution, provided by the surface. */
wasTouchInteraction: (event?: Event) => boolean;
/**
* Whether the `focusout` being dispatched is the surface releasing the
* focus a pointer click left on a control. The pointer is still over the
* anchor, so the popover must not schedule its close on it.
*/
wasPointerFocusRelease: () => boolean;
/** Whether the controller advertises the volume capability. */
canAdjustVolume: () => boolean;
reveal: (options?: { scheduleHide?: boolean }) => void;
@@ -30,7 +36,10 @@ export class ControlsVolumeInteractions {
}
hoverLeave(event?: Event): void {
if (this.deps.wasTouchInteraction(event)) {
if (
this.deps.wasTouchInteraction(event) ||
this.deps.wasPointerFocusRelease()
) {
return;
}
this.deps.volume.hoverLeave();
@@ -34,6 +34,7 @@
(pointerenter)="onBarPointerEnter()"
(pointerleave)="onBarPointerLeave()"
(pointerdown)="onBarPointerDown()"
(click)="onBarClick($event)"
(keydown)="onBarKeyDown()"
(focusin)="onBarFocusIn($event)"
(focusout)="onBarFocusOut($event)"
@@ -319,7 +319,7 @@ describe('PlayerControlsComponent', () => {
outside.remove();
});
it('does not pin the bar on the focus a mouse click on a control produces', () => {
it('does not pin the bar on the focus a mouse press on a control produces', () => {
setCapabilities({ fullscreen: true });
setState({ status: 'playing' });
const surface = document.createElement('div');
@@ -337,14 +337,14 @@ describe('PlayerControlsComponent', () => {
'[aria-label="Enter fullscreen"]'
) as HTMLButtonElement;
const icon = button.querySelector('mat-icon') as HTMLElement;
// Chromium: pointerdown on the icon, focus moves to the button
// (focusin on the bar), then click — all from one mouse press.
// Chromium: pointerdown on the icon moves focus to the button
// (focusin on the bar). The press is released off the control,
// so no click completes it and nothing releases that focus.
bar()?.dispatchEvent(new MouseEvent('pointerenter'));
icon.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
button.focus();
button.click();
fixture.detectChanges();
expect(document.activeElement).toBe(button);
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
@@ -419,7 +419,7 @@ describe('PlayerControlsComponent', () => {
expect(fixture.componentInstance.controlsAreVisible()).toBe(false);
});
it('releases a keyboard pin when the mouse clicks the focused control itself', () => {
it('releases a keyboard pin when the mouse presses the focused control itself', () => {
const buttons = Array.from(
bar()?.querySelectorAll('button') ?? []
) as HTMLButtonElement[];
@@ -428,12 +428,13 @@ describe('PlayerControlsComponent', () => {
jest.advanceTimersByTime(10000);
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
// Clicking the already focused button produces no focus event.
// A press on the already focused button produces no focus event;
// released off the control it produces no click either, so the
// pointerdown alone must release the pin.
bar()?.dispatchEvent(new MouseEvent('pointerenter'));
buttons[0].dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
buttons[0].click();
fixture.detectChanges();
expect(document.activeElement).toBe(buttons[0]);
bar()?.dispatchEvent(new MouseEvent('pointerleave'));
@@ -462,7 +463,8 @@ describe('PlayerControlsComponent', () => {
buttons[1].focus();
buttons[1].click();
fixture.detectChanges();
// ...then Shift+Tab away and Tab back before the press expires.
// ...which releases its focus. Tab back into the bar and on to
// the clicked control before the press expires.
buttons[0].focus();
buttons[1].focus();
fixture.detectChanges();
@@ -518,7 +520,7 @@ describe('PlayerControlsComponent', () => {
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
});
it('pins the bar when the keyboard operates a mouse-focused control', () => {
it('pins the bar when the keyboard operates a control a press left focused', () => {
setCapabilities({ seek: true });
setState({
status: 'playing',
@@ -530,13 +532,13 @@ describe('PlayerControlsComponent', () => {
bar()?.querySelectorAll('button') ?? []
) as HTMLButtonElement[];
// Mouse click leaves the control focused but not pinned...
// A press released off the control leaves it focused but not
// pinned (no click completed, so nothing released the focus)...
bar()?.dispatchEvent(new MouseEvent('pointerenter'));
buttons[1].dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
buttons[1].focus();
buttons[1].click();
fixture.detectChanges();
bar()?.dispatchEvent(new MouseEvent('pointerleave'));
fixture.detectChanges();
@@ -563,6 +565,177 @@ describe('PlayerControlsComponent', () => {
});
});
describe('pointer focus release', () => {
const bar = () => query('.player-controls__bar') as HTMLElement | null;
beforeEach(() => {
jest.useFakeTimers();
setState({ status: 'playing' });
fixture.detectChanges();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
/** A click as Chromium dispatches it: a PointerEvent whose
* `pointerType` names the device, or is empty for Enter/Space. */
const click = (target: HTMLElement, pointerType: string) => {
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
Object.defineProperty(event, 'pointerType', {
value: pointerType,
});
target.dispatchEvent(event);
};
const space = (target: EventTarget) => {
const event = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
});
target.dispatchEvent(event);
return event.defaultPrevented;
};
const mountFullscreenSurface = () => {
const surface = document.createElement('div');
document.body.appendChild(surface);
const requestFullscreen = jest.fn(() => Promise.resolve());
(
surface as unknown as { requestFullscreen: unknown }
).requestFullscreen = requestFullscreen;
(
document as unknown as { exitFullscreen: unknown }
).exitFullscreen = jest.fn(() => Promise.resolve());
setCapabilities({ fullscreen: true });
fixture.componentRef.setInput('playerSurface', surface);
fixture.detectChanges();
const button = query(
'[aria-label="Enter fullscreen"]'
) as HTMLButtonElement;
return {
button,
requestFullscreen,
dispose: () => {
surface.remove();
delete (document as unknown as { exitFullscreen?: unknown })
.exitFullscreen;
},
};
};
it('releases the focus a mouse click leaves on a button so Space toggles playback again', () => {
const { button, requestFullscreen, dispose } =
mountFullscreenSurface();
const icon = button.querySelector('mat-icon') as HTMLElement;
// Chromium: pointerdown focuses the button, then the click.
icon.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
button.focus();
expect(document.activeElement).toBe(button);
click(icon, 'mouse');
fixture.detectChanges();
expect(requestFullscreen).toHaveBeenCalledTimes(1);
expect(document.activeElement).not.toBe(button);
// Space now reaches the playback shortcut instead of activating
// the fullscreen button again.
expect(space(document.activeElement ?? document.body)).toBe(true);
expect(fake.commands.togglePlay).toHaveBeenCalledTimes(1);
dispose();
});
it('releases focus after a legacy MouseEvent click that completes a recorded press', () => {
const { button, dispose } = mountFullscreenSurface();
button.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
button.focus();
// jsdom dispatches a click without a pointerType.
button.click();
fixture.detectChanges();
expect(document.activeElement).not.toBe(button);
dispose();
});
it('keeps focus on a control the keyboard activates', () => {
const { button, requestFullscreen, dispose } =
mountFullscreenSurface();
button.focus();
// Enter/Space: a synthetic click with an empty pointer type...
click(button, '');
fixture.detectChanges();
expect(requestFullscreen).toHaveBeenCalledTimes(1);
expect(document.activeElement).toBe(button);
// ...and a legacy synthetic click with no press to attribute.
button.click();
fixture.detectChanges();
expect(requestFullscreen).toHaveBeenCalledTimes(2);
expect(document.activeElement).toBe(button);
dispose();
});
it('keeps the volume popover open when a mouse click on the mute button releases its focus', () => {
setCapabilities({ volume: true });
fixture.detectChanges();
const anchor = query(
'.player-controls__popover-anchor'
) as HTMLElement;
const mute = anchor.querySelector('button') as HTMLButtonElement;
// Hovering the anchor opens the popover; the pointer stays there.
anchor.dispatchEvent(new MouseEvent('pointerenter'));
fixture.detectChanges();
expect(fixture.componentInstance.menus.volumeOpen()).toBe(true);
mute.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
mute.focus();
click(mute, 'mouse');
fixture.detectChanges();
expect(document.activeElement).not.toBe(mute);
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(fixture.componentInstance.menus.volumeOpen()).toBe(true);
expect(bar()).not.toBeNull();
});
it('still closes the volume popover when keyboard focus leaves the anchor', () => {
setCapabilities({ volume: true });
fixture.detectChanges();
const anchor = query(
'.player-controls__popover-anchor'
) as HTMLElement;
const mute = anchor.querySelector('button') as HTMLButtonElement;
mute.focus();
fixture.detectChanges();
expect(fixture.componentInstance.menus.volumeOpen()).toBe(true);
const outside = document.createElement('button');
document.body.appendChild(outside);
outside.focus();
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(fixture.componentInstance.menus.volumeOpen()).toBe(false);
outside.remove();
});
});
describe('bar-hover state', () => {
const bar = () => query('.player-controls__bar') as HTMLElement | null;
@@ -114,6 +114,7 @@ export class PlayerControlsComponent implements OnDestroy {
volume: this.volume,
menus: this.menus,
wasTouchInteraction: (event) => this.surface.wasTouchInteraction(event),
wasPointerFocusRelease: () => this.surface.wasPointerFocusRelease(),
canAdjustVolume: () => this.capabilities().volume,
reveal: (options) => this.reveal(options),
});
@@ -387,21 +388,39 @@ export class PlayerControlsComponent implements OnDestroy {
* A key press that bubbles out of a control inside the bar means the
* keyboard is operating that control (Space/Enter on a button, arrows on
* a slider): the bar is pinned exactly as if the control had been focused
* with Tab. A mouse click leaves its button focused without a pin, and
* the key press produces no new focus event, so this is the only place
* that hands ownership back to the keyboard.
* with Tab. A completed pointer click no longer leaves its control
* focused (`onBarClick`), but a press released off the control does,
* without a pin, and the key press then produces no new focus event, so
* this is the only place that hands ownership back to the keyboard.
*/
onBarKeyDown(): void {
this.barFocused.set(true);
this.reveal({ scheduleHide: false });
}
/**
* A pointer click leaves the clicked control focused (`onBarFocusIn`),
* and a focused control captures the keyboard: Space and Enter activate
* it again instead of toggling playback, and the playback shortcuts
* yield to any interactive element in the key's path — after a click on
* the fullscreen button, Space left fullscreen instead of pausing. The
* focus was never the keyboard's, so it is released once the click
* completes; keyboard activation (an empty click `pointerType`) keeps
* focus where Tab put it.
*/
onBarClick(event: MouseEvent): void {
const bar = event.currentTarget;
if (bar instanceof HTMLElement && this.surface.wasPointerClick(event)) {
this.surface.releasePointerFocus(bar);
}
}
onBarFocusIn(event: FocusEvent): void {
// Chromium moves focus to a clicked <button>. That focus is a side
// effect of the click, not keyboard navigation: it must reveal like
// any pointer activity, but never pin the bar open until the next
// click on the video takes focus away (issue: fullscreen button left
// the controls on screen until a click-to-pause on the viewport).
// any pointer activity, but never pin the bar open; `onBarClick`
// drops it again once the click completes (issue: fullscreen button
// left the controls on screen until a click-to-pause on the viewport).
if (this.surface.wasPointerInteraction(event)) {
this.barFocused.set(false);
this.reveal();