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