@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.
@@ -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