Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[Video] Volume controls on web (#5363)

* split up VideoWebControls

* add basic slider

* logarithmic volume

* integrate mute state

* fix typo

* shared video volume

* rm log

* animate in/out

* disable for touch devices

* remove flicker on touch devices

* more detailed comment

* move into correct context provider

* add minHeight

* hack

* bettern umber

---------

Co-authored-by: Hailey <me@haileyok.com>

authored by

Samuel Newman
Hailey
and committed by
GitHub
8241747f 38c8f015

+1148 -911
+45
bskyweb/templates/base.html
··· 258 258 .force-no-clicks * { 259 259 pointer-events: none !important; 260 260 } 261 + 262 + input[type=range][orient=vertical] { 263 + writing-mode: vertical-lr; 264 + direction: rtl; 265 + appearance: slider-vertical; 266 + width: 16px; 267 + vertical-align: bottom; 268 + -webkit-appearance: none; 269 + appearance: none; 270 + background: transparent; 271 + cursor: pointer; 272 + } 273 + 274 + input[type="range"][orient=vertical]::-webkit-slider-runnable-track { 275 + background: white; 276 + height: 100%; 277 + width: 4px; 278 + border-radius: 4px; 279 + } 280 + 281 + input[type="range"][orient=vertical]::-moz-range-track { 282 + background: white; 283 + height: 100%; 284 + width: 4px; 285 + border-radius: 4px; 286 + } 287 + 288 + input[type="range"]::-webkit-slider-thumb { 289 + -webkit-appearance: none; 290 + appearance: none; 291 + border-radius: 50%; 292 + background-color: white; 293 + height: 16px; 294 + width: 16px; 295 + margin-left: -6px; 296 + } 297 + 298 + input[type="range"][orient=vertical]::-moz-range-thumb { 299 + border: none; 300 + border-radius: 50%; 301 + background-color: white; 302 + height: 16px; 303 + width: 16px; 304 + margin-left: -6px; 305 + } 261 306 </style> 262 307 {% include "scripts.html" %} 263 308 <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
+1 -1
src/view/com/util/post-embeds/ActiveVideoWebContext.tsx
··· 18 18 19 19 export function Provider({children}: {children: React.ReactNode}) { 20 20 if (!isWeb) { 21 - throw new Error('ActiveVideoWebContext may onl be used on web.') 21 + throw new Error('ActiveVideoWebContext may only be used on web.') 22 22 } 23 23 24 24 const [activeViewId, setActiveViewId] = useState<string | null>(null)
+3 -3
src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
··· 9 9 import {HITSLOP_30} from '#/lib/constants' 10 10 import {clamp} from '#/lib/numbers' 11 11 import {useAutoplayDisabled} from '#/state/preferences' 12 - import {useVideoVolumeState} from 'view/com/util/post-embeds/VideoVolumeContext' 12 + import {useVideoMuteState} from 'view/com/util/post-embeds/VideoVolumeContext' 13 13 import {atoms as a, useTheme} from '#/alf' 14 14 import {useIsWithinMessage} from '#/components/dms/MessageContext' 15 15 import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' ··· 38 38 const videoRef = useRef<BlueskyVideoView>(null) 39 39 const autoplayDisabled = useAutoplayDisabled() 40 40 const isWithinMessage = useIsWithinMessage() 41 - const {muted, setMuted} = useVideoVolumeState() 41 + const [muted, setMuted] = useVideoMuteState() 42 42 43 43 const [isPlaying, setIsPlaying] = React.useState(false) 44 44 const [timeRemaining, setTimeRemaining] = React.useState(0) ··· 128 128 }) { 129 129 const {_} = useLingui() 130 130 const t = useTheme() 131 - const {muted} = useVideoVolumeState() 131 + const [muted] = useVideoMuteState() 132 132 133 133 // show countdown when: 134 134 // 1. timeRemaining is a number - was seeing NaNs
+1 -1
src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
··· 5 5 6 6 import {atoms as a} from '#/alf' 7 7 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 8 - import {Controls} from './VideoWebControls' 8 + import {Controls} from './web-controls/VideoControls' 9 9 10 10 export function VideoEmbedInnerWeb({ 11 11 embed,
src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx
-898
src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
··· 1 - import React, {useCallback, useEffect, useRef, useState} from 'react' 2 - import {Pressable, View} from 'react-native' 3 - import {SvgProps} from 'react-native-svg' 4 - import {msg, Trans} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 6 - import type Hls from 'hls.js' 7 - 8 - import {isFirefox} from '#/lib/browser' 9 - import {clamp} from '#/lib/numbers' 10 - import {isIPhoneWeb} from '#/platform/detection' 11 - import { 12 - useAutoplayDisabled, 13 - useSetSubtitlesEnabled, 14 - useSubtitlesEnabled, 15 - } from '#/state/preferences' 16 - import {atoms as a, useTheme, web} from '#/alf' 17 - import {Button} from '#/components/Button' 18 - import {useIsWithinMessage} from '#/components/dms/MessageContext' 19 - import {useFullscreen} from '#/components/hooks/useFullscreen' 20 - import {useInteractionState} from '#/components/hooks/useInteractionState' 21 - import { 22 - ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, 23 - ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, 24 - } from '#/components/icons/ArrowsDiagonal' 25 - import { 26 - CC_Filled_Corner0_Rounded as CCActiveIcon, 27 - CC_Stroke2_Corner0_Rounded as CCInactiveIcon, 28 - } from '#/components/icons/CC' 29 - import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 30 - import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' 31 - import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 32 - import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' 33 - import {Loader} from '#/components/Loader' 34 - import {Text} from '#/components/Typography' 35 - import {TimeIndicator} from './TimeIndicator' 36 - 37 - export function Controls({ 38 - videoRef, 39 - hlsRef, 40 - active, 41 - setActive, 42 - focused, 43 - setFocused, 44 - onScreen, 45 - fullscreenRef, 46 - hasSubtitleTrack, 47 - }: { 48 - videoRef: React.RefObject<HTMLVideoElement> 49 - hlsRef: React.RefObject<Hls | undefined> 50 - active: boolean 51 - setActive: () => void 52 - focused: boolean 53 - setFocused: (focused: boolean) => void 54 - onScreen: boolean 55 - fullscreenRef: React.RefObject<HTMLDivElement> 56 - hasSubtitleTrack: boolean 57 - }) { 58 - const { 59 - play, 60 - pause, 61 - playing, 62 - muted, 63 - toggleMute, 64 - togglePlayPause, 65 - currentTime, 66 - duration, 67 - buffering, 68 - error, 69 - canPlay, 70 - } = useVideoUtils(videoRef) 71 - const t = useTheme() 72 - const {_} = useLingui() 73 - const subtitlesEnabled = useSubtitlesEnabled() 74 - const setSubtitlesEnabled = useSetSubtitlesEnabled() 75 - const { 76 - state: hovered, 77 - onIn: onHover, 78 - onOut: onEndHover, 79 - } = useInteractionState() 80 - const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) 81 - const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() 82 - const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) 83 - 84 - const onKeyDown = useCallback(() => { 85 - setInteractingViaKeypress(true) 86 - }, []) 87 - 88 - useEffect(() => { 89 - if (interactingViaKeypress) { 90 - document.addEventListener('click', () => setInteractingViaKeypress(false)) 91 - return () => { 92 - document.removeEventListener('click', () => 93 - setInteractingViaKeypress(false), 94 - ) 95 - } 96 - } 97 - }, [interactingViaKeypress]) 98 - 99 - useEffect(() => { 100 - if (isFullscreen) { 101 - document.documentElement.style.scrollbarGutter = 'unset' 102 - return () => { 103 - document.documentElement.style.removeProperty('scrollbar-gutter') 104 - } 105 - } 106 - }, [isFullscreen]) 107 - 108 - // pause + unfocus when another video is active 109 - useEffect(() => { 110 - if (!active) { 111 - pause() 112 - setFocused(false) 113 - } 114 - }, [active, pause, setFocused]) 115 - 116 - // autoplay/pause based on visibility 117 - const isWithinMessage = useIsWithinMessage() 118 - const autoplayDisabled = useAutoplayDisabled() || isWithinMessage 119 - useEffect(() => { 120 - if (active) { 121 - if (onScreen) { 122 - if (!autoplayDisabled) play() 123 - } else { 124 - pause() 125 - } 126 - } 127 - }, [onScreen, pause, active, play, autoplayDisabled]) 128 - 129 - // use minimal quality when not focused 130 - useEffect(() => { 131 - if (!hlsRef.current) return 132 - if (focused) { 133 - // auto decide quality based on network conditions 134 - hlsRef.current.autoLevelCapping = -1 135 - // allow 30s of buffering 136 - hlsRef.current.config.maxMaxBufferLength = 30 137 - } else { 138 - // back to what we initially set 139 - hlsRef.current.autoLevelCapping = 0 140 - hlsRef.current.config.maxMaxBufferLength = 10 141 - } 142 - }, [hlsRef, focused]) 143 - 144 - useEffect(() => { 145 - if (!hlsRef.current) return 146 - if (hasSubtitleTrack && subtitlesEnabled && canPlay) { 147 - hlsRef.current.subtitleTrack = 0 148 - } else { 149 - hlsRef.current.subtitleTrack = -1 150 - } 151 - }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) 152 - 153 - // clicking on any button should focus the player, if it's not already focused 154 - const drawFocus = useCallback(() => { 155 - if (!active) { 156 - setActive() 157 - } 158 - setFocused(true) 159 - }, [active, setActive, setFocused]) 160 - 161 - const onPressEmptySpace = useCallback(() => { 162 - if (!focused) { 163 - drawFocus() 164 - if (autoplayDisabled) play() 165 - } else { 166 - togglePlayPause() 167 - } 168 - }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play]) 169 - 170 - const onPressPlayPause = useCallback(() => { 171 - drawFocus() 172 - togglePlayPause() 173 - }, [drawFocus, togglePlayPause]) 174 - 175 - const onPressSubtitles = useCallback(() => { 176 - drawFocus() 177 - setSubtitlesEnabled(!subtitlesEnabled) 178 - }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) 179 - 180 - const onPressMute = useCallback(() => { 181 - drawFocus() 182 - toggleMute() 183 - }, [drawFocus, toggleMute]) 184 - 185 - const onPressFullscreen = useCallback(() => { 186 - drawFocus() 187 - toggleFullscreen() 188 - }, [drawFocus, toggleFullscreen]) 189 - 190 - const onSeek = useCallback( 191 - (time: number) => { 192 - if (!videoRef.current) return 193 - if (videoRef.current.fastSeek) { 194 - videoRef.current.fastSeek(time) 195 - } else { 196 - videoRef.current.currentTime = time 197 - } 198 - }, 199 - [videoRef], 200 - ) 201 - 202 - const playStateBeforeSeekRef = useRef(false) 203 - 204 - const onSeekStart = useCallback(() => { 205 - drawFocus() 206 - playStateBeforeSeekRef.current = playing 207 - pause() 208 - }, [playing, pause, drawFocus]) 209 - 210 - const onSeekEnd = useCallback(() => { 211 - if (playStateBeforeSeekRef.current) { 212 - play() 213 - } 214 - }, [play]) 215 - 216 - const seekLeft = useCallback(() => { 217 - if (!videoRef.current) return 218 - // eslint-disable-next-line @typescript-eslint/no-shadow 219 - const currentTime = videoRef.current.currentTime 220 - // eslint-disable-next-line @typescript-eslint/no-shadow 221 - const duration = videoRef.current.duration || 0 222 - onSeek(clamp(currentTime - 5, 0, duration)) 223 - }, [onSeek, videoRef]) 224 - 225 - const seekRight = useCallback(() => { 226 - if (!videoRef.current) return 227 - // eslint-disable-next-line @typescript-eslint/no-shadow 228 - const currentTime = videoRef.current.currentTime 229 - // eslint-disable-next-line @typescript-eslint/no-shadow 230 - const duration = videoRef.current.duration || 0 231 - onSeek(clamp(currentTime + 5, 0, duration)) 232 - }, [onSeek, videoRef]) 233 - 234 - const [showCursor, setShowCursor] = useState(true) 235 - const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>() 236 - const onPointerMoveEmptySpace = useCallback(() => { 237 - setShowCursor(true) 238 - if (cursorTimeoutRef.current) { 239 - clearTimeout(cursorTimeoutRef.current) 240 - } 241 - cursorTimeoutRef.current = setTimeout(() => { 242 - setShowCursor(false) 243 - onEndHover() 244 - }, 2000) 245 - }, [onEndHover]) 246 - const onPointerLeaveEmptySpace = useCallback(() => { 247 - setShowCursor(false) 248 - if (cursorTimeoutRef.current) { 249 - clearTimeout(cursorTimeoutRef.current) 250 - } 251 - }, []) 252 - 253 - // these are used to trigger the hover state. on mobile, the hover state 254 - // should stick around for a bit after they tap, and if the controls aren't 255 - // present this initial tab should *only* show the controls and not activate anything 256 - 257 - const onPointerDown = useCallback( 258 - (evt: React.PointerEvent<HTMLDivElement>) => { 259 - if (evt.pointerType !== 'mouse' && !hovered) { 260 - evt.preventDefault() 261 - } 262 - clearTimeout(timeoutRef.current) 263 - }, 264 - [hovered], 265 - ) 266 - 267 - const timeoutRef = useRef<ReturnType<typeof setTimeout>>() 268 - 269 - const onHoverWithTimeout = useCallback(() => { 270 - onHover() 271 - clearTimeout(timeoutRef.current) 272 - }, [onHover]) 273 - 274 - const onEndHoverWithTimeout = useCallback( 275 - (evt: React.PointerEvent<HTMLDivElement>) => { 276 - // if touch, end after 3s 277 - // if mouse, end immediately 278 - if (evt.pointerType !== 'mouse') { 279 - setTimeout(onEndHover, 3000) 280 - } else { 281 - onEndHover() 282 - } 283 - }, 284 - [onEndHover], 285 - ) 286 - 287 - const showControls = 288 - ((focused || autoplayDisabled) && !playing) || 289 - (interactingViaKeypress ? hasFocus : hovered) 290 - 291 - return ( 292 - <div 293 - style={{ 294 - position: 'absolute', 295 - inset: 0, 296 - overflow: 'hidden', 297 - display: 'flex', 298 - flexDirection: 'column', 299 - }} 300 - onClick={evt => { 301 - evt.stopPropagation() 302 - setInteractingViaKeypress(false) 303 - }} 304 - onPointerEnter={onHoverWithTimeout} 305 - onPointerMove={onHoverWithTimeout} 306 - onPointerLeave={onEndHoverWithTimeout} 307 - onPointerDown={onPointerDown} 308 - onFocus={onFocus} 309 - onBlur={onBlur} 310 - onKeyDown={onKeyDown}> 311 - <Pressable 312 - accessibilityRole="button" 313 - onPointerEnter={onPointerMoveEmptySpace} 314 - onPointerMove={onPointerMoveEmptySpace} 315 - onPointerLeave={onPointerLeaveEmptySpace} 316 - accessibilityHint={_( 317 - !focused 318 - ? msg`Unmute video` 319 - : playing 320 - ? msg`Pause video` 321 - : msg`Play video`, 322 - )} 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 - <Scrubber 345 - duration={duration} 346 - currentTime={currentTime} 347 - onSeek={onSeek} 348 - onSeekStart={onSeekStart} 349 - onSeekEnd={onSeekEnd} 350 - seekLeft={seekLeft} 351 - seekRight={seekRight} 352 - togglePlayPause={togglePlayPause} 353 - drawFocus={drawFocus} 354 - /> 355 - <View 356 - style={[ 357 - a.flex_1, 358 - a.px_xs, 359 - a.pt_2xs, 360 - a.pb_md, 361 - a.gap_md, 362 - a.flex_row, 363 - a.align_center, 364 - ]}> 365 - <ControlButton 366 - active={playing} 367 - activeLabel={_(msg`Pause`)} 368 - inactiveLabel={_(msg`Play`)} 369 - activeIcon={PauseIcon} 370 - inactiveIcon={PlayIcon} 371 - onPress={onPressPlayPause} 372 - /> 373 - <View style={a.flex_1} /> 374 - <Text style={{color: t.palette.white, fontVariant: ['tabular-nums']}}> 375 - {formatTime(currentTime)} / {formatTime(duration)} 376 - </Text> 377 - {hasSubtitleTrack && ( 378 - <ControlButton 379 - active={subtitlesEnabled} 380 - activeLabel={_(msg`Disable subtitles`)} 381 - inactiveLabel={_(msg`Enable subtitles`)} 382 - activeIcon={CCActiveIcon} 383 - inactiveIcon={CCInactiveIcon} 384 - onPress={onPressSubtitles} 385 - /> 386 - )} 387 - <ControlButton 388 - active={muted} 389 - activeLabel={_(msg({message: `Unmute`, context: 'video'}))} 390 - inactiveLabel={_(msg({message: `Mute`, context: 'video'}))} 391 - activeIcon={MuteIcon} 392 - inactiveIcon={UnmuteIcon} 393 - onPress={onPressMute} 394 - /> 395 - {!isIPhoneWeb && ( 396 - <ControlButton 397 - active={isFullscreen} 398 - activeLabel={_(msg`Exit fullscreen`)} 399 - inactiveLabel={_(msg`Fullscreen`)} 400 - activeIcon={ArrowsInIcon} 401 - inactiveIcon={ArrowsOutIcon} 402 - onPress={onPressFullscreen} 403 - /> 404 - )} 405 - </View> 406 - </View> 407 - {(buffering || error) && ( 408 - <View 409 - pointerEvents="none" 410 - style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 411 - {buffering && <Loader fill={t.palette.white} size="lg" />} 412 - {error && ( 413 - <Text style={{color: t.palette.white}}> 414 - <Trans>An error occurred</Trans> 415 - </Text> 416 - )} 417 - </View> 418 - )} 419 - </div> 420 - ) 421 - } 422 - 423 - function ControlButton({ 424 - active, 425 - activeLabel, 426 - inactiveLabel, 427 - activeIcon: ActiveIcon, 428 - inactiveIcon: InactiveIcon, 429 - onPress, 430 - }: { 431 - active: boolean 432 - activeLabel: string 433 - inactiveLabel: string 434 - activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> 435 - inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> 436 - onPress: () => void 437 - }) { 438 - const t = useTheme() 439 - return ( 440 - <Button 441 - label={active ? activeLabel : inactiveLabel} 442 - onPress={onPress} 443 - variant="ghost" 444 - shape="round" 445 - size="medium" 446 - style={a.p_2xs} 447 - hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.1)'}}> 448 - {active ? ( 449 - <ActiveIcon fill={t.palette.white} width={20} /> 450 - ) : ( 451 - <InactiveIcon fill={t.palette.white} width={20} /> 452 - )} 453 - </Button> 454 - ) 455 - } 456 - 457 - function Scrubber({ 458 - duration, 459 - currentTime, 460 - onSeek, 461 - onSeekEnd, 462 - onSeekStart, 463 - seekLeft, 464 - seekRight, 465 - togglePlayPause, 466 - drawFocus, 467 - }: { 468 - duration: number 469 - currentTime: number 470 - onSeek: (time: number) => void 471 - onSeekEnd: () => void 472 - onSeekStart: () => void 473 - seekLeft: () => void 474 - seekRight: () => void 475 - togglePlayPause: () => void 476 - drawFocus: () => void 477 - }) { 478 - const {_} = useLingui() 479 - const t = useTheme() 480 - const [scrubberActive, setScrubberActive] = useState(false) 481 - const { 482 - state: hovered, 483 - onIn: onStartHover, 484 - onOut: onEndHover, 485 - } = useInteractionState() 486 - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 487 - const [seekPosition, setSeekPosition] = useState(0) 488 - const isSeekingRef = useRef(false) 489 - const barRef = useRef<HTMLDivElement>(null) 490 - const circleRef = useRef<HTMLDivElement>(null) 491 - 492 - const seek = useCallback( 493 - (evt: React.PointerEvent<HTMLDivElement>) => { 494 - if (!barRef.current) return 495 - const {left, width} = barRef.current.getBoundingClientRect() 496 - const x = evt.clientX 497 - const percent = clamp((x - left) / width, 0, 1) * duration 498 - onSeek(percent) 499 - setSeekPosition(percent) 500 - }, 501 - [duration, onSeek], 502 - ) 503 - 504 - const onPointerDown = useCallback( 505 - (evt: React.PointerEvent<HTMLDivElement>) => { 506 - const target = evt.target 507 - if (target instanceof Element) { 508 - evt.preventDefault() 509 - target.setPointerCapture(evt.pointerId) 510 - isSeekingRef.current = true 511 - seek(evt) 512 - setScrubberActive(true) 513 - onSeekStart() 514 - } 515 - }, 516 - [seek, onSeekStart], 517 - ) 518 - 519 - const onPointerMove = useCallback( 520 - (evt: React.PointerEvent<HTMLDivElement>) => { 521 - if (isSeekingRef.current) { 522 - evt.preventDefault() 523 - seek(evt) 524 - } 525 - }, 526 - [seek], 527 - ) 528 - 529 - const onPointerUp = useCallback( 530 - (evt: React.PointerEvent<HTMLDivElement>) => { 531 - const target = evt.target 532 - if (isSeekingRef.current && target instanceof Element) { 533 - evt.preventDefault() 534 - target.releasePointerCapture(evt.pointerId) 535 - isSeekingRef.current = false 536 - onSeekEnd() 537 - setScrubberActive(false) 538 - } 539 - }, 540 - [onSeekEnd], 541 - ) 542 - 543 - useEffect(() => { 544 - // HACK: there's divergent browser behaviour about what to do when 545 - // a pointerUp event is fired outside the element that captured the 546 - // pointer. Firefox clicks on the element the mouse is over, so we have 547 - // to make everything unclickable while seeking -sfn 548 - if (isFirefox && scrubberActive) { 549 - document.body.classList.add('force-no-clicks') 550 - 551 - return () => { 552 - document.body.classList.remove('force-no-clicks') 553 - } 554 - } 555 - }, [scrubberActive, onSeekEnd]) 556 - 557 - useEffect(() => { 558 - if (!circleRef.current) return 559 - if (focused) { 560 - const abortController = new AbortController() 561 - const {signal} = abortController 562 - circleRef.current.addEventListener( 563 - 'keydown', 564 - evt => { 565 - // space: play/pause 566 - // arrow left: seek backward 567 - // arrow right: seek forward 568 - 569 - if (evt.key === ' ') { 570 - evt.preventDefault() 571 - drawFocus() 572 - togglePlayPause() 573 - } else if (evt.key === 'ArrowLeft') { 574 - evt.preventDefault() 575 - drawFocus() 576 - seekLeft() 577 - } else if (evt.key === 'ArrowRight') { 578 - evt.preventDefault() 579 - drawFocus() 580 - seekRight() 581 - } 582 - }, 583 - {signal}, 584 - ) 585 - 586 - return () => abortController.abort() 587 - } 588 - }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus]) 589 - 590 - const progress = scrubberActive ? seekPosition : currentTime 591 - const progressPercent = (progress / duration) * 100 592 - 593 - return ( 594 - <View 595 - testID="scrubber" 596 - style={[{height: 18, width: '100%'}, a.flex_shrink_0, a.px_xs]} 597 - onPointerEnter={onStartHover} 598 - onPointerLeave={onEndHover}> 599 - <div 600 - ref={barRef} 601 - style={{ 602 - flex: 1, 603 - display: 'flex', 604 - alignItems: 'center', 605 - position: 'relative', 606 - cursor: scrubberActive ? 'grabbing' : 'grab', 607 - padding: '4px 0', 608 - }} 609 - onPointerDown={onPointerDown} 610 - onPointerMove={onPointerMove} 611 - onPointerUp={onPointerUp} 612 - onPointerCancel={onPointerUp}> 613 - <View 614 - style={[ 615 - a.w_full, 616 - a.rounded_full, 617 - a.overflow_hidden, 618 - {backgroundColor: 'rgba(255, 255, 255, 0.4)'}, 619 - {height: hovered || scrubberActive ? 6 : 3}, 620 - ]}> 621 - {duration > 0 && ( 622 - <View 623 - style={[ 624 - a.h_full, 625 - {backgroundColor: t.palette.white}, 626 - {width: `${progressPercent}%`}, 627 - ]} 628 - /> 629 - )} 630 - </View> 631 - <div 632 - ref={circleRef} 633 - aria-label={_(msg`Seek slider`)} 634 - role="slider" 635 - aria-valuemax={duration} 636 - aria-valuemin={0} 637 - aria-valuenow={currentTime} 638 - aria-valuetext={_( 639 - msg`${formatTime(currentTime)} of ${formatTime(duration)}`, 640 - )} 641 - tabIndex={0} 642 - onFocus={onFocus} 643 - onBlur={onBlur} 644 - style={{ 645 - position: 'absolute', 646 - height: 16, 647 - width: 16, 648 - left: `calc(${progressPercent}% - 8px)`, 649 - borderRadius: 8, 650 - pointerEvents: 'none', 651 - }}> 652 - <View 653 - style={[ 654 - a.w_full, 655 - a.h_full, 656 - a.rounded_full, 657 - {backgroundColor: t.palette.white}, 658 - { 659 - transform: [ 660 - { 661 - scale: 662 - hovered || scrubberActive || focused 663 - ? scrubberActive 664 - ? 1 665 - : 0.6 666 - : 0, 667 - }, 668 - ], 669 - }, 670 - ]} 671 - /> 672 - </div> 673 - </div> 674 - </View> 675 - ) 676 - } 677 - 678 - function formatTime(time: number) { 679 - if (isNaN(time)) { 680 - return '--' 681 - } 682 - 683 - time = Math.round(time) 684 - 685 - const minutes = Math.floor(time / 60) 686 - const seconds = String(time % 60).padStart(2, '0') 687 - 688 - return `${minutes}:${seconds}` 689 - } 690 - 691 - function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) { 692 - const [playing, setPlaying] = useState(false) 693 - const [muted, setMuted] = useState(true) 694 - const [currentTime, setCurrentTime] = useState(0) 695 - const [duration, setDuration] = useState(0) 696 - const [buffering, setBuffering] = useState(false) 697 - const [error, setError] = useState(false) 698 - const [canPlay, setCanPlay] = useState(false) 699 - const playWhenReadyRef = useRef(false) 700 - 701 - useEffect(() => { 702 - if (!ref.current) return 703 - 704 - let bufferingTimeout: ReturnType<typeof setTimeout> | undefined 705 - 706 - function round(num: number) { 707 - return Math.round(num * 100) / 100 708 - } 709 - 710 - // Initial values 711 - setCurrentTime(round(ref.current.currentTime) || 0) 712 - setDuration(round(ref.current.duration) || 0) 713 - setMuted(ref.current.muted) 714 - setPlaying(!ref.current.paused) 715 - 716 - const handleTimeUpdate = () => { 717 - if (!ref.current) return 718 - setCurrentTime(round(ref.current.currentTime) || 0) 719 - } 720 - 721 - const handleDurationChange = () => { 722 - if (!ref.current) return 723 - setDuration(round(ref.current.duration) || 0) 724 - } 725 - 726 - const handlePlay = () => { 727 - setPlaying(true) 728 - } 729 - 730 - const handlePause = () => { 731 - setPlaying(false) 732 - } 733 - 734 - const handleVolumeChange = () => { 735 - if (!ref.current) return 736 - setMuted(ref.current.muted) 737 - } 738 - 739 - const handleError = () => { 740 - setError(true) 741 - } 742 - 743 - const handleCanPlay = () => { 744 - setBuffering(false) 745 - setCanPlay(true) 746 - 747 - if (!ref.current) return 748 - if (playWhenReadyRef.current) { 749 - ref.current.play() 750 - playWhenReadyRef.current = false 751 - } 752 - } 753 - 754 - const handleCanPlayThrough = () => { 755 - setBuffering(false) 756 - } 757 - 758 - const handleWaiting = () => { 759 - if (bufferingTimeout) clearTimeout(bufferingTimeout) 760 - bufferingTimeout = setTimeout(() => { 761 - setBuffering(true) 762 - }, 200) // Delay to avoid frequent buffering state changes 763 - } 764 - 765 - const handlePlaying = () => { 766 - if (bufferingTimeout) clearTimeout(bufferingTimeout) 767 - setBuffering(false) 768 - setError(false) 769 - } 770 - 771 - const handleStalled = () => { 772 - if (bufferingTimeout) clearTimeout(bufferingTimeout) 773 - bufferingTimeout = setTimeout(() => { 774 - setBuffering(true) 775 - }, 200) // Delay to avoid frequent buffering state changes 776 - } 777 - 778 - const handleEnded = () => { 779 - setPlaying(false) 780 - setBuffering(false) 781 - setError(false) 782 - } 783 - 784 - const abortController = new AbortController() 785 - 786 - ref.current.addEventListener('timeupdate', handleTimeUpdate, { 787 - signal: abortController.signal, 788 - }) 789 - ref.current.addEventListener('durationchange', handleDurationChange, { 790 - signal: abortController.signal, 791 - }) 792 - ref.current.addEventListener('play', handlePlay, { 793 - signal: abortController.signal, 794 - }) 795 - ref.current.addEventListener('pause', handlePause, { 796 - signal: abortController.signal, 797 - }) 798 - ref.current.addEventListener('volumechange', handleVolumeChange, { 799 - signal: abortController.signal, 800 - }) 801 - ref.current.addEventListener('error', handleError, { 802 - signal: abortController.signal, 803 - }) 804 - ref.current.addEventListener('canplay', handleCanPlay, { 805 - signal: abortController.signal, 806 - }) 807 - ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { 808 - signal: abortController.signal, 809 - }) 810 - ref.current.addEventListener('waiting', handleWaiting, { 811 - signal: abortController.signal, 812 - }) 813 - ref.current.addEventListener('playing', handlePlaying, { 814 - signal: abortController.signal, 815 - }) 816 - ref.current.addEventListener('stalled', handleStalled, { 817 - signal: abortController.signal, 818 - }) 819 - ref.current.addEventListener('ended', handleEnded, { 820 - signal: abortController.signal, 821 - }) 822 - 823 - return () => { 824 - abortController.abort() 825 - clearTimeout(bufferingTimeout) 826 - } 827 - }, [ref]) 828 - 829 - const play = useCallback(() => { 830 - if (!ref.current) return 831 - 832 - if (ref.current.ended) { 833 - ref.current.currentTime = 0 834 - } 835 - 836 - if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { 837 - playWhenReadyRef.current = true 838 - } else { 839 - const promise = ref.current.play() 840 - if (promise !== undefined) { 841 - promise.catch(err => { 842 - console.error('Error playing video:', err) 843 - }) 844 - } 845 - } 846 - }, [ref]) 847 - 848 - const pause = useCallback(() => { 849 - if (!ref.current) return 850 - 851 - ref.current.pause() 852 - playWhenReadyRef.current = false 853 - }, [ref]) 854 - 855 - const togglePlayPause = useCallback(() => { 856 - if (!ref.current) return 857 - 858 - if (ref.current.paused) { 859 - play() 860 - } else { 861 - pause() 862 - } 863 - }, [ref, play, pause]) 864 - 865 - const mute = useCallback(() => { 866 - if (!ref.current) return 867 - 868 - ref.current.muted = true 869 - }, [ref]) 870 - 871 - const unmute = useCallback(() => { 872 - if (!ref.current) return 873 - 874 - ref.current.muted = false 875 - }, [ref]) 876 - 877 - const toggleMute = useCallback(() => { 878 - if (!ref.current) return 879 - 880 - ref.current.muted = !ref.current.muted 881 - }, [ref]) 882 - 883 - return { 884 - play, 885 - pause, 886 - togglePlayPause, 887 - duration, 888 - currentTime, 889 - playing, 890 - muted, 891 - mute, 892 - unmute, 893 - toggleMute, 894 - buffering, 895 - error, 896 - canPlay, 897 - } 898 - }
+39
src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx
··· 1 + import React from 'react' 2 + import {SvgProps} from 'react-native-svg' 3 + 4 + import {atoms as a, useTheme} from '#/alf' 5 + import {Button} from '#/components/Button' 6 + 7 + export function ControlButton({ 8 + active, 9 + activeLabel, 10 + inactiveLabel, 11 + activeIcon: ActiveIcon, 12 + inactiveIcon: InactiveIcon, 13 + onPress, 14 + }: { 15 + active: boolean 16 + activeLabel: string 17 + inactiveLabel: string 18 + activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> 19 + inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> 20 + onPress: () => void 21 + }) { 22 + const t = useTheme() 23 + return ( 24 + <Button 25 + label={active ? activeLabel : inactiveLabel} 26 + onPress={onPress} 27 + variant="ghost" 28 + shape="round" 29 + size="medium" 30 + style={a.p_2xs} 31 + hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.1)'}}> 32 + {active ? ( 33 + <ActiveIcon fill={t.palette.white} width={20} /> 34 + ) : ( 35 + <InactiveIcon fill={t.palette.white} width={20} /> 36 + )} 37 + </Button> 38 + ) 39 + }
+231
src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx
··· 1 + import React, {useCallback, useEffect, useRef, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {isFirefox} from '#/lib/browser' 7 + import {clamp} from '#/lib/numbers' 8 + import {atoms as a, useTheme} from '#/alf' 9 + import {useInteractionState} from '#/components/hooks/useInteractionState' 10 + import {formatTime} from './utils' 11 + 12 + export function Scrubber({ 13 + duration, 14 + currentTime, 15 + onSeek, 16 + onSeekEnd, 17 + onSeekStart, 18 + seekLeft, 19 + seekRight, 20 + togglePlayPause, 21 + drawFocus, 22 + }: { 23 + duration: number 24 + currentTime: number 25 + onSeek: (time: number) => void 26 + onSeekEnd: () => void 27 + onSeekStart: () => void 28 + seekLeft: () => void 29 + seekRight: () => void 30 + togglePlayPause: () => void 31 + drawFocus: () => void 32 + }) { 33 + const {_} = useLingui() 34 + const t = useTheme() 35 + const [scrubberActive, setScrubberActive] = useState(false) 36 + const { 37 + state: hovered, 38 + onIn: onStartHover, 39 + onOut: onEndHover, 40 + } = useInteractionState() 41 + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 42 + const [seekPosition, setSeekPosition] = useState(0) 43 + const isSeekingRef = useRef(false) 44 + const barRef = useRef<HTMLDivElement>(null) 45 + const circleRef = useRef<HTMLDivElement>(null) 46 + 47 + const seek = useCallback( 48 + (evt: React.PointerEvent<HTMLDivElement>) => { 49 + if (!barRef.current) return 50 + const {left, width} = barRef.current.getBoundingClientRect() 51 + const x = evt.clientX 52 + const percent = clamp((x - left) / width, 0, 1) * duration 53 + onSeek(percent) 54 + setSeekPosition(percent) 55 + }, 56 + [duration, onSeek], 57 + ) 58 + 59 + const onPointerDown = useCallback( 60 + (evt: React.PointerEvent<HTMLDivElement>) => { 61 + const target = evt.target 62 + if (target instanceof Element) { 63 + evt.preventDefault() 64 + target.setPointerCapture(evt.pointerId) 65 + isSeekingRef.current = true 66 + seek(evt) 67 + setScrubberActive(true) 68 + onSeekStart() 69 + } 70 + }, 71 + [seek, onSeekStart], 72 + ) 73 + 74 + const onPointerMove = useCallback( 75 + (evt: React.PointerEvent<HTMLDivElement>) => { 76 + if (isSeekingRef.current) { 77 + evt.preventDefault() 78 + seek(evt) 79 + } 80 + }, 81 + [seek], 82 + ) 83 + 84 + const onPointerUp = useCallback( 85 + (evt: React.PointerEvent<HTMLDivElement>) => { 86 + const target = evt.target 87 + if (isSeekingRef.current && target instanceof Element) { 88 + evt.preventDefault() 89 + target.releasePointerCapture(evt.pointerId) 90 + isSeekingRef.current = false 91 + onSeekEnd() 92 + setScrubberActive(false) 93 + } 94 + }, 95 + [onSeekEnd], 96 + ) 97 + 98 + useEffect(() => { 99 + // HACK: there's divergent browser behaviour about what to do when 100 + // a pointerUp event is fired outside the element that captured the 101 + // pointer. Firefox clicks on the element the mouse is over, so we have 102 + // to make everything unclickable while seeking -sfn 103 + if (isFirefox && scrubberActive) { 104 + document.body.classList.add('force-no-clicks') 105 + 106 + return () => { 107 + document.body.classList.remove('force-no-clicks') 108 + } 109 + } 110 + }, [scrubberActive, onSeekEnd]) 111 + 112 + useEffect(() => { 113 + if (!circleRef.current) return 114 + if (focused) { 115 + const abortController = new AbortController() 116 + const {signal} = abortController 117 + circleRef.current.addEventListener( 118 + 'keydown', 119 + evt => { 120 + // space: play/pause 121 + // arrow left: seek backward 122 + // arrow right: seek forward 123 + 124 + if (evt.key === ' ') { 125 + evt.preventDefault() 126 + drawFocus() 127 + togglePlayPause() 128 + } else if (evt.key === 'ArrowLeft') { 129 + evt.preventDefault() 130 + drawFocus() 131 + seekLeft() 132 + } else if (evt.key === 'ArrowRight') { 133 + evt.preventDefault() 134 + drawFocus() 135 + seekRight() 136 + } 137 + }, 138 + {signal}, 139 + ) 140 + 141 + return () => abortController.abort() 142 + } 143 + }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus]) 144 + 145 + const progress = scrubberActive ? seekPosition : currentTime 146 + const progressPercent = (progress / duration) * 100 147 + 148 + return ( 149 + <View 150 + testID="scrubber" 151 + style={[{height: 18, width: '100%'}, a.flex_shrink_0, a.px_xs]} 152 + onPointerEnter={onStartHover} 153 + onPointerLeave={onEndHover}> 154 + <div 155 + ref={barRef} 156 + style={{ 157 + flex: 1, 158 + display: 'flex', 159 + alignItems: 'center', 160 + position: 'relative', 161 + cursor: scrubberActive ? 'grabbing' : 'grab', 162 + padding: '4px 0', 163 + }} 164 + onPointerDown={onPointerDown} 165 + onPointerMove={onPointerMove} 166 + onPointerUp={onPointerUp} 167 + onPointerCancel={onPointerUp}> 168 + <View 169 + style={[ 170 + a.w_full, 171 + a.rounded_full, 172 + a.overflow_hidden, 173 + {backgroundColor: 'rgba(255, 255, 255, 0.4)'}, 174 + {height: hovered || scrubberActive ? 6 : 3}, 175 + ]}> 176 + {duration > 0 && ( 177 + <View 178 + style={[ 179 + a.h_full, 180 + {backgroundColor: t.palette.white}, 181 + {width: `${progressPercent}%`}, 182 + ]} 183 + /> 184 + )} 185 + </View> 186 + <div 187 + ref={circleRef} 188 + aria-label={_(msg`Seek slider`)} 189 + role="slider" 190 + aria-valuemax={duration} 191 + aria-valuemin={0} 192 + aria-valuenow={currentTime} 193 + aria-valuetext={_( 194 + msg`${formatTime(currentTime)} of ${formatTime(duration)}`, 195 + )} 196 + tabIndex={0} 197 + onFocus={onFocus} 198 + onBlur={onBlur} 199 + style={{ 200 + position: 'absolute', 201 + height: 16, 202 + width: 16, 203 + left: `calc(${progressPercent}% - 8px)`, 204 + borderRadius: 8, 205 + pointerEvents: 'none', 206 + }}> 207 + <View 208 + style={[ 209 + a.w_full, 210 + a.h_full, 211 + a.rounded_full, 212 + {backgroundColor: t.palette.white}, 213 + { 214 + transform: [ 215 + { 216 + scale: 217 + hovered || scrubberActive || focused 218 + ? scrubberActive 219 + ? 1 220 + : 0.6 221 + : 0, 222 + }, 223 + ], 224 + }, 225 + ]} 226 + /> 227 + </div> 228 + </div> 229 + </View> 230 + ) 231 + }
+423
src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
··· 1 + import React, {useCallback, useEffect, useRef, useState} from 'react' 2 + import {Pressable, View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import type Hls from 'hls.js' 6 + 7 + import {isTouchDevice} from '#/lib/browser' 8 + import {clamp} from '#/lib/numbers' 9 + import {isIPhoneWeb} from '#/platform/detection' 10 + import { 11 + useAutoplayDisabled, 12 + useSetSubtitlesEnabled, 13 + useSubtitlesEnabled, 14 + } from '#/state/preferences' 15 + import {atoms as a, useTheme, web} from '#/alf' 16 + import {useIsWithinMessage} from '#/components/dms/MessageContext' 17 + import {useFullscreen} from '#/components/hooks/useFullscreen' 18 + import {useInteractionState} from '#/components/hooks/useInteractionState' 19 + import { 20 + ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, 21 + ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, 22 + } from '#/components/icons/ArrowsDiagonal' 23 + import { 24 + CC_Filled_Corner0_Rounded as CCActiveIcon, 25 + CC_Stroke2_Corner0_Rounded as CCInactiveIcon, 26 + } from '#/components/icons/CC' 27 + import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' 28 + import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 29 + import {Loader} from '#/components/Loader' 30 + import {Text} from '#/components/Typography' 31 + import {TimeIndicator} from '../TimeIndicator' 32 + import {ControlButton} from './ControlButton' 33 + import {Scrubber} from './Scrubber' 34 + import {formatTime, useVideoElement} from './utils' 35 + import {VolumeControl} from './VolumeControl' 36 + 37 + export function Controls({ 38 + videoRef, 39 + hlsRef, 40 + active, 41 + setActive, 42 + focused, 43 + setFocused, 44 + onScreen, 45 + fullscreenRef, 46 + hasSubtitleTrack, 47 + }: { 48 + videoRef: React.RefObject<HTMLVideoElement> 49 + hlsRef: React.RefObject<Hls | undefined> 50 + active: boolean 51 + setActive: () => void 52 + focused: boolean 53 + setFocused: (focused: boolean) => void 54 + onScreen: boolean 55 + fullscreenRef: React.RefObject<HTMLDivElement> 56 + hasSubtitleTrack: boolean 57 + }) { 58 + const { 59 + play, 60 + pause, 61 + playing, 62 + muted, 63 + changeMuted, 64 + togglePlayPause, 65 + currentTime, 66 + duration, 67 + buffering, 68 + error, 69 + canPlay, 70 + } = useVideoElement(videoRef) 71 + const t = useTheme() 72 + const {_} = useLingui() 73 + const subtitlesEnabled = useSubtitlesEnabled() 74 + const setSubtitlesEnabled = useSetSubtitlesEnabled() 75 + const { 76 + state: hovered, 77 + onIn: onHover, 78 + onOut: onEndHover, 79 + } = useInteractionState() 80 + const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) 81 + const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() 82 + const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) 83 + const { 84 + state: volumeHovered, 85 + onIn: onVolumeHover, 86 + onOut: onVolumeEndHover, 87 + } = useInteractionState() 88 + 89 + const onKeyDown = useCallback(() => { 90 + setInteractingViaKeypress(true) 91 + }, []) 92 + 93 + useEffect(() => { 94 + if (interactingViaKeypress) { 95 + document.addEventListener('click', () => setInteractingViaKeypress(false)) 96 + return () => { 97 + document.removeEventListener('click', () => 98 + setInteractingViaKeypress(false), 99 + ) 100 + } 101 + } 102 + }, [interactingViaKeypress]) 103 + 104 + useEffect(() => { 105 + if (isFullscreen) { 106 + document.documentElement.style.scrollbarGutter = 'unset' 107 + return () => { 108 + document.documentElement.style.removeProperty('scrollbar-gutter') 109 + } 110 + } 111 + }, [isFullscreen]) 112 + 113 + // pause + unfocus when another video is active 114 + useEffect(() => { 115 + if (!active) { 116 + pause() 117 + setFocused(false) 118 + } 119 + }, [active, pause, setFocused]) 120 + 121 + // autoplay/pause based on visibility 122 + const isWithinMessage = useIsWithinMessage() 123 + const autoplayDisabled = useAutoplayDisabled() || isWithinMessage 124 + useEffect(() => { 125 + if (active) { 126 + if (onScreen) { 127 + if (!autoplayDisabled) play() 128 + } else { 129 + pause() 130 + } 131 + } 132 + }, [onScreen, pause, active, play, autoplayDisabled]) 133 + 134 + // use minimal quality when not focused 135 + useEffect(() => { 136 + if (!hlsRef.current) return 137 + if (focused) { 138 + // auto decide quality based on network conditions 139 + hlsRef.current.autoLevelCapping = -1 140 + // allow 30s of buffering 141 + hlsRef.current.config.maxMaxBufferLength = 30 142 + } else { 143 + // back to what we initially set 144 + hlsRef.current.autoLevelCapping = 0 145 + hlsRef.current.config.maxMaxBufferLength = 10 146 + } 147 + }, [hlsRef, focused]) 148 + 149 + useEffect(() => { 150 + if (!hlsRef.current) return 151 + if (hasSubtitleTrack && subtitlesEnabled && canPlay) { 152 + hlsRef.current.subtitleTrack = 0 153 + } else { 154 + hlsRef.current.subtitleTrack = -1 155 + } 156 + }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) 157 + 158 + // clicking on any button should focus the player, if it's not already focused 159 + const drawFocus = useCallback(() => { 160 + if (!active) { 161 + setActive() 162 + } 163 + setFocused(true) 164 + }, [active, setActive, setFocused]) 165 + 166 + const onPressEmptySpace = useCallback(() => { 167 + if (!focused) { 168 + drawFocus() 169 + if (autoplayDisabled) play() 170 + } else { 171 + togglePlayPause() 172 + } 173 + }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play]) 174 + 175 + const onPressPlayPause = useCallback(() => { 176 + drawFocus() 177 + togglePlayPause() 178 + }, [drawFocus, togglePlayPause]) 179 + 180 + const onPressSubtitles = useCallback(() => { 181 + drawFocus() 182 + setSubtitlesEnabled(!subtitlesEnabled) 183 + }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) 184 + 185 + const onPressFullscreen = useCallback(() => { 186 + drawFocus() 187 + toggleFullscreen() 188 + }, [drawFocus, toggleFullscreen]) 189 + 190 + const onSeek = useCallback( 191 + (time: number) => { 192 + if (!videoRef.current) return 193 + if (videoRef.current.fastSeek) { 194 + videoRef.current.fastSeek(time) 195 + } else { 196 + videoRef.current.currentTime = time 197 + } 198 + }, 199 + [videoRef], 200 + ) 201 + 202 + const playStateBeforeSeekRef = useRef(false) 203 + 204 + const onSeekStart = useCallback(() => { 205 + drawFocus() 206 + playStateBeforeSeekRef.current = playing 207 + pause() 208 + }, [playing, pause, drawFocus]) 209 + 210 + const onSeekEnd = useCallback(() => { 211 + if (playStateBeforeSeekRef.current) { 212 + play() 213 + } 214 + }, [play]) 215 + 216 + const seekLeft = useCallback(() => { 217 + if (!videoRef.current) return 218 + // eslint-disable-next-line @typescript-eslint/no-shadow 219 + const currentTime = videoRef.current.currentTime 220 + // eslint-disable-next-line @typescript-eslint/no-shadow 221 + const duration = videoRef.current.duration || 0 222 + onSeek(clamp(currentTime - 5, 0, duration)) 223 + }, [onSeek, videoRef]) 224 + 225 + const seekRight = useCallback(() => { 226 + if (!videoRef.current) return 227 + // eslint-disable-next-line @typescript-eslint/no-shadow 228 + const currentTime = videoRef.current.currentTime 229 + // eslint-disable-next-line @typescript-eslint/no-shadow 230 + const duration = videoRef.current.duration || 0 231 + onSeek(clamp(currentTime + 5, 0, duration)) 232 + }, [onSeek, videoRef]) 233 + 234 + const [showCursor, setShowCursor] = useState(true) 235 + const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>() 236 + const onPointerMoveEmptySpace = useCallback(() => { 237 + setShowCursor(true) 238 + if (cursorTimeoutRef.current) { 239 + clearTimeout(cursorTimeoutRef.current) 240 + } 241 + cursorTimeoutRef.current = setTimeout(() => { 242 + setShowCursor(false) 243 + onEndHover() 244 + }, 2000) 245 + }, [onEndHover]) 246 + const onPointerLeaveEmptySpace = useCallback(() => { 247 + setShowCursor(false) 248 + if (cursorTimeoutRef.current) { 249 + clearTimeout(cursorTimeoutRef.current) 250 + } 251 + }, []) 252 + 253 + // these are used to trigger the hover state. on mobile, the hover state 254 + // should stick around for a bit after they tap, and if the controls aren't 255 + // present this initial tab should *only* show the controls and not activate anything 256 + 257 + const onPointerDown = useCallback( 258 + (evt: React.PointerEvent<HTMLDivElement>) => { 259 + if (evt.pointerType !== 'mouse' && !hovered) { 260 + evt.preventDefault() 261 + } 262 + clearTimeout(timeoutRef.current) 263 + }, 264 + [hovered], 265 + ) 266 + 267 + const timeoutRef = useRef<ReturnType<typeof setTimeout>>() 268 + 269 + const onHoverWithTimeout = useCallback(() => { 270 + onHover() 271 + clearTimeout(timeoutRef.current) 272 + }, [onHover]) 273 + 274 + const onEndHoverWithTimeout = useCallback( 275 + (evt: React.PointerEvent<HTMLDivElement>) => { 276 + // if touch, end after 3s 277 + // if mouse, end immediately 278 + if (evt.pointerType !== 'mouse') { 279 + setTimeout(onEndHover, 3000) 280 + } else { 281 + onEndHover() 282 + } 283 + }, 284 + [onEndHover], 285 + ) 286 + 287 + const showControls = 288 + ((focused || autoplayDisabled) && !playing) || 289 + (interactingViaKeypress ? hasFocus : hovered) 290 + 291 + return ( 292 + <div 293 + style={{ 294 + position: 'absolute', 295 + inset: 0, 296 + overflow: 'hidden', 297 + display: 'flex', 298 + flexDirection: 'column', 299 + }} 300 + onClick={evt => { 301 + evt.stopPropagation() 302 + setInteractingViaKeypress(false) 303 + }} 304 + onPointerEnter={onHoverWithTimeout} 305 + onPointerMove={onHoverWithTimeout} 306 + onPointerLeave={onEndHoverWithTimeout} 307 + onPointerDown={onPointerDown} 308 + onFocus={onFocus} 309 + onBlur={onBlur} 310 + onKeyDown={onKeyDown}> 311 + <Pressable 312 + accessibilityRole="button" 313 + onPointerEnter={onPointerMoveEmptySpace} 314 + onPointerMove={onPointerMoveEmptySpace} 315 + onPointerLeave={onPointerLeaveEmptySpace} 316 + accessibilityHint={_( 317 + !focused 318 + ? msg`Unmute video` 319 + : playing 320 + ? msg`Pause video` 321 + : msg`Play video`, 322 + )} 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 || isTouchDevice) && ( 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.pt_2xs, 362 + a.pb_md, 363 + a.gap_md, 364 + a.flex_row, 365 + a.align_center, 366 + ]}> 367 + <ControlButton 368 + active={playing} 369 + activeLabel={_(msg`Pause`)} 370 + inactiveLabel={_(msg`Play`)} 371 + activeIcon={PauseIcon} 372 + inactiveIcon={PlayIcon} 373 + onPress={onPressPlayPause} 374 + /> 375 + <View style={a.flex_1} /> 376 + <Text style={{color: t.palette.white, fontVariant: ['tabular-nums']}}> 377 + {formatTime(currentTime)} / {formatTime(duration)} 378 + </Text> 379 + {hasSubtitleTrack && ( 380 + <ControlButton 381 + active={subtitlesEnabled} 382 + activeLabel={_(msg`Disable subtitles`)} 383 + inactiveLabel={_(msg`Enable subtitles`)} 384 + activeIcon={CCActiveIcon} 385 + inactiveIcon={CCInactiveIcon} 386 + onPress={onPressSubtitles} 387 + /> 388 + )} 389 + <VolumeControl 390 + muted={muted} 391 + changeMuted={changeMuted} 392 + hovered={volumeHovered} 393 + onHover={onVolumeHover} 394 + onEndHover={onVolumeEndHover} 395 + drawFocus={drawFocus} 396 + /> 397 + {!isIPhoneWeb && ( 398 + <ControlButton 399 + active={isFullscreen} 400 + activeLabel={_(msg`Exit fullscreen`)} 401 + inactiveLabel={_(msg`Fullscreen`)} 402 + activeIcon={ArrowsInIcon} 403 + inactiveIcon={ArrowsOutIcon} 404 + onPress={onPressFullscreen} 405 + /> 406 + )} 407 + </View> 408 + </View> 409 + {(buffering || error) && ( 410 + <View 411 + pointerEvents="none" 412 + style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> 413 + {buffering && <Loader fill={t.palette.white} size="lg" />} 414 + {error && ( 415 + <Text style={{color: t.palette.white}}> 416 + <Trans>An error occurred</Trans> 417 + </Text> 418 + )} 419 + </View> 420 + )} 421 + </div> 422 + ) 423 + }
+109
src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {isSafari, isTouchDevice} from '#/lib/browser' 8 + import {atoms as a} from '#/alf' 9 + import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 10 + import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' 11 + import {useVideoVolumeState} from '../../VideoVolumeContext' 12 + import {ControlButton} from './ControlButton' 13 + 14 + export function VolumeControl({ 15 + muted, 16 + changeMuted, 17 + hovered, 18 + onHover, 19 + onEndHover, 20 + drawFocus, 21 + }: { 22 + muted: boolean 23 + changeMuted: (muted: boolean | ((prev: boolean) => boolean)) => void 24 + hovered: boolean 25 + onHover: () => void 26 + onEndHover: () => void 27 + drawFocus: () => void 28 + }) { 29 + const {_} = useLingui() 30 + const [volume, setVolume] = useVideoVolumeState() 31 + 32 + const onVolumeChange = useCallback( 33 + (evt: React.ChangeEvent<HTMLInputElement>) => { 34 + drawFocus() 35 + const vol = sliderVolumeToVideoVolume(Number(evt.target.value)) 36 + setVolume(vol) 37 + changeMuted(vol === 0) 38 + }, 39 + [setVolume, drawFocus, changeMuted], 40 + ) 41 + 42 + const sliderVolume = muted ? 0 : videoVolumeToSliderVolume(volume) 43 + 44 + const isZeroVolume = volume === 0 45 + const onPressMute = useCallback(() => { 46 + drawFocus() 47 + if (isZeroVolume) { 48 + setVolume(1) 49 + changeMuted(false) 50 + } else { 51 + changeMuted(prevMuted => !prevMuted) 52 + } 53 + }, [drawFocus, setVolume, isZeroVolume, changeMuted]) 54 + 55 + return ( 56 + <View 57 + onPointerEnter={onHover} 58 + onPointerLeave={onEndHover} 59 + style={[a.relative]}> 60 + {hovered && !isTouchDevice && ( 61 + <Animated.View 62 + entering={FadeIn.duration(100)} 63 + exiting={FadeOut.duration(100)} 64 + style={[a.absolute, a.w_full, {height: 100, bottom: '100%'}]}> 65 + <View 66 + style={[ 67 + a.flex_1, 68 + a.mb_xs, 69 + a.px_2xs, 70 + a.py_xs, 71 + {backgroundColor: 'rgba(0, 0, 0, 0.6)'}, 72 + a.rounded_xs, 73 + a.align_center, 74 + ]}> 75 + <input 76 + type="range" 77 + min={0} 78 + max={100} 79 + value={sliderVolume} 80 + style={ 81 + // Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h 82 + isSafari ? {height: 92, minHeight: '100%'} : {height: '100%'} 83 + } 84 + onChange={onVolumeChange} 85 + // @ts-expect-error for old versions of firefox, and then re-using it for targeting the CSS -sfn 86 + orient="vertical" 87 + /> 88 + </View> 89 + </Animated.View> 90 + )} 91 + <ControlButton 92 + active={muted || volume === 0} 93 + activeLabel={_(msg({message: `Unmute`, context: 'video'}))} 94 + inactiveLabel={_(msg({message: `Mute`, context: 'video'}))} 95 + activeIcon={MuteIcon} 96 + inactiveIcon={UnmuteIcon} 97 + onPress={onPressMute} 98 + /> 99 + </View> 100 + ) 101 + } 102 + 103 + function sliderVolumeToVideoVolume(value: number) { 104 + return Math.pow(value / 100, 4) 105 + } 106 + 107 + function videoVolumeToSliderVolume(value: number) { 108 + return Math.round(Math.pow(value, 1 / 4) * 100) 109 + }
+228
src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx
··· 1 + import React, {useCallback, useEffect, useRef, useState} from 'react' 2 + 3 + import {useVideoVolumeState} from '../../VideoVolumeContext' 4 + 5 + export function useVideoElement(ref: React.RefObject<HTMLVideoElement>) { 6 + const [playing, setPlaying] = useState(false) 7 + const [muted, setMuted] = useState(true) 8 + const [currentTime, setCurrentTime] = useState(0) 9 + const [volume, setVolume] = useVideoVolumeState() 10 + const [duration, setDuration] = useState(0) 11 + const [buffering, setBuffering] = useState(false) 12 + const [error, setError] = useState(false) 13 + const [canPlay, setCanPlay] = useState(false) 14 + const playWhenReadyRef = useRef(false) 15 + 16 + useEffect(() => { 17 + if (!ref.current) return 18 + ref.current.volume = volume 19 + }, [ref, volume]) 20 + 21 + useEffect(() => { 22 + if (!ref.current) return 23 + 24 + let bufferingTimeout: ReturnType<typeof setTimeout> | undefined 25 + 26 + function round(num: number) { 27 + return Math.round(num * 100) / 100 28 + } 29 + 30 + // Initial values 31 + setCurrentTime(round(ref.current.currentTime) || 0) 32 + setDuration(round(ref.current.duration) || 0) 33 + setMuted(ref.current.muted) 34 + setPlaying(!ref.current.paused) 35 + setVolume(ref.current.volume) 36 + 37 + const handleTimeUpdate = () => { 38 + if (!ref.current) return 39 + setCurrentTime(round(ref.current.currentTime) || 0) 40 + } 41 + 42 + const handleDurationChange = () => { 43 + if (!ref.current) return 44 + setDuration(round(ref.current.duration) || 0) 45 + } 46 + 47 + const handlePlay = () => { 48 + setPlaying(true) 49 + } 50 + 51 + const handlePause = () => { 52 + setPlaying(false) 53 + } 54 + 55 + const handleVolumeChange = () => { 56 + if (!ref.current) return 57 + setMuted(ref.current.muted) 58 + } 59 + 60 + const handleError = () => { 61 + setError(true) 62 + } 63 + 64 + const handleCanPlay = () => { 65 + if (bufferingTimeout) clearTimeout(bufferingTimeout) 66 + setBuffering(false) 67 + setCanPlay(true) 68 + 69 + if (!ref.current) return 70 + if (playWhenReadyRef.current) { 71 + ref.current.play() 72 + playWhenReadyRef.current = false 73 + } 74 + } 75 + 76 + const handleCanPlayThrough = () => { 77 + if (bufferingTimeout) clearTimeout(bufferingTimeout) 78 + setBuffering(false) 79 + } 80 + 81 + const handleWaiting = () => { 82 + if (bufferingTimeout) clearTimeout(bufferingTimeout) 83 + bufferingTimeout = setTimeout(() => { 84 + setBuffering(true) 85 + }, 200) // Delay to avoid frequent buffering state changes 86 + } 87 + 88 + const handlePlaying = () => { 89 + if (bufferingTimeout) clearTimeout(bufferingTimeout) 90 + setBuffering(false) 91 + setError(false) 92 + } 93 + 94 + const handleStalled = () => { 95 + if (bufferingTimeout) clearTimeout(bufferingTimeout) 96 + bufferingTimeout = setTimeout(() => { 97 + setBuffering(true) 98 + }, 200) // Delay to avoid frequent buffering state changes 99 + } 100 + 101 + const handleEnded = () => { 102 + setPlaying(false) 103 + setBuffering(false) 104 + setError(false) 105 + } 106 + 107 + const abortController = new AbortController() 108 + 109 + ref.current.addEventListener('timeupdate', handleTimeUpdate, { 110 + signal: abortController.signal, 111 + }) 112 + ref.current.addEventListener('durationchange', handleDurationChange, { 113 + signal: abortController.signal, 114 + }) 115 + ref.current.addEventListener('play', handlePlay, { 116 + signal: abortController.signal, 117 + }) 118 + ref.current.addEventListener('pause', handlePause, { 119 + signal: abortController.signal, 120 + }) 121 + ref.current.addEventListener('volumechange', handleVolumeChange, { 122 + signal: abortController.signal, 123 + }) 124 + ref.current.addEventListener('error', handleError, { 125 + signal: abortController.signal, 126 + }) 127 + ref.current.addEventListener('canplay', handleCanPlay, { 128 + signal: abortController.signal, 129 + }) 130 + ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { 131 + signal: abortController.signal, 132 + }) 133 + ref.current.addEventListener('waiting', handleWaiting, { 134 + signal: abortController.signal, 135 + }) 136 + ref.current.addEventListener('playing', handlePlaying, { 137 + signal: abortController.signal, 138 + }) 139 + ref.current.addEventListener('stalled', handleStalled, { 140 + signal: abortController.signal, 141 + }) 142 + ref.current.addEventListener('ended', handleEnded, { 143 + signal: abortController.signal, 144 + }) 145 + ref.current.addEventListener('volumechange', handleVolumeChange, { 146 + signal: abortController.signal, 147 + }) 148 + 149 + return () => { 150 + abortController.abort() 151 + clearTimeout(bufferingTimeout) 152 + } 153 + }, [ref, setVolume]) 154 + 155 + const play = useCallback(() => { 156 + if (!ref.current) return 157 + 158 + if (ref.current.ended) { 159 + ref.current.currentTime = 0 160 + } 161 + 162 + if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { 163 + playWhenReadyRef.current = true 164 + } else { 165 + const promise = ref.current.play() 166 + if (promise !== undefined) { 167 + promise.catch(err => { 168 + console.error('Error playing video:', err) 169 + }) 170 + } 171 + } 172 + }, [ref]) 173 + 174 + const pause = useCallback(() => { 175 + if (!ref.current) return 176 + 177 + ref.current.pause() 178 + playWhenReadyRef.current = false 179 + }, [ref]) 180 + 181 + const togglePlayPause = useCallback(() => { 182 + if (!ref.current) return 183 + 184 + if (ref.current.paused) { 185 + play() 186 + } else { 187 + pause() 188 + } 189 + }, [ref, play, pause]) 190 + 191 + const changeMuted = useCallback( 192 + (newMuted: boolean | ((prev: boolean) => boolean)) => { 193 + if (!ref.current) return 194 + 195 + const value = 196 + typeof newMuted === 'function' ? newMuted(ref.current.muted) : newMuted 197 + ref.current.muted = value 198 + }, 199 + [ref], 200 + ) 201 + 202 + return { 203 + play, 204 + pause, 205 + togglePlayPause, 206 + duration, 207 + currentTime, 208 + playing, 209 + muted, 210 + changeMuted, 211 + buffering, 212 + error, 213 + canPlay, 214 + } 215 + } 216 + 217 + export function formatTime(time: number) { 218 + if (isNaN(time)) { 219 + return '--' 220 + } 221 + 222 + time = Math.round(time) 223 + 224 + const minutes = Math.floor(time / 60) 225 + const seconds = String(time % 60).padStart(2, '0') 226 + 227 + return `${minutes}:${seconds}` 228 + }
+23 -8
src/view/com/util/post-embeds/VideoVolumeContext.tsx
··· 1 1 import React from 'react' 2 2 3 - const Context = React.createContext( 4 - {} as { 5 - muted: boolean 6 - setMuted: (muted: boolean) => void 7 - }, 8 - ) 3 + const Context = React.createContext<{ 4 + // native 5 + muted: boolean 6 + setMuted: React.Dispatch<React.SetStateAction<boolean>> 7 + // web 8 + volume: number 9 + setVolume: React.Dispatch<React.SetStateAction<number>> 10 + } | null>(null) 9 11 10 12 export function Provider({children}: {children: React.ReactNode}) { 11 13 const [muted, setMuted] = React.useState(true) 14 + const [volume, setVolume] = React.useState(1) 12 15 13 16 const value = React.useMemo( 14 17 () => ({ 15 18 muted, 16 19 setMuted, 20 + volume, 21 + setVolume, 17 22 }), 18 - [muted, setMuted], 23 + [muted, setMuted, volume, setVolume], 19 24 ) 20 25 21 26 return <Context.Provider value={value}>{children}</Context.Provider> ··· 28 33 'useVideoVolumeState must be used within a VideoVolumeProvider', 29 34 ) 30 35 } 31 - return context 36 + return [context.volume, context.setVolume] as const 37 + } 38 + 39 + export function useVideoMuteState() { 40 + const context = React.useContext(Context) 41 + if (!context) { 42 + throw new Error( 43 + 'useVideoMuteState must be used within a VideoVolumeProvider', 44 + ) 45 + } 46 + return [context.muted, context.setMuted] as const 32 47 }
+45
web/index.html
··· 262 262 .force-no-clicks * { 263 263 pointer-events: none !important; 264 264 } 265 + 266 + input[type=range][orient=vertical] { 267 + writing-mode: vertical-lr; 268 + direction: rtl; 269 + appearance: slider-vertical; 270 + width: 16px; 271 + vertical-align: bottom; 272 + -webkit-appearance: none; 273 + appearance: none; 274 + background: transparent; 275 + cursor: pointer; 276 + } 277 + 278 + input[type="range"][orient=vertical]::-webkit-slider-runnable-track { 279 + background: white; 280 + height: 100%; 281 + width: 4px; 282 + border-radius: 4px; 283 + } 284 + 285 + input[type="range"][orient=vertical]::-moz-range-track { 286 + background: white; 287 + height: 100%; 288 + width: 4px; 289 + border-radius: 4px; 290 + } 291 + 292 + input[type="range"]::-webkit-slider-thumb { 293 + -webkit-appearance: none; 294 + appearance: none; 295 + border-radius: 50%; 296 + background-color: white; 297 + height: 16px; 298 + width: 16px; 299 + margin-left: -6px; 300 + } 301 + 302 + input[type="range"][orient=vertical]::-moz-range-thumb { 303 + border: none; 304 + border-radius: 50%; 305 + background-color: white; 306 + height: 16px; 307 + width: 16px; 308 + margin-left: -6px; 309 + } 265 310 </style> 266 311 </head> 267 312