/* 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 = `${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 }; })();