From 45f0f7eefecae1922c2f30d4e7760d2b93b1ae56 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 13 Jun 2025 12:05:41 -0500 Subject: 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 --- .../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 307 +++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx (limited to 'src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx') 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 +}) { + const containerRef = useRef(null) + const videoRef = useRef(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(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 ( + +
+
+
+ +
+ +
+ ) +} + +export class HLSUnsupportedError extends Error { + constructor() { + super('HLS is not supported') + } +} + +export class VideoNotFoundError extends Error { + constructor() { + super('Video not found') + } +} + +type CachedPromise = Promise & {value: undefined | T} +const promiseForHls = import( + // @ts-ignore + 'hls.js/dist/hls.min' +).then(mod => mod.default) as CachedPromise +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 + setHlsLoading: (v: boolean) => void +}) { + const [Hls, setHls] = useState( + () => promiseForHls.value, + ) + useEffect(() => { + if (!Hls) { + setHlsLoading(true) + promiseForHls.then(loadedHls => { + setHls(() => loadedHls) + setHlsLoading(false) + }) + } + }, [Hls, setHlsLoading]) + + const hlsRef = useRef(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 +} -- cgit 1.4.1