1502 lines
71 KiB
JavaScript
1502 lines
71 KiB
JavaScript
/* 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 & 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 & 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 & 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 & 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);
|
||
});
|
||
}
|