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
@@ -14,6 +14,16 @@ export * from './ui/error-dialog/error-dialog-core';
|
||||
export * from './ui/error-dialog/error-dialog-data-attrs';
|
||||
export * from './ui/fullscreen-button/fullscreen-button-core';
|
||||
export * from './ui/fullscreen-button/fullscreen-button-data-attrs';
|
||||
export * from './ui/input-feedback/indicator-lifecycle';
|
||||
export * from './ui/input-feedback/seek-indicator-core';
|
||||
export * from './ui/input-feedback/seek-indicator-data-attrs';
|
||||
export * from './ui/input-feedback/status';
|
||||
export * from './ui/input-feedback/status-announcer-core';
|
||||
export * from './ui/input-feedback/status-indicator-core';
|
||||
export * from './ui/input-feedback/status-indicator-data-attrs';
|
||||
export * from './ui/input-feedback/volume-indicator-core';
|
||||
export * from './ui/input-feedback/volume-indicator-css-vars';
|
||||
export * from './ui/input-feedback/volume-indicator-data-attrs';
|
||||
export * from './ui/live-button/live-button-core';
|
||||
export * from './ui/live-button/live-button-data-attrs';
|
||||
export * from './ui/mute-button/mute-button-core';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { TransitionFlags, TransitionState } from '../transition';
|
||||
import { getTransitionFlags } from '../transition';
|
||||
|
||||
export const INDICATOR_CLOSE_DELAY = 800;
|
||||
|
||||
export interface IndicatorCoreProps {
|
||||
/** Delay in milliseconds before the indicator closes. */
|
||||
closeDelay?: number | undefined;
|
||||
}
|
||||
|
||||
export interface IndicatorLifecycleState extends TransitionFlags {
|
||||
open: boolean;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export class IndicatorCloseController {
|
||||
#timer: ReturnType<typeof setTimeout> | null = null;
|
||||
#close: () => void;
|
||||
#getDelay: () => number;
|
||||
|
||||
constructor(close: () => void, getDelay: () => number) {
|
||||
this.#close = close;
|
||||
this.#getDelay = getDelay;
|
||||
}
|
||||
|
||||
arm(): void {
|
||||
this.clear();
|
||||
this.#timer = setTimeout(() => {
|
||||
this.#timer = null;
|
||||
this.#close();
|
||||
}, this.#getDelay());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.#timer === null) return;
|
||||
clearTimeout(this.#timer);
|
||||
this.#timer = null;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.clear();
|
||||
this.#close();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export interface IndicatorVisibilityHandle {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export class IndicatorVisibilityCoordinator<Handle extends IndicatorVisibilityHandle = IndicatorVisibilityHandle> {
|
||||
#handles = new Set<Handle>();
|
||||
|
||||
register(handle: Handle): () => void {
|
||||
this.#handles.add(handle);
|
||||
return () => this.#handles.delete(handle);
|
||||
}
|
||||
|
||||
show(handle: Handle): void {
|
||||
for (const nextHandle of this.#handles) {
|
||||
if (nextHandle !== handle) nextHandle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getIndicatorCloseDelay(props: IndicatorCoreProps): number {
|
||||
return props.closeDelay ?? INDICATOR_CLOSE_DELAY;
|
||||
}
|
||||
|
||||
export function isIndicatorPresent(
|
||||
current: Pick<IndicatorLifecycleState, 'open'>,
|
||||
transition: Pick<TransitionState, 'active'>
|
||||
): boolean {
|
||||
return current.open || transition.active;
|
||||
}
|
||||
|
||||
export function getRenderedIndicatorState<State extends IndicatorLifecycleState>(
|
||||
current: State,
|
||||
snapshot: State,
|
||||
transition: TransitionState
|
||||
): State {
|
||||
const payload = current.open ? current : snapshot;
|
||||
|
||||
return {
|
||||
...payload,
|
||||
open: current.open && transition.active,
|
||||
generation: current.open ? current.generation : payload.generation,
|
||||
...getTransitionFlags(transition.status),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createState } from '@videojs/store';
|
||||
|
||||
import type { IndicatorCoreProps, IndicatorLifecycleState } from './indicator-lifecycle';
|
||||
import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle';
|
||||
import {
|
||||
formatCurrentTime,
|
||||
getSeekDirection,
|
||||
type IndicatorDirection,
|
||||
type InputActionEvent,
|
||||
isSeekIndicatorAction,
|
||||
type MediaSnapshot,
|
||||
} from './status';
|
||||
|
||||
export interface SeekIndicatorProps extends IndicatorCoreProps {}
|
||||
|
||||
export interface SeekIndicatorState extends IndicatorLifecycleState {
|
||||
direction: IndicatorDirection | null;
|
||||
count: number;
|
||||
seekTotal: number;
|
||||
value: string | null;
|
||||
currentTime: string;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: SeekIndicatorState = {
|
||||
open: false,
|
||||
generation: 0,
|
||||
direction: null,
|
||||
count: 0,
|
||||
seekTotal: 0,
|
||||
value: null,
|
||||
currentTime: '0:00',
|
||||
transitionStarting: false,
|
||||
transitionEnding: false,
|
||||
};
|
||||
|
||||
export class SeekIndicatorCore {
|
||||
readonly state = createState<SeekIndicatorState>({ ...INITIAL_STATE });
|
||||
|
||||
#props: SeekIndicatorProps = {};
|
||||
#originTime: number | null = null;
|
||||
#close = new IndicatorCloseController(
|
||||
() => {
|
||||
this.#originTime = null;
|
||||
this.state.patch({
|
||||
open: false,
|
||||
direction: null,
|
||||
count: 0,
|
||||
seekTotal: 0,
|
||||
value: null,
|
||||
});
|
||||
},
|
||||
() => getIndicatorCloseDelay(this.#props)
|
||||
);
|
||||
|
||||
setProps(props: SeekIndicatorProps): void {
|
||||
this.#props = props;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.#close.destroy();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#close.close();
|
||||
}
|
||||
|
||||
processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean {
|
||||
if (!isSeekIndicatorAction(event.action)) return false;
|
||||
|
||||
const current = this.state.current;
|
||||
const direction = getSeekDirection(event, snapshot);
|
||||
const rapidRepeat = current.open && event.action === 'seekStep' && current.direction === direction;
|
||||
|
||||
if (!rapidRepeat) {
|
||||
this.#originTime = snapshot.currentTime ?? null;
|
||||
}
|
||||
|
||||
const value = this.#getEffectiveSeekValue(event, snapshot, rapidRepeat);
|
||||
const seekTotal = rapidRepeat ? current.seekTotal + Math.abs(value) : Math.abs(value);
|
||||
|
||||
this.state.patch({
|
||||
open: true,
|
||||
generation: current.generation + 1,
|
||||
direction,
|
||||
count: rapidRepeat ? current.count + 1 : 1,
|
||||
seekTotal,
|
||||
value: event.action === 'seekStep' && seekTotal > 0 ? `${seekTotal}s` : null,
|
||||
currentTime: formatCurrentTime(snapshot),
|
||||
});
|
||||
this.#close.arm();
|
||||
return true;
|
||||
}
|
||||
|
||||
#getEffectiveSeekValue(event: InputActionEvent, snapshot: MediaSnapshot, rapidRepeat: boolean): number {
|
||||
if (event.action !== 'seekStep' || event.value === undefined) return 0;
|
||||
if (!rapidRepeat || this.#originTime === null) return event.value;
|
||||
|
||||
const originTime = this.#originTime;
|
||||
const duration = snapshot.duration ?? Infinity;
|
||||
const currentTotal = this.state.current.seekTotal;
|
||||
const step = Math.abs(event.value);
|
||||
const room =
|
||||
event.value < 0 ? Math.max(0, originTime - currentTotal) : Math.max(0, duration - originTime - currentTotal);
|
||||
|
||||
return room >= step ? event.value : 0;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace SeekIndicatorCore {
|
||||
export type Props = SeekIndicatorProps;
|
||||
export type State = SeekIndicatorState;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { StateAttrMap } from '../types';
|
||||
import type { SeekIndicatorState } from './seek-indicator-core';
|
||||
|
||||
export const SeekIndicatorDataAttrs = {
|
||||
open: 'data-open',
|
||||
direction: 'data-direction',
|
||||
transitionStarting: 'data-starting-style',
|
||||
transitionEnding: 'data-ending-style',
|
||||
} as const satisfies StateAttrMap<SeekIndicatorState>;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createState } from '@videojs/store';
|
||||
|
||||
import type { IndicatorCoreProps } from './indicator-lifecycle';
|
||||
import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle';
|
||||
import {
|
||||
DEFAULT_INPUT_INDICATOR_LABELS,
|
||||
deriveAnnouncerLabel,
|
||||
type InputActionEvent,
|
||||
type InputIndicatorLabels,
|
||||
type MediaSnapshot,
|
||||
} from './status';
|
||||
|
||||
export interface StatusAnnouncerProps extends IndicatorCoreProps {
|
||||
labels?: Partial<InputIndicatorLabels> | undefined;
|
||||
}
|
||||
|
||||
export interface StatusAnnouncerState {
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export class StatusAnnouncerCore {
|
||||
readonly state = createState<StatusAnnouncerState>({ label: null });
|
||||
|
||||
#props: StatusAnnouncerProps = {};
|
||||
#close = new IndicatorCloseController(
|
||||
() => this.state.patch({ label: null }),
|
||||
() => getIndicatorCloseDelay(this.#props)
|
||||
);
|
||||
|
||||
setProps(props: StatusAnnouncerProps): void {
|
||||
this.#props = props;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.#close.destroy();
|
||||
}
|
||||
|
||||
processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean {
|
||||
const label = deriveAnnouncerLabel(event, snapshot, {
|
||||
...DEFAULT_INPUT_INDICATOR_LABELS,
|
||||
...this.#props.labels,
|
||||
});
|
||||
if (!label) return false;
|
||||
|
||||
this.state.patch({ label });
|
||||
this.#close.arm();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace StatusAnnouncerCore {
|
||||
export type Props = StatusAnnouncerProps;
|
||||
export type State = StatusAnnouncerState;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createState } from '@videojs/store';
|
||||
|
||||
import type { IndicatorCoreProps, IndicatorLifecycleState } from './indicator-lifecycle';
|
||||
import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle';
|
||||
import {
|
||||
DEFAULT_INPUT_INDICATOR_LABELS,
|
||||
deriveStatus,
|
||||
type InputAction,
|
||||
type InputActionEvent,
|
||||
type InputIndicatorLabels,
|
||||
isInputActionIncluded,
|
||||
type MediaSnapshot,
|
||||
} from './status';
|
||||
|
||||
export interface StatusIndicatorProps extends IndicatorCoreProps {
|
||||
actions?: readonly InputAction[] | undefined;
|
||||
labels?: Partial<InputIndicatorLabels> | undefined;
|
||||
}
|
||||
|
||||
export interface StatusIndicatorState extends IndicatorLifecycleState {
|
||||
status: ReturnType<typeof deriveStatus> extends infer Details
|
||||
? Details extends { status: infer Status }
|
||||
? Status | null
|
||||
: never
|
||||
: never;
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: StatusIndicatorState = {
|
||||
open: false,
|
||||
generation: 0,
|
||||
status: null,
|
||||
label: null,
|
||||
value: null,
|
||||
transitionStarting: false,
|
||||
transitionEnding: false,
|
||||
};
|
||||
|
||||
export class StatusIndicatorCore {
|
||||
readonly state = createState<StatusIndicatorState>({ ...INITIAL_STATE });
|
||||
|
||||
#props: StatusIndicatorProps = {};
|
||||
#close = new IndicatorCloseController(
|
||||
() => this.state.patch({ open: false, status: null, label: null, value: null }),
|
||||
() => getIndicatorCloseDelay(this.#props)
|
||||
);
|
||||
|
||||
setProps(props: StatusIndicatorProps): void {
|
||||
this.#props = props;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.#close.destroy();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#close.close();
|
||||
}
|
||||
|
||||
processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean {
|
||||
if (!isInputActionIncluded(event.action, this.#props.actions)) return false;
|
||||
|
||||
const details = deriveStatus(event, snapshot, {
|
||||
...DEFAULT_INPUT_INDICATOR_LABELS,
|
||||
...this.#props.labels,
|
||||
});
|
||||
if (!details) return false;
|
||||
|
||||
this.state.patch({
|
||||
open: true,
|
||||
generation: this.state.current.generation + 1,
|
||||
status: details.status,
|
||||
label: details.label,
|
||||
value: details.value,
|
||||
});
|
||||
this.#close.arm();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace StatusIndicatorCore {
|
||||
export type Props = StatusIndicatorProps;
|
||||
export type State = StatusIndicatorState;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { StateAttrMap } from '../types';
|
||||
import type { StatusIndicatorState } from './status-indicator-core';
|
||||
|
||||
export const StatusIndicatorDataAttrs = {
|
||||
open: 'data-open',
|
||||
status: 'data-status',
|
||||
transitionStarting: 'data-starting-style',
|
||||
transitionEnding: 'data-ending-style',
|
||||
} as const satisfies StateAttrMap<StatusIndicatorState>;
|
||||
@@ -0,0 +1,267 @@
|
||||
import { clamp } from '@videojs/utils/number';
|
||||
import { formatTime } from '@videojs/utils/time';
|
||||
|
||||
export type InputActionSource = 'gesture' | 'hotkey';
|
||||
|
||||
export type InputAction =
|
||||
| 'togglePaused'
|
||||
| 'toggleMuted'
|
||||
| 'toggleFullscreen'
|
||||
| 'toggleSubtitles'
|
||||
| 'togglePictureInPicture'
|
||||
| 'toggleControls'
|
||||
| 'seekStep'
|
||||
| 'seekToPercent'
|
||||
| 'volumeStep'
|
||||
| 'speedUp'
|
||||
| 'speedDown'
|
||||
| (string & {});
|
||||
|
||||
export type IndicatorDirection = 'forward' | 'backward';
|
||||
export type IndicatorVolumeLevel = 'off' | 'low' | 'high';
|
||||
|
||||
export type IndicatorStatus =
|
||||
| 'pause'
|
||||
| 'play'
|
||||
| 'volume-off'
|
||||
| 'volume-low'
|
||||
| 'volume-high'
|
||||
| 'captions-on'
|
||||
| 'captions-off'
|
||||
| 'fullscreen'
|
||||
| 'exit-fullscreen'
|
||||
| 'pip'
|
||||
| 'exit-pip';
|
||||
|
||||
export interface InputActionEvent {
|
||||
action?: string | undefined;
|
||||
value?: number | undefined;
|
||||
source?: InputActionSource | undefined;
|
||||
key?: string | undefined;
|
||||
}
|
||||
|
||||
export interface MediaSnapshot {
|
||||
paused?: boolean | undefined;
|
||||
volume?: number | undefined;
|
||||
muted?: boolean | undefined;
|
||||
fullscreen?: boolean | undefined;
|
||||
subtitlesShowing?: boolean | undefined;
|
||||
pip?: boolean | undefined;
|
||||
currentTime?: number | undefined;
|
||||
duration?: number | undefined;
|
||||
}
|
||||
|
||||
export interface InputIndicatorLabels {
|
||||
muted: string;
|
||||
volume: string;
|
||||
captionsOn: string;
|
||||
captionsOff: string;
|
||||
paused: string;
|
||||
playing: string;
|
||||
fullscreen: string;
|
||||
exitFullscreen: string;
|
||||
pictureInPicture: string;
|
||||
exitPictureInPicture: string;
|
||||
}
|
||||
|
||||
export interface StatusDetails {
|
||||
status: IndicatorStatus;
|
||||
label: string;
|
||||
value: string | null;
|
||||
volumeLevel: IndicatorVolumeLevel | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_INPUT_INDICATOR_LABELS: InputIndicatorLabels = {
|
||||
muted: 'Muted',
|
||||
volume: 'Volume',
|
||||
captionsOn: 'Captions on',
|
||||
captionsOff: 'Captions off',
|
||||
paused: 'Paused',
|
||||
playing: 'Playing',
|
||||
fullscreen: 'Fullscreen',
|
||||
exitFullscreen: 'Exit fullscreen',
|
||||
pictureInPicture: 'Picture in picture',
|
||||
exitPictureInPicture: 'Exit picture in picture',
|
||||
};
|
||||
|
||||
export function isVolumeIndicatorAction(action: string | null | undefined): action is 'toggleMuted' | 'volumeStep' {
|
||||
return action === 'toggleMuted' || action === 'volumeStep';
|
||||
}
|
||||
|
||||
export function isSeekIndicatorAction(action: string | null | undefined): action is 'seekStep' | 'seekToPercent' {
|
||||
return action === 'seekStep' || action === 'seekToPercent';
|
||||
}
|
||||
|
||||
export function deriveStatus(
|
||||
event: InputActionEvent,
|
||||
snapshot: MediaSnapshot,
|
||||
labels: InputIndicatorLabels = DEFAULT_INPUT_INDICATOR_LABELS
|
||||
): StatusDetails | null {
|
||||
switch (event.action) {
|
||||
case 'togglePaused': {
|
||||
const paused = snapshot.paused !== undefined ? !snapshot.paused : true;
|
||||
return {
|
||||
status: paused ? 'pause' : 'play',
|
||||
label: paused ? labels.paused : labels.playing,
|
||||
value: null,
|
||||
volumeLevel: null,
|
||||
};
|
||||
}
|
||||
case 'toggleMuted':
|
||||
case 'volumeStep':
|
||||
return deriveVolumeStatus(event, snapshot, labels);
|
||||
case 'toggleSubtitles': {
|
||||
const showing = snapshot.subtitlesShowing !== undefined ? !snapshot.subtitlesShowing : true;
|
||||
return {
|
||||
status: showing ? 'captions-on' : 'captions-off',
|
||||
label: showing ? labels.captionsOn : labels.captionsOff,
|
||||
value: null,
|
||||
volumeLevel: null,
|
||||
};
|
||||
}
|
||||
case 'toggleFullscreen': {
|
||||
const fullscreen = snapshot.fullscreen !== undefined ? !snapshot.fullscreen : true;
|
||||
return {
|
||||
status: fullscreen ? 'fullscreen' : 'exit-fullscreen',
|
||||
label: fullscreen ? labels.fullscreen : labels.exitFullscreen,
|
||||
value: null,
|
||||
volumeLevel: null,
|
||||
};
|
||||
}
|
||||
case 'togglePictureInPicture': {
|
||||
const pip = snapshot.pip !== undefined ? !snapshot.pip : true;
|
||||
return {
|
||||
status: pip ? 'pip' : 'exit-pip',
|
||||
label: pip ? labels.pictureInPicture : labels.exitPictureInPicture,
|
||||
value: null,
|
||||
volumeLevel: null,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveAnnouncerLabel(
|
||||
event: InputActionEvent,
|
||||
snapshot: MediaSnapshot,
|
||||
labels: InputIndicatorLabels = DEFAULT_INPUT_INDICATOR_LABELS
|
||||
): string | null {
|
||||
const details = deriveStatus(event, snapshot, labels);
|
||||
if (!details) return null;
|
||||
|
||||
if (isVolumeIndicatorAction(event.action)) {
|
||||
return details.status === 'volume-off' ? labels.muted : `${labels.volume} ${details.value}`;
|
||||
}
|
||||
|
||||
return details.label;
|
||||
}
|
||||
|
||||
export function getVolumeLevel(volume: number): IndicatorVolumeLevel {
|
||||
if (volume <= 0) return 'off';
|
||||
return volume <= 0.5 ? 'low' : 'high';
|
||||
}
|
||||
|
||||
export function formatVolumeValue(volume: number): string {
|
||||
return `${Math.round(clamp(volume, 0, 1) * 100)}%`;
|
||||
}
|
||||
|
||||
export function formatCurrentTime(snapshot: MediaSnapshot): string {
|
||||
return formatTime(snapshot.currentTime ?? 0, snapshot.duration);
|
||||
}
|
||||
|
||||
export function getStatusIndicatorDisplayValue(state: { value: string | null; label: string | null }): string {
|
||||
return state.value ?? state.label ?? '';
|
||||
}
|
||||
|
||||
export function getVolumeIndicatorDisplayValue(state: { value: string | null }): string {
|
||||
return state.value ?? '';
|
||||
}
|
||||
|
||||
export function getSeekIndicatorDisplayValue(state: { value: string | null; currentTime: string }): string {
|
||||
return state.value ?? state.currentTime;
|
||||
}
|
||||
|
||||
export function getSeekToPercent(event: InputActionEvent): number | null {
|
||||
if (event.value !== undefined) return clamp(event.value, 0, 100);
|
||||
if (!event.key || event.key < '0' || event.key > '9') return null;
|
||||
return Number(event.key) * 10;
|
||||
}
|
||||
|
||||
export function getSeekDirection(event: InputActionEvent, snapshot: MediaSnapshot): IndicatorDirection | null {
|
||||
if (event.action === 'seekStep' && event.value !== undefined) {
|
||||
if (event.value > 0) return 'forward';
|
||||
if (event.value < 0) return 'backward';
|
||||
}
|
||||
|
||||
if (event.action === 'seekToPercent') {
|
||||
const percent = getSeekToPercent(event);
|
||||
if (percent === null || snapshot.duration === undefined || snapshot.duration <= 0) return null;
|
||||
|
||||
const targetTime = (percent / 100) * snapshot.duration;
|
||||
const currentTime = snapshot.currentTime ?? 0;
|
||||
if (targetTime > currentTime) return 'forward';
|
||||
if (targetTime < currentTime) return 'backward';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isInputActionIncluded(
|
||||
action: string | undefined,
|
||||
actions: readonly InputAction[] | undefined
|
||||
): boolean {
|
||||
if (!action) return false;
|
||||
return !actions || actions.includes(action);
|
||||
}
|
||||
|
||||
/** Predicted mute/volume after a volume-indicator action — shared by status derivation and boundary detection. */
|
||||
export interface VolumeActionPrediction {
|
||||
snapshotVolume: number;
|
||||
nextMuted: boolean;
|
||||
nextVolume: number;
|
||||
}
|
||||
|
||||
export function predictVolumeActionOutcome(event: InputActionEvent, snapshot: MediaSnapshot): VolumeActionPrediction {
|
||||
const muted = snapshot.muted === true;
|
||||
const snapshotVolume = snapshot.volume ?? 0;
|
||||
|
||||
if (event.action === 'toggleMuted') {
|
||||
return { snapshotVolume, nextMuted: !muted, nextVolume: snapshotVolume };
|
||||
}
|
||||
|
||||
if (event.action === 'volumeStep') {
|
||||
const nextVolume = clamp(snapshotVolume + (event.value ?? 0), 0, 1);
|
||||
/** Mirrors `volumeFeature.setVolume`: mute clears only when the clamped volume is greater than 0. */
|
||||
const nextMuted = muted && nextVolume <= 0;
|
||||
return { snapshotVolume, nextMuted, nextVolume };
|
||||
}
|
||||
|
||||
return { snapshotVolume, nextMuted: muted, nextVolume: snapshotVolume };
|
||||
}
|
||||
|
||||
function volumePredictionToStatusDetails(
|
||||
prediction: VolumeActionPrediction,
|
||||
labels: InputIndicatorLabels
|
||||
): StatusDetails {
|
||||
const level = prediction.nextMuted ? 'off' : getVolumeLevel(prediction.nextVolume);
|
||||
const value = prediction.nextMuted ? '0%' : formatVolumeValue(prediction.nextVolume);
|
||||
|
||||
return {
|
||||
status: level === 'off' ? 'volume-off' : level === 'low' ? 'volume-low' : 'volume-high',
|
||||
label: level === 'off' ? labels.muted : labels.volume,
|
||||
value,
|
||||
volumeLevel: level,
|
||||
};
|
||||
}
|
||||
|
||||
/** Labels/value/level for volume actions — single source shared with `VolumeIndicatorCore`. */
|
||||
export function deriveVolumeStatus(
|
||||
event: InputActionEvent,
|
||||
snapshot: MediaSnapshot,
|
||||
labels: InputIndicatorLabels = DEFAULT_INPUT_INDICATOR_LABELS,
|
||||
cachedPrediction?: VolumeActionPrediction
|
||||
): StatusDetails {
|
||||
const prediction = cachedPrediction ?? predictVolumeActionOutcome(event, snapshot);
|
||||
return volumePredictionToStatusDetails(prediction, labels);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getRenderedIndicatorState,
|
||||
type IndicatorLifecycleState,
|
||||
IndicatorVisibilityCoordinator,
|
||||
isIndicatorPresent,
|
||||
} from '../indicator-lifecycle';
|
||||
|
||||
interface TestState extends IndicatorLifecycleState {
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const IDLE_STATE: TestState = {
|
||||
open: false,
|
||||
generation: 0,
|
||||
value: null,
|
||||
transitionStarting: false,
|
||||
transitionEnding: false,
|
||||
};
|
||||
|
||||
describe('indicator-lifecycle', () => {
|
||||
it('keeps the snapshot payload while an indicator transitions out', () => {
|
||||
const snapshot: TestState = {
|
||||
...IDLE_STATE,
|
||||
open: true,
|
||||
generation: 1,
|
||||
value: 'Paused',
|
||||
};
|
||||
|
||||
const rendered = getRenderedIndicatorState(IDLE_STATE, snapshot, {
|
||||
active: true,
|
||||
status: 'ending',
|
||||
});
|
||||
|
||||
expect(rendered.open).toBe(false);
|
||||
expect(rendered.value).toBe('Paused');
|
||||
expect(rendered.transitionEnding).toBe(true);
|
||||
});
|
||||
|
||||
it('stays present until both logical state and transition are inactive', () => {
|
||||
expect(isIndicatorPresent(IDLE_STATE, { active: true })).toBe(true);
|
||||
expect(isIndicatorPresent(IDLE_STATE, { active: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('closes registered indicators when another indicator is shown', () => {
|
||||
const coordinator = new IndicatorVisibilityCoordinator();
|
||||
const first = { close: vi.fn() };
|
||||
const second = { close: vi.fn() };
|
||||
|
||||
coordinator.register(first);
|
||||
coordinator.register(second);
|
||||
coordinator.show(second);
|
||||
|
||||
expect(first.close).toHaveBeenCalledOnce();
|
||||
expect(second.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SeekIndicatorCore } from '../seek-indicator-core';
|
||||
|
||||
describe('SeekIndicatorCore', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('accumulates rapid seek steps in the same direction', () => {
|
||||
const core = new SeekIndicatorCore();
|
||||
|
||||
core.processEvent({ action: 'seekStep', value: 10 }, { currentTime: 30, duration: 120 });
|
||||
core.processEvent({ action: 'seekStep', value: 10 }, { currentTime: 30, duration: 120 });
|
||||
|
||||
expect(core.state.current.count).toBe(2);
|
||||
expect(core.state.current.seekTotal).toBe(20);
|
||||
expect(core.state.current.value).toBe('20s');
|
||||
});
|
||||
|
||||
it('clamps accumulated seek steps to the available media range', () => {
|
||||
const core = new SeekIndicatorCore();
|
||||
const snapshot = { currentTime: 115, duration: 120 };
|
||||
|
||||
core.processEvent({ action: 'seekStep', value: 10 }, snapshot);
|
||||
core.processEvent({ action: 'seekStep', value: 10 }, snapshot);
|
||||
|
||||
expect(core.state.current.seekTotal).toBe(10);
|
||||
});
|
||||
|
||||
it('infers seek-to-percent direction and always keeps current-time text', () => {
|
||||
const core = new SeekIndicatorCore();
|
||||
|
||||
core.processEvent({ action: 'seekToPercent', key: '8' }, { currentTime: 30, duration: 120 });
|
||||
|
||||
expect(core.state.current.direction).toBe('forward');
|
||||
expect(core.state.current.value).toBeNull();
|
||||
expect(core.state.current.currentTime).toBe('0:30');
|
||||
});
|
||||
|
||||
it('closes and resets accumulation after the configured delay', () => {
|
||||
const core = new SeekIndicatorCore();
|
||||
core.setProps({ closeDelay: 100 });
|
||||
core.processEvent({ action: 'seekStep', value: -10 }, { currentTime: 30, duration: 120 });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(core.state.current.open).toBe(false);
|
||||
expect(core.state.current.count).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { StatusAnnouncerCore } from '../status-announcer-core';
|
||||
import { StatusIndicatorCore } from '../status-indicator-core';
|
||||
|
||||
describe('StatusIndicatorCore', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('honors the optional action filter', () => {
|
||||
const core = new StatusIndicatorCore();
|
||||
core.setProps({ actions: ['toggleSubtitles'] });
|
||||
|
||||
expect(core.processEvent({ action: 'togglePaused' }, { paused: false })).toBe(false);
|
||||
expect(core.processEvent({ action: 'toggleSubtitles' }, { subtitlesShowing: false })).toBe(true);
|
||||
expect(core.state.current.status).toBe('captions-on');
|
||||
});
|
||||
|
||||
it('increments generation on each accepted trigger', () => {
|
||||
const core = new StatusIndicatorCore();
|
||||
|
||||
core.processEvent({ action: 'togglePaused' }, { paused: false });
|
||||
core.processEvent({ action: 'togglePaused' }, { paused: false });
|
||||
|
||||
expect(core.state.current.generation).toBe(2);
|
||||
});
|
||||
|
||||
it('clears after the configured delay', () => {
|
||||
const core = new StatusIndicatorCore();
|
||||
core.setProps({ closeDelay: 100 });
|
||||
core.processEvent({ action: 'toggleFullscreen' }, { fullscreen: false });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(core.state.current.open).toBe(false);
|
||||
expect(core.state.current.status).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatusAnnouncerCore', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('announces status labels and clears them after the delay', () => {
|
||||
const core = new StatusAnnouncerCore();
|
||||
core.setProps({ closeDelay: 100 });
|
||||
|
||||
expect(core.processEvent({ action: 'volumeStep', value: 0.1 }, { volume: 0.5, muted: false })).toBe(true);
|
||||
expect(core.state.current.label).toBe('Volume 60%');
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(core.state.current.label).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
deriveAnnouncerLabel,
|
||||
deriveStatus,
|
||||
getSeekDirection,
|
||||
getSeekIndicatorDisplayValue,
|
||||
getStatusIndicatorDisplayValue,
|
||||
getVolumeIndicatorDisplayValue,
|
||||
type MediaSnapshot,
|
||||
predictVolumeActionOutcome,
|
||||
} from '../status';
|
||||
|
||||
const SNAPSHOT: MediaSnapshot = {
|
||||
paused: false,
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
fullscreen: false,
|
||||
subtitlesShowing: false,
|
||||
pip: false,
|
||||
currentTime: 30,
|
||||
duration: 120,
|
||||
};
|
||||
|
||||
describe('status', () => {
|
||||
it('derives playback status from the expected next state', () => {
|
||||
expect(deriveStatus({ action: 'togglePaused' }, SNAPSHOT)).toMatchObject({
|
||||
status: 'pause',
|
||||
label: 'Paused',
|
||||
});
|
||||
|
||||
expect(deriveStatus({ action: 'togglePaused' }, { ...SNAPSHOT, paused: true })).toMatchObject({
|
||||
status: 'play',
|
||||
label: 'Playing',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives volume status, value, and announcer labels', () => {
|
||||
expect(deriveStatus({ action: 'volumeStep', value: 0.3 }, SNAPSHOT)).toMatchObject({
|
||||
status: 'volume-high',
|
||||
label: 'Volume',
|
||||
value: '80%',
|
||||
volumeLevel: 'high',
|
||||
});
|
||||
|
||||
expect(deriveAnnouncerLabel({ action: 'volumeStep', value: 0.3 }, SNAPSHOT)).toBe('Volume 80%');
|
||||
expect(deriveAnnouncerLabel({ action: 'toggleMuted' }, SNAPSHOT)).toBe('Muted');
|
||||
});
|
||||
|
||||
it('derives captions, fullscreen, and picture-in-picture statuses', () => {
|
||||
expect(deriveStatus({ action: 'toggleSubtitles' }, SNAPSHOT)?.status).toBe('captions-on');
|
||||
expect(deriveStatus({ action: 'toggleFullscreen' }, SNAPSHOT)?.status).toBe('fullscreen');
|
||||
expect(deriveStatus({ action: 'toggleFullscreen' }, { ...SNAPSHOT, fullscreen: true })?.status).toBe(
|
||||
'exit-fullscreen'
|
||||
);
|
||||
expect(deriveStatus({ action: 'togglePictureInPicture' }, SNAPSHOT)?.status).toBe('pip');
|
||||
expect(deriveStatus({ action: 'togglePictureInPicture' }, { ...SNAPSHOT, pip: true })?.status).toBe('exit-pip');
|
||||
});
|
||||
|
||||
it('does not derive status or values for seek and unsupported actions', () => {
|
||||
expect(deriveStatus({ action: 'seekStep', value: 10 }, SNAPSHOT)).toBeNull();
|
||||
expect(deriveStatus({ action: 'seekToPercent', value: 50 }, SNAPSHOT)?.value ?? null).toBeNull();
|
||||
expect(deriveStatus({ action: 'speedUp' }, SNAPSHOT)).toBeNull();
|
||||
});
|
||||
|
||||
it('predicts volume outcome like volumeFeature.setVolume when muted', () => {
|
||||
expect(predictVolumeActionOutcome({ action: 'volumeStep', value: 0.05 }, { muted: true, volume: 0.5 })).toEqual({
|
||||
snapshotVolume: 0.5,
|
||||
nextMuted: false,
|
||||
nextVolume: 0.55,
|
||||
});
|
||||
|
||||
expect(predictVolumeActionOutcome({ action: 'volumeStep', value: -0.05 }, { muted: true, volume: 0.05 })).toEqual({
|
||||
snapshotVolume: 0.05,
|
||||
nextMuted: true,
|
||||
nextVolume: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('infers seek direction from action details', () => {
|
||||
expect(getSeekDirection({ action: 'seekStep', value: -10 }, SNAPSHOT)).toBe('backward');
|
||||
expect(getSeekDirection({ action: 'seekToPercent', key: '8' }, SNAPSHOT)).toBe('forward');
|
||||
});
|
||||
|
||||
it('derives display values for mounted indicators', () => {
|
||||
expect(getStatusIndicatorDisplayValue({ label: 'Paused', value: null })).toBe('Paused');
|
||||
expect(getVolumeIndicatorDisplayValue({ value: null })).toBe('');
|
||||
expect(getSeekIndicatorDisplayValue({ value: null, currentTime: '0:30' })).toBe('0:30');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { VolumeIndicatorCore } from '../volume-indicator-core';
|
||||
|
||||
describe('VolumeIndicatorCore', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('opens with scoped volume state for volume actions only', () => {
|
||||
const core = new VolumeIndicatorCore();
|
||||
|
||||
expect(core.processEvent({ action: 'togglePaused' }, { volume: 0.5, muted: false })).toBe(false);
|
||||
expect(core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 0.5, muted: false })).toBe(true);
|
||||
expect(core.state.current.open).toBe(true);
|
||||
expect(core.state.current.level).toBe('high');
|
||||
expect(core.state.current.value).toBe('55%');
|
||||
expect(core.state.current.fill).toBe('55%');
|
||||
});
|
||||
|
||||
it('accumulates volume steps from sequential media snapshots', () => {
|
||||
const core = new VolumeIndicatorCore();
|
||||
|
||||
core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 0.5, muted: false });
|
||||
core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 0.55, muted: false });
|
||||
|
||||
expect(core.state.current.value).toBe('60%');
|
||||
});
|
||||
|
||||
it('uses snapshot volume after mute feedback showed 0%', () => {
|
||||
const core = new VolumeIndicatorCore();
|
||||
|
||||
core.processEvent({ action: 'toggleMuted' }, { volume: 0.5, muted: false });
|
||||
expect(core.state.current.value).toBe('0%');
|
||||
|
||||
core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 0.5, muted: true });
|
||||
expect(core.state.current.value).toBe('55%');
|
||||
});
|
||||
|
||||
it('does not treat a zero volume step as a min/max boundary hit', () => {
|
||||
const core = new VolumeIndicatorCore();
|
||||
|
||||
core.processEvent({ action: 'volumeStep', value: 0 }, { volume: 1, muted: false });
|
||||
expect(core.state.current.max).toBe(false);
|
||||
expect(core.state.current.min).toBe(false);
|
||||
|
||||
core.processEvent({ action: 'volumeStep', value: 0 }, { volume: 0, muted: false });
|
||||
expect(core.state.current.max).toBe(false);
|
||||
expect(core.state.current.min).toBe(false);
|
||||
});
|
||||
|
||||
it('restarts the boundary flag when the same edge is hit repeatedly', () => {
|
||||
const core = new VolumeIndicatorCore();
|
||||
|
||||
core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 1, muted: false });
|
||||
expect(core.state.current.max).toBe(true);
|
||||
|
||||
core.processEvent({ action: 'volumeStep', value: 0.05 }, { volume: 1, muted: false });
|
||||
expect(core.state.current.max).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(core.state.current.max).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(core.state.current.max).toBe(false);
|
||||
});
|
||||
|
||||
it('closes after the configured delay', () => {
|
||||
const core = new VolumeIndicatorCore();
|
||||
core.setProps({ closeDelay: 100 });
|
||||
core.processEvent({ action: 'toggleMuted' }, { volume: 0.5, muted: false });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(core.state.current.open).toBe(false);
|
||||
expect(core.state.current.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createState } from '@videojs/store';
|
||||
|
||||
import type { IndicatorCoreProps, IndicatorLifecycleState } from './indicator-lifecycle';
|
||||
import { getIndicatorCloseDelay, IndicatorCloseController } from './indicator-lifecycle';
|
||||
import {
|
||||
DEFAULT_INPUT_INDICATOR_LABELS,
|
||||
deriveVolumeStatus,
|
||||
type IndicatorVolumeLevel,
|
||||
type InputActionEvent,
|
||||
isVolumeIndicatorAction,
|
||||
type MediaSnapshot,
|
||||
predictVolumeActionOutcome,
|
||||
} from './status';
|
||||
|
||||
export interface VolumeIndicatorProps extends IndicatorCoreProps {}
|
||||
|
||||
export interface VolumeIndicatorState extends IndicatorLifecycleState {
|
||||
level: IndicatorVolumeLevel | null;
|
||||
value: string | null;
|
||||
fill: string | null;
|
||||
min: boolean;
|
||||
max: boolean;
|
||||
}
|
||||
|
||||
const BOUNDARY_CLEAR_DELAY = 300;
|
||||
|
||||
const INITIAL_STATE: VolumeIndicatorState = {
|
||||
open: false,
|
||||
generation: 0,
|
||||
level: null,
|
||||
value: null,
|
||||
fill: null,
|
||||
min: false,
|
||||
max: false,
|
||||
transitionStarting: false,
|
||||
transitionEnding: false,
|
||||
};
|
||||
|
||||
export class VolumeIndicatorCore {
|
||||
readonly state = createState<VolumeIndicatorState>({ ...INITIAL_STATE });
|
||||
|
||||
#props: VolumeIndicatorProps = {};
|
||||
#boundaryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
#boundaryRestartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
#close = new IndicatorCloseController(
|
||||
() => this.state.patch({ open: false, level: null, value: null, fill: null, min: false, max: false }),
|
||||
() => getIndicatorCloseDelay(this.#props)
|
||||
);
|
||||
|
||||
setProps(props: VolumeIndicatorProps): void {
|
||||
this.#props = props;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.#close.destroy();
|
||||
this.#clearBoundaryTimers();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#clearBoundaryTimers();
|
||||
this.#close.close();
|
||||
}
|
||||
|
||||
processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean {
|
||||
if (!isVolumeIndicatorAction(event.action)) return false;
|
||||
|
||||
const current = this.state.current;
|
||||
const prediction = predictVolumeActionOutcome(event, snapshot);
|
||||
const details = deriveVolumeStatus(event, snapshot, DEFAULT_INPUT_INDICATOR_LABELS, prediction);
|
||||
const boundary = getVolumeBoundary(event, prediction.snapshotVolume, prediction.nextVolume);
|
||||
const repeatedBoundary = boundary !== null && current[boundary] === true;
|
||||
|
||||
if (!boundary) this.#clearBoundaryTimers();
|
||||
|
||||
this.state.patch({
|
||||
open: true,
|
||||
generation: current.generation + 1,
|
||||
level: details.volumeLevel,
|
||||
value: details.value,
|
||||
fill: details.value,
|
||||
min: boundary === 'min' && !repeatedBoundary,
|
||||
max: boundary === 'max' && !repeatedBoundary,
|
||||
});
|
||||
|
||||
if (boundary) {
|
||||
if (repeatedBoundary) {
|
||||
this.#restartBoundary(boundary);
|
||||
} else {
|
||||
this.#scheduleBoundaryClear();
|
||||
}
|
||||
}
|
||||
|
||||
this.#close.arm();
|
||||
return true;
|
||||
}
|
||||
|
||||
#scheduleBoundaryClear(): void {
|
||||
this.#clearBoundaryTimer();
|
||||
this.#boundaryTimer = setTimeout(() => {
|
||||
this.#boundaryTimer = null;
|
||||
this.state.patch({ min: false, max: false });
|
||||
}, BOUNDARY_CLEAR_DELAY);
|
||||
}
|
||||
|
||||
#restartBoundary(boundary: 'min' | 'max'): void {
|
||||
this.#clearBoundaryTimers();
|
||||
this.state.patch({ min: false, max: false });
|
||||
this.#boundaryRestartTimer = setTimeout(() => {
|
||||
this.#boundaryRestartTimer = null;
|
||||
this.state.patch({ [boundary]: true });
|
||||
this.#scheduleBoundaryClear();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
#clearBoundaryTimer(): void {
|
||||
if (this.#boundaryTimer === null) return;
|
||||
clearTimeout(this.#boundaryTimer);
|
||||
this.#boundaryTimer = null;
|
||||
}
|
||||
|
||||
#clearBoundaryRestartTimer(): void {
|
||||
if (this.#boundaryRestartTimer === null) return;
|
||||
clearTimeout(this.#boundaryRestartTimer);
|
||||
this.#boundaryRestartTimer = null;
|
||||
}
|
||||
|
||||
#clearBoundaryTimers(): void {
|
||||
this.#clearBoundaryTimer();
|
||||
this.#clearBoundaryRestartTimer();
|
||||
}
|
||||
}
|
||||
|
||||
export namespace VolumeIndicatorCore {
|
||||
export type Props = VolumeIndicatorProps;
|
||||
export type State = VolumeIndicatorState;
|
||||
}
|
||||
|
||||
function getVolumeBoundary(event: InputActionEvent, currentVolume: number, nextVolume: number): 'min' | 'max' | null {
|
||||
if (event.action !== 'volumeStep' || event.value === undefined || event.value === 0) return null;
|
||||
if (nextVolume !== currentVolume) return null;
|
||||
return event.value < 0 ? 'min' : 'max';
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const VolumeIndicatorCSSVars = {
|
||||
fill: '--media-volume-fill',
|
||||
} as const;
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { StateAttrMap } from '../types';
|
||||
import type { VolumeIndicatorState } from './volume-indicator-core';
|
||||
|
||||
export const VolumeIndicatorDataAttrs = {
|
||||
open: 'data-open',
|
||||
level: 'data-level',
|
||||
min: 'data-min',
|
||||
max: 'data-max',
|
||||
transitionStarting: 'data-starting-style',
|
||||
transitionEnding: 'data-ending-style',
|
||||
} as const satisfies StateAttrMap<VolumeIndicatorState>;
|
||||
@@ -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