mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add controls component with activity tracking (#514)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -163,6 +163,13 @@ export interface MediaFullscreenState {
|
||||
exitFullscreen(): Promise<void>;
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ControlsState>;
|
||||
@@ -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> = {}): MediaControlsState {
|
||||
return {
|
||||
userActive: true,
|
||||
controlsVisible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -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<MediaBufferState>,
|
||||
PlayerFeature<MediaFullscreenState>,
|
||||
PlayerFeature<MediaPictureInPictureState>,
|
||||
PlayerFeature<MediaControlsState>,
|
||||
];
|
||||
|
||||
export type AudioFeatures = [
|
||||
|
||||
@@ -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<typeof setTimeout> | 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();
|
||||
},
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './buffer';
|
||||
export * from './controls';
|
||||
export * as features from './feature.parts';
|
||||
export * from './fullscreen';
|
||||
export * from './pip';
|
||||
|
||||
@@ -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<PlayerTarget>()(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<PlayerTarget>()(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<string, unknown>).pointerType = init?.pointerType ?? '';
|
||||
return event;
|
||||
}
|
||||
|
||||
function createPlayerStore(video?: HTMLVideoElement, container?: HTMLElement | null) {
|
||||
const store = createStore<PlayerTarget>()(controlsFeature);
|
||||
|
||||
const media = video ?? createMockVideo({ paused: true });
|
||||
const cont = container === undefined ? createContainer() : container;
|
||||
|
||||
store.attach({ media, container: cont });
|
||||
flush();
|
||||
|
||||
return { store, media, container: cont };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, never>;
|
||||
|
||||
export interface ControlsGroupProps extends UIComponentProps<'div', GroupState> {
|
||||
children?: ReactNode | undefined;
|
||||
}
|
||||
|
||||
export const ControlsGroup = forwardRef(function ControlsGroup(
|
||||
componentProps: ControlsGroupProps,
|
||||
forwardedRef: ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
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;
|
||||
}
|
||||
@@ -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<HTMLDivElement>
|
||||
) {
|
||||
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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1 @@
|
||||
export * as Controls from './index.parts';
|
||||
Reference in New Issue
Block a user