forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}