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

@@ -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 = '<div class="sm-busy">Loading the server library…</div>';
@@ -7524,7 +7526,46 @@ function setListPaneHidden(hidden) {
if (pane) pane.classList.toggle('hidden', hidden);
}
// Small icon-only button for the playlist title row and the filter row.
const LIST_ICONS = {
rename: '<path d="M4 20h4L19 9l-4-4L4 16v4zM14 6l4 4"/>',
share: '<path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1"/><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"/>',
offline: '<path d="M12 4v11M7 10l5 5 5-5M5 20h14"/>',
offlineDone: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
pin: '<path d="M9 3h6l-1 6 4 4H6l4-4zM12 13v8"/>',
select: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 12l3 3 5-6"/>',
del: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6"/>',
};
function listIconBtn(icon, label, onClick, extraClass) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'list-icon-btn' + (extraClass ? ' ' + extraClass : '');
b.title = label;
b.setAttribute('aria-label', label);
b.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${LIST_ICONS[icon]}</svg>`;
if (onClick) b.onclick = onClick;
return b;
}
function setListIcon(btn, icon) {
btn.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${LIST_ICONS[icon]}</svg>`;
}
// Drop the playlist-only title/filter icons so they never linger on other views.
function resetListHeaderExtras() {
$('listTitleActions').innerHTML = '';
const fa = $('listFilterActions');
fa.innerHTML = '';
fa._del = null;
els.listTitle.onclick = null;
els.listTitle.onkeydown = null;
els.listTitle.classList.remove('title-clickable');
els.listTitle.removeAttribute('role');
els.listTitle.removeAttribute('tabindex');
els.listTitle.removeAttribute('title');
}
function renderList() {
resetListHeaderExtras();
{ const ls = $('listSub'); if (ls) ls.textContent = ''; const ps = $('p2pStrip'); if (ps) ps.classList.add('hidden'); }
if (view.type !== 'search') { const sm = $('searchMeta'); if (sm) sm.classList.add('hidden'); }
setListPaneHidden(view.type === 'search' && !searchResults.length && !searchAttempted);
@@ -7553,6 +7594,7 @@ function renderList() {
els.cards.innerHTML = '';
els.cards.classList.toggle('select-mode', selectMode);
resetListHeaderExtras();
els.listActions.innerHTML = '';
if (view.type === 'search') {
@@ -7635,47 +7677,45 @@ function renderList() {
queueAll.className = 'la-main';
queueAll.dataset.sub = `+${pl.videos.length} items`;
queueAll.onclick = () => { pl.videos.forEach((v) => addToQueue(v, { quiet: true })); toast('Added playlist to queue'); };
const rename = document.createElement('button');
rename.textContent = 'Rename';
rename.onclick = () => renamePlaylist(pl);
const del = document.createElement('button');
del.textContent = 'Delete';
del.onclick = () => deletePlaylist(pl);
// Title row: a long name wraps; tapping it opens the rename modal,
// which also shows the full name. Rename + Share sit beside it as icons.
els.listTitle.classList.add('title-clickable');
els.listTitle.setAttribute('role', 'button');
els.listTitle.tabIndex = 0;
els.listTitle.title = 'Show full name / rename';
els.listTitle.onclick = () => renamePlaylist(pl);
els.listTitle.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); renamePlaylist(pl); } };
const titleIcons = [listIconBtn('rename', 'Rename playlist', () => renamePlaylist(pl))];
if (pl.videos && pl.videos.length >= 1) titleIcons.push(listIconBtn('share', 'Share playlist', () => sharePlaylist(pl)));
$('listTitleActions').append(...titleIcons);
const offlineBtn = document.createElement('button');
// Filter row icons: offline + pin show their state by colour alone.
const offlineBtn = listIconBtn('offline', 'Make available offline', () => makePlaylistOffline(pl));
offlineBtn.id = 'plOfflineBtn';
updatePlaylistOfflineButton(offlineBtn, pl);
offlineBtn.onclick = () => makePlaylistOffline(pl);
const pinBtn = document.createElement('button');
pinBtn.id = 'plPinBtn';
const isPinned = (data.pinnedPlaylists || []).includes(pl.id);
pinBtn.textContent = isPinned ? '📌 Pinned' : '📌 Keep offline';
const pinBtn = listIconBtn('pin', isPinned ? 'Pinned for offline keep — tap to unpin' : 'Pin to keep this playlist available offline', () => togglePinPlaylist(pl));
pinBtn.id = 'plPinBtn';
pinBtn.classList.toggle('pinned', isPinned);
pinBtn.title = isPinned ? 'Pinned for offline keep — click to unpin' : 'Pin to keep all videos in this playlist available offline';
pinBtn.onclick = () => togglePinPlaylist(pl);
pinBtn.setAttribute('aria-pressed', String(isPinned));
const buttons = [playAll, playFull, serviceBtn, queueAll, offlineBtn, pinBtn];
if (pl.videos && pl.videos.length >= 1) {
const share = document.createElement('button');
share.textContent = '🔗 Share';
share.onclick = () => sharePlaylist(pl);
buttons.push(share);
}
buttons.push(rename, del);
els.listActions.append(...buttons);
const del = listIconBtn('del', 'Delete playlist', () => deletePlaylist(pl), 'danger');
$('listFilterActions').append(offlineBtn, pinBtn);
els.listActions.append(playAll, playFull, serviceBtn, queueAll);
$('listFilterActions')._del = del; // appended after Select below
}
}
// Select button for batch-operable views
const batchViews = ['history', 'playlist', 'queue', 'smart'];
if (batchViews.includes(view.type) && list.length) {
const selBtn = document.createElement('button');
selBtn.textContent = selectMode ? '✓ Selecting' : 'Select';
selBtn.style.fontWeight = selectMode ? '700' : '';
selBtn.onclick = toggleSelectMode;
els.listActions.appendChild(selBtn);
const selBtn = listIconBtn('select', selectMode ? 'Stop selecting' : 'Select items', toggleSelectMode);
selBtn.classList.toggle('pinned', selectMode);
selBtn.setAttribute('aria-pressed', String(selectMode));
$('listFilterActions').appendChild(selBtn);
}
{ const fa = $('listFilterActions'); if (fa._del) { fa.appendChild(fa._del); fa._del = null; } }
// Channel still loading — show skeletons.
if (view.type === 'channel' && channelData.loading && !list.length) {
@@ -7907,6 +7947,7 @@ function fmtAgo(ts) {
}
function renderSavedSearches() {
els.listTitle.textContent = '📚 Saved searches';
resetListHeaderExtras();
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
const c = els.cards;
@@ -7937,6 +7978,7 @@ function renderSavedSearches() {
async function renderP2pList() {
els.listTitle.textContent = '📡 On other devices';
resetListHeaderExtras();
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
const c = els.cards;
@@ -8001,6 +8043,7 @@ async function renderP2pList() {
async function renderSaved() {
els.listTitle.textContent = 'Saved videos';
resetListHeaderExtras();
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
const c = els.cards;
@@ -8116,6 +8159,7 @@ async function renderSaved() {
// ============================================================================
function renderDownloads() {
els.listTitle.textContent = 'Downloads';
resetListHeaderExtras();
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
const c = els.cards;
@@ -8186,6 +8230,7 @@ function videoTitleById(id) {
async function renderSettings() {
els.listTitle.textContent = 'Settings';
resetListHeaderExtras();
els.listActions.innerHTML = '';
const c = els.cards;
els.status.classList.add('hidden');
@@ -9200,9 +9245,20 @@ function newPlaylist(addVideo) {
}
function renamePlaylist(pl) {
const input = document.createElement('input');
input.type = 'text';
// A wrapping box so a long name is shown in full (tapping the title opens
// this too). Names stay single-line: Enter saves, pasted newlines collapse.
const input = document.createElement('textarea');
input.className = 'rename-input';
input.rows = 1;
input.value = pl.name;
const fit = () => { input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; };
input.addEventListener('input', () => {
if (/\n/.test(input.value)) input.value = input.value.replace(/\s*\n+\s*/g, ' ');
fit();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); const save = $('modalActions').querySelector('.btn.primary'); if (save) save.click(); }
});
showModal('Rename playlist', input, [
{ label: 'Cancel', onClick: closeModal },
{
@@ -9213,7 +9269,7 @@ function renamePlaylist(pl) {
},
},
]);
setTimeout(() => { input.focus(); input.select(); }, 50);
setTimeout(() => { fit(); input.focus(); input.select(); }, 50);
}
function openSongNoteModal(entry) {
@@ -9288,22 +9344,27 @@ function updatePlaylistOfflineButton(btn, pl) {
if (!btn || !pl) return;
const stats = playlistOfflineStats(pl);
const isDownloading = (pl.videos || []).some((v) => v && downloading.has(v.id));
const iconOnly = btn.classList.contains('list-icon-btn');
let label;
btn.classList.remove('done', 'busy');
if (stats.total > 0 && stats.cached === stats.total) {
btn.textContent = '✓ Available offline';
if (iconOnly) { setListIcon(btn, 'offlineDone'); btn.classList.add('done'); } else btn.textContent = '✓ Available offline';
btn.disabled = true;
btn.classList.add('disabled');
btn.title = 'All videos in this playlist are available offline';
label = 'All videos in this playlist are available offline';
} else if (isDownloading) {
btn.textContent = `Saving ${stats.cached}/${stats.total}…`;
if (iconOnly) { btn.innerHTML = `<span class="lib-progress">${stats.cached}/${stats.total}</span>`; btn.classList.add('busy'); } else btn.textContent = `Saving ${stats.cached}/${stats.total}…`;
btn.disabled = true;
btn.classList.add('disabled');
btn.title = `${stats.cached} of ${stats.total} videos saved offline`;
label = `${stats.cached} of ${stats.total} videos saved offline`;
} else {
btn.textContent = '⬇ Make available offline';
if (iconOnly) setListIcon(btn, 'offline'); else btn.textContent = '⬇ Make available offline';
btn.disabled = stats.total === 0;
btn.classList.toggle('disabled', stats.total === 0);
btn.title = stats.total === 0 ? 'Playlist is empty' : 'Download all videos for offline playback';
label = stats.total === 0 ? 'Playlist is empty' : 'Make available offline';
}
btn.title = label;
btn.setAttribute('aria-label', label);
}
async function makePlaylistOffline(pl) {
@@ -9314,7 +9375,8 @@ async function makePlaylistOffline(pl) {
const btn = $('plOfflineBtn');
if (btn) {
const stats = playlistOfflineStats(pl);
btn.textContent = `Saving ${stats.cached}/${stats.total}…`;
btn.innerHTML = `<span class="lib-progress">${stats.cached}/${stats.total}</span>`;
btn.classList.add('busy');
btn.disabled = true;
btn.classList.add('disabled');
}
@@ -9355,9 +9417,11 @@ function togglePinPlaylist(pl) {
const pinBtn = $('plPinBtn');
if (pinBtn) {
const nowPinned = data.pinnedPlaylists.includes(pl.id);
pinBtn.textContent = nowPinned ? '📌 Pinned' : '📌 Keep offline';
const label = nowPinned ? 'Pinned for offline keep — tap to unpin' : 'Pin to keep this playlist available offline';
pinBtn.classList.toggle('pinned', nowPinned);
pinBtn.title = nowPinned ? 'Pinned for offline keep — click to unpin' : 'Pin to keep all videos in this playlist available offline';
pinBtn.setAttribute('aria-pressed', String(nowPinned));
pinBtn.title = label;
pinBtn.setAttribute('aria-label', label);
}
const offlineBtn = $('plOfflineBtn');
if (offlineBtn) updatePlaylistOfflineButton(offlineBtn, pl);
@@ -10712,13 +10776,17 @@ async function applyUpdate(target, { quiet = false } = {}) {
if (_updateToast) _updateToast.remove();
_updateToast = phase === 'ready'
? toast('Applying the update…', { duration: 20000 })
: toast('Downloading the update…', { duration: 125000 });
: toast('Downloading the update…', { duration: 305000 });
},
refreshShell: ('caches' in window) ? () => SU.refreshShellInPlace({
cachesApi: caches,
fetchFn: (url, init) => fetch(url, init),
isShellCache: (name) => SHELL_CACHE_RE.test(name),
timeoutMs: 120000,
// Lossy link: 4 downloads at a time, 5 minutes overall, and files
// that arrived survive a timeout so a retry only fetches the rest.
timeoutMs: 300000,
concurrency: 4,
stagingKey: target,
attempts: 3,
attemptTimeoutMs: 25000,
setTimeout: (fn, ms) => setTimeout(fn, ms),

View File

@@ -359,12 +359,16 @@
<!-- List pane -->
<section id="listPane" class="list-pane">
<div class="list-header">
<div class="list-title-row">
<h2 id="listTitle">Search</h2>
<div id="listTitleActions" class="list-title-actions"></div>
</div>
<p id="listSub" class="list-sub"></p>
<div id="listActions" class="list-actions"></div>
</div>
<div id="listFilterBar" class="list-filter-bar hidden">
<input id="listFilterInput" type="text" placeholder="Filter…" autocomplete="off" />
<div id="listFilterActions" class="list-filter-actions"></div>
</div>
<div id="batchBar" class="batch-bar hidden">
<span id="batchCount" class="batch-count">0 selected</span>

View File

@@ -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); }

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 };
}
/**

View File

@@ -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 () => {

View File

@@ -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); }