From 985063648fda9571cd2bdc627460e5433addd511 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 2 Oct 2026 20:59:51 +0800 Subject: [PATCH] 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 --- frontend/app.js | 150 +++++++++++++++++++++++++++---------- frontend/index.html | 6 +- frontend/refresh.css | 47 ++++++++++++ frontend/sw-update.js | 58 +++++++++++--- frontend/sw-update.test.js | 55 +++++++++++++- frontend/theme-glass.css | 4 + 6 files changed, 266 insertions(+), 54 deletions(-) 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 = '
Loading the server library…
'; @@ -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: '', + share: '', + offline: '', + offlineDone: '', + pin: '', + select: '', + del: '', +}; +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 = ``; + if (onClick) b.onclick = onClick; + return b; +} +function setListIcon(btn, icon) { + btn.innerHTML = ``; +} + +// 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 = `${stats.cached}/${stats.total}`; 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 = `${stats.cached}/${stats.total}`; + 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), diff --git a/frontend/index.html b/frontend/index.html index 15705c1..0398292 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -359,12 +359,16 @@
-

Search

+
+

Search

+
+