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:
Jonathan Sykes
2026-10-02 21:11:15 +08:00
parent 985063648f
commit 53da71ceb9
4 changed files with 237 additions and 33 deletions

View File

@@ -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(); });