mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
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:
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user