From 04a74ab45da4647669d56615e626aec33de6dc07 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 15:15:07 +0000 Subject: [PATCH] Redesign service-mode controls and add per-profile wrong-lyric-line reports --- frontend/app.js | 106 +++++++++-- frontend/flag-ui.js | 120 ++++++++++++ frontend/index.html | 41 +++- frontend/service-ui.css | 407 ++++++++++++++++++++++++++++++++++++++++ frontend/styles.css | 80 +------- frontend/sw.js | 2 + server/db.js | 118 ++++++++++++ server/flags.test.js | 158 ++++++++++++++++ server/notes.js | 130 ++++++++++++- server/package.json | 2 +- 10 files changed, 1060 insertions(+), 104 deletions(-) create mode 100644 frontend/flag-ui.js create mode 100644 frontend/service-ui.css create mode 100644 server/flags.test.js diff --git a/frontend/app.js b/frontend/app.js index 0431fd0..68628de 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -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) => `
${escapeHtml(l.text)}
`).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() { +
@@ -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(); diff --git a/frontend/flag-ui.js b/frontend/flag-ui.js new file mode 100644 index 0000000..1eaf595 --- /dev/null +++ b/frontend/flag-ui.js @@ -0,0 +1,120 @@ +/* Report-a-wrong-lyric-line: API client + bottom sheet. No dependencies. + * window.FlagUI.open({videoId, text, existing, profile}) -> Promise + * result = {action:'saved'|'withdrawn', id?, reason, note} + * Descriptions are optional; a bare report is valid. */ +(function () { + 'use strict'; + const REASONS = [ + ['words', 'Wrong words'], ['timing', 'Wrong timing'], + ['typo', 'Typo'], ['other', 'Other'], + ]; + const NOTE_MAX = 500; + const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const base = (id) => `/api/notes/${encodeURIComponent(id)}/flags`; + const jpost = (url, body) => fetch(url, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }); + + async function list(videoId, profile) { + try { + const r = await fetch(base(videoId), { headers: profile ? { 'X-Profile': profile } : {} }); + if (!r.ok) return []; + const j = await r.json(); + return Array.isArray(j.flags) ? j.flags : []; + } catch { return []; } + } + async function submit(videoId, profile, text, reason, note) { + const r = await jpost(base(videoId), { profile, text, reason, note }); + const j = await r.json().catch(() => ({})); + return { status: r.status, ...j }; + } + async function withdraw(videoId, profile, id) { + const r = await jpost(`${base(videoId)}/${id}/withdraw`, { profile }); + return r.ok; + } + + const ICON = ''; + + function open({ videoId, text, existing, profile }) { + return new Promise((resolve) => { + const mine = existing && existing.mine ? existing : null; + const others = existing ? Math.max(0, (existing.count || 0) - (mine ? 1 : 0)) : 0; + let reason = mine ? mine.reason || '' : ''; + const back = document.createElement('div'); + back.className = 'fs-backdrop'; + back.innerHTML = ` + `; + const $ = (s) => back.querySelector(s); + const ta = $('.fs-note'); + ta.value = mine ? mine.note || '' : ''; + const count = () => { $('.fs-n').textContent = ta.value.length; }; + count(); + ta.addEventListener('input', count); + const prevFocus = document.activeElement; + let done = false; + const close = (val) => { + if (done) return; + done = true; + document.removeEventListener('keydown', onKey, true); + back.classList.add('closing'); + setTimeout(() => { back.remove(); if (prevFocus && prevFocus.focus) try { prevFocus.focus(); } catch {} }, 190); + resolve(val); + }; + const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(null); } }; + document.addEventListener('keydown', onKey, true); + back.addEventListener('click', (e) => { if (e.target === back) close(null); }); + $('.fs-x').onclick = $('.fs-cancel').onclick = () => close(null); + back.querySelectorAll('.fs-chip').forEach((b) => b.addEventListener('click', () => { + reason = reason === b.dataset.r ? '' : b.dataset.r; // tap again to clear + back.querySelectorAll('.fs-chip').forEach((c) => c.setAttribute('aria-pressed', String(c.dataset.r === reason))); + })); + const fail = (msg) => { const e = $('.fs-err'); e.textContent = msg; e.hidden = false; }; + const busy = (on) => back.querySelectorAll('.fs-btn').forEach((b) => { b.disabled = on; }); + $('.fs-send').onclick = async () => { + busy(true); + try { + const r = await submit(videoId, profile, text, reason, ta.value.trim()); + if (r.ok) return close({ action: 'saved', id: r.id, reason, note: ta.value.trim() }); + busy(false); + fail(r.stale ? 'This line was just changed β€” it may already be fixed.' + : r.status === 401 ? 'Link an online profile to report lines.' + : r.status === 429 ? 'Slow down a little β€” too many reports.' : (r.error || 'Could not send the report.')); + } catch { busy(false); fail('You appear to be offline.'); } + }; + const w = $('.fs-withdraw'); + if (w) w.onclick = async () => { + busy(true); + try { + if (await withdraw(videoId, profile, mine.id)) return close({ action: 'withdrawn' }); + } catch {} + busy(false); fail('Could not withdraw β€” try again.'); + }; + document.body.appendChild(back); + setTimeout(() => ta.blur(), 0); + $('.fs-send').focus(); + }); + } + + window.FlagUI = { open, list, submit, withdraw, REASONS }; +})(); diff --git a/frontend/index.html b/frontend/index.html index e1d6581..beb4aee 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,6 +21,7 @@ +
@@ -358,16 +359,34 @@