From 9912a8e7593dc9f26c6dfe5cd8ddca34ef89a8ef Mon Sep 17 00:00:00 2001 From: rahim Date: Tue, 7 Apr 2026 19:46:44 -0700 Subject: [PATCH] feat(packages): add hotkey bindings to preset skins (#1264) --- packages/html/src/define/audio/skin.ts | 16 ++++++++++ packages/html/src/define/audio/ui.ts | 1 + packages/html/src/define/video/skin.ts | 19 ++++++++++++ packages/html/src/define/video/ui.ts | 1 + packages/html/src/media/container-element.ts | 30 +++++++++++++++++++ packages/html/src/ui/hotkey/hotkey-element.ts | 6 +++- packages/react/src/player/context.tsx | 19 ++++++++++-- packages/react/src/presets/audio/skin.tsx | 17 +++++++++++ packages/react/src/presets/video/skin.tsx | 20 +++++++++++++ .../skins/src/default/css/components/root.css | 6 ++++ .../skins/src/minimal/css/components/root.css | 6 ++++ 11 files changed, 137 insertions(+), 4 deletions(-) diff --git a/packages/html/src/define/audio/skin.ts b/packages/html/src/define/audio/skin.ts index 69e64d57..50a3fc9d 100644 --- a/packages/html/src/define/audio/skin.ts +++ b/packages/html/src/define/audio/skin.ts @@ -94,6 +94,22 @@ function getTemplateHTML() { + + + + + + + + + + + + + + + + `; } diff --git a/packages/html/src/define/audio/ui.ts b/packages/html/src/define/audio/ui.ts index a55d5992..4178e381 100644 --- a/packages/html/src/define/audio/ui.ts +++ b/packages/html/src/define/audio/ui.ts @@ -4,6 +4,7 @@ import './player'; import '../ui/error-dialog'; +import '../ui/hotkey'; import '../ui/mute-button'; import '../ui/play-button'; import '../ui/playback-rate-button'; diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index 0bf19a10..8b830cb6 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -130,6 +130,25 @@ function getTemplateHTML() {
+ + + + + + + + + + + + + + + + + + + `; } diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts index 02951e2e..cd2fa1f1 100644 --- a/packages/html/src/define/video/ui.ts +++ b/packages/html/src/define/video/ui.ts @@ -8,6 +8,7 @@ import '../ui/captions-button'; import '../ui/controls'; import '../ui/error-dialog'; import '../ui/fullscreen-button'; +import '../ui/hotkey'; import '../ui/mute-button'; import '../ui/pip-button'; import '../ui/play-button'; diff --git a/packages/html/src/media/container-element.ts b/packages/html/src/media/container-element.ts index 286fc15f..a3eb2236 100644 --- a/packages/html/src/media/container-element.ts +++ b/packages/html/src/media/container-element.ts @@ -1,3 +1,5 @@ +import { listen } from '@videojs/utils/dom'; + import { containerContext, playerContext } from '../player/context'; import { createContainerMixin } from '../store/container-mixin'; import { MediaElement } from '../ui/media-element'; @@ -6,4 +8,32 @@ const ContainerMixin = createContainerMixin({ playerContext, containerContext }) export class MediaContainerElement extends ContainerMixin(MediaElement) { static readonly tagName = 'media-container'; + + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + + // Make focusable so keyboard events reach hotkey listeners. + if (!this.hasAttribute('tabindex')) { + this.setAttribute('tabindex', '0'); + } + + this.#disconnect = new AbortController(); + listen(this, 'pointerup', this.#onPointerUp, { signal: this.#disconnect.signal }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#disconnect?.abort(); + this.#disconnect = null; + } + + #onPointerUp = (): void => { + // If nothing inside the container has focus, grab it so keyboard + // events reach the hotkey coordinator's listener. + if (!this.contains(document.activeElement) || document.activeElement === document.body) { + this.focus({ preventScroll: true }); + } + }; } diff --git a/packages/html/src/ui/hotkey/hotkey-element.ts b/packages/html/src/ui/hotkey/hotkey-element.ts index 68c03fa6..ebf540df 100644 --- a/packages/html/src/ui/hotkey/hotkey-element.ts +++ b/packages/html/src/ui/hotkey/hotkey-element.ts @@ -24,7 +24,11 @@ export class HotkeyElement extends MediaElement { target: 'player' | 'document' = 'player'; readonly #player = new PlayerController(this, playerContext); - readonly #container = new ContextConsumer(this, { context: containerContext, subscribe: true }); + readonly #container = new ContextConsumer(this, { + context: containerContext, + callback: () => this.requestUpdate(), + subscribe: true, + }); #cleanup: (() => void) | null = null; override connectedCallback(): void { diff --git a/packages/react/src/player/context.tsx b/packages/react/src/player/context.tsx index 2024f39d..ce5fc7f2 100644 --- a/packages/react/src/player/context.tsx +++ b/packages/react/src/player/context.tsx @@ -3,7 +3,7 @@ import type { Media, MediaContainer } from '@videojs/core/dom'; import type { UnknownState, UnknownStore } from '@videojs/store'; import { useStore } from '@videojs/store/react'; -import type { Dispatch, HTMLAttributes, ReactNode, SetStateAction } from 'react'; +import type { Dispatch, HTMLAttributes, ReactNode, PointerEvent as ReactPointerEvent, SetStateAction } from 'react'; import { createContext, forwardRef, useContext, useEffect, useRef } from 'react'; import { useComposedRefs } from '../utils/use-composed-refs'; @@ -103,7 +103,10 @@ export interface ContainerProps extends HTMLAttributes { children?: ReactNode; } -export const Container = forwardRef(function Container({ children, ...props }, ref) { +export const Container = forwardRef(function Container( + { children, tabIndex = 0, ...props }, + ref +) { const setContainer = useContainerAttach(); const internalRef = useRef(null); const composedRef = useComposedRefs(ref, internalRef); @@ -113,8 +116,18 @@ export const Container = forwardRef(function Con return () => setContainer?.(null); }, [setContainer]); + const handlePointerUp = (event: ReactPointerEvent) => { + props.onPointerUp?.(event); + const el = internalRef.current; + if (!el) return; + // If nothing inside has focus, grab it so keyboard events reach hotkey listeners. + if (!el.contains(document.activeElement) || document.activeElement === document.body) { + el.focus({ preventScroll: true }); + } + }; + return ( -
+
{children}
); diff --git a/packages/react/src/presets/audio/skin.tsx b/packages/react/src/presets/audio/skin.tsx index f15f5748..d3ee198a 100644 --- a/packages/react/src/presets/audio/skin.tsx +++ b/packages/react/src/presets/audio/skin.tsx @@ -11,6 +11,7 @@ import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { Container, usePlayer } from '@/player/context'; import { ErrorDialog } from '@/ui/error-dialog'; +import { MediaHotkey } from '@/ui/hotkey/media-hotkey'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; import { PlaybackRateButton } from '@/ui/playback-rate-button'; @@ -155,6 +156,22 @@ export function AudioSkin(props: AudioSkinProps): ReactNode {
+ + {/* Hotkeys */} + + + + + + + + + + + + + + ); } diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index 78698215..8c45610a 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -23,6 +23,7 @@ import { CaptionsButton } from '@/ui/captions-button'; import { Controls } from '@/ui/controls'; import { ErrorDialog } from '@/ui/error-dialog'; import { FullscreenButton } from '@/ui/fullscreen-button'; +import { MediaHotkey } from '@/ui/hotkey/media-hotkey'; import { MuteButton } from '@/ui/mute-button'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; @@ -229,6 +230,25 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
+ + {/* Hotkeys */} + + + + + + + + + + + + + + + + + ); } diff --git a/packages/skins/src/default/css/components/root.css b/packages/skins/src/default/css/components/root.css index e8844311..3ec7677a 100644 --- a/packages/skins/src/default/css/components/root.css +++ b/packages/skins/src/default/css/components/root.css @@ -10,6 +10,12 @@ height: 100%; width: 100%; border-radius: var(--media-border-radius, 2rem); + outline: 2px solid transparent; + outline-offset: 2px; + + &:focus-visible { + outline-color: currentColor; + } font-family: Inter Variable, Inter, diff --git a/packages/skins/src/minimal/css/components/root.css b/packages/skins/src/minimal/css/components/root.css index 03e03af6..c4260229 100644 --- a/packages/skins/src/minimal/css/components/root.css +++ b/packages/skins/src/minimal/css/components/root.css @@ -10,6 +10,12 @@ height: 100%; width: 100%; border-radius: var(--media-border-radius, 0.75rem); + outline: 2px solid transparent; + outline-offset: 2px; + + &:focus-visible { + outline-color: currentColor; + } font-family: Inter Variable, Inter,