chore(root): prepare workspace for alpha (#276)

This commit is contained in:
rahim
2025-12-21 20:26:13 +11:00
committed by GitHub
parent 5ddf02278d
commit b89fef56dd
247 changed files with 1197 additions and 446 deletions
-319
View File
@@ -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 });
}
}
-176
View File
@@ -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)) });
}
}
-118
View File
@@ -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);
}
}
-14
View File
@@ -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)}%`;
}
-4
View File
@@ -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();
-2
View File
@@ -1,2 +0,0 @@
export type { PlaybackEngine as IBasePlaybackEngine } from './hls-engine';
export { createHlsPlaybackEngine, HlsPlaybackEngine } from './hls-engine';
-3
View File
@@ -1,3 +0,0 @@
export * from './engine';
export type { AudibleMediaStateOwner, MediaStateOwner, PlayableMediaStateOwner } from './playable';
export { createMediaPlaybackController, Events, MediaPlaybackController } from './playable';
-138
View File
@@ -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 });
},
}),
};
-168
View File
@@ -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;
}
-15
View File
@@ -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';
-21
View File
@@ -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,
},
};
-4
View File
@@ -1,4 +0,0 @@
export interface StateOwners {
media?: HTMLMediaElement;
container?: HTMLElement;
}