An ATproto social media client -- with an independent Appview.
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}