@banou/media-player 0.10.2 → 0.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engine/index.js +1 -1
- package/dist/{engine-DqyZgmTv.js → engine-CB5J5kah.js} +29 -29
- package/dist/index.js +348 -324
- package/dist/react/player.d.ts +2 -0
- package/dist/react/source-feature.d.ts +50 -0
- package/package.json +1 -1
- package/src/lib/engine/thumbnails.ts +13 -1
- package/src/lib/react/components/chrome.tsx +25 -3
- package/src/lib/react/components/control-bar.tsx +3 -1
- package/src/lib/react/components/overlay.tsx +5 -2
- package/src/lib/react/components/progress-bar.tsx +16 -4
- package/src/lib/react/hooks/use-playback.ts +27 -0
- package/src/lib/react/source-feature.ts +28 -0
- package/src/lib/react/video-player.tsx +9 -2
package/dist/react/player.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
|
|
|
16
16
|
selectAudioTrack: (id: string | number) => void | Promise<void>;
|
|
17
17
|
hideUI: boolean;
|
|
18
18
|
setHideUI: (hide: boolean) => void;
|
|
19
|
+
hasMedia: boolean;
|
|
19
20
|
togglePictureInPicture: (() => void) | null;
|
|
20
21
|
pictureInPictureMode: import("../engine").PictureInPictureMode | null;
|
|
21
22
|
burnedInSubtitles: boolean;
|
|
@@ -23,6 +24,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
|
|
|
23
24
|
playbackError: unknown;
|
|
24
25
|
playbackErrors: import("./source-feature").PlaybackErrorEntry[];
|
|
25
26
|
ready: boolean;
|
|
27
|
+
seekingTo?: number;
|
|
26
28
|
setSourceState: (partial: Partial<import("./source-feature").SourceState>) => void;
|
|
27
29
|
}>]>>;
|
|
28
30
|
/**
|
|
@@ -116,6 +116,19 @@ export type SourceState = {
|
|
|
116
116
|
/** Chrome auto-hide. True means the controls, title and cursor are hidden. */
|
|
117
117
|
hideUI: boolean;
|
|
118
118
|
setHideUI: (hide: boolean) => void;
|
|
119
|
+
/**
|
|
120
|
+
* Whether there is anything loaded to play at all.
|
|
121
|
+
*
|
|
122
|
+
* Not the same question as `ready`, which is about a source that HAS been handed over and is still
|
|
123
|
+
* booting. This one separates that from having been given nothing, and the chrome needs the
|
|
124
|
+
* difference: idle-hiding with no media leaves a black rectangle with no controls, no title and no
|
|
125
|
+
* cursor, and no way back, since the only thing that reveals the chrome again is a pointer move over
|
|
126
|
+
* a player the viewer has no reason to think is there.
|
|
127
|
+
*
|
|
128
|
+
* Written by the React layer, which is the only place both arms are visible: bytes for a local
|
|
129
|
+
* source, an element for a media this player does not own.
|
|
130
|
+
*/
|
|
131
|
+
hasMedia: boolean;
|
|
119
132
|
/**
|
|
120
133
|
* Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
|
|
121
134
|
*
|
|
@@ -159,6 +172,18 @@ export type SourceState = {
|
|
|
159
172
|
playbackErrors: PlaybackErrorEntry[];
|
|
160
173
|
/** Whether the engine has produced its first media segment. */
|
|
161
174
|
ready: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* Where a seek is headed while the element has not arrived there yet, in seconds.
|
|
177
|
+
*
|
|
178
|
+
* The element's own `currentTime` does not move until it can present the frame, which on a long
|
|
179
|
+
* GOP is a few hundred milliseconds after the click. Reading it directly leaves the seekbar and
|
|
180
|
+
* the clock sitting at the old position for that whole time, which reads as the player ignoring
|
|
181
|
+
* the click. The chrome shows THIS instead while it is set, so the bar and the clock answer at
|
|
182
|
+
* once and the spinner says the picture is still coming.
|
|
183
|
+
*
|
|
184
|
+
* Undefined whenever no seek is outstanding, which is almost always.
|
|
185
|
+
*/
|
|
186
|
+
seekingTo?: number;
|
|
162
187
|
/**
|
|
163
188
|
* The write seam, wired in `attach`. Only the React layer calls it.
|
|
164
189
|
*
|
|
@@ -222,6 +247,19 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
|
|
|
222
247
|
/** Chrome auto-hide. True means the controls, title and cursor are hidden. */
|
|
223
248
|
hideUI: boolean;
|
|
224
249
|
setHideUI: (hide: boolean) => void;
|
|
250
|
+
/**
|
|
251
|
+
* Whether there is anything loaded to play at all.
|
|
252
|
+
*
|
|
253
|
+
* Not the same question as `ready`, which is about a source that HAS been handed over and is still
|
|
254
|
+
* booting. This one separates that from having been given nothing, and the chrome needs the
|
|
255
|
+
* difference: idle-hiding with no media leaves a black rectangle with no controls, no title and no
|
|
256
|
+
* cursor, and no way back, since the only thing that reveals the chrome again is a pointer move over
|
|
257
|
+
* a player the viewer has no reason to think is there.
|
|
258
|
+
*
|
|
259
|
+
* Written by the React layer, which is the only place both arms are visible: bytes for a local
|
|
260
|
+
* source, an element for a media this player does not own.
|
|
261
|
+
*/
|
|
262
|
+
hasMedia: boolean;
|
|
225
263
|
/**
|
|
226
264
|
* Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
|
|
227
265
|
*
|
|
@@ -265,6 +303,18 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
|
|
|
265
303
|
playbackErrors: PlaybackErrorEntry[];
|
|
266
304
|
/** Whether the engine has produced its first media segment. */
|
|
267
305
|
ready: boolean;
|
|
306
|
+
/**
|
|
307
|
+
* Where a seek is headed while the element has not arrived there yet, in seconds.
|
|
308
|
+
*
|
|
309
|
+
* The element's own `currentTime` does not move until it can present the frame, which on a long
|
|
310
|
+
* GOP is a few hundred milliseconds after the click. Reading it directly leaves the seekbar and
|
|
311
|
+
* the clock sitting at the old position for that whole time, which reads as the player ignoring
|
|
312
|
+
* the click. The chrome shows THIS instead while it is set, so the bar and the clock answer at
|
|
313
|
+
* once and the spinner says the picture is still coming.
|
|
314
|
+
*
|
|
315
|
+
* Undefined whenever no seek is outstanding, which is almost always.
|
|
316
|
+
*/
|
|
317
|
+
seekingTo?: number;
|
|
268
318
|
/**
|
|
269
319
|
* The write seam, wired in `attach`. Only the React layer calls it.
|
|
270
320
|
*
|
package/package.json
CHANGED
|
@@ -36,6 +36,15 @@ const KEYFRAME_TIMEOUT = 10_000
|
|
|
36
36
|
const REINDEX_GROWTH = 1.5
|
|
37
37
|
/** And never re-walk for a trickle, however early. */
|
|
38
38
|
const REINDEX_MIN_BYTES = 4_000_000
|
|
39
|
+
/**
|
|
40
|
+
* A bound on the re-walk, because this one holds the worker.
|
|
41
|
+
*
|
|
42
|
+
* The walk at boot can hang without costing anything that was working: there are no previews yet. A
|
|
43
|
+
* re-walk is different, since the pump waits for it, so a reader that stops answering would take
|
|
44
|
+
* the previews the current index CAN still produce down with it. Longer than a keyframe decode
|
|
45
|
+
* because a walk reads far more of the file.
|
|
46
|
+
*/
|
|
47
|
+
const REINDEX_TIMEOUT = 30_000
|
|
39
48
|
|
|
40
49
|
export type ThumbnailGenerator = {
|
|
41
50
|
/** Report which byte ranges are readable. Called with no argument when the whole file is. */
|
|
@@ -236,7 +245,10 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
|
|
|
236
245
|
const reindex = async (readable: number) => {
|
|
237
246
|
reindexing = true
|
|
238
247
|
try {
|
|
239
|
-
const next = await
|
|
248
|
+
const next = await Promise.race([
|
|
249
|
+
remuxer.init(),
|
|
250
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), REINDEX_TIMEOUT)),
|
|
251
|
+
])
|
|
240
252
|
if (destroyed) return
|
|
241
253
|
indexedBytes = readable
|
|
242
254
|
const rebuilt = buildSlots(next.indexes)
|
|
@@ -119,6 +119,7 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
|
|
|
119
119
|
const player = usePlayer()
|
|
120
120
|
const hideUI = usePlayer((state) => state.hideUI)
|
|
121
121
|
const setHideUI = usePlayer((state) => state.setHideUI)
|
|
122
|
+
const hasMedia = usePlayer((state) => state.hasMedia)
|
|
122
123
|
const autoHide = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
123
124
|
// a tap and a click mean different things, so the last pointer kind is remembered
|
|
124
125
|
const lastPointerType = useRef<string>('mouse')
|
|
@@ -128,6 +129,27 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
|
|
|
128
129
|
|
|
129
130
|
useEffect(() => () => clearTimeout(autoHide.current), [])
|
|
130
131
|
|
|
132
|
+
/*
|
|
133
|
+
* With nothing loaded the chrome never hides, and anything already hidden comes back.
|
|
134
|
+
*
|
|
135
|
+
* Hiding is only ever reversed by a pointer move over the player, which is a fair bet while a video
|
|
136
|
+
* is playing and a bad one when the box is empty: three seconds after mount the controls, the title
|
|
137
|
+
* and the cursor all go, leaving a black rectangle that gives the viewer no reason to think there is
|
|
138
|
+
* a player there to wave at. The effect covers the media going AWAY, which the guard below cannot:
|
|
139
|
+
* its timer may already be in flight, holding a `hasMedia` that was true when it was scheduled.
|
|
140
|
+
*/
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (hasMedia) return
|
|
143
|
+
clearTimeout(autoHide.current)
|
|
144
|
+
setHideUI(false)
|
|
145
|
+
}, [hasMedia, setHideUI])
|
|
146
|
+
|
|
147
|
+
/** The single door to hiding, so no path can bypass the empty-player case. */
|
|
148
|
+
const hide = () => {
|
|
149
|
+
if (!hasMedia) return
|
|
150
|
+
setHideUI(true)
|
|
151
|
+
}
|
|
152
|
+
|
|
131
153
|
// The caller's ref still gets the element: video.js attaches the fullscreen container through it,
|
|
132
154
|
// and this needs the same node to hit test against.
|
|
133
155
|
const setRoot = (element: HTMLDivElement | null) => {
|
|
@@ -165,7 +187,7 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
|
|
|
165
187
|
autoHide.current = setTimeout(hideUnlessTheMouseIsOnAControl, AUTO_HIDE_DELAY)
|
|
166
188
|
return
|
|
167
189
|
}
|
|
168
|
-
|
|
190
|
+
hide()
|
|
169
191
|
}
|
|
170
192
|
|
|
171
193
|
const reveal = () => {
|
|
@@ -196,7 +218,7 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
|
|
|
196
218
|
if (hideUI) reveal()
|
|
197
219
|
else {
|
|
198
220
|
clearTimeout(autoHide.current)
|
|
199
|
-
|
|
221
|
+
hide()
|
|
200
222
|
}
|
|
201
223
|
}
|
|
202
224
|
|
|
@@ -209,7 +231,7 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
|
|
|
209
231
|
const inside = ev.clientX >= left && ev.clientX < right && ev.clientY >= top && ev.clientY < bottom
|
|
210
232
|
if (inside) return
|
|
211
233
|
clearTimeout(autoHide.current)
|
|
212
|
-
|
|
234
|
+
hide()
|
|
213
235
|
}
|
|
214
236
|
|
|
215
237
|
return (
|
|
@@ -172,6 +172,8 @@ export const ControlBar = () => {
|
|
|
172
172
|
const paused = usePlayer((state) => state.paused)
|
|
173
173
|
const currentTime = usePlayer((state) => state.currentTime)
|
|
174
174
|
const duration = usePlayer((state) => state.duration)
|
|
175
|
+
// a seek in flight reads as its destination, so the clock answers the click at once
|
|
176
|
+
const seekingTo = usePlayer((state) => state.seekingTo)
|
|
175
177
|
const fullscreen = usePlayer((state) => state.fullscreen)
|
|
176
178
|
const hideUI = usePlayer((state) => state.hideUI)
|
|
177
179
|
const togglePictureInPicture = usePlayer((state) => state.togglePictureInPicture)
|
|
@@ -296,7 +298,7 @@ export const ControlBar = () => {
|
|
|
296
298
|
/>
|
|
297
299
|
<Sound ref={setVolumeElement}/>
|
|
298
300
|
<div className='time'>
|
|
299
|
-
{formatMediaTime(currentTime, duration)}
|
|
301
|
+
{formatMediaTime(seekingTo ?? currentTime, duration)}
|
|
300
302
|
</div>
|
|
301
303
|
</div>
|
|
302
304
|
<div className='right'>
|
|
@@ -115,6 +115,9 @@ export const Overlay = ({ onSubtitleRef }: { onSubtitleRef: (element: HTMLDivEle
|
|
|
115
115
|
const size = usePlayer((state) => state.size)
|
|
116
116
|
// video.js's own: readyState below HAVE_FUTURE_DATA while not paused
|
|
117
117
|
const waiting = usePlayer((state) => state.waiting)
|
|
118
|
+
// a seek that has not presented its frame yet is a wait like any other, and the one most likely to
|
|
119
|
+
// be mistaken for the player having ignored the click
|
|
120
|
+
const seekingTo = usePlayer((state) => state.seekingTo)
|
|
118
121
|
|
|
119
122
|
return (
|
|
120
123
|
<>
|
|
@@ -129,11 +132,11 @@ export const Overlay = ({ onSubtitleRef }: { onSubtitleRef: (element: HTMLDivEle
|
|
|
129
132
|
</div>
|
|
130
133
|
)
|
|
131
134
|
: undefined}
|
|
132
|
-
{/*
|
|
135
|
+
{/* Three waits, one spinner. With bytes it is pre-metadata rather than buffering: the
|
|
133
136
|
store reports 0 both before metadata and for a genuinely unknown duration, so `size` is what
|
|
134
137
|
tells whether a source was handed over at all. A media this player does not own has neither
|
|
135
138
|
`size` nor `ready`, and reports the ordinary `waiting` every element does. */}
|
|
136
|
-
{(size ? !ready : waiting) && !playbackError
|
|
139
|
+
{((size ? !ready : waiting) || seekingTo !== undefined) && !playbackError
|
|
137
140
|
? <div css={loadingStyle} />
|
|
138
141
|
: undefined}
|
|
139
142
|
{playbackError
|
|
@@ -246,6 +246,7 @@ export const ProgressBar = () => {
|
|
|
246
246
|
const thumbnailAt = usePlayer((state) => state.thumbnailAt)
|
|
247
247
|
const requestThumbnail = usePlayer((state) => state.requestThumbnail)
|
|
248
248
|
const chapters = usePlayer((state) => state.chapters)
|
|
249
|
+
const seekingTo = usePlayer((state) => state.seekingTo)
|
|
249
250
|
|
|
250
251
|
const progressBarRef = useRef<HTMLDivElement>(null)
|
|
251
252
|
|
|
@@ -393,11 +394,22 @@ export const ProgressBar = () => {
|
|
|
393
394
|
player.seek(seekFraction * duration)
|
|
394
395
|
}, [player, dragging, seekFraction, duration])
|
|
395
396
|
|
|
397
|
+
/*
|
|
398
|
+
* Where the bar is drawn, which is not always where the element is.
|
|
399
|
+
*
|
|
400
|
+
* Three sources, in the order they beat each other. A drag is the pointer's own position, so the
|
|
401
|
+
* bar tracks the finger exactly rather than trailing the element through a seek per move. A
|
|
402
|
+
* settled seek is its target, held until the element gets there. Everything else is the element.
|
|
403
|
+
*
|
|
404
|
+
* The point of the first two is that a seek takes a few hundred milliseconds to present a frame,
|
|
405
|
+
* and a bar that waits for it looks like it ignored the click.
|
|
406
|
+
*/
|
|
396
407
|
const scaleX = useMemo(() => {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
408
|
+
if (!duration) return 0
|
|
409
|
+
if (dragging && seekFraction !== undefined) return seekFraction
|
|
410
|
+
const at = seekingTo ?? currentTime
|
|
411
|
+
return typeof at === 'number' ? at / duration : 0
|
|
412
|
+
}, [duration, currentTime, dragging, seekFraction, seekingTo])
|
|
401
413
|
|
|
402
414
|
const bounds = useMemo(() => segmentBounds(chapters, duration), [chapters, duration])
|
|
403
415
|
const segmented = bounds.length > 0
|
|
@@ -32,6 +32,13 @@ const RESTART_SETTLED_MS = 60_000
|
|
|
32
32
|
* this is the ceiling. Half a second is the point where a seek stops feeling like a seek.
|
|
33
33
|
*/
|
|
34
34
|
const SEEK_PREPARE_BUDGET_MS = 500
|
|
35
|
+
/**
|
|
36
|
+
* How long the chrome may show a seek that has not landed, in ms.
|
|
37
|
+
*
|
|
38
|
+
* Only a backstop. The element firing `seeked` is what normally ends it, and a seek that never
|
|
39
|
+
* completes at all would otherwise leave the clock reading a time the picture never reached.
|
|
40
|
+
*/
|
|
41
|
+
const SEEK_DISPLAY_LIMIT_MS = 15_000
|
|
35
42
|
|
|
36
43
|
const messageOf = (error: unknown) =>
|
|
37
44
|
error instanceof Error ? error.message : String(error)
|
|
@@ -212,6 +219,26 @@ export const usePlayback = (
|
|
|
212
219
|
console.warn(`[media-player] seek to ${time.toFixed(2)} moved after ${Math.round(performance.now() - startedAt)}ms via ${movedBecause}, runway ${runway.toFixed(1)}s${movedBecause === 'deadline' ? ' (EXPOSED: moved before its data was ready)' : ''}`)
|
|
213
220
|
}
|
|
214
221
|
}
|
|
222
|
+
/*
|
|
223
|
+
* Say where the seek is going before anything has gone there.
|
|
224
|
+
*
|
|
225
|
+
* Everything below this line takes time the viewer is watching: the prepare, the element's own
|
|
226
|
+
* seek, and the decode from the preceding keyframe. Until all of it lands the element still
|
|
227
|
+
* reports the OLD position, so a chrome reading it directly sits still and looks broken. This is
|
|
228
|
+
* cleared by the element arriving, not by the prepare finishing, because the frame is what the
|
|
229
|
+
* viewer is actually waiting for.
|
|
230
|
+
*/
|
|
231
|
+
player.setSourceState({ seekingTo: time })
|
|
232
|
+
const video = controller.videoElement
|
|
233
|
+
const settled = () => {
|
|
234
|
+
video?.removeEventListener('seeked', settled)
|
|
235
|
+
clearTimeout(giveUp)
|
|
236
|
+
player.setSourceState({ seekingTo: undefined })
|
|
237
|
+
}
|
|
238
|
+
// a seek the element never completes must not leave the clock stuck on a time it never reached
|
|
239
|
+
const giveUp = setTimeout(settled, SEEK_DISPLAY_LIMIT_MS)
|
|
240
|
+
video?.addEventListener('seeked', settled)
|
|
241
|
+
|
|
215
242
|
const deadline = setTimeout(() => { movedBecause = 'deadline'; move() }, seekPrepareBudgetMs)
|
|
216
243
|
void controller
|
|
217
244
|
.prepareSeek(time)
|
|
@@ -127,6 +127,20 @@ export type SourceState = {
|
|
|
127
127
|
hideUI: boolean
|
|
128
128
|
setHideUI: (hide: boolean) => void
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Whether there is anything loaded to play at all.
|
|
132
|
+
*
|
|
133
|
+
* Not the same question as `ready`, which is about a source that HAS been handed over and is still
|
|
134
|
+
* booting. This one separates that from having been given nothing, and the chrome needs the
|
|
135
|
+
* difference: idle-hiding with no media leaves a black rectangle with no controls, no title and no
|
|
136
|
+
* cursor, and no way back, since the only thing that reveals the chrome again is a pointer move over
|
|
137
|
+
* a player the viewer has no reason to think is there.
|
|
138
|
+
*
|
|
139
|
+
* Written by the React layer, which is the only place both arms are visible: bytes for a local
|
|
140
|
+
* source, an element for a media this player does not own.
|
|
141
|
+
*/
|
|
142
|
+
hasMedia: boolean
|
|
143
|
+
|
|
130
144
|
/**
|
|
131
145
|
* Owned here, not by `pip`: that watches the media element, and the window holds a mirror.
|
|
132
146
|
*
|
|
@@ -174,6 +188,19 @@ export type SourceState = {
|
|
|
174
188
|
/** Whether the engine has produced its first media segment. */
|
|
175
189
|
ready: boolean
|
|
176
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Where a seek is headed while the element has not arrived there yet, in seconds.
|
|
193
|
+
*
|
|
194
|
+
* The element's own `currentTime` does not move until it can present the frame, which on a long
|
|
195
|
+
* GOP is a few hundred milliseconds after the click. Reading it directly leaves the seekbar and
|
|
196
|
+
* the clock sitting at the old position for that whole time, which reads as the player ignoring
|
|
197
|
+
* the click. The chrome shows THIS instead while it is set, so the bar and the clock answer at
|
|
198
|
+
* once and the spinner says the picture is still coming.
|
|
199
|
+
*
|
|
200
|
+
* Undefined whenever no seek is outstanding, which is almost always.
|
|
201
|
+
*/
|
|
202
|
+
seekingTo?: number
|
|
203
|
+
|
|
177
204
|
/**
|
|
178
205
|
* The write seam, wired in `attach`. Only the React layer calls it.
|
|
179
206
|
*
|
|
@@ -197,6 +224,7 @@ const initialState: SourceState = {
|
|
|
197
224
|
selectAudioTrack: () => {},
|
|
198
225
|
hideUI: false,
|
|
199
226
|
setHideUI: () => {},
|
|
227
|
+
hasMedia: false,
|
|
200
228
|
togglePictureInPicture: null,
|
|
201
229
|
pictureInPictureMode: null,
|
|
202
230
|
burnedInSubtitles: false,
|
|
@@ -269,9 +269,16 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
|
|
|
269
269
|
// attaches: when attach swaps in the real setter the identity changes and these publish again.
|
|
270
270
|
const setSourceState = usePlayer((state) => state.setSourceState)
|
|
271
271
|
|
|
272
|
+
/*
|
|
273
|
+
* Read here because this is the only place both arms are in scope: a local source is bytes, and a
|
|
274
|
+
* remote one is an element somebody else owns. `size` alone cannot answer it, since the remote arm
|
|
275
|
+
* legitimately has none, and the chrome would then treat every remote player as having no media.
|
|
276
|
+
*/
|
|
277
|
+
const hasMedia = remote ? !!remote.media : size !== undefined
|
|
278
|
+
|
|
272
279
|
useEffect(() => {
|
|
273
|
-
setSourceState({ title, size, downloadedRanges })
|
|
274
|
-
}, [setSourceState, title, size, downloadedRanges])
|
|
280
|
+
setSourceState({ title, size, downloadedRanges, hasMedia })
|
|
281
|
+
}, [setSourceState, title, size, downloadedRanges, hasMedia])
|
|
275
282
|
|
|
276
283
|
const thumbnailAt = remote?.thumbnails?.at
|
|
277
284
|
useEffect(() => {
|