mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
committed by
Christian Pillsbury
co-authored by
Claude
parent
1e12f66f16
commit
2282f4799b
@@ -0,0 +1,44 @@
|
||||
export interface VolumeRangeState {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
volumeLevel: 'high' | 'medium' | 'low' | 'off';
|
||||
}
|
||||
|
||||
export interface VolumeRangeMethods {
|
||||
requestVolumeChange: (volume: number) => void;
|
||||
}
|
||||
|
||||
export interface VolumeRangeStateDefinition {
|
||||
keys: string[];
|
||||
stateTransform: (rawState: any) => VolumeRangeState;
|
||||
createRequestMethods: (
|
||||
dispatch: (action: { type: string; detail?: any }) => void,
|
||||
) => VolumeRangeMethods;
|
||||
}
|
||||
|
||||
/**
|
||||
* VolumeRange state definition
|
||||
* Defines the core state logic that can be shared between implementations
|
||||
*/
|
||||
export const volumeRangeStateDefinition: VolumeRangeStateDefinition = {
|
||||
keys: ['volume', 'muted', 'volumeLevel'],
|
||||
stateTransform: (rawState: any) => ({
|
||||
volume: rawState.volume ?? 1,
|
||||
muted: rawState.muted ?? false,
|
||||
volumeLevel: rawState.volumeLevel ?? 'high',
|
||||
}),
|
||||
createRequestMethods: (dispatch) => ({
|
||||
/**
|
||||
* @TODO Unmuting is owned by the "request-map" in media-chrome.
|
||||
* The closest equivalent to that is the "actions" in the current architecture.
|
||||
* Should unmuting live here (even if "here" gets promoted to the state model) or "actions" or state setter?
|
||||
* Currently this is solved in the state setter (as is the corresponding unmute behavior). See state-mediators/audible for details. (CJP)
|
||||
**/
|
||||
requestVolumeChange: (volume: number) => {
|
||||
// if (volume > 0) {
|
||||
// dispatch({ type: 'unmuterequest' });
|
||||
// }
|
||||
dispatch({ type: 'volumerequest', detail: volume });
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -4,4 +4,5 @@ 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';
|
||||
export * from './component-state-definitions/mute-button';
|
||||
export * from './component-state-definitions/volume-range';
|
||||
@@ -8,6 +8,9 @@ export const audible = {
|
||||
const { media } = stateOwners;
|
||||
if (!media) return;
|
||||
media.muted = value;
|
||||
if (!value && !media.volume) {
|
||||
media.volume = 0.25;
|
||||
}
|
||||
},
|
||||
mediaEvents: ['volumechange'],
|
||||
actions: {
|
||||
@@ -24,8 +27,12 @@ export const audible = {
|
||||
set(value: number, stateOwners: any) {
|
||||
const { media } = stateOwners;
|
||||
if (!media) return;
|
||||
if (!Number.isFinite(+value)) return;
|
||||
media.volume = +value;
|
||||
const numericValue = +value;
|
||||
if (!Number.isFinite(numericValue)) return;
|
||||
media.volume = numericValue;
|
||||
if (numericValue > 0) {
|
||||
media.mute = false;
|
||||
}
|
||||
},
|
||||
mediaEvents: ['volumechange'],
|
||||
actions: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -69,7 +69,7 @@ export const renderMuteButton = (
|
||||
onClick={() => {
|
||||
/** @ts-ignore */
|
||||
if (props.disabled) return;
|
||||
if (state.muted) {
|
||||
if (state.volumeLevel === 'off') {
|
||||
state.requestUnmute();
|
||||
} else {
|
||||
state.requestMute();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
shallowEqual,
|
||||
useMediaSelector,
|
||||
useMediaStore,
|
||||
} from '@vjs-10/react-media-store';
|
||||
import { volumeRangeStateDefinition } from '@vjs-10/media-store';
|
||||
|
||||
interface VolumeRangeProps {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const VolumeRange: React.FC<VolumeRangeProps> = ({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const mediaStore = useMediaStore();
|
||||
|
||||
// Use useMediaSelector to properly subscribe to state changes
|
||||
const mediaState = useMediaSelector(
|
||||
volumeRangeStateDefinition.stateTransform,
|
||||
shallowEqual,
|
||||
);
|
||||
|
||||
const methods = React.useMemo(
|
||||
() => volumeRangeStateDefinition.createRequestMethods(mediaStore.dispatch),
|
||||
[mediaStore],
|
||||
);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
methods.requestVolumeChange(parseFloat(e.target.value));
|
||||
};
|
||||
|
||||
const displayValue = mediaState.muted ? 0 : mediaState.volume;
|
||||
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
aria-label="Volume"
|
||||
aria-valuetext={`${Math.round(displayValue * 100)}%`}
|
||||
data-muted={mediaState.muted}
|
||||
data-volume-level={mediaState.volumeLevel}
|
||||
className={className}
|
||||
style={style}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,4 +2,9 @@
|
||||
|
||||
export * from '@vjs-10/react-media-store';
|
||||
export * from './skins/MediaSkinDefault';
|
||||
export { Video, MediaElementVideo } from './components/Video';
|
||||
export { Video, MediaElementVideo } from './components/Video';
|
||||
|
||||
// New hook-style components
|
||||
export { PlayButton } from './components/PlayButton';
|
||||
export { MuteButton } from './components/MuteButton';
|
||||
export { VolumeRange } from './components/VolumeRange';
|
||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
||||
import { PauseIcon, PlayIcon } from '@vjs-10/react-icons';
|
||||
import PlayButton from '../components/PlayButton';
|
||||
import MuteButton from '../components/MuteButton';
|
||||
import { VolumeRange } from '../components/VolumeRange';
|
||||
import {
|
||||
VolumeHighIcon,
|
||||
VolumeLowIcon,
|
||||
@@ -35,6 +36,7 @@ export const MediaSkinDefault: React.FC<{ children: React.ReactNode }> = ({
|
||||
className={`${styles.Icon} ${styles.VolumeOffIcon}`}
|
||||
></VolumeOffIcon>
|
||||
</MuteButton>
|
||||
<VolumeRange className={styles.VolumeRange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,38 @@
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Volume Range UI/Styles */
|
||||
.VolumeRange {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.VolumeRange input[type="range"] {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: rgb(50 50 50);
|
||||
outline: none;
|
||||
border-radius: 2px;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.VolumeRange input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: rgb(238 238 238);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.VolumeRange input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: rgb(238 238 238);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Base Button Styling */
|
||||
.MediaPlayButton {
|
||||
border: none;
|
||||
|
||||
Reference in New Issue
Block a user