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