/* 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);
}
}