feat(core,html,react): implement VolumeRange component with integrated state management

Add volume range slider component following established hook-style architecture.
Includes state synchronization improvements between volume and mute controls.

Core changes:
- Add volumeRangeStateDefinition with volume, muted, volumeLevel state
- Enhance audible state mediator with volume/mute coordination logic
- Export VolumeRange state definition from media-store

HTML component:
- Create media-volume-range web component using hook architecture
- Add native range input with accessibility attributes
- Integrate into default skin with styled slider

React component:
- Create VolumeRange component using useMediaSelector for state reactivity
- Fix state subscription to properly update slider thumb position
- Add CSS module styling matching HTML implementation

Bug fixes:
- Fix React VolumeRange thumb position updates with proper state subscription
- Improve mute button logic to use volumeLevel instead of muted state
- Add volume/mute state coordination (unmute sets volume, volume > 0 unmutes)

🤖 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 1e12f66f16
commit 2282f4799b
12 changed files with 306 additions and 6 deletions
@@ -21,7 +21,7 @@ export class MuteButtonBase extends MediaChromeButton {
const state = this._state;
if (state) {
if (type === 'click') {
if (state.muted) {
if (state.volumeLevel === 'off') {
state.requestUnmute();
} else {
state.requestMute();
@@ -0,0 +1,120 @@
import {
toConnectedHTMLComponent,
StateHook,
PropsHook,
} from '../utils/component-factory';
import { volumeRangeStateDefinition } from '@vjs-10/media-store';
export class VolumeRangeBase extends HTMLElement {
_state:
| {
volume: number;
muted: boolean;
volumeLevel: string;
requestVolumeChange: (volume: number) => void;
}
| undefined;
_input: HTMLInputElement;
constructor() {
super();
this._input = document.createElement('input');
this._input.type = 'range';
this._input.min = '0';
this._input.max = '1';
this._input.step = '0.01';
this._input.addEventListener('input', this.handleInput.bind(this));
this.appendChild(this._input);
}
handleInput() {
if (this._state) {
this._state.requestVolumeChange(parseFloat(this._input.value));
}
}
get volume() {
return this._state?.volume;
}
get muted() {
return this._state?.muted;
}
get volumeLevel() {
return this._state?.volumeLevel;
}
_update(props: any, state: any) {
this._state = state;
const displayValue = state.muted ? 0 : state.volume;
this._input.value = displayValue.toString();
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-volume-level', props['data-volume-level']);
this.toggleAttribute('data-muted', props['data-muted']);
}
}
/**
* VolumeRange state hook - equivalent to React's useVolumeRangeState
* Handles media store state subscription and transformation
*/
export const useVolumeRangeState: StateHook<{
volume: number;
muted: boolean;
volumeLevel: string;
}> = {
keys: volumeRangeStateDefinition.keys,
transform: (rawState, mediaStore) => ({
...volumeRangeStateDefinition.stateTransform(rawState),
...volumeRangeStateDefinition.createRequestMethods(mediaStore.dispatch),
}),
};
/**
* VolumeRange props hook - equivalent to React's useVolumeRangeProps
* Handles element attributes and properties based on state
*/
export const useVolumeRangeProps: PropsHook<{
volume: number;
muted: boolean;
volumeLevel: string;
}> = (state, _element) => {
const displayValue = state.muted ? 0 : state.volume;
const baseProps: Record<string, any> = {
/** data attributes/props */
['data-muted']: state.muted,
['data-volume-level']: state.volumeLevel,
/** aria attributes/props */
['aria-label']: 'Volume',
['aria-valuetext']: `${Math.round(displayValue * 100)}%`,
/** input props */
disabled: false,
};
return baseProps;
};
/**
* Connected VolumeRange component using hook-style architecture
* Equivalent to React's VolumeRange = toConnectedComponent(...)
*/
export const VolumeRange = toConnectedHTMLComponent(
VolumeRangeBase,
useVolumeRangeState,
useVolumeRangeProps,
'VolumeRange',
);
// 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-volume-range')) {
// @ts-ignore - Custom element constructor compatibility
globalThis.customElements.define('media-volume-range', VolumeRange);
}
export default VolumeRange;
+1
View File
@@ -4,6 +4,7 @@ export * as MediaThemeDefault from './skins/media-skin-default.js';
// New hook-style components
export { PlayButton } from './components/media-play-button.js';
export { MuteButton } from './components/media-mute-button.js';
export { VolumeRange } from './components/media-volume-range.js';
export function defineVjsPlayer() {
/** @TODO - Reimplement me (at least as a POC) (CJP) */
@@ -3,6 +3,7 @@ import { MediaSkin } from '../media-skin';
import '../media-container';
import '../components/media-play-button';
import '../components/media-mute-button';
import '../components/media-volume-range';
import '@vjs-10/html-icons';
export function getTemplateHTML() {
@@ -78,6 +79,37 @@ export function getTemplateHTML() {
.spacer {
flex-grow: 1;
}
/* Volume Range UI/Styles */
media-volume-range {
margin: 0 8px;
}
media-volume-range input[type="range"] {
width: 80px;
height: 4px;
background: rgb(50 50 50);
outline: none;
border-radius: 2px;
}
media-volume-range input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
background: rgb(238 238 238);
border-radius: 50%;
cursor: pointer;
}
media-volume-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>
@@ -94,6 +126,7 @@ export function getTemplateHTML() {
<media-volume-low-icon class="icon volume-low-icon"></media-volume-low-icon>
<media-volume-off-icon class="icon volume-off-icon"></media-volume-off-icon>
</media-mute-button>
<media-volume-range></media-volume-range>
</div>
<div>
</media-container>