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