An ATproto social media client -- with an independent Appview.
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}