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