diff --git a/CLAUDE.md b/CLAUDE.md index b9ad66f..c9ab9cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ The PWA is what runs in production; `legacy/` holds the old native-only docs. | `tests/` | Playwright e2e specs · unit tests live next to sources in `frontend/*.test.js` | | `legacy/` | Old native-shell docs — do not treat as current | -API endpoints: `GET /api/search|channel|streams|download/:id|version|user/data|profile/load`, +API endpoints (notes/admin routes are listed in the header of `server/notes.js`): `GET /api/search|channel|streams|download/:id|version|user/data|profile/load`, `GET /api/media/:id|media/:id/status|media/stats`, `POST /api/media/:id/redownload`, `POST /api/user/sync|profile/create|profile/save`, `GET /sw.js` (BUILD_TAG-injected), `GET /*` static. JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone @@ -68,6 +68,36 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone - Ops: `curl https://worship.hesed.sbs/api/media/stats`; files under `/app/data/media` in the container. +## Lyrics · chapters · bookmarks (`server/notes.js`, `frontend/lyrics-core.js`) +- **Shared per video** (every user sees the same copy): `video_notes` holds the live + lyrics / chapters doc per `(video_id, kind)`; **every save** also lands in + `video_note_revs` as a full snapshot (history + undo). A daily JSON dump of all + live notes goes to `/backups/notes-YYYY-MM-DD.json` (30 kept). +- **Who writes**: a user linked to an online profile (body `profile` must exist — + same trust level as the profile API), a script with an API token + (`Authorization: Bearer ytp_…`, only a SHA-256 is stored), or the admin + (signed httpOnly cookie). Saves are optimistic: client sends `baseRev`, gets + 409 + `current` if someone saved in between (UI asks "Keep mine / Load theirs"). +- **Personal, not shared**: bookmarks (`data.bookmarks[videoId]`) and the per-song + sync offset (`data.lyricOffsets[videoId]`) — both ride the profile sync. + Line shows at `t + doc.offset + personal offset` (positive = later). +- Editor text format = LRC superset: `[1:23.45] line`, `# Section`, `! band cue`, + `@ Key G, 70 BPM` tags, multi-stamp LRC lines and `[offset:±ms]` accepted. +- Captions: `GET /api/notes/:id/captions` (preview) and token/admin-only + `POST /api/notes/:id/lyrics/auto` pick human subs first, else the auto track in + the spoken language (`*-orig`), never a machine translation. Auto captions of + **music** are mostly junk ("oh", "n to") — expect to hand-fix or paste lyrics. +- **Admin**: `/admin` (frontend/admin.html, never cached by the SW) — tokens, + edit feed with view/restore, caption injection, JSON export, API examples. + Disabled unless `ADMIN_PASSWORD` is set (Dokploy Environment tab; compose passes + `${ADMIN_PASSWORD:-}`). Login is rate-limited per IP. +- Offline: the last-seen notes per video are cached in localStorage + (`ytpNotesCache`, 200 videos), so saved songs keep their lyrics offline. +- **Toast cap bug (fixed with this feature):** `toast()` used + `while (children.length > 3)` with a deferred remove — the 4th toast spun + forever and Chrome killed the tab ("Target crashed" in Playwright, no JS + error). Never cap with a loop whose exit depends on a deferred removal. + ## Local dev ```bash @@ -192,7 +222,10 @@ Local DB file: `server/data/ytplayer.db` (gitignored). `BUILD_TAG` is computed f ## Testing - Unit: **`node --test frontend/*.test.js`** (sw, sw-update, async-guard, video-edit). - Server: **`cd server && bun test`** (media-cache: real ffmpeg fixtures + a temp libsql DB). + Server: **`cd server && bun run test`** — runs each file in its own process (db.js is a + singleton, so two test files in one `bun test` run share one temp DB; bare `bun test` + also drags in the frontend node tests via the `public` symlink). media-cache needs a + 60 s hook timeout for its ffmpeg fixtures. Local e2e needs a CURRENT yt-dlp — a 2-month-old one 403s on every download. The directory form `node --test frontend/` fails on Node 22 with `Cannot find module .../frontend` — it resolves the dir as a module, not a test diff --git a/docker-compose.yml b/docker-compose.yml index 6c7144f..99a6cbf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ services: PORT: "3000" DB_PATH: "/app/data/ytplayer.db" APP_VERSION: "1.0.0" + # Unlocks /admin (API tokens, lyric/chapter history + restore). Set it in + # Dokploy's Environment tab — never commit it. Unset = admin disabled. + ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}" # Optional: override yt-dlp binary path if you mount a custom one # YTDLP_PATH: "/usr/local/bin/yt-dlp" # Server media cache (server/media-cache.js) — defaults shown. diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..5f0aa61 --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,320 @@ + + + + + + + YT Player Admin + + + +
+
+

YT Player Admin

+ + ← App + +
+ + + + + + +
+ + + diff --git a/frontend/app.js b/frontend/app.js index c798fb8..8a8e3e4 100755 --- a/frontend/app.js +++ b/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) => `${escapeHtml(t)}`).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 = ` +
+
No lyrics yet for this song.
+
+ + +
+
Lyrics and chapters are shared with everyone who plays this song.
+
`; + 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 `
${escapeHtml(l.text)}${time ? ` · ${time}` : ''}
`; + } + if (l.kind === 'cue') { + return `
🥁 ${escapeHtml(l.text)}
`; + } + return `
${time}${escapeHtml(l.text)}
`; + }).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) => ` + `).join(''); + $('markerChips').classList.toggle('hidden', !chips.length); + + const row = (m, kind, i) => ` +
+ +
+
${escapeHtml(m.title)}
+ ${m.note ? `
${escapeHtml(m.note)}
` : ''} +
+ + + +
`; + $('chapterList').innerHTML = chs.length + ? chs.map((c, i) => row(c, 'chapter', i)).join('') + : '
No chapters yet — tap + Chapter while the song plays.
'; + $('bookmarkList').innerHTML = bms.length + ? bms.map((b, i) => row(b, 'bookmark', i)).join('') + : '
No bookmarks — tap 📌 or press P.
'; + 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) => ``) + .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 = ``; + 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 = ` +

One lyric line per line. Optional: [1:23.45] times a line + (or time them later with Tap-sync), # Chorus is a section, ! Drums in a band cue, + @ Key G, Capo 2, 70 BPM adds tags. Pasted .lrc files work as-is.

+ ${note ? `

${escapeHtml(note)}

` : ''} + +
+ + +
`; + 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) => ` +
+
+ v${r.rev} + ${escapeHtml(r.updatedBy || 'unknown')} + ${escapeHtml(r.source)} + ${new Date(r.createdAt * 1000).toLocaleString()} +
+ ${r.rev === live ? 'current' : ``} +
`).join('') : '
No history yet.
'; + 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 = ` + + + `; + 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 = ` + + + `; + 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, diff --git a/frontend/index.html b/frontend/index.html index 8e6b535..18804e1 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -141,6 +141,7 @@ 0:00
+
+ + +