Add shared synced lyrics, chapters and personal bookmarks with revision history, API tokens and an admin page
This commit is contained in:
745
frontend/app.js
745
frontend/app.js
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user