Files
ytplayer/frontend/section-rail.js

248 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Eager UI definitions. Read app lexical state only when called after initialization. */
const SectionRail = (() => {
const CLASSIC_ICONS = {
player: '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>',
meta: '<path d="M9 18V5l11-2v13M9 8l11-2"/><ellipse cx="6" cy="18" rx="3" ry="2"/><ellipse cx="17" cy="16" rx="3" ry="2"/>',
notes: '<path d="M14 3H4v18h16V11M9 15l2-5 7-7 3 3-7 7z"/>',
related: '<path d="m10 14 4-4m-5 6-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0m0 10a4 4 0 0 0 6 0l4-4a4 4 0 0 0-6-6l-1 1"/>',
list: '<path d="M9 6h12M9 12h12M9 18h12M3 6h1M3 12h1M3 18h1"/>',
};
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 = `<span class="legacy-nav-icon" aria-hidden="true">${sec.icon}</span><svg class="classic-nav-icon" hidden viewBox="0 0 24 24" aria-hidden="true">${CLASSIC_ICONS[sec.id]}</svg><span class="sec-rail-label">${escapeHtml(sec.label)}</span>`;
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 };
})();