From 89f13eb6d7db1d7cbf70182680a89114400414f3 Mon Sep 17 00:00:00 2001 From: Rahim Date: Mon, 22 Jun 2026 20:19:50 -0700 Subject: [PATCH] feat(core): add overlay primitive --- packages/core/src/core/index.ts | 2 + .../core/src/core/ui/components.generated.ts | 3 + .../src/core/ui/overlay/overlay-component.ts | 7 + .../core/src/core/ui/overlay/overlay-core.ts | 36 +++++ .../src/core/ui/overlay/overlay-data-attrs.ts | 11 ++ .../ui/overlay/tests/overlay-core.test.ts | 75 +++++++++ .../live-video/minimal-skin.tailwind.ts | 2 +- .../src/define/live-video/minimal-skin.ts | 2 +- .../html/src/define/live-video/minimal-ui.ts | 2 + .../src/define/live-video/skin.tailwind.ts | 2 +- packages/html/src/define/live-video/skin.ts | 2 +- packages/html/src/define/live-video/ui.ts | 2 + packages/html/src/define/ui/overlay.ts | 10 ++ .../src/define/video/minimal-skin.tailwind.ts | 2 +- .../html/src/define/video/minimal-skin.ts | 2 +- packages/html/src/define/video/minimal-ui.ts | 2 + .../html/src/define/video/skin.tailwind.ts | 2 +- packages/html/src/define/video/skin.ts | 2 +- packages/html/src/define/video/ui.ts | 2 + packages/html/src/index.ts | 1 + .../html/src/ui/overlay/overlay-element.ts | 27 ++++ .../ui/overlay/tests/overlay-element.test.ts | 144 ++++++++++++++++++ packages/react/src/index.ts | 1 + .../live-video/minimal-skin.tailwind.tsx | 3 +- .../src/presets/live-video/minimal-skin.tsx | 3 +- .../src/presets/live-video/skin.tailwind.tsx | 3 +- .../react/src/presets/live-video/skin.tsx | 3 +- .../tests/skins-compiler-config.test.ts | 3 + .../default-video.generated.tailwind.tsx | 4 + .../presets/video/minimal-skin.tailwind.tsx | 3 +- .../react/src/presets/video/minimal-skin.tsx | 3 +- .../react/src/presets/video/skin.tailwind.tsx | 3 +- packages/react/src/presets/video/skin.tsx | 3 +- packages/react/src/ui/overlay/index.ts | 1 + packages/react/src/ui/overlay/overlay.tsx | 42 +++++ .../src/ui/overlay/tests/overlay.test.tsx | 61 ++++++++ .../default/tailwind/components/overlay.ts | 14 +- packages/skins/src/default/video.skin.tsx | 4 + .../minimal/tailwind/components/overlay.ts | 14 +- 39 files changed, 476 insertions(+), 32 deletions(-) create mode 100644 packages/core/src/core/ui/overlay/overlay-component.ts create mode 100644 packages/core/src/core/ui/overlay/overlay-core.ts create mode 100644 packages/core/src/core/ui/overlay/overlay-data-attrs.ts create mode 100644 packages/core/src/core/ui/overlay/tests/overlay-core.test.ts create mode 100644 packages/html/src/define/ui/overlay.ts create mode 100644 packages/html/src/ui/overlay/overlay-element.ts create mode 100644 packages/html/src/ui/overlay/tests/overlay-element.test.ts create mode 100644 packages/react/src/ui/overlay/index.ts create mode 100644 packages/react/src/ui/overlay/overlay.tsx create mode 100644 packages/react/src/ui/overlay/tests/overlay.test.tsx diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index f8199376..c4cda2d9 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -54,6 +54,8 @@ export * from './ui/menu/props'; export * from './ui/mute-button/mute-button-core'; export * from './ui/mute-button/mute-button-data-attrs'; export * from './ui/mute-button/props'; +export * from './ui/overlay/overlay-core'; +export * from './ui/overlay/overlay-data-attrs'; export * from './ui/pip-button/pip-button-core'; export * from './ui/pip-button/pip-button-data-attrs'; export * from './ui/pip-button/props'; diff --git a/packages/core/src/core/ui/components.generated.ts b/packages/core/src/core/ui/components.generated.ts index dabde488..cf0fe218 100644 --- a/packages/core/src/core/ui/components.generated.ts +++ b/packages/core/src/core/ui/components.generated.ts @@ -16,6 +16,7 @@ import HotkeyDef from './hotkey/hotkey-component'; import LiveButtonDef from './live-button/live-button-component'; import MenuDef from './menu/menu-component'; import MuteButtonDef from './mute-button/mute-button-component'; +import OverlayDef from './overlay/overlay-component'; import PiPButtonDef from './pip-button/pip-button-component'; import PlayButtonDef from './play-button/play-button-component'; import PlaybackRateButtonDef from './playback-rate-button/playback-rate-button-component'; @@ -50,6 +51,7 @@ export const Hotkey = createComponent(HotkeyDef); export const LiveButton = createComponent(LiveButtonDef); export const Menu = createComponent(MenuDef); export const MuteButton = createComponent(MuteButtonDef); +export const Overlay = createComponent(OverlayDef); export const PiPButton = createComponent(PiPButtonDef); export const PlaybackRateButton = createComponent(PlaybackRateButtonDef); export const PlaybackRateRadioGroup = createComponent(PlaybackRateRadioGroupDef); @@ -85,6 +87,7 @@ export const COMPONENTS = { LiveButton: LiveButtonDef, Menu: MenuDef, MuteButton: MuteButtonDef, + Overlay: OverlayDef, PiPButton: PiPButtonDef, PlaybackRateButton: PlaybackRateButtonDef, PlaybackRateRadioGroup: PlaybackRateRadioGroupDef, diff --git a/packages/core/src/core/ui/overlay/overlay-component.ts b/packages/core/src/core/ui/overlay/overlay-component.ts new file mode 100644 index 00000000..360acdfb --- /dev/null +++ b/packages/core/src/core/ui/overlay/overlay-component.ts @@ -0,0 +1,7 @@ +import { defineComponent } from '../manifest'; +import { OverlayDataAttrs } from './overlay-data-attrs'; + +export default defineComponent()({ + name: 'Overlay', + dataAttrs: OverlayDataAttrs, +}); diff --git a/packages/core/src/core/ui/overlay/overlay-core.ts b/packages/core/src/core/ui/overlay/overlay-core.ts new file mode 100644 index 00000000..905c94d6 --- /dev/null +++ b/packages/core/src/core/ui/overlay/overlay-core.ts @@ -0,0 +1,36 @@ +import type { MediaControlsState, MediaErrorState } from '../../media/state'; + +export interface OverlayMediaState { + controls?: MediaControlsState | null | undefined; + error?: MediaErrorState | null | undefined; +} + +export interface OverlayState { + visible: boolean; + controlsVisible: boolean; + errorVisible: boolean; +} + +export class OverlayCore { + #media: OverlayMediaState = {}; + + setMedia(media: OverlayMediaState): void { + this.#media = media; + } + + getState(): OverlayState { + const controlsVisible = this.#media.controls?.controlsVisible ?? false; + const errorVisible = Boolean(this.#media.error?.error); + + return { + visible: controlsVisible || errorVisible, + controlsVisible, + errorVisible, + }; + } +} + +export namespace OverlayCore { + export type MediaState = OverlayMediaState; + export type State = OverlayState; +} diff --git a/packages/core/src/core/ui/overlay/overlay-data-attrs.ts b/packages/core/src/core/ui/overlay/overlay-data-attrs.ts new file mode 100644 index 00000000..ce004f82 --- /dev/null +++ b/packages/core/src/core/ui/overlay/overlay-data-attrs.ts @@ -0,0 +1,11 @@ +import type { StateAttrMap } from '../types'; +import type { OverlayState } from './overlay-core'; + +export const OverlayDataAttrs = { + /** Present when the overlay should be visible. */ + visible: 'data-visible', + /** Present when controls visibility is showing the overlay. */ + controlsVisible: 'data-controls-visible', + /** Present when error visibility is showing the overlay. */ + errorVisible: 'data-error-visible', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/overlay/tests/overlay-core.test.ts b/packages/core/src/core/ui/overlay/tests/overlay-core.test.ts new file mode 100644 index 00000000..6bb01705 --- /dev/null +++ b/packages/core/src/core/ui/overlay/tests/overlay-core.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import type { MediaControlsState, MediaErrorState } from '../../../media/state'; +import { OverlayCore } from '../overlay-core'; + +describe('OverlayCore', () => { + describe('getState', () => { + it('is visible when controls are visible', () => { + const core = new OverlayCore(); + + core.setMedia({ controls: createControlsState({ controlsVisible: true }), error: createErrorState() }); + + expect(core.getState()).toEqual({ + visible: true, + controlsVisible: true, + errorVisible: false, + }); + }); + + it('is visible when an error is active', () => { + const core = new OverlayCore(); + + core.setMedia({ + controls: createControlsState({ controlsVisible: false }), + error: createErrorState({ code: 1, message: 'failed' }), + }); + + expect(core.getState()).toEqual({ + visible: true, + controlsVisible: false, + errorVisible: true, + }); + }); + + it('is hidden when neither controls nor error need it', () => { + const core = new OverlayCore(); + + core.setMedia({ controls: createControlsState({ controlsVisible: false }), error: createErrorState() }); + + expect(core.getState()).toEqual({ + visible: false, + controlsVisible: false, + errorVisible: false, + }); + }); + + it('treats missing feature state as hidden', () => { + const core = new OverlayCore(); + + core.setMedia({}); + + expect(core.getState()).toEqual({ + visible: false, + controlsVisible: false, + errorVisible: false, + }); + }); + }); +}); + +function createControlsState(overrides: Partial = {}): MediaControlsState { + return { + userActive: true, + controlsVisible: true, + toggleControls: () => true, + ...overrides, + }; +} + +function createErrorState(error: MediaErrorState['error'] = null): MediaErrorState { + return { + error, + dismissError: () => {}, + }; +} diff --git a/packages/html/src/define/live-video/minimal-skin.tailwind.ts b/packages/html/src/define/live-video/minimal-skin.tailwind.ts index 0f440803..221e155d 100644 --- a/packages/html/src/define/live-video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/live-video/minimal-skin.tailwind.ts @@ -146,7 +146,7 @@ function getTemplateHTML() { -
+ diff --git a/packages/html/src/define/live-video/minimal-skin.ts b/packages/html/src/define/live-video/minimal-skin.ts index 332e8cfc..f4c5a51a 100644 --- a/packages/html/src/define/live-video/minimal-skin.ts +++ b/packages/html/src/define/live-video/minimal-skin.ts @@ -128,7 +128,7 @@ function getTemplateHTML() { -
+ diff --git a/packages/html/src/define/live-video/minimal-ui.ts b/packages/html/src/define/live-video/minimal-ui.ts index d91abfe0..aab751a2 100644 --- a/packages/html/src/define/live-video/minimal-ui.ts +++ b/packages/html/src/define/live-video/minimal-ui.ts @@ -13,6 +13,7 @@ import { GestureElement } from '../../ui/gesture/gesture-element'; import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; import { LiveButtonElement } from '../../ui/live-button/live-button-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; +import { OverlayElement } from '../../ui/overlay/overlay-element'; import { PiPButtonElement } from '../../ui/pip-button/pip-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; import { PopoverElement } from '../../ui/popover/popover-element'; @@ -58,6 +59,7 @@ safeDefine(GestureElement); safeDefine(HotkeyElement); safeDefine(LiveButtonElement); safeDefine(MuteButtonElement); +safeDefine(OverlayElement); safeDefine(PiPButtonElement); safeDefine(PlayButtonElement); safeDefine(PopoverElement); diff --git a/packages/html/src/define/live-video/skin.tailwind.ts b/packages/html/src/define/live-video/skin.tailwind.ts index 2d0a2fab..df86263c 100644 --- a/packages/html/src/define/live-video/skin.tailwind.ts +++ b/packages/html/src/define/live-video/skin.tailwind.ts @@ -148,7 +148,7 @@ function getTemplateHTML() { -
+ diff --git a/packages/html/src/define/live-video/skin.ts b/packages/html/src/define/live-video/skin.ts index 81a3a745..9e6d1e3b 100644 --- a/packages/html/src/define/live-video/skin.ts +++ b/packages/html/src/define/live-video/skin.ts @@ -130,7 +130,7 @@ function getTemplateHTML() { -
+ diff --git a/packages/html/src/define/live-video/ui.ts b/packages/html/src/define/live-video/ui.ts index 3fb971fe..dad12287 100644 --- a/packages/html/src/define/live-video/ui.ts +++ b/packages/html/src/define/live-video/ui.ts @@ -12,6 +12,7 @@ import { GestureElement } from '../../ui/gesture/gesture-element'; import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; import { LiveButtonElement } from '../../ui/live-button/live-button-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; +import { OverlayElement } from '../../ui/overlay/overlay-element'; import { PiPButtonElement } from '../../ui/pip-button/pip-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; import { PopoverElement } from '../../ui/popover/popover-element'; @@ -55,6 +56,7 @@ safeDefine(GestureElement); safeDefine(HotkeyElement); safeDefine(LiveButtonElement); safeDefine(MuteButtonElement); +safeDefine(OverlayElement); safeDefine(PiPButtonElement); safeDefine(PlayButtonElement); safeDefine(PopoverElement); diff --git a/packages/html/src/define/ui/overlay.ts b/packages/html/src/define/ui/overlay.ts new file mode 100644 index 00000000..cd35bd86 --- /dev/null +++ b/packages/html/src/define/ui/overlay.ts @@ -0,0 +1,10 @@ +import { OverlayElement } from '../../ui/overlay/overlay-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(OverlayElement); + +declare global { + interface HTMLElementTagNameMap { + [OverlayElement.tagName]: OverlayElement; + } +} diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index 21f56a89..89c36b6c 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -271,7 +271,7 @@ function getTemplateHTML() { -
+ diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index a109eb0a..5bc47a3e 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -247,7 +247,7 @@ function getTemplateHTML() { -
+ diff --git a/packages/html/src/define/video/minimal-ui.ts b/packages/html/src/define/video/minimal-ui.ts index 763c051d..c211d49b 100644 --- a/packages/html/src/define/video/minimal-ui.ts +++ b/packages/html/src/define/video/minimal-ui.ts @@ -12,6 +12,7 @@ import { FullscreenButtonElement } from '../../ui/fullscreen-button/fullscreen-b import { GestureElement } from '../../ui/gesture/gesture-element'; import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; +import { OverlayElement } from '../../ui/overlay/overlay-element'; import { PiPButtonElement } from '../../ui/pip-button/pip-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; import { PlaybackRateButtonElement } from '../../ui/playback-rate-button/playback-rate-button-element'; @@ -61,6 +62,7 @@ safeDefine(FullscreenButtonElement); safeDefine(GestureElement); safeDefine(HotkeyElement); safeDefine(MuteButtonElement); +safeDefine(OverlayElement); safeDefine(PiPButtonElement); safeDefine(PlayButtonElement); safeDefine(PlaybackRateButtonElement); diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index eef76b6f..362d3a32 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -267,7 +267,7 @@ function getTemplateHTML() { -
+ diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index 53db1622..07894c18 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -243,7 +243,7 @@ function getTemplateHTML() { -
+ diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts index 1fb0c322..7a4ddece 100644 --- a/packages/html/src/define/video/ui.ts +++ b/packages/html/src/define/video/ui.ts @@ -12,6 +12,7 @@ import { GestureElement } from '../../ui/gesture/gesture-element'; import { HotkeyElement } from '../../ui/hotkey/hotkey-element'; import { LiveButtonElement } from '../../ui/live-button/live-button-element'; import { MuteButtonElement } from '../../ui/mute-button/mute-button-element'; +import { OverlayElement } from '../../ui/overlay/overlay-element'; import { PiPButtonElement } from '../../ui/pip-button/pip-button-element'; import { PlayButtonElement } from '../../ui/play-button/play-button-element'; import { PlaybackRateButtonElement } from '../../ui/playback-rate-button/playback-rate-button-element'; @@ -60,6 +61,7 @@ safeDefine(GestureElement); safeDefine(HotkeyElement); safeDefine(LiveButtonElement); safeDefine(MuteButtonElement); +safeDefine(OverlayElement); safeDefine(PiPButtonElement); safeDefine(PlayButtonElement); safeDefine(PlaybackRateButtonElement); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 3b56f82b..c09bb0dc 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -69,6 +69,7 @@ export { MenuRadioItemElement } from './ui/menu/menu-radio-item-element'; export { MenuSeparatorElement } from './ui/menu/menu-separator-element'; export { MenuViewElement } from './ui/menu/menu-view-element'; export { MuteButtonElement } from './ui/mute-button/mute-button-element'; +export { OverlayElement } from './ui/overlay/overlay-element'; export { PiPButtonElement } from './ui/pip-button/pip-button-element'; export { PlayButtonElement } from './ui/play-button/play-button-element'; export { PlaybackRateButtonElement } from './ui/playback-rate-button/playback-rate-button-element'; diff --git a/packages/html/src/ui/overlay/overlay-element.ts b/packages/html/src/ui/overlay/overlay-element.ts new file mode 100644 index 00000000..ebcedf88 --- /dev/null +++ b/packages/html/src/ui/overlay/overlay-element.ts @@ -0,0 +1,27 @@ +import { OverlayCore, OverlayDataAttrs } from '@videojs/core'; +import { applyStateDataAttrs, selectControls, selectError } from '@videojs/core/dom'; +import type { PropertyValues } from '@videojs/element'; + +import { playerContext } from '../../player/context'; +import { PlayerController } from '../../player/player-controller'; +import { MediaElement } from '../media-element'; + +export class OverlayElement extends MediaElement { + static readonly tagName = 'media-overlay'; + + readonly #core = new OverlayCore(); + readonly #controls = new PlayerController(this, playerContext, selectControls); + readonly #error = new PlayerController(this, playerContext, selectError); + + override connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('aria-hidden', 'true'); + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + this.#core.setMedia({ controls: this.#controls.value, error: this.#error.value }); + applyStateDataAttrs(this, this.#core.getState(), OverlayDataAttrs); + } +} diff --git a/packages/html/src/ui/overlay/tests/overlay-element.test.ts b/packages/html/src/ui/overlay/tests/overlay-element.test.ts new file mode 100644 index 00000000..99a8d58d --- /dev/null +++ b/packages/html/src/ui/overlay/tests/overlay-element.test.ts @@ -0,0 +1,144 @@ +import type { MediaControlsState, MediaError, MediaErrorState } from '@videojs/core'; +import type { AnyPlayerStore } from '@videojs/core/dom'; +import { ContextProvider } from '@videojs/element/context'; +import { createStore, flush } from '@videojs/store'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { playerContext } from '../../../player/context'; +import { MediaElement } from '../../media-element'; +import { OverlayElement } from '../overlay-element'; + +interface OverlayTestState extends MediaControlsState, MediaErrorState { + setControlsVisible(visible: boolean): void; + setError(error: MediaError | null): void; +} + +function ensureCustomElementDefined(Constructor: CustomElementConstructor & { readonly tagName: string }): void { + const { tagName } = Constructor; + if (!customElements.get(tagName)) { + customElements.define(tagName, Constructor); + } +} + +function createDefinedElement( + Constructor: Class +): InstanceType { + ensureCustomElementDefined(Constructor); + return document.createElement(Constructor.tagName) as InstanceType; +} + +function defineElement(tagName: string, Base: CustomElementConstructor): void { + if (!customElements.get(tagName)) { + customElements.define(tagName, Base); + } +} + +function createOverlayStore(): AnyPlayerStore { + return createStore()({ + name: 'overlay', + state: ({ get, set }) => ({ + userActive: false, + controlsVisible: false, + error: null, + toggleControls() { + const visible = !(get().controlsVisible as boolean); + + set({ userActive: visible, controlsVisible: visible }); + + return visible; + }, + dismissError() { + set({ error: null }); + }, + setControlsVisible(visible) { + set({ userActive: visible, controlsVisible: visible }); + }, + setError(error) { + set({ error }); + }, + }), + }) as unknown as AnyPlayerStore; +} + +class TestOverlayPlayerProviderElement extends MediaElement { + store = createOverlayStore(); + + readonly #provider = new ContextProvider(this, { context: playerContext, initialValue: this.store }); + + override connectedCallback(): void { + super.connectedCallback(); + this.#provider.setValue(this.store); + } + + setControlsVisible(visible: boolean): void { + const state = this.store.state as OverlayTestState; + + state.setControlsVisible(visible); + flush(); + } + + setError(error: MediaError | null): void { + const state = this.store.state as OverlayTestState; + + state.setError(error); + flush(); + } +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitForAssertion(assertion: () => void): Promise { + let error: unknown; + + for (let index = 0; index < 10; index++) { + try { + assertion(); + return; + } catch (caught) { + error = caught; + await nextFrame(); + } + } + + throw error; +} + +defineElement('test-overlay-player-provider', TestOverlayPlayerProviderElement); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('OverlayElement', () => { + it('reflects controls and error visibility through data attrs', async () => { + const provider = document.createElement('test-overlay-player-provider') as TestOverlayPlayerProviderElement; + const overlay = createDefinedElement(OverlayElement); + + document.body.append(provider); + provider.append(overlay); + + await overlay.updateComplete; + + expect(overlay.getAttribute('aria-hidden')).toBe('true'); + expect(overlay.hasAttribute('data-visible')).toBe(false); + + provider.setControlsVisible(true); + + await waitForAssertion(() => { + expect(overlay.hasAttribute('data-visible')).toBe(true); + expect(overlay.hasAttribute('data-controls-visible')).toBe(true); + expect(overlay.hasAttribute('data-error-visible')).toBe(false); + }); + + provider.setControlsVisible(false); + provider.setError({ code: 1, message: 'failed' }); + + await waitForAssertion(() => { + expect(overlay.hasAttribute('data-visible')).toBe(true); + expect(overlay.hasAttribute('data-controls-visible')).toBe(false); + expect(overlay.hasAttribute('data-error-visible')).toBe(true); + }); + }); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d04a0488..52474df4 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -56,6 +56,7 @@ export { useHotkeyShortcut } from './ui/hotkey/use-hotkey-shortcut'; export { LiveButton, type LiveButtonProps } from './ui/live-button/live-button'; export { Menu, type MenuContextValue, useMenuContext, useOptionalMenuContext } from './ui/menu'; export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button'; +export { Overlay, type OverlayProps } from './ui/overlay/overlay'; export { PiPButton, type PiPButtonProps } from './ui/pip-button/pip-button'; export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button'; export { diff --git a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx index e1128d89..9a0406d8 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tailwind.tsx @@ -58,6 +58,7 @@ import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; +import { Overlay } from '@/ui/overlay'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; @@ -333,7 +334,7 @@ export function MinimalLiveVideoSkinTailwind(props: MinimalLiveVideoSkinProps): -
+ {/* Hotkeys */} diff --git a/packages/react/src/presets/live-video/minimal-skin.tsx b/packages/react/src/presets/live-video/minimal-skin.tsx index b2884bd6..f382a9b4 100644 --- a/packages/react/src/presets/live-video/minimal-skin.tsx +++ b/packages/react/src/presets/live-video/minimal-skin.tsx @@ -35,6 +35,7 @@ import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; +import { Overlay } from '@/ui/overlay'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; @@ -289,7 +290,7 @@ export function MinimalLiveVideoSkin(props: MinimalLiveVideoSkinProps): ReactNod -
+ {/* Hotkeys */} diff --git a/packages/react/src/presets/live-video/skin.tailwind.tsx b/packages/react/src/presets/live-video/skin.tailwind.tsx index cbfd645b..1eee1f67 100644 --- a/packages/react/src/presets/live-video/skin.tailwind.tsx +++ b/packages/react/src/presets/live-video/skin.tailwind.tsx @@ -58,6 +58,7 @@ import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; +import { Overlay } from '@/ui/overlay'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; @@ -335,7 +336,7 @@ export function LiveVideoSkinTailwind(props: LiveVideoSkinProps): ReactNode { -
+ {/* Hotkeys */} diff --git a/packages/react/src/presets/live-video/skin.tsx b/packages/react/src/presets/live-video/skin.tsx index a6ccc9d8..97aee8a3 100644 --- a/packages/react/src/presets/live-video/skin.tsx +++ b/packages/react/src/presets/live-video/skin.tsx @@ -35,6 +35,7 @@ import { Hotkey } from '@/ui/hotkey'; import { LiveButton } from '@/ui/live-button'; import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; +import { Overlay } from '@/ui/overlay'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { Popover } from '@/ui/popover'; @@ -283,7 +284,7 @@ export function LiveVideoSkin(props: LiveVideoSkinProps): ReactNode { -
+ {/* Hotkeys */} diff --git a/packages/react/src/presets/tests/skins-compiler-config.test.ts b/packages/react/src/presets/tests/skins-compiler-config.test.ts index 491da70b..df662fa5 100644 --- a/packages/react/src/presets/tests/skins-compiler-config.test.ts +++ b/packages/react/src/presets/tests/skins-compiler-config.test.ts @@ -24,6 +24,7 @@ describe('skins compiler config', () => { expect(code).toContain('from "@/ui/airplay-button"'); expect(code).toContain('from "@/ui/buffering-indicator"'); expect(code).toContain('from "@/ui/error-dialog"'); + expect(code).toContain('from "@/ui/overlay"'); expect(code).toContain('from "@/ui/pip-button"'); expect(code).toContain('from "@/ui/play-button"'); expect(code).toContain('from "@/ui/poster"'); @@ -63,6 +64,8 @@ describe('skins compiler config', () => { expect(compactCode).not.toContain('ErrorDialog.Content'); expect(compactCode).not.toContain('ErrorDialog.Actions'); expect(compactCode).toContain(compact('')); + expect(compactCode).toContain(compact('')); + expect(compactCode).not.toContain(compact('
')); expect(compactCode).toContain(compact('')); expect(compactCode).toContain( diff --git a/packages/react/src/presets/video/default-video.generated.tailwind.tsx b/packages/react/src/presets/video/default-video.generated.tailwind.tsx index 12875166..c8ce16be 100644 --- a/packages/react/src/presets/video/default-video.generated.tailwind.tsx +++ b/packages/react/src/presets/video/default-video.generated.tailwind.tsx @@ -14,6 +14,7 @@ import { icon, iconContainer, iconFlipped, + overlay, pipIcon, playIcon, popup, @@ -47,6 +48,7 @@ import { ErrorDialog } from '@/ui/error-dialog'; import { FullscreenButton } from '@/ui/fullscreen-button'; import { Gesture } from '@/ui/gesture'; import { Hotkey } from '@/ui/hotkey'; +import { Overlay } from '@/ui/overlay'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { Poster } from '@/ui/poster'; @@ -215,6 +217,8 @@ export function DefaultVideoSkin({ className, children, poster, ...rest }: Defau + + diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index 4937dd4c..a576abf5 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -66,6 +66,7 @@ import { Gesture } from '@/ui/gesture'; import { Hotkey } from '@/ui/hotkey'; import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; +import { Overlay } from '@/ui/overlay'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { usePlaybackRateOptions } from '@/ui/playback-rate'; @@ -538,7 +539,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod -
+ {/* Hotkeys */} diff --git a/packages/react/src/presets/video/minimal-skin.tsx b/packages/react/src/presets/video/minimal-skin.tsx index b824f0d3..755c6ebd 100644 --- a/packages/react/src/presets/video/minimal-skin.tsx +++ b/packages/react/src/presets/video/minimal-skin.tsx @@ -38,6 +38,7 @@ import { Gesture } from '@/ui/gesture'; import { Hotkey } from '@/ui/hotkey'; import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; +import { Overlay } from '@/ui/overlay'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { usePlaybackRateOptions } from '@/ui/playback-rate'; @@ -464,7 +465,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode { -
+ {/* Hotkeys */} diff --git a/packages/react/src/presets/video/skin.tailwind.tsx b/packages/react/src/presets/video/skin.tailwind.tsx index 4206f315..3a330529 100644 --- a/packages/react/src/presets/video/skin.tailwind.tsx +++ b/packages/react/src/presets/video/skin.tailwind.tsx @@ -66,6 +66,7 @@ import { Gesture } from '@/ui/gesture'; import { Hotkey } from '@/ui/hotkey'; import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; +import { Overlay } from '@/ui/overlay'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { usePlaybackRateOptions } from '@/ui/playback-rate'; @@ -534,7 +535,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { -
+ {/* Hotkeys */} diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index 4a057908..e9ecce41 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -38,6 +38,7 @@ import { Gesture } from '@/ui/gesture'; import { Hotkey } from '@/ui/hotkey'; import { Menu } from '@/ui/menu'; import { MuteButton } from '@/ui/mute-button'; +import { Overlay } from '@/ui/overlay'; import { PiPButton } from '@/ui/pip-button'; import { PlayButton } from '@/ui/play-button'; import { usePlaybackRateOptions } from '@/ui/playback-rate'; @@ -460,7 +461,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { -
+ {/* Hotkeys */} diff --git a/packages/react/src/ui/overlay/index.ts b/packages/react/src/ui/overlay/index.ts new file mode 100644 index 00000000..40adbf0f --- /dev/null +++ b/packages/react/src/ui/overlay/index.ts @@ -0,0 +1 @@ +export * from './overlay'; diff --git a/packages/react/src/ui/overlay/overlay.tsx b/packages/react/src/ui/overlay/overlay.tsx new file mode 100644 index 00000000..daf62203 --- /dev/null +++ b/packages/react/src/ui/overlay/overlay.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { OverlayCore, OverlayDataAttrs } from '@videojs/core'; +import { selectControls, selectError } from '@videojs/core/dom'; +import type { ForwardedRef } from 'react'; +import { forwardRef, useState } from 'react'; + +import { usePlayer } from '../../player/context'; +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; + +export interface OverlayProps extends UIComponentProps<'div', OverlayCore.State> {} + +/** Decorative media overlay layer for controls and error scrims. */ +export const Overlay = forwardRef(function Overlay( + componentProps: OverlayProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, ...elementProps } = componentProps; + const controls = usePlayer(selectControls); + const error = usePlayer(selectError); + const [core] = useState(() => new OverlayCore()); + + core.setMedia({ controls, error }); + const state = core.getState(); + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap: OverlayDataAttrs, + ref: [forwardedRef], + props: [{ 'aria-hidden': 'true' }, elementProps], + } + ); +}); + +export namespace Overlay { + export type Props = OverlayProps; + export type State = OverlayCore.State; +} diff --git a/packages/react/src/ui/overlay/tests/overlay.test.tsx b/packages/react/src/ui/overlay/tests/overlay.test.tsx new file mode 100644 index 00000000..b937deea --- /dev/null +++ b/packages/react/src/ui/overlay/tests/overlay.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { createPlayerWrapper } from '../../../testing/mocks'; +import { Overlay } from '../overlay'; + +afterEach(cleanup); + +describe('Overlay', () => { + it('reflects controls visibility through data attrs', () => { + const { Wrapper } = createPlayerWrapper({ + userActive: true, + controlsVisible: true, + toggleControls: () => true, + error: null, + dismissError: () => {}, + }); + + const { getByTestId } = render(, { wrapper: Wrapper }); + const overlay = getByTestId('overlay'); + + expect(overlay.getAttribute('aria-hidden')).toBe('true'); + expect(overlay.hasAttribute('data-visible')).toBe(true); + expect(overlay.hasAttribute('data-controls-visible')).toBe(true); + expect(overlay.hasAttribute('data-error-visible')).toBe(false); + }); + + it('reflects error visibility through data attrs', () => { + const { Wrapper } = createPlayerWrapper({ + userActive: false, + controlsVisible: false, + toggleControls: () => false, + error: { code: 1, message: 'failed' }, + dismissError: () => {}, + }); + + const { getByTestId } = render(, { wrapper: Wrapper }); + const overlay = getByTestId('overlay'); + + expect(overlay.hasAttribute('data-visible')).toBe(true); + expect(overlay.hasAttribute('data-controls-visible')).toBe(false); + expect(overlay.hasAttribute('data-error-visible')).toBe(true); + }); + + it('does not set visibility attrs when hidden', () => { + const { Wrapper } = createPlayerWrapper({ + userActive: false, + controlsVisible: false, + toggleControls: () => false, + error: null, + dismissError: () => {}, + }); + + const { getByTestId } = render(, { wrapper: Wrapper }); + const overlay = getByTestId('overlay'); + + expect(overlay.hasAttribute('data-visible')).toBe(false); + expect(overlay.hasAttribute('data-controls-visible')).toBe(false); + expect(overlay.hasAttribute('data-error-visible')).toBe(false); + }); +}); diff --git a/packages/skins/src/default/tailwind/components/overlay.ts b/packages/skins/src/default/tailwind/components/overlay.ts index 6411f4cd..4b9ccda7 100644 --- a/packages/skins/src/default/tailwind/components/overlay.ts +++ b/packages/skins/src/default/tailwind/components/overlay.ts @@ -12,12 +12,10 @@ export const overlay = cn( 'transition-[opacity,backdrop-filter]', 'duration-(--media-controls-transition-duration)', 'ease-out', - // Shown when controls visible - 'peer-data-visible/controls:opacity-100', - // Shown when error visible (+ blur) - // Light DOM: peer/error is a direct sibling (React) - 'peer-data-open/error:opacity-100', - 'peer-data-open/error:duration-(--media-error-dialog-transition-duration)', - 'peer-data-open/error:delay-(--media-error-dialog-transition-delay)', - 'peer-data-open/error:backdrop-blur-lg peer-data-open/error:backdrop-saturate-150' + // Shown by Overlay state when controls or error need a scrim. + 'data-visible:opacity-100', + // Error state adds dialog timing and blur. + 'data-error-visible:duration-(--media-error-dialog-transition-duration)', + 'data-error-visible:delay-(--media-error-dialog-transition-delay)', + 'data-error-visible:backdrop-blur-lg data-error-visible:backdrop-saturate-150' ); diff --git a/packages/skins/src/default/video.skin.tsx b/packages/skins/src/default/video.skin.tsx index 2b67844f..08564be5 100644 --- a/packages/skins/src/default/video.skin.tsx +++ b/packages/skins/src/default/video.skin.tsx @@ -8,6 +8,7 @@ import { FullscreenButton, Gesture, Hotkey, + Overlay, PiPButton, PlayButton, Poster, @@ -46,6 +47,7 @@ import { icon, iconContainer, iconFlipped, + overlay, pipIcon, playIcon, popup, @@ -193,6 +195,8 @@ export function DefaultVideoSkin({ className, children }: DefaultVideoSkinProps) + + diff --git a/packages/skins/src/minimal/tailwind/components/overlay.ts b/packages/skins/src/minimal/tailwind/components/overlay.ts index 5884d680..4760cbdf 100644 --- a/packages/skins/src/minimal/tailwind/components/overlay.ts +++ b/packages/skins/src/minimal/tailwind/components/overlay.ts @@ -12,12 +12,10 @@ export const overlay = cn( 'transition-[opacity,backdrop-filter]', 'duration-(--media-controls-transition-duration)', 'ease-out', - // Shown when controls visible - 'peer-data-visible/controls:opacity-100', - // Shown when error visible (+ blur) - // Light DOM: peer/error is a direct sibling (React) - 'peer-data-open/error:opacity-100', - 'peer-data-open/error:duration-(--media-error-dialog-transition-duration)', - 'peer-data-open/error:delay-(--media-error-dialog-transition-delay)', - 'peer-data-open/error:backdrop-blur-lg peer-data-open/error:backdrop-saturate-120' + // Shown by Overlay state when controls or error need a scrim. + 'data-visible:opacity-100', + // Error state adds dialog timing and blur. + 'data-error-visible:duration-(--media-error-dialog-transition-duration)', + 'data-error-visible:delay-(--media-error-dialog-transition-delay)', + 'data-error-visible:backdrop-blur-lg data-error-visible:backdrop-saturate-120' );