diff options
author | Eric Bailey <git@esb.lol> | 2025-06-13 12:05:41 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-06-13 12:05:41 -0500 |
commit | 45f0f7eefecae1922c2f30d4e7760d2b93b1ae56 (patch) | |
tree | a2fd6917867f18fe334b54dd3289775c2930bc85 /src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx | |
parent | ba0f5a9bdef5bd0447ded23cab1af222b65511cc (diff) | |
download | voidsky-45f0f7eefecae1922c2f30d4e7760d2b93b1ae56.tar.zst |
Port post embeds to new arch (#7408)
* Direct port of embeds to new arch (cherry picked from commit cc3fa1f6cea396dd9222486c633a508bfee1ecd6) * Re-org * Split out ListEmbed and FeedEmbed * Split out ImageEmbed * DRY up a bit * Port over ExternalLinkEmbed * Port over Player and Gif embeds * Migrate ComposerReplyTo * Replace other usages of old post-embeds * Migrate view contexts * Copy pasta VideoEmbed * Copy pasta GifEmbed * Swap in new file location * Clean up * Fix up native * Add back in correct moderation on List and Feed embeds * Format * Prettier * delete old video utils * move bandwidth-estimate.ts * Remove log * Add LazyQuoteEmbed for composer use * Clean up unused things * Remove remaining items * Prettier * Fix imports * Handle nested quotes same as prod * Add back silenced error handling * Fix lint --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx')
-rw-r--r-- | src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx | 307 |
1 files changed, 307 insertions, 0 deletions
diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx new file mode 100644 index 000000000..ce3a7b2c9 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -0,0 +1,307 @@ +import {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 {MediaInsetBorder} from '#/components/MediaInsetBorder' +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.MutableRefObject<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 = 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={!focused} + aria-labelledby={embed.alt ? figId : undefined} + onTimeUpdate={e => { + lastKnownTime.current = e.currentTarget.currentTime + }} + /> + {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} + /> + </div> + <MediaInsetBorder /> + </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 = undefined +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> + 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))) + } + }, + ) + + const flushOnLoop = useNonReactiveCallback(() => { + if (!Hls) return + if (!hlsRef.current) return + const hls = hlsRef.current + // the above callback 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([]) + } + }) + + useEffect(() => { + if (!videoRef.current) return + if (!Hls) return + if (!Hls.isSupported()) { + throw new HLSUnsupportedError() + } + + 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 + }) + hlsRef.current = hls + + const latestEstimate = BandwidthEstimate.get() + if (latestEstimate !== undefined) { + hls.bandwidthEstimate = latestEstimate + } + + hls.attachMedia(videoRef.current) + hls.loadSource(playlist) + + // manually loop, so if we've flushed the first buffer it doesn't get confused + const abortController = new AbortController() + const {signal} = abortController + const videoNode = videoRef.current + videoNode.addEventListener( + 'ended', + () => { + flushOnLoop() + videoNode.currentTime = 0 + videoNode.play() + }, + {signal}, + ) + + 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() + abortController.abort() + } + }, [ + playlist, + setError, + setHasSubtitleTrack, + videoRef, + handleFragChange, + flushOnLoop, + Hls, + ]) + + return hlsRef +} |