diff --git a/frontend/app.js b/frontend/app.js index d808561..2145032 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -623,8 +623,9 @@ const API = { // Pre-resolve streams for videos the user is likely to play next (server // /api/streams/warm). Each id is warmed at most once per 20 min per tab. const warmedAt = new Map(); +const knownServerCachedIds = new Set(); function warmStreams(ids) { - if (!WEB || navigator.onLine === false) return; + if (!WEB || navigator.onLine === false || navigator.connection?.saveData) return; const now = Date.now(); for (const id of ids) { if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id) || cachedIds.has(id)) continue; @@ -634,6 +635,14 @@ function warmStreams(ids) { } } +function warmNextQueue() { + if (!window.PrewarmNext || queueIndex < 0) return; + warmStreams(window.PrewarmNext.candidates(queue, queueIndex, data.settings, 2, { + offline: cachedIds, + serverCached: knownServerCachedIds, + })); +} + // Resolve a native file path to a URL the WebView can load (Tauri asset // protocol). Returns null on shells without it. function toAssetUrl(path) { @@ -2332,6 +2341,7 @@ const Player = { if (seq !== this._loadSeq) return; // a newer load took over meanwhile if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams'); current = res.data; + if (current.serverCached && current.meta?.id) knownServerCachedIds.add(current.meta.id); // Merge richer metadata we may already have from the list card. current.meta = { ...videoObj, ...current.meta }; @@ -3014,7 +3024,7 @@ function wirePlayerEvents() { perfMeasure('ytp:tap-to-play', 'ytp:tap'); try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ } } - if (Array.isArray(queue) && queueIndex >= 0) warmStreams(queue.slice(queueIndex + 1, queueIndex + 3).map((x) => x && x.id)); + warmNextQueue(); showSpinner(false); Player.clearBufferGrace(); if (Player.secondary && !el.paused && Player.secondary.paused) { @@ -8044,6 +8054,7 @@ function playFromList(list, index, source = '', { playFull = false } = {}) { unshuffledQueue = null; // Play-in-full keeps the list order — a service set plays as arranged. if (data.settings.shuffle && !playFull) applyShuffle(); + warmNextQueue(); Player.loadVideo(queue[queueIndex]); renderUpNext(); } diff --git a/frontend/index.html b/frontend/index.html index f4b56f3..8e55e31 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -694,6 +694,7 @@ + diff --git a/frontend/prewarm-next.js b/frontend/prewarm-next.js new file mode 100644 index 0000000..ec56f35 --- /dev/null +++ b/frontend/prewarm-next.js @@ -0,0 +1,37 @@ +/* Pure queue candidate selection for stream prewarming. */ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.PrewarmNext = factory(); +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + function candidates(queue, index, state = {}, n = 2, known = {}) { + if (!Array.isArray(queue) || !queue.length || !Number.isInteger(index) || index < 0 || index >= queue.length) return []; + if (state.loopOne) return []; + const count = Math.max(0, Math.floor(Number(n) || 0)); + if (!count) return []; + const repeatAll = state.repeatMode === 'all' || state.repeatAll === true; + const offline = new Set(known.offline || []); + const serverCached = new Set(known.serverCached || []); + const current = queue[index]; + const currentId = typeof current === 'string' ? current : current && current.id; + const seen = new Set(currentId ? [currentId] : []); + const ids = []; + for (let step = 1; step < queue.length + (repeatAll ? 1 : 0) && ids.length < count; step++) { + let at = index + step; + if (at >= queue.length) { + if (!repeatAll) break; + at %= queue.length; + } + const item = queue[at]; + const id = typeof item === 'string' ? item : item && item.id; + if (!id || seen.has(id)) continue; + seen.add(id); + if (offline.has(id) || serverCached.has(id) || (item && item.serverCached)) continue; + ids.push(id); + } + return ids; + } + + return { candidates }; +}); diff --git a/frontend/prewarm-next.test.js b/frontend/prewarm-next.test.js new file mode 100644 index 0000000..9a4f467 --- /dev/null +++ b/frontend/prewarm-next.test.js @@ -0,0 +1,26 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { candidates } = require('./prewarm-next.js'); + +const q = ['a', 'b', 'c', 'd'].map(id => ({ id })); + +test('returns the next n queue ids in existing order', () => { + assert.deepEqual(candidates(q, 0, {}, 2), ['b', 'c']); +}); +test('repeat all wraps and does not duplicate ids', () => { + assert.deepEqual(candidates(q, 3, { repeatMode: 'all' }, 2), ['a', 'b']); + assert.deepEqual(candidates([{ id: 'x' }, { id: 'x' }], 0, { repeatMode: 'all' }, 3), []); +}); +test('repeat off stops at the end and loop one has no candidates', () => { + assert.deepEqual(candidates(q, 3, {}, 2), []); + assert.deepEqual(candidates(q, 0, { loopOne: true, repeatMode: 'all' }, 2), []); +}); +test('skips offline and known server cached ids and deduplicates', () => { + assert.deepEqual(candidates(q, 0, {}, 3, { offline: ['b'], serverCached: ['c'] }), ['d']); + assert.deepEqual(candidates([{ id: 'a' }, { id: 'b', serverCached: true }, { id: 'c' }], 0, {}, 2), ['c']); +}); +test('shuffle follows the queue order selected before advance', () => { + const shuffled = ['a', 'd', 'b', 'c'].map(id => ({ id })); + assert.deepEqual(candidates(shuffled, 0, { shuffle: true }, 2), ['d', 'b']); +}); diff --git a/frontend/sw.js b/frontend/sw.js index 95f791e..2c50908 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -125,6 +125,7 @@ const SHELL = [ '/piano.js', '/setlist-import.js', '/car-mode.js', + '/prewarm-next.js', '/car-mode.css', '/lower-third.css', '/settings-sections.css',