Redesign service-mode controls and add per-profile wrong-lyric-line reports

This commit is contained in:
Claude
2026-09-30 15:15:07 +00:00
parent 0d68e7e72f
commit 04a74ab45d
10 changed files with 1060 additions and 104 deletions

View File

@@ -3306,7 +3306,8 @@ const Notes = (() => {
// The sung line sits in the middle of the screen at the largest size that
// still fits on ONE row (measured per line with a canvas), highlighted; its
// neighbours shrink and fade with distance. Auto-scroll keeps it centred.
const sv = { key: '', idx: -2, timer: null };
const sv = { key: '', idx: -2, timer: null, flags: new Map(), flagsKey: '', flagMode: false, pressed: 0 };
const flagsEnabled = () => data.settings.lyricFlags !== false && !!window.FlagUI;
const serviceOn = () => isServiceModeOpen() && !!data.settings.serviceLyrics;
const serviceAuto = () => data.settings.serviceLyricsAutoscroll !== false;
const sungLines = () => lines().map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
@@ -3324,13 +3325,18 @@ const Notes = (() => {
function renderServiceButtons() {
const on = !!data.settings.serviceLyrics;
const lb = $('serviceLyricsBtn');
lb.textContent = on ? '🎵 Lyrics on' : '🎵 Lyrics off';
lb.classList.toggle('on', on);
lb.setAttribute('aria-pressed', String(on));
const fb = $('serviceFlagBtn');
if (fb) {
const show = on && flagsEnabled();
fb.classList.toggle('hidden', !show);
if (!show) sv.flagMode = false;
fb.setAttribute('aria-pressed', String(sv.flagMode));
$('serviceFlagHint').classList.toggle('hidden', !(show && sv.flagMode));
$('serviceLyrics').classList.toggle('flagging', show && sv.flagMode);
}
const ab = $('serviceAutoscrollBtn');
ab.classList.toggle('hidden', !on);
ab.textContent = serviceAuto() ? '⇅ Auto-scroll on' : '⇅ Auto-scroll off';
ab.classList.toggle('on', serviceAuto());
ab.setAttribute('aria-pressed', String(serviceAuto()));
}
@@ -3357,6 +3363,8 @@ const Notes = (() => {
}
list.innerHTML = items.map((l) =>
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
paintFlags();
loadFlags();
applyFits();
tickService(now(), true);
// One more fit once the view has settled (fonts, first layout).
@@ -3364,6 +3372,42 @@ const Notes = (() => {
sv.refit = setTimeout(() => { if (serviceOn()) applyFits(); }, 900);
}
// ---- reporting wrong lines (per-profile setting: settings.lyricFlags) ----
function paintFlags() {
const list = $('serviceLyricsList');
if (!list) return;
list.querySelectorAll('.sl-line').forEach((el) => {
const l = lines()[Number(el.dataset.i)];
const f = flagsEnabled() && l ? sv.flags.get(l.text) : null;
el.classList.toggle('flagged', !!f);
el.classList.toggle('mine', !!(f && f.mine));
if (f) el.dataset.n = f.count > 1 ? String(f.count) : ''; else delete el.dataset.n;
});
}
async function loadFlags() {
if (!flagsEnabled() || !st.videoId || !st.lyrics) return;
const key = `${st.videoId}|${st.lyrics.rev}|${profileName()}`;
if (key === sv.flagsKey) return;
sv.flagsKey = key;
const vid = st.videoId;
const got = await FlagUI.list(vid, profileName());
if (vid !== st.videoId) return; // moved on while the request was out
sv.flags = new Map(got.map((f) => [f.text, f]));
paintFlags();
}
async function openFlag(row) {
const l = row && lines()[Number(row.dataset.i)];
if (!l || !st.videoId) return;
if (!requireProfile()) return;
const vid = st.videoId;
const res = await FlagUI.open({ videoId: vid, text: l.text, existing: sv.flags.get(l.text), profile: profileName() });
if (!res) return;
sv.flagsKey = ''; // force a refresh from the server (counts, ids)
if (vid === st.videoId) await loadFlags();
toast(res.action === 'withdrawn' ? 'Report withdrawn' : 'Thanks — reported ✓');
if (res.action === 'saved') { sv.flagMode = false; renderServiceButtons(); }
}
function centreOn(list, el) {
const go = () => list.scrollTo({ top: Math.max(0, el.offsetTop - list.clientHeight / 2 + el.offsetHeight / 2), behavior: 'smooth' });
go();
@@ -3502,11 +3546,35 @@ const Notes = (() => {
renderServiceButtons();
if (serviceAuto()) tickService(now(), true);
});
$('serviceLyricsList').addEventListener('click', (e) => {
const row = e.target.closest('.sl-line');
const l = row && lines()[Number(row.dataset.i)];
if (l && l.t !== null && l.t !== undefined) Player.seek(Math.max(0, l.t + offset()));
$('serviceFlagBtn').addEventListener('click', () => {
sv.flagMode = !sv.flagMode;
renderServiceButtons();
});
{
// Tap = seek (or report, in report mode). Holding a line reports it too, so
// it works without switching modes.
const list = $('serviceLyricsList');
let timer = null, sx = 0, sy = 0;
const cancel = () => { clearTimeout(timer); timer = null; };
list.addEventListener('pointerdown', (e) => {
if (!flagsEnabled() || !e.target.closest('.sl-line')) return;
sx = e.clientX; sy = e.clientY;
const row = e.target.closest('.sl-line');
cancel();
timer = setTimeout(() => { timer = null; sv.pressed = Date.now(); if (navigator.vibrate) navigator.vibrate(12); openFlag(row); }, 550);
});
list.addEventListener('pointermove', (e) => { if (timer && Math.hypot(e.clientX - sx, e.clientY - sy) > 8) cancel(); });
['pointerup', 'pointercancel', 'pointerleave', 'scroll'].forEach((t) => list.addEventListener(t, cancel, { passive: true }));
list.addEventListener('contextmenu', (e) => { if (flagsEnabled()) e.preventDefault(); });
list.addEventListener('click', (e) => {
if (Date.now() - sv.pressed < 700) return; // that was the long-press
const row = e.target.closest('.sl-line');
if (!row) return;
if (sv.flagMode) { openFlag(row); return; }
const l = lines()[Number(row.dataset.i)];
if (l && l.t !== null && l.t !== undefined) Player.seek(Math.max(0, l.t + offset()));
});
}
window.addEventListener('resize', () => { if (serviceOn()) renderService(); });
// Sizes are measured with the web font; a measurement taken while it was
// still loading used the narrower fallback, so re-fit once fonts land.
@@ -3658,7 +3726,7 @@ const Notes = (() => {
});
return {
load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh,
load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh, refreshFlags: () => { sv.flagsKey = ''; renderServiceButtons(); paintFlags(); loadFlags(); },
stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn },
};
})();
@@ -6732,7 +6800,7 @@ function updateWakeStateUI() {
const el = $('serviceWakeState');
if (!el) return;
const isAwake = !!(serviceWakeLock && !serviceWakeLock.released);
el.textContent = isAwake ? 'Screen will stay awake' : 'Screen may sleep';
el.textContent = isAwake ? 'Screen stays awake' : 'Screen may sleep';
el.classList.toggle('awake', isAwake);
}
@@ -6752,6 +6820,8 @@ function updateServiceProgress(cur, dur) {
const durEl = $('serviceDurTime');
if (curEl) curEl.textContent = fmtTime(cur || 0);
if (durEl) durEl.textContent = fmtTime(dur || 0);
const fill = $('serviceProgressFill');
if (fill) fill.style.width = (dur > 0 ? Math.min(100, Math.max(0, (cur || 0) / dur * 100)) : 0).toFixed(1) + '%';
}
function updateServiceMode() {
@@ -6830,9 +6900,7 @@ function updateServiceVideoUI() {
applyServiceVideoLayout();
const btn = $('serviceVideoBtn');
if (btn) {
btn.textContent = serviceVideoOn() ? '📺 Video on' : '📺 Video off';
btn.setAttribute('aria-pressed', serviceVideoOn() ? 'true' : 'false');
btn.classList.toggle('on', serviceVideoOn());
}
}
@@ -7543,6 +7611,13 @@ async function renderSettings() {
</span>
<input id="setSaveBeforePlay" type="checkbox" ${data.settings.saveBeforePlay ? 'checked' : ''} />
</label>
<label class="set-row">
<span>
Report wrong lyric lines
<small>Service mode → Lyrics shows a Report button, and holding a line reports it. Saved with your online profile, so it follows you between devices.</small>
</span>
<input id="setLyricFlags" type="checkbox" ${data.settings.lyricFlags !== false ? 'checked' : ''} />
</label>
</div>
<div class="set-group">
@@ -7737,6 +7812,11 @@ async function renderSettings() {
data.settings.saveBeforePlay = e.target.checked;
persist();
});
$('setLyricFlags').addEventListener('change', (e) => {
data.settings.lyricFlags = e.target.checked;
persist();
Notes.refreshFlags();
});
$('setAutoPreload').addEventListener('change', (e) => {
data.settings.autoPreload = e.target.checked;
persist();