From 530d07d92063eacb56a1a71e1dd02a2c94c05d9f Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Tue, 22 Sep 2026 13:47:26 +0800 Subject: [PATCH] Add a mobile section rail that follows the scroll, jumps between cards and folds the one you are on --- CLAUDE.md | 20 +++++ frontend/app.js | 213 +++++++++++++++++++++++++++++++++++++++++++- frontend/index.html | 5 ++ frontend/styles.css | 98 ++++++++++++++++++++ 4 files changed, 333 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 73aae28..d0c467a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -375,6 +375,26 @@ Local DB file: `server/data/ytplayer.db` (gitignored). `BUILD_TAG` is computed f the fetch resolves to `null` offline and `respondWith(null)` throws. It ends with `|| Response.error()`. +## Section rail + foldable cards (phones) +`SectionRail` in app.js draws a floating right-hand rail on phones with one +icon per visible card (player · now playing · lyrics · related · list). It +follows the scroll, a tap jumps to that card, and a tap on the card you are +**already** on folds/unfolds it (`data.settings.foldedCards`, per device). +Folding hides a card's body but never its header, so it stays a landmark you +can scroll to and reopen; the Related card's own +/− button routes through +`SectionRail.setFolded` so the two can never disagree. + +Two things about this page that break naive implementations: +- **The document does not scroll.** `.player-pane` is the scroll container in + the browser, but the installed PWA in portrait scrolls `.body` instead + (`.player-pane` becomes `overflow-y: visible` there). `scrollerOf()` walks up + to whichever ancestor actually scrolls, and `viewBox()` measures against it. +- **Scroll events do not bubble**, so a `window` scroll listener never fires + for an element scroller — the listener is on `document` with `capture: true`. +Cards also appear outside `render()` (Related arrives with the video's related +list), so `onScroll` re-runs `build()`, which is a no-op unless the visible set +changed; without that the rail could highlight nothing at all. + ## First paint vs. the network (launch) `boot()` used to `await` the profile pull and the shared-playlist inbox before the first `render()`, so on a slow link the sidebar stayed empty for as long as diff --git a/frontend/app.js b/frontend/app.js index 9c5d319..2788176 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -5419,6 +5419,209 @@ const Gestures = (() => { // URL with audio+video in one file — the in-app split streams are useless to // other players). Android: intent: links; iOS: VLC's x-callback scheme; // desktop: vlc:// or an .m3u playlist file. +/* ============================================================================ + * SectionRail — floating jump-to-card rail for phones. + * + * The player page is a tall stack of cards on a phone (player, now playing, + * lyrics, related, the list). The rail gives each visible card an icon: + * • it follows the scroll and highlights whichever card you are looking at, + * • tapping an icon scrolls to that card, + * • tapping the icon of the card you are ALREADY on folds/unfolds it + * (the "double tap" of the request — the first tap takes you there, the + * second one collapses it). + * A folded card keeps its header, so it stays a landmark you can scroll to + * and reopen. Fold state is per device and rides in settings. + * ========================================================================== */ +const SectionRail = (() => { + 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 }; + 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 (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'); + 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; + } + + 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(); + 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(); onScroll(); }); + build(); + onScroll(); + } + + // Called from render(): cards appear and disappear as videos load. + function refresh() { + SECTIONS.forEach(applyFold); + build(); + onScroll(); + } + + return { boot, refresh, setFolded, isFolded }; +})(); + const External = (() => { const isAndroid = /Android/i.test(navigator.userAgent); const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); @@ -7533,6 +7736,9 @@ function render() { const viewKey = [view.type, view.id, view.smartType].join('|'); if (_lastViewKey !== null && viewKey !== _lastViewKey) scrollListIntoViewPortrait(); _lastViewKey = viewKey; + // Cards come and go with the view, so the rail is rebuilt from what is + // actually on screen. + SectionRail.refresh(); } // ============================================================================ @@ -8309,9 +8515,9 @@ document.querySelectorAll('.chip').forEach((c) => { // Related panel collapse toggle $('relatedToggleBtn').addEventListener('click', () => { - relatedCollapsed = !relatedCollapsed; - $('relatedList').classList.toggle('hidden', relatedCollapsed); - $('relatedToggleBtn').textContent = relatedCollapsed ? '+' : '−'; + // One source of truth: SectionRail owns fold state and applies it here + // (it sets relatedCollapsed, the list class and this button's label). + SectionRail.setFolded('related', !relatedCollapsed); }); // List filter @@ -9359,6 +9565,7 @@ async function boot() { syncOnLaunch(); startPlaylistInboxWatch(); Presenter.boot(); + SectionRail.boot(); Share.bootFromUrl(); Party.boot(); Remote.boot(); diff --git a/frontend/index.html b/frontend/index.html index 45ef538..679caa9 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -242,6 +242,7 @@ +
@@ -353,6 +354,10 @@
+ + +