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.
- package/AGENTS.md +2 -0
- package/README.md +54 -7
- package/dist/core/MoviPlayer.d.ts +77 -1
- package/dist/core/MoviPlayer.d.ts.map +1 -1
- package/dist/core/MoviPlayer.js +667 -121
- package/dist/core/MoviPlayer.js.map +1 -1
- package/dist/core/PlayerState.js +1 -1
- package/dist/core/PlayerState.js.map +1 -1
- package/dist/decode/VideoDecoder.d.ts.map +1 -1
- package/dist/decode/VideoDecoder.js +6 -2
- package/dist/decode/VideoDecoder.js.map +1 -1
- package/dist/demuxer.cjs +315 -129
- package/dist/demuxer.js +377 -191
- package/dist/element.cjs +58309 -16596
- package/dist/element.js +59520 -17807
- package/dist/index.cjs +58309 -16596
- package/dist/index.js +59520 -17807
- package/dist/player.cjs +56818 -15360
- package/dist/player.js +57818 -16360
- package/dist/render/DASHPlayerWrapper.d.ts +65 -0
- package/dist/render/DASHPlayerWrapper.d.ts.map +1 -0
- package/dist/render/DASHPlayerWrapper.js +540 -0
- package/dist/render/DASHPlayerWrapper.js.map +1 -0
- package/dist/render/HLSPlayerWrapper.d.ts.map +1 -1
- package/dist/render/HLSPlayerWrapper.js +47 -11
- package/dist/render/HLSPlayerWrapper.js.map +1 -1
- package/dist/render/MoviElement.d.ts +97 -0
- package/dist/render/MoviElement.d.ts.map +1 -1
- package/dist/render/MoviElement.js +852 -152
- package/dist/render/MoviElement.js.map +1 -1
- package/dist/render/ShakaPlayerWrapper.d.ts +111 -0
- package/dist/render/ShakaPlayerWrapper.d.ts.map +1 -0
- package/dist/render/ShakaPlayerWrapper.js +956 -0
- package/dist/render/ShakaPlayerWrapper.js.map +1 -0
- package/dist/source/DashFallback.d.ts +32 -0
- package/dist/source/DashFallback.d.ts.map +1 -0
- package/dist/source/DashFallback.js +142 -0
- package/dist/source/DashFallback.js.map +1 -0
- package/dist/source/DashManifest.d.ts +34 -0
- package/dist/source/DashManifest.d.ts.map +1 -0
- package/dist/source/DashManifest.js +194 -0
- package/dist/source/DashManifest.js.map +1 -0
- package/dist/source/DashSegmentSource.d.ts +32 -0
- package/dist/source/DashSegmentSource.d.ts.map +1 -0
- package/dist/source/DashSegmentSource.js +86 -0
- package/dist/source/DashSegmentSource.js.map +1 -0
- package/dist/source/HttpSource.d.ts +64 -0
- package/dist/source/HttpSource.d.ts.map +1 -1
- package/dist/source/HttpSource.js +444 -57
- package/dist/source/HttpSource.js.map +1 -1
- package/dist/source/ThumbnailHttpSource.d.ts +13 -0
- package/dist/source/ThumbnailHttpSource.d.ts.map +1 -1
- package/dist/source/ThumbnailHttpSource.js +70 -8
- package/dist/source/ThumbnailHttpSource.js.map +1 -1
- package/dist/source/index.d.ts +2 -0
- package/dist/source/index.d.ts.map +1 -1
- package/dist/source/index.js +1 -0
- package/dist/source/index.js.map +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- 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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
if (
|
|
276
|
-
|
|
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 (
|
|
290
|
-
|
|
332
|
+
if (attempt < MAX_ATTEMPTS - 1) {
|
|
333
|
+
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
|
|
291
334
|
}
|
|
292
335
|
}
|
|
293
|
-
|
|
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
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
//
|
|
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
|
|
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) {
|