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:
Christian Pillsbury
2025-09-09 07:59:56 -07:00
committed by Christian Pillsbury
co-authored by Claude
parent dad2fea986
commit c29fd2c2c1
8 changed files with 329 additions and 2 deletions
@@ -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>
`;
}