Add deck tabs, uniform action buttons, lyric reports in the player panel and a song list in the lyrics studio

This commit is contained in:
Claude
2026-09-30 15:57:11 +00:00
parent e64880c163
commit 9d74769029
4 changed files with 160 additions and 10 deletions

View File

@@ -118,6 +118,13 @@
.step { padding: 14px; border-radius: 16px; background: rgba(255,255,255,.035); border: 1px solid var(--line); } .step { padding: 14px; border-radius: 16px; background: rgba(255,255,255,.035); border: 1px solid var(--line); }
.step b { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: var(--grad); color: #fff; font-size: 13px; margin-bottom: 8px; } .step b { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: var(--grad); color: #fff; font-size: 13px; margin-bottom: 8px; }
.step h3 { margin: 0 0 2px; font-size: 14px; } .step p { margin: 0; font-size: 12.5px; color: var(--text-dim); } .step h3 { margin: 0 0 2px; font-size: 14px; } .step p { margin: 0; font-size: 12.5px; color: var(--text-dim); }
.songs { display: grid; gap: 6px; margin-top: 14px; max-height: 46vh; overflow-y: auto; }
.song-row { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 10px 12px; border-radius: 14px; border: 1px solid var(--line); background: rgba(255,255,255,.03); }
.song-row:hover { border-color: var(--a1); }
.song-row b { display: block; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.song-row small { color: var(--text-dim); font-size: 12px; }
.song-row .st { flex: none; font: 700 11px var(--ui); padding: 3px 9px; border-radius: 99px; border: 1px solid var(--line-2); color: var(--text-dim); }
.song-row .st.has { color: var(--ok); border-color: rgba(74,222,128,.4); }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } .chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.chip { height: 34px; padding: 0 13px; border-radius: 99px; border: 1px solid var(--line-2); background: rgba(255,255,255,.04); font-size: 13px; font-weight: 600; color: var(--text-2); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .chip { height: 34px; padding: 0 13px; border-radius: 99px; border: 1px solid var(--line-2); background: rgba(255,255,255,.04); font-size: 13px; font-weight: 600; color: var(--text-2); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip:hover { color: var(--text); border-color: var(--a1); } .chip:hover { color: var(--text); border-color: var(--a1); }
@@ -284,6 +291,10 @@
</form> </form>
<div id="loadMsg" class="msg"></div> <div id="loadMsg" class="msg"></div>
<div id="recentChips" class="chips"></div> <div id="recentChips" class="chips"></div>
<div class="row" style="margin-top:16px"><h2 style="margin:0;font-size:14px">Songs on the server</h2><span class="grow"></span>
<input class="field" id="songFilter" type="search" placeholder="Filter…" style="flex:0 1 200px;height:36px" /></div>
<p class="muted" style="margin:4px 0 0;font-size:12.5px">Downloaded videos and uploads. Songs that already have lyrics can be corrected here too.</p>
<div id="songList" class="songs"><div class="muted" style="padding:10px">Loading…</div></div>
<div class="steps"> <div class="steps">
<div class="step"><b>1</b><h3>Open a song</h3><p>Paste a link or pick one from your library.</p></div> <div class="step"><b>1</b><h3>Open a song</h3><p>Paste a link or pick one from your library.</p></div>
<div class="step"><b>2</b><h3>Play &amp; tap</h3><p>Switch to Tap-sync and hit the big button on each line as it is sung.</p></div> <div class="step"><b>2</b><h3>Play &amp; tap</h3><p>Switch to Tap-sync and hit the big button on each line as it is sung.</p></div>
@@ -513,6 +524,7 @@
if (!$('tabsTop').children.length) buildTabs(); if (!$('tabsTop').children.length) buildTabs();
renderDocs(); renderDocs();
loadRecent(); loadRecent();
loadSongs();
refreshBadge(); refreshBadge();
const want = videoIdFrom(new URLSearchParams(location.search).get('v') || ''); const want = videoIdFrom(new URLSearchParams(location.search).get('v') || '');
const hash = location.hash.replace('#', ''); const hash = location.hash.replace('#', '');
@@ -656,6 +668,25 @@
const j = await api('/api/uploads?limit=10'); const j = await api('/api/uploads?limit=10');
$('recentChips').innerHTML = (j.uploads || []).map((u) => `<button type="button" class="chip" data-open="${esc(u.id)}">♪ ${esc(u.title)}</button>`).join(''); $('recentChips').innerHTML = (j.uploads || []).map((u) => `<button type="button" class="chip" data-open="${esc(u.id)}">♪ ${esc(u.title)}</button>`).join('');
} }
let SONGS = [];
async function loadSongs() {
const [m, u] = await Promise.all([api('/api/admin/media'), api('/api/uploads?limit=200')]);
const seen = new Set();
SONGS = [];
for (const r of (m.media || [])) { seen.add(r.id); SONGS.push({ id: r.id, title: r.title || r.id, sub: r.channel, lines: r.lyricsLines || 0 }); }
for (const r of (u.uploads || [])) if (!seen.has(r.id)) SONGS.push({ id: r.id, title: r.title, sub: r.artist || 'Upload', lines: -1 });
paintSongs();
}
function paintSongs() {
const q = $('songFilter').value.trim().toLowerCase();
const rows = SONGS.filter((r) => !q || (r.title + ' ' + (r.sub || '')).toLowerCase().includes(q));
$('songList').innerHTML = rows.length ? rows.map((r) => `<button type="button" class="song-row" data-open="${esc(r.id)}">
<span style="min-width:0;flex:1"><b>${esc(r.title)}</b><small>${esc(r.sub || '')}</small></span>
<span class="st ${r.lines > 0 ? 'has' : ''}">${r.lines > 0 ? `${r.lines} lines` : r.lines === 0 ? 'no lyrics' : 'open'}</span></button>`).join('')
: '<div class="muted" style="padding:10px">Nothing matches — paste a link above to open any YouTube song.</div>';
}
$('songFilter').addEventListener('input', paintSongs);
$('songList').addEventListener('click', (e) => { const b = e.target.closest('[data-open]'); if (b) leOpen(b.dataset.open); });
$('recentChips').addEventListener('click', (e) => { const b = e.target.closest('[data-open]'); if (b) leOpen(b.dataset.open); }); $('recentChips').addEventListener('click', (e) => { const b = e.target.closest('[data-open]'); if (b) leOpen(b.dataset.open); });
/* ---------------- p2p ---------------- */ /* ---------------- p2p ---------------- */

View File

@@ -2770,6 +2770,9 @@ const Notes = (() => {
}).join(''); }).join('');
if (st.sync) renderSyncPointer(); if (st.sync) renderSyncPointer();
else tick(now(), true); else tick(now(), true);
paintFlags();
loadFlags();
setPanelFlagMode(sv.panelFlag && flagsEnabled());
} }
function renderOffset() { function renderOffset() {
@@ -3306,7 +3309,7 @@ const Notes = (() => {
// The sung line sits in the middle of the screen at the largest size that // 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 // still fits on ONE row (measured per line with a canvas), highlighted; its
// neighbours shrink and fade with distance. Auto-scroll keeps it centred. // neighbours shrink and fade with distance. Auto-scroll keeps it centred.
const sv = { key: '', idx: -2, timer: null, flags: new Map(), flagsKey: '', flagMode: false, pressed: 0 }; const sv = { key: '', idx: -2, timer: null, flags: new Map(), flagsKey: '', flagMode: false, panelFlag: false, pressed: 0 };
const flagsEnabled = () => data.settings.lyricFlags !== false && !!window.FlagUI; const flagsEnabled = () => data.settings.lyricFlags !== false && !!window.FlagUI;
const serviceOn = () => isServiceModeOpen() && !!data.settings.serviceLyrics; const serviceOn = () => isServiceModeOpen() && !!data.settings.serviceLyrics;
const serviceAuto = () => data.settings.serviceLyricsAutoscroll !== false; const serviceAuto = () => data.settings.serviceLyricsAutoscroll !== false;
@@ -3374,9 +3377,7 @@ const Notes = (() => {
// ---- reporting wrong lines (per-profile setting: settings.lyricFlags) ---- // ---- reporting wrong lines (per-profile setting: settings.lyricFlags) ----
function paintFlags() { function paintFlags() {
const list = $('serviceLyricsList'); document.querySelectorAll('#serviceLyricsList .sl-line, #lyricsList .lyr-line').forEach((el) => {
if (!list) return;
list.querySelectorAll('.sl-line').forEach((el) => {
const l = lines()[Number(el.dataset.i)]; const l = lines()[Number(el.dataset.i)];
const f = flagsEnabled() && l ? sv.flags.get(l.text) : null; const f = flagsEnabled() && l ? sv.flags.get(l.text) : null;
el.classList.toggle('flagged', !!f); el.classList.toggle('flagged', !!f);
@@ -3405,7 +3406,7 @@ const Notes = (() => {
sv.flagsKey = ''; // force a refresh from the server (counts, ids) sv.flagsKey = ''; // force a refresh from the server (counts, ids)
if (vid === st.videoId) await loadFlags(); if (vid === st.videoId) await loadFlags();
toast(res.action === 'withdrawn' ? 'Report withdrawn' : 'Thanks — reported ✓'); toast(res.action === 'withdrawn' ? 'Report withdrawn' : 'Thanks — reported ✓');
if (res.action === 'saved') { sv.flagMode = false; renderServiceButtons(); } if (res.action === 'saved') { sv.flagMode = false; renderServiceButtons(); setPanelFlagMode(false); }
} }
function centreOn(list, el) { function centreOn(list, el) {
@@ -3677,6 +3678,31 @@ const Notes = (() => {
renderOffset(); renderOffset();
tick(now(), true); tick(now(), true);
}; };
function setPanelFlagMode(on) {
sv.panelFlag = !!on;
const b = $('lyricsFlagBtn');
if (b) {
b.setAttribute('aria-pressed', String(sv.panelFlag));
b.classList.toggle('hidden', !flagsEnabled());
b.querySelector('span').textContent = sv.panelFlag ? 'Tap the line that is wrong…' : 'Report a wrong line';
}
$('lyricsList').classList.toggle('flagging', sv.panelFlag);
}
if ($('lyricsFlagBtn')) $('lyricsFlagBtn').addEventListener('click', () => setPanelFlagMode(!sv.panelFlag));
{
// Hold a lyric line to report it without switching modes.
const pl = $('lyricsList');
let timer = null, sx = 0, sy = 0;
const cancel = () => { clearTimeout(timer); timer = null; };
pl.addEventListener('pointerdown', (e) => {
const row = e.target.closest('.lyr-line');
if (!row || !flagsEnabled() || st.sync) return;
sx = e.clientX; sy = e.clientY; cancel();
timer = setTimeout(() => { timer = null; sv.pressed = Date.now(); if (navigator.vibrate) navigator.vibrate(12); openFlag(row); }, 550);
});
pl.addEventListener('pointermove', (e) => { if (timer && Math.hypot(e.clientX - sx, e.clientY - sy) > 8) cancel(); });
['pointerup', 'pointercancel', 'pointerleave', 'scroll'].forEach((t) => pl.addEventListener(t, cancel, { passive: true }));
}
$('lyrOffMinus').addEventListener('click', () => nudge(-0.5)); $('lyrOffMinus').addEventListener('click', () => nudge(-0.5));
$('lyrOffPlus').addEventListener('click', () => nudge(0.5)); $('lyrOffPlus').addEventListener('click', () => nudge(0.5));
$('lyrAutoscroll').addEventListener('change', (e) => { $('lyrAutoscroll').addEventListener('change', (e) => {
@@ -3697,8 +3723,10 @@ const Notes = (() => {
} }
const row = e.target.closest('[data-i]'); const row = e.target.closest('[data-i]');
if (!row) return; if (!row) return;
if (Date.now() - sv.pressed < 700) return; // that was the long-press
const i = Number(row.dataset.i); const i = Number(row.dataset.i);
if (st.sync) { st.sync.ptr = i; renderSyncPointer(); return; } if (st.sync) { st.sync.ptr = i; renderSyncPointer(); return; }
if (sv.panelFlag && row.classList.contains('lyr-line')) { openFlag(row); return; }
const l = lines()[i]; const l = lines()[i];
if (l && l.t !== null && l.t !== undefined) { if (l && l.t !== null && l.t !== undefined) {
st.lastUserScroll = 0; st.lastUserScroll = 0;
@@ -3726,11 +3754,26 @@ const Notes = (() => {
}); });
return { return {
load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh, refreshFlags: () => { sv.flagsKey = ''; renderServiceButtons(); paintFlags(); loadFlags(); }, load, tick, addBookmark, addNote, handleSyncKey, serviceRefresh, refreshFlags: () => { sv.flagsKey = ''; renderServiceButtons(); setPanelFlagMode(sv.panelFlag && flagsEnabled()); paintFlags(); loadFlags(); },
stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn }, stageLyrics: { render: renderStage, set: setStageLyrics, on: stageOn },
}; };
})(); })();
// ---- Player deck tabs: Sound & view (default) / Practice ----
(function deckTabs() {
const tabs = document.querySelectorAll('.dk-tab');
if (!tabs.length) return;
let cur = 'sound';
try { cur = localStorage.getItem('ytpDeckTab') === 'practice' ? 'practice' : 'sound'; } catch { /* default */ }
const show = (name) => {
tabs.forEach((t) => { const on = t.dataset.dk === name; t.classList.toggle('active', on); t.setAttribute('aria-selected', String(on)); });
document.querySelectorAll('.dk-group').forEach((g) => g.classList.toggle('hidden', g.dataset.dk !== name));
try { localStorage.setItem('ytpDeckTab', name); } catch { /* private mode */ }
};
tabs.forEach((t) => t.addEventListener('click', () => show(t.dataset.dk)));
show(cur);
})();
// ============================================================================ // ============================================================================
// Phone remote (server/remote.js relays both ends over /ws/remote) // Phone remote (server/remote.js relays both ends over /ws/remote)
// ============================================================================ // ============================================================================

View File

@@ -181,9 +181,12 @@
<button id="muteBtn" class="ctrl" title="Mute" aria-label="Mute">🔊</button> <button id="muteBtn" class="ctrl" title="Mute" aria-label="Mute">🔊</button>
<input id="volume" type="range" min="0" max="1" step="0.01" value="1" aria-label="Volume" /> <input id="volume" type="range" min="0" max="1" step="0.01" value="1" aria-label="Volume" />
</div> </div>
<div class="dk-tabs" role="tablist" aria-label="More controls">
<button class="dk-tab" data-dk="sound" role="tab" type="button">Sound &amp; view</button>
<button class="dk-tab" data-dk="practice" role="tab" type="button">Practice</button>
</div>
<div class="deck-groups"> <div class="deck-groups">
<div class="dk-group"> <div class="dk-group" data-dk="practice">
<div class="dk-title">Practice</div>
<div class="dk-tiles"> <div class="dk-tiles">
<button id="abABtn" class="ctrl ab-ctrl" title="Set A-B loop start [A]">A</button> <button id="abABtn" class="ctrl ab-ctrl" title="Set A-B loop start [A]">A</button>
<button id="abBBtn" class="ctrl ab-ctrl" title="Set A-B loop end [B]">B</button> <button id="abBBtn" class="ctrl ab-ctrl" title="Set A-B loop end [B]">B</button>
@@ -203,8 +206,7 @@
</label> </label>
</div> </div>
</div> </div>
<div class="dk-group"> <div class="dk-group" data-dk="sound">
<div class="dk-title">Sound &amp; view</div>
<div class="dk-tiles"> <div class="dk-tiles">
<button id="sleepTimerBtn" class="ctrl" title="Sleep timer">⏱</button> <button id="sleepTimerBtn" class="ctrl" title="Sleep timer">⏱</button>
<button id="transitionBtn" class="ctrl" title="Between songs in a playlist: Normal — click to change">↦</button> <button id="transitionBtn" class="ctrl" title="Between songs in a playlist: Normal — click to change">↦</button>
@@ -264,6 +266,7 @@
<button id="lyrOffPlus" class="lyr-off-btn" type="button">+0.5s</button> <button id="lyrOffPlus" class="lyr-off-btn" type="button">+0.5s</button>
</span> </span>
<label class="lyr-auto"><input type="checkbox" id="lyrAutoscroll" checked /> Autoscroll</label> <label class="lyr-auto"><input type="checkbox" id="lyrAutoscroll" checked /> Autoscroll</label>
<button id="lyricsFlagBtn" class="lyr-flag hidden" type="button" aria-pressed="false" title="Mark a line with wrong words or timing — an admin reviews it"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 21V4M5 4h11l-2 4 2 4H5"/></svg><span>Report a wrong line</span></button>
</div> </div>
<div id="lyricsSyncBar" class="lyrics-syncbar hidden"> <div id="lyricsSyncBar" class="lyrics-syncbar hidden">
<span id="lyricsSyncHint" class="lyrics-sync-hint"></span> <span id="lyricsSyncHint" class="lyrics-sync-hint"></span>

View File

@@ -297,3 +297,76 @@
/* Cards in the player column never shrink below their content. */ /* Cards in the player column never shrink below their content. */
#controls, #nowPlayingMeta, #notesPanel { flex-shrink: 0; } #controls, #nowPlayingMeta, #notesPanel { flex-shrink: 0; }
#lyricsPane .lyr-auto { font-size: 11.5px; gap: 5px; padding: 0 4px; white-space: nowrap; } #lyricsPane .lyr-auto { font-size: 11.5px; gap: 5px; padding: 0 4px; white-space: nowrap; }
/* ---- Round 2: uniform secondary buttons, deck tabs, borderless autoscroll, reports in the lyrics panel ---- */
#listActions button:not(.la-primary):not(.la-main) {
flex: 0 0 calc(50% - 4px);
height: 40px;
padding: 0 10px;
justify-content: center;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 13px;
overflow: hidden; text-overflow: ellipsis;
}
@media (min-width: 900px) {
#listActions button:not(.la-primary):not(.la-main) { flex: 0 0 auto; min-width: 124px; }
}
#nowPlayingMeta .np-actions .np-btn:not(#queueBtn):not(#saveBtn):not(#addPlaylistBtn) {
flex: 0 0 calc(50% - 4px);
height: 42px;
padding: 0 10px;
justify-content: center;
border-radius: 12px;
overflow: hidden; text-overflow: ellipsis;
}
@media (min-width: 900px) {
#nowPlayingMeta .np-actions .np-btn:not(#queueBtn):not(#saveBtn):not(#addPlaylistBtn) { flex: 0 0 auto; min-width: 140px; }
}
/* Deck tabs */
#controls .dk-tabs {
display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
padding: 4px; border-radius: 14px;
background: color-mix(in srgb, var(--bg) 60%, transparent);
border: 1px solid var(--line-soft, var(--line));
}
#controls .dk-tab {
height: 38px; border: 0; border-radius: 10px; cursor: pointer;
background: transparent; color: var(--text-dim);
font: 700 11px/1 var(--mono); letter-spacing: 0.12em; text-transform: uppercase;
transition: background 0.16s, color 0.16s;
}
#controls .dk-tab.active {
color: #fff;
background: color-mix(in srgb, var(--accent) 30%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent);
}
#controls .deck-groups { grid-template-columns: 1fr; }
#controls .dk-group { padding: 8px; }
#controls .dk-tiles { grid-template-columns: repeat(auto-fit, minmax(56px, 1fr)); }
/* Autoscroll: checkbox + label without a box around them */
#lyricsPane .lyr-auto { border: 0; background: transparent; }
/* Report toggle in the lyrics toolbar */
#lyricsPane .lyr-flag {
grid-column: 1 / -1;
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 40px; border-radius: 12px; cursor: pointer;
border: 1px dashed color-mix(in srgb, #ffb454 55%, transparent);
background: transparent; color: #ffb454;
font: 600 13px var(--ui);
}
#lyricsPane .lyr-flag svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
#lyricsPane .lyr-flag[aria-pressed="true"] { background: rgba(255, 180, 84, 0.16); border-style: solid; }
#lyricsList.flagging .lyr-line { outline: 1.5px dashed rgba(255, 180, 84, 0.45); outline-offset: -2px; cursor: crosshair; }
.lyr-line.flagged { position: relative; }
.lyr-line.flagged::after {
content: "⚑" attr(data-n);
margin-left: auto; flex: none;
font: 700 12px var(--ui);
color: #ffb454; padding: 3px 8px; border-radius: 999px;
border: 1px solid rgba(255, 180, 84, 0.5);
}
.lyr-line.flagged.mine::after { background: #ffb454; color: #1a1206; border-color: #ffb454; }