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.MutableRefObject<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 = 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={!focused}
68 aria-labelledby={embed.alt ? figId : undefined}
69 onTimeUpdate={e => {
70 lastKnownTime.current = e.currentTarget.currentTime
71 }}
72 />
73 {embed.alt && (
74 <figcaption
75 id={figId}
76 style={{
77 position: 'absolute',
78 width: 1,
79 height: 1,
80 padding: 0,
81 margin: -1,
82 overflow: 'hidden',
83 clip: 'rect(0, 0, 0, 0)',
84 whiteSpace: 'nowrap',
85 borderWidth: 0,
86 }}>
87 {embed.alt}
88 </figcaption>
89 )}
90 </figure>
91 <Controls
92 videoRef={videoRef}
93 hlsRef={hlsRef}
94 active={active}
95 setActive={setActive}
96 focused={focused}
97 setFocused={setFocused}
98 hlsLoading={hlsLoading}
99 onScreen={onScreen}
100 fullscreenRef={containerRef}
101 hasSubtitleTrack={hasSubtitleTrack}
102 />
103 </div>
104 </View>
105 )
106}
107
108export class HLSUnsupportedError extends Error {
109 constructor() {
110 super('HLS is not supported')
111 }
112}
113
114export class VideoNotFoundError extends Error {
115 constructor() {
116 super('Video not found')
117 }
118}
119
120type CachedPromise<T> = Promise<T> & {value: undefined | T}
121const promiseForHls = import(
122 // @ts-ignore
123 'hls.js/dist/hls.min'
124).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default>
125promiseForHls.value = undefined
126promiseForHls.then(Hls => {
127 promiseForHls.value = Hls
128})
129
130function useHLS({
131 playlist,
132 setHasSubtitleTrack,
133 setError,
134 videoRef,
135 setHlsLoading,
136}: {
137 playlist: string
138 setHasSubtitleTrack: (v: boolean) => void
139 setError: (v: Error | null) => void
140 videoRef: React.RefObject<HTMLVideoElement | null>
141 setHlsLoading: (v: boolean) => void
142}) {
143 const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>(
144 () => promiseForHls.value,
145 )
146 useEffect(() => {
147 if (!Hls) {
148 setHlsLoading(true)
149 promiseForHls.then(loadedHls => {
150 setHls(() => loadedHls)
151 setHlsLoading(false)
152 })
153 }
154 }, [Hls, setHlsLoading])
155
156 const hlsRef = useRef<HlsTypes.default | undefined>(undefined)
157 const [lowQualityFragments, setLowQualityFragments] = useState<
158 HlsTypes.Fragment[]
159 >([])
160
161 // purge low quality segments from buffer on next frag change
162 const handleFragChange = useNonReactiveCallback(
163 (
164 _event: HlsTypes.Events.FRAG_CHANGED,
165 {frag}: HlsTypes.FragChangedData,
166 ) => {
167 if (!Hls) return
168 if (!hlsRef.current) return
169 const hls = hlsRef.current
170
171 // if the current quality level goes above 0, flush the low quality segments
172 if (hls.nextAutoLevel > 0) {
173 const flushed: HlsTypes.Fragment[] = []
174
175 for (const lowQualFrag of lowQualityFragments) {
176 // avoid if close to the current fragment
177 if (Math.abs(frag.start - lowQualFrag.start) < 0.1) {
178 continue
179 }
180
181 hls.trigger(Hls.Events.BUFFER_FLUSHING, {
182 startOffset: lowQualFrag.start,
183 endOffset: lowQualFrag.end,
184 type: 'video',
185 })
186
187 flushed.push(lowQualFrag)
188 }
189
190 setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f)))
191 }
192 },
193 )
194
195 const flushOnLoop = useNonReactiveCallback(() => {
196 if (!Hls) return
197 if (!hlsRef.current) return
198 const hls = hlsRef.current
199 // the above callback will catch most stale frags, but there's a corner case -
200 // if there's only one segment in the video, it won't get flushed because it avoids
201 // flushing the currently active segment. Therefore, we have to catch it when we loop
202 if (
203 hls.nextAutoLevel > 0 &&
204 lowQualityFragments.length === 1 &&
205 lowQualityFragments[0].start === 0
206 ) {
207 const lowQualFrag = lowQualityFragments[0]
208
209 hls.trigger(Hls.Events.BUFFER_FLUSHING, {
210 startOffset: lowQualFrag.start,
211 endOffset: lowQualFrag.end,
212 type: 'video',
213 })
214 setLowQualityFragments([])
215 }
216 })
217
218 useEffect(() => {
219 if (!videoRef.current) return
220 if (!Hls) return
221 if (!Hls.isSupported()) {
222 throw new HLSUnsupportedError()
223 }
224
225 const latestEstimate = BandwidthEstimate.get()
226 const hls = new Hls({
227 maxMaxBufferLength: 10, // only load 10s ahead
228 // note: the amount buffered is affected by both maxBufferLength and maxBufferSize
229 // it will buffer until it is greater than *both* of those values
230 // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead
231 startLevel:
232 latestEstimate === undefined ? -1 : Hls.DefaultConfig.startLevel,
233 // the '-1' value makes a test request to estimate bandwidth and quality level
234 // before showing the first fragment
235 })
236 hlsRef.current = hls
237
238 if (latestEstimate !== undefined) {
239 hls.bandwidthEstimate = latestEstimate
240 }
241
242 hls.attachMedia(videoRef.current)
243 hls.loadSource(playlist)
244
245 // manually loop, so if we've flushed the first buffer it doesn't get confused
246 const abortController = new AbortController()
247 const {signal} = abortController
248 const videoNode = videoRef.current
249 videoNode.addEventListener(
250 'ended',
251 () => {
252 flushOnLoop()
253 videoNode.currentTime = 0
254 videoNode.play()
255 },
256 {signal},
257 )
258
259 hls.on(Hls.Events.FRAG_LOADED, () => {
260 BandwidthEstimate.set(hls.bandwidthEstimate)
261 })
262
263 hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
264 if (data.subtitleTracks.length > 0) {
265 setHasSubtitleTrack(true)
266 }
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 abortController.abort()
297 }
298 }, [
299 playlist,
300 setError,
301 setHasSubtitleTrack,
302 videoRef,
303 handleFragChange,
304 flushOnLoop,
305 Hls,
306 ])
307
308 return hlsRef
309}