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:
@@ -118,6 +118,13 @@
|
||||
.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 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; }
|
||||
.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); }
|
||||
@@ -284,6 +291,10 @@
|
||||
</form>
|
||||
<div id="loadMsg" class="msg"></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="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 & 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();
|
||||
renderDocs();
|
||||
loadRecent();
|
||||
loadSongs();
|
||||
refreshBadge();
|
||||
const want = videoIdFrom(new URLSearchParams(location.search).get('v') || '');
|
||||
const hash = location.hash.replace('#', '');
|
||||
@@ -656,6 +668,25 @@
|
||||
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('');
|
||||
}
|
||||
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); });
|
||||
|
||||
/* ---------------- p2p ---------------- */
|
||||
|
||||
@@ -2770,6 +2770,9 @@ const Notes = (() => {
|
||||
}).join('');
|
||||
if (st.sync) renderSyncPointer();
|
||||
else tick(now(), true);
|
||||
paintFlags();
|
||||
loadFlags();
|
||||
setPanelFlagMode(sv.panelFlag && flagsEnabled());
|
||||
}
|
||||
|
||||
function renderOffset() {
|
||||
@@ -3306,7 +3309,7 @@ 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, 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 serviceOn = () => isServiceModeOpen() && !!data.settings.serviceLyrics;
|
||||
const serviceAuto = () => data.settings.serviceLyricsAutoscroll !== false;
|
||||
@@ -3374,9 +3377,7 @@ const Notes = (() => {
|
||||
|
||||
// ---- reporting wrong lines (per-profile setting: settings.lyricFlags) ----
|
||||
function paintFlags() {
|
||||
const list = $('serviceLyricsList');
|
||||
if (!list) return;
|
||||
list.querySelectorAll('.sl-line').forEach((el) => {
|
||||
document.querySelectorAll('#serviceLyricsList .sl-line, #lyricsList .lyr-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);
|
||||
@@ -3405,7 +3406,7 @@ const Notes = (() => {
|
||||
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(); }
|
||||
if (res.action === 'saved') { sv.flagMode = false; renderServiceButtons(); setPanelFlagMode(false); }
|
||||
}
|
||||
|
||||
function centreOn(list, el) {
|
||||
@@ -3677,6 +3678,31 @@ const Notes = (() => {
|
||||
renderOffset();
|
||||
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));
|
||||
$('lyrOffPlus').addEventListener('click', () => nudge(0.5));
|
||||
$('lyrAutoscroll').addEventListener('change', (e) => {
|
||||
@@ -3697,8 +3723,10 @@ const Notes = (() => {
|
||||
}
|
||||
const row = e.target.closest('[data-i]');
|
||||
if (!row) return;
|
||||
if (Date.now() - sv.pressed < 700) return; // that was the long-press
|
||||
const i = Number(row.dataset.i);
|
||||
if (st.sync) { st.sync.ptr = i; renderSyncPointer(); return; }
|
||||
if (sv.panelFlag && row.classList.contains('lyr-line')) { openFlag(row); return; }
|
||||
const l = lines()[i];
|
||||
if (l && l.t !== null && l.t !== undefined) {
|
||||
st.lastUserScroll = 0;
|
||||
@@ -3726,11 +3754,26 @@ const Notes = (() => {
|
||||
});
|
||||
|
||||
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 },
|
||||
};
|
||||
})();
|
||||
|
||||
// ---- 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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -181,9 +181,12 @@
|
||||
<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" />
|
||||
</div>
|
||||
<div class="dk-tabs" role="tablist" aria-label="More controls">
|
||||
<button class="dk-tab" data-dk="sound" role="tab" type="button">Sound & view</button>
|
||||
<button class="dk-tab" data-dk="practice" role="tab" type="button">Practice</button>
|
||||
</div>
|
||||
<div class="deck-groups">
|
||||
<div class="dk-group">
|
||||
<div class="dk-title">Practice</div>
|
||||
<div class="dk-group" data-dk="practice">
|
||||
<div class="dk-tiles">
|
||||
<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>
|
||||
@@ -203,8 +206,7 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dk-group">
|
||||
<div class="dk-title">Sound & view</div>
|
||||
<div class="dk-group" data-dk="sound">
|
||||
<div class="dk-tiles">
|
||||
<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>
|
||||
@@ -264,6 +266,7 @@
|
||||
<button id="lyrOffPlus" class="lyr-off-btn" type="button">+0.5s</button>
|
||||
</span>
|
||||
<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 id="lyricsSyncBar" class="lyrics-syncbar hidden">
|
||||
<span id="lyricsSyncHint" class="lyrics-sync-hint"></span>
|
||||
|
||||
@@ -297,3 +297,76 @@
|
||||
/* Cards in the player column never shrink below their content. */
|
||||
#controls, #nowPlayingMeta, #notesPanel { flex-shrink: 0; }
|
||||
#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; }
|
||||
|
||||
Reference in New Issue
Block a user