mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(media-store,html,react): implement TimeRange component with hook-style architecture
- Add timeRangeStateDefinition in core media-store package for currentTime/duration state - Create HTML TimeRange component using handleEvent pattern and <input type="range"> - Create React TimeRange component using render function pattern - Update HTML and React skins to include TimeRange in control bars with proper styling - TimeRange handles seek requests and displays current playback position - Follow established architectural patterns from PlayButton/MuteButton/VolumeRange components 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
Christian Pillsbury
co-authored by
Claude
parent
dad2fea986
commit
c29fd2c2c1
@@ -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 });
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -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';
|
||||
export * from './component-state-definitions/volume-range';
|
||||
export * from './component-state-definitions/time-range';
|
||||
@@ -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<string, any> = {
|
||||
/** 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;
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
<media-container>
|
||||
<slot name="media" slot="media"></slot>
|
||||
@@ -121,6 +154,7 @@ export function getTemplateHTML() {
|
||||
<media-play-icon class="icon play-icon"></media-play-icon>
|
||||
<media-pause-icon class="icon pause-icon"></media-pause-icon>
|
||||
</media-play-button>
|
||||
<media-time-range></media-time-range>
|
||||
<media-mute-button class="button">
|
||||
<media-volume-high-icon class="icon volume-high-icon"></media-volume-high-icon>
|
||||
<media-volume-low-icon class="icon volume-low-icon"></media-volume-low-icon>
|
||||
@@ -128,7 +162,7 @@ export function getTemplateHTML() {
|
||||
</media-mute-button>
|
||||
<media-volume-range></media-volume-range>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
</media-container>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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<useTimeRangeState>;
|
||||
|
||||
export const useTimeRangeProps = (
|
||||
props: React.PropsWithChildren<{ [k: string]: any }>,
|
||||
state: ReturnType<typeof useTimeRangeState>,
|
||||
) => {
|
||||
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<string, any> = {
|
||||
/** 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<useTimeRangeProps>;
|
||||
|
||||
export const renderTimeRange = (
|
||||
props: TimeRangeProps,
|
||||
state: TimeRangeState,
|
||||
) => {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
/** @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;
|
||||
@@ -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 }> = ({
|
||||
<PlayIcon className={styles.PlayIcon}></PlayIcon>
|
||||
<PauseIcon className={styles.PauseIcon}></PauseIcon>
|
||||
</PlayButton>
|
||||
<TimeRange className={styles.TimeRange} />
|
||||
{/* @ts-ignore */}
|
||||
<MuteButton className={`${styles.Button} ${styles.MediaMuteButton}`}>
|
||||
<VolumeHighIcon
|
||||
|
||||
@@ -67,6 +67,39 @@
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Time Range UI/Styles */
|
||||
.TimeRange {
|
||||
flex-grow: 1;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.TimeRange input[type="range"] {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgb(50 50 50);
|
||||
outline: none;
|
||||
border-radius: 2px;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.TimeRange input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: rgb(238 238 238);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.TimeRange input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: rgb(238 238 238);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Volume Range UI/Styles */
|
||||
.VolumeRange {
|
||||
margin: 0 8px;
|
||||
|
||||
Reference in New Issue
Block a user