import {useCallback, useEffect, useRef, useState} from 'react' import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import type React from 'react' import {isFirefox, isTouchDevice} from '#/lib/browser' import {clamp} from '#/lib/numbers' import {atoms as a, useTheme, web} from '#/alf' import {useInteractionState} from '#/components/hooks/useInteractionState' import {formatTime} from './utils' export function Scrubber({ duration, currentTime, onSeek, onSeekEnd, onSeekStart, seekLeft, seekRight, togglePlayPause, drawFocus, }: { duration: number currentTime: number onSeek: (time: number) => void onSeekEnd: () => void onSeekStart: () => void seekLeft: () => void seekRight: () => void togglePlayPause: () => void drawFocus: () => void }) { const {_} = useLingui() const t = useTheme() const [scrubberActive, setScrubberActive] = useState(false) const { state: hovered, onIn: onStartHover, onOut: onEndHover, } = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const [seekPosition, setSeekPosition] = useState(0) const isSeekingRef = useRef(false) const barRef = useRef(null) const circleRef = useRef(null) const seek = useCallback( (evt: React.PointerEvent) => { if (!barRef.current) return const {left, width} = barRef.current.getBoundingClientRect() const x = evt.clientX const percent = clamp((x - left) / width, 0, 1) * duration onSeek(percent) setSeekPosition(percent) }, [duration, onSeek], ) const onPointerDown = useCallback( (evt: React.PointerEvent) => { const target = evt.target if (target instanceof Element) { evt.preventDefault() target.setPointerCapture(evt.pointerId) isSeekingRef.current = true seek(evt) setScrubberActive(true) onSeekStart() } }, [seek, onSeekStart], ) const onPointerMove = useCallback( (evt: React.PointerEvent) => { if (isSeekingRef.current) { evt.preventDefault() seek(evt) } }, [seek], ) const onPointerUp = useCallback( (evt: React.PointerEvent) => { const target = evt.target if (isSeekingRef.current && target instanceof Element) { evt.preventDefault() target.releasePointerCapture(evt.pointerId) isSeekingRef.current = false onSeekEnd() setScrubberActive(false) } }, [onSeekEnd], ) useEffect(() => { // HACK: there's divergent browser behaviour about what to do when // a pointerUp event is fired outside the element that captured the // pointer. Firefox clicks on the element the mouse is over, so we have // to make everything unclickable while seeking -sfn if (isFirefox && scrubberActive) { document.body.classList.add('force-no-clicks') return () => { document.body.classList.remove('force-no-clicks') } } }, [scrubberActive, onSeekEnd]) useEffect(() => { if (!circleRef.current) return if (focused) { const abortController = new AbortController() const {signal} = abortController circleRef.current.addEventListener( 'keydown', evt => { // space: play/pause // arrow left: seek backward // arrow right: seek forward if (evt.key === ' ') { evt.preventDefault() drawFocus() togglePlayPause() } else if (evt.key === 'ArrowLeft') { evt.preventDefault() drawFocus() seekLeft() } else if (evt.key === 'ArrowRight') { evt.preventDefault() drawFocus() seekRight() } }, {signal}, ) return () => abortController.abort() } }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus]) const progress = scrubberActive ? seekPosition : currentTime const progressPercent = (progress / duration) * 100 if (duration < 3) return null return (
{duration > 0 && ( )}
) }