mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add overlay primitive
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './overlay';
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user