movi-player 0.3.0 → 0.3.2

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 (61) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +54 -7
  3. package/dist/core/MoviPlayer.d.ts +77 -1
  4. package/dist/core/MoviPlayer.d.ts.map +1 -1
  5. package/dist/core/MoviPlayer.js +667 -121
  6. package/dist/core/MoviPlayer.js.map +1 -1
  7. package/dist/core/PlayerState.js +1 -1
  8. package/dist/core/PlayerState.js.map +1 -1
  9. package/dist/decode/VideoDecoder.d.ts.map +1 -1
  10. package/dist/decode/VideoDecoder.js +6 -2
  11. package/dist/decode/VideoDecoder.js.map +1 -1
  12. package/dist/demuxer.cjs +315 -129
  13. package/dist/demuxer.js +377 -191
  14. package/dist/element.cjs +58309 -16596
  15. package/dist/element.js +59520 -17807
  16. package/dist/index.cjs +58309 -16596
  17. package/dist/index.js +59520 -17807
  18. package/dist/player.cjs +56818 -15360
  19. package/dist/player.js +57818 -16360
  20. package/dist/render/DASHPlayerWrapper.d.ts +65 -0
  21. package/dist/render/DASHPlayerWrapper.d.ts.map +1 -0
  22. package/dist/render/DASHPlayerWrapper.js +540 -0
  23. package/dist/render/DASHPlayerWrapper.js.map +1 -0
  24. package/dist/render/HLSPlayerWrapper.d.ts.map +1 -1
  25. package/dist/render/HLSPlayerWrapper.js +47 -11
  26. package/dist/render/HLSPlayerWrapper.js.map +1 -1
  27. package/dist/render/MoviElement.d.ts +97 -0
  28. package/dist/render/MoviElement.d.ts.map +1 -1
  29. package/dist/render/MoviElement.js +852 -152
  30. package/dist/render/MoviElement.js.map +1 -1
  31. package/dist/render/ShakaPlayerWrapper.d.ts +111 -0
  32. package/dist/render/ShakaPlayerWrapper.d.ts.map +1 -0
  33. package/dist/render/ShakaPlayerWrapper.js +956 -0
  34. package/dist/render/ShakaPlayerWrapper.js.map +1 -0
  35. package/dist/source/DashFallback.d.ts +32 -0
  36. package/dist/source/DashFallback.d.ts.map +1 -0
  37. package/dist/source/DashFallback.js +142 -0
  38. package/dist/source/DashFallback.js.map +1 -0
  39. package/dist/source/DashManifest.d.ts +34 -0
  40. package/dist/source/DashManifest.d.ts.map +1 -0
  41. package/dist/source/DashManifest.js +194 -0
  42. package/dist/source/DashManifest.js.map +1 -0
  43. package/dist/source/DashSegmentSource.d.ts +32 -0
  44. package/dist/source/DashSegmentSource.d.ts.map +1 -0
  45. package/dist/source/DashSegmentSource.js +86 -0
  46. package/dist/source/DashSegmentSource.js.map +1 -0
  47. package/dist/source/HttpSource.d.ts +64 -0
  48. package/dist/source/HttpSource.d.ts.map +1 -1
  49. package/dist/source/HttpSource.js +444 -57
  50. package/dist/source/HttpSource.js.map +1 -1
  51. package/dist/source/ThumbnailHttpSource.d.ts +13 -0
  52. package/dist/source/ThumbnailHttpSource.d.ts.map +1 -1
  53. package/dist/source/ThumbnailHttpSource.js +70 -8
  54. package/dist/source/ThumbnailHttpSource.js.map +1 -1
  55. package/dist/source/index.d.ts +2 -0
  56. package/dist/source/index.d.ts.map +1 -1
  57. package/dist/source/index.js +1 -0
  58. package/dist/source/index.js.map +1 -1
  59. package/dist/types.d.ts +10 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +16 -6
@@ -64,6 +64,27 @@ export class HttpSource {
64
64
  // True when the entire file fits in the buffer and has been fully downloaded.
65
65
  // In this state, all reads can be served from memory — no re-fetching needed.
66
66
  fullyBuffered = false;
67
+ // Set once we've confirmed the server ignores Range (returns 200, not 206).
68
+ // From then on there is exactly one stream — the full file from byte 0 — and
69
+ // we must never restart it at a non-zero offset (the server would resend from
70
+ // the start, mis-aligning the buffer).
71
+ rangeUnsupported = false;
72
+ // Subset of rangeUnsupported: the file is larger than the buffer cap (or its
73
+ // size is unknown), so it can't be held whole in memory. We run a bounded
74
+ // forward-only sliding window — playback is linear, seeking is impossible.
75
+ linearMode = false;
76
+ // Fired once when we enter linearMode, so the UI can disable seek/thumbnails/
77
+ // the timeline. Wired up by MoviPlayer.createSource.
78
+ onLinearMode = null;
79
+ // Forward high-water mark of successful reads (the demuxer's furthest read
80
+ // point). Monotonic — unlike `position`, an internal rewind doesn't pull it
81
+ // back — so the linear window can keep ~trail of history behind the frontier.
82
+ readMax = 0;
83
+ // Ring of recent read offsets (linear mode). Their minimum approximates the
84
+ // lowest offset any demuxer stream / a just-happened backward seek still
85
+ // needs, so the sliding window never drops bytes a live reader is using —
86
+ // even when streams sit far apart in the file or a rewind just occurred.
87
+ recentReads = [];
67
88
  // Force restart tracking (to prevent cascading failures)
68
89
  consecutiveForceRestarts = 0;
69
90
  lastForceRestartTime = 0;
@@ -114,6 +135,30 @@ export class HttpSource {
114
135
  this.resizeBuffer(calculatedBufferSize);
115
136
  }
116
137
  }
138
+ /**
139
+ * Register a callback fired once when the source falls back to linear
140
+ * (forward-only, non-seekable) playback because the server has no Range
141
+ * support and the file is too large to cache whole. The UI uses this to
142
+ * hide the timeline and disable seeking/thumbnails.
143
+ */
144
+ setOnLinearMode(cb) {
145
+ this.onLinearMode = cb;
146
+ // Already linear (callback registered late)? Fire immediately.
147
+ if (this.linearMode)
148
+ cb();
149
+ }
150
+ /** True once the source is in forward-only linear (non-seekable) playback. */
151
+ isLinearMode() {
152
+ return this.linearMode;
153
+ }
154
+ /**
155
+ * True once we've confirmed the server has no Range support (covers both the
156
+ * full-cache and the linear fallback). Callers use it to skip features that
157
+ * need scattered random-access reads (e.g. the thumbnail pipeline).
158
+ */
159
+ isRangeUnsupported() {
160
+ return this.rangeUnsupported;
161
+ }
117
162
  /**
118
163
  * Resize buffer based on file size (3% of file, clamped to min/max)
119
164
  */
@@ -234,63 +279,169 @@ export class HttpSource {
234
279
  * throw on failure.
235
280
  */
236
281
  async resolveSize() {
237
- const response = await fetch(this.url, {
238
- method: "HEAD",
239
- headers: await this.buildRequestHeaders(),
240
- });
241
- if (!response.ok) {
242
- if (response.status === 403)
243
- throw new Error("Access denied. Check video permissions.");
244
- if (response.status === 401)
245
- throw new Error("Authentication required.");
246
- if (response.status === 404)
247
- throw new Error("Video not found.");
248
- throw new Error(`HTTP ${response.status}`);
249
- }
250
- const contentLength = response.headers.get("Content-Length");
251
- if (!contentLength)
252
- throw new Error("Content-Length missing");
253
- // Extract filename from Content-Disposition header (e.g., "attachment; filename=\"video.mp4\"")
254
- const disposition = response.headers.get("Content-Disposition");
255
- if (disposition) {
256
- // Try filename*= (RFC 5987 encoded) first, then filename=.
257
- // Per RFC 5987 the value after `UTF-8''` should be percent-encoded
258
- // (so spaces become %20), but some CDNs (fsl-buckets.life seen in
259
- // the wild) ship the filename with raw spaces. The old `[^;\s]+`
260
- // greedy was stopping at the first space — capturing only "The"
261
- // from "The Super Mario ...mkv". Capture up to `;` or end-of-string
262
- // and trim, so both compliant and lazy servers work.
263
- let filenameMatch = disposition.match(/filename\*\s*=\s*(?:UTF-8''|utf-8'')([^;]+)/i);
264
- if (filenameMatch) {
265
- try {
266
- this._contentDispositionFilename = decodeURIComponent(filenameMatch[1].trim());
282
+ // CDNs intermittently strip Content-Length from a HEAD (and the ranged-GET
283
+ // fallback can transiently flake on a cold/concurrent path), so a single
284
+ // attempt occasionally fails for a file that's perfectly fine. Retry a few
285
+ // times before giving up; only auth/not-found (4xx) errors are fatal.
286
+ const MAX_ATTEMPTS = 4;
287
+ let lastError = new Error("Content-Length missing");
288
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
289
+ try {
290
+ const response = await fetch(this.url, {
291
+ method: "HEAD",
292
+ headers: await this.buildRequestHeaders(),
293
+ });
294
+ if (!response.ok) {
295
+ if (response.status === 403)
296
+ throw new Error("Access denied. Check video permissions.");
297
+ if (response.status === 401)
298
+ throw new Error("Authentication required.");
299
+ if (response.status === 404)
300
+ throw new Error("Video not found.");
301
+ // 5xx / 429 etc. retryable.
302
+ lastError = new Error(`HTTP ${response.status}`);
267
303
  }
268
- catch {
269
- this._contentDispositionFilename = filenameMatch[1].trim();
304
+ else {
305
+ // Read the download filename off the HEAD response.
306
+ this.setFilenameFromDisposition(response.headers.get("Content-Disposition"));
307
+ const contentLength = response.headers.get("Content-Length");
308
+ if (contentLength)
309
+ return parseInt(contentLength, 10);
310
+ // HEAD had no Content-Length — recover the total from a 1-byte ranged
311
+ // GET's Content-Range ("bytes 0-0/<total>").
312
+ const sizeViaRange = await this.resolveSizeViaRange();
313
+ if (sizeViaRange !== null)
314
+ return sizeViaRange;
315
+ // Last resort: a plain (un-ranged) GET. Some servers strip
316
+ // Content-Length on HEAD and don't CORS-expose Content-Range on a
317
+ // 206, yet still send Content-Length on a full 200 response.
318
+ const sizeViaGet = await this.resolveSizeViaPlainGet();
319
+ if (sizeViaGet !== null)
320
+ return sizeViaGet;
321
+ lastError = new Error("Content-Length missing");
270
322
  }
271
323
  }
272
- if (!this._contentDispositionFilename) {
273
- // Try quoted filename first (allows spaces inside quotes)
274
- filenameMatch = disposition.match(/filename\s*=\s*"([^"]+)"/i);
275
- if (!filenameMatch) {
276
- // Unquoted: capture everything until semicolon or end, then trim
277
- filenameMatch = disposition.match(/filename\s*=\s*([^;]+)/i);
278
- }
279
- if (filenameMatch) {
280
- const raw = filenameMatch[1].trim();
281
- try {
282
- this._contentDispositionFilename = decodeURIComponent(raw);
283
- }
284
- catch {
285
- this._contentDispositionFilename = raw;
286
- }
324
+ catch (err) {
325
+ // Auth / not-found are definitive don't waste retries on them.
326
+ const msg = err?.message || "";
327
+ if (/Access denied|Authentication required|Video not found/.test(msg)) {
328
+ throw err;
287
329
  }
330
+ lastError = err instanceof Error ? err : new Error(String(err));
288
331
  }
289
- if (this._contentDispositionFilename) {
290
- Logger.debug(TAG, `Content-Disposition filename: ${this._contentDispositionFilename}`);
332
+ if (attempt < MAX_ATTEMPTS - 1) {
333
+ await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
291
334
  }
292
335
  }
293
- return parseInt(contentLength, 10);
336
+ throw lastError;
337
+ }
338
+ /**
339
+ * Recover the total file size from a 1-byte ranged GET when HEAD didn't
340
+ * carry Content-Length. Reads the total out of Content-Range; returns null
341
+ * if the server gives us nothing usable. The body is cancelled immediately —
342
+ * we only want headers, and a server that ignores Range would otherwise
343
+ * start streaming the whole file.
344
+ */
345
+ async resolveSizeViaRange() {
346
+ let res;
347
+ try {
348
+ res = await fetch(this.url, {
349
+ method: "GET",
350
+ headers: await this.buildRequestHeaders({ offset: 0, length: 1 }),
351
+ });
352
+ }
353
+ catch {
354
+ return null;
355
+ }
356
+ res.body?.cancel().catch(() => { });
357
+ if (!res.ok && res.status !== 206)
358
+ return null;
359
+ const contentRange = res.headers.get("Content-Range");
360
+ if (contentRange) {
361
+ // "bytes 0-0/12345678" — capture the total after the slash (skip "*").
362
+ const m = /\/\s*(\d+)\s*$/.exec(contentRange);
363
+ if (m)
364
+ return parseInt(m[1], 10);
365
+ }
366
+ // Server ignored Range and answered 200, but still reported a length.
367
+ const cl = res.headers.get("Content-Length");
368
+ if (res.status === 200 && cl)
369
+ return parseInt(cl, 10);
370
+ return null;
371
+ }
372
+ /**
373
+ * Last-resort size probe: a plain (un-ranged) GET. Catches servers that strip
374
+ * Content-Length on HEAD and don't CORS-expose Content-Range on a 206, yet
375
+ * still send Content-Length on a full 200 response. The body is cancelled the
376
+ * moment headers are in, so nothing past the headers is downloaded.
377
+ */
378
+ async resolveSizeViaPlainGet() {
379
+ let res;
380
+ try {
381
+ res = await fetch(this.url, {
382
+ method: "GET",
383
+ headers: await this.buildRequestHeaders(),
384
+ });
385
+ }
386
+ catch {
387
+ return null;
388
+ }
389
+ res.body?.cancel().catch(() => { });
390
+ if (!res.ok)
391
+ return null;
392
+ const cl = res.headers.get("Content-Length");
393
+ if (res.status === 200 && cl)
394
+ return parseInt(cl, 10);
395
+ // A 206 (server forced a default range) still carries the total in
396
+ // Content-Range, when it's exposed.
397
+ const contentRange = res.headers.get("Content-Range");
398
+ if (contentRange) {
399
+ const m = /\/\s*(\d+)\s*$/.exec(contentRange);
400
+ if (m)
401
+ return parseInt(m[1], 10);
402
+ }
403
+ return null;
404
+ }
405
+ /** Pull a download filename out of a Content-Disposition header, if present. */
406
+ setFilenameFromDisposition(disposition) {
407
+ if (!disposition)
408
+ return;
409
+ // Try filename*= (RFC 5987 encoded) first, then filename=.
410
+ // Per RFC 5987 the value after `UTF-8''` should be percent-encoded
411
+ // (so spaces become %20), but some CDNs (fsl-buckets.life seen in
412
+ // the wild) ship the filename with raw spaces. The old `[^;\s]+`
413
+ // greedy was stopping at the first space — capturing only "The"
414
+ // from "The Super Mario ...mkv". Capture up to `;` or end-of-string
415
+ // and trim, so both compliant and lazy servers work.
416
+ let filenameMatch = disposition.match(/filename\*\s*=\s*(?:UTF-8''|utf-8'')([^;]+)/i);
417
+ if (filenameMatch) {
418
+ try {
419
+ this._contentDispositionFilename = decodeURIComponent(filenameMatch[1].trim());
420
+ }
421
+ catch {
422
+ this._contentDispositionFilename = filenameMatch[1].trim();
423
+ }
424
+ }
425
+ if (!this._contentDispositionFilename) {
426
+ // Try quoted filename first (allows spaces inside quotes)
427
+ filenameMatch = disposition.match(/filename\s*=\s*"([^"]+)"/i);
428
+ if (!filenameMatch) {
429
+ // Unquoted: capture everything until semicolon or end, then trim
430
+ filenameMatch = disposition.match(/filename\s*=\s*([^;]+)/i);
431
+ }
432
+ if (filenameMatch) {
433
+ const raw = filenameMatch[1].trim();
434
+ try {
435
+ this._contentDispositionFilename = decodeURIComponent(raw);
436
+ }
437
+ catch {
438
+ this._contentDispositionFilename = raw;
439
+ }
440
+ }
441
+ }
442
+ if (this._contentDispositionFilename) {
443
+ Logger.debug(TAG, `Content-Disposition filename: ${this._contentDispositionFilename}`);
444
+ }
294
445
  }
295
446
  /**
296
447
  * Build the HTTP headers used for every outbound request (HEAD, range
@@ -552,21 +703,35 @@ export class HttpSource {
552
703
  // If server returns 200, it may be a CDN cache warming issue (e.g. Cloudflare first hit)
553
704
  // Retry a few times before treating as fatal — CDN often supports range after caching the file
554
705
  if (response.status === 200) {
555
- // Abort the 200 response body to prevent downloading entire file
556
- try {
557
- response.body?.cancel();
558
- }
559
- catch { }
706
+ // CDN cache-warming (e.g. Cloudflare's first hit) sometimes answers
707
+ // 200, then 206 once the file is cached — retry a few times before
708
+ // concluding the server truly lacks Range support.
560
709
  rangeRetryCount++;
561
710
  if (rangeRetryCount <= MAX_RANGE_RETRIES) {
711
+ try {
712
+ response.body?.cancel();
713
+ }
714
+ catch { }
562
715
  Logger.warn(TAG, `Server returned 200 instead of 206 (attempt ${rangeRetryCount}/${MAX_RANGE_RETRIES}). ` +
563
716
  `CDN may be caching — retrying in ${RANGE_RETRY_DELAY}ms...`);
564
717
  await new Promise(r => setTimeout(r, RANGE_RETRY_DELAY));
565
718
  continue; // Retry the fetch loop
566
719
  }
567
- // Exhausted retries fatal
720
+ // Retries exhausted no Range support. For the initial 0-based
721
+ // stream we can still play by consuming the whole body sequentially
722
+ // (full-cache if it fits the cap, else bounded linear mode). A
723
+ // non-zero offset means a seek, which can't be served without Range.
724
+ if (startOffset === 0) {
725
+ Logger.warn(TAG, `No Range support after ${MAX_RANGE_RETRIES} retries — falling back to sequential playback.`);
726
+ await this.consumeNonRangeStream(response);
727
+ return; // consumeNonRangeStream drives the buffer to EOF + clears streaming
728
+ }
729
+ try {
730
+ response.body?.cancel();
731
+ }
732
+ catch { }
568
733
  const rangeError = new Error("Server does not support range requests.");
569
- Logger.error(TAG, `Server returned 200 after ${MAX_RANGE_RETRIES} retries. Range requests not supported.`);
734
+ Logger.error(TAG, `Server returned 200 for offset ${startOffset}; range requests not supported.`);
570
735
  this.abortController?.abort();
571
736
  this.atomicSetStreaming(false);
572
737
  this.streamError = rangeError;
@@ -864,6 +1029,192 @@ export class HttpSource {
864
1029
  this.reader = null;
865
1030
  }
866
1031
  }
1032
+ /**
1033
+ * Consume a single 200 response as the whole file from byte 0, used when the
1034
+ * server has no Range support. Two outcomes:
1035
+ * - File fits the buffer cap → cache it entirely → full random access
1036
+ * (seek/thumbnails keep working once downloaded).
1037
+ * - File exceeds the cap (or size unknown) → bounded forward-only sliding
1038
+ * window (linearMode): playback is linear, seeking impossible. The UI is
1039
+ * notified via onLinearMode so it can hide the timeline.
1040
+ * The response body MUST start at byte 0 (caller guarantees startOffset===0).
1041
+ */
1042
+ async consumeNonRangeStream(response) {
1043
+ this.rangeUnsupported = true;
1044
+ // Grow the buffer toward the cap: a ≤cap file caches whole, an over-cap
1045
+ // file gets as large a linear window as the cap allows. resizeBuffer
1046
+ // reallocates the SharedArrayBuffer, which resets the VERSION counter in
1047
+ // the fresh header — an in-flight waitForData() from the first read would
1048
+ // then read a different version and bail as "superseded". Preserve VERSION
1049
+ // across the realloc so that waiter keeps going.
1050
+ if (this.size > 0) {
1051
+ const ver = this.useSharedBuffer && this.headerView
1052
+ ? Atomics.load(this.headerView, HEADER.VERSION)
1053
+ : 0;
1054
+ this.resizeBuffer(this.size);
1055
+ if (this.useSharedBuffer && this.headerView) {
1056
+ Atomics.store(this.headerView, HEADER.VERSION, ver);
1057
+ }
1058
+ }
1059
+ const buffer = this.getBuffer();
1060
+ const fullFit = this.size > 0 && this.bufferSize >= this.size;
1061
+ if (!fullFit) {
1062
+ this.linearMode = true;
1063
+ Logger.warn(TAG, `Linear (non-seekable) playback: ${this.size > 0 ? (this.size / 1048576).toFixed(0) + "MB" : "unknown size"} ` +
1064
+ `exceeds the ${this.maxBufferSizeMB}MB cache cap or size is unknown.`);
1065
+ try {
1066
+ this.onLinearMode?.();
1067
+ }
1068
+ catch { }
1069
+ }
1070
+ else {
1071
+ Logger.info(TAG, `Caching entire ${(this.size / 1048576).toFixed(1)}MB file in memory (no Range support).`);
1072
+ }
1073
+ // The body represents [0, size). Reset the window to the start. NOTE: do
1074
+ // NOT bump the version here — this is a continuation of the same 0-based
1075
+ // stream startStream() already opened, and an in-flight waitForData() from
1076
+ // the first read would treat a version change as "superseded" and bail,
1077
+ // tearing down this consume loop.
1078
+ this.atomicSetBufferStart(0);
1079
+ this.atomicSetWritePos(0);
1080
+ this.maxBufferedEnd = 0;
1081
+ this.fullyBuffered = false;
1082
+ this.atomicSetStreaming(true);
1083
+ if (!response.body) {
1084
+ this.streamError = new Error("Empty response body");
1085
+ this.atomicSetStreaming(false);
1086
+ return;
1087
+ }
1088
+ const reader = response.body.getReader();
1089
+ this.reader = reader;
1090
+ try {
1091
+ while (this.atomicIsStreaming()) {
1092
+ const { done, value } = await reader.read();
1093
+ if (done)
1094
+ break;
1095
+ if (!value || value.length === 0)
1096
+ continue;
1097
+ this.totalBytesDownloaded += value.length;
1098
+ await this.writeSequential(value, buffer, fullFit);
1099
+ }
1100
+ }
1101
+ catch (err) {
1102
+ if (err?.name !== "AbortError") {
1103
+ this.streamError = err instanceof Error ? err : new Error(String(err));
1104
+ Logger.error(TAG, "Non-range stream failed", err);
1105
+ }
1106
+ }
1107
+ finally {
1108
+ try {
1109
+ await reader.cancel();
1110
+ }
1111
+ catch { }
1112
+ this.reader = null;
1113
+ }
1114
+ // EOF housekeeping.
1115
+ const end = this.atomicGetBufferStart() + this.atomicGetWritePos();
1116
+ if (this.size <= 0)
1117
+ this.size = end; // size was unknown — we know it now
1118
+ if (fullFit && this.atomicGetBufferStart() === 0 && this.size > 0 && end >= this.size) {
1119
+ this.fullyBuffered = true;
1120
+ Logger.info(TAG, `Entire file cached (${(this.size / 1048576).toFixed(1)}MB) — full random access.`);
1121
+ }
1122
+ this.atomicSetStreaming(false);
1123
+ }
1124
+ /**
1125
+ * Append a chunk to the buffer for the non-range stream. In full-cache mode
1126
+ * the buffer always has room. In linearMode it slides the window forward by
1127
+ * discarding already-consumed bytes, applying backpressure (waiting for the
1128
+ * demuxer to catch up) when the window can't be advanced yet.
1129
+ */
1130
+ async writeSequential(value, buffer, fullFit) {
1131
+ let written = 0;
1132
+ while (written < value.length) {
1133
+ if (!this.atomicIsStreaming())
1134
+ return;
1135
+ let writePos = this.atomicGetWritePos();
1136
+ if (writePos >= buffer.length) {
1137
+ if (fullFit)
1138
+ return; // Shouldn't happen — buffer >= file. Safety stop.
1139
+ // Linear: the buffer's full of read-ahead. Drop the oldest history to
1140
+ // make room; if readMax hasn't advanced past the trailing window yet
1141
+ // there's nothing droppable — that's normal backpressure (we already
1142
+ // hold ~half a buffer ahead), so just wait for the demuxer to consume.
1143
+ // The while-loop's streaming check exits us cleanly on seek/stop, and an
1144
+ // unreachable read fails on the read side, so no hard stall is needed.
1145
+ if (!(await this.slideWindow(buffer))) {
1146
+ await new Promise((r) => setTimeout(r, 5));
1147
+ continue;
1148
+ }
1149
+ writePos = this.atomicGetWritePos();
1150
+ }
1151
+ const room = buffer.length - writePos;
1152
+ const chunk = Math.min(room, value.length - written);
1153
+ let locked = false;
1154
+ for (let i = 0; i < 50; i++) {
1155
+ if (this.tryLock()) {
1156
+ locked = true;
1157
+ break;
1158
+ }
1159
+ await new Promise((r) => setTimeout(r, 1));
1160
+ }
1161
+ if (!locked) {
1162
+ await new Promise((r) => setTimeout(r, 5));
1163
+ continue;
1164
+ }
1165
+ buffer.set(value.subarray(written, written + chunk), writePos);
1166
+ const newWritePos = writePos + chunk;
1167
+ this.atomicSetWritePos(newWritePos);
1168
+ const end = this.atomicGetBufferStart() + newWritePos;
1169
+ if (end > this.maxBufferedEnd)
1170
+ this.maxBufferedEnd = end;
1171
+ this.unlock();
1172
+ written += chunk;
1173
+ }
1174
+ }
1175
+ /**
1176
+ * Slide the linear-mode window forward to make room for new download. Keeps a
1177
+ * trailing history of up to LINEAR_TRAIL_BYTES behind the playhead so the user
1178
+ * can seek backward into already-played content that's still in RAM — only the
1179
+ * bytes older than that are dropped. Returns false when nothing can be dropped
1180
+ * yet (playhead hasn't advanced past the trailing window), so the caller
1181
+ * applies backpressure. This bounds read-ahead to (bufferSize - trail).
1182
+ */
1183
+ async slideWindow(buffer) {
1184
+ const bufStart = this.atomicGetBufferStart();
1185
+ const writePos = this.atomicGetWritePos();
1186
+ // Decide the oldest byte to keep. Two pulls, take whichever is LOWER so we
1187
+ // never drop something in use:
1188
+ // - lag: the lowest recent read offset — covers a lagging interleaved
1189
+ // stream or a just-happened backward seek (their bytes must stay).
1190
+ // - readMax - trail: keep ~half a buffer of history behind the frontier
1191
+ // for backward seeking when the streams sit close together.
1192
+ // Both rise as playback advances, so keepFrom advances and the window can
1193
+ // always slide forward to feed the frontier — no deadlock on a rewind.
1194
+ const trail = Math.floor(this.bufferSize / 2);
1195
+ const lag = this.recentReads.length
1196
+ ? Math.min(...this.recentReads)
1197
+ : this.readMax;
1198
+ const keepFrom = Math.max(0, Math.min(lag, this.readMax - trail));
1199
+ const shift = Math.min(keepFrom - bufStart, writePos);
1200
+ if (shift <= 0)
1201
+ return false;
1202
+ let locked = false;
1203
+ for (let i = 0; i < 50; i++) {
1204
+ if (this.tryLock()) {
1205
+ locked = true;
1206
+ break;
1207
+ }
1208
+ await new Promise((r) => setTimeout(r, 1));
1209
+ }
1210
+ if (!locked)
1211
+ return false;
1212
+ buffer.copyWithin(0, shift, writePos);
1213
+ this.atomicSetBufferStart(bufStart + shift);
1214
+ this.atomicSetWritePos(writePos - shift);
1215
+ this.unlock();
1216
+ return true;
1217
+ }
867
1218
  async stopStream() {
868
1219
  this.atomicSetStreaming(false);
869
1220
  if (this.abortController) {
@@ -1009,6 +1360,34 @@ export class HttpSource {
1009
1360
  Logger.debug(TAG, `Read: serving from buffer`);
1010
1361
  return this.readFromBuffer(offset, length);
1011
1362
  }
1363
+ // Non-range source: there is exactly one stream (the full file from 0),
1364
+ // so we can never restart at a different offset. Forward reads wait for the
1365
+ // single sequential stream to reach them; reads behind the sliding window
1366
+ // were discarded (linear mode) and can't be served.
1367
+ if (this.rangeUnsupported) {
1368
+ if (offset < this.atomicGetBufferStart()) {
1369
+ // Behind the window — only happens on a backward seek / random access
1370
+ // in linear mode, which this source can't satisfy.
1371
+ throw new Error("Server does not support range requests.");
1372
+ }
1373
+ // Linear mode holds at most one bufferSize-wide window. A read whose end
1374
+ // is past (windowStart + bufferSize) can never sit in the window — that's
1375
+ // a far forward seek / moov-at-end on an over-cap file, or a single read
1376
+ // bigger than the buffer. Bail rather than wait forever.
1377
+ if (this.linearMode && offset + length > this.atomicGetBufferStart() + this.bufferSize) {
1378
+ throw new Error("Server does not support range requests.");
1379
+ }
1380
+ if (this.atomicIsStreaming()) {
1381
+ const ok = await this.waitForData(offset, length);
1382
+ if (ok)
1383
+ return this.readFromBuffer(offset, length);
1384
+ }
1385
+ if (this.isInBuffer(offset, length))
1386
+ return this.readFromBuffer(offset, length);
1387
+ if (this.streamError)
1388
+ throw this.streamError;
1389
+ throw new Error("Server does not support range requests.");
1390
+ }
1012
1391
  // Optimization: Check if the ACTIVE stream covers this request.
1013
1392
  // If so, we strictly wait for it. Interrupting an active stream that is
1014
1393
  // successfully filling the buffer is inefficient and causes stalls.
@@ -1126,6 +1505,14 @@ export class HttpSource {
1126
1505
  const result = new Uint8Array(available);
1127
1506
  result.set(buffer.subarray(localOffset, localOffset + available));
1128
1507
  this.position = offset + available;
1508
+ if (this.position > this.readMax)
1509
+ this.readMax = this.position;
1510
+ // Track recent read offsets so the linear window knows the lowest byte any
1511
+ // stream still needs (see slideWindow). Bounded ring — ~64 reads ≈ 32MB of
1512
+ // activity, plenty to span the streams + a transient rewind.
1513
+ this.recentReads.push(offset);
1514
+ if (this.recentReads.length > 64)
1515
+ this.recentReads.shift();
1129
1516
  return result.buffer;
1130
1517
  }
1131
1518
  seek(offset) {