Queue reviewable Whisper drafts from the song list and lyrics editor. Preserve line breaks within one timed cue across editing, saving, reporting, and service views. Add storage and listening analytics with a durable metadata collector, related-search depth, video limits, thumbnail storage, and a browsable metadata library.
1506 lines
100 KiB
HTML
1506 lines
100 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: #6143cf; color: #fff; box-shadow: none; }
|
||
.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); }
|
||
|
||
/* ---------- upload card ---------- */
|
||
.sr-file { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; clip: rect(0 0 0 0); }
|
||
.up-form { display: grid; gap: 14px; }
|
||
.drop {
|
||
position: relative; display: flex; align-items: center; gap: 14px; min-height: 76px; padding: 14px 16px;
|
||
border: 1.5px dashed var(--line-2); border-radius: 16px; background: rgba(0,0,0,.22); cursor: pointer;
|
||
transition: border-color .15s, background .15s;
|
||
}
|
||
.drop:hover, .drop.over { border-color: var(--a1); background: rgba(124,92,255,.08); }
|
||
.drop:focus-within { outline: 2px solid var(--a1); outline-offset: 2px; }
|
||
.drop.has { border-style: solid; border-color: rgba(34,211,238,.45); background: rgba(34,211,238,.06); }
|
||
.drop-ico { flex: none; width: 40px; height: 40px; padding: 9px; border-radius: 12px; background: rgba(255,255,255,.06);
|
||
fill: none; stroke: var(--a2); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||
.drop-text { flex: 1 1 auto; min-width: 0; display: grid; gap: 2px; }
|
||
.drop-text strong { font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.drop-text span { font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.drop-btn { flex: none; height: 36px; padding: 0 14px; display: inline-flex; align-items: center; border-radius: 11px;
|
||
border: 1px solid var(--line-2); background: rgba(255,255,255,.06); font-weight: 700; font-size: 13px; }
|
||
.up-meta { display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: end; }
|
||
.fl { display: grid; gap: 6px; min-width: 0; }
|
||
.fl > span:first-child { font: 700 11px/1 var(--ui); letter-spacing: .08em; text-transform: uppercase; color: var(--text-dim); }
|
||
.fl .field { width: 100%; }
|
||
.cover-pick { cursor: pointer; }
|
||
.cover-box { height: 44px; display: inline-flex; align-items: center; gap: 8px; padding: 0 12px 0 6px; border-radius: 13px;
|
||
border: 1px solid var(--line-2); background: rgba(0,0,0,.28); max-width: 220px; }
|
||
.cover-box svg { flex: none; width: 32px; height: 32px; padding: 5px; border-radius: 9px; background: rgba(255,255,255,.06);
|
||
fill: none; stroke: var(--text-2); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
||
.cover-box img { flex: none; width: 32px; height: 32px; border-radius: 8px; object-fit: cover; }
|
||
.cover-box em { font-style: normal; font-size: 13.5px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cover-pick:hover .cover-box { border-color: var(--a1); }
|
||
.cover-pick:focus-within .cover-box { outline: 2px solid var(--a1); outline-offset: 1px; }
|
||
.up-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||
.up-actions .btn.pri { min-width: 132px; }
|
||
@media (max-width: 640px) {
|
||
.up-meta { grid-template-columns: 1fr; }
|
||
.cover-box { max-width: none; width: 100%; }
|
||
.drop-btn { display: none; }
|
||
}
|
||
|
||
/* ---------- 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); }
|
||
.song-open { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; padding: 0; border: 0; background: none; color: inherit; text-align: left; }
|
||
.whisper-request { min-height: 44px; flex: none; }
|
||
.whisper-draft { margin-block: 14px; }
|
||
.whisper-draft pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; font: 400 14px/1.55 var(--ui); color: var(--text); }
|
||
#whisperStatus { color: var(--text-dim); font-size: 14px; margin-block: 10px; }
|
||
.song { flex-wrap: wrap; }
|
||
@media (max-width: 480px) { .song-row { flex-wrap: wrap; } .song-open { flex-basis: 100%; min-height: 44px; } }
|
||
.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; min-height: 38px; height: auto; padding: 7px 8px; resize: vertical; overflow: hidden; line-height: 1.3; 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); }
|
||
|
||
.analytics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 20px 0; }
|
||
.metric { padding: 18px; background: var(--bg-2); border: 1px solid var(--line-2); border-radius: 14px; }
|
||
.metric b { display: block; font-size: 26px; margin: 4px 0; }
|
||
.metric small, .metric span { color: var(--text-2); }
|
||
.analytics-form { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; }
|
||
.analytics-form label { display: grid; gap: 5px; min-width: 0; }
|
||
.analytics-form .query { flex: 1 1 250px; }
|
||
.analytics-form .field { width: 100%; min-width: 0; }
|
||
.analytics-form button { min-height: 44px; }
|
||
.analytics-table { overflow-x: auto; margin-top: 12px; }
|
||
.analytics-table table { width: 100%; }
|
||
.collection-job { padding: 14px 0; border-bottom: 1px solid var(--line-2); overflow-wrap: anywhere; }
|
||
.collection-job p { margin: 4px 0; color: var(--text-2); }
|
||
#metadataDetail { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 400px; overflow: auto; font-size: 13px; }
|
||
@media (max-width: 480px) { .analytics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .metric { padding: 12px; } .metric b { font-size: 22px; } }
|
||
@media (min-width: 761px) and (max-width: 1100px) {
|
||
.top { flex-wrap: wrap; }
|
||
.tabs { order: 2; width: 100%; margin-left: 0; overflow-x: auto; }
|
||
.tabs .tab { flex: none; }
|
||
}
|
||
.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="btn sm whisper-request" id="whisperBtn" type="button">Transcribe with Whisper</button>
|
||
<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>
|
||
<p id="whisperStatus" role="status" aria-live="polite"></p>
|
||
<div id="whisperResult" class="card whisper-draft hidden">
|
||
<h2 style="font-size:18px;margin:0 0 8px">Whisper transcript ready</h2>
|
||
<p class="muted">Saved lyrics stay unchanged until you review and save this draft.</p>
|
||
<pre id="whisperPreview"></pre>
|
||
<button id="whisperUse" class="btn" type="button">Use transcript in editor</button>
|
||
</div>
|
||
<div id="lines" class="lines"></div>
|
||
<textarea id="raw" aria-label="Lyrics as text; prefix continuation lines with vertical bar and space" 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="up-form" novalidate>
|
||
<label class="drop" id="upDrop">
|
||
<input class="sr-file" type="file" id="upFile" accept="video/*,audio/*" />
|
||
<svg class="drop-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V4m-4.5 4.5L12 4l4.5 4.5M5 14v4.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V14"/></svg>
|
||
<span class="drop-text">
|
||
<strong id="upFileName">Choose a video or audio file</strong>
|
||
<span id="upFileMeta">or drop it here · mp4, mov, webm, mkv, mp3, m4a, flac, wav…</span>
|
||
</span>
|
||
<span class="drop-btn" aria-hidden="true">Browse</span>
|
||
</label>
|
||
<div class="up-meta">
|
||
<label class="fl"><span>Title</span><input class="field" type="text" id="upTitle" placeholder="From the file" autocomplete="off" /></label>
|
||
<label class="fl"><span>Artist</span><input class="field" type="text" id="upArtist" placeholder="From the file" autocomplete="off" /></label>
|
||
<label class="fl cover-pick" title="Cover image (optional)">
|
||
<span>Cover</span>
|
||
<input class="sr-file" type="file" id="upArt" accept="image/*" />
|
||
<span class="cover-box" id="upArtBox"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="3"/><circle cx="9" cy="9.5" r="1.6"/><path d="m20 15-4.5-4.5L6 20"/></svg><em id="upArtName">Add image</em></span>
|
||
</label>
|
||
</div>
|
||
<div class="up-actions">
|
||
<button class="btn pri" type="submit" id="upSubmit">Upload</button>
|
||
</div>
|
||
</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-analytics" class="panel">
|
||
<h1 class="page">Server analytics</h1>
|
||
<p class="lead">Video storage, listening activity and the metadata library.</p>
|
||
<button class="btn" type="button" id="analyticsRefresh">Refresh analytics</button>
|
||
<p id="analyticsStatus" role="status" aria-live="polite"></p>
|
||
<div class="analytics-grid" id="analyticsMetrics"></div>
|
||
<div class="card"><h2>Remaining disk space</h2><div id="analyticsVolumes"></div><p class="muted">Volumes on the same device share the reported free space. Stored media totals exclude temporary files and filesystem overhead.</p></div>
|
||
<div class="card"><h2>Metadata collector</h2>
|
||
<p class="muted">Search and save descriptive video metadata and thumbnails. Video and audio files are not downloaded.</p>
|
||
<form id="collectorForm" class="analytics-form">
|
||
<label class="query">Search<input id="collectorQuery" class="field" type="search" maxlength="200" required placeholder="Cathedral of Praise Worship" /></label>
|
||
<label>Total video limit<input id="collectorLimit" class="field" type="number" min="1" max="500" value="30" required /></label>
|
||
<label>Related-search depth<select id="collectorDepth" class="field"><option value="0">0 · Search only</option><option value="1">1 · Channels and topics</option><option value="2">2 · Two related rounds</option><option value="3">3 · Three related rounds</option></select></label>
|
||
<button class="btn pri" type="submit" id="collectorStart">Collect metadata</button>
|
||
</form>
|
||
<p class="muted">The limit applies across every round. Depth follows channels and tags found in video metadata, with at most 24 searches. A collection can return fewer videos when results repeat or sources are unavailable. Thumbnails follow the server’s storage budget.</p>
|
||
<p id="collectorStatus" role="status" aria-live="polite"></p><div id="collectionJobs"></div>
|
||
</div>
|
||
<div class="card"><h2>Metadata library</h2>
|
||
<form id="metadataFilterForm" class="analytics-form"><label class="query">Filter saved metadata<input id="metadataFilter" class="field" type="search" maxlength="200" placeholder="Title, channel or tag" /></label><button class="btn" type="submit">Filter</button></form>
|
||
<p id="metadataCount" class="muted"></p>
|
||
<div class="analytics-table"><table id="metadataTable"></table></div>
|
||
<div class="row"><button id="metadataPrev" class="btn" type="button">Previous</button><button id="metadataNext" class="btn" type="button">Next</button></div>
|
||
<details id="metadataDetails" class="hidden"><summary>Video metadata</summary><pre id="metadataDetail"></pre></details>
|
||
</div>
|
||
<div class="card"><h2>Discovery sources</h2><div class="analytics-table"><table id="analyticsSources"></table></div></div>
|
||
<div class="card"><h2>Most played</h2><div id="analyticsTop"></div></div>
|
||
</section>
|
||
|
||
<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 src="lyrics-core.js"></script>
|
||
<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"/>',
|
||
analytics: '<path d="M4 20h16M7 16V9M12 16V4M17 16v-5"/>',
|
||
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'], ['analytics', 'Stats'], ['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();
|
||
if (name === 'analytics') loadAnalytics();
|
||
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(); });
|
||
|
||
/* ---------------- server analytics / metadata collection ---------------- */
|
||
let analyticsTimer = null, metadataOffset = 0;
|
||
const bytes = value => { let size = Number(value) || 0, unit = 0; const units = ['B','KB','MB','GB','TB']; while (size >= 1024 && unit < 4) { size /= 1024; unit++; } return `${size.toFixed(unit ? 1 : 0)} ${units[unit]}`; };
|
||
async function loadAnalytics() {
|
||
$('analyticsStatus').textContent = 'Loading server analytics…';
|
||
try {
|
||
const j = await api('/api/admin/analytics');
|
||
if (!j.ok) throw Error(j.error || 'Analytics unavailable.');
|
||
const ready = j.media.find(row => row.status === 'ready') || {}, uploadBytes = j.uploads.reduce((total, row) => total + Number(row.bytes), 0);
|
||
const metrics = [
|
||
['Stored media', bytes(Number(ready.bytes || 0) + uploadBytes), `${ready.count || 0} cached videos · ${j.uploads.reduce((sum, row) => sum + Number(row.count), 0)} uploads`],
|
||
['Metadata videos', Number(j.catalog.videos).toLocaleString(), `${j.details.videos} with detailed extractor metadata`],
|
||
['Saved thumbnails', Number(j.thumbnails.saved || 0).toLocaleString(), `${bytes(j.thumbnails.bytes)} · ${j.thumbnails.pending || 0} awaiting download`],
|
||
['Recorded plays', Number(j.listening.plays).toLocaleString(), `${j.listening.videos} different videos`],
|
||
['Cached video size', bytes(ready.bytes), `${Math.round(Number(ready.seconds || 0) / 3600)} hours of audio/video`],
|
||
['Upload size', bytes(uploadBytes), 'Server media library'],
|
||
['Metadata size', bytes(Number(j.catalog.bytes) + Number(j.details.bytes)), 'JSON payloads; database indexes excluded'],
|
||
['Cache queue', j.media.filter(row => row.status !== 'ready').reduce((sum, row) => sum + Number(row.count), 0), j.media.filter(row => row.status !== 'ready').map(row => `${row.status}: ${row.count}`).join(' · ') || 'No pending downloads'],
|
||
];
|
||
$('analyticsMetrics').innerHTML = metrics.map(([label, value, detail]) => `<div class="metric"><span>${esc(label)}</span><b>${esc(value)}</b><small>${esc(detail)}</small></div>`).join('');
|
||
$('analyticsVolumes').innerHTML = j.volumes.map(v => `<p><b>${esc(v.label)}</b> · ${v.unavailable ? 'Storage unavailable' : `${bytes(v.free)} available of ${bytes(v.total)} · device ${esc(v.device)}`}</p>`).join('');
|
||
$('analyticsSources').innerHTML = '<tr><th>Source</th><th>Videos</th><th>Discoveries</th></tr>' + j.sources.map(row => `<tr><td>${esc(row.source)}</td><td>${esc(row.videos)}</td><td>${esc(row.discoveries)}</td></tr>`).join('');
|
||
$('analyticsTop').innerHTML = j.topPlayed.length ? j.topPlayed.map(row => `<p>${esc(row.title)} · <b>${esc(row.plays)} plays</b></p>`).join('') : '<p class="muted">No listening activity recorded yet.</p>';
|
||
$('analyticsStatus').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||
await Promise.all([loadCollections(), loadMetadata()]);
|
||
} catch (error) { $('analyticsStatus').textContent = error.message || 'Could not load server analytics.'; }
|
||
}
|
||
async function loadCollections() {
|
||
try {
|
||
const j = await api('/api/admin/collections'); if (!j.ok) return;
|
||
$('collectionJobs').innerHTML = j.jobs.length ? j.jobs.map(job => `<div class="collection-job"><b>${esc(job.query)}</b> · ${esc(job.status)}<p>${job.collected}/${job.maxVideos} videos collected · ${job.enriched} enriched · ${job.failed} failed · ${job.searches} searches · depth ${job.depth}</p>${job.current ? `<p>Working on ${esc(job.current)}</p>` : ''}${job.error ? `<p>${esc(job.error)}</p>` : ''}${job.errors?.length ? `<details><summary>Extraction errors</summary>${job.errors.map(e => `<p>${esc(e.id)}: ${esc(e.error)}</p>`).join('')}</details>` : ''}${['queued','running'].includes(job.status) ? `<button class="btn" type="button" data-cancel-collection="${esc(job.id)}">Stop collection</button>` : ''}</div>`).join('') : '<p class="muted">No collections yet.</p>';
|
||
clearTimeout(analyticsTimer); analyticsTimer = null;
|
||
if (tab === 'analytics' && j.jobs.some(job => ['queued','running'].includes(job.status))) analyticsTimer = setTimeout(loadCollections, 4000);
|
||
} catch { $('collectorStatus').textContent = 'Connection interrupted. Collection continues on the server.'; }
|
||
}
|
||
async function loadMetadata() {
|
||
const j = await api(`/api/admin/metadata?q=${encodeURIComponent($('metadataFilter').value)}&offset=${metadataOffset}`); if (!j.ok) return;
|
||
$('metadataCount').textContent = `${j.total} saved videos · ${j.total ? metadataOffset + 1 : 0}–${metadataOffset + j.videos.length}`;
|
||
$('metadataTable').innerHTML = '<tr><th>Video</th><th>Metadata</th><th>Thumbnail</th></tr>' + j.videos.map(v => `<tr><td><button class="btn sm" type="button" data-metadata-id="${esc(v.id)}">${esc(v.title)}</button><br><small>${esc(v.channel)} · ${Number(v.duration) > 0 ? Math.round(Number(v.duration)) + 's' : 'Duration unavailable'}</small></td><td>${v.enriched ? 'Detailed' : 'Search card'}</td><td>${v.thumbnailBytes ? bytes(v.thumbnailBytes) : 'Pending / unavailable'}</td></tr>`).join('');
|
||
$('metadataPrev').disabled = metadataOffset === 0; $('metadataNext').disabled = metadataOffset + 50 >= j.total;
|
||
}
|
||
$('analyticsRefresh').addEventListener('click', loadAnalytics);
|
||
$('collectorForm').addEventListener('submit', async e => {
|
||
e.preventDefault(); $('collectorStart').disabled = true; $('collectorStatus').textContent = 'Queuing collection…';
|
||
try {
|
||
const j = await api('/api/admin/collections', { method: 'POST', body: { query: $('collectorQuery').value, maxVideos: Number($('collectorLimit').value), depth: Number($('collectorDepth').value) } });
|
||
$('collectorStatus').textContent = j.ok ? 'Collection queued. You can leave this page while it runs.' : j.error || 'Could not start collection.';
|
||
if (j.ok) await loadCollections();
|
||
} catch { $('collectorStatus').textContent = 'Could not reach the server.'; }
|
||
finally { $('collectorStart').disabled = false; }
|
||
});
|
||
$('collectionJobs').addEventListener('click', async e => { const btn = e.target.closest('[data-cancel-collection]'); if (!btn) return; await api('/api/admin/collections/' + btn.dataset.cancelCollection + '/cancel', { method: 'POST', body: {} }); await loadCollections(); });
|
||
$('metadataFilterForm').addEventListener('submit', e => { e.preventDefault(); metadataOffset = 0; loadMetadata(); });
|
||
$('metadataPrev').addEventListener('click', () => { metadataOffset = Math.max(0, metadataOffset - 50); loadMetadata(); });
|
||
$('metadataNext').addEventListener('click', () => { metadataOffset += 50; loadMetadata(); });
|
||
$('metadataTable').addEventListener('click', async e => {
|
||
const btn = e.target.closest('[data-metadata-id]'); if (!btn) return;
|
||
const j = await api('/api/admin/metadata/' + btn.dataset.metadataId); if (!j.ok) return;
|
||
$('metadataDetail').textContent = JSON.stringify(j.metadata, null, 2); $('metadataDetails').classList.remove('hidden'); $('metadataDetails').open = true;
|
||
});
|
||
|
||
/* ---------------- 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 || [];
|
||
}
|
||
// Upload card: show the chosen file / cover, accept drag-and-drop.
|
||
const fmtSize = (n) => n >= 1073741824 ? (n / 1073741824).toFixed(1) + ' GB' : n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB';
|
||
function paintUpFile() {
|
||
const f = $('upFile').files[0];
|
||
$('upDrop').classList.toggle('has', !!f);
|
||
$('upFileName').textContent = f ? f.name : 'Choose a video or audio file';
|
||
$('upFileMeta').textContent = f ? `${fmtSize(f.size)} · ${f.type || 'media file'} · click to change` : 'or drop it here · mp4, mov, webm, mkv, mp3, m4a, flac, wav…';
|
||
}
|
||
function paintUpArt() {
|
||
const f = $('upArt').files[0];
|
||
const box = $('upArtBox');
|
||
const old = box.querySelector('img'); if (old) { URL.revokeObjectURL(old.src); old.remove(); }
|
||
box.querySelector('svg').style.display = f ? 'none' : '';
|
||
$('upArtName').textContent = f ? f.name : 'Add image';
|
||
if (f) { const img = document.createElement('img'); img.alt = ''; img.src = URL.createObjectURL(f); box.prepend(img); }
|
||
}
|
||
$('upFile').addEventListener('change', paintUpFile);
|
||
$('upArt').addEventListener('change', paintUpArt);
|
||
$('upForm').addEventListener('reset', () => setTimeout(() => { paintUpFile(); paintUpArt(); }, 0));
|
||
['dragenter', 'dragover'].forEach((t) => $('upDrop').addEventListener(t, (e) => { e.preventDefault(); $('upDrop').classList.add('over'); }));
|
||
['dragleave', 'drop'].forEach((t) => $('upDrop').addEventListener(t, () => $('upDrop').classList.remove('over')));
|
||
$('upDrop').addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||
if (!f) return;
|
||
const dt = new DataTransfer(); dt.items.add(f); $('upFile').files = dt.files; paintUpFile();
|
||
});
|
||
|
||
$('upForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const f = $('upFile').files[0];
|
||
if (!f) { const m = $('upProgress'); m.className = 'msg err'; m.textContent = 'Choose a video or audio file first.'; $('upDrop').focus(); $('upFile').click(); 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) => `<div class="song-row"><button type="button" class="song-open" 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>
|
||
<button type="button" class="btn sm whisper-request" data-whisper="${esc(r.id)}" aria-label="Transcribe ${esc(r.title)} with Whisper">Whisper</button></div>`).join('')
|
||
: '<div class="muted" style="padding:10px">Nothing matches — paste a link above to open any YouTube song.</div>';
|
||
paintWhisper();
|
||
}
|
||
$('songFilter').addEventListener('input', paintSongs);
|
||
$('songList').addEventListener('click', (e) => {
|
||
const whisper = e.target.closest('[data-whisper]'); if (whisper) { startWhisper(whisper.dataset.whisper); return; }
|
||
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;
|
||
}
|
||
|
||
const whisperJobs = new Map(), whisperStarting = new Set();
|
||
let whisperTimer = null;
|
||
const activeWhisper = job => job && (job.status === 'queued' || job.status === 'running');
|
||
function paintWhisper() {
|
||
document.querySelectorAll('[data-whisper]').forEach(btn => {
|
||
const job = whisperJobs.get(btn.dataset.whisper);
|
||
btn.disabled = whisperStarting.has(btn.dataset.whisper) || activeWhisper(job);
|
||
btn.textContent = job?.status === 'running' ? 'Transcribing…' : job?.status === 'queued' ? 'Queued' : 'Whisper';
|
||
});
|
||
const job = whisperJobs.get(LE.id);
|
||
$('whisperBtn').disabled = whisperStarting.has(LE.id) || activeWhisper(job);
|
||
$('whisperBtn').textContent = activeWhisper(job) ? (job.status === 'queued' ? 'Whisper queued' : 'Whisper transcribing…') : 'Transcribe with Whisper';
|
||
const labels = { 'loading-model': 'Whisper is loading its model…', 'downloading-audio': 'Whisper is reading the song’s audio…', transcribing: 'Whisper is transcribing the vocals. This can take several minutes.' };
|
||
$('whisperStatus').textContent = job?.status === 'queued' ? 'Queued for Whisper. Waiting for the lyrics worker.'
|
||
: job?.status === 'running' ? labels[job.stage] || 'Whisper is working…'
|
||
: job?.status === 'failed' ? 'Whisper could not finish: ' + (job.error || 'Try again.') : '';
|
||
const ready = job?.status === 'complete' && job.result?.lines?.length;
|
||
$('whisperResult').classList.toggle('hidden', !ready);
|
||
$('whisperPreview').textContent = ready ? job.result.lines.map(l => l.text).join('\n') : '';
|
||
}
|
||
async function refreshWhisper(id) {
|
||
try {
|
||
const result = await api('/api/admin/transcriptions/' + encodeURIComponent(id));
|
||
if (result.ok) whisperJobs.set(id, result.job);
|
||
paintWhisper(); scheduleWhisper();
|
||
} catch { /* a reconnect or another poll can recover the durable job */ }
|
||
}
|
||
function scheduleWhisper() {
|
||
if (whisperTimer || ![...whisperJobs.values()].some(activeWhisper)) return;
|
||
whisperTimer = setTimeout(async () => {
|
||
whisperTimer = null;
|
||
for (const [id, job] of whisperJobs) if (activeWhisper(job)) await refreshWhisper(id);
|
||
scheduleWhisper();
|
||
}, 5000);
|
||
}
|
||
async function startWhisper(id) {
|
||
if (!id || whisperStarting.has(id) || activeWhisper(whisperJobs.get(id))) return;
|
||
whisperStarting.add(id); paintWhisper();
|
||
try {
|
||
if (LE.id !== id || !LE.loaded) { const opened = await leOpen(id); if (opened === false) return; }
|
||
const result = await api('/api/admin/transcriptions/' + encodeURIComponent(id), { method: 'POST', body: {} });
|
||
if (!result.ok) { say(result.error || 'Whisper could not start.', 'err'); return; }
|
||
whisperJobs.set(id, result.job); paintWhisper(); scheduleWhisper();
|
||
} catch { say('Could not reach Whisper. Try again when connected.', 'err'); }
|
||
finally { whisperStarting.delete(id); paintWhisper(); }
|
||
}
|
||
$('whisperBtn').addEventListener('click', () => startWhisper(LE.id));
|
||
$('whisperUse').addEventListener('click', () => {
|
||
const job = whisperJobs.get(LE.id); if (job?.status !== 'complete' || !job.result?.lines?.length) return;
|
||
if (LE.dirty && !confirm('Replace your unsaved editor text with the Whisper draft? Undo can restore it.')) return;
|
||
edit(() => {
|
||
LE.lines = job.result.lines.map(l => ({ ...l })); LE.offset = 0;
|
||
LE.tags = [...LE.tags.filter(t => !/^(from |auto-transcribed)/i.test(t)), ...job.result.tags];
|
||
LE.raw = false; $('raw').classList.add('hidden'); $('lines').classList.remove('hidden');
|
||
$('rawBtn').textContent = '✎ Edit as text'; $('tags').value = LE.tags.join(', ');
|
||
});
|
||
render(); draw(); syncBars();
|
||
$('whisperResult').classList.add('hidden');
|
||
say('Whisper draft loaded. Check the words and timing, then Save to publish; Undo restores the previous text.', 'ok');
|
||
});
|
||
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, o: LE.offset });
|
||
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; LE.offset = o.o ?? LE.offset; $('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();
|
||
paintWhisper(); refreshWhisper(id);
|
||
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>
|
||
<textarea class="ln-text" rows="1" placeholder="Type the line… (Shift+Enter for a line break)" aria-label="Line text">${esc(line.text)}</textarea>
|
||
<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="break" type="button" title="Same timestamp, both lines highlight together">↵ Line break</button>
|
||
<button class="btn" data-act="add" type="button">+ Below</button>
|
||
<button class="btn dng" data-act="del" type="button">Delete</button>
|
||
</div>`;
|
||
const text = row.querySelector('.ln-text');
|
||
text.rows = Math.max(1, line.text.split('\n').length);
|
||
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));
|
||
const fields = LE.rows.map(row => row.querySelector('.ln-text'));
|
||
const heights = fields.map(field => field.scrollHeight);
|
||
fields.forEach((field, i) => { field.style.height = heights[i] + 'px'; });
|
||
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 === 'break') {
|
||
const text = LE.rows[i].querySelector('.ln-text');
|
||
const pos = text.selectionStart;
|
||
edit(() => { LE.lines[i].text = text.value.slice(0, pos) + '\n' + text.value.slice(text.selectionEnd); });
|
||
render(); select(i);
|
||
const target = LE.rows[i].querySelector('.ln-text'); target.focus(); target.setSelectionRange(pos + 1, pos + 1);
|
||
}
|
||
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; e.target.rows = Math.max(1, e.target.value.split('\n').length); e.target.style.height = 'auto'; e.target.style.height = e.target.scrollHeight + 'px'; 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;
|
||
if (e.shiftKey) return; // Native textarea break stays in the current timed cue.
|
||
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 = LyricsCore.toText({ lines: LE.lines });
|
||
} else {
|
||
pushUndo();
|
||
LE.lines = LyricsCore.fromText($('raw').value).lines;
|
||
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 false;
|
||
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;
|
||
}
|
||
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>
|