254 lines
6.7 KiB
TypeScript
254 lines
6.7 KiB
TypeScript
import {type RefObject, useCallback, useEffect, useRef, useState} from 'react'
|
|
|
|
import {logger} from '#/logger'
|
|
import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
|
|
import {IS_WEB_SAFARI} from '#/env'
|
|
|
|
export function useVideoElement(ref: RefObject<HTMLVideoElement | null>) {
|
|
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)
|
|
// HACK: Safari randomly fires `stalled` events when changing between segments
|
|
// let's just clear the buffering state if the video is still progressing -sfn
|
|
if (IS_WEB_SAFARI) {
|
|
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
|
setBuffering(false)
|
|
}
|
|
}
|
|
|
|
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 = async () => {
|
|
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
|
setBuffering(false)
|
|
setCanPlay(true)
|
|
|
|
if (!ref.current) return
|
|
if (playWhenReadyRef.current) {
|
|
try {
|
|
await ref.current.play()
|
|
} catch (e: any) {
|
|
if (
|
|
!e.message?.includes(
|
|
`The request is not allowed by the user agent`,
|
|
) &&
|
|
!e.message?.includes(
|
|
`The play() request was interrupted by a call to pause()`,
|
|
)
|
|
) {
|
|
throw e
|
|
}
|
|
}
|
|
playWhenReadyRef.current = false
|
|
}
|
|
}
|
|
|
|
const handleCanPlayThrough = () => {
|
|
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
|
setBuffering(false)
|
|
}
|
|
|
|
const handleWaiting = () => {
|
|
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
|
bufferingTimeout = setTimeout(() => {
|
|
setBuffering(true)
|
|
}, 500) // 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)
|
|
}, 500) // 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, 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: any) => {
|
|
if (
|
|
// ignore this common error. it's fine
|
|
!err.message?.includes(
|
|
`The play() request was interrupted by a call to pause()`,
|
|
)
|
|
) {
|
|
logger.error('Error playing video:', {message: 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}`
|
|
}
|