Files
ytplayer/frontend/lyrics-core.js
Jonathan Sykes 2b38717c05 Add admin analytics, metadata collection, and grouped lyric cues
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.
2026-10-03 07:55:33 +08:00

175 lines
6.8 KiB
JavaScript

/* ============================================================================
* lyrics-core.js — pure helpers for synced lyrics, chapters and bookmarks
*
* No DOM. Used by app.js (window.LyricsCore) and unit-tested under node.
*
* Editor text format (a superset of LRC, so pasted .lrc files just work):
* [0:18.00] Worthy of every song we could ever sing timed line
* Worthy of all the praise untimed line
* [0:18] # Verse 1 section header
* ! Full dynamic swell into Bridge band cue
* @ Key G, Capo 2 (Play F), 70 BPM tags
* [00:12.00][01:30.00] repeated line LRC multi-stamp
* [offset:+500] LRC offset (ms, + = sooner)
* | You can do anything continuation, same cue
* Other LRC headers ([ar:], [ti:], …) are ignored.
*
* Sync offset: a line is shown at (t + offset) seconds of playback, so a
* positive offset makes the lyrics appear later.
* ========================================================================== */
(function (root) {
'use strict';
const STAMP_RE = /^\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/;
const HEADER_RE = /^\[([a-z]+):([^\]]*)\]$/i;
function parseStamp(str) {
const m = String(str || '').trim().match(/^(?:(\d+):)?(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?$/);
if (!m) return null;
const h = Number(m[1] || 0);
const frac = m[4] ? Number('0.' + m[4]) : 0;
return h * 3600 + Number(m[2]) * 60 + Number(m[3]) + frac;
}
// 83.456 → "1:23.46" (editor stamps keep hundredths).
function fmtStamp(t) {
const cs = Math.round(Math.max(0, Number(t) || 0) * 100);
const m = Math.floor(cs / 6000);
const s = Math.floor((cs % 6000) / 100);
const c = cs % 100;
return `${m}:${String(s).padStart(2, '0')}.${String(c).padStart(2, '0')}`;
}
// 83.9 → "1:23"; ≥1 h → "1:02:03" (YouTube chapter / display form).
function fmtClock(t) {
const total = Math.max(0, Math.floor(Number(t) || 0));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return h
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${m}:${String(s).padStart(2, '0')}`;
}
function splitTags(s) {
return String(s).split(/[,|·]/).map((x) => x.trim()).filter(Boolean);
}
// Editor text → { lines, tags, offset }.
function fromText(text) {
const lines = [];
const tags = [];
let offset = null;
let group = [];
for (const rawLine of String(text || '').replace(/\r/g, '').split('\n')) {
let rest = rawLine.trim();
if (!rest) continue;
if (rest.startsWith('| ') && group.length) {
for (const line of group) line.text += '\n' + rest.slice(2).trim();
continue;
}
group = [];
const header = rest.match(HEADER_RE);
if (header && !/^\d/.test(header[1])) {
if (header[1].toLowerCase() === 'offset') {
const ms = Number(header[2]);
if (Number.isFinite(ms)) offset = Math.round((-ms / 1000) * 100) / 100;
}
continue;
}
const stamps = [];
let m;
while ((m = rest.match(STAMP_RE))) {
const frac = m[3] ? Number('0.' + m[3]) : 0;
stamps.push(Number(m[1]) * 60 + Number(m[2]) + frac);
rest = rest.slice(m[0].length).trim();
}
if (rest.startsWith('@')) { tags.push(...splitTags(rest.slice(1))); continue; }
let kind = 'line';
if (rest.startsWith('#')) { kind = 'section'; rest = rest.replace(/^#+/, '').trim(); }
else if (rest.startsWith('!')) { kind = 'cue'; rest = rest.slice(1).trim(); }
if (!rest) continue;
group = stamps.length ? stamps.map(t => ({ t: Math.round(t * 100) / 100, text: rest, kind }))
: [{ t: null, text: rest, kind }];
lines.push(...group);
}
// A fully timed sheet (typical .lrc with repeated-chorus stamps) is put in
// time order; a partly timed one keeps the order it was written in.
if (lines.length && lines.every((l) => l.t !== null)) {
lines.sort((a, b) => a.t - b.t);
}
return { lines, tags, offset };
}
function toText(doc) {
const out = [];
if (doc && Array.isArray(doc.tags) && doc.tags.length) out.push('@ ' + doc.tags.join(', '));
for (const l of (doc && doc.lines) || []) {
const stamp = l.t === null || l.t === undefined ? '' : `[${fmtStamp(l.t)}] `;
const mark = l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '';
const parts = String(l.text || '').split('\n');
out.push(stamp + mark + parts[0], ...parts.slice(1).map(part => '| ' + part));
}
return out.join('\n');
}
// Index (into lines) of the sung line playing at `time`, or -1. Sections
// and cues are never "the active line" — the highlight follows the words.
function activeIndex(lines, time, offset) {
const now = Number(time) - (Number(offset) || 0);
let best = -1;
let bestT = -Infinity;
(lines || []).forEach((l, i) => {
if (l.kind !== 'line' || l.t === null || l.t === undefined) return;
if (l.t <= now && l.t >= bestT) { best = i; bestT = l.t; }
});
return best;
}
// The section header that the active position falls under (for "Chorus in
// progress"-style labels), or null.
function activeSection(lines, time, offset) {
const now = Number(time) - (Number(offset) || 0);
let best = null;
for (const l of lines || []) {
if (l.kind === 'section' && l.t !== null && l.t !== undefined && l.t <= now) {
if (!best || l.t >= best.t) best = l;
}
}
return best;
}
function timedCount(lines) {
return (lines || []).filter((l) => l.kind === 'line' && l.t !== null && l.t !== undefined).length;
}
// Chapters / bookmarks: [{t, title, note}] sorted by t.
function activeMarker(items, time) {
let best = null;
for (const it of items || []) if (it.t <= time && (!best || it.t >= best.t)) best = it;
return best;
}
// YouTube-description form: "0:00 Intro". YouTube only turns a list into
// chapters when the first one is at 0:00, so an "Start" entry is added when
// the list doesn't begin there.
function toYouTubeTimestamps(items, { ensureZero = true } = {}) {
const sorted = (items || []).slice().sort((a, b) => a.t - b.t);
const rows = sorted.map((it) => `${fmtClock(it.t)} ${it.title}`);
if (ensureZero && sorted.length && sorted[0].t >= 1) rows.unshift('0:00 Start');
return rows.join('\n');
}
const LyricsCore = {
parseStamp, fmtStamp, fmtClock, fromText, toText,
activeIndex, activeSection, timedCount, activeMarker, toYouTubeTimestamps,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = LyricsCore;
} else {
root.LyricsCore = LyricsCore;
}
})(typeof globalThis !== 'undefined' ? globalThis : this);