305 lines
14 KiB
JavaScript
305 lines
14 KiB
JavaScript
/* 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 = `<span class="pl-offline-badge all" title="Available offline">⬇</span>`;
|
|
} else if (stats.cached > 0) {
|
|
offlineBadge = `<span class="pl-offline-badge partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
|
|
}
|
|
}
|
|
const item = document.createElement('div');
|
|
item.className = 'playlist-item' + (view.type === 'playlist' && view.id === pl.id ? ' active' : '');
|
|
item.innerHTML = `<span class="pl-name"></span>${offlineBadge}<span class="pl-count">${pl.videos.length}</span>`;
|
|
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 = `<span class="home-pl-offline all" title="Available offline">⬇</span>`;
|
|
} else if (stats.cached > 0) {
|
|
offlineHtml = `<span class="home-pl-offline partial" title="${stats.cached} of ${stats.total} videos available offline">${stats.cached}/${stats.total}</span>`;
|
|
}
|
|
}
|
|
const card = document.createElement('button');
|
|
card.className = 'home-pl-card';
|
|
card.type = 'button';
|
|
card.innerHTML = `
|
|
<span class="home-pl-thumb">
|
|
${thumb ? `<img src="${escapeHtml(thumb)}" alt="" loading="lazy" decoding="async" />`
|
|
: '<span class="home-pl-thumb-fallback">🎵</span>'}
|
|
${offlineHtml}
|
|
<span class="home-pl-play" title="Play all">▶</span>
|
|
</span>
|
|
<span class="home-pl-name"></span>
|
|
<span class="home-pl-count">${count} video${count === 1 ? '' : 's'}</span>`;
|
|
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 = `<span class="pl-name">${sp.label}</span>`;
|
|
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 = '<span class="pl-name">📡 On other devices</span>';
|
|
item.addEventListener('click', () => { view = { type: 'p2p' }; render(); });
|
|
list.appendChild(item);
|
|
}
|
|
}
|