Files
feedBack/static/v3/player-chrome.js
T
db81d7dafb Add progress bar to v3 "Up Next" pill (#649)
The persistent top-right "Up Next" pill showed the upcoming section name
and a countdown ("in 12.3s") but no at-a-glance sense of how far through
the current section the song is. Add a thin progress bar directly under
the existing text that fills as the current section elapses toward the
next, reaching full when the section flips.

The text row is wrapped unchanged in a flex row and the pill stacks the
bar beneath it; nothing else about the pill's content or styling changes.
Progress is computed in updateUpNext() as the fraction elapsed between the
previous section boundary (last section at/before now, else song start)
and the next section. The fill uses the same gradient as the section name
for visual cohesion.

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:44:09 +02:00

304 lines
14 KiB
JavaScript

/*
* fee[dB]ack v0.3.0 — P22 player chrome.
*
* Drives the v3 player overlay (static/v3/index.html #player): the Up-Next
* section pill, the hover-reveal left icon rail + its feature popovers, the
* auto-hiding bottom transport, and the speed-level visual (bars + chevrons).
*
* Design contract: the actual controls are the SAME legacy elements/handlers
* (ids unchanged), just relocated into rail popovers — so app.js/highway.js
* keep populating and reacting to them unmodified. This module only adds
* presentation behavior (open/close, reveal/hide, mirror state). It runs only
* while #player is the active screen.
*/
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
const player = () => $('player');
const now = () => (window.performance && performance.now ? performance.now() : (window.__pcNow = (window.__pcNow || 0) + 16));
const IDLE_MS = 2500; // transport hides after this much pointer stillness
const UPNEXT_MS = 160; // throttle the Up-Next recompute (~6 Hz)
let rafId = null, running = false;
let lastMove = 0, lastUpNext = 0;
let openPop = null; // { btn, pop }
// ── v3 UI signal + plugin-control slot API ───────────────────────────────
// Lets plugins detect v3 (window.feedBack.uiVersion === 'v3') and mount
// controls into a stable slot instead of the auto-hiding transport.
window.feedBack = window.feedBack || {};
window.feedBack.uiVersion = 'v3';
window.feedBack.ui = window.feedBack.ui || {};
window.feedBack.ui.version = 'v3';
window.feedBack.ui.playerControlSlot = function () { return $('v3-plugin-controls-slot'); };
// ── Plugin-control re-homing shim ────────────────────────────────────────
// Legacy plugins inject controls into #player-controls (now an auto-hiding
// minimal transport, often via a now-deleted separator anchor). Move any
// non-native child into the always-reachable Plugins rail popover. Moving a
// node preserves its identity + listeners, so the plugin's own toggle logic
// keeps working.
function updatePluginSlotState() {
const slot = $('v3-plugin-controls-slot');
if (!slot) return;
// Count only controls that are actually shown — plugins inject hidden
// gear/settings panels (display:none / .hidden) too, which shouldn't
// inflate the badge.
const n = Array.prototype.filter.call(slot.children, (el) =>
el.nodeType === 1 &&
!el.hasAttribute('hidden') &&
!(el.classList && el.classList.contains('hidden')) &&
(el.style ? el.style.display !== 'none' : true)).length;
const empty = $('v3-plugin-slot-empty');
if (empty) empty.style.display = n ? 'none' : '';
const badge = $('v3-plugin-count');
if (badge) {
if (n) { badge.textContent = String(n); badge.hidden = false; }
else { badge.hidden = true; }
}
}
function rehomePluginControls() {
const bar = $('player-controls');
const slot = $('v3-plugin-controls-slot');
if (!bar || !slot) return;
// Snapshot children first — appendChild mutates the live list.
Array.prototype.slice.call(bar.children).forEach((el) => {
if (el.nodeType !== 1 || el.hasAttribute('data-v3-native')) return;
slot.appendChild(el); // move (identity + listeners preserved)
});
updatePluginSlotState();
}
function installRehomeObserver() {
const bar = $('player-controls');
if (!bar || bar.dataset.pcRehome) return;
bar.dataset.pcRehome = '1';
rehomePluginControls(); // initial sweep
// Only addedNodes matter; our own moves are removals from the bar and
// never re-trigger a move, so there's no loop.
try {
new MutationObserver((muts) => {
if (muts.some((m) => m.addedNodes && m.addedNodes.length)) rehomePluginControls();
}).observe(bar, { childList: true });
} catch (e) { /* MutationObserver always present in target browsers */ }
// Also watch the slot itself: v3-aware plugins mount controls straight
// into it (bypassing #player-controls), and plugins show/hide their own
// controls — both must refresh the badge/empty state.
const slot = $('v3-plugin-controls-slot');
if (slot) {
try {
new MutationObserver(updatePluginSlotState).observe(slot, {
childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden'],
});
} catch (e) { /* non-fatal */ }
}
}
// ── Rail popovers ───────────────────────────────────────────────────────
function setPopOpenFlag(on) {
const p = player();
if (p) p.classList.toggle('pop-open', !!on);
}
function closePop() {
if (!openPop) return;
if (openPop.pop) openPop.pop.classList.add('hidden');
if (openPop.btn) {
openPop.btn.setAttribute('aria-expanded', 'false');
openPop.btn.classList.remove('is-active');
}
openPop = null;
setPopOpenFlag(false);
}
function openPopFor(btn) {
const key = btn.getAttribute('data-rail');
const pop = $('v3-rail-pop-' + key);
if (!pop) return;
if (openPop && openPop.pop === pop) { closePop(); return; } // toggle
closePop();
pop.classList.remove('hidden');
btn.setAttribute('aria-expanded', 'true');
btn.classList.add('is-active');
openPop = { btn: btn, pop: pop };
setPopOpenFlag(true);
// Lazily mount the P18 audio-routing widget the first time it's shown.
if (key === 'audio' && window.v3AudioRouting && typeof window.v3AudioRouting.render === 'function') {
try { window.v3AudioRouting.render($('v3-rail-audio-routing')); } catch (e) { /* non-fatal */ }
}
}
// Reflect the real highway lyrics state onto the Mic rail icon (lyrics
// default ON and persist in localStorage, so click-parity would desync).
function syncLyricsIcon() {
const rail = $('v3-player-rail');
const lyr = rail && rail.querySelector('[data-rail-action="lyrics"]');
if (!lyr) return;
const on = (window.highway && typeof highway.getLyricsVisible === 'function')
? highway.getLyricsVisible()
: lyr.classList.contains('is-active');
lyr.classList.toggle('is-active', !!on);
lyr.setAttribute('aria-pressed', on ? 'true' : 'false');
}
function wireRail() {
const rail = $('v3-player-rail');
if (!rail || rail.dataset.pcWired) return;
rail.dataset.pcWired = '1';
rail.querySelectorAll('[data-rail]').forEach((b) =>
b.addEventListener('click', (e) => { e.stopPropagation(); openPopFor(b); }));
// Mic icon: a direct lyrics toggle (clicks the hidden canonical button so
// highway.toggleLyrics() + any label logic runs), mirroring on/off state.
const lyr = rail.querySelector('[data-rail-action="lyrics"]');
if (lyr) lyr.addEventListener('click', (e) => {
e.stopPropagation();
const real = $('btn-lyrics');
if (real) real.click(); // runs highway.toggleLyrics() via its onclick
else if (window.highway && typeof highway.toggleLyrics === 'function') highway.toggleLyrics();
syncLyricsIcon(); // reflect the ACTUAL toggled state, not click parity
});
// Click-outside + Esc close (bound once; harmless when no popover open).
if (!window.__pcGlobalClose) {
window.__pcGlobalClose = true;
document.addEventListener('click', (e) => {
if (!openPop) return;
if (openPop.pop.contains(e.target) || openPop.btn.contains(e.target)) return;
closePop();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePop(); });
}
}
// ── Up Next pill ─────────────────────────────────────────────────────────
function updateUpNext() {
const pill = $('v3-upnext');
if (!pill) return;
// Gated by the core "Show 'Up Next'" pref (Gameplay tab, default ON).
if (window.feedBack && window.feedBack.showUpNext === false) { pill.classList.add('hidden'); return; }
const hw = window.highway;
const secs = (hw && typeof hw.getSections === 'function') ? hw.getSections() : null;
const t = (hw && typeof hw.getTime === 'function') ? hw.getTime() : null;
if (!Array.isArray(secs) || !secs.length || t == null || isNaN(t)) { pill.classList.add('hidden'); return; }
let next = null;
for (let i = 0; i < secs.length; i++) {
if (typeof secs[i].time === 'number' && secs[i].time > t + 0.05) { next = secs[i]; break; }
}
if (!next) { pill.classList.add('hidden'); return; }
const dt = Math.max(0, next.time - t);
const nm = $('v3-upnext-name'), eta = $('v3-upnext-eta');
if (nm) nm.textContent = next.name || '—';
if (eta) eta.textContent = 'in ' + dt.toFixed(1) + 's';
// Progress bar: fraction of the current section elapsed toward `next`.
// Previous boundary is the last section at/before now (else song start).
const fill = $('v3-upnext-bar-fill');
if (fill) {
let prevT = 0;
for (let i = 0; i < secs.length; i++) {
if (typeof secs[i].time === 'number' && secs[i].time <= t) prevT = secs[i].time;
else break;
}
const span = next.time - prevT;
const prog = span > 0 ? Math.max(0, Math.min(1, (t - prevT) / span)) : 0;
fill.style.width = (prog * 100).toFixed(1) + '%';
}
pill.classList.remove('hidden');
}
// ── Speed visual (bars + chevrons reflect #speed-slider) ──────────────────
function updateSpeedViz() {
const s = $('speed-slider');
if (!s) return;
const min = parseFloat(s.min) || 25, max = parseFloat(s.max) || 150;
const frac = Math.min(1, Math.max(0, (parseFloat(s.value) - min) / (max - min)));
const bars = document.querySelectorAll('#player-controls .v3-speed-bars i');
const onBars = Math.round(frac * bars.length);
bars.forEach((b, i) => b.classList.toggle('on', i < onBars));
const chev = document.querySelectorAll('#player-controls .v3-chevrons i');
const onChev = Math.round(frac * chev.length);
chev.forEach((c, i) => c.classList.toggle('on', i < onChev));
}
// ── Auto-hide transport ───────────────────────────────────────────────────
function revealChrome() {
const p = player();
if (!p) return;
p.classList.add('chrome-active');
p.classList.remove('chrome-idle');
lastMove = now();
}
function tickIdle() {
const p = player();
if (!p) return;
const playBtn = $('btn-play');
const playing = playBtn && playBtn.getAttribute('aria-pressed') === 'true';
const controls = $('player-controls');
const overControls = controls && typeof controls.matches === 'function' && controls.matches(':hover');
// Keep the transport up while paused, hovering it, or a popover is open.
if (openPop || overControls || !playing) { lastMove = now(); return; }
if (now() - lastMove > IDLE_MS) {
p.classList.remove('chrome-active');
p.classList.add('chrome-idle');
}
}
// ── rAF loop ──────────────────────────────────────────────────────────────
function loop() {
const t = now();
if (t - lastUpNext >= UPNEXT_MS) {
lastUpNext = t;
updateUpNext();
// Re-sync the lyrics icon so programmatic highway.setLyricsVisible()
// (e.g. from lyrics_karaoke) isn't left stale; cheap + idempotent.
syncLyricsIcon();
}
tickIdle();
rafId = requestAnimationFrame(loop);
}
// ── Lifecycle ───────────────────────────────────────────────────────────--
function start() {
if (running) return;
const p = player();
if (!p) return;
running = true;
wireRail();
p.addEventListener('mousemove', revealChrome);
p.addEventListener('touchstart', revealChrome, { passive: true });
const s = $('speed-slider');
if (s && !s.dataset.pcVizWired) { s.dataset.pcVizWired = '1'; s.addEventListener('input', updateSpeedViz); }
updateSpeedViz();
syncLyricsIcon();
rehomePluginControls(); // sweep controls a plugin injected before/at player open
revealChrome();
if (!rafId) loop();
}
function stop() {
if (!running) return;
running = false;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
const p = player();
if (p) {
p.removeEventListener('mousemove', revealChrome);
p.removeEventListener('touchstart', revealChrome);
p.classList.remove('chrome-active', 'chrome-idle');
}
closePop();
}
function syncActivation() {
const p = player();
if (!p) return;
if (p.classList.contains('active')) start(); else stop();
}
function init() {
const p = player();
if (!p) return;
installRehomeObserver(); // always on, so plugin injections are caught whenever they happen
try {
new MutationObserver(syncActivation).observe(p, { attributes: true, attributeFilter: ['class'] });
} catch (e) { /* MutationObserver always present in target browsers */ }
syncActivation();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();