Add a mobile section rail that follows the scroll, jumps between cards and folds the one you are on
This commit is contained in:
20
CLAUDE.md
20
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
|
the fetch resolves to `null` offline and `respondWith(null)` throws. It ends
|
||||||
with `|| Response.error()`.
|
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)
|
## First paint vs. the network (launch)
|
||||||
`boot()` used to `await` the profile pull and the shared-playlist inbox before
|
`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
|
the first `render()`, so on a slow link the sidebar stayed empty for as long as
|
||||||
|
|||||||
213
frontend/app.js
213
frontend/app.js
@@ -5419,6 +5419,209 @@ const Gestures = (() => {
|
|||||||
// URL with audio+video in one file — the in-app split streams are useless to
|
// 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;
|
// other players). Android: intent: links; iOS: VLC's x-callback scheme;
|
||||||
// desktop: vlc:// or an .m3u playlist file.
|
// 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 = `<span aria-hidden="true">${sec.icon}</span><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');
|
||||||
|
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 External = (() => {
|
||||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
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('|');
|
const viewKey = [view.type, view.id, view.smartType].join('|');
|
||||||
if (_lastViewKey !== null && viewKey !== _lastViewKey) scrollListIntoViewPortrait();
|
if (_lastViewKey !== null && viewKey !== _lastViewKey) scrollListIntoViewPortrait();
|
||||||
_lastViewKey = viewKey;
|
_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
|
// Related panel collapse toggle
|
||||||
$('relatedToggleBtn').addEventListener('click', () => {
|
$('relatedToggleBtn').addEventListener('click', () => {
|
||||||
relatedCollapsed = !relatedCollapsed;
|
// One source of truth: SectionRail owns fold state and applies it here
|
||||||
$('relatedList').classList.toggle('hidden', relatedCollapsed);
|
// (it sets relatedCollapsed, the list class and this button's label).
|
||||||
$('relatedToggleBtn').textContent = relatedCollapsed ? '+' : '−';
|
SectionRail.setFolded('related', !relatedCollapsed);
|
||||||
});
|
});
|
||||||
|
|
||||||
// List filter
|
// List filter
|
||||||
@@ -9359,6 +9565,7 @@ async function boot() {
|
|||||||
syncOnLaunch();
|
syncOnLaunch();
|
||||||
startPlaylistInboxWatch();
|
startPlaylistInboxWatch();
|
||||||
Presenter.boot();
|
Presenter.boot();
|
||||||
|
SectionRail.boot();
|
||||||
Share.bootFromUrl();
|
Share.bootFromUrl();
|
||||||
Party.boot();
|
Party.boot();
|
||||||
Remote.boot();
|
Remote.boot();
|
||||||
|
|||||||
@@ -242,6 +242,7 @@
|
|||||||
<button class="notes-tab" data-tab="markers" role="tab" type="button">Chapters & bookmarks <span id="markersCount" class="notes-count"></span></button>
|
<button class="notes-tab" data-tab="markers" role="tab" type="button">Chapters & bookmarks <span id="markersCount" class="notes-count"></span></button>
|
||||||
<button class="notes-tab" data-tab="search" role="tab" type="button">🔎 Search</button>
|
<button class="notes-tab" data-tab="search" role="tab" type="button">🔎 Search</button>
|
||||||
<span id="notesStatus" class="notes-status"></span>
|
<span id="notesStatus" class="notes-status"></span>
|
||||||
|
<button id="notesFoldBtn" class="notes-fold" type="button" aria-expanded="true" title="Fold this card">▾</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="lyricsPane" class="notes-pane">
|
<div id="lyricsPane" class="notes-pane">
|
||||||
@@ -353,6 +354,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Service mode (set-list view) overlay -->
|
<!-- Service mode (set-list view) overlay -->
|
||||||
|
<!-- Mobile: floating rail that jumps between the cards on this page and
|
||||||
|
follows the scroll. Tapping the active icon folds that card. -->
|
||||||
|
<nav id="sectionRail" class="sec-rail hidden" aria-label="Jump to a section"></nav>
|
||||||
|
|
||||||
<section id="serviceMode" class="service-mode hidden" aria-label="Service Mode">
|
<section id="serviceMode" class="service-mode hidden" aria-label="Service Mode">
|
||||||
<div class="service-topbar">
|
<div class="service-topbar">
|
||||||
<div id="serviceWakeState" class="service-wake-state">Screen may sleep</div>
|
<div id="serviceWakeState" class="service-wake-state">Screen may sleep</div>
|
||||||
|
|||||||
@@ -4135,3 +4135,101 @@ body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or t
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.sync-spinner { animation-duration: 2.4s; }
|
.sync-spinner { animation-duration: 2.4s; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Foldable cards + the mobile section rail
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* Folding hides a card's body but never its header, so the card stays a
|
||||||
|
visible landmark (and a tap target) when collapsed. */
|
||||||
|
#notesPanel.folded .notes-pane,
|
||||||
|
#nowPlayingMeta.folded .np-actions,
|
||||||
|
#relatedPanel.folded .related-list { display: none !important; }
|
||||||
|
#notesPanel.folded .notes-tabs { border-bottom: 0; margin-bottom: 0; }
|
||||||
|
|
||||||
|
.notes-fold {
|
||||||
|
margin-left: 6px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 30px; height: 30px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
transition: transform 0.16s var(--ease), color 0.16s var(--ease);
|
||||||
|
}
|
||||||
|
.notes-fold:hover { color: var(--text); border-color: var(--line); }
|
||||||
|
#notesPanel.folded .notes-fold { transform: rotate(-90deg); }
|
||||||
|
|
||||||
|
/* --- the rail ------------------------------------------------------------ */
|
||||||
|
.sec-rail {
|
||||||
|
position: fixed;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--bg-1) 84%, transparent);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.sec-rail-btn {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
transition: background 0.16s var(--ease), color 0.16s var(--ease);
|
||||||
|
}
|
||||||
|
.sec-rail-btn:active { transform: scale(0.94); }
|
||||||
|
.sec-rail-btn.active {
|
||||||
|
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
}
|
||||||
|
/* A folded card's icon is dimmed and marked, so the rail doubles as a map of
|
||||||
|
what is currently collapsed. */
|
||||||
|
.sec-rail-btn.folded { opacity: 0.55; }
|
||||||
|
.sec-rail-btn.folded::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 8px; right: 8px; bottom: 6px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
.sec-rail-label {
|
||||||
|
position: absolute;
|
||||||
|
right: 44px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--bg-1);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.16s var(--ease);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.sec-rail-btn.show-label .sec-rail-label { opacity: 1; }
|
||||||
|
|
||||||
|
/* Phones only — on desktop the whole page is visible at once. */
|
||||||
|
@media (min-width: 861px) { .sec-rail { display: none !important; } }
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
/* clear of the bottom nav / mini player */
|
||||||
|
.sec-rail { top: auto; bottom: 132px; transform: none; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user