Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
9.3 kB · 323 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324import {useEffect, useId, useRef, useState} from 'react'import {View} from 'react-native'import {type AppBskyEmbedVideo} from '@atproto/api'import {msg} from '@lingui/macro'import {useLingui} from '@lingui/react'import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'import {atoms as a} from '#/alf'import * as BandwidthEstimate from './bandwidth-estimate'import {Controls} from './web-controls/VideoControls'
export function VideoEmbedInnerWeb({ embed, active, setActive, onScreen, lastKnownTime,}: { embed: AppBskyEmbedVideo.View active: boolean setActive: () => void onScreen: boolean lastKnownTime: React.RefObject<number | undefined>}) { const containerRef = useRef<HTMLDivElement>(null) const videoRef = useRef<HTMLVideoElement>(null) const [focused, setFocused] = useState(false) const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) const [hlsLoading, setHlsLoading] = useState(false) const figId = useId() const {_} = useLingui()
// send error up to error boundary const [error, setError] = useState<Error | null>(null) if (error) { throw error }
const {hlsRef, loop} = useHLS({ playlist: embed.playlist, setHasSubtitleTrack, setError, videoRef, setHlsLoading, })
useEffect(() => { if (lastKnownTime.current && videoRef.current) { videoRef.current.currentTime = lastKnownTime.current } }, [lastKnownTime])
return ( <View style={[a.flex_1, a.rounded_md, a.overflow_hidden]} accessibilityLabel={_(msg`Embedded video player`)} accessibilityHint=""> <div ref={containerRef} style={{height: '100%', width: '100%'}}> <figure style={{margin: 0, position: 'absolute', inset: 0}}> <video ref={videoRef} poster={embed.thumbnail} style={{width: '100%', height: '100%', objectFit: 'contain'}} playsInline preload="none" muted={embed.presentation === 'gif' || !focused} aria-labelledby={embed.alt ? figId : undefined} onTimeUpdate={e => { lastKnownTime.current = e.currentTarget.currentTime }} loop={loop} /> {embed.alt && ( <figcaption id={figId} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', whiteSpace: 'nowrap', borderWidth: 0, }}> {embed.alt} </figcaption> )} </figure> <Controls videoRef={videoRef} hlsRef={hlsRef} active={active} setActive={setActive} focused={focused} setFocused={setFocused} hlsLoading={hlsLoading} onScreen={onScreen} fullscreenRef={containerRef} hasSubtitleTrack={hasSubtitleTrack} isGif={embed.presentation === 'gif'} altText={embed.alt} /> </div> </View> )}
export class HLSUnsupportedError extends Error { constructor() { super('HLS is not supported') }}
export class VideoNotFoundError extends Error { constructor() { super('Video not found') }}
type CachedPromise<T> = Promise<T> & {value: undefined | T}const promiseForHls = import( // @ts-ignore 'hls.js/dist/hls.min').then(mod => mod.default) as CachedPromise<typeof HlsTypes.default>promiseForHls.value = undefinedpromiseForHls.then(Hls => { promiseForHls.value = Hls})
function useHLS({ playlist, setHasSubtitleTrack, setError, videoRef, setHlsLoading,}: { playlist: string setHasSubtitleTrack: (v: boolean) => void setError: (v: Error | null) => void videoRef: React.RefObject<HTMLVideoElement | null> setHlsLoading: (v: boolean) => void}) { const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>( () => promiseForHls.value, ) useEffect(() => { if (!Hls) { setHlsLoading(true) promiseForHls.then(loadedHls => { setHls(() => loadedHls) setHlsLoading(false) }) } }, [Hls, setHlsLoading])
const hlsRef = useRef<HlsTypes.default | undefined>(undefined) const [lowQualityFragments, setLowQualityFragments] = useState< HlsTypes.Fragment[] >([])
// purge low quality segments from buffer on next frag change const handleFragChange = useNonReactiveCallback( ( _event: HlsTypes.Events.FRAG_CHANGED, {frag}: HlsTypes.FragChangedData, ) => { if (!Hls) return if (!hlsRef.current) return const hls = hlsRef.current
// if the current quality level goes above 0, flush the low quality segments if (hls.nextAutoLevel > 0) { const flushed: HlsTypes.Fragment[] = []
for (const lowQualFrag of lowQualityFragments) { // avoid if close to the current fragment if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { continue }
hls.trigger(Hls.Events.BUFFER_FLUSHING, { startOffset: lowQualFrag.start, endOffset: lowQualFrag.end, type: 'video', })
flushed.push(lowQualFrag) }
setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f))) } }, )
useEffect(() => { if (!videoRef.current) return if (!Hls) return if (!Hls.isSupported()) { throw new HLSUnsupportedError() }
const latestEstimate = BandwidthEstimate.get() const hls = new Hls({ maxMaxBufferLength: 10, // only load 10s ahead // note: the amount buffered is affected by both maxBufferLength and maxBufferSize // it will buffer until it is greater than *both* of those values // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead startLevel: latestEstimate === undefined ? -1 : Hls.DefaultConfig.startLevel, // the '-1' value makes a test request to estimate bandwidth and quality level // before showing the first fragment }) hlsRef.current = hls
if (latestEstimate !== undefined) { hls.bandwidthEstimate = latestEstimate }
hls.attachMedia(videoRef.current) hls.loadSource(playlist)
hls.on(Hls.Events.FRAG_LOADED, () => { BandwidthEstimate.set(hls.bandwidthEstimate) })
hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { if (data.subtitleTracks.length > 0) { setHasSubtitleTrack(true) } })
hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { if (frag.level === 0) { setLowQualityFragments(prev => [...prev, frag]) } })
hls.on(Hls.Events.ERROR, (_event, data) => { if (data.fatal) { if ( data.details === 'manifestLoadError' && data.response?.code === 404 ) { setError(new VideoNotFoundError()) } else { setError(data.error) } } else { console.error(data.error) } })
hls.on(Hls.Events.FRAG_CHANGED, handleFragChange)
return () => { hlsRef.current = undefined hls.detachMedia() hls.destroy() } }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls])
const flushOnLoop = useNonReactiveCallback(() => { if (!Hls) return if (!hlsRef.current) return const hls = hlsRef.current // `handleFragChange` will catch most stale frags, but there's a corner case - // if there's only one segment in the video, it won't get flushed because it avoids // flushing the currently active segment. Therefore, we have to catch it when we loop if ( hls.nextAutoLevel > 0 && lowQualityFragments.length === 1 && lowQualityFragments[0].start === 0 ) { const lowQualFrag = lowQualityFragments[0]
hls.trigger(Hls.Events.BUFFER_FLUSHING, { startOffset: lowQualFrag.start, endOffset: lowQualFrag.end, type: 'video', }) setLowQualityFragments([]) } })
// manually loop, so if we've flushed the first buffer it doesn't get confused const hasLowQualityFragmentAtStart = lowQualityFragments.some( frag => frag.start === 0, ) useEffect(() => { if (!videoRef.current) return
// use `loop` prop on `<video>` element if the starting frag is high quality. // otherwise, we need to do it with an event listener as we may need to manually flush the frag if (!hasLowQualityFragmentAtStart) return
const abortController = new AbortController() const {signal} = abortController const videoNode = videoRef.current videoNode.addEventListener( 'ended', () => { flushOnLoop() videoNode.currentTime = 0 const maybePromise = videoNode.play() as Promise<void> | undefined if (maybePromise) { maybePromise.catch(() => {}) } }, {signal}, ) return () => { abortController.abort() } }, [videoRef, flushOnLoop, hasLowQualityFragmentAtStart])
return { hlsRef, loop: !hasLowQualityFragmentAtStart, }}