From 1e12f66f16eedfe13af17ccfabca7ab0e8239313 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Mon, 8 Sep 2025 17:48:37 -0700 Subject: [PATCH] feat(core): implement temporal state management for time-based media controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/core/media-store/src/factory.ts | 3 + packages/core/media-store/src/index.ts | 1 + packages/core/media-store/src/media-store.ts | 3 +- .../src/state-mediators/temporal.ts | 58 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 packages/core/media-store/src/state-mediators/temporal.ts diff --git a/packages/core/media-store/src/factory.ts b/packages/core/media-store/src/factory.ts index d5917dad..69c696d7 100644 --- a/packages/core/media-store/src/factory.ts +++ b/packages/core/media-store/src/factory.ts @@ -42,6 +42,9 @@ export type StateMediator = { muted: FacadeProp; volume: FacadeProp; volumeLevel: ReadonlyFacadeProp<'high' | 'medium' | 'low' | 'off'>; + currentTime: FacadeProp; + duration: ReadonlyFacadeProp; + seekable: ReadonlyFacadeProp<[number, number] | undefined>; }; export function createMediaStore({ diff --git a/packages/core/media-store/src/index.ts b/packages/core/media-store/src/index.ts index b6ca3fea..fa3db319 100644 --- a/packages/core/media-store/src/index.ts +++ b/packages/core/media-store/src/index.ts @@ -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'; \ No newline at end of file diff --git a/packages/core/media-store/src/media-store.ts b/packages/core/media-store/src/media-store.ts index 5fb07826..f3f26347 100644 --- a/packages/core/media-store/src/media-store.ts +++ b/packages/core/media-store/src/media-store.ts @@ -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[0]>; export const createMediaStore = (params: Params = {}) => factory({ stateMediator, ...params }); \ No newline at end of file diff --git a/packages/core/media-store/src/state-mediators/temporal.ts b/packages/core/media-store/src/state-mediators/temporal.ts new file mode 100644 index 00000000..ebdc3122 --- /dev/null +++ b/packages/core/media-store/src/state-mediators/temporal.ts @@ -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, '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'], + }, +}; \ No newline at end of file