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