@agent-native/core 0.175.0 → 0.175.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.
Files changed (36) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/assets/app/root.tsx +5 -2
  3. package/corpus/templates/clips/actions/create-recording.ts +8 -1
  4. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +3 -1
  5. package/corpus/templates/clips/app/components/player/share-dialog.tsx +11 -0
  6. package/corpus/templates/clips/app/components/player/timestamped-comment-button.tsx +2 -41
  7. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +48 -4
  8. package/corpus/templates/clips/app/i18n/en-US.ts +1 -2
  9. package/corpus/templates/clips/app/lib/fmp4-seek.ts +274 -0
  10. package/corpus/templates/clips/app/lib/fmp4.ts +169 -0
  11. package/corpus/templates/clips/app/lib/mse-video-loader.ts +351 -109
  12. package/corpus/templates/clips/app/root.tsx +1 -1
  13. package/corpus/templates/clips/changelog/2026-08-25-seeking-into-a-long-recording-no-longer-sticks-on-buffering.md +6 -0
  14. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +103 -13
  15. package/corpus/templates/clips/server/lib/resumable-upload-provider.ts +2 -2
  16. package/corpus/templates/clips/server/lib/video-storage.ts +2 -2
  17. package/corpus/templates/forms/app/components/builder/FieldRenderer.tsx +162 -157
  18. package/corpus/templates/tasks/app/root.tsx +5 -2
  19. package/dist/collab/awareness.d.ts +2 -2
  20. package/dist/file-upload/builder.d.ts +2 -2
  21. package/dist/file-upload/builder.js +28 -26
  22. package/dist/file-upload/registry.js +11 -8
  23. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  24. package/dist/notifications/routes.d.ts +3 -3
  25. package/dist/observability/routes.d.ts +5 -5
  26. package/dist/secrets/routes.d.ts +9 -9
  27. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  28. package/dist/server/builder-api-auth.d.ts +31 -0
  29. package/dist/server/builder-api-auth.js +62 -0
  30. package/dist/server/builder-oauth.d.ts +5 -3
  31. package/dist/server/builder-oauth.js +40 -12
  32. package/dist/server/core-routes-plugin.js +5 -2
  33. package/dist/server/index.d.ts +2 -0
  34. package/dist/server/index.js +2 -0
  35. package/dist/server/transcribe-voice.d.ts +1 -1
  36. package/package.json +3 -3
@@ -15,18 +15,42 @@
15
15
  * ranges under memory pressure. Any unrecoverable failure calls `onFatal` so
16
16
  * the caller can drop back to the plain `<video src>` path.
17
17
  *
18
+ * Seeking reads each probed fragment's real `tfdt` timestamp rather than
19
+ * trusting a byte-fraction estimate. These recordings are variable-bitrate, so
20
+ * byte position and presentation time drift apart by tens of seconds over a
21
+ * long clip; appending a fragment that starts *after* `currentTime` leaves the
22
+ * element permanently unplayable, because MSE never jumps a forward gap on its
23
+ * own. See `seekToTime`.
24
+ *
18
25
  * The player component only ever sees a normal `HTMLVideoElement`; this class
19
26
  * drives it entirely through `video.src = objectUrl` + range fetches.
20
27
  */
21
28
 
22
- import { findMoofOffset, parseInitSegment } from "./fmp4";
29
+ import {
30
+ type Mp4Track,
31
+ findMoofOffset,
32
+ fragmentPtsSeconds,
33
+ parseInitSegment,
34
+ } from "./fmp4";
35
+ import { ByteTimeMap, resolveSeekFragment } from "./fmp4-seek";
23
36
 
24
37
  const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB sequential range reads
25
38
  const INIT_PROBE_SIZE = 512 * 1024; // enough to always contain ftyp+moov
26
- const SEEK_PROBE_SIZE = 4 * 1024 * 1024; // window scanned for a moof after seek
39
+ const SEEK_PROBE_SIZE = 1024 * 1024; // window scanned for a moof + its tfdt
27
40
  const BUFFER_AHEAD_SECONDS = 30; // download target ahead of currentTime
28
41
  const BUFFER_BEHIND_SECONDS = 10; // played media kept before evicting on quota
29
- const SEEK_BACKOFF_BYTES = 512 * 1024; // land a little early so we don't overshoot
42
+ /** Probe budget per seek. Bracketed interpolation converges in 2-3 in practice. */
43
+ const MAX_SEEK_PROBES = 6;
44
+ /**
45
+ * How far before the seek target we accept landing. The gap is downloaded and
46
+ * decoded before playback can resume, so a small number keeps seeks responsive;
47
+ * landing even slightly *past* the target is never acceptable.
48
+ */
49
+ const SEEK_ACCEPT_UNDERSHOOT_SECONDS = 4;
50
+ /** Realign retries before nudging `currentTime` into the buffer we do have. */
51
+ const MAX_REALIGN_ATTEMPTS = 3;
52
+ /** Overlap between probe steps, so a `moof` on a window boundary is not skipped. */
53
+ const PROBE_STEP_OVERLAP_BYTES = 4096;
30
54
 
31
55
  export interface MseVideoLoaderOptions {
32
56
  /** Asset URL. Range requests go straight here (external/proxied media). */
@@ -59,6 +83,18 @@ function isAbortError(err: unknown): boolean {
59
83
  return err instanceof DOMException && err.name === "AbortError";
60
84
  }
61
85
 
86
+ /**
87
+ * True when a ranged request came back as a whole-file `200` for a nonzero
88
+ * start, meaning the origin ignored `Range`. A `200` to a request starting at
89
+ * byte 0 is just the whole asset, which lines up fine.
90
+ */
91
+ export function rangeWasIgnored(
92
+ status: number,
93
+ requestedStart: number,
94
+ ): boolean {
95
+ return status === 200 && requestedStart > 0;
96
+ }
97
+
62
98
  export class MseVideoLoader {
63
99
  readonly objectUrl: string;
64
100
 
@@ -79,17 +115,26 @@ export class MseVideoLoader {
79
115
  private initLength = 0;
80
116
  private nextOffset = 0;
81
117
  private initAppended = false;
82
- private needsRealign = false;
83
118
  /** Duration (seconds) waiting to be written once the source buffer is idle. */
84
119
  private pendingDurationSec: number | null = null;
120
+ /** Track ids and timescales from the init segment, for reading `tfdt`. */
121
+ private tracks: Mp4Track[] = [];
122
+ /** Observed byte<->time anchors, built as the asset is read. */
123
+ private anchors: ByteTimeMap | null = null;
124
+ /** Seek target waiting to be resolved by the pump, in seconds. */
125
+ private pendingSeek: number | null = null;
85
126
  /**
86
- * Byte offset the current realign probe started from (the seek estimate,
87
- * then walked backward by `backUpRealignProbe`). Used to recover when a seek
88
- * estimate overshoots past the target fragment's `moof` into its `mdat`.
127
+ * Bumped on every `seeking` event, including one whose target is already
128
+ * buffered. A resolve in flight for an older target must not be allowed to
129
+ * finish and repoint the download cursor: the playhead has moved on, so the
130
+ * cursor would end up filling a region the playhead never reaches.
89
131
  */
90
- private realignProbeStart = 0;
91
- /** Media bytes appended so far (excludes the init segment). */
92
- private mediaBytesAppended = 0;
132
+ private seekGeneration = 0;
133
+ /** Target of the realigns counted by `realignAttempts`. */
134
+ private realignTarget: number | null = null;
135
+ private realignAttempts = 0;
136
+ /** Start time of the most recently appended fragment, for stall detection. */
137
+ private lastAppendSec: number | null = null;
93
138
 
94
139
  private destroyed = false;
95
140
  private pumping = false;
@@ -184,8 +229,8 @@ export class MseVideoLoader {
184
229
  // Reopened after `endOfStream()`: a seek into an evicted/unbuffered range
185
230
  // transitions the "ended" source back to "open". The pipeline is already
186
231
  // built, so just clear the EOF latch and resume fetching for the seek
187
- // (`onSeeking` has set `needsRealign`/`nextOffset`) instead of re-running
188
- // init (which would try to add a second source buffer).
232
+ // (`onSeeking` has set `pendingSeek`) instead of re-running init (which
233
+ // would try to add a second source buffer).
189
234
  if (this.initAppended) {
190
235
  this.eofReached = false;
191
236
  this.schedulePump();
@@ -205,6 +250,14 @@ export class MseVideoLoader {
205
250
  const parsed = parseInitSegment(first.bytes);
206
251
  if (!parsed) throw new Error("Could not parse fMP4 init segment");
207
252
  this.initLength = parsed.initLength;
253
+ this.tracks = parsed.tracks;
254
+ this.anchors = new ByteTimeMap(parsed.initLength);
255
+ if (this.tracks.length === 0) {
256
+ // Without timescales a fragment's tfdt cannot be converted to seconds,
257
+ // so seeking would be back to blind byte-fraction guessing. Fall back to
258
+ // the native pipeline rather than ship a player that cannot seek.
259
+ throw new Error("fMP4 init segment declares no readable tracks");
260
+ }
208
261
 
209
262
  const mime = `video/mp4; codecs="${parsed.codecs}"`;
210
263
  if (!window.MediaSource.isTypeSupported(mime)) {
@@ -229,8 +282,9 @@ export class MseVideoLoader {
229
282
  const fetchedEnd = first.bytes.byteLength;
230
283
  if (fetchedEnd > this.initLength) {
231
284
  const media = first.bytes.subarray(this.initLength);
285
+ const firstSec = this.recordAnchorAt(first.bytes, 0, this.initLength);
232
286
  await this.appendWithQuota(media);
233
- this.mediaBytesAppended += media.byteLength;
287
+ if (firstSec !== null) this.lastAppendSec = firstSec;
234
288
  }
235
289
  this.nextOffset = fetchedEnd;
236
290
  if (first.eof) this.eofReached = true;
@@ -250,39 +304,54 @@ export class MseVideoLoader {
250
304
  private onSeeking = (): void => {
251
305
  if (this.destroyed || !this.initAppended) return;
252
306
  const target = this.video.currentTime;
253
- if (this.isBuffered(target)) return; // already have this position
307
+ // Invalidate first, and for every seek: a target that is already buffered
308
+ // needs no resolve of its own, but it still has to cancel one in flight.
309
+ this.seekGeneration++;
310
+ if (this.isBuffered(target)) {
311
+ this.pendingSeek = null;
312
+ // Let the pump re-check whether its download cursor still feeds this
313
+ // playhead; the buffered range it landed in may not be the one growing.
314
+ this.schedulePump();
315
+ return;
316
+ }
254
317
 
255
318
  // Abort any in-flight sequential fetch so we can jump.
256
319
  this.currentFetch?.abort();
257
320
  this.eofReached = false;
258
321
 
259
- this.nextOffset = Math.max(
260
- this.initLength,
261
- this.estimateByteOffset(target),
262
- );
263
- this.realignProbeStart = this.nextOffset;
264
- this.needsRealign = true;
322
+ if (
323
+ this.realignTarget === null ||
324
+ Math.abs(this.realignTarget - target) > 1
325
+ ) {
326
+ this.realignTarget = target;
327
+ this.realignAttempts = 0;
328
+ }
329
+ this.pendingSeek = target;
265
330
  this.schedulePump();
266
331
  };
267
332
 
268
333
  /**
269
- * Recover a seek whose estimate overshot past the target fragment's `moof`
270
- * into its `mdat`. That can only happen inside the FINAL fragment (every
271
- * earlier one is followed by another `moof`), so a forward scan reaches EOF
272
- * with no boundary. Back the probe window up by one `SEEK_PROBE_SIZE`
273
- * (floored at the init segment) and re-scan, instead of treating EOF as a
274
- * completed seek and stalling. Returns false when there is nothing earlier
275
- * left to probe.
334
+ * Record a (byte -> fragment start time) anchor for the first `moof` at or
335
+ * after `searchFrom` within `bytes`, which was fetched from absolute offset
336
+ * `fetchStart`. Both offsets are required: defaulting either one silently
337
+ * files the anchor under the wrong byte position, which corrupts every later
338
+ * seek estimate rather than failing visibly.
339
+ *
340
+ * Returns that fragment's start time, or null when the chunk carries no
341
+ * readable fragment header.
276
342
  */
277
- private backUpRealignProbe(): boolean {
278
- if (this.realignProbeStart <= this.initLength) return false;
279
- this.realignProbeStart = Math.max(
280
- this.initLength,
281
- this.realignProbeStart - SEEK_PROBE_SIZE,
282
- );
283
- this.nextOffset = this.realignProbeStart;
284
- this.eofReached = false;
285
- return true;
343
+ private recordAnchorAt(
344
+ bytes: Uint8Array,
345
+ fetchStart: number,
346
+ searchFrom: number,
347
+ ): number | null {
348
+ const moof = findMoofOffset(bytes.subarray(searchFrom));
349
+ if (moof < 0) return null;
350
+ const at = searchFrom + moof;
351
+ const sec = fragmentPtsSeconds(bytes, at, this.tracks);
352
+ if (sec === null) return null;
353
+ this.anchors?.add(fetchStart + at, sec);
354
+ return sec;
286
355
  }
287
356
 
288
357
  private schedulePump(): void {
@@ -302,33 +371,38 @@ export class MseVideoLoader {
302
371
  this.sourceBuffer &&
303
372
  this.mediaSource.readyState === "open"
304
373
  ) {
374
+ if (this.pendingSeek !== null) {
375
+ const target = this.pendingSeek;
376
+ this.pendingSeek = null;
377
+ const resolved = await this.seekToTime(target);
378
+ if (this.destroyed) break;
379
+ if (!resolved) break;
380
+ continue;
381
+ }
382
+
305
383
  if (
306
384
  this.eofReached ||
307
385
  (this.totalKnown && this.nextOffset >= this.totalBytes)
308
386
  ) {
309
- // A realign that reached EOF without finding its boundary overshot
310
- // into the final fragment — back up and keep looking before ending.
311
- if (this.needsRealign && this.backUpRealignProbe()) {
312
- continue;
313
- }
314
- this.needsRealign = false;
315
387
  this.tryEndOfStream();
316
388
  break;
317
389
  }
318
- // Stop downloading once we're comfortably ahead — unless we still need
319
- // to realign to a fragment boundary after a seek.
320
- if (
321
- !this.needsRealign &&
322
- this.bufferedAhead() >= BUFFER_AHEAD_SECONDS
323
- ) {
390
+
391
+ // We are filling media the playhead cannot reach. Downloading further
392
+ // only widens the gap, so realign to the playhead instead of quietly
393
+ // streaming to EOF behind a spinner.
394
+ if (this.downloadIsDisjoint()) {
395
+ if (this.requestRealign(this.video.currentTime)) continue;
324
396
  break;
325
397
  }
326
398
 
399
+ // Stop downloading once we're comfortably ahead.
400
+ if (this.bufferedAhead() >= BUFFER_AHEAD_SECONDS) break;
401
+
327
402
  const chunkStart = this.nextOffset;
328
- const windowSize = this.needsRealign ? SEEK_PROBE_SIZE : CHUNK_SIZE;
329
403
  const chunkEnd = this.totalKnown
330
- ? Math.min(chunkStart + windowSize, this.totalBytes) - 1
331
- : chunkStart + windowSize - 1;
404
+ ? Math.min(chunkStart + CHUNK_SIZE, this.totalBytes) - 1
405
+ : chunkStart + CHUNK_SIZE - 1;
332
406
 
333
407
  let res: { bytes: Uint8Array; eof: boolean };
334
408
  try {
@@ -339,46 +413,14 @@ export class MseVideoLoader {
339
413
  }
340
414
  if (this.destroyed) break;
341
415
  if (res.bytes.byteLength === 0) {
342
- if (this.needsRealign && this.backUpRealignProbe()) {
343
- continue;
344
- }
345
416
  this.eofReached = true;
346
417
  this.tryEndOfStream();
347
418
  break;
348
419
  }
349
420
 
350
- let bytes = res.bytes;
351
- if (this.needsRealign) {
352
- const moof = findMoofOffset(bytes);
353
- if (moof < 0) {
354
- if (res.eof) {
355
- // Reached EOF with no boundary — the estimate overshot into the
356
- // final fragment's mdat. Back up and re-probe rather than
357
- // stalling the seek; give up only once nothing earlier remains.
358
- if (this.backUpRealignProbe()) {
359
- continue;
360
- }
361
- this.needsRealign = false;
362
- this.eofReached = true;
363
- this.tryEndOfStream();
364
- break;
365
- }
366
- // No fragment boundary in this window — advance and keep scanning.
367
- this.nextOffset = chunkStart + bytes.byteLength;
368
- continue;
369
- }
370
- bytes = bytes.subarray(moof);
371
- this.nextOffset = chunkStart + moof;
372
- this.needsRealign = false;
373
- // Reset the segment parser so it drops any partial fragment left over
374
- // from the aborted sequential append and treats these bytes as a
375
- // fresh media segment. Without this, appending a fragment from a new
376
- // byte position fails with CHUNK_DEMUXER_ERROR_APPEND_FAILED.
377
- this.abortParser();
378
- }
379
-
380
- await this.appendWithQuota(bytes);
381
- this.mediaBytesAppended += bytes.byteLength;
421
+ const chunkSec = this.recordAnchorAt(res.bytes, chunkStart, 0);
422
+ await this.appendWithQuota(res.bytes);
423
+ if (chunkSec !== null) this.lastAppendSec = chunkSec;
382
424
  this.nextOffset = chunkStart + res.bytes.byteLength;
383
425
  if (res.eof) this.eofReached = true;
384
426
  }
@@ -426,34 +468,225 @@ export class MseVideoLoader {
426
468
  return 0;
427
469
  }
428
470
 
429
- /** Highest buffered presentation time — how far into the media we've decoded. */
430
- private bufferedSeconds(): number {
431
- const buffered = this.sourceBuffer?.buffered;
432
- if (!buffered || buffered.length === 0) return 0;
433
- return buffered.end(buffered.length - 1);
471
+ private estimateByteOffset(targetSec: number): number {
472
+ if (!this.anchors) return this.initLength;
473
+ return this.anchors.estimate(targetSec, {
474
+ totalBytes: this.totalKnown ? this.totalBytes : null,
475
+ durationSec: this.opts.durationMs / 1000,
476
+ });
477
+ }
478
+
479
+ private clampProbeStart(byte: number): number {
480
+ const floor = Math.max(this.initLength, Math.floor(byte));
481
+ if (!this.totalKnown) return floor;
482
+ return Math.min(
483
+ floor,
484
+ Math.max(this.initLength, this.totalBytes - SEEK_PROBE_SIZE),
485
+ );
434
486
  }
435
487
 
436
488
  /**
437
- * Estimate the byte offset for a seek target. Uses the known total when we
438
- * have it, otherwise the observed average bitrate (bytes of media appended per
439
- * second buffered). Lands `SEEK_BACKOFF_BYTES` early so we don't overshoot the
440
- * target fragment; the caller realigns forward to the next `moof`.
489
+ * Fetch a window at `startByte` and return the first fragment in it whose
490
+ * timestamp is readable, walking forward a couple of windows when a fragment
491
+ * is larger than one probe. Records the (byte -> time) pair as an anchor.
441
492
  */
442
- private estimateByteOffset(targetSec: number): number {
443
- const durationSec = this.opts.durationMs / 1000;
444
- if (this.totalKnown && durationSec > 0) {
445
- const frac = Math.min(Math.max(targetSec / durationSec, 0), 1);
446
- return Math.floor(this.totalBytes * frac) - SEEK_BACKOFF_BYTES;
493
+ private async probeFragmentAt(startByte: number): Promise<{
494
+ fetchStart: number;
495
+ bytes: Uint8Array;
496
+ moof: number;
497
+ byte: number;
498
+ sec: number;
499
+ } | null> {
500
+ let start = this.clampProbeStart(startByte);
501
+ for (let step = 0; step < 3; step++) {
502
+ const end = this.totalKnown
503
+ ? Math.min(start + SEEK_PROBE_SIZE, this.totalBytes) - 1
504
+ : start + SEEK_PROBE_SIZE - 1;
505
+ const res = await this.fetchRange(start, end);
506
+ if (this.destroyed || res.bytes.byteLength === 0) return null;
507
+
508
+ const moof = findMoofOffset(res.bytes);
509
+ if (moof >= 0) {
510
+ const sec = fragmentPtsSeconds(res.bytes, moof, this.tracks);
511
+ if (sec !== null) {
512
+ this.anchors?.add(start + moof, sec);
513
+ return {
514
+ fetchStart: start,
515
+ bytes: res.bytes,
516
+ moof,
517
+ byte: start + moof,
518
+ sec,
519
+ };
520
+ }
521
+ }
522
+ if (res.eof) return null;
523
+ // Overlap the step: a `moof` header straddling the window boundary is
524
+ // unreadable in this window, and stepping the full width would skip past
525
+ // that fragment entirely.
526
+ start += Math.max(1, res.bytes.byteLength - PROBE_STEP_OVERLAP_BYTES);
527
+ }
528
+ return null;
529
+ }
530
+
531
+ /**
532
+ * Resolve a seek by reading real fragment timestamps (see `fmp4-seek`),
533
+ * append from the fragment it picks, and leave the sequential pump to fill
534
+ * forward from there.
535
+ *
536
+ * Returns false when the pump should stop (destroyed, or reported fatal).
537
+ */
538
+ private async seekToTime(target: number): Promise<boolean> {
539
+ const generation = this.seekGeneration;
540
+ let resolution;
541
+ try {
542
+ resolution = await resolveSeekFragment({
543
+ target,
544
+ initLength: this.initLength,
545
+ probeSize: SEEK_PROBE_SIZE,
546
+ maxProbes: MAX_SEEK_PROBES,
547
+ acceptUndershootSeconds: SEEK_ACCEPT_UNDERSHOOT_SECONDS,
548
+ estimate: () => this.estimateByteOffset(target),
549
+ probe: (startByte) => this.probeFragmentAt(startByte),
550
+ superseded: () =>
551
+ this.destroyed ||
552
+ this.pendingSeek !== null ||
553
+ this.seekGeneration !== generation,
554
+ });
555
+ } catch (err) {
556
+ if (isAbortError(err)) return !this.destroyed; // superseded by a new seek
557
+ throw err;
447
558
  }
448
- const bufferedSec = this.bufferedSeconds();
449
- if (bufferedSec > 0 && this.mediaBytesAppended > 0) {
450
- const bytesPerSec = this.mediaBytesAppended / bufferedSec;
451
- return (
452
- Math.floor(this.initLength + targetSec * bytesPerSec) -
453
- SEEK_BACKOFF_BYTES
559
+ if (this.destroyed) return false;
560
+ // A newer seek arrived; the pump loop picks it up on the next iteration.
561
+ // Checked again here because the resolve may have completed in the same
562
+ // tick the new seek arrived — appending now would aim the download cursor
563
+ // at a target the viewer has already left.
564
+ if (resolution.superseded || this.seekGeneration !== generation)
565
+ return true;
566
+
567
+ const chosen = resolution.chosen;
568
+ if (!chosen) {
569
+ this.fail(
570
+ new Error(`No fMP4 fragment found for a seek to ${target.toFixed(2)}s`),
454
571
  );
572
+ return false;
573
+ }
574
+
575
+ this.eofReached = false;
576
+ // Reset the segment parser so it drops any partial fragment left over from
577
+ // the aborted sequential append and treats these bytes as a fresh media
578
+ // segment. Without this, appending a fragment from a new byte position
579
+ // fails with CHUNK_DEMUXER_ERROR_APPEND_FAILED.
580
+ this.abortParser();
581
+ await this.appendWithQuota(chosen.bytes.subarray(chosen.moof));
582
+ this.lastAppendSec = chosen.sec;
583
+ this.nextOffset = chosen.fetchStart + chosen.bytes.byteLength;
584
+
585
+ if (resolution.overshot) {
586
+ // Every probe landed past the target — a target beyond the last fragment,
587
+ // or a tail we cannot parse. Move the playhead onto media we actually
588
+ // hold instead of leaving it on a position that will never arrive.
589
+ this.nudgeCurrentTimeTo(chosen.sec);
590
+ return true;
591
+ }
592
+
593
+ // A resolved seek clears the realign budget: the next stall, if any, is a
594
+ // new problem and deserves its own retries.
595
+ this.realignTarget = null;
596
+ this.realignAttempts = 0;
597
+ return true;
598
+ }
599
+
600
+ /**
601
+ * True when the download cursor is filling media the playhead cannot reach:
602
+ * either the playhead has nothing buffered at all, or it sits in a range that
603
+ * we are not extending, so that range will run out with nothing behind it.
604
+ *
605
+ * Checking only "playhead unbuffered" is not enough. After a seek back into
606
+ * an already-buffered stretch, the playhead is buffered while the cursor is
607
+ * still filling somewhere else entirely — playback then runs to the end of
608
+ * its own range and stops, which looks exactly like the stall this loader
609
+ * exists to prevent.
610
+ */
611
+ private downloadIsDisjoint(): boolean {
612
+ if (this.lastAppendSec === null) return false;
613
+ const t = this.video.currentTime;
614
+ const end = this.bufferedEndAt(t);
615
+ if (end === null) return true; // nothing buffered at the playhead
616
+ return this.lastAppendSec > end + 1;
617
+ }
618
+
619
+ /** End of the buffered range containing `time`, or null when unbuffered. */
620
+ private bufferedEndAt(time: number): number | null {
621
+ const buffered = this.sourceBuffer?.buffered;
622
+ if (!buffered) return null;
623
+ for (let i = 0; i < buffered.length; i++) {
624
+ if (time >= buffered.start(i) - 0.25 && time <= buffered.end(i)) {
625
+ return buffered.end(i);
626
+ }
627
+ }
628
+ return null;
629
+ }
630
+
631
+ /**
632
+ * Queue another seek resolution for `target`. Returns false once the retry
633
+ * budget is spent, having either moved the playhead onto media we hold or
634
+ * reported a fatal — never leaving the pump stopped with the playhead parked
635
+ * on an unreachable position and no pending work to fix it.
636
+ */
637
+ private requestRealign(target: number): boolean {
638
+ if (
639
+ this.realignTarget === null ||
640
+ Math.abs(this.realignTarget - target) > 1
641
+ ) {
642
+ this.realignTarget = target;
643
+ this.realignAttempts = 0;
644
+ }
645
+ if (this.realignAttempts >= MAX_REALIGN_ATTEMPTS) {
646
+ const start = this.firstBufferedStartAfter(target);
647
+ if (start === null) {
648
+ this.fail(
649
+ new Error(
650
+ `Playback stalled at ${target.toFixed(2)}s with no buffered media to resume from`,
651
+ ),
652
+ );
653
+ } else {
654
+ this.nudgeCurrentTimeTo(start);
655
+ }
656
+ return false;
657
+ }
658
+ this.realignAttempts++;
659
+ this.pendingSeek = target;
660
+ return true;
661
+ }
662
+
663
+ private firstBufferedStartAfter(time: number): number | null {
664
+ const buffered = this.sourceBuffer?.buffered;
665
+ if (!buffered) return null;
666
+ for (let i = 0; i < buffered.length; i++) {
667
+ if (buffered.start(i) > time) return buffered.start(i);
668
+ }
669
+ return null;
670
+ }
671
+
672
+ /**
673
+ * Move the playhead onto buffered media. The resulting `seeking` event finds
674
+ * the position already buffered and returns early, so this cannot re-enter
675
+ * the seek resolver.
676
+ */
677
+ private nudgeCurrentTimeTo(sec: number): void {
678
+ if (!Number.isFinite(sec) || sec < 0) {
679
+ this.fail(new Error(`Cannot nudge the playhead to ${sec}`));
680
+ return;
681
+ }
682
+ try {
683
+ this.video.currentTime = sec + 0.05;
684
+ } catch (err) {
685
+ // Terminal safety net: without the nudge the element stays parked on an
686
+ // unreachable time, which is the stall this path exists to prevent.
687
+ // Report so the caller drops back to the native `<video src>` pipeline.
688
+ this.fail(err);
455
689
  }
456
- return this.initLength;
457
690
  }
458
691
 
459
692
  private isBuffered(time: number): boolean {
@@ -569,6 +802,15 @@ export class MseVideoLoader {
569
802
  if (!res.ok) {
570
803
  throw new Error(`Range request failed: ${res.status}`);
571
804
  }
805
+ if (rangeWasIgnored(res.status, start)) {
806
+ // A 200 to a ranged request is the whole file, so its bytes start at 0 —
807
+ // not at `start`. Reading it as though they lined up files every anchor
808
+ // and the resume offset under the wrong byte position, poisoning later
809
+ // seeks. An origin that ignores Range also defeats the point of this
810
+ // loader (each 2MB window would pull the entire asset), so hand back to
811
+ // the native pipeline instead of trying to compensate.
812
+ throw new Error("Origin ignored the Range header; cannot stream windows");
813
+ }
572
814
  // If the backing file shrank mid-response (ERR_CONTENT_LENGTH_MISMATCH),
573
815
  // arrayBuffer() throws here, which also routes through fail() -> onFatal.
574
816
  const buffer = await res.arrayBuffer();
@@ -584,8 +826,8 @@ export class MseVideoLoader {
584
826
  }
585
827
 
586
828
  const requested = end - start + 1;
587
- // A 200 (server ignored Range) or a short 206 both mean this response ran to
588
- // the end of the asset.
829
+ // A 200 (only reachable from a start of 0, per the guard above, so it is
830
+ // the whole asset) or a short 206 both mean this response ran to the end.
589
831
  const eof =
590
832
  res.status === 200 ||
591
833
  bytes.byteLength < requested ||
@@ -388,7 +388,6 @@ function AppContent() {
388
388
  )}
389
389
  {standalonePublic ? null : <DevOverlay />}
390
390
  <Outlet />
391
- <Toaster richColors position="bottom-left" />
392
391
  </>
393
392
  );
394
393
  }
@@ -408,6 +407,7 @@ export default function Root() {
408
407
  <AppProviders
409
408
  queryClient={queryClient}
410
409
  isPublicPath={isStandalonePublicPath(location.pathname)}
410
+ toaster={<Toaster richColors position="bottom-left" />}
411
411
  i18n={{
412
412
  catalog: i18nCatalog,
413
413
  initialLocale: loaderData.locale,
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-25
4
+ ---
5
+
6
+ Skipping to the middle or end of a long recording no longer sticks on buffering