mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): add UI support for gestures and hotkeys (#1388)
Co-authored-by: Rahim <rahim.alwer@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Rahim
Claude Opus 4.6
parent
6c81f2d190
commit
0620814a67
@@ -1,7 +1,6 @@
|
||||
import { isFunction, isUndefined } from '@videojs/utils/predicate';
|
||||
|
||||
import { isFunction } from '@videojs/utils/predicate';
|
||||
import type { AnyPlayerStore } from '../media/types';
|
||||
import { selectPlaybackRate, selectTime, selectVolume } from '../store/selectors';
|
||||
import { MEDIA_INPUT_ACTION_OVERRIDES } from '../media-actions';
|
||||
|
||||
export type GestureActionName =
|
||||
| 'togglePaused'
|
||||
@@ -25,37 +24,13 @@ export type GestureActionResolver = (context: GestureActionContext) => void;
|
||||
|
||||
/** Actions that need custom logic beyond `store.state[action]()`. */
|
||||
const GESTURE_ACTION_OVERRIDES: Partial<Record<GestureActionName, GestureActionResolver>> = {
|
||||
seekStep({ store, value }) {
|
||||
if (isUndefined(value)) return;
|
||||
const time = selectTime(store.state);
|
||||
if (!time) return;
|
||||
time.seek(time.currentTime + value);
|
||||
},
|
||||
seekStep: MEDIA_INPUT_ACTION_OVERRIDES.seekStep,
|
||||
|
||||
volumeStep({ store, value }) {
|
||||
if (isUndefined(value)) return;
|
||||
const vol = selectVolume(store.state);
|
||||
if (!vol) return;
|
||||
vol.setVolume(vol.volume + value);
|
||||
},
|
||||
volumeStep: MEDIA_INPUT_ACTION_OVERRIDES.volumeStep,
|
||||
|
||||
speedUp({ store }) {
|
||||
const rate = selectPlaybackRate(store.state);
|
||||
if (!rate) return;
|
||||
const { playbackRates, playbackRate } = rate;
|
||||
const idx = playbackRates.indexOf(playbackRate);
|
||||
const next = idx < 0 || idx >= playbackRates.length - 1 ? 0 : idx + 1;
|
||||
rate.setPlaybackRate(playbackRates[next]!);
|
||||
},
|
||||
speedUp: MEDIA_INPUT_ACTION_OVERRIDES.speedUp,
|
||||
|
||||
speedDown({ store }) {
|
||||
const rate = selectPlaybackRate(store.state);
|
||||
if (!rate) return;
|
||||
const { playbackRates, playbackRate } = rate;
|
||||
const idx = playbackRates.indexOf(playbackRate);
|
||||
const next = idx <= 0 ? playbackRates.length - 1 : idx - 1;
|
||||
rate.setPlaybackRate(playbackRates[next]!);
|
||||
},
|
||||
speedDown: MEDIA_INPUT_ACTION_OVERRIDES.speedDown,
|
||||
};
|
||||
|
||||
export function resolveGestureAction(name: GestureActionName | (string & {})): GestureActionResolver | undefined {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { isInteractiveTarget, listen } from '@videojs/utils/dom';
|
||||
|
||||
import type { GestureBinding, GestureMatchResult, GestureRecognizer, GestureRegion, GestureType } from './gesture';
|
||||
import type {
|
||||
GestureActivateEvent,
|
||||
GestureBinding,
|
||||
GestureMatchResult,
|
||||
GestureRecognizer,
|
||||
GestureRegion,
|
||||
GestureType,
|
||||
} from './gesture';
|
||||
import { resolveRegion } from './region';
|
||||
|
||||
const TAP_THRESHOLD = 250;
|
||||
@@ -10,6 +17,7 @@ export class GestureCoordinator {
|
||||
#bindings: GestureBinding[] = [];
|
||||
#recognizers = new Set<GestureRecognizer>();
|
||||
#disconnect: AbortController | null = null;
|
||||
#subscribers = new Set<(event: GestureActivateEvent) => void>();
|
||||
|
||||
constructor(target: HTMLElement) {
|
||||
this.#target = target;
|
||||
@@ -19,9 +27,39 @@ export class GestureCoordinator {
|
||||
return this.#bindings;
|
||||
}
|
||||
|
||||
subscribe(callback: (event: GestureActivateEvent) => void): () => void {
|
||||
this.#subscribers.add(callback);
|
||||
return () => this.#subscribers.delete(callback);
|
||||
}
|
||||
|
||||
add(binding: GestureBinding): () => void {
|
||||
this.#bindings.push(binding);
|
||||
this.#recognizers.add(binding.recognizer);
|
||||
const wrapped: GestureBinding = {
|
||||
...binding,
|
||||
onActivate: (event) => {
|
||||
if (this.#subscribers.size > 0) {
|
||||
const activateEvent: GestureActivateEvent = {
|
||||
type: binding.type,
|
||||
source: 'gesture',
|
||||
action: binding.action,
|
||||
value: binding.value,
|
||||
region: binding.region,
|
||||
pointer: binding.pointer,
|
||||
event,
|
||||
};
|
||||
for (const cb of this.#subscribers) {
|
||||
try {
|
||||
cb(activateEvent);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.warn('[vjs-gesture] subscribe callback threw:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.onActivate(event);
|
||||
},
|
||||
};
|
||||
|
||||
this.#bindings.push(wrapped);
|
||||
this.#recognizers.add(wrapped.recognizer);
|
||||
this.#connect();
|
||||
|
||||
let removed = false;
|
||||
@@ -29,7 +67,7 @@ export class GestureCoordinator {
|
||||
if (removed) return;
|
||||
removed = true;
|
||||
|
||||
const idx = this.#bindings.indexOf(binding);
|
||||
const idx = this.#bindings.indexOf(wrapped);
|
||||
if (idx !== -1) this.#bindings.splice(idx, 1);
|
||||
|
||||
this.#maybeDisconnect();
|
||||
|
||||
@@ -36,6 +36,7 @@ export function createTapGesture(
|
||||
region: options?.region,
|
||||
disabled: options?.disabled,
|
||||
action: options?.action,
|
||||
value: options?.value,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,5 +63,6 @@ export function createDoubleTapGesture(
|
||||
region: options?.region,
|
||||
disabled: options?.disabled,
|
||||
action: options?.action,
|
||||
value: options?.value,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface GestureOptions {
|
||||
region?: GestureRegion | undefined;
|
||||
disabled?: boolean | undefined;
|
||||
action?: string | undefined;
|
||||
value?: number | undefined;
|
||||
}
|
||||
|
||||
export interface GestureBinding {
|
||||
@@ -19,6 +20,17 @@ export interface GestureBinding {
|
||||
region?: GestureRegion | undefined;
|
||||
disabled?: boolean | undefined;
|
||||
action?: string | undefined;
|
||||
value?: number | undefined;
|
||||
}
|
||||
|
||||
export interface GestureActivateEvent {
|
||||
type: GestureType;
|
||||
source: 'gesture';
|
||||
action?: string | undefined;
|
||||
value?: number | undefined;
|
||||
region?: GestureRegion | undefined;
|
||||
pointer?: GesturePointerType | undefined;
|
||||
event: PointerEvent;
|
||||
}
|
||||
|
||||
export interface GestureRecognizer {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getGestureCoordinator } from '../coordinator';
|
||||
import { createDoubleTapGesture, createTapGesture } from '../create-tap-gesture';
|
||||
|
||||
function setup() {
|
||||
const container = document.createElement('div');
|
||||
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
right: 300,
|
||||
width: 300,
|
||||
top: 0,
|
||||
bottom: 200,
|
||||
height: 200,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('GestureCoordinator.subscribe', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('fires subscriber on tap', () => {
|
||||
const container = setup();
|
||||
const subscriber = vi.fn();
|
||||
|
||||
getGestureCoordinator(container).subscribe(subscriber);
|
||||
createTapGesture(container, vi.fn(), { action: 'togglePaused' });
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
expect(subscriber).toHaveBeenCalledWith(expect.objectContaining({ type: 'tap', action: 'togglePaused' }));
|
||||
});
|
||||
|
||||
it('fires subscriber on doubletap', () => {
|
||||
const container = setup();
|
||||
const subscriber = vi.fn();
|
||||
|
||||
getGestureCoordinator(container).subscribe(subscriber);
|
||||
createDoubleTapGesture(container, vi.fn(), { action: 'seekStep', value: 10, region: 'right' });
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 250 });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 250 });
|
||||
|
||||
expect(subscriber).toHaveBeenCalledOnce();
|
||||
expect(subscriber).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'doubletap', action: 'seekStep', value: 10, region: 'right' })
|
||||
);
|
||||
});
|
||||
|
||||
it('includes pointer type in subscriber event', () => {
|
||||
const container = setup();
|
||||
const subscriber = vi.fn();
|
||||
|
||||
getGestureCoordinator(container).subscribe(subscriber);
|
||||
createTapGesture(container, vi.fn(), { pointer: 'touch' });
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'touch', clientX: 150 });
|
||||
|
||||
expect(subscriber).toHaveBeenCalledWith(expect.objectContaining({ pointer: 'touch' }));
|
||||
});
|
||||
|
||||
it('returns unsubscribe function that stops callbacks', () => {
|
||||
const container = setup();
|
||||
const subscriber = vi.fn();
|
||||
|
||||
const unsubscribe = getGestureCoordinator(container).subscribe(subscriber);
|
||||
createTapGesture(container, vi.fn());
|
||||
|
||||
unsubscribe();
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still invokes binding onActivate when a subscriber throws', () => {
|
||||
const container = setup();
|
||||
const bindingActivate = vi.fn();
|
||||
|
||||
getGestureCoordinator(container).subscribe(() => {
|
||||
throw new Error('subscriber boom');
|
||||
});
|
||||
createTapGesture(container, bindingActivate, { action: 'togglePaused' });
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(bindingActivate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not fire subscriber when gesture binding does not match', () => {
|
||||
const container = setup();
|
||||
const subscriber = vi.fn();
|
||||
|
||||
getGestureCoordinator(container).subscribe(subscriber);
|
||||
createTapGesture(container, vi.fn(), { pointer: 'touch' });
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
// Fire with mouse, but binding is touch-only.
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pointerDown(target: HTMLElement, init: { button?: number } = {}): void {
|
||||
const event = new Event('pointerdown', { bubbles: true });
|
||||
Object.defineProperty(event, 'button', { value: init.button ?? 0 });
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function pointerUp(target: HTMLElement, init: { pointerType: string; clientX: number; button?: number }): void {
|
||||
const event = new Event('pointerup', { bubbles: true });
|
||||
Object.defineProperty(event, 'pointerType', { value: init.pointerType });
|
||||
Object.defineProperty(event, 'clientX', { value: init.clientX });
|
||||
Object.defineProperty(event, 'button', { value: init.button ?? 0 });
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { isUndefined } from '@videojs/utils/predicate';
|
||||
|
||||
import type { AnyPlayerStore } from '../media/types';
|
||||
import { MEDIA_INPUT_ACTION_OVERRIDES } from '../media-actions';
|
||||
import {
|
||||
selectFullscreen,
|
||||
selectPiP,
|
||||
selectPlayback,
|
||||
selectPlaybackRate,
|
||||
selectTextTrack,
|
||||
selectTime,
|
||||
selectVolume,
|
||||
@@ -63,37 +62,13 @@ const HOTKEY_ACTIONS: Record<HotkeyActionName, HotkeyActionResolver> = {
|
||||
pip.pip ? pip.exitPictureInPicture() : pip.requestPictureInPicture();
|
||||
},
|
||||
|
||||
seekStep({ store, value }) {
|
||||
if (isUndefined(value)) return;
|
||||
const time = selectTime(store.state);
|
||||
if (!time) return;
|
||||
time.seek(time.currentTime + value);
|
||||
},
|
||||
seekStep: MEDIA_INPUT_ACTION_OVERRIDES.seekStep,
|
||||
|
||||
volumeStep({ store, value }) {
|
||||
if (isUndefined(value)) return;
|
||||
const vol = selectVolume(store.state);
|
||||
if (!vol) return;
|
||||
vol.setVolume(vol.volume + value);
|
||||
},
|
||||
volumeStep: MEDIA_INPUT_ACTION_OVERRIDES.volumeStep,
|
||||
|
||||
speedUp({ store }) {
|
||||
const rate = selectPlaybackRate(store.state);
|
||||
if (!rate) return;
|
||||
const { playbackRates, playbackRate } = rate;
|
||||
const idx = playbackRates.indexOf(playbackRate);
|
||||
const next = idx < 0 || idx >= playbackRates.length - 1 ? 0 : idx + 1;
|
||||
rate.setPlaybackRate(playbackRates[next]!);
|
||||
},
|
||||
speedUp: MEDIA_INPUT_ACTION_OVERRIDES.speedUp,
|
||||
|
||||
speedDown({ store }) {
|
||||
const rate = selectPlaybackRate(store.state);
|
||||
if (!rate) return;
|
||||
const { playbackRates, playbackRate } = rate;
|
||||
const idx = playbackRates.indexOf(playbackRate);
|
||||
const next = idx <= 0 ? playbackRates.length - 1 : idx - 1;
|
||||
rate.setPlaybackRate(playbackRates[next]!);
|
||||
},
|
||||
speedDown: MEDIA_INPUT_ACTION_OVERRIDES.speedDown,
|
||||
|
||||
seekToPercent({ store, value, key }) {
|
||||
const time = selectTime(store.state);
|
||||
|
||||
@@ -4,6 +4,13 @@ import { toAriaKeyShortcut } from './aria';
|
||||
import type { HotkeyOptions, ParsedHotkeyBinding } from './hotkey';
|
||||
import { matchesHotkeyEvent, parseHotkeyPattern } from './hotkey';
|
||||
|
||||
export interface HotkeyActivateEvent {
|
||||
source: 'hotkey';
|
||||
action?: string | undefined;
|
||||
value?: number | undefined;
|
||||
event: KeyboardEvent;
|
||||
}
|
||||
|
||||
interface HotkeyBinding {
|
||||
parsed: ParsedHotkeyBinding[];
|
||||
options: HotkeyOptions;
|
||||
@@ -19,12 +26,18 @@ export class HotkeyCoordinator {
|
||||
#docDisconnect: AbortController | null = null;
|
||||
/** Action name → bound keys. Controls query this to set `aria-keyshortcuts`. */
|
||||
#ariaRegistry = new Map<string, ParsedHotkeyBinding[]>();
|
||||
#subscribers = new Set<(event: HotkeyActivateEvent) => void>();
|
||||
#destroyed = false;
|
||||
|
||||
constructor(target: HTMLElement) {
|
||||
this.#target = target;
|
||||
}
|
||||
|
||||
subscribe(callback: (event: HotkeyActivateEvent) => void): () => void {
|
||||
this.#subscribers.add(callback);
|
||||
return () => this.#subscribers.delete(callback);
|
||||
}
|
||||
|
||||
add(options: HotkeyOptions): () => void {
|
||||
const parsed = parseHotkeyPattern(options.keys);
|
||||
const binding: HotkeyBinding = { parsed, options, id: this.#nextId++ };
|
||||
@@ -141,6 +154,21 @@ export class HotkeyCoordinator {
|
||||
// Input safety: single-key shortcuts suppressed in editable fields.
|
||||
if (editable && p.modifiers.size === 0) continue;
|
||||
|
||||
if (this.#subscribers.size > 0) {
|
||||
const activateEvent: HotkeyActivateEvent = {
|
||||
source: 'hotkey',
|
||||
action: options.action,
|
||||
value: options.value,
|
||||
event,
|
||||
};
|
||||
for (const cb of this.#subscribers) {
|
||||
try {
|
||||
cb(activateEvent);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.warn('[vjs-hotkey] subscribe callback threw:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
options.onActivate(event, p.originalKey);
|
||||
return;
|
||||
|
||||
@@ -20,8 +20,10 @@ export interface HotkeyOptions {
|
||||
/** Whether `event.repeat` should fire the callback. */
|
||||
repeatable?: boolean | undefined;
|
||||
disabled?: boolean | undefined;
|
||||
/** Action name for the ARIA registry. */
|
||||
/** Action name for the ARIA registry and subscriber events. */
|
||||
action?: string | undefined;
|
||||
/** Numeric magnitude passed to subscriber events (e.g. 10 for `seekStep`). */
|
||||
value?: number | undefined;
|
||||
}
|
||||
|
||||
const MODIFIER_KEYS = new Set(['shift', 'ctrl', 'alt', 'meta']);
|
||||
@@ -114,7 +116,8 @@ export function findHotkeyCoordinator(target: HTMLElement): HotkeyCoordinator |
|
||||
return coordinators.get(target);
|
||||
}
|
||||
|
||||
function getCoordinator(target: HTMLElement): HotkeyCoordinator {
|
||||
/** Look up or create the hotkey coordinator for a target element. */
|
||||
export function getHotkeyCoordinator(target: HTMLElement): HotkeyCoordinator {
|
||||
let coordinator = coordinators.get(target);
|
||||
if (!coordinator) {
|
||||
coordinator = new HotkeyCoordinator(target);
|
||||
@@ -140,6 +143,6 @@ function getCoordinator(target: HTMLElement): HotkeyCoordinator {
|
||||
* @returns A cleanup function that removes the binding.
|
||||
*/
|
||||
export function createHotkey(target: HTMLElement, options: HotkeyOptions): () => void {
|
||||
const coordinator = getCoordinator(target);
|
||||
const coordinator = getHotkeyCoordinator(target);
|
||||
return coordinator.add(options);
|
||||
}
|
||||
|
||||
@@ -325,6 +325,35 @@ describe('HotkeyCoordinator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribe', () => {
|
||||
it('still invokes onActivate when a subscriber throws', () => {
|
||||
const c = setup();
|
||||
const onActivate = vi.fn();
|
||||
c.subscribe(() => {
|
||||
throw new Error('subscriber boom');
|
||||
});
|
||||
c.add({ keys: 'k', onActivate });
|
||||
|
||||
keydown(container, 'k');
|
||||
|
||||
expect(onActivate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('runs subsequent subscribers after one throws', () => {
|
||||
const c = setup();
|
||||
const second = vi.fn();
|
||||
c.subscribe(() => {
|
||||
throw new Error('first');
|
||||
});
|
||||
c.subscribe(second);
|
||||
c.add({ keys: 'k', onActivate: vi.fn() });
|
||||
|
||||
keydown(container, 'k');
|
||||
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ARIA registry', () => {
|
||||
it('returns undefined for unregistered action', () => {
|
||||
const c = setup();
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from './ui/alert-dialog';
|
||||
export * from './ui/button';
|
||||
export * from './ui/dismiss-layer';
|
||||
export * from './ui/event';
|
||||
export * from './ui/input-action';
|
||||
export * from './ui/popover/popover';
|
||||
export * from './ui/popover/popover-positioning';
|
||||
export * from './ui/slider';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { isUndefined } from '@videojs/utils/predicate';
|
||||
|
||||
import type { AnyPlayerStore } from './media/types';
|
||||
import { selectPlaybackRate, selectTime, selectVolume } from './store/selectors';
|
||||
|
||||
export type MediaInputActionName = 'seekStep' | 'volumeStep' | 'speedUp' | 'speedDown';
|
||||
|
||||
export interface MediaInputActionContext {
|
||||
store: AnyPlayerStore;
|
||||
value?: number | undefined;
|
||||
}
|
||||
|
||||
export type MediaInputActionResolver = (context: MediaInputActionContext) => void;
|
||||
|
||||
export const MEDIA_INPUT_ACTION_OVERRIDES: Record<MediaInputActionName, MediaInputActionResolver> = {
|
||||
seekStep({ store, value }) {
|
||||
if (isUndefined(value)) return;
|
||||
const time = selectTime(store.state);
|
||||
if (!time) return;
|
||||
time.seek(time.currentTime + value);
|
||||
},
|
||||
|
||||
volumeStep({ store, value }) {
|
||||
if (isUndefined(value)) return;
|
||||
const vol = selectVolume(store.state);
|
||||
if (!vol) return;
|
||||
vol.setVolume(vol.volume + value);
|
||||
},
|
||||
|
||||
speedUp({ store }) {
|
||||
const rate = selectPlaybackRate(store.state);
|
||||
if (!rate) return;
|
||||
const { playbackRates, playbackRate } = rate;
|
||||
const idx = playbackRates.indexOf(playbackRate);
|
||||
const next = idx < 0 || idx >= playbackRates.length - 1 ? 0 : idx + 1;
|
||||
rate.setPlaybackRate(playbackRates[next]!);
|
||||
},
|
||||
|
||||
speedDown({ store }) {
|
||||
const rate = selectPlaybackRate(store.state);
|
||||
if (!rate) return;
|
||||
const { playbackRates, playbackRate } = rate;
|
||||
const idx = playbackRates.indexOf(playbackRate);
|
||||
const next = idx <= 0 ? playbackRates.length - 1 : idx - 1;
|
||||
rate.setPlaybackRate(playbackRates[next]!);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { IndicatorVisibilityCoordinator } from '../../core/ui/input-feedback/indicator-lifecycle';
|
||||
import type { InputActionEvent, MediaSnapshot } from '../../core/ui/input-feedback/status';
|
||||
import { getGestureCoordinator } from '../gesture/coordinator';
|
||||
import type { GestureActivateEvent } from '../gesture/gesture';
|
||||
import type { HotkeyActivateEvent } from '../hotkey/coordinator';
|
||||
import { getHotkeyCoordinator } from '../hotkey/hotkey';
|
||||
import {
|
||||
selectFullscreen,
|
||||
selectPiP,
|
||||
selectPlayback,
|
||||
selectTextTrack,
|
||||
selectTime,
|
||||
selectVolume,
|
||||
} from '../store/selectors';
|
||||
|
||||
export type CoordinatorEvent = GestureActivateEvent | HotkeyActivateEvent;
|
||||
|
||||
export interface MediaSnapshotStore {
|
||||
readonly state: object;
|
||||
}
|
||||
|
||||
export function toInputActionEvent(event: CoordinatorEvent): InputActionEvent {
|
||||
return {
|
||||
action: event.action,
|
||||
value: event.value,
|
||||
source: event.source,
|
||||
key: 'key' in event.event ? event.event.key : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMediaSnapshot(store: MediaSnapshotStore | undefined): MediaSnapshot {
|
||||
if (!store) return {};
|
||||
|
||||
const state = store.state;
|
||||
const time = selectTime(state);
|
||||
|
||||
return {
|
||||
paused: selectPlayback(state)?.paused,
|
||||
volume: selectVolume(state)?.volume,
|
||||
muted: selectVolume(state)?.muted,
|
||||
fullscreen: selectFullscreen(state)?.fullscreen,
|
||||
subtitlesShowing: selectTextTrack(state)?.subtitlesShowing,
|
||||
pip: selectPiP(state)?.pip,
|
||||
currentTime: time?.currentTime,
|
||||
duration: time?.duration,
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeToInputActions(
|
||||
container: HTMLElement,
|
||||
callback: (event: InputActionEvent) => void
|
||||
): () => void {
|
||||
const handleEvent = (event: CoordinatorEvent) => callback(toInputActionEvent(event));
|
||||
const gestureUnsubscribe = getGestureCoordinator(container).subscribe(handleEvent);
|
||||
const hotkeyUnsubscribe = getHotkeyCoordinator(container).subscribe(handleEvent);
|
||||
|
||||
return () => {
|
||||
gestureUnsubscribe();
|
||||
hotkeyUnsubscribe();
|
||||
};
|
||||
}
|
||||
|
||||
const indicatorVisibilityCoordinators = new WeakMap<HTMLElement, IndicatorVisibilityCoordinator>();
|
||||
|
||||
export function getIndicatorVisibilityCoordinator(container: HTMLElement): IndicatorVisibilityCoordinator {
|
||||
let coordinator = indicatorVisibilityCoordinators.get(container);
|
||||
if (!coordinator) {
|
||||
coordinator = new IndicatorVisibilityCoordinator();
|
||||
indicatorVisibilityCoordinators.set(container, coordinator);
|
||||
}
|
||||
return coordinator;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getIndicatorVisibilityCoordinator,
|
||||
getMediaSnapshot,
|
||||
type MediaSnapshotStore,
|
||||
toInputActionEvent,
|
||||
} from '../input-action';
|
||||
|
||||
function mockStore(state: Record<string, unknown>): MediaSnapshotStore {
|
||||
return { state };
|
||||
}
|
||||
|
||||
describe('input-action', () => {
|
||||
it('converts coordinator events to input action events', () => {
|
||||
expect(
|
||||
toInputActionEvent({
|
||||
source: 'hotkey',
|
||||
action: 'togglePaused',
|
||||
value: 1,
|
||||
event: new KeyboardEvent('keydown', { key: 'k' }),
|
||||
})
|
||||
).toEqual({
|
||||
source: 'hotkey',
|
||||
action: 'togglePaused',
|
||||
value: 1,
|
||||
key: 'k',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives media snapshots from player store selectors', () => {
|
||||
expect(
|
||||
getMediaSnapshot(
|
||||
mockStore({
|
||||
chaptersCues: [],
|
||||
paused: true,
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
fullscreen: true,
|
||||
subtitlesShowing: true,
|
||||
pip: false,
|
||||
currentTime: 30,
|
||||
duration: 120,
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
paused: true,
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
fullscreen: true,
|
||||
subtitlesShowing: true,
|
||||
pip: false,
|
||||
currentTime: 30,
|
||||
duration: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it('shares a visibility coordinator per container', () => {
|
||||
const container = document.createElement('div');
|
||||
const first = { close: vi.fn() };
|
||||
const second = { close: vi.fn() };
|
||||
|
||||
const coordinator = getIndicatorVisibilityCoordinator(container);
|
||||
coordinator.register(first);
|
||||
coordinator.register(second);
|
||||
coordinator.show(second);
|
||||
|
||||
expect(getIndicatorVisibilityCoordinator(container)).toBe(coordinator);
|
||||
expect(first.close).toHaveBeenCalledOnce();
|
||||
expect(second.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user