1220 lines
76 KiB
HTML
1220 lines
76 KiB
HTML
<!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 & 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 & 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|