diff --git a/frontend/app.js b/frontend/app.js
index ce72c94..0d079d8 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -5161,6 +5161,7 @@ const StatsTrack = (() => {
function renderStats() {
StatsTrack.flush();
els.listTitle.textContent = 'Stats';
+ resetListHeaderExtras();
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
const c = els.cards;
@@ -5511,6 +5512,7 @@ async function playVideoAt(v, t = 0) {
// ============================================================================
async function renderLibrary() {
els.listTitle.textContent = 'Library';
+ resetListHeaderExtras();
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
els.cards.innerHTML = '
0 selected
diff --git a/frontend/refresh.css b/frontend/refresh.css
index cf18eb0..cda472d 100644
--- a/frontend/refresh.css
+++ b/frontend/refresh.css
@@ -551,3 +551,50 @@
.search-meta.hidden { display: none; }
.search-meta .sm-btn { flex: none; border: 1px solid var(--line); background: transparent; color: var(--text); border-radius: 999px; padding: 6px 12px; font: inherit; font-weight: 600; cursor: pointer; }
.search-meta .sm-btn:disabled { opacity: .55; cursor: default; }
+
+/* ---- Playlist header: wrapping title (≤90%) + icon actions; icon row beside the filter ---- */
+.list-title-row { display: flex; align-items: flex-start; gap: 6px; min-width: 0; }
+.list-title-row h2 { flex: 0 1 auto; max-width: 90%; min-width: 0; overflow-wrap: anywhere; }
+.list-title-row h2.title-clickable { cursor: pointer; }
+.list-title-row h2.title-clickable:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; }
+.list-title-actions { flex: 0 0 auto; display: flex; flex-direction: column; gap: 4px; margin-left: auto; }
+.list-title-actions:empty { display: none; }
+.list-filter-bar { display: flex; align-items: center; gap: 6px; }
+.list-filter-bar input { flex: 1 1 auto; min-width: 0; width: auto; }
+.list-filter-actions { flex: 0 0 auto; display: flex; gap: 6px; }
+.list-filter-actions:empty { display: none; }
+.list-icon-btn {
+ flex: none; width: 40px; height: 40px; padding: 0;
+ display: grid; place-items: center;
+ border-radius: 12px; border: 1px solid var(--line);
+ background: var(--bg-2); color: var(--text-2); cursor: pointer;
+ transition: color 0.16s, border-color 0.16s, background 0.16s;
+}
+.list-title-actions .list-icon-btn { width: 34px; height: 34px; border-radius: 10px; }
+.list-icon-btn svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+.list-icon-btn:hover { color: var(--text); border-color: var(--accent); }
+.list-icon-btn.pinned { color: var(--accent-bright); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
+.list-icon-btn.done { color: #3ecf8e; border-color: color-mix(in srgb, #3ecf8e 55%, transparent); opacity: 1; }
+.list-icon-btn.busy { width: auto; min-width: 40px; padding: 0 8px; }
+.list-icon-btn .lib-progress { font: 700 11px var(--mono); color: var(--accent-bright); }
+.list-icon-btn.danger:hover { color: #ff5a4f; border-color: #ff5a4f; }
+.list-icon-btn:disabled, .list-icon-btn.disabled { cursor: default; pointer-events: none; }
+.list-icon-btn.disabled:not(.done):not(.busy) { opacity: 0.45; }
+
+/* ---- Transport: one row — prev · −10 · play · +10 · next ---- */
+#controls .deck-main { grid-template-columns: repeat(5, auto); gap: 14px; }
+#controls .deck-main #prevBtn { order: 1; }
+#controls .deck-main #back10Btn { order: 2; }
+#controls .deck-main #playBtn { order: 3; }
+#controls .deck-main #fwd10Btn { order: 4; }
+#controls .deck-main #nextBtn { order: 5; }
+@media (max-width: 400px) { #controls .deck-main { gap: 6px; } }
+
+/* Rename modal: wrapping box so a long playlist name shows in full */
+.modal textarea.rename-input {
+ display: block; width: 100%; resize: none; overflow: hidden;
+ background: var(--bg-3); border: 1px solid var(--line); border-radius: 13px;
+ padding: 13px 15px; color: var(--text); font: 600 16px/1.35 var(--ui);
+ outline: none; overflow-wrap: anywhere; transition: border-color 0.16s, box-shadow 0.16s;
+}
+.modal textarea.rename-input:focus { border-color: var(--accent); box-shadow: 0 0 0 4px rgba(255,75,50,0.12); }
diff --git a/frontend/sw-update.js b/frontend/sw-update.js
index d1688b5..b221636 100644
--- a/frontend/sw-update.js
+++ b/frontend/sw-update.js
@@ -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 };
}
/**
diff --git a/frontend/sw-update.test.js b/frontend/sw-update.test.js
index f815fae..11f3e0a 100644
--- a/frontend/sw-update.test.js
+++ b/frontend/sw-update.test.js
@@ -52,6 +52,7 @@ function fakeCaches(initial) {
return {
store,
async keys() { return [...store.keys()]; },
+ async delete(name) { return store.delete(name); },
async open(name) {
if (!store.has(name)) store.set(name, new Map());
const m = store.get(name);
@@ -59,6 +60,7 @@ function fakeCaches(initial) {
async keys() { return [...m.keys()].map((url) => ({ url })); },
async put(url, res) { m.set(url, res.body); },
async delete(req) { return m.delete(req.url); },
+ async match(url) { return m.has(url) ? res(m.get(url)) : undefined; },
};
},
};
@@ -154,7 +156,58 @@ test('refreshShellInPlace times out instead of hanging on a stalled request', as
test('refreshShellInPlace with no shell caches (uncontrolled page) is a no-op', async () => {
const r = await refreshShellInPlace({ cachesApi: fakeCaches({}), isShellCache, fetchFn: async () => { throw new Error('no fetch expected'); } });
- assert.deepStrictEqual(r, { refreshed: 0, caches: 0 });
+ assert.deepStrictEqual(r, { refreshed: 0, caches: 0, resumed: 0 });
+});
+
+test('refreshShellInPlace resumes: files that arrived before a failure are not fetched again', async () => {
+ const cachesApi = fakeCaches({
+ 'ytplayer-old': { [`${ORIGIN}/`]: 'old-index', [`${ORIGIN}/app.js`]: 'old-app', [`${ORIGIN}/styles.css`]: 'old-css' },
+ });
+ let cssUp = false;
+ const fetched = [];
+ const fetchFn = async (url) => {
+ fetched.push(new URL(url).pathname);
+ return url.includes('/styles.css') && !cssUp ? res('gateway timeout', 504) : res('NEW ' + new URL(url).pathname);
+ };
+ await assert.rejects(refreshShellInPlace({ cachesApi, isShellCache, fetchFn, stagingKey: 'B1' }), /styles\.css/);
+ assert.deepStrictEqual([...cachesApi.store.get('ytplayer-old').values()], ['old-index', 'old-app', 'old-css'], 'live shell untouched');
+ fetched.length = 0;
+ cssUp = true;
+ const r = await refreshShellInPlace({ cachesApi, isShellCache, fetchFn, stagingKey: 'B1' });
+ assert.deepStrictEqual(fetched, ['/styles.css'], 'only the missing file is fetched on the retry');
+ assert.strictEqual(r.resumed, 2);
+ assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/app.js`), 'NEW /app.js');
+ assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/styles.css`), 'NEW /styles.css');
+ assert.ok(![...cachesApi.store.keys()].some((k) => k.startsWith('ytp-staging-')), 'staging removed after success');
+});
+
+test('refreshShellInPlace never reuses files staged for a different build', async () => {
+ const cachesApi = fakeCaches({
+ 'ytplayer-old': { [`${ORIGIN}/app.js`]: 'old-app' },
+ 'ytp-staging-OLDBUILD': { [`${ORIGIN}/app.js`]: 'STALE' },
+ });
+ const r = await refreshShellInPlace({ cachesApi, isShellCache, stagingKey: 'NEWBUILD', fetchFn: async () => res('FRESH') });
+ assert.strictEqual(r.resumed, 0);
+ assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/app.js`), 'FRESH');
+ assert.ok(!cachesApi.store.has('ytp-staging-OLDBUILD'));
+});
+
+test('refreshShellInPlace keeps at most `concurrency` downloads in flight', async () => {
+ const files = {};
+ for (let i = 0; i < 12; i++) files[`${ORIGIN}/f${i}.js`] = 'old';
+ const cachesApi = fakeCaches({ 'ytplayer-old': files });
+ let inFlight = 0, peak = 0;
+ await refreshShellInPlace({
+ cachesApi, isShellCache, concurrency: 4,
+ fetchFn: async () => {
+ inFlight++; peak = Math.max(peak, inFlight);
+ await new Promise((r) => setImmediate(r));
+ inFlight--;
+ return res('NEW');
+ },
+ });
+ assert.strictEqual(peak, 4);
+ assert.ok([...cachesApi.store.get('ytplayer-old').values()].every((v) => v === 'NEW'));
});
test('applyUpdate refreshes first, then activates the waiting worker, then reloads once on controllerchange', async () => {
diff --git a/frontend/theme-glass.css b/frontend/theme-glass.css
index 77b9d45..5385ffb 100644
--- a/frontend/theme-glass.css
+++ b/frontend/theme-glass.css
@@ -369,3 +369,7 @@ html[data-layout="glass-stage"] #nowPlayingMeta .np-channel.link .np-ch-name {
html[data-layout="glass-stage"] #nowPlayingMeta .np-channel.link:hover .np-ch-name { color: var(--accent-bright); text-decoration-color: currentColor; }
html[data-layout="glass-stage"] #nowPlayingMeta .np-channel.link .np-ch-name::after { content: " ›"; color: var(--accent-bright); text-decoration: none; display: inline-block; }
html[data-layout="glass-stage"] #nowPlayingMeta .np-channel:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; }
+
+/* Playlist title row takes the old title slot in the glass header grid */
+html[data-layout="glass-stage"] .list-header .list-title-row { grid-column: 1 / 5; }
+html[data-layout="glass-stage"] .list-icon-btn { background: var(--tile); border-color: var(--glass-line); }