mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
Flatten workspace (#120)
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
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
|
||||
>;
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
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>;
|
||||
@@ -0,0 +1,25 @@
|
||||
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 }),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
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' }),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
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' }),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
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,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
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 });
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
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 });
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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';
|
||||
@@ -0,0 +1,21 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { containsComposedNode } from '@vjs-10/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 } = stateOwners;
|
||||
if (!container || !globalThis?.document) return;
|
||||
|
||||
try {
|
||||
if (value) {
|
||||
// Enter fullscreen
|
||||
if (container.requestFullscreen) {
|
||||
container.requestFullscreen();
|
||||
} else if (container.webkitRequestFullscreen) {
|
||||
// Safari support
|
||||
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;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { audible } from './audible';
|
||||
export { fullscreenable } from './fullscreenable';
|
||||
export { playable } from './playable';
|
||||
export { preview } from './preview';
|
||||
export { temporal } from './temporal';
|
||||
@@ -0,0 +1,29 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export const preview = {
|
||||
previewTime: {
|
||||
actions: {
|
||||
previewrequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 }): number => +detail,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { isValidNumber } from '@vjs-10/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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface StateOwners {
|
||||
media?: HTMLMediaElement;
|
||||
container?: HTMLElement;
|
||||
}
|
||||
Reference in New Issue
Block a user