import { CircleNotchIcon, PauseIcon, PlayIcon } from '@phosphor-icons/react' import React, { useCallback, useEffect, useRef, useState } from 'react' import { isDevBuild } from '../../utils/isDevBuild' import { nativePlayerProps } from '../../utils/nativeMediaPlayers' import { renderTypeIcon } from './utils/icons' import { getSourceType } from './utils/mimeType' type TouchEventUnion = MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent const getClientXFromEvent = (e: TouchEventUnion): number => { if ('touches' in e) { return e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0 } return e.clientX } export interface MediaPlayerProps { source: string mimeType: string poster?: string autoPlay?: boolean /** Controlled playing state. When provided, syncs to internal play/pause. */ playing?: boolean loop?: boolean controls?: boolean showProgress?: boolean /** When true, requests muted playback (helps autoplay policies on video). */ muted?: boolean /** * When provided, overrides the default click-to-play-toggle behaviour on the * player container. The play/pause button (which calls stopPropagation) is * unaffected, so inline playback still works. */ onContainerClick?: (e: React.MouseEvent) => void } const MediaPlayer: React.FC = ({ source, mimeType, poster, autoPlay = false, playing: playingProp, loop = false, controls = true, showProgress = false, muted = false, onContainerClick, }) => { // --- Derived --- const sourceType = getSourceType(mimeType) // --- Refs --- const playerRef = useRef(null) const trackRef = useRef(null) const rafRef = useRef(null) const prevPlayingPropRef = useRef(playingProp) // --- State: playback --- const [playing, setPlaying] = useState(autoPlay) const [played, setPlayed] = useState(0) const [seeking, setSeeking] = useState(false) // --- State: UI --- const [scrubberHovered, setScrubberHovered] = useState(false) /** Set when autoplay/play() was rejected so user can start via gesture (no controls UI). */ const [manualPlayRequired, setManualPlayRequired] = useState(false) // --- State: loading --- const [buffering, setBuffering] = useState(false) /** True until the first canPlay fires for the current source — hides controls/spinner behind poster. */ const [initialLoad, setInitialLoad] = useState(true) const [videoAspect, setVideoAspect] = useState(null) // --- Callbacks --- const startPlaybackFromGesture = useCallback(() => { setManualPlayRequired(false) setPlaying(true) }, []) const getFraction = useCallback((e: TouchEventUnion) => { const track = trackRef.current if (!track) return 0 const rect = track.getBoundingClientRect() return Math.max( 0, Math.min(1, (getClientXFromEvent(e) - rect.left) / rect.width) ) }, []) const seekTo = useCallback((fraction: number) => { const el = playerRef.current if (el && el.duration) el.currentTime = fraction * el.duration }, []) const handleTrackPointerDown = ( e: React.MouseEvent | React.TouchEvent ) => { e.stopPropagation() setSeeking(true) const fraction = getFraction(e) setPlayed(fraction) seekTo(fraction) } const handleScrubberKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'ArrowRight') seekTo(Math.min(1, played + 0.05)) if (e.key === 'ArrowLeft') seekTo(Math.max(0, played - 0.05)) }, [seekTo, played] ) // --- Effects --- // Sync controlled playing prop to internal state useEffect(() => { if ( playingProp !== undefined && playingProp !== prevPlayingPropRef.current ) { prevPlayingPropRef.current = playingProp setPlaying(playingProp) } }, [playingProp]) // RAF-driven progress updates useEffect(() => { if (!playing) { if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current) rafRef.current = null } return } const tick = () => { const el = playerRef.current if (el && el.duration && !seeking) setPlayed(el.currentTime / el.duration) rafRef.current = requestAnimationFrame(tick) } rafRef.current = requestAnimationFrame(tick) return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current) } }, [playing, seeking]) // ReactPlayer v3 uses native HTML media elements and does not support a // declarative `playing` prop — playback must be driven imperatively. useEffect(() => { const el = playerRef.current if (!el) return if (playing) { void el.play().catch((err) => { setPlaying(false) setManualPlayRequired(true) if (isDevBuild()) { console.debug('[MediaPlayer] play() failed', err) } }) } else { el.pause() } }, [playing]) // Global seeking listeners useEffect(() => { if (!seeking) return const onMove = (e: MouseEvent | TouchEvent) => setPlayed(getFraction(e)) const onUp = (e: MouseEvent | TouchEvent) => { setSeeking(false) seekTo(getFraction(e)) } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) window.addEventListener('touchmove', onMove, { passive: true }) window.addEventListener('touchend', onUp) return () => { window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) window.removeEventListener('touchmove', onMove) window.removeEventListener('touchend', onUp) } }, [seeking, getFraction, seekTo]) // --- Derived render values --- // Use natural aspect ratio once metadata loads, fall back to 16:9 before then. const aspectStyle = videoAspect ? { aspectRatio: String(videoAspect) } : undefined const aspectClass = !videoAspect ? ' aspect-video' : '' const scrubberPercent = Math.round(played * 100) return (
{ if (onContainerClick) { onContainerClick(e) return } if (manualPlayRequired) return if (controls) setPlaying((p) => !p) }} onKeyDown={(e) => { if (e.key !== 'Enter' && e.key !== ' ') return e.preventDefault() if (onContainerClick) { onContainerClick(e as unknown as React.MouseEvent) return } if (manualPlayRequired) return if (controls) setPlaying((p) => !p) }} > {/* For audio, poster persists as a visual background. For video, hide once loaded. */} {poster && (sourceType === 'audio' || initialLoad) && ( )} {!poster && (sourceType === 'audio' || initialLoad) && (
{renderTypeIcon(mimeType, { className: 'size-12 text-black/20', weight: 'regular', })}
)}
{sourceType === 'audio' ? ( ) : ( )}
{buffering && !manualPlayRequired && (
)} {manualPlayRequired && !controls && (
{ e.stopPropagation() startPlaybackFromGesture() }} onKeyDown={(e) => { if (e.key !== 'Enter' && e.key !== ' ') return e.preventDefault() e.stopPropagation() startPlaybackFromGesture() }} >
)} {showProgress && !controls && (
e.stopPropagation()} onKeyDown={handleScrubberKeyDown} >
)} {controls && (
e.stopPropagation()} onMouseEnter={() => setScrubberHovered(true)} onMouseLeave={() => setScrubberHovered(false)} onKeyDown={handleScrubberKeyDown} >
)}
) } export default MediaPlayer