feat(core): add overlay primitive

This commit is contained in:
Rahim
2026-06-22 20:19:50 -07:00
parent aefa6c39c8
commit 89f13eb6d7
39 changed files with 476 additions and 32 deletions
+2
View File
@@ -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';
@@ -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,
@@ -0,0 +1,7 @@
import { defineComponent } from '../manifest';
import { OverlayDataAttrs } from './overlay-data-attrs';
export default defineComponent()({
name: 'Overlay',
dataAttrs: OverlayDataAttrs,
});
@@ -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;
}
@@ -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<OverlayState>;
@@ -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> = {}): MediaControlsState {
return {
userActive: true,
controlsVisible: true,
toggleControls: () => true,
...overrides,
};
}
function createErrorState(error: MediaErrorState['error'] = null): MediaErrorState {
return {
error,
dismissError: () => {},
};
}
@@ -146,7 +146,7 @@ function getTemplateHTML() {
</media-tooltip-group>
</media-controls>
<div class="${overlay}"></div>
<media-overlay class="${overlay}"></media-overlay>
<!-- Hotkeys -->
<media-hotkey keys="Space" action="togglePaused"></media-hotkey>
@@ -128,7 +128,7 @@ function getTemplateHTML() {
</media-tooltip-group>
</media-controls>
<div class="media-overlay"></div>
<media-overlay class="media-overlay"></media-overlay>
<!-- Hotkeys -->
<media-hotkey keys="Space" action="togglePaused"></media-hotkey>
@@ -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);
@@ -148,7 +148,7 @@ function getTemplateHTML() {
</media-tooltip-group>
</media-controls>
<div class="${overlay}"></div>
<media-overlay class="${overlay}"></media-overlay>
<!-- Hotkeys -->
<media-hotkey keys="Space" action="togglePaused"></media-hotkey>
+1 -1
View File
@@ -130,7 +130,7 @@ function getTemplateHTML() {
</media-tooltip-group>
</media-controls>
<div class="media-overlay"></div>
<media-overlay class="media-overlay"></media-overlay>
<!-- Hotkeys -->
<media-hotkey keys="Space" action="togglePaused"></media-hotkey>
@@ -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);
+10
View File
@@ -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;
}
}
@@ -271,7 +271,7 @@ function getTemplateHTML() {
</media-tooltip-group>
</media-controls>
<div class="${overlay}"></div>
<media-overlay class="${overlay}"></media-overlay>
<!-- Hotkeys -->
<media-hotkey keys="Space" action="togglePaused"></media-hotkey>
@@ -247,7 +247,7 @@ function getTemplateHTML() {
</media-tooltip-group>
</media-controls>
<div class="media-overlay"></div>
<media-overlay class="media-overlay"></media-overlay>
<!-- Hotkeys -->
<media-hotkey keys="Space" action="togglePaused"></media-hotkey>
@@ -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);
@@ -267,7 +267,7 @@ function getTemplateHTML() {
</media-tooltip-group>
</media-controls>
<div class="${overlay}"></div>
<media-overlay class="${overlay}"></media-overlay>
<!-- Hotkeys -->
<media-hotkey keys="Space" action="togglePaused"></media-hotkey>
+1 -1
View File
@@ -243,7 +243,7 @@ function getTemplateHTML() {
</media-tooltip-group>
</media-controls>
<div class="media-overlay"></div>
<media-overlay class="media-overlay"></media-overlay>
<!-- Hotkeys -->
<media-hotkey keys="Space" action="togglePaused"></media-hotkey>
+2
View File
@@ -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);
+1
View File
@@ -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';
@@ -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);
}
}
@@ -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<Class extends CustomElementConstructor & { readonly tagName: string }>(
Constructor: Class
): InstanceType<Class> {
ensureCustomElementDefined(Constructor);
return document.createElement(Constructor.tagName) as InstanceType<Class>;
}
function defineElement(tagName: string, Base: CustomElementConstructor): void {
if (!customElements.get(tagName)) {
customElements.define(tagName, Base);
}
}
function createOverlayStore(): AnyPlayerStore {
return createStore<unknown>()<OverlayTestState>({
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<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function waitForAssertion(assertion: () => void): Promise<void> {
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);
});
});
});
+1
View File
@@ -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 {
@@ -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):
</Tooltip.Provider>
</Controls.Root>
<div className={overlay} />
<Overlay className={overlay} />
{/* Hotkeys */}
<Hotkey keys="Space" action="togglePaused" />
@@ -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
</Tooltip.Provider>
</Controls.Root>
<div className="media-overlay" />
<Overlay className="media-overlay" />
{/* Hotkeys */}
<Hotkey keys="Space" action="togglePaused" />
@@ -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 {
</Tooltip.Provider>
</Controls.Root>
<div className={overlay} />
<Overlay className={overlay} />
{/* Hotkeys */}
<Hotkey keys="Space" action="togglePaused" />
@@ -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 {
</Tooltip.Provider>
</Controls.Root>
<div className="media-overlay" />
<Overlay className="media-overlay" />
{/* Hotkeys */}
<Hotkey keys="Space" action="togglePaused" />
@@ -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('<Controls.Root className={controls} data-controls="">'));
expect(compactCode).toContain(compact('<Overlay className={overlay} />'));
expect(compactCode).not.toContain(compact('<div className={overlay} />'));
expect(compactCode).toContain(compact('<Tooltip.Trigger render={<PlayButton'));
expect(compactCode).toContain(compact('<PlayButton className={cn(iconButton, playIcon.button)} type="button">'));
expect(compactCode).toContain(
@@ -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
</Tooltip.Provider>
</Controls.Root>
<Overlay className={overlay} />
<Hotkey keys="Space" action="togglePaused" />
<Hotkey keys="k" action="togglePaused" />
<Hotkey keys="m" action="toggleMuted" />
@@ -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
</Tooltip.Provider>
</Controls.Root>
<div className={overlay} />
<Overlay className={overlay} />
{/* Hotkeys */}
<Hotkey keys="Space" action="togglePaused" />
@@ -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 {
</Tooltip.Provider>
</Controls.Root>
<div className="media-overlay" />
<Overlay className="media-overlay" />
{/* Hotkeys */}
<Hotkey keys="Space" action="togglePaused" />
@@ -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 {
</Tooltip.Provider>
</Controls.Root>
<div className={overlay} />
<Overlay className={overlay} />
{/* Hotkeys */}
<Hotkey keys="Space" action="togglePaused" />
+2 -1
View File
@@ -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 {
</Tooltip.Provider>
</Controls.Root>
<div className="media-overlay" />
<Overlay className="media-overlay" />
{/* Hotkeys */}
<Hotkey keys="Space" action="togglePaused" />
+1
View File
@@ -0,0 +1 @@
export * from './overlay';
+42
View File
@@ -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<HTMLDivElement>
) {
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;
}
@@ -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(<Overlay data-testid="overlay" />, { 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(<Overlay data-testid="overlay" />, { 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(<Overlay data-testid="overlay" />, { 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);
});
});
@@ -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'
);
@@ -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)
</Tooltip.Provider>
</Controls.Root>
<Overlay className={overlay} />
<Hotkey keys="Space" action="togglePaused" />
<Hotkey keys="k" action="togglePaused" />
<Hotkey keys="m" action="toggleMuted" />
@@ -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'
);