diff --git a/packages/core/media-store/src/component-state-definitions/volume-range.ts b/packages/core/media-store/src/component-state-definitions/volume-range.ts new file mode 100644 index 00000000..7561816c --- /dev/null +++ b/packages/core/media-store/src/component-state-definitions/volume-range.ts @@ -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 }); + }, + }), +}; diff --git a/packages/core/media-store/src/index.ts b/packages/core/media-store/src/index.ts index fa3db319..b6727987 100644 --- a/packages/core/media-store/src/index.ts +++ b/packages/core/media-store/src/index.ts @@ -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'; \ No newline at end of file +export * from './component-state-definitions/mute-button'; +export * from './component-state-definitions/volume-range'; \ No newline at end of file diff --git a/packages/core/media-store/src/state-mediators/audible.ts b/packages/core/media-store/src/state-mediators/audible.ts index 5ac584b4..7e70736b 100644 --- a/packages/core/media-store/src/state-mediators/audible.ts +++ b/packages/core/media-store/src/state-mediators/audible.ts @@ -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: { diff --git a/packages/html/html/src/components/media-mute-button.ts b/packages/html/html/src/components/media-mute-button.ts index cf35e651..1a010262 100644 --- a/packages/html/html/src/components/media-mute-button.ts +++ b/packages/html/html/src/components/media-mute-button.ts @@ -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(); diff --git a/packages/html/html/src/components/media-volume-range.ts b/packages/html/html/src/components/media-volume-range.ts new file mode 100644 index 00000000..f50615ca --- /dev/null +++ b/packages/html/html/src/components/media-volume-range.ts @@ -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 = { + /** 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; \ No newline at end of file diff --git a/packages/html/html/src/index.ts b/packages/html/html/src/index.ts index 898e9e6f..2185fd2f 100644 --- a/packages/html/html/src/index.ts +++ b/packages/html/html/src/index.ts @@ -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) */ diff --git a/packages/html/html/src/skins/media-skin-default.ts b/packages/html/html/src/skins/media-skin-default.ts index 9e8f1a70..1141248f 100644 --- a/packages/html/html/src/skins/media-skin-default.ts +++ b/packages/html/html/src/skins/media-skin-default.ts @@ -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; + } @@ -94,6 +126,7 @@ export function getTemplateHTML() { +
diff --git a/packages/react/react/src/components/MuteButton.tsx b/packages/react/react/src/components/MuteButton.tsx index ace1046a..5fce8a38 100644 --- a/packages/react/react/src/components/MuteButton.tsx +++ b/packages/react/react/src/components/MuteButton.tsx @@ -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(); diff --git a/packages/react/react/src/components/VolumeRange.tsx b/packages/react/react/src/components/VolumeRange.tsx new file mode 100644 index 00000000..39fbbdc9 --- /dev/null +++ b/packages/react/react/src/components/VolumeRange.tsx @@ -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 = ({ + 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) => { + methods.requestVolumeChange(parseFloat(e.target.value)); + }; + + const displayValue = mediaState.muted ? 0 : mediaState.volume; + + return ( + + ); +}; diff --git a/packages/react/react/src/index.tsx b/packages/react/react/src/index.tsx index a250bba3..67a269f6 100644 --- a/packages/react/react/src/index.tsx +++ b/packages/react/react/src/index.tsx @@ -2,4 +2,9 @@ export * from '@vjs-10/react-media-store'; export * from './skins/MediaSkinDefault'; -export { Video, MediaElementVideo } from './components/Video'; \ No newline at end of file +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'; \ 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 534d0840..0a94bbb9 100644 --- a/packages/react/react/src/skins/MediaSkinDefault.tsx +++ b/packages/react/react/src/skins/MediaSkinDefault.tsx @@ -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}`} > +
diff --git a/packages/react/react/src/skins/styles.module.css b/packages/react/react/src/skins/styles.module.css index 91f72a0b..3fa03c96 100644 --- a/packages/react/react/src/skins/styles.module.css +++ b/packages/react/react/src/skins/styles.module.css @@ -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;