feat(core): implement temporal state management for time-based media controls

Add temporal state mediator with currentTime, duration, and seekable properties
ported from media-chrome architecture. Includes proper input validation,
media event bindings, and seek request handling.

- Add temporal.ts state mediator with time-based functionality
- Update StateMediator type to include temporal properties
- Integrate temporal module into default media store
- Export temporal module from package index

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2025-09-08 17:50:03 -07:00
committed by Christian Pillsbury
co-authored by Claude
parent 370762f500
commit 1e12f66f16
4 changed files with 64 additions and 1 deletions
+3
View File
@@ -42,6 +42,9 @@ export type StateMediator = {
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>;
};
export function createMediaStore({
+1
View File
@@ -2,5 +2,6 @@ export * from './factory';
export { createMediaStore } from './media-store';
export * from './state-mediators/playable';
export * from './state-mediators/audible';
export * from './state-mediators/temporal';
export * from './component-state-definitions/play-button';
export * from './component-state-definitions/mute-button';
+2 -1
View File
@@ -1,9 +1,10 @@
import { playable } from './state-mediators/playable';
import { audible } from './state-mediators/audible';
import { temporal } from './state-mediators/temporal';
import { createMediaStore as factory } from './factory';
// 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 };
const stateMediator = { ...playable, ...audible, ...temporal };
type Params = Partial<Parameters<typeof factory>[0]>;
export const createMediaStore = (params: Params = {}) =>
factory({ stateMediator, ...params });
@@ -0,0 +1,58 @@
const isValidNumber = (value: any): value is number => {
return typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value);
};
export const temporal = {
currentTime: {
get(stateOwners: any) {
const { media } = stateOwners;
return media?.currentTime ?? 0;
},
set(value: number, stateOwners: any) {
const { media } = stateOwners;
if (!media || !isValidNumber(value)) return;
media.currentTime = value;
},
mediaEvents: ['timeupdate', 'loadedmetadata'],
actions: {
/** @TODO Support more sophisticated seeking patterns like seek-to-live, relative seeking, etc. (CJP) */
seekrequest: (
{ detail }: Pick<CustomEvent<any>, 'detail'> = { detail: 0 },
) => +detail,
},
},
duration: {
get(stateOwners: any) {
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;
},
mediaEvents: ['loadedmetadata', 'durationchange', 'emptied'],
},
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))
];
},
mediaEvents: ['loadedmetadata', 'emptied', 'progress', 'seekablechange'],
},
};