Bluesky app fork with some witchin' additions 馃挮
at twelve 323 lines 9.3 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/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 76 id={figId} 77 style={{ 78 position: 'absolute', 79 width: 1, 80 height: 1, 81 padding: 0, 82 margin: -1, 83 overflow: 'hidden', 84 clip: 'rect(0, 0, 0, 0)', 85 whiteSpace: 'nowrap', 86 borderWidth: 0, 87 }}> 88 {embed.alt} 89 </figcaption> 90 )} 91 </figure> 92 <Controls 93 videoRef={videoRef} 94 hlsRef={hlsRef} 95 active={active} 96 setActive={setActive} 97 focused={focused} 98 setFocused={setFocused} 99 hlsLoading={hlsLoading} 100 onScreen={onScreen} 101 fullscreenRef={containerRef} 102 hasSubtitleTrack={hasSubtitleTrack} 103 isGif={embed.presentation === 'gif'} 104 altText={embed.alt} 105 /> 106 </div> 107 </View> 108 ) 109} 110 111export class HLSUnsupportedError extends Error { 112 constructor() { 113 super('HLS is not supported') 114 } 115} 116 117export class VideoNotFoundError extends Error { 118 constructor() { 119 super('Video not found') 120 } 121} 122 123type CachedPromise<T> = Promise<T> & {value: undefined | T} 124const promiseForHls = import( 125 // @ts-ignore 126 'hls.js/dist/hls.min' 127).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default> 128promiseForHls.value = undefined 129promiseForHls.then(Hls => { 130 promiseForHls.value = Hls 131}) 132 133function useHLS({ 134 playlist, 135 setHasSubtitleTrack, 136 setError, 137 videoRef, 138 setHlsLoading, 139}: { 140 playlist: string 141 setHasSubtitleTrack: (v: boolean) => void 142 setError: (v: Error | null) => void 143 videoRef: React.RefObject<HTMLVideoElement | null> 144 setHlsLoading: (v: boolean) => void 145}) { 146 const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>( 147 () => promiseForHls.value, 148 ) 149 useEffect(() => { 150 if (!Hls) { 151 setHlsLoading(true) 152 promiseForHls.then(loadedHls => { 153 setHls(() => loadedHls) 154 setHlsLoading(false) 155 }) 156 } 157 }, [Hls, setHlsLoading]) 158 159 const hlsRef = useRef<HlsTypes.default | undefined>(undefined) 160 const [lowQualityFragments, setLowQualityFragments] = useState< 161 HlsTypes.Fragment[] 162 >([]) 163 164 // purge low quality segments from buffer on next frag change 165 const handleFragChange = useNonReactiveCallback( 166 ( 167 _event: HlsTypes.Events.FRAG_CHANGED, 168 {frag}: HlsTypes.FragChangedData, 169 ) => { 170 if (!Hls) return 171 if (!hlsRef.current) return 172 const hls = hlsRef.current 173 174 // if the current quality level goes above 0, flush the low quality segments 175 if (hls.nextAutoLevel > 0) { 176 const flushed: HlsTypes.Fragment[] = [] 177 178 for (const lowQualFrag of lowQualityFragments) { 179 // avoid if close to the current fragment 180 if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { 181 continue 182 } 183 184 hls.trigger(Hls.Events.BUFFER_FLUSHING, { 185 startOffset: lowQualFrag.start, 186 endOffset: lowQualFrag.end, 187 type: 'video', 188 }) 189 190 flushed.push(lowQualFrag) 191 } 192 193 setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f))) 194 } 195 }, 196 ) 197 198 useEffect(() => { 199 if (!videoRef.current) return 200 if (!Hls) return 201 if (!Hls.isSupported()) { 202 throw new HLSUnsupportedError() 203 } 204 205 const latestEstimate = BandwidthEstimate.get() 206 const hls = new Hls({ 207 maxMaxBufferLength: 10, // only load 10s ahead 208 // note: the amount buffered is affected by both maxBufferLength and maxBufferSize 209 // it will buffer until it is greater than *both* of those values 210 // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead 211 startLevel: 212 latestEstimate === undefined ? -1 : Hls.DefaultConfig.startLevel, 213 // the '-1' value makes a test request to estimate bandwidth and quality level 214 // before showing the first fragment 215 }) 216 hlsRef.current = hls 217 218 if (latestEstimate !== undefined) { 219 hls.bandwidthEstimate = latestEstimate 220 } 221 222 hls.attachMedia(videoRef.current) 223 hls.loadSource(playlist) 224 225 hls.on(Hls.Events.FRAG_LOADED, () => { 226 BandwidthEstimate.set(hls.bandwidthEstimate) 227 }) 228 229 hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { 230 if (data.subtitleTracks.length > 0) { 231 setHasSubtitleTrack(true) 232 } 233 }) 234 235 hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { 236 if (frag.level === 0) { 237 setLowQualityFragments(prev => [...prev, frag]) 238 } 239 }) 240 241 hls.on(Hls.Events.ERROR, (_event, data) => { 242 if (data.fatal) { 243 if ( 244 data.details === 'manifestLoadError' && 245 data.response?.code === 404 246 ) { 247 setError(new VideoNotFoundError()) 248 } else { 249 setError(data.error) 250 } 251 } else { 252 console.error(data.error) 253 } 254 }) 255 256 hls.on(Hls.Events.FRAG_CHANGED, handleFragChange) 257 258 return () => { 259 hlsRef.current = undefined 260 hls.detachMedia() 261 hls.destroy() 262 } 263 }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls]) 264 265 const flushOnLoop = useNonReactiveCallback(() => { 266 if (!Hls) return 267 if (!hlsRef.current) return 268 const hls = hlsRef.current 269 // `handleFragChange` will catch most stale frags, but there's a corner case - 270 // if there's only one segment in the video, it won't get flushed because it avoids 271 // flushing the currently active segment. Therefore, we have to catch it when we loop 272 if ( 273 hls.nextAutoLevel > 0 && 274 lowQualityFragments.length === 1 && 275 lowQualityFragments[0].start === 0 276 ) { 277 const lowQualFrag = lowQualityFragments[0] 278 279 hls.trigger(Hls.Events.BUFFER_FLUSHING, { 280 startOffset: lowQualFrag.start, 281 endOffset: lowQualFrag.end, 282 type: 'video', 283 }) 284 setLowQualityFragments([]) 285 } 286 }) 287 288 // manually loop, so if we've flushed the first buffer it doesn't get confused 289 const hasLowQualityFragmentAtStart = lowQualityFragments.some( 290 frag => frag.start === 0, 291 ) 292 useEffect(() => { 293 if (!videoRef.current) return 294 295 // use `loop` prop on `<video>` element if the starting frag is high quality. 296 // otherwise, we need to do it with an event listener as we may need to manually flush the frag 297 if (!hasLowQualityFragmentAtStart) return 298 299 const abortController = new AbortController() 300 const {signal} = abortController 301 const videoNode = videoRef.current 302 videoNode.addEventListener( 303 'ended', 304 () => { 305 flushOnLoop() 306 videoNode.currentTime = 0 307 const maybePromise = videoNode.play() as Promise<void> | undefined 308 if (maybePromise) { 309 maybePromise.catch(() => {}) 310 } 311 }, 312 {signal}, 313 ) 314 return () => { 315 abortController.abort() 316 } 317 }, [videoRef, flushOnLoop, hasLowQualityFragmentAtStart]) 318 319 return { 320 hlsRef, 321 loop: !hasLowQualityFragmentAtStart, 322 } 323}