/* Eager UI definitions. Read app lexical state only when called after initialization. */
const SectionRail = (() => {
const CLASSIC_ICONS = {
player: '',
meta: '',
notes: '',
related: '',
list: '',
};
const SECTIONS = [
{ id: 'player', sel: '.player-stage', icon: '📺', label: 'Player' },
{ id: 'meta', sel: '#nowPlayingMeta', icon: '🎵', label: 'Now playing', fold: true },
{ id: 'notes', sel: '#notesPanel', icon: '📝', label: 'Lyrics', fold: true },
{ id: 'related', sel: '#relatedPanel', icon: '🔗', label: 'Related', fold: true },
{ id: 'list', sel: '#listPane', icon: '📋', label: 'List' },
];
const st = { active: '', btns: new Map(), labelTimer: null, raf: 0, bottom: -1 };
const rail = () => document.getElementById('sectionRail');
const folds = () => (data.settings.foldedCards ||= {});
const isFolded = (id) => !!folds()[id];
const elOf = (s) => document.querySelector(s.sel);
// Only cards that are actually on screen belong in the rail.
const visible = () => SECTIONS.filter((s) => {
const el = elOf(s);
return el && !el.classList.contains('hidden') && el.offsetParent !== null;
});
function applyFold(sec) {
const el = elOf(sec);
if (!el || !sec.fold) return;
el.classList.toggle('folded', isFolded(sec.id));
if (sec.id === 'notes') {
const b = document.getElementById('notesFoldBtn');
if (b) b.setAttribute('aria-expanded', String(!isFolded(sec.id)));
}
// The Related card already had its own +/− button; keep the two in step.
if (sec.id === 'related') {
relatedCollapsed = isFolded(sec.id);
const rl = document.getElementById('relatedList');
const rb = document.getElementById('relatedToggleBtn');
if (rl) rl.classList.toggle('hidden', relatedCollapsed);
if (rb) rb.textContent = relatedCollapsed ? '+' : '−';
}
}
function setFolded(id, on) {
const sec = SECTIONS.find((x) => x.id === id);
if (!sec || !sec.fold) return;
folds()[id] = !!on;
persist();
applyFold(sec);
paint(); // NOT render() — folding a card must not rebuild the list
}
function flashLabel(btn) {
clearTimeout(st.labelTimer);
[...st.btns.values()].forEach((b) => b.classList.remove('show-label'));
btn.classList.add('show-label');
st.labelTimer = setTimeout(() => btn.classList.remove('show-label'), 1400);
}
function onTap(sec, btn) {
const el = elOf(sec);
if (!el) return;
flashLabel(btn);
if (sec.id === 'player' || sec.id === 'meta') restoreNowPlayingList();
if (st.active === sec.id && sec.fold) {
// Already here: this is the second tap — fold or unfold the card.
setFolded(sec.id, !isFolded(sec.id));
if (isFolded(sec.id)) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
st.active = sec.id; // claim it now so the next tap folds
paint();
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function build() {
const r = rail();
if (!r) return;
const items = visible();
// A rail with one entry is noise; so is one on the empty home screen.
if (items.length < 2) { r.classList.add('hidden'); r.innerHTML = ''; st.btns.clear(); return; }
const key = items.map((s) => s.id).join(',');
if (r.dataset.key !== key) {
r.dataset.key = key;
r.innerHTML = '';
st.btns.clear();
for (const sec of items) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sec-rail-btn';
btn.dataset.sec = sec.id;
btn.setAttribute('aria-label', sec.label);
btn.innerHTML = `${sec.icon}${escapeHtml(sec.label)}`;
btn.addEventListener('click', () => onTap(sec, btn));
r.appendChild(btn);
st.btns.set(sec.id, btn);
}
}
r.classList.remove('hidden');
placeRail();
paint();
}
// The page does NOT scroll the document: `.player-pane` is the scroller in
// the browser, but the installed PWA in portrait scrolls `.body` instead
// (`.player-pane` goes overflow:visible there). So find the real scroller
// rather than assuming one.
function scrollerOf(el) {
for (let e = el && el.parentElement; e; e = e.parentElement) {
const cs = getComputedStyle(e);
if (/(auto|scroll)/.test(cs.overflowY) && e.scrollHeight > e.clientHeight + 4) return e;
}
return document.scrollingElement || document.documentElement;
}
// The visible box to measure against — the scroller's own rect, or the
// window when the document itself scrolls.
function viewBox() {
const first = elOf(visible()[0] || SECTIONS[0]);
const sc = scrollerOf(first);
if (!sc || sc === document.scrollingElement || sc === document.documentElement) {
return { top: 0, height: window.innerHeight };
}
const r = sc.getBoundingClientRect();
return { top: r.top, height: r.height };
}
// Which card is the reader looking at? The one whose top is closest to a
// line a third of the way down the visible box — steadier than "first
// visible", which flickers between two cards at a boundary.
function currentId() {
const items = visible();
if (!items.length) return '';
const box = viewBox();
const line = box.top + box.height / 3;
let best = '', bestD = Infinity;
let near = items[0].id, nearD = Infinity; // fallback: closest card overall
for (const sec of items) {
const el = elOf(sec);
if (!el) continue;
const r = el.getBoundingClientRect();
const d = Math.abs(r.top - line);
// Distance to the visible band, for the fallback below.
const outside = Math.max(box.top - r.bottom, r.top - (box.top + box.height), 0);
if (outside < nearD || (outside === nearD && d < bestD)) { nearD = outside; near = sec.id; }
if (r.bottom < box.top || r.top > box.top + box.height) continue;
if (d < bestD) { bestD = d; best = sec.id; }
}
// Scrolled past the last card (or a card is taller than the viewport and
// its top has gone by): never leave the rail with nothing highlighted.
return best || near;
}
// The mini player and the bottom nav are both position:fixed bottom:0, and
// either can be absent (nothing playing / landscape). Measure whatever is
// actually pinned down there and park the rail above it, rather than
// guessing an offset that is wrong on the next phone.
function placeRail() {
const r = rail();
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) continue;
if (getComputedStyle(el).position !== 'fixed') continue;
// Count any fixed bar sitting in the lower half. It must NOT be required
// to touch the viewport bottom: on an iPhone XR the bottom nav ends
// above the home-indicator gap, so a "bottom >= innerHeight" test missed
// it entirely and the rail was parked underneath it.
if (box.top > window.innerHeight * 0.5) occupied = Math.max(occupied, window.innerHeight - box.top);
}
// 14px was too tight: the mini player is a floating CARD with a shadow and
// rounded corners, so its visual edge reads higher than its box.
const want = Math.round(occupied + 24);
if (st.bottom !== want) {
st.bottom = want;
if (r) r.style.setProperty('--rail-bottom', want + 'px');
// Published for anything else that must sit above the bottom bars —
// the watch-party panel's leave button was hidden behind them.
document.documentElement.style.setProperty('--bottom-chrome', Math.round(occupied) + 'px');
}
}
function paint() {
for (const [id, btn] of st.btns) {
btn.classList.toggle('active', id === st.active);
btn.classList.toggle('folded', isFolded(id));
}
}
function onScroll() {
if (st.raf) return;
st.raf = requestAnimationFrame(() => {
st.raf = 0;
// Panels appear and disappear outside render() too (the Related card
// shows once a video's related list arrives), and a card with no icon
// would leave the rail with nothing highlighted. build() is a no-op
// unless the set of visible cards actually changed.
build();
placeRail();
const id = currentId();
if (id && id !== st.active) { st.active = id; paint(); }
});
}
function boot() {
const fb = document.getElementById('notesFoldBtn');
if (fb) fb.addEventListener('click', () => setFolded('notes', !isFolded('notes')));
SECTIONS.forEach(applyFold);
// Scroll events from an element do NOT bubble, so a window listener never
// fires here. A capture-phase listener on document catches them all, and
// keeps working when the scroller changes between browser and installed
// PWA (different elements scroll in each).
document.addEventListener('scroll', onScroll, { passive: true, capture: true });
window.addEventListener('resize', () => { build(); placeRail(); onScroll(); });
// The mini player appears AFTER the scroll that reveals it, so a scroll
// handler alone measures the layout as it was a moment ago and parks the
// rail underneath it. Watch the two bottom bars for real.
const watch = ['bottomNav', 'miniBar'].map((id) => document.getElementById(id)).filter(Boolean);
if (watch.length && typeof MutationObserver !== 'undefined') {
// The mini bar slides in over 0.25s, so a measurement taken the instant
// it appears catches it mid-animation and lands the rail a few pixels
// too low. Measure now and again once it has settled.
const mo = new MutationObserver(() => { placeRail(); setTimeout(placeRail, 320); });
watch.forEach((el) => mo.observe(el, { attributes: true, attributeFilter: ['class', 'style'] }));
}
if (watch.length && typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => placeRail());
watch.forEach((el) => ro.observe(el));
}
build();
onScroll();
}
// Called from render(): cards appear and disappear as videos load.
function refresh() {
SECTIONS.forEach(applyFold);
build();
placeRail();
onScroll();
}
return { boot, refresh, setFolded, isFolded };
})();
/* Cache the single page-owned app response before it registers the worker. */
(function (root) {
'use strict';
const doc=root.document, manifest=root.Lazy.manifest, key=root.Lazy.url('/app.js');
let domReady=doc.readyState==='complete', appSawDom=false;
doc.addEventListener('DOMContentLoaded',()=>{domReady=true;appSawDom=typeof root.boot==='function';},{once:true});
function execute(node) {
doc.head.append(node);
node.remove();
delete root.Lazy.appResponse;
// app.js keeps its original DOMContentLoaded listener. Async response
// consumption can finish after that event; boot exactly once in that case.
if(domReady)root.boot();
}
async function external(url) {
await new Promise((resolve,reject)=>{
const node=doc.createElement('script');node.src=url;
node.onload=()=>{if(domReady&&!appSawDom)root.boot();resolve();};node.onerror=()=>reject(Error('Unable to load player'));
doc.head.append(node);
});
}
function native() {
if(doc.readyState!=='loading' || !doc.currentScript)return external(key);
return new Promise((resolve,reject)=>{
doc.addEventListener('DOMContentLoaded',()=>typeof root.boot==='function'?resolve():reject(Error('Unable to load player')),{once:true});
const escaped=key.replaceAll('&','&').replaceAll('"','"').replaceAll('<','<');
doc.write('');
});
}
async function verified(response, expected) {
const bytes=await response.clone().arrayBuffer();
const digest=new Uint8Array(await root.crypto.subtle.digest('SHA-256',bytes));
const hex=Array.from(digest,b=>b.toString(16).padStart(2,'0')).join('');
if(hex.slice(0,10)!==expected)throw Error('Player body hash mismatch');
return {bytes,digest};
}
async function start() {
if(!manifest.appCache || !root.crypto?.subtle || !root.Lazy.captureApp)return native();
const expected=manifest.files['/app.js'].h;
let cache;
try { cache=await root.caches?.open('ytplayer-assets'); } catch {}
let response=await cache?.match(key);
if(!response || response.headers.get('X-Asset-Hash')!==expected) {
try { response=await (root.Lazy.appResponse || root.fetch(key,{credentials:'same-origin'})); } catch {}
}
if(!response?.ok || response.headers.get('X-Asset-Hash')!==expected) {
// Preserve staged N-1 boot after an individual cache eviction. Only the
// controller can serve this old URL safely; never fetch stale URLs bare.
const state=cache && await root.AssetSyncCore.state(cache);
const previousKey=state && root.AssetSyncCore.fallback(manifest,state.previous,'/app.js');
const previous=previousKey && await cache.match(previousKey);
const previousHash=state?.previous?.files['/app.js']?.h;
const contractsMatch=state?.previous && Object.entries(manifest.groups).every(([name,group])=>state.previous.groups[name]?.contract===group.contract);
if(contractsMatch && root.navigator?.serviceWorker?.controller && previous?.ok && previous.headers.get('X-Asset-Hash')===previousHash) {
await verified(previous,previousHash);
delete root.Lazy.appResponse;
return external(previousKey);
}
throw Error('Player asset hash mismatch');
}
const {bytes,digest}=await verified(response,expected);
const permission="'sha256-"+root.btoa(String.fromCharCode(...digest))+"'";
if(!doc.querySelector('meta[http-equiv="Content-Security-Policy"]').content.includes(permission))throw Error('Player body hash mismatch');
// CacheStorage may share a full quota with saved music. Never remove data
// to make room; boot from these verified bytes even if caching is refused.
try { await cache?.put(key,response); } catch(error) { root.console.warn('[app-cache]',error.message); }
const node=doc.createElement('script');node.textContent=new root.TextDecoder().decode(bytes);
execute(node);
}
const ready=start();
root.AppBootstrap={ready};
ready.catch(error=>root.console.error('[app-cache]',error.message));
})(typeof window!=='undefined'?window:globalThis);