Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 358 lines 11 kB view raw
1import {useCallback, 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, updateCuePositions} = 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 updateCuePositions={updateCuePositions} 94 /> 95 </div> 96 </View> 97 ) 98} 99 100export class HLSUnsupportedError extends Error { 101 constructor() { 102 super('HLS is not supported') 103 } 104} 105 106export class VideoNotFoundError extends Error { 107 constructor() { 108 super('Video not found') 109 } 110} 111 112type CachedPromise<T> = Promise<T> & {value: undefined | T} 113const promiseForHls = import( 114 // @ts-ignore 115 'hls.js/dist/hls.min' 116).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default> 117promiseForHls.value = undefined 118promiseForHls.then(Hls => { 119 promiseForHls.value = Hls 120}) 121 122function useHLS({ 123 playlist, 124 setHasSubtitleTrack, 125 setError, 126 videoRef, 127 setHlsLoading, 128}: { 129 playlist: string 130 setHasSubtitleTrack: (v: boolean) => void 131 setError: (v: Error | null) => void 132 videoRef: React.RefObject<HTMLVideoElement | null> 133 setHlsLoading: (v: boolean) => void 134}) { 135 const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>( 136 () => promiseForHls.value, 137 ) 138 useEffect(() => { 139 if (!Hls) { 140 setHlsLoading(true) 141 promiseForHls.then(loadedHls => { 142 setHls(() => loadedHls) 143 setHlsLoading(false) 144 }) 145 } 146 }, [Hls, setHlsLoading]) 147 148 const hlsRef = useRef<HlsTypes.default | undefined>(undefined) 149 const controlsVisibleRef = useRef(false) 150 151 /** 152 * Repositions VTT subtitle cues using percentage-based line values 153 * (snapToLines=false) so that multi-line/wrapped cues grow upward 154 * instead of extending offscreen. Moves cues higher when controls 155 * are visible to avoid occlusion by the scrub bar. 156 * 157 * Called from two sites: 158 * - SUBTITLE_FRAG_PROCESSED: applies positioning to newly loaded cues 159 * - VideoControls effect: updates positioning when controls show/hide 160 */ 161 const updateCuePositions = useCallback( 162 (controlsVisible?: boolean) => { 163 if (controlsVisible != null) { 164 // save controlsVisible state so that when it's called from SUBTITLE_FRAG_PROCESSED, 165 // the most recent value is used (as we won't know the control state there) 166 controlsVisibleRef.current = controlsVisible 167 } 168 // magic numbers: cue position, % from top of video 169 const line = controlsVisibleRef.current ? 70 : 85 170 const video = videoRef.current 171 if (!video) return 172 for (let i = 0; i < video.textTracks.length; i++) { 173 const track = video.textTracks[i] 174 if (track.cues) { 175 for (let j = 0; j < track.cues.length; j++) { 176 const cue = track.cues[j] as VTTCue 177 cue.snapToLines = false 178 cue.line = line 179 } 180 } 181 // toggle track mode to force the browser to re-render active cues 182 if (track.mode === 'showing') { 183 track.mode = 'hidden' 184 track.mode = 'showing' 185 } 186 } 187 }, 188 [videoRef], 189 ) 190 const [lowQualityFragments, setLowQualityFragments] = useState< 191 HlsTypes.Fragment[] 192 >([]) 193 194 // purge low quality segments from buffer on next frag change 195 const handleFragChange = useNonReactiveCallback( 196 ( 197 _event: HlsTypes.Events.FRAG_CHANGED, 198 {frag}: HlsTypes.FragChangedData, 199 ) => { 200 if (!Hls) return 201 if (!hlsRef.current) return 202 const hls = hlsRef.current 203 204 // if the current quality level goes above 0, flush the low quality segments 205 if (hls.nextAutoLevel > 0) { 206 const flushed: HlsTypes.Fragment[] = [] 207 208 for (const lowQualFrag of lowQualityFragments) { 209 // avoid if close to the current fragment 210 if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { 211 continue 212 } 213 214 hls.trigger(Hls.Events.BUFFER_FLUSHING, { 215 startOffset: lowQualFrag.start, 216 endOffset: lowQualFrag.end, 217 type: 'video', 218 }) 219 220 flushed.push(lowQualFrag) 221 } 222 223 setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f))) 224 } 225 }, 226 ) 227 228 useEffect(() => { 229 if (!videoRef.current) return 230 if (!Hls) return 231 if (!Hls.isSupported()) { 232 throw new HLSUnsupportedError() 233 } 234 235 const latestEstimate = BandwidthEstimate.get() 236 const hls = new Hls({ 237 maxMaxBufferLength: 10, // only load 10s ahead 238 // note: the amount buffered is affected by both maxBufferLength and maxBufferSize 239 // it will buffer until it is greater than *both* of those values 240 // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead 241 startLevel: 242 latestEstimate === undefined ? -1 : Hls.DefaultConfig.startLevel, 243 // the '-1' value makes a test request to estimate bandwidth and quality level 244 // before showing the first fragment 245 }) 246 hlsRef.current = hls 247 248 if (latestEstimate !== undefined) { 249 hls.bandwidthEstimate = latestEstimate 250 } 251 252 hls.attachMedia(videoRef.current) 253 hls.loadSource(playlist) 254 255 hls.on(Hls.Events.FRAG_LOADED, () => { 256 BandwidthEstimate.set(hls.bandwidthEstimate) 257 }) 258 259 hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { 260 if (data.subtitleTracks.length > 0) { 261 setHasSubtitleTrack(true) 262 } 263 }) 264 265 hls.on(Hls.Events.SUBTITLE_FRAG_PROCESSED, () => { 266 updateCuePositions() 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 } 297 }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls]) 298 299 const flushOnLoop = useNonReactiveCallback(() => { 300 if (!Hls) return 301 if (!hlsRef.current) return 302 const hls = hlsRef.current 303 // `handleFragChange` will catch most stale frags, but there's a corner case - 304 // if there's only one segment in the video, it won't get flushed because it avoids 305 // flushing the currently active segment. Therefore, we have to catch it when we loop 306 if ( 307 hls.nextAutoLevel > 0 && 308 lowQualityFragments.length === 1 && 309 lowQualityFragments[0].start === 0 310 ) { 311 const lowQualFrag = lowQualityFragments[0] 312 313 hls.trigger(Hls.Events.BUFFER_FLUSHING, { 314 startOffset: lowQualFrag.start, 315 endOffset: lowQualFrag.end, 316 type: 'video', 317 }) 318 setLowQualityFragments([]) 319 } 320 }) 321 322 // manually loop, so if we've flushed the first buffer it doesn't get confused 323 const hasLowQualityFragmentAtStart = lowQualityFragments.some( 324 frag => frag.start === 0, 325 ) 326 useEffect(() => { 327 if (!videoRef.current) return 328 329 // use `loop` prop on `<video>` element if the starting frag is high quality. 330 // otherwise, we need to do it with an event listener as we may need to manually flush the frag 331 if (!hasLowQualityFragmentAtStart) return 332 333 const abortController = new AbortController() 334 const {signal} = abortController 335 const videoNode = videoRef.current 336 videoNode.addEventListener( 337 'ended', 338 () => { 339 flushOnLoop() 340 videoNode.currentTime = 0 341 const maybePromise = videoNode.play() as Promise<void> | undefined 342 if (maybePromise) { 343 maybePromise.catch(() => {}) 344 } 345 }, 346 {signal}, 347 ) 348 return () => { 349 abortController.abort() 350 } 351 }, [videoRef, flushOnLoop, hasLowQualityFragmentAtStart]) 352 353 return { 354 hlsRef, 355 loop: !hasLowQualityFragmentAtStart, 356 updateCuePositions, 357 } 358}