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

View File

@@ -409,9 +409,9 @@
</button>
</div>
<div class="svc-dock" role="toolbar" aria-label="Service view">
<button id="serviceLyricsBtn" class="svc-tile" type="button" aria-pressed="false" aria-label="Show only the lyrics">
<svg class="svc-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg>
<span class="svc-label">Lyrics</span><i class="svc-led"></i>
<button id="serviceViewBtn" class="svc-tile svc-view" type="button" aria-label="Service view: Default — tap to change">
<svg class="svc-ico" viewBox="0 0 24 24" aria-hidden="true"></svg>
<span class="svc-label">Default</span><i class="svc-led"></i>
</button>
<button id="serviceAutoscrollBtn" class="svc-tile hidden" type="button" aria-pressed="true" aria-label="Keep the sung line centred">
<svg class="svc-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v16M7 9l5-5 5 5M7 15l5 5 5-5"/></svg>
@@ -421,10 +421,11 @@
<svg class="svc-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 21V4M5 4h11l-2 4 2 4H5"/></svg>
<span class="svc-label">Report</span><i class="svc-led"></i>
</button>
<button id="serviceVideoBtn" class="svc-tile" type="button" aria-pressed="false" aria-label="Show or hide the video">
<svg class="svc-ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="13" height="12" rx="2.5"/><path d="M16 10.5l5-3v9l-5-3"/></svg>
<span class="svc-label">Video</span><i class="svc-led"></i>
</button>
<div class="svc-font" role="group" aria-label="Lyrics text size">
<button id="svcFontDown" type="button" aria-label="Smaller text">A−</button>
<span id="svcFontVal" aria-live="polite">0</span>
<button id="svcFontUp" type="button" aria-label="Larger text">A+</button>
</div>
</div>
<div id="serviceFlagHint" class="svc-hint hidden" role="status">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 21V4M5 4h11l-2 4 2 4H5"/></svg>
@@ -437,6 +438,11 @@
<div id="serviceChannel" class="service-channel"></div>
<div id="serviceNote" class="service-note hidden"></div>
</div>
<!-- Album art / visualizer views -->
<div id="serviceVisual" class="service-visual hidden" aria-hidden="true">
<img id="serviceArt" class="svc-art" alt="" decoding="async" />
<canvas id="serviceViz" class="svc-viz"></canvas>
</div>
<div id="serviceUpNext" class="service-upnext">Last song</div>
<!-- Lyrics-only view: the sung line is centred, largest and highlighted -->
<div id="serviceLyrics" class="service-lyrics hidden" aria-live="off">

View File

@@ -405,3 +405,49 @@
.svc-tile, .svc-hint, .fs-backdrop, .fs-sheet { animation: none !important; }
.svc-tile, .svc-tile .svc-ico, .svc-progress > i { transition: none !important; }
}
/* ---- one view button + text-size box (all themes) ---- */
.svc-dock { display: flex; }
.svc-dock > .svc-tile { flex: 1 1 0; min-width: 0; }
.svc-dock > .svc-tile.hidden { display: none; }
.svc-font {
flex: 0 0 auto; align-self: center;
display: flex; align-items: center; gap: 2px;
height: 40px; padding: 2px; border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.svc-font button {
width: 32px; height: 34px; padding: 0; border: 0; border-radius: 9px;
background: transparent; color: var(--svc-ink-2, #ccc);
font: 800 13px/1 var(--ui, system-ui, sans-serif); cursor: pointer;
touch-action: manipulation; -webkit-tap-highlight-color: transparent;
}
.svc-font button:hover:not(:disabled) { color: var(--svc-ink, #fff); background: rgba(255, 255, 255, 0.07); }
.svc-font button:disabled { opacity: 0.3; cursor: default; }
.svc-font span { min-width: 22px; text-align: center; font: 700 12px/1 var(--mono, ui-monospace, monospace); color: var(--svc-ink, #fff); }
/* text size scales lyrics, title and channel */
#serviceMode .service-title { font-size: calc(clamp(28px, 5.5vw, 56px) * var(--svc-fs, 1)); }
#serviceMode .service-channel { font-size: calc(1em * var(--svc-fs, 1)); }
#serviceMode .service-lyrics .sl-line { font-size: calc(max(22px, calc(var(--fit, 40px) * 0.48)) * var(--svc-fs, 1)); }
#serviceMode .service-lyrics .sl-line.near { font-size: calc(max(26px, calc(var(--fit, 40px) * 0.6)) * var(--svc-fs, 1)); }
#serviceMode .service-lyrics .sl-line.active { font-size: calc(max(30px, var(--fit, 40px)) * var(--svc-fs, 1)); }
#serviceMode .service-lyrics .sl-list.plain .sl-line { font-size: calc(max(30px, var(--fit, 40px)) * var(--svc-fs, 1)); }
/* album art / visualizer stage */
.service-visual {
position: relative; width: min(78vw, 52vh, 460px); aspect-ratio: 1;
margin: 0 auto 22px; border-radius: 26px; overflow: hidden; flex: none;
box-shadow: 0 30px 60px -24px rgba(0, 0, 0, 0.9);
}
.service-visual::before {
content: ""; position: absolute; inset: -20%;
background: var(--art, none) center / cover; filter: blur(34px) saturate(140%); opacity: 0.55;
}
.service-visual .svc-art { position: relative; width: 100%; height: 100%; object-fit: cover; display: block; }
.service-visual .svc-viz { position: absolute; left: 6%; right: 6%; bottom: 6%; width: 88%; height: 58%; display: none; }
.service-visual[data-view="visual"] .svc-art { opacity: 0.28; filter: blur(2px); }
.service-visual[data-view="visual"] .svc-viz { display: block; }
.service-mode.visual-on .service-title { font-size: calc(clamp(24px, 4.4vw, 44px) * var(--svc-fs, 1)); }
.service-visual .svc-art.yt-art { transform: scale(1.34); }

View File

@@ -373,3 +373,10 @@ html[data-layout="glass-stage"] #nowPlayingMeta .np-channel:focus-visible { outl
/* Playlist title row takes the old title slot in the glass header grid */
html[data-layout="glass-stage"] .list-header .list-title-row { grid-column: 1 / 5; }
html[data-layout="glass-stage"] .list-icon-btn { background: var(--tile); border-color: var(--glass-line); }
/* Service dock: a touch more compact in Glass Stage */
html[data-layout="glass-stage"] .svc-dock { gap: 4px; padding: 5px; border-radius: 20px; }
html[data-layout="glass-stage"] .svc-tile { min-height: 58px; padding: 8px 4px 7px; gap: 5px; border-radius: 15px; font-size: 10.5px; }
html[data-layout="glass-stage"] .svc-tile .svc-ico { width: 20px; height: 20px; }
html[data-layout="glass-stage"] .svc-font { height: 36px; }
html[data-layout="glass-stage"] .svc-font button { width: 28px; height: 30px; }