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