[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>zio/dev^2
parent
38c8f01594
commit
8241747fc2
|
@ -258,6 +258,51 @@
|
|||
.force-no-clicks * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
input[type=range][orient=vertical] {
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
appearance: slider-vertical;
|
||||
width: 16px;
|
||||
vertical-align: bottom;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"][orient=vertical]::-webkit-slider-runnable-track {
|
||||
background: white;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"][orient=vertical]::-moz-range-track {
|
||||
background: white;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
input[type="range"][orient=vertical]::-moz-range-thumb {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-left: -6px;
|
||||
}
|
||||
</style>
|
||||
{% include "scripts.html" %}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
|
|
|
@ -18,7 +18,7 @@ const Context = React.createContext<{
|
|||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
if (!isWeb) {
|
||||
throw new Error('ActiveVideoWebContext may onl be used on web.')
|
||||
throw new Error('ActiveVideoWebContext may only be used on web.')
|
||||
}
|
||||
|
||||
const [activeViewId, setActiveViewId] = useState<string | null>(null)
|
||||
|
|
|
@ -9,7 +9,7 @@ import {useLingui} from '@lingui/react'
|
|||
import {HITSLOP_30} from '#/lib/constants'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {useAutoplayDisabled} from '#/state/preferences'
|
||||
import {useVideoVolumeState} from 'view/com/util/post-embeds/VideoVolumeContext'
|
||||
import {useVideoMuteState} from 'view/com/util/post-embeds/VideoVolumeContext'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
|
@ -38,7 +38,7 @@ export const VideoEmbedInnerNative = React.forwardRef(
|
|||
const videoRef = useRef<BlueskyVideoView>(null)
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
const isWithinMessage = useIsWithinMessage()
|
||||
const {muted, setMuted} = useVideoVolumeState()
|
||||
const [muted, setMuted] = useVideoMuteState()
|
||||
|
||||
const [isPlaying, setIsPlaying] = React.useState(false)
|
||||
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||
|
@ -128,7 +128,7 @@ function VideoControls({
|
|||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {muted} = useVideoVolumeState()
|
||||
const [muted] = useVideoMuteState()
|
||||
|
||||
// show countdown when:
|
||||
// 1. timeRemaining is a number - was seeing NaNs
|
||||
|
|
|
@ -5,7 +5,7 @@ import Hls from 'hls.js'
|
|||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Controls} from './VideoWebControls'
|
||||
import {Controls} from './web-controls/VideoControls'
|
||||
|
||||
export function VideoEmbedInnerWeb({
|
||||
embed,
|
||||
|
|
|
@ -1,898 +0,0 @@
|
|||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {SvgProps} from 'react-native-svg'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type Hls from 'hls.js'
|
||||
|
||||
import {isFirefox} from '#/lib/browser'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {isIPhoneWeb} from '#/platform/detection'
|
||||
import {
|
||||
useAutoplayDisabled,
|
||||
useSetSubtitlesEnabled,
|
||||
useSubtitlesEnabled,
|
||||
} from '#/state/preferences'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {
|
||||
ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
|
||||
ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
|
||||
} from '#/components/icons/ArrowsDiagonal'
|
||||
import {
|
||||
CC_Filled_Corner0_Rounded as CCActiveIcon,
|
||||
CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
|
||||
} from '#/components/icons/CC'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
|
||||
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TimeIndicator} from './TimeIndicator'
|
||||
|
||||
export function Controls({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
active,
|
||||
setActive,
|
||||
focused,
|
||||
setFocused,
|
||||
onScreen,
|
||||
fullscreenRef,
|
||||
hasSubtitleTrack,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
hlsRef: React.RefObject<Hls | undefined>
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
focused: boolean
|
||||
setFocused: (focused: boolean) => void
|
||||
onScreen: boolean
|
||||
fullscreenRef: React.RefObject<HTMLDivElement>
|
||||
hasSubtitleTrack: boolean
|
||||
}) {
|
||||
const {
|
||||
play,
|
||||
pause,
|
||||
playing,
|
||||
muted,
|
||||
toggleMute,
|
||||
togglePlayPause,
|
||||
currentTime,
|
||||
duration,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
} = useVideoUtils(videoRef)
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const subtitlesEnabled = useSubtitlesEnabled()
|
||||
const setSubtitlesEnabled = useSetSubtitlesEnabled()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHover,
|
||||
onOut: onEndHover,
|
||||
} = useInteractionState()
|
||||
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
|
||||
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
|
||||
|
||||
const onKeyDown = useCallback(() => {
|
||||
setInteractingViaKeypress(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (interactingViaKeypress) {
|
||||
document.addEventListener('click', () => setInteractingViaKeypress(false))
|
||||
return () => {
|
||||
document.removeEventListener('click', () =>
|
||||
setInteractingViaKeypress(false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [interactingViaKeypress])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFullscreen) {
|
||||
document.documentElement.style.scrollbarGutter = 'unset'
|
||||
return () => {
|
||||
document.documentElement.style.removeProperty('scrollbar-gutter')
|
||||
}
|
||||
}
|
||||
}, [isFullscreen])
|
||||
|
||||
// pause + unfocus when another video is active
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
pause()
|
||||
setFocused(false)
|
||||
}
|
||||
}, [active, pause, setFocused])
|
||||
|
||||
// autoplay/pause based on visibility
|
||||
const isWithinMessage = useIsWithinMessage()
|
||||
const autoplayDisabled = useAutoplayDisabled() || isWithinMessage
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
if (onScreen) {
|
||||
if (!autoplayDisabled) play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}, [onScreen, pause, active, play, autoplayDisabled])
|
||||
|
||||
// use minimal quality when not focused
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (focused) {
|
||||
// auto decide quality based on network conditions
|
||||
hlsRef.current.autoLevelCapping = -1
|
||||
// allow 30s of buffering
|
||||
hlsRef.current.config.maxMaxBufferLength = 30
|
||||
} else {
|
||||
// back to what we initially set
|
||||
hlsRef.current.autoLevelCapping = 0
|
||||
hlsRef.current.config.maxMaxBufferLength = 10
|
||||
}
|
||||
}, [hlsRef, focused])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
|
||||
hlsRef.current.subtitleTrack = 0
|
||||
} else {
|
||||
hlsRef.current.subtitleTrack = -1
|
||||
}
|
||||
}, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
|
||||
|
||||
// clicking on any button should focus the player, if it's not already focused
|
||||
const drawFocus = useCallback(() => {
|
||||
if (!active) {
|
||||
setActive()
|
||||
}
|
||||
setFocused(true)
|
||||
}, [active, setActive, setFocused])
|
||||
|
||||
const onPressEmptySpace = useCallback(() => {
|
||||
if (!focused) {
|
||||
drawFocus()
|
||||
if (autoplayDisabled) play()
|
||||
} else {
|
||||
togglePlayPause()
|
||||
}
|
||||
}, [togglePlayPause, drawFocus, focused, autoplayDisabled, play])
|
||||
|
||||
const onPressPlayPause = useCallback(() => {
|
||||
drawFocus()
|
||||
togglePlayPause()
|
||||
}, [drawFocus, togglePlayPause])
|
||||
|
||||
const onPressSubtitles = useCallback(() => {
|
||||
drawFocus()
|
||||
setSubtitlesEnabled(!subtitlesEnabled)
|
||||
}, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
|
||||
|
||||
const onPressMute = useCallback(() => {
|
||||
drawFocus()
|
||||
toggleMute()
|
||||
}, [drawFocus, toggleMute])
|
||||
|
||||
const onPressFullscreen = useCallback(() => {
|
||||
drawFocus()
|
||||
toggleFullscreen()
|
||||
}, [drawFocus, toggleFullscreen])
|
||||
|
||||
const onSeek = useCallback(
|
||||
(time: number) => {
|
||||
if (!videoRef.current) return
|
||||
if (videoRef.current.fastSeek) {
|
||||
videoRef.current.fastSeek(time)
|
||||
} else {
|
||||
videoRef.current.currentTime = time
|
||||
}
|
||||
},
|
||||
[videoRef],
|
||||
)
|
||||
|
||||
const playStateBeforeSeekRef = useRef(false)
|
||||
|
||||
const onSeekStart = useCallback(() => {
|
||||
drawFocus()
|
||||
playStateBeforeSeekRef.current = playing
|
||||
pause()
|
||||
}, [playing, pause, drawFocus])
|
||||
|
||||
const onSeekEnd = useCallback(() => {
|
||||
if (playStateBeforeSeekRef.current) {
|
||||
play()
|
||||
}
|
||||
}, [play])
|
||||
|
||||
const seekLeft = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const currentTime = videoRef.current.currentTime
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const duration = videoRef.current.duration || 0
|
||||
onSeek(clamp(currentTime - 5, 0, duration))
|
||||
}, [onSeek, videoRef])
|
||||
|
||||
const seekRight = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const currentTime = videoRef.current.currentTime
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const duration = videoRef.current.duration || 0
|
||||
onSeek(clamp(currentTime + 5, 0, duration))
|
||||
}, [onSeek, videoRef])
|
||||
|
||||
const [showCursor, setShowCursor] = useState(true)
|
||||
const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const onPointerMoveEmptySpace = useCallback(() => {
|
||||
setShowCursor(true)
|
||||
if (cursorTimeoutRef.current) {
|
||||
clearTimeout(cursorTimeoutRef.current)
|
||||
}
|
||||
cursorTimeoutRef.current = setTimeout(() => {
|
||||
setShowCursor(false)
|
||||
onEndHover()
|
||||
}, 2000)
|
||||
}, [onEndHover])
|
||||
const onPointerLeaveEmptySpace = useCallback(() => {
|
||||
setShowCursor(false)
|
||||
if (cursorTimeoutRef.current) {
|
||||
clearTimeout(cursorTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// these are used to trigger the hover state. on mobile, the hover state
|
||||
// should stick around for a bit after they tap, and if the controls aren't
|
||||
// present this initial tab should *only* show the controls and not activate anything
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (evt.pointerType !== 'mouse' && !hovered) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
clearTimeout(timeoutRef.current)
|
||||
},
|
||||
[hovered],
|
||||
)
|
||||
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
const onHoverWithTimeout = useCallback(() => {
|
||||
onHover()
|
||||
clearTimeout(timeoutRef.current)
|
||||
}, [onHover])
|
||||
|
||||
const onEndHoverWithTimeout = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
// if touch, end after 3s
|
||||
// if mouse, end immediately
|
||||
if (evt.pointerType !== 'mouse') {
|
||||
setTimeout(onEndHover, 3000)
|
||||
} else {
|
||||
onEndHover()
|
||||
}
|
||||
},
|
||||
[onEndHover],
|
||||
)
|
||||
|
||||
const showControls =
|
||||
((focused || autoplayDisabled) && !playing) ||
|
||||
(interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={evt => {
|
||||
evt.stopPropagation()
|
||||
setInteractingViaKeypress(false)
|
||||
}}
|
||||
onPointerEnter={onHoverWithTimeout}
|
||||
onPointerMove={onHoverWithTimeout}
|
||||
onPointerLeave={onEndHoverWithTimeout}
|
||||
onPointerDown={onPointerDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPointerEnter={onPointerMoveEmptySpace}
|
||||
onPointerMove={onPointerMoveEmptySpace}
|
||||
onPointerLeave={onPointerLeaveEmptySpace}
|
||||
accessibilityHint={_(
|
||||
!focused
|
||||
? msg`Unmute video`
|
||||
: playing
|
||||
? msg`Pause video`
|
||||
: msg`Play video`,
|
||||
)}
|
||||
style={[
|
||||
a.flex_1,
|
||||
web({cursor: showCursor || !playing ? 'pointer' : 'none'}),
|
||||
]}
|
||||
onPress={onPressEmptySpace}
|
||||
/>
|
||||
{!showControls && !focused && duration > 0 && (
|
||||
<TimeIndicator time={Math.floor(duration - currentTime)} />
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.flex_shrink_0,
|
||||
a.w_full,
|
||||
a.px_xs,
|
||||
web({
|
||||
background:
|
||||
'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
|
||||
}),
|
||||
{opacity: showControls ? 1 : 0},
|
||||
{transition: 'opacity 0.2s ease-in-out'},
|
||||
]}>
|
||||
<Scrubber
|
||||
duration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={onSeek}
|
||||
onSeekStart={onSeekStart}
|
||||
onSeekEnd={onSeekEnd}
|
||||
seekLeft={seekLeft}
|
||||
seekRight={seekRight}
|
||||
togglePlayPause={togglePlayPause}
|
||||
drawFocus={drawFocus}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.px_xs,
|
||||
a.pt_2xs,
|
||||
a.pb_md,
|
||||
a.gap_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
]}>
|
||||
<ControlButton
|
||||
active={playing}
|
||||
activeLabel={_(msg`Pause`)}
|
||||
inactiveLabel={_(msg`Play`)}
|
||||
activeIcon={PauseIcon}
|
||||
inactiveIcon={PlayIcon}
|
||||
onPress={onPressPlayPause}
|
||||
/>
|
||||
<View style={a.flex_1} />
|
||||
<Text style={{color: t.palette.white, fontVariant: ['tabular-nums']}}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</Text>
|
||||
{hasSubtitleTrack && (
|
||||
<ControlButton
|
||||
active={subtitlesEnabled}
|
||||
activeLabel={_(msg`Disable subtitles`)}
|
||||
inactiveLabel={_(msg`Enable subtitles`)}
|
||||
activeIcon={CCActiveIcon}
|
||||
inactiveIcon={CCInactiveIcon}
|
||||
onPress={onPressSubtitles}
|
||||
/>
|
||||
)}
|
||||
<ControlButton
|
||||
active={muted}
|
||||
activeLabel={_(msg({message: `Unmute`, context: 'video'}))}
|
||||
inactiveLabel={_(msg({message: `Mute`, context: 'video'}))}
|
||||
activeIcon={MuteIcon}
|
||||
inactiveIcon={UnmuteIcon}
|
||||
onPress={onPressMute}
|
||||
/>
|
||||
{!isIPhoneWeb && (
|
||||
<ControlButton
|
||||
active={isFullscreen}
|
||||
activeLabel={_(msg`Exit fullscreen`)}
|
||||
inactiveLabel={_(msg`Fullscreen`)}
|
||||
activeIcon={ArrowsInIcon}
|
||||
inactiveIcon={ArrowsOutIcon}
|
||||
onPress={onPressFullscreen}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{(buffering || error) && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
{buffering && <Loader fill={t.palette.white} size="lg" />}
|
||||
{error && (
|
||||
<Text style={{color: t.palette.white}}>
|
||||
<Trans>An error occurred</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
active,
|
||||
activeLabel,
|
||||
inactiveLabel,
|
||||
activeIcon: ActiveIcon,
|
||||
inactiveIcon: InactiveIcon,
|
||||
onPress,
|
||||
}: {
|
||||
active: boolean
|
||||
activeLabel: string
|
||||
inactiveLabel: string
|
||||
activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>>
|
||||
inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>>
|
||||
onPress: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Button
|
||||
label={active ? activeLabel : inactiveLabel}
|
||||
onPress={onPress}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
size="medium"
|
||||
style={a.p_2xs}
|
||||
hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.1)'}}>
|
||||
{active ? (
|
||||
<ActiveIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<InactiveIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function Scrubber({
|
||||
duration,
|
||||
currentTime,
|
||||
onSeek,
|
||||
onSeekEnd,
|
||||
onSeekStart,
|
||||
seekLeft,
|
||||
seekRight,
|
||||
togglePlayPause,
|
||||
drawFocus,
|
||||
}: {
|
||||
duration: number
|
||||
currentTime: number
|
||||
onSeek: (time: number) => void
|
||||
onSeekEnd: () => void
|
||||
onSeekStart: () => void
|
||||
seekLeft: () => void
|
||||
seekRight: () => void
|
||||
togglePlayPause: () => void
|
||||
drawFocus: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const [scrubberActive, setScrubberActive] = useState(false)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onStartHover,
|
||||
onOut: onEndHover,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [seekPosition, setSeekPosition] = useState(0)
|
||||
const isSeekingRef = useRef(false)
|
||||
const barRef = useRef<HTMLDivElement>(null)
|
||||
const circleRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const seek = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!barRef.current) return
|
||||
const {left, width} = barRef.current.getBoundingClientRect()
|
||||
const x = evt.clientX
|
||||
const percent = clamp((x - left) / width, 0, 1) * duration
|
||||
onSeek(percent)
|
||||
setSeekPosition(percent)
|
||||
},
|
||||
[duration, onSeek],
|
||||
)
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const target = evt.target
|
||||
if (target instanceof Element) {
|
||||
evt.preventDefault()
|
||||
target.setPointerCapture(evt.pointerId)
|
||||
isSeekingRef.current = true
|
||||
seek(evt)
|
||||
setScrubberActive(true)
|
||||
onSeekStart()
|
||||
}
|
||||
},
|
||||
[seek, onSeekStart],
|
||||
)
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (isSeekingRef.current) {
|
||||
evt.preventDefault()
|
||||
seek(evt)
|
||||
}
|
||||
},
|
||||
[seek],
|
||||
)
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const target = evt.target
|
||||
if (isSeekingRef.current && target instanceof Element) {
|
||||
evt.preventDefault()
|
||||
target.releasePointerCapture(evt.pointerId)
|
||||
isSeekingRef.current = false
|
||||
onSeekEnd()
|
||||
setScrubberActive(false)
|
||||
}
|
||||
},
|
||||
[onSeekEnd],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// HACK: there's divergent browser behaviour about what to do when
|
||||
// a pointerUp event is fired outside the element that captured the
|
||||
// pointer. Firefox clicks on the element the mouse is over, so we have
|
||||
// to make everything unclickable while seeking -sfn
|
||||
if (isFirefox && scrubberActive) {
|
||||
document.body.classList.add('force-no-clicks')
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('force-no-clicks')
|
||||
}
|
||||
}
|
||||
}, [scrubberActive, onSeekEnd])
|
||||
|
||||
useEffect(() => {
|
||||
if (!circleRef.current) return
|
||||
if (focused) {
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
circleRef.current.addEventListener(
|
||||
'keydown',
|
||||
evt => {
|
||||
// space: play/pause
|
||||
// arrow left: seek backward
|
||||
// arrow right: seek forward
|
||||
|
||||
if (evt.key === ' ') {
|
||||
evt.preventDefault()
|
||||
drawFocus()
|
||||
togglePlayPause()
|
||||
} else if (evt.key === 'ArrowLeft') {
|
||||
evt.preventDefault()
|
||||
drawFocus()
|
||||
seekLeft()
|
||||
} else if (evt.key === 'ArrowRight') {
|
||||
evt.preventDefault()
|
||||
drawFocus()
|
||||
seekRight()
|
||||
}
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
|
||||
return () => abortController.abort()
|
||||
}
|
||||
}, [focused, seekLeft, seekRight, togglePlayPause, drawFocus])
|
||||
|
||||
const progress = scrubberActive ? seekPosition : currentTime
|
||||
const progressPercent = (progress / duration) * 100
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="scrubber"
|
||||
style={[{height: 18, width: '100%'}, a.flex_shrink_0, a.px_xs]}
|
||||
onPointerEnter={onStartHover}
|
||||
onPointerLeave={onEndHover}>
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
cursor: scrubberActive ? 'grabbing' : 'grab',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
{backgroundColor: 'rgba(255, 255, 255, 0.4)'},
|
||||
{height: hovered || scrubberActive ? 6 : 3},
|
||||
]}>
|
||||
{duration > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.h_full,
|
||||
{backgroundColor: t.palette.white},
|
||||
{width: `${progressPercent}%`},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<div
|
||||
ref={circleRef}
|
||||
aria-label={_(msg`Seek slider`)}
|
||||
role="slider"
|
||||
aria-valuemax={duration}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={currentTime}
|
||||
aria-valuetext={_(
|
||||
msg`${formatTime(currentTime)} of ${formatTime(duration)}`,
|
||||
)}
|
||||
tabIndex={0}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: 16,
|
||||
width: 16,
|
||||
left: `calc(${progressPercent}% - 8px)`,
|
||||
borderRadius: 8,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
a.rounded_full,
|
||||
{backgroundColor: t.palette.white},
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale:
|
||||
hovered || scrubberActive || focused
|
||||
? scrubberActive
|
||||
? 1
|
||||
: 0.6
|
||||
: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(time: number) {
|
||||
if (isNaN(time)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
time = Math.round(time)
|
||||
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = String(time % 60).padStart(2, '0')
|
||||
|
||||
return `${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [muted, setMuted] = useState(true)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [buffering, setBuffering] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [canPlay, setCanPlay] = useState(false)
|
||||
const playWhenReadyRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
let bufferingTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function round(num: number) {
|
||||
return Math.round(num * 100) / 100
|
||||
}
|
||||
|
||||
// Initial values
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
setMuted(ref.current.muted)
|
||||
setPlaying(!ref.current.paused)
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!ref.current) return
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
}
|
||||
|
||||
const handleDurationChange = () => {
|
||||
if (!ref.current) return
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
setPlaying(true)
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
if (!ref.current) return
|
||||
setMuted(ref.current.muted)
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
setError(true)
|
||||
}
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setBuffering(false)
|
||||
setCanPlay(true)
|
||||
|
||||
if (!ref.current) return
|
||||
if (playWhenReadyRef.current) {
|
||||
ref.current.play()
|
||||
playWhenReadyRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCanPlayThrough = () => {
|
||||
setBuffering(false)
|
||||
}
|
||||
|
||||
const handleWaiting = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 200) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handlePlaying = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const handleStalled = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 200) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setPlaying(false)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
ref.current.addEventListener('timeupdate', handleTimeUpdate, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('durationchange', handleDurationChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('play', handlePlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('pause', handlePause, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('volumechange', handleVolumeChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('error', handleError, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplay', handleCanPlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('waiting', handleWaiting, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('playing', handlePlaying, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('stalled', handleStalled, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('ended', handleEnded, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
clearTimeout(bufferingTimeout)
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const play = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.ended) {
|
||||
ref.current.currentTime = 0
|
||||
}
|
||||
|
||||
if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
playWhenReadyRef.current = true
|
||||
} else {
|
||||
const promise = ref.current.play()
|
||||
if (promise !== undefined) {
|
||||
promise.catch(err => {
|
||||
console.error('Error playing video:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.pause()
|
||||
playWhenReadyRef.current = false
|
||||
}, [ref])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.paused) {
|
||||
play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}, [ref, play, pause])
|
||||
|
||||
const mute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = true
|
||||
}, [ref])
|
||||
|
||||
const unmute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = false
|
||||
}, [ref])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = !ref.current.muted
|
||||
}, [ref])
|
||||
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
duration,
|
||||
currentTime,
|
||||
playing,
|
||||
muted,
|
||||
mute,
|
||||
unmute,
|
||||
toggleMute,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react'
|
||||
import {SvgProps} from 'react-native-svg'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
|
||||
export function ControlButton({
|
||||
active,
|
||||
activeLabel,
|
||||
inactiveLabel,
|
||||
activeIcon: ActiveIcon,
|
||||
inactiveIcon: InactiveIcon,
|
||||
onPress,
|
||||
}: {
|
||||
active: boolean
|
||||
activeLabel: string
|
||||
inactiveLabel: string
|
||||
activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>>
|
||||
inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>>
|
||||
onPress: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Button
|
||||
label={active ? activeLabel : inactiveLabel}
|
||||
onPress={onPress}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
size="medium"
|
||||
style={a.p_2xs}
|
||||
hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.1)'}}>
|
||||
{active ? (
|
||||
<ActiveIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<InactiveIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,231 @@
|
|||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isFirefox} from '#/lib/browser'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {formatTime} from './utils'
|
||||
|
||||
export function Scrubber({
|
||||
duration,
|
||||
currentTime,
|
||||
onSeek,
|
||||
onSeekEnd,
|
||||
onSeekStart,
|
||||
seekLeft,
|
||||
seekRight,
|
||||
togglePlayPause,
|
||||
drawFocus,
|
||||
}: {
|
||||
duration: number
|
||||
currentTime: number
|
||||
onSeek: (time: number) => void
|
||||
onSeekEnd: () => void
|
||||
onSeekStart: () => void
|
||||
seekLeft: () => void
|
||||
seekRight: () => void
|
||||
togglePlayPause: () => void
|
||||
drawFocus: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const [scrubberActive, setScrubberActive] = useState(false)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onStartHover,
|
||||
onOut: onEndHover,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [seekPosition, setSeekPosition] = useState(0)
|
||||
const isSeekingRef = useRef(false)
|
||||
const barRef = useRef<HTMLDivElement>(null)
|
||||
const circleRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const seek = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!barRef.current) return
|
||||
const {left, width} = barRef.current.getBoundingClientRect()
|
||||
const x = evt.clientX
|
||||
const percent = clamp((x - left) / width, 0, 1) * duration
|
||||
onSeek(percent)
|
||||
setSeekPosition(percent)
|
||||
},
|
||||
[duration, onSeek],
|
||||
)
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const target = evt.target
|
||||
if (target instanceof Element) {
|
||||
evt.preventDefault()
|
||||
target.setPointerCapture(evt.pointerId)
|
||||
isSeekingRef.current = true
|
||||
seek(evt)
|
||||
setScrubberActive(true)
|
||||
onSeekStart()
|
||||
}
|
||||
},
|
||||
[seek, onSeekStart],
|
||||
)
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (isSeekingRef.current) {
|
||||
evt.preventDefault()
|
||||
seek(evt)
|
||||
}
|
||||
},
|
||||
[seek],
|
||||
)
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const target = evt.target
|
||||
if (isSeekingRef.current && target instanceof Element) {
|
||||
evt.preventDefault()
|
||||
target.releasePointerCapture(evt.pointerId)
|
||||
isSeekingRef.current = false
|
||||
onSeekEnd()
|
||||
setScrubberActive(false)
|
||||
}
|
||||
},
|
||||
[onSeekEnd],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// HACK: there's divergent browser behaviour about what to do when
|
||||
// a pointerUp event is fired outside the element that captured the
|
||||
// pointer. Firefox clicks on the element the mouse is over, so we have
|
||||
// to make everything unclickable while seeking -sfn
|
||||
if (isFirefox && scrubberActive) {
|
||||
document.body.classList.add('force-no-clicks')
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('force-no-clicks')
|
||||
}
|
||||
}
|
||||
}, [scrubberActive, onSeekEnd])
|
||||
|
||||
useEffect(() => {
|
||||
if (!circleRef.current) return
|
||||
if (focused) {
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
circleRef.current.addEventListener(
|
||||
'keydown',
|
||||
evt => {
|
||||
// space: play/pause
|
||||
// arrow left: seek backward
|
||||
// arrow right: seek forward
|
||||
|
||||
if (evt.key === ' ') {
|
||||
evt.preventDefault()
|
||||
drawFocus()
|
||||
togglePlayPause()
|
||||
} else if (evt.key === 'ArrowLeft') {
|
||||
evt.preventDefault()
|
||||
drawFocus()
|
||||
seekLeft()
|
||||
} else if (evt.key === 'ArrowRight') {
|
||||
evt.preventDefault()
|
||||
drawFocus()
|
||||
seekRight()
|
||||
}
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
|
||||
return () => abortController.abort()
|
||||
}
|
||||
}, [focused, seekLeft, seekRight, togglePlayPause, drawFocus])
|
||||
|
||||
const progress = scrubberActive ? seekPosition : currentTime
|
||||
const progressPercent = (progress / duration) * 100
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="scrubber"
|
||||
style={[{height: 18, width: '100%'}, a.flex_shrink_0, a.px_xs]}
|
||||
onPointerEnter={onStartHover}
|
||||
onPointerLeave={onEndHover}>
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
cursor: scrubberActive ? 'grabbing' : 'grab',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
{backgroundColor: 'rgba(255, 255, 255, 0.4)'},
|
||||
{height: hovered || scrubberActive ? 6 : 3},
|
||||
]}>
|
||||
{duration > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.h_full,
|
||||
{backgroundColor: t.palette.white},
|
||||
{width: `${progressPercent}%`},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<div
|
||||
ref={circleRef}
|
||||
aria-label={_(msg`Seek slider`)}
|
||||
role="slider"
|
||||
aria-valuemax={duration}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={currentTime}
|
||||
aria-valuetext={_(
|
||||
msg`${formatTime(currentTime)} of ${formatTime(duration)}`,
|
||||
)}
|
||||
tabIndex={0}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: 16,
|
||||
width: 16,
|
||||
left: `calc(${progressPercent}% - 8px)`,
|
||||
borderRadius: 8,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
a.rounded_full,
|
||||
{backgroundColor: t.palette.white},
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale:
|
||||
hovered || scrubberActive || focused
|
||||
? scrubberActive
|
||||
? 1
|
||||
: 0.6
|
||||
: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</View>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,423 @@
|
|||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type Hls from 'hls.js'
|
||||
|
||||
import {isTouchDevice} from '#/lib/browser'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {isIPhoneWeb} from '#/platform/detection'
|
||||
import {
|
||||
useAutoplayDisabled,
|
||||
useSetSubtitlesEnabled,
|
||||
useSubtitlesEnabled,
|
||||
} from '#/state/preferences'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {
|
||||
ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
|
||||
ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
|
||||
} from '#/components/icons/ArrowsDiagonal'
|
||||
import {
|
||||
CC_Filled_Corner0_Rounded as CCActiveIcon,
|
||||
CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
|
||||
} from '#/components/icons/CC'
|
||||
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
|
||||
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TimeIndicator} from '../TimeIndicator'
|
||||
import {ControlButton} from './ControlButton'
|
||||
import {Scrubber} from './Scrubber'
|
||||
import {formatTime, useVideoElement} from './utils'
|
||||
import {VolumeControl} from './VolumeControl'
|
||||
|
||||
export function Controls({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
active,
|
||||
setActive,
|
||||
focused,
|
||||
setFocused,
|
||||
onScreen,
|
||||
fullscreenRef,
|
||||
hasSubtitleTrack,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
hlsRef: React.RefObject<Hls | undefined>
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
focused: boolean
|
||||
setFocused: (focused: boolean) => void
|
||||
onScreen: boolean
|
||||
fullscreenRef: React.RefObject<HTMLDivElement>
|
||||
hasSubtitleTrack: boolean
|
||||
}) {
|
||||
const {
|
||||
play,
|
||||
pause,
|
||||
playing,
|
||||
muted,
|
||||
changeMuted,
|
||||
togglePlayPause,
|
||||
currentTime,
|
||||
duration,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
} = useVideoElement(videoRef)
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const subtitlesEnabled = useSubtitlesEnabled()
|
||||
const setSubtitlesEnabled = useSetSubtitlesEnabled()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHover,
|
||||
onOut: onEndHover,
|
||||
} = useInteractionState()
|
||||
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
|
||||
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
|
||||
const {
|
||||
state: volumeHovered,
|
||||
onIn: onVolumeHover,
|
||||
onOut: onVolumeEndHover,
|
||||
} = useInteractionState()
|
||||
|
||||
const onKeyDown = useCallback(() => {
|
||||
setInteractingViaKeypress(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (interactingViaKeypress) {
|
||||
document.addEventListener('click', () => setInteractingViaKeypress(false))
|
||||
return () => {
|
||||
document.removeEventListener('click', () =>
|
||||
setInteractingViaKeypress(false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [interactingViaKeypress])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFullscreen) {
|
||||
document.documentElement.style.scrollbarGutter = 'unset'
|
||||
return () => {
|
||||
document.documentElement.style.removeProperty('scrollbar-gutter')
|
||||
}
|
||||
}
|
||||
}, [isFullscreen])
|
||||
|
||||
// pause + unfocus when another video is active
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
pause()
|
||||
setFocused(false)
|
||||
}
|
||||
}, [active, pause, setFocused])
|
||||
|
||||
// autoplay/pause based on visibility
|
||||
const isWithinMessage = useIsWithinMessage()
|
||||
const autoplayDisabled = useAutoplayDisabled() || isWithinMessage
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
if (onScreen) {
|
||||
if (!autoplayDisabled) play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}, [onScreen, pause, active, play, autoplayDisabled])
|
||||
|
||||
// use minimal quality when not focused
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (focused) {
|
||||
// auto decide quality based on network conditions
|
||||
hlsRef.current.autoLevelCapping = -1
|
||||
// allow 30s of buffering
|
||||
hlsRef.current.config.maxMaxBufferLength = 30
|
||||
} else {
|
||||
// back to what we initially set
|
||||
hlsRef.current.autoLevelCapping = 0
|
||||
hlsRef.current.config.maxMaxBufferLength = 10
|
||||
}
|
||||
}, [hlsRef, focused])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
|
||||
hlsRef.current.subtitleTrack = 0
|
||||
} else {
|
||||
hlsRef.current.subtitleTrack = -1
|
||||
}
|
||||
}, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
|
||||
|
||||
// clicking on any button should focus the player, if it's not already focused
|
||||
const drawFocus = useCallback(() => {
|
||||
if (!active) {
|
||||
setActive()
|
||||
}
|
||||
setFocused(true)
|
||||
}, [active, setActive, setFocused])
|
||||
|
||||
const onPressEmptySpace = useCallback(() => {
|
||||
if (!focused) {
|
||||
drawFocus()
|
||||
if (autoplayDisabled) play()
|
||||
} else {
|
||||
togglePlayPause()
|
||||
}
|
||||
}, [togglePlayPause, drawFocus, focused, autoplayDisabled, play])
|
||||
|
||||
const onPressPlayPause = useCallback(() => {
|
||||
drawFocus()
|
||||
togglePlayPause()
|
||||
}, [drawFocus, togglePlayPause])
|
||||
|
||||
const onPressSubtitles = useCallback(() => {
|
||||
drawFocus()
|
||||
setSubtitlesEnabled(!subtitlesEnabled)
|
||||
}, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
|
||||
|
||||
const onPressFullscreen = useCallback(() => {
|
||||
drawFocus()
|
||||
toggleFullscreen()
|
||||
}, [drawFocus, toggleFullscreen])
|
||||
|
||||
const onSeek = useCallback(
|
||||
(time: number) => {
|
||||
if (!videoRef.current) return
|
||||
if (videoRef.current.fastSeek) {
|
||||
videoRef.current.fastSeek(time)
|
||||
} else {
|
||||
videoRef.current.currentTime = time
|
||||
}
|
||||
},
|
||||
[videoRef],
|
||||
)
|
||||
|
||||
const playStateBeforeSeekRef = useRef(false)
|
||||
|
||||
const onSeekStart = useCallback(() => {
|
||||
drawFocus()
|
||||
playStateBeforeSeekRef.current = playing
|
||||
pause()
|
||||
}, [playing, pause, drawFocus])
|
||||
|
||||
const onSeekEnd = useCallback(() => {
|
||||
if (playStateBeforeSeekRef.current) {
|
||||
play()
|
||||
}
|
||||
}, [play])
|
||||
|
||||
const seekLeft = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const currentTime = videoRef.current.currentTime
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const duration = videoRef.current.duration || 0
|
||||
onSeek(clamp(currentTime - 5, 0, duration))
|
||||
}, [onSeek, videoRef])
|
||||
|
||||
const seekRight = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const currentTime = videoRef.current.currentTime
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const duration = videoRef.current.duration || 0
|
||||
onSeek(clamp(currentTime + 5, 0, duration))
|
||||
}, [onSeek, videoRef])
|
||||
|
||||
const [showCursor, setShowCursor] = useState(true)
|
||||
const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const onPointerMoveEmptySpace = useCallback(() => {
|
||||
setShowCursor(true)
|
||||
if (cursorTimeoutRef.current) {
|
||||
clearTimeout(cursorTimeoutRef.current)
|
||||
}
|
||||
cursorTimeoutRef.current = setTimeout(() => {
|
||||
setShowCursor(false)
|
||||
onEndHover()
|
||||
}, 2000)
|
||||
}, [onEndHover])
|
||||
const onPointerLeaveEmptySpace = useCallback(() => {
|
||||
setShowCursor(false)
|
||||
if (cursorTimeoutRef.current) {
|
||||
clearTimeout(cursorTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// these are used to trigger the hover state. on mobile, the hover state
|
||||
// should stick around for a bit after they tap, and if the controls aren't
|
||||
// present this initial tab should *only* show the controls and not activate anything
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (evt.pointerType !== 'mouse' && !hovered) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
clearTimeout(timeoutRef.current)
|
||||
},
|
||||
[hovered],
|
||||
)
|
||||
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
const onHoverWithTimeout = useCallback(() => {
|
||||
onHover()
|
||||
clearTimeout(timeoutRef.current)
|
||||
}, [onHover])
|
||||
|
||||
const onEndHoverWithTimeout = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
// if touch, end after 3s
|
||||
// if mouse, end immediately
|
||||
if (evt.pointerType !== 'mouse') {
|
||||
setTimeout(onEndHover, 3000)
|
||||
} else {
|
||||
onEndHover()
|
||||
}
|
||||
},
|
||||
[onEndHover],
|
||||
)
|
||||
|
||||
const showControls =
|
||||
((focused || autoplayDisabled) && !playing) ||
|
||||
(interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={evt => {
|
||||
evt.stopPropagation()
|
||||
setInteractingViaKeypress(false)
|
||||
}}
|
||||
onPointerEnter={onHoverWithTimeout}
|
||||
onPointerMove={onHoverWithTimeout}
|
||||
onPointerLeave={onEndHoverWithTimeout}
|
||||
onPointerDown={onPointerDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPointerEnter={onPointerMoveEmptySpace}
|
||||
onPointerMove={onPointerMoveEmptySpace}
|
||||
onPointerLeave={onPointerLeaveEmptySpace}
|
||||
accessibilityHint={_(
|
||||
!focused
|
||||
? msg`Unmute video`
|
||||
: playing
|
||||
? msg`Pause video`
|
||||
: msg`Play video`,
|
||||
)}
|
||||
style={[
|
||||
a.flex_1,
|
||||
web({cursor: showCursor || !playing ? 'pointer' : 'none'}),
|
||||
]}
|
||||
onPress={onPressEmptySpace}
|
||||
/>
|
||||
{!showControls && !focused && duration > 0 && (
|
||||
<TimeIndicator time={Math.floor(duration - currentTime)} />
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.flex_shrink_0,
|
||||
a.w_full,
|
||||
a.px_xs,
|
||||
web({
|
||||
background:
|
||||
'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
|
||||
}),
|
||||
{opacity: showControls ? 1 : 0},
|
||||
{transition: 'opacity 0.2s ease-in-out'},
|
||||
]}>
|
||||
{(!volumeHovered || isTouchDevice) && (
|
||||
<Scrubber
|
||||
duration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={onSeek}
|
||||
onSeekStart={onSeekStart}
|
||||
onSeekEnd={onSeekEnd}
|
||||
seekLeft={seekLeft}
|
||||
seekRight={seekRight}
|
||||
togglePlayPause={togglePlayPause}
|
||||
drawFocus={drawFocus}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.px_xs,
|
||||
a.pt_2xs,
|
||||
a.pb_md,
|
||||
a.gap_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
]}>
|
||||
<ControlButton
|
||||
active={playing}
|
||||
activeLabel={_(msg`Pause`)}
|
||||
inactiveLabel={_(msg`Play`)}
|
||||
activeIcon={PauseIcon}
|
||||
inactiveIcon={PlayIcon}
|
||||
onPress={onPressPlayPause}
|
||||
/>
|
||||
<View style={a.flex_1} />
|
||||
<Text style={{color: t.palette.white, fontVariant: ['tabular-nums']}}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</Text>
|
||||
{hasSubtitleTrack && (
|
||||
<ControlButton
|
||||
active={subtitlesEnabled}
|
||||
activeLabel={_(msg`Disable subtitles`)}
|
||||
inactiveLabel={_(msg`Enable subtitles`)}
|
||||
activeIcon={CCActiveIcon}
|
||||
inactiveIcon={CCInactiveIcon}
|
||||
onPress={onPressSubtitles}
|
||||
/>
|
||||
)}
|
||||
<VolumeControl
|
||||
muted={muted}
|
||||
changeMuted={changeMuted}
|
||||
hovered={volumeHovered}
|
||||
onHover={onVolumeHover}
|
||||
onEndHover={onVolumeEndHover}
|
||||
drawFocus={drawFocus}
|
||||
/>
|
||||
{!isIPhoneWeb && (
|
||||
<ControlButton
|
||||
active={isFullscreen}
|
||||
activeLabel={_(msg`Exit fullscreen`)}
|
||||
inactiveLabel={_(msg`Fullscreen`)}
|
||||
activeIcon={ArrowsInIcon}
|
||||
inactiveIcon={ArrowsOutIcon}
|
||||
onPress={onPressFullscreen}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{(buffering || error) && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
{buffering && <Loader fill={t.palette.white} size="lg" />}
|
||||
{error && (
|
||||
<Text style={{color: t.palette.white}}>
|
||||
<Trans>An error occurred</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isSafari, isTouchDevice} from '#/lib/browser'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {useVideoVolumeState} from '../../VideoVolumeContext'
|
||||
import {ControlButton} from './ControlButton'
|
||||
|
||||
export function VolumeControl({
|
||||
muted,
|
||||
changeMuted,
|
||||
hovered,
|
||||
onHover,
|
||||
onEndHover,
|
||||
drawFocus,
|
||||
}: {
|
||||
muted: boolean
|
||||
changeMuted: (muted: boolean | ((prev: boolean) => boolean)) => void
|
||||
hovered: boolean
|
||||
onHover: () => void
|
||||
onEndHover: () => void
|
||||
drawFocus: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const [volume, setVolume] = useVideoVolumeState()
|
||||
|
||||
const onVolumeChange = useCallback(
|
||||
(evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
drawFocus()
|
||||
const vol = sliderVolumeToVideoVolume(Number(evt.target.value))
|
||||
setVolume(vol)
|
||||
changeMuted(vol === 0)
|
||||
},
|
||||
[setVolume, drawFocus, changeMuted],
|
||||
)
|
||||
|
||||
const sliderVolume = muted ? 0 : videoVolumeToSliderVolume(volume)
|
||||
|
||||
const isZeroVolume = volume === 0
|
||||
const onPressMute = useCallback(() => {
|
||||
drawFocus()
|
||||
if (isZeroVolume) {
|
||||
setVolume(1)
|
||||
changeMuted(false)
|
||||
} else {
|
||||
changeMuted(prevMuted => !prevMuted)
|
||||
}
|
||||
}, [drawFocus, setVolume, isZeroVolume, changeMuted])
|
||||
|
||||
return (
|
||||
<View
|
||||
onPointerEnter={onHover}
|
||||
onPointerLeave={onEndHover}
|
||||
style={[a.relative]}>
|
||||
{hovered && !isTouchDevice && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(100)}
|
||||
exiting={FadeOut.duration(100)}
|
||||
style={[a.absolute, a.w_full, {height: 100, bottom: '100%'}]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mb_xs,
|
||||
a.px_2xs,
|
||||
a.py_xs,
|
||||
{backgroundColor: 'rgba(0, 0, 0, 0.6)'},
|
||||
a.rounded_xs,
|
||||
a.align_center,
|
||||
]}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sliderVolume}
|
||||
style={
|
||||
// Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h
|
||||
isSafari ? {height: 92, minHeight: '100%'} : {height: '100%'}
|
||||
}
|
||||
onChange={onVolumeChange}
|
||||
// @ts-expect-error for old versions of firefox, and then re-using it for targeting the CSS -sfn
|
||||
orient="vertical"
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
<ControlButton
|
||||
active={muted || volume === 0}
|
||||
activeLabel={_(msg({message: `Unmute`, context: 'video'}))}
|
||||
inactiveLabel={_(msg({message: `Mute`, context: 'video'}))}
|
||||
activeIcon={MuteIcon}
|
||||
inactiveIcon={UnmuteIcon}
|
||||
onPress={onPressMute}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function sliderVolumeToVideoVolume(value: number) {
|
||||
return Math.pow(value / 100, 4)
|
||||
}
|
||||
|
||||
function videoVolumeToSliderVolume(value: number) {
|
||||
return Math.round(Math.pow(value, 1 / 4) * 100)
|
||||
}
|
|
@ -0,0 +1,228 @@
|
|||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
|
||||
import {useVideoVolumeState} from '../../VideoVolumeContext'
|
||||
|
||||
export function useVideoElement(ref: React.RefObject<HTMLVideoElement>) {
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [muted, setMuted] = useState(true)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [volume, setVolume] = useVideoVolumeState()
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [buffering, setBuffering] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [canPlay, setCanPlay] = useState(false)
|
||||
const playWhenReadyRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
ref.current.volume = volume
|
||||
}, [ref, volume])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
let bufferingTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function round(num: number) {
|
||||
return Math.round(num * 100) / 100
|
||||
}
|
||||
|
||||
// Initial values
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
setMuted(ref.current.muted)
|
||||
setPlaying(!ref.current.paused)
|
||||
setVolume(ref.current.volume)
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!ref.current) return
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
}
|
||||
|
||||
const handleDurationChange = () => {
|
||||
if (!ref.current) return
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
setPlaying(true)
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
if (!ref.current) return
|
||||
setMuted(ref.current.muted)
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
setError(true)
|
||||
}
|
||||
|
||||
const handleCanPlay = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
setBuffering(false)
|
||||
setCanPlay(true)
|
||||
|
||||
if (!ref.current) return
|
||||
if (playWhenReadyRef.current) {
|
||||
ref.current.play()
|
||||
playWhenReadyRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCanPlayThrough = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
setBuffering(false)
|
||||
}
|
||||
|
||||
const handleWaiting = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 200) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handlePlaying = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const handleStalled = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 200) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setPlaying(false)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
ref.current.addEventListener('timeupdate', handleTimeUpdate, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('durationchange', handleDurationChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('play', handlePlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('pause', handlePause, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('volumechange', handleVolumeChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('error', handleError, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplay', handleCanPlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('waiting', handleWaiting, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('playing', handlePlaying, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('stalled', handleStalled, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('ended', handleEnded, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('volumechange', handleVolumeChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
clearTimeout(bufferingTimeout)
|
||||
}
|
||||
}, [ref, setVolume])
|
||||
|
||||
const play = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.ended) {
|
||||
ref.current.currentTime = 0
|
||||
}
|
||||
|
||||
if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
playWhenReadyRef.current = true
|
||||
} else {
|
||||
const promise = ref.current.play()
|
||||
if (promise !== undefined) {
|
||||
promise.catch(err => {
|
||||
console.error('Error playing video:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.pause()
|
||||
playWhenReadyRef.current = false
|
||||
}, [ref])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.paused) {
|
||||
play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}, [ref, play, pause])
|
||||
|
||||
const changeMuted = useCallback(
|
||||
(newMuted: boolean | ((prev: boolean) => boolean)) => {
|
||||
if (!ref.current) return
|
||||
|
||||
const value =
|
||||
typeof newMuted === 'function' ? newMuted(ref.current.muted) : newMuted
|
||||
ref.current.muted = value
|
||||
},
|
||||
[ref],
|
||||
)
|
||||
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
duration,
|
||||
currentTime,
|
||||
playing,
|
||||
muted,
|
||||
changeMuted,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTime(time: number) {
|
||||
if (isNaN(time)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
time = Math.round(time)
|
||||
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = String(time % 60).padStart(2, '0')
|
||||
|
||||
return `${minutes}:${seconds}`
|
||||
}
|
|
@ -1,21 +1,26 @@
|
|||
import React from 'react'
|
||||
|
||||
const Context = React.createContext(
|
||||
{} as {
|
||||
const Context = React.createContext<{
|
||||
// native
|
||||
muted: boolean
|
||||
setMuted: (muted: boolean) => void
|
||||
},
|
||||
)
|
||||
setMuted: React.Dispatch<React.SetStateAction<boolean>>
|
||||
// web
|
||||
volume: number
|
||||
setVolume: React.Dispatch<React.SetStateAction<number>>
|
||||
} | null>(null)
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [muted, setMuted] = React.useState(true)
|
||||
const [volume, setVolume] = React.useState(1)
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
muted,
|
||||
setMuted,
|
||||
volume,
|
||||
setVolume,
|
||||
}),
|
||||
[muted, setMuted],
|
||||
[muted, setMuted, volume, setVolume],
|
||||
)
|
||||
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||
|
@ -28,5 +33,15 @@ export function useVideoVolumeState() {
|
|||
'useVideoVolumeState must be used within a VideoVolumeProvider',
|
||||
)
|
||||
}
|
||||
return context
|
||||
return [context.volume, context.setVolume] as const
|
||||
}
|
||||
|
||||
export function useVideoMuteState() {
|
||||
const context = React.useContext(Context)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useVideoMuteState must be used within a VideoVolumeProvider',
|
||||
)
|
||||
}
|
||||
return [context.muted, context.setMuted] as const
|
||||
}
|
||||
|
|
|
@ -262,6 +262,51 @@
|
|||
.force-no-clicks * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
input[type=range][orient=vertical] {
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
appearance: slider-vertical;
|
||||
width: 16px;
|
||||
vertical-align: bottom;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"][orient=vertical]::-webkit-slider-runnable-track {
|
||||
background: white;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"][orient=vertical]::-moz-range-track {
|
||||
background: white;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
input[type="range"][orient=vertical]::-moz-range-thumb {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-left: -6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
|
Loading…
Reference in New Issue