Files
ytplayer/frontend/admin.html

1220 lines
76 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex" />
<meta name="theme-color" content="#0a0c12" />
<title>Lyrics Studio</title>
<style>
:root {
color-scheme: dark;
--bg: #0a0c12; --bg-1: #10131b; --bg-2: #161a25; --bg-3: #1d2230; --bg-4: #262c3d;
--line: rgba(255,255,255,.08); --line-2: rgba(255,255,255,.14);
--text: #f2f4fa; --text-2: #b5bacb; --text-dim: #7b8299;
--a1: #7c5cff; --a2: #22d3ee; /* signature gradient */
--grad: linear-gradient(135deg, var(--a1), var(--a2));
--ok: #4ade80; --warn: #ffb454; --bad: #ff6b6b;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
--ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--nav-h: 62px;
--safe: env(safe-area-inset-bottom, 0px);
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html { scroll-padding-top: 150px; }
body {
margin: 0; min-height: 100dvh; color: var(--text); font: 15px/1.5 var(--ui);
background:
radial-gradient(900px 500px at 8% -10%, rgba(124,92,255,.16), transparent 60%),
radial-gradient(800px 480px at 100% 0%, rgba(34,211,238,.10), transparent 55%),
var(--bg);
background-attachment: fixed;
}
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; touch-action: manipulation; }
.hidden { display: none !important; }
.muted { color: var(--text-dim); }
code { font-family: var(--mono); font-size: 12.5px; }
a { color: var(--a2); }
/* ---------- chrome ---------- */
.top {
position: sticky; top: 0; z-index: 40; display: flex; align-items: center; gap: 14px;
padding: 10px 18px; background: rgba(10,12,18,.78);
-webkit-backdrop-filter: blur(16px) saturate(150%); backdrop-filter: blur(16px) saturate(150%);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; letter-spacing: -.02em; font-size: 17px; white-space: nowrap; }
.brand i { width: 30px; height: 30px; border-radius: 10px; background: var(--grad); display: grid; place-items: center; box-shadow: 0 6px 20px -6px var(--a1); }
.brand i svg { width: 17px; height: 17px; stroke: #fff; fill: none; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.tabs { display: flex; gap: 2px; margin-left: 14px; }
.tab {
position: relative; display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border: 0; border-radius: 11px;
background: transparent; color: var(--text-2); font-weight: 650; font-size: 14px; transition: background .15s, color .15s;
}
.tab svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; flex: none; }
.tab:hover { background: rgba(255,255,255,.05); color: var(--text); }
.tab[aria-selected="true"] { color: #fff; background: rgba(124,92,255,.22); box-shadow: inset 0 0 0 1px rgba(124,92,255,.5); }
.badge {
min-width: 19px; height: 19px; padding: 0 6px; border-radius: 99px; display: inline-grid; place-items: center;
background: var(--warn); color: #1a1206; font: 800 11px/1 var(--ui);
}
.grow { flex: 1; }
.ghost {
height: 36px; padding: 0 13px; border-radius: 10px; border: 1px solid var(--line-2); background: rgba(255,255,255,.04);
color: var(--text-2); font-weight: 600; font-size: 13px; text-decoration: none; display: inline-flex; align-items: center; gap: 6px;
}
.ghost:hover { color: var(--text); border-color: rgba(255,255,255,.28); }
main { max-width: 1040px; margin: 0 auto; padding: 22px 18px calc(var(--nav-h) + var(--safe) + 110px); }
.panel { display: none; animation: rise .28s ease both; }
.panel.on { display: block; }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
h1.page { font-size: clamp(24px, 4.4vw, 32px); letter-spacing: -.03em; margin: 4px 0 4px; }
p.lead { color: var(--text-2); margin: 0 0 20px; max-width: 62ch; }
.card {
background: linear-gradient(180deg, rgba(255,255,255,.045), rgba(255,255,255,.02)), var(--bg-1);
border: 1px solid var(--line); border-radius: 20px; padding: 18px; margin-bottom: 16px;
}
.card h2 { margin: 0 0 4px; font-size: 16px; letter-spacing: -.01em; }
.card > p.muted { margin: 0 0 14px; font-size: 13.5px; }
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.field {
flex: 1 1 200px; min-width: 0; height: 44px; padding: 0 14px; border-radius: 13px;
border: 1px solid var(--line-2); background: rgba(0,0,0,.28); font-size: 16px;
}
.field:focus, .ln-text:focus, .note:focus { outline: 2px solid var(--a1); outline-offset: 1px; }
.btn {
height: 44px; padding: 0 18px; border-radius: 13px; border: 1px solid var(--line-2); background: rgba(255,255,255,.06);
font-weight: 700; font-size: 14px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; text-decoration: none; color: var(--text);
transition: transform .12s, filter .15s, background .15s;
}
.btn:hover { background: rgba(255,255,255,.1); }
.btn:active { transform: scale(.97); }
.btn.pri { border: 0; background: var(--grad); color: #fff; box-shadow: 0 10px 26px -10px var(--a1); }
.btn.pri:hover { filter: brightness(1.1); }
.btn.ok { border: 0; background: var(--ok); color: #06210f; }
.btn.dng { color: var(--bad); border-color: rgba(255,107,107,.35); background: rgba(255,107,107,.07); }
.btn.sm { height: 34px; padding: 0 12px; font-size: 13px; border-radius: 10px; }
.btn[disabled] { opacity: .45; pointer-events: none; }
.msg { min-height: 1.3em; margin-top: 10px; font-size: 13.5px; color: var(--text-2); }
.msg.err { color: var(--bad); } .msg.ok { color: var(--ok); }
.tbl { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.tbl th { text-align: left; color: var(--text-dim); font: 700 11px/1 var(--ui); letter-spacing: .1em; text-transform: uppercase; padding: 8px 8px; border-bottom: 1px solid var(--line); }
.tbl td { padding: 10px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }
.scroll { overflow-x: auto; margin: 0 -4px; padding: 0 4px; }
pre { margin: 0; padding: 14px; border-radius: 14px; background: rgba(0,0,0,.35); border: 1px solid var(--line); overflow: auto; font: 12px/1.55 var(--mono); white-space: pre-wrap; word-break: break-word; max-height: 60vh; }
.newtoken { margin-top: 12px; padding: 12px; border: 1px solid var(--ok); border-radius: 12px; background: rgba(74,222,128,.07); word-break: break-all; }
.src { font: 11px var(--mono); color: var(--text-dim); } .src.api, .src.auto { color: var(--warn); } .src.restore { color: var(--ok); }
/* ---------- login ---------- */
.gate { max-width: 420px; margin: 12vh auto 0; text-align: center; }
.gate .brand { justify-content: center; margin-bottom: 18px; font-size: 22px; }
/* ---------- editor: picker + empty state ---------- */
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 18px 0 4px; }
.step { padding: 14px; border-radius: 16px; background: rgba(255,255,255,.035); border: 1px solid var(--line); }
.step b { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: var(--grad); color: #fff; font-size: 13px; margin-bottom: 8px; }
.step h3 { margin: 0 0 2px; font-size: 14px; } .step p { margin: 0; font-size: 12.5px; color: var(--text-dim); }
.songs { display: grid; gap: 6px; margin-top: 14px; max-height: 46vh; overflow-y: auto; }
.song-row { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 10px 12px; border-radius: 14px; border: 1px solid var(--line); background: rgba(255,255,255,.03); }
.song-row:hover { border-color: var(--a1); }
.song-row b { display: block; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.song-row small { color: var(--text-dim); font-size: 12px; }
.song-row .st { flex: none; font: 700 11px var(--ui); padding: 3px 9px; border-radius: 99px; border: 1px solid var(--line-2); color: var(--text-dim); }
.song-row .st.has { color: var(--ok); border-color: rgba(74,222,128,.4); }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.chip { height: 34px; padding: 0 13px; border-radius: 99px; border: 1px solid var(--line-2); background: rgba(255,255,255,.04); font-size: 13px; font-weight: 600; color: var(--text-2); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip:hover { color: var(--text); border-color: var(--a1); }
/* ---------- editor: song header + deck ---------- */
.song { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
.ring { --p: 0; width: 54px; height: 54px; border-radius: 50%; flex: none; display: grid; place-items: center;
background: conic-gradient(var(--a2) calc(var(--p) * 1%), rgba(255,255,255,.1) 0); position: relative; font: 700 12px var(--mono); }
.ring::before { content: ""; position: absolute; inset: 5px; border-radius: 50%; background: var(--bg-1); }
.ring span { position: relative; }
.song h1 { margin: 0; font-size: clamp(18px, 3.6vw, 24px); letter-spacing: -.02em; line-height: 1.2; overflow-wrap: anywhere; }
.song small { color: var(--text-dim); }
.deck {
position: sticky; top: 58px; z-index: 30; padding: 12px; margin: 0 -6px 14px; border-radius: 22px;
background: #11141e; border: 1px solid var(--line-2);
-webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px); box-shadow: 0 18px 40px -22px #000;
}
#wave { width: 100%; height: 64px; display: block; border-radius: 12px; background: rgba(0,0,0,.35); cursor: pointer; touch-action: none; }
.transport { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.round { width: 42px; height: 42px; border-radius: 50%; border: 1px solid var(--line-2); background: rgba(255,255,255,.06); display: grid; place-items: center; font-size: 12px; font-weight: 800; }
.round.big { width: 52px; height: 52px; border: 0; background: var(--grad); box-shadow: 0 10px 24px -8px var(--a1); }
.round svg { width: 20px; height: 20px; fill: #fff; }
.clock { font: 600 17px var(--mono); letter-spacing: -.02em; min-width: 108px; text-align: center; }
.clock b { color: var(--a2); } .clock span { color: var(--text-dim); font-size: 13px; }
.seg { display: inline-flex; padding: 3px; border-radius: 13px; background: rgba(0,0,0,.35); border: 1px solid var(--line); }
.seg button { height: 34px; padding: 0 14px; border: 0; border-radius: 10px; background: transparent; color: var(--text-2); font-weight: 700; font-size: 13px; }
.seg button[aria-pressed="true"] { background: var(--grad); color: #fff; }
select.mini { height: 34px; border-radius: 10px; border: 1px solid var(--line-2); background: rgba(0,0,0,.35); padding: 0 8px; font-size: 13px; }
.iconbtn { width: 38px; height: 38px; border-radius: 11px; border: 1px solid var(--line-2); background: rgba(255,255,255,.05); display: grid; place-items: center; }
.iconbtn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.iconbtn[disabled] { opacity: .3; pointer-events: none; }
.iconbtn[aria-pressed="true"] { background: rgba(124,92,255,.3); border-color: var(--a1); }
.drawer { display: grid; gap: 10px; padding: 14px; margin-bottom: 14px; border-radius: 18px; border: 1px dashed var(--line-2); background: rgba(255,255,255,.025); }
.drawer .row label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: 8px; }
/* ---------- editor: lines ---------- */
.lines { display: grid; gap: 8px; }
.ln { border-radius: 16px; border: 1px solid var(--line); background: rgba(255,255,255,.03); transition: background .2s, border-color .2s, box-shadow .2s; scroll-margin: 200px; }
.ln-main { display: flex; align-items: center; gap: 10px; padding: 8px 10px; }
.tc {
flex: none; width: 82px; height: 38px; border-radius: 11px; border: 1px solid var(--line-2); background: rgba(0,0,0,.32);
font: 700 13px var(--mono); color: var(--text-2);
}
.tc:hover { border-color: var(--a2); color: var(--text); }
.ln.untimed .tc { color: var(--warn); border-style: dashed; border-color: rgba(255,180,84,.55); }
.ln-text { flex: 1 1 auto; min-width: 0; height: 38px; padding: 0 8px; border: 0; border-radius: 9px; background: transparent; font-size: 16px; }
.ln.section .ln-text { font-weight: 800; text-transform: uppercase; letter-spacing: .06em; font-size: 13px; color: var(--a2); }
.ln.cue .ln-text { font-style: italic; color: var(--warn); }
.ln.now { background: rgba(124,92,255,.16); border-color: rgba(124,92,255,.55); box-shadow: 0 0 0 1px rgba(124,92,255,.25), 0 10px 30px -16px var(--a1); }
.ln.next { background: rgba(124,92,255,.06); }
.ln.sel { border-color: var(--a2); }
.ln.flash { animation: flash 1.6s ease; }
@keyframes flash { 0%,60% { box-shadow: 0 0 0 3px var(--warn); } 100% { box-shadow: 0 0 0 0 transparent; } }
.flagchip { flex: none; display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 9px; border-radius: 99px; border: 1px solid rgba(255,180,84,.55); background: rgba(255,180,84,.12); color: var(--warn); font: 800 12px var(--ui); }
.flagchip svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.ln.flagged { border-color: rgba(255,180,84,.5); background: rgba(255,180,84,.05); }
.more { flex: none; width: 34px; height: 34px; border-radius: 10px; border: 0; background: transparent; color: var(--text-dim); font-size: 20px; line-height: 1; }
.more:hover { background: rgba(255,255,255,.07); color: var(--text); }
.ln-tools { display: none; flex-wrap: wrap; gap: 8px; align-items: center; padding: 0 10px 10px 10px; }
.ln.sel .ln-tools { display: flex; }
.ln-tools .btn { height: 34px; padding: 0 12px; font-size: 13px; border-radius: 10px; }
.ln-tools input.tin { width: 88px; height: 34px; text-align: center; font: 600 13px var(--mono); border-radius: 10px; border: 1px solid var(--line-2); background: rgba(0,0,0,.35); }
.flagbox { display: grid; gap: 8px; margin: 0 10px 10px; padding: 12px; border-radius: 13px; background: rgba(255,180,84,.09); border: 1px solid rgba(255,180,84,.35); }
.rep { display: flex; gap: 10px; align-items: flex-start; font-size: 13.5px; }
.rep .who { font-weight: 800; color: var(--warn); }
.rep .why { display: inline-block; margin-left: 6px; padding: 1px 8px; border-radius: 99px; background: rgba(255,255,255,.08); font-size: 11.5px; color: var(--text-2); }
.rep .txt { color: var(--text-2); overflow-wrap: anywhere; }
.empty { padding: 34px 14px; text-align: center; color: var(--text-dim); border: 1px dashed var(--line-2); border-radius: 18px; }
#raw { width: 100%; min-height: 46vh; padding: 14px; border-radius: 16px; border: 1px solid var(--line-2); background: rgba(0,0,0,.35); font: 13px/1.6 var(--mono); }
/* ---------- bars ---------- */
.savebar {
position: fixed; left: 0; right: 0; bottom: calc(var(--nav-h) + var(--safe)); z-index: 45; display: flex; justify-content: center; padding: 0 12px 10px; pointer-events: none;
}
.savebar > div {
pointer-events: auto; display: flex; align-items: center; gap: 12px; width: min(760px, 100%); padding: 10px 10px 10px 16px; border-radius: 20px;
background: rgba(20,23,34,.92); border: 1px solid var(--line-2); box-shadow: 0 20px 50px -14px #000;
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
}
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--ok); flex: none; box-shadow: 0 0 10px var(--ok); }
.dirty .dot { background: var(--warn); box-shadow: 0 0 10px var(--warn); animation: pulse 1.4s infinite; }
@keyframes pulse { 50% { opacity: .35; } }
.savebar .stat { font-size: 13px; color: var(--text-2); flex: 1; min-width: 0; }
.tap {
position: fixed; right: 18px; bottom: calc(var(--nav-h) + var(--safe) + 84px); z-index: 46; width: 104px; height: 104px; border-radius: 50%; border: 0;
background: var(--grad); color: #fff; font: 800 15px var(--ui); letter-spacing: .04em; box-shadow: 0 18px 40px -8px var(--a1), inset 0 2px 0 rgba(255,255,255,.35);
display: grid; place-items: center; text-align: center; line-height: 1.15;
}
.tap small { display: block; font-weight: 600; opacity: .8; font-size: 11px; letter-spacing: 0; max-width: 84px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tap:active { transform: scale(.93); }
.nav { display: none; }
@media (max-width: 760px) {
.tabs, .top .ghost.desk { display: none; }
.top { padding: 10px 14px; }
.nav {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
height: calc(var(--nav-h) + var(--safe)); padding: 6px 6px var(--safe); background: rgba(12,14,21,.92);
-webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px); border-top: 1px solid var(--line-2);
}
.nav .tab { flex-direction: column; justify-content: center; gap: 3px; height: auto; padding: 4px 0; font-size: 10.5px; border-radius: 12px; }
.nav .tab .badge { position: absolute; top: 2px; left: 55%; }
.steps { grid-template-columns: 1fr; }
.ln-main { flex-wrap: wrap; }
.ln-text { flex: 1 1 60%; }
.tc { width: 74px; }
.deck { top: 54px; padding: 8px; margin: 0 -6px 10px; border-radius: 18px; }
#wave { height: 40px; }
.transport { gap: 6px; margin-top: 8px; }
.round { width: 36px; height: 36px; } .round.big { width: 44px; height: 44px; }
.clock { font-size: 15px; }
.transport .grow { display: none; }
select.mini, .seg button { height: 32px; }
.iconbtn { width: 34px; height: 34px; }
.song { margin-bottom: 10px; } .ring { width: 44px; height: 44px; }
.clock { min-width: 0; }
main { padding-top: 16px; }
}
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
</style>
</head>
<body>
<!-- ============ sign-in ============ -->
<div id="loginBox" class="gate hidden">
<div class="brand"><i><svg viewBox="0 0 24 24"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg></i>Lyrics Studio</div>
<div class="card">
<h2>Sign in</h2>
<p class="muted">Admin password</p>
<form id="loginForm" class="row">
<input class="field" type="password" id="pw" placeholder="Password" autocomplete="current-password" />
<button class="btn pri" type="submit">Sign in</button>
</form>
<div id="loginMsg" class="msg"></div>
</div>
</div>
<div id="disabledBox" class="gate hidden">
<div class="card"><h2>Admin disabled</h2>
<p class="muted">Set <code>ADMIN_PASSWORD</code> in the server environment (Dokploy → Environment) and redeploy.</p></div>
</div>
<div id="app" class="hidden">
<header class="top">
<div class="brand"><i><svg viewBox="0 0 24 24"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg></i>Lyrics Studio</div>
<nav class="tabs" role="tablist" id="tabsTop"></nav>
<span class="grow"></span>
<a class="ghost desk" href="/">← Back to app</a>
<button id="logoutBtn" class="ghost">Sign out</button>
</header>
<main>
<!-- ================= EDITOR ================= -->
<section id="p-editor" class="panel">
<div id="pickBox">
<h1 class="page">Time &amp; fix lyrics</h1>
<p class="lead">Play a song right here and stamp every line against what you actually hear. Everything is undoable and every save keeps the previous version.</p>
<div class="card">
<form id="loadForm" class="row">
<input class="field" type="text" id="songId" placeholder="Paste a YouTube link, video id or upl_… id" autocomplete="off" />
<button class="btn pri" type="submit">Open</button>
</form>
<div id="loadMsg" class="msg"></div>
<div id="recentChips" class="chips"></div>
<div class="row" style="margin-top:16px"><h2 style="margin:0;font-size:14px">Songs on the server</h2><span class="grow"></span>
<input class="field" id="songFilter" type="search" placeholder="Filter…" style="flex:0 1 200px;height:36px" /></div>
<p class="muted" style="margin:4px 0 0;font-size:12.5px">Downloaded videos and uploads. Songs that already have lyrics can be corrected here too.</p>
<div id="songList" class="songs"><div class="muted" style="padding:10px">Loading…</div></div>
<div class="steps">
<div class="step"><b>1</b><h3>Open a song</h3><p>Paste a link or pick one from your library.</p></div>
<div class="step"><b>2</b><h3>Play &amp; tap</h3><p>Switch to Tap-sync and hit the big button on each line as it is sung.</p></div>
<div class="step"><b>3</b><h3>Save</h3><p>Everyone gets the new timing instantly.</p></div>
</div>
</div>
</div>
<div id="studio" class="hidden">
<div class="song">
<div class="ring" id="ring"><span id="ringTxt">0/0</span></div>
<div style="min-width:0"><h1 id="songTitle"></h1><small id="songMeta"></small></div>
<span class="grow"></span>
<button class="ghost" id="closeSong" type="button">Change song</button>
</div>
<div class="deck">
<canvas id="wave" height="64"></canvas>
<div class="transport">
<button class="round" id="bBack" type="button" title="Back 5 s" aria-label="Back 5 seconds">−5</button>
<button class="round big" id="bPlay" type="button" aria-label="Play or pause"><svg viewBox="0 0 24 24"><path id="playIcon" d="M8 5v14l11-7z"/></svg></button>
<button class="round" id="bFwd" type="button" title="Forward 5 s" aria-label="Forward 5 seconds">+5</button>
<div class="clock"><b id="cur">0:00.00</b> <span>/ <span id="dur">0:00</span></span></div>
<select class="mini" id="rate" title="Playback speed" aria-label="Playback speed">
<option value="0.5">0.5×</option><option value="0.75">0.75×</option><option value="1" selected>1×</option><option value="1.25">1.25×</option><option value="1.5">1.5×</option>
</select>
<span class="grow"></span>
<div class="seg" role="group" aria-label="Mode">
<button id="mEdit" type="button" aria-pressed="true">Edit</button>
<button id="mTap" type="button" aria-pressed="false">Tap-sync</button>
</div>
<button class="iconbtn" id="bUndo" type="button" title="Undo (Ctrl/⌘ Z)" aria-label="Undo" disabled><svg viewBox="0 0 24 24"><path d="M9 14L4 9l5-5"/><path d="M4 9h10a6 6 0 010 12h-3"/></svg></button>
<button class="iconbtn" id="bRedo" type="button" title="Redo (Ctrl/⌘ Shift Z)" aria-label="Redo" disabled><svg viewBox="0 0 24 24"><path d="M15 14l5-5-5-5"/><path d="M20 9H10a6 6 0 000 12h3"/></svg></button>
<button class="iconbtn" id="bTools" type="button" title="More tools" aria-label="More tools" aria-pressed="false"><svg viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="19" cy="12" r="1.2"/></svg></button>
</div>
</div>
<div id="drawer" class="drawer hidden">
<div class="row">
<input class="field" id="tags" type="text" placeholder="Tags — e.g. Key G, 70 BPM" />
</div>
<div class="row">
<label>Shift every timed line by <input class="field" id="shiftBy" type="text" value="0.0" style="flex:0 0 84px;height:36px;text-align:center" /> s</label>
<button class="btn sm" id="shiftBtn" type="button">Apply</button>
<span class="grow"></span>
<button class="btn sm" id="webBtn" type="button" title="Fetch lyrics from LRCLIB">⤓ Pull from LRCLIB</button>
<button class="btn sm" id="rawBtn" type="button">✎ Edit as text</button>
<label><input type="checkbox" id="follow" checked /> Follow playback</label>
</div>
</div>
<div id="msg" class="msg"></div>
<div id="lines" class="lines"></div>
<textarea id="raw" class="hidden" spellcheck="false"></textarea>
<div class="row" style="margin-top:14px">
<button class="btn sm" id="addLine" type="button">+ Add line</button>
<span class="muted" style="font-size:12.5px">Shortcuts: <code>Space</code> stamp (Tap-sync) · <code>S</code> stamp selected · <code>K</code> play · <code>↑↓</code> select · <code>⌘/Ctrl S</code> save</span>
</div>
</div>
</section>
<!-- ================= REPORTS ================= -->
<section id="p-reports" class="panel">
<h1 class="page">Reported lines</h1>
<p class="lead">Listeners flag lines that look wrong. Fix one in the editor and its report closes itself; or mark it resolved if it was fine.</p>
<div class="row" style="margin-bottom:14px">
<div class="seg" role="group" aria-label="Status">
<button id="fOpen" type="button" aria-pressed="true">Open</button>
<button id="fRes" type="button" aria-pressed="false">Resolved</button>
</div>
<span class="grow"></span>
<button class="btn sm" id="repRefresh" type="button">↻ Refresh</button>
</div>
<div id="repList"></div>
</section>
<!-- ================= LIBRARY ================= -->
<section id="p-library" class="panel">
<h1 class="page">Library</h1>
<p class="lead">Upload your own audio or video, or pull lyrics straight from a YouTube video's captions.</p>
<div class="card">
<h2>Upload video / audio</h2>
<p class="muted">Title, artist, duration, cover art and embedded lyrics are read from the file. A cover image or manual title/artist override them.</p>
<form id="upForm" class="row">
<input class="field" type="file" id="upFile" accept="video/*,audio/*" required />
<input class="field" type="text" id="upTitle" placeholder="Title (optional)" />
<input class="field" type="text" id="upArtist" placeholder="Artist (optional)" />
<label class="muted" style="display:flex;gap:8px;align-items:center;font-size:13px">Cover <input type="file" id="upArt" accept="image/*" /></label>
<button class="btn pri" type="submit">Upload</button>
</form>
<div id="upProgress" class="msg"></div>
<div class="scroll"><table class="tbl" id="upTable"></table></div>
</div>
<div class="card">
<h2>Inject lyrics from captions</h2>
<p class="muted">Human subtitles first, else the auto captions in the spoken language, saved as the shared lyrics.</p>
<form id="injectForm" class="row">
<input class="field" type="text" id="injectId" placeholder="YouTube URL or video id" />
<label class="muted" style="font-size:13px"><input type="checkbox" id="injectOverwrite" /> Replace existing</label>
<button class="btn pri" type="submit">Inject</button>
</form>
<div id="injectMsg" class="msg"></div>
</div>
</section>
<!-- ================= PEER-TO-PEER ================= -->
<section id="p-peers" class="panel">
<h1 class="page">Peer-to-peer</h1>
<p class="lead">Devices share verified copies with each other. A file's hash is added only after the server itself hashed and validated it. Holders are never expired — stale ones are only marked.</p>
<div class="card">
<div class="row"><h2 style="margin:0">Overview</h2><span class="grow"></span><button class="btn sm" id="p2pRefreshBtn" type="button">↻ Refresh</button></div>
<div id="p2pSummary" class="msg"></div>
<div class="scroll"><table class="tbl" id="p2pTable"></table></div>
</div>
</section>
<!-- ================= HISTORY ================= -->
<section id="p-history" class="panel">
<h1 class="page">History</h1>
<p class="lead">Every save is 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.</p>
<div class="card">
<div class="row" style="margin-bottom:8px"><h2 style="margin:0">Recent edits</h2><span class="grow"></span>
<button class="btn sm" id="refreshBtn" type="button">↻ Refresh</button>
<a class="btn sm" href="/api/admin/notes/export">⤓ Export all (JSON)</a></div>
<div class="scroll"><table class="tbl" id="revTable"></table></div>
<pre id="revView" class="hidden" style="margin-top:12px"></pre>
</div>
</section>
<!-- ================= ACCESS ================= -->
<section id="p-access" class="panel">
<h1 class="page">API access</h1>
<p class="lead">Tokens let scripts write shared lyrics and chapters. A token is shown once — store it in your secrets vault.</p>
<div class="card">
<h2>Tokens</h2>
<form id="tokenForm" class="row" style="margin-top:10px">
<input class="field" type="text" id="tokenLabel" maxlength="60" placeholder="Label, e.g. lyrics-bot" />
<button class="btn pri" type="submit">Create token</button>
</form>
<div id="newToken" class="newtoken hidden"></div>
<div class="scroll" style="margin-top:8px"><table class="tbl" id="tokenTable"></table></div>
</div>
<div class="card"><h2 style="margin-bottom:10px">API reference</h2><pre id="apiDocs"></pre></div>
</section>
</main>
<div id="saveBar" class="savebar hidden"><div id="saveInner">
<span class="dot"></span><span class="stat" id="saveStat">All changes saved</span>
<button class="btn sm" id="discard" type="button">Discard</button>
<button class="btn pri" id="save" type="button">Save lyrics</button>
</div></div>
<button id="tapPad" class="tap hidden" type="button" aria-label="Stamp the next line">TAP<small id="tapNext">next line</small></button>
<nav class="nav" id="tabsBottom" role="tablist"></nav>
</div>
<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) || /^upl_[a-f0-9]{12}$/.test(s)) return s;
const m = s.match(/(?:v=|youtu\.be\/|shorts\/|embed\/|live\/)([A-Za-z0-9_-]{11})/) || s.match(/(upl_[a-f0-9]{12})/);
return m ? m[1] : null;
}
/* ---------------- tabs ---------------- */
const ICONS = {
editor: '<path d="M4 20h4L19 9a2.8 2.8 0 00-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/>',
reports: '<path d="M5 21V4M5 4h12l-2.5 4L17 12H5"/>',
library: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
peers: '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 10.9l7.4-3.8M8.3 13.1l7.4 3.8"/>',
history: '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
access: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
};
const TABS = [['editor', 'Editor'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['access', 'API']];
let tab = 'editor';
function buildTabs() {
const html = TABS.map(([k, l]) => `<button class="tab" role="tab" data-tab="${k}" aria-selected="false"><svg viewBox="0 0 24 24">${ICONS[k]}</svg><span>${l}</span>${k === 'reports' ? '<span class="badge hidden" data-badge>0</span>' : ''}</button>`).join('');
$('tabsTop').innerHTML = html; $('tabsBottom').innerHTML = html;
document.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab)));
}
function showTab(name, push = true) {
if (!$('p-' + name)) name = 'editor';
tab = name;
document.querySelectorAll('.panel').forEach((p) => p.classList.toggle('on', p.id === 'p-' + name));
document.querySelectorAll('[data-tab]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.tab === name)));
if (push) history.replaceState(null, '', location.pathname + location.search + '#' + name);
syncBars();
if (name === 'reports') loadReports();
if (name === 'history') loadRevs();
if (name === 'peers') loadP2p();
if (name === 'library') loadUploads();
if (name === 'access') loadTokens();
window.scrollTo({ top: 0 });
}
function setBadge(n) {
document.querySelectorAll('[data-badge]').forEach((b) => { b.textContent = n > 99 ? '99+' : String(n); b.classList.toggle('hidden', !n); });
}
function showLogin() {
$('loginBox').classList.remove('hidden');
$('app').classList.add('hidden');
}
/* ---------------- boot / login ---------------- */
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');
if (!$('tabsTop').children.length) buildTabs();
renderDocs();
loadRecent();
loadSongs();
refreshBadge();
const want = videoIdFrom(new URLSearchParams(location.search).get('v') || '');
const hash = location.hash.replace('#', '');
showTab(want ? 'editor' : (hash || 'editor'), false);
if (want) leOpen(want);
}
$('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(); });
/* ---------------- tokens / inject ---------------- */
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="btn sm dng" 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>${esc(j.token)}</code><div class="row" style="margin-top:10px"><button class="btn sm" id="copyTok" type="button">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 || !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}.`;
});
/* ---------------- history ---------------- */
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><div class="row" style="flex-wrap:nowrap">
<button class="btn sm" data-view="${esc(r.videoId)}|${esc(r.kind)}|${r.rev}">View</button>
${r.kind === 'lyrics' ? `<button class="btn sm" data-edit="${esc(r.videoId)}">Edit</button>` : ''}
<button class="btn sm" data-restore="${esc(r.videoId)}|${esc(r.kind)}|${r.rev}">Restore</button></div></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 b = e.target.closest('button'); if (!b) return;
if (b.dataset.edit) { leOpen(b.dataset.edit); return; }
if (b.dataset.view) {
const [id, kind, rev] = b.dataset.view.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 (b.dataset.restore) {
const [id, kind, rev] = b.dataset.restore.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);
/* ---------------- uploads ---------------- */
async function loadUploads() {
const j = await api('/api/uploads?limit=200');
const rows = (j.uploads || []).map((u) => `<tr>
<td>${u.art ? `<img src="/api/uploads/${u.id}/art" alt="" style="width:56px;height:32px;object-fit:cover;border-radius:6px" />` : '<span class="muted">—</span>'}</td>
<td>${esc(u.title)}<br><span class="muted">${esc(u.artist || '')}</span></td><td>${u.kind}</td>
<td class="muted">${Math.round(u.duration)}s · ${(u.size / 1048576).toFixed(1)} MB</td><td><code>${esc(u.id)}</code></td>
<td><div class="row" style="flex-wrap:nowrap"><a class="btn sm" href="/?v=${esc(u.id)}" target="_blank" rel="noopener">Play</a>
<button class="btn sm" data-lyrics="${esc(u.id)}">Lyrics</button>
<button class="btn sm dng" data-del="${esc(u.id)}">Delete</button></div></td></tr>`).join('');
$('upTable').innerHTML = rows ? `<tr><th></th><th>Title</th><th>Kind</th><th>Size</th><th>Id</th><th></th></tr>${rows}` : '<tr><td class="muted">Nothing uploaded yet.</td></tr>';
return j.uploads || [];
}
$('upForm').addEventListener('submit', (e) => {
e.preventDefault();
const f = $('upFile').files[0]; if (!f) return;
const qs = new URLSearchParams({ name: f.name });
if ($('upTitle').value.trim()) qs.set('title', $('upTitle').value.trim());
if ($('upArtist').value.trim()) qs.set('artist', $('upArtist').value.trim());
const cover = $('upArt').files[0];
const msg = $('upProgress'); msg.className = 'msg'; msg.textContent = `Uploading ${f.name}…`;
// The raw file is the request body (streamed from disk by the browser, to
// disk by the server) so hour-long, multi-GB videos need no memory.
const xhr = new XMLHttpRequest();
xhr.open('PUT', '/api/admin/uploads/stream?' + qs); xhr.withCredentials = true;
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
const t0 = Date.now();
xhr.upload.onprogress = (ev) => {
if (!ev.lengthComputable) return;
const pct = Math.round((ev.loaded / ev.total) * 100), mbps = ev.loaded / 1048576 / Math.max(1, (Date.now() - t0) / 1000);
msg.textContent = `Uploading ${f.name}… ${pct}% · ${(ev.loaded / 1048576).toFixed(0)} of ${(ev.total / 1048576).toFixed(0)} MB · ${mbps.toFixed(1)} MB/s${pct >= 100 ? ' — processing…' : ''}`;
};
xhr.onload = async () => {
let j = {}; try { j = JSON.parse(xhr.responseText); } catch { /* non-JSON */ }
if (xhr.status === 200 && j.ok) {
let artNote = j.upload.art ? ', cover art' : '';
if (cover) {
const ar = await fetch(`/api/admin/uploads/${j.upload.id}/art`, { method: 'PUT', credentials: 'same-origin', body: cover }).then((r) => r.json()).catch(() => ({}));
artNote = ar.ok ? ', cover art' : ', cover image unusable';
}
msg.className = 'msg ok';
msg.textContent = `Added “${j.upload.title}” (${j.upload.kind}${artNote}${j.lyricLines ? `, ${j.lyricLines} lyric lines` : ''}).`;
$('upForm').reset(); loadUploads(); loadRecent(); loadSongs();
} else { msg.className = 'msg err'; msg.textContent = j.error || (xhr.status === 413 ? 'File is too large (limit 4 GB)' : `Upload failed (HTTP ${xhr.status})`); }
};
xhr.onerror = () => { msg.className = 'msg err'; msg.textContent = 'Upload failed — network error'; };
xhr.send(f);
});
$('upTable').addEventListener('click', async (e) => {
const b = e.target.closest('button'); if (!b) return;
if (b.dataset.lyrics) { leOpen(b.dataset.lyrics); return; }
if (!b.dataset.del || !confirm('Delete this upload? The file is removed from the server.')) return;
const j = await api(`/api/admin/uploads/${encodeURIComponent(b.dataset.del)}`, { method: 'DELETE' });
if (!j.ok) alert(j.error || 'failed');
loadUploads(); loadRecent();
});
async function loadRecent() {
const j = await api('/api/uploads?limit=10');
$('recentChips').innerHTML = (j.uploads || []).map((u) => `<button type="button" class="chip" data-open="${esc(u.id)}">♪ ${esc(u.title)}</button>`).join('');
}
let SONGS = [];
async function loadSongs() {
const [m, u] = await Promise.all([api('/api/admin/media'), api('/api/uploads?limit=200')]);
const seen = new Set();
SONGS = [];
for (const r of (m.media || [])) { seen.add(r.id); SONGS.push({ id: r.id, title: r.title || r.id, sub: r.channel, lines: r.lyricsLines || 0 }); }
for (const r of (u.uploads || [])) if (!seen.has(r.id)) SONGS.push({ id: r.id, title: r.title, sub: r.artist || 'Upload', lines: -1 });
paintSongs();
}
function paintSongs() {
const q = $('songFilter').value.trim().toLowerCase();
const rows = SONGS.filter((r) => !q || (r.title + ' ' + (r.sub || '')).toLowerCase().includes(q));
$('songList').innerHTML = rows.length ? rows.map((r) => `<button type="button" class="song-row" data-open="${esc(r.id)}">
<span style="min-width:0;flex:1"><b>${esc(r.title)}</b><small>${esc(r.sub || '')}</small></span>
<span class="st ${r.lines > 0 ? 'has' : ''}">${r.lines > 0 ? `${r.lines} lines` : r.lines === 0 ? 'no lyrics' : 'open'}</span></button>`).join('')
: '<div class="muted" style="padding:10px">Nothing matches — paste a link above to open any YouTube song.</div>';
}
$('songFilter').addEventListener('input', paintSongs);
$('songList').addEventListener('click', (e) => { const b = e.target.closest('[data-open]'); if (b) leOpen(b.dataset.open); });
$('recentChips').addEventListener('click', (e) => { const b = e.target.closest('[data-open]'); if (b) leOpen(b.dataset.open); });
/* ---------------- p2p ---------------- */
async function loadP2p() {
const j = await api('/api/admin/p2p');
if (!j.ok) { $('p2pSummary').textContent = j.error || 'P2P unavailable'; return; }
const c = j.config, s = j.stats;
$('p2pSummary').textContent =
`P2P ${c.enabled ? 'ON' : 'OFF'} · malware scan ${c.malwareScan ? 'ON' : 'off'} · stale after ${c.staleDays} d · keep ≥${c.keepMinViews} views/${c.keepDays} d or played in ${c.keepRecentDays} d — ` +
`${s.content} verified (${s.revoked} revoked) · ${s.devices} devices, ${s.online} online · ${s.holders} holdings over ${s.heldCids} files · intake ${s.intakeActive} running / ${s.intakeOpen} open`;
const mb = (b) => (Number(b) / 1048576).toFixed(1) + ' MB';
$('p2pTable').innerHTML = '<tr><th>Video</th><th>cid</th><th>Origin</th><th>Scan</th><th>Size</th><th>Verified</th><th>Holders</th><th></th></tr>' +
j.recent.map((r) => {
let title = ''; try { title = JSON.parse(r.meta || '{}').title || ''; } catch { /* no meta */ }
return `<tr${r.status === 'revoked' ? ' class="muted"' : ''}><td>${esc(title || r.video_id)}<br><small>${esc(r.video_id)}</small></td>` +
`<td><code>${esc(r.cid.slice(0, 12))}</code></td><td>${esc(r.origin)}</td><td>${esc(r.scan)}</td><td>${mb(r.size)}</td>` +
`<td>${esc(new Date(Number(r.verified_at)).toLocaleString())}</td><td>${r.holders}</td>` +
`<td>${r.status === 'verified' ? `<button class="btn sm dng" data-revoke="${esc(r.cid)}">Revoke</button>` : 'revoked'}</td></tr>`;
}).join('');
}
$('p2pRefreshBtn').addEventListener('click', loadP2p);
$('p2pTable').addEventListener('click', async (e) => {
const b = e.target.closest('[data-revoke]');
if (!b || !confirm('Revoke this file? Devices will stop sharing it and the server will never accept it again.')) return;
const j = await api('/api/admin/p2p/revoke', { method: 'POST', body: { cid: b.dataset.revoke } });
if (!j.ok) alert(j.error || 'failed');
loadP2p();
});
/* ---------------- reports ---------------- */
let repStatus = 'open';
const REASON = { words: 'Wrong words', timing: 'Wrong timing', typo: 'Typo', other: 'Other' };
async function refreshBadge() { const j = await api('/api/admin/flags?status=open'); if (j.ok) setBadge(j.open); }
async function loadReports() {
const box = $('repList');
box.innerHTML = '<div class="empty">Loading…</div>';
const j = await api('/api/admin/flags?status=' + repStatus);
if (!j.ok) { box.innerHTML = `<div class="empty">${esc(j.error || 'failed')}</div>`; return; }
setBadge(j.open);
if (!j.flags.length) {
box.innerHTML = `<div class="empty">${repStatus === 'open' ? '🎉 Nothing reported — every line is looking good.' : 'No resolved reports yet.'}</div>`;
return;
}
// Group per song, then per line text.
const bySong = new Map();
for (const f of j.flags) {
if (!bySong.has(f.videoId)) bySong.set(f.videoId, { title: f.title, channel: f.channel, lines: new Map() });
const s = bySong.get(f.videoId);
if (!s.lines.has(f.text)) s.lines.set(f.text, []);
s.lines.get(f.text).push(f);
}
box.innerHTML = [...bySong].map(([vid, s]) => `
<div class="card">
<div class="row" style="margin-bottom:10px">
<div style="min-width:0"><h2>${esc(s.title || vid)}</h2><span class="muted" style="font-size:12.5px">${esc(s.channel || '')} <code>${esc(vid)}</code></span></div>
<span class="grow"></span>
<button class="btn sm pri" data-fix="${esc(vid)}">Open in editor</button>
</div>
<div class="lines">${[...s.lines].map(([text, fl]) => `
<div class="ln flagged" style="padding:12px">
<div style="font-size:17px;font-weight:700;margin-bottom:8px;overflow-wrap:anywhere">“${esc(text)}”</div>
<div class="flagbox" style="margin:0 0 10px">${fl.map((f) => `
<div class="rep"><div style="flex:1;min-width:0"><span class="who">${esc(f.reporter)}</span>${f.reason ? `<span class="why">${esc(REASON[f.reason] || f.reason)}</span>` : ''}
<span class="muted" style="font-size:12px"> · ${when(f.createdAt)}</span>
${f.note ? `<div class="txt">${esc(f.note)}</div>` : ''}</div></div>`).join('')}</div>
<div class="row">
<button class="btn sm pri" data-fix="${esc(vid)}" data-text="${esc(text)}">Fix this line</button>
${repStatus === 'open'
? `<button class="btn sm" data-resall="${fl.map((f) => f.id).join(',')}">Mark resolved</button>`
: `<button class="btn sm" data-reopen="${fl.map((f) => f.id).join(',')}">Reopen</button>`}
<button class="btn sm dng" data-delall="${fl.map((f) => f.id).join(',')}">Delete</button>
</div>
</div>`).join('')}</div>
</div>`).join('');
}
$('repList').addEventListener('click', async (e) => {
const b = e.target.closest('button'); if (!b) return;
if (b.dataset.fix) { leOpen(b.dataset.fix, b.dataset.text); return; }
const each = async (ids, fn) => { for (const id of ids.split(',')) await fn(id); await loadReports(); leReloadFlagsOnly(); };
if (b.dataset.resall) each(b.dataset.resall, (id) => api(`/api/admin/flags/${id}`, { method: 'POST', body: { status: 'resolved' } }));
else if (b.dataset.reopen) each(b.dataset.reopen, (id) => api(`/api/admin/flags/${id}`, { method: 'POST', body: { status: 'open' } }));
else if (b.dataset.delall && confirm('Delete these reports for good?')) each(b.dataset.delall, (id) => api(`/api/admin/flags/${id}`, { method: 'DELETE' }));
});
function setFilter(s) {
repStatus = s;
$('fOpen').setAttribute('aria-pressed', String(s === 'open'));
$('fRes').setAttribute('aria-pressed', String(s === 'resolved'));
loadReports();
}
$('fOpen').addEventListener('click', () => setFilter('open'));
$('fRes').addEventListener('click', () => setFilter('resolved'));
$('repRefresh').addEventListener('click', loadReports);
/* ========================================================================
* Editor. Rows are built ONCE per (re)render and then only classes/values
* change — rebuilding on every tick would steal focus and reset scrolling.
* ====================================================================== */
const LE = {
id: '', rev: 0, lines: [], tags: [], offset: 0, dur: 0, audio: null, peaks: null, rows: [],
tap: false, dirty: false, raw: false, sel: -1, flags: new Map(), undo: [], redo: [], loaded: false,
};
const r2 = (n) => Math.round(n * 100) / 100;
const fmtT = (t) => (t == null ? '' : `${Math.floor(t / 60)}:${(t - Math.floor(t / 60) * 60).toFixed(2).padStart(5, '0')}`);
function parseT(s) {
const v = String(s || '').trim(); if (!v) return null;
const m = v.match(/^(\d+):(\d{1,2}(?:\.\d+)?)$/);
const n = m ? Number(m[1]) * 60 + Number(m[2]) : Number(v);
return Number.isFinite(n) && n >= 0 ? r2(n) : null;
}
const say = (t, c) => { const m = $('msg'); m.className = 'msg' + (c ? ' ' + c : ''); m.textContent = t; };
const cur = () => (LE.audio ? LE.audio.currentTime : 0);
function syncBars() {
const show = tab === 'editor' && LE.loaded;
$('saveBar').classList.toggle('hidden', !show);
$('tapPad').classList.toggle('hidden', !(show && LE.tap && !LE.raw));
}
function setDirty(on) {
LE.dirty = on;
$('saveInner').classList.toggle('dirty', on);
$('saveStat').textContent = on ? 'Unsaved changes' : 'All changes saved';
$('save').disabled = !on;
$('discard').disabled = !on;
}
function progress() {
const timed = LE.lines.filter((l) => l.kind === 'line' && l.t != null).length;
const all = LE.lines.filter((l) => l.kind === 'line').length;
$('ringTxt').textContent = `${timed}/${all}`;
$('ring').style.setProperty('--p', all ? Math.round((timed / all) * 100) : 0);
}
function tapLabel() {
const i = tapIdx();
$('tapNext').textContent = i >= 0 && LE.lines[i] ? LE.lines[i].text : 'done';
}
/* undo / redo — whole-document snapshots, typing is coalesced */
const snap = () => JSON.stringify({ l: LE.lines, t: LE.tags });
function pushUndo() { LE.undo.push(snap()); if (LE.undo.length > 100) LE.undo.shift(); LE.redo = []; undoBtns(); }
function undoBtns() { $('bUndo').disabled = !LE.undo.length; $('bRedo').disabled = !LE.redo.length; }
function restore(s) { const o = JSON.parse(s); LE.lines = o.l; LE.tags = o.t; $('tags').value = LE.tags.join(', '); render(); setDirty(true); undoBtns(); }
function doUndo() { if (!LE.undo.length) return; LE.redo.push(snap()); restore(LE.undo.pop()); say('Undone'); }
function doRedo() { if (!LE.redo.length) return; LE.undo.push(snap()); restore(LE.redo.pop()); say('Redone'); }
let typing = null;
function typed() { if (!typing) pushUndo(); clearTimeout(typing); typing = setTimeout(() => { typing = null; }, 900); }
const edit = (fn) => { pushUndo(); fn(); setDirty(true); };
async function loadFlags() {
const j = await api('/api/admin/flags?status=open');
LE.flags = new Map();
if (j.ok) for (const f of j.flags) if (f.videoId === LE.id) { if (!LE.flags.has(f.text)) LE.flags.set(f.text, []); LE.flags.get(f.text).push(f); }
if (j.ok) setBadge(j.open);
}
async function leReloadFlagsOnly() { if (LE.loaded) { await loadFlags(); paintFlags(); } }
async function leLoad(id, keepAudio, focusText) {
say('Loading…');
const [notes, streams] = await Promise.all([
api(`/api/notes/${id}`),
fetch(`/api/streams?v=${encodeURIComponent(id)}`, { credentials: 'same-origin' }).then((r) => r.json()).catch(() => ({})),
]);
const doc = (notes.lyrics && notes.lyrics.data) || { lines: [], tags: [], offset: 0 };
LE.id = id; LE.rev = (notes.lyrics && notes.lyrics.rev) || 0;
LE.lines = (doc.lines || []).map((l) => ({ t: l.t == null ? null : r2(l.t), text: String(l.text || ''), kind: l.kind || 'line' }));
LE.tags = doc.tags || []; LE.offset = Number(doc.offset) || 0;
LE.undo = []; LE.redo = []; LE.sel = -1; LE.loaded = true; LE.raw = false;
const meta = (streams.data && streams.data.meta) || {};
LE.dur = Number(meta.duration) || 0;
$('songTitle').textContent = meta.title || id;
$('songMeta').textContent = `${meta.channel ? meta.channel + ' · ' : ''}${LE.lines.length} lines · rev ${LE.rev}`;
$('tags').value = LE.tags.join(', ');
$('pickBox').classList.add('hidden'); $('studio').classList.remove('hidden');
$('raw').classList.add('hidden'); $('lines').classList.remove('hidden'); $('rawBtn').textContent = '✎ Edit as text';
await loadFlags();
render(); setDirty(false); undoBtns(); syncBars();
say('');
if (!keepAudio) initAudio(id, streams);
draw();
if (focusText) {
const i = LE.lines.findIndex((l) => l.text === focusText);
if (i >= 0) select(i, true, true);
}
}
function initAudio(id, streams) {
const url = (streams.data && streams.data.audioUrl) || ((streams.data && streams.data.qualities || []).find((q) => q.hasAudio) || {}).url || '';
if (LE.audio) { LE.audio.pause(); LE.audio.src = ''; }
LE.audio = null;
if (url) {
const a = new Audio(url); a.preload = 'auto';
a.addEventListener('timeupdate', tick); a.addEventListener('seeked', tick);
a.addEventListener('loadedmetadata', () => { if (a.duration) { LE.dur = a.duration; $('dur').textContent = fmtT(a.duration).slice(0, -3); } draw(); });
a.addEventListener('play', () => { $('playIcon').setAttribute('d', 'M7 5h4v14H7zM13 5h4v14h-4z'); });
a.addEventListener('pause', () => { $('playIcon').setAttribute('d', 'M8 5v14l11-7z'); });
a.addEventListener('error', () => say('No playable audio for this song on the server — you can still edit the text.', 'err'));
a.playbackRate = Number($('rate').value);
LE.audio = a;
} else say('This song is not cached on the server, so there is no audio to time against.', 'err');
$('dur').textContent = fmtT(LE.dur).slice(0, -3);
LE.peaks = null;
fetch(`/api/media/${id}/peaks`, { credentials: 'same-origin' }).then((r) => r.json())
.then((j) => { if (j.ok) { LE.peaks = j.peaks; if (j.duration) LE.dur = j.duration; draw(); } }).catch(() => {});
}
/* ---- rows ---- */
function rowEl(line, i) {
const row = document.createElement('div');
row.className = 'ln'; row.dataset.i = String(i);
row.innerHTML = `
<div class="ln-main">
<button class="tc" data-act="chip" type="button"></button>
<input class="ln-text" value="${esc(line.text)}" placeholder="Type the line…" aria-label="Line text" />
<span class="flagchip hidden" data-flag><svg viewBox="0 0 24 24"><path d="M5 21V4M5 4h11l-2 4 2 4H5"/></svg><b></b></span>
<button class="more" data-act="more" type="button" aria-label="Line options">⋯</button>
</div>
<div class="flagbox hidden" data-flagbox></div>
<div class="ln-tools">
<input class="tin" value="${esc(fmtT(line.t))}" placeholder="0:00.00" aria-label="Time" />
<button class="btn" data-act="set" type="button">◎ Stamp now</button>
<button class="btn" data-act="seek" type="button">▶ Play here</button>
<button class="btn" data-act="nudge" data-d="-0.2" type="button">−0.2</button>
<button class="btn" data-act="nudge" data-d="0.2" type="button">+0.2</button>
<select class="mini" data-kind aria-label="Line type"><option value="line">Line</option><option value="section">Section</option><option value="cue">Cue</option></select>
<button class="btn" data-act="add" type="button">+ Below</button>
<button class="btn dng" data-act="del" type="button">Delete</button>
</div>`;
row.querySelector('[data-kind]').value = line.kind;
return row;
}
function paintRow(i) {
const l = LE.lines[i], row = LE.rows[i]; if (!l || !row) return;
row.classList.toggle('untimed', l.t == null);
row.classList.toggle('section', l.kind === 'section');
row.classList.toggle('cue', l.kind === 'cue');
row.classList.toggle('sel', i === LE.sel);
row.querySelector('.tc').textContent = l.t == null ? 'Set' : fmtT(l.t);
row.querySelector('.tc').title = l.t == null ? 'Stamp the playhead on this line' : 'Play from this line';
const tin = row.querySelector('.tin'); if (document.activeElement !== tin) tin.value = fmtT(l.t);
}
function paintFlags() {
LE.rows.forEach((row, i) => {
const l = LE.lines[i]; const fl = (l && LE.flags.get(l.text)) || [];
row.classList.toggle('flagged', fl.length > 0);
const chip = row.querySelector('[data-flag]'); chip.classList.toggle('hidden', !fl.length); chip.querySelector('b').textContent = fl.length;
const box = row.querySelector('[data-flagbox]'); box.classList.toggle('hidden', !fl.length);
box.innerHTML = fl.length ? fl.map((f) => `<div class="rep"><div style="flex:1;min-width:0"><span class="who">${esc(f.reporter)}</span>${f.reason ? `<span class="why">${esc(REASON[f.reason] || f.reason)}</span>` : ''}${f.note ? `<div class="txt">${esc(f.note)}</div>` : '<div class="txt muted">No description</div>'}</div></div>`).join('')
+ `<div class="row"><span class="muted" style="font-size:12.5px;flex:1">Edit the text and save — the report closes itself.</span><button class="btn sm" data-act="resolve" data-ids="${fl.map((f) => f.id).join(',')}" type="button">It’s fine — dismiss</button></div>` : '';
});
}
function render() {
const box = $('lines'); box.innerHTML = '';
LE.rows = LE.lines.map((l, i) => { const r = rowEl(l, i); box.appendChild(r); return r; });
if (!LE.lines.length) box.innerHTML = '<div class="empty">No lines yet — press “+ Add line”, paste text with ✎ Edit as text, or pull them from LRCLIB (⋯ tools).</div>';
LE.lines.forEach((_, i) => paintRow(i));
paintFlags(); progress(); lastIdx = -2; tick(); tapLabel();
$('songMeta').textContent = $('songMeta').textContent.replace(/\d+ lines/, `${LE.lines.length} lines`);
}
function select(i, scroll, flash) {
const prev = LE.sel; LE.sel = i;
if (prev !== i && prev >= 0) paintRow(prev);
paintRow(i);
const row = LE.rows[i];
if (row && scroll) row.scrollIntoView({ block: 'center', behavior: 'smooth' });
if (row && flash) { row.classList.remove('flash'); void row.offsetWidth; row.classList.add('flash'); }
}
const indexAt = (t) => { let hit = -1; for (let i = 0; i < LE.lines.length; i++) { const lt = LE.lines[i].t; if (lt != null && lt + LE.offset <= t) hit = i; else if (lt != null) break; } return hit; };
function tapIdx() {
const i = LE.lines.findIndex((l) => l.t == null && l.kind === 'line');
return i >= 0 ? i : -1;
}
let lastIdx = -2;
function tick() {
$('cur').textContent = fmtT(cur());
const i = LE.tap ? tapIdx() : indexAt(cur());
if (i !== lastIdx) {
for (const r of LE.rows) r.classList.remove('now', 'next');
if (LE.rows[i]) LE.rows[i].classList.add('now');
if (LE.rows[i + 1]) LE.rows[i + 1].classList.add('next');
if ($('follow').checked && LE.rows[i] && LE.audio && !LE.audio.paused) LE.rows[i].scrollIntoView({ block: 'center', behavior: 'smooth' });
lastIdx = i;
}
draw();
}
function draw() {
const c = $('wave'); if (!c || !LE.loaded) return;
const w = c.clientWidth || 600, h = c.height, dpr = window.devicePixelRatio || 1;
if (c.width !== Math.round(w * dpr)) c.width = Math.round(w * dpr);
const g = c.getContext('2d'); g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, w, h);
const dur = LE.dur || 1, t = cur();
if (LE.peaks && LE.peaks.length) {
const n = LE.peaks.length, bw = w / n;
const grad = g.createLinearGradient(0, 0, w, 0); grad.addColorStop(0, '#7c5cff'); grad.addColorStop(1, '#22d3ee');
for (let i = 0; i < n; i++) {
const bh = Math.max(2, (LE.peaks[i] / 100) * (h - 10));
g.fillStyle = (i * dur) / n <= t ? grad : 'rgba(255,255,255,.16)';
g.fillRect(i * bw, (h - bh) / 2, Math.max(1, bw - 1), bh);
}
} else {
g.fillStyle = 'rgba(255,255,255,.14)'; g.fillRect(0, h / 2 - 2, w, 4);
g.fillStyle = '#22d3ee'; g.fillRect(0, h / 2 - 2, (t / dur) * w, 4);
}
g.fillStyle = 'rgba(255,255,255,.55)';
for (const l of LE.lines) { if (l.t == null) continue; const x = ((l.t + LE.offset) / dur) * w; g.fillRect(x, 0, 1, 7); g.fillRect(x, h - 7, 1, 7); }
g.fillStyle = '#fff'; g.fillRect((t / dur) * w - 1, 0, 2, h);
}
function seek(t) { if (!LE.audio) return; LE.audio.currentTime = Math.max(0, Math.min(LE.dur || 1e9, t)); tick(); }
function stamp(i) {
if (!LE.audio || i < 0 || i >= LE.lines.length) return;
edit(() => { LE.lines[i].t = r2(cur()); });
paintRow(i); progress(); lastIdx = -2; tick(); tapLabel();
}
/* ---- events ---- */
$('lines').addEventListener('click', async (e) => {
const row = e.target.closest('.ln'); if (!row) return;
const i = Number(row.dataset.i);
if (LE.sel !== i) select(i);
const btn = e.target.closest('button[data-act]'); if (!btn) return;
const act = btn.dataset.act;
if (act === 'chip') {
if (LE.lines[i].t == null) stamp(i);
else { seek(LE.lines[i].t + LE.offset); if (LE.audio && LE.audio.paused) LE.audio.play().catch(() => {}); }
} else if (act === 'more') { LE.rows[i].classList.toggle('sel'); if (!LE.rows[i].classList.contains('sel')) LE.sel = -1; }
else if (act === 'set') stamp(i);
else if (act === 'seek') { seek((LE.lines[i].t || 0) + LE.offset); if (LE.audio && LE.audio.paused) LE.audio.play().catch(() => {}); }
else if (act === 'nudge') { if (LE.lines[i].t == null) return; edit(() => { LE.lines[i].t = r2(Math.max(0, LE.lines[i].t + Number(btn.dataset.d))); }); paintRow(i); draw(); }
else if (act === 'add') { edit(() => LE.lines.splice(i + 1, 0, { t: null, text: '', kind: 'line' })); render(); select(i + 1); LE.rows[i + 1].querySelector('.ln-text').focus(); }
else if (act === 'del') { edit(() => LE.lines.splice(i, 1)); LE.sel = -1; render(); draw(); }
else if (act === 'resolve') {
for (const id of btn.dataset.ids.split(',')) await api(`/api/admin/flags/${id}`, { method: 'POST', body: { status: 'resolved' } });
await loadFlags(); paintFlags();
}
});
$('lines').addEventListener('input', (e) => {
const row = e.target.closest('.ln'); if (!row) return; const i = Number(row.dataset.i);
if (e.target.classList.contains('ln-text')) { typed(); LE.lines[i].text = e.target.value; setDirty(true); tapLabel(); }
});
$('lines').addEventListener('change', (e) => {
const row = e.target.closest('.ln'); if (!row) return; const i = Number(row.dataset.i);
if (e.target.classList.contains('tin')) { edit(() => { LE.lines[i].t = parseT(e.target.value); }); paintRow(i); progress(); draw(); tapLabel(); }
else if (e.target.matches('[data-kind]')) { edit(() => { LE.lines[i].kind = e.target.value; }); paintRow(i); progress(); }
});
$('lines').addEventListener('focusin', (e) => { const row = e.target.closest('.ln'); if (row && Number(row.dataset.i) !== LE.sel) select(Number(row.dataset.i)); });
$('lines').addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || !e.target.classList.contains('ln-text')) return;
e.preventDefault();
const i = Number(e.target.closest('.ln').dataset.i);
edit(() => LE.lines.splice(i + 1, 0, { t: null, text: '', kind: LE.lines[i].kind === 'section' ? 'line' : LE.lines[i].kind }));
render(); select(i + 1); LE.rows[i + 1].querySelector('.ln-text').focus();
});
function togglePlay() { if (!LE.audio) return; if (LE.audio.paused) LE.audio.play().catch((err) => say('Playback failed: ' + err.message, 'err')); else LE.audio.pause(); }
$('bPlay').addEventListener('click', togglePlay);
$('bBack').addEventListener('click', () => seek(cur() - 5));
$('bFwd').addEventListener('click', () => seek(cur() + 5));
$('rate').addEventListener('change', () => { if (LE.audio) LE.audio.playbackRate = Number($('rate').value); });
$('wave').addEventListener('pointerdown', (e) => { const r = e.currentTarget.getBoundingClientRect(); seek(((e.clientX - r.left) / r.width) * (LE.dur || 0)); });
window.addEventListener('resize', draw);
$('bUndo').addEventListener('click', doUndo);
$('bRedo').addEventListener('click', doRedo);
$('bTools').addEventListener('click', () => { const on = $('drawer').classList.toggle('hidden') === false; $('bTools').setAttribute('aria-pressed', String(on)); });
$('closeSong').addEventListener('click', () => {
if (LE.dirty && !confirm('Discard the unsaved changes to this song?')) return;
if (LE.audio) { LE.audio.pause(); LE.audio = null; }
LE.loaded = false; LE.dirty = false; setDirty(false);
$('studio').classList.add('hidden'); $('pickBox').classList.remove('hidden'); syncBars();
});
function setMode(tapOn) {
LE.tap = tapOn;
$('mTap').setAttribute('aria-pressed', String(tapOn)); $('mEdit').setAttribute('aria-pressed', String(!tapOn));
say(tapOn ? 'Tap-sync: press the big TAP button (or Space) each time the highlighted line starts. Play the song first.' : '', tapOn ? 'ok' : '');
lastIdx = -2; tick(); tapLabel(); syncBars();
}
$('mEdit').addEventListener('click', () => setMode(false));
$('mTap').addEventListener('click', () => { setMode(true); if (LE.audio && LE.audio.paused) LE.audio.play().catch(() => {}); });
$('tapPad').addEventListener('click', () => { const i = tapIdx(); if (i >= 0) { stamp(i); if (LE.rows[i] && $('follow').checked) LE.rows[i].scrollIntoView({ block: 'center', behavior: 'smooth' }); } });
document.addEventListener('keydown', (e) => {
if (tab !== 'editor' || !LE.loaded) return;
const inField = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === 's') { e.preventDefault(); $('save').click(); return; }
if (mod && e.key.toLowerCase() === 'z' && !inField) { e.preventDefault(); e.shiftKey ? doRedo() : doUndo(); return; }
if (mod && e.key.toLowerCase() === 'y' && !inField) { e.preventDefault(); doRedo(); return; }
if (inField) return;
if (e.code === 'Space') { e.preventDefault(); if (LE.tap) { const i = tapIdx(); if (i >= 0) stamp(i); } else togglePlay(); return; }
if ((e.key === 's' || e.key === 'S') && !mod) { e.preventDefault(); stamp(LE.tap ? tapIdx() : (LE.sel >= 0 ? LE.sel : indexAt(cur()))); return; }
if (e.key === 'k') { e.preventDefault(); togglePlay(); return; }
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const n = Math.max(0, Math.min(LE.lines.length - 1, (LE.sel < 0 ? -1 : LE.sel) + (e.key === 'ArrowDown' ? 1 : -1))); select(n, true); }
});
$('shiftBtn').addEventListener('click', () => {
const d = Number($('shiftBy').value); if (!Number.isFinite(d) || !d) return;
edit(() => { for (const l of LE.lines) if (l.t != null) l.t = r2(Math.max(0, l.t + d)); });
render(); draw(); say(`Shifted every timed line by ${d > 0 ? '+' : ''}${d}s`, 'ok');
});
$('addLine').addEventListener('click', () => {
edit(() => LE.lines.push({ t: LE.audio && !LE.audio.paused ? r2(cur()) : null, text: '', kind: 'line' }));
render(); select(LE.lines.length - 1, true); LE.rows[LE.rows.length - 1].querySelector('.ln-text').focus();
});
$('tags').addEventListener('input', () => { typed(); LE.tags = $('tags').value.split(',').map((s) => s.trim()).filter(Boolean); setDirty(true); });
$('webBtn').addEventListener('click', async () => {
if (!LE.id) return;
if (LE.dirty && !confirm('LRCLIB replaces the shared lyrics and your unsaved edits are lost. Continue?')) return;
say('Asking LRCLIB…');
const j = await api(`/api/notes/${LE.id}/lyrics/web`, { method: 'POST', body: { overwrite: true } });
if (!j.ok) { say(j.error || 'LRCLIB has nothing for this song', 'err'); return; }
await leLoad(LE.id, true);
say(`LRCLIB: ${j.lines} ${j.synced ? 'synced' : 'plain'} lines saved as rev ${j.rev} — the previous version is in History.`, 'ok');
});
$('rawBtn').addEventListener('click', () => {
LE.raw = !LE.raw;
$('raw').classList.toggle('hidden', !LE.raw); $('lines').classList.toggle('hidden', LE.raw);
$('rawBtn').textContent = LE.raw ? '≡ Back to lines' : '✎ Edit as text';
if (LE.raw) {
$('raw').value = LE.lines.map((l) => (l.t == null ? '' : `[${fmtT(l.t)}] `) + (l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '') + l.text).join('\n');
} else {
pushUndo();
LE.lines = $('raw').value.split('\n').map((raw) => {
let rest = raw.trim(), t = null;
const m = rest.match(/^\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]\s*/);
if (m) { t = r2(Number(m[1]) * 60 + Number(m[2].replace(':', '.'))); rest = rest.slice(m[0].length); }
let kind = 'line';
if (rest.startsWith('# ')) { kind = 'section'; rest = rest.slice(2); } else if (rest.startsWith('! ')) { kind = 'cue'; rest = rest.slice(2); }
return { t, text: rest, kind };
}).filter((l) => l.text);
setDirty(true); render(); draw();
}
syncBars();
});
$('discard').addEventListener('click', () => { if (LE.id && confirm('Throw away the unsaved changes and reload from the server?')) leLoad(LE.id, true); });
$('save').addEventListener('click', async () => {
if (!LE.id) return;
if (LE.raw) $('rawBtn').click();
const lines = LE.lines.filter((l) => l.text.trim()).map((l) => ({ t: l.t, text: l.text.trim(), kind: l.kind }));
if (!lines.length) { say('Nothing to save', 'err'); return; }
const data = { lines, tags: LE.tags, offset: LE.offset };
$('save').disabled = true; $('saveStat').textContent = 'Saving…';
let j = await api(`/api/notes/${LE.id}/lyrics`, { method: 'PUT', body: { data, baseRev: LE.rev } });
if (!j.ok && j.current) {
if (!confirm(`Someone else saved this song while you were editing (their rev ${j.current.rev}).\n\nOK = keep YOUR version, Cancel = load theirs.`)) { await leLoad(LE.id, true); return; }
j = await api(`/api/notes/${LE.id}/lyrics`, { method: 'PUT', body: { data, force: true } });
}
if (!j.ok) { say(j.error || 'save failed', 'err'); setDirty(true); return; }
LE.rev = j.rev; setDirty(false);
say(`Saved ${lines.length} lines as rev ${j.rev}.`, 'ok');
setTimeout(async () => { await loadFlags(); paintFlags(); }, 400); // edited lines auto-close their reports
});
window.addEventListener('beforeunload', (e) => { if (LE.dirty) { e.preventDefault(); e.returnValue = ''; } });
$('loadForm').addEventListener('submit', (e) => {
e.preventDefault();
const id = videoIdFrom($('songId').value);
if (!id) { $('loadMsg').className = 'msg err'; $('loadMsg').textContent = 'That is not a YouTube URL, video id or upl_ id'; return; }
$('loadMsg').textContent = '';
leOpen(id);
});
function leOpen(id, focusText) {
if (LE.dirty && id !== LE.id && !confirm('Discard the unsaved changes to the song you have open?')) return;
showTab('editor');
if (id === LE.id && LE.loaded && focusText) {
const i = LE.lines.findIndex((l) => l.text === focusText); if (i >= 0) select(i, true, true); return;
}
leLoad(id, false, focusText);
}
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": ""}]}}'
# Upload media (admin cookie or token; multipart)
curl -X POST ${o}/api/admin/uploads -H "Authorization: Bearer $YTP_TOKEN" \\
-F file=@song.mp3 -F art=@cover.jpg -F 'title=My song' -F 'artist=Some artist'
curl ${o}/api/uploads?q=worship # public list / search
# Lyrics from LRCLIB (synced when available; profile, token or admin)
curl -X POST ${o}/api/notes/VIDEO_ID/lyrics/web \\
-H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" -d '{"overwrite": false}'
# Reported lines (admin or token)
curl ${o}/api/admin/flags?status=open -H "Authorization: Bearer $YTP_TOKEN"
# Studio deep link (admin): /admin?v=VIDEO_ID opens that song in the editor
# 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>