'use client'; import { forwardRef, useCallback, useEffect, useRef, useState, type ButtonHTMLAttributes, type ReactNode, } from 'react'; import { Volume2, VolumeX, Volume1 } from 'lucide-react'; import { cn } from '@djangocfg/ui-core/lib'; import { Popover, PopoverContent, PopoverTrigger, Slider, Tooltip, TooltipContent, TooltipTrigger, } from '@djangocfg/ui-core/components'; export interface VolumeProps { readonly className?: string; /** * Reserved for backwards compatibility. The control is always a single * icon button that opens a popover with a vertical slider on hover/focus, * so this flag is now a no-op. Kept so existing consumers * (``) don't break. */ readonly iconOnly?: boolean; /** Mute-button tooltip copy. Defaults to `"Volume"`. Pass `false` to opt out. */ readonly muteLabel?: ReactNode | false; } // Hide the popover slider on iOS Safari — `video.volume` is read-only // there (controlled by hardware buttons), so a JS slider does nothing. // The trigger still toggles mute, which iOS *does* honour. function isIosSafari(): boolean { if (typeof navigator === 'undefined') return false; const ua = navigator.userAgent; const iOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && (navigator as { maxTouchPoints?: number }).maxTouchPoints! > 1); return iOS; } // media-chrome request event names. Dispatched on any descendant of // ``; the controller catches them and updates its // internal store, which then propagates state back via attributes. const MEDIA_VOLUME_REQUEST = 'mediavolumerequest'; const MEDIA_MUTE_REQUEST = 'mediamuterequest'; const MEDIA_UNMUTE_REQUEST = 'mediaunmuterequest'; /** * Click-style trigger that visually matches the rest of the VideoPlayer * control bar. Mirrors `.video-player__control` styling from * `styles/video-player.css` so it slots in next to `` & * friends without standing out. */ const TriggerButton = forwardRef< HTMLButtonElement, ButtonHTMLAttributes >(function TriggerButton({ className, children, ...rest }, ref) { return ( ); }); /** * VideoPlayer volume control. * * - **Click** the trigger → mute / unmute toggle (dispatches a * `mediamuterequest` / `mediaunmuterequest` so media-chrome's store * stays the single source of truth — no parallel state machine). * - **Hover / keyboard focus** → opens a popover containing a vertical * `` (0..100). Drag = volume change via `mediavolumerequest`. * - Icon reflects level (mute / low / high) and mirrors `media.muted`. * * State is read directly off the underlying `HTMLMediaElement` (located * via the closest ``'s `.media` accessor); we never * shadow media-chrome's store. */ export function Volume({ className, muteLabel }: VolumeProps) { const triggerRef = useRef(null); const [volume, setVolume] = useState(1); const [muted, setMuted] = useState(false); const [open, setOpen] = useState(false); const [iosSafari] = useState(isIosSafari); // Resolve & track the underlying HTMLMediaElement so we can read live // volume / muted. media-chrome fires `mediaelementchange` on the // controller whenever the slotted media element swaps (e.g. source // change), so we re-bind listeners on each swap. useEffect(() => { const trigger = triggerRef.current; if (!trigger) return; const controller = trigger.closest('media-controller') as | (HTMLElement & { media?: HTMLMediaElement | null }) | null; if (!controller) return; let media: HTMLMediaElement | null = controller.media ?? null; const sync = () => { if (!media) return; setVolume(media.volume); setMuted(media.muted); }; const bind = (el: HTMLMediaElement | null) => { if (!el) return; el.addEventListener('volumechange', sync); sync(); }; const unbind = (el: HTMLMediaElement | null) => { if (!el) return; el.removeEventListener('volumechange', sync); }; bind(media); const onMediaChange = (e: Event) => { unbind(media); const detail = (e as CustomEvent).detail as HTMLMediaElement | null; media = detail ?? controller.media ?? null; bind(media); }; controller.addEventListener('mediaelementchange', onMediaChange); return () => { unbind(media); controller.removeEventListener('mediaelementchange', onMediaChange); }; }, []); const dispatch = useCallback((name: string, detail?: number) => { const trigger = triggerRef.current; if (!trigger) return; trigger.dispatchEvent( new CustomEvent(name, { detail, bubbles: true, composed: true }), ); }, []); const toggleMute = useCallback(() => { dispatch(muted ? MEDIA_UNMUTE_REQUEST : MEDIA_MUTE_REQUEST); }, [dispatch, muted]); const onSlider = useCallback( (value: number) => { dispatch(MEDIA_VOLUME_REQUEST, value); // If the user nudges the slider above zero, treat that as an // implicit unmute — matches what the native HTML5 volume slider does. if (value > 0 && muted) dispatch(MEDIA_UNMUTE_REQUEST); }, [dispatch, muted], ); const effectiveVolume = muted ? 0 : volume; const Icon = muted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2; const trigger = ( ); // On iOS Safari volume is hardware-only — skip the popover entirely // and let the trigger work as a plain mute/unmute button. if (iosSafari) { if (muteLabel === false) return
{trigger}
; return (
{trigger} {muteLabel ?? 'Mute / unmute'}
); } return (
setOpen(true)} onPointerLeave={() => setOpen(false)} onFocus={() => setOpen(true)} onBlur={(e) => { // Don't close when focus moves between the trigger and the // popover content (both live in this wrapper / its portal). if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setOpen(false); } }} > {trigger} e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} onPointerEnter={() => setOpen(true)} onPointerLeave={() => setOpen(false)} className="flex w-12 flex-col items-center gap-2 p-3" > {Math.round(effectiveVolume * 100)} onSlider(v)} aria-label="Volume" className="h-24" />
); }