/* 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 = '
Loading the server library…
'; let list = []; try { const j = await (await fetch('/api/uploads?limit=200')).json(); list = (j && j.ok && j.uploads) || []; } catch { els.cards.innerHTML = '
⚠ Could not reach the server
'; return; } if (view.type !== 'library') return; if (!list.length) { els.cards.innerHTML = `
📁

Nothing uploaded yet

An admin can upload videos and audio at /admin; they show up here and in search, with their cover art and any embedded lyrics.

`; 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 ? '' : ''; const c = els.cards; if (!groups.length) { c.innerHTML = `
📝

No notes yet

While a video plays, open Chapters & bookmarks → 📝 Note (or press N) to write a note tied to that exact moment. They sync with your online profile.

`; return; } const total = groups.reduce((n, g) => n + g.list.length, 0); c.innerHTML = `
${groups.map((g) => `
${escapeHtml(g.title)}${escapeHtml(g.channel)} · ${g.list.length}
${g.list.map((b) => ` `).join('')}
`).join('')}
`; 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 = `
`; els.cards.appendChild(s); } } function setListPaneHidden(hidden) { const pane = document.getElementById('listPane'); if (pane) pane.classList.toggle('hidden', hidden); } const LIST_ICONS = { rename: '', share: '', offline: '', offlineDone: '', pin: '', select: '', del: '', }; function listIconBtn(icon, label, onClick, extraClass) { const b = document.createElement('button'); b.type = 'button'; b.className = 'list-icon-btn' + (extraClass ? ' ' + extraClass : ''); b.title = label; b.setAttribute('aria-label', label); b.innerHTML = ``; if (onClick) b.onclick = onClick; return b; } function setListIcon(btn, icon) { btn.innerHTML = ``; } 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 = `
🕘

Nothing watched yet

Your viewing history shows up here once you start playing videos.

`; addBrowseCta(empty, '🔍 Search videos'); } else if (view.type === 'queue') { empty.innerHTML = `
▶

Your queue is empty

Use + Queue on any video to line it up. The queue is temporary and plays in order.

`; addBrowseCta(empty, '🔍 Find something to play'); } else if (view.type === 'channel') { empty.innerHTML = `
📺

No videos found

This channel didn't return any uploads.

`; addBrowseCta(empty, '🔍 Back to search'); } else { // Playlist view — empty empty.innerHTML = `
🎵

This playlist is empty

Add videos from search results or use the + Playlist button while playing.

`; 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 = `
On other devices

${items.length} video${items.length === 1 ? '' : 's'} held by other devices · ${online ? online + ' online now' : 'none online'}

`; 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 = '📡'; 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 = '
📚

No saved searches yet

Every search you run is kept here with its thumbnails, even after you clear the search box. You can remove them in Settings.

'; return; } const sum = document.createElement('div'); sum.className = 'saved-summary'; sum.innerHTML = `${items.length}saved search${items.length === 1 ? '' : 'es'} · ${fmtBytes(SearchLibrary.bytes())} · manage in Settings`; c.appendChild(sum); items.forEach((it) => { const row = document.createElement('div'); row.className = 'card saved-card ss-card'; row.innerHTML = `
${it.count} result${it.count === 1 ? '' : 's'} · ${fmtAgo(it.ts)}
`; 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 = '
Looking for videos on other devices…
'; 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 = '
📡

Not available

Peer-to-peer sharing is off on this server, or you are offline.

'; 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 = '
📡

Nothing shared yet

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.

'; return; } const online = items.filter((i) => i.online).length; const sum = document.createElement('div'); sum.className = 'saved-summary'; sum.innerHTML = `${items.length}video${items.length === 1 ? '' : 's'} on other devices · ${online} with a device online now`; 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 ? `● ${it.online} online` : '○ none online'; row.innerHTML = `
${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · not checked lately' : ''}
`; 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 = '
Loading saved videos…
'; 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 = `
💾

Offline cache unavailable

This build doesn't support saving videos for offline playback.

`; 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 = `
💾

Nothing saved yet

Use ⬇ Save while playing, or add videos to a playlist to keep them offline.

`; 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 = `${fmtBytes(total)} · ${items.length} video${items.length === 1 ? '' : 's'} stored offline`; 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 = `
${isCustom ? '✂' : ''}${isUploadId(v.id) ? `${v.kind === 'audio' ? '♪' : '📁'}` : ''}
${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}
`; 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) => ``) .join(''); const sel = (id, val, opts) => ``; wrap.innerHTML = `
Appearance & accessibility
Playback
${EQ.isIOS() ? `` : ''}
Sharing (peer-to-peer)
This device… …
Offline cache
Storage used …
Saved videos
Saved searches
Kept on this device Search results and their thumbnails stay saved after you clear the search box. Only you can remove them, here. …
Playlists
Backup & restore
Reset
Force refresh UI Re-fetches the app shell and assets, bypassing the browser cache, then reloads.
Reset app Clears all local storage, unregisters the service worker, and deletes every cache. You will need to reload after.
Online profile — sync between devices
Linked profile 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. ${data.profile && data.profile.name ? data.profile.name : 'Not linked'}
Profile link 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. ${escapeHtml(profileShareUrl() || '')}
About
Version App version and current build.
v${APP_VERSION}
Build time When the running server build was produced — compare across devices to confirm you're on the latest version. …
`; c.appendChild(wrap); const definitions = [ ['appearance', 'Appearance', 'Listening', () => `${data.settings.theme || 'dark'} · ${data.settings.fontScale || 'normal'} text`, ''], ['playback', 'Playback & sound', 'Listening', () => `${data.settings.quality || 'auto'} quality · ${Level.enabled() ? 'Levelled volume' : 'Original volume'}`, ''], ['sharing', 'Sharing', 'Library & storage', () => data.settings.p2pShare !== false ? 'Sharing saved videos' : 'Sharing off', ''], ['offline', 'Offline storage', 'Library & storage', () => `${SaveSlots.limit ? SaveSlots.limit() : Number(data.settings.parallelSaves) || 4} at once · ${$('cacheTotal')?.textContent || 'Checking storage'}`, ''], ['searches', 'Saved searches', 'Library & storage', () => `${SearchLibrary.list().length} saved searches`, ''], ['playlists', 'Playlists', 'Library & storage', () => `${data.playlists.length} playlists`, ''], ['backup', 'Backup & restore', 'Account & sync', () => data.settings.autoBackupEnabled ? 'Scheduled backup on' : 'Manual backups', ''], ['reset', 'Refresh & reset', 'Advanced & about', () => 'Refresh the app or clear device data', ''], ['profile', 'Profile & sync', 'Account & sync', () => data.profile?.name || 'Not linked', ''], ['about', 'About', 'Advanced & about', () => `Version ${APP_VERSION}`, ''], ]; 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: '', summary: () => `Fullscreen: ${FullscreenOrientation.normalize(data.settings.fullscreenOrientation)}`, render(container) { const row = document.createElement('label'); row.className = 'set-row'; row.innerHTML = 'Fullscreen orientation'; 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) => ` `).join('') : '
No saved searches.
'; $('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 = '
No videos saved offline yet.
'; } else { items.sort((a, b) => b.size - a.size); items.forEach((it) => { const row = document.createElement('div'); row.className = 'cache-item'; row.innerHTML = `${fmtBytes(it.size)}`; 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 = `
${v.duration ? `${fmtTime(v.duration)}` : ''} ${isDownloading ? '' : '⬇'} ${isDownloading ? '
' : ''}
`; 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 = 'Retry'; 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 = `
`; 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); }); }