@xiboplayer/cache 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/cache",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Offline caching and download management with parallel chunk downloads",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -10,9 +10,14 @@
10
10
  "./cache-proxy": "./src/cache-proxy.js",
11
11
  "./download-manager": "./src/download-manager.js"
12
12
  },
13
+ "scripts": {
14
+ "test": "vitest run",
15
+ "test:watch": "vitest",
16
+ "test:coverage": "vitest run --coverage"
17
+ },
13
18
  "dependencies": {
14
- "spark-md5": "^3.0.2",
15
- "@xiboplayer/utils": "0.1.0"
19
+ "@xiboplayer/utils": "workspace:*",
20
+ "spark-md5": "^3.0.2"
16
21
  },
17
22
  "devDependencies": {
18
23
  "vitest": "^2.0.0",
@@ -32,10 +37,5 @@
32
37
  "type": "git",
33
38
  "url": "git+https://github.com/xibo-players/xiboplayer.git",
34
39
  "directory": "packages/cache"
35
- },
36
- "scripts": {
37
- "test": "vitest run",
38
- "test:watch": "vitest",
39
- "test:coverage": "vitest run --coverage"
40
40
  }
41
- }
41
+ }
@@ -237,6 +237,15 @@ class ServiceWorkerBackend extends EventEmitter {
237
237
  return false;
238
238
  }
239
239
  }
240
+
241
+ /**
242
+ * Prioritize layout files — reorder queue and hold other downloads until done.
243
+ * @param {string[]} mediaIds - Media IDs needed by the current layout
244
+ */
245
+ async prioritizeLayoutFiles(mediaIds) {
246
+ if (!this.controller) return;
247
+ this.controller.postMessage({ type: 'PRIORITIZE_LAYOUT_FILES', data: { mediaIds } });
248
+ }
240
249
  }
241
250
 
242
251
  // DirectCacheBackend removed - Service Worker only architecture
@@ -387,6 +396,15 @@ export class CacheProxy extends EventEmitter {
387
396
  return await this.backend.isCached(type, id);
388
397
  }
389
398
 
399
+ /**
400
+ * Prioritize layout files — reorder queue and hold other downloads until done.
401
+ * @param {string[]} mediaIds - Media IDs needed by the current layout
402
+ */
403
+ async prioritizeLayoutFiles(mediaIds) {
404
+ if (!this.backend?.prioritizeLayoutFiles) return;
405
+ return await this.backend.prioritizeLayoutFiles(mediaIds);
406
+ }
407
+
390
408
  /**
391
409
  * Get backend type for debugging
392
410
  * @returns {string} 'service-worker' or 'direct'
@@ -159,18 +159,19 @@ export class DownloadTask {
159
159
  chunkRanges.push({ start, end, index: chunkRanges.length });
160
160
  }
161
161
 
162
- // Prioritize chunk 0 (ftyp header) and last chunk (moov atom) for video early playback.
163
- // Modern browsers seek to end of MP4 for moov, so having both extremes first
164
- // lets video start playing while middle chunks are still downloading.
165
- if (chunkRanges.length > 2) {
166
- const lastChunk = chunkRanges.pop(); // remove last
167
- chunkRanges.splice(1, 0, lastChunk); // insert after chunk 0
162
+ // Phase 1: Download chunk 0 (ftyp header) and last chunk (moov atom) in parallel.
163
+ // Browsers need both extremes to start MP4 playback.
164
+ const priorityChunks = [chunkRanges[0]];
165
+ if (chunkRanges.length > 1) {
166
+ priorityChunks.push(chunkRanges[chunkRanges.length - 1]);
168
167
  }
169
- console.log('[DownloadTask] Downloading', chunkRanges.length, 'chunks (chunk 0 + last prioritized)');
170
168
 
171
- // Download chunks in parallel with concurrency limit
169
+ // Remaining chunks in sequential order (1, 2, 3, ..., N-2) for gap-free playback
170
+ const remainingChunks = chunkRanges.slice(1, chunkRanges.length > 1 ? -1 : 1);
171
+
172
+ console.log('[DownloadTask] Downloading', chunkRanges.length, 'chunks: phase 1 (chunk 0 + last parallel), phase 2 (sequential)');
173
+
172
174
  const chunkMap = new Map();
173
- let nextChunkIndex = 0;
174
175
 
175
176
  const downloadChunk = async (range) => {
176
177
  const rangeHeader = `bytes=${range.start}-${range.end}`;
@@ -213,20 +214,13 @@ export class DownloadTask {
213
214
  }
214
215
  };
215
216
 
216
- // Download with concurrency control
217
- const downloadNext = async () => {
218
- while (nextChunkIndex < chunkRanges.length) {
219
- const range = chunkRanges[nextChunkIndex++];
220
- await downloadChunk(range);
221
- }
222
- };
217
+ // Phase 1: chunk 0 + last chunk in parallel (both needed for playback start)
218
+ await Promise.all(priorityChunks.map(range => downloadChunk(range)));
223
219
 
224
- // Start concurrent downloaders
225
- const downloaders = [];
226
- for (let i = 0; i < concurrentChunks; i++) {
227
- downloaders.push(downloadNext());
220
+ // Phase 2: remaining chunks strictly sequential (guarantees gap-free playback)
221
+ for (const range of remainingChunks) {
222
+ await downloadChunk(range);
228
223
  }
229
- await Promise.all(downloaders);
230
224
 
231
225
  // If progressive caching was used, skip reassembly (chunks are already cached)
232
226
  if (this.onChunkDownloaded) {
@@ -293,39 +287,83 @@ export class DownloadQueue {
293
287
  }
294
288
 
295
289
  /**
296
- * Process queue - start downloads up to concurrency limit
290
+ * Prioritize files for the current layout reorder queue and hold other downloads.
291
+ * All prioritized files must complete (including all chunks) before others start.
292
+ * @param {Array<string|number>} fileIds - Media IDs to prioritize
293
+ */
294
+ prioritizeLayoutFiles(fileIds) {
295
+ const idSet = new Set(fileIds.map(String));
296
+ this._layoutHoldIds = idSet;
297
+
298
+ // Reorder queue: matching IDs first, rest after
299
+ const prioritized = [];
300
+ const rest = [];
301
+ for (const task of this.queue) {
302
+ if (idSet.has(String(task.fileInfo.id))) {
303
+ prioritized.push(task);
304
+ } else {
305
+ rest.push(task);
306
+ }
307
+ }
308
+ this.queue = [...prioritized, ...rest];
309
+ console.log('[DownloadQueue] Layout hold:', idSet.size, 'files prioritized,', rest.length, 'held back');
310
+ }
311
+
312
+ /**
313
+ * Process queue - start downloads up to concurrency limit.
314
+ * When layout hold is active, only starts held files until all complete.
297
315
  */
298
316
  async processQueue() {
299
- console.log('[DownloadQueue] processQueue:', this.running, 'running,', this.queue.length, 'queued');
317
+ // Layout hold: only start held files while any are still pending/active
318
+ if (this._layoutHoldIds?.size > 0) {
319
+ const hasHeldInQueue = this.queue.some(t => this._layoutHoldIds.has(String(t.fileInfo.id)));
320
+ const hasHeldActive = [...this.active.values()].some(t =>
321
+ this._layoutHoldIds.has(String(t.fileInfo.id))
322
+ );
323
+
324
+ if (hasHeldInQueue || hasHeldActive) {
325
+ // Only start held files
326
+ while (this.running < this.concurrency && this.queue.length > 0) {
327
+ const idx = this.queue.findIndex(t => this._layoutHoldIds.has(String(t.fileInfo.id)));
328
+ if (idx === -1) break;
329
+ const task = this.queue.splice(idx, 1)[0];
330
+ this._startTask(task);
331
+ }
332
+ return;
333
+ }
334
+ // All held files done — clear hold
335
+ console.log('[DownloadQueue] Layout hold cleared, resuming normal downloads');
336
+ this._layoutHoldIds = null;
337
+ }
300
338
 
339
+ // Normal FIFO processing
301
340
  while (this.running < this.concurrency && this.queue.length > 0) {
302
341
  const task = this.queue.shift();
303
- this.running++;
304
-
305
- console.log('[DownloadQueue] Starting:', task.fileInfo.path, `(${this.running}/${this.concurrency} active)`);
306
-
307
- // Start download (don't await - let it run in background)
308
- // .catch is safe here: errors are already propagated to waiters inside start()
309
- task.start()
310
- .catch(() => {}) // Suppress — error handled internally via waiters
311
- .finally(() => {
312
- this.running--;
313
- this.active.delete(task.fileInfo.path);
314
- console.log('[DownloadQueue] Complete:', task.fileInfo.path, `(${this.running} active, ${this.queue.length} pending)`);
315
-
316
- // Process next in queue
317
- this.processQueue();
318
- });
342
+ this._startTask(task);
319
343
  }
320
344
 
321
- if (this.running >= this.concurrency) {
322
- console.log('[DownloadQueue] Concurrency limit reached:', this.running, '/', this.concurrency);
323
- }
324
345
  if (this.queue.length === 0 && this.running === 0) {
325
346
  console.log('[DownloadQueue] All downloads complete');
326
347
  }
327
348
  }
328
349
 
350
+ /**
351
+ * Start a download task (extracted to avoid duplication between hold and normal paths)
352
+ */
353
+ _startTask(task) {
354
+ this.running++;
355
+ console.log('[DownloadQueue] Starting:', task.fileInfo.path, `(${this.running}/${this.concurrency} active)`);
356
+
357
+ task.start()
358
+ .catch(() => {}) // Suppress — error handled internally via waiters
359
+ .finally(() => {
360
+ this.running--;
361
+ this.active.delete(task.fileInfo.path);
362
+ console.log('[DownloadQueue] Complete:', task.fileInfo.path, `(${this.running} active, ${this.queue.length} pending)`);
363
+ this.processQueue();
364
+ });
365
+ }
366
+
329
367
  /**
330
368
  * Move a file to the front of the queue (if still queued, not yet started)
331
369
  * @param {string} fileType - 'media' or 'layout'
@@ -425,6 +463,14 @@ export class DownloadManager {
425
463
  return this.queue.getProgress();
426
464
  }
427
465
 
466
+ /**
467
+ * Prioritize layout files — reorder queue and hold other downloads
468
+ */
469
+ prioritizeLayoutFiles(fileIds) {
470
+ this.queue.prioritizeLayoutFiles(fileIds);
471
+ this.queue.processQueue();
472
+ }
473
+
428
474
  /**
429
475
  * Clear all downloads
430
476
  */