This commit is contained in:
Rahim
2025-09-22 23:39:30 +10:00
parent d3221ec211
commit d2b2dd4868
132 changed files with 860 additions and 969 deletions
@@ -1,6 +1,6 @@
/**
* @fileoverview Current time display component state definition
*
*
* This module provides the component state definition for current time display
* components across HTML, React, and React Native platforms. The current time
* display is a read-only component that shows the current playback time of media.
@@ -12,7 +12,7 @@
export interface CurrentTimeDisplayState {
/** The current time value in seconds */
currentTime: number | undefined;
/** The total duration in seconds (for future functionality) */
duration: number | undefined;
}
@@ -34,7 +34,7 @@ export const currentTimeDisplayStateDefinition = {
*/
stateTransform: (rawState: Record<string, any>): CurrentTimeDisplayState => {
const { currentTime, duration } = rawState;
return {
currentTime,
duration,
@@ -62,4 +62,6 @@ export type CurrentTimeDisplayComponentState = ReturnType<typeof currentTimeDisp
/**
* Type helper to extract the request methods type (empty for read-only component)
*/
export type CurrentTimeDisplayRequestMethods = ReturnType<typeof currentTimeDisplayStateDefinition.createRequestMethods>;
export type CurrentTimeDisplayRequestMethods = ReturnType<
typeof currentTimeDisplayStateDefinition.createRequestMethods
>;
@@ -1,6 +1,6 @@
/**
* @fileoverview Duration display component state definition
*
*
* This module provides the component state definition for duration display
* components across HTML, React, and React Native platforms. The duration
* display is a read-only component that shows the total duration of media.
@@ -31,7 +31,7 @@ export const durationDisplayStateDefinition = {
*/
stateTransform: (rawState: Record<string, any>): DurationDisplayState => {
const { duration } = rawState;
return {
duration,
};
@@ -58,4 +58,4 @@ export type DurationDisplayComponentState = ReturnType<typeof durationDisplaySta
/**
* Type helper to extract the request methods type (empty for read-only component)
*/
export type DurationDisplayRequestMethods = ReturnType<typeof durationDisplayStateDefinition.createRequestMethods>;
export type DurationDisplayRequestMethods = ReturnType<typeof durationDisplayStateDefinition.createRequestMethods>;
@@ -10,27 +10,22 @@ export interface FullscreenButtonMethods {
export interface FullscreenButtonStateDefinition {
keys: string[];
stateTransform: (rawState: any) => FullscreenButtonState;
createRequestMethods: (
dispatch: (action: { type: string; detail?: any }) => void,
) => FullscreenButtonMethods;
createRequestMethods: (dispatch: (action: { type: string; detail?: any }) => void) => FullscreenButtonMethods;
}
/**
* FullscreenButton state definition
* Defines the core state logic that can be shared between implementations
*/
export const fullscreenButtonStateDefinition: FullscreenButtonStateDefinition =
{
keys: ['fullscreen'],
export const fullscreenButtonStateDefinition: FullscreenButtonStateDefinition = {
keys: ['fullscreen'],
stateTransform: (rawState: any): FullscreenButtonState => ({
fullscreen: rawState.fullscreen ?? false,
}),
stateTransform: (rawState: any): FullscreenButtonState => ({
fullscreen: rawState.fullscreen ?? false,
}),
createRequestMethods: (dispatch): FullscreenButtonMethods => ({
requestEnterFullscreen: () =>
dispatch({ type: 'fullscreenrequest', detail: true }),
requestExitFullscreen: () =>
dispatch({ type: 'fullscreenrequest', detail: false }),
}),
};
createRequestMethods: (dispatch): FullscreenButtonMethods => ({
requestEnterFullscreen: () => dispatch({ type: 'fullscreenrequest', detail: true }),
requestExitFullscreen: () => dispatch({ type: 'fullscreenrequest', detail: false }),
}),
};
@@ -11,9 +11,7 @@ export interface MuteButtonMethods {
export interface MuteButtonStateDefinition {
keys: string[];
stateTransform: (rawState: any) => MuteButtonState;
createRequestMethods: (
dispatch: (action: { type: string }) => void,
) => MuteButtonMethods;
createRequestMethods: (dispatch: (action: { type: string }) => void) => MuteButtonMethods;
}
/**
@@ -32,4 +30,4 @@ export const muteButtonStateDefinition: MuteButtonStateDefinition = {
requestMute: () => dispatch({ type: 'muterequest' }),
requestUnmute: () => dispatch({ type: 'unmuterequest' }),
}),
};
};
@@ -10,9 +10,7 @@ export interface PlayButtonMethods {
export interface PlayButtonStateDefinition {
keys: string[];
stateTransform: (rawState: any) => PlayButtonState;
createRequestMethods: (
dispatch: (action: { type: string }) => void,
) => PlayButtonMethods;
createRequestMethods: (dispatch: (action: { type: string }) => void) => PlayButtonMethods;
}
/**
@@ -30,4 +28,4 @@ export const playButtonStateDefinition: PlayButtonStateDefinition = {
requestPlay: () => dispatch({ type: 'playrequest' }),
requestPause: () => dispatch({ type: 'pauserequest' }),
}),
};
};
@@ -10,9 +10,7 @@ export interface TimeRangeMethods {
export interface TimeRangeStateDefinition {
keys: string[];
stateTransform: (rawState: any) => TimeRangeState;
createRequestMethods: (
dispatch: (action: { type: string; detail?: any }) => void,
) => TimeRangeMethods;
createRequestMethods: (dispatch: (action: { type: string; detail?: any }) => void) => TimeRangeMethods;
}
/**
@@ -30,4 +28,4 @@ export const timeRangeStateDefinition: TimeRangeStateDefinition = {
dispatch({ type: 'seekrequest', detail: time });
},
}),
};
};
@@ -11,9 +11,7 @@ export interface VolumeRangeMethods {
export interface VolumeRangeStateDefinition {
keys: string[];
stateTransform: (rawState: any) => VolumeRangeState;
createRequestMethods: (
dispatch: (action: { type: string; detail?: any }) => void,
) => VolumeRangeMethods;
createRequestMethods: (dispatch: (action: { type: string; detail?: any }) => void) => VolumeRangeMethods;
}
/**
+16 -24
View File
@@ -11,16 +11,13 @@ export type EventOrAction<D = undefined> = {
target?: EventTarget;
};
export type FacadeGetter<T, D = T> = (
stateOwners: StateOwners,
event?: EventOrAction<D>,
) => T;
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,
stateOwners: StateOwners
) => (() => void) | void;
export type ReadonlyFacadeProp<T, D = T> = {
@@ -32,9 +29,7 @@ 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: Pick<CustomEvent<any>, 'type' | 'detail'>,
) => ReturnType<FacadeGetter<T, D>>;
[k: string]: (val: Pick<CustomEvent<any>, 'type' | 'detail'>) => ReturnType<FacadeGetter<T, D>>;
};
};
@@ -67,14 +62,14 @@ export function createMediaStore({
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?.());
cleanups.forEach((cleanup) => cleanup?.());
stateUpdateHandlerCleanups[stateName] = [];
});
@@ -84,7 +79,7 @@ export function createMediaStore({
// Set up new handlers
Object.entries(stateMediator).forEach(([stateName, stateObject]) => {
const { get, stateOwnersUpdateHandlers = [] } = stateObject;
if (!stateUpdateHandlerCleanups[stateName]) {
stateUpdateHandlerCleanups[stateName] = [];
}
@@ -96,7 +91,7 @@ export function createMediaStore({
};
// Execute each stateOwnersUpdateHandler
stateOwnersUpdateHandlers.forEach(setupHandler => {
stateOwnersUpdateHandlers.forEach((setupHandler) => {
const cleanup = setupHandler(updateHandler, stateOwners);
if (typeof cleanup === 'function') {
stateUpdateHandlerCleanups[stateName]?.push(cleanup);
@@ -115,10 +110,7 @@ export function createMediaStore({
updateStateOwners({ container: detail });
} else {
for (const stateObject of Object.values(stateMediator).filter(
(
stateMediatorEntry,
): stateMediatorEntry is FacadeProp<any, any, any> =>
'set' in stateMediatorEntry,
(stateMediatorEntry): stateMediatorEntry is FacadeProp<any, any, any> => 'set' in stateMediatorEntry
)) {
const { set, actions } = stateObject;
if (actions[type]) {
@@ -135,10 +127,13 @@ export function createMediaStore({
},
getKeys(keys: string[]) {
return keys.reduce((acc, k) => {
acc[k] = getKey(store, k);
return acc;
}, {} as { [k: 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) {
@@ -152,10 +147,7 @@ export function createMediaStore({
};
}
function getInitialState(
stateMediator: Partial<StateMediator> & Pick<StateMediator, 'paused'>,
stateOwners: any,
) {
function getInitialState(stateMediator: Partial<StateMediator> & Pick<StateMediator, 'paused'>, stateOwners: any) {
let initialState: any = {};
for (const [stateName, { get }] of Object.entries(stateMediator)) {
initialState[stateName] = get(stateOwners);
+1 -1
View File
@@ -10,4 +10,4 @@ export * from './component-state-definitions/time-range';
export * from './component-state-definitions/fullscreen-button';
export * from './component-state-definitions/duration-display';
export * from './component-state-definitions/current-time-display';
export * from './utils/time';
export * from './utils/time';
+6 -6
View File
@@ -1,11 +1,11 @@
import { playable } from './state-mediators/playable';
import { audible } from './state-mediators/audible';
import { temporal } from './state-mediators/temporal';
import { fullscreenable } from './state-mediators/fullscreenable';
import { createMediaStore as factory } from './factory';
import { audible } from './state-mediators/audible';
import { fullscreenable } from './state-mediators/fullscreenable';
import { playable } from './state-mediators/playable';
import { temporal } from './state-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 };
type Params = Partial<Parameters<typeof factory>[0]>;
export const createMediaStore = (params: Params = {}) =>
factory({ stateMediator, ...params });
export const createMediaStore = (params: Params = {}) => factory({ stateMediator, ...params });
@@ -16,12 +16,12 @@ export const audible = {
(handler: (value?: boolean) => void, stateOwners: any) => {
const { media } = stateOwners;
if (!media) return;
const eventHandler = () => handler();
media.addEventListener('volumechange', eventHandler);
return () => media.removeEventListener('volumechange', eventHandler);
}
},
],
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) */
@@ -48,18 +48,16 @@ export const audible = {
(handler: (value?: number) => void, stateOwners: any) => {
const { media } = stateOwners;
if (!media) return;
const eventHandler = () => handler();
media.addEventListener('volumechange', eventHandler);
return () => media.removeEventListener('volumechange', eventHandler);
}
},
],
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 },
) => +detail,
volumerequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 }) => +detail,
},
},
// NOTE: This could be (re)implemented as "derived state" in some manner (e.g. selectors but also other patterns/conventions) if preferred. (CJP)
@@ -76,12 +74,12 @@ export const audible = {
(handler: (value?: 'high' | 'medium' | 'low' | 'off') => void, stateOwners: any) => {
const { media } = stateOwners;
if (!media) return;
const eventHandler = () => handler();
media.addEventListener('volumechange', eventHandler);
return () => media.removeEventListener('volumechange', eventHandler);
}
},
],
},
};
@@ -46,13 +46,10 @@ export const fullscreenable = {
'fullscreenElement' in doc
? 'fullscreenElement'
: 'webkitFullscreenElement' in doc
? 'webkitFullscreenElement'
: undefined;
? 'webkitFullscreenElement'
: undefined;
if (
fullscreenElementKey &&
!(fullscreenElementKey in (currentRoot || {}))
) {
if (fullscreenElementKey && !(fullscreenElementKey in (currentRoot || {}))) {
// For older Safari versions, use composed node containment check
return containsComposedNode(currentFullscreenElement, container);
}
@@ -60,8 +57,7 @@ export const fullscreenable = {
// Traverse shadow DOM layers looking for our container
while (fullscreenElementKey && currentRoot?.[fullscreenElementKey]) {
if (currentRoot[fullscreenElementKey] === container) return true;
if (currentRoot[fullscreenElementKey]?.contains?.(container))
return true;
if (currentRoot[fullscreenElementKey]?.contains?.(container)) return true;
currentRoot = currentRoot[fullscreenElementKey]?.shadowRoot;
}
}
@@ -113,12 +109,7 @@ export const fullscreenable = {
if (!globalThis?.document) return;
const eventHandler = () => handler();
const events = [
'fullscreenchange',
'webkitfullscreenchange',
'mozfullscreenchange',
'MSFullscreenChange',
];
const events = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange'];
events.forEach((event) => {
globalThis.document.addEventListener(event, eventHandler);
@@ -133,9 +124,7 @@ export const fullscreenable = {
],
actions: {
/** Toggle fullscreen state or explicitly enter/exit based on detail */
fullscreenrequest: (
{ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: undefined },
) => {
fullscreenrequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: undefined }) => {
// If detail is provided, use it; otherwise toggle current state
if (typeof detail === 'boolean') {
return detail;
@@ -1,4 +1,4 @@
export { audible } from './audible';
export { playable } from './playable';
export { temporal } from './temporal';
export { fullscreenable } from './fullscreenable';
export { fullscreenable } from './fullscreenable';
@@ -12,13 +12,13 @@ export const playable = {
(handler: (value?: boolean) => void, stateOwners: any) => {
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));
}
events.forEach((event) => media.addEventListener(event, eventHandler));
return () => events.forEach((event) => media.removeEventListener(event, eventHandler));
},
],
actions: {
/** @TODO Refactor me to play more nicely with side effects that don't/can't correlate with set() API (CJP) */
@@ -1,7 +1,5 @@
const isValidNumber = (value: any): value is number => {
return (
typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value)
);
return typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value);
};
export const temporal = {
@@ -19,19 +17,17 @@ export const temporal = {
(handler: (value?: number) => void, stateOwners: any) => {
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));
}
events.forEach((event) => media.addEventListener(event, eventHandler));
return () => events.forEach((event) => media.removeEventListener(event, eventHandler));
},
],
actions: {
/** @TODO Support more sophisticated seeking patterns like seek-to-live, relative seeking, etc. (CJP) */
seekrequest: (
{ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 },
) => +detail,
seekrequest: ({ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 }) => +detail,
},
},
@@ -40,11 +36,7 @@ export const temporal = {
const { media } = stateOwners;
// Return 0 if no media or invalid duration
if (
!media?.duration ||
Number.isNaN(media.duration) ||
!Number.isFinite(media.duration)
) {
if (!media?.duration || Number.isNaN(media.duration) || !Number.isFinite(media.duration)) {
return 0;
}
@@ -54,13 +46,13 @@ export const temporal = {
(handler: (value?: number) => void, stateOwners: any) => {
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));
}
events.forEach((event) => media.addEventListener(event, eventHandler));
return () => events.forEach((event) => media.removeEventListener(event, eventHandler));
},
],
},
@@ -82,13 +74,13 @@ export const temporal = {
(handler: (value?: [number, number] | undefined) => void, stateOwners: any) => {
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));
}
events.forEach((event) => media.addEventListener(event, eventHandler));
return () => events.forEach((event) => media.removeEventListener(event, eventHandler));
},
],
},
};
+5 -20
View File
@@ -29,10 +29,7 @@ const UnitLabels = [
] as const;
const toTimeUnitPhrase = (timeUnitValue: number, unitIndex: number): string => {
const unitLabel =
timeUnitValue === 1
? UnitLabels[unitIndex]?.singular
: UnitLabels[unitIndex]?.plural;
const unitLabel = timeUnitValue === 1 ? UnitLabels[unitIndex]?.singular : UnitLabels[unitIndex]?.plural;
return `${timeUnitValue} ${unitLabel}`;
};
@@ -53,18 +50,11 @@ export function formatAsTimePhrase(seconds: number): string {
const positiveSeconds = Math.abs(seconds);
const negative = positiveSeconds !== seconds;
const secondsDateTime = new Date(0, 0, 0, 0, 0, positiveSeconds, 0);
const timeParts = [
secondsDateTime.getHours(),
secondsDateTime.getMinutes(),
secondsDateTime.getSeconds(),
];
const timeParts = [secondsDateTime.getHours(), secondsDateTime.getMinutes(), secondsDateTime.getSeconds()];
const timeString = timeParts
// Convert non-0 values to a string of the value plus its unit
.map(
(timeUnitValue, index) =>
timeUnitValue && toTimeUnitPhrase(timeUnitValue, index),
)
.map((timeUnitValue, index) => timeUnitValue && toTimeUnitPhrase(timeUnitValue, index))
// Ignore/exclude any 0 values
.filter((x) => x)
// join into a single comma-separated string phrase
@@ -124,8 +114,7 @@ export function formatTime(seconds: number, guide?: number): string {
// If hours are showing, we may need to add a leading zero.
// Always show at least one digit of minutes.
const minutesString =
((showHours || gm >= 10) && (m as number) < 10 ? '0' + m : m) + ':';
const minutesString = ((showHours || gm >= 10) && (m as number) < 10 ? '0' + m : m) + ':';
// Check if leading zero is needed for seconds
const secondsString = (s as number) < 10 ? '0' + s : s;
@@ -140,11 +129,7 @@ export function formatTime(seconds: number, guide?: number): string {
* @param fallback - Fallback text when time is invalid (default: "--:--")
* @returns Formatted time string or fallback
*/
export function formatDisplayTime(
time: unknown,
guide?: number,
fallback: string = '--:--',
): string {
export function formatDisplayTime(time: unknown, guide?: number, fallback: string = '--:--'): string {
if (!isValidNumber(time)) {
return fallback;
}