From 90d881cec21d7f5e1e619061727e6c8d1ff48296 Mon Sep 17 00:00:00 2001 From: rahim Date: Fri, 13 Feb 2026 01:14:16 +1100 Subject: [PATCH] feat(core): add controls component with activity tracking (#514) --- packages/core/src/core/index.ts | 2 + packages/core/src/core/media/state.ts | 7 + .../src/core/ui/controls/controls-core.ts | 19 + .../core/ui/controls/controls-data-attrs.ts | 9 + .../ui/controls/tests/controls-core.test.ts | 70 +++ packages/core/src/dom/media/types.ts | 2 + .../core/src/dom/store/features/controls.ts | 106 +++++ .../src/dom/store/features/feature.parts.ts | 3 + packages/core/src/dom/store/features/index.ts | 1 + .../dom/store/features/tests/controls.test.ts | 413 ++++++++++++++++++ packages/core/src/dom/store/selectors.ts | 2 + packages/html/src/define/ui/controls.ts | 12 + packages/html/src/index.ts | 2 + .../html/src/ui/controls/controls-element.ts | 34 ++ .../src/ui/controls/controls-group-element.ts | 13 + packages/react/src/index.ts | 3 + .../react/src/ui/controls/controls-group.tsx | 38 ++ .../react/src/ui/controls/controls-root.tsx | 48 ++ packages/react/src/ui/controls/index.parts.ts | 2 + packages/react/src/ui/controls/index.ts | 1 + 20 files changed, 787 insertions(+) create mode 100644 packages/core/src/core/ui/controls/controls-core.ts create mode 100644 packages/core/src/core/ui/controls/controls-data-attrs.ts create mode 100644 packages/core/src/core/ui/controls/tests/controls-core.test.ts create mode 100644 packages/core/src/dom/store/features/controls.ts create mode 100644 packages/core/src/dom/store/features/tests/controls.test.ts create mode 100644 packages/html/src/define/ui/controls.ts create mode 100644 packages/html/src/ui/controls/controls-element.ts create mode 100644 packages/html/src/ui/controls/controls-group-element.ts create mode 100644 packages/react/src/ui/controls/controls-group.tsx create mode 100644 packages/react/src/ui/controls/controls-root.tsx create mode 100644 packages/react/src/ui/controls/index.parts.ts create mode 100644 packages/react/src/ui/controls/index.ts diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 3fbdfa84..de2f4f16 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -1,4 +1,6 @@ export * from './media/state'; +export * from './ui/controls/controls-core'; +export * from './ui/controls/controls-data-attrs'; export * from './ui/fullscreen-button/fullscreen-button-core'; export * from './ui/fullscreen-button/fullscreen-button-data-attrs'; export * from './ui/mute-button/mute-button-core'; diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index 7b51acd1..7fc43c81 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -163,6 +163,13 @@ export interface MediaFullscreenState { exitFullscreen(): Promise; } +export interface MediaControlsState { + /** Whether the user has recently interacted with the player. */ + userActive: boolean; + /** Whether controls should be visible (userActive || paused). */ + controlsVisible: boolean; +} + export interface MediaPictureInPictureState { /** * Whether picture-in-picture mode is currently active. diff --git a/packages/core/src/core/ui/controls/controls-core.ts b/packages/core/src/core/ui/controls/controls-core.ts new file mode 100644 index 00000000..9db679b2 --- /dev/null +++ b/packages/core/src/core/ui/controls/controls-core.ts @@ -0,0 +1,19 @@ +import type { MediaControlsState } from '../../media/state'; + +export interface ControlsState { + visible: boolean; + userActive: boolean; +} + +export class ControlsCore { + getState(media: MediaControlsState): ControlsState { + return { + visible: media.controlsVisible, + userActive: media.userActive, + }; + } +} + +export namespace ControlsCore { + export type State = ControlsState; +} diff --git a/packages/core/src/core/ui/controls/controls-data-attrs.ts b/packages/core/src/core/ui/controls/controls-data-attrs.ts new file mode 100644 index 00000000..429b6501 --- /dev/null +++ b/packages/core/src/core/ui/controls/controls-data-attrs.ts @@ -0,0 +1,9 @@ +import type { StateAttrMap } from '../types'; +import type { ControlsState } from './controls-core'; + +export const ControlsDataAttrs = { + /** Present when controls are visible. */ + visible: 'data-visible', + /** Present when the user has recently interacted. */ + userActive: 'data-user-active', +} as const satisfies StateAttrMap; diff --git a/packages/core/src/core/ui/controls/tests/controls-core.test.ts b/packages/core/src/core/ui/controls/tests/controls-core.test.ts new file mode 100644 index 00000000..96bbea0e --- /dev/null +++ b/packages/core/src/core/ui/controls/tests/controls-core.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import type { MediaControlsState } from '../../../media/state'; +import { ControlsCore } from '../controls-core'; + +describe('ControlsCore', () => { + describe('getState', () => { + it('returns visible: true when controlsVisible is true', () => { + const core = new ControlsCore(); + const media = createControlsState({ controlsVisible: true }); + + expect(core.getState(media)).toEqual({ visible: true, userActive: true }); + }); + + it('returns visible: false when controlsVisible is false', () => { + const core = new ControlsCore(); + const media = createControlsState({ controlsVisible: false }); + + expect(core.getState(media)).toEqual({ visible: false, userActive: true }); + }); + + it('returns userActive: false when userActive is false', () => { + const core = new ControlsCore(); + const media = createControlsState({ userActive: false }); + + expect(core.getState(media)).toEqual({ visible: true, userActive: false }); + }); + + it('projects visible and userActive independently', () => { + const core = new ControlsCore(); + + // All four quadrants of the 2x2 boolean matrix + expect(core.getState(createControlsState({ controlsVisible: true, userActive: true }))).toEqual({ + visible: true, + userActive: true, + }); + + expect(core.getState(createControlsState({ controlsVisible: true, userActive: false }))).toEqual({ + visible: true, + userActive: false, + }); + + expect(core.getState(createControlsState({ controlsVisible: false, userActive: true }))).toEqual({ + visible: false, + userActive: true, + }); + + expect(core.getState(createControlsState({ controlsVisible: false, userActive: false }))).toEqual({ + visible: false, + userActive: false, + }); + }); + + it('returns only primitive values', () => { + const core = new ControlsCore(); + const state = core.getState(createControlsState()); + + const functionKeys = Object.entries(state).filter(([, value]) => typeof value === 'function'); + expect(functionKeys).toHaveLength(0); + }); + }); +}); + +function createControlsState(overrides: Partial = {}): MediaControlsState { + return { + userActive: true, + controlsVisible: true, + ...overrides, + }; +} diff --git a/packages/core/src/dom/media/types.ts b/packages/core/src/dom/media/types.ts index 097804b9..48f3d50f 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -2,6 +2,7 @@ import type { AnySlice, Slice, Store, UnionSliceState } from '@videojs/store'; import type { MediaBufferState, + MediaControlsState, MediaFullscreenState, MediaPictureInPictureState, MediaPlaybackState, @@ -41,6 +42,7 @@ export type VideoFeatures = [ PlayerFeature, PlayerFeature, PlayerFeature, + PlayerFeature, ]; export type AudioFeatures = [ diff --git a/packages/core/src/dom/store/features/controls.ts b/packages/core/src/dom/store/features/controls.ts new file mode 100644 index 00000000..170a510e --- /dev/null +++ b/packages/core/src/dom/store/features/controls.ts @@ -0,0 +1,106 @@ +import { listen } from '@videojs/utils/dom'; +import { isNull } from '@videojs/utils/predicate'; + +import type { MediaControlsState } from '../../../core/media/state'; +import { definePlayerFeature } from '../../feature'; + +const IDLE_DELAY = 2000; +const TAP_THRESHOLD = 250; + +export const controlsFeature = definePlayerFeature({ + state: (): MediaControlsState => ({ + userActive: true, + controlsVisible: true, + }), + + attach({ target, signal, get, set }) { + const { media, container } = target; + + if (isNull(container)) { + if (__DEV__) { + console.warn('[vjs] controlsFeature requires a container element for activity tracking.'); + } + return; + } + + function computeVisible(userActive: boolean): boolean { + return userActive || media.paused; + } + + // Idle timer + let idleTimer: ReturnType | undefined; + + function clearIdle() { + clearTimeout(idleTimer); + idleTimer = undefined; + } + + function scheduleIdle() { + clearIdle(); + idleTimer = setTimeout(setInactive, IDLE_DELAY); + } + + function setActive() { + if (!get().userActive) { + set({ userActive: true, controlsVisible: true }); + } + scheduleIdle(); + } + + function setInactive() { + clearIdle(); + set({ userActive: false, controlsVisible: computeVisible(false) }); + } + + // Touch tap-to-toggle + let pointerDownTime = 0; + + function onPointerDown() { + pointerDownTime = Date.now(); + } + + function onPointerUp(event: PointerEvent) { + if (event.pointerType === 'touch' && Date.now() - pointerDownTime < TAP_THRESHOLD) { + if (get().controlsVisible) { + clearIdle(); + set({ userActive: false, controlsVisible: computeVisible(false) }); + } else { + setActive(); + } + } else { + setActive(); + } + } + + // Recompute visibility when playback state changes. + function onPlaybackChange() { + const { userActive } = get(); + set({ controlsVisible: computeVisible(userActive) }); + + // When playback starts, schedule idle if user is active. + if (!media.paused && userActive) { + scheduleIdle(); + } + } + + // Container event listeners + listen(container, 'pointermove', setActive, { signal }); + listen(container, 'pointerdown', onPointerDown, { signal }); + listen(container, 'pointerup', onPointerUp, { signal }); + listen(container, 'keyup', setActive, { signal }); + listen(container, 'focusin', setActive, { signal }); + listen(container, 'pointerleave', setInactive, { signal }); + + // Media event listeners for playback state changes. + listen(media, 'play', onPlaybackChange, { signal }); + listen(media, 'pause', onPlaybackChange, { signal }); + listen(media, 'ended', onPlaybackChange, { signal }); + + // Clean up timer on signal abort. + signal.addEventListener('abort', clearIdle, { once: true }); + + // Always schedule idle initially. When paused, userActive will go false + // but controlsVisible stays true (because paused keeps controls visible). + scheduleIdle(); + }, +}); diff --git a/packages/core/src/dom/store/features/feature.parts.ts b/packages/core/src/dom/store/features/feature.parts.ts index 0fcfa8f4..55b34265 100644 --- a/packages/core/src/dom/store/features/feature.parts.ts +++ b/packages/core/src/dom/store/features/feature.parts.ts @@ -1,5 +1,6 @@ import type { AudioFeatures, BackgroundFeatures, VideoFeatures } from '../../media/types'; import { bufferFeature } from './buffer'; +import { controlsFeature } from './controls'; import { fullscreenFeature } from './fullscreen'; import { pipFeature } from './pip'; import { playbackFeature } from './playback'; @@ -10,6 +11,7 @@ import { volumeFeature } from './volume'; // Short aliases export { bufferFeature as buffer, + controlsFeature as controls, fullscreenFeature as fullscreen, pipFeature as pip, playbackFeature as playback, @@ -26,6 +28,7 @@ export const video: VideoFeatures = [ bufferFeature, fullscreenFeature, pipFeature, + controlsFeature, ]; export const audio: AudioFeatures = [playbackFeature, volumeFeature, timeFeature, sourceFeature, bufferFeature]; diff --git a/packages/core/src/dom/store/features/index.ts b/packages/core/src/dom/store/features/index.ts index 45552105..d993c9f4 100644 --- a/packages/core/src/dom/store/features/index.ts +++ b/packages/core/src/dom/store/features/index.ts @@ -1,4 +1,5 @@ export * from './buffer'; +export * from './controls'; export * as features from './feature.parts'; export * from './fullscreen'; export * from './pip'; diff --git a/packages/core/src/dom/store/features/tests/controls.test.ts b/packages/core/src/dom/store/features/tests/controls.test.ts new file mode 100644 index 00000000..256f9c58 --- /dev/null +++ b/packages/core/src/dom/store/features/tests/controls.test.ts @@ -0,0 +1,413 @@ +import { createStore, flush } from '@videojs/store'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PlayerTarget } from '../../../media/types'; +import { controlsFeature } from '../controls'; + +const IDLE_DELAY = 2000; + +describe('controlsFeature', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('initial state', () => { + it('starts with userActive: true and controlsVisible: true', () => { + const { store } = createPlayerStore(); + + expect(store.state.userActive).toBe(true); + expect(store.state.controlsVisible).toBe(true); + }); + }); + + describe('idle timeout', () => { + it('sets inactive after idle delay when playing', () => { + const video = createMockVideo({ paused: false }); + const { store } = createPlayerStore(video); + + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.userActive).toBe(false); + expect(store.state.controlsVisible).toBe(false); + }); + + it('sets userActive false but keeps controlsVisible true when paused', () => { + const video = createMockVideo({ paused: true }); + const { store } = createPlayerStore(video); + + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.userActive).toBe(false); + expect(store.state.controlsVisible).toBe(true); + }); + }); + + describe('activity detection', () => { + it('resets idle timer on pointermove', () => { + const video = createMockVideo({ paused: false }); + const { store, container } = createPlayerStore(video); + + // Advance partway through idle delay + vi.advanceTimersByTime(IDLE_DELAY - 500); + + container!.dispatchEvent(new Event('pointermove')); + flush(); + + expect(store.state.userActive).toBe(true); + expect(store.state.controlsVisible).toBe(true); + + // Advance past original delay — still active since timer was reset + vi.advanceTimersByTime(500); + flush(); + + expect(store.state.userActive).toBe(true); + + // Now wait full delay from the pointermove + vi.advanceTimersByTime(IDLE_DELAY - 500); + flush(); + + expect(store.state.userActive).toBe(false); + }); + + it('sets active on keyup', () => { + const video = createMockVideo({ paused: false }); + const { store, container } = createPlayerStore(video); + + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.userActive).toBe(false); + + container!.dispatchEvent(new Event('keyup')); + flush(); + + expect(store.state.userActive).toBe(true); + expect(store.state.controlsVisible).toBe(true); + }); + + it('reactivates on pointermove after idle', () => { + const video = createMockVideo({ paused: false }); + const { store, container } = createPlayerStore(video); + + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.userActive).toBe(false); + expect(store.state.controlsVisible).toBe(false); + + container!.dispatchEvent(new Event('pointermove')); + flush(); + + expect(store.state.userActive).toBe(true); + expect(store.state.controlsVisible).toBe(true); + }); + + it('sets active on focusin', () => { + const video = createMockVideo({ paused: false }); + const { store, container } = createPlayerStore(video); + + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.userActive).toBe(false); + + container!.dispatchEvent(new Event('focusin')); + flush(); + + expect(store.state.userActive).toBe(true); + }); + + it('sets inactive immediately on pointerleave', () => { + const video = createMockVideo({ paused: false }); + const { store, container } = createPlayerStore(video); + + container!.dispatchEvent(new Event('pointerleave')); + flush(); + + expect(store.state.userActive).toBe(false); + expect(store.state.controlsVisible).toBe(false); + }); + + it('keeps controlsVisible true on pointerleave when paused', () => { + const video = createMockVideo({ paused: true }); + const { store, container } = createPlayerStore(video); + + container!.dispatchEvent(new Event('pointerleave')); + flush(); + + expect(store.state.userActive).toBe(false); + expect(store.state.controlsVisible).toBe(true); + }); + }); + + describe('touch tap-to-toggle', () => { + it('hides controls on tap when visible and playing', () => { + const video = createMockVideo({ paused: false }); + const { store, container } = createPlayerStore(video); + + container!.dispatchEvent(new Event('pointerdown')); + vi.advanceTimersByTime(100); + + container!.dispatchEvent(createPointerEvent('pointerup', { pointerType: 'touch' })); + flush(); + + expect(store.state.userActive).toBe(false); + expect(store.state.controlsVisible).toBe(false); + }); + + it('keeps controls visible on tap when paused', () => { + const video = createMockVideo({ paused: true }); + const { store, container } = createPlayerStore(video); + + container!.dispatchEvent(new Event('pointerdown')); + vi.advanceTimersByTime(100); + + container!.dispatchEvent(createPointerEvent('pointerup', { pointerType: 'touch' })); + flush(); + + // userActive goes false but controlsVisible stays true (paused) + expect(store.state.userActive).toBe(false); + expect(store.state.controlsVisible).toBe(true); + }); + + it('shows controls on tap when hidden', () => { + const video = createMockVideo({ paused: false }); + const { store, container } = createPlayerStore(video); + + // First tap to hide + container!.dispatchEvent(new Event('pointerdown')); + vi.advanceTimersByTime(100); + container!.dispatchEvent(createPointerEvent('pointerup', { pointerType: 'touch' })); + flush(); + + expect(store.state.controlsVisible).toBe(false); + + // Second tap to show + container!.dispatchEvent(new Event('pointerdown')); + vi.advanceTimersByTime(100); + container!.dispatchEvent(createPointerEvent('pointerup', { pointerType: 'touch' })); + flush(); + + expect(store.state.userActive).toBe(true); + expect(store.state.controlsVisible).toBe(true); + }); + + it('does not toggle on long press', () => { + const video = createMockVideo({ paused: false }); + const { store, container } = createPlayerStore(video); + + container!.dispatchEvent(new Event('pointerdown')); + vi.advanceTimersByTime(300); + + container!.dispatchEvent(createPointerEvent('pointerup', { pointerType: 'touch' })); + flush(); + + expect(store.state.userActive).toBe(true); + expect(store.state.controlsVisible).toBe(true); + }); + + it('does not toggle for mouse clicks', () => { + const video = createMockVideo({ paused: false }); + const { store, container } = createPlayerStore(video); + + container!.dispatchEvent(new Event('pointerdown')); + vi.advanceTimersByTime(100); + + container!.dispatchEvent(createPointerEvent('pointerup', { pointerType: 'mouse' })); + flush(); + + expect(store.state.userActive).toBe(true); + expect(store.state.controlsVisible).toBe(true); + }); + }); + + describe('playback state interaction', () => { + it('shows controls when media pauses', () => { + const video = createMockVideo({ paused: false }); + const { store } = createPlayerStore(video); + + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.controlsVisible).toBe(false); + + // Pause media + Object.defineProperty(video, 'paused', { value: true, configurable: true }); + video.dispatchEvent(new Event('pause')); + flush(); + + expect(store.state.controlsVisible).toBe(true); + }); + + it('hides controls when media resumes and user is inactive', () => { + const video = createMockVideo({ paused: true }); + const { store } = createPlayerStore(video); + + // Let idle expire — user inactive, but visible because paused + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.userActive).toBe(false); + expect(store.state.controlsVisible).toBe(true); + + // Resume playback while user is still inactive + Object.defineProperty(video, 'paused', { value: false, configurable: true }); + video.dispatchEvent(new Event('play')); + flush(); + + expect(store.state.controlsVisible).toBe(false); + }); + + it('schedules idle when playback resumes and user is active', () => { + const video = createMockVideo({ paused: true }); + const { store, container } = createPlayerStore(video); + + // Trigger activity to keep user active + container!.dispatchEvent(new Event('pointermove')); + flush(); + + // Resume playback + Object.defineProperty(video, 'paused', { value: false, configurable: true }); + video.dispatchEvent(new Event('play')); + flush(); + + expect(store.state.controlsVisible).toBe(true); + + // After idle delay, should hide + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.controlsVisible).toBe(false); + }); + }); + + describe('null container', () => { + it('does not track activity without container', () => { + const video = createMockVideo({ paused: false }); + const { store } = createPlayerStore(video, null); + + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.userActive).toBe(true); + expect(store.state.controlsVisible).toBe(true); + }); + }); + + describe('cleanup', () => { + it('stops listening when store is destroyed', () => { + const video = createMockVideo({ paused: false }); + const { store } = createPlayerStore(video); + + store.destroy(); + + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.userActive).toBe(true); + }); + + it('clears idle timer on detach', () => { + const video = createMockVideo({ paused: false }); + const store = createStore()(controlsFeature); + + const container = createContainer(); + const detach = store.attach({ media: video, container }); + flush(); + + detach(); + flush(); + + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.userActive).toBe(true); + expect(store.state.controlsVisible).toBe(true); + }); + + it('does not react to media events after detach', () => { + const video = createMockVideo({ paused: false }); + const store = createStore()(controlsFeature); + + const container = createContainer(); + const detach = store.attach({ media: video, container }); + flush(); + + vi.advanceTimersByTime(IDLE_DELAY); + flush(); + + expect(store.state.controlsVisible).toBe(false); + + detach(); + flush(); + + // Pause after detach — should not affect state + Object.defineProperty(video, 'paused', { value: true, configurable: true }); + video.dispatchEvent(new Event('pause')); + flush(); + + // State was reset to initial on detach + expect(store.state.userActive).toBe(true); + expect(store.state.controlsVisible).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockVideo( + overrides: Partial<{ + paused: boolean; + ended: boolean; + currentTime: number; + readyState: number; + tagName: string; + }> = {} +): HTMLVideoElement { + const video = document.createElement(overrides.tagName === 'AUDIO' ? 'audio' : 'video') as HTMLVideoElement; + + if (overrides.paused !== undefined) { + Object.defineProperty(video, 'paused', { value: overrides.paused, configurable: true }); + } + if (overrides.ended !== undefined) { + Object.defineProperty(video, 'ended', { value: overrides.ended, configurable: true }); + } + if (overrides.currentTime !== undefined) { + video.currentTime = overrides.currentTime; + } + if (overrides.readyState !== undefined) { + Object.defineProperty(video, 'readyState', { value: overrides.readyState, configurable: true }); + } + + return video; +} + +function createContainer(): HTMLElement { + return document.createElement('div'); +} + +function createPointerEvent(type: string, init?: { pointerType?: string }): Event { + const event = new Event(type, { bubbles: true }); + (event as unknown as Record).pointerType = init?.pointerType ?? ''; + return event; +} + +function createPlayerStore(video?: HTMLVideoElement, container?: HTMLElement | null) { + const store = createStore()(controlsFeature); + + const media = video ?? createMockVideo({ paused: true }); + const cont = container === undefined ? createContainer() : container; + + store.attach({ media, container: cont }); + flush(); + + return { store, media, container: cont }; +} diff --git a/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index 7ba6c61f..600a5ce2 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -1,6 +1,7 @@ import { createSelector } from '@videojs/store'; import { bufferFeature } from './features/buffer'; +import { controlsFeature } from './features/controls'; import { fullscreenFeature } from './features/fullscreen'; import { pipFeature } from './features/pip'; import { playbackFeature } from './features/playback'; @@ -9,6 +10,7 @@ import { timeFeature } from './features/time'; import { volumeFeature } from './features/volume'; export const selectBuffer = createSelector(bufferFeature); +export const selectControls = createSelector(controlsFeature); export const selectFullscreen = createSelector(fullscreenFeature); export const selectPiP = createSelector(pipFeature); export const selectPlayback = createSelector(playbackFeature); diff --git a/packages/html/src/define/ui/controls.ts b/packages/html/src/define/ui/controls.ts new file mode 100644 index 00000000..8682c85e --- /dev/null +++ b/packages/html/src/define/ui/controls.ts @@ -0,0 +1,12 @@ +import { ControlsElement } from '../../ui/controls/controls-element'; +import { ControlsGroupElement } from '../../ui/controls/controls-group-element'; + +customElements.define(ControlsElement.tagName, ControlsElement); +customElements.define(ControlsGroupElement.tagName, ControlsGroupElement); + +declare global { + interface HTMLElementTagNameMap { + [ControlsElement.tagName]: ControlsElement; + [ControlsGroupElement.tagName]: ControlsGroupElement; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index c888df70..e6262cb3 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -14,6 +14,8 @@ export * from './store/container-mixin'; export * from './store/provider-mixin'; export * from './store/types'; // UI Components +export { ControlsElement } from './ui/controls/controls-element'; +export { ControlsGroupElement } from './ui/controls/controls-group-element'; export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-button-element'; // Primitives export * from './ui/media-element'; diff --git a/packages/html/src/ui/controls/controls-element.ts b/packages/html/src/ui/controls/controls-element.ts new file mode 100644 index 00000000..3710a17f --- /dev/null +++ b/packages/html/src/ui/controls/controls-element.ts @@ -0,0 +1,34 @@ +import { ControlsCore, ControlsDataAttrs } from '@videojs/core'; +import { applyStateDataAttrs, logMissingFeature, selectControls } 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 ControlsElement extends MediaElement { + static readonly tagName = 'media-controls'; + + readonly #core = new ControlsCore(); + readonly #state = new PlayerController(this, playerContext, selectControls); + + override connectedCallback(): void { + super.connectedCallback(); + + if (__DEV__ && !this.#state.value) { + logMissingFeature(ControlsElement.tagName, 'controls'); + } + } + + protected override update(changed: PropertyValues): void { + super.update(changed); + + const controls = this.#state.value; + + if (!controls) { + return; + } + + applyStateDataAttrs(this, this.#core.getState(controls), ControlsDataAttrs); + } +} diff --git a/packages/html/src/ui/controls/controls-group-element.ts b/packages/html/src/ui/controls/controls-group-element.ts new file mode 100644 index 00000000..4d87ec56 --- /dev/null +++ b/packages/html/src/ui/controls/controls-group-element.ts @@ -0,0 +1,13 @@ +import { MediaElement } from '../media-element'; + +export class ControlsGroupElement extends MediaElement { + static readonly tagName = 'media-controls-group'; + + override connectedCallback(): void { + super.connectedCallback(); + + if (this.hasAttribute('aria-label') || this.hasAttribute('aria-labelledby')) { + this.setAttribute('role', 'group'); + } + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 5bd38aad..8712348d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -30,6 +30,9 @@ export { } from './player/create-player'; // UI +export { Controls } from './ui/controls'; +export type { ControlsGroupProps } from './ui/controls/controls-group'; +export type { ControlsRootProps } from './ui/controls/controls-root'; export { FullscreenButton, type FullscreenButtonProps } from './ui/fullscreen-button/fullscreen-button'; export { useButton } from './ui/hooks/use-button'; export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button'; diff --git a/packages/react/src/ui/controls/controls-group.tsx b/packages/react/src/ui/controls/controls-group.tsx new file mode 100644 index 00000000..dc54db97 --- /dev/null +++ b/packages/react/src/ui/controls/controls-group.tsx @@ -0,0 +1,38 @@ +'use client'; + +import type { ForwardedRef, ReactNode } from 'react'; +import { forwardRef } from 'react'; + +import type { UIComponentProps } from '../../utils/types'; +import { renderElement } from '../../utils/use-render'; + +type GroupState = Record; + +export interface ControlsGroupProps extends UIComponentProps<'div', GroupState> { + children?: ReactNode | undefined; +} + +export const ControlsGroup = forwardRef(function ControlsGroup( + componentProps: ControlsGroupProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, children, ...elementProps } = componentProps; + + const role = elementProps['aria-label'] || elementProps['aria-labelledby'] ? 'group' : undefined; + + const state: GroupState = {}; + + return renderElement( + 'div', + { render, className, style }, + { + state, + ref: [forwardedRef], + props: [{ role, children }, elementProps], + } + ); +}); + +export namespace ControlsGroup { + export type Props = ControlsGroupProps; +} diff --git a/packages/react/src/ui/controls/controls-root.tsx b/packages/react/src/ui/controls/controls-root.tsx new file mode 100644 index 00000000..76c26fe4 --- /dev/null +++ b/packages/react/src/ui/controls/controls-root.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { ControlsCore, ControlsDataAttrs } from '@videojs/core'; +import { logMissingFeature, selectControls } from '@videojs/core/dom'; +import type { ForwardedRef, ReactNode } 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 ControlsRootProps extends UIComponentProps<'div', ControlsCore.State> { + children?: ReactNode | undefined; +} + +export const ControlsRoot = forwardRef(function ControlsRoot( + componentProps: ControlsRootProps, + forwardedRef: ForwardedRef +) { + const { render, className, style, children, ...elementProps } = componentProps; + + const controls = usePlayer(selectControls); + + const [core] = useState(() => new ControlsCore()); + + if (!controls) { + if (__DEV__) logMissingFeature('Controls.Root', 'controls'); + return null; + } + + const state = core.getState(controls); + + return renderElement( + 'div', + { render, className, style }, + { + state, + stateAttrMap: ControlsDataAttrs, + ref: [forwardedRef], + props: [{ children }, elementProps], + } + ); +}); + +export namespace ControlsRoot { + export type Props = ControlsRootProps; + export type State = ControlsCore.State; +} diff --git a/packages/react/src/ui/controls/index.parts.ts b/packages/react/src/ui/controls/index.parts.ts new file mode 100644 index 00000000..52d63729 --- /dev/null +++ b/packages/react/src/ui/controls/index.parts.ts @@ -0,0 +1,2 @@ +export { ControlsGroup as Group, type ControlsGroupProps as GroupProps } from './controls-group'; +export { ControlsRoot as Root, type ControlsRootProps as RootProps } from './controls-root'; diff --git a/packages/react/src/ui/controls/index.ts b/packages/react/src/ui/controls/index.ts new file mode 100644 index 00000000..85c4d9da --- /dev/null +++ b/packages/react/src/ui/controls/index.ts @@ -0,0 +1 @@ +export * as Controls from './index.parts';