Merge pull request #71 from meowarex/dev

Context Aware Lyrics & Aniamtions (Line)
This commit is contained in:
Meow Meow
2026-02-24 23:09:59 +11:00
committed by GitHub
3 changed files with 663 additions and 127 deletions
+67 -18
View File
@@ -42,8 +42,11 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
settingsAffectNowPlaying: true,
stickyLyrics: false,
stickyLyricsIcon: "sparkle" as string,
lyricsStyle: 0,
lyricsStyle: 2,
syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon)
contextAwareLyrics: true,
blurInactive: true,
bubbledLyrics: true,
syllableLogging: false,
});
@@ -124,12 +127,18 @@ export const Settings = () => {
}, []);
const [lyricsStyle, setLyricsStyle] = React.useState(settings.lyricsStyle);
React.useEffect(() => {
window.updateLyricsStyleSetting = (value: number) =>
setLyricsStyle(value);
window.updateLyricsStyleSetting = (value: number) => setLyricsStyle(value);
return () => {
window.updateLyricsStyleSetting = undefined;
};
}, []);
const [contextAwareLyrics, setContextAwareLyrics] = React.useState(
settings.contextAwareLyrics,
);
const [blurInactive, setBlurInactive] = React.useState(settings.blurInactive);
const [bubbledLyrics, setBubbledLyrics] = React.useState(
settings.bubbledLyrics,
);
const [qualityProgressColor, setQualityProgressColor] = React.useState(
settings.qualityProgressColor,
);
@@ -188,21 +197,61 @@ export const Settings = () => {
}}
/>
)}
<LunaNumberSetting
title="Lyrics Style"
desc="0 = Line (default), 1 = Word, 2 = Syllable (mirrored in lyrics dropdown)"
min={0}
max={1}
step={1}
value={lyricsStyle}
onNumber={(value: number) => {
settings.lyricsStyle = value;
setLyricsStyle(value);
if (window.updateLyricsStyle) {
window.updateLyricsStyle();
}
}}
/>
<LunaNumberSetting
title="Lyrics Style"
desc="0 = Line (default), 1 = Word, 2 = Syllable (mirrored in lyrics dropdown)"
min={0}
max={1}
step={1}
value={lyricsStyle}
onNumber={(value: number) => {
settings.lyricsStyle = value;
setLyricsStyle(value);
if (window.updateLyricsStyle) {
window.updateLyricsStyle();
}
}}
/>
<AnySwitch
title="Blur Inactive"
desc="Blurs inactive lyric lines, scaling with distance from the active line"
checked={blurInactive}
onChange={(_: unknown, checked: boolean) => {
settings.blurInactive = checked;
setBlurInactive(checked);
if (window.updateLyricsStyle) {
window.updateLyricsStyle();
}
}}
/>
{lyricsStyle >= 1 && (
<>
<AnySwitch
title="Context Aware Lyrics"
desc="Enables background vocal display & duet singer positioning"
checked={contextAwareLyrics}
onChange={(_: unknown, checked: boolean) => {
settings.contextAwareLyrics = checked;
setContextAwareLyrics(checked);
if (window.updateLyricsStyle) {
window.updateLyricsStyle();
}
}}
/>
<AnySwitch
title="Bubbled Lyrics"
desc="Smooth bounce animation on line/word transitions"
checked={bubbledLyrics}
onChange={(_: unknown, checked: boolean) => {
settings.bubbledLyrics = checked;
setBubbledLyrics(checked);
if (window.updateLyricsStyle) {
window.updateLyricsStyle();
}
}}
/>
</>
)}
<AnySwitch
title="Sticky Lyrics"
desc="auto-switches to Play Queue when lyrics aren't available (mirrored in lyrics dropdown)"
+466 -106
View File
@@ -1250,6 +1250,8 @@ interface WordLyricsResponse {
title: string;
language: string;
totalDuration: string;
agents?: Record<string, { type: string; name: string; alias: string }>;
songParts?: Array<{ name: string; time: number; duration: number }>;
};
_cached?: boolean;
}
@@ -1257,6 +1259,7 @@ interface WordLyricsResponse {
// syllable state
let trackChangeToken = 0;
let lyricsData: WordLine[] | null = null;
let lyricsResponse: WordLyricsResponse | null = null;
let tickLoopUnload: LunaUnload | null = null;
let isActive = false;
let savedTidalClasses: string[] | null = null;
@@ -1274,13 +1277,17 @@ interface LineEntry {
startMs: number; // first word start
endMs: number; // last word end
words: WordEntry[];
bgWords: WordEntry[];
isBg: boolean; // entirely background/adlib line
}
let lines: LineEntry[] = [];
let rerenderObserver: MutationObserver | null = null;
let rerenderDebounce: number | null = null;
let activeWordEl: HTMLSpanElement | null = null;
let activeLineIdx = -1;
const activeWordEls = new Map<number, HTMLSpanElement | null>();
const activeBgWordEls = new Map<number, HTMLSpanElement | null>();
let activeLineIdxs = new Set<number>();
let primaryLineIdx = -1;
// Scroll sync (unhook on user scroll)
let scrollSynced = true;
@@ -1288,6 +1295,117 @@ let userScrollListener: (() => void) | null = null;
let syncButtonListener: (() => void) | null = null;
let syncButtonEl: HTMLElement | null = null;
// scroll bounce animation state
let scrollAnimIsAnimating = false;
let scrollAnimPending: { parent: HTMLElement; refIdx: number; target: number } | null = null;
let scrollUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
let scrollCleanupTimeout: ReturnType<typeof setTimeout> | null = null;
let animatingEls: HTMLElement[] = [];
const clearScrollAnim = (): void => {
if (scrollUnlockTimeout) { clearTimeout(scrollUnlockTimeout); scrollUnlockTimeout = null; }
if (scrollCleanupTimeout) { clearTimeout(scrollCleanupTimeout); scrollCleanupTimeout = null; }
for (const el of animatingEls) {
el.classList.remove("rl-scroll-animate");
el.style.removeProperty("--rl-scroll-delta");
el.style.removeProperty("--rl-line-delay");
}
animatingEls = [];
scrollAnimIsAnimating = false;
scrollAnimPending = null;
};
const applyScrollBounce = (scrollParent: HTMLElement, referenceIdx: number, scrollTarget: number): void => {
// queue if an animation is already running
if (scrollAnimIsAnimating) {
scrollAnimPending = { parent: scrollParent, refIdx: referenceIdx, target: scrollTarget };
return;
}
// clear previous animation timeouts
if (scrollUnlockTimeout) { clearTimeout(scrollUnlockTimeout); scrollUnlockTimeout = null; }
if (scrollCleanupTimeout) { clearTimeout(scrollCleanupTimeout); scrollCleanupTimeout = null; }
// clean up previous animation classes
for (const el of animatingEls) {
el.classList.remove("rl-scroll-animate");
el.style.removeProperty("--rl-scroll-delta");
el.style.removeProperty("--rl-line-delay");
}
animatingEls = [];
// cancel any in-flight CSS animations
const container = scrollParent.querySelector(".rl-wbw-container");
if (container) {
for (const anim of container.getAnimations({ subtree: true })) {
if (anim instanceof CSSAnimation && anim.animationName === "rl-scroll-bounce") {
anim.cancel();
}
}
}
// clamp target to the actual scrollable range (avoid overshoot at bottom of lyrics)
const maxScroll = scrollParent.scrollHeight - scrollParent.clientHeight;
const clampedTarget = Math.max(0, Math.min(scrollTarget, maxScroll));
const delta = clampedTarget - scrollParent.scrollTop;
if (Math.abs(delta) < 2) {
scrollTo(scrollParent, { top: clampedTarget, behavior: "instant" });
return;
}
const lookBehind = 5;
const lookAhead = 20;
const delayIncrement = 30;
const start = Math.max(0, referenceIdx - lookBehind);
const end = Math.min(lines.length, referenceIdx + lookAhead);
let maxDuration = 0;
let delayCounter = 0;
// apply animation classes FIRST (offset elements to starting position)
for (let i = start; i < end; i++) {
const el = lines[i].el;
const delay = i >= referenceIdx ? delayCounter * delayIncrement : 0;
if (i >= referenceIdx && !el.classList.contains("rl-wbw-spacer")) {
delayCounter++;
}
el.style.setProperty("--rl-scroll-delta", `${delta}px`);
el.style.setProperty("--rl-line-delay", `${delay}ms`);
el.classList.add("rl-scroll-animate");
animatingEls.push(el);
const duration = 400 + delay;
if (duration > maxDuration) maxDuration = duration;
}
// scroll AFTER classes are applied (invisible because elements are offset by delta)
scrollAnimIsAnimating = true;
scrollTo(scrollParent, { top: clampedTarget, behavior: "instant" });
// unlock animation state after base duration, process pending if queued
const BASE_DURATION = 400;
scrollUnlockTimeout = setTimeout(() => {
scrollAnimIsAnimating = false;
if (scrollAnimPending) {
const pending = scrollAnimPending;
scrollAnimPending = null;
applyScrollBounce(pending.parent, pending.refIdx, pending.target);
}
}, BASE_DURATION);
// clean up animation classes after all staggered animations complete
scrollCleanupTimeout = setTimeout(() => {
for (const el of animatingEls) {
el.classList.remove("rl-scroll-animate");
el.style.removeProperty("--rl-scroll-delta");
el.style.removeProperty("--rl-line-delay");
}
animatingEls = [];
}, maxDuration + 50);
};
// scroll lock (for scroll gate)
let scrollParentRef: HTMLElement | null = null;
let savedScrollTo: any = null;
@@ -1477,6 +1595,60 @@ const restoreTidalLyrics = (): void => {
savedTidalClasses = null;
};
// compute left/right singer sides for duet positioning
// Uses a pre-computed fixed mapping: first person = left, second person = right,
// 3rd+ persons / group / other = left. Same singer always gets the same side.
// (thx Opus 4.6 for this <3)
const computeSingerSides = (
data: WordLine[],
agents: Record<string, { type: string; name: string; alias: string }>,
): { sides: string[]; isDualSide: boolean } => {
const sides = new Array<string>(data.length).fill("");
const personOrder: string[] = [];
const singerSideMap = new Map<string, string>();
for (const line of data) {
const singerId = line.element?.singer;
if (!singerId || singerSideMap.has(singerId)) continue;
const agentData = agents[singerId];
const type = agentData
? agentData.type
: singerId === "v1000" ? "group" : singerId === "v2000" ? "other" : "person";
if (type === "group" || type === "other") {
singerSideMap.set(singerId, "rl-singer-left");
} else {
personOrder.push(singerId);
singerSideMap.set(singerId,
personOrder.length === 2 ? "rl-singer-right" : "rl-singer-left",
);
}
}
let rightCount = 0;
let totalCount = 0;
for (let i = 0; i < data.length; i++) {
const singerId = data[i].element?.singer;
if (!singerId) continue;
const side = singerSideMap.get(singerId) || "rl-singer-left";
sides[i] = side;
totalCount++;
if (side === "rl-singer-right") rightCount++;
}
if (totalCount > 0 && Math.round((rightCount / totalCount) * 100) >= 85) {
const flip = (s: string) =>
s === "rl-singer-left" ? "rl-singer-right"
: s === "rl-singer-right" ? "rl-singer-left" : s;
for (let i = 0; i < sides.length; i++) sides[i] = flip(sides[i]);
}
const hasLeft = sides.includes("rl-singer-left");
const hasRight = sides.includes("rl-singer-right");
return { sides, isDualSide: hasLeft && hasRight };
};
// build word/syllable container over tidal spans
const buildWordSpans = (): {
lines: LineEntry[];
@@ -1512,6 +1684,8 @@ const buildWordSpans = (): {
// create lyrics container for word/syllable lines
const wbwContainer = document.createElement("div");
wbwContainer.className = "rl-wbw-container";
if (settings.blurInactive) wbwContainer.classList.add("rl-blur-active");
if (settings.bubbledLyrics) wbwContainer.classList.add("rl-bubbled");
// MARKER: Syllable animations (WIP coming soon)
if (settings.syllableStyle === 1) wbwContainer.classList.add("rl-syl-pop");
else if (settings.syllableStyle === 2) wbwContainer.classList.add("rl-syl-jump");
@@ -1527,10 +1701,24 @@ const buildWordSpans = (): {
overflow: "visible",
});
const contextAware = settings.contextAwareLyrics;
const agents = lyricsResponse?.metadata?.agents;
let singerSides: { sides: string[]; isDualSide: boolean } | null = null;
if (contextAware && agents && Object.keys(agents).length > 0) {
singerSides = computeSingerSides(lyricsData, agents);
if (singerSides.isDualSide) {
wbwContainer.classList.add("rl-dual-side");
}
}
const FONT_STACK =
'"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif';
let lineIndex = 0;
for (const apiLine of lyricsData) {
const currentLineIndex = lineIndex++;
// skip empty/stanza-end lines
if (!apiLine.syllabus || apiLine.syllabus.length === 0) {
const spacer = document.createElement("div");
@@ -1548,13 +1736,11 @@ const buildWordSpans = (): {
lineDiv.className = "rl-wbw-line";
forceStyle(lineDiv, {
display: "block",
"text-align": "left",
"white-space": "normal",
"word-spacing": "normal",
"letter-spacing": "normal",
"margin-bottom": "2rem",
"padding-top": "0",
"padding-right": "0",
"padding-bottom": "0",
"font-size": "40px",
"font-family": FONT_STACK,
@@ -1568,10 +1754,35 @@ const buildWordSpans = (): {
"align-items": "initial",
});
if (contextAware && singerSides) {
const sideClass = singerSides.sides[currentLineIndex];
if (sideClass) lineDiv.classList.add(sideClass);
}
const lineWords: WordEntry[] = [];
const lineBgWords: WordEntry[] = [];
const syllabus = apiLine.syllabus;
const isSylMode = settings.lyricsStyle === 2;
const hasBgSyllables = contextAware && syllabus.some(s => s.isBackground);
const allAreBg = hasBgSyllables && syllabus.every(s => s.isBackground);
const splitBg = hasBgSyllables && !allAreBg;
let mainContainer: HTMLElement = lineDiv;
let bgContainer: HTMLElement | null = null;
if (splitBg) {
mainContainer = document.createElement("p");
mainContainer.className = "rl-wbw-main";
forceStyle(mainContainer, { margin: "0", padding: "0" });
lineDiv.appendChild(mainContainer);
bgContainer = document.createElement("p");
bgContainer.className = "rl-wbw-bg-container";
forceStyle(bgContainer, { margin: "0" });
lineDiv.appendChild(bgContainer);
}
const WORD_SPAN_STYLE: Record<string, string> = {
display: "inline-block",
float: "none",
@@ -1585,7 +1796,11 @@ const buildWordSpans = (): {
const makeSpan = (text: string, seekMs: number, bg: boolean): HTMLSpanElement => {
const span = document.createElement("span");
span.className = "rl-wbw-word";
span.textContent = text;
if (splitBg && bg) {
span.textContent = text.replace(/[()]/g, "");
} else {
span.textContent = text;
}
forceStyle(span, WORD_SPAN_STYLE);
if (bg) span.classList.add("rl-wbw-bg");
span.addEventListener("click", () => {
@@ -1609,8 +1824,11 @@ const buildWordSpans = (): {
}
for (const group of wordGroups) {
const groupIsBg = splitBg && syllabus[group[0]].isBackground;
const targetContainer = groupIsBg ? bgContainer! : mainContainer;
const targetWords = groupIsBg ? lineBgWords : lineWords;
if (isSylMode) {
// Syllable mode: separate span per syllable, seek/hover grouped by word
const wordStartMs = syllabus[group[0]].time;
const groupSpans: HTMLSpanElement[] = [];
for (const si of group) {
@@ -1623,12 +1841,11 @@ const buildWordSpans = (): {
for (const s of groupSpans) s.classList.remove("rl-wbw-word-hover");
});
groupSpans.push(span);
lineDiv.appendChild(span);
targetContainer.appendChild(span);
const entry: WordEntry = { el: span, start: syl.time, end: syl.time + syl.duration, duration: syl.duration };
lineWords.push(entry);
targetWords.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]];
@@ -1636,24 +1853,33 @@ const buildWordSpans = (): {
const end = last.time + last.duration;
const bg = first.isBackground;
const span = makeSpan(mergedText, start, bg);
lineDiv.appendChild(span);
targetContainer.appendChild(span);
const entry: WordEntry = { el: span, start, end, duration: end - start };
lineWords.push(entry);
targetWords.push(entry);
}
// Space between words (not between syllables of the same word)
lineDiv.appendChild(document.createTextNode(" "));
targetContainer.appendChild(document.createTextNode(" "));
}
wbwContainer.appendChild(lineDiv);
// build entry from syllables
if (lineWords.length > 0) {
const allWords = lineWords.length > 0 ? lineWords : lineBgWords;
if (allWords.length > 0) {
const firstStart = Math.min(
lineWords.length > 0 ? lineWords[0].start : Infinity,
lineBgWords.length > 0 ? lineBgWords[0].start : Infinity,
);
const lastEnd = Math.max(
lineWords.length > 0 ? lineWords[lineWords.length - 1].end : 0,
lineBgWords.length > 0 ? lineBgWords[lineBgWords.length - 1].end : 0,
);
lines.push({
el: lineDiv,
tidalSpan: null,
startMs: lineWords[0].start,
endMs: lineWords[lineWords.length - 1].end,
words: lineWords,
startMs: firstStart,
endMs: lastEnd,
words: allWords,
bgWords: lineBgWords,
isBg: allAreBg,
});
}
}
@@ -1752,6 +1978,7 @@ const clearTickLoop = (): void => {
const teardown = (): void => {
trackChangeToken++;
clearTickLoop();
clearScrollAnim();
unwatchRerender();
unhookUserScroll();
unhookSyncButton();
@@ -1759,9 +1986,12 @@ const teardown = (): void => {
scrollSynced = true;
isActive = false;
lyricsData = null;
lyricsResponse = null;
lines = [];
activeWordEl = null;
activeLineIdx = -1;
activeWordEls.clear();
activeBgWordEls.clear();
activeLineIdxs.clear();
primaryLineIdx = -1;
restoreTidalLyrics();
};
@@ -1847,20 +2077,24 @@ const scrollTo = (parent: HTMLElement, options: ScrollToOptions): void => {
// Scroll to active line (resync)
const scrollToActiveLine = (): void => {
if (activeLineIdx < 0 || activeLineIdx >= lines.length) return;
const line = lines[activeLineIdx];
if (primaryLineIdx < 0 || primaryLineIdx >= lines.length) return;
const line = lines[primaryLineIdx];
const scroller = findScroller(line.el);
lockScroll(scroller);
const lineRect = line.el.getBoundingClientRect();
const parentRect = scroller.getBoundingClientRect();
const targetOffset = parentRect.height * 0.2;
const scrollTarget = scroller.scrollTop + (lineRect.top - parentRect.top) - targetOffset;
scrollTo(scroller, { top: Math.max(0, scrollTarget), behavior: "smooth" });
clearScrollAnim();
scrollTo(scroller, { top: Math.max(0, scrollTarget), behavior: "instant" });
};
// Resync lyric scroll (scrubbing and lyric jumps)
const resync = (): void => {
scrollSynced = true;
if (settings.blurInactive) {
document.querySelector(".rl-wbw-container")?.classList.add("rl-blur-active");
}
scrollToActiveLine();
const tidalSyncBtn = document.querySelector('div[class*="_syncButton"] button') as HTMLElement;
if (tidalSyncBtn) tidalSyncBtn.click();
@@ -1874,6 +2108,9 @@ const hookUserScroll = (parent: HTMLElement): void => {
const onUserScroll = () => {
if (!scrollSynced) return;
scrollSynced = false;
if (settings.blurInactive) {
document.querySelector(".rl-wbw-container")?.classList.remove("rl-blur-active");
}
sylLog("[RL-Syllable] User scrolled — auto-scroll unhooked");
};
parent.addEventListener("wheel", onUserScroll, { passive: true });
@@ -1945,22 +2182,30 @@ const startTickLoop = (): void => {
sylLog(`[RL-Syllable] Playback | ${nowMs.toFixed(0)} ms`);
}
// find active line (-1 if before all lyrics or in instrumental)
let newLineIdx = -1;
// find all active lines (supports overlapping duet/adlib lines)
const newActiveSet = new Set<number>();
for (let i = 0; i < lines.length; i++) {
const nextStart = lines[i + 1]?.startMs ?? Number.MAX_SAFE_INTEGER;
const effectiveEnd = Math.min(nextStart, lines[i].endMs + 2500);
const lineEnd = lines[i].endMs;
// skip over background/adlib lines when computing nextStart so main lines
// stay active while their attached adlibs play (vewy important thx Opus 4.6)
let nextMainIdx = i + 1;
while (nextMainIdx < lines.length && lines[nextMainIdx].isBg) nextMainIdx++;
const nextStart = nextMainIdx < lines.length ? lines[nextMainIdx].startMs : Infinity;
const effectiveEnd = Math.max(lineEnd, Math.min(lineEnd + 2500, nextStart));
if (nowMs >= lines[i].startMs && nowMs < effectiveEnd) {
newLineIdx = i;
break;
newActiveSet.add(i);
}
}
const newPrimary = newActiveSet.size > 0 ? Math.min(...newActiveSet) : -1;
// 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) {
const allEntries = lines[li].bgWords.length > 0
? [...lines[li].words, ...lines[li].bgWords]
: lines[li].words;
for (const w of allEntries) {
if (li < newPrimary) {
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);
@@ -1970,35 +2215,72 @@ const startTickLoop = (): void => {
}
}
}
activeWordEl = null;
if (activeLineIdx >= 0 && activeLineIdx < lines.length) {
lines[activeLineIdx].el.classList.remove("rl-wbw-line-active");
lines[activeLineIdx].el.removeAttribute("data-current");
activeWordEls.clear();
activeBgWordEls.clear();
for (const idx of activeLineIdxs) {
if (idx < lines.length) {
lines[idx].el.classList.remove("rl-wbw-line-active");
lines[idx].el.removeAttribute("data-current");
}
}
activeLineIdx = -1;
activeLineIdxs.clear();
primaryLineIdx = -1;
const held = document.querySelector(".rl-gap-hold");
if (held) held.classList.remove("rl-gap-hold");
sylLog(`[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;
// deactivate lines no longer active
for (const idx of activeLineIdxs) {
if (!newActiveSet.has(idx) && idx < lines.length) {
lines[idx].el.classList.remove("rl-wbw-line-active");
lines[idx].el.removeAttribute("data-current");
const lastWord = activeWordEls.get(idx);
if (lastWord) {
lastWord.classList.remove(CLS_ACTIVE);
if (isSyl) lastWord.style.animation = "";
lastWord.classList.add(CLS_FINISHED);
}
const lastBgWord = activeBgWordEls.get(idx);
if (lastBgWord) {
lastBgWord.classList.remove(CLS_ACTIVE);
if (isSyl) lastBgWord.style.animation = "";
lastBgWord.classList.add(CLS_FINISHED);
}
activeWordEls.delete(idx);
activeBgWordEls.delete(idx);
}
}
// Scroll to new line and set active/inactive
if (newLineIdx !== activeLineIdx && newLineIdx >= 0) {
if (activeLineIdx >= 0 && activeLineIdx < lines.length) {
const oldLine = lines[activeLineIdx];
oldLine.el.classList.remove("rl-wbw-line-active");
oldLine.el.removeAttribute("data-current");
// activate newly active lines
for (const idx of newActiveSet) {
if (!activeLineIdxs.has(idx)) {
lines[idx].el.classList.add("rl-wbw-line-active");
lines[idx].el.classList.remove("rl-pos-1", "rl-pos-2", "rl-pos-3");
lines[idx].el.setAttribute("data-current", "true");
sylLog(
`[RL-Syllable] Line ${idx} Active "${lines[idx].el.textContent?.slice(0, 40)}" | ${lines[idx].startMs} ms - ${lines[idx].endMs} ms [${nowMs.toFixed(0)} ms]`,
);
}
activeLineIdx = newLineIdx;
const newLine = lines[activeLineIdx];
newLine.el.classList.add("rl-wbw-line-active");
newLine.el.setAttribute("data-current", "true");
}
// instrumental gaps, keep the last-active line unblurred
if (settings.blurInactive) {
if (newActiveSet.size === 0 && primaryLineIdx >= 0 && primaryLineIdx < lines.length) {
lines[primaryLineIdx].el.classList.add("rl-gap-hold");
} else if (newActiveSet.size > 0) {
const held = document.querySelector(".rl-gap-hold");
if (held) held.classList.remove("rl-gap-hold");
}
}
activeLineIdxs = newActiveSet;
// scroll to primary (topmost) active line
if (newPrimary !== primaryLineIdx && newPrimary >= 0) {
const prevPrimary = primaryLineIdx;
primaryLineIdx = newPrimary;
const newLine = lines[primaryLineIdx];
const scrollParent = findScroller(newLine.el);
lockScroll(scrollParent);
hookUserScroll(scrollParent);
@@ -2008,12 +2290,32 @@ const startTickLoop = (): void => {
const parentRect = scrollParent.getBoundingClientRect();
const targetOffset = parentRect.height * 0.2;
const scrollTarget = scrollParent.scrollTop + (lineRect.top - parentRect.top) - targetOffset;
scrollTo(scrollParent, { top: Math.max(0, scrollTarget), behavior: "smooth" });
// only bounce on normal sequential line changes (not scrubs, jumps, or overlapping activations)
const isSequential = !didScrub && prevPrimary >= 0 && newActiveSet.size <= 1;
if (settings.bubbledLyrics && isSequential) {
applyScrollBounce(scrollParent, primaryLineIdx, scrollTarget);
} else if (isSequential) {
clearScrollAnim();
scrollTo(scrollParent, { top: Math.max(0, scrollTarget), behavior: "smooth" });
} else {
clearScrollAnim();
scrollTo(scrollParent, { top: Math.max(0, scrollTarget), behavior: "instant" });
}
}
sylLog(
`[RL-Syllable] Line ${activeLineIdx} Active "${newLine.el.textContent?.slice(0, 40)}" | ${newLine.startMs} ms - ${newLine.endMs} ms [${nowMs.toFixed(0)} ms]`,
);
// distance-based blur position classes (skip active lines)
if (settings.blurInactive) {
for (let i = 0; i < lines.length; i++) {
lines[i].el.classList.remove("rl-pos-1", "rl-pos-2", "rl-pos-3");
}
for (let dist = 1; dist <= 3; dist++) {
const before = newPrimary - dist;
const after = newPrimary + dist;
const cls = `rl-pos-${dist}`;
if (before >= 0 && !newActiveSet.has(before)) lines[before].el.classList.add(cls);
if (after < lines.length && !newActiveSet.has(after)) lines[after].el.classList.add(cls);
}
}
}
// hook lyric scroll sync button
@@ -2021,60 +2323,114 @@ const startTickLoop = (): void => {
hookSyncButton();
}
// find and activate current word
if (activeLineIdx < 0) return;
const currentLine = lines[activeLineIdx];
// highlight words in all active lines
if (activeLineIdxs.size === 0) return;
let activeWordIdx = -1;
for (let i = currentLine.words.length - 1; i >= 0; i--) {
if (nowMs >= currentLine.words[i].start) {
activeWordIdx = i;
break;
}
}
for (const lineIdx of activeLineIdxs) {
const currentLine = lines[lineIdx];
const prevActiveWord = activeWordEls.get(lineIdx) ?? null;
if (activeWordIdx < 0) return;
const word = currentLine.words[activeWordIdx];
// mark all words before as finished
for (let i = 0; i < activeWordIdx; i++) {
const prev = currentLine.words[i].el;
if (prev.classList.contains(CLS_ACTIVE) || !prev.classList.contains(CLS_FINISHED)) {
prev.classList.remove(CLS_ACTIVE);
if (isSyl) prev.style.animation = "";
prev.classList.add(CLS_FINISHED);
}
}
const isStillSinging = nowMs <= word.end;
if (isStillSinging) {
if (activeWordEl !== word.el) {
if (activeWordEl) {
activeWordEl.classList.remove(CLS_ACTIVE);
if (isSyl) activeWordEl.style.animation = "";
activeWordEl.classList.add(CLS_FINISHED);
let activeWordIdx = -1;
for (let i = currentLine.words.length - 1; i >= 0; i--) {
if (nowMs >= currentLine.words[i].start) {
activeWordIdx = i;
break;
}
word.el.classList.add(CLS_ACTIVE);
word.el.classList.remove(CLS_FINISHED);
if (isSyl) { // MARKER: Syllable animations (WIP coming soon)
const wipe = `rl-wipe ${word.duration}ms linear forwards`;
const sylAnim = settings.syllableStyle === 1 ? ", rl-pop 0.6s ease-out"
: settings.syllableStyle === 2 ? ", rl-jump 0.35s ease-out" : "";
word.el.style.animation = wipe + sylAnim;
}
if (activeWordIdx < 0) continue;
const word = currentLine.words[activeWordIdx];
for (let i = 0; i < activeWordIdx; i++) {
const prev = currentLine.words[i].el;
if (prev.classList.contains(CLS_ACTIVE) || !prev.classList.contains(CLS_FINISHED)) {
prev.classList.remove(CLS_ACTIVE);
if (isSyl) prev.style.animation = "";
prev.classList.add(CLS_FINISHED);
}
activeWordEl = word.el;
sylLog(
`[RL-Syllable] Word/Syllable "${word.el.textContent}" | ${word.start} ms - ${word.end} ms [${nowMs.toFixed(0)} ms]`,
);
}
} else {
word.el.classList.remove(CLS_ACTIVE);
if (isSyl) word.el.style.animation = "";
if (!word.el.classList.contains(CLS_FINISHED)) {
word.el.classList.add(CLS_FINISHED);
const isStillSinging = nowMs <= word.end;
if (isStillSinging) {
if (prevActiveWord !== word.el) {
if (prevActiveWord) {
prevActiveWord.classList.remove(CLS_ACTIVE);
if (isSyl) prevActiveWord.style.animation = "";
prevActiveWord.classList.add(CLS_FINISHED);
}
word.el.classList.add(CLS_ACTIVE);
word.el.classList.remove(CLS_FINISHED);
if (isSyl) {
const wipe = `rl-wipe ${word.duration}ms linear forwards`;
const sylAnim = settings.syllableStyle === 1 ? ", rl-pop 0.6s ease-out"
: settings.syllableStyle === 2 ? ", rl-jump 0.35s ease-out" : "";
word.el.style.animation = wipe + sylAnim;
}
activeWordEls.set(lineIdx, word.el);
sylLog(
`[RL-Syllable] Word/Syllable "${word.el.textContent}" | ${word.start} ms - ${word.end} ms [${nowMs.toFixed(0)} ms]`,
);
}
} else {
word.el.classList.remove(CLS_ACTIVE);
if (isSyl) word.el.style.animation = "";
if (!word.el.classList.contains(CLS_FINISHED)) {
word.el.classList.add(CLS_FINISHED);
}
if (prevActiveWord === word.el) {
activeWordEls.set(lineIdx, null);
}
}
if (activeWordEl === word.el) {
activeWordEl = null;
// highlight bg words independently (adlibs no interfere with main words *angy*)
const bgWords = currentLine.bgWords;
if (bgWords.length === 0) continue;
const prevBgWord = activeBgWordEls.get(lineIdx) ?? null;
let activeBgIdx = -1;
for (let i = bgWords.length - 1; i >= 0; i--) {
if (nowMs >= bgWords[i].start) {
activeBgIdx = i;
break;
}
}
if (activeBgIdx < 0) continue;
const bgWord = bgWords[activeBgIdx];
for (let i = 0; i < activeBgIdx; i++) {
const prev = bgWords[i].el;
if (prev.classList.contains(CLS_ACTIVE) || !prev.classList.contains(CLS_FINISHED)) {
prev.classList.remove(CLS_ACTIVE);
if (isSyl) prev.style.animation = "";
prev.classList.add(CLS_FINISHED);
}
}
const bgStillSinging = nowMs <= bgWord.end;
if (bgStillSinging) {
if (prevBgWord !== bgWord.el) {
if (prevBgWord) {
prevBgWord.classList.remove(CLS_ACTIVE);
if (isSyl) prevBgWord.style.animation = "";
prevBgWord.classList.add(CLS_FINISHED);
}
bgWord.el.classList.add(CLS_ACTIVE);
bgWord.el.classList.remove(CLS_FINISHED);
if (isSyl) {
bgWord.el.style.animation = `rl-wipe ${bgWord.duration}ms linear forwards`;
}
activeBgWordEls.set(lineIdx, bgWord.el);
}
} else {
bgWord.el.classList.remove(CLS_ACTIVE);
if (isSyl) bgWord.el.style.animation = "";
if (!bgWord.el.classList.contains(CLS_FINISHED)) {
bgWord.el.classList.add(CLS_FINISHED);
}
if (prevBgWord === bgWord.el) {
activeBgWordEls.set(lineIdx, null);
}
}
}
}, 50);
@@ -2119,6 +2475,7 @@ const onTrackChange = async (): Promise<void> => {
// Store data
lyricsData = response.data;
lyricsResponse = response;
isActive = true;
// Remove Tidal classes
@@ -2140,12 +2497,15 @@ const reapplyWordLyrics = (): void => {
if (settings.lyricsStyle === 0 || !lyricsData) return;
clearTickLoop();
clearScrollAnim();
unwatchRerender();
unhookUserScroll();
unhookSyncButton();
unlockScroll();
activeWordEl = null;
activeLineIdx = -1;
activeWordEls.clear();
activeBgWordEls.clear();
activeLineIdxs.clear();
primaryLineIdx = -1;
isActive = true;
hideTidalLyrics();
+130 -3
View File
@@ -119,13 +119,93 @@
/* Active line slide */
.rl-wbw-line {
text-align: left;
padding-left: 0;
padding-right: 0;
filter: none;
transform: translateZ(0);
transform-origin: left;
transition:
padding-left 0.7s ease-in-out;
filter 0.4s ease,
padding-left 0.7s ease-in-out,
padding-right 0.7s ease-in-out;
overflow: visible;
}
.rl-wbw-line.rl-wbw-line-active {
.rl-wbw-line.rl-wbw-spacer {
filter: none;
}
/* Blur Inactive (opt-in via .rl-blur-active on container) */
.rl-blur-active .rl-wbw-line {
filter: blur(0.07em);
}
.rl-blur-active .rl-wbw-line.rl-pos-1 {
filter: blur(0.035em);
}
.rl-blur-active .rl-wbw-line.rl-pos-2 {
filter: blur(0.05em);
}
.rl-blur-active .rl-wbw-line.rl-pos-3 {
filter: blur(0.06em);
}
/* Active line overrides (MUST come after blur rules to win on equal specificity) */
.rl-wbw-line.rl-wbw-line-active,
.rl-blur-active .rl-wbw-line.rl-wbw-line-active {
padding-left: 20px;
filter: none;
}
/* Keep last-active line unblurred during instrumental gaps */
.rl-blur-active .rl-wbw-line.rl-gap-hold {
filter: none;
}
/* Bubbled Lyrics scale (opt-in via .rl-bubbled on container) */
.rl-bubbled .rl-wbw-line {
scale: 0.93 0.93 0.95;
transition:
scale 0.7s ease,
filter 0.4s ease,
padding-left 0.7s ease-in-out,
padding-right 0.7s ease-in-out;
will-change: scale, translate, filter;
}
.rl-bubbled .rl-wbw-line.rl-wbw-spacer {
scale: none;
}
.rl-bubbled .rl-wbw-line.rl-wbw-line-active {
scale: 1;
transition:
scale 0.5s ease,
filter 0.4s ease,
padding-left 0.7s ease-in-out,
padding-right 0.7s ease-in-out;
}
/* Staggered scroll bounce animation (part of Bubbled Lyrics) */
@keyframes rl-scroll-bounce {
from {
translate: 0 var(--rl-scroll-delta);
}
to {
translate: 0 0;
}
}
.rl-wbw-line:not(.rl-scroll-animate) {
animation: none;
}
.rl-scroll-animate {
animation: rl-scroll-bounce 400ms cubic-bezier(0.41, 0, 0.12, 0.99) both;
animation-delay: var(--rl-line-delay, 0ms);
}
/* Word span */
@@ -141,7 +221,9 @@
/* Hover word (Grouped Syllables) */
.rl-wbw-line:not(.rl-wbw-line-active) > .rl-wbw-word:hover,
.rl-wbw-line:not(.rl-wbw-line-active) > .rl-wbw-word.rl-wbw-word-hover {
.rl-wbw-line:not(.rl-wbw-line-active) > .rl-wbw-word.rl-wbw-word-hover,
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-main .rl-wbw-word:hover,
.rl-wbw-line:not(.rl-wbw-line-active) .rl-wbw-main .rl-wbw-word.rl-wbw-word-hover {
text-shadow:
0 0 var(--rl-glow-inner, 2px) lightgray,
/* biome-ignore lint: Hover glow should override defaults */
@@ -256,6 +338,51 @@
margin-bottom: 2rem;
}
/* MARKER: Context Aware Lyrics CSS */
/* Background vocal sub-container */
.rl-wbw-bg-container {
max-height: 0;
overflow: visible;
opacity: 0;
font-size: 0.55em;
padding-top: 0.15em;
transition: max-height 0.3s ease, opacity 0.5s ease;
color: rgba(128, 128, 128, 0.4);
}
.rl-wbw-line.rl-wbw-line-active .rl-wbw-bg-container {
max-height: 3em;
opacity: 1;
transition: max-height 0.5s ease, opacity 0.5s ease;
}
/* Singer duet positioning */
.rl-wbw-line.rl-singer-right {
text-align: end;
transform-origin: right;
}
.rl-dual-side .rl-wbw-line.rl-singer-left {
padding-right: 20%;
}
.rl-dual-side .rl-wbw-line.rl-singer-right {
padding-left: 20%;
}
.rl-dual-side .rl-wbw-line.rl-singer-right .rl-wbw-bg-container {
text-align: end;
}
.rl-dual-side .rl-wbw-line.rl-singer-right.rl-wbw-line-active {
padding-right: 20px;
}
.rl-dual-side .rl-wbw-line.rl-singer-left.rl-wbw-line-active {
padding-left: 20px;
}
/* Reset lyrics styling when disabled */
.lyrics-glow-disabled [class*="_lyricsText"] > div > span[data-current="true"],
.lyrics-glow-disabled [class*="_lyricsText"] > div > span,