diff --git a/bbb.mp4 b/bbb.mp4 new file mode 100644 index 00000000..0a4dd5b4 Binary files /dev/null and b/bbb.mp4 differ diff --git a/packages/core/media-store/src/component-state-definitions/time-range.ts b/packages/core/media-store/src/component-state-definitions/time-range.ts new file mode 100644 index 00000000..c2b96143 --- /dev/null +++ b/packages/core/media-store/src/component-state-definitions/time-range.ts @@ -0,0 +1,33 @@ +export interface TimeRangeState { + currentTime: number; + duration: number; +} + +export interface TimeRangeMethods { + requestSeek: (time: number) => void; +} + +export interface TimeRangeStateDefinition { + keys: string[]; + stateTransform: (rawState: any) => TimeRangeState; + createRequestMethods: ( + dispatch: (action: { type: string; detail?: any }) => void, + ) => TimeRangeMethods; +} + +/** + * TimeRange state definition + * Defines the core state logic that can be shared between implementations + */ +export const timeRangeStateDefinition: TimeRangeStateDefinition = { + keys: ['currentTime', 'duration'], + stateTransform: (rawState: any) => ({ + currentTime: rawState.currentTime ?? 0, + duration: rawState.duration ?? 0, + }), + createRequestMethods: (dispatch) => ({ + requestSeek: (time: number) => { + dispatch({ type: 'seekrequest', detail: time }); + }, + }), +}; \ No newline at end of file diff --git a/packages/core/media-store/src/index.ts b/packages/core/media-store/src/index.ts index b6727987..e286b794 100644 --- a/packages/core/media-store/src/index.ts +++ b/packages/core/media-store/src/index.ts @@ -5,4 +5,5 @@ export * from './state-mediators/audible'; export * from './state-mediators/temporal'; export * from './component-state-definitions/play-button'; export * from './component-state-definitions/mute-button'; -export * from './component-state-definitions/volume-range'; \ No newline at end of file +export * from './component-state-definitions/volume-range'; +export * from './component-state-definitions/time-range'; \ No newline at end of file diff --git a/packages/html/html/src/components/media-time-range.ts b/packages/html/html/src/components/media-time-range.ts new file mode 100644 index 00000000..540fd1ba --- /dev/null +++ b/packages/html/html/src/components/media-time-range.ts @@ -0,0 +1,127 @@ +import { + toConnectedHTMLComponent, + StateHook, + PropsHook, +} from '../utils/component-factory'; +import { timeRangeStateDefinition } from '@vjs-10/media-store'; + +export class TimeRangeBase extends HTMLElement { + _state: + | { + currentTime: number; + duration: number; + requestSeek: (time: number) => void; + } + | undefined; + _input: HTMLInputElement; + + constructor() { + super(); + this._input = document.createElement('input'); + this._input.type = 'range'; + this._input.min = '0'; + this._input.max = '100'; + this._input.step = '0.1'; + this._input.addEventListener('input', this); + this.appendChild(this._input); + } + + handleEvent(event: Event) { + const { type } = event; + const state = this._state; + if (state) { + if (type === 'input') { + const ratio = parseFloat(this._input.value) / 100; + const seekTime = ratio * state.duration; + state.requestSeek(seekTime); + } + } + } + + get currentTime() { + return this._state?.currentTime; + } + + get duration() { + return this._state?.duration; + } + + _update(props: any, state: any) { + this._state = state; + const ratio = state.duration > 0 ? (state.currentTime / state.duration) * 100 : 0; + this._input.value = ratio.toString(); + this._input.max = '100'; + this._input.setAttribute('aria-label', props['aria-label']); + this._input.setAttribute('aria-valuetext', props['aria-valuetext']); + this._input.disabled = props.disabled ?? false; + + // Update data attributes for styling + this.setAttribute('data-current-time', props['data-current-time']); + this.setAttribute('data-duration', props['data-duration']); + } +} + +/** + * TimeRange state hook - equivalent to React's useTimeRangeState + * Handles media store state subscription and transformation + */ +export const useTimeRangeState: StateHook<{ + currentTime: number; + duration: number; +}> = { + keys: timeRangeStateDefinition.keys, + transform: (rawState, mediaStore) => ({ + ...timeRangeStateDefinition.stateTransform(rawState), + ...timeRangeStateDefinition.createRequestMethods(mediaStore.dispatch), + }), +}; + +/** + * TimeRange props hook - equivalent to React's useTimeRangeProps + * Handles element attributes and properties based on state + */ +export const useTimeRangeProps: PropsHook<{ + currentTime: number; + duration: number; +}> = (state, _element) => { + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + const currentTimeText = formatTime(state.currentTime); + const durationText = formatTime(state.duration); + + const baseProps: Record = { + /** data attributes/props */ + ['data-current-time']: state.currentTime.toString(), + ['data-duration']: state.duration.toString(), + /** aria attributes/props */ + ['aria-label']: 'Seek', + ['aria-valuetext']: `${currentTimeText} of ${durationText}`, + /** input props */ + disabled: false, + }; + + return baseProps; +}; + +/** + * Connected TimeRange component using hook-style architecture + * Equivalent to React's TimeRange = toConnectedComponent(...) + */ +export const TimeRange = toConnectedHTMLComponent( + TimeRangeBase, + useTimeRangeState, + useTimeRangeProps, + 'TimeRange', +); + +// NOTE: In this architecture it will be important to decouple component class definitions from their registration in the CustomElementsRegistry. (CJP) +if (!globalThis.customElements.get('media-time-range')) { + // @ts-ignore - Custom element constructor compatibility + globalThis.customElements.define('media-time-range', TimeRange); +} + +export default TimeRange; \ No newline at end of file diff --git a/packages/html/html/src/skins/media-skin-default.ts b/packages/html/html/src/skins/media-skin-default.ts index 1141248f..76cb964c 100644 --- a/packages/html/html/src/skins/media-skin-default.ts +++ b/packages/html/html/src/skins/media-skin-default.ts @@ -4,6 +4,7 @@ import '../media-container'; import '../components/media-play-button'; import '../components/media-mute-button'; import '../components/media-volume-range'; +import '../components/media-time-range'; import '@vjs-10/html-icons'; export function getTemplateHTML() { @@ -110,6 +111,38 @@ export function getTemplateHTML() { cursor: pointer; border: none; } + + /* Time Range UI/Styles */ + media-time-range { + flex-grow: 1; + margin: 0 8px; + } + + media-time-range input[type="range"] { + width: 100%; + height: 4px; + background: rgb(50 50 50); + outline: none; + border-radius: 2px; + } + + media-time-range input[type="range"]::-webkit-slider-thumb { + appearance: none; + width: 12px; + height: 12px; + background: rgb(238 238 238); + border-radius: 50%; + cursor: pointer; + } + + media-time-range input[type="range"]::-moz-range-thumb { + width: 12px; + height: 12px; + background: rgb(238 238 238); + border-radius: 50%; + cursor: pointer; + border: none; + } @@ -121,6 +154,7 @@ export function getTemplateHTML() { + @@ -128,7 +162,7 @@ export function getTemplateHTML() { -
+
`; } diff --git a/packages/react/react/src/components/TimeRange.tsx b/packages/react/react/src/components/TimeRange.tsx new file mode 100644 index 00000000..d1e867d0 --- /dev/null +++ b/packages/react/react/src/components/TimeRange.tsx @@ -0,0 +1,97 @@ +import { + shallowEqual, + useMediaSelector, + useMediaStore, +} from '@vjs-10/react-media-store'; +import * as React from 'react'; +import { toConnectedComponent } from '../utils/component-factory'; +import { timeRangeStateDefinition } from '@vjs-10/media-store'; + +export const useTimeRangeState = (_props: any) => { + const mediaStore = useMediaStore(); + /** @TODO Fix type issues with hooks (CJP) */ + const mediaState = useMediaSelector( + timeRangeStateDefinition.stateTransform, + shallowEqual, + ); + + const methods = React.useMemo( + () => timeRangeStateDefinition.createRequestMethods(mediaStore.dispatch), + [mediaStore], + ); + + return { + currentTime: mediaState.currentTime, + duration: mediaState.duration, + requestSeek: methods.requestSeek, + } as const; +}; + +export type useTimeRangeState = typeof useTimeRangeState; +export type TimeRangeState = ReturnType; + +export const useTimeRangeProps = ( + props: React.PropsWithChildren<{ [k: string]: any }>, + state: ReturnType, +) => { + const ratio = state.duration > 0 ? (state.currentTime / state.duration) * 100 : 0; + + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + const currentTimeText = formatTime(state.currentTime); + const durationText = formatTime(state.duration); + + const baseProps: Record = { + /** input properties */ + type: 'range', + min: '0', + max: '100', + step: '0.1', + value: ratio, + /** aria attributes/props */ + 'aria-label': 'Seek', + 'aria-valuetext': `${currentTimeText} of ${durationText}`, + /** data attributes */ + 'data-current-time': state.currentTime, + 'data-duration': state.duration, + /** external props spread last to allow for overriding */ + ...props, + }; + + return baseProps; +}; + +export type useTimeRangeProps = typeof useTimeRangeProps; +type TimeRangeProps = ReturnType; + +export const renderTimeRange = ( + props: TimeRangeProps, + state: TimeRangeState, +) => { + return ( + ) => { + /** @ts-ignore */ + if (props.disabled) return; + const ratio = parseFloat(e.target.value) / 100; + const seekTime = ratio * state.duration; + state.requestSeek(seekTime); + }} + /> + ); +}; + +export type renderTimeRange = typeof renderTimeRange; + +export const TimeRange = toConnectedComponent( + useTimeRangeState, + useTimeRangeProps, + renderTimeRange, + 'TimeRange', +); +export default TimeRange; \ No newline at end of file diff --git a/packages/react/react/src/skins/MediaSkinDefault.tsx b/packages/react/react/src/skins/MediaSkinDefault.tsx index 0a94bbb9..e2c0d8a6 100644 --- a/packages/react/react/src/skins/MediaSkinDefault.tsx +++ b/packages/react/react/src/skins/MediaSkinDefault.tsx @@ -3,6 +3,7 @@ import { PauseIcon, PlayIcon } from '@vjs-10/react-icons'; import PlayButton from '../components/PlayButton'; import MuteButton from '../components/MuteButton'; import { VolumeRange } from '../components/VolumeRange'; +import { TimeRange } from '../components/TimeRange'; import { VolumeHighIcon, VolumeLowIcon, @@ -24,6 +25,7 @@ export const MediaSkinDefault: React.FC<{ children: React.ReactNode }> = ({ + {/* @ts-ignore */}