@banou/media-player 0.10.1 → 0.10.3
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-b5_5HscR.js → engine-CB5J5kah.js} +180 -142
- package/dist/index.js +282 -265
- package/dist/react/player.d.ts +1 -0
- package/dist/react/source-feature.d.ts +24 -0
- package/package.json +1 -1
- package/src/lib/engine/thumbnails.ts +147 -19
- 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 +13 -0
package/dist/react/player.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
|
|
|
23
23
|
playbackError: unknown;
|
|
24
24
|
playbackErrors: import("./source-feature").PlaybackErrorEntry[];
|
|
25
25
|
ready: boolean;
|
|
26
|
+
seekingTo?: number;
|
|
26
27
|
setSourceState: (partial: Partial<import("./source-feature").SourceState>) => void;
|
|
27
28
|
}>]>>;
|
|
28
29
|
/**
|
|
@@ -159,6 +159,18 @@ export type SourceState = {
|
|
|
159
159
|
playbackErrors: PlaybackErrorEntry[];
|
|
160
160
|
/** Whether the engine has produced its first media segment. */
|
|
161
161
|
ready: boolean;
|
|
162
|
+
/**
|
|
163
|
+
* Where a seek is headed while the element has not arrived there yet, in seconds.
|
|
164
|
+
*
|
|
165
|
+
* The element's own `currentTime` does not move until it can present the frame, which on a long
|
|
166
|
+
* GOP is a few hundred milliseconds after the click. Reading it directly leaves the seekbar and
|
|
167
|
+
* the clock sitting at the old position for that whole time, which reads as the player ignoring
|
|
168
|
+
* the click. The chrome shows THIS instead while it is set, so the bar and the clock answer at
|
|
169
|
+
* once and the spinner says the picture is still coming.
|
|
170
|
+
*
|
|
171
|
+
* Undefined whenever no seek is outstanding, which is almost always.
|
|
172
|
+
*/
|
|
173
|
+
seekingTo?: number;
|
|
162
174
|
/**
|
|
163
175
|
* The write seam, wired in `attach`. Only the React layer calls it.
|
|
164
176
|
*
|
|
@@ -265,6 +277,18 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
|
|
|
265
277
|
playbackErrors: PlaybackErrorEntry[];
|
|
266
278
|
/** Whether the engine has produced its first media segment. */
|
|
267
279
|
ready: boolean;
|
|
280
|
+
/**
|
|
281
|
+
* Where a seek is headed while the element has not arrived there yet, in seconds.
|
|
282
|
+
*
|
|
283
|
+
* The element's own `currentTime` does not move until it can present the frame, which on a long
|
|
284
|
+
* GOP is a few hundred milliseconds after the click. Reading it directly leaves the seekbar and
|
|
285
|
+
* the clock sitting at the old position for that whole time, which reads as the player ignoring
|
|
286
|
+
* the click. The chrome shows THIS instead while it is set, so the bar and the clock answer at
|
|
287
|
+
* once and the spinner says the picture is still coming.
|
|
288
|
+
*
|
|
289
|
+
* Undefined whenever no seek is outstanding, which is almost always.
|
|
290
|
+
*/
|
|
291
|
+
seekingTo?: number;
|
|
268
292
|
/**
|
|
269
293
|
* The write seam, wired in `attach`. Only the React layer calls it.
|
|
270
294
|
*
|
package/package.json
CHANGED
|
@@ -25,6 +25,26 @@ const READAHEAD = 1_000_000
|
|
|
25
25
|
const MAX_ATTEMPTS = 3
|
|
26
26
|
// a keyframe decode can hang without ever settling
|
|
27
27
|
const KEYFRAME_TIMEOUT = 10_000
|
|
28
|
+
/**
|
|
29
|
+
* How much more of the file has to become readable before the index is walked again.
|
|
30
|
+
*
|
|
31
|
+
* An index is only as complete as the bytes behind it, and over a torrent those arrive for minutes
|
|
32
|
+
* after the player starts. Re-walking on every range change would demux the file over and over; a
|
|
33
|
+
* multiplier makes the number of walks logarithmic in the file size instead, so a download that
|
|
34
|
+
* starts at 2% re-indexes around eight times on its way to whole rather than hundreds.
|
|
35
|
+
*/
|
|
36
|
+
const REINDEX_GROWTH = 1.5
|
|
37
|
+
/** And never re-walk for a trickle, however early. */
|
|
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
|
|
28
48
|
|
|
29
49
|
export type ThumbnailGenerator = {
|
|
30
50
|
/** Report which byte ranges are readable. Called with no argument when the whole file is. */
|
|
@@ -60,22 +80,37 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
|
|
|
60
80
|
const interval = Math.max(options.interval ?? INTERVAL, duration / MAX_THUMBNAILS)
|
|
61
81
|
|
|
62
82
|
type Slot = { timestamp: number, endTime: number, startByte: number, endByte: number, done: boolean, attempts: number }
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
let slots: Slot[] = []
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The slot list for one index, which is only ever as complete as the bytes it was built from.
|
|
87
|
+
*
|
|
88
|
+
* libav walks the clusters it can read and stops, WITHOUT failing: on a file whose first tenth is
|
|
89
|
+
* readable it reports a single keyframe and a correct duration, because the duration comes from
|
|
90
|
+
* the header. That single entry then becomes a single slot whose endTime falls through to the
|
|
91
|
+
* duration, which is one preview covering the whole seekbar.
|
|
92
|
+
*/
|
|
93
|
+
const buildSlots = (indexes: typeof metadata.indexes): Slot[] => {
|
|
94
|
+
const built: Slot[] = []
|
|
95
|
+
for (const [i, index] of indexes.entries()) {
|
|
96
|
+
const last = built.at(-1)
|
|
97
|
+
if (last && index.timestamp - last.timestamp < interval) continue
|
|
98
|
+
built.push({
|
|
99
|
+
timestamp: index.timestamp,
|
|
100
|
+
endTime: duration,
|
|
101
|
+
startByte: index.pos,
|
|
102
|
+
endByte: Math.min((indexes[i + 1]?.pos ?? length) + READAHEAD, length),
|
|
103
|
+
done: false,
|
|
104
|
+
attempts: 0,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
for (const [i, slot] of built.entries()) slot.endTime = built[i + 1]?.timestamp ?? duration
|
|
108
|
+
// reading the last keyframe runs the demuxer into EOF, which crashes the libav build
|
|
109
|
+
if (built.length > 1 && (built.at(-1)!.timestamp > duration - interval * 2)) built.pop()
|
|
110
|
+
return built
|
|
75
111
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (slots.length > 1 && (slots.at(-1)!.timestamp > duration - interval * 2)) slots.pop()
|
|
112
|
+
|
|
113
|
+
slots = buildSlots(metadata.indexes)
|
|
79
114
|
|
|
80
115
|
let thumbnails: ThumbnailImage[] = []
|
|
81
116
|
let destroyed = false
|
|
@@ -93,6 +128,19 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
|
|
|
93
128
|
/** Where the pointer is on the seekbar, or undefined when it is off it. */
|
|
94
129
|
let priorityTime: number | undefined
|
|
95
130
|
|
|
131
|
+
/**
|
|
132
|
+
* How much of the file was readable when the current index was built.
|
|
133
|
+
*
|
|
134
|
+
* Negative until the first update, which establishes it: the boot walk has just happened against
|
|
135
|
+
* whatever was readable then, so the first report of the ranges is the baseline rather than a
|
|
136
|
+
* reason to walk again.
|
|
137
|
+
*/
|
|
138
|
+
let indexedBytes = -1
|
|
139
|
+
let reindexWanted = false
|
|
140
|
+
let reindexing = false
|
|
141
|
+
/** The ranges last reported, so a walk deferred behind a decode can still claim against them. */
|
|
142
|
+
let lastRanges: [number, number][] | undefined
|
|
143
|
+
|
|
96
144
|
/*
|
|
97
145
|
* The slot to decode next: the one under the pointer when it is still waiting, else the oldest claim.
|
|
98
146
|
*
|
|
@@ -146,7 +194,25 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
|
|
|
146
194
|
|
|
147
195
|
// one decode at a time, because there is one wasm worker behind them all
|
|
148
196
|
const pump = () => {
|
|
149
|
-
if (running || destroyed ||
|
|
197
|
+
if (running || destroyed || reindexing) return
|
|
198
|
+
/*
|
|
199
|
+
* A deferred index walk goes first.
|
|
200
|
+
*
|
|
201
|
+
* There is one worker behind both, and a walk started while a decode holds it would have them
|
|
202
|
+
* interleaved on the same demuxer. Taking it here means the walk happens at the one moment
|
|
203
|
+
* nothing else is using it, and it happens BEFORE the next decode so that decode comes from
|
|
204
|
+
* the new slot list rather than the stale one.
|
|
205
|
+
*/
|
|
206
|
+
if (reindexWanted) {
|
|
207
|
+
reindexWanted = false
|
|
208
|
+
const ranges = lastRanges
|
|
209
|
+
void reindex(readableTo(ranges)).then(() => {
|
|
210
|
+
claimReadable(ranges)
|
|
211
|
+
pump()
|
|
212
|
+
})
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
if (!pending.length) return
|
|
150
216
|
const slot = pending.splice(nextIndex(), 1)[0]!
|
|
151
217
|
running = true
|
|
152
218
|
void decode(slot)
|
|
@@ -167,15 +233,77 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
|
|
|
167
233
|
pump()
|
|
168
234
|
}
|
|
169
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Walk the index again now that more of the file can be read, and keep what is already drawn.
|
|
238
|
+
*
|
|
239
|
+
* A preview survives if its slot survives, which is why the previous timestamps are matched
|
|
240
|
+
* rather than the list being thrown away: the first slot is almost always still the first slot,
|
|
241
|
+
* and re-decoding it would throw away work and flicker the seekbar for no reason. What DOES
|
|
242
|
+
* change is its endTime, since a slot that used to run to the end of the file now runs only as
|
|
243
|
+
* far as the neighbour the new index revealed.
|
|
244
|
+
*/
|
|
245
|
+
const reindex = async (readable: number) => {
|
|
246
|
+
reindexing = true
|
|
247
|
+
try {
|
|
248
|
+
const next = await Promise.race([
|
|
249
|
+
remuxer.init(),
|
|
250
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), REINDEX_TIMEOUT)),
|
|
251
|
+
])
|
|
252
|
+
if (destroyed) return
|
|
253
|
+
indexedBytes = readable
|
|
254
|
+
const rebuilt = buildSlots(next.indexes)
|
|
255
|
+
if (!rebuilt.length) return
|
|
256
|
+
|
|
257
|
+
const drawn = new Map(thumbnails.map((t) => [t.startTime, t]))
|
|
258
|
+
for (const slot of rebuilt) {
|
|
259
|
+
if (drawn.has(slot.timestamp)) slot.done = true
|
|
260
|
+
}
|
|
261
|
+
slots = rebuilt
|
|
262
|
+
// a preview whose slot is gone is no longer addressable, and its blob would leak
|
|
263
|
+
const kept = new Set(rebuilt.map((slot) => slot.timestamp))
|
|
264
|
+
for (const t of thumbnails) if (!kept.has(t.startTime)) URL.revokeObjectURL(t.url)
|
|
265
|
+
thumbnails = rebuilt
|
|
266
|
+
.filter((slot) => drawn.has(slot.timestamp))
|
|
267
|
+
.map((slot) => ({ url: drawn.get(slot.timestamp)!.url, startTime: slot.timestamp, endTime: slot.endTime }))
|
|
268
|
+
// anything claimed against the old list is stale, and the update below re-claims from the new one
|
|
269
|
+
pending.length = 0
|
|
270
|
+
emit()
|
|
271
|
+
} catch {
|
|
272
|
+
// an index that could not be re-read leaves the old one in place, and the next update retries
|
|
273
|
+
} finally {
|
|
274
|
+
reindexing = false
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The end of what can be read, which is what an index walk can reach. */
|
|
279
|
+
const readableTo = (ranges?: [number, number][]) =>
|
|
280
|
+
ranges ? ranges.reduce((most, [, to]) => Math.max(most, to), 0) : length
|
|
281
|
+
|
|
282
|
+
const claimReadable = (ranges?: [number, number][]) => {
|
|
283
|
+
for (const slot of slots) {
|
|
284
|
+
if (slot.done) continue
|
|
285
|
+
if (!ranges || ranges.some(([from, to]) => from <= slot.startByte && slot.endByte <= to)) claim(slot)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
170
289
|
emit()
|
|
171
290
|
|
|
172
291
|
return {
|
|
173
292
|
update: (ranges) => {
|
|
174
293
|
if (destroyed) return
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
294
|
+
lastRanges = ranges
|
|
295
|
+
const readable = readableTo(ranges)
|
|
296
|
+
if (indexedBytes < 0) indexedBytes = readable
|
|
297
|
+
else {
|
|
298
|
+
const grown = readable >= Math.max(indexedBytes * REINDEX_GROWTH, indexedBytes + REINDEX_MIN_BYTES)
|
|
299
|
+
// and one last walk when the file becomes whole, so a finished download is fully indexed
|
|
300
|
+
const whole = readable >= length && indexedBytes < length
|
|
301
|
+
if (grown || whole) reindexWanted = true
|
|
178
302
|
}
|
|
303
|
+
claimReadable(ranges)
|
|
304
|
+
// claims first, so a walk that has to wait for a decode does not hold up the previews that
|
|
305
|
+
// the current index can already produce
|
|
306
|
+
pump()
|
|
179
307
|
},
|
|
180
308
|
prioritize: (time) => {
|
|
181
309
|
if (destroyed) 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)
|
|
@@ -174,6 +174,19 @@ export type SourceState = {
|
|
|
174
174
|
/** Whether the engine has produced its first media segment. */
|
|
175
175
|
ready: boolean
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Where a seek is headed while the element has not arrived there yet, in seconds.
|
|
179
|
+
*
|
|
180
|
+
* The element's own `currentTime` does not move until it can present the frame, which on a long
|
|
181
|
+
* GOP is a few hundred milliseconds after the click. Reading it directly leaves the seekbar and
|
|
182
|
+
* the clock sitting at the old position for that whole time, which reads as the player ignoring
|
|
183
|
+
* the click. The chrome shows THIS instead while it is set, so the bar and the clock answer at
|
|
184
|
+
* once and the spinner says the picture is still coming.
|
|
185
|
+
*
|
|
186
|
+
* Undefined whenever no seek is outstanding, which is almost always.
|
|
187
|
+
*/
|
|
188
|
+
seekingTo?: number
|
|
189
|
+
|
|
177
190
|
/**
|
|
178
191
|
* The write seam, wired in `attach`. Only the React layer calls it.
|
|
179
192
|
*
|