@xorgate/react-native 0.1.0 → 0.2.1
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/CHANGELOG.md +117 -0
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -4
- package/dist/index.js.map +1 -1
- package/dist/replay-engine.d.ts +193 -0
- package/dist/replay-engine.d.ts.map +1 -0
- package/dist/replay-engine.js +843 -0
- package/dist/replay-engine.js.map +1 -0
- package/dist/replay-video.d.ts +13 -0
- package/dist/replay-video.d.ts.map +1 -0
- package/dist/replay-video.js +66 -0
- package/dist/replay-video.js.map +1 -0
- package/dist/use-replay-player.d.ts +58 -0
- package/dist/use-replay-player.d.ts.map +1 -0
- package/dist/use-replay-player.js +108 -0
- package/dist/use-replay-player.js.map +1 -0
- package/package.json +4 -2
- package/readme.md +66 -14
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
import { nextSegmentAfter, segmentAt, segmentEnd, } from "@xorgate/react";
|
|
2
|
+
/**
|
|
3
|
+
* PREPARE the next segment this far before the manifest boundary. Nothing is
|
|
4
|
+
* played early — the incoming player is only loaded and parked on its first
|
|
5
|
+
* frame — so the lead costs no content and a generous one is free.
|
|
6
|
+
*/
|
|
7
|
+
const BOUNDARY_LEAD_S = 0.5;
|
|
8
|
+
/**
|
|
9
|
+
* How close to a segment's end counts as having reached it. It must exceed
|
|
10
|
+
* one `timeUpdate` interval: the last event before a file runs out lands
|
|
11
|
+
* wherever the 50 ms cadence happens to put it, and a stricter test simply
|
|
12
|
+
* never fires. Kept under one frame at the recorder's 15 fps, because
|
|
13
|
+
* releasing early skips that much video; the boundary timer below is what
|
|
14
|
+
* covers the rest, and it errs the other way.
|
|
15
|
+
*/
|
|
16
|
+
const BOUNDARY_EPSILON_S = 0.03;
|
|
17
|
+
/**
|
|
18
|
+
* How long after a segment's expected end the boundary timer gives up waiting
|
|
19
|
+
* for an event and swaps anyway. The device showed that the last `timeUpdate`
|
|
20
|
+
* of a segment lands 60-80 ms short of the file's end and `playToEnd` /
|
|
21
|
+
* `playingChange` follow a beat later, so something has to close the gap —
|
|
22
|
+
* and it has to close it AFTER the end, not before: a timer that fires early
|
|
23
|
+
* skips real video, where one that fires late only holds a frame.
|
|
24
|
+
*/
|
|
25
|
+
const BOUNDARY_TIMER_GRACE_MS = 120;
|
|
26
|
+
/**
|
|
27
|
+
* Last resort, off the core's 500 ms pump: a lane that has stopped advancing
|
|
28
|
+
* near the end of its segment with no lead-in armed at all. Nothing should
|
|
29
|
+
* reach it, but a lane frozen at the end of a segment is invisible to the
|
|
30
|
+
* player core — the media reports itself `readyToPlay`, because it is: it is
|
|
31
|
+
* simply out of content — so it must not depend on any one signal.
|
|
32
|
+
*/
|
|
33
|
+
const BOUNDARY_WAIT_MAX_MS = 700;
|
|
34
|
+
/**
|
|
35
|
+
* Two segments whose seam is wider than this are a CUT, not a boundary. The
|
|
36
|
+
* lead-in only applies to contiguous media: playing across a real hole early
|
|
37
|
+
* would show the future.
|
|
38
|
+
*/
|
|
39
|
+
const CONTIGUOUS_MS = 250;
|
|
40
|
+
/**
|
|
41
|
+
* How long after crossing a boundary a move BACK into the segment just left is
|
|
42
|
+
* treated as the clock catching up rather than as a seek. Promoting does not
|
|
43
|
+
* move the playhead — the clock is paced by this lane, and it only learns the
|
|
44
|
+
* new position on its next tick — so without this the very next `ensureAt`
|
|
45
|
+
* arrives with a timestamp a few milliseconds before the boundary, drags the
|
|
46
|
+
* lane back to the old segment, and the two ping-pong until the core gives up
|
|
47
|
+
* and skips the whole segment. Measured on the device.
|
|
48
|
+
*/
|
|
49
|
+
const BOUNDARY_GRACE_MS = 1000;
|
|
50
|
+
/** Do not re-attempt a segment that just failed to load more often than this. */
|
|
51
|
+
const LOAD_RETRY_MS = 4000;
|
|
52
|
+
/**
|
|
53
|
+
* `replaceAsync` resolves with `status` still `loading` and `readyToPlay`
|
|
54
|
+
* follows ~200 ms later, so readiness is the EVENT's business (SPIKES.md §4).
|
|
55
|
+
* This is the reconciler for the one case the event cannot cover: a player
|
|
56
|
+
* whose status never left `readyToPlay` across the replace.
|
|
57
|
+
*/
|
|
58
|
+
const READY_RECONCILE_MS = 500;
|
|
59
|
+
/**
|
|
60
|
+
* One replay lane over two `expo-video` players (replay option A).
|
|
61
|
+
*
|
|
62
|
+
* The browser plays a lane as ONE `<video>` fed by Media Source Extensions:
|
|
63
|
+
* segments are appended into a single continuous buffer and there is no seam
|
|
64
|
+
* to manage. A phone has no MSE. `AVPlayer` and `ExoPlayer` play a FILE, and
|
|
65
|
+
* every recorded segment is a self-contained fMP4 (own `ftyp`+`moov`, `tfdt`
|
|
66
|
+
* from 0) — an ordinary playable MP4, and nothing that can be concatenated at
|
|
67
|
+
* runtime. So a lane is two players: one showing segment N while the other
|
|
68
|
+
* already holds N+1, and a boundary is an opacity swap between two views that
|
|
69
|
+
* are both mounted.
|
|
70
|
+
*
|
|
71
|
+
* Four things shape the code, all measured on real segments before it was
|
|
72
|
+
* written (SPIKES.md §4):
|
|
73
|
+
*
|
|
74
|
+
* - **The boundary is the MANIFEST's** (`effectiveDurationMs`), not the file's
|
|
75
|
+
* end: a segment's file routinely runs past the next segment's start
|
|
76
|
+
* (`fstat` anchors), and playing that tail would show the same wall clock
|
|
77
|
+
* twice. `playToEnd` is the fallback for the opposite case, a file SHORTER
|
|
78
|
+
* than the manifest claims.
|
|
79
|
+
* - **Readiness is an event, not a promise.** `replaceAsync` resolves with
|
|
80
|
+
* `status` still `loading`, and `bufferedPosition` can report the previous
|
|
81
|
+
* item's value for a beat afterwards. Nothing here trusts a player until
|
|
82
|
+
* `statusChange → readyToPlay`.
|
|
83
|
+
* - **Prepare early, promote on time.** The incoming player is loaded and
|
|
84
|
+
* parked on its first frame half a second before the boundary, but it is
|
|
85
|
+
* not played until the outgoing one actually reaches the boundary. That is
|
|
86
|
+
* what makes the seam a picture change with no playhead jump — swapping
|
|
87
|
+
* early would silently skip the lead, once per segment.
|
|
88
|
+
* - **A cross-segment seek is download-bound** (1.3-5.3 s for a 6-13 MB
|
|
89
|
+
* segment), so it always loads onto the HIDDEN player and the outgoing
|
|
90
|
+
* picture stays up until the new one has a frame. Replacing the visible
|
|
91
|
+
* player's source instead is seconds of black.
|
|
92
|
+
*
|
|
93
|
+
* Positions are lane-local seconds, as `ReplayEngine` requires; the wall clock
|
|
94
|
+
* stays the player core's business.
|
|
95
|
+
*/
|
|
96
|
+
export class ExpoVideoReplayEngine {
|
|
97
|
+
timeline;
|
|
98
|
+
getUrl;
|
|
99
|
+
onError;
|
|
100
|
+
onUpdate;
|
|
101
|
+
onAuthError;
|
|
102
|
+
onActivePlayerChange;
|
|
103
|
+
onDebug;
|
|
104
|
+
slots;
|
|
105
|
+
activeIdx = 0;
|
|
106
|
+
activation = null;
|
|
107
|
+
boundaryTimer = null;
|
|
108
|
+
wantPlay = false;
|
|
109
|
+
rate = 1;
|
|
110
|
+
destroyed = false;
|
|
111
|
+
firstFrameFired = false;
|
|
112
|
+
/** Lane-local seconds, kept so `position` is honest while nothing is ready. */
|
|
113
|
+
lastPos = 0;
|
|
114
|
+
/** The last position the active player was seen at, and when — see `watchStall`. */
|
|
115
|
+
stalledAt = null;
|
|
116
|
+
stalledSince = null;
|
|
117
|
+
/** The segment most recently left at a boundary, and when. See `activate`. */
|
|
118
|
+
crossed = null;
|
|
119
|
+
failedAt = new Map();
|
|
120
|
+
probing = new Set();
|
|
121
|
+
abort = new AbortController();
|
|
122
|
+
waitingCbs = new Set();
|
|
123
|
+
firstFrameCbs = new Set();
|
|
124
|
+
constructor(opts) {
|
|
125
|
+
this.timeline = opts.timeline;
|
|
126
|
+
this.getUrl = opts.getUrl;
|
|
127
|
+
this.onError = opts.onError;
|
|
128
|
+
this.onUpdate = opts.onUpdate;
|
|
129
|
+
this.onAuthError = opts.onAuthError;
|
|
130
|
+
this.onActivePlayerChange = opts.onActivePlayerChange;
|
|
131
|
+
this.onDebug = opts.onDebug;
|
|
132
|
+
this.slots = [
|
|
133
|
+
this.makeSlot(opts.players[0], 0),
|
|
134
|
+
this.makeSlot(opts.players[1], 1),
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
makeSlot(player, idx) {
|
|
138
|
+
try {
|
|
139
|
+
// Recorded segments are video-only; muting keeps the player away from
|
|
140
|
+
// the audio session entirely, so a replay never ducks anything.
|
|
141
|
+
player.muted = true;
|
|
142
|
+
player.loop = false;
|
|
143
|
+
// 50 ms: the resolution the boundary check and the seam measurement
|
|
144
|
+
// rest on, and under one frame at the recorder's 15 fps.
|
|
145
|
+
player.timeUpdateEventInterval = 0.05;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// The players belong to the hook. One that has already been released
|
|
149
|
+
// throws `NotFoundException` on every access, so an engine handed a
|
|
150
|
+
// dead pair goes inert instead of throwing into the React tree — which
|
|
151
|
+
// is what a torn-down lane looked like on the device.
|
|
152
|
+
this.destroyed = true;
|
|
153
|
+
}
|
|
154
|
+
const slot = {
|
|
155
|
+
player,
|
|
156
|
+
seg: null,
|
|
157
|
+
ready: false,
|
|
158
|
+
loading: false,
|
|
159
|
+
loadedAt: null,
|
|
160
|
+
gen: 0,
|
|
161
|
+
subs: [],
|
|
162
|
+
};
|
|
163
|
+
slot.subs.push(player.addListener("timeUpdate", ({ currentTime }) => {
|
|
164
|
+
this.onTimeUpdate(idx, currentTime);
|
|
165
|
+
}), player.addListener("statusChange", ({ status, error }) => {
|
|
166
|
+
this.onStatusChange(idx, status, error?.message ?? null);
|
|
167
|
+
}), player.addListener("playToEnd", () => {
|
|
168
|
+
this.onPlayToEnd(idx);
|
|
169
|
+
}), player.addListener("playingChange", ({ isPlaying }) => {
|
|
170
|
+
// A player that stops on its own while the transport says play has
|
|
171
|
+
// run out of file. On the real segments this fires where `playToEnd`
|
|
172
|
+
// does not, and it is the signal that releases a boundary whose file
|
|
173
|
+
// ended a few milliseconds short of what the manifest claims.
|
|
174
|
+
if (!isPlaying && this.wantPlay && idx === this.activeIdx) {
|
|
175
|
+
this.releaseBoundary("the player stopped");
|
|
176
|
+
}
|
|
177
|
+
}));
|
|
178
|
+
return slot;
|
|
179
|
+
}
|
|
180
|
+
// --- the media surface (ReplayEngine) ------------------------------------
|
|
181
|
+
get position() {
|
|
182
|
+
const pending = this.activation;
|
|
183
|
+
// A seek's target IS the position from the instant it is requested: the
|
|
184
|
+
// browser's `video.currentTime = x` takes effect whether the media is
|
|
185
|
+
// there or not, and a pacer that reported the outgoing segment instead
|
|
186
|
+
// would drag the clock straight back to where the picture still is.
|
|
187
|
+
if (pending && pending.released) {
|
|
188
|
+
return this.segPos(pending.seg) + pending.offsetSec;
|
|
189
|
+
}
|
|
190
|
+
const active = this.slots[this.activeIdx];
|
|
191
|
+
if (active.seg && active.ready) {
|
|
192
|
+
return this.segPos(active.seg) + active.player.currentTime;
|
|
193
|
+
}
|
|
194
|
+
if (pending)
|
|
195
|
+
return this.segPos(pending.seg) + pending.offsetSec;
|
|
196
|
+
return this.lastPos;
|
|
197
|
+
}
|
|
198
|
+
get paused() {
|
|
199
|
+
// Intent, not the native flag: a player mid-load is not "paused", and the
|
|
200
|
+
// core reconciles transport off this on every notify.
|
|
201
|
+
return !this.wantPlay;
|
|
202
|
+
}
|
|
203
|
+
/** The player whose picture belongs on screen. */
|
|
204
|
+
get activePlayer() {
|
|
205
|
+
return this.slots[this.activeIdx].player;
|
|
206
|
+
}
|
|
207
|
+
/** Which of the two players `activePlayer` is. */
|
|
208
|
+
get activeIndex() {
|
|
209
|
+
return this.activeIdx;
|
|
210
|
+
}
|
|
211
|
+
buffered() {
|
|
212
|
+
const out = [];
|
|
213
|
+
for (const slot of this.slots) {
|
|
214
|
+
if (!slot.seg || !slot.ready)
|
|
215
|
+
continue;
|
|
216
|
+
const start = this.segPos(slot.seg);
|
|
217
|
+
// Clamped to the MANIFEST duration: a file's tail past the next
|
|
218
|
+
// segment's start is media this lane must never play.
|
|
219
|
+
const end = start +
|
|
220
|
+
Math.min(slot.player.bufferedPosition, slot.seg.effectiveDurationMs / 1000);
|
|
221
|
+
if (end > start)
|
|
222
|
+
out.push({ start, end });
|
|
223
|
+
}
|
|
224
|
+
return out.sort((a, b) => a.start - b.start);
|
|
225
|
+
}
|
|
226
|
+
isBufferedAt(posSec) {
|
|
227
|
+
const ts = this.timeline.from + posSec * 1000;
|
|
228
|
+
const hit = segmentAt(this.timeline, ts);
|
|
229
|
+
if (!hit)
|
|
230
|
+
return false;
|
|
231
|
+
// Per-player state: "is ts buffered" is a question about the player that
|
|
232
|
+
// HOLDS that segment, and a segment neither player holds is by definition
|
|
233
|
+
// not buffered (SPIKES.md §4). `ready` is what makes `bufferedPosition`
|
|
234
|
+
// safe to read — it can answer for the previous item until then.
|
|
235
|
+
const slot = this.slotHolding(hit.segment);
|
|
236
|
+
if (!slot || !slot.ready)
|
|
237
|
+
return false;
|
|
238
|
+
return ((ts - hit.segment.startTs) / 1000 <= slot.player.bufferedPosition + 0.1);
|
|
239
|
+
}
|
|
240
|
+
isStalled() {
|
|
241
|
+
// A prepared lead-in is NOT a stall: the next segment is loaded and one
|
|
242
|
+
// event away from being shown. Saying otherwise sends the core's stall
|
|
243
|
+
// recovery into a boundary it is about to cross anyway — and because the
|
|
244
|
+
// playhead is already inside the NEXT segment by then, that recovery
|
|
245
|
+
// jumps to the end of it and skips the whole segment. Measured on the
|
|
246
|
+
// device: 57.7 s of a 145.6 s replay silently gone.
|
|
247
|
+
if (this.activation?.prepared && !this.activation.released)
|
|
248
|
+
return false;
|
|
249
|
+
// Waiting on media that is due now: the outgoing player has run out (or a
|
|
250
|
+
// seek has landed) and the incoming one is not loaded yet. Without this
|
|
251
|
+
// the core cannot see a boundary the download did not make in time — the
|
|
252
|
+
// frozen player still reports `readyToPlay`.
|
|
253
|
+
if (this.activation?.released && !this.activation.prepared)
|
|
254
|
+
return true;
|
|
255
|
+
const active = this.slots[this.activeIdx];
|
|
256
|
+
if (!active.seg || !active.ready)
|
|
257
|
+
return true;
|
|
258
|
+
return active.player.status !== "readyToPlay";
|
|
259
|
+
}
|
|
260
|
+
seek(posSec) {
|
|
261
|
+
if (this.destroyed)
|
|
262
|
+
return;
|
|
263
|
+
this.lastPos = posSec;
|
|
264
|
+
const ts = this.timeline.from + posSec * 1000;
|
|
265
|
+
const hit = segmentAt(this.timeline, ts);
|
|
266
|
+
// In a gap the clock owns the playhead; `ensureAt` keeps the far side warm
|
|
267
|
+
// so coming out of it is instant.
|
|
268
|
+
if (!hit)
|
|
269
|
+
return;
|
|
270
|
+
const offsetSec = (ts - hit.segment.startTs) / 1000;
|
|
271
|
+
const pending = this.activation;
|
|
272
|
+
if (pending && sameSeg(pending.seg, hit.segment)) {
|
|
273
|
+
pending.offsetSec = offsetSec;
|
|
274
|
+
// A seek is due at once, even if this activation started as a boundary
|
|
275
|
+
// lead-in that was still waiting for the outgoing segment.
|
|
276
|
+
pending.released = true;
|
|
277
|
+
if (pending.prepared)
|
|
278
|
+
this.slots[pending.idx].player.currentTime = offsetSec;
|
|
279
|
+
this.maybePromote();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const active = this.slots[this.activeIdx];
|
|
283
|
+
if (sameSeg(active.seg, hit.segment) && active.ready) {
|
|
284
|
+
// Back inside what is already on screen: an in-segment seek, 63 ms.
|
|
285
|
+
this.cancelActivation();
|
|
286
|
+
active.player.currentTime = offsetSec;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.activate(hit.segment, offsetSec, false);
|
|
290
|
+
}
|
|
291
|
+
play() {
|
|
292
|
+
if (this.destroyed)
|
|
293
|
+
return;
|
|
294
|
+
this.wantPlay = true;
|
|
295
|
+
this.applyTransport();
|
|
296
|
+
// A lead-in prepared while paused has no timer: arm it now that the
|
|
297
|
+
// outgoing segment is running towards its end again.
|
|
298
|
+
if (this.activation?.prepared && !this.activation.released)
|
|
299
|
+
this.armBoundaryTimer();
|
|
300
|
+
}
|
|
301
|
+
pause() {
|
|
302
|
+
if (this.destroyed)
|
|
303
|
+
return;
|
|
304
|
+
this.wantPlay = false;
|
|
305
|
+
this.applyTransport();
|
|
306
|
+
}
|
|
307
|
+
setRate(rate) {
|
|
308
|
+
if (this.destroyed || this.rate === rate)
|
|
309
|
+
return;
|
|
310
|
+
this.rate = rate;
|
|
311
|
+
this.applyTransport();
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Keep the media around `ts` — and the segment after it — loaded. Called at
|
|
315
|
+
* clock-notify rate and from the core's 500 ms pump, so every path is
|
|
316
|
+
* guarded: steady state does no work.
|
|
317
|
+
*/
|
|
318
|
+
ensureAt(ts) {
|
|
319
|
+
if (this.destroyed)
|
|
320
|
+
return;
|
|
321
|
+
this.reconcileReady();
|
|
322
|
+
// A load the backoff refused still has an activation waiting on it.
|
|
323
|
+
// `load` re-throttles itself, so this is the retry ladder.
|
|
324
|
+
const pending = this.activation;
|
|
325
|
+
if (pending) {
|
|
326
|
+
const slot = this.slots[pending.idx];
|
|
327
|
+
if (!slot.ready && !slot.loading)
|
|
328
|
+
this.load(pending.idx, pending.seg);
|
|
329
|
+
// A prepared lead-in with no timer running (the app was backgrounded
|
|
330
|
+
// and the timer was starved, say) still has to cross its boundary.
|
|
331
|
+
if (this.wantPlay &&
|
|
332
|
+
pending.prepared &&
|
|
333
|
+
!pending.released &&
|
|
334
|
+
this.boundaryTimer === null) {
|
|
335
|
+
this.armBoundaryTimer();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
this.watchStall();
|
|
339
|
+
const hit = segmentAt(this.timeline, ts);
|
|
340
|
+
if (hit) {
|
|
341
|
+
const active = this.slots[this.activeIdx];
|
|
342
|
+
const pendingSeg = this.activation?.seg ?? null;
|
|
343
|
+
if (!sameSeg(active.seg, hit.segment) &&
|
|
344
|
+
!sameSeg(pendingSeg, hit.segment)) {
|
|
345
|
+
this.activate(hit.segment, (ts - hit.segment.startTs) / 1000, false);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (sameSeg(active.seg, hit.segment) &&
|
|
349
|
+
!active.ready &&
|
|
350
|
+
!active.loading) {
|
|
351
|
+
// The visible slot's own load failed. Retry it through the backoff;
|
|
352
|
+
// there is no picture to protect here.
|
|
353
|
+
this.load(this.activeIdx, hit.segment);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
this.prefetchAfter(hit ? segmentEnd(hit.segment) : ts);
|
|
358
|
+
}
|
|
359
|
+
on(event, cb) {
|
|
360
|
+
const set = event === "waiting" ? this.waitingCbs : this.firstFrameCbs;
|
|
361
|
+
set.add(cb);
|
|
362
|
+
return () => set.delete(cb);
|
|
363
|
+
}
|
|
364
|
+
destroy() {
|
|
365
|
+
this.destroyed = true;
|
|
366
|
+
this.abort.abort();
|
|
367
|
+
this.clearBoundaryTimer();
|
|
368
|
+
this.activation = null;
|
|
369
|
+
for (const slot of this.slots) {
|
|
370
|
+
for (const sub of slot.subs)
|
|
371
|
+
sub.remove();
|
|
372
|
+
slot.subs = [];
|
|
373
|
+
slot.gen++;
|
|
374
|
+
slot.seg = null;
|
|
375
|
+
slot.ready = false;
|
|
376
|
+
slot.loading = false;
|
|
377
|
+
slot.loadedAt = null;
|
|
378
|
+
try {
|
|
379
|
+
slot.player.pause();
|
|
380
|
+
// Drop the item so the native decoder and its buffer go with it. The
|
|
381
|
+
// players themselves belong to the hook and are released there.
|
|
382
|
+
void slot.player.replaceAsync(null).catch(() => undefined);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
/* already released by the hook's cleanup */
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
this.waitingCbs.clear();
|
|
389
|
+
this.firstFrameCbs.clear();
|
|
390
|
+
}
|
|
391
|
+
// --- internals -----------------------------------------------------------
|
|
392
|
+
debug(message) {
|
|
393
|
+
this.onDebug?.(message);
|
|
394
|
+
}
|
|
395
|
+
/** One line of state, for a debug message that has to explain a decision. */
|
|
396
|
+
snapshot() {
|
|
397
|
+
const [a, b] = this.slots;
|
|
398
|
+
const pending = this.activation;
|
|
399
|
+
return (`active=${this.activeIdx === 0 ? "A" : "B"} ` +
|
|
400
|
+
`A[seq=${a.seg?.seq ?? "-"} ready=${a.ready} loading=${a.loading} st=${a.player.status} ct=${a.player.currentTime.toFixed(2)} dur=${a.player.duration.toFixed(2)}] ` +
|
|
401
|
+
`B[seq=${b.seg?.seq ?? "-"} ready=${b.ready} loading=${b.loading} st=${b.player.status} ct=${b.player.currentTime.toFixed(2)} dur=${b.player.duration.toFixed(2)}] ` +
|
|
402
|
+
`pending=${pending ? `${pending.idx === 0 ? "A" : "B"}/seq${pending.seg.seq}/prep=${pending.prepared}/rel=${pending.released}` : "-"}`);
|
|
403
|
+
}
|
|
404
|
+
segPos(seg) {
|
|
405
|
+
return (seg.startTs - this.timeline.from) / 1000;
|
|
406
|
+
}
|
|
407
|
+
slotHolding(seg) {
|
|
408
|
+
for (const slot of this.slots)
|
|
409
|
+
if (sameSeg(slot.seg, seg))
|
|
410
|
+
return slot;
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Only the player on screen ever plays; the other one is preload.
|
|
415
|
+
*
|
|
416
|
+
* Every write below goes through JSI to a native object the hook owns, and
|
|
417
|
+
* a call on one that has been released throws `NotFoundException` rather
|
|
418
|
+
* than returning — so nothing here may run after `destroy()`.
|
|
419
|
+
*/
|
|
420
|
+
applyTransport() {
|
|
421
|
+
if (this.destroyed)
|
|
422
|
+
return;
|
|
423
|
+
// A seek whose media is still downloading: hold the outgoing picture on
|
|
424
|
+
// its last frame rather than let it run on. The clock is already pinned
|
|
425
|
+
// at the seek target, so a picture that keeps advancing is showing a
|
|
426
|
+
// different moment from the one the transport bar says it is — measured
|
|
427
|
+
// on the device as six seconds of the previous segment playing on after a
|
|
428
|
+
// seek. A boundary lead-in is NOT this: there the outgoing segment is
|
|
429
|
+
// still the right one, right up to its last frame.
|
|
430
|
+
const seeking = this.activation !== null &&
|
|
431
|
+
this.activation.released &&
|
|
432
|
+
!this.activation.prepared;
|
|
433
|
+
for (let i = 0; i < 2; i++) {
|
|
434
|
+
const idx = i;
|
|
435
|
+
const slot = this.slots[idx];
|
|
436
|
+
if (idx === this.activeIdx && this.wantPlay && slot.ready && !seeking) {
|
|
437
|
+
// The rate goes on the player that is about to play, and on no other:
|
|
438
|
+
// on iOS a rate IS the transport, so setting it on a parked preload
|
|
439
|
+
// can start it — which is how a preloaded segment reached 15.68 s
|
|
440
|
+
// before it was ever shown.
|
|
441
|
+
if (slot.player.playbackRate !== this.rate)
|
|
442
|
+
slot.player.playbackRate = this.rate;
|
|
443
|
+
slot.player.play();
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
slot.player.pause();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Bring `seg` to the front at `offsetSec`, always through the HIDDEN player
|
|
452
|
+
* so the picture on screen survives the load.
|
|
453
|
+
*/
|
|
454
|
+
activate(seg, offsetSec, seamless) {
|
|
455
|
+
if (this.destroyed)
|
|
456
|
+
return;
|
|
457
|
+
const crossed = this.crossed;
|
|
458
|
+
if (crossed &&
|
|
459
|
+
sameSeg(crossed.seg, seg) &&
|
|
460
|
+
Date.now() - crossed.at < BOUNDARY_GRACE_MS) {
|
|
461
|
+
// The clock is still a few milliseconds behind the boundary this lane
|
|
462
|
+
// has already crossed. Going back would undo the swap, and then the
|
|
463
|
+
// next tick would redo it.
|
|
464
|
+
this.debug(`ignored a move back into seq ${seg.seq}: the clock has not caught up`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const idx = (1 - this.activeIdx);
|
|
468
|
+
const incoming = this.slots[idx];
|
|
469
|
+
this.activation = {
|
|
470
|
+
idx,
|
|
471
|
+
seg,
|
|
472
|
+
offsetSec,
|
|
473
|
+
prepared: false,
|
|
474
|
+
preparedAt: null,
|
|
475
|
+
// A boundary lead-in waits for the outgoing segment to finish; anything
|
|
476
|
+
// else (a seek, a gap exit, the first segment of all) is due now.
|
|
477
|
+
released: !seamless,
|
|
478
|
+
};
|
|
479
|
+
this.debug(`activate seq ${seg.seq} +${offsetSec.toFixed(2)}s seamless=${seamless} · ${this.snapshot()}`);
|
|
480
|
+
this.applyTransport();
|
|
481
|
+
if (sameSeg(incoming.seg, seg) && incoming.ready) {
|
|
482
|
+
this.prepare();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (sameSeg(incoming.seg, seg) && incoming.loading)
|
|
486
|
+
return;
|
|
487
|
+
this.load(idx, seg);
|
|
488
|
+
}
|
|
489
|
+
cancelActivation() {
|
|
490
|
+
const pending = this.activation;
|
|
491
|
+
if (!pending)
|
|
492
|
+
return;
|
|
493
|
+
this.activation = null;
|
|
494
|
+
this.clearBoundaryTimer();
|
|
495
|
+
this.slots[pending.idx].player.pause();
|
|
496
|
+
}
|
|
497
|
+
/** Park the incoming player on the frame it will be promoted with. */
|
|
498
|
+
prepare() {
|
|
499
|
+
if (this.destroyed)
|
|
500
|
+
return;
|
|
501
|
+
const pending = this.activation;
|
|
502
|
+
if (!pending || pending.prepared)
|
|
503
|
+
return;
|
|
504
|
+
const slot = this.slots[pending.idx];
|
|
505
|
+
if (!slot.ready)
|
|
506
|
+
return;
|
|
507
|
+
// Unconditionally, not only for a non-zero offset: a preloaded player is
|
|
508
|
+
// not reliably parked at 0 (the device found one sitting at 15.68 s), and
|
|
509
|
+
// promoting it then starts the new segment part-way through.
|
|
510
|
+
if (Math.abs(slot.player.currentTime - pending.offsetSec) > 0.05) {
|
|
511
|
+
slot.player.currentTime = pending.offsetSec;
|
|
512
|
+
}
|
|
513
|
+
pending.prepared = true;
|
|
514
|
+
pending.preparedAt = Date.now();
|
|
515
|
+
this.debug(`prepared seq ${pending.seg.seq} on ${pending.idx === 0 ? "A" : "B"}`);
|
|
516
|
+
this.armBoundaryTimer();
|
|
517
|
+
this.maybePromote();
|
|
518
|
+
}
|
|
519
|
+
maybePromote() {
|
|
520
|
+
if (this.destroyed)
|
|
521
|
+
return;
|
|
522
|
+
const pending = this.activation;
|
|
523
|
+
if (!pending || !pending.prepared || !pending.released)
|
|
524
|
+
return;
|
|
525
|
+
this.activation = null;
|
|
526
|
+
this.clearBoundaryTimer();
|
|
527
|
+
this.debug(`promote seq ${pending.seg.seq} to ${pending.idx === 0 ? "A" : "B"} at +${pending.offsetSec.toFixed(2)}s`);
|
|
528
|
+
const retiredIdx = this.activeIdx;
|
|
529
|
+
const retiredSeg = this.slots[retiredIdx].seg;
|
|
530
|
+
this.activeIdx = pending.idx;
|
|
531
|
+
if (retiredIdx !== pending.idx)
|
|
532
|
+
this.slots[retiredIdx].player.pause();
|
|
533
|
+
// Remember what was left, so the clock catching up cannot drag us back.
|
|
534
|
+
if (retiredSeg && !sameSeg(retiredSeg, pending.seg)) {
|
|
535
|
+
this.crossed = { seg: retiredSeg, at: Date.now() };
|
|
536
|
+
}
|
|
537
|
+
this.stalledAt = null;
|
|
538
|
+
this.stalledSince = null;
|
|
539
|
+
this.lastPos = this.segPos(pending.seg) + pending.offsetSec;
|
|
540
|
+
this.applyTransport();
|
|
541
|
+
this.onActivePlayerChange?.();
|
|
542
|
+
if (!this.firstFrameFired) {
|
|
543
|
+
this.firstFrameFired = true;
|
|
544
|
+
for (const cb of this.firstFrameCbs)
|
|
545
|
+
cb();
|
|
546
|
+
}
|
|
547
|
+
this.onUpdate();
|
|
548
|
+
this.prefetchAfter(segmentEnd(pending.seg));
|
|
549
|
+
}
|
|
550
|
+
/** One segment of look-ahead on the hidden player — what makes a boundary free. */
|
|
551
|
+
/**
|
|
552
|
+
* Cross the boundary on time even if the outgoing player never says it is
|
|
553
|
+
* finished. Armed when the lead-in is prepared, for the moment the segment
|
|
554
|
+
* is expected to run out plus a short grace, and re-armed rather than fired
|
|
555
|
+
* if the player is somehow still behind that (a pause, a slower rate). An
|
|
556
|
+
* event almost always beats it; what it buys is that none of them has to,
|
|
557
|
+
* and that the swap lands AFTER the end rather than before it — a timer
|
|
558
|
+
* that fires early skips real video, one that fires late holds a frame.
|
|
559
|
+
*/
|
|
560
|
+
armBoundaryTimer() {
|
|
561
|
+
this.clearBoundaryTimer();
|
|
562
|
+
if (!this.wantPlay || this.destroyed)
|
|
563
|
+
return;
|
|
564
|
+
const slot = this.slots[this.activeIdx];
|
|
565
|
+
if (!slot.seg)
|
|
566
|
+
return;
|
|
567
|
+
const remainMs = ((this.endOf(slot) - slot.player.currentTime) * 1000) / (this.rate || 1);
|
|
568
|
+
this.boundaryTimer = setTimeout(() => this.onBoundaryTimer(), Math.max(0, remainMs) + BOUNDARY_TIMER_GRACE_MS);
|
|
569
|
+
}
|
|
570
|
+
onBoundaryTimer() {
|
|
571
|
+
this.boundaryTimer = null;
|
|
572
|
+
if (this.destroyed || !this.wantPlay)
|
|
573
|
+
return;
|
|
574
|
+
const pending = this.activation;
|
|
575
|
+
if (!pending || pending.released || !pending.prepared)
|
|
576
|
+
return;
|
|
577
|
+
const slot = this.slots[this.activeIdx];
|
|
578
|
+
if (!slot.seg)
|
|
579
|
+
return;
|
|
580
|
+
const remainMs = ((this.endOf(slot) - slot.player.currentTime) * 1000) / (this.rate || 1);
|
|
581
|
+
if (remainMs > BOUNDARY_TIMER_GRACE_MS) {
|
|
582
|
+
this.armBoundaryTimer();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
this.releaseBoundary("the boundary timer");
|
|
586
|
+
}
|
|
587
|
+
clearBoundaryTimer() {
|
|
588
|
+
if (this.boundaryTimer !== null) {
|
|
589
|
+
clearTimeout(this.boundaryTimer);
|
|
590
|
+
this.boundaryTimer = null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
prefetchAfter(refTs) {
|
|
594
|
+
if (this.destroyed || this.activation)
|
|
595
|
+
return;
|
|
596
|
+
const next = nextSegmentAfter(this.timeline, refTs);
|
|
597
|
+
if (!next)
|
|
598
|
+
return;
|
|
599
|
+
const idx = (1 - this.activeIdx);
|
|
600
|
+
if (sameSeg(this.slots[idx].seg, next))
|
|
601
|
+
return;
|
|
602
|
+
if (sameSeg(this.slots[this.activeIdx].seg, next))
|
|
603
|
+
return;
|
|
604
|
+
this.load(idx, next);
|
|
605
|
+
}
|
|
606
|
+
load(idx, seg) {
|
|
607
|
+
const slot = this.slots[idx];
|
|
608
|
+
const failed = this.failedAt.get(seg.startTs);
|
|
609
|
+
if (failed !== undefined && Date.now() - failed < LOAD_RETRY_MS)
|
|
610
|
+
return;
|
|
611
|
+
this.debug(`load seq ${seg.seq} onto ${idx === 0 ? "A" : "B"}`);
|
|
612
|
+
const gen = ++slot.gen;
|
|
613
|
+
slot.seg = seg;
|
|
614
|
+
slot.ready = false;
|
|
615
|
+
slot.loading = true;
|
|
616
|
+
slot.loadedAt = null;
|
|
617
|
+
void slot.player.replaceAsync({ uri: this.getUrl(seg) }).then(() => {
|
|
618
|
+
if (this.destroyed || slot.gen !== gen)
|
|
619
|
+
return;
|
|
620
|
+
slot.loadedAt = Date.now();
|
|
621
|
+
// Deliberately not ready here: the promise resolves with `status`
|
|
622
|
+
// still `loading`. `onStatusChange` is what promotes it, and
|
|
623
|
+
// `reconcileReady` covers a status that never moved.
|
|
624
|
+
}, (err) => {
|
|
625
|
+
if (this.destroyed || slot.gen !== gen)
|
|
626
|
+
return;
|
|
627
|
+
slot.loading = false;
|
|
628
|
+
this.onLoadFailure(seg, err);
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* The player on screen has stopped advancing while the transport says play.
|
|
633
|
+
* That is a segment that has run out of file without saying so — and it is
|
|
634
|
+
* the state that must never be left alone, because the player core's stall
|
|
635
|
+
* recovery reads the playhead (already inside the NEXT segment) rather than
|
|
636
|
+
* the media, and jumps to the end of a segment that was never shown.
|
|
637
|
+
*/
|
|
638
|
+
watchStall() {
|
|
639
|
+
if (!this.wantPlay || this.destroyed)
|
|
640
|
+
return;
|
|
641
|
+
const slot = this.slots[this.activeIdx];
|
|
642
|
+
if (!slot.seg || !slot.ready) {
|
|
643
|
+
this.stalledSince = null;
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const at = slot.player.currentTime;
|
|
647
|
+
if (this.stalledAt === null || Math.abs(at - this.stalledAt) > 0.001) {
|
|
648
|
+
this.stalledAt = at;
|
|
649
|
+
this.stalledSince = Date.now();
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (this.stalledSince === null) {
|
|
653
|
+
this.stalledSince = Date.now();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (Date.now() - this.stalledSince <= BOUNDARY_WAIT_MAX_MS)
|
|
657
|
+
return;
|
|
658
|
+
// Only a stall at the END of the segment is a boundary; anywhere else it
|
|
659
|
+
// is a buffer underrun and the core's recovery is the right answer.
|
|
660
|
+
if (this.endOf(slot) - at > BOUNDARY_LEAD_S)
|
|
661
|
+
return;
|
|
662
|
+
this.stalledSince = null;
|
|
663
|
+
this.releaseBoundary("the player stopped advancing");
|
|
664
|
+
}
|
|
665
|
+
markReady(idx) {
|
|
666
|
+
if (this.destroyed)
|
|
667
|
+
return;
|
|
668
|
+
const slot = this.slots[idx];
|
|
669
|
+
if (!slot.seg || slot.ready)
|
|
670
|
+
return;
|
|
671
|
+
slot.ready = true;
|
|
672
|
+
slot.loading = false;
|
|
673
|
+
this.debug(`ready seq ${slot.seg.seq} on ${idx === 0 ? "A" : "B"} (buffered ${slot.player.bufferedPosition.toFixed(1)}s, duration ${slot.player.duration.toFixed(2)}s)`);
|
|
674
|
+
this.failedAt.delete(slot.seg.startTs);
|
|
675
|
+
// Always, not only for the visible slot: a freshly loaded preload must be
|
|
676
|
+
// held still, and the rate belongs to whatever is about to play.
|
|
677
|
+
this.applyTransport();
|
|
678
|
+
if (this.activation?.idx === idx)
|
|
679
|
+
this.prepare();
|
|
680
|
+
this.onUpdate();
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* The one case `statusChange` cannot report: a player that was already
|
|
684
|
+
* `readyToPlay` and whose new item loaded without the status ever leaving
|
|
685
|
+
* that value. Runs off the core's pump, so it costs nothing in steady state.
|
|
686
|
+
*/
|
|
687
|
+
reconcileReady() {
|
|
688
|
+
const now = Date.now();
|
|
689
|
+
for (let i = 0; i < 2; i++) {
|
|
690
|
+
const idx = i;
|
|
691
|
+
const slot = this.slots[idx];
|
|
692
|
+
if (!slot.loading || slot.loadedAt === null)
|
|
693
|
+
continue;
|
|
694
|
+
if (now - slot.loadedAt < READY_RECONCILE_MS)
|
|
695
|
+
continue;
|
|
696
|
+
if (slot.player.status === "readyToPlay")
|
|
697
|
+
this.markReady(idx);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
onTimeUpdate(idx, currentTime) {
|
|
701
|
+
if (this.destroyed || idx !== this.activeIdx)
|
|
702
|
+
return;
|
|
703
|
+
const slot = this.slots[idx];
|
|
704
|
+
if (!slot.seg)
|
|
705
|
+
return;
|
|
706
|
+
this.lastPos = this.segPos(slot.seg) + currentTime;
|
|
707
|
+
if (!this.wantPlay)
|
|
708
|
+
return;
|
|
709
|
+
const remainS = this.endOf(slot) - currentTime;
|
|
710
|
+
if (this.activation) {
|
|
711
|
+
// The outgoing segment has played out: release the lead-in. THIS is the
|
|
712
|
+
// moment that keeps the playhead continuous across the seam — releasing
|
|
713
|
+
// at the lead instead would silently drop half a second per boundary.
|
|
714
|
+
if (remainS <= BOUNDARY_EPSILON_S)
|
|
715
|
+
this.releaseBoundary("reached the boundary");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (remainS > BOUNDARY_LEAD_S)
|
|
719
|
+
return;
|
|
720
|
+
const next = this.contiguousNext(slot.seg);
|
|
721
|
+
if (next)
|
|
722
|
+
this.activate(next, 0, true);
|
|
723
|
+
}
|
|
724
|
+
onPlayToEnd(idx) {
|
|
725
|
+
if (this.destroyed || idx !== this.activeIdx || !this.wantPlay)
|
|
726
|
+
return;
|
|
727
|
+
this.releaseBoundary("playToEnd");
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* The outgoing segment is finished, however we found out. With a lead-in
|
|
731
|
+
* already prepared this swaps; without one — the file ran out well before
|
|
732
|
+
* the manifest said it would, which `fstat`-anchored rows do — it starts
|
|
733
|
+
* the next segment now rather than leave the clock on a player that will
|
|
734
|
+
* never advance again.
|
|
735
|
+
*/
|
|
736
|
+
releaseBoundary(reason) {
|
|
737
|
+
if (this.destroyed || !this.wantPlay)
|
|
738
|
+
return;
|
|
739
|
+
const slot = this.slots[this.activeIdx];
|
|
740
|
+
if (!slot.seg)
|
|
741
|
+
return;
|
|
742
|
+
const next = this.contiguousNext(slot.seg);
|
|
743
|
+
if (!next)
|
|
744
|
+
return;
|
|
745
|
+
this.debug(`release (${reason}) seq ${slot.seg.seq} → ${next.seq} · ${this.snapshot()}`);
|
|
746
|
+
const pending = this.activation;
|
|
747
|
+
if (pending) {
|
|
748
|
+
if (!sameSeg(pending.seg, next) || pending.released)
|
|
749
|
+
return;
|
|
750
|
+
pending.released = true;
|
|
751
|
+
this.maybePromote();
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
this.activate(next, 0, false);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Where this player's segment really ends, in its own seconds: the manifest
|
|
758
|
+
* duration, or the file's if the file is shorter. A file that outruns the
|
|
759
|
+
* manifest must be cut at the manifest (its tail is the next segment's wall
|
|
760
|
+
* clock); a file that falls short can only be played to its own end, and
|
|
761
|
+
* waiting for a boundary it can never reach is what freezes a lane.
|
|
762
|
+
*/
|
|
763
|
+
endOf(slot) {
|
|
764
|
+
const manifestEnd = slot.seg ? slot.seg.effectiveDurationMs / 1000 : 0;
|
|
765
|
+
const fileEnd = slot.player.duration;
|
|
766
|
+
return fileEnd > 0 ? Math.min(manifestEnd, fileEnd) : manifestEnd;
|
|
767
|
+
}
|
|
768
|
+
contiguousNext(seg) {
|
|
769
|
+
const end = segmentEnd(seg);
|
|
770
|
+
const next = nextSegmentAfter(this.timeline, end);
|
|
771
|
+
if (!next)
|
|
772
|
+
return null;
|
|
773
|
+
return Math.abs(next.startTs - end) <= CONTIGUOUS_MS ? next : null;
|
|
774
|
+
}
|
|
775
|
+
onStatusChange(idx, status, message) {
|
|
776
|
+
if (this.destroyed)
|
|
777
|
+
return;
|
|
778
|
+
const slot = this.slots[idx];
|
|
779
|
+
if (status === "readyToPlay") {
|
|
780
|
+
if (!slot.ready)
|
|
781
|
+
this.markReady(idx);
|
|
782
|
+
else if (idx === this.activeIdx)
|
|
783
|
+
this.onUpdate();
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (status === "error") {
|
|
787
|
+
slot.ready = false;
|
|
788
|
+
slot.loading = false;
|
|
789
|
+
if (slot.seg)
|
|
790
|
+
this.onLoadFailure(slot.seg, message === null ? null : new Error(message));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (status === "loading" &&
|
|
794
|
+
idx === this.activeIdx &&
|
|
795
|
+
slot.ready &&
|
|
796
|
+
this.wantPlay) {
|
|
797
|
+
// A buffer underrun on the player that is on screen. The core's stall
|
|
798
|
+
// recovery reads `buffered()` and decides whether to jump or wait.
|
|
799
|
+
for (const cb of this.waitingCbs)
|
|
800
|
+
cb();
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* A native player reports that a source would not load, and never why: the
|
|
805
|
+
* HTTP status is not on the error. A one-byte ranged GET against the same
|
|
806
|
+
* presigned URL is the cheapest way to tell an expired signature (403 —
|
|
807
|
+
* refresh the manifest, exactly as the browser engine does) from a network
|
|
808
|
+
* blip (retry), and it only ever runs on a failure.
|
|
809
|
+
*/
|
|
810
|
+
onLoadFailure(seg, err) {
|
|
811
|
+
this.failedAt.set(seg.startTs, Date.now());
|
|
812
|
+
console.warn(`replay segment ${seg.seq} failed to load`, err instanceof Error ? err.message : err);
|
|
813
|
+
if (this.probing.has(seg.startTs))
|
|
814
|
+
return;
|
|
815
|
+
this.probing.add(seg.startTs);
|
|
816
|
+
void (async () => {
|
|
817
|
+
try {
|
|
818
|
+
const res = await fetch(this.getUrl(seg), {
|
|
819
|
+
headers: { Range: "bytes=0-0" },
|
|
820
|
+
signal: this.abort.signal,
|
|
821
|
+
});
|
|
822
|
+
if (this.destroyed)
|
|
823
|
+
return;
|
|
824
|
+
if (res.status === 403)
|
|
825
|
+
this.onAuthError?.();
|
|
826
|
+
else if (res.status === 404 || res.status === 410) {
|
|
827
|
+
this.onError(`Recorded video segment ${seg.seq} is no longer available.`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
/* offline or aborted: `ensureAt` retries after the backoff window */
|
|
832
|
+
}
|
|
833
|
+
finally {
|
|
834
|
+
this.probing.delete(seg.startTs);
|
|
835
|
+
}
|
|
836
|
+
})();
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/** Segment identity within a lane is `startTs`: `seq` restarts per session. */
|
|
840
|
+
function sameSeg(a, b) {
|
|
841
|
+
return a !== null && b !== null && a.startTs === b.startTs;
|
|
842
|
+
}
|
|
843
|
+
//# sourceMappingURL=replay-engine.js.map
|