An ATproto social media client -- with an independent Appview.
at main 253 lines 6.8 kB view raw
1import {type RefObject, useCallback, useEffect, useRef, useState} from 'react' 2 3import {isSafari} from '#/lib/browser' 4import {logger} from '#/logger' 5import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 6 7export function useVideoElement(ref: RefObject<HTMLVideoElement | null>) { 8 const [playing, setPlaying] = useState(false) 9 const [muted, setMuted] = useState(true) 10 const [currentTime, setCurrentTime] = useState(0) 11 const [volume, setVolume] = useVideoVolumeState() 12 const [duration, setDuration] = useState(0) 13 const [buffering, setBuffering] = useState(false) 14 const [error, setError] = useState(false) 15 const [canPlay, setCanPlay] = useState(false) 16 const playWhenReadyRef = useRef(false) 17 18 useEffect(() => { 19 if (!ref.current) return 20 ref.current.volume = volume 21 }, [ref, volume]) 22 23 useEffect(() => { 24 if (!ref.current) return 25 26 let bufferingTimeout: ReturnType<typeof setTimeout> | undefined 27 28 function round(num: number) { 29 return Math.round(num * 100) / 100 30 } 31 32 // Initial values 33 setCurrentTime(round(ref.current.currentTime) || 0) 34 setDuration(round(ref.current.duration) || 0) 35 setMuted(ref.current.muted) 36 setPlaying(!ref.current.paused) 37 setVolume(ref.current.volume) 38 39 const handleTimeUpdate = () => { 40 if (!ref.current) return 41 setCurrentTime(round(ref.current.currentTime) || 0) 42 // HACK: Safari randomly fires `stalled` events when changing between segments 43 // let's just clear the buffering state if the video is still progressing -sfn 44 if (isSafari) { 45 if (bufferingTimeout) clearTimeout(bufferingTimeout) 46 setBuffering(false) 47 } 48 } 49 50 const handleDurationChange = () => { 51 if (!ref.current) return 52 setDuration(round(ref.current.duration) || 0) 53 } 54 55 const handlePlay = () => { 56 setPlaying(true) 57 } 58 59 const handlePause = () => { 60 setPlaying(false) 61 } 62 63 const handleVolumeChange = () => { 64 if (!ref.current) return 65 setMuted(ref.current.muted) 66 } 67 68 const handleError = () => { 69 setError(true) 70 } 71 72 const handleCanPlay = async () => { 73 if (bufferingTimeout) clearTimeout(bufferingTimeout) 74 setBuffering(false) 75 setCanPlay(true) 76 77 if (!ref.current) return 78 if (playWhenReadyRef.current) { 79 try { 80 await ref.current.play() 81 } catch (e: any) { 82 if ( 83 !e.message?.includes( 84 `The request is not allowed by the user agent`, 85 ) && 86 !e.message?.includes( 87 `The play() request was interrupted by a call to pause()`, 88 ) 89 ) { 90 throw e 91 } 92 } 93 playWhenReadyRef.current = false 94 } 95 } 96 97 const handleCanPlayThrough = () => { 98 if (bufferingTimeout) clearTimeout(bufferingTimeout) 99 setBuffering(false) 100 } 101 102 const handleWaiting = () => { 103 if (bufferingTimeout) clearTimeout(bufferingTimeout) 104 bufferingTimeout = setTimeout(() => { 105 setBuffering(true) 106 }, 500) // Delay to avoid frequent buffering state changes 107 } 108 109 const handlePlaying = () => { 110 if (bufferingTimeout) clearTimeout(bufferingTimeout) 111 setBuffering(false) 112 setError(false) 113 } 114 115 const handleStalled = () => { 116 if (bufferingTimeout) clearTimeout(bufferingTimeout) 117 bufferingTimeout = setTimeout(() => { 118 setBuffering(true) 119 }, 500) // Delay to avoid frequent buffering state changes 120 } 121 122 const handleEnded = () => { 123 setPlaying(false) 124 setBuffering(false) 125 setError(false) 126 } 127 128 const abortController = new AbortController() 129 130 ref.current.addEventListener('timeupdate', handleTimeUpdate, { 131 signal: abortController.signal, 132 }) 133 ref.current.addEventListener('durationchange', handleDurationChange, { 134 signal: abortController.signal, 135 }) 136 ref.current.addEventListener('play', handlePlay, { 137 signal: abortController.signal, 138 }) 139 ref.current.addEventListener('pause', handlePause, { 140 signal: abortController.signal, 141 }) 142 ref.current.addEventListener('volumechange', handleVolumeChange, { 143 signal: abortController.signal, 144 }) 145 ref.current.addEventListener('error', handleError, { 146 signal: abortController.signal, 147 }) 148 ref.current.addEventListener('canplay', handleCanPlay, { 149 signal: abortController.signal, 150 }) 151 ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { 152 signal: abortController.signal, 153 }) 154 ref.current.addEventListener('waiting', handleWaiting, { 155 signal: abortController.signal, 156 }) 157 ref.current.addEventListener('playing', handlePlaying, { 158 signal: abortController.signal, 159 }) 160 ref.current.addEventListener('stalled', handleStalled, { 161 signal: abortController.signal, 162 }) 163 ref.current.addEventListener('ended', handleEnded, { 164 signal: abortController.signal, 165 }) 166 167 return () => { 168 abortController.abort() 169 clearTimeout(bufferingTimeout) 170 } 171 }, [ref, setVolume]) 172 173 const play = useCallback(() => { 174 if (!ref.current) return 175 176 if (ref.current.ended) { 177 ref.current.currentTime = 0 178 } 179 180 if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { 181 playWhenReadyRef.current = true 182 } else { 183 const promise = ref.current.play() 184 if (promise !== undefined) { 185 promise.catch((err: any) => { 186 if ( 187 // ignore this common error. it's fine 188 !err.message?.includes( 189 `The play() request was interrupted by a call to pause()`, 190 ) 191 ) { 192 logger.error('Error playing video:', {message: err}) 193 } 194 }) 195 } 196 } 197 }, [ref]) 198 199 const pause = useCallback(() => { 200 if (!ref.current) return 201 202 ref.current.pause() 203 playWhenReadyRef.current = false 204 }, [ref]) 205 206 const togglePlayPause = useCallback(() => { 207 if (!ref.current) return 208 209 if (ref.current.paused) { 210 play() 211 } else { 212 pause() 213 } 214 }, [ref, play, pause]) 215 216 const changeMuted = useCallback( 217 (newMuted: boolean | ((prev: boolean) => boolean)) => { 218 if (!ref.current) return 219 220 const value = 221 typeof newMuted === 'function' ? newMuted(ref.current.muted) : newMuted 222 ref.current.muted = value 223 }, 224 [ref], 225 ) 226 227 return { 228 play, 229 pause, 230 togglePlayPause, 231 duration, 232 currentTime, 233 playing, 234 muted, 235 changeMuted, 236 buffering, 237 error, 238 canPlay, 239 } 240} 241 242export function formatTime(time: number) { 243 if (isNaN(time)) { 244 return '--' 245 } 246 247 time = Math.round(time) 248 249 const minutes = Math.floor(time / 60) 250 const seconds = String(time % 60).padStart(2, '0') 251 252 return `${minutes}:${seconds}` 253}