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/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} = 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 />
94 </div>
95 </View>
96 )
97}
98
99export class HLSUnsupportedError extends Error {
100 constructor() {
101 super('HLS is not supported')
102 }
103}
104
105export class VideoNotFoundError extends Error {
106 constructor() {
107 super('Video not found')
108 }
109}
110
111type CachedPromise<T> = Promise<T> & {value: undefined | T}
112const promiseForHls = import(
113 // @ts-ignore
114 'hls.js/dist/hls.min'
115).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default>
116promiseForHls.value = undefined
117promiseForHls.then(Hls => {
118 promiseForHls.value = Hls
119})
120
121function useHLS({
122 playlist,
123 setHasSubtitleTrack,
124 setError,
125 videoRef,
126 setHlsLoading,
127}: {
128 playlist: string
129 setHasSubtitleTrack: (v: boolean) => void
130 setError: (v: Error | null) => void
131 videoRef: React.RefObject<HTMLVideoElement | null>
132 setHlsLoading: (v: boolean) => void
133}) {
134 const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>(
135 () => promiseForHls.value,
136 )
137 useEffect(() => {
138 if (!Hls) {
139 setHlsLoading(true)
140 promiseForHls.then(loadedHls => {
141 setHls(() => loadedHls)
142 setHlsLoading(false)
143 })
144 }
145 }, [Hls, setHlsLoading])
146
147 const hlsRef = useRef<HlsTypes.default | undefined>(undefined)
148 const [lowQualityFragments, setLowQualityFragments] = useState<
149 HlsTypes.Fragment[]
150 >([])
151
152 // purge low quality segments from buffer on next frag change
153 const handleFragChange = useNonReactiveCallback(
154 (
155 _event: HlsTypes.Events.FRAG_CHANGED,
156 {frag}: HlsTypes.FragChangedData,
157 ) => {
158 if (!Hls) return
159 if (!hlsRef.current) return
160 const hls = hlsRef.current
161
162 // if the current quality level goes above 0, flush the low quality segments
163 if (hls.nextAutoLevel > 0) {
164 const flushed: HlsTypes.Fragment[] = []
165
166 for (const lowQualFrag of lowQualityFragments) {
167 // avoid if close to the current fragment
168 if (Math.abs(frag.start - lowQualFrag.start) < 0.1) {
169 continue
170 }
171
172 hls.trigger(Hls.Events.BUFFER_FLUSHING, {
173 startOffset: lowQualFrag.start,
174 endOffset: lowQualFrag.end,
175 type: 'video',
176 })
177
178 flushed.push(lowQualFrag)
179 }
180
181 setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f)))
182 }
183 },
184 )
185
186 useEffect(() => {
187 if (!videoRef.current) return
188 if (!Hls) return
189 if (!Hls.isSupported()) {
190 throw new HLSUnsupportedError()
191 }
192
193 const latestEstimate = BandwidthEstimate.get()
194 const hls = new Hls({
195 maxMaxBufferLength: 10, // only load 10s ahead
196 // note: the amount buffered is affected by both maxBufferLength and maxBufferSize
197 // it will buffer until it is greater than *both* of those values
198 // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead
199 startLevel:
200 latestEstimate === undefined ? -1 : Hls.DefaultConfig.startLevel,
201 // the '-1' value makes a test request to estimate bandwidth and quality level
202 // before showing the first fragment
203 })
204 hlsRef.current = hls
205
206 if (latestEstimate !== undefined) {
207 hls.bandwidthEstimate = latestEstimate
208 }
209
210 hls.attachMedia(videoRef.current)
211 hls.loadSource(playlist)
212
213 hls.on(Hls.Events.FRAG_LOADED, () => {
214 BandwidthEstimate.set(hls.bandwidthEstimate)
215 })
216
217 hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
218 if (data.subtitleTracks.length > 0) {
219 setHasSubtitleTrack(true)
220 }
221 })
222
223 hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => {
224 if (frag.level === 0) {
225 setLowQualityFragments(prev => [...prev, frag])
226 }
227 })
228
229 hls.on(Hls.Events.ERROR, (_event, data) => {
230 if (data.fatal) {
231 if (
232 data.details === 'manifestLoadError' &&
233 data.response?.code === 404
234 ) {
235 setError(new VideoNotFoundError())
236 } else {
237 setError(data.error)
238 }
239 } else {
240 console.error(data.error)
241 }
242 })
243
244 hls.on(Hls.Events.FRAG_CHANGED, handleFragChange)
245
246 return () => {
247 hlsRef.current = undefined
248 hls.detachMedia()
249 hls.destroy()
250 }
251 }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls])
252
253 const flushOnLoop = useNonReactiveCallback(() => {
254 if (!Hls) return
255 if (!hlsRef.current) return
256 const hls = hlsRef.current
257 // `handleFragChange` will catch most stale frags, but there's a corner case -
258 // if there's only one segment in the video, it won't get flushed because it avoids
259 // flushing the currently active segment. Therefore, we have to catch it when we loop
260 if (
261 hls.nextAutoLevel > 0 &&
262 lowQualityFragments.length === 1 &&
263 lowQualityFragments[0].start === 0
264 ) {
265 const lowQualFrag = lowQualityFragments[0]
266
267 hls.trigger(Hls.Events.BUFFER_FLUSHING, {
268 startOffset: lowQualFrag.start,
269 endOffset: lowQualFrag.end,
270 type: 'video',
271 })
272 setLowQualityFragments([])
273 }
274 })
275
276 // manually loop, so if we've flushed the first buffer it doesn't get confused
277 const hasLowQualityFragmentAtStart = lowQualityFragments.some(
278 frag => frag.start === 0,
279 )
280 useEffect(() => {
281 if (!videoRef.current) return
282
283 // use `loop` prop on `<video>` element if the starting frag is high quality.
284 // otherwise, we need to do it with an event listener as we may need to manually flush the frag
285 if (!hasLowQualityFragmentAtStart) return
286
287 const abortController = new AbortController()
288 const {signal} = abortController
289 const videoNode = videoRef.current
290 videoNode.addEventListener(
291 'ended',
292 () => {
293 flushOnLoop()
294 videoNode.currentTime = 0
295 const maybePromise = videoNode.play() as Promise<void> | undefined
296 if (maybePromise) {
297 maybePromise.catch(() => {})
298 }
299 },
300 {signal},
301 )
302 return () => {
303 abortController.abort()
304 }
305 }, [videoRef, flushOnLoop, hasLowQualityFragmentAtStart])
306
307 return {
308 hlsRef,
309 loop: !hasLowQualityFragmentAtStart,
310 }
311}