import React, { useCallback, useEffect, useRef, useState, useSyncExternalStore, } from 'react' import {Pressable, View} from 'react-native' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import type Hls from 'hls.js' import {isIPhoneWeb} from '#/platform/detection' import { useAutoplayDisabled, useSetSubtitlesEnabled, useSubtitlesEnabled, } from '#/state/preferences' import {atoms as a, useTheme, web} from '#/alf' import {Button} from '#/components/Button' import {useInteractionState} from '#/components/hooks/useInteractionState' import { ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, } from '#/components/icons/ArrowsDiagonal' import { CC_Filled_Corner0_Rounded as CCActiveIcon, CC_Stroke2_Corner0_Rounded as CCInactiveIcon, } from '#/components/icons/CC' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' export function Controls({ videoRef, hlsRef, active, setActive, focused, setFocused, onScreen, fullscreenRef, hasSubtitleTrack, }: { videoRef: React.RefObject hlsRef: React.RefObject active: boolean setActive: () => void focused: boolean setFocused: (focused: boolean) => void onScreen: boolean fullscreenRef: React.RefObject hasSubtitleTrack: boolean }) { const { play, pause, playing, muted, toggleMute, togglePlayPause, currentTime, duration, buffering, error, canPlay, } = useVideoUtils(videoRef) const t = useTheme() const {_} = useLingui() const subtitlesEnabled = useSubtitlesEnabled() const setSubtitlesEnabled = useSetSubtitlesEnabled() const { state: hovered, onIn: onMouseEnter, onOut: onMouseLeave, } = useInteractionState() const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) const onKeyDown = useCallback(() => { setInteractingViaKeypress(true) }, []) useEffect(() => { if (interactingViaKeypress) { document.addEventListener('click', () => setInteractingViaKeypress(false)) return () => { document.removeEventListener('click', () => setInteractingViaKeypress(false), ) } } }, [interactingViaKeypress]) // pause + unfocus when another video is active useEffect(() => { if (!active) { pause() setFocused(false) } }, [active, pause, setFocused]) // autoplay/pause based on visibility const autoplayDisabled = useAutoplayDisabled() useEffect(() => { if (active && !autoplayDisabled) { if (onScreen) { play() } else { pause() } } }, [onScreen, pause, active, play, autoplayDisabled]) // use minimal quality when not focused useEffect(() => { if (!hlsRef.current) return if (focused) { // auto decide quality based on network conditions hlsRef.current.autoLevelCapping = -1 } else { hlsRef.current.autoLevelCapping = 0 } }, [hlsRef, focused]) useEffect(() => { if (!hlsRef.current) return if (hasSubtitleTrack && subtitlesEnabled && canPlay) { hlsRef.current.subtitleTrack = 0 } else { hlsRef.current.subtitleTrack = -1 } }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) // clicking on any button should focus the player, if it's not already focused const drawFocus = useCallback(() => { if (!active) { setActive() } setFocused(true) }, [active, setActive, setFocused]) const onPressEmptySpace = useCallback(() => { if (!focused) { drawFocus() } else { togglePlayPause() } }, [togglePlayPause, drawFocus, focused]) const onPressPlayPause = useCallback(() => { drawFocus() togglePlayPause() }, [drawFocus, togglePlayPause]) const onPressSubtitles = useCallback(() => { drawFocus() setSubtitlesEnabled(!subtitlesEnabled) }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) const onPressMute = useCallback(() => { drawFocus() toggleMute() }, [drawFocus, toggleMute]) const onPressFullscreen = useCallback(() => { drawFocus() toggleFullscreen() }, [drawFocus, toggleFullscreen]) const showControls = (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered) return (
{ evt.stopPropagation() setInteractingViaKeypress(false) }} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onFocus={onFocus} onBlur={onBlur} onKeyDown={onKeyDown}> {formatTime(currentTime)} / {formatTime(duration)} {hasSubtitleTrack && ( )} {!isIPhoneWeb && ( )} {(showControls || !focused) && ( {duration > 0 && ( )} )} {(buffering || error) && ( {buffering && } {error && ( An error occurred )} )}
) } const btnProps = { variant: 'ghost', shape: 'round', size: 'medium', style: a.p_2xs, hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'}, } as const function formatTime(time: number) { if (isNaN(time)) { return '--' } time = Math.round(time) const minutes = Math.floor(time / 60) const seconds = String(time % 60).padStart(2, '0') return `${minutes}:${seconds}` } function useVideoUtils(ref: React.RefObject) { const [playing, setPlaying] = useState(false) const [muted, setMuted] = useState(true) const [currentTime, setCurrentTime] = useState(0) const [duration, setDuration] = useState(0) const [buffering, setBuffering] = useState(false) const [error, setError] = useState(false) const [canPlay, setCanPlay] = useState(false) const playWhenReadyRef = useRef(false) useEffect(() => { if (!ref.current) return let bufferingTimeout: ReturnType | undefined function round(num: number) { return Math.round(num * 100) / 100 } // Initial values setCurrentTime(round(ref.current.currentTime) || 0) setDuration(round(ref.current.duration) || 0) setMuted(ref.current.muted) setPlaying(!ref.current.paused) const handleTimeUpdate = () => { if (!ref.current) return setCurrentTime(round(ref.current.currentTime) || 0) } const handleDurationChange = () => { if (!ref.current) return setDuration(round(ref.current.duration) || 0) } const handlePlay = () => { setPlaying(true) } const handlePause = () => { setPlaying(false) } const handleVolumeChange = () => { if (!ref.current) return setMuted(ref.current.muted) } const handleError = () => { setError(true) } const handleCanPlay = () => { setBuffering(false) setCanPlay(true) if (!ref.current) return if (playWhenReadyRef.current) { ref.current.play() playWhenReadyRef.current = false } } const handleCanPlayThrough = () => { setBuffering(false) } const handleWaiting = () => { if (bufferingTimeout) clearTimeout(bufferingTimeout) bufferingTimeout = setTimeout(() => { setBuffering(true) }, 200) // Delay to avoid frequent buffering state changes } const handlePlaying = () => { if (bufferingTimeout) clearTimeout(bufferingTimeout) setBuffering(false) setError(false) } const handleSeeking = () => { setBuffering(true) } const handleSeeked = () => { setBuffering(false) } const handleStalled = () => { if (bufferingTimeout) clearTimeout(bufferingTimeout) bufferingTimeout = setTimeout(() => { setBuffering(true) }, 200) // Delay to avoid frequent buffering state changes } const handleEnded = () => { setPlaying(false) setBuffering(false) setError(false) } const abortController = new AbortController() ref.current.addEventListener('timeupdate', handleTimeUpdate, { signal: abortController.signal, }) ref.current.addEventListener('durationchange', handleDurationChange, { signal: abortController.signal, }) ref.current.addEventListener('play', handlePlay, { signal: abortController.signal, }) ref.current.addEventListener('pause', handlePause, { signal: abortController.signal, }) ref.current.addEventListener('volumechange', handleVolumeChange, { signal: abortController.signal, }) ref.current.addEventListener('error', handleError, { signal: abortController.signal, }) ref.current.addEventListener('canplay', handleCanPlay, { signal: abortController.signal, }) ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { signal: abortController.signal, }) ref.current.addEventListener('waiting', handleWaiting, { signal: abortController.signal, }) ref.current.addEventListener('playing', handlePlaying, { signal: abortController.signal, }) ref.current.addEventListener('seeking', handleSeeking, { signal: abortController.signal, }) ref.current.addEventListener('seeked', handleSeeked, { signal: abortController.signal, }) ref.current.addEventListener('stalled', handleStalled, { signal: abortController.signal, }) ref.current.addEventListener('ended', handleEnded, { signal: abortController.signal, }) return () => { abortController.abort() clearTimeout(bufferingTimeout) } }, [ref]) const play = useCallback(() => { if (!ref.current) return if (ref.current.ended) { ref.current.currentTime = 0 } if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { playWhenReadyRef.current = true } else { const promise = ref.current.play() if (promise !== undefined) { promise.catch(err => { console.error('Error playing video:', err) }) } } }, [ref]) const pause = useCallback(() => { if (!ref.current) return ref.current.pause() playWhenReadyRef.current = false }, [ref]) const togglePlayPause = useCallback(() => { if (!ref.current) return if (ref.current.paused) { play() } else { pause() } }, [ref, play, pause]) const mute = useCallback(() => { if (!ref.current) return ref.current.muted = true }, [ref]) const unmute = useCallback(() => { if (!ref.current) return ref.current.muted = false }, [ref]) const toggleMute = useCallback(() => { if (!ref.current) return ref.current.muted = !ref.current.muted }, [ref]) return { play, pause, togglePlayPause, duration, currentTime, playing, muted, mute, unmute, toggleMute, buffering, error, canPlay, } } function fullscreenSubscribe(onChange: () => void) { document.addEventListener('fullscreenchange', onChange) return () => document.removeEventListener('fullscreenchange', onChange) } function useFullscreen(ref: React.RefObject) { const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () => Boolean(document.fullscreenElement), ) const toggleFullscreen = useCallback(() => { if (isFullscreen) { document.exitFullscreen() } else { if (!ref.current) return ref.current.requestFullscreen() } }, [isFullscreen, ref]) return [isFullscreen, toggleFullscreen] as const }