Keep Glass Stage section navigation and panel content clear of playback chrome
This commit is contained in:
42
frontend/glass-panel-layout.js
Normal file
42
frontend/glass-panel-layout.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/* Glass Stage section shortcuts participate in layout; Classic keeps its rail. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
function bottomClearance(viewport, boxes, gap = 16) {
|
||||
const occupied = boxes.reduce((height, b) => b.fixed && b.height > 0 && b.top < viewport && b.bottom > 0 ? Math.max(height, viewport - b.top) : height, 0);
|
||||
return Math.ceil(occupied + (occupied ? gap : 0));
|
||||
}
|
||||
function mount(doc) {
|
||||
const win = doc.defaultView, html = doc.documentElement, rail = doc.getElementById('sectionRail'), panel = doc.getElementById('listPane');
|
||||
if (!rail || !panel) return;
|
||||
const original = doc.createComment('section rail original position'); rail.before(original);
|
||||
let frame = 0;
|
||||
function active() { return html.dataset.layout === 'glass-stage'; }
|
||||
function schedule() { if (!frame) frame = win.requestAnimationFrame(measure); }
|
||||
function set(name, value) { if (html.style.getPropertyValue(name) !== value) html.style.setProperty(name, value); }
|
||||
function box(el) { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, height: el.classList.contains('hidden') ? 0 : r.height, fixed: win.getComputedStyle(el).position === 'fixed' }; }
|
||||
function measure() {
|
||||
frame = 0; if (!active()) return;
|
||||
const nav = doc.getElementById('bottomNav'), mini = doc.getElementById('miniBar');
|
||||
const navBox = box(nav);
|
||||
set('--glass-nav-height', `${Math.ceil(navBox.height)}px`);
|
||||
// Read again after nav-height changes: the mini player's bottom depends on it.
|
||||
set('--glass-bottom-clearance', `${bottomClearance(win.innerHeight, [box(nav), box(mini)])}px`);
|
||||
}
|
||||
function sync() {
|
||||
if (active()) { if (rail.parentElement !== panel) panel.prepend(rail); schedule(); }
|
||||
else {
|
||||
if (rail.parentNode !== original.parentNode) original.after(rail);
|
||||
html.style.removeProperty('--glass-nav-height'); html.style.removeProperty('--glass-bottom-clearance');
|
||||
}
|
||||
}
|
||||
const observer = new MutationObserver(sync); observer.observe(html, { attributes: true, attributeFilter: ['data-layout'] });
|
||||
const chrome = [doc.getElementById('bottomNav'), doc.getElementById('miniBar')].filter(Boolean);
|
||||
const changes = new MutationObserver(schedule); chrome.forEach(el => changes.observe(el, { attributes: true, attributeFilter: ['class', 'style'] }));
|
||||
const resize = typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(schedule) : null; chrome.forEach(el => resize?.observe(el));
|
||||
win.addEventListener('resize', schedule); win.addEventListener('orientationchange', schedule); win.visualViewport?.addEventListener('resize', schedule);
|
||||
sync();
|
||||
}
|
||||
if (typeof module !== 'undefined') module.exports = { bottomClearance };
|
||||
else if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', () => mount(root.document), { once: true });
|
||||
else mount(root.document);
|
||||
}(typeof window !== 'undefined' ? window : globalThis));
|
||||
10
frontend/glass-panel-layout.test.js
Normal file
10
frontend/glass-panel-layout.test.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { bottomClearance } = require('./glass-panel-layout.js');
|
||||
test('clearance covers stacked floating chrome and includes its viewport inset once', () => {
|
||||
assert.equal(bottomClearance(844, [{ fixed: true, top: 740, bottom: 844, height: 104 }, { fixed: true, top: 668, bottom: 732, height: 64 }]), 192);
|
||||
assert.equal(bottomClearance(844, [{ fixed: true, top: 740, bottom: 844, height: 104 }]), 120);
|
||||
});
|
||||
test('hidden, nonfixed and offscreen elements do not reserve chrome space', () => {
|
||||
assert.equal(bottomClearance(844, [{ fixed: true, top: 740, bottom: 844, height: 0 }, { fixed: false, top: 668, bottom: 732, height: 64 }, { fixed: true, top: 900, bottom: 964, height: 64 }]), 0);
|
||||
});
|
||||
@@ -34,6 +34,7 @@
|
||||
<link rel="stylesheet" href="offline-pages.css" />
|
||||
<link rel="stylesheet" href="saved-page.css" />
|
||||
<link rel="stylesheet" href="theme-glass-controls.css" />
|
||||
<link rel="stylesheet" href="theme-glass-panel.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -670,6 +671,7 @@
|
||||
<script src="saved-page.js"></script>
|
||||
<script src="server-backfill.js"></script>
|
||||
<script src="glass-controls.js"></script>
|
||||
<script src="glass-panel-layout.js"></script>
|
||||
<script src="party-dj.js"></script>
|
||||
<script src="lyrics-window.js"></script>
|
||||
<script src="midi.js"></script>
|
||||
|
||||
@@ -59,6 +59,8 @@ const SHELL = [
|
||||
'/service-ui.css',
|
||||
'/refresh.css',
|
||||
'/theme-glass.css',
|
||||
'/theme-glass-panel.css',
|
||||
'/glass-panel-layout.js',
|
||||
'/theme-classic.css',
|
||||
'/recommendations.css',
|
||||
'/flag-ui.js',
|
||||
|
||||
37
frontend/theme-glass-panel.css
Normal file
37
frontend/theme-glass-panel.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* In-flow section shortcuts cannot cover a heading, filter or list row. */
|
||||
html[data-layout="glass-stage"] #listPane > .sec-rail {
|
||||
position:static; inset:auto; transform:none; display:flex !important;
|
||||
align-self:flex-start; flex:none; flex-direction:row; flex-wrap:nowrap;
|
||||
width:max-content; max-width:100%; margin:0 0 12px; z-index:auto;
|
||||
}
|
||||
html[data-layout="glass-stage"] #listPane > .sec-rail.hidden { display:none !important; }
|
||||
html[data-layout="glass-stage"] #listPane > .sec-rail .sec-rail-btn { flex:none; }
|
||||
html[data-layout="glass-stage"] #listPane > .sec-rail .sec-rail-label { z-index:1; }
|
||||
/* Keep the progress strip inside the same rounded card as the controls. */
|
||||
html[data-layout="glass-stage"] .mini-bar {
|
||||
bottom:calc(var(--glass-nav-height, 66px + env(safe-area-inset-bottom)) + 8px);
|
||||
border-radius:18px; overflow:hidden; border:1px solid var(--glass-line);
|
||||
background:var(--glass); -webkit-backdrop-filter:blur(24px); backdrop-filter:blur(24px);
|
||||
}
|
||||
html[data-layout="glass-stage"] .mini-bar-inner {
|
||||
border:0; border-radius:0; background:transparent; box-shadow:none;
|
||||
-webkit-backdrop-filter:none; backdrop-filter:none;
|
||||
}
|
||||
html[data-layout="glass-stage"] .mini-progress { margin:0; height:3px; overflow:hidden; }
|
||||
html[data-layout="glass-stage"] .player-pane {
|
||||
padding-bottom:var(--glass-bottom-clearance, 32px);
|
||||
scroll-padding-bottom:var(--glass-bottom-clearance, 32px);
|
||||
}
|
||||
html[data-layout="glass-stage"] #listPane {
|
||||
padding-bottom:var(--glass-bottom-clearance, 32px) !important;
|
||||
scroll-padding-bottom:var(--glass-bottom-clearance, 32px);
|
||||
}
|
||||
@media (max-width:860px) {
|
||||
/* Portrait uses one shared scroller: reserve chrome once, at its tail. */
|
||||
html[data-layout="glass-stage"] .body {
|
||||
padding-bottom:var(--glass-bottom-clearance, calc(82px + env(safe-area-inset-bottom)));
|
||||
scroll-padding-bottom:var(--glass-bottom-clearance, calc(82px + env(safe-area-inset-bottom)));
|
||||
}
|
||||
html[data-layout="glass-stage"] .player-pane { padding-bottom:14px; }
|
||||
html[data-layout="glass-stage"] #listPane { padding-bottom:16px !important; }
|
||||
}
|
||||
Reference in New Issue
Block a user