Bluesky app fork with some witchin' additions 馃挮
at main 311 lines 9.0 kB view raw
1import {useEffect, useId, useRef, useState} from 'react' 2import {View} from 'react-native' 3import {type AppBskyEmbedVideo} from '@atproto/api' 4import {msg} from '@lingui/core/macro' 5import {useLingui} from '@lingui/react' 6import type * as HlsTypes from 'hls.js' 7 8import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 9import {atoms as a} from '#/alf' 10import * as BandwidthEstimate from './bandwidth-estimate' 11import {Controls} from './web-controls/VideoControls' 12 13export function VideoEmbedInnerWeb({ 14 embed, 15 active, 16 setActive, 17 onScreen, 18 lastKnownTime, 19}: { 20 embed: AppBskyEmbedVideo.View 21 active: boolean 22 setActive: () => void 23 onScreen: boolean 24 lastKnownTime: React.RefObject<number | undefined> 25}) { 26 const containerRef = useRef<HTMLDivElement>(null) 27 const videoRef = useRef<HTMLVideoElement>(null) 28 const [focused, setFocused] = useState(false) 29 const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) 30 const [hlsLoading, setHlsLoading] = useState(false) 31 const figId = useId() 32 const {_} = useLingui() 33 34 // send error up to error boundary 35 const [error, setError] = useState<Error | null>(null) 36 if (error) { 37 throw error 38 } 39 40 const {hlsRef, loop} = useHLS({ 41 playlist: embed.playlist, 42 setHasSubtitleTrack, 43 setError, 44 videoRef, 45 setHlsLoading, 46 }) 47 48 useEffect(() => { 49 if (lastKnownTime.current && videoRef.current) { 50 videoRef.current.currentTime = lastKnownTime.current 51 } 52 }, [lastKnownTime]) 53 54 return ( 55 <View 56 style={[a.flex_1, a.rounded_md, a.overflow_hidden]} 57 accessibilityLabel={_(msg`Embedded video player`)} 58 accessibilityHint=""> 59 <div ref={containerRef} style={{height: '100%', width: '100%'}}> 60 <figure style={{margin: 0, position: 'absolute', inset: 0}}> 61 <video 62 ref={videoRef} 63 poster={embed.thumbnail} 64 style={{width: '100%', height: '100%', objectFit: 'contain'}} 65 playsInline 66 preload="none" 67 muted={embed.presentation === 'gif' || !focused} 68 aria-labelledby={embed.alt ? figId : undefined} 69 onTimeUpdate={e => { 70 lastKnownTime.current = e.currentTarget.currentTime 71 }} 72 loop={loop} 73 /> 74 {embed.alt && ( 75 <figcaption id={figId} style={a.sr_only}> 76 {embed.alt} 77 </figcaption> 78 )} 79 </figure> 80 <Controls 81 videoRef={videoRef} 82 hlsRef={hlsRef} 83 active={active} 84 setActive={setActive} 85 focused={focused} 86 setFocused={setFocused} 87 hlsLoading={hlsLoading} 88 onScreen={onScreen} 89 fullscreenRef={containerRef} 90 hasSubtitleTrack={hasSubtitleTrack} 91 isGif={embed.presentation === 'gif'} 92 altText={embed.alt} 93 /> 94 </div> 95 </View> 96 ) 97} 98 99export class HLSUnsupportedError extends Error { 100 constructor() { 101 super('HLS is not supported') 102 } 103} 104 105export class VideoNotFoundError extends Error { 106 constructor() { 107 super('Video not found') 108 } 109} 110 111type CachedPromise<T> = Promise<T> & {value: undefined | T} 112const promiseForHls = import( 113 // @ts-ignore 114 'hls.js/dist/hls.min' 115).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default> 116promiseForHls.value = undefined 117promiseForHls.then(Hls => { 118 promiseForHls.value = Hls 119}) 120 121function useHLS({ 122 playlist, 123 setHasSubtitleTrack, 124 setError, 125 videoRef, 126 setHlsLoading, 127}: { 128 playlist: string 129 setHasSubtitleTrack: (v: boolean) => void 130 setError: (v: Error | null) => void 131 videoRef: React.RefObject<HTMLVideoElement | null> 132 setHlsLoading: (v: boolean) => void 133}) { 134 const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>( 135 () => promiseForHls.value, 136 ) 137 useEffect(() => { 138 if (!Hls) { 139 setHlsLoading(true) 140 promiseForHls.then(loadedHls => { 141 setHls(() => loadedHls) 142 setHlsLoading(false) 143 }) 144 } 145 }, [Hls, setHlsLoading]) 146 147 const hlsRef = useRef<HlsTypes.default | undefined>(undefined) 148 const [lowQualityFragments, setLowQualityFragments] = useState< 149 HlsTypes.Fragment[] 150 >([]) 151 152 // purge low quality segments from buffer on next frag change 153 const handleFragChange = useNonReactiveCallback( 154 ( 155 _event: HlsTypes.Events.FRAG_CHANGED, 156 {frag}: HlsTypes.FragChangedData, 157 ) => { 158 if (!Hls) return 159 if (!hlsRef.current) return 160 const hls = hlsRef.current 161 162 // if the current quality level goes above 0, flush the low quality segments 163 if (hls.nextAutoLevel > 0) { 164 const flushed: HlsTypes.Fragment[] = [] 165 166 for (const lowQualFrag of lowQualityFragments) { 167 // avoid if close to the current fragment 168 if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { 169 continue 170 } 171 172 hls.trigger(Hls.Events.BUFFER_FLUSHING, { 173 startOffset: lowQualFrag.start, 174 endOffset: lowQualFrag.end, 175 type: 'video', 176 }) 177 178 flushed.push(lowQualFrag) 179 } 180 181 setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f))) 182 } 183 }, 184 ) 185 186 useEffect(() => { 187 if (!videoRef.current) return 188 if (!Hls) return 189 if (!Hls.isSupported()) { 190 throw new HLSUnsupportedError() 191 } 192 193 const latestEstimate = BandwidthEstimate.get() 194 const hls = new Hls({ 195 maxMaxBufferLength: 10, // only load 10s ahead 196 // note: the amount buffered is affected by both maxBufferLength and maxBufferSize 197 // it will buffer until it is greater than *both* of those values 198 // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead 199 startLevel: 200 latestEstimate === undefined ? -1 : Hls.DefaultConfig.startLevel, 201 // the '-1' value makes a test request to estimate bandwidth and quality level 202 // before showing the first fragment 203 }) 204 hlsRef.current = hls 205 206 if (latestEstimate !== undefined) { 207 hls.bandwidthEstimate = latestEstimate 208 } 209 210 hls.attachMedia(videoRef.current) 211 hls.loadSource(playlist) 212 213 hls.on(Hls.Events.FRAG_LOADED, () => { 214 BandwidthEstimate.set(hls.bandwidthEstimate) 215 }) 216 217 hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { 218 if (data.subtitleTracks.length > 0) { 219 setHasSubtitleTrack(true) 220 } 221 }) 222 223 hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { 224 if (frag.level === 0) { 225 setLowQualityFragments(prev => [...prev, frag]) 226 } 227 }) 228 229 hls.on(Hls.Events.ERROR, (_event, data) => { 230 if (data.fatal) { 231 if ( 232 data.details === 'manifestLoadError' && 233 data.response?.code === 404 234 ) { 235 setError(new VideoNotFoundError()) 236 } else { 237 setError(data.error) 238 } 239 } else { 240 console.error(data.error) 241 } 242 }) 243 244 hls.on(Hls.Events.FRAG_CHANGED, handleFragChange) 245 246 return () => { 247 hlsRef.current = undefined 248 hls.detachMedia() 249 hls.destroy() 250 } 251 }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls]) 252 253 const flushOnLoop = useNonReactiveCallback(() => { 254 if (!Hls) return 255 if (!hlsRef.current) return 256 const hls = hlsRef.current 257 // `handleFragChange` will catch most stale frags, but there's a corner case - 258 // if there's only one segment in the video, it won't get flushed because it avoids 259 // flushing the currently active segment. Therefore, we have to catch it when we loop 260 if ( 261 hls.nextAutoLevel > 0 && 262 lowQualityFragments.length === 1 && 263 lowQualityFragments[0].start === 0 264 ) { 265 const lowQualFrag = lowQualityFragments[0] 266 267 hls.trigger(Hls.Events.BUFFER_FLUSHING, { 268 startOffset: lowQualFrag.start, 269 endOffset: lowQualFrag.end, 270 type: 'video', 271 }) 272 setLowQualityFragments([]) 273 } 274 }) 275 276 // manually loop, so if we've flushed the first buffer it doesn't get confused 277 const hasLowQualityFragmentAtStart = lowQualityFragments.some( 278 frag => frag.start === 0, 279 ) 280 useEffect(() => { 281 if (!videoRef.current) return 282 283 // use `loop` prop on `<video>` element if the starting frag is high quality. 284 // otherwise, we need to do it with an event listener as we may need to manually flush the frag 285 if (!hasLowQualityFragmentAtStart) return 286 287 const abortController = new AbortController() 288 const {signal} = abortController 289 const videoNode = videoRef.current 290 videoNode.addEventListener( 291 'ended', 292 () => { 293 flushOnLoop() 294 videoNode.currentTime = 0 295 const maybePromise = videoNode.play() as Promise<void> | undefined 296 if (maybePromise) { 297 maybePromise.catch(() => {}) 298 } 299 }, 300 {signal}, 301 ) 302 return () => { 303 abortController.abort() 304 } 305 }, [videoRef, flushOnLoop, hasLowQualityFragmentAtStart]) 306 307 return { 308 hlsRef, 309 loop: !hasLowQualityFragmentAtStart, 310 } 311}