/* Eager UI definitions. Read app lexical state only when called after initialization. */ const Recommendations = (() => { let cards = [], fetchedAt = 0, loading = false, error = false, actor = '', queuedRefresh = false; let listHost = null; const deviceFingerprint = () => window.getFingerprint ? window.getFingerprint() : 'unknown'; const fingerprint = () => data.profile?.name ? 'profile:' + data.profile.name.toLowerCase() : 'device:' + deviceFingerprint(); function load() { const fp = fingerprint(); if (fp === actor) return; actor = fp; cards = []; fetchedAt = 0; try { const saved = JSON.parse(localStorage.getItem('ytpRecommendations:' + fp) || 'null'); if (saved && Array.isArray(saved.cards)) cards = saved.cards; } catch { /* no saved recommendations */ } } function paint(host) { if (!host) return; host.replaceChildren(); const heading = document.createElement('div'); heading.className = 'rec-heading'; const copy = document.createElement('div'); const title = document.createElement('h2'); title.textContent = 'Recommended for you'; const sub = document.createElement('p'); sub.textContent = error && cards.length ? 'Saved picks · reconnect to refresh' : 'Familiar favorites and something new, inspired by your listening.'; copy.append(title, sub); const refresh = document.createElement('button'); refresh.type = 'button'; refresh.className = 'rec-refresh'; refresh.textContent = loading ? 'Updating…' : 'Refresh'; refresh.disabled = loading; refresh.onclick = () => update(true); heading.append(copy, refresh); host.appendChild(heading); if (!cards.length) { const empty = document.createElement('p'); empty.className = 'rec-empty'; empty.setAttribute('role', 'status'); empty.textContent = loading ? 'Finding your next listen…' : error ? 'Recommendations are unavailable. You can still search or play your playlists.' : 'Search or play a few videos to start discovering recommendations here.'; host.appendChild(empty); return; } const grid = document.createElement('div'); grid.className = 'rec-grid'; cards.forEach((v, i) => { const item = document.createElement('article'); item.className = 'rec-item'; const play = document.createElement('button'); play.type = 'button'; play.className = 'rec-play'; play.setAttribute('aria-label', 'Play ' + v.title); const cover = document.createElement('span'); cover.className = 'rec-cover'; const img = document.createElement('img'); img.src = v.thumbnail || ''; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async'; cover.appendChild(img); const title = document.createElement('span'); title.className = 'rec-title'; title.textContent = v.title; const channel = document.createElement('span'); channel.className = 'rec-channel'; channel.textContent = v.channel || ''; play.append(cover, title, channel); play.onclick = () => { setHomeOverPlayer(false); playFromList(cards.slice(), i, 'recommendations'); }; const reason = document.createElement('div'); reason.className = 'rec-reason'; reason.textContent = v.reason || 'Recommended for you'; const actions = document.createElement('div'); actions.className = 'rec-actions'; const queue = document.createElement('button'); queue.type = 'button'; queue.textContent = '+ Queue'; queue.setAttribute('aria-label', 'Queue ' + v.title); queue.onclick = () => addToQueue(v); const more = document.createElement('button'); more.type = 'button'; more.textContent = '•••'; more.setAttribute('aria-label', 'More actions for ' + v.title); more.onclick = () => openCardMenu(v); actions.append(queue, more); item.append(play, reason, actions); grid.appendChild(item); }); host.appendChild(grid); } function repaint() { if (!WEB) return; const home = $('homeRecommendations'); if (home) { home.classList.remove('hidden'); paint(home); } els.playerPane?.classList.add('has-recommendations'); if (listHost?.isConnected) paint(listHost); requestAnimationFrame(updateBottomChrome); } async function update(force = false) { load(); if (!WEB) return; if (loading) { if (force) queuedRefresh = true; return; } if (!force && Date.now() - fetchedAt < 300000) return; const fp = actor; loading = true; error = false; repaint(); try { const name = data.profile?.name || ''; const result = await webFetch('/api/recommendations?fp=' + encodeURIComponent(deviceFingerprint()) + (name ? '&profile=' + encodeURIComponent(name) : ''), { headers: name ? profileHeaders(name) : {} }); if (fp !== fingerprint()) return; if (!result.ok || !Array.isArray(result.results)) throw new Error('invalid recommendations'); cards = result.results; try { localStorage.setItem('ytpRecommendations:' + fp, JSON.stringify({ cards })); } catch { /* full */ } } catch { error = true; } finally { loading = false; fetchedAt = fp === fingerprint() ? Date.now() : 0; repaint(); if (queuedRefresh || fp !== fingerprint()) { queuedRefresh = false; update(true); } } } function home() { if (WEB) { load(); repaint(); update(); } } function inList() { if (!WEB) return; listHost = document.createElement('section'); listHost.className = 'recommendations'; listHost.setAttribute('aria-label', 'Recommended videos'); els.cards.appendChild(listHost); load(); paint(listHost); update(); } return { home, inList, update }; })(); function renderSidebar() { els.playlistList.innerHTML = ''; for (const pl of data.playlists) { const stats = playlistOfflineStats(pl); let offlineBadge = ''; if (stats.total > 0) { if (stats.cached === stats.total) { offlineBadge = `⬇`; } else if (stats.cached > 0) { offlineBadge = `${stats.cached}/${stats.total}`; } } const item = document.createElement('div'); item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : ''); item.innerHTML = `${offlineBadge}${pl.videos.length}`; item.querySelector('.pl-name').textContent = pl.name; item.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); }); els.playlistList.appendChild(item); } document.querySelectorAll('.nav-item').forEach((b) => { b.classList.toggle('active', b.dataset.view === view.type); }); // Keep bottom-nav in sync with sidebar nav active state document.querySelectorAll('.bottom-nav-btn').forEach((b) => { b.classList.toggle('active', b.dataset.view === view.type); }); updateQueueBadge(); updateDownloadBadge(); renderHomePlaylists(); } function renderHomePlaylists() { if (window.BentoHub) BentoHub.playlists = () => data.playlists || []; const wrap = document.getElementById('homePlaylists'); const hero = document.querySelector('#playerPlaceholder .hero'); const grid = document.getElementById('homePlGrid'); if (!wrap || !hero || !grid) return; Recommendations.home(); const lists = data.playlists || []; wrap.classList.toggle('hidden', !lists.length); hero.classList.toggle('hidden', !!lists.length); // The hero is centered art; the grid wants the full box, so the placeholder // switches from centering to stretching (and tightens its padding), and the // stage drops its 16:9 lock so the grid gets the pane's real height. if (els.placeholder) els.placeholder.classList.toggle('showing-playlists', !!lists.length); if (els.playerPane) els.playerPane.classList.toggle('home-playlists', !!lists.length); if (!lists.length) { grid.innerHTML = ''; return; } grid.innerHTML = ''; for (const pl of lists) { const count = pl.videos.length; const thumb = (pl.videos.find((v) => v && v.thumbnail) || {}).thumbnail || ''; const stats = playlistOfflineStats(pl); let offlineHtml = ''; if (stats.total > 0) { if (stats.cached === stats.total) { offlineHtml = `⬇`; } else if (stats.cached > 0) { offlineHtml = `${stats.cached}/${stats.total}`; } } const card = document.createElement('button'); card.className = 'home-pl-card'; card.type = 'button'; card.innerHTML = ` ${thumb ? `` : '🎵'} ${offlineHtml} ▶ ${count} video${count === 1 ? '' : 's'}`; card.querySelector('.home-pl-name').textContent = pl.name; card.title = pl.name; card.addEventListener('click', () => { view = { type: 'playlist', id: pl.id }; render(); }); card.querySelector('.home-pl-play').addEventListener('click', (e) => { e.stopPropagation(); // the ▶ badge plays; the rest of the card opens the list if (count) playFromList(pl.videos, 0, 'playlist:' + pl.id); else toast('That playlist is empty'); }); grid.appendChild(card); } // Measure after layout — scrollHeight is meaningless until the cards lay out. requestAnimationFrame(updateHomeGridScrollState); } function updateHomeGridScrollState() { const grid = document.getElementById('homePlGrid'); if (!grid) return; const overflows = grid.scrollHeight > grid.clientHeight + 1; grid.classList.toggle('is-scrollable', overflows); grid.classList.toggle('at-end', !overflows || grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 2); } function updateBottomChrome() { let occupied = 0; for (const id of ['bottomNav', 'miniBar']) { const el = document.getElementById(id); if (!el || el.classList.contains('hidden')) continue; const box = el.getBoundingClientRect(); if (!box.height || getComputedStyle(el).position !== 'fixed') continue; if (box.top > window.innerHeight * 0.5) occupied = Math.max(occupied, window.innerHeight - box.top); } document.documentElement.style.setProperty('--bottom-chrome', Math.round(occupied) + 'px'); } function setHomeOverPlayer(on) { const pane = els.playerPane; if (!pane) return; on = !!on && !!(current && current.meta); // Service mode hides the bottom bar and mini bar (and, in its Video view, // lives inside this pane): leave it first or the home screen is a dead end. if (on && isServiceModeOpen()) exitServiceMode(); if (pane.classList.contains('home-over') === on) return; pane.classList.toggle('home-over', on); pane.classList.toggle('empty', on); if (els.placeholder) els.placeholder.classList.toggle('hidden', !on); if (on) { renderHomePlaylists(); showMiniBar(); const body = document.querySelector('.body'); if (body) body.scrollTo({ top: 0 }); } } function showMiniBar() { if (!current || !current.meta) return; $('miniTitle').textContent = current.meta.title; const miniCh = $('miniChannel'); miniCh.textContent = current.meta.channel || ''; miniCh.classList.toggle('link', !!channelKeyOf(current.meta)); $('miniBar').classList.remove('hidden'); updateMiniBar(); requestAnimationFrame(updateBottomChrome); } function updateMiniBar() { if (!current || !current.meta) return; const cur = Player.master.currentTime || 0; const dur = Player.master.duration || 0; $('miniTime').textContent = fmtTime(cur); if (dur > 0) { $('miniProgressFill').style.width = Math.min(100, (cur / dur) * 100) + '%'; } } function hideMiniBar() { $('miniBar').classList.add('hidden'); requestAnimationFrame(updateBottomChrome); } function setTile(btn, label) { const tl = btn.querySelector('.tl'); if (tl) tl.textContent = label; else btn.textContent = label; } function updateNowPlayingActions() { if (!current || !current.meta) return; const id = current.meta.id; renderAvailability(id); // Custom (edited) videos are already a saved-offline cut of a source video; // re-saving or re-editing them makes no sense, so hide those actions. const isCustom = !!current.meta.custom; if (els.editBtn) els.editBtn.hidden = isCustom; if (els.saveBtn) els.saveBtn.hidden = isCustom; // The server cache exists only behind the web PWA backend. if (els.brokenBtn) { els.brokenBtn.hidden = isCustom || !WEB; els.brokenBtn.disabled = reportingBroken.has(id); els.brokenBtn.textContent = reportingBroken.has(id) ? '⏳ Reporting…' : '⚠ Broken'; } { const ch = $('npChannel'); if (ch) { const ok = isUploadId(id) || cachedIds.has(id); ch.classList.toggle('verified', ok); ch.title = ok ? 'Available on this device or the server' : ''; } } const btn = els.saveBtn; if (!btn) return; if (removing.has(id)) { setTile(btn, 'Removing…'); btn.classList.remove('done'); btn.disabled = true; } else if (downloading.has(id)) { setTile(btn, 'Saving…'); btn.classList.remove('done'); btn.disabled = true; } else if (cachedIds.has(id)) { setTile(btn, 'Saved'); btn.classList.add('done'); btn.disabled = false; btn.title = 'Saved for offline — click to remove from cache'; } else { setTile(btn, 'Save'); btn.classList.remove('done'); btn.disabled = false; btn.title = 'Save this video for offline playback'; } } function renderSmartSidebar() { const list = $('smartPlaylistList'); if (!list) return; list.innerHTML = ''; SMART_PLAYLISTS.forEach((sp) => { const item = document.createElement('div'); const isActive = view.type === 'smart' && view.smartType === sp.id; item.className = 'playlist-item smart-item' + (isActive ? ' active' : ''); item.innerHTML = `${sp.label}`; item.addEventListener('click', () => { view = { type: 'smart', smartType: sp.id }; render(); }); list.appendChild(item); }); if (WEB && data.settings.p2pReceive !== false) { const item = document.createElement('div'); item.className = 'playlist-item smart-item' + (view.type === 'p2p' ? ' active' : ''); item.innerHTML = '📡 On other devices'; item.addEventListener('click', () => { view = { type: 'p2p' }; render(); }); list.appendChild(item); } }