Bluesky app fork with some witchin' additions 💫

swap control files (#4936)

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>

authored by samuel.fm

Samuel Newman and committed by
GitHub
b9975697 b6fa0d2d

+579 -592
+1 -1
src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
··· 1 1 export function VideoEmbedInnerNative() { 2 - throw new Error('VideoEmbedInnerNative may not be used on native.') 2 + throw new Error('VideoEmbedInnerNative may not be used on web.') 3 3 }
+3
src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx
··· 1 + export function Controls() { 2 + throw new Error('VideoWebControls may not be used on native.') 3 + }
+575 -4
src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
··· 1 - import React from 'react' 1 + import React, { 2 + useCallback, 3 + useEffect, 4 + useRef, 5 + useState, 6 + useSyncExternalStore, 7 + } from 'react' 8 + import {Pressable, View} from 'react-native' 9 + import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 10 + import {msg, Trans} from '@lingui/macro' 11 + import {useLingui} from '@lingui/react' 2 12 import type Hls from 'hls.js' 3 13 4 - export function Controls({}: { 14 + import {isIPhoneWeb} from 'platform/detection' 15 + import { 16 + useAutoplayDisabled, 17 + useSetSubtitlesEnabled, 18 + useSubtitlesEnabled, 19 + } from 'state/preferences' 20 + import {atoms as a, useTheme, web} from '#/alf' 21 + import {Button} from '#/components/Button' 22 + import {useInteractionState} from '#/components/hooks/useInteractionState' 23 + import { 24 + ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, 25 + ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, 26 + } from '#/components/icons/ArrowsDiagonal' 27 + import { 28 + CC_Filled_Corner0_Rounded as CCActiveIcon, 29 + CC_Stroke2_Corner0_Rounded as CCInactiveIcon, 30 + } from '#/components/icons/CC' 31 + import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 32 + import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' 33 + import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 34 + import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' 35 + import {Loader} from '#/components/Loader' 36 + import {Text} from '#/components/Typography' 37 + 38 + export function Controls({ 39 + videoRef, 40 + hlsRef, 41 + active, 42 + setActive, 43 + focused, 44 + setFocused, 45 + onScreen, 46 + fullscreenRef, 47 + hasSubtitleTrack, 48 + }: { 5 49 videoRef: React.RefObject<HTMLVideoElement> 6 50 hlsRef: React.RefObject<Hls | undefined> 7 51 active: boolean ··· 11 55 onScreen: boolean 12 56 fullscreenRef: React.RefObject<HTMLDivElement> 13 57 hasSubtitleTrack: boolean 14 - }): React.ReactElement { 15 - throw new Error('Web-only component') 58 + }) { 59 + const { 60 + play, 61 + pause, 62 + playing, 63 + muted, 64 + toggleMute, 65 + togglePlayPause, 66 + currentTime, 67 + duration, 68 + buffering, 69 + error, 70 + canPlay, 71 + } = useVideoUtils(videoRef) 72 + const t = useTheme() 73 + const {_} = useLingui() 74 + const subtitlesEnabled = useSubtitlesEnabled() 75 + const setSubtitlesEnabled = useSetSubtitlesEnabled() 76 + const { 77 + state: hovered, 78 + onIn: onMouseEnter, 79 + onOut: onMouseLeave, 80 + } = useInteractionState() 81 + const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) 82 + const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() 83 + const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) 84 + 85 + const onKeyDown = useCallback(() => { 86 + setInteractingViaKeypress(true) 87 + }, []) 88 + 89 + useEffect(() => { 90 + if (interactingViaKeypress) { 91 + document.addEventListener('click', () => setInteractingViaKeypress(false)) 92 + return () => { 93 + document.removeEventListener('click', () => 94 + setInteractingViaKeypress(false), 95 + ) 96 + } 97 + } 98 + }, [interactingViaKeypress]) 99 + 100 + // pause + unfocus when another video is active 101 + useEffect(() => { 102 + if (!active) { 103 + pause() 104 + setFocused(false) 105 + } 106 + }, [active, pause, setFocused]) 107 + 108 + // autoplay/pause based on visibility 109 + const autoplayDisabled = useAutoplayDisabled() 110 + useEffect(() => { 111 + if (active && !autoplayDisabled) { 112 + if (onScreen) { 113 + play() 114 + } else { 115 + pause() 116 + } 117 + } 118 + }, [onScreen, pause, active, play, autoplayDisabled]) 119 + 120 + // use minimal quality when not focused 121 + useEffect(() => { 122 + if (!hlsRef.current) return 123 + if (focused) { 124 + // auto decide quality based on network conditions 125 + hlsRef.current.autoLevelCapping = -1 126 + } else { 127 + hlsRef.current.autoLevelCapping = 0 128 + } 129 + }, [hlsRef, focused]) 130 + 131 + useEffect(() => { 132 + if (!hlsRef.current) return 133 + if (hasSubtitleTrack && subtitlesEnabled && canPlay) { 134 + hlsRef.current.subtitleTrack = 0 135 + } else { 136 + hlsRef.current.subtitleTrack = -1 137 + } 138 + }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) 139 + 140 + // clicking on any button should focus the player, if it's not already focused 141 + const drawFocus = useCallback(() => { 142 + if (!active) { 143 + setActive() 144 + } 145 + setFocused(true) 146 + }, [active, setActive, setFocused]) 147 + 148 + const onPressEmptySpace = useCallback(() => { 149 + if (!focused) { 150 + drawFocus() 151 + } else { 152 + togglePlayPause() 153 + } 154 + }, [togglePlayPause, drawFocus, focused]) 155 + 156 + const onPressPlayPause = useCallback(() => { 157 + drawFocus() 158 + togglePlayPause() 159 + }, [drawFocus, togglePlayPause]) 160 + 161 + const onPressSubtitles = useCallback(() => { 162 + drawFocus() 163 + setSubtitlesEnabled(!subtitlesEnabled) 164 + }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) 165 + 166 + const onPressMute = useCallback(() => { 167 + drawFocus() 168 + toggleMute() 169 + }, [drawFocus, toggleMute]) 170 + 171 + const onPressFullscreen = useCallback(() => { 172 + drawFocus() 173 + toggleFullscreen() 174 + }, [drawFocus, toggleFullscreen]) 175 + 176 + const showControls = 177 + (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered) 178 + 179 + return ( 180 + <div 181 + style={{ 182 + position: 'absolute', 183 + inset: 0, 184 + overflow: 'hidden', 185 + display: 'flex', 186 + flexDirection: 'column', 187 + }} 188 + onClick={evt => { 189 + evt.stopPropagation() 190 + setInteractingViaKeypress(false) 191 + }} 192 + onMouseEnter={onMouseEnter} 193 + onMouseLeave={onMouseLeave} 194 + onFocus={onFocus} 195 + onBlur={onBlur} 196 + onKeyDown={onKeyDown}> 197 + <Pressable 198 + accessibilityRole="button" 199 + accessibilityHint={_( 200 + focused 201 + ? msg`Unmute video` 202 + : playing 203 + ? msg`Pause video` 204 + : msg`Play video`, 205 + )} 206 + style={a.flex_1} 207 + onPress={onPressEmptySpace} 208 + /> 209 + <View 210 + style={[ 211 + a.flex_shrink_0, 212 + a.w_full, 213 + a.px_sm, 214 + a.pt_sm, 215 + a.pb_md, 216 + a.gap_md, 217 + a.flex_row, 218 + a.align_center, 219 + web({ 220 + background: 221 + 'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))', 222 + }), 223 + showControls ? {opacity: 1} : {opacity: 0}, 224 + ]}> 225 + <Button 226 + label={_(playing ? msg`Pause` : msg`Play`)} 227 + onPress={onPressPlayPause} 228 + {...btnProps}> 229 + {playing ? ( 230 + <PauseIcon fill={t.palette.white} width={20} /> 231 + ) : ( 232 + <PlayIcon fill={t.palette.white} width={20} /> 233 + )} 234 + </Button> 235 + <View style={a.flex_1} /> 236 + <Text style={{color: t.palette.white}}> 237 + {formatTime(currentTime)} / {formatTime(duration)} 238 + </Text> 239 + {hasSubtitleTrack && ( 240 + <Button 241 + label={_( 242 + subtitlesEnabled ? msg`Disable subtitles` : msg`Enable subtitles`, 243 + )} 244 + onPress={onPressSubtitles} 245 + {...btnProps}> 246 + {subtitlesEnabled ? ( 247 + <CCActiveIcon fill={t.palette.white} width={20} /> 248 + ) : ( 249 + <CCInactiveIcon fill={t.palette.white} width={20} /> 250 + )} 251 + </Button> 252 + )} 253 + <Button 254 + label={_(muted ? msg`Unmute` : msg`Mute`)} 255 + onPress={onPressMute} 256 + {...btnProps}> 257 + {muted ? ( 258 + <MuteIcon fill={t.palette.white} width={20} /> 259 + ) : ( 260 + <UnmuteIcon fill={t.palette.white} width={20} /> 261 + )} 262 + </Button> 263 + {!isIPhoneWeb && ( 264 + <Button 265 + label={_(muted ? msg`Unmute` : msg`Mute`)} 266 + onPress={onPressFullscreen} 267 + {...btnProps}> 268 + {isFullscreen ? ( 269 + <ArrowsInIcon fill={t.palette.white} width={20} /> 270 + ) : ( 271 + <ArrowsOutIcon fill={t.palette.white} width={20} /> 272 + )} 273 + </Button> 274 + )} 275 + </View> 276 + {(showControls || !focused) && ( 277 + <Animated.View 278 + entering={FadeIn.duration(200)} 279 + exiting={FadeOut.duration(200)} 280 + style={[ 281 + a.absolute, 282 + { 283 + height: 5, 284 + bottom: 0, 285 + left: 0, 286 + right: 0, 287 + backgroundColor: 'rgba(255,255,255,0.4)', 288 + }, 289 + ]}> 290 + {duration > 0 && ( 291 + <View 292 + style={[ 293 + a.h_full, 294 + a.mr_auto, 295 + { 296 + backgroundColor: t.palette.white, 297 + width: `${(currentTime / duration) * 100}%`, 298 + opacity: 0.8, 299 + }, 300 + ]} 301 + /> 302 + )} 303 + </Animated.View> 304 + )} 305 + {(buffering || error) && ( 306 + <Animated.View 307 + pointerEvents="none" 308 + entering={FadeIn.delay(1000).duration(200)} 309 + exiting={FadeOut.duration(200)} 310 + style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 311 + {buffering && <Loader fill={t.palette.white} size="lg" />} 312 + {error && ( 313 + <Text style={{color: t.palette.white}}> 314 + <Trans>An error occurred</Trans> 315 + </Text> 316 + )} 317 + </Animated.View> 318 + )} 319 + </div> 320 + ) 321 + } 322 + 323 + const btnProps = { 324 + variant: 'ghost', 325 + shape: 'round', 326 + size: 'medium', 327 + style: a.p_2xs, 328 + hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'}, 329 + } as const 330 + 331 + function formatTime(time: number) { 332 + if (isNaN(time)) { 333 + return '--' 334 + } 335 + 336 + time = Math.round(time) 337 + 338 + const minutes = Math.floor(time / 60) 339 + const seconds = String(time % 60).padStart(2, '0') 340 + 341 + return `${minutes}:${seconds}` 342 + } 343 + 344 + function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) { 345 + const [playing, setPlaying] = useState(false) 346 + const [muted, setMuted] = useState(true) 347 + const [currentTime, setCurrentTime] = useState(0) 348 + const [duration, setDuration] = useState(0) 349 + const [buffering, setBuffering] = useState(false) 350 + const [error, setError] = useState(false) 351 + const [canPlay, setCanPlay] = useState(false) 352 + const playWhenReadyRef = useRef(false) 353 + 354 + useEffect(() => { 355 + if (!ref.current) return 356 + 357 + let bufferingTimeout: ReturnType<typeof setTimeout> | undefined 358 + 359 + function round(num: number) { 360 + return Math.round(num * 100) / 100 361 + } 362 + 363 + // Initial values 364 + setCurrentTime(round(ref.current.currentTime) || 0) 365 + setDuration(round(ref.current.duration) || 0) 366 + setMuted(ref.current.muted) 367 + setPlaying(!ref.current.paused) 368 + 369 + const handleTimeUpdate = () => { 370 + if (!ref.current) return 371 + setCurrentTime(round(ref.current.currentTime) || 0) 372 + } 373 + 374 + const handleDurationChange = () => { 375 + if (!ref.current) return 376 + setDuration(round(ref.current.duration) || 0) 377 + } 378 + 379 + const handlePlay = () => { 380 + setPlaying(true) 381 + } 382 + 383 + const handlePause = () => { 384 + setPlaying(false) 385 + } 386 + 387 + const handleVolumeChange = () => { 388 + if (!ref.current) return 389 + setMuted(ref.current.muted) 390 + } 391 + 392 + const handleError = () => { 393 + setError(true) 394 + } 395 + 396 + const handleCanPlay = () => { 397 + setBuffering(false) 398 + setCanPlay(true) 399 + 400 + if (!ref.current) return 401 + if (playWhenReadyRef.current) { 402 + ref.current.play() 403 + playWhenReadyRef.current = false 404 + } 405 + } 406 + 407 + const handleCanPlayThrough = () => { 408 + setBuffering(false) 409 + } 410 + 411 + const handleWaiting = () => { 412 + if (bufferingTimeout) clearTimeout(bufferingTimeout) 413 + bufferingTimeout = setTimeout(() => { 414 + setBuffering(true) 415 + }, 200) // Delay to avoid frequent buffering state changes 416 + } 417 + 418 + const handlePlaying = () => { 419 + if (bufferingTimeout) clearTimeout(bufferingTimeout) 420 + setBuffering(false) 421 + setError(false) 422 + } 423 + 424 + const handleSeeking = () => { 425 + setBuffering(true) 426 + } 427 + 428 + const handleSeeked = () => { 429 + setBuffering(false) 430 + } 431 + 432 + const handleStalled = () => { 433 + if (bufferingTimeout) clearTimeout(bufferingTimeout) 434 + bufferingTimeout = setTimeout(() => { 435 + setBuffering(true) 436 + }, 200) // Delay to avoid frequent buffering state changes 437 + } 438 + 439 + const handleEnded = () => { 440 + setPlaying(false) 441 + setBuffering(false) 442 + setError(false) 443 + } 444 + 445 + const abortController = new AbortController() 446 + 447 + ref.current.addEventListener('timeupdate', handleTimeUpdate, { 448 + signal: abortController.signal, 449 + }) 450 + ref.current.addEventListener('durationchange', handleDurationChange, { 451 + signal: abortController.signal, 452 + }) 453 + ref.current.addEventListener('play', handlePlay, { 454 + signal: abortController.signal, 455 + }) 456 + ref.current.addEventListener('pause', handlePause, { 457 + signal: abortController.signal, 458 + }) 459 + ref.current.addEventListener('volumechange', handleVolumeChange, { 460 + signal: abortController.signal, 461 + }) 462 + ref.current.addEventListener('error', handleError, { 463 + signal: abortController.signal, 464 + }) 465 + ref.current.addEventListener('canplay', handleCanPlay, { 466 + signal: abortController.signal, 467 + }) 468 + ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { 469 + signal: abortController.signal, 470 + }) 471 + ref.current.addEventListener('waiting', handleWaiting, { 472 + signal: abortController.signal, 473 + }) 474 + ref.current.addEventListener('playing', handlePlaying, { 475 + signal: abortController.signal, 476 + }) 477 + ref.current.addEventListener('seeking', handleSeeking, { 478 + signal: abortController.signal, 479 + }) 480 + ref.current.addEventListener('seeked', handleSeeked, { 481 + signal: abortController.signal, 482 + }) 483 + ref.current.addEventListener('stalled', handleStalled, { 484 + signal: abortController.signal, 485 + }) 486 + ref.current.addEventListener('ended', handleEnded, { 487 + signal: abortController.signal, 488 + }) 489 + 490 + return () => { 491 + abortController.abort() 492 + clearTimeout(bufferingTimeout) 493 + } 494 + }, [ref]) 495 + 496 + const play = useCallback(() => { 497 + if (!ref.current) return 498 + 499 + if (ref.current.ended) { 500 + ref.current.currentTime = 0 501 + } 502 + 503 + if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { 504 + playWhenReadyRef.current = true 505 + } else { 506 + const promise = ref.current.play() 507 + if (promise !== undefined) { 508 + promise.catch(err => { 509 + console.error('Error playing video:', err) 510 + }) 511 + } 512 + } 513 + }, [ref]) 514 + 515 + const pause = useCallback(() => { 516 + if (!ref.current) return 517 + 518 + ref.current.pause() 519 + playWhenReadyRef.current = false 520 + }, [ref]) 521 + 522 + const togglePlayPause = useCallback(() => { 523 + if (!ref.current) return 524 + 525 + if (ref.current.paused) { 526 + play() 527 + } else { 528 + pause() 529 + } 530 + }, [ref, play, pause]) 531 + 532 + const mute = useCallback(() => { 533 + if (!ref.current) return 534 + 535 + ref.current.muted = true 536 + }, [ref]) 537 + 538 + const unmute = useCallback(() => { 539 + if (!ref.current) return 540 + 541 + ref.current.muted = false 542 + }, [ref]) 543 + 544 + const toggleMute = useCallback(() => { 545 + if (!ref.current) return 546 + 547 + ref.current.muted = !ref.current.muted 548 + }, [ref]) 549 + 550 + return { 551 + play, 552 + pause, 553 + togglePlayPause, 554 + duration, 555 + currentTime, 556 + playing, 557 + muted, 558 + mute, 559 + unmute, 560 + toggleMute, 561 + buffering, 562 + error, 563 + canPlay, 564 + } 565 + } 566 + 567 + function fullscreenSubscribe(onChange: () => void) { 568 + document.addEventListener('fullscreenchange', onChange) 569 + return () => document.removeEventListener('fullscreenchange', onChange) 570 + } 571 + 572 + function useFullscreen(ref: React.RefObject<HTMLElement>) { 573 + const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () => 574 + Boolean(document.fullscreenElement), 575 + ) 576 + 577 + const toggleFullscreen = useCallback(() => { 578 + if (isFullscreen) { 579 + document.exitFullscreen() 580 + } else { 581 + if (!ref.current) return 582 + ref.current.requestFullscreen() 583 + } 584 + }, [isFullscreen, ref]) 585 + 586 + return [isFullscreen, toggleFullscreen] as const 16 587 }
-587
src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx
··· 1 - import React, { 2 - useCallback, 3 - useEffect, 4 - useRef, 5 - useState, 6 - useSyncExternalStore, 7 - } from 'react' 8 - import {Pressable, View} from 'react-native' 9 - import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 10 - import {msg, Trans} from '@lingui/macro' 11 - import {useLingui} from '@lingui/react' 12 - import type Hls from 'hls.js' 13 - 14 - import {isIPhoneWeb} from 'platform/detection' 15 - import { 16 - useAutoplayDisabled, 17 - useSetSubtitlesEnabled, 18 - useSubtitlesEnabled, 19 - } from 'state/preferences' 20 - import {atoms as a, useTheme, web} from '#/alf' 21 - import {Button} from '#/components/Button' 22 - import {useInteractionState} from '#/components/hooks/useInteractionState' 23 - import { 24 - ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, 25 - ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, 26 - } from '#/components/icons/ArrowsDiagonal' 27 - import { 28 - CC_Filled_Corner0_Rounded as CCActiveIcon, 29 - CC_Stroke2_Corner0_Rounded as CCInactiveIcon, 30 - } from '#/components/icons/CC' 31 - import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 32 - import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' 33 - import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 34 - import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' 35 - import {Loader} from '#/components/Loader' 36 - import {Text} from '#/components/Typography' 37 - 38 - export function Controls({ 39 - videoRef, 40 - hlsRef, 41 - active, 42 - setActive, 43 - focused, 44 - setFocused, 45 - onScreen, 46 - fullscreenRef, 47 - hasSubtitleTrack, 48 - }: { 49 - videoRef: React.RefObject<HTMLVideoElement> 50 - hlsRef: React.RefObject<Hls | undefined> 51 - active: boolean 52 - setActive: () => void 53 - focused: boolean 54 - setFocused: (focused: boolean) => void 55 - onScreen: boolean 56 - fullscreenRef: React.RefObject<HTMLDivElement> 57 - hasSubtitleTrack: boolean 58 - }) { 59 - const { 60 - play, 61 - pause, 62 - playing, 63 - muted, 64 - toggleMute, 65 - togglePlayPause, 66 - currentTime, 67 - duration, 68 - buffering, 69 - error, 70 - canPlay, 71 - } = useVideoUtils(videoRef) 72 - const t = useTheme() 73 - const {_} = useLingui() 74 - const subtitlesEnabled = useSubtitlesEnabled() 75 - const setSubtitlesEnabled = useSetSubtitlesEnabled() 76 - const { 77 - state: hovered, 78 - onIn: onMouseEnter, 79 - onOut: onMouseLeave, 80 - } = useInteractionState() 81 - const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) 82 - const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() 83 - const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) 84 - 85 - const onKeyDown = useCallback(() => { 86 - setInteractingViaKeypress(true) 87 - }, []) 88 - 89 - useEffect(() => { 90 - if (interactingViaKeypress) { 91 - document.addEventListener('click', () => setInteractingViaKeypress(false)) 92 - return () => { 93 - document.removeEventListener('click', () => 94 - setInteractingViaKeypress(false), 95 - ) 96 - } 97 - } 98 - }, [interactingViaKeypress]) 99 - 100 - // pause + unfocus when another video is active 101 - useEffect(() => { 102 - if (!active) { 103 - pause() 104 - setFocused(false) 105 - } 106 - }, [active, pause, setFocused]) 107 - 108 - // autoplay/pause based on visibility 109 - const autoplayDisabled = useAutoplayDisabled() 110 - useEffect(() => { 111 - if (active && !autoplayDisabled) { 112 - if (onScreen) { 113 - play() 114 - } else { 115 - pause() 116 - } 117 - } 118 - }, [onScreen, pause, active, play, autoplayDisabled]) 119 - 120 - // use minimal quality when not focused 121 - useEffect(() => { 122 - if (!hlsRef.current) return 123 - if (focused) { 124 - // auto decide quality based on network conditions 125 - hlsRef.current.autoLevelCapping = -1 126 - } else { 127 - hlsRef.current.autoLevelCapping = 0 128 - } 129 - }, [hlsRef, focused]) 130 - 131 - useEffect(() => { 132 - if (!hlsRef.current) return 133 - if (hasSubtitleTrack && subtitlesEnabled && canPlay) { 134 - hlsRef.current.subtitleTrack = 0 135 - } else { 136 - hlsRef.current.subtitleTrack = -1 137 - } 138 - }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) 139 - 140 - // clicking on any button should focus the player, if it's not already focused 141 - const drawFocus = useCallback(() => { 142 - if (!active) { 143 - setActive() 144 - } 145 - setFocused(true) 146 - }, [active, setActive, setFocused]) 147 - 148 - const onPressEmptySpace = useCallback(() => { 149 - if (!focused) { 150 - drawFocus() 151 - } else { 152 - togglePlayPause() 153 - } 154 - }, [togglePlayPause, drawFocus, focused]) 155 - 156 - const onPressPlayPause = useCallback(() => { 157 - drawFocus() 158 - togglePlayPause() 159 - }, [drawFocus, togglePlayPause]) 160 - 161 - const onPressSubtitles = useCallback(() => { 162 - drawFocus() 163 - setSubtitlesEnabled(!subtitlesEnabled) 164 - }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) 165 - 166 - const onPressMute = useCallback(() => { 167 - drawFocus() 168 - toggleMute() 169 - }, [drawFocus, toggleMute]) 170 - 171 - const onPressFullscreen = useCallback(() => { 172 - drawFocus() 173 - toggleFullscreen() 174 - }, [drawFocus, toggleFullscreen]) 175 - 176 - const showControls = 177 - (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered) 178 - 179 - return ( 180 - <div 181 - style={{ 182 - position: 'absolute', 183 - inset: 0, 184 - overflow: 'hidden', 185 - display: 'flex', 186 - flexDirection: 'column', 187 - }} 188 - onClick={evt => { 189 - evt.stopPropagation() 190 - setInteractingViaKeypress(false) 191 - }} 192 - onMouseEnter={onMouseEnter} 193 - onMouseLeave={onMouseLeave} 194 - onFocus={onFocus} 195 - onBlur={onBlur} 196 - onKeyDown={onKeyDown}> 197 - <Pressable 198 - accessibilityRole="button" 199 - accessibilityHint={_( 200 - focused 201 - ? msg`Unmute video` 202 - : playing 203 - ? msg`Pause video` 204 - : msg`Play video`, 205 - )} 206 - style={a.flex_1} 207 - onPress={onPressEmptySpace} 208 - /> 209 - <View 210 - style={[ 211 - a.flex_shrink_0, 212 - a.w_full, 213 - a.px_sm, 214 - a.pt_sm, 215 - a.pb_md, 216 - a.gap_md, 217 - a.flex_row, 218 - a.align_center, 219 - web({ 220 - background: 221 - 'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))', 222 - }), 223 - showControls ? {opacity: 1} : {opacity: 0}, 224 - ]}> 225 - <Button 226 - label={_(playing ? msg`Pause` : msg`Play`)} 227 - onPress={onPressPlayPause} 228 - {...btnProps}> 229 - {playing ? ( 230 - <PauseIcon fill={t.palette.white} width={20} /> 231 - ) : ( 232 - <PlayIcon fill={t.palette.white} width={20} /> 233 - )} 234 - </Button> 235 - <View style={a.flex_1} /> 236 - <Text style={{color: t.palette.white}}> 237 - {formatTime(currentTime)} / {formatTime(duration)} 238 - </Text> 239 - {hasSubtitleTrack && ( 240 - <Button 241 - label={_( 242 - subtitlesEnabled ? msg`Disable subtitles` : msg`Enable subtitles`, 243 - )} 244 - onPress={onPressSubtitles} 245 - {...btnProps}> 246 - {subtitlesEnabled ? ( 247 - <CCActiveIcon fill={t.palette.white} width={20} /> 248 - ) : ( 249 - <CCInactiveIcon fill={t.palette.white} width={20} /> 250 - )} 251 - </Button> 252 - )} 253 - <Button 254 - label={_(muted ? msg`Unmute` : msg`Mute`)} 255 - onPress={onPressMute} 256 - {...btnProps}> 257 - {muted ? ( 258 - <MuteIcon fill={t.palette.white} width={20} /> 259 - ) : ( 260 - <UnmuteIcon fill={t.palette.white} width={20} /> 261 - )} 262 - </Button> 263 - {!isIPhoneWeb && ( 264 - <Button 265 - label={_(muted ? msg`Unmute` : msg`Mute`)} 266 - onPress={onPressFullscreen} 267 - {...btnProps}> 268 - {isFullscreen ? ( 269 - <ArrowsInIcon fill={t.palette.white} width={20} /> 270 - ) : ( 271 - <ArrowsOutIcon fill={t.palette.white} width={20} /> 272 - )} 273 - </Button> 274 - )} 275 - </View> 276 - {(showControls || !focused) && ( 277 - <Animated.View 278 - entering={FadeIn.duration(200)} 279 - exiting={FadeOut.duration(200)} 280 - style={[ 281 - a.absolute, 282 - { 283 - height: 5, 284 - bottom: 0, 285 - left: 0, 286 - right: 0, 287 - backgroundColor: 'rgba(255,255,255,0.4)', 288 - }, 289 - ]}> 290 - {duration > 0 && ( 291 - <View 292 - style={[ 293 - a.h_full, 294 - a.mr_auto, 295 - { 296 - backgroundColor: t.palette.white, 297 - width: `${(currentTime / duration) * 100}%`, 298 - opacity: 0.8, 299 - }, 300 - ]} 301 - /> 302 - )} 303 - </Animated.View> 304 - )} 305 - {(buffering || error) && ( 306 - <Animated.View 307 - pointerEvents="none" 308 - entering={FadeIn.delay(1000).duration(200)} 309 - exiting={FadeOut.duration(200)} 310 - style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 311 - {buffering && <Loader fill={t.palette.white} size="lg" />} 312 - {error && ( 313 - <Text style={{color: t.palette.white}}> 314 - <Trans>An error occurred</Trans> 315 - </Text> 316 - )} 317 - </Animated.View> 318 - )} 319 - </div> 320 - ) 321 - } 322 - 323 - const btnProps = { 324 - variant: 'ghost', 325 - shape: 'round', 326 - size: 'medium', 327 - style: a.p_2xs, 328 - hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'}, 329 - } as const 330 - 331 - function formatTime(time: number) { 332 - if (isNaN(time)) { 333 - return '--' 334 - } 335 - 336 - time = Math.round(time) 337 - 338 - const minutes = Math.floor(time / 60) 339 - const seconds = String(time % 60).padStart(2, '0') 340 - 341 - return `${minutes}:${seconds}` 342 - } 343 - 344 - function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) { 345 - const [playing, setPlaying] = useState(false) 346 - const [muted, setMuted] = useState(true) 347 - const [currentTime, setCurrentTime] = useState(0) 348 - const [duration, setDuration] = useState(0) 349 - const [buffering, setBuffering] = useState(false) 350 - const [error, setError] = useState(false) 351 - const [canPlay, setCanPlay] = useState(false) 352 - const playWhenReadyRef = useRef(false) 353 - 354 - useEffect(() => { 355 - if (!ref.current) return 356 - 357 - let bufferingTimeout: ReturnType<typeof setTimeout> | undefined 358 - 359 - function round(num: number) { 360 - return Math.round(num * 100) / 100 361 - } 362 - 363 - // Initial values 364 - setCurrentTime(round(ref.current.currentTime) || 0) 365 - setDuration(round(ref.current.duration) || 0) 366 - setMuted(ref.current.muted) 367 - setPlaying(!ref.current.paused) 368 - 369 - const handleTimeUpdate = () => { 370 - if (!ref.current) return 371 - setCurrentTime(round(ref.current.currentTime) || 0) 372 - } 373 - 374 - const handleDurationChange = () => { 375 - if (!ref.current) return 376 - setDuration(round(ref.current.duration) || 0) 377 - } 378 - 379 - const handlePlay = () => { 380 - setPlaying(true) 381 - } 382 - 383 - const handlePause = () => { 384 - setPlaying(false) 385 - } 386 - 387 - const handleVolumeChange = () => { 388 - if (!ref.current) return 389 - setMuted(ref.current.muted) 390 - } 391 - 392 - const handleError = () => { 393 - setError(true) 394 - } 395 - 396 - const handleCanPlay = () => { 397 - setBuffering(false) 398 - setCanPlay(true) 399 - 400 - if (!ref.current) return 401 - if (playWhenReadyRef.current) { 402 - ref.current.play() 403 - playWhenReadyRef.current = false 404 - } 405 - } 406 - 407 - const handleCanPlayThrough = () => { 408 - setBuffering(false) 409 - } 410 - 411 - const handleWaiting = () => { 412 - if (bufferingTimeout) clearTimeout(bufferingTimeout) 413 - bufferingTimeout = setTimeout(() => { 414 - setBuffering(true) 415 - }, 200) // Delay to avoid frequent buffering state changes 416 - } 417 - 418 - const handlePlaying = () => { 419 - if (bufferingTimeout) clearTimeout(bufferingTimeout) 420 - setBuffering(false) 421 - setError(false) 422 - } 423 - 424 - const handleSeeking = () => { 425 - setBuffering(true) 426 - } 427 - 428 - const handleSeeked = () => { 429 - setBuffering(false) 430 - } 431 - 432 - const handleStalled = () => { 433 - if (bufferingTimeout) clearTimeout(bufferingTimeout) 434 - bufferingTimeout = setTimeout(() => { 435 - setBuffering(true) 436 - }, 200) // Delay to avoid frequent buffering state changes 437 - } 438 - 439 - const handleEnded = () => { 440 - setPlaying(false) 441 - setBuffering(false) 442 - setError(false) 443 - } 444 - 445 - const abortController = new AbortController() 446 - 447 - ref.current.addEventListener('timeupdate', handleTimeUpdate, { 448 - signal: abortController.signal, 449 - }) 450 - ref.current.addEventListener('durationchange', handleDurationChange, { 451 - signal: abortController.signal, 452 - }) 453 - ref.current.addEventListener('play', handlePlay, { 454 - signal: abortController.signal, 455 - }) 456 - ref.current.addEventListener('pause', handlePause, { 457 - signal: abortController.signal, 458 - }) 459 - ref.current.addEventListener('volumechange', handleVolumeChange, { 460 - signal: abortController.signal, 461 - }) 462 - ref.current.addEventListener('error', handleError, { 463 - signal: abortController.signal, 464 - }) 465 - ref.current.addEventListener('canplay', handleCanPlay, { 466 - signal: abortController.signal, 467 - }) 468 - ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { 469 - signal: abortController.signal, 470 - }) 471 - ref.current.addEventListener('waiting', handleWaiting, { 472 - signal: abortController.signal, 473 - }) 474 - ref.current.addEventListener('playing', handlePlaying, { 475 - signal: abortController.signal, 476 - }) 477 - ref.current.addEventListener('seeking', handleSeeking, { 478 - signal: abortController.signal, 479 - }) 480 - ref.current.addEventListener('seeked', handleSeeked, { 481 - signal: abortController.signal, 482 - }) 483 - ref.current.addEventListener('stalled', handleStalled, { 484 - signal: abortController.signal, 485 - }) 486 - ref.current.addEventListener('ended', handleEnded, { 487 - signal: abortController.signal, 488 - }) 489 - 490 - return () => { 491 - abortController.abort() 492 - clearTimeout(bufferingTimeout) 493 - } 494 - }, [ref]) 495 - 496 - const play = useCallback(() => { 497 - if (!ref.current) return 498 - 499 - if (ref.current.ended) { 500 - ref.current.currentTime = 0 501 - } 502 - 503 - if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { 504 - playWhenReadyRef.current = true 505 - } else { 506 - const promise = ref.current.play() 507 - if (promise !== undefined) { 508 - promise.catch(err => { 509 - console.error('Error playing video:', err) 510 - }) 511 - } 512 - } 513 - }, [ref]) 514 - 515 - const pause = useCallback(() => { 516 - if (!ref.current) return 517 - 518 - ref.current.pause() 519 - playWhenReadyRef.current = false 520 - }, [ref]) 521 - 522 - const togglePlayPause = useCallback(() => { 523 - if (!ref.current) return 524 - 525 - if (ref.current.paused) { 526 - play() 527 - } else { 528 - pause() 529 - } 530 - }, [ref, play, pause]) 531 - 532 - const mute = useCallback(() => { 533 - if (!ref.current) return 534 - 535 - ref.current.muted = true 536 - }, [ref]) 537 - 538 - const unmute = useCallback(() => { 539 - if (!ref.current) return 540 - 541 - ref.current.muted = false 542 - }, [ref]) 543 - 544 - const toggleMute = useCallback(() => { 545 - if (!ref.current) return 546 - 547 - ref.current.muted = !ref.current.muted 548 - }, [ref]) 549 - 550 - return { 551 - play, 552 - pause, 553 - togglePlayPause, 554 - duration, 555 - currentTime, 556 - playing, 557 - muted, 558 - mute, 559 - unmute, 560 - toggleMute, 561 - buffering, 562 - error, 563 - canPlay, 564 - } 565 - } 566 - 567 - function fullscreenSubscribe(onChange: () => void) { 568 - document.addEventListener('fullscreenchange', onChange) 569 - return () => document.removeEventListener('fullscreenchange', onChange) 570 - } 571 - 572 - function useFullscreen(ref: React.RefObject<HTMLElement>) { 573 - const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () => 574 - Boolean(document.fullscreenElement), 575 - ) 576 - 577 - const toggleFullscreen = useCallback(() => { 578 - if (isFullscreen) { 579 - document.exitFullscreen() 580 - } else { 581 - if (!ref.current) return 582 - ref.current.requestFullscreen() 583 - } 584 - }, [isFullscreen, ref]) 585 - 586 - return [isFullscreen, toggleFullscreen] as const 587 - }