Icon-only playlist header and filter-row actions, one-row transport, resumable update download

- Playlist title wraps within 90% of the width; rename and share are icons beside it,
  and tapping the title opens the rename box showing the full name
- Offline, pin, select and delete are icon buttons to the right of the filter input
- Transport is one row again: prev, back 10, play, forward 10, next
- Update download stages each file as it arrives so a retry only fetches what is
  missing, keeps 4 requests in flight and allows 5 minutes
This commit is contained in:
Jonathan Sykes
2026-10-02 20:59:51 +08:00
parent 236c129b90
commit 985063648f
6 changed files with 266 additions and 54 deletions

View File

@@ -33,6 +33,8 @@
'use strict';
const BUST_PARAM = '__ytpfresh';
const STAGING_PREFIX = 'ytp-staging-';
const DEFAULT_CONCURRENCY = 4;
function withTimeout(promise, ms, setTimeoutFn, label) {
if (!ms || !setTimeoutFn) return promise;
@@ -44,9 +46,10 @@
/**
* Re-downloads every file held by the versioned shell caches and replaces
* the cached copies. All-or-nothing: if any download fails, nothing is
* written and it throws, so a flaky connection can never leave a
* half-old/half-new shell.
* the cached copies. All-or-nothing: if any download fails, the live shell
* is untouched and it throws, so a flaky connection can never leave a
* half-old/half-new shell. Files that did arrive stay staged, so the next
* try for the same build resumes instead of starting over.
*
* @param {object} opts
* @param {CacheStorage} opts.cachesApi
@@ -56,12 +59,14 @@
* @param {number} [opts.timeoutMs] overall download budget
* @param {number} [opts.attempts] tries per file (default 3)
* @param {number} [opts.attemptTimeoutMs] bound on a single try
* @param {number} [opts.concurrency] downloads in flight at once (default 4)
* @param {string} [opts.stagingKey] target build; staged files survive a failed try for it
* @param {Function} [opts.setTimeout]
* @returns {Promise<{refreshed: number, caches: number}>}
* @returns {Promise<{refreshed: number, caches: number, resumed: number}>}
*/
async function refreshShellInPlace({ cachesApi, fetchFn, isShellCache, bust, timeoutMs, attempts, attemptTimeoutMs, setTimeout: setTimeoutFn }) {
async function refreshShellInPlace({ cachesApi, fetchFn, isShellCache, bust, timeoutMs, attempts, attemptTimeoutMs, concurrency, stagingKey, setTimeout: setTimeoutFn }) {
const names = (await cachesApi.keys()).filter(isShellCache);
if (!names.length) return { refreshed: 0, caches: 0 }; // uncontrolled page: a reload already hits the network
if (!names.length) return { refreshed: 0, caches: 0, resumed: 0 }; // uncontrolled page: a reload already hits the network
const urls = new Set();
for (const name of names) {
@@ -73,6 +78,20 @@
}
}
// Resumable: every file lands in a staging cache the moment it arrives,
// so a retry after a timeout only fetches what is still missing. The
// staging cache is keyed by the target build — a newer deploy never
// reuses files staged for an older one. Nothing touches the live shell
// caches until the whole set is staged (still all-or-nothing).
const stagingName = STAGING_PREFIX + (stagingKey || 'current');
for (const k of await cachesApi.keys()) {
if (k.startsWith(STAGING_PREFIX) && k !== stagingName) await cachesApi.delete(k);
}
const staging = await cachesApi.open(stagingName);
const missing = [];
for (const url of urls) if (!(await staging.match(url))) missing.push(url);
const resumed = urls.size - missing.length;
const token = bust || String(Date.now());
// Each file gets a few attempts (the homelab link drops requests), each
// bounded so one hung connection can't eat the whole budget.
@@ -86,7 +105,7 @@
const res = await withTimeout(
fetchFn(u.href, { cache: 'reload', credentials: 'same-origin', signal: ctl ? ctl.signal : undefined }),
attemptTimeoutMs, setTimeoutFn, `${new URL(url).pathname} timed out`);
if (res && res.ok) return [url, res];
if (res && res.ok) { await staging.put(url, res); return; }
last = new Error(`${new URL(url).pathname} → ${res ? res.status : 'no response'}`);
} catch (err) {
if (ctl) { try { ctl.abort(); } catch { /* already done */ } }
@@ -95,18 +114,35 @@
}
throw last;
};
const fetched = await withTimeout(Promise.all([...urls].map(fetchOne)),
timeoutMs, setTimeoutFn, 'update download timed out');
// A lossy link copes far better with a few requests in flight than with
// the whole shell at once.
const pool = async () => {
const queue = missing.slice();
let failed = null;
const worker = async () => {
while (queue.length && !failed) {
const url = queue.shift();
try { await fetchOne(url); } catch (err) { failed = failed || err; }
}
};
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency || DEFAULT_CONCURRENCY, queue.length || 1)) }, worker));
if (failed) throw failed;
};
await withTimeout(pool(), timeoutMs, setTimeoutFn, 'update download timed out');
for (const name of names) {
const cache = await cachesApi.open(name);
for (const [url, res] of fetched) await cache.put(url, res.clone());
for (const url of urls) {
const res = await staging.match(url);
if (res) await cache.put(url, res);
}
// An old worker's cache-first handler may have stored the busted URLs.
for (const req of await cache.keys()) {
if (new URL(req.url).searchParams.has(BUST_PARAM)) await cache.delete(req);
}
}
return { refreshed: fetched.length, caches: names.length };
await cachesApi.delete(stagingName);
return { refreshed: urls.size, caches: names.length, resumed };
}
/**