mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
chore(root): prepare workspace for alpha (#276)
This commit is contained in:
@@ -1,319 +0,0 @@
|
||||
import { shallowEqual } from '@videojs/utils';
|
||||
import {
|
||||
addTranslateToBoundingRect,
|
||||
contains,
|
||||
getDocument,
|
||||
getInBoundsAdjustments,
|
||||
getUntransformedBoundingRect,
|
||||
safePolygon,
|
||||
} from '@videojs/utils/dom';
|
||||
import { map } from 'nanostores';
|
||||
|
||||
type Placement = 'top' | 'top-start' | 'top-end';
|
||||
|
||||
export interface PopoverState {
|
||||
openOnHover: boolean;
|
||||
delay: number;
|
||||
closeDelay: number;
|
||||
placement: Placement;
|
||||
sideOffset: number;
|
||||
collisionPadding: number;
|
||||
disableHoverablePopover: boolean;
|
||||
trackCursorAxis?: 'x';
|
||||
_open: boolean;
|
||||
_transitionStatus: 'initial' | 'open' | 'close' | 'unmounted';
|
||||
_collisionOffset: { x: number };
|
||||
_pointerPosition: { x: number };
|
||||
_setTriggerElement: (element: HTMLElement | null) => void;
|
||||
_triggerElement?: HTMLElement | null;
|
||||
_setPopoverElement: (element: HTMLElement | null) => void;
|
||||
_popoverElement?: HTMLElement | null;
|
||||
_setCollisionBoundaryElement: (element: HTMLElement | null) => void;
|
||||
_collisionBoundaryElement?: HTMLElement | null;
|
||||
_popoverStyle?: Partial<CSSStyleDeclaration>;
|
||||
_minLeft?: number;
|
||||
_maxLeft?: number;
|
||||
}
|
||||
|
||||
export class Popover {
|
||||
#hoverTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
#resizeObserver: ResizeObserver | null = null;
|
||||
#state = map<PopoverState>({
|
||||
openOnHover: false,
|
||||
delay: 0,
|
||||
closeDelay: 0,
|
||||
placement: 'top',
|
||||
sideOffset: 5,
|
||||
collisionPadding: 0,
|
||||
disableHoverablePopover: false,
|
||||
_open: false,
|
||||
_transitionStatus: 'initial',
|
||||
_collisionOffset: { x: 0 },
|
||||
_pointerPosition: { x: 0 },
|
||||
_setTriggerElement: this._setTriggerElement.bind(this),
|
||||
_setPopoverElement: this._setPopoverElement.bind(this),
|
||||
_setCollisionBoundaryElement: this._setCollisionBoundaryElement.bind(this),
|
||||
});
|
||||
|
||||
_setPopoverElement(element: HTMLElement | null): void {
|
||||
if (!element) {
|
||||
this.#resizeObserver?.disconnect();
|
||||
this.#clearHoverTimeout();
|
||||
this.#popoverElement?.removeEventListener('pointerenter', this);
|
||||
this.#popoverElement?.removeEventListener('focusout', this);
|
||||
getDocument(this.#popoverElement).documentElement.removeEventListener('pointermove', this);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ _popoverElement: element });
|
||||
|
||||
this.#resizeObserver = new ResizeObserver(() => this.#checkCollision());
|
||||
this.#resizeObserver.observe(element);
|
||||
|
||||
element.addEventListener('pointerenter', this);
|
||||
element.addEventListener('focusout', this);
|
||||
}
|
||||
|
||||
_setTriggerElement(element: HTMLElement | null): void {
|
||||
if (!element) {
|
||||
this.#triggerElement?.removeEventListener('pointerenter', this);
|
||||
this.#triggerElement?.removeEventListener('pointerleave', this);
|
||||
this.#triggerElement?.removeEventListener('focusin', this);
|
||||
this.#triggerElement?.removeEventListener('focusout', this);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ _triggerElement: element });
|
||||
|
||||
if (globalThis.matchMedia?.('(hover: hover)')?.matches) {
|
||||
element.addEventListener('pointerenter', this);
|
||||
element.addEventListener('pointerleave', this);
|
||||
}
|
||||
|
||||
element.addEventListener('focusin', this);
|
||||
element.addEventListener('focusout', this);
|
||||
}
|
||||
|
||||
_setCollisionBoundaryElement(element: HTMLElement | null): void {
|
||||
this.setState({ _collisionBoundaryElement: element });
|
||||
}
|
||||
|
||||
subscribe(callback: (state: PopoverState) => void): () => void {
|
||||
return this.#state.subscribe(callback);
|
||||
}
|
||||
|
||||
setState(state: Partial<PopoverState>): void {
|
||||
if (shallowEqual(state, this.#state.get())) return;
|
||||
this.#state.set({ ...this.#state.get(), ...state });
|
||||
}
|
||||
|
||||
getState(): PopoverState {
|
||||
const baseState = this.#state.get();
|
||||
const {
|
||||
placement,
|
||||
sideOffset,
|
||||
trackCursorAxis,
|
||||
_popoverElement,
|
||||
_pointerPosition,
|
||||
_collisionOffset,
|
||||
_minLeft,
|
||||
_maxLeft,
|
||||
} = baseState;
|
||||
const [side, alignment] = placement.split('-');
|
||||
|
||||
const _popoverStyle: Partial<CSSStyleDeclaration> = {
|
||||
...(_popoverElement ? { positionAnchor: `--${_popoverElement.id}` } : {}),
|
||||
top: `calc(anchor(${side}) - ${sideOffset}px)`,
|
||||
};
|
||||
|
||||
if (trackCursorAxis === 'x') {
|
||||
_popoverStyle.translate = `-50% -100%`;
|
||||
_popoverStyle.left = `clamp(${_minLeft}px, ${_pointerPosition.x}px, ${_maxLeft}px)`;
|
||||
} else {
|
||||
_popoverStyle.translate = `${_collisionOffset.x}px -100%`;
|
||||
_popoverStyle.justifySelf
|
||||
= alignment === 'start' ? 'anchor-start' : alignment === 'end' ? 'anchor-end' : 'anchor-center';
|
||||
}
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
_popoverStyle,
|
||||
};
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
switch (event.type) {
|
||||
case 'pointerenter':
|
||||
this.#handlePointerEnter(event as PointerEvent);
|
||||
break;
|
||||
case 'pointerleave':
|
||||
this.#handlePointerLeave(event as PointerEvent);
|
||||
break;
|
||||
case 'pointermove':
|
||||
this.#handlePointerMove(event as PointerEvent);
|
||||
break;
|
||||
case 'focusin':
|
||||
this.#handleFocusIn(event as FocusEvent);
|
||||
break;
|
||||
case 'focusout':
|
||||
this.#handleFocusOut(event as FocusEvent);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
get #popoverElement(): HTMLElement | undefined | null {
|
||||
return this.getState()._popoverElement;
|
||||
}
|
||||
|
||||
get #triggerElement(): HTMLElement | undefined | null {
|
||||
return this.getState()._triggerElement;
|
||||
}
|
||||
|
||||
get #open(): boolean {
|
||||
return this.getState()._open;
|
||||
}
|
||||
|
||||
#setOpen(open: boolean): void {
|
||||
if (this.#open === open) return;
|
||||
|
||||
this.setState({ _open: open });
|
||||
|
||||
if (open) {
|
||||
this.setState({ _transitionStatus: 'initial' });
|
||||
|
||||
this.#popoverElement?.showPopover();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.setState({ _transitionStatus: 'open' });
|
||||
|
||||
// This requestAnimationFrame is required for React because it renders async.
|
||||
requestAnimationFrame(() => this.#checkCollision());
|
||||
});
|
||||
} else {
|
||||
this.setState({ _transitionStatus: 'close' });
|
||||
|
||||
// This requestAnimationFrame is required for React because the data- attributes are not updated immediately.
|
||||
requestAnimationFrame(() => {
|
||||
const transitions = this.#popoverElement?.getAnimations().filter(anim => anim instanceof CSSTransition);
|
||||
if (transitions && transitions.length > 0) {
|
||||
Promise.all(transitions.map(t => t.finished))
|
||||
.then(() => this.#popoverElement?.hidePopover())
|
||||
.catch(() => this.#popoverElement?.hidePopover());
|
||||
} else {
|
||||
this.#popoverElement?.hidePopover();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#clearHoverTimeout(): void {
|
||||
if (this.#hoverTimeout) {
|
||||
globalThis.clearTimeout(this.#hoverTimeout);
|
||||
this.#hoverTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
#handlePointerEnter(event: PointerEvent): void {
|
||||
const { openOnHover, trackCursorAxis } = this.getState();
|
||||
if (!openOnHover) return;
|
||||
|
||||
this.#clearHoverTimeout();
|
||||
|
||||
if (event.currentTarget === this.#popoverElement || trackCursorAxis === 'x') {
|
||||
this.#addPointerMoveListener();
|
||||
}
|
||||
|
||||
if (this.getState()._open) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.#setOpen(true);
|
||||
}, this.getState().delay);
|
||||
}
|
||||
|
||||
#handlePointerLeave(_event: PointerEvent): void {
|
||||
const { disableHoverablePopover, closeDelay } = this.getState();
|
||||
|
||||
if (!disableHoverablePopover) {
|
||||
this.#addPointerMoveListener();
|
||||
}
|
||||
|
||||
if (disableHoverablePopover) {
|
||||
this.#clearHoverTimeout();
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.#setOpen(false);
|
||||
}, closeDelay);
|
||||
}
|
||||
}
|
||||
|
||||
#addPointerMoveListener(): void {
|
||||
if (!globalThis.matchMedia?.('(hover: hover)')?.matches || !this.#popoverElement) return;
|
||||
|
||||
getDocument(this.#popoverElement).documentElement.addEventListener('pointermove', this);
|
||||
}
|
||||
|
||||
#handlePointerMove(event: PointerEvent): void {
|
||||
const { disableHoverablePopover, openOnHover, trackCursorAxis } = this.getState();
|
||||
if (!openOnHover || !this.#triggerElement || !this.#popoverElement) return;
|
||||
|
||||
if (trackCursorAxis === 'x') {
|
||||
this.setState({ _pointerPosition: { x: event.clientX } });
|
||||
|
||||
// This requestAnimationFrame is required for React because it renders async.
|
||||
requestAnimationFrame(() => this.#checkCollision());
|
||||
}
|
||||
|
||||
if (disableHoverablePopover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const close = safePolygon({ blockPointerEvents: true })({
|
||||
placement: this.getState().placement,
|
||||
elements: {
|
||||
domReference: this.#triggerElement,
|
||||
floating: this.#popoverElement,
|
||||
},
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
onClose: () => {
|
||||
getDocument(this.#popoverElement).documentElement.removeEventListener('pointermove', this);
|
||||
|
||||
this.#clearHoverTimeout();
|
||||
this.#hoverTimeout = globalThis.setTimeout(() => {
|
||||
this.#setOpen(false);
|
||||
}, this.getState().closeDelay);
|
||||
},
|
||||
});
|
||||
close(event);
|
||||
}
|
||||
|
||||
#handleFocusIn(_event: FocusEvent): void {
|
||||
this.#setOpen(true);
|
||||
}
|
||||
|
||||
#handleFocusOut(event: FocusEvent): void {
|
||||
const relatedTarget = event.relatedTarget as HTMLElement;
|
||||
if (relatedTarget && contains(this.#popoverElement, relatedTarget)) return;
|
||||
|
||||
this.#setOpen(false);
|
||||
}
|
||||
|
||||
#checkCollision(): void {
|
||||
const { _collisionBoundaryElement, _open, _popoverElement, collisionPadding } = this.getState();
|
||||
|
||||
if (!_open || !_collisionBoundaryElement || !_popoverElement) return;
|
||||
|
||||
const popoverRect = getUntransformedBoundingRect(_popoverElement);
|
||||
const boundsRect = getUntransformedBoundingRect(_collisionBoundaryElement);
|
||||
const translatedPopoverRect = addTranslateToBoundingRect(popoverRect, _popoverElement);
|
||||
|
||||
const _minLeft = boundsRect.left + translatedPopoverRect.width / 2 + collisionPadding;
|
||||
const _maxLeft = boundsRect.right - translatedPopoverRect.width / 2 - collisionPadding;
|
||||
const _collisionOffset = getInBoundsAdjustments(popoverRect, boundsRect, collisionPadding);
|
||||
|
||||
this.setState({ _minLeft, _maxLeft, _collisionOffset });
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { getPointProgressOnLine, shallowEqual } from '@videojs/utils';
|
||||
import { map } from 'nanostores';
|
||||
|
||||
export interface SliderState {
|
||||
_setRootElement: (element: HTMLElement | null) => void;
|
||||
_rootElement: HTMLElement | null;
|
||||
_setTrackElement: (element: HTMLElement | null) => void;
|
||||
_trackElement: HTMLElement | null;
|
||||
_pointerRatio: number;
|
||||
_hovering: boolean;
|
||||
_dragging: boolean;
|
||||
_keying: boolean;
|
||||
_fillWidth: number;
|
||||
_pointerWidth: number;
|
||||
_stepSize: number;
|
||||
}
|
||||
|
||||
export class Slider {
|
||||
#abortController: AbortController | null = null;
|
||||
#state = map<SliderState>({
|
||||
_setRootElement: this._setRootElement.bind(this),
|
||||
_rootElement: null,
|
||||
_setTrackElement: (element: HTMLElement | null) => this.setState({ _trackElement: element }),
|
||||
_trackElement: null,
|
||||
_pointerRatio: 0,
|
||||
_hovering: false,
|
||||
_dragging: false,
|
||||
_keying: false,
|
||||
_fillWidth: 0,
|
||||
_pointerWidth: 0,
|
||||
_stepSize: 0.01,
|
||||
});
|
||||
|
||||
_setRootElement(element: HTMLElement | null): void {
|
||||
this.setState({ _rootElement: element });
|
||||
|
||||
if (!element) {
|
||||
this.#abortController?.abort();
|
||||
this.#abortController = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.#abortController = new AbortController();
|
||||
const { signal } = this.#abortController;
|
||||
|
||||
element.addEventListener('pointerdown', this, { signal });
|
||||
element.addEventListener('pointermove', this, { signal });
|
||||
element.addEventListener('pointerup', this, { signal });
|
||||
element.addEventListener('pointerenter', this, { signal });
|
||||
element.addEventListener('pointerleave', this, { signal });
|
||||
element.addEventListener('keydown', this, { signal });
|
||||
element.addEventListener('keyup', this, { signal });
|
||||
}
|
||||
|
||||
subscribe(callback: (state: SliderState) => void): () => void {
|
||||
return this.#state.subscribe(callback);
|
||||
}
|
||||
|
||||
setState(state: Partial<SliderState>): void {
|
||||
if (shallowEqual(state, this.#state.get())) return;
|
||||
this.#state.set({ ...this.#state.get(), ...state });
|
||||
}
|
||||
|
||||
getState(): SliderState {
|
||||
const state = this.#state.get();
|
||||
|
||||
let _pointerWidth = 0;
|
||||
if (state._hovering) {
|
||||
_pointerWidth = state._pointerRatio;
|
||||
}
|
||||
|
||||
return { ...state, _pointerWidth };
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
const { type } = event;
|
||||
switch (type) {
|
||||
case 'pointerdown':
|
||||
this.#handlePointerDown(event as PointerEvent);
|
||||
break;
|
||||
case 'pointermove':
|
||||
this.#handlePointerMove(event as PointerEvent);
|
||||
break;
|
||||
case 'pointerup':
|
||||
this.#handlePointerUp(event as PointerEvent);
|
||||
break;
|
||||
case 'pointerenter':
|
||||
this.#handlePointerEnter(event as PointerEvent);
|
||||
break;
|
||||
case 'pointerleave':
|
||||
this.#handlePointerLeave(event as PointerEvent);
|
||||
break;
|
||||
case 'keydown':
|
||||
this.#handleKeyDown(event as KeyboardEvent);
|
||||
break;
|
||||
case 'keyup':
|
||||
this.#handleKeyUp(event as KeyboardEvent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getPointerRatio(evt: PointerEvent): number {
|
||||
const { _trackElement } = this.#state.get();
|
||||
if (!_trackElement) return 0;
|
||||
|
||||
const rect = _trackElement.getBoundingClientRect();
|
||||
return getPointProgressOnLine(
|
||||
evt.clientX,
|
||||
evt.clientY,
|
||||
{ x: rect.left, y: rect.bottom },
|
||||
{ x: rect.right, y: rect.top },
|
||||
);
|
||||
}
|
||||
|
||||
#handlePointerDown(event: PointerEvent) {
|
||||
this.#state.get()._rootElement?.setPointerCapture(event.pointerId);
|
||||
this.setState({ _pointerRatio: this.getPointerRatio(event), _dragging: true });
|
||||
}
|
||||
|
||||
#handlePointerMove(event: PointerEvent) {
|
||||
this.setState({ _pointerRatio: this.getPointerRatio(event) });
|
||||
}
|
||||
|
||||
#handlePointerUp(event: PointerEvent) {
|
||||
this.setState({ _pointerRatio: this.getPointerRatio(event), _dragging: false });
|
||||
this.#state.get()._rootElement?.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
#handlePointerEnter(_event: PointerEvent) {
|
||||
this.setState({ _hovering: true });
|
||||
}
|
||||
|
||||
#handlePointerLeave(_event: PointerEvent) {
|
||||
this.setState({ _hovering: false });
|
||||
}
|
||||
|
||||
#handleKeyDown(event: KeyboardEvent) {
|
||||
const { key } = event;
|
||||
const { _pointerRatio, _stepSize } = this.#state.get();
|
||||
|
||||
let newRatio = _pointerRatio;
|
||||
|
||||
switch (key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
newRatio = Math.max(0, _pointerRatio - _stepSize);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
newRatio = Math.min(1, _pointerRatio + _stepSize);
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
newRatio = 0;
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
newRatio = 1;
|
||||
break;
|
||||
default:
|
||||
return; // Don't update state for other keys
|
||||
}
|
||||
|
||||
this.setState({ _pointerRatio: newRatio, _keying: true });
|
||||
}
|
||||
|
||||
#handleKeyUp(_event: KeyboardEvent) {
|
||||
this.setState({ _keying: false });
|
||||
}
|
||||
|
||||
setStepSize(stepSize: number): void {
|
||||
this.setState({ _stepSize: Math.max(0.001, Math.min(1, stepSize)) });
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { SliderState } from './slider';
|
||||
|
||||
import { formatTime } from '@videojs/utils';
|
||||
import { Slider } from './slider';
|
||||
|
||||
export interface TimeSliderState extends SliderState {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
requestSeek: (time: number) => void;
|
||||
requestPreview: (time: number) => void;
|
||||
_currentTimeText: string;
|
||||
_durationText: string;
|
||||
}
|
||||
|
||||
export class TimeSlider extends Slider {
|
||||
#seekingTime: number | null = null;
|
||||
#oldCurrentTime: number | null = null;
|
||||
|
||||
getState(): TimeSliderState {
|
||||
const state = super.getState() as TimeSliderState;
|
||||
|
||||
// When dragging or keying, use pointer position for immediate feedback;
|
||||
// While seeking, use seeking time so it doesn't jump back to the current time;
|
||||
// Otherwise, use current time;
|
||||
let _fillWidth = 0;
|
||||
if (state._dragging || state._keying) {
|
||||
_fillWidth = state._pointerRatio * 100;
|
||||
} else if (state.duration > 0) {
|
||||
if (this.#seekingTime !== null && this.#oldCurrentTime === state.currentTime) {
|
||||
_fillWidth = (this.#seekingTime / state.duration) * 100;
|
||||
} else {
|
||||
_fillWidth = (state.currentTime / state.duration) * 100;
|
||||
this.#seekingTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.#oldCurrentTime = state.currentTime;
|
||||
|
||||
const _currentTimeText = formatTime(state.currentTime);
|
||||
const _durationText = formatTime(state.duration);
|
||||
|
||||
return { ...state, _fillWidth, _currentTimeText, _durationText };
|
||||
}
|
||||
|
||||
setState(newState: Partial<TimeSliderState>): void {
|
||||
const state = this.getState();
|
||||
// When not dragging or keying, set pointer ratio to current time / duration.
|
||||
if (!state._hovering && !state._dragging && !state._keying && newState.currentTime && newState.duration) {
|
||||
super.setState({ ...newState, _pointerRatio: newState.currentTime / newState.duration });
|
||||
return;
|
||||
}
|
||||
super.setState(newState);
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
const { type } = event;
|
||||
switch (type) {
|
||||
case 'pointerdown':
|
||||
this.#handlePointerDown(event as PointerEvent);
|
||||
break;
|
||||
case 'pointermove':
|
||||
this.#handlePointerMove(event as PointerEvent);
|
||||
break;
|
||||
case 'pointerup':
|
||||
this.#handlePointerUp(event as PointerEvent);
|
||||
break;
|
||||
case 'keydown':
|
||||
this.#handleKeyDown(event as KeyboardEvent);
|
||||
break;
|
||||
default:
|
||||
super.handleEvent(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#handlePointerDown(event: PointerEvent) {
|
||||
super.handleEvent(event);
|
||||
|
||||
const { _pointerRatio, duration, requestSeek } = super.getState() as TimeSliderState;
|
||||
|
||||
this.#seekingTime = _pointerRatio * duration;
|
||||
requestSeek(this.#seekingTime);
|
||||
}
|
||||
|
||||
#handlePointerMove(event: PointerEvent) {
|
||||
super.handleEvent(event);
|
||||
|
||||
const { _dragging, _pointerRatio, duration, requestSeek, requestPreview } = super.getState() as TimeSliderState;
|
||||
|
||||
const previewTime = _pointerRatio * duration;
|
||||
requestPreview(previewTime);
|
||||
|
||||
if (_dragging) {
|
||||
this.#seekingTime = previewTime;
|
||||
requestSeek(this.#seekingTime);
|
||||
}
|
||||
}
|
||||
|
||||
#handlePointerUp(event: PointerEvent) {
|
||||
const { _dragging, _pointerRatio, duration, requestSeek } = super.getState() as TimeSliderState;
|
||||
|
||||
if (_dragging) {
|
||||
this.#seekingTime = _pointerRatio * duration;
|
||||
requestSeek(this.#seekingTime);
|
||||
}
|
||||
|
||||
super.handleEvent(event);
|
||||
}
|
||||
|
||||
#handleKeyDown(event: KeyboardEvent) {
|
||||
super.handleEvent(event);
|
||||
|
||||
const { _pointerRatio, duration, requestSeek } = super.getState() as TimeSliderState;
|
||||
|
||||
this.#seekingTime = _pointerRatio * duration;
|
||||
requestSeek(this.#seekingTime);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { PopoverState } from './popover';
|
||||
import { Popover } from './popover';
|
||||
|
||||
export interface TooltipState extends PopoverState {}
|
||||
|
||||
export class Tooltip extends Popover {
|
||||
constructor() {
|
||||
super();
|
||||
this.setState({
|
||||
openOnHover: true,
|
||||
disableHoverablePopover: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { SliderState } from './slider';
|
||||
|
||||
import { Slider } from './slider';
|
||||
|
||||
export interface VolumeSliderState extends SliderState {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
volumeLevel: string;
|
||||
requestVolumeChange: (volume: number) => void;
|
||||
_volumeText: string;
|
||||
}
|
||||
|
||||
export class VolumeSlider extends Slider {
|
||||
constructor() {
|
||||
super();
|
||||
this.setStepSize(0.1);
|
||||
}
|
||||
|
||||
getState(): VolumeSliderState {
|
||||
const state = super.getState() as VolumeSliderState;
|
||||
|
||||
// When dragging or keying, use pointer position for immediate feedback;
|
||||
// Otherwise, use current volume;
|
||||
let _fillWidth = 0;
|
||||
if (state._dragging || state._keying) {
|
||||
_fillWidth = state._pointerRatio * 100;
|
||||
} else {
|
||||
_fillWidth = state.muted ? 0 : (state.volume || 0) * 100;
|
||||
}
|
||||
|
||||
const _volumeText = formatVolume(state.muted ? 0 : state.volume || 0);
|
||||
|
||||
return { ...state, _fillWidth, _volumeText };
|
||||
}
|
||||
|
||||
setState(newState: Partial<VolumeSliderState>): void {
|
||||
const state = this.getState();
|
||||
// When not dragging or keying, set pointer ratio to current volume.
|
||||
if (!state._dragging && !state._keying && newState.volume) {
|
||||
super.setState({ ...newState, _pointerRatio: newState.volume });
|
||||
return;
|
||||
}
|
||||
super.setState(newState);
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
const { type } = event;
|
||||
switch (type) {
|
||||
case 'pointerdown':
|
||||
this.#handlePointerDown(event as PointerEvent);
|
||||
break;
|
||||
case 'pointermove':
|
||||
this.#handlePointerMove(event as PointerEvent);
|
||||
break;
|
||||
case 'pointerup':
|
||||
this.#handlePointerUp(event as PointerEvent);
|
||||
break;
|
||||
case 'keydown':
|
||||
this.#handleKeyDown(event as KeyboardEvent);
|
||||
break;
|
||||
default:
|
||||
super.handleEvent(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#handlePointerDown(event: PointerEvent) {
|
||||
super.handleEvent(event);
|
||||
|
||||
const { _pointerRatio, requestVolumeChange } = super.getState() as VolumeSliderState;
|
||||
requestVolumeChange(_pointerRatio);
|
||||
}
|
||||
|
||||
#handlePointerMove(event: PointerEvent) {
|
||||
super.handleEvent(event);
|
||||
|
||||
const { _dragging, _pointerRatio, requestVolumeChange } = super.getState() as VolumeSliderState;
|
||||
|
||||
if (_dragging) {
|
||||
requestVolumeChange(_pointerRatio);
|
||||
}
|
||||
}
|
||||
|
||||
#handlePointerUp(event: PointerEvent) {
|
||||
const { _dragging, _pointerRatio, requestVolumeChange } = super.getState() as VolumeSliderState;
|
||||
|
||||
if (_dragging) {
|
||||
requestVolumeChange(_pointerRatio);
|
||||
}
|
||||
|
||||
super.handleEvent(event);
|
||||
}
|
||||
|
||||
#handleKeyDown(event: KeyboardEvent) {
|
||||
super.handleEvent(event);
|
||||
|
||||
const { _pointerRatio, requestVolumeChange } = super.getState() as VolumeSliderState;
|
||||
requestVolumeChange(_pointerRatio);
|
||||
}
|
||||
}
|
||||
|
||||
function formatVolume(volume: number): string {
|
||||
return `${Math.round(volume * 100)}%`;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { Popover, type PopoverState } from './components/popover';
|
||||
export { TimeSlider, type TimeSliderState } from './components/time-slider';
|
||||
export { Tooltip, type TooltipState } from './components/tooltip';
|
||||
export { VolumeSlider, type VolumeSliderState } from './components/volume-slider';
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import Hls from 'hls.js';
|
||||
|
||||
/** @TODO Move interface/type defs (CJP) */
|
||||
/** @TODO Narrow and/or parameterize EventTarget via generics/inference (CJP) */
|
||||
export interface PlaybackEngine extends EventTarget {
|
||||
src: HTMLMediaElement['src'] | undefined;
|
||||
// NOTE: Unlike other APIs, this *must* be an HTMLMediaElement based on internal and/or external API assumptions (CJP)
|
||||
mediaElement: HTMLMediaElement | undefined;
|
||||
/** @TODO Widen/extend and/or parameterize EventTarget via generics/inference (CJP) */
|
||||
element: HTMLElement | undefined;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export class HlsPlaybackEngine extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
protected _hlsInstance: Hls | undefined;
|
||||
|
||||
get src(): string | undefined {
|
||||
return this._hlsInstance?.url ?? undefined;
|
||||
}
|
||||
|
||||
set src(src: string | undefined) {
|
||||
if (this.src === src) return;
|
||||
|
||||
if (!this._hlsInstance) {
|
||||
this._createHlsInstance();
|
||||
}
|
||||
|
||||
if (!this.src && src) {
|
||||
this._hlsInstance?.loadSource(src);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Some playback engines may not rely on an underlying HTMLMediaElement but still have
|
||||
// an HTMLElement (e.g. HTMLCanvasElement, HTMLImageElement, etc.). This API provides a more
|
||||
// generic interface for cases where we want more minimal assumptions (e.g. fullscreen behaviors for most playtforms but not iOS)
|
||||
get element(): HTMLElement | undefined {
|
||||
return this.mediaElement;
|
||||
}
|
||||
|
||||
set element(val) {
|
||||
if (this.element === val) return;
|
||||
/** @TODO programmatic type checking? (CJP) */
|
||||
this.mediaElement = val as HTMLMediaElement;
|
||||
}
|
||||
|
||||
get mediaElement(): HTMLMediaElement | undefined {
|
||||
return this._hlsInstance?.media ?? undefined;
|
||||
}
|
||||
|
||||
set mediaElement(val) {
|
||||
if (this.mediaElement === val) return;
|
||||
|
||||
if (!this._hlsInstance) {
|
||||
this._createHlsInstance();
|
||||
}
|
||||
|
||||
if (!this.mediaElement && val) {
|
||||
this._hlsInstance?.attachMedia(val);
|
||||
}
|
||||
}
|
||||
|
||||
protected _createHlsInstance(): void {
|
||||
this._hlsInstance = new Hls();
|
||||
}
|
||||
|
||||
protected _destroyHlsInstance(): void {
|
||||
this._hlsInstance?.destroy();
|
||||
this._hlsInstance = undefined;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this._destroyHlsInstance();
|
||||
}
|
||||
}
|
||||
|
||||
export const createHlsPlaybackEngine = (): PlaybackEngine => new HlsPlaybackEngine();
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { PlaybackEngine as IBasePlaybackEngine } from './hls-engine';
|
||||
export { createHlsPlaybackEngine, HlsPlaybackEngine } from './hls-engine';
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './engine';
|
||||
export type { AudibleMediaStateOwner, MediaStateOwner, PlayableMediaStateOwner } from './playable';
|
||||
export { createMediaPlaybackController, Events, MediaPlaybackController } from './playable';
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { PlaybackEngine } from '@/media/engine/hls-engine';
|
||||
import { createHlsPlaybackEngine } from '@/media/engine/hls-engine';
|
||||
|
||||
/** @TODO Split out "playable" vs. "audible" vs. "temporal" and compose via factory (current mixin pattern or spreadable mixin pattern) (CJP) */
|
||||
export const Events = [
|
||||
'volumechange',
|
||||
'pause',
|
||||
'play',
|
||||
'playing',
|
||||
'emptied',
|
||||
'loadedmetadata',
|
||||
'timeupdate',
|
||||
'durationchange',
|
||||
] as const;
|
||||
|
||||
export interface MediaStateOwner extends EventTarget, Pick<HTMLMediaElement, 'src'> {
|
||||
mediaElement?: HTMLMediaElement | undefined;
|
||||
}
|
||||
|
||||
export interface PlayableMediaStateOwner
|
||||
extends EventTarget,
|
||||
MediaStateOwner,
|
||||
Pick<HTMLMediaElement, 'play' | 'pause' | 'paused'> {}
|
||||
|
||||
export interface AudibleMediaStateOwner
|
||||
extends EventTarget,
|
||||
MediaStateOwner,
|
||||
Pick<HTMLMediaElement, 'muted' | 'volume'> {}
|
||||
|
||||
export interface TemporalMediaStateOwner
|
||||
extends EventTarget,
|
||||
MediaStateOwner,
|
||||
Pick<HTMLMediaElement, 'duration' | 'currentTime'> {}
|
||||
|
||||
export class MediaPlaybackController extends EventTarget implements MediaStateOwner, PlayableMediaStateOwner, AudibleMediaStateOwner {
|
||||
protected _playbackEngine: PlaybackEngine;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._playbackEngine = createHlsPlaybackEngine();
|
||||
}
|
||||
|
||||
get mediaElement(): HTMLMediaElement | undefined {
|
||||
return this._playbackEngine.mediaElement;
|
||||
}
|
||||
|
||||
set mediaElement(value) {
|
||||
if (this.mediaElement === value) return;
|
||||
|
||||
if (this.mediaElement != null) {
|
||||
Events.forEach((eventType) => {
|
||||
this.mediaElement?.removeEventListener(eventType, this);
|
||||
});
|
||||
}
|
||||
|
||||
this._playbackEngine.mediaElement = value;
|
||||
|
||||
if (this.mediaElement) {
|
||||
Events.forEach((eventType) => {
|
||||
this.mediaElement?.addEventListener(eventType, this);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get paused(): boolean {
|
||||
return this.mediaElement?.paused ?? false;
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
/** @TODO implement deferred state etc. for cases where media has yet to be set */
|
||||
if (!this.mediaElement) return;
|
||||
return this.mediaElement.pause();
|
||||
}
|
||||
|
||||
play(): Promise<void> {
|
||||
/** @TODO implement deferred state etc. for cases where media has yet to be set */
|
||||
if (!this.mediaElement) throw new Error('No media element is set');
|
||||
return this.mediaElement.play();
|
||||
}
|
||||
|
||||
get muted(): boolean {
|
||||
return this.mediaElement?.muted ?? false;
|
||||
}
|
||||
|
||||
set muted(value: boolean) {
|
||||
if (value === this.muted) return;
|
||||
/** @TODO implement deferred state etc. for cases where media has yet to be set */
|
||||
if (!this.mediaElement) return;
|
||||
this.mediaElement.muted = value;
|
||||
}
|
||||
|
||||
get volume(): number {
|
||||
return this.mediaElement?.volume ?? 0;
|
||||
}
|
||||
|
||||
set volume(value: number) {
|
||||
if (value === this.volume) return;
|
||||
/** @TODO implement deferred state etc. for cases where media has yet to be set */
|
||||
if (!this.mediaElement) return;
|
||||
this.mediaElement.volume = value;
|
||||
}
|
||||
|
||||
get duration(): number {
|
||||
return this.mediaElement?.duration ?? 0;
|
||||
}
|
||||
|
||||
get currentTime(): number {
|
||||
return this.mediaElement?.currentTime ?? 0;
|
||||
}
|
||||
|
||||
set currentTime(value: number) {
|
||||
if (value === this.currentTime) return;
|
||||
/** @TODO implement deferred state etc. for cases where media has yet to be set */
|
||||
if (!this.mediaElement) return;
|
||||
this.mediaElement.currentTime = value;
|
||||
}
|
||||
|
||||
get src(): string {
|
||||
return this._playbackEngine.src ?? '';
|
||||
}
|
||||
|
||||
set src(value: string) {
|
||||
this._playbackEngine.src = value;
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
if (event.target === this.mediaElement) {
|
||||
const clonedEvent = new (event.constructor as (typeof globalThis)['Event'])(event.type, event);
|
||||
this.dispatchEvent(clonedEvent);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this._playbackEngine.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export const createMediaPlaybackController = (): MediaPlaybackController => new MediaPlaybackController();
|
||||
@@ -1,27 +0,0 @@
|
||||
export interface CurrentTimeDisplayState {
|
||||
/** The current time value in seconds */
|
||||
currentTime: number | undefined;
|
||||
/** The total duration in seconds (for future functionality) */
|
||||
duration: number | undefined;
|
||||
}
|
||||
|
||||
export const currentTimeDisplayStateDefinition = {
|
||||
keys: ['currentTime', 'duration'] as const,
|
||||
stateTransform: (rawState: Record<string, any>): CurrentTimeDisplayState => {
|
||||
const { currentTime, duration } = rawState;
|
||||
|
||||
return {
|
||||
currentTime,
|
||||
duration,
|
||||
};
|
||||
},
|
||||
createRequestMethods: (_dispatch: (action: { type: string; detail?: any }) => void) => ({}),
|
||||
};
|
||||
|
||||
export type CurrentTimeDisplayStateDefinition = typeof currentTimeDisplayStateDefinition;
|
||||
|
||||
export type CurrentTimeDisplayComponentState = ReturnType<typeof currentTimeDisplayStateDefinition.stateTransform>;
|
||||
|
||||
export type CurrentTimeDisplayRequestMethods = ReturnType<
|
||||
typeof currentTimeDisplayStateDefinition.createRequestMethods
|
||||
>;
|
||||
@@ -1,24 +0,0 @@
|
||||
|
||||
export interface DurationDisplayState {
|
||||
/** The raw duration value in seconds */
|
||||
duration: number | undefined;
|
||||
}
|
||||
|
||||
|
||||
export const durationDisplayStateDefinition = {
|
||||
keys: ['duration'] as const,
|
||||
stateTransform: (rawState: Record<string, any>): DurationDisplayState => {
|
||||
const { duration } = rawState;
|
||||
|
||||
return {
|
||||
duration,
|
||||
};
|
||||
},
|
||||
createRequestMethods: (_dispatch: (action: { type: string; detail?: any }) => void) => ({}),
|
||||
};
|
||||
|
||||
export type DurationDisplayStateDefinition = typeof durationDisplayStateDefinition;
|
||||
|
||||
export type DurationDisplayComponentState = ReturnType<typeof durationDisplayStateDefinition.stateTransform>;
|
||||
|
||||
export type DurationDisplayRequestMethods = ReturnType<typeof durationDisplayStateDefinition.createRequestMethods>;
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface FullscreenButtonState {
|
||||
fullscreen: boolean;
|
||||
}
|
||||
|
||||
export interface FullscreenButtonMethods {
|
||||
requestEnterFullscreen: () => void;
|
||||
requestExitFullscreen: () => void;
|
||||
}
|
||||
|
||||
export interface FullscreenButtonStateDefinition {
|
||||
keys: string[];
|
||||
stateTransform: (rawState: any) => FullscreenButtonState;
|
||||
createRequestMethods: (dispatch: (action: { type: string; detail?: any }) => void) => FullscreenButtonMethods;
|
||||
}
|
||||
|
||||
export const fullscreenButtonStateDefinition: FullscreenButtonStateDefinition = {
|
||||
keys: ['fullscreen'],
|
||||
stateTransform: (rawState: any): FullscreenButtonState => ({
|
||||
fullscreen: rawState.fullscreen ?? false,
|
||||
}),
|
||||
createRequestMethods: (dispatch): FullscreenButtonMethods => ({
|
||||
requestEnterFullscreen: () => dispatch({ type: 'fullscreenrequest', detail: true }),
|
||||
requestExitFullscreen: () => dispatch({ type: 'fullscreenrequest', detail: false }),
|
||||
}),
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
export interface MuteButtonState {
|
||||
muted: boolean;
|
||||
volumeLevel: string;
|
||||
}
|
||||
|
||||
export interface MuteButtonMethods {
|
||||
requestMute: () => void;
|
||||
requestUnmute: () => void;
|
||||
}
|
||||
|
||||
export interface MuteButtonStateDefinition {
|
||||
keys: string[];
|
||||
stateTransform: (rawState: any) => MuteButtonState;
|
||||
createRequestMethods: (dispatch: (action: { type: string }) => void) => MuteButtonMethods;
|
||||
}
|
||||
|
||||
export const muteButtonStateDefinition: MuteButtonStateDefinition = {
|
||||
keys: ['muted', 'volumeLevel'],
|
||||
|
||||
stateTransform: (rawState: any): MuteButtonState => ({
|
||||
muted: rawState.muted ?? false,
|
||||
volumeLevel: rawState.volumeLevel ?? 'off',
|
||||
}),
|
||||
|
||||
createRequestMethods: (dispatch): MuteButtonMethods => ({
|
||||
requestMute: () => dispatch({ type: 'muterequest' }),
|
||||
requestUnmute: () => dispatch({ type: 'unmuterequest' }),
|
||||
}),
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
export interface PlayButtonState {
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
export interface PlayButtonMethods {
|
||||
requestPlay: () => void;
|
||||
requestPause: () => void;
|
||||
}
|
||||
|
||||
export interface PlayButtonStateDefinition {
|
||||
keys: string[];
|
||||
stateTransform: (rawState: any) => PlayButtonState;
|
||||
createRequestMethods: (dispatch: (action: { type: string; detail?: unknown }) => void) => PlayButtonMethods;
|
||||
}
|
||||
|
||||
export const playButtonStateDefinition: PlayButtonStateDefinition = {
|
||||
keys: ['paused'],
|
||||
|
||||
stateTransform: (rawState: any): PlayButtonState => ({
|
||||
paused: rawState.paused ?? true,
|
||||
}),
|
||||
|
||||
createRequestMethods: (dispatch): PlayButtonMethods => ({
|
||||
requestPlay: () => dispatch({ type: 'playrequest' }),
|
||||
requestPause: () => dispatch({ type: 'pauserequest' }),
|
||||
}),
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
export interface PreviewTimeDisplayState {
|
||||
/** The preview time value in seconds */
|
||||
previewTime: number | undefined;
|
||||
}
|
||||
|
||||
export interface PreviewTimeDisplayStateDefinition {
|
||||
keys: (keyof PreviewTimeDisplayState)[];
|
||||
stateTransform: (rawState: any) => PreviewTimeDisplayState;
|
||||
}
|
||||
|
||||
export const previewTimeDisplayStateDefinition: PreviewTimeDisplayStateDefinition = {
|
||||
keys: ['previewTime'],
|
||||
stateTransform: (rawState: any) => ({
|
||||
previewTime: rawState.previewTime ?? 0,
|
||||
}),
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
export interface TimeSliderState {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
previewTime: number;
|
||||
}
|
||||
|
||||
export interface TimeSliderMethods {
|
||||
requestSeek: (time: number) => void;
|
||||
requestPreview: (time: number) => void;
|
||||
}
|
||||
|
||||
export interface TimeSliderStateDefinition {
|
||||
keys: string[];
|
||||
stateTransform: (rawState: any) => TimeSliderState;
|
||||
createRequestMethods: (dispatch: (action: { type: string; detail?: any }) => void) => TimeSliderMethods;
|
||||
}
|
||||
|
||||
export const timeSliderStateDefinition: TimeSliderStateDefinition = {
|
||||
keys: ['currentTime', 'duration', 'previewTime'],
|
||||
stateTransform: (rawState: any) => ({
|
||||
currentTime: rawState.currentTime ?? 0,
|
||||
duration: rawState.duration ?? 0,
|
||||
previewTime: rawState.previewTime ?? 0,
|
||||
}),
|
||||
createRequestMethods: dispatch => ({
|
||||
requestSeek: (time: number) => {
|
||||
dispatch({ type: 'seekrequest', detail: time });
|
||||
},
|
||||
requestPreview: (time: number) => {
|
||||
dispatch({ type: 'previewrequest', detail: time });
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
export interface VolumeSliderState {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
volumeLevel: 'high' | 'medium' | 'low' | 'off';
|
||||
}
|
||||
|
||||
export interface VolumeSliderMethods {
|
||||
requestVolumeChange: (volume: number) => void;
|
||||
}
|
||||
|
||||
export interface VolumeSliderStateDefinition {
|
||||
keys: string[];
|
||||
stateTransform: (rawState: any) => VolumeSliderState;
|
||||
createRequestMethods: (dispatch: (action: { type: string; detail?: any }) => void) => VolumeSliderMethods;
|
||||
}
|
||||
|
||||
export const volumeSliderStateDefinition: VolumeSliderStateDefinition = {
|
||||
keys: ['volume', 'muted', 'volumeLevel'],
|
||||
stateTransform: (rawState: any) => ({
|
||||
volume: rawState.volume ?? 1,
|
||||
muted: rawState.muted ?? false,
|
||||
volumeLevel: rawState.volumeLevel ?? 'high',
|
||||
}),
|
||||
createRequestMethods: dispatch => ({
|
||||
/**
|
||||
* @TODO Unmuting is owned by the "request-map" in media-chrome.
|
||||
* The closest equivalent to that is the "actions" in the current architecture.
|
||||
* Should unmuting live here (even if "here" gets promoted to the state model) or "actions" or state setter?
|
||||
* Currently this is solved in the state setter (as is the corresponding unmute behavior). See mediators/audible for details. (CJP)
|
||||
*/
|
||||
requestVolumeChange: (volume: number) => {
|
||||
// if (volume > 0) {
|
||||
// dispatch({ type: 'unmuterequest' });
|
||||
// }
|
||||
dispatch({ type: 'volumerequest', detail: volume });
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -1,168 +0,0 @@
|
||||
import type { StateOwners } from './types';
|
||||
|
||||
import { getKey, map, subscribeKeys } from 'nanostores';
|
||||
|
||||
export interface EventOrAction<D = undefined> {
|
||||
type: string;
|
||||
detail?: D;
|
||||
target?: EventTarget;
|
||||
}
|
||||
|
||||
export type FacadeGetter<T, D = T> = (stateOwners: StateOwners, event?: EventOrAction<D>) => T;
|
||||
|
||||
export type FacadeSetter<T> = (value: T, stateOwners: StateOwners) => void;
|
||||
|
||||
export type StateOwnerUpdateHandler<T> = (
|
||||
handler: (value?: T) => void,
|
||||
stateOwners: StateOwners
|
||||
) => (() => void) | void;
|
||||
|
||||
export interface ReadonlyFacadeProp<T, D = T> {
|
||||
get: FacadeGetter<T, D>;
|
||||
stateOwnersUpdateHandlers?: StateOwnerUpdateHandler<T>[];
|
||||
}
|
||||
|
||||
export type FacadeProp<T, S = T, D = T> = ReadonlyFacadeProp<T, D> & {
|
||||
set: FacadeSetter<S>;
|
||||
/** @TODO We probably need to refactor this for more complex cases where we can't simply translate to a setter */
|
||||
actions: {
|
||||
[k: string]: (val: { type: string; detail?: unknown }) => ReturnType<FacadeGetter<T, D>>;
|
||||
};
|
||||
};
|
||||
|
||||
export interface StateMediator {
|
||||
paused: FacadeProp<HTMLMediaElement['paused']>;
|
||||
muted: FacadeProp<HTMLMediaElement['muted']>;
|
||||
volume: FacadeProp<HTMLMediaElement['volume']>;
|
||||
volumeLevel: ReadonlyFacadeProp<'high' | 'medium' | 'low' | 'off'>;
|
||||
currentTime: FacadeProp<HTMLMediaElement['currentTime']>;
|
||||
duration: ReadonlyFacadeProp<HTMLMediaElement['duration']>;
|
||||
seekable: ReadonlyFacadeProp<[number, number] | undefined>;
|
||||
previewTime: FacadeProp<number>;
|
||||
fullscreen: FacadeProp<boolean>;
|
||||
}
|
||||
|
||||
export interface MediaStore {
|
||||
dispatch: (action: { type: string; detail?: unknown }) => void;
|
||||
getState: () => any;
|
||||
getKeys: (keys: string[]) => Record<string, any>;
|
||||
subscribeKeys: (keys: string[], callback: (state: any) => void) => void;
|
||||
subscribe: (callback: (state: any) => void) => void;
|
||||
}
|
||||
|
||||
export function createMediaStore({
|
||||
// media,
|
||||
stateMediator,
|
||||
}: {
|
||||
media?: any;
|
||||
container?: any;
|
||||
stateMediator: Partial<StateMediator>;
|
||||
}): MediaStore {
|
||||
const stateOwners: StateOwners = {};
|
||||
const store = map<any>({});
|
||||
const stateUpdateHandlerCleanups: Record<string, (() => void)[]> = {};
|
||||
const keys = Object.keys(stateMediator);
|
||||
|
||||
function updateStateOwners(nextStateOwners: any) {
|
||||
// Check if any state owner has changed
|
||||
const hasChanges = Object.entries(nextStateOwners).some(
|
||||
([key, value]) => stateOwners[key as keyof StateOwners] !== value,
|
||||
);
|
||||
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up existing handlers
|
||||
Object.entries(stateUpdateHandlerCleanups).forEach(([stateName, cleanups]) => {
|
||||
cleanups.forEach(cleanup => cleanup?.());
|
||||
stateUpdateHandlerCleanups[stateName] = [];
|
||||
});
|
||||
|
||||
Object.assign(stateOwners, nextStateOwners);
|
||||
store.set(getInitialState(stateMediator, stateOwners));
|
||||
|
||||
// Set up new handlers
|
||||
Object.entries(stateMediator).forEach(([stateName, stateObject]) => {
|
||||
const { get, stateOwnersUpdateHandlers = [] } = stateObject;
|
||||
|
||||
if (!stateUpdateHandlerCleanups[stateName]) {
|
||||
stateUpdateHandlerCleanups[stateName] = [];
|
||||
}
|
||||
|
||||
// Create handler that updates the store
|
||||
const updateHandler = (value?: any) => {
|
||||
const nextValue = value !== undefined ? value : get(stateOwners);
|
||||
store.setKey(stateName, nextValue);
|
||||
};
|
||||
|
||||
// Execute each stateOwnersUpdateHandler
|
||||
stateOwnersUpdateHandlers.forEach((setupHandler) => {
|
||||
const cleanup = setupHandler(updateHandler, stateOwners);
|
||||
if (typeof cleanup === 'function') {
|
||||
stateUpdateHandlerCleanups[stateName]?.push(cleanup);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
dispatch(action: Pick<CustomEvent<any>, 'type' | 'detail'>): void {
|
||||
const { type, detail } = action;
|
||||
|
||||
if (type === 'mediastateownerchangerequest') {
|
||||
updateStateOwners({ media: detail });
|
||||
} else if (type === 'containerstateownerchangerequest') {
|
||||
updateStateOwners({ container: detail });
|
||||
} else {
|
||||
Object.entries(stateMediator).forEach(([stateName, stateObject]) => {
|
||||
const { set, actions } = stateObject as FacadeProp<any, any, any>;
|
||||
|
||||
if (actions?.[type]) {
|
||||
const actionFn = actions[type];
|
||||
const actionValue = actionFn(action);
|
||||
if (set) {
|
||||
(set as FacadeSetter<any>)(actionValue, stateOwners);
|
||||
} else {
|
||||
// If there is no setter, store the action value in the store.
|
||||
// Might need revisiting.
|
||||
store.setKey(stateName, actionValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getState(): any {
|
||||
return store.get();
|
||||
},
|
||||
|
||||
getKeys(keys: string[]): Record<string, any> {
|
||||
return keys.reduce(
|
||||
(acc, k) => {
|
||||
acc[k] = getKey(store, k);
|
||||
return acc;
|
||||
},
|
||||
{} as { [k: string]: any },
|
||||
);
|
||||
},
|
||||
|
||||
subscribeKeys(keys: string[], callback: (state: any) => void): void {
|
||||
subscribeKeys(store, keys, callback);
|
||||
},
|
||||
|
||||
// NOTE: In the POC architecture using nano-stores, subscribe is simply subscribeKeys across all keys. (CJP)
|
||||
subscribe(callback: (state: any) => void): void {
|
||||
subscribeKeys(store, keys, callback);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getInitialState(stateMediator: Partial<StateMediator>, stateOwners: any) {
|
||||
const initialState: any = {};
|
||||
for (const [stateName, { get }] of Object.entries(stateMediator)) {
|
||||
if (!get) continue;
|
||||
initialState[stateName] = get(stateOwners);
|
||||
}
|
||||
return initialState;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export * from './definitions/current-time-display';
|
||||
export * from './definitions/duration-display';
|
||||
export * from './definitions/fullscreen-button';
|
||||
export * from './definitions/mute-button';
|
||||
export * from './definitions/play-button';
|
||||
export * from './definitions/preview-time-display';
|
||||
export * from './definitions/time-slider';
|
||||
export * from './definitions/volume-slider';
|
||||
|
||||
export * from './factory';
|
||||
export { createMediaStore } from './media-store';
|
||||
|
||||
export * from './mediators/audible';
|
||||
export * from './mediators/playable';
|
||||
export * from './mediators/temporal';
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { MediaStore } from './factory';
|
||||
|
||||
import { createMediaStore as factory } from './factory';
|
||||
import { audible } from './mediators/audible';
|
||||
import { fullscreenable } from './mediators/fullscreenable';
|
||||
import { playable } from './mediators/playable';
|
||||
import { preview } from './mediators/preview';
|
||||
import { temporal } from './mediators/temporal';
|
||||
|
||||
// Example of default media store with default state mediator definitions. (CJP)
|
||||
// NOTE: We can also change the API to take an array of stateMediators (or either/both) (CJP)
|
||||
const stateMediator = { ...playable, ...audible, ...temporal, ...fullscreenable, ...preview };
|
||||
|
||||
type Params = Partial<Parameters<typeof factory>[0]>;
|
||||
|
||||
export function createMediaStore(params: Params = {}): MediaStore {
|
||||
return factory({
|
||||
stateMediator: stateMediator as any,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { StateOwners } from '../types';
|
||||
|
||||
export const audible = {
|
||||
muted: {
|
||||
get(stateOwners: StateOwners): boolean {
|
||||
const { media } = stateOwners;
|
||||
return media?.muted ?? false;
|
||||
},
|
||||
set(value: boolean, stateOwners: StateOwners): void {
|
||||
const { media } = stateOwners;
|
||||
if (!media) return;
|
||||
media.muted = value;
|
||||
if (!value && !media.volume) {
|
||||
media.volume = 0.25;
|
||||
}
|
||||
},
|
||||
stateOwnersUpdateHandlers: [
|
||||
(handler: (value?: boolean) => void, stateOwners: StateOwners): (() => void) | void => {
|
||||
const { media } = stateOwners;
|
||||
if (!media) return;
|
||||
|
||||
const eventHandler = () => handler();
|
||||
media.addEventListener('volumechange', eventHandler);
|
||||
|
||||
return () => media.removeEventListener('volumechange', eventHandler);
|
||||
},
|
||||
] as const,
|
||||
actions: {
|
||||
/** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API or aren't simple 1:1 with getter vs. setter (CJP) */
|
||||
muterequest: () => true,
|
||||
unmuterequest: () => false,
|
||||
},
|
||||
},
|
||||
volume: {
|
||||
get(stateOwners: StateOwners): number {
|
||||
const { media } = stateOwners;
|
||||
return media?.volume ?? 1.0;
|
||||
},
|
||||
set(value: number, stateOwners: StateOwners): void {
|
||||
const { media } = stateOwners;
|
||||
if (!media) return;
|
||||
const numericValue = +value;
|
||||
if (!Number.isFinite(numericValue)) return;
|
||||
media.volume = numericValue;
|
||||
if (numericValue > 0) {
|
||||
media.muted = false;
|
||||
}
|
||||
},
|
||||
stateOwnersUpdateHandlers: [
|
||||
(handler: (value?: number) => void, stateOwners: StateOwners): (() => void) | void => {
|
||||
const { media } = stateOwners;
|
||||
if (!media) return;
|
||||
|
||||
const eventHandler = () => handler();
|
||||
media.addEventListener('volumechange', eventHandler);
|
||||
|
||||
return () => media.removeEventListener('volumechange', eventHandler);
|
||||
},
|
||||
] as const,
|
||||
actions: {
|
||||
/** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API (CJP) */
|
||||
volumerequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 }): number => +detail,
|
||||
},
|
||||
},
|
||||
// NOTE: This could be (re)implemented as "derived state" in some manner (e.g. selectors but also other patterns/conventions) if preferred. (CJP)
|
||||
volumeLevel: {
|
||||
get(stateOwners: StateOwners): 'high' | 'medium' | 'low' | 'off' {
|
||||
const { media } = stateOwners;
|
||||
if (typeof media?.volume == 'undefined') return 'high';
|
||||
if (media.muted || media.volume === 0) return 'off';
|
||||
if (media.volume < 0.5) return 'low';
|
||||
if (media.volume < 0.75) return 'medium';
|
||||
return 'high';
|
||||
},
|
||||
stateOwnersUpdateHandlers: [
|
||||
(handler: (value?: 'high' | 'medium' | 'low' | 'off') => void, stateOwners: any): void | (() => void) => {
|
||||
const { media } = stateOwners;
|
||||
if (!media) return;
|
||||
|
||||
const eventHandler = () => handler();
|
||||
media.addEventListener('volumechange', eventHandler);
|
||||
|
||||
return () => media.removeEventListener('volumechange', eventHandler);
|
||||
},
|
||||
] as const,
|
||||
},
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
import { containsComposedNode } from '@videojs/utils/dom';
|
||||
|
||||
/** @TODO This is implemented for web/browser only! We will need an alternative state mediator model for e.g. React Native. (CJP) */
|
||||
export const fullscreenable = {
|
||||
fullscreen: {
|
||||
get(stateOwners: any): boolean {
|
||||
const { container } = stateOwners;
|
||||
if (!container || !globalThis?.document) return false;
|
||||
|
||||
const doc = globalThis.document;
|
||||
const currentFullscreenElement
|
||||
= doc.fullscreenElement
|
||||
|| (doc as any).webkitFullscreenElement
|
||||
|| (doc as any).mozFullScreenElement
|
||||
|| (doc as any).msFullscreenElement;
|
||||
|
||||
if (!currentFullscreenElement) return false;
|
||||
|
||||
// If document.fullscreenElement is the container, we're definitely in fullscreen
|
||||
if (currentFullscreenElement === container) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if container is contained within the fullscreen element
|
||||
if (currentFullscreenElement.contains?.(container)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle web components with shadow DOM - traverse shadow DOM layers
|
||||
// In this case (most modern browsers), the fullscreenElement may be
|
||||
// a web component that contains our container within shadow DOM layers
|
||||
if (currentFullscreenElement.localName?.includes('-')) {
|
||||
let currentRoot = currentFullscreenElement.shadowRoot;
|
||||
|
||||
// Check if ShadowRoot supports fullscreenElement (Safari < 16.4 workaround)
|
||||
const fullscreenElementKey
|
||||
= 'fullscreenElement' in doc
|
||||
? 'fullscreenElement'
|
||||
: 'webkitFullscreenElement' in doc
|
||||
? 'webkitFullscreenElement'
|
||||
: undefined;
|
||||
|
||||
if (fullscreenElementKey && !(fullscreenElementKey in (currentRoot || {}))) {
|
||||
// For older Safari versions, use composed node containment check
|
||||
return containsComposedNode(currentFullscreenElement, container);
|
||||
}
|
||||
|
||||
// Traverse shadow DOM layers looking for our container
|
||||
if (fullscreenElementKey) {
|
||||
while (currentRoot?.[fullscreenElementKey]) {
|
||||
if (currentRoot[fullscreenElementKey] === container) return true;
|
||||
if (currentRoot[fullscreenElementKey]?.contains?.(container)) return true;
|
||||
currentRoot = currentRoot[fullscreenElementKey]?.shadowRoot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
set(value: boolean, stateOwners: any): void {
|
||||
const { container, media } = stateOwners;
|
||||
if (!container || !media || !globalThis?.document) return;
|
||||
|
||||
try {
|
||||
if (value) {
|
||||
// Enter fullscreen
|
||||
if (container.requestFullscreen) {
|
||||
container.requestFullscreen();
|
||||
} else if (media._playbackEngine?.element?.webkitEnterFullscreen) {
|
||||
// Safari support (IOS)
|
||||
media._playbackEngine.element.webkitEnterFullscreen();
|
||||
} else if (container.webkitRequestFullscreen) {
|
||||
// Safari support (non IOS)
|
||||
container.webkitRequestFullscreen();
|
||||
} else if (container.mozRequestFullScreen) {
|
||||
// Firefox support
|
||||
container.mozRequestFullScreen();
|
||||
} else if (container.msRequestFullscreen) {
|
||||
// IE/Edge support
|
||||
container.msRequestFullscreen();
|
||||
}
|
||||
} else {
|
||||
// Exit fullscreen
|
||||
const doc = globalThis.document as any;
|
||||
if (doc.exitFullscreen) {
|
||||
doc.exitFullscreen();
|
||||
} else if (doc.webkitExitFullscreen) {
|
||||
// Safari support
|
||||
doc.webkitExitFullscreen();
|
||||
} else if (doc.mozCancelFullScreen) {
|
||||
// Firefox support
|
||||
doc.mozCancelFullScreen();
|
||||
} else if (doc.msExitFullscreen) {
|
||||
// IE/Edge support
|
||||
doc.msExitFullscreen();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Gracefully handle fullscreen API errors (e.g., user interaction required)
|
||||
console.warn('Fullscreen operation failed:', error);
|
||||
}
|
||||
},
|
||||
stateOwnersUpdateHandlers: [
|
||||
(handler: (value?: boolean) => void, _stateOwners: any): void | (() => void) => {
|
||||
if (!globalThis?.document) return;
|
||||
|
||||
const eventHandler = () => handler();
|
||||
const events = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange'];
|
||||
|
||||
events.forEach((event) => {
|
||||
globalThis.document.addEventListener(event, eventHandler);
|
||||
});
|
||||
|
||||
return () => {
|
||||
events.forEach((event) => {
|
||||
globalThis.document.removeEventListener(event, eventHandler);
|
||||
});
|
||||
};
|
||||
},
|
||||
] as const,
|
||||
actions: {
|
||||
/** Toggle fullscreen state or explicitly enter/exit based on detail */
|
||||
fullscreenrequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: undefined }): boolean => {
|
||||
// If detail is provided, use it; otherwise toggle current state
|
||||
if (typeof detail === 'boolean') {
|
||||
return detail;
|
||||
}
|
||||
|
||||
// Toggle behavior: check current fullscreen state
|
||||
const fullscreenEl = globalThis?.document?.fullscreenElement;
|
||||
return !fullscreenEl;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export { audible } from './audible';
|
||||
export { fullscreenable } from './fullscreenable';
|
||||
export { playable } from './playable';
|
||||
export { preview } from './preview';
|
||||
export { temporal } from './temporal';
|
||||
@@ -1,29 +0,0 @@
|
||||
export const playable = {
|
||||
paused: {
|
||||
get(stateOwners: any): boolean {
|
||||
const { media } = stateOwners;
|
||||
return media?.paused ?? true;
|
||||
},
|
||||
set(value: boolean, stateOwners: any): void {
|
||||
const { media } = stateOwners;
|
||||
media?.[value ? 'pause' : 'play']();
|
||||
},
|
||||
stateOwnersUpdateHandlers: [
|
||||
(handler: (value?: boolean) => void, stateOwners: any): void | (() => void) => {
|
||||
const { media } = stateOwners;
|
||||
if (!media) return;
|
||||
|
||||
const eventHandler = () => handler();
|
||||
const events = ['play', 'playing', 'pause', 'emptied'];
|
||||
events.forEach(event => media.addEventListener(event, eventHandler));
|
||||
|
||||
return () => events.forEach(event => media.removeEventListener(event, eventHandler));
|
||||
},
|
||||
] as const,
|
||||
actions: {
|
||||
/** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API (CJP) */
|
||||
playrequest: () => false,
|
||||
pauserequest: () => true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
export const preview = {
|
||||
previewTime: {
|
||||
actions: {
|
||||
previewrequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 }): number => +detail,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
import { isValidNumber } from '@videojs/utils';
|
||||
|
||||
export const temporal = {
|
||||
currentTime: {
|
||||
get(stateOwners: any): number {
|
||||
const { media } = stateOwners;
|
||||
return media?.currentTime ?? 0;
|
||||
},
|
||||
set(value: number, stateOwners: any): void {
|
||||
const { media } = stateOwners;
|
||||
if (!media || !isValidNumber(value)) return;
|
||||
media.currentTime = value;
|
||||
},
|
||||
stateOwnersUpdateHandlers: [
|
||||
(handler: (value?: number) => void, stateOwners: any): void | (() => void) => {
|
||||
const { media } = stateOwners;
|
||||
if (!media) return;
|
||||
|
||||
const eventHandler = () => handler();
|
||||
const events = ['timeupdate', 'loadedmetadata'];
|
||||
events.forEach(event => media.addEventListener(event, eventHandler));
|
||||
|
||||
return () => events.forEach(event => media.removeEventListener(event, eventHandler));
|
||||
},
|
||||
] as const,
|
||||
actions: {
|
||||
/** @TODO Support more sophisticated seeking patterns like seek-to-live, relative seeking, etc. (CJP) */
|
||||
seekrequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 }): number => +detail,
|
||||
},
|
||||
},
|
||||
|
||||
duration: {
|
||||
get(stateOwners: any): number {
|
||||
const { media } = stateOwners;
|
||||
|
||||
// Return 0 if no media or invalid duration
|
||||
if (!media?.duration || Number.isNaN(media.duration) || !Number.isFinite(media.duration)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return media.duration;
|
||||
},
|
||||
stateOwnersUpdateHandlers: [
|
||||
(handler: (value?: number) => void, stateOwners: any): void | (() => void) => {
|
||||
const { media } = stateOwners;
|
||||
if (!media) return;
|
||||
|
||||
const eventHandler = () => handler();
|
||||
const events = ['loadedmetadata', 'durationchange', 'emptied'];
|
||||
events.forEach(event => media.addEventListener(event, eventHandler));
|
||||
|
||||
return () => events.forEach(event => media.removeEventListener(event, eventHandler));
|
||||
},
|
||||
] as const,
|
||||
},
|
||||
|
||||
seekable: {
|
||||
get(stateOwners: any): [number, number] | undefined {
|
||||
const { media } = stateOwners;
|
||||
|
||||
if (!media?.seekable?.length) return undefined;
|
||||
|
||||
const start = media.seekable.start(0);
|
||||
const end = media.seekable.end(media.seekable.length - 1);
|
||||
|
||||
// Account for cases where metadata has an "empty" seekable range
|
||||
if (!start && !end) return undefined;
|
||||
|
||||
return [Number(start.toFixed(3)), Number(end.toFixed(3))];
|
||||
},
|
||||
stateOwnersUpdateHandlers: [
|
||||
(handler: (value?: [number, number] | undefined) => void, stateOwners: any): void | (() => void) => {
|
||||
const { media } = stateOwners;
|
||||
if (!media) return;
|
||||
|
||||
const eventHandler = () => handler();
|
||||
const events = ['loadedmetadata', 'emptied', 'progress', 'seekablechange'];
|
||||
events.forEach(event => media.addEventListener(event, eventHandler));
|
||||
|
||||
return () => events.forEach(event => media.removeEventListener(event, eventHandler));
|
||||
},
|
||||
] as const,
|
||||
},
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface StateOwners {
|
||||
media?: HTMLMediaElement;
|
||||
container?: HTMLElement;
|
||||
}
|
||||
Reference in New Issue
Block a user