Files
ytplayer/frontend/lyrics-core.js

166 lines
6.5 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)
* 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;
for (const rawLine of String(text || '').replace(/\r/g, '').split('\n')) {
let rest = rawLine.trim();
if (!rest) continue;
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;
if (!stamps.length) lines.push({ t: null, text: rest, kind });
for (const t of stamps) lines.push({ t: Math.round(t * 100) / 100, text: rest, kind });
}
// 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' ? '! ' : '';
out.push(stamp + mark + l.text);
}
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);