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