mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-18 03:43:10 +10:00
Context Aware Lyrics & Aniamtions (Line)
This commit is contained in:
@@ -42,8 +42,11 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
|
|||||||
settingsAffectNowPlaying: true,
|
settingsAffectNowPlaying: true,
|
||||||
stickyLyrics: false,
|
stickyLyrics: false,
|
||||||
stickyLyricsIcon: "sparkle" as string,
|
stickyLyricsIcon: "sparkle" as string,
|
||||||
lyricsStyle: 0,
|
lyricsStyle: 2,
|
||||||
syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon)
|
syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon)
|
||||||
|
contextAwareLyrics: true,
|
||||||
|
blurInactive: true,
|
||||||
|
bubbledLyrics: true,
|
||||||
syllableLogging: false,
|
syllableLogging: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,12 +127,18 @@ export const Settings = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
const [lyricsStyle, setLyricsStyle] = React.useState(settings.lyricsStyle);
|
const [lyricsStyle, setLyricsStyle] = React.useState(settings.lyricsStyle);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
window.updateLyricsStyleSetting = (value: number) =>
|
window.updateLyricsStyleSetting = (value: number) => setLyricsStyle(value);
|
||||||
setLyricsStyle(value);
|
|
||||||
return () => {
|
return () => {
|
||||||
window.updateLyricsStyleSetting = undefined;
|
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(
|
const [qualityProgressColor, setQualityProgressColor] = React.useState(
|
||||||
settings.qualityProgressColor,
|
settings.qualityProgressColor,
|
||||||
);
|
);
|
||||||
@@ -188,21 +197,61 @@ export const Settings = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<LunaNumberSetting
|
<LunaNumberSetting
|
||||||
title="Lyrics Style"
|
title="Lyrics Style"
|
||||||
desc="0 = Line (default), 1 = Word, 2 = Syllable (mirrored in lyrics dropdown)"
|
desc="0 = Line (default), 1 = Word, 2 = Syllable (mirrored in lyrics dropdown)"
|
||||||
min={0}
|
min={0}
|
||||||
max={1}
|
max={1}
|
||||||
step={1}
|
step={1}
|
||||||
value={lyricsStyle}
|
value={lyricsStyle}
|
||||||
onNumber={(value: number) => {
|
onNumber={(value: number) => {
|
||||||
settings.lyricsStyle = value;
|
settings.lyricsStyle = value;
|
||||||
setLyricsStyle(value);
|
setLyricsStyle(value);
|
||||||
if (window.updateLyricsStyle) {
|
if (window.updateLyricsStyle) {
|
||||||
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
|
<AnySwitch
|
||||||
title="Sticky Lyrics"
|
title="Sticky Lyrics"
|
||||||
desc="auto-switches to Play Queue when lyrics aren't available (mirrored in lyrics dropdown)"
|
desc="auto-switches to Play Queue when lyrics aren't available (mirrored in lyrics dropdown)"
|
||||||
|
|||||||
@@ -1250,6 +1250,8 @@ interface WordLyricsResponse {
|
|||||||
title: string;
|
title: string;
|
||||||
language: string;
|
language: string;
|
||||||
totalDuration: string;
|
totalDuration: string;
|
||||||
|
agents?: Record<string, { type: string; name: string; alias: string }>;
|
||||||
|
songParts?: Array<{ name: string; time: number; duration: number }>;
|
||||||
};
|
};
|
||||||
_cached?: boolean;
|
_cached?: boolean;
|
||||||
}
|
}
|
||||||
@@ -1257,6 +1259,7 @@ interface WordLyricsResponse {
|
|||||||
// syllable state
|
// syllable state
|
||||||
let trackChangeToken = 0;
|
let trackChangeToken = 0;
|
||||||
let lyricsData: WordLine[] | null = null;
|
let lyricsData: WordLine[] | null = null;
|
||||||
|
let lyricsResponse: WordLyricsResponse | null = null;
|
||||||
let tickLoopUnload: LunaUnload | null = null;
|
let tickLoopUnload: LunaUnload | null = null;
|
||||||
let isActive = false;
|
let isActive = false;
|
||||||
let savedTidalClasses: string[] | null = null;
|
let savedTidalClasses: string[] | null = null;
|
||||||
@@ -1274,13 +1277,17 @@ interface LineEntry {
|
|||||||
startMs: number; // first word start
|
startMs: number; // first word start
|
||||||
endMs: number; // last word end
|
endMs: number; // last word end
|
||||||
words: WordEntry[];
|
words: WordEntry[];
|
||||||
|
bgWords: WordEntry[];
|
||||||
|
isBg: boolean; // entirely background/adlib line
|
||||||
}
|
}
|
||||||
|
|
||||||
let lines: LineEntry[] = [];
|
let lines: LineEntry[] = [];
|
||||||
let rerenderObserver: MutationObserver | null = null;
|
let rerenderObserver: MutationObserver | null = null;
|
||||||
let rerenderDebounce: number | null = null;
|
let rerenderDebounce: number | null = null;
|
||||||
let activeWordEl: HTMLSpanElement | null = null;
|
const activeWordEls = new Map<number, HTMLSpanElement | null>();
|
||||||
let activeLineIdx = -1;
|
const activeBgWordEls = new Map<number, HTMLSpanElement | null>();
|
||||||
|
let activeLineIdxs = new Set<number>();
|
||||||
|
let primaryLineIdx = -1;
|
||||||
|
|
||||||
// Scroll sync (unhook on user scroll)
|
// Scroll sync (unhook on user scroll)
|
||||||
let scrollSynced = true;
|
let scrollSynced = true;
|
||||||
@@ -1288,6 +1295,117 @@ let userScrollListener: (() => void) | null = null;
|
|||||||
let syncButtonListener: (() => void) | null = null;
|
let syncButtonListener: (() => void) | null = null;
|
||||||
let syncButtonEl: HTMLElement | 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)
|
// scroll lock (for scroll gate)
|
||||||
let scrollParentRef: HTMLElement | null = null;
|
let scrollParentRef: HTMLElement | null = null;
|
||||||
let savedScrollTo: any = null;
|
let savedScrollTo: any = null;
|
||||||
@@ -1477,6 +1595,60 @@ const restoreTidalLyrics = (): void => {
|
|||||||
savedTidalClasses = null;
|
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
|
// build word/syllable container over tidal spans
|
||||||
const buildWordSpans = (): {
|
const buildWordSpans = (): {
|
||||||
lines: LineEntry[];
|
lines: LineEntry[];
|
||||||
@@ -1512,6 +1684,8 @@ const buildWordSpans = (): {
|
|||||||
// create lyrics container for word/syllable lines
|
// create lyrics container for word/syllable lines
|
||||||
const wbwContainer = document.createElement("div");
|
const wbwContainer = document.createElement("div");
|
||||||
wbwContainer.className = "rl-wbw-container";
|
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)
|
// MARKER: Syllable animations (WIP coming soon)
|
||||||
if (settings.syllableStyle === 1) wbwContainer.classList.add("rl-syl-pop");
|
if (settings.syllableStyle === 1) wbwContainer.classList.add("rl-syl-pop");
|
||||||
else if (settings.syllableStyle === 2) wbwContainer.classList.add("rl-syl-jump");
|
else if (settings.syllableStyle === 2) wbwContainer.classList.add("rl-syl-jump");
|
||||||
@@ -1527,10 +1701,24 @@ const buildWordSpans = (): {
|
|||||||
overflow: "visible",
|
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 =
|
const FONT_STACK =
|
||||||
'"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif';
|
'"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) {
|
for (const apiLine of lyricsData) {
|
||||||
|
const currentLineIndex = lineIndex++;
|
||||||
|
|
||||||
// skip empty/stanza-end lines
|
// skip empty/stanza-end lines
|
||||||
if (!apiLine.syllabus || apiLine.syllabus.length === 0) {
|
if (!apiLine.syllabus || apiLine.syllabus.length === 0) {
|
||||||
const spacer = document.createElement("div");
|
const spacer = document.createElement("div");
|
||||||
@@ -1548,13 +1736,11 @@ const buildWordSpans = (): {
|
|||||||
lineDiv.className = "rl-wbw-line";
|
lineDiv.className = "rl-wbw-line";
|
||||||
forceStyle(lineDiv, {
|
forceStyle(lineDiv, {
|
||||||
display: "block",
|
display: "block",
|
||||||
"text-align": "left",
|
|
||||||
"white-space": "normal",
|
"white-space": "normal",
|
||||||
"word-spacing": "normal",
|
"word-spacing": "normal",
|
||||||
"letter-spacing": "normal",
|
"letter-spacing": "normal",
|
||||||
"margin-bottom": "2rem",
|
"margin-bottom": "2rem",
|
||||||
"padding-top": "0",
|
"padding-top": "0",
|
||||||
"padding-right": "0",
|
|
||||||
"padding-bottom": "0",
|
"padding-bottom": "0",
|
||||||
"font-size": "40px",
|
"font-size": "40px",
|
||||||
"font-family": FONT_STACK,
|
"font-family": FONT_STACK,
|
||||||
@@ -1568,10 +1754,35 @@ const buildWordSpans = (): {
|
|||||||
"align-items": "initial",
|
"align-items": "initial",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (contextAware && singerSides) {
|
||||||
|
const sideClass = singerSides.sides[currentLineIndex];
|
||||||
|
if (sideClass) lineDiv.classList.add(sideClass);
|
||||||
|
}
|
||||||
|
|
||||||
const lineWords: WordEntry[] = [];
|
const lineWords: WordEntry[] = [];
|
||||||
|
const lineBgWords: WordEntry[] = [];
|
||||||
const syllabus = apiLine.syllabus;
|
const syllabus = apiLine.syllabus;
|
||||||
const isSylMode = settings.lyricsStyle === 2;
|
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> = {
|
const WORD_SPAN_STYLE: Record<string, string> = {
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
float: "none",
|
float: "none",
|
||||||
@@ -1585,7 +1796,11 @@ const buildWordSpans = (): {
|
|||||||
const makeSpan = (text: string, seekMs: number, bg: boolean): HTMLSpanElement => {
|
const makeSpan = (text: string, seekMs: number, bg: boolean): HTMLSpanElement => {
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "rl-wbw-word";
|
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);
|
forceStyle(span, WORD_SPAN_STYLE);
|
||||||
if (bg) span.classList.add("rl-wbw-bg");
|
if (bg) span.classList.add("rl-wbw-bg");
|
||||||
span.addEventListener("click", () => {
|
span.addEventListener("click", () => {
|
||||||
@@ -1609,8 +1824,11 @@ const buildWordSpans = (): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const group of wordGroups) {
|
for (const group of wordGroups) {
|
||||||
|
const groupIsBg = splitBg && syllabus[group[0]].isBackground;
|
||||||
|
const targetContainer = groupIsBg ? bgContainer! : mainContainer;
|
||||||
|
const targetWords = groupIsBg ? lineBgWords : lineWords;
|
||||||
|
|
||||||
if (isSylMode) {
|
if (isSylMode) {
|
||||||
// Syllable mode: separate span per syllable, seek/hover grouped by word
|
|
||||||
const wordStartMs = syllabus[group[0]].time;
|
const wordStartMs = syllabus[group[0]].time;
|
||||||
const groupSpans: HTMLSpanElement[] = [];
|
const groupSpans: HTMLSpanElement[] = [];
|
||||||
for (const si of group) {
|
for (const si of group) {
|
||||||
@@ -1623,12 +1841,11 @@ const buildWordSpans = (): {
|
|||||||
for (const s of groupSpans) s.classList.remove("rl-wbw-word-hover");
|
for (const s of groupSpans) s.classList.remove("rl-wbw-word-hover");
|
||||||
});
|
});
|
||||||
groupSpans.push(span);
|
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 };
|
const entry: WordEntry = { el: span, start: syl.time, end: syl.time + syl.duration, duration: syl.duration };
|
||||||
lineWords.push(entry);
|
targetWords.push(entry);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Word mode: merge syllables into one span
|
|
||||||
const mergedText = group.map(si => syllabus[si].text.trimEnd()).join("");
|
const mergedText = group.map(si => syllabus[si].text.trimEnd()).join("");
|
||||||
const first = syllabus[group[0]];
|
const first = syllabus[group[0]];
|
||||||
const last = syllabus[group[group.length - 1]];
|
const last = syllabus[group[group.length - 1]];
|
||||||
@@ -1636,24 +1853,33 @@ const buildWordSpans = (): {
|
|||||||
const end = last.time + last.duration;
|
const end = last.time + last.duration;
|
||||||
const bg = first.isBackground;
|
const bg = first.isBackground;
|
||||||
const span = makeSpan(mergedText, start, bg);
|
const span = makeSpan(mergedText, start, bg);
|
||||||
lineDiv.appendChild(span);
|
targetContainer.appendChild(span);
|
||||||
const entry: WordEntry = { el: span, start, end, duration: end - start };
|
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)
|
targetContainer.appendChild(document.createTextNode(" "));
|
||||||
lineDiv.appendChild(document.createTextNode(" "));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wbwContainer.appendChild(lineDiv);
|
wbwContainer.appendChild(lineDiv);
|
||||||
|
|
||||||
// build entry from syllables
|
const allWords = lineWords.length > 0 ? lineWords : lineBgWords;
|
||||||
if (lineWords.length > 0) {
|
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({
|
lines.push({
|
||||||
el: lineDiv,
|
el: lineDiv,
|
||||||
tidalSpan: null,
|
tidalSpan: null,
|
||||||
startMs: lineWords[0].start,
|
startMs: firstStart,
|
||||||
endMs: lineWords[lineWords.length - 1].end,
|
endMs: lastEnd,
|
||||||
words: lineWords,
|
words: allWords,
|
||||||
|
bgWords: lineBgWords,
|
||||||
|
isBg: allAreBg,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1752,6 +1978,7 @@ const clearTickLoop = (): void => {
|
|||||||
const teardown = (): void => {
|
const teardown = (): void => {
|
||||||
trackChangeToken++;
|
trackChangeToken++;
|
||||||
clearTickLoop();
|
clearTickLoop();
|
||||||
|
clearScrollAnim();
|
||||||
unwatchRerender();
|
unwatchRerender();
|
||||||
unhookUserScroll();
|
unhookUserScroll();
|
||||||
unhookSyncButton();
|
unhookSyncButton();
|
||||||
@@ -1759,9 +1986,12 @@ const teardown = (): void => {
|
|||||||
scrollSynced = true;
|
scrollSynced = true;
|
||||||
isActive = false;
|
isActive = false;
|
||||||
lyricsData = null;
|
lyricsData = null;
|
||||||
|
lyricsResponse = null;
|
||||||
lines = [];
|
lines = [];
|
||||||
activeWordEl = null;
|
activeWordEls.clear();
|
||||||
activeLineIdx = -1;
|
activeBgWordEls.clear();
|
||||||
|
activeLineIdxs.clear();
|
||||||
|
primaryLineIdx = -1;
|
||||||
restoreTidalLyrics();
|
restoreTidalLyrics();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1847,20 +2077,24 @@ const scrollTo = (parent: HTMLElement, options: ScrollToOptions): void => {
|
|||||||
|
|
||||||
// Scroll to active line (resync)
|
// Scroll to active line (resync)
|
||||||
const scrollToActiveLine = (): void => {
|
const scrollToActiveLine = (): void => {
|
||||||
if (activeLineIdx < 0 || activeLineIdx >= lines.length) return;
|
if (primaryLineIdx < 0 || primaryLineIdx >= lines.length) return;
|
||||||
const line = lines[activeLineIdx];
|
const line = lines[primaryLineIdx];
|
||||||
const scroller = findScroller(line.el);
|
const scroller = findScroller(line.el);
|
||||||
lockScroll(scroller);
|
lockScroll(scroller);
|
||||||
const lineRect = line.el.getBoundingClientRect();
|
const lineRect = line.el.getBoundingClientRect();
|
||||||
const parentRect = scroller.getBoundingClientRect();
|
const parentRect = scroller.getBoundingClientRect();
|
||||||
const targetOffset = parentRect.height * 0.2;
|
const targetOffset = parentRect.height * 0.2;
|
||||||
const scrollTarget = scroller.scrollTop + (lineRect.top - parentRect.top) - targetOffset;
|
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)
|
// Resync lyric scroll (scrubbing and lyric jumps)
|
||||||
const resync = (): void => {
|
const resync = (): void => {
|
||||||
scrollSynced = true;
|
scrollSynced = true;
|
||||||
|
if (settings.blurInactive) {
|
||||||
|
document.querySelector(".rl-wbw-container")?.classList.add("rl-blur-active");
|
||||||
|
}
|
||||||
scrollToActiveLine();
|
scrollToActiveLine();
|
||||||
const tidalSyncBtn = document.querySelector('div[class*="_syncButton"] button') as HTMLElement;
|
const tidalSyncBtn = document.querySelector('div[class*="_syncButton"] button') as HTMLElement;
|
||||||
if (tidalSyncBtn) tidalSyncBtn.click();
|
if (tidalSyncBtn) tidalSyncBtn.click();
|
||||||
@@ -1874,6 +2108,9 @@ const hookUserScroll = (parent: HTMLElement): void => {
|
|||||||
const onUserScroll = () => {
|
const onUserScroll = () => {
|
||||||
if (!scrollSynced) return;
|
if (!scrollSynced) return;
|
||||||
scrollSynced = false;
|
scrollSynced = false;
|
||||||
|
if (settings.blurInactive) {
|
||||||
|
document.querySelector(".rl-wbw-container")?.classList.remove("rl-blur-active");
|
||||||
|
}
|
||||||
sylLog("[RL-Syllable] User scrolled — auto-scroll unhooked");
|
sylLog("[RL-Syllable] User scrolled — auto-scroll unhooked");
|
||||||
};
|
};
|
||||||
parent.addEventListener("wheel", onUserScroll, { passive: true });
|
parent.addEventListener("wheel", onUserScroll, { passive: true });
|
||||||
@@ -1945,22 +2182,30 @@ const startTickLoop = (): void => {
|
|||||||
sylLog(`[RL-Syllable] Playback | ${nowMs.toFixed(0)} ms`);
|
sylLog(`[RL-Syllable] Playback | ${nowMs.toFixed(0)} ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// find active line (-1 if before all lyrics or in instrumental)
|
// find all active lines (supports overlapping duet/adlib lines)
|
||||||
let newLineIdx = -1;
|
const newActiveSet = new Set<number>();
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const nextStart = lines[i + 1]?.startMs ?? Number.MAX_SAFE_INTEGER;
|
const lineEnd = lines[i].endMs;
|
||||||
const effectiveEnd = Math.min(nextStart, lines[i].endMs + 2500);
|
// 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) {
|
if (nowMs >= lines[i].startMs && nowMs < effectiveEnd) {
|
||||||
newLineIdx = i;
|
newActiveSet.add(i);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const newPrimary = newActiveSet.size > 0 ? Math.min(...newActiveSet) : -1;
|
||||||
|
|
||||||
// single pass to set correct state for all words (scrub or seek)
|
// single pass to set correct state for all words (scrub or seek)
|
||||||
if (didScrub) {
|
if (didScrub) {
|
||||||
for (let li = 0; li < lines.length; li++) {
|
for (let li = 0; li < lines.length; li++) {
|
||||||
for (const w of lines[li].words) {
|
const allEntries = lines[li].bgWords.length > 0
|
||||||
if (li < newLineIdx) {
|
? [...lines[li].words, ...lines[li].bgWords]
|
||||||
|
: lines[li].words;
|
||||||
|
for (const w of allEntries) {
|
||||||
|
if (li < newPrimary) {
|
||||||
w.el.classList.remove(CLS_ACTIVE);
|
w.el.classList.remove(CLS_ACTIVE);
|
||||||
if (isSyl) w.el.style.animation = "";
|
if (isSyl) w.el.style.animation = "";
|
||||||
if (!w.el.classList.contains(CLS_FINISHED)) w.el.classList.add(CLS_FINISHED);
|
if (!w.el.classList.contains(CLS_FINISHED)) w.el.classList.add(CLS_FINISHED);
|
||||||
@@ -1970,35 +2215,72 @@ const startTickLoop = (): void => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
activeWordEl = null;
|
activeWordEls.clear();
|
||||||
if (activeLineIdx >= 0 && activeLineIdx < lines.length) {
|
activeBgWordEls.clear();
|
||||||
lines[activeLineIdx].el.classList.remove("rl-wbw-line-active");
|
for (const idx of activeLineIdxs) {
|
||||||
lines[activeLineIdx].el.removeAttribute("data-current");
|
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`);
|
sylLog(`[RL-Syllable] Scrub detected (${timeDelta > 0 ? "+" : ""}${timeDelta.toFixed(0)} ms) → resync`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deactivate line when entering instrumental
|
// deactivate lines no longer active
|
||||||
if (newLineIdx === -1 && activeLineIdx >= 0 && activeLineIdx < lines.length) {
|
for (const idx of activeLineIdxs) {
|
||||||
lines[activeLineIdx].el.classList.remove("rl-wbw-line-active");
|
if (!newActiveSet.has(idx) && idx < lines.length) {
|
||||||
lines[activeLineIdx].el.removeAttribute("data-current");
|
lines[idx].el.classList.remove("rl-wbw-line-active");
|
||||||
activeLineIdx = -1;
|
lines[idx].el.removeAttribute("data-current");
|
||||||
activeWordEl = null;
|
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
|
// activate newly active lines
|
||||||
if (newLineIdx !== activeLineIdx && newLineIdx >= 0) {
|
for (const idx of newActiveSet) {
|
||||||
if (activeLineIdx >= 0 && activeLineIdx < lines.length) {
|
if (!activeLineIdxs.has(idx)) {
|
||||||
const oldLine = lines[activeLineIdx];
|
lines[idx].el.classList.add("rl-wbw-line-active");
|
||||||
oldLine.el.classList.remove("rl-wbw-line-active");
|
lines[idx].el.classList.remove("rl-pos-1", "rl-pos-2", "rl-pos-3");
|
||||||
oldLine.el.removeAttribute("data-current");
|
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);
|
const scrollParent = findScroller(newLine.el);
|
||||||
lockScroll(scrollParent);
|
lockScroll(scrollParent);
|
||||||
hookUserScroll(scrollParent);
|
hookUserScroll(scrollParent);
|
||||||
@@ -2008,12 +2290,32 @@ const startTickLoop = (): void => {
|
|||||||
const parentRect = scrollParent.getBoundingClientRect();
|
const parentRect = scrollParent.getBoundingClientRect();
|
||||||
const targetOffset = parentRect.height * 0.2;
|
const targetOffset = parentRect.height * 0.2;
|
||||||
const scrollTarget = scrollParent.scrollTop + (lineRect.top - parentRect.top) - targetOffset;
|
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(
|
// distance-based blur position classes (skip active lines)
|
||||||
`[RL-Syllable] Line ${activeLineIdx} Active "${newLine.el.textContent?.slice(0, 40)}" | ${newLine.startMs} ms - ${newLine.endMs} ms [${nowMs.toFixed(0)} ms]`,
|
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
|
// hook lyric scroll sync button
|
||||||
@@ -2021,60 +2323,114 @@ const startTickLoop = (): void => {
|
|||||||
hookSyncButton();
|
hookSyncButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
// find and activate current word
|
// highlight words in all active lines
|
||||||
if (activeLineIdx < 0) return;
|
if (activeLineIdxs.size === 0) return;
|
||||||
const currentLine = lines[activeLineIdx];
|
|
||||||
|
|
||||||
let activeWordIdx = -1;
|
for (const lineIdx of activeLineIdxs) {
|
||||||
for (let i = currentLine.words.length - 1; i >= 0; i--) {
|
const currentLine = lines[lineIdx];
|
||||||
if (nowMs >= currentLine.words[i].start) {
|
const prevActiveWord = activeWordEls.get(lineIdx) ?? null;
|
||||||
activeWordIdx = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeWordIdx < 0) return;
|
let activeWordIdx = -1;
|
||||||
const word = currentLine.words[activeWordIdx];
|
for (let i = currentLine.words.length - 1; i >= 0; i--) {
|
||||||
|
if (nowMs >= currentLine.words[i].start) {
|
||||||
// mark all words before as finished
|
activeWordIdx = i;
|
||||||
for (let i = 0; i < activeWordIdx; i++) {
|
break;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
word.el.classList.add(CLS_ACTIVE);
|
}
|
||||||
word.el.classList.remove(CLS_FINISHED);
|
|
||||||
if (isSyl) { // MARKER: Syllable animations (WIP coming soon)
|
if (activeWordIdx < 0) continue;
|
||||||
const wipe = `rl-wipe ${word.duration}ms linear forwards`;
|
const word = currentLine.words[activeWordIdx];
|
||||||
const sylAnim = settings.syllableStyle === 1 ? ", rl-pop 0.6s ease-out"
|
|
||||||
: settings.syllableStyle === 2 ? ", rl-jump 0.35s ease-out" : "";
|
for (let i = 0; i < activeWordIdx; i++) {
|
||||||
word.el.style.animation = wipe + sylAnim;
|
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);
|
const isStillSinging = nowMs <= word.end;
|
||||||
if (isSyl) word.el.style.animation = "";
|
if (isStillSinging) {
|
||||||
if (!word.el.classList.contains(CLS_FINISHED)) {
|
if (prevActiveWord !== word.el) {
|
||||||
word.el.classList.add(CLS_FINISHED);
|
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);
|
}, 50);
|
||||||
@@ -2119,6 +2475,7 @@ const onTrackChange = async (): Promise<void> => {
|
|||||||
|
|
||||||
// Store data
|
// Store data
|
||||||
lyricsData = response.data;
|
lyricsData = response.data;
|
||||||
|
lyricsResponse = response;
|
||||||
isActive = true;
|
isActive = true;
|
||||||
|
|
||||||
// Remove Tidal classes
|
// Remove Tidal classes
|
||||||
@@ -2140,12 +2497,15 @@ const reapplyWordLyrics = (): void => {
|
|||||||
if (settings.lyricsStyle === 0 || !lyricsData) return;
|
if (settings.lyricsStyle === 0 || !lyricsData) return;
|
||||||
|
|
||||||
clearTickLoop();
|
clearTickLoop();
|
||||||
|
clearScrollAnim();
|
||||||
unwatchRerender();
|
unwatchRerender();
|
||||||
unhookUserScroll();
|
unhookUserScroll();
|
||||||
unhookSyncButton();
|
unhookSyncButton();
|
||||||
unlockScroll();
|
unlockScroll();
|
||||||
activeWordEl = null;
|
activeWordEls.clear();
|
||||||
activeLineIdx = -1;
|
activeBgWordEls.clear();
|
||||||
|
activeLineIdxs.clear();
|
||||||
|
primaryLineIdx = -1;
|
||||||
|
|
||||||
isActive = true;
|
isActive = true;
|
||||||
hideTidalLyrics();
|
hideTidalLyrics();
|
||||||
|
|||||||
@@ -119,13 +119,93 @@
|
|||||||
|
|
||||||
/* Active line slide */
|
/* Active line slide */
|
||||||
.rl-wbw-line {
|
.rl-wbw-line {
|
||||||
|
text-align: left;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
filter: none;
|
||||||
|
transform: translateZ(0);
|
||||||
|
transform-origin: left;
|
||||||
transition:
|
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;
|
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 */
|
/* Word span */
|
||||||
@@ -141,7 +221,9 @@
|
|||||||
|
|
||||||
/* Hover word (Grouped Syllables) */
|
/* 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: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:
|
text-shadow:
|
||||||
0 0 var(--rl-glow-inner, 2px) lightgray,
|
0 0 var(--rl-glow-inner, 2px) lightgray,
|
||||||
/* biome-ignore lint: Hover glow should override defaults */
|
/* biome-ignore lint: Hover glow should override defaults */
|
||||||
@@ -256,6 +338,51 @@
|
|||||||
margin-bottom: 2rem;
|
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 */
|
/* Reset lyrics styling when disabled */
|
||||||
.lyrics-glow-disabled [class*="_lyricsText"] > div > span[data-current="true"],
|
.lyrics-glow-disabled [class*="_lyricsText"] > div > span[data-current="true"],
|
||||||
.lyrics-glow-disabled [class*="_lyricsText"] > div > span,
|
.lyrics-glow-disabled [class*="_lyricsText"] > div > span,
|
||||||
|
|||||||
Reference in New Issue
Block a user