An ATproto social media client -- with an independent Appview.
1import {useCallback, useEffect, useRef, useState} from 'react'
2import {Pressable, View} from 'react-native'
3import {msg, Trans} from '@lingui/macro'
4import {useLingui} from '@lingui/react'
5import type Hls from 'hls.js'
6
7import {isTouchDevice} from '#/lib/browser'
8import {clamp} from '#/lib/numbers'
9import {isIPhoneWeb} from '#/platform/detection'
10import {
11 useAutoplayDisabled,
12 useSetSubtitlesEnabled,
13 useSubtitlesEnabled,
14} from '#/state/preferences'
15import {atoms as a, useTheme, web} from '#/alf'
16import {useIsWithinMessage} from '#/components/dms/MessageContext'
17import {useFullscreen} from '#/components/hooks/useFullscreen'
18import {useInteractionState} from '#/components/hooks/useInteractionState'
19import {
20 ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
21 ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
22} from '#/components/icons/ArrowsDiagonal'
23import {
24 CC_Filled_Corner0_Rounded as CCActiveIcon,
25 CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
26} from '#/components/icons/CC'
27import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
28import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
29import {Loader} from '#/components/Loader'
30import {Text} from '#/components/Typography'
31import {TimeIndicator} from '../TimeIndicator'
32import {ControlButton} from './ControlButton'
33import {Scrubber} from './Scrubber'
34import {formatTime, useVideoElement} from './utils'
35import {VolumeControl} from './VolumeControl'
36
37export function Controls({
38 videoRef,
39 hlsRef,
40 active,
41 setActive,
42 focused,
43 setFocused,
44 onScreen,
45 fullscreenRef,
46 hlsLoading,
47 hasSubtitleTrack,
48}: {
49 videoRef: React.RefObject<HTMLVideoElement | null>
50 hlsRef: React.RefObject<Hls | undefined | null>
51 active: boolean
52 setActive: () => void
53 focused: boolean
54 setFocused: (focused: boolean) => void
55 onScreen: boolean
56 fullscreenRef: React.RefObject<HTMLDivElement | null>
57 hlsLoading: boolean
58 hasSubtitleTrack: boolean
59}) {
60 const {
61 play,
62 pause,
63 playing,
64 muted,
65 changeMuted,
66 togglePlayPause,
67 currentTime,
68 duration,
69 buffering,
70 error,
71 canPlay,
72 } = useVideoElement(videoRef)
73 const t = useTheme()
74 const {_} = useLingui()
75 const subtitlesEnabled = useSubtitlesEnabled()
76 const setSubtitlesEnabled = useSetSubtitlesEnabled()
77 const {
78 state: hovered,
79 onIn: onHover,
80 onOut: onEndHover,
81 } = useInteractionState()
82 const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
83 const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
84 const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
85 const showSpinner = hlsLoading || buffering
86 const {
87 state: volumeHovered,
88 onIn: onVolumeHover,
89 onOut: onVolumeEndHover,
90 } = useInteractionState()
91
92 const onKeyDown = useCallback(() => {
93 setInteractingViaKeypress(true)
94 }, [])
95
96 useEffect(() => {
97 if (interactingViaKeypress) {
98 document.addEventListener('click', () => setInteractingViaKeypress(false))
99 return () => {
100 document.removeEventListener('click', () =>
101 setInteractingViaKeypress(false),
102 )
103 }
104 }
105 }, [interactingViaKeypress])
106
107 useEffect(() => {
108 if (isFullscreen) {
109 document.documentElement.style.scrollbarGutter = 'unset'
110 return () => {
111 document.documentElement.style.removeProperty('scrollbar-gutter')
112 }
113 }
114 }, [isFullscreen])
115
116 // pause + unfocus when another video is active
117 useEffect(() => {
118 if (!active) {
119 pause()
120 setFocused(false)
121 }
122 }, [active, pause, setFocused])
123
124 // autoplay/pause based on visibility
125 const isWithinMessage = useIsWithinMessage()
126 const autoplayDisabled = useAutoplayDisabled() || isWithinMessage
127 useEffect(() => {
128 if (active) {
129 if (onScreen) {
130 if (!autoplayDisabled) play()
131 } else {
132 pause()
133 }
134 }
135 }, [onScreen, pause, active, play, autoplayDisabled])
136
137 // use minimal quality when not focused
138 useEffect(() => {
139 if (!hlsRef.current) return
140 if (focused) {
141 // allow 30s of buffering
142 hlsRef.current.config.maxMaxBufferLength = 30
143 } else {
144 // back to what we initially set
145 hlsRef.current.config.maxMaxBufferLength = 10
146 }
147 }, [hlsRef, focused])
148
149 useEffect(() => {
150 if (!hlsRef.current) return
151 if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
152 hlsRef.current.subtitleTrack = 0
153 } else {
154 hlsRef.current.subtitleTrack = -1
155 }
156 }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
157
158 // clicking on any button should focus the player, if it's not already focused
159 const drawFocus = useCallback(() => {
160 if (!active) {
161 setActive()
162 }
163 setFocused(true)
164 }, [active, setActive, setFocused])
165
166 const onPressEmptySpace = useCallback(() => {
167 if (!focused) {
168 drawFocus()
169 if (autoplayDisabled) play()
170 } else {
171 togglePlayPause()
172 }
173 }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play])
174
175 const onPressPlayPause = useCallback(() => {
176 drawFocus()
177 togglePlayPause()
178 }, [drawFocus, togglePlayPause])
179
180 const onPressSubtitles = useCallback(() => {
181 drawFocus()
182 setSubtitlesEnabled(!subtitlesEnabled)
183 }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
184
185 const onPressFullscreen = useCallback(() => {
186 drawFocus()
187 toggleFullscreen()
188 }, [drawFocus, toggleFullscreen])
189
190 const onSeek = useCallback(
191 (time: number) => {
192 if (!videoRef.current) return
193 if (videoRef.current.fastSeek) {
194 videoRef.current.fastSeek(time)
195 } else {
196 videoRef.current.currentTime = time
197 }
198 },
199 [videoRef],
200 )
201
202 const playStateBeforeSeekRef = useRef(false)
203
204 const onSeekStart = useCallback(() => {
205 drawFocus()
206 playStateBeforeSeekRef.current = playing
207 pause()
208 }, [playing, pause, drawFocus])
209
210 const onSeekEnd = useCallback(() => {
211 if (playStateBeforeSeekRef.current) {
212 play()
213 }
214 }, [play])
215
216 const seekLeft = useCallback(() => {
217 if (!videoRef.current) return
218 // eslint-disable-next-line @typescript-eslint/no-shadow
219 const currentTime = videoRef.current.currentTime
220 // eslint-disable-next-line @typescript-eslint/no-shadow
221 const duration = videoRef.current.duration || 0
222 onSeek(clamp(currentTime - 5, 0, duration))
223 }, [onSeek, videoRef])
224
225 const seekRight = useCallback(() => {
226 if (!videoRef.current) return
227 // eslint-disable-next-line @typescript-eslint/no-shadow
228 const currentTime = videoRef.current.currentTime
229 // eslint-disable-next-line @typescript-eslint/no-shadow
230 const duration = videoRef.current.duration || 0
231 onSeek(clamp(currentTime + 5, 0, duration))
232 }, [onSeek, videoRef])
233
234 const [showCursor, setShowCursor] = useState(true)
235 const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined)
236 const onPointerMoveEmptySpace = useCallback(() => {
237 setShowCursor(true)
238 if (cursorTimeoutRef.current) {
239 clearTimeout(cursorTimeoutRef.current)
240 }
241 cursorTimeoutRef.current = setTimeout(() => {
242 setShowCursor(false)
243 onEndHover()
244 }, 2000)
245 }, [onEndHover])
246 const onPointerLeaveEmptySpace = useCallback(() => {
247 setShowCursor(false)
248 if (cursorTimeoutRef.current) {
249 clearTimeout(cursorTimeoutRef.current)
250 }
251 }, [])
252
253 // these are used to trigger the hover state. on mobile, the hover state
254 // should stick around for a bit after they tap, and if the controls aren't
255 // present this initial tab should *only* show the controls and not activate anything
256
257 const onPointerDown = useCallback(
258 (evt: React.PointerEvent<HTMLDivElement>) => {
259 if (evt.pointerType !== 'mouse' && !hovered) {
260 evt.preventDefault()
261 }
262 clearTimeout(timeoutRef.current)
263 },
264 [hovered],
265 )
266
267 const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined)
268
269 const onHoverWithTimeout = useCallback(() => {
270 onHover()
271 clearTimeout(timeoutRef.current)
272 }, [onHover])
273
274 const onEndHoverWithTimeout = useCallback(
275 (evt: React.PointerEvent<HTMLDivElement>) => {
276 // if touch, end after 3s
277 // if mouse, end immediately
278 if (evt.pointerType !== 'mouse') {
279 setTimeout(onEndHover, 3000)
280 } else {
281 onEndHover()
282 }
283 },
284 [onEndHover],
285 )
286
287 const showControls =
288 ((focused || autoplayDisabled) && !playing) ||
289 (interactingViaKeypress ? hasFocus : hovered)
290
291 return (
292 <div
293 style={{
294 position: 'absolute',
295 inset: 0,
296 overflow: 'hidden',
297 display: 'flex',
298 flexDirection: 'column',
299 }}
300 onClick={evt => {
301 evt.stopPropagation()
302 setInteractingViaKeypress(false)
303 }}
304 onPointerEnter={onHoverWithTimeout}
305 onPointerMove={onHoverWithTimeout}
306 onPointerLeave={onEndHoverWithTimeout}
307 onPointerDown={onPointerDown}
308 onFocus={onFocus}
309 onBlur={onBlur}
310 onKeyDown={onKeyDown}>
311 <Pressable
312 accessibilityRole="button"
313 onPointerEnter={onPointerMoveEmptySpace}
314 onPointerMove={onPointerMoveEmptySpace}
315 onPointerLeave={onPointerLeaveEmptySpace}
316 accessibilityLabel={_(
317 !focused
318 ? msg`Unmute video`
319 : playing
320 ? msg`Pause video`
321 : msg`Play video`,
322 )}
323 accessibilityHint=""
324 style={[
325 a.flex_1,
326 web({cursor: showCursor || !playing ? 'pointer' : 'none'}),
327 ]}
328 onPress={onPressEmptySpace}
329 />
330 {!showControls && !focused && duration > 0 && (
331 <TimeIndicator time={Math.floor(duration - currentTime)} />
332 )}
333 <View
334 style={[
335 a.flex_shrink_0,
336 a.w_full,
337 a.px_xs,
338 web({
339 background:
340 'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
341 }),
342 {opacity: showControls ? 1 : 0},
343 {transition: 'opacity 0.2s ease-in-out'},
344 ]}>
345 {(!volumeHovered || isTouchDevice) && (
346 <Scrubber
347 duration={duration}
348 currentTime={currentTime}
349 onSeek={onSeek}
350 onSeekStart={onSeekStart}
351 onSeekEnd={onSeekEnd}
352 seekLeft={seekLeft}
353 seekRight={seekRight}
354 togglePlayPause={togglePlayPause}
355 drawFocus={drawFocus}
356 />
357 )}
358 <View
359 style={[
360 a.flex_1,
361 a.px_xs,
362 a.pb_sm,
363 a.gap_sm,
364 a.flex_row,
365 a.align_center,
366 ]}>
367 <ControlButton
368 active={playing}
369 activeLabel={_(msg`Pause`)}
370 inactiveLabel={_(msg`Play`)}
371 activeIcon={PauseIcon}
372 inactiveIcon={PlayIcon}
373 onPress={onPressPlayPause}
374 />
375 <View style={a.flex_1} />
376 {Math.round(duration) > 0 && (
377 <Text
378 style={[
379 a.px_xs,
380 {color: t.palette.white, fontVariant: ['tabular-nums']},
381 ]}>
382 {formatTime(currentTime)} / {formatTime(duration)}
383 </Text>
384 )}
385 {hasSubtitleTrack && (
386 <ControlButton
387 active={subtitlesEnabled}
388 activeLabel={_(msg`Disable subtitles`)}
389 inactiveLabel={_(msg`Enable subtitles`)}
390 activeIcon={CCActiveIcon}
391 inactiveIcon={CCInactiveIcon}
392 onPress={onPressSubtitles}
393 />
394 )}
395 <VolumeControl
396 muted={muted}
397 changeMuted={changeMuted}
398 hovered={volumeHovered}
399 onHover={onVolumeHover}
400 onEndHover={onVolumeEndHover}
401 drawFocus={drawFocus}
402 />
403 {!isIPhoneWeb && (
404 <ControlButton
405 active={isFullscreen}
406 activeLabel={_(msg`Exit fullscreen`)}
407 inactiveLabel={_(msg`Enter fullscreen`)}
408 activeIcon={ArrowsInIcon}
409 inactiveIcon={ArrowsOutIcon}
410 onPress={onPressFullscreen}
411 />
412 )}
413 </View>
414 </View>
415 {(showSpinner || error) && (
416 <View
417 pointerEvents="none"
418 style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
419 {showSpinner && <Loader fill={t.palette.white} size="lg" />}
420 {error && (
421 <Text style={{color: t.palette.white}}>
422 <Trans>An error occurred</Trans>
423 </Text>
424 )}
425 </View>
426 )}
427 </div>
428 )
429}