Service mode: one view button (default, lyrics, video, visualizer, album art) and a text-size box
- A single dock button cycles the service view; video is skipped in audio-only mode - Visualizer reacts to the audio where the EQ graph is available and falls back to an ambient pulse on iOS so background playback is never routed through Web Audio - Album art shows the song artwork with the letterbox cropped - A-/A+ box steps lyrics, title and channel size from -2 to +2 in every theme - Glass Stage dock is slightly more compact
This commit is contained in:
197
frontend/app.js
197
frontend/app.js
@@ -3593,8 +3593,7 @@ const Notes = (() => {
|
||||
|
||||
function renderServiceButtons() {
|
||||
const on = !!data.settings.serviceLyrics;
|
||||
const lb = $('serviceLyricsBtn');
|
||||
lb.setAttribute('aria-pressed', String(on));
|
||||
updateServiceViewBtn();
|
||||
const fb = $('serviceFlagBtn');
|
||||
if (fb) {
|
||||
const show = on && flagsEnabled();
|
||||
@@ -3797,16 +3796,6 @@ const Notes = (() => {
|
||||
window.addEventListener('resize', () => { if (stageOn()) renderStage(true); });
|
||||
}
|
||||
|
||||
$('serviceLyricsBtn').addEventListener('click', () => {
|
||||
data.settings.serviceLyrics = !data.settings.serviceLyrics;
|
||||
// Lyrics fill the screen; the inline video panel would squeeze them.
|
||||
if (data.settings.serviceLyrics && data.settings.serviceVideo) {
|
||||
data.settings.serviceVideo = false;
|
||||
updateServiceVideoUI();
|
||||
}
|
||||
persist();
|
||||
renderService(true);
|
||||
});
|
||||
$('serviceAutoscrollBtn').addEventListener('click', () => {
|
||||
data.settings.serviceLyricsAutoscroll = !serviceAuto();
|
||||
persist();
|
||||
@@ -5941,7 +5930,24 @@ const EQ = (() => {
|
||||
|
||||
// A context created before any user gesture starts suspended.
|
||||
document.addEventListener('pointerdown', () => { if (st.ctx && st.ctx.state === 'suspended') st.ctx.resume().catch(() => {}); }, true);
|
||||
return { open, apply, renderButton, available, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null }) };
|
||||
// Tap the same graph for the service-mode visualizer. Wiring is the same
|
||||
// one-way step the EQ takes, so it is only offered where the EQ is (never
|
||||
// on iOS, where Web Audio would silence background playback).
|
||||
function analyser() {
|
||||
if (!available() || !build()) return null;
|
||||
attach(els.video);
|
||||
attach(els.audio);
|
||||
if (!st.analyser) {
|
||||
st.analyser = st.ctx.createAnalyser();
|
||||
st.analyser.fftSize = 128;
|
||||
st.analyser.smoothingTimeConstant = 0.78;
|
||||
st.filters[st.filters.length - 1].connect(st.analyser);
|
||||
}
|
||||
if (st.ctx.state === 'suspended') st.ctx.resume().catch(() => {});
|
||||
return st.analyser;
|
||||
}
|
||||
|
||||
return { open, apply, renderButton, available, analyser, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null }) };
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
@@ -7245,6 +7251,7 @@ function updateServiceMode() {
|
||||
}
|
||||
|
||||
updateServiceUpNext();
|
||||
ServiceVisual.update();
|
||||
|
||||
const cur = Player.master ? (Player.master.currentTime || 0) : 0;
|
||||
const dur = Player.master ? (Player.master.duration || (meta ? meta.duration : 0) || 0) : 0;
|
||||
@@ -7292,24 +7299,157 @@ function applyServiceVideoLayout() {
|
||||
|
||||
function updateServiceVideoUI() {
|
||||
applyServiceVideoLayout();
|
||||
const btn = $('serviceVideoBtn');
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-pressed', serviceVideoOn() ? 'true' : 'false');
|
||||
}
|
||||
updateServiceViewBtn();
|
||||
}
|
||||
|
||||
function toggleServiceVideo() {
|
||||
if (data.settings.audioOnly) {
|
||||
toast('Audio-only mode is on — turn it off in Settings to show video');
|
||||
return;
|
||||
}
|
||||
data.settings.serviceVideo = !data.settings.serviceVideo;
|
||||
// Video and lyrics-only share the screen; turning one on turns the other off.
|
||||
if (data.settings.serviceVideo && data.settings.serviceLyrics) data.settings.serviceLyrics = false;
|
||||
// ---------- Service view: one button cycles Default → Lyrics → Video → Visualizer → Album art ----------
|
||||
const SERVICE_VIEWS = [
|
||||
{ id: 'default', label: 'Default', icon: '<rect x="4" y="5" width="16" height="14" rx="3"/><path d="M8 10h8M8 14h5"/>' },
|
||||
{ id: 'lyrics', label: 'Lyrics', icon: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>' },
|
||||
{ id: 'video', label: 'Video', icon: '<rect x="3" y="6" width="13" height="12" rx="2.5"/><path d="M16 10.5l5-3v9l-5-3"/>' },
|
||||
{ id: 'visual', label: 'Visualizer', icon: '<path d="M4 14v4M8 9v9M12 5v13M16 10v8M20 13v5"/>' },
|
||||
{ id: 'art', label: 'Album art', icon: '<rect x="4" y="4" width="16" height="16" rx="3"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r="0.8"/>' },
|
||||
];
|
||||
function serviceView() {
|
||||
if (data.settings.serviceLyrics) return 'lyrics';
|
||||
if (serviceVideoOn()) return 'video';
|
||||
const v = data.settings.serviceView;
|
||||
return v === 'visual' || v === 'art' ? v : 'default';
|
||||
}
|
||||
function updateServiceViewBtn() {
|
||||
const btn = $('serviceViewBtn');
|
||||
if (!btn) return;
|
||||
const v = SERVICE_VIEWS.find((x) => x.id === serviceView()) || SERVICE_VIEWS[0];
|
||||
btn.querySelector('.svc-ico').innerHTML = v.icon;
|
||||
btn.querySelector('.svc-label').textContent = v.label;
|
||||
btn.setAttribute('aria-pressed', String(v.id !== 'default'));
|
||||
btn.setAttribute('aria-label', `Service view: ${v.label} — tap to change`);
|
||||
btn.title = `View: ${v.label} (tap for the next view)`;
|
||||
}
|
||||
function setServiceView(id) {
|
||||
data.settings.serviceLyrics = id === 'lyrics';
|
||||
data.settings.serviceVideo = id === 'video';
|
||||
data.settings.serviceView = id;
|
||||
persist();
|
||||
updateServiceVideoUI();
|
||||
Notes.serviceRefresh();
|
||||
updateServiceViewBtn();
|
||||
ServiceVisual.update();
|
||||
}
|
||||
function cycleServiceView() {
|
||||
const order = SERVICE_VIEWS.map((v) => v.id).filter((id) => id !== 'video' || !data.settings.audioOnly);
|
||||
const i = order.indexOf(serviceView());
|
||||
const next = order[(i + 1) % order.length];
|
||||
setServiceView(next);
|
||||
toast(SERVICE_VIEWS.find((v) => v.id === next).label, { duration: 1200 });
|
||||
}
|
||||
|
||||
// ---------- Service text size: −2 … +2 steps, in every theme ----------
|
||||
const SERVICE_FONT_SCALES = { '-2': 0.72, '-1': 0.86, '0': 1, '1': 1.16, '2': 1.32 };
|
||||
function serviceFontStep() {
|
||||
const n = Math.round(Number(data.settings.serviceFontStep) || 0);
|
||||
return Math.max(-2, Math.min(2, n));
|
||||
}
|
||||
function applyServiceFont() {
|
||||
const step = serviceFontStep();
|
||||
const el = $('serviceMode');
|
||||
if (el) el.style.setProperty('--svc-fs', String(SERVICE_FONT_SCALES[step]));
|
||||
const val = $('svcFontVal');
|
||||
if (val) val.textContent = step > 0 ? '+' + step : String(step);
|
||||
const down = $('svcFontDown'), up = $('svcFontUp');
|
||||
if (down) down.disabled = step <= -2;
|
||||
if (up) up.disabled = step >= 2;
|
||||
}
|
||||
function stepServiceFont(d) {
|
||||
data.settings.serviceFontStep = Math.max(-2, Math.min(2, serviceFontStep() + d));
|
||||
persist();
|
||||
applyServiceFont();
|
||||
}
|
||||
|
||||
// ---------- Album art + visualizer ----------
|
||||
const ServiceVisual = (() => {
|
||||
let raf = 0, an = null, buf = null, lastId = '';
|
||||
const reduced = () => window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
function update() {
|
||||
const box = $('serviceVisual');
|
||||
if (!box) return;
|
||||
const v = isServiceModeOpen() ? serviceView() : 'default';
|
||||
const on = v === 'visual' || v === 'art';
|
||||
box.classList.toggle('hidden', !on);
|
||||
$('serviceMode').classList.toggle('visual-on', on);
|
||||
box.dataset.view = on ? v : '';
|
||||
const meta = current ? current.meta : null;
|
||||
const img = $('serviceArt');
|
||||
if (on && meta && meta.id !== lastId) {
|
||||
lastId = meta.id;
|
||||
const src = thumbUrlFor(meta.id, meta);
|
||||
img.style.visibility = src ? '' : 'hidden';
|
||||
// hqdefault is 4:3 with the 16:9 frame letterboxed inside; .yt-art zooms the bars away.
|
||||
const big = src ? src.replace('/mqdefault.jpg', '/hqdefault.jpg') : '';
|
||||
img.classList.toggle('yt-art', /ytimg\.com\/vi(_webp)?\/[^/]+\/(hq|sd)default\.(jpg|webp)/.test(big));
|
||||
if (big) img.src = big;
|
||||
img.onerror = () => { if (src && img.src !== new URL(src, location.href).href) { img.classList.remove('yt-art'); img.src = src; } };
|
||||
box.style.setProperty('--art', src ? `url("${src}")` : 'none');
|
||||
}
|
||||
if (v === 'visual') start(); else stop();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (raf) return;
|
||||
an = EQ.analyser ? EQ.analyser() : null;
|
||||
buf = an ? new Uint8Array(an.frequencyBinCount) : null;
|
||||
const tick = (t) => { draw(t); raf = requestAnimationFrame(tick); };
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
function stop() {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
|
||||
function draw(t) {
|
||||
const c = $('serviceViz');
|
||||
if (!c) return;
|
||||
const w = c.clientWidth, h = c.clientHeight;
|
||||
if (!w || !h) return;
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); }
|
||||
const g = c.getContext('2d');
|
||||
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
g.clearRect(0, 0, w, h);
|
||||
const bars = 32;
|
||||
const playing = Player.master && !Player.master.paused;
|
||||
if (an && buf) an.getByteFrequencyData(buf);
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const c1 = css.getPropertyValue('--accent-bright').trim() || '#ff7a5c';
|
||||
const c2 = css.getPropertyValue('--accent-deep').trim() || '#d6362a';
|
||||
const grad = g.createLinearGradient(0, h, 0, 0);
|
||||
grad.addColorStop(0, c2); grad.addColorStop(1, c1);
|
||||
g.fillStyle = grad;
|
||||
const gap = Math.max(3, w / bars * 0.28);
|
||||
const bw = (w - gap * (bars - 1)) / bars;
|
||||
for (let i = 0; i < bars; i++) {
|
||||
let v;
|
||||
if (an && buf) {
|
||||
// Log-ish spread over the lower bins, where the music lives.
|
||||
const idx = Math.min(buf.length - 1, Math.floor(Math.pow(i / bars, 1.6) * buf.length * 0.85));
|
||||
v = buf[idx] / 255;
|
||||
} else {
|
||||
// iOS / no Web Audio: an ambient pulse that follows play/pause, not the audio.
|
||||
const s = reduced() ? 0 : t / 1000;
|
||||
v = playing ? 0.25 + 0.55 * Math.abs(Math.sin(s * 1.7 + i * 0.55) * Math.cos(s * 0.9 + i * 0.21)) : 0.06;
|
||||
}
|
||||
const bh = Math.max(4, v * h * 0.92);
|
||||
const x = i * (bw + gap);
|
||||
const r = Math.min(bw / 2, 8);
|
||||
g.beginPath();
|
||||
if (g.roundRect) g.roundRect(x, h - bh, bw, bh, r); else g.rect(x, h - bh, bw, bh);
|
||||
g.fill();
|
||||
}
|
||||
}
|
||||
|
||||
return { update, stop };
|
||||
})();
|
||||
|
||||
function enterServiceMode(pl) {
|
||||
if (pl && pl.videos && pl.videos.length) {
|
||||
@@ -7332,8 +7472,10 @@ function enterServiceMode(pl) {
|
||||
// (see .service-open in styles.css).
|
||||
document.body.classList.add('service-open');
|
||||
updateServiceVideoUI();
|
||||
applyServiceFont();
|
||||
updateServiceMode();
|
||||
Notes.serviceRefresh();
|
||||
ServiceVisual.update();
|
||||
acquireWakeLock();
|
||||
}
|
||||
|
||||
@@ -7344,6 +7486,7 @@ function exitServiceMode() {
|
||||
// Runs after the overlay is hidden, so this clears the pinned video styles
|
||||
// and returns .player-stage to the player pane.
|
||||
updateServiceVideoUI();
|
||||
ServiceVisual.update();
|
||||
releaseWakeLock();
|
||||
}
|
||||
|
||||
@@ -10128,7 +10271,9 @@ document.querySelectorAll('.chip').forEach((c) => {
|
||||
$('serviceNextBtn').addEventListener('click', playNext);
|
||||
$('servicePrevBtn').addEventListener('click', playPrev);
|
||||
$('serviceExitBtn').addEventListener('click', exitServiceMode);
|
||||
$('serviceVideoBtn').addEventListener('click', toggleServiceVideo);
|
||||
$('serviceViewBtn').addEventListener('click', cycleServiceView);
|
||||
$('svcFontDown').addEventListener('click', () => stepServiceFont(-1));
|
||||
$('svcFontUp').addEventListener('click', () => stepServiceFont(1));
|
||||
|
||||
// Mini now-playing bar
|
||||
$('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); });
|
||||
|
||||
Reference in New Issue
Block a user