fix(playback): auto-hide shared controls after a mouse click on a bar button (#1512)

Chromium focuses a clicked <button>, so the shared controls bar treated every
mouse click on a control (fullscreen, mute, ...) as keyboard navigation and
pinned itself open until a click on the viewport took focus away — a click
that also paused playback. Most visible on Embedded MPV frame-copy after
entering fullscreen; reproduces on HTML5, Video.js and ArtPlayer too.

Only keyboard-originated focus pins the bar now: pointer-attributed focus
reveals without a pin, 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.

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 13:06:46 +02:00
1 parent 82295356ed
commit 4f723b63a0
9 files changed
+479 -19

No files matched your search

@@ -0,0 +1,10 @@
---
type: fix
area: playback
---
The built-in player controls now hide on their own after you click a button
in the bar with the mouse, such as fullscreen or mute. Previously the clicked
button kept the bar pinned on screen until you clicked the video, which also
paused it. Applies to the shared controls in the web players and the
Embedded MPV frame-copy engine.
+12
View File
@@ -203,6 +203,18 @@ Key files:
`PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`;
movie and live hosts fall back to `playback.title`, skipping raw stream-URL
fallbacks. Outside fullscreen the overlay stays hidden.
- Auto-hide pauses while the pointer is over the controls bar or keyboard
focus is inside it, but 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; 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 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:
`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
vendor chrome), and its checkbox appears only when HTML5, Video.js, or
+1 -1
View File
@@ -1129,7 +1129,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). 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 component per playback application and the Fullscreen API exits when its element leaves the document; that is what keeps fullscreen across episode/channel/alternative-source switches (the vendor-chrome opt-out still loses it) — and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guardLine 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. 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 web picture-in-picture stays inside that default-on rollout.
`PlayerController` exposes capability `pictureInPicture`, state
`pictureInPictureActive`/`canPictureInPicture`, and command
+23 -1
View File
@@ -334,7 +334,29 @@ controls instance.
Auto-hide pauses while the pointer is over the controls bar or keyboard focus
is anywhere inside it. Focus entering a hidden bar reveals it; moving focus
between controls does not restart hiding, and leaving the bar resumes the normal
hide delay. In fullscreen playback, hiding the controls also hides the pointer
hide delay. Only keyboard-originated focus pins the bar: Chromium also moves
focus to a clicked `<button>`, and that focus is a side effect of the click,
so `ControlsSurface.wasPointerInteraction` attributes a `focusin` to the press
when a `pointerdown` was recorded within the last second whose target lies
inside the newly focused element. A press moves focus at most once and does so
synchronously, so the record is discarded on the first bar focus event it is
asked about, matching or not, and on any key press; nothing a later Tab or
Shift+Tab focuses can be attributed to a stale press, not even the control
the press hit while it was already focused and hence produced no focus event.
Such focus reveals like any pointer activity and the bar hides on the normal
delay while the button stays the active element; a Tab shortly after a click
on the video still counts as keyboard navigation because the press did not
land inside the focused control. A key press that bubbles out of a control
inside the bar (Space or Enter on the still-focused button, arrows on a
slider) hands ownership back to the keyboard and pins the bar exactly as Tab
focus does, because operating a focused control produces no focus event. A
pointer press anywhere in the bar also releases an existing keyboard pin: the
press may produce no focus event at all (clicking the control that already has
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.
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.
@@ -241,15 +241,15 @@ describe('ControlsSurface', () => {
it('attributes pointer-typeless events to a recent touch pointerdown', () => {
document.dispatchEvent(pointerTypedEvent('pointerdown', 'touch'));
expect(
surface.wasTouchInteraction(new FocusEvent('focusin'))
).toBe(true);
expect(surface.wasTouchInteraction(new FocusEvent('focusin'))).toBe(
true
);
expect(surface.wasTouchInteraction(undefined)).toBe(true);
document.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
expect(
surface.wasTouchInteraction(new FocusEvent('focusin'))
).toBe(false);
expect(surface.wasTouchInteraction(new FocusEvent('focusin'))).toBe(
false
);
});
it('trusts an explicit pointer type over the pointerdown history', () => {
@@ -260,10 +260,96 @@ describe('ControlsSurface', () => {
)
).toBe(false);
expect(
surface.wasTouchInteraction(
pointerTypedEvent('click', 'touch')
)
surface.wasTouchInteraction(pointerTypedEvent('click', 'touch'))
).toBe(true);
});
});
describe('pointer-originated focus attribution', () => {
let button: HTMLButtonElement;
let icon: HTMLElement;
beforeEach(() => {
button = document.createElement('button');
icon = document.createElement('span');
button.appendChild(icon);
element.appendChild(button);
});
const focusOn = (target: EventTarget) => {
const event = new FocusEvent('focusin');
Object.defineProperty(event, 'target', { value: target });
return event;
};
it('attributes focus to a recent press inside the focused element', () => {
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
expect(surface.wasPointerInteraction(focusOn(button))).toBe(true);
});
it('treats a press on the focused element itself as pointer focus', () => {
button.dispatchEvent(pointerTypedEvent('pointerdown', 'pen'));
expect(surface.wasPointerInteraction(focusOn(button))).toBe(true);
});
it('reports keyboard focus when no press was recorded', () => {
expect(surface.wasPointerInteraction(focusOn(button))).toBe(false);
});
it('reports keyboard focus when the press landed outside the focused element', () => {
element.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
expect(surface.wasPointerInteraction(focusOn(button))).toBe(false);
});
it('consumes the press on its first matching focus event', () => {
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
expect(surface.wasPointerInteraction(focusOn(button))).toBe(true);
// Shift+Tab away and Tab back within the window: keyboard focus.
expect(surface.wasPointerInteraction(focusOn(button))).toBe(false);
});
it('discards the press on the first focus event even without a match', () => {
const other = document.createElement('button');
element.appendChild(other);
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
// The press hit an already focused control (no focus event of its
// own); the next focus event is keyboard navigation elsewhere...
expect(surface.wasPointerInteraction(focusOn(other))).toBe(false);
// ...and returning to the pressed control is keyboard navigation.
expect(surface.wasPointerInteraction(focusOn(button))).toBe(false);
});
it('discards the press on any key press', () => {
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
);
expect(surface.wasPointerInteraction(focusOn(button))).toBe(false);
});
it('removes its capture-phase keydown listener on dispose', () => {
const remove = jest.spyOn(document, 'removeEventListener');
surface.dispose();
expect(remove).toHaveBeenCalledWith(
'keydown',
expect.any(Function),
{ capture: true }
);
remove.mockRestore();
});
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.wasPointerInteraction(focusOn(button))).toBe(false);
});
it('stops recording presses after dispose', () => {
surface.dispose();
icon.dispatchEvent(pointerTypedEvent('pointerdown', 'mouse'));
expect(surface.wasPointerInteraction(focusOn(button))).toBe(false);
});
});
});
@@ -28,11 +28,11 @@ export interface ControlsSurfaceHandlers {
const VIEWPORT_CLICK_PAUSE_DELAY_MS = 250;
/**
* How long after a touch pointerdown a focus/click event is still attributed
* to that touch. Covers browsers whose click events are plain MouseEvents
* (no pointerType) and focusin handlers, which never carry a pointer type.
* How long after a pointerdown a focus/click event is still attributed to
* that press. Covers browsers whose click events are plain MouseEvents (no
* pointerType) and focus handlers, which never carry a pointer type.
*/
const TOUCH_ATTRIBUTION_WINDOW_MS = 1000;
const POINTER_ATTRIBUTION_WINDOW_MS = 1000;
const INTERACTIVE_SELECTOR = 'button, input, [role="slider"]';
@@ -48,10 +48,14 @@ 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;
private readonly onDocumentPointerDown = (event: PointerEvent) => {
const now = Date.now();
this.lastTouchPointerDownAt =
event.pointerType === 'touch' ? Date.now() : null;
event.pointerType === 'touch' ? now : null;
this.lastPointerDown = { at: now, target: event.target };
const path = event.composedPath();
if (
!this.surface ||
@@ -63,6 +67,14 @@ export class ControlsSurface {
this.handlers.closePopovers();
};
/**
* Keyboard input ends pointer attribution: focus that moves after a key
* press was moved by the keyboard, whatever the last press hit.
*/
private readonly onDocumentKeyDown = () => {
this.lastPointerDown = null;
};
constructor(
private readonly handlers: ControlsSurfaceHandlers,
/** Additional root whose descendants count as inside for dismissal. */
@@ -73,6 +85,9 @@ export class ControlsSurface {
'pointerdown',
this.onDocumentPointerDown
);
document.addEventListener('keydown', this.onDocumentKeyDown, {
capture: true,
});
}
}
@@ -118,6 +133,9 @@ export class ControlsSurface {
'pointerdown',
this.onDocumentPointerDown
);
document.removeEventListener('keydown', this.onDocumentKeyDown, {
capture: true,
});
}
}
@@ -134,7 +152,40 @@ export class ControlsSurface {
return (
this.lastTouchPointerDownAt !== null &&
Date.now() - this.lastTouchPointerDownAt <=
TOUCH_ATTRIBUTION_WINDOW_MS
POINTER_ATTRIBUTION_WINDOW_MS
);
}
/**
* Whether a focus event is the side effect of a pointer press on the
* element that received focus. Chromium focuses a clicked `<button>`, so
* `focusin` alone cannot tell a mouse click from Tab navigation; only the
* latter should keep the controls pinned. The press must be recent and
* must have landed inside the newly focused element — a Tab shortly after
* a click on the video still counts as keyboard navigation.
*
* A press moves focus at most once, and it does so synchronously, so the
* focus change it caused is always the first focus event after it. The
* record is therefore discarded on the first focus event it is asked
* about, matching or not, and on any key press (`onDocumentKeyDown`).
* Nothing a later Tab or Shift+Tab focuses can be attributed to a stale
* press, including the control the press hit while it was already
* focused and hence produced no focus event at all.
*/
wasPointerInteraction(event: FocusEvent): boolean {
const press = this.lastPointerDown;
this.lastPointerDown = null;
if (
press === null ||
Date.now() - press.at > POINTER_ATTRIBUTION_WINDOW_MS
) {
return false;
}
const focused = event.target;
return (
focused instanceof Node &&
press.target instanceof Node &&
focused.contains(press.target)
);
}
@@ -33,7 +33,9 @@
[class.player-controls__bar--visible]="controlsAreVisible()"
(pointerenter)="onBarPointerEnter()"
(pointerleave)="onBarPointerLeave()"
(focusin)="onBarFocusIn()"
(pointerdown)="onBarPointerDown()"
(keydown)="onBarKeyDown()"
(focusin)="onBarFocusIn($event)"
(focusout)="onBarFocusOut($event)"
>
<div class="player-controls__transport">
@@ -318,6 +318,249 @@ describe('PlayerControlsComponent', () => {
expect(fixture.componentInstance.controlsAreVisible()).toBe(false);
outside.remove();
});
it('does not pin the bar on the focus a mouse click on a control produces', () => {
setCapabilities({ fullscreen: true });
setState({ status: 'playing' });
const surface = document.createElement('div');
document.body.appendChild(surface);
(
surface as unknown as { requestFullscreen: unknown }
).requestFullscreen = jest.fn(() => Promise.resolve());
(
document as unknown as { exitFullscreen: unknown }
).exitFullscreen = jest.fn(() => Promise.resolve());
fixture.componentRef.setInput('playerSurface', surface);
fixture.detectChanges();
const button = query(
'[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.
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);
// The pointer leaves the bar and moves over the video while the
// button is still the active element.
bar()?.dispatchEvent(new MouseEvent('pointerleave'));
surface.dispatchEvent(new MouseEvent('pointermove'));
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(fixture.componentInstance.controlsAreVisible()).toBe(false);
surface.remove();
delete (document as unknown as { exitFullscreen?: unknown })
.exitFullscreen;
});
it('still pins the bar for keyboard focus that follows a click on the video', () => {
const surface = document.createElement('div');
document.body.appendChild(surface);
fixture.componentRef.setInput('playerSurface', surface);
fixture.detectChanges();
// A click on the viewport, then Tab into the bar within a second:
// the press did not land on the focused control, so it is
// keyboard navigation and keeps the controls visible.
surface.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
const buttons = Array.from(
bar()?.querySelectorAll('button') ?? []
) as HTMLButtonElement[];
buttons[0].focus();
fixture.detectChanges();
bar()?.dispatchEvent(new MouseEvent('pointerleave'));
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
surface.remove();
});
it('releases a keyboard pin when the mouse clicks another bar control', () => {
setCapabilities({ seek: true });
setState({
status: 'playing',
canSeek: true,
durationSeconds: 600,
});
fixture.detectChanges();
const buttons = Array.from(
bar()?.querySelectorAll('button') ?? []
) as HTMLButtonElement[];
// Tab into the bar: pinned.
buttons[0].focus();
fixture.detectChanges();
jest.advanceTimersByTime(10000);
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
// Mouse click on a different control. The focus transfer stays
// inside the bar, which focusout ignores by design.
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'));
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(fixture.componentInstance.controlsAreVisible()).toBe(false);
});
it('releases a keyboard pin when the mouse clicks the focused control itself', () => {
const buttons = Array.from(
bar()?.querySelectorAll('button') ?? []
) as HTMLButtonElement[];
buttons[0].focus();
fixture.detectChanges();
jest.advanceTimersByTime(10000);
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
// Clicking the already focused button produces no focus event.
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'));
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(fixture.componentInstance.controlsAreVisible()).toBe(false);
});
it('pins again when keyboard focus returns to the clicked control within the window', () => {
setCapabilities({ seek: true });
setState({
status: 'playing',
canSeek: true,
durationSeconds: 600,
});
fixture.detectChanges();
const buttons = Array.from(
bar()?.querySelectorAll('button') ?? []
) as HTMLButtonElement[];
// Mouse click on the second control...
bar()?.dispatchEvent(new MouseEvent('pointerenter'));
buttons[1].dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
buttons[1].focus();
buttons[1].click();
fixture.detectChanges();
// ...then Shift+Tab away and Tab back before the press expires.
buttons[0].focus();
buttons[1].focus();
fixture.detectChanges();
bar()?.dispatchEvent(new MouseEvent('pointerleave'));
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(document.activeElement).toBe(buttons[1]);
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
});
it('pins again after a click on the focused control, Tab away and Shift+Tab back', () => {
setCapabilities({ seek: true });
setState({
status: 'playing',
canSeek: true,
durationSeconds: 600,
});
fixture.detectChanges();
const buttons = Array.from(
bar()?.querySelectorAll('button') ?? []
) as HTMLButtonElement[];
const tab = (shift: boolean) =>
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: shift,
bubbles: true,
})
);
// Keyboard focus on the second control, then a mouse click on
// it: no focus event, so the press record is not consumed.
buttons[1].focus();
fixture.detectChanges();
bar()?.dispatchEvent(new MouseEvent('pointerenter'));
buttons[1].dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true })
);
buttons[1].click();
fixture.detectChanges();
// Tab to the next control and Shift+Tab back within the window.
tab(false);
buttons[2].focus();
tab(true);
buttons[1].focus();
fixture.detectChanges();
bar()?.dispatchEvent(new MouseEvent('pointerleave'));
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(document.activeElement).toBe(buttons[1]);
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
});
it('pins the bar when the keyboard operates a mouse-focused control', () => {
setCapabilities({ seek: true });
setState({
status: 'playing',
canSeek: true,
durationSeconds: 600,
});
fixture.detectChanges();
const buttons = Array.from(
bar()?.querySelectorAll('button') ?? []
) as HTMLButtonElement[];
// Mouse click leaves the control focused but not pinned...
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();
// ...then Space on that control: no focus event, but keyboard use.
buttons[1].dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true })
);
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(document.activeElement).toBe(buttons[1]);
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
// Focus leaving the bar releases the pin again.
const outside = document.createElement('button');
document.body.appendChild(outside);
outside.focus();
fixture.detectChanges();
jest.advanceTimersByTime(10000);
fixture.detectChanges();
expect(fixture.componentInstance.controlsAreVisible()).toBe(false);
outside.remove();
});
});
describe('bar-hover state', () => {
@@ -372,7 +372,41 @@ export class PlayerControlsComponent implements OnDestroy {
this.visibility.scheduleHide();
}
onBarFocusIn(): void {
/**
* A pointer press anywhere in the bar hands the interaction over to the
* pointer: a keyboard pin set by an earlier Tab is released here, because
* the press may not produce any focus event at all (clicking the control
* that is already focused) or only a focus transfer inside the bar, which
* `onBarFocusOut` deliberately ignores.
*/
onBarPointerDown(): void {
this.barFocused.set(false);
}
/**
* 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.
*/
onBarKeyDown(): void {
this.barFocused.set(true);
this.reveal({ scheduleHide: false });
}
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).
if (this.surface.wasPointerInteraction(event)) {
this.barFocused.set(false);
this.reveal();
return;
}
this.barFocused.set(true);
this.reveal({ scheduleHide: false });
}