Add shared synced lyrics, chapters and personal bookmarks with revision history, API tokens and an admin page

This commit is contained in:
Jonathan Sykes
2026-09-18 18:10:10 +08:00
parent a20dd0a3a6
commit 524db986d2
14 changed files with 2493 additions and 11 deletions

320
frontend/admin.html Normal file
View File

@@ -0,0 +1,320 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>YT Player Admin</title>
<style>
:root {
--bg: #0a0a0c; --bg-1: #101013; --bg-2: #16161b; --bg-3: #1d1d24;
--line: #26262f; --text: #f5f3f0; --text-2: #b6b5bf; --text-dim: #76757f;
--accent: #ff4b32; --ok: #7ee0a8; --warn: #ffb454;
--mono: ui-monospace, "JetBrains Mono", monospace;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f6f5f2; --bg-1: #ffffff; --bg-2: #f0efeb; --bg-3: #e7e5e0;
--line: #d9d6cf; --text: #17161a; --text-2: #45434d; --text-dim: #77757f;
--accent: #d4321d; --ok: #1f8a4c; --warn: #9a5b00;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, sans-serif; }
main { max-width: 980px; margin: 0 auto; padding: 24px 16px 60px; }
header { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
h1 { font-size: 20px; margin: 0; }
h1 span { color: var(--accent); }
h2 { font-size: 13px; font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-dim); margin: 0 0 12px; }
section { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; padding: 16px; margin-bottom: 16px; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
input[type=text], input[type=password] {
background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: 9px;
padding: 9px 11px; font-size: 14px; min-width: 0; flex: 1 1 220px;
}
button, .btn {
background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: 9px;
padding: 8px 12px; font-size: 13px; cursor: pointer; text-decoration: none; display: inline-block;
}
button:hover, .btn:hover { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.danger { color: var(--accent); }
.spacer { flex: 1; }
.muted { color: var(--text-dim); font-size: 12px; }
.msg { font-size: 13px; margin-top: 8px; min-height: 1em; }
.msg.err { color: var(--accent); } .msg.ok { color: var(--ok); }
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; padding: 6px; border-bottom: 1px solid var(--line); }
.tbl td { padding: 7px 6px; border-bottom: 1px solid var(--line); vertical-align: middle; }
.tbl code, code { font-family: var(--mono); font-size: 12px; }
.scroll { overflow-x: auto; }
.newtoken { margin-top: 10px; padding: 10px; border: 1px solid var(--ok); border-radius: 9px; background: var(--bg-1); word-break: break-all; }
pre { background: var(--bg-1); border: 1px solid var(--line); border-radius: 9px; padding: 10px; overflow-x: auto; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; max-height: 50vh; }
.hidden { display: none !important; }
.src { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
.src.api, .src.auto { color: var(--warn); } .src.restore { color: var(--ok); }
</style>
</head>
<body>
<main>
<header>
<h1>YT Player <span>Admin</span></h1>
<span class="spacer"></span>
<a class="btn" href="/">← App</a>
<button id="logoutBtn" class="hidden">Log out</button>
</header>
<section id="loginBox" class="hidden">
<h2>Sign in</h2>
<form id="loginForm" class="row">
<input type="password" id="pw" placeholder="Admin password" autocomplete="current-password" />
<button class="primary" type="submit">Sign in</button>
</form>
<div id="loginMsg" class="msg"></div>
</section>
<section id="disabledBox" class="hidden">
<h2>Admin disabled</h2>
<p class="muted">Set <code>ADMIN_PASSWORD</code> in the server environment (Dokploy → Environment) and redeploy.</p>
</section>
<div id="app" class="hidden">
<section>
<h2>API tokens</h2>
<p class="muted">Tokens let scripts write shared lyrics and chapters (<code>Authorization: Bearer …</code>). The token is shown once — store it in your secrets vault.</p>
<form id="tokenForm" class="row">
<input type="text" id="tokenLabel" maxlength="60" placeholder="Label, e.g. lyrics-bot" />
<button class="primary" type="submit">Create token</button>
</form>
<div id="newToken" class="newtoken hidden"></div>
<div class="scroll"><table class="tbl" id="tokenTable"></table></div>
</section>
<section>
<h2>Inject lyrics from captions</h2>
<p class="muted">Reads the video's YouTube captions (human subtitles first, else auto captions in the spoken language) and saves them as the shared lyrics.</p>
<form id="injectForm" class="row">
<input type="text" id="injectId" placeholder="YouTube URL or video id" />
<label class="muted"><input type="checkbox" id="injectOverwrite" /> Replace existing lyrics</label>
<button class="primary" type="submit">Inject</button>
</form>
<div id="injectMsg" class="msg"></div>
</section>
<section>
<div class="row" style="margin-bottom:10px">
<h2 style="margin:0">Recent edits</h2>
<span class="spacer"></span>
<button id="refreshBtn">↻ Refresh</button>
<a class="btn" href="/api/admin/notes/export">⤓ Export all (JSON)</a>
</div>
<p class="muted">Every save is kept as a revision. Restoring writes the old version back as a new revision, so nothing is ever lost. A JSON backup of all notes is also written daily to <code>/app/data/backups</code>.</p>
<div class="scroll"><table class="tbl" id="revTable"></table></div>
<pre id="revView" class="hidden"></pre>
</section>
<section>
<h2>API reference</h2>
<pre id="apiDocs"></pre>
</section>
</div>
</main>
<script>
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const when = (sec) => (sec ? new Date(sec * 1000).toLocaleString() : '—');
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
headers: opts.body ? { 'Content-Type': 'application/json' } : {},
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
const j = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
if (res.status === 401 && path !== '/api/admin/login') { showLogin(); }
return j;
}
function videoIdFrom(input) {
const s = String(input || '').trim();
if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s;
const m = s.match(/(?:v=|youtu\.be\/|shorts\/|embed\/|live\/)([A-Za-z0-9_-]{11})/);
return m ? m[1] : null;
}
function showLogin() {
$('loginBox').classList.remove('hidden');
$('app').classList.add('hidden');
$('logoutBtn').classList.add('hidden');
}
async function boot() {
const me = await api('/api/admin/me');
if (!me.enabled) { $('disabledBox').classList.remove('hidden'); return; }
if (!me.admin) { showLogin(); return; }
$('loginBox').classList.add('hidden');
$('app').classList.remove('hidden');
$('logoutBtn').classList.remove('hidden');
renderDocs();
loadTokens();
loadRevs();
}
$('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
$('loginMsg').className = 'msg';
$('loginMsg').textContent = 'Signing in…';
const j = await api('/api/admin/login', { method: 'POST', body: { password: $('pw').value } });
if (!j.ok) { $('loginMsg').className = 'msg err'; $('loginMsg').textContent = j.error || 'failed'; return; }
$('pw').value = '';
$('loginMsg').textContent = '';
boot();
});
$('logoutBtn').addEventListener('click', async () => {
await api('/api/admin/logout', { method: 'POST' });
showLogin();
});
async function loadTokens() {
const j = await api('/api/admin/tokens');
const rows = (j.tokens || []).map((t) => `
<tr>
<td>${esc(t.label)}</td>
<td class="muted">${when(t.createdAt)}</td>
<td class="muted">${t.lastUsedAt ? when(t.lastUsedAt) : 'never'}</td>
<td><button class="danger" data-revoke="${esc(t.id)}">Revoke</button></td>
</tr>`).join('');
$('tokenTable').innerHTML = rows
? `<tr><th>Label</th><th>Created</th><th>Last used</th><th></th></tr>${rows}`
: '<tr><td class="muted">No tokens yet.</td></tr>';
}
$('tokenForm').addEventListener('submit', async (e) => {
e.preventDefault();
const label = $('tokenLabel').value.trim();
if (!label) return;
const j = await api('/api/admin/tokens', { method: 'POST', body: { label } });
const box = $('newToken');
box.classList.remove('hidden');
if (!j.ok) { box.textContent = j.error || 'failed'; return; }
box.innerHTML = `New token for <b>${esc(j.label)}</b> — copy it now, it won't be shown again:<br><code id="tokVal">${esc(j.token)}</code>
<div class="row" style="margin-top:8px"><button id="copyTok">Copy</button></div>`;
$('copyTok').onclick = () => navigator.clipboard.writeText(j.token).then(() => { $('copyTok').textContent = 'Copied ✓'; });
$('tokenLabel').value = '';
loadTokens();
});
$('tokenTable').addEventListener('click', async (e) => {
const id = e.target.dataset.revoke;
if (!id) return;
if (!confirm('Revoke this token? Scripts using it stop working immediately.')) return;
await api(`/api/admin/tokens/${encodeURIComponent(id)}`, { method: 'DELETE' });
loadTokens();
});
$('injectForm').addEventListener('submit', async (e) => {
e.preventDefault();
const msg = $('injectMsg');
const id = videoIdFrom($('injectId').value);
if (!id) { msg.className = 'msg err'; msg.textContent = 'Not a YouTube URL or id'; return; }
msg.className = 'msg';
msg.textContent = 'Fetching captions…';
const j = await api(`/api/notes/${id}/lyrics/auto`, { method: 'POST', body: { overwrite: $('injectOverwrite').checked } });
if (!j.ok) { msg.className = 'msg err'; msg.textContent = j.error || 'failed'; return; }
msg.className = 'msg ok';
msg.textContent = `Saved ${j.lines} lines (${j.auto ? 'auto captions' : 'subtitles'}, ${j.lang}) as revision ${j.rev}.`;
loadRevs();
});
async function loadRevs() {
const j = await api('/api/admin/notes/recent');
const rows = (j.revs || []).map((r) => `
<tr>
<td class="muted">${when(r.createdAt)}</td>
<td><a href="https://youtu.be/${esc(r.videoId)}" target="_blank" rel="noopener"><code>${esc(r.videoId)}</code></a></td>
<td>${esc(r.kind)}</td>
<td><code>v${r.rev}</code></td>
<td>${esc(r.updatedBy || '—')}</td>
<td><span class="src ${esc(r.source)}">${esc(r.source)}</span></td>
<td class="row">
<button data-view="${esc(r.videoId)}|${esc(r.kind)}|${r.rev}">View</button>
<button data-restore="${esc(r.videoId)}|${esc(r.kind)}|${r.rev}">Restore</button>
</td>
</tr>`).join('');
$('revTable').innerHTML = rows
? `<tr><th>When</th><th>Video</th><th>Kind</th><th>Rev</th><th>By</th><th>Source</th><th></th></tr>${rows}`
: '<tr><td class="muted">No edits yet.</td></tr>';
}
function asText(kind, d) {
if (kind === 'lyrics') {
const head = (d.tags || []).length ? `@ ${d.tags.join(', ')}\n` : '';
return head + (d.lines || []).map((l) => {
const t = l.t == null ? '' : `[${Math.floor(l.t / 60)}:${(l.t % 60).toFixed(2).padStart(5, '0')}] `;
return t + (l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '') + l.text;
}).join('\n');
}
return (d.items || []).map((c) => `${Math.floor(c.t / 60)}:${String(Math.floor(c.t % 60)).padStart(2, '0')} ${c.title}${c.note ? ' — ' + c.note : ''}`).join('\n');
}
$('revTable').addEventListener('click', async (e) => {
const v = e.target.dataset.view;
const r = e.target.dataset.restore;
if (v) {
const [id, kind, rev] = v.split('|');
const j = await api(`/api/notes/${id}/${kind}/revs/${rev}`);
const pre = $('revView');
pre.classList.remove('hidden');
pre.textContent = j.ok ? `${id} · ${kind} · v${rev} by ${j.updatedBy || '—'}\n\n${asText(kind, j.data)}` : (j.error || 'failed');
pre.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else if (r) {
const [id, kind, rev] = r.split('|');
if (!confirm(`Restore ${kind} of ${id} to v${rev}? It becomes a new revision.`)) return;
const j = await api(`/api/admin/notes/${id}/${kind}/restore`, { method: 'POST', body: { rev: Number(rev) } });
alert(j.ok ? `Restored as v${j.rev}` : (j.error || 'failed'));
loadRevs();
}
});
$('refreshBtn').addEventListener('click', loadRevs);
function renderDocs() {
const o = location.origin;
$('apiDocs').textContent = `# Read (public)
curl ${o}/api/notes/VIDEO_ID
curl ${o}/api/notes/VIDEO_ID/captions # YouTube captions as timed lines, no save
# Inject captions as the shared lyrics (token)
curl -X POST ${o}/api/notes/VIDEO_ID/lyrics/auto \\
-H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" \\
-d '{"overwrite": false}'
# Write lyrics (token). baseRev = the rev you read, or add "force": true to overwrite.
curl -X PUT ${o}/api/notes/VIDEO_ID/lyrics \\
-H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" \\
-d '{"baseRev": 0, "data": {"tags": ["Key G", "70 BPM"], "offset": 0, "lines": [
{"t": 18.0, "text": "Verse 1", "kind": "section"},
{"t": 18.0, "text": "First sung line", "kind": "line"},
{"t": null, "text": "An untimed line", "kind": "line"},
{"t": 140, "text": "Drums in", "kind": "cue"}]}}'
# Write chapters (token)
curl -X PUT ${o}/api/notes/VIDEO_ID/chapters \\
-H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" \\
-d '{"baseRev": 0, "data": {"items": [{"t": 42, "title": "Intro", "note": ""}]}}'
# History (public)
curl ${o}/api/notes/VIDEO_ID/lyrics/revs
curl ${o}/api/notes/VIDEO_ID/lyrics/revs/3
# 409 = someone saved in between: the response carries "current" (with its rev).`;
}
boot();
})();
</script>
</body>
</html>

View File

@@ -269,7 +269,7 @@ const DEFAULT_SETTINGS = {
autoBackupIntervalDays: 7,
serviceVideo: false, // show the video panel inside service mode (lyric videos)
};
let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null };
let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, bookmarks: {}, lyricOffsets: {}, lastAutoBackup: 0, profile: null };
let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
let searchResults = [];
// Distinguishes "never searched" (hide the list pane, show the landing) from
@@ -377,6 +377,8 @@ function profilePayload() {
rememberPos: data.rememberPos,
playCount: data.playCount,
abMarkers: data.abMarkers,
bookmarks: data.bookmarks || {},
lyricOffsets: data.lyricOffsets || {},
};
}
@@ -416,6 +418,8 @@ function applyProfileData(name, payload, updatedAt) {
if (payload.rememberPos && typeof payload.rememberPos === 'object') data.rememberPos = payload.rememberPos;
if (payload.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount;
if (payload.abMarkers && typeof payload.abMarkers === 'object') data.abMarkers = payload.abMarkers;
if (payload.bookmarks && typeof payload.bookmarks === 'object') data.bookmarks = payload.bookmarks;
if (payload.lyricOffsets && typeof payload.lyricOffsets === 'object') data.lyricOffsets = payload.lyricOffsets;
if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings };
data.profile = { name, syncedAt: updatedAt || 0 };
API.saveData(data).catch(() => {});
@@ -966,11 +970,14 @@ function toast(msg, { duration = 2200 } = {}) {
setTimeout(() => t.remove(), 280);
}, duration);
// Cap at 3 toasts — remove oldest
while (container.children.length > 3) {
const first = container.firstChild;
first.classList.add('toast-exit');
setTimeout(() => first.remove(), 280);
// Cap at 3 toasts — fade out the oldest. Removal waits for the exit
// animation, so only count toasts that aren't already leaving: the old
// `while (children.length > 3)` never removed anything synchronously and
// froze the page for good the moment a 4th toast appeared.
const live = [...container.children].filter((c) => !c.classList.contains('toast-exit'));
for (const old of live.slice(0, Math.max(0, live.length - 3))) {
old.classList.add('toast-exit');
setTimeout(() => old.remove(), 280);
}
return t;
}
@@ -1622,6 +1629,7 @@ const Player = {
// Shared UI updates once `current` is populated (cached or streamed).
afterLoad() {
addToHistory(current.meta);
Notes.load(current.meta);
buildQualityMenu();
els.playerPane.classList.remove('empty');
els.controls.classList.remove('hidden');
@@ -2262,6 +2270,721 @@ function updateNowPlayingActions() {
btn.title = 'Save this video for offline playback';
}
}
// ============================================================================
// Lyrics · chapters · bookmarks
// ============================================================================
// Lyrics and chapters are shared per video by every user (server/notes.js
// keeps each save as a revision). Bookmarks and the sync offset are personal
// and ride along with the profile sync (data.bookmarks / data.lyricOffsets).
// The last copy seen of each video's shared notes is kept in localStorage so
// saved songs still show their lyrics offline.
const Notes = (() => {
const LC = window.LyricsCore;
const panel = $('notesPanel');
const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; } };
if (!panel || !LC) return noop;
const CACHE_KEY = 'ytpNotesCache';
const CACHE_MAX = 200;
const st = {
videoId: null, lyrics: null, chapters: null, tab: 'lyrics',
activeIdx: -1, lastUserScroll: 0, sync: null, loadSeq: 0,
statusMsg: '', sectionText: '', lastDur: 0, chipKey: '', addLabel: '',
};
// ---- small helpers ----
const supported = (meta) => WEB && meta && !meta.custom && /^[A-Za-z0-9_-]{11}$/.test(meta.id || '');
const profileName = () => (data.profile && data.profile.name) || '';
const lines = () => (st.sync ? st.sync.lines : (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || []);
const chapters = () => (st.chapters && st.chapters.data && st.chapters.data.items) || [];
const bookmarks = () => {
if (!st.videoId || !data.bookmarks) return [];
return Array.isArray(data.bookmarks[st.videoId]) ? data.bookmarks[st.videoId] : [];
};
const docOffset = () => (st.lyrics && Number(st.lyrics.data.offset)) || 0;
const personalOffset = () => (st.videoId && data.lyricOffsets && Number(data.lyricOffsets[st.videoId])) || 0;
const offset = () => docOffset() + personalOffset();
const now = () => (Player.master && Player.master.currentTime) || 0;
const round2 = (n) => Math.round(n * 100) / 100;
function readCache() {
try { return JSON.parse(localStorage.getItem(CACHE_KEY) || '{}') || {}; } catch { return {}; }
}
function writeCache(id, entry) {
try {
const c = readCache();
c[id] = { ...(c[id] || {}), ...entry, at: Date.now() };
const ids = Object.keys(c);
if (ids.length > CACHE_MAX) {
ids.sort((a, b) => (c[a].at || 0) - (c[b].at || 0))
.slice(0, ids.length - CACHE_MAX).forEach((k) => delete c[k]);
}
localStorage.setItem(CACHE_KEY, JSON.stringify(c));
} catch { /* storage full or blocked — lyrics just won't be offline */ }
}
function requireProfile() {
if (profileName()) return true;
toast('Link an online profile (Settings → Online profile) to edit shared lyrics and chapters.', { duration: 5000 });
return false;
}
function setStatus(msg) { st.statusMsg = msg || ''; renderStatus(); }
function renderStatus() {
const el = $('notesStatus');
if (el) el.textContent = st.statusMsg || st.sectionText || '';
}
// ---- loading ----
async function load(meta) {
if (st.sync && meta && meta.id === st.videoId) return; // a fallback reload mid-sync
endSync();
if (!supported(meta)) {
st.videoId = null;
panel.classList.add('hidden');
renderSeekMarkers();
return;
}
const id = meta.id;
const seq = ++st.loadSeq;
st.videoId = id;
st.activeIdx = -1;
st.lastDur = 0;
const cached = readCache()[id];
st.lyrics = cached ? cached.lyrics || null : null;
st.chapters = cached ? cached.chapters || null : null;
panel.classList.remove('hidden');
setStatus(cached ? '' : 'Loading…');
renderAll();
try {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}`);
const j = await res.json();
if (seq !== st.loadSeq) return;
if (!j || !j.ok) throw new Error((j && j.error) || 'failed');
st.lyrics = j.lyrics;
st.chapters = j.chapters;
writeCache(id, { lyrics: j.lyrics, chapters: j.chapters });
setStatus('');
if (!st.sync) renderAll();
} catch {
if (seq !== st.loadSeq) return;
setStatus(cached ? 'Offline copy' : 'Offline');
}
}
function applyNote(id, kind, note) {
writeCache(id, { [kind]: note });
if (id !== st.videoId) return;
st[kind] = note;
renderAll();
}
// ---- rendering ----
function renderAll() {
renderLyrics();
renderMarkers();
renderSeekMarkers();
renderOffset();
}
function renderLyrics() {
const list = $('lyricsList');
const tagsEl = $('lyricsTags');
const ls = lines();
const tags = (st.lyrics && st.lyrics.data && st.lyrics.data.tags) || [];
tagsEl.innerHTML = tags.map((t) => `<span class="lyr-tag">${escapeHtml(t)}</span>`).join('');
tagsEl.classList.toggle('hidden', !tags.length);
$('lyricsSyncBtn').disabled = !ls.length || !!st.sync;
$('lyricsHistoryBtn').disabled = !st.lyrics;
$('lyricsEditBtn').disabled = !!st.sync;
st.activeIdx = -1;
if (!ls.length) {
list.innerHTML = `
<div class="notes-empty">
<div>No lyrics yet for this song.</div>
<div class="notes-empty-actions">
<button class="np-btn" type="button" data-act="paste">✎ Add lyrics</button>
<button class="np-btn" type="button" data-act="captions">⤓ Import YouTube captions</button>
</div>
<div class="notes-empty-hint">Lyrics and chapters are shared with everyone who plays this song.</div>
</div>`;
st.sectionText = '';
renderStatus();
return;
}
list.innerHTML = ls.map((l, i) => {
const time = l.t === null || l.t === undefined ? '' : LC.fmtClock(l.t);
if (l.kind === 'section') {
return `<div class="lyr-section" data-i="${i}">${escapeHtml(l.text)}${time ? `<span class="lyr-section-time"> · ${time}</span>` : ''}</div>`;
}
if (l.kind === 'cue') {
return `<div class="lyr-cue" data-i="${i}"><span>🥁 ${escapeHtml(l.text)}</span><span class="lyr-cue-in"></span></div>`;
}
return `<div class="lyr-line${time ? '' : ' untimed'}" data-i="${i}"><span class="lyr-time">${time}</span><span class="lyr-text">${escapeHtml(l.text)}</span></div>`;
}).join('');
if (st.sync) renderSyncPointer();
else tick(now(), true);
}
function renderOffset() {
const p = personalOffset();
$('lyrOffVal').textContent = `${p > 0 ? '+' : ''}${p.toFixed(1)}s`;
$('lyrOffVal').classList.toggle('nonzero', p !== 0);
const auto = data.settings.lyricsAutoscroll !== false;
$('lyrAutoscroll').checked = auto;
}
function markerItems() {
const out = [];
chapters().forEach((c, i) => out.push({ ...c, kind: 'chapter', i }));
bookmarks().forEach((b, i) => out.push({ ...b, kind: 'bookmark', i }));
return out.sort((a, b) => a.t - b.t);
}
function renderMarkers() {
const chs = chapters();
const bms = bookmarks();
const count = chs.length + bms.length;
$('markersCount').textContent = count ? String(count) : '';
$('chaptersHistoryBtn').disabled = !st.chapters;
const chips = markerItems();
$('markerChips').innerHTML = chips.map((m) => `
<button class="marker-chip ${m.kind}" type="button" data-t="${m.t}" title="${escapeHtml(m.note || m.title)}">
<span class="marker-chip-time">${LC.fmtClock(m.t)}</span> ${m.kind === 'bookmark' ? '📌 ' : ''}${escapeHtml(m.title)}
</button>`).join('');
$('markerChips').classList.toggle('hidden', !chips.length);
const row = (m, kind, i) => `
<div class="marker-row" data-kind="${kind}" data-i="${i}">
<button class="mk-time" type="button" data-act="jump" title="Jump here">${LC.fmtClock(m.t)}</button>
<div class="mk-body">
<div class="mk-title">${escapeHtml(m.title)}</div>
${m.note ? `<div class="mk-note">${escapeHtml(m.note)}</div>` : ''}
</div>
<button class="mk-btn" type="button" data-act="jump" title="Play from here">▶</button>
<button class="mk-btn" type="button" data-act="edit" title="Edit">✎</button>
<button class="mk-btn" type="button" data-act="del" title="Delete">🗑</button>
</div>`;
$('chapterList').innerHTML = chs.length
? chs.map((c, i) => row(c, 'chapter', i)).join('')
: '<div class="notes-empty small">No chapters yet — tap + Chapter while the song plays.</div>';
$('bookmarkList').innerHTML = bms.length
? bms.map((b, i) => row(b, 'bookmark', i)).join('')
: '<div class="notes-empty small">No bookmarks — tap 📌 or press P.</div>';
st.chipKey = '';
}
function renderSeekMarkers() {
const host = $('seekMarkers');
if (!host) return;
const dur = (Player.master && Player.master.duration) || (current && current.meta && current.meta.duration) || 0;
st.lastDur = dur;
if (!st.videoId || !dur || !isFinite(dur)) { host.innerHTML = ''; return; }
host.innerHTML = markerItems()
.filter((m) => m.t <= dur)
.map((m) => `<span class="seek-tick ${m.kind}" style="left:${((m.t / dur) * 100).toFixed(3)}%"></span>`)
.join('');
}
// ---- playback tick (timeupdate) ----
function scrollToRow(list, el) {
const top = el.offsetTop - list.clientHeight / 2 + el.clientHeight / 2;
list.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
function tick(cur, force = false) {
if (!st.videoId || panel.classList.contains('hidden')) return;
const dur = (Player.master && Player.master.duration) || 0;
if (dur && isFinite(dur) && Math.abs(dur - st.lastDur) > 0.5) renderSeekMarkers();
// "+ Chapter at 2:41" follows the playhead.
const label = `+ Chapter at ${LC.fmtClock(cur)}`;
if (label !== st.addLabel) { st.addLabel = label; $('addChapterBtn').textContent = label; }
// Active chip.
const active = LC.activeMarker(markerItems(), cur);
const key = active ? `${active.kind}:${active.i}` : '';
if (key !== st.chipKey) {
st.chipKey = key;
$('markerChips').querySelectorAll('.marker-chip.active').forEach((el) => el.classList.remove('active'));
if (active) {
const chip = $('markerChips').querySelector(`.marker-chip[data-t="${active.t}"]`);
if (chip) chip.classList.add('active');
}
}
if (st.sync) return;
const ls = lines();
if (!ls.length) return;
const off = offset();
const idx = LC.activeIndex(ls, cur, off);
const list = $('lyricsList');
if (idx !== st.activeIdx || force) {
const prev = list.querySelector('.lyr-line.active');
if (prev) prev.classList.remove('active');
const el = idx >= 0 ? list.querySelector(`[data-i="${idx}"]`) : null;
if (el) {
el.classList.add('active');
const auto = data.settings.lyricsAutoscroll !== false;
if (auto && st.tab === 'lyrics' && Date.now() - st.lastUserScroll > 4000) scrollToRow(list, el);
}
st.activeIdx = idx;
const sec = LC.activeSection(ls, cur, off);
st.sectionText = sec ? `${sec.text} · now` : '';
renderStatus();
}
// Band cues count down over their last minute.
list.querySelectorAll('.lyr-cue').forEach((el) => {
const l = ls[Number(el.dataset.i)];
const inEl = el.querySelector('.lyr-cue-in');
if (!l || l.t === null || l.t === undefined || !inEl) return;
const left = l.t + off - cur;
const txt = left > 0 && left <= 60 ? `in ${LC.fmtClock(Math.ceil(left))}` : '';
if (inEl.textContent !== txt) inEl.textContent = txt;
el.classList.toggle('soon', !!txt);
});
}
// ---- saving shared notes ----
async function putNote(id, kind, payload, baseRev) {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/${kind}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: payload, baseRev: baseRev || 0, profile: profileName() }),
});
const j = await res.json().catch(() => null);
if (res.status === 409 && j && j.conflict) return { conflict: true, current: j.current };
if (!j || !j.ok) throw new Error((j && j.error) || `HTTP ${res.status}`);
return j;
}
function askConflict(kind, current) {
return new Promise((resolve) => {
const body = document.createElement('div');
const who = current && current.updatedBy ? escapeHtml(current.updatedBy) : 'Someone';
body.innerHTML = `<p class="modal-text">${who} saved a newer version of these ${kind} while you were editing.
Keep yours (theirs stays in the history) or load theirs?</p>`;
showModal('Newer version on the server', body, [
{ label: 'Load theirs', onClick: () => { closeModal(); resolve(false); } },
{ label: 'Keep mine', primary: true, onClick: () => { closeModal(); resolve(true); } },
]);
});
}
// 'saved' | 'theirs' | 'failed'
async function saveShared(kind, payload, baseRev) {
const id = st.videoId;
if (!id || !requireProfile()) return 'failed';
try {
let r = await putNote(id, kind, payload, baseRev);
if (r.conflict) {
if (!(await askConflict(kind, r.current))) {
applyNote(id, kind, r.current);
return 'theirs';
}
r = await putNote(id, kind, payload, r.current ? r.current.rev : 0);
if (r.conflict) throw new Error('someone saved again — try once more');
}
applyNote(id, kind, {
data: r.data, rev: r.rev, source: 'user', updatedBy: profileName(),
updatedAt: Math.floor(Date.now() / 1000),
});
toast(`Saved ✓ — shared with everyone`);
return 'saved';
} catch (err) {
toast('⚠ Could not save: ' + err.message, { duration: 4000 });
return 'failed';
}
}
// ---- lyrics editor ----
async function captionsText() {
const id = st.videoId;
if (!id) return null;
toast('Fetching YouTube captions…');
try {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/captions`);
const j = await res.json().catch(() => null);
if (!j || !j.ok) throw new Error((j && j.error) || `HTTP ${res.status}`);
toast(`Imported ${j.lines.length} lines from ${j.auto ? 'auto-generated ' : ''}captions (${j.lang}) — check the words before saving.`, { duration: 5000 });
return LC.toText({ lines: j.lines.map((l) => ({ ...l, kind: 'line' })), tags: [] });
} catch (err) {
toast('⚠ ' + err.message, { duration: 4000 });
return null;
}
}
function openEditor(prefill, { importCaptions = false, note = '' } = {}) {
if (!st.videoId || !requireProfile()) return;
const baseRev = st.lyrics ? st.lyrics.rev : 0;
const body = document.createElement('div');
body.className = 'lyr-editor';
body.innerHTML = `
<p class="lyr-editor-help">One lyric line per line. Optional: <code>[1:23.45]</code> times a line
(or time them later with Tap-sync), <code># Chorus</code> is a section, <code>! Drums in</code> a band cue,
<code>@ Key G, Capo 2, 70 BPM</code> adds tags. Pasted .lrc files work as-is.</p>
${note ? `<p class="lyr-editor-note">${escapeHtml(note)}</p>` : ''}
<textarea class="lyr-editor-text" spellcheck="false" placeholder="Paste or type the lyrics…"></textarea>
<div class="lyr-editor-tools">
<button type="button" class="np-btn" data-act="captions">⤓ Import YouTube captions</button>
<button type="button" class="np-btn" data-act="stamp">⏱ Stamp current time on this line</button>
</div>`;
const ta = body.querySelector('textarea');
ta.value = prefill != null ? prefill : (st.lyrics ? LC.toText(st.lyrics.data) : '');
ta.addEventListener('keydown', (e) => e.stopPropagation());
const runCaptions = async () => {
const text = await captionsText();
if (text != null) ta.value = text;
};
body.querySelector('[data-act="captions"]').addEventListener('click', runCaptions);
body.querySelector('[data-act="stamp"]').addEventListener('click', () => {
const pos = ta.selectionStart;
const start = ta.value.lastIndexOf('\n', pos - 1) + 1;
const endNl = ta.value.indexOf('\n', start);
const end = endNl < 0 ? ta.value.length : endNl;
const lineText = ta.value.slice(start, end).replace(/^(\[[^\]]*\]\s*)+/, '');
const stamp = `[${LC.fmtStamp(Math.max(0, now() - docOffset()))}] `;
ta.value = ta.value.slice(0, start) + stamp + lineText + ta.value.slice(end);
// Move to the next line so repeated taps walk down the sheet.
const next = ta.value.indexOf('\n', start);
const caret = next < 0 ? ta.value.length : next + 1;
ta.focus();
ta.setSelectionRange(caret, caret);
});
showModal(st.lyrics ? 'Edit lyrics' : 'Add lyrics', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Save for everyone', primary: true, onClick: async () => {
const text = ta.value;
const doc = LC.fromText(text);
const payload = {
lines: doc.lines,
tags: doc.tags,
offset: doc.offset !== null ? doc.offset : docOffset(),
};
closeModal();
const r = await saveShared('lyrics', payload, baseRev);
if (r === 'failed') openEditor(text, { note: 'Not saved yet — your text is still here.' });
} },
]);
if (importCaptions) runCaptions();
else setTimeout(() => ta.focus(), 50);
}
// ---- tap-sync ----
function startSync() {
if (!st.videoId || !requireProfile()) return;
const src = (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || [];
if (!src.length) return;
st.sync = {
lines: src.map((l) => ({ ...l })),
ptr: 0,
history: [],
baseRev: st.lyrics.rev,
videoId: st.videoId,
};
panel.classList.add('syncing');
$('lyricsSyncBar').classList.remove('hidden');
st.tab = 'lyrics';
showTab('lyrics');
renderLyrics();
toast('Play the song · tap Stamp line (or Enter) as each line starts', { duration: 4000 });
}
function renderSyncPointer() {
const s = st.sync;
if (!s) return;
const list = $('lyricsList');
list.querySelectorAll('.sync-ptr').forEach((el) => el.classList.remove('sync-ptr'));
const el = list.querySelector(`[data-i="${s.ptr}"]`);
if (el) { el.classList.add('sync-ptr'); scrollToRow(list, el); }
const total = s.lines.length;
const next = s.lines[s.ptr];
$('lyricsSyncHint').textContent = next
? `${s.ptr + 1}/${total} · next: “${next.text.slice(0, 60)}”`
: `All ${total} lines stamped — save when ready.`;
$('lyricsUndoBtn').disabled = !s.history.length;
}
function stamp() {
const s = st.sync;
if (!s || s.ptr >= s.lines.length) return;
s.history.push({ i: s.ptr, prev: s.lines[s.ptr].t });
s.lines[s.ptr].t = round2(Math.max(0, now() - docOffset()));
s.ptr++;
renderLyrics();
}
function undoStamp() {
const s = st.sync;
const h = s && s.history.pop();
if (!h) return;
s.lines[h.i].t = h.prev;
s.ptr = h.i;
renderLyrics();
}
function endSync() {
if (!st.sync) return;
st.sync = null;
panel.classList.remove('syncing');
$('lyricsSyncBar').classList.add('hidden');
renderLyrics();
}
async function finishSync() {
const s = st.sync;
if (!s) return;
const payload = { ...(st.lyrics ? st.lyrics.data : {}), lines: s.lines };
const r = await saveShared('lyrics', payload, s.baseRev);
if (r !== 'failed') endSync();
}
function handleSyncKey(e) {
if (!st.sync) return false;
if (e.key === 'Enter' || e.key === 't') { e.preventDefault(); stamp(); return true; }
if (e.key === 'Backspace') { e.preventDefault(); undoStamp(); return true; }
if (e.key === 'Escape') { e.preventDefault(); endSync(); return true; }
return false;
}
// ---- history (both kinds) ----
async function openHistory(kind) {
const id = st.videoId;
if (!id) return;
let revs = [];
try {
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}/${kind}/revs`)).json();
if (!j.ok) throw new Error(j.error || 'failed');
revs = j.revs;
} catch (err) {
toast('⚠ Could not load history: ' + err.message);
return;
}
const live = st[kind] ? st[kind].rev : 0;
const body = document.createElement('div');
body.className = 'notes-history';
body.innerHTML = revs.length ? revs.map((r) => `
<div class="hist-row">
<div class="hist-main">
<span class="hist-rev">v${r.rev}</span>
<span class="hist-who">${escapeHtml(r.updatedBy || 'unknown')}</span>
<span class="hist-src">${escapeHtml(r.source)}</span>
<span class="hist-when">${new Date(r.createdAt * 1000).toLocaleString()}</span>
</div>
${r.rev === live ? '<span class="hist-live">current</span>' : `<button class="np-btn" type="button" data-rev="${r.rev}">Restore</button>`}
</div>`).join('') : '<div class="notes-empty small">No history yet.</div>';
body.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-rev]');
if (!btn) return;
try {
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}/${kind}/revs/${btn.dataset.rev}`)).json();
if (!j.ok) throw new Error(j.error || 'failed');
closeModal();
await saveShared(kind, j.data, live);
} catch (err) {
toast('⚠ Could not restore: ' + err.message);
}
});
showModal(kind === 'lyrics' ? '🕘 Lyrics history' : '🕘 Chapters history', body, [
{ label: 'Close', primary: true, onClick: closeModal },
]);
}
// ---- chapters & bookmarks ----
function markerForm(title, init) {
return new Promise((resolve) => {
const body = document.createElement('div');
body.className = 'marker-form';
body.innerHTML = `
<label>Time <input type="text" class="mf-time" inputmode="decimal" placeholder="m:ss" /></label>
<label>Title <input type="text" class="mf-title" maxlength="100" placeholder="e.g. Key change → D" /></label>
<label>Note <input type="text" class="mf-note" maxlength="200" placeholder="optional" /></label>`;
const $t = body.querySelector('.mf-time');
const $ti = body.querySelector('.mf-title');
const $n = body.querySelector('.mf-note');
$t.value = LC.fmtClock(init.t);
$ti.value = init.title || '';
$n.value = init.note || '';
const submit = () => {
const t = LC.parseStamp($t.value);
const ttl = $ti.value.trim();
if (t === null) { toast('Time must look like 1:23'); return; }
if (!ttl) { toast('Give it a title'); return; }
closeModal();
resolve({ t, title: ttl, note: $n.value.trim() });
};
body.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } });
showModal(title, body, [
{ label: 'Cancel', onClick: () => { closeModal(); resolve(null); } },
{ label: 'Save', primary: true, onClick: submit },
]);
setTimeout(() => $ti.focus(), 50);
});
}
async function addChapter() {
if (!st.videoId || !requireProfile()) return;
const baseRev = st.chapters ? st.chapters.rev : 0;
const v = await markerForm('+ Add chapter', { t: Math.floor(now()), title: '' });
if (!v) return;
await saveShared('chapters', { items: [...chapters(), v] }, baseRev);
}
async function editMarker(kind, i) {
const list = kind === 'chapter' ? chapters() : bookmarks();
const item = list[i];
if (!item) return;
if (kind === 'chapter' && !requireProfile()) return;
const baseRev = st.chapters ? st.chapters.rev : 0;
const v = await markerForm(kind === 'chapter' ? '✎ Edit chapter' : '✎ Edit bookmark', item);
if (!v) return;
const next = list.slice();
next[i] = v;
if (kind === 'chapter') await saveShared('chapters', { items: next }, baseRev);
else setBookmarks(next);
}
async function deleteMarker(kind, i) {
if (kind === 'chapter') {
if (!requireProfile()) return;
const next = chapters().filter((_, j) => j !== i);
await saveShared('chapters', { items: next }, st.chapters ? st.chapters.rev : 0);
} else {
setBookmarks(bookmarks().filter((_, j) => j !== i));
toast('Bookmark removed');
}
}
function setBookmarks(list) {
if (!st.videoId) return;
if (!data.bookmarks) data.bookmarks = {};
const sorted = list.slice().sort((a, b) => a.t - b.t);
if (sorted.length) data.bookmarks[st.videoId] = sorted;
else delete data.bookmarks[st.videoId];
persist();
renderMarkers();
renderSeekMarkers();
}
function addBookmark() {
if (!st.videoId) { toast('Bookmarks need a YouTube video'); return; }
const t = round2(now());
const n = bookmarks().length + 1;
setBookmarks([...bookmarks(), { t, title: `Bookmark ${n}`, note: '' }]);
toast(`📌 Bookmarked ${LC.fmtClock(t)} — rename it under Chapters & bookmarks`);
}
function openExport() {
const chs = chapters();
const bms = bookmarks();
if (!chs.length && !bms.length) { toast('Nothing to export yet'); return; }
const title = (current && current.meta && current.meta.title) || st.videoId;
const build = (withBm) => LC.toYouTubeTimestamps(withBm ? [...chs, ...bms] : chs);
const body = document.createElement('div');
body.className = 'marker-export';
body.innerHTML = `
<p class="modal-text">YouTube timestamp format — paste into a description, setlist or chat.</p>
<label class="mx-inc"><input type="checkbox" class="mx-bm" ${chs.length ? '' : 'checked'} /> Include my bookmarks</label>
<textarea class="mx-text" readonly spellcheck="false"></textarea>`;
const ta = body.querySelector('textarea');
const inc = body.querySelector('.mx-bm');
const refresh = () => { ta.value = build(inc.checked); };
inc.addEventListener('change', refresh);
refresh();
showModal('⤓ Export chapters', body, [
{ label: 'Download .txt', onClick: () => {
const blob = new Blob([`${title}\nhttps://youtu.be/${st.videoId}\n\n${ta.value}\n`], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${String(title).replace(/[^\w\- ]+/g, '').trim().slice(0, 60) || st.videoId}-chapters.txt`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
} },
{ label: 'Copy', primary: true, onClick: async () => {
try { await navigator.clipboard.writeText(ta.value); toast('Copied ✓'); }
catch { ta.select(); toast('Select-all done — copy with Ctrl/⌘+C'); }
} },
]);
}
// ---- tabs + events ----
function showTab(tab) {
st.tab = tab;
panel.querySelectorAll('.notes-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === tab));
$('lyricsPane').classList.toggle('hidden', tab !== 'lyrics');
$('markersPane').classList.toggle('hidden', tab !== 'markers');
if (tab === 'lyrics') tick(now(), true);
}
panel.querySelectorAll('.notes-tab').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab)));
$('lyricsEditBtn').addEventListener('click', () => openEditor(null));
$('lyricsSyncBtn').addEventListener('click', startSync);
$('lyricsHistoryBtn').addEventListener('click', () => openHistory('lyrics'));
$('chaptersHistoryBtn').addEventListener('click', () => openHistory('chapters'));
$('lyricsStampBtn').addEventListener('click', (e) => { e.currentTarget.blur(); stamp(); });
$('lyricsUndoBtn').addEventListener('click', undoStamp);
$('lyricsSyncCancel').addEventListener('click', endSync);
$('lyricsSyncDone').addEventListener('click', finishSync);
const nudge = (d) => {
if (!st.videoId) return;
if (!data.lyricOffsets) data.lyricOffsets = {};
const v = Math.round((personalOffset() + d) * 10) / 10;
if (v) data.lyricOffsets[st.videoId] = v;
else delete data.lyricOffsets[st.videoId];
persist();
renderOffset();
tick(now(), true);
};
$('lyrOffMinus').addEventListener('click', () => nudge(-0.5));
$('lyrOffPlus').addEventListener('click', () => nudge(0.5));
$('lyrAutoscroll').addEventListener('change', (e) => {
data.settings.lyricsAutoscroll = e.target.checked;
persist();
if (e.target.checked) { st.lastUserScroll = 0; tick(now(), true); }
});
const list = $('lyricsList');
['wheel', 'touchstart', 'touchmove'].forEach((ev) =>
list.addEventListener(ev, () => { st.lastUserScroll = Date.now(); }, { passive: true }));
list.addEventListener('click', (e) => {
const act = e.target.closest('[data-act]');
if (act) {
if (act.dataset.act === 'paste') openEditor('');
else if (act.dataset.act === 'captions') openEditor('', { importCaptions: true });
return;
}
const row = e.target.closest('[data-i]');
if (!row) return;
const i = Number(row.dataset.i);
if (st.sync) { st.sync.ptr = i; renderSyncPointer(); return; }
const l = lines()[i];
if (l && l.t !== null && l.t !== undefined) {
st.lastUserScroll = 0;
Player.seek(Math.max(0, l.t + offset()));
}
});
$('markerChips').addEventListener('click', (e) => {
const chip = e.target.closest('.marker-chip');
if (chip) Player.seek(Number(chip.dataset.t));
});
$('addChapterBtn').addEventListener('click', addChapter);
$('addBookmarkBtn').addEventListener('click', addBookmark);
$('exportMarkersBtn').addEventListener('click', openExport);
$('markersPane').addEventListener('click', (e) => {
const btn = e.target.closest('.marker-row [data-act]');
if (!btn) return;
const row = btn.closest('.marker-row');
const kind = row.dataset.kind;
const i = Number(row.dataset.i);
const item = (kind === 'chapter' ? chapters() : bookmarks())[i];
if (btn.dataset.act === 'jump' && item) Player.seek(item.t);
else if (btn.dataset.act === 'edit') editMarker(kind, i);
else if (btn.dataset.act === 'del') deleteMarker(kind, i);
});
return { load, tick, addBookmark, handleSyncKey };
})();
function updateProgress() {
const cur = Player.master.currentTime || 0;
const dur = Player.master.duration || current?.meta?.duration || 0;
@@ -2270,6 +2993,7 @@ function updateProgress() {
if (dur) els.seek.value = String((cur / dur) * 1000);
updateMiniBar();
updateServiceProgress(cur, dur);
Notes.tick(cur);
// A-B loop: passing B behaves like the track ending. Loop the A-B segment only
// when "loop current" is on; otherwise B is the effective end of the track and
// we advance per the repeat settings (next track, or wrap when "repeat list").
@@ -4833,7 +5557,9 @@ document.querySelectorAll('.chip').forEach((c) => {
// Keyboard
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
// Tap-sync owns Enter/Backspace while it runs (see lyrics tap-sync).
if (Notes.handleSyncKey(e)) return;
if (e.code === 'Space') { e.preventDefault(); Player.toggle(); }
else if (e.code === 'ArrowRight') Player.seek(Player.master.currentTime + 5);
else if (e.code === 'ArrowLeft') Player.seek(Math.max(0, Player.master.currentTime - 5));
@@ -4845,6 +5571,7 @@ document.querySelectorAll('.chip').forEach((c) => {
else if (e.key === 'q') { if (current && current.meta) addToQueue(current.meta); }
else if (e.key === 'a') { if (current) setAbA(); }
else if (e.key === 'b') { if (current) setAbB(); }
else if (e.key === 'p') { if (current) Notes.addBookmark(); }
else if (e.key === '?') toggleShortcutHelp();
else if (e.key === 'Escape') {
if (isServiceModeOpen()) exitServiceMode();
@@ -5208,6 +5935,8 @@ function importBackup(e) {
Object.assign(data.rememberPos, imp.rememberPos || {});
for (const [id, cnt] of Object.entries(imp.playCount || {})) data.playCount[id] = Math.max(data.playCount[id] || 0, cnt);
Object.assign(data.abMarkers, imp.abMarkers || {});
Object.assign(data.bookmarks, imp.bookmarks || {});
Object.assign(data.lyricOffsets, imp.lyricOffsets || {});
persist();
closeModal();
render();
@@ -5467,6 +6196,8 @@ async function boot() {
rememberPos: loaded.rememberPos || {},
playCount: loaded.playCount || {},
abMarkers: loaded.abMarkers || {},
bookmarks: loaded.bookmarks || {},
lyricOffsets: loaded.lyricOffsets || {},
lastAutoBackup: loaded.lastAutoBackup || 0,
settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) },
profile: loaded.profile || null,

View File

@@ -141,6 +141,7 @@
<span id="curTime" class="time">0:00</span>
<div class="seek-wrap">
<input id="seek" class="seek" type="range" min="0" max="1000" value="0" />
<div id="seekMarkers" class="seek-markers" aria-hidden="true"></div>
<div id="abIndicator" class="ab-indicator hidden">
<span id="abALabel" class="ab-label">A: --</span>
<span style="flex:1"></span>
@@ -209,6 +210,58 @@
</div>
</div>
<!-- Lyrics · chapters · bookmarks (shared notes, notes.js on the server) -->
<div id="notesPanel" class="notes-panel hidden">
<div class="notes-tabs" role="tablist">
<button class="notes-tab active" data-tab="lyrics" role="tab" type="button">Lyrics</button>
<button class="notes-tab" data-tab="markers" role="tab" type="button">Chapters &amp; bookmarks <span id="markersCount" class="notes-count"></span></button>
<span id="notesStatus" class="notes-status"></span>
</div>
<div id="lyricsPane" class="notes-pane">
<div class="lyrics-toolbar">
<button id="lyricsEditBtn" class="np-btn" type="button" title="Edit the shared lyrics">✎ Edit</button>
<button id="lyricsSyncBtn" class="np-btn" type="button" title="Tap along to time each line">⏱ Tap-sync</button>
<button id="lyricsHistoryBtn" class="np-btn" type="button" title="Earlier versions">🕘 History</button>
<span class="notes-spacer"></span>
<span class="lyr-offset" title="Your personal sync offset for this song — positive shows lyrics later">
<button id="lyrOffMinus" class="lyr-off-btn" type="button">−0.5s</button>
<span id="lyrOffVal" class="lyr-off-val">0.0s</span>
<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>
</div>
<div id="lyricsSyncBar" class="lyrics-syncbar hidden">
<span id="lyricsSyncHint" class="lyrics-sync-hint"></span>
<button id="lyricsStampBtn" class="np-btn primary-btn" type="button">Stamp line ⏎</button>
<button id="lyricsUndoBtn" class="np-btn" type="button">↶ Undo</button>
<button id="lyricsSyncCancel" class="np-btn" type="button">Cancel</button>
<button id="lyricsSyncDone" class="np-btn" type="button">✓ Save timings</button>
</div>
<div id="lyricsTags" class="lyrics-tags"></div>
<div id="lyricsList" class="lyrics-list"></div>
</div>
<div id="markersPane" class="notes-pane hidden">
<div id="markerChips" class="marker-chips"></div>
<div class="markers-toolbar">
<button id="addChapterBtn" class="np-btn" type="button">+ Chapter</button>
<button id="addBookmarkBtn" class="np-btn" type="button" title="Pin a private bookmark here [P]">📌 Bookmark</button>
<span class="notes-spacer"></span>
<button id="chaptersHistoryBtn" class="np-btn" type="button" title="Earlier versions of the shared chapters">🕘</button>
<button id="exportMarkersBtn" class="np-btn" type="button">⤓ Export</button>
</div>
<div class="markers-section">
<div class="markers-heading">Chapters <span class="markers-scope">shared with everyone</span></div>
<div id="chapterList" class="marker-list"></div>
</div>
<div class="markers-section">
<div class="markers-heading">My bookmarks <span class="markers-scope">only you</span></div>
<div id="bookmarkList" class="marker-list"></div>
</div>
</div>
</div>
<!-- Up next queue -->
<div id="upnext" class="upnext hidden">
<div class="upnext-header">
@@ -357,6 +410,7 @@
<div class="shortcut-row"><kbd>Q</kbd><span>Add current video to queue</span></div>
<div class="shortcut-row"><kbd>A</kbd><span>Set A-B loop start point</span></div>
<div class="shortcut-row"><kbd>B</kbd><span>Set A-B loop end point</span></div>
<div class="shortcut-row"><kbd>P</kbd><span>Pin a bookmark at the current time</span></div>
<div class="shortcut-row"><kbd>?</kbd><span>Show this help</span></div>
<div class="shortcut-row"><kbd>Esc</kbd><span>Close overlay / cancel selection</span></div>
</div>
@@ -366,6 +420,7 @@
<script src="fingerprint.js"></script>
<script src="opfs.js"></script>
<script src="video-edit.js"></script>
<script src="lyrics-core.js"></script>
<script src="async-guard.js"></script>
<script src="sw-update.js"></script>
<script src="app.js"></script>

165
frontend/lyrics-core.js Normal file
View File

@@ -0,0 +1,165 @@
/* ============================================================================
* lyrics-core.js — pure helpers for synced lyrics, chapters and bookmarks
*
* No DOM. Used by app.js (window.LyricsCore) and unit-tested under node.
*
* Editor text format (a superset of LRC, so pasted .lrc files just work):
* [0:18.00] Worthy of every song we could ever sing timed line
* Worthy of all the praise untimed line
* [0:18] # Verse 1 section header
* ! Full dynamic swell into Bridge band cue
* @ Key G, Capo 2 (Play F), 70 BPM tags
* [00:12.00][01:30.00] repeated line LRC multi-stamp
* [offset:+500] LRC offset (ms, + = sooner)
* Other LRC headers ([ar:], [ti:], …) are ignored.
*
* Sync offset: a line is shown at (t + offset) seconds of playback, so a
* positive offset makes the lyrics appear later.
* ========================================================================== */
(function (root) {
'use strict';
const STAMP_RE = /^\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/;
const HEADER_RE = /^\[([a-z]+):([^\]]*)\]$/i;
function parseStamp(str) {
const m = String(str || '').trim().match(/^(?:(\d+):)?(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?$/);
if (!m) return null;
const h = Number(m[1] || 0);
const frac = m[4] ? Number('0.' + m[4]) : 0;
return h * 3600 + Number(m[2]) * 60 + Number(m[3]) + frac;
}
// 83.456 → "1:23.46" (editor stamps keep hundredths).
function fmtStamp(t) {
const cs = Math.round(Math.max(0, Number(t) || 0) * 100);
const m = Math.floor(cs / 6000);
const s = Math.floor((cs % 6000) / 100);
const c = cs % 100;
return `${m}:${String(s).padStart(2, '0')}.${String(c).padStart(2, '0')}`;
}
// 83.9 → "1:23"; ≥1 h → "1:02:03" (YouTube chapter / display form).
function fmtClock(t) {
const total = Math.max(0, Math.floor(Number(t) || 0));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return h
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${m}:${String(s).padStart(2, '0')}`;
}
function splitTags(s) {
return String(s).split(/[,|·]/).map((x) => x.trim()).filter(Boolean);
}
// Editor text → { lines, tags, offset }.
function fromText(text) {
const lines = [];
const tags = [];
let offset = null;
for (const rawLine of String(text || '').replace(/\r/g, '').split('\n')) {
let rest = rawLine.trim();
if (!rest) continue;
const header = rest.match(HEADER_RE);
if (header && !/^\d/.test(header[1])) {
if (header[1].toLowerCase() === 'offset') {
const ms = Number(header[2]);
if (Number.isFinite(ms)) offset = Math.round((-ms / 1000) * 100) / 100;
}
continue;
}
const stamps = [];
let m;
while ((m = rest.match(STAMP_RE))) {
const frac = m[3] ? Number('0.' + m[3]) : 0;
stamps.push(Number(m[1]) * 60 + Number(m[2]) + frac);
rest = rest.slice(m[0].length).trim();
}
if (rest.startsWith('@')) { tags.push(...splitTags(rest.slice(1))); continue; }
let kind = 'line';
if (rest.startsWith('#')) { kind = 'section'; rest = rest.replace(/^#+/, '').trim(); }
else if (rest.startsWith('!')) { kind = 'cue'; rest = rest.slice(1).trim(); }
if (!rest) continue;
if (!stamps.length) lines.push({ t: null, text: rest, kind });
for (const t of stamps) lines.push({ t: Math.round(t * 100) / 100, text: rest, kind });
}
// A fully timed sheet (typical .lrc with repeated-chorus stamps) is put in
// time order; a partly timed one keeps the order it was written in.
if (lines.length && lines.every((l) => l.t !== null)) {
lines.sort((a, b) => a.t - b.t);
}
return { lines, tags, offset };
}
function toText(doc) {
const out = [];
if (doc && Array.isArray(doc.tags) && doc.tags.length) out.push('@ ' + doc.tags.join(', '));
for (const l of (doc && doc.lines) || []) {
const stamp = l.t === null || l.t === undefined ? '' : `[${fmtStamp(l.t)}] `;
const mark = l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '';
out.push(stamp + mark + l.text);
}
return out.join('\n');
}
// Index (into lines) of the sung line playing at `time`, or -1. Sections
// and cues are never "the active line" — the highlight follows the words.
function activeIndex(lines, time, offset) {
const now = Number(time) - (Number(offset) || 0);
let best = -1;
let bestT = -Infinity;
(lines || []).forEach((l, i) => {
if (l.kind !== 'line' || l.t === null || l.t === undefined) return;
if (l.t <= now && l.t >= bestT) { best = i; bestT = l.t; }
});
return best;
}
// The section header that the active position falls under (for "Chorus in
// progress"-style labels), or null.
function activeSection(lines, time, offset) {
const now = Number(time) - (Number(offset) || 0);
let best = null;
for (const l of lines || []) {
if (l.kind === 'section' && l.t !== null && l.t !== undefined && l.t <= now) {
if (!best || l.t >= best.t) best = l;
}
}
return best;
}
function timedCount(lines) {
return (lines || []).filter((l) => l.kind === 'line' && l.t !== null && l.t !== undefined).length;
}
// Chapters / bookmarks: [{t, title, note}] sorted by t.
function activeMarker(items, time) {
let best = null;
for (const it of items || []) if (it.t <= time && (!best || it.t >= best.t)) best = it;
return best;
}
// YouTube-description form: "0:00 Intro". YouTube only turns a list into
// chapters when the first one is at 0:00, so an "Start" entry is added when
// the list doesn't begin there.
function toYouTubeTimestamps(items, { ensureZero = true } = {}) {
const sorted = (items || []).slice().sort((a, b) => a.t - b.t);
const rows = sorted.map((it) => `${fmtClock(it.t)} ${it.title}`);
if (ensureZero && sorted.length && sorted[0].t >= 1) rows.unshift('0:00 Start');
return rows.join('\n');
}
const LyricsCore = {
parseStamp, fmtStamp, fmtClock, fromText, toText,
activeIndex, activeSection, timedCount, activeMarker, toYouTubeTimestamps,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = LyricsCore;
} else {
root.LyricsCore = LyricsCore;
}
})(typeof globalThis !== 'undefined' ? globalThis : this);

View File

@@ -0,0 +1,80 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const LC = require('./lyrics-core');
test('fromText: timed, untimed, sections, cues, tags', () => {
const doc = LC.fromText([
'@ Key G, Capo 2 (Play F) | 70 BPM',
'[0:18] # Verse 1',
'[0:18.00] Worthy of every song',
'an untimed line',
'! Drums in',
'',
].join('\n'));
assert.deepStrictEqual(doc.tags, ['Key G', 'Capo 2 (Play F)', '70 BPM']);
assert.deepStrictEqual(doc.lines, [
{ t: 18, text: 'Verse 1', kind: 'section' },
{ t: 18, text: 'Worthy of every song', kind: 'line' },
{ t: null, text: 'an untimed line', kind: 'line' },
{ t: null, text: 'Drums in', kind: 'cue' },
]);
assert.strictEqual(doc.offset, null);
});
test('fromText: LRC multi-stamps expand and a fully timed sheet is time-sorted', () => {
const doc = LC.fromText('[ti:Song]\n[ar:Band]\n[offset:+500]\n[00:30.00]b\n[00:10.00][00:50.00]chorus');
assert.deepStrictEqual(doc.lines.map((l) => [l.t, l.text]), [[10, 'chorus'], [30, 'b'], [50, 'chorus']]);
assert.strictEqual(doc.offset, -0.5); // LRC +500 ms = sooner = our -0.5 s
});
test('toText round-trips through fromText', () => {
const doc = {
tags: ['Key G'],
lines: [
{ t: 83.456, text: 'line one', kind: 'line' },
{ t: null, text: 'Bridge', kind: 'section' },
{ t: 90, text: 'swell', kind: 'cue' },
],
};
const text = LC.toText(doc);
assert.strictEqual(text, '@ Key G\n[1:23.46] line one\n# Bridge\n[1:30.00] ! swell');
const back = LC.fromText(text);
assert.deepStrictEqual(back.lines.map((l) => l.kind), ['line', 'section', 'cue']);
assert.strictEqual(back.lines[0].t, 83.46);
});
test('activeIndex follows sung lines only and honours the offset', () => {
const lines = [
{ t: 10, text: 'Verse', kind: 'section' },
{ t: 10, text: 'a', kind: 'line' },
{ t: null, text: 'untimed', kind: 'line' },
{ t: 20, text: 'b', kind: 'line' },
{ t: 25, text: 'cue', kind: 'cue' },
];
assert.strictEqual(LC.activeIndex(lines, 5, 0), -1);
assert.strictEqual(LC.activeIndex(lines, 12, 0), 1);
assert.strictEqual(LC.activeIndex(lines, 26, 0), 3);
// +2 s offset: the "b" line shows at 22 s of playback, not 20.
assert.strictEqual(LC.activeIndex(lines, 21, 2), 1);
assert.strictEqual(LC.activeIndex(lines, 22, 2), 3);
assert.strictEqual(LC.activeSection(lines, 12, 0).text, 'Verse');
assert.strictEqual(LC.timedCount(lines), 2);
});
test('clock and stamp formatting', () => {
assert.strictEqual(LC.fmtClock(83.9), '1:23');
assert.strictEqual(LC.fmtClock(3723), '1:02:03');
assert.strictEqual(LC.fmtStamp(5.5), '0:05.50');
assert.strictEqual(LC.parseStamp('1:23.5'), 83.5);
assert.strictEqual(LC.parseStamp('1:02:03'), 3723);
assert.strictEqual(LC.parseStamp('nope'), null);
});
test('YouTube export adds a 0:00 entry only when needed', () => {
const items = [{ t: 130, title: 'Key change → D' }, { t: 42, title: 'Intro' }];
assert.strictEqual(LC.toYouTubeTimestamps(items), '0:00 Start\n0:42 Intro\n2:10 Key change → D');
assert.strictEqual(LC.toYouTubeTimestamps([{ t: 0, title: 'Top' }]), '0:00 Top');
assert.strictEqual(LC.activeMarker(items, 60).title, 'Intro');
assert.strictEqual(LC.activeMarker(items, 10), null);
});

View File

@@ -1238,7 +1238,13 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
box-shadow: var(--shadow);
font-size: 13.5px;
font-weight: 500;
white-space: nowrap;
/* Short toasts stay on one line; longer ones wrap inside the viewport
instead of running off both edges on a phone. */
width: max-content;
max-width: min(560px, calc(100vw - 32px));
box-sizing: border-box;
white-space: normal;
text-align: center;
animation: toastIn 0.3s var(--ease);
pointer-events: auto;
}
@@ -3400,3 +3406,202 @@ html[data-layout="bento-hub"] .mini-bar-inner {
html[data-layout="living-hymnal"] .app { grid-template-columns: 220px 1fr; }
}
/* ============================================================================
* Lyrics · chapters · bookmarks panel (Notes in app.js)
* ========================================================================== */
.notes-panel {
margin-top: 16px;
background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.notes-tabs {
display: flex; align-items: center; gap: 4px;
padding: 6px 10px 0;
border-bottom: 1px solid var(--line-soft);
}
.notes-tab {
background: transparent; border: none; cursor: pointer;
color: var(--text-dim);
font-family: var(--mono); font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.14em;
padding: 9px 8px 10px;
border-bottom: 2px solid transparent;
transition: color 0.16s, border-color 0.16s;
}
.notes-tab:hover { color: var(--text-2); }
.notes-tab.active { color: var(--text); border-bottom-color: var(--accent); }
.notes-count {
display: inline-block; min-width: 16px; padding: 1px 5px; margin-left: 4px;
border-radius: 8px; background: var(--bg-3); color: var(--text-2);
font-size: 9px; letter-spacing: 0; text-align: center;
}
.notes-count:empty { display: none; }
.notes-status {
margin-left: auto; min-width: 0;
font-family: var(--mono); font-size: 10px; color: var(--accent-bright);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.notes-pane { padding: 12px 14px 14px; }
.notes-spacer { flex: 1; }
.lyrics-toolbar, .markers-toolbar {
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
margin-bottom: 10px;
}
.lyrics-toolbar .np-btn, .markers-toolbar .np-btn, .lyrics-syncbar .np-btn { padding: 6px 10px; font-size: 12px; }
.lyr-offset {
display: inline-flex; align-items: center; gap: 2px;
border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 2px; background: var(--bg-1);
}
.lyr-off-btn {
background: transparent; border: none; color: var(--text-2); cursor: pointer;
font-family: var(--mono); font-size: 11px; padding: 4px 6px; border-radius: 6px;
}
.lyr-off-btn:hover { background: var(--bg-3); color: var(--text); }
.lyr-off-val { font-family: var(--mono); font-size: 11px; color: var(--text-dim); min-width: 38px; text-align: center; }
.lyr-off-val.nonzero { color: var(--accent-bright); }
.lyr-auto { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-2); cursor: pointer; }
.lyrics-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.lyr-tag {
font-family: var(--mono); font-size: 11px; color: var(--text-2);
background: var(--bg-3); border: 1px solid var(--line); border-radius: 999px;
padding: 3px 9px;
}
.lyrics-list {
position: relative;
max-height: min(52vh, 460px);
overflow-y: auto;
overscroll-behavior: contain;
padding: 4px 2px 40vh 2px; /* tail room so the last line can centre */
scrollbar-width: thin;
}
.lyr-section {
margin: 14px 0 6px;
font-family: var(--mono); font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.16em; color: var(--accent);
}
.lyr-section:first-child { margin-top: 2px; }
.lyr-section-time { color: var(--text-dim); font-weight: 400; }
.lyr-line {
display: flex; gap: 12px; align-items: baseline;
padding: 6px 8px; border-radius: var(--radius-sm);
cursor: pointer; color: var(--text-2);
transition: background 0.2s var(--ease), color 0.2s var(--ease);
}
.lyr-line:hover { background: var(--bg-3); }
.lyr-line.untimed { cursor: default; }
.lyr-time { flex: none; width: 38px; font-family: var(--mono); font-size: 10px; color: var(--text-dim); }
.lyr-text { font-size: 15px; line-height: 1.45; }
.lyr-line.active { background: var(--bg-3); color: var(--text); }
.lyr-line.active .lyr-text { font-weight: 700; font-size: 17px; }
.lyr-line.active .lyr-time { color: var(--accent-bright); }
.lyr-cue {
display: flex; justify-content: space-between; gap: 10px;
margin: 8px 0; padding: 8px 10px; border-radius: var(--radius-sm);
border: 1px dashed var(--line); color: var(--text-2);
font-family: var(--mono); font-size: 11px; cursor: pointer;
}
.lyr-cue.soon { border-color: var(--accent); color: var(--text); }
.lyr-cue-in { color: var(--accent-bright); white-space: nowrap; }
.lyrics-syncbar {
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
margin-bottom: 10px; padding: 8px 10px;
border: 1px solid var(--accent); border-radius: var(--radius-sm);
background: var(--bg-1);
}
.lyrics-sync-hint { flex: 1 1 180px; min-width: 0; font-size: 12px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lyrics-syncbar .primary-btn { border-color: var(--accent); color: var(--text); background: var(--accent-deep); }
.notes-panel.syncing .lyr-line { cursor: pointer; }
.lyrics-list .sync-ptr { outline: 2px solid var(--accent); outline-offset: -2px; background: var(--bg-3); color: var(--text); }
.notes-empty { padding: 18px 6px; text-align: center; color: var(--text-2); font-size: 13px; }
.notes-empty.small { padding: 8px 4px; text-align: left; font-size: 12px; color: var(--text-dim); }
.notes-empty-actions { display: flex; justify-content: center; flex-wrap: wrap; gap: 8px; margin: 12px 0 8px; }
.notes-empty-hint { font-size: 11px; color: var(--text-dim); }
.marker-chips { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 6px; scrollbar-width: thin; }
.marker-chip {
flex: none; cursor: pointer;
border: 1px solid var(--line); background: var(--bg-1); color: var(--text-2);
border-radius: 999px; padding: 5px 11px; font-size: 12px; white-space: nowrap;
transition: border-color 0.16s, color 0.16s;
}
.marker-chip:hover { color: var(--text); border-color: var(--text-dim); }
.marker-chip.active { border-color: var(--accent); color: var(--text); background: var(--bg-3); }
.marker-chip-time { font-family: var(--mono); font-size: 10px; color: var(--accent-bright); }
.marker-chip.bookmark .marker-chip-time { color: #7ec8ff; }
.markers-section { margin-top: 12px; }
.markers-heading {
font-family: var(--mono); font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-dim);
margin-bottom: 6px;
}
.markers-scope { font-weight: 400; letter-spacing: 0.04em; text-transform: none; margin-left: 6px; }
.marker-row {
display: flex; align-items: center; gap: 8px;
padding: 7px 6px; border-bottom: 1px solid var(--line-soft);
}
.marker-row:last-child { border-bottom: none; }
.mk-time {
flex: none; width: 52px; text-align: left; cursor: pointer;
background: transparent; border: none; padding: 0;
font-family: var(--mono); font-size: 12px; color: var(--accent-bright);
}
.marker-row[data-kind="bookmark"] .mk-time { color: #7ec8ff; }
.mk-body { flex: 1; min-width: 0; }
.mk-title { font-size: 13px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mk-note { font-size: 11px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mk-btn {
flex: none; background: transparent; border: 1px solid transparent; border-radius: 7px;
color: var(--text-dim); cursor: pointer; padding: 4px 6px; font-size: 12px;
}
.mk-btn:hover { color: var(--text); border-color: var(--line); background: var(--bg-3); }
.seek-markers { position: absolute; left: 0; right: 0; top: 0; height: 0; pointer-events: none; }
.seek-tick {
position: absolute; top: 2px; width: 2px; height: 10px; margin-left: -1px;
border-radius: 1px; background: var(--accent-bright); opacity: 0.85;
}
.seek-tick.bookmark { background: #7ec8ff; }
/* Modals opened by the notes panel */
.lyr-editor-help, .modal-text { margin: 0 0 10px; font-size: 12px; line-height: 1.5; color: var(--text-2); }
.lyr-editor-help code { font-family: var(--mono); font-size: 11px; color: var(--text); }
.lyr-editor-note { margin: 0 0 8px; font-size: 12px; color: var(--accent-bright); }
.lyr-editor-text, .mx-text {
width: 100%; min-height: min(46vh, 360px); resize: vertical; box-sizing: border-box;
background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
border-radius: var(--radius-sm); padding: 10px;
font-family: var(--mono); font-size: 12px; line-height: 1.55;
}
.mx-text { min-height: 180px; }
.lyr-editor-tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.mx-inc { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-2); margin-bottom: 8px; }
.marker-form { display: flex; flex-direction: column; gap: 10px; }
.marker-form label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: var(--text-dim); }
.marker-form input {
background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
border-radius: var(--radius-sm); padding: 9px 10px; font-size: 14px;
}
.notes-history { display: flex; flex-direction: column; gap: 2px; max-height: 50vh; overflow-y: auto; }
.hist-row { display: flex; align-items: center; gap: 10px; padding: 7px 2px; border-bottom: 1px solid var(--line-soft); }
.hist-main { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; font-size: 12px; }
.hist-rev { font-family: var(--mono); color: var(--accent-bright); }
.hist-who { color: var(--text); }
.hist-src, .hist-when { color: var(--text-dim); font-size: 11px; }
.hist-live { font-family: var(--mono); font-size: 10px; color: #7ee0a8; }
@media (max-width: 560px) {
.notes-pane { padding: 10px 10px 12px; }
.lyr-text { font-size: 14px; }
.lyr-line.active .lyr-text { font-size: 16px; }
.lyr-auto { margin-left: auto; }
.notes-status { display: none; }
}

View File

@@ -61,6 +61,8 @@ const SHELL = [
'/fingerprint.js',
'/opfs.js',
'/opfs-worker.js',
'/video-edit.js',
'/lyrics-core.js',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',
@@ -143,6 +145,9 @@ self.addEventListener('fetch', (e) => {
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
if (request.method !== 'GET' && request.method !== 'HEAD') return;
// The admin page is online-only and must never be served from a cache.
if (url.pathname === '/admin' || url.pathname.startsWith('/admin.')) return;
// API calls and binary downloads → network only, no caching
if (url.pathname.startsWith('/api/')) {
e.respondWith(fetch(request));