Files
ytplayer/frontend/views-core.js

1502 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Eager UI definitions. Read app lexical state only when called after initialization. */
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>';
let list = [];
try {
const j = await (await fetch('/api/uploads?limit=200')).json();
list = (j && j.ok && j.uploads) || [];
} catch {
els.cards.innerHTML = '<div class="sm-err">⚠ Could not reach the server</div>';
return;
}
if (view.type !== 'library') return;
if (!list.length) {
els.cards.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📁</div>
<h3 class="empty-title">Nothing uploaded yet</h3>
<p class="empty-desc">An admin can upload videos and audio at <b>/admin</b>; they show up here and in search, with their cover art and any embedded lyrics.</p>
</div>`;
return;
}
const cards = list.map((u) => ({
id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', duration: u.duration,
thumbnail: u.art ? `/api/uploads/${u.id}/art` : '', kind: u.kind, upload: true,
}));
els.cards.innerHTML = '';
cards.forEach((v, i) => els.cards.appendChild(renderCard(v, i, cards)));
}
function renderNotesView() {
els.listTitle.textContent = 'Notes';
els.status.classList.add('hidden');
const groups = Object.entries(data.bookmarks || {})
.filter(([, list]) => Array.isArray(list) && list.length)
.map(([id, list]) => {
const h = data.history.find((x) => x.id === id);
const first = list.find((b) => b.vt) || {};
return { id, title: first.vt || (h && h.title) || id, channel: first.vc || (h && h.channel) || '', list };
});
// Most recently played first (history order), the rest after.
const order = new Map(data.history.map((v, i) => [v.id, i]));
groups.sort((a, b) => (order.has(a.id) ? order.get(a.id) : 1e9) - (order.has(b.id) ? order.get(b.id) : 1e9));
els.listActions.innerHTML = groups.length ? '<button class="np-btn" id="notesExportBtn" type="button">⤓ Export Markdown</button>' : '';
const c = els.cards;
if (!groups.length) {
c.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📝</div>
<h3 class="empty-title">No notes yet</h3>
<p class="empty-desc">While a video plays, open <b>Chapters &amp; bookmarks</b> → <b>📝 Note</b> (or press N) to write a note tied to that exact moment. They sync with your online profile.</p>
</div>`;
return;
}
const total = groups.reduce((n, g) => n + g.list.length, 0);
c.innerHTML = `
<div class="notes-view">
<input class="nv-filter" type="search" placeholder="Filter ${total} notes & bookmarks…" />
${groups.map((g) => `
<section class="nv-group" data-id="${escapeHtml(g.id)}">
<div class="nv-head"><b>${escapeHtml(g.title)}</b><small>${escapeHtml(g.channel)} · ${g.list.length}</small></div>
${g.list.map((b) => `
<button class="nv-item" type="button" data-t="${b.t}" data-q="${escapeHtml(`${b.title} ${b.note || ''} ${g.title}`.toLowerCase())}">
<span class="nv-time">${LyricsCore.fmtClock(b.t)}</span>
<span class="nv-body"><span class="nv-title">${b.kind === 'note' ? '📝 ' : '📌 '}${escapeHtml(b.title)}</span>${b.note ? `<span class="nv-note">${escapeHtml(b.note)}</span>` : ''}</span>
</button>`).join('')}
</section>`).join('')}
</div>`;
c.querySelector('.nv-filter').addEventListener('input', (e) => {
const q = e.target.value.trim().toLowerCase();
c.querySelectorAll('.nv-group').forEach((g) => {
let any = false;
g.querySelectorAll('.nv-item').forEach((it) => { const hit = !q || it.dataset.q.includes(q); it.classList.toggle('hidden', !hit); any = any || hit; });
g.classList.toggle('hidden', !any);
});
});
c.querySelector('.nv-filter').addEventListener('keydown', (e) => e.stopPropagation());
c.querySelectorAll('.nv-item').forEach((it) => it.addEventListener('click', () => {
const g = groups.find((x) => x.id === it.closest('.nv-group').dataset.id);
playVideoAt({ id: g.id, title: g.title, channel: g.channel }, Number(it.dataset.t));
}));
const ex = $('notesExportBtn');
if (ex) ex.addEventListener('click', () => {
const md = ['# YT Player notes', `_Exported ${new Date().toLocaleString()}_`, ''];
for (const g of groups) {
md.push(`## ${g.title}`, `${g.channel ? g.channel + ' · ' : ''}https://youtu.be/${g.id}`, '');
for (const b of g.list) {
md.push(`- **[${LyricsCore.fmtClock(b.t)}](${location.origin}/?v=${g.id}&t=${Math.floor(b.t)})** ${b.title}`);
if (b.note && b.note !== b.title) md.push(...b.note.split('\n').map((l) => ` ${l}`));
}
md.push('');
}
const blob = new Blob([md.join('\n')], { type: 'text/markdown' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `ytplayer-notes-${new Date().toISOString().slice(0, 10)}.md`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
});
}
function showSearchSkeletons() {
// A search is in flight — the pane is about to have results, so reveal it
// now rather than popping in only once they land.
setListPaneHidden(false);
els.status.classList.add('hidden');
els.cards.innerHTML = '';
for (let i = 0; i < 8; i++) {
const s = document.createElement('div');
s.className = 'card skeleton-card';
s.innerHTML = `
<div class="thumb skeleton-shimmer"></div>
<div class="card-info">
<div class="skeleton-line skeleton-shimmer" style="width:85%"></div>
<div class="skeleton-line skeleton-shimmer" style="width:55%;margin-top:8px"></div>
</div>
<div class="skeleton-dot skeleton-shimmer"></div>`;
els.cards.appendChild(s);
}
}
function setListPaneHidden(hidden) {
const pane = document.getElementById('listPane');
if (pane) pane.classList.toggle('hidden', hidden);
}
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>`;
}
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() {
// Theme composition follows the real view without changing its controls.
els.cards.dataset.view = view.type;
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 && !els.placeholder.classList.contains('hidden'));
if (view.type === 'settings') { renderSettings(); return; }
if (view.type === 'saved') { renderSaved(); return; }
if (view.type === 'p2p') { renderP2pList(); return; }
if (view.type === 'savedSearches') { renderSavedSearches(); return; }
if (view.type === 'downloads') { renderDownloads(); return; }
if (view.type === 'stats') { renderStats(); return; }
if (view.type === 'notes') { renderNotesView(); return; }
if (view.type === 'library') { renderLibrary(); return; }
// Filter bar — show for filterable views
const filterableViews = ['history', 'playlist', 'queue', 'channel', 'smart', 'nowPlaying'];
const showFilter = filterableViews.includes(view.type);
$('listFilterBar').classList.toggle('hidden', !showFilter);
$('batchBar').classList.toggle('hidden', !selectMode);
if (selectMode) $('batchCount').textContent = `${selectedIds.size} selected`;
// A channel's videos can be queued or added to a playlist, not deleted.
$('batchDeleteBtn').classList.toggle('hidden', view.type === 'channel');
// Build the list, applying filter if active
let list = currentList();
if (listFilter && showFilter) {
const q = listFilter.toLowerCase();
list = list.filter((v) => (v.title || '').toLowerCase().includes(q) || (v.channel || '').toLowerCase().includes(q));
}
els.cards.innerHTML = '';
els.cards.classList.toggle('select-mode', selectMode);
resetListHeaderExtras();
els.listActions.innerHTML = '';
if (view.type === 'search' && WEB && els.placeholder.classList.contains('hidden')) {
Recommendations.inList();
}
if (view.type === 'nowPlaying') {
els.listTitle.textContent = 'Now playing';
} else if (view.type === 'search') {
els.listTitle.textContent = 'Search results';
} else if (view.type === 'history') {
els.listTitle.textContent = 'History';
if (list.length) {
const clear = document.createElement('button');
clear.textContent = 'Clear';
clear.onclick = () => { data.history = []; persist(); renderList(); };
els.listActions.appendChild(clear);
}
} else if (view.type === 'queue') {
els.listTitle.textContent = 'Queue';
if (list.length) {
const playAll = document.createElement('button');
playAll.textContent = '▶ Play queue';
playAll.onclick = () => playQueue(0);
const clear = document.createElement('button');
clear.textContent = 'Clear';
clear.onclick = clearQueue;
els.listActions.append(playAll, clear);
}
} else if (view.type === 'smart') {
const sp = SMART_PLAYLISTS.find((s) => s.id === view.smartType);
els.listTitle.textContent = sp ? sp.label : 'Auto Playlist';
if (list.length) {
const playAll = document.createElement('button');
playAll.textContent = '▶ Play all';
playAll.onclick = () => playFromList(list.slice(), 0, 'smart:' + view.smartType);
const queueAll = document.createElement('button');
queueAll.textContent = '+ Queue all';
queueAll.onclick = () => { list.forEach((v) => addToQueue(v, { quiet: true })); toast('Added to queue'); };
els.listActions.append(playAll, queueAll);
}
} else if (view.type === 'channel') {
els.listTitle.textContent = channelData.name || 'Channel';
if (list.length) {
const playAll = document.createElement('button');
playAll.textContent = '▶ Play all';
playAll.onclick = () => playFromList(channelData.results.slice(), 0, 'channel');
const queueAll = document.createElement('button');
queueAll.textContent = '+ Queue all';
queueAll.onclick = () => { channelData.results.forEach((v) => addToQueue(v, { quiet: true })); toast('Added channel to queue'); };
els.listActions.append(playAll, queueAll);
}
} else if (view.type === 'playlist') {
const pl = data.playlists.find((p) => p.id === view.id);
els.listTitle.textContent = pl ? pl.name : 'Playlist';
if (pl) {
{ const secs = (pl.videos || []).reduce((a, v) => a + (Number(v.duration) || 0), 0);
const h = Math.floor(secs / 3600), m = Math.round((secs % 3600) / 60);
$('listSub').textContent = `${pl.videos.length} track${pl.videos.length === 1 ? '' : 's'}${secs ? ' · ' + (h ? h + 'h ' : '') + m + 'm' : ''}`; }
renderP2pStrip();
const playAll = document.createElement('button');
playAll.textContent = '▶ Play all';
playAll.className = 'la-primary';
playAll.onclick = () => { if (pl.videos.length) playFromList(pl.videos, 0, 'playlist:' + pl.id); };
const playFull = document.createElement('button');
playFull.textContent = '▶ Play in full';
playFull.className = 'la-main';
playFull.dataset.sub = 'Start to finish';
playFull.title = 'Play every video start to finish — ignores saved positions and A-B loops, stops after the last one';
playFull.onclick = () => {
if (!pl.videos.length) return;
playFromList(pl.videos, 0, 'playlist:' + pl.id, { playFull: true });
toast('Playing in full — stops after the last video');
};
const serviceBtn = document.createElement('button');
serviceBtn.textContent = '🎤 Service mode';
serviceBtn.className = 'la-main';
serviceBtn.dataset.sub = 'Full lyrics';
serviceBtn.title = 'Set-list view with screen wake lock';
serviceBtn.onclick = () => {
if (!pl.videos || !pl.videos.length) { toast('Playlist is empty'); return; }
enterServiceMode(pl);
};
const queueAll = document.createElement('button');
queueAll.textContent = '+ Queue';
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'); };
// 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);
// 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);
const isPinned = (data.pinnedPlaylists || []).includes(pl.id);
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.setAttribute('aria-pressed', String(isPinned));
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', 'channel'];
if (batchViews.includes(view.type) && list.length) {
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) {
showSearchSkeletons();
return;
}
if (!list.length) {
els.status.classList.add('hidden');
els.cards.innerHTML = '';
const empty = document.createElement('div');
empty.className = 'empty-state';
if (view.type === 'search') {
// Landing (hero or playlist board) lives in the player pane. Before the
// first search this pane is hidden outright; after one that came back
// empty it stays visible so the result is legible.
els.status.classList.remove('hidden');
els.status.textContent = searchAttempted
? 'No results for that search.'
: 'Search for something to begin.';
return;
}
if (view.type === 'history') {
empty.innerHTML = `
<div class="empty-icon">🕘</div>
<h3 class="empty-title">Nothing watched yet</h3>
<p class="empty-desc">Your viewing history shows up here once you start playing videos.</p>
`;
addBrowseCta(empty, '🔍 Search videos');
} else if (view.type === 'queue') {
empty.innerHTML = `
<div class="empty-icon">▶</div>
<h3 class="empty-title">Your queue is empty</h3>
<p class="empty-desc">Use <strong>+ Queue</strong> on any video to line it up. The queue is temporary and plays in order.</p>
`;
addBrowseCta(empty, '🔍 Find something to play');
} else if (view.type === 'channel') {
empty.innerHTML = `
<div class="empty-icon">📺</div>
<h3 class="empty-title">No videos found</h3>
<p class="empty-desc">This channel didn't return any uploads.</p>
`;
addBrowseCta(empty, '🔍 Back to search');
} else {
// Playlist view — empty
empty.innerHTML = `
<div class="empty-icon">🎵</div>
<h3 class="empty-title">This playlist is empty</h3>
<p class="empty-desc">Add videos from search results or use the <strong>+ Playlist</strong> button while playing.</p>
`;
addBrowseCta(empty, '🔍 Browse videos');
}
els.cards.appendChild(empty);
return;
}
els.status.classList.add('hidden');
list.forEach((v, i) => els.cards.appendChild(renderCard(v, i, list)));
markPlayingCard();
}
function addBrowseCta(empty, label) {
const cta = document.createElement('button');
cta.className = 'empty-cta';
cta.textContent = label;
cta.addEventListener('click', () => { view = { type: 'search' }; render(); });
empty.appendChild(cta);
}
async function renderP2pStrip() {
const box = $('p2pStrip');
if (!box || document.documentElement.dataset.layout !== 'glass-stage' || !WEB || data.settings.p2pReceive === false) return;
if (!p2pStripData || Date.now() - p2pStripAt > 60000) {
try { const r = await fetch('/api/p2p/available?limit=3'); p2pStripData = r.ok ? await r.json() : null; } catch { p2pStripData = null; }
p2pStripAt = Date.now();
}
if (view.type !== 'playlist') return;
const items = (p2pStripData && p2pStripData.ok && p2pStripData.items) || [];
if (!items.length) { box.classList.add('hidden'); return; }
const online = items.filter((i) => i.online).length;
box.innerHTML = `<div class="ps-head"><span>On other devices</span><i class="ps-dot${online ? ' on' : ''}"></i></div>
<p class="ps-sub">${items.length} video${items.length === 1 ? '' : 's'} held by other devices · ${online ? online + ' online now' : 'none online'}</p>`;
items.forEach((it) => {
const v = { id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '', duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg` };
const row = document.createElement('div');
row.className = 'ps-row';
row.innerHTML = '<span class="ps-ic">📡</span><span class="ps-txt"><b></b><small></small></span><button type="button" class="ps-dl" aria-label="Save to this device">⤓</button>';
row.querySelector('b').textContent = v.title;
row.querySelector('small').textContent = `${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''} · ${it.online ? 'Online' : 'Offline'}`;
row.querySelector('.ps-dl').onclick = () => exportToDevice(v);
row.querySelector('.ps-txt').onclick = () => playFromList([v], 0, 'p2p');
box.appendChild(row);
});
box.classList.remove('hidden');
}
function fmtAgo(ts) {
const s = Math.max(0, (Date.now() - ts) / 1000);
if (s < 90) return 'just now';
if (s < 3600) return Math.round(s / 60) + ' min ago';
if (s < 86400) return Math.round(s / 3600) + ' h ago';
return Math.round(s / 86400) + ' d ago';
}
function renderSavedSearches() {
els.listTitle.textContent = '📚 Saved searches';
resetListHeaderExtras();
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
const c = els.cards;
c.innerHTML = '';
const items = SearchLibrary.list();
if (!items.length) {
c.innerHTML = '<div class="empty-state"><div class="empty-icon">📚</div><h3 class="empty-title">No saved searches yet</h3><p class="empty-desc">Every search you run is kept here with its thumbnails, even after you clear the search box. You can remove them in Settings.</p></div>';
return;
}
const sum = document.createElement('div');
sum.className = 'saved-summary';
sum.innerHTML = `<span class="saved-total">${items.length}</span><span class="saved-sub">saved search${items.length === 1 ? '' : 'es'} · ${fmtBytes(SearchLibrary.bytes())} · manage in Settings</span>`;
c.appendChild(sum);
items.forEach((it) => {
const row = document.createElement('div');
row.className = 'card saved-card ss-card';
row.innerHTML = `
<div class="thumb"><img loading="lazy" decoding="async" src="${escapeHtml(it.thumb)}" alt="" onerror="this.classList.add('no-art')" /></div>
<div class="card-info"><div class="card-title"></div><div class="card-channel">${it.count} result${it.count === 1 ? '' : 's'} · ${fmtAgo(it.ts)}</div></div>`;
row.querySelector('.card-title').textContent = it.query;
row.addEventListener('click', () => {
els.searchInput.value = it.query;
if (els.runSearch) els.runSearch(it.query);
});
c.appendChild(row);
});
}
async function renderP2pList() {
els.listTitle.textContent = '📡 On other devices';
resetListHeaderExtras();
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
const c = els.cards;
c.innerHTML = '<div class="status">Looking for videos on other devices…</div>';
let res = null;
try { const r = await fetch('/api/p2p/available?limit=100'); res = r.ok ? await r.json() : null; } catch { /* offline */ }
if (view.type !== 'p2p') return;
c.innerHTML = '';
if (!res || !res.ok) {
c.innerHTML = '<div class="empty-state"><div class="empty-icon">📡</div><h3 class="empty-title">Not available</h3><p class="empty-desc">Peer-to-peer sharing is off on this server, or you are offline.</p></div>';
return;
}
const items = res.items || [];
const list = items.map((it) => ({
id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '',
duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`,
}));
const refresh = document.createElement('button');
refresh.textContent = '↻';
refresh.className = 'la-icon';
refresh.title = 'Refresh';
refresh.setAttribute('aria-label', 'Refresh');
refresh.onclick = () => renderP2pList();
const playAll = document.createElement('button');
playAll.textContent = '▶ Play all';
playAll.className = 'la-primary la-inline';
playAll.onclick = () => { if (list.length) playFromList(list, 0, 'p2p'); };
els.listActions.append(playAll, refresh);
if (!items.length) {
c.innerHTML = '<div class="empty-state"><div class="empty-icon">📡</div><h3 class="empty-title">Nothing shared yet</h3><p class="empty-desc">Videos that other devices have saved and verified show up here. Save a video and keep sharing on, and yours will appear for everyone else.</p></div>';
return;
}
const online = items.filter((i) => i.online).length;
const sum = document.createElement('div');
sum.className = 'saved-summary';
sum.innerHTML = `<span class="saved-total">${items.length}</span><span class="saved-sub">video${items.length === 1 ? '' : 's'} on other devices · ${online} with a device online now</span>`;
c.appendChild(sum);
items.forEach((it, idx) => {
const v = list[idx];
const row = document.createElement('div');
row.className = 'card saved-card p2p-card';
row.dataset.id = it.id;
const state = it.online ? `<b class="p2p-on">● ${it.online} online</b>` : '<span class="p2p-off">○ none online</span>';
row.innerHTML = `
<div class="thumb"><img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail)}" alt="" onerror="this.classList.add('no-art')" /></div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel">${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · <i>not checked lately</i>' : ''}</div>
</div>
<button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">⤓</button>
<button class="card-act" data-act="add" title="Add to a playlist" aria-label="Add to a playlist">+</button>`;
row.querySelector('.card-title').textContent = v.title;
row.addEventListener('click', (e) => {
const act = e.target.closest('[data-act]');
if (act) { e.stopPropagation(); if (act.dataset.act === 'export') exportToDevice(v); else openCardMenu(v); return; }
playFromList(list, idx, 'p2p');
});
c.appendChild(row);
});
markPlayingCard();
}
async function renderSaved() {
els.listTitle.textContent = 'Saved videos';
resetListHeaderExtras();
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
$('batchBar').classList.add('hidden');
const c = els.cards;
c.classList.remove('select-mode');
c.innerHTML = '<div class="status">Loading saved videos…</div>';
let res;
try { res = await API.cacheList(); } catch { res = null; }
if (view.type !== 'saved') return; // navigated away
if (!res || !res.ok) {
$('listFilterBar').classList.add('hidden');
c.innerHTML = '';
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.innerHTML = `
<div class="empty-icon">💾</div>
<h3 class="empty-title">Offline cache unavailable</h3>
<p class="empty-desc">This build doesn't support saving videos for offline playback.</p>`;
c.appendChild(empty);
return;
}
const items = (res.items || []).slice().sort((a, b) => b.size - a.size);
const total = res.total || 0;
if (!items.length) {
$('listFilterBar').classList.add('hidden');
c.innerHTML = '';
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.innerHTML = `
<div class="empty-icon">💾</div>
<h3 class="empty-title">Nothing saved yet</h3>
<p class="empty-desc">Use <strong>⬇ Save</strong> while playing, or add videos to a playlist to keep them offline.</p>`;
addBrowseCta(empty, '🔍 Find videos');
c.appendChild(empty);
return;
}
// Header actions: total + clear all.
const clearAll = document.createElement('button');
clearAll.type = 'button';
clearAll.className = 'saved-clear';
clearAll.textContent = 'Clear all';
clearAll.setAttribute('aria-label', 'Clear all saved videos');
clearAll.onclick = () => {
showModal('Clear all saved videos?', document.createTextNode('Frees disk space. Normal videos re-download on demand, but edited videos are removed for good (they exist only here).'), [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Clear all', danger: true, onClick: async () => {
try {
const result = await API.cacheClear();
if (result?.ok === false) throw new Error(result.error || 'Storage could not be cleared');
} catch { toast('Could not clear saved videos. Try again.'); closeModal(); return; }
cachedIds.clear();
// Custom cuts live only in the cache — clearing it destroys their media,
// so drop their registry entries and playlist references too.
const customIds = new Set((data.customVideos || []).map((v) => v.id));
if (customIds.size) {
data.customVideos = [];
data.playlists.forEach((pl) => { pl.videos = pl.videos.filter((x) => !customIds.has(x.id)); });
data.queue = (data.queue || []).filter((x) => !customIds.has(x.id));
persist();
}
closeModal();
toast('Cache cleared');
if (current) updateNowPlayingActions();
renderSaved();
} },
]);
};
c.innerHTML = '';
const summary = document.createElement('div');
summary.className = 'saved-summary saved-storage-summary';
summary.innerHTML = `<span class="saved-storage-text">${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'} stored offline</span>`;
summary.appendChild(clearAll);
$('listFilterBar').classList.remove('hidden');
c.appendChild(summary);
items.forEach((it) => {
const known = videoById(it.id);
// A custom (edited) id has no YouTube thumbnail; fall back to its source's
// thumbnail when we know it, else a neutral placeholder.
const isCustom = (known && known.custom) || String(it.id).startsWith('edit_');
const fallbackThumb = isCustom ? '' : `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`;
const v = known || { id: it.id, title: videoTitleById(it.id), thumbnail: fallbackThumb };
if (!SavedPage.matches(v, listFilter)) return;
const row = document.createElement('div');
row.className = 'card saved-card' + (isCustom ? ' custom' : '');
row.dataset.id = it.id;
row.innerHTML = `
<div class="thumb"><img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || thumbUrlFor(v.id, null) || '')}" alt=""
onerror="this.classList.add('no-art')" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}${isUploadId(v.id) ? `<span class="upload-badge" title="On this server">${v.kind === 'audio' ? '♪' : '📁'}</span>` : ''}</div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>
</div>`;
row.querySelector('.card-title').textContent = v.title || it.id;
row.addEventListener('click', (e) => {
if (e.target.closest('.saved-file-actions')) return;
playFromList([v], 0, 'saved');
});
SavedPage.actions(row, v, () => exportToDevice(v), () => {
showModal(`Delete saved file “${v.title || v.id}”?`, document.createTextNode(isCustom
? 'This edited copy exists only on this device and will be removed permanently.'
: 'Frees space on this device. You can save this video again later.'), [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Delete saved file', danger: true, onClick: async () => {
try {
const result = await API.cacheDelete(it.id);
if (result?.ok === false) throw new Error(result.error || 'Storage could not be updated');
} catch { toast('Could not delete this saved file. Try again.'); closeModal(); return; }
cachedIds.delete(it.id);
if (isCustom) {
data.customVideos = (data.customVideos || []).filter(x => x.id !== it.id);
data.playlists.forEach(pl => { pl.videos = pl.videos.filter(x => x.id !== it.id); });
data.queue = (data.queue || []).filter(x => x.id !== it.id);
persist();
}
if (current?.meta?.id === it.id) updateNowPlayingActions();
markCardCacheState(it.id, 'none'); closeModal();
toast('Removed saved file'); renderSaved();
} },
]);
});
c.appendChild(row);
});
if (!c.querySelector('.saved-card')) {
const message = document.createElement('p'); message.className = 'status';
message.textContent = 'No saved videos match your filter.'; c.appendChild(message);
}
markPlayingCard();
}
function renderDownloads() {
els.listTitle.textContent = 'Downloads';
resetListHeaderExtras();
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
const c = els.cards;
c.innerHTML = '';
$('listFilterBar').classList.toggle('hidden', !Downloads.getJobs().length);
$('batchBar').classList.add('hidden');
c.classList.remove('select-mode');
if (window.DownloadsPage && window.DownloadsPage.render) {
window.DownloadsPage.render(c, {
downloadsApi: window.Downloads,
filter: listFilter,
isActive: () => view.type === 'downloads',
showModal: typeof showModal === 'function' ? showModal : null,
closeModal: typeof closeModal === 'function' ? closeModal : null,
onNavigateSaved: () => { view = { type: 'saved' }; render(); },
thumbUrlFor: typeof thumbUrlFor === 'function' ? thumbUrlFor : null,
refresh: renderList
});
}
const jobs = window.Downloads && window.Downloads.getJobs ? window.Downloads.getJobs() : [];
jobs.filter(j => j.status === 'active' || j.status === 'queued').forEach(v => {
if (typeof SaveQueue !== 'undefined' && SaveQueue.progress) {
SaveQueue.progress(v.id, SaveQueue.get(v.id));
}
});
}
async function renderSettings() {
els.listTitle.textContent = 'Settings';
resetListHeaderExtras();
els.listActions.innerHTML = '';
const c = els.cards;
els.status.classList.add('hidden');
c.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'settings';
// ---- Playback ----
const qualities = ['auto', '1080p', '720p', '480p', '360p', '240p'];
const qOptions = qualities
.map((q) => `<option value="${q}"${data.settings.quality === q ? ' selected' : ''}>${q === 'auto' ? 'Auto' : q}</option>`)
.join('');
const sel = (id, val, opts) => `<select id="${id}" class="set-select">` +
opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
`</select>`;
wrap.innerHTML = `
<div class="set-group">
<div class="set-group-title">Appearance &amp; accessibility</div>
<label class="set-row">
<span>Theme</span>
${sel('setTheme', data.settings.theme, [['dark', 'Dark'], ['light', 'Light'], ['contrast', 'High contrast']])}
</label>
<label class="set-row">
<span>Layout theme</span>
${sel('setLayout', data.settings.layout || 'classic', [['classic', 'Classic (default)'], ['glass-stage', 'Glass Stage'], ['sanctuary-stage', 'Sanctuary Stage'], ['liturgical', 'Liturgical Chronicle'], ['living-hymnal', 'Living Hymnal'], ['broadcaster', 'Sunday Broadcaster'], ['contemplative', 'Contemplative'], ['bento-hub', 'Bento Hub'], ['f7-swipe', 'Framework7 Swipe']])}
</label>
<label class="set-row">
<span>Font size</span>
${sel('setFont', data.settings.fontScale, [['small', 'Small'], ['normal', 'Normal'], ['large', 'Large'], ['xl', 'Extra large']])}
</label>
<label class="set-row">
<span>Layout density</span>
${sel('setDensity', data.settings.density, [['comfortable', 'Comfortable'], ['compact', 'Compact']])}
</label>
<label class="set-row">
<span>
Performance mode
<small>Turns off heavy visual effects (grain, blur, glows) for a faster, lighter UI.</small>
</span>
<input id="setPerf" type="checkbox" ${data.settings.perfMode ? 'checked' : ''} />
</label>
<label class="set-row">
<span>
Reduce motion
<small>Disables animations and transitions.</small>
</span>
<input id="setMotion" type="checkbox" ${data.settings.reduceMotion ? 'checked' : ''} />
</label>
</div>
<div class="set-group">
<div class="set-group-title">Playback</div>
<label class="set-row">
<span>Default quality</span>
<select id="setQuality" class="set-select">${qOptions}</select>
</label>
<label class="set-row">
<span>Repeat list when finished</span>
<input id="setRepeat" type="checkbox" ${data.settings.repeatMode === 'all' ? 'checked' : ''} />
</label>
<label class="set-row">
<span>Loop the current video</span>
<input id="setLoopOne" type="checkbox" ${data.settings.loopOne ? 'checked' : ''} />
</label>
<label class="set-row">
<span>
Level volume between songs
<small>${EQ.isIOS()
? 'Brings every song to the same loudness (−14 LUFS). On iPhone it is built into saved songs, so they keep playing with the screen locked.'
: 'Brings every song to the same loudness (−14 LUFS), measured on this device and shared.'}</small>
</span>
<input id="setLevelVolume" type="checkbox" ${Level.enabled() ? 'checked' : ''} />
</label>
${EQ.isIOS() ? `<label class="set-row">
<span>
Live sound processing
<small>Applies the equalizer and levelling to streamed songs too. iOS stops this sound when the screen locks — saved songs are unaffected.</small>
</span>
<input id="setLiveAudioIOS" type="checkbox" ${data.settings.liveAudioIOS ? 'checked' : ''} />
</label>` : ''}
<label class="set-row">
<span>Default volume</span>
<input id="setVolume" type="range" min="0" max="1" step="0.01" value="${data.settings.volume}" />
</label>
<label class="set-row">
<span>Audio-only mode</span>
<input id="setAudioOnly" type="checkbox" ${data.settings.audioOnly ? 'checked' : ''} />
</label>
<label class="set-row">
<span>
Save before playing
<small>Downloads the whole video to this device first (the server compiles video + audio into one file), then plays the local copy. One stream instead of two, but playback starts after the download finishes.</small>
</span>
<input id="setSaveBeforePlay" type="checkbox" ${data.settings.saveBeforePlay ? 'checked' : ''} />
</label>
<label class="set-row">
<span>
Report wrong lyric lines
<small>Service mode → Lyrics shows a Report button, and holding a line reports it. Saved with your online profile, so it follows you between devices.</small>
</span>
<input id="setLyricFlags" type="checkbox" ${data.settings.lyricFlags !== false ? 'checked' : ''} />
</label>
</div>
<div class="set-group">
<div class="set-group-title">Sharing (peer-to-peer)</div>
<label class="set-row">
<span>
Share my saved videos
<small>Other devices can download videos you saved when YouTube and the server can't provide them. Files are verified by their hash. On by default.</small>
</span>
<input id="setP2pShare" type="checkbox" ${data.settings.p2pShare !== false ? 'checked' : ''} />
</label>
<label class="set-row">
<span>
Get videos from other devices
<small>When a video is gone from YouTube and the server, download a verified copy from a device that has it. On by default.</small>
</span>
<input id="setP2pReceive" type="checkbox" ${data.settings.p2pReceive !== false ? 'checked' : ''} />
</label>
<div class="set-row">
<span>This device<small id="p2pDeviceInfo">…</small></span>
<span id="p2pHoldingCount" class="set-stat">…</span>
</div>
<div class="set-row hidden" id="p2pContributeRow">
<span>
Saved videos the server can't verify yet
<small>Uploading lets the server check them (hash + media validation) so other devices can get them. Uses your upload bandwidth.</small>
</span>
<button id="p2pContributeBtn" class="btn">Verify &amp; share</button>
</div>
</div>
<div class="set-group">
<div class="set-group-title">Offline cache</div>
<label class="set-row">
<span>
Auto-save playlist videos
<small>Videos you add to a playlist are downloaded and kept for offline playback.</small>
</span>
<input id="setAutoPreload" type="checkbox" ${data.settings.autoPreload ? 'checked' : ''} />
</label>
<label class="set-row">
<span>
Videos saved at once
<small>How many offline saves run in parallel; the rest wait their turn.</small>
</span>
${sel('setParallelSaves', String(Math.max(1, Math.min(8, Number(data.settings.parallelSaves) || 4))), [['1', '1'], ['2', '2'], ['3', '3'], ['4', '4 (default)'], ['6', '6'], ['8', '8']])}
</label>
<div class="set-row">
<span>Storage used</span>
<span id="cacheTotal" class="set-stat">…</span>
</div>
<label class="set-row">
<span>
Cache size limit
<small>Auto-evict oldest files when cache exceeds this.</small>
</span>
<select id="cacheCapSelect" class="set-select">
<option value="0">No limit</option>
<option value="1073741824">1 GB</option>
<option value="2147483648">2 GB</option>
<option value="5368709120">5 GB</option>
<option value="10737418240">10 GB</option>
</select>
</label>
<div class="set-actions">
<button id="clearCacheBtn" class="btn danger">Clear all cached videos</button>
</div>
<details id="cacheFold" class="cache-fold">
<summary><span>Saved videos</span><span id="cacheFoldCount" class="cache-fold-count"></span></summary>
<div id="cacheList" class="cache-list"></div>
</details>
</div>
<div class="set-group">
<div class="set-group-title">Saved searches</div>
<div class="set-row">
<span>
Kept on this device
<small>Search results and their thumbnails stay saved after you clear the search box. Only you can remove them, here.</small>
</span>
<span id="ssTotal" class="set-stat">…</span>
</div>
<div class="set-actions">
<button id="ssSelectAll" class="btn">Select all</button>
<button id="ssDeleteSel" class="btn danger" disabled>Delete selected</button>
<button id="ssClearAll" class="btn danger">Clear all</button>
</div>
<div id="ssList" class="ss-list"></div>
</div>
<div class="set-group">
<div class="set-group-title">Playlists</div>
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
<button id="importYtBtn" class="btn">⬇ Import from YouTube</button>
<button id="exportBtn" class="btn">Export playlists</button>
<button id="importBtn" class="btn">Import playlists</button>
<input id="fileInput" type="file" accept=".json" style="display:none" />
</div>
</div>
<div class="set-group">
<div class="set-group-title">Backup &amp; restore</div>
<label class="set-row">
<span>
Auto-backup
<small>Export a full backup JSON on a schedule.</small>
</span>
<input id="setAutoBackup" type="checkbox" ${data.settings.autoBackupEnabled ? 'checked' : ''} />
</label>
<label class="set-row">
<span>Backup interval</span>
${sel('setBackupInterval', String(data.settings.autoBackupIntervalDays || 7), [['1','Daily'],['7','Weekly'],['30','Monthly']])}
</label>
<div class="set-row" style="border:none;gap:8px;flex-wrap:wrap">
<button id="backupNowBtn" class="btn">Export full backup now</button>
<button id="importBackupBtn" class="btn">Import backup</button>
<input id="backupFileInput" type="file" accept=".json" style="display:none" />
</div>
</div>
<div class="set-group">
<div class="set-group-title">Reset</div>
<div class="set-row">
<span>
Force refresh UI
<small>Re-fetches the app shell and assets, bypassing the browser cache, then reloads.</small>
</span>
<div class="set-actions" style="margin:0">
<button id="refreshUiBtn" class="btn">Refresh UI</button>
</div>
</div>
<div class="set-row">
<span>
Reset app
<small>Clears all local storage, unregisters the service worker, and deletes every cache. You will need to reload after.</small>
</span>
<div class="set-actions" style="margin:0">
<button id="resetAppBtn" class="btn danger">Reset app</button>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-title">Online profile — sync between devices</div>
<div class="set-row">
<span>
Linked profile
<small>Playlists, history and settings sync to the server under this name. The name works like a passkey — anyone who knows it can load and change this data, so prefer a random one.</small>
</span>
<span id="profileStatus" class="set-stat">${data.profile && data.profile.name ? data.profile.name : 'Not linked'}</span>
</div>
<div class="set-row${profileShareUrl() ? '' : ' hidden'}" id="profileLinkRow">
<span>
Profile link
<small>Open this URL on another device to load the profile there. Anyone with the link gets full access to this data — share it only with your own devices or people you trust.</small>
</span>
<span id="profileLinkValue" class="set-stat" style="word-break:break-all;text-align:right">${escapeHtml(profileShareUrl() || '')}</span>
</div>
<div class="set-actions" style="display:flex;gap:8px;flex-wrap:wrap">
<button id="profileCreateBtn" class="btn">Create profile</button>
<button id="profileLoadBtn" class="btn">Load profile</button>
<button id="profileCopyLinkBtn" class="btn"${profileShareUrl() ? '' : ' style="display:none"'}>⧉ Copy link</button>
<button id="profileShareBtn" class="btn"${profileShareUrl() ? '' : ' style="display:none"'}>↗ Share</button>
<button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button>
<button id="profileProtectBtn" class="btn"${data.profile && data.profile.name ? '' : ' style="display:none"'}>🔒 Protect…</button>
<button id="profileExportCredBtn" class="btn" style="display:none">⤓ Credentials file</button>
<button id="profileImportCredBtn" class="btn">⤒ Import credentials</button>
<input id="profileCredFileInput" type="file" accept=".json,application/json" hidden />
</div>
</div>
<div class="set-group">
<div class="set-group-title">About</div>
<div class="set-row">
<span>
Version
<small>App version and current build.</small>
</span>
<div class="set-actions" style="margin:0">
<span id="aboutVersion" class="about-version">v${APP_VERSION}</span>
</div>
</div>
<div class="set-row">
<span>
Build time
<small>When the running server build was produced — compare across devices to confirm you're on the latest version.</small>
</span>
<span id="aboutBuildTime" class="about-version">…</span>
</div>
</div>`;
c.appendChild(wrap);
const definitions = [
['appearance', 'Appearance', 'Listening', () => `${data.settings.theme || 'dark'} · ${data.settings.fontScale || 'normal'} text`, '<circle cx="12" cy="12" r="8"/><path d="M12 4v16"/>'],
['playback', 'Playback & sound', 'Listening', () => `${data.settings.quality || 'auto'} quality · ${Level.enabled() ? 'Levelled volume' : 'Original volume'}`, '<path d="m9 5 10 7-10 7zM4 5v14"/>'],
['sharing', 'Sharing', 'Library & storage', () => data.settings.p2pShare !== false ? 'Sharing saved videos' : 'Sharing off', '<circle cx="6" cy="12" r="2"/><circle cx="18" cy="5" r="2"/><circle cx="18" cy="19" r="2"/><path d="m8 11 8-5M8 13l8 5"/>'],
['offline', 'Offline storage', 'Library & storage', () => `${SaveSlots.limit ? SaveSlots.limit() : Number(data.settings.parallelSaves) || 4} at once · ${$('cacheTotal')?.textContent || 'Checking storage'}`, '<path d="M12 3v12m-4-4 4 4 4-4M4 16v5h16v-5"/>'],
['searches', 'Saved searches', 'Library & storage', () => `${SearchLibrary.list().length} saved searches`, '<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>'],
['playlists', 'Playlists', 'Library & storage', () => `${data.playlists.length} playlists`, '<path d="M4 5h14M4 10h14M4 15h8m5-2v8m-3-4h6"/>'],
['backup', 'Backup & restore', 'Account & sync', () => data.settings.autoBackupEnabled ? 'Scheduled backup on' : 'Manual backups', '<path d="M3 12a9 9 0 1 0 3-7M3 3v6h6M12 8v8m-3-3 3 3 3-3"/>'],
['reset', 'Refresh & reset', 'Advanced & about', () => 'Refresh the app or clear device data', '<path d="M3 12a9 9 0 1 0 3-7M3 3v6h6"/>'],
['profile', 'Profile & sync', 'Account & sync', () => data.profile?.name || 'Not linked', '<circle cx="12" cy="8" r="4"/><path d="M4 21v-3a8 8 0 0 1 16 0v3"/>'],
['about', 'About', 'Advanced & about', () => `Version ${APP_VERSION}`, '<circle cx="12" cy="12" r="9"/><path d="M12 11v6m0-10v1"/>'],
];
const groups = [...wrap.querySelectorAll(':scope > .set-group')];
definitions.forEach(([id, title, cluster, summary, icon], i) => SettingsSections.register({ id, title, cluster, summary, icon, render(container) { container.append(groups[i]); } }));
SettingsSections.register({ id: 'video-fullscreen', title: 'Video & fullscreen', cluster: 'Listening', icon: '<path d="M4 9V4h5m6 0h5v5M4 15v5h5m6 0h5v-5"/>', summary: () => `Fullscreen: ${FullscreenOrientation.normalize(data.settings.fullscreenOrientation)}`, render(container) {
const row = document.createElement('label'); row.className = 'set-row';
row.innerHTML = '<span>Fullscreen orientation</span><select id="setFullscreenOrientation"><option value="auto">Auto (follow device)</option><option value="landscape">Landscape</option><option value="portrait">Portrait</option></select>';
const select = row.querySelector('select'); select.value = FullscreenOrientation.normalize(data.settings.fullscreenOrientation);
select.onchange = () => { data.settings.fullscreenOrientation = select.value; persist(); };
container.append(row);
} });
SettingsSections.mount(wrap, { settings: data.settings, data, persist, toast });
// ---- Wire appearance / accessibility ----
$('setTheme').addEventListener('change', (e) => { data.settings.theme = e.target.value; applyAppearance(); persist(); });
$('setLayout').addEventListener('change', async (e) => {
const select=e.target, next=select.value, previous=data.settings.layout; select.disabled=true;
try { await Lazy.load('layout:'+next); data.settings.layout=next; Lazy.layout=next; if(WEB)saveDataToStorage(JSON.stringify(data)); applyAppearance(); persist(); Lazy.reportLayout?.(); }
catch(error) { select.value=previous; toast(error.message); }
finally { select.disabled=false; }
});
$('setFont').addEventListener('change', (e) => { data.settings.fontScale = e.target.value; applyAppearance(); persist(); });
$('setParallelSaves').addEventListener('change', (e) => { data.settings.parallelSaves = Number(e.target.value) || 4; persist(); SaveSlots.pump(); });
$('setDensity').addEventListener('change', (e) => { data.settings.density = e.target.value; applyAppearance(); persist(); });
$('setPerf').addEventListener('change', (e) => { data.settings.perfMode = e.target.checked; applyAppearance(); persist(); });
$('setMotion').addEventListener('change', (e) => { data.settings.reduceMotion = e.target.checked; applyAppearance(); persist(); });
$('setRepeat').addEventListener('change', (e) => { data.settings.repeatMode = e.target.checked ? 'all' : 'off'; updateLoopRepeatButtons(); persist(); });
$('setLoopOne').addEventListener('change', (e) => { data.settings.loopOne = e.target.checked; updateLoopRepeatButtons(); persist(); });
$('setLevelVolume').addEventListener('change', (e) => {
data.settings.levelVolume = e.target.checked;
persist();
if (current && current.meta) Level.onTrack(current.meta); else EQ.setLevel(0);
EqRender.scheduleAll();
});
if ($('setLiveAudioIOS')) $('setLiveAudioIOS').addEventListener('change', (e) => {
data.settings.liveAudioIOS = e.target.checked;
persist();
EQ.apply();
if (current && current.meta) Level.onTrack(current.meta);
if (e.target.checked) toast('Live sound processing on — streamed songs go silent when the screen locks', { duration: 4500 });
});
// ---- Saved searches manager ----
{
const listEl = $('ssList');
const sel = new Set();
const refresh = () => {
const items = SearchLibrary.list();
for (const k of [...sel]) if (!items.some((i) => i.key === k)) sel.delete(k);
$('ssTotal').textContent = items.length ? `${items.length} · ${fmtBytes(SearchLibrary.bytes())}` : 'None';
listEl.innerHTML = items.length ? items.map((i) => `
<label class="ss-row"><input type="checkbox" data-k="${escapeHtml(i.key)}" ${sel.has(i.key) ? 'checked' : ''} />
<span class="ss-q">${escapeHtml(i.query)}</span><small>${i.count} · ${fmtAgo(i.ts)}</small></label>`).join('') : '<div class="muted" style="padding:8px 2px">No saved searches.</div>';
$('ssDeleteSel').disabled = !sel.size;
$('ssDeleteSel').textContent = sel.size ? `Delete selected (${sel.size})` : 'Delete selected';
$('ssSelectAll').textContent = items.length && sel.size === items.length ? 'Select none' : 'Select all';
$('ssClearAll').disabled = !items.length;
};
listEl.addEventListener('change', (e) => { const k = e.target.dataset && e.target.dataset.k; if (!k) return; e.target.checked ? sel.add(k) : sel.delete(k); refresh(); });
$('ssSelectAll').addEventListener('click', () => {
const items = SearchLibrary.list();
if (sel.size === items.length) sel.clear(); else items.forEach((i) => sel.add(i.key));
refresh();
});
$('ssDeleteSel').addEventListener('click', () => {
if (!sel.size || !confirm(`Delete ${sel.size} saved search${sel.size === 1 ? '' : 'es'}? Their results and thumbnails are removed from this device.`)) return;
SearchLibrary.remove([...sel]); sel.clear(); refresh(); toast('Saved searches deleted');
});
$('ssClearAll').addEventListener('click', () => {
if (!confirm('Delete ALL saved searches and their thumbnails from this device?')) return;
SearchLibrary.clear(); sel.clear(); refresh(); toast('All saved searches deleted');
});
refresh();
}
// ---- Wire playback controls ----
$('setQuality').addEventListener('change', (e) => {
data.settings.quality = e.target.value;
els.quality.value = e.target.value;
persist();
});
$('setVolume').addEventListener('input', (e) => {
data.settings.volume = parseFloat(e.target.value);
els.volume.value = e.target.value;
Player.applyVolume();
syncMuteIcon();
persist();
});
$('setAudioOnly').addEventListener('change', (e) => {
data.settings.audioOnly = e.target.checked;
els.audioOnlyToggle.checked = e.target.checked;
persist();
});
$('setSaveBeforePlay').addEventListener('change', (e) => {
data.settings.saveBeforePlay = e.target.checked;
persist();
});
$('setLyricFlags').addEventListener('change', (e) => {
data.settings.lyricFlags = e.target.checked;
persist();
Notes.refreshFlags();
});
$('setAutoPreload').addEventListener('change', (e) => {
data.settings.autoPreload = e.target.checked;
persist();
if (e.target.checked) data.playlists.forEach(preloadPlaylist);
});
$('setP2pShare').addEventListener('change', (e) => {
data.settings.p2pShare = e.target.checked;
persist();
if (window.P2PClient) window.P2PClient.sync(); // share off → withdraws every holding
});
$('setP2pReceive').addEventListener('change', (e) => {
data.settings.p2pReceive = e.target.checked;
persist();
if (window.P2PClient) window.P2PClient.sync();
});
(async () => {
const info = $('p2pDeviceInfo');
const cnt = $('p2pHoldingCount');
if (!info || !cnt) return;
const P = window.P2PClient;
const d = P && P.device();
info.textContent = !d ? 'Not registered yet'
: P.isConnected() ? `Online · peer ${P.peer()}` : 'Registered · not connected';
const files = window.DeviceDB ? await window.DeviceDB.listFiles() : [];
cnt.textContent = `${files.filter((f) => f.state === 'verified').length} verified / ${files.length} saved`;
const unknown = window.P2PClient ? await window.P2PClient.unknownVideos() : [];
const row = $('p2pContributeRow');
const btn = $('p2pContributeBtn');
if (row && btn && unknown.length) {
row.classList.remove('hidden');
btn.textContent = `Verify & share ${unknown.length} saved video${unknown.length === 1 ? '' : 's'}`;
btn.onclick = async () => {
btn.disabled = true;
let ok = 0;
for (let i = 0; i < unknown.length; i++) {
const v = videoById(unknown[i]) || {};
btn.textContent = `Uploading ${i + 1} of ${unknown.length}…`;
const r = await window.P2PClient.contribute(unknown[i], { title: v.title || '', channel: v.channel || '' });
if (r && r.ok) ok++;
}
toast(`Verified ${ok} of ${unknown.length} saved video${unknown.length === 1 ? '' : 's'}`);
btn.disabled = false;
row.classList.add('hidden');
};
}
})();
// ---- Cache management ----
$('clearCacheBtn').addEventListener('click', () => {
showModal('Clear all cached videos?', document.createTextNode('This frees disk space. Playlists keep their entries and will re-download on demand.'), [
{ label: 'Cancel', onClick: closeModal },
{
label: 'Clear all', danger: true, onClick: async () => {
try { await API.cacheClear(); } catch {}
cachedIds.clear();
closeModal();
toast('Cache cleared');
if (current) updateNowPlayingActions();
renderList();
},
},
]);
});
// The saved-videos list folds; remember how the user left it.
{
const fold = $('cacheFold');
try { fold.open = localStorage.getItem('ytpCacheFoldOpen') === '1'; } catch { /* blocked */ }
fold.addEventListener('toggle', () => { try { localStorage.setItem('ytpCacheFoldOpen', fold.open ? '1' : '0'); } catch { /* blocked */ } });
}
// ---- Populate live cache stats ----
try {
const res = await API.cacheList();
if (view.type !== 'settings') return; // user navigated away
const total = (res && res.total) || 0;
const items = (res && res.items) || [];
$('cacheTotal').textContent = `${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'}`;
$('cacheFoldCount').textContent = items.length ? `${items.length} · ${fmtBytes(total)}` : 'none';
const listEl = $('cacheList');
if (!items.length) {
listEl.innerHTML = '<div class="cache-empty">No videos saved offline yet.</div>';
} else {
items.sort((a, b) => b.size - a.size);
items.forEach((it) => {
const row = document.createElement('div');
row.className = 'cache-item';
row.innerHTML = `<span class="ci-title"></span><span class="ci-size">${fmtBytes(it.size)}</span><button class="ci-del" title="Delete from cache">✕</button>`;
row.querySelector('.ci-title').textContent = videoTitleById(it.id);
row.querySelector('.ci-del').addEventListener('click', async () => {
try { await API.cacheDelete(it.id); } catch {}
cachedIds.delete(it.id);
if (current && current.meta && current.meta.id === it.id) updateNowPlayingActions();
toast('Removed from cache');
renderList();
});
listEl.appendChild(row);
});
}
} catch {
const t = $('cacheTotal');
if (t) t.textContent = 'Offline cache not available in this build';
}
// ---- Cache cap ----
const capSel = $('cacheCapSelect');
capSel.value = String(data.settings.cacheCap || 0);
capSel.addEventListener('change', (e) => {
data.settings.cacheCap = parseInt(e.target.value) || 0;
persist();
});
// ---- Export / Import playlists ----
$('importYtBtn')?.addEventListener('click', openImportYouTubeModal);
$('exportBtn').addEventListener('click', exportPlaylists);
$('importBtn').addEventListener('click', () => $('fileInput').click());
$('fileInput').addEventListener('change', importPlaylists);
// ---- Backup ----
$('setAutoBackup').addEventListener('change', (e) => { data.settings.autoBackupEnabled = e.target.checked; persist(); });
$('setBackupInterval').addEventListener('change', (e) => { data.settings.autoBackupIntervalDays = parseInt(e.target.value) || 7; persist(); });
$('backupNowBtn').addEventListener('click', () => doAutoBackup());
$('importBackupBtn').addEventListener('click', () => $('backupFileInput').click());
$('backupFileInput').addEventListener('change', importBackup);
// ---- Online profile ----
$('profileCreateBtn').addEventListener('click', createProfileFlow);
$('profileLoadBtn').addEventListener('click', loadProfileFlow);
$('profileCopyLinkBtn').addEventListener('click', copyProfileLink);
$('profileShareBtn').addEventListener('click', shareProfileLink);
$('profileProtectBtn').addEventListener('click', protectProfileFlow);
$('profileExportCredBtn').addEventListener('click', exportProfileCredentials);
$('profileImportCredBtn').addEventListener('click', importProfileCredentials);
$('profileUnlinkBtn').addEventListener('click', () => {
data.profile = null;
ProfileSecret.clear();
persist();
updateProfileStatus();
toast('Profile unlinked — this device stops syncing (server copy is kept)');
});
updateProfileStatus();
// ---- About: append server build tag + build time (WEB mode only) ----
if (WEB) {
try {
const res = await fetch('/api/version', { cache: 'no-store' });
if (res.ok && view.type === 'settings') {
const v = await res.json();
const el = $('aboutVersion');
if (el && v.buildTag) el.textContent = `v${v.version || APP_VERSION} · build ${v.buildTag}`;
const bt = $('aboutBuildTime');
if (bt) {
const t = v.buildTime ? new Date(v.buildTime) : null;
if (t && !isNaN(t)) {
// Shown in GMT+8 regardless of device/server timezone, plus build age.
const gmt8 = new Date(t.getTime() + 8 * 3600e3).toISOString().replace('T', ' ').slice(0, 16);
const s = Math.max(0, Math.floor((Date.now() - t.getTime()) / 1000));
const ago = s < 60 ? `${s} second${s === 1 ? '' : 's'} ago`
: s < 3600 ? `${Math.floor(s / 60)} minute${Math.floor(s / 60) === 1 ? '' : 's'} ago`
: s < 86400 ? `${Math.floor(s / 3600)} hour${Math.floor(s / 3600) === 1 ? '' : 's'} ago`
: `${Math.floor(s / 86400)} day${Math.floor(s / 86400) === 1 ? '' : 's'} ago`;
bt.textContent = `${gmt8} +08 · built ${ago}`;
} else {
bt.textContent = 'unknown';
}
}
}
} catch { /* offline — leave the static version in place */ }
}
// ---- Reset ----
$('refreshUiBtn').addEventListener('click', async () => {
const btn = $('refreshUiBtn');
btn.disabled = true;
btn.textContent = 'Refreshing…';
// hardReloadUI() deletes all caches and unregisters the SW so the
// next page load always fetches fresh assets from the network.
await hardReloadUI();
});
$('resetAppBtn').addEventListener('click', () => {
showModal(
'Reset app?',
(() => { const p = document.createElement('p'); p.textContent = 'This clears all local data and caches. The page will reload.'; return p; })(),
[
{ label: 'Cancel', onClick: closeModal },
{
label: 'Reset', danger: true, onClick: async () => {
closeModal();
try {
localStorage.clear();
sessionStorage.clear();
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
}
if ('caches' in window) {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
}
} catch { /* best effort — reload regardless */ }
window.location.href = '/';
},
},
],
);
});
}
function renderCard(v, index, list) {
const card = document.createElement('div');
const isCached = cachedIds.has(v.id);
const isDownloading = downloading.has(v.id);
card.className = 'card' + (isCached ? ' cached' : '') + (isDownloading ? ' downloading' : '') + (selectedIds.has(v.id) ? ' selected' : '');
card.dataset.id = v.id;
card._video = v; // read by f7-layout.js swipe actions
card.innerHTML = `
<div class="batch-check"></div>
<div class="thumb">
<img loading="lazy" decoding="async" src="${escapeHtml(v.thumbnail || '')}" alt="" />
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${isDownloading ? '' : '⬇'}</span>
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
</div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel"></div>
</div>
<button class="card-menu" title="Add to playlist">+</button>`;
card.querySelector('.card-title').textContent = v.title;
const chEl = card.querySelector('.card-channel');
chEl.textContent = v.channel || '';
if (channelKeyOf(v)) {
chEl.classList.add('link');
chEl.title = 'View channel';
chEl.addEventListener('click', (e) => {
e.stopPropagation();
openChannel(channelKeyOf(v), v.channel);
});
}
// Playlist view: note under title (if present) and note edit button (Section 5)
if (view.type === 'playlist') {
const hasNote = !!(v.notes && v.notes.trim());
if (hasNote) {
const noteEl = document.createElement('div');
noteEl.className = 'card-note';
noteEl.textContent = v.notes.trim();
noteEl.title = v.notes.trim();
const titleEl = card.querySelector('.card-title');
if (titleEl) titleEl.after(noteEl);
}
const noteBtn = document.createElement('button');
noteBtn.type = 'button';
noteBtn.className = 'card-note-btn' + (hasNote ? ' has-note' : '');
noteBtn.title = hasNote ? 'Edit note' : 'Add note';
noteBtn.textContent = '✎';
noteBtn.addEventListener('click', (e) => {
e.stopPropagation();
openSongNoteModal(v);
});
const menuBtn = card.querySelector('.card-menu');
card.insertBefore(noteBtn, menuBtn);
}
card.addEventListener('pointerdown', () => warmStreams([v.id]), { passive: true });
card.addEventListener('click', (e) => {
if (e.target.closest('.card-menu') || e.target.closest('.card-del') || e.target.closest('.card-note-btn') || e.target.closest('.card-channel.link')) return;
if (selectMode) { toggleSelectCard(v.id); return; }
playFromList(list, index, view.type === 'playlist' ? 'playlist:' + view.id : view.type === 'nowPlaying' ? queueSource : view.type);
});
card.querySelector('.card-menu').addEventListener('click', (e) => {
e.stopPropagation();
openCardMenu(v);
});
// Per-item delete in playlist, queue, history, and smart views.
if (view.type === 'playlist' || view.type === 'queue' || view.type === 'history' || view.type === 'smart') {
const del = document.createElement('button');
del.className = 'card-del';
del.title = view.type === 'queue' ? 'Remove from queue'
: (view.type === 'history' || view.type === 'smart') ? 'Remove from history'
: 'Remove from playlist';
del.textContent = '✕';
del.addEventListener('click', (e) => {
e.stopPropagation();
if (view.type === 'queue') { removeFromQueue(v.id); return; }
if (view.type === 'history' || view.type === 'smart') {
data.history = data.history.filter((x) => x.id !== v.id);
persist();
renderList();
return;
}
const pl = data.playlists.find((p) => p.id === view.id);
if (pl) { pl.videos = pl.videos.filter((x) => x.id !== v.id); persist(); render(); toast('Removed from playlist'); }
});
card.appendChild(del);
}
// Drag-to-reorder in playlist and queue views
if (view.type === 'playlist' || view.type === 'queue') {
card.draggable = true;
card.addEventListener('dragstart', () => {
card.classList.add('dragging');
dragSource = index;
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
});
card.addEventListener('dragover', (e) => {
e.preventDefault();
card.classList.add('drag-over');
});
card.addEventListener('dragleave', () => {
card.classList.remove('drag-over');
});
card.addEventListener('drop', (e) => {
e.preventDefault();
card.classList.remove('drag-over');
const from = dragSource;
const to = index;
if (from === to || from < 0) return;
const arr = view.type === 'queue'
? data.queue
: (data.playlists.find((p) => p.id === view.id) || {}).videos;
if (!arr) return;
const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved);
if (view.type === 'queue' && queueSource === 'queue') {
const playingId = current && current.meta && current.meta.id;
queue = data.queue.slice();
queueIndex = playingId ? queue.findIndex((x) => x.id === playingId) : queueIndex;
renderUpNext();
}
persist();
renderList();
});
}
return card;
}
function markPlayingCard() {
document.querySelectorAll('.card').forEach((c) => {
c.classList.toggle('playing', current && c.dataset.id === current.meta.id);
});
}
function renderRelated() {
const panel = $('relatedPanel'), list = $('relatedList');
if (!panel || !list || (relatedState.status === 'idle' && !relatedVideos.length)) { if (panel) panel.classList.add('hidden'); return; }
panel.classList.remove('hidden');
list.classList.toggle('hidden', relatedCollapsed);
list.innerHTML = '';
if (relatedState.message) {
const notice = document.createElement('div'); notice.className = 'related-notice'; notice.setAttribute('role', 'status');
const message = document.createElement('p'); message.textContent = relatedState.message; notice.append(message);
if (relatedState.status === 'error') {
const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'related-retry'; retry.setAttribute('aria-label', 'Retry related videos');
retry.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 11a9 9 0 1 1 2.6 7M3 3v8h8"/></svg><span>Retry</span>';
retry.onclick = () => loadRelated({ refresh: true }); notice.append(retry);
}
list.append(notice);
}
relatedVideos.forEach((v) => {
const item = document.createElement('div');
item.className = 'related-item';
item.innerHTML = `<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" decoding="async" /><div class="ri-info"><div class="ri-title"></div><div class="ri-channel"></div></div>`;
item.querySelector('.ri-title').textContent = v.title;
const riChEl = item.querySelector('.ri-channel');
riChEl.textContent = v.channel || '';
if (channelKeyOf(v)) {
riChEl.classList.add('link');
riChEl.title = 'View channel';
riChEl.addEventListener('click', (e) => { e.stopPropagation(); openChannel(channelKeyOf(v), v.channel); });
}
item.addEventListener('click', (e) => {
if (e.target.closest('.ri-channel.link')) return;
queue = [v]; queueIndex = 0; playFullMode = false; Player.loadVideo(v); renderUpNext();
});
list.appendChild(item);
});
}