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),