From 45f0f7eefecae1922c2f30d4e7760d2b93b1ae56 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 13 Jun 2025 12:05:41 -0500 Subject: Port post embeds to new arch (#7408) * Direct port of embeds to new arch (cherry picked from commit cc3fa1f6cea396dd9222486c633a508bfee1ecd6) * Re-org * Split out ListEmbed and FeedEmbed * Split out ImageEmbed * DRY up a bit * Port over ExternalLinkEmbed * Port over Player and Gif embeds * Migrate ComposerReplyTo * Replace other usages of old post-embeds * Migrate view contexts * Copy pasta VideoEmbed * Copy pasta GifEmbed * Swap in new file location * Clean up * Fix up native * Add back in correct moderation on List and Feed embeds * Format * Prettier * delete old video utils * move bandwidth-estimate.ts * Remove log * Add LazyQuoteEmbed for composer use * Clean up unused things * Remove remaining items * Prettier * Fix imports * Handle nested quotes same as prod * Add back silenced error handling * Fix lint --------- Co-authored-by: Samuel Newman --- .../VideoEmbedInner/web-controls/ControlButton.tsx | 42 ++ .../VideoEmbedInner/web-controls/Scrubber.tsx | 238 ++++++++++++ .../web-controls/VideoControls.native.tsx | 3 + .../VideoEmbedInner/web-controls/VideoControls.tsx | 427 +++++++++++++++++++++ .../VideoEmbedInner/web-controls/VolumeControl.tsx | 110 ++++++ .../VideoEmbedInner/web-controls/utils.tsx | 240 ++++++++++++ 6 files changed, 1060 insertions(+) create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx (limited to 'src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls') diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx new file mode 100644 index 000000000..1b69a3e25 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import {SvgProps} from 'react-native-svg' + +import {PressableWithHover} from '#/view/com/util/PressableWithHover' +import {atoms as a, useTheme, web} from '#/alf' + +export function ControlButton({ + active, + activeLabel, + inactiveLabel, + activeIcon: ActiveIcon, + inactiveIcon: InactiveIcon, + onPress, +}: { + active: boolean + activeLabel: string + inactiveLabel: string + activeIcon: React.ComponentType> + inactiveIcon: React.ComponentType> + onPress: () => void +}) { + const t = useTheme() + return ( + + {active ? ( + + ) : ( + + )} + + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx new file mode 100644 index 000000000..96960bad4 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx @@ -0,0 +1,238 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/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 + + return ( + +
+ + {duration > 0 && ( + + )} + +
+ +
+
+
+ ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx new file mode 100644 index 000000000..e2e24ed36 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx @@ -0,0 +1,3 @@ +export function Controls() { + throw new Error('VideoWebControls may not be used on native.') +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx new file mode 100644 index 000000000..6d14deafc --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx @@ -0,0 +1,427 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import {Pressable, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import type Hls from 'hls.js' + +import {isTouchDevice} from '#/lib/browser' +import {clamp} from '#/lib/numbers' +import {isIPhoneWeb} from '#/platform/detection' +import { + useAutoplayDisabled, + useSetSubtitlesEnabled, + useSubtitlesEnabled, +} from '#/state/preferences' +import {atoms as a, useTheme, web} from '#/alf' +import {useIsWithinMessage} from '#/components/dms/MessageContext' +import {useFullscreen} from '#/components/hooks/useFullscreen' +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 {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' +import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {TimeIndicator} from '../TimeIndicator' +import {ControlButton} from './ControlButton' +import {Scrubber} from './Scrubber' +import {formatTime, useVideoElement} from './utils' +import {VolumeControl} from './VolumeControl' + +export function Controls({ + videoRef, + hlsRef, + active, + setActive, + focused, + setFocused, + onScreen, + fullscreenRef, + hlsLoading, + hasSubtitleTrack, +}: { + videoRef: React.RefObject + hlsRef: React.RefObject + active: boolean + setActive: () => void + focused: boolean + setFocused: (focused: boolean) => void + onScreen: boolean + fullscreenRef: React.RefObject + hlsLoading: boolean + hasSubtitleTrack: boolean +}) { + const { + play, + pause, + playing, + muted, + changeMuted, + togglePlayPause, + currentTime, + duration, + buffering, + error, + canPlay, + } = useVideoElement(videoRef) + const t = useTheme() + const {_} = useLingui() + const subtitlesEnabled = useSubtitlesEnabled() + const setSubtitlesEnabled = useSetSubtitlesEnabled() + const { + state: hovered, + onIn: onHover, + onOut: onEndHover, + } = useInteractionState() + const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) + const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() + const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) + const showSpinner = hlsLoading || buffering + const { + state: volumeHovered, + onIn: onVolumeHover, + onOut: onVolumeEndHover, + } = useInteractionState() + + const onKeyDown = useCallback(() => { + setInteractingViaKeypress(true) + }, []) + + useEffect(() => { + if (interactingViaKeypress) { + document.addEventListener('click', () => setInteractingViaKeypress(false)) + return () => { + document.removeEventListener('click', () => + setInteractingViaKeypress(false), + ) + } + } + }, [interactingViaKeypress]) + + useEffect(() => { + if (isFullscreen) { + document.documentElement.style.scrollbarGutter = 'unset' + return () => { + document.documentElement.style.removeProperty('scrollbar-gutter') + } + } + }, [isFullscreen]) + + // pause + unfocus when another video is active + useEffect(() => { + if (!active) { + pause() + setFocused(false) + } + }, [active, pause, setFocused]) + + // autoplay/pause based on visibility + const isWithinMessage = useIsWithinMessage() + const autoplayDisabled = useAutoplayDisabled() || isWithinMessage + useEffect(() => { + if (active) { + if (onScreen) { + if (!autoplayDisabled) play() + } else { + pause() + } + } + }, [onScreen, pause, active, play, autoplayDisabled]) + + // use minimal quality when not focused + useEffect(() => { + if (!hlsRef.current) return + if (focused) { + // allow 30s of buffering + hlsRef.current.config.maxMaxBufferLength = 30 + } else { + // back to what we initially set + hlsRef.current.config.maxMaxBufferLength = 10 + } + }, [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() + if (autoplayDisabled) play() + } else { + togglePlayPause() + } + }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play]) + + const onPressPlayPause = useCallback(() => { + drawFocus() + togglePlayPause() + }, [drawFocus, togglePlayPause]) + + const onPressSubtitles = useCallback(() => { + drawFocus() + setSubtitlesEnabled(!subtitlesEnabled) + }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) + + const onPressFullscreen = useCallback(() => { + drawFocus() + toggleFullscreen() + }, [drawFocus, toggleFullscreen]) + + const onSeek = useCallback( + (time: number) => { + if (!videoRef.current) return + if (videoRef.current.fastSeek) { + videoRef.current.fastSeek(time) + } else { + videoRef.current.currentTime = time + } + }, + [videoRef], + ) + + const playStateBeforeSeekRef = useRef(false) + + const onSeekStart = useCallback(() => { + drawFocus() + playStateBeforeSeekRef.current = playing + pause() + }, [playing, pause, drawFocus]) + + const onSeekEnd = useCallback(() => { + if (playStateBeforeSeekRef.current) { + play() + } + }, [play]) + + const seekLeft = useCallback(() => { + if (!videoRef.current) return + // eslint-disable-next-line @typescript-eslint/no-shadow + const currentTime = videoRef.current.currentTime + // eslint-disable-next-line @typescript-eslint/no-shadow + const duration = videoRef.current.duration || 0 + onSeek(clamp(currentTime - 5, 0, duration)) + }, [onSeek, videoRef]) + + const seekRight = useCallback(() => { + if (!videoRef.current) return + // eslint-disable-next-line @typescript-eslint/no-shadow + const currentTime = videoRef.current.currentTime + // eslint-disable-next-line @typescript-eslint/no-shadow + const duration = videoRef.current.duration || 0 + onSeek(clamp(currentTime + 5, 0, duration)) + }, [onSeek, videoRef]) + + const [showCursor, setShowCursor] = useState(true) + const cursorTimeoutRef = useRef>() + const onPointerMoveEmptySpace = useCallback(() => { + setShowCursor(true) + if (cursorTimeoutRef.current) { + clearTimeout(cursorTimeoutRef.current) + } + cursorTimeoutRef.current = setTimeout(() => { + setShowCursor(false) + onEndHover() + }, 2000) + }, [onEndHover]) + const onPointerLeaveEmptySpace = useCallback(() => { + setShowCursor(false) + if (cursorTimeoutRef.current) { + clearTimeout(cursorTimeoutRef.current) + } + }, []) + + // these are used to trigger the hover state. on mobile, the hover state + // should stick around for a bit after they tap, and if the controls aren't + // present this initial tab should *only* show the controls and not activate anything + + const onPointerDown = useCallback( + (evt: React.PointerEvent) => { + if (evt.pointerType !== 'mouse' && !hovered) { + evt.preventDefault() + } + clearTimeout(timeoutRef.current) + }, + [hovered], + ) + + const timeoutRef = useRef>() + + const onHoverWithTimeout = useCallback(() => { + onHover() + clearTimeout(timeoutRef.current) + }, [onHover]) + + const onEndHoverWithTimeout = useCallback( + (evt: React.PointerEvent) => { + // if touch, end after 3s + // if mouse, end immediately + if (evt.pointerType !== 'mouse') { + setTimeout(onEndHover, 3000) + } else { + onEndHover() + } + }, + [onEndHover], + ) + + const showControls = + ((focused || autoplayDisabled) && !playing) || + (interactingViaKeypress ? hasFocus : hovered) + + return ( +
{ + evt.stopPropagation() + setInteractingViaKeypress(false) + }} + onPointerEnter={onHoverWithTimeout} + onPointerMove={onHoverWithTimeout} + onPointerLeave={onEndHoverWithTimeout} + onPointerDown={onPointerDown} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={onKeyDown}> + + {!showControls && !focused && duration > 0 && ( + + )} + + {(!volumeHovered || isTouchDevice) && ( + + )} + + + + + {formatTime(currentTime)} / {formatTime(duration)} + + {hasSubtitleTrack && ( + + )} + + {!isIPhoneWeb && ( + + )} + + + {(showSpinner || error) && ( + + {showSpinner && } + {error && ( + + An error occurred + + )} + + )} +
+ ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx new file mode 100644 index 000000000..e0b688075 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx @@ -0,0 +1,110 @@ +import React, {useCallback} from 'react' +import {View} from 'react-native' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isSafari, isTouchDevice} from '#/lib/browser' +import {atoms as a} from '#/alf' +import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' +import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' +import {ControlButton} from './ControlButton' + +export function VolumeControl({ + muted, + changeMuted, + hovered, + onHover, + onEndHover, + drawFocus, +}: { + muted: boolean + changeMuted: (muted: boolean | ((prev: boolean) => boolean)) => void + hovered: boolean + onHover: () => void + onEndHover: () => void + drawFocus: () => void +}) { + const {_} = useLingui() + const [volume, setVolume] = useVideoVolumeState() + + const onVolumeChange = useCallback( + (evt: React.ChangeEvent) => { + drawFocus() + const vol = sliderVolumeToVideoVolume(Number(evt.target.value)) + setVolume(vol) + changeMuted(vol === 0) + }, + [setVolume, drawFocus, changeMuted], + ) + + const sliderVolume = muted ? 0 : videoVolumeToSliderVolume(volume) + + const isZeroVolume = volume === 0 + const onPressMute = useCallback(() => { + drawFocus() + if (isZeroVolume) { + setVolume(1) + changeMuted(false) + } else { + changeMuted(prevMuted => !prevMuted) + } + }, [drawFocus, setVolume, isZeroVolume, changeMuted]) + + return ( + + {hovered && !isTouchDevice && ( + + + + + + )} + + + ) +} + +function sliderVolumeToVideoVolume(value: number) { + return Math.pow(value / 100, 4) +} + +function videoVolumeToSliderVolume(value: number) { + return Math.round(Math.pow(value, 1 / 4) * 100) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx new file mode 100644 index 000000000..320f61a5f --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx @@ -0,0 +1,240 @@ +import {type RefObject, useCallback, useEffect, useRef, useState} from 'react' + +import {isSafari} from '#/lib/browser' +import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' + +export function useVideoElement(ref: RefObject) { + const [playing, setPlaying] = useState(false) + const [muted, setMuted] = useState(true) + const [currentTime, setCurrentTime] = useState(0) + const [volume, setVolume] = useVideoVolumeState() + 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 + ref.current.volume = volume + }, [ref, volume]) + + 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) + setVolume(ref.current.volume) + + const handleTimeUpdate = () => { + if (!ref.current) return + setCurrentTime(round(ref.current.currentTime) || 0) + // HACK: Safari randomly fires `stalled` events when changing between segments + // let's just clear the buffering state if the video is still progressing -sfn + if (isSafari) { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + } + } + + 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 = async () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + setCanPlay(true) + + if (!ref.current) return + if (playWhenReadyRef.current) { + try { + await ref.current.play() + } catch (e: any) { + if ( + !e.message?.includes(`The request is not allowed by the user agent`) + ) { + throw e + } + } + playWhenReadyRef.current = false + } + } + + const handleCanPlayThrough = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + } + + const handleWaiting = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + bufferingTimeout = setTimeout(() => { + setBuffering(true) + }, 500) // Delay to avoid frequent buffering state changes + } + + const handlePlaying = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + setError(false) + } + + const handleStalled = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + bufferingTimeout = setTimeout(() => { + setBuffering(true) + }, 500) // 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('stalled', handleStalled, { + signal: abortController.signal, + }) + ref.current.addEventListener('ended', handleEnded, { + signal: abortController.signal, + }) + + return () => { + abortController.abort() + clearTimeout(bufferingTimeout) + } + }, [ref, setVolume]) + + 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 changeMuted = useCallback( + (newMuted: boolean | ((prev: boolean) => boolean)) => { + if (!ref.current) return + + const value = + typeof newMuted === 'function' ? newMuted(ref.current.muted) : newMuted + ref.current.muted = value + }, + [ref], + ) + + return { + play, + pause, + togglePlayPause, + duration, + currentTime, + playing, + muted, + changeMuted, + buffering, + error, + canPlay, + } +} + +export 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}` +} -- cgit 1.4.1