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:
150
frontend/app.js
150
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 = '<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),
|
||||
|
||||
Reference in New Issue
Block a user