Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
15 kB · 478 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479import { useCallback, useEffect, useEffectEvent, useId, useRef, useState,} from 'react'import {View} from 'react-native'import {useLingui} from '@lingui/react/macro'import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'import {hasPlaybackStarted} from '#/lib/media/video/analytics'import {atoms as a} from '#/alf'import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'import {useFullscreen} from '#/components/hooks/useFullscreen'import {useReportDialogMetadataContext} from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'import * as BandwidthEstimate from './bandwidth-estimate'import { HLSFatalError, HLSUnsupportedError, type VideoEmbedInnerWebProps, VideoNotFoundError,} from './VideoEmbedInnerWeb.shared'import {Controls} from './web-controls/VideoControls'
export { HLSFatalError, HLSUnsupportedError, VideoNotFoundError,} from './VideoEmbedInnerWeb.shared'
export function VideoEmbedInnerWeb({ embed, onDownload, active, setActive, onScreen, lastKnownTime, onPlaybackStart,}: VideoEmbedInnerWebProps) { 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 {t: l} = useLingui() const [isFullscreen] = useFullscreen(containerRef) const isGif = embed.presentation === 'gif' const reportDialogMetadata = useReportDialogMetadataContext() const playbackStartTrackedRef = useRef(false)
// send error up to error boundary const [error, setError] = useState<Error | null>(null) if (error) { throw error }
const {hlsRef, loop, updateCuePositions} = 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={l`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 => { const currentTime = e.currentTarget.currentTime lastKnownTime.current = currentTime if ( !playbackStartTrackedRef.current && hasPlaybackStarted(currentTime) ) { playbackStartTrackedRef.current = true onPlaybackStart(!focused) } if ( !isGif && reportDialogMetadata && Number.isFinite(currentTime) && currentTime >= 0 ) { reportDialogMetadata.current.videoTimestampSeconds = currentTime } }} loop={loop} /> {embed.alt && ( <figcaption id={figId} style={a.sr_only}> {embed.alt} </figcaption> )} </figure> {!isFullscreen && !isGif && embed.alt && ( <AltBadgeWithDialog text={embed.alt} position="top-right" /> )} <Controls videoRef={videoRef} hlsRef={hlsRef} onDownload={onDownload} active={active} setActive={setActive} focused={focused} setFocused={setFocused} hlsLoading={hlsLoading} onScreen={onScreen} fullscreenRef={containerRef} hasSubtitleTrack={hasSubtitleTrack} isGif={isGif} altText={embed.alt} updateCuePositions={updateCuePositions} /> </div> </View> )}
// Bluesky serves HLS as MPEG-TS with H.264 + AAC. `Hls.isSupported()` is loose// (true if MSE supports *any* of {H.264, AV1, VP9} OR *any* of {AAC, FLAC}),// so on Linux boxes missing H.264 (e.g. no ubuntu-restricted-extras, sandboxed// Firefox snap) it returns true and playback fails later on segment append.// Use Baseline 3.0 to match hls.js's own probe - it's the most universal H.264// profile, so if it isn't supported, no H.264 is.// Mirror hls.js's `getMediaSource` lookup (ManagedMediaSource on modern iOS,// then MediaSource, then WebKitMediaSource) so we probe the same constructor// it will actually use for playback.function canPlayBskyVideoCodecs(): boolean { if (typeof self === 'undefined') return false const globalSelf = self as typeof self & { ManagedMediaSource?: typeof MediaSource WebKitMediaSource?: typeof MediaSource } const mediaSource = globalSelf.ManagedMediaSource || globalSelf.MediaSource || globalSelf.WebKitMediaSource if (!mediaSource || typeof mediaSource.isTypeSupported !== 'function') { return false } return ( mediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E"') && mediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') )}
type CachedPromise<T> = Promise<T> & {value: undefined | T}const promiseForHls = import( // @ts-expect-error 'hls.js/dist/hls.min' // oxlint-disable-next-line typescript/no-unsafe-member-access).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default>promiseForHls.value = undefinedvoid promiseForHls.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) void promiseForHls.then(loadedHls => { setHls(() => loadedHls) setHlsLoading(false) }) } }, [Hls, setHlsLoading])
const hlsRef = useRef<HlsTypes.default | undefined>(undefined) const controlsVisibleRef = useRef(false)
/** * Repositions VTT subtitle cues using percentage-based line values * (snapToLines=false) so that multi-line/wrapped cues grow upward * instead of extending offscreen. Moves cues higher when controls * are visible to avoid occlusion by the scrub bar. * * Called from two sites: * - SUBTITLE_FRAG_PROCESSED: applies positioning to newly loaded cues * - VideoControls effect: updates positioning when controls show/hide */ const updateCuePositions = useCallback( (controlsVisible?: boolean) => { if (controlsVisible != null) { // save controlsVisible state so that when it's called from SUBTITLE_FRAG_PROCESSED, // the most recent value is used (as we won't know the control state there) controlsVisibleRef.current = controlsVisible } // magic numbers: cue position, % from top of video const line = controlsVisibleRef.current ? 70 : 85 const video = videoRef.current if (!video) return for (let i = 0; i < video.textTracks.length; i++) { const track = video.textTracks[i] if (track.cues) { for (let j = 0; j < track.cues.length; j++) { const cue = track.cues[j] as VTTCue cue.snapToLines = false cue.line = line } } // toggle track mode to force the browser to re-render active cues if (track.mode === 'showing') { track.mode = 'hidden' track.mode = 'showing' } } }, [videoRef], ) 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))) } }, )
/* * The hls handler below must call the latest `updateCuePositions` without the * effect tearing down and re-attaching every time its identity changes. */ const onSubtitleFragProcessed = useEffectEvent(() => { updateCuePositions() })
useEffect(() => { if (!videoRef.current) return if (!Hls) return if (!Hls.isSupported() || !canPlayBskyVideoCodecs()) { 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.SUBTITLE_FRAG_PROCESSED, () => { onSubtitleFragProcessed() })
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 as string) === 'manifestLoadError' && data.response?.code === 404 ) { setError(new VideoNotFoundError()) } else { const video = videoRef.current const mediaError = video?.error setError( new HLSFatalError({ detail: data.details, type: data.type, cause: data.error, diagnostics: { hlsError: { detail: data.details, type: data.type, sourceBufferName: data.sourceBufferName, parent: data.parent, reason: data.reason, errorName: data.error.name, errorCode: (data.error as DOMException).code, }, fragment: data.frag ? { sn: data.frag.sn, level: data.frag.level, type: data.frag.type, start: data.frag.start, duration: data.frag.duration, cc: data.frag.cc, } : undefined, media: video ? { errorCode: mediaError?.code, errorMessage: mediaError?.message, readyState: video.readyState, networkState: video.networkState, currentTime: video.currentTime, paused: video.paused, ended: video.ended, seeking: video.seeking, } : undefined, lifecycle: { documentVisibility: document.visibilityState, hlsIsCurrent: hlsRef.current === hls, }, playlist, }, }), ) } } 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, updateCuePositions, ])
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, updateCuePositions, }}