mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-18 03:43:10 +10:00
@@ -1162,11 +1162,6 @@ const createStickyLyricsDropdown = (): void => {
|
|||||||
const style = Number(raw);
|
const style = Number(raw);
|
||||||
if (style === settings.lyricsStyle) return;
|
if (style === settings.lyricsStyle) return;
|
||||||
|
|
||||||
if (style === 2) {
|
|
||||||
trace.msg.log("Syllables are coming very soon");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
settings.lyricsStyle = style;
|
settings.lyricsStyle = style;
|
||||||
for (const b of segButtons) b.classList.remove("rl-seg-active");
|
for (const b of segButtons) b.classList.remove("rl-seg-active");
|
||||||
btn.classList.add("rl-seg-active");
|
btn.classList.add("rl-seg-active");
|
||||||
@@ -1314,6 +1309,7 @@ interface WordEntry {
|
|||||||
el: HTMLSpanElement;
|
el: HTMLSpanElement;
|
||||||
start: number; // ms
|
start: number; // ms
|
||||||
end: number; // ms
|
end: number; // ms
|
||||||
|
duration: number; // ms
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LineEntry {
|
interface LineEntry {
|
||||||
@@ -1571,12 +1567,10 @@ const buildWordSpans = (): {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lineWords: WordEntry[] = [];
|
const lineWords: WordEntry[] = [];
|
||||||
|
const syllabus = apiLine.syllabus;
|
||||||
|
const isSylMode = settings.lyricsStyle === 2;
|
||||||
|
|
||||||
for (const syl of apiLine.syllabus) {
|
const WORD_SPAN_STYLE: Record<string, string> = {
|
||||||
const wordSpan = document.createElement("span");
|
|
||||||
wordSpan.className = "rl-wbw-word";
|
|
||||||
wordSpan.textContent = syl.text.trimEnd();
|
|
||||||
forceStyle(wordSpan, {
|
|
||||||
display: "inline",
|
display: "inline",
|
||||||
float: "none",
|
float: "none",
|
||||||
flex: "none",
|
flex: "none",
|
||||||
@@ -1584,30 +1578,61 @@ const buildWordSpans = (): {
|
|||||||
padding: "0",
|
padding: "0",
|
||||||
"word-spacing": "normal",
|
"word-spacing": "normal",
|
||||||
"letter-spacing": "normal",
|
"letter-spacing": "normal",
|
||||||
});
|
};
|
||||||
if (syl.isBackground) {
|
|
||||||
wordSpan.classList.add("rl-wbw-bg");
|
|
||||||
}
|
|
||||||
|
|
||||||
const seekTimeMs = syl.time;
|
const makeSpan = (text: string, seekMs: number, bg: boolean): HTMLSpanElement => {
|
||||||
wordSpan.addEventListener("click", () => {
|
const span = document.createElement("span");
|
||||||
PlayState.seek(seekTimeMs / 1000);
|
span.className = "rl-wbw-word";
|
||||||
|
span.textContent = text;
|
||||||
|
forceStyle(span, WORD_SPAN_STYLE);
|
||||||
|
if (bg) span.classList.add("rl-wbw-bg");
|
||||||
|
span.addEventListener("click", () => {
|
||||||
|
PlayState.seek(seekMs / 1000);
|
||||||
if (!PlayState.playing) PlayState.play();
|
if (!PlayState.playing) PlayState.play();
|
||||||
resync();
|
resync();
|
||||||
});
|
});
|
||||||
|
return span;
|
||||||
lineDiv.appendChild(wordSpan);
|
|
||||||
|
|
||||||
// insert text spacebar between words (most reliable inline spacing)
|
|
||||||
lineDiv.appendChild(document.createTextNode(" "));
|
|
||||||
|
|
||||||
const wordEntry: WordEntry = {
|
|
||||||
el: wordSpan,
|
|
||||||
start: syl.time,
|
|
||||||
end: syl.time + syl.duration,
|
|
||||||
};
|
};
|
||||||
lineWords.push(wordEntry);
|
|
||||||
words.push(wordEntry);
|
// Group syllables into words: trailing whitespace in syl.text marks a word boundary
|
||||||
|
const wordGroups: number[][] = [];
|
||||||
|
let currentGroup: number[] = [];
|
||||||
|
for (let si = 0; si < syllabus.length; si++) {
|
||||||
|
currentGroup.push(si);
|
||||||
|
const isWordEnd = syllabus[si].text !== syllabus[si].text.trimEnd() || si === syllabus.length - 1;
|
||||||
|
if (isWordEnd) {
|
||||||
|
wordGroups.push(currentGroup);
|
||||||
|
currentGroup = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of wordGroups) {
|
||||||
|
if (isSylMode) {
|
||||||
|
// Syllable mode: separate span per syllable, no space within same word
|
||||||
|
for (const si of group) {
|
||||||
|
const syl = syllabus[si];
|
||||||
|
const span = makeSpan(syl.text.trimEnd(), syl.time, syl.isBackground);
|
||||||
|
lineDiv.appendChild(span);
|
||||||
|
const entry: WordEntry = { el: span, start: syl.time, end: syl.time + syl.duration, duration: syl.duration };
|
||||||
|
lineWords.push(entry);
|
||||||
|
words.push(entry);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Word mode: merge syllables into one span
|
||||||
|
const mergedText = group.map(si => syllabus[si].text.trimEnd()).join("");
|
||||||
|
const first = syllabus[group[0]];
|
||||||
|
const last = syllabus[group[group.length - 1]];
|
||||||
|
const start = first.time;
|
||||||
|
const end = last.time + last.duration;
|
||||||
|
const bg = first.isBackground;
|
||||||
|
const span = makeSpan(mergedText, start, bg);
|
||||||
|
lineDiv.appendChild(span);
|
||||||
|
const entry: WordEntry = { el: span, start, end, duration: end - start };
|
||||||
|
lineWords.push(entry);
|
||||||
|
words.push(entry);
|
||||||
|
}
|
||||||
|
// Space between words (not between syllables of the same word)
|
||||||
|
lineDiv.appendChild(document.createTextNode(" "));
|
||||||
}
|
}
|
||||||
|
|
||||||
wbwContainer.appendChild(lineDiv);
|
wbwContainer.appendChild(lineDiv);
|
||||||
@@ -1624,6 +1649,21 @@ const buildWordSpans = (): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// insert spacers between lines with large timing gaps (instrumental breaks)
|
||||||
|
for (let i = 0; i < lines.length - 1; i++) {
|
||||||
|
const gap = lines[i + 1].startMs - lines[i].endMs;
|
||||||
|
if (gap > 2500) {
|
||||||
|
const spacer = document.createElement("div");
|
||||||
|
spacer.className = "rl-wbw-spacer";
|
||||||
|
forceStyle(spacer, {
|
||||||
|
display: "block",
|
||||||
|
height: "2rem",
|
||||||
|
margin: "0 0 1rem 0",
|
||||||
|
});
|
||||||
|
lines[i].el.after(spacer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// match lines to tidal spans by index
|
// match lines to tidal spans by index
|
||||||
const tidalSpans = Array.from(
|
const tidalSpans = Array.from(
|
||||||
innerDiv.querySelectorAll('span[data-test="lyrics-line"]'),
|
innerDiv.querySelectorAll('span[data-test="lyrics-line"]'),
|
||||||
@@ -1870,11 +1910,20 @@ const startTickLoop = (): void => {
|
|||||||
console.log("[RL-Syllable] Tick loop started");
|
console.log("[RL-Syllable] Tick loop started");
|
||||||
|
|
||||||
let lastLogTime = 0;
|
let lastLogTime = 0;
|
||||||
|
let lastTickMs = -1;
|
||||||
|
|
||||||
tickLoopUnload = safeInterval(unloads, () => {
|
tickLoopUnload = safeInterval(unloads, () => {
|
||||||
if (!isActive || lines.length === 0) return;
|
if (!isActive || lines.length === 0) return;
|
||||||
|
|
||||||
const nowMs = getPlaybackMs();
|
const nowMs = getPlaybackMs();
|
||||||
|
const isSyl = settings.lyricsStyle === 2;
|
||||||
|
const CLS_ACTIVE = isSyl ? "rl-syl-active" : "rl-wbw-active";
|
||||||
|
const CLS_FINISHED = isSyl ? "rl-syl-finished" : "rl-wbw-finished";
|
||||||
|
|
||||||
|
// scrub/seek detection: time went backward or jumped forward significantly
|
||||||
|
const timeDelta = nowMs - lastTickMs;
|
||||||
|
const didScrub = lastTickMs >= 0 && (timeDelta < -100 || timeDelta > 1000);
|
||||||
|
lastTickMs = nowMs;
|
||||||
|
|
||||||
// remove data-current from tidals hidden spans
|
// remove data-current from tidals hidden spans
|
||||||
const tidalCurrentSpans = document.querySelectorAll(
|
const tidalCurrentSpans = document.querySelectorAll(
|
||||||
@@ -1889,23 +1938,49 @@ const startTickLoop = (): void => {
|
|||||||
console.log(`[RL-Syllable] Playback | ${nowMs.toFixed(0)} ms`);
|
console.log(`[RL-Syllable] Playback | ${nowMs.toFixed(0)} ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// find active line
|
// find active line (-1 if before all lyrics or in instrumental)
|
||||||
let newLineIdx = activeLineIdx;
|
let newLineIdx = -1;
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const nextStart = lines[i + 1]?.startMs ?? Number.MAX_SAFE_INTEGER;
|
||||||
const nextLine = lines[i + 1];
|
const effectiveEnd = Math.min(nextStart, lines[i].endMs + 2500);
|
||||||
|
if (nowMs >= lines[i].startMs && nowMs < effectiveEnd) {
|
||||||
// Line is active until the next line start
|
|
||||||
const lineEnd = nextLine ? nextLine.startMs : Number.MAX_SAFE_INTEGER;
|
|
||||||
|
|
||||||
if (nowMs >= line.startMs && nowMs < lineEnd) {
|
|
||||||
newLineIdx = i;
|
newLineIdx = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to new line and set active/inactive + Hook scroll
|
// single pass to set correct state for all words (scrub or seek)
|
||||||
|
if (didScrub) {
|
||||||
|
for (let li = 0; li < lines.length; li++) {
|
||||||
|
for (const w of lines[li].words) {
|
||||||
|
if (li < newLineIdx) {
|
||||||
|
w.el.classList.remove(CLS_ACTIVE);
|
||||||
|
if (isSyl) w.el.style.animation = "";
|
||||||
|
if (!w.el.classList.contains(CLS_FINISHED)) w.el.classList.add(CLS_FINISHED);
|
||||||
|
} else {
|
||||||
|
w.el.classList.remove(CLS_ACTIVE, CLS_FINISHED);
|
||||||
|
if (isSyl) w.el.style.animation = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeWordEl = null;
|
||||||
|
if (activeLineIdx >= 0 && activeLineIdx < lines.length) {
|
||||||
|
lines[activeLineIdx].el.classList.remove("rl-wbw-line-active");
|
||||||
|
lines[activeLineIdx].el.removeAttribute("data-current");
|
||||||
|
}
|
||||||
|
activeLineIdx = -1;
|
||||||
|
console.log(`[RL-Syllable] Scrub detected (${timeDelta > 0 ? "+" : ""}${timeDelta.toFixed(0)} ms) → resync`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate line when entering instrumental
|
||||||
|
if (newLineIdx === -1 && activeLineIdx >= 0 && activeLineIdx < lines.length) {
|
||||||
|
lines[activeLineIdx].el.classList.remove("rl-wbw-line-active");
|
||||||
|
lines[activeLineIdx].el.removeAttribute("data-current");
|
||||||
|
activeLineIdx = -1;
|
||||||
|
activeWordEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to new line and set active/inactive
|
||||||
if (newLineIdx !== activeLineIdx && newLineIdx >= 0) {
|
if (newLineIdx !== activeLineIdx && newLineIdx >= 0) {
|
||||||
if (activeLineIdx >= 0 && activeLineIdx < lines.length) {
|
if (activeLineIdx >= 0 && activeLineIdx < lines.length) {
|
||||||
const oldLine = lines[activeLineIdx];
|
const oldLine = lines[activeLineIdx];
|
||||||
@@ -1939,7 +2014,7 @@ const startTickLoop = (): void => {
|
|||||||
hookSyncButton();
|
hookSyncButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
// find latest word that just started (for scrubbing and lyric jumps)
|
// find and activate current word
|
||||||
if (activeLineIdx < 0) return;
|
if (activeLineIdx < 0) return;
|
||||||
const currentLine = lines[activeLineIdx];
|
const currentLine = lines[activeLineIdx];
|
||||||
|
|
||||||
@@ -1951,15 +2026,16 @@ const startTickLoop = (): void => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeWordIdx >= 0) {
|
if (activeWordIdx < 0) return;
|
||||||
const word = currentLine.words[activeWordIdx];
|
const word = currentLine.words[activeWordIdx];
|
||||||
|
|
||||||
// make all words before are marked finished
|
// mark all words before as finished
|
||||||
for (let i = 0; i < activeWordIdx; i++) {
|
for (let i = 0; i < activeWordIdx; i++) {
|
||||||
const prev = currentLine.words[i].el;
|
const prev = currentLine.words[i].el;
|
||||||
if (prev.classList.contains("rl-wbw-active") || !prev.classList.contains("rl-wbw-finished")) {
|
if (prev.classList.contains(CLS_ACTIVE) || !prev.classList.contains(CLS_FINISHED)) {
|
||||||
prev.classList.remove("rl-wbw-active");
|
prev.classList.remove(CLS_ACTIVE);
|
||||||
prev.classList.add("rl-wbw-finished");
|
if (isSyl) prev.style.animation = "";
|
||||||
|
prev.classList.add(CLS_FINISHED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1967,27 +2043,30 @@ const startTickLoop = (): void => {
|
|||||||
if (isStillSinging) {
|
if (isStillSinging) {
|
||||||
if (activeWordEl !== word.el) {
|
if (activeWordEl !== word.el) {
|
||||||
if (activeWordEl) {
|
if (activeWordEl) {
|
||||||
activeWordEl.classList.remove("rl-wbw-active");
|
activeWordEl.classList.remove(CLS_ACTIVE);
|
||||||
activeWordEl.classList.add("rl-wbw-finished");
|
if (isSyl) activeWordEl.style.animation = "";
|
||||||
|
activeWordEl.classList.add(CLS_FINISHED);
|
||||||
|
}
|
||||||
|
word.el.classList.add(CLS_ACTIVE);
|
||||||
|
word.el.classList.remove(CLS_FINISHED);
|
||||||
|
if (isSyl) {
|
||||||
|
word.el.style.animation = `rl-wipe ${word.duration}ms linear forwards`;
|
||||||
}
|
}
|
||||||
word.el.classList.add("rl-wbw-active");
|
|
||||||
word.el.classList.remove("rl-wbw-finished");
|
|
||||||
activeWordEl = word.el;
|
activeWordEl = word.el;
|
||||||
console.log(
|
console.log(
|
||||||
`[RL-Syllable] Word "${word.el.textContent}" | ${word.start} ms - ${word.end} ms [${nowMs.toFixed(0)} ms]`,
|
`[RL-Syllable] Word "${word.el.textContent}" | ${word.start} ms - ${word.end} ms [${nowMs.toFixed(0)} ms]`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Past this words end, waiting for next word
|
word.el.classList.remove(CLS_ACTIVE);
|
||||||
word.el.classList.remove("rl-wbw-active");
|
if (isSyl) word.el.style.animation = "";
|
||||||
if (!word.el.classList.contains("rl-wbw-finished")) {
|
if (!word.el.classList.contains(CLS_FINISHED)) {
|
||||||
word.el.classList.add("rl-wbw-finished");
|
word.el.classList.add(CLS_FINISHED);
|
||||||
}
|
}
|
||||||
if (activeWordEl === word.el) {
|
if (activeWordEl === word.el) {
|
||||||
activeWordEl = null;
|
activeWordEl = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, 50);
|
}, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -160,7 +160,53 @@
|
|||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Finished word */
|
/* MARKER: Syllable sweep animation CSS */
|
||||||
|
|
||||||
|
@keyframes rl-wipe {
|
||||||
|
from {
|
||||||
|
background-size: 0.75em 100%, 0% 100%, 100% 100%;
|
||||||
|
background-position: -0.375em 0%, left, left;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-size: 0.75em 100%, 100% 100%, 100% 100%;
|
||||||
|
background-position: calc(100% + 0.375em) 0%, left, left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syllable active: gradient sweep L-to-R via background-clip */
|
||||||
|
.rl-wbw-word.rl-syl-active {
|
||||||
|
/* biome-ignore lint: Kill base transitions so class swaps are instant */
|
||||||
|
transition: none !important;
|
||||||
|
/* biome-ignore lint: Transparent fill so gradient paints the text */
|
||||||
|
color: transparent !important;
|
||||||
|
/* biome-ignore lint: Clip gradient to text glyphs */
|
||||||
|
-webkit-background-clip: text !important;
|
||||||
|
/* biome-ignore lint: Clip gradient to text glyphs */
|
||||||
|
background-clip: text !important;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(90deg, transparent 0%, var(--cl-glow1, #fff) 50%, transparent 100%),
|
||||||
|
linear-gradient(90deg, var(--cl-glow1, #fff) 100%, transparent 100%),
|
||||||
|
linear-gradient(90deg, rgba(128, 128, 128, 0.4), rgba(128, 128, 128, 0.4));
|
||||||
|
background-size: 0.75em 100%, 0% 100%, 100% 100%;
|
||||||
|
background-position: -0.375em 0%, left, left;
|
||||||
|
/* biome-ignore lint: No glow for syllable mode */
|
||||||
|
text-shadow: none !important;
|
||||||
|
/* biome-ignore lint: No glow for syllable mode */
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syllable finished: word stays white, no glow */
|
||||||
|
.rl-wbw-word.rl-syl-finished {
|
||||||
|
/* biome-ignore lint: Kill base transitions so class swaps are instant */
|
||||||
|
transition: none !important;
|
||||||
|
/* biome-ignore lint: Finished syllable word stays white */
|
||||||
|
color: white !important;
|
||||||
|
/* biome-ignore lint: No glow for syllable mode */
|
||||||
|
text-shadow: none !important;
|
||||||
|
/* biome-ignore lint: No glow for syllable mode */
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tidals "..." at the top of the container */
|
/* Tidals "..." at the top of the container */
|
||||||
.rl-wbw-active > span:not([data-test="lyrics-line"]) {
|
.rl-wbw-active > span:not([data-test="lyrics-line"]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user