// MARKER: Core Setup import { type LunaUnload, Tracer } from "@luna/core"; import { StyleTag, PlayState, MediaItem, observePromise, observe, safeInterval, safeTimeout } from "@luna/lib"; import { settings, Settings } from "./Settings"; // Interpret integer backgroundScale (e.g., 10=1.0x, 20=2.0x) const getScaledMultiplier = (): number => { const value = settings.backgroundScale; return value / 10; }; // Import CSS files directly using Luna's file:// syntax - Took me a while to figure out <3 import baseStyles from "file://styles.css?minify"; import playerBarHidden from "file://player-bar-hidden.css?minify"; import lyricsGlow from "file://lyrics-glow.css?minify"; import coverEverywhereCss from "file://cover-everywhere.css?minify"; import floatingPlayerBarCss from "file://floating-player-bar.css?minify"; // Core tracer and exports export const { trace } = Tracer("[Radiant Lyrics]"); export { Settings }; // clean up resources export const unloads = new Set(); // StyleTag instances for different CSS modules const baseStyleTag = new StyleTag("RadiantLyrics-base", unloads); const playerBarStyleTag = new StyleTag("RadiantLyrics-player-bar", unloads); const lyricsGlowStyleTag = new StyleTag("RadiantLyrics-lyrics-glow", unloads); const floatingPlayerBarStyleTag = new StyleTag("RadiantLyrics-floating-player-bar", unloads); // Apply lyrics glow styles if enabled if (settings.lyricsGlowEnabled) { lyricsGlowStyleTag.css = lyricsGlow; } // MARKER: Floating Player Bar // Hex color to RGB // (i'm deranged and love Hexadecimal) const hexToRgb = (hex: string): { r: number; g: number; b: number } => { let cleaned = (hex || "#000000").replace("#", ""); if (cleaned.length === 3) { cleaned = cleaned[0] + cleaned[0] + cleaned[1] + cleaned[1] + cleaned[2] + cleaned[2]; } if (cleaned.length !== 6) { return { r: 0, g: 0, b: 0 }; } return { r: parseInt(cleaned.substring(0, 2), 16) || 0, g: parseInt(cleaned.substring(2, 4), 16) || 0, b: parseInt(cleaned.substring(4, 6), 16) || 0, }; }; // Apply Settings to Floating Player Bar using inline styles because idk.. CSS is hard (Change my mind!) const applyPlayerBarTintToElement = (): void => { const footerPlayer = document.querySelector('[data-test="footer-player"]') as HTMLElement; if (!footerPlayer) return; // Always apply tint regardless of floating state const alpha = settings.playerBarTint / 10; const { r, g, b } = hexToRgb(settings.playerBarTintColor); footerPlayer.style.setProperty("background-color", `rgba(${r}, ${g}, ${b}, ${alpha})`, "important"); if (settings.floatingPlayerBar) { footerPlayer.style.setProperty("border-radius", `${settings.playerBarRadius}px`, "important"); const spacing = settings.playerBarSpacing; footerPlayer.style.setProperty("bottom", `${spacing}px`, "important"); footerPlayer.style.setProperty("left", `${spacing}px`, "important"); footerPlayer.style.setProperty("width", `calc(100% - ${spacing * 2}px)`, "important"); } else { footerPlayer.style.removeProperty("border-radius"); footerPlayer.style.removeProperty("bottom"); footerPlayer.style.removeProperty("left"); footerPlayer.style.removeProperty("width"); } }; // Apply/update the floating player bar stylesheet + tint const applyFloatingPlayerBar = (): void => { if (settings.floatingPlayerBar) { floatingPlayerBarStyleTag.css = floatingPlayerBarCss; } else { floatingPlayerBarStyleTag.remove(); } applyPlayerBarTintToElement(); }; // Alias for settings callback const updateRadiantLyricsPlayerBarTint = applyFloatingPlayerBar; // Apply Tint and Observe in case doesn't exist yet (ik this isnt the best way to do it but.. make a PR i dare ya!) applyPlayerBarTintToElement(); observe(unloads, '[data-test="footer-player"]', () => { applyPlayerBarTintToElement(); }); // MARKER: Quality-Based Seeker Color // Maps data-test-media-state-indicator-streaming-quality values to colors const qualityColors: Record = { HI_RES_LOSSLESS: "#ffd432", //Max LOSSLESS: "#3fe", //High HIGH: "#FFFFFF", //Low }; const applyQualityProgressColor = (): void => { const progressIndicator = document.querySelector( '[data-test="progress-indicator"]', ) as HTMLElement | null; if (!progressIndicator) return; // Remove inline style if disabled if (!settings.qualityProgressColor) { progressIndicator.style.removeProperty("background-color"); return; } // Read quality from the media-state tag // (using data-test-media-state-indicator-streaming-quality) const qualityButton = document.querySelector( "[data-test-media-state-indicator-streaming-quality]", ) as HTMLElement | null; if (!qualityButton) return; const quality = qualityButton.getAttribute("data-test-media-state-indicator-streaming-quality") ?? ""; const color = qualityColors[quality]; if (!color) return; progressIndicator.style.setProperty("background-color", color, "important"); }; // Apply on load if (settings.qualityProgressColor) { applyQualityProgressColor(); } // Apply base styles always (I kinda dont really remember what this does but it's important i guess) baseStyleTag.css = baseStyles; // Update CSS variables for lyrics text glow based on settings const updateRadiantLyricsTextGlow = function (): void { const root = document.documentElement; root.style.setProperty("--rl-glow-outer", `${settings.textGlow}px`); root.style.setProperty("--rl-glow-inner", "2px"); }; // Function to update styles when settings change const updateRadiantLyricsStyles = function (): void { // Handle Player Bar Visibility if (isHidden) { if (!settings.playerBarVisible) { playerBarStyleTag.css = playerBarHidden; } else { playerBarStyleTag.remove(); } } else { playerBarStyleTag.remove(); } // Handle Floating Player Bar applyFloatingPlayerBar(); // Update lyrics glow based on setting (Always apply if enabled, even when UI is hidden) const lyricsContainer = document.querySelector('[class^="_lyricsContainer"]'); if (lyricsContainer) { if (settings.lyricsGlowEnabled) { (lyricsContainer as HTMLElement).classList.remove("lyrics-glow-disabled"); lyricsGlowStyleTag.css = lyricsGlow; updateRadiantLyricsTextGlow(); } else { (lyricsContainer as HTMLElement).classList.add("lyrics-glow-disabled"); lyricsGlowStyleTag.remove(); } } else { observePromise(unloads, '[class^="_lyricsContainer"]') .then((el) => { if (!el) return; if (settings.lyricsGlowEnabled) { el.classList.remove("lyrics-glow-disabled"); lyricsGlowStyleTag.css = lyricsGlow; updateRadiantLyricsTextGlow(); } else { el.classList.add("lyrics-glow-disabled"); lyricsGlowStyleTag.remove(); } }) .catch(() => {}); } // Track title glow toggle based on settings const trackTitleEl = document.querySelector( '[data-test="now-playing-track-title"]', ) as HTMLElement | null; if (trackTitleEl) { if (settings.trackTitleGlow && settings.lyricsGlowEnabled) { trackTitleEl.classList.remove("rl-title-glow-disabled"); } else { trackTitleEl.classList.add("rl-title-glow-disabled"); } } }; // MARKER: UI Visibility Control // UI state shared across features let isHidden = false; let unhideButtonAutoFadeTimeout: number | null = null; // Helper to safely create a one-off timeout that clears previous if any const safelySetAutoFadeTimeout = ( existingId: number | null, fn: () => void, delay: number, ): number => { if (existingId != null) window.clearTimeout(existingId); return window.setTimeout(fn, delay); }; const updateButtonStates = function (): void { const hideButton = document.querySelector(".hide-ui-button") as HTMLElement; const unhideButton = document.querySelector( ".unhide-ui-button", ) as HTMLElement; if (hideButton) { if (settings.hideUIEnabled && !isHidden) { hideButton.style.display = "flex"; // Small delay to ensure display is set first, then fade in safeTimeout(unloads, () => { hideButton.style.opacity = "1"; hideButton.style.visibility = "visible"; hideButton.style.pointerEvents = "auto"; }, 50); } else { // Hide UI button immediately when clicked - (couldn't get the fade to work) hideButton.style.display = "none"; hideButton.style.opacity = "0"; hideButton.style.visibility = "hidden"; hideButton.style.pointerEvents = "none"; } } if (unhideButton) { // Clear any existing auto-fade timeout if (unhideButtonAutoFadeTimeout != null) { window.clearTimeout(unhideButtonAutoFadeTimeout); unhideButtonAutoFadeTimeout = null; } if (settings.hideUIEnabled && isHidden) { unhideButton.style.display = "flex"; // Remove the hide-immediately class and let it fade in smoothly unhideButton.classList.remove("hide-immediately"); unhideButton.classList.remove("auto-faded"); // Small delay to ensure display is set first, then fade in - (Works for unhide button.. but not hide button.. because uhh idk) safeTimeout(unloads, () => { unhideButton.style.opacity = "1"; unhideButton.style.visibility = "visible"; unhideButton.style.pointerEvents = "auto"; // Set up auto-fade after 2 seconds unhideButtonAutoFadeTimeout = safelySetAutoFadeTimeout( unhideButtonAutoFadeTimeout, () => { if (isHidden && unhideButton && !unhideButton.matches(":hover")) { unhideButton.classList.add("auto-faded"); } }, 2000, ); }, 50); } else { // Smooth fade out for Unhide UI button unhideButton.style.opacity = "0"; unhideButton.style.visibility = "hidden"; unhideButton.style.pointerEvents = "none"; unhideButton.classList.remove("auto-faded"); // Keep display: flex to maintain transitions, then hide after fade safeTimeout(unloads, () => { if (unhideButton.style.opacity === "0") { unhideButton.style.display = "none"; } }, 500); } } }; // Toggle hide/unhide UI const toggleRadiantLyrics = function (): void { const nowPlayingContainer = document.querySelector( '[class*="_nowPlayingContainer"]', ) as HTMLElement; if (isHidden) { const unhideButton = document.querySelector( ".unhide-ui-button", ) as HTMLElement; if (unhideButton) unhideButton.classList.add("hide-immediately"); isHidden = !isHidden; if (nowPlayingContainer) nowPlayingContainer.classList.remove("radiant-lyrics-ui-hidden"); document.body.classList.remove("radiant-lyrics-ui-hidden"); safeTimeout(unloads, () => { if (!isHidden) { updateRadiantLyricsStyles(); } }, 500); updateButtonStates(); } else { isHidden = !isHidden; updateButtonStates(); safeTimeout(unloads, () => { updateRadiantLyricsStyles(); if (nowPlayingContainer) nowPlayingContainer.classList.add("radiant-lyrics-ui-hidden"); document.body.classList.add("radiant-lyrics-ui-hidden"); }, 50); } }; // Create buttons const createHideUIButton = function (): void { safeTimeout(unloads, () => { if (!settings.hideUIEnabled) return; const fullscreenButton = document.querySelector( '[data-test="request-fullscreen"]', ); if (!fullscreenButton || !fullscreenButton.parentElement) { safeTimeout(unloads, () => createHideUIButton(), 1000); return; } if (document.querySelector(".hide-ui-button")) return; const buttonContainer = fullscreenButton.parentElement; const hideUIButton = document.createElement("button"); hideUIButton.className = "hide-ui-button"; hideUIButton.setAttribute("aria-label", "Hide UI"); hideUIButton.setAttribute("title", "Hide UI"); hideUIButton.textContent = "Hide UI"; hideUIButton.style.backgroundColor = "#ffffff"; hideUIButton.style.color = "black"; hideUIButton.style.border = "none"; hideUIButton.style.borderRadius = "12px"; hideUIButton.style.height = "40px"; hideUIButton.style.padding = "0 12px"; hideUIButton.style.marginLeft = "8px"; hideUIButton.style.cursor = "pointer"; hideUIButton.style.display = "flex"; hideUIButton.style.alignItems = "center"; hideUIButton.style.justifyContent = "center"; hideUIButton.style.fontSize = "12px"; hideUIButton.style.fontWeight = "600"; hideUIButton.style.whiteSpace = "nowrap"; hideUIButton.style.transition = "opacity 0.5s ease-in-out, visibility 0.5s ease-in-out, background-color 0.2s ease-in-out"; hideUIButton.style.opacity = "0"; hideUIButton.style.visibility = "hidden"; hideUIButton.style.pointerEvents = "none"; hideUIButton.addEventListener("mouseenter", () => { hideUIButton.style.backgroundColor = "#e5e5e5"; }); hideUIButton.addEventListener("mouseleave", () => { hideUIButton.style.backgroundColor = "#ffffff"; }); hideUIButton.onclick = toggleRadiantLyrics; buttonContainer.insertBefore(hideUIButton, fullscreenButton.nextSibling); safeTimeout(unloads, () => { if (settings.hideUIEnabled && !isHidden) { hideUIButton.style.opacity = "1"; hideUIButton.style.visibility = "visible"; hideUIButton.style.pointerEvents = "auto"; } }, 100); }, 1000); }; const createUnhideUIButton = function (): void { safeTimeout(unloads, () => { if (!settings.hideUIEnabled) return; if (document.querySelector(".unhide-ui-button")) return; const nowPlayingContainer = document.querySelector( '[class*="_nowPlayingContainer"]', ) as HTMLElement; if (!nowPlayingContainer) { safeTimeout(unloads, () => createUnhideUIButton(), 1000); return; } const unhideUIButton = document.createElement("button"); unhideUIButton.className = "unhide-ui-button"; unhideUIButton.setAttribute("aria-label", "Unhide UI"); unhideUIButton.setAttribute("title", "Unhide UI"); unhideUIButton.textContent = "Unhide"; unhideUIButton.style.cssText = `position: absolute; top: 10px; right: 10px; background-color: rgba(255,255,255,0.2); color: white; border: 1px solid rgba(255,255,255,0.3); border-radius: 12px; height: 40px; padding: 0 12px; cursor: pointer; display: none; align-items: center; justify-content: center; transition: all 0.5s ease-in-out; font-size: 12px; font-weight: 600; white-space: nowrap; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); z-index: 1000; box-shadow: 0 4px 12px rgba(0,0,0,0.3); opacity: 0; visibility: hidden; pointer-events: none;`; unhideUIButton.addEventListener("mouseenter", () => { unhideUIButton.style.backgroundColor = "rgba(255,255,255,0.3)"; unhideUIButton.style.transform = "scale(1.05)"; unhideUIButton.classList.remove("auto-faded"); }); unhideUIButton.addEventListener("mouseleave", () => { unhideUIButton.style.backgroundColor = "rgba(255,255,255,0.2)"; unhideUIButton.style.transform = "scale(1)"; safeTimeout(unloads, () => { if (isHidden && !unhideUIButton.matches(":hover")) { unhideUIButton.classList.add("auto-faded"); } }, 2000); }); unhideUIButton.onclick = toggleRadiantLyrics; nowPlayingContainer.appendChild(unhideUIButton); updateButtonStates(); }, 1500); }; // MARKER: Background Rendering // Variable setup let globalSpinningBgStyleTag: StyleTag | null = null; let globalBackgroundContainer: HTMLElement | null = null; let globalBackgroundImage: HTMLImageElement | null = null; let globalBlackBg: HTMLElement | null = null; let globalGradientOverlay: HTMLElement | null = null; let currentGlobalCoverSrc: string | null = null; let lastUpdateTime = 0; const getUpdateThrottle = () => (settings.performanceMode ? 1500 : 500); // Now Playing background caching let nowPlayingBackgroundContainer: HTMLElement | null = null; let nowPlayingBackgroundImage: HTMLImageElement | null = null; let nowPlayingBlackBg: HTMLElement | null = null; let nowPlayingGradientOverlay: HTMLElement | null = null; let spinAnimationAdded = false; // apply scaled pixel sizes to cover art const applyScaledPixelSize = (img: HTMLImageElement | null): void => { if (!img) return; const scale = getScaledMultiplier(); const apply = () => { const w = img.naturalWidth; const h = img.naturalHeight; if (w > 0 && h > 0) { const wPx = Math.round(w * scale); const hPx = Math.round(h * scale); const wStr = `${wPx}px`; const hStr = `${hPx}px`; if (img.style.width !== wStr) img.style.width = wStr; if (img.style.height !== hStr) img.style.height = hStr; } }; if (img.complete && img.naturalWidth > 0) { apply(); } else { img.addEventListener("load", apply, { once: true }); } }; // Update Cover Art background for Now Playing and Global function updateCoverArtBackground(method: number = 0): void { if (method === 1) { safeTimeout(unloads, () => { updateCoverArtBackground(); }, 2000); return; } const coverArtImageElement = document.querySelector( 'figure[class*="_albumImage"] > div > div > div > img', ) as HTMLImageElement; let coverArtImageSrc: string | null = null; if (coverArtImageElement) { coverArtImageSrc = coverArtImageElement.src; // Use higher resolution for better quality, but consider performance mode const targetRes = settings.performanceMode ? "640x640" : "1280x1280"; coverArtImageSrc = coverArtImageSrc.replace(/\d+x\d+/, targetRes); if (coverArtImageElement.src !== coverArtImageSrc) { coverArtImageElement.src = coverArtImageSrc; } } else { const videoElement = document.querySelector( 'figure[class*="_albumImage"] > div > div > div > video', ) as HTMLVideoElement; if (videoElement) { coverArtImageSrc = videoElement.getAttribute("poster"); if (coverArtImageSrc) { const targetRes = settings.performanceMode ? "640x640" : "1280x1280"; coverArtImageSrc = coverArtImageSrc.replace(/\d+x\d+/, targetRes); } } else { cleanUpDynamicArt(); return; } } // Update backgrounds when we have a valid cover art source if (coverArtImageSrc) { // Apply global spinning background if enabled if (settings.CoverEverywhere) { applyGlobalSpinningBackground(coverArtImageSrc); } // Apply spinning CoverArt background to the Now Playing container - OPTIMIZED const nowPlayingContainerElement = document.querySelector( '[class*="_nowPlayingContainer"]', ) as HTMLElement; if (nowPlayingContainerElement) { // Create DOM structure if it doesn't exist (REUSE ELEMENTS) if ( !nowPlayingBackgroundContainer || !nowPlayingContainerElement.contains(nowPlayingBackgroundContainer) ) { // Clean up any old elements first nowPlayingContainerElement .querySelectorAll( ".now-playing-background-image, .now-playing-black-bg, .now-playing-gradient-overlay", ) .forEach((el) => { el.remove(); }); // Create container nowPlayingBackgroundContainer = document.createElement("div"); nowPlayingBackgroundContainer.className = "now-playing-background-container"; nowPlayingBackgroundContainer.style.cssText = ` position: absolute; left: 0; top: 0; width: 100%; height: 100%; z-index: -3; pointer-events: none; overflow: hidden; `; nowPlayingContainerElement.appendChild(nowPlayingBackgroundContainer); // Create black background layer nowPlayingBlackBg = document.createElement("div"); nowPlayingBlackBg.className = "now-playing-black-bg"; nowPlayingBlackBg.style.cssText = ` position: absolute; left: 0; top: 0; width: 100%; height: 100%; background: #000; z-index: -2; `; nowPlayingBackgroundContainer.appendChild(nowPlayingBlackBg); // Create image element nowPlayingBackgroundImage = document.createElement("img"); nowPlayingBackgroundImage.className = "now-playing-background-image"; nowPlayingBackgroundImage.style.cssText = ` position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); object-fit: cover; z-index: -1; will-change: transform; transform-origin: center center; `; nowPlayingBackgroundContainer.appendChild(nowPlayingBackgroundImage); // Create gradient overlay nowPlayingGradientOverlay = document.createElement("div"); nowPlayingGradientOverlay.className = "now-playing-gradient-overlay"; nowPlayingGradientOverlay.style.cssText = ` position: absolute; left: 0; top: 0; width: 100%; height: 100%; background: radial-gradient(circle at center, transparent 0%, rgba(0, 0, 0, 0.3) 60%, rgba(0, 0, 0, 0.8) 90%); z-index: -1; pointer-events: none; `; nowPlayingBackgroundContainer.appendChild(nowPlayingGradientOverlay); } // Update image source efficiently if ( nowPlayingBackgroundImage && nowPlayingBackgroundImage.src !== coverArtImageSrc ) { nowPlayingBackgroundImage.src = coverArtImageSrc; } // Apply pixel-based size using intrinsic dimensions applyScaledPixelSize(nowPlayingBackgroundImage); if (nowPlayingBackgroundImage) { const blur = settings.performanceMode ? Math.min(settings.backgroundBlur, 20) : settings.backgroundBlur; const contrast = settings.performanceMode ? Math.min(settings.backgroundContrast, 150) : settings.backgroundContrast; const radius = `${settings.backgroundRadius}%`; if (nowPlayingBackgroundImage.style.borderRadius !== radius) nowPlayingBackgroundImage.style.borderRadius = radius; const filt = `blur(${blur}px) brightness(${settings.backgroundBrightness / 100}) contrast(${contrast}%)`; if (nowPlayingBackgroundImage.style.filter !== filt) nowPlayingBackgroundImage.style.filter = filt; const anim = settings.spinningArt ? `spin ${settings.spinSpeed}s linear infinite` : "none"; const wc = settings.spinningArt ? "transform" : "auto"; if (nowPlayingBackgroundImage.style.animation !== anim) nowPlayingBackgroundImage.style.animation = anim; if (nowPlayingBackgroundImage.style.willChange !== wc) nowPlayingBackgroundImage.style.willChange = wc; } // Add keyframe animation only once if (!spinAnimationAdded) { const styleSheet = document.createElement("style"); styleSheet.id = "spinAnimation"; styleSheet.textContent = ` @keyframes spin { from { transform: translate(-50%, -50%) rotate(0deg); } to { transform: translate(-50%, -50%) rotate(360deg); } } `; document.head.appendChild(styleSheet); spinAnimationAdded = true; } } } } // Function to apply spinning background to the entire app (cover everywhere) const applyGlobalSpinningBackground = (coverArtImageSrc: string): void => { const appContainer = document.querySelector( '[data-test="main"]', ) as HTMLElement; if (!settings.CoverEverywhere) { cleanUpGlobalSpinningBackground(); return; } // Only throttle image src updates; style updates below always run for responsiveness const now = Date.now(); const shouldUpdateImageSrc = now - lastUpdateTime >= getUpdateThrottle() || currentGlobalCoverSrc !== coverArtImageSrc; if (shouldUpdateImageSrc) { lastUpdateTime = now; currentGlobalCoverSrc = coverArtImageSrc; } // Add StyleTag if not present if (!globalSpinningBgStyleTag) { globalSpinningBgStyleTag = new StyleTag( "RadiantLyrics-global-spinning-bg", unloads, coverEverywhereCss, ); } if (!appContainer) return; // Create container structure if it doesn't exist (REUSE DOM ELEMENTS) if (!globalBackgroundContainer) { globalBackgroundContainer = document.createElement("div"); globalBackgroundContainer.className = "global-background-container"; globalBackgroundContainer.style.cssText = ` position: fixed; left: 0; top: 0; width: 100vw; height: 100vh; z-index: -3; pointer-events: none; overflow: hidden; `; appContainer.appendChild(globalBackgroundContainer); // Create black background layer globalBlackBg = document.createElement("div"); globalBlackBg.className = "global-spinning-black-bg"; globalBackgroundContainer.appendChild(globalBlackBg); // Create image element globalBackgroundImage = document.createElement("img"); globalBackgroundImage.className = "global-spinning-image"; globalBackgroundImage.style.cssText = ` position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); object-fit: cover; z-index: -1; will-change: transform; transform-origin: center center; `; globalBackgroundContainer.appendChild(globalBackgroundImage); // Create gradient overlay globalGradientOverlay = document.createElement("div"); globalGradientOverlay.className = "global-spinning-gradient-overlay"; globalGradientOverlay.style.cssText = ` position: absolute; left: 0; top: 0; width: 100%; height: 100%; background: radial-gradient(circle at center, transparent 0%, rgba(0, 0, 0, 0.3) 60%, rgba(0, 0, 0, 0.8) 90%); z-index: -1; pointer-events: none; `; globalBackgroundContainer.appendChild(globalGradientOverlay); } // Ensure gradient overlay exists even if container was pre-existing if (!globalGradientOverlay && globalBackgroundContainer) { globalGradientOverlay = document.createElement("div"); globalGradientOverlay.className = "global-spinning-gradient-overlay"; globalGradientOverlay.style.cssText = ` position: absolute; left: 0; top: 0; width: 100%; height: 100%; background: radial-gradient(circle at center, transparent 0%, rgba(0, 0, 0, 0.3) 60%, rgba(0, 0, 0, 0.8) 90%); z-index: -1; pointer-events: none; `; globalBackgroundContainer.appendChild(globalGradientOverlay); } // Update image source efficiently (throttled) if ( shouldUpdateImageSrc && globalBackgroundImage && globalBackgroundImage.src !== coverArtImageSrc ) { globalBackgroundImage.src = coverArtImageSrc; } if (globalBackgroundImage) { applyScaledPixelSize(globalBackgroundImage); const blur = settings.performanceMode ? Math.min(settings.backgroundBlur, 20) : settings.backgroundBlur; const contrast = settings.performanceMode ? Math.min(settings.backgroundContrast, 150) : settings.backgroundContrast; const radius = `${settings.backgroundRadius}%`; globalBackgroundImage.style.filter = `blur(${blur}px) brightness(${settings.backgroundBrightness / 100}) contrast(${contrast}%)`; if (globalBackgroundImage.style.borderRadius !== radius) globalBackgroundImage.style.borderRadius = radius; if (settings.spinningArt) { globalBackgroundImage.style.animation = `spinGlobal ${settings.spinSpeed}s linear infinite`; globalBackgroundImage.style.willChange = "transform"; } else { globalBackgroundImage.style.animation = "none"; globalBackgroundImage.style.willChange = "auto"; } } }; // cleanup function const cleanUpGlobalSpinningBackground = function (): void { if (globalBackgroundContainer && globalBackgroundContainer.parentNode) { globalBackgroundContainer.parentNode.removeChild(globalBackgroundContainer); } globalBackgroundContainer = null; globalBackgroundImage = null; globalBlackBg = null; globalGradientOverlay = null; currentGlobalCoverSrc = null; if (globalSpinningBgStyleTag) { globalSpinningBgStyleTag.remove(); globalSpinningBgStyleTag = null; } }; // Function to update global background when settings change const updateRadiantLyricsGlobalBackground = function (): void { // Apply performance mode class to document body if (settings.performanceMode) { document.body.classList.add("performance-mode"); } else { document.body.classList.remove("performance-mode"); } if (settings.CoverEverywhere) { // Get current cover art and apply global background updateCoverArtBackground(); } else { cleanUpGlobalSpinningBackground(); } }; // Function to update Now Playing background when settings change const updateRadiantLyricsNowPlayingBackground = function (): void { const nowPlayingBackgroundImages = document.querySelectorAll( ".now-playing-background-image", ); nowPlayingBackgroundImages.forEach((img: Element) => { const imgElement = img as HTMLImageElement; // Default values when settings don't affect Now Playing const defaultBlur = 80; const defaultBrightness = 40; const defaultContrast = 120; const defaultSpinSpeed = 45; let blur: number, brightness: number, contrast: number, spinSpeed: number; if (settings.settingsAffectNowPlaying) { blur = settings.backgroundBlur; brightness = settings.backgroundBrightness; contrast = settings.backgroundContrast; spinSpeed = settings.spinSpeed; } else { blur = defaultBlur; brightness = defaultBrightness; contrast = defaultContrast; spinSpeed = defaultSpinSpeed; } // Apply pixel-based size using intrinsic dimensions and current scale applyScaledPixelSize(imgElement); const radius = `${settings.backgroundRadius}%`; if (imgElement.style.borderRadius !== radius) imgElement.style.borderRadius = radius; if (settings.performanceMode) { blur = Math.min(blur, 20); contrast = Math.min(contrast, 150); } if (settings.spinningArt) { imgElement.style.animation = `spin ${spinSpeed}s linear infinite`; imgElement.style.willChange = "transform"; } else { imgElement.style.animation = "none"; imgElement.style.willChange = "auto"; } imgElement.style.filter = `blur(${blur}px) brightness(${brightness / 100}) contrast(${contrast}%)`; }); }; // Make these functions available globally so Settings can call them (window as any).updateRadiantLyricsStyles = updateRadiantLyricsStyles; (window as any).updateRadiantLyricsGlobalBackground = updateRadiantLyricsGlobalBackground; (window as any).updateRadiantLyricsNowPlayingBackground = updateRadiantLyricsNowPlayingBackground; (window as any).updateRadiantLyricsTextGlow = updateRadiantLyricsTextGlow; (window as any).updateRadiantLyricsPlayerBarTint = updateRadiantLyricsPlayerBarTint; (window as any).updateQualityProgressColor = applyQualityProgressColor; const cleanUpDynamicArt = function (): void { // Clean up cached Now Playing elements if ( nowPlayingBackgroundContainer && nowPlayingBackgroundContainer.parentNode ) { nowPlayingBackgroundContainer.parentNode.removeChild( nowPlayingBackgroundContainer, ); } nowPlayingBackgroundContainer = null; nowPlayingBackgroundImage = null; nowPlayingBlackBg = null; nowPlayingGradientOverlay = null; // Clean up any remaining elements (fallback) const nowPlayingBackgroundImages = document.getElementsByClassName( "now-playing-background-image", ); Array.from(nowPlayingBackgroundImages).forEach((element) => { element.remove(); }); // Clean up spinning background cleanUpGlobalSpinningBackground(); }; // I may or may not have forgotten what this does.. document.addEventListener("visibilitychange", () => { const isHiddenDoc = document.hidden; const images = document.querySelectorAll( ".global-spinning-image, .now-playing-background-image", ); images.forEach((img) => { const el = img as HTMLElement; if (isHiddenDoc) { // Pause animation but keep state if (el.style.animationPlayState !== "paused") el.style.animationPlayState = "paused"; if (el.style.willChange !== "auto") el.style.willChange = "auto"; } else { if (el.style.animationPlayState !== "running") el.style.animationPlayState = "running"; if ( el.classList.contains("global-spinning-image") || el.classList.contains("now-playing-background-image") ) { if (el.style.willChange !== "transform") el.style.willChange = "transform"; } } }); }); // Init performance mode if (settings.performanceMode) { document.body.classList.add("performance-mode"); } // Init text glow updateRadiantLyricsTextGlow(); // Init global background updateCoverArtBackground(1); // Cleanups unloads.add(() => { cleanUpDynamicArt(); // Clean up floating player bar inline styles const footerPlayer = document.querySelector('[data-test="footer-player"]') as HTMLElement; if (footerPlayer) { footerPlayer.style.removeProperty("background-color"); footerPlayer.style.removeProperty("border-radius"); footerPlayer.style.removeProperty("bottom"); footerPlayer.style.removeProperty("left"); footerPlayer.style.removeProperty("width"); } // Clean up HideUI button auto-fade timeout if (unhideButtonAutoFadeTimeout != null) { window.clearTimeout(unhideButtonAutoFadeTimeout); unhideButtonAutoFadeTimeout = null; } // Clean up HideUI button const hideButton = document.querySelector(".hide-ui-button"); if (hideButton && hideButton.parentNode) { hideButton.parentNode.removeChild(hideButton); } const unhideButton = document.querySelector(".unhide-ui-button"); if (unhideButton && unhideButton.parentNode) { unhideButton.parentNode.removeChild(unhideButton); } // Clean up sticky lyrics elements document.querySelectorAll(".sticky-lyrics-trigger, .sticky-lyrics-dropdown").forEach((el) => { el.remove(); }); // Clean up spin animations const spinAnimationStyle = document.querySelector("#spinAnimation"); if (spinAnimationStyle && spinAnimationStyle.parentNode) { spinAnimationStyle.parentNode.removeChild(spinAnimationStyle); } // Clean up spinning background cleanUpGlobalSpinningBackground(); }); // MARKER: Sticky Lyrics Feature const STICKY_ICONS: Record = { chevron: '', sparkle: '', }; const getStickyIcon = (): string => STICKY_ICONS[settings.stickyLyricsIcon] ?? STICKY_ICONS.chevron; const applyStickyIcon = (): void => { const trigger = document.querySelector(".sticky-lyrics-trigger") as HTMLElement; if (!trigger) return; trigger.innerHTML = getStickyIcon(); trigger.style.paddingLeft = "5px"; }; // Console: StickyLyrics.icon = "sparkle" or "chevron" // I'm picky and prefer the Sparkle.. shhh (window as any).StickyLyrics = { get icon() { return settings.stickyLyricsIcon; }, set icon(value: string) { const key = value.toLowerCase(); if (!STICKY_ICONS[key]) { console.log(`[Radiant Lyrics] Unknown icon "${value}". Available: ${Object.keys(STICKY_ICONS).join(", ")}`); return; } settings.stickyLyricsIcon = key; applyStickyIcon(); console.log(`[Radiant Lyrics] Sticky Lyrics icon set to "${key}"`); }, }; // Console: Syllables.log = true/false // Verbose logging for word/syllable lyrics (hidden setting) const sylLog = (...args: unknown[]) => { if (settings.syllableLogging) console.log(...args); }; const sylTrace = (...args: unknown[]) => { if (settings.syllableLogging) trace.log(...args); }; (window as any).Syllables = { get log() { return settings.syllableLogging; }, set log(value: boolean) { settings.syllableLogging = value; console.log(`[Radiant Lyrics] Syllable logging ${value ? "enabled" : "disabled"}`); }, // MARKER: Syllable animations (WIP coming soon) get style() { return settings.syllableStyle; }, set style(value: number) { const names = ["none", "Pop!", "Jump"]; const clamped = Math.max(0, Math.min(2, Math.floor(value))); settings.syllableStyle = clamped; const container = document.querySelector(".rl-wbw-container"); if (container) { container.classList.remove("rl-syl-pop", "rl-syl-jump"); if (clamped === 1) container.classList.add("rl-syl-pop"); else if (clamped === 2) container.classList.add("rl-syl-jump"); } console.log(`[Radiant Lyrics] Syllable style: ${names[clamped] ?? clamped}`); }, }; // Called from Settings (mirrors dropdown checkbox) const updateStickyLyricsFeature = (): void => { const checkbox = document.querySelector('input[data-setting="stickyLyrics"]') as HTMLInputElement; if (checkbox) checkbox.checked = settings.stickyLyrics; }; (window as any).updateStickyLyricsFeature = updateStickyLyricsFeature; const createStickyLyricsDropdown = (): void => { const lyricsTab = document.querySelector( '[data-test="tabs-lyrics"]', ) as HTMLElement; if (!lyricsTab) return; if (lyricsTab.querySelector(".sticky-lyrics-trigger")) return; // Trigger // lives inside the Lyrics
  • const trigger = document.createElement("div"); trigger.className = "sticky-lyrics-trigger"; trigger.setAttribute("title", "Sticky Lyrics"); // Set the icon & it's styling // is only needed because i'm picky and prefer the Sparkle.. shhh trigger.innerHTML = getStickyIcon(); // Block non-click events on trigger from reaching the Lyrics tab (capture phase) // (capture phase stops the tab from activating & runs the toggle before the event is consumed by the SVG child) - Thx React.. again.. for (const evtName of ["pointerdown", "pointerup", "mousedown", "mouseup"] as const) { trigger.addEventListener(evtName, (e: Event) => { e.stopPropagation(); }, true); } // Dropdown // lives in document.body so its events never touch the Lyrics tab - Thx React.. const dropdown = document.createElement("div"); dropdown.className = "sticky-lyrics-dropdown"; dropdown.style.display = "none"; dropdown.innerHTML = `
    Sticky Lyrics
    `; // Toggle dropdown on trigger click const openDropdown = (): void => { const buttonRect = lyricsTab.getBoundingClientRect(); dropdown.style.top = `${buttonRect.bottom}px`; dropdown.style.left = `${buttonRect.left}px`; dropdown.style.width = `${buttonRect.width}px`; dropdown.style.display = "block"; lyricsTab.classList.add("sticky-lyrics-open"); }; const closeDropdown = (): void => { dropdown.style.display = "none"; lyricsTab.classList.remove("sticky-lyrics-open"); }; trigger.addEventListener("click", (e: MouseEvent) => { e.stopPropagation(); const isActive = lyricsTab.getAttribute("aria-selected") === "true"; if (!isActive) { // Navigate to Lyrics & open dropdown lyricsTab.click(); // Delay to let the tab activate safeTimeout(unloads, () => openDropdown(), 150); return; } // Toggle dropdown if (dropdown.style.display === "none") { openDropdown(); } else { closeDropdown(); } }, true); // Handle toggle switch const stickyCheckbox = dropdown.querySelector( 'input[data-setting="stickyLyrics"]', ) as HTMLInputElement; stickyCheckbox.addEventListener("change", () => { settings.stickyLyrics = stickyCheckbox.checked; (window as any).updateStickyLyricsSetting?.(stickyCheckbox.checked); if (settings.stickyLyrics) { handleStickyLyricsTrackChange(); } }); const styleNames = ["Line", "Word", "Syllable"]; const segButtons = dropdown.querySelectorAll(".rl-seg-btn"); for (const btn of segButtons) { btn.addEventListener("click", (e: Event) => { e.stopPropagation(); const raw = (btn as HTMLElement).dataset.style; if (raw === undefined) return; const style = Number(raw); if (style === settings.lyricsStyle) return; settings.lyricsStyle = style; for (const b of segButtons) b.classList.remove("rl-seg-active"); btn.classList.add("rl-seg-active"); (window as any).updateLyricsStyleSetting?.(style); sylLog(`[RL-Syllable] Lyrics style changed to "${styleNames[style]}"`); toggle(); }); } // Close dropdown when clicking outside trigger & dropdown const handleOutsideClick = (e: MouseEvent): void => { if (!trigger.contains(e.target as Node) && !dropdown.contains(e.target as Node)) { closeDropdown(); } }; document.addEventListener("click", handleOutsideClick); // Trigger goes inside the Lyrics
  • & dropdown goes in lyricsTab.appendChild(trigger); document.body.appendChild(dropdown); // Register cleanup unloads.add(() => { document.removeEventListener("click", handleOutsideClick); lyricsTab.classList.remove("sticky-lyrics-open"); trigger.remove(); dropdown.remove(); }); }; // Handle switching tabs on track change const handleStickyLyricsTrackChange = (): void => { if (!settings.stickyLyrics) return; // Process the track change and update tab state // Tidal takes a while to process the track change sometimes :( safeTimeout(unloads, () => { if (!settings.stickyLyrics) return; const lyricsTab = document.querySelector( '[data-test="tabs-lyrics"]', ) as HTMLElement; const playQueueTab = document.querySelector( '[data-test="tabs-play-queue"]', ) as HTMLElement; if (!lyricsTab) { if (playQueueTab) playQueueTab.click(); return; } lyricsTab.click(); // Verify we actually stayed on lyrics after a short delay // TODO: Make not shitty (one day maybe) safeTimeout(unloads, () => { if (!settings.stickyLyrics) return; const onLyrics = document.querySelector( '[data-test="tabs-lyrics"][aria-selected="true"]', ); if (!onLyrics && playQueueTab) { playQueueTab.click(); } }, 800); }, 1200); }; // Observer: create dropdown when lyrics tab appears & detect track changes function setupStickyLyricsObserver(): void { // Create dropdown if lyrics tab already exists const existing = document.querySelector('[data-test="tabs-lyrics"]'); if (existing && !existing.querySelector(".sticky-lyrics-trigger")) { createStickyLyricsDropdown(); } // Re-create dropdown whenever lyrics tab is back from the ether observe(unloads, '[data-test="tabs-lyrics"]', () => { const tab = document.querySelector('[data-test="tabs-lyrics"]'); if (tab && !tab.querySelector(".sticky-lyrics-trigger")) { createStickyLyricsDropdown(); } }); // Apply word lyrics when lyrics container appears or reappears observe(unloads, '[data-test="lyrics-lines"]', () => { if (lyricsData) { reapplyWordLyrics(); } else { onTrackChange(); } }); // sticky lyrics track changes onGlobalTrackChange(() => { if (settings.stickyLyrics) { handleStickyLyricsTrackChange(); } }); } // track change system (used everywhere) const trackChangeListeners: (() => void)[] = []; const onGlobalTrackChange = (listener: () => void): void => { trackChangeListeners.push(listener); }; // MARKER: Syllable Lyrics interface WordTiming { text: string; time: number; // ms duration: number; // ms isBackground: boolean; } interface WordLine { text: string; startTime: number; // s duration: number; // s endTime: number; // s syllabus: WordTiming[]; element: { key: string; songPart: string; singer: string }; translation: string | null; } interface WordLyricsResponse { type: string; data: WordLine[]; metadata: { source: string; title: string; language: string; totalDuration: string; agents?: Record; songParts?: Array<{ name: string; time: number; duration: number }>; }; _cached?: boolean; } // 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; interface WordEntry { el: HTMLSpanElement; start: number; // ms end: number; // ms duration: number; // ms } interface LineEntry { el: HTMLElement; tidalSpan: HTMLElement | null; // matching tidal span for data-current 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; const activeWordEls = new Map(); const activeBgWordEls = new Map(); let activeLineIdxs = new Set(); let primaryLineIdx = -1; // Scroll sync (unhook on user scroll) let scrollSynced = true; 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: LunaUnload | null = null; let scrollCleanupTimeout: LunaUnload | null = null; let animatingEls: HTMLElement[] = []; const clearScrollAnim = (): void => { if (scrollUnlockTimeout) { scrollUnlockTimeout(); scrollUnlockTimeout = null; } if (scrollCleanupTimeout) { 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) { scrollUnlockTimeout(); scrollUnlockTimeout = null; } if (scrollCleanupTimeout) { 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 = safeTimeout(unloads, () => { 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 = safeTimeout(unloads, () => { 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; let savedScroll: any = null; let savedScrollBy: any = null; let scrollAllowed = false; // playback time in ms (interpolated between currentTime updates) let lastPlayerTime = 0; let lastPlayerTimeAt = 0; let wasPlaying = false; const getPlaybackMs = (): number => { const playerTime = PlayState.currentTime; const playing = PlayState.playing; const now = performance.now(); // reset interpolation for pause/resume resyncs if (playing !== wasPlaying) { wasPlaying = playing; lastPlayerTimeAt = now; lastPlayerTime = playerTime; return playerTime * 1000; } if (playerTime !== lastPlayerTime) { lastPlayerTime = playerTime; lastPlayerTimeAt = now; return playerTime * 1000; } if (playing && lastPlayerTimeAt > 0) { const elapsed = now - lastPlayerTimeAt; return (lastPlayerTime * 1000) + elapsed; } return playerTime * 1000; }; // get title + artist from media item (Used everywhere now <3) const getTrackInfo = async (): Promise<{ title: string; artist: string; isrc?: string } | null> => { const mi = await MediaItem.fromPlaybackContext(); if (!mi?.tidalItem) return null; const baseTitle = mi.tidalItem.title ?? ""; const version = mi.tidalItem.version; // REMIX Detection const title = version ? `${baseTitle} (${version})` : baseTitle; const artist = mi.tidalItem.artist?.name ?? mi.tidalItem.artists?.[0]?.name ?? ""; // REMIX Detection const isrc = mi.tidalItem.isrc ?? undefined; if (!baseTitle || !artist) return null; return { title, artist, isrc }; }; // fetch syllables from the API (wiped on track change) let cachedLyricsKey: string | null = null; let cachedLyricsData: WordLyricsResponse | null = null; const fetchWordLyrics = async ( title: string, artist: string, isrc?: string, ): Promise => { const cacheKey = `${title}\0${artist}\0${isrc ?? ""}`; if (cachedLyricsKey === cacheKey) { sylLog(`[RL-Syllable] Cache hit for "${title}" by "${artist}"`); return cachedLyricsData; } let params = `lyrics?title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`; if (isrc) params += `&isrc=${encodeURIComponent(isrc)}`; const primaryUrls = [ `https://rl-api.atomix.one/${params}`, `https://lyricsplus-api.atomix.one/${params}`, ]; const fallbackUrl = `https://rl-api.kineticsand.net/${params}`; // "ok" = got a response (data may still be null if type != Word) // "500" = serverless timeout, skip remaining primaries and go to fallback // "err" = network/other error, try next host type FetchOutcome = | { status: "ok"; data: WordLyricsResponse | null } | { status: "500" } | { status: "err" }; const tryFetch = async (url: string): Promise => { try { sylTrace(`RL API: Fetching word/syllable lyrics: ${url}`); const res = await fetch(url); if (!res.ok) { trace.log(`RL API: fetch failed: ${res.status} from ${url}`); return res.status === 500 ? { status: "500" } : { status: "err" }; } const data: WordLyricsResponse = await res.json(); if (data.type !== "Word" || !data.data) { trace.log(`Word/Syllable lyrics not available (type: ${data.type})`); return { status: "ok", data: null }; } return { status: "ok", data }; } catch (err) { trace.log(`RL API: fetch error from ${url}: ${err}`); return { status: "err" }; } }; const finish = (data: WordLyricsResponse | null): WordLyricsResponse | null => { cachedLyricsKey = cacheKey; cachedLyricsData = data; return data; }; // Try primary hosts; bail to fallback immediately on 500 for (const url of primaryUrls) { const outcome = await tryFetch(url); if (outcome.status === "ok") return finish(outcome.data); if (outcome.status === "500") { trace.log("RL API: 500 from primary host — skipping to fallback"); break; } // "err" → try next primary } // Fallback: kineticsand (no serverless timeout) const fallback = await tryFetch(fallbackUrl); if (fallback.status === "ok") return finish(fallback.data); trace.log("RL API: All Endpoints Failed"); cachedLyricsKey = cacheKey; cachedLyricsData = null; return null; }; // strip tidal css classes (prevent conflict) const hideTidalLyrics = (): boolean => { const lyricsContainer = document.querySelector( '[data-test="lyrics-lines"]', ) as HTMLElement; if (!lyricsContainer) return false; // collect _ tidal classes const tidalClasses = Array.from(lyricsContainer.classList).filter((c) => c.startsWith("_"), ); if (tidalClasses.length === 0) return true; // Save classes on first call (for teardown) if (!savedTidalClasses) { savedTidalClasses = tidalClasses; sylTrace(`Saved Tidal classes: ${savedTidalClasses.join(", ")}`); } for (const c of tidalClasses) lyricsContainer.classList.remove(c); return true; }; // restore tidal classes (remove our container + cleanup) const restoreTidalLyrics = (): void => { const lyricsContainer = document.querySelector( '[data-test="lyrics-lines"]', ) as HTMLElement; if (lyricsContainer) { // re-add the exact _ classes if (savedTidalClasses) { for (const c of savedTidalClasses) { if (!lyricsContainer.classList.contains(c)) { lyricsContainer.classList.add(c); } } sylTrace(`Restored Tidal classes: ${savedTidalClasses.join(", ")}`); } lyricsContainer.classList.remove("rl-wbw-active"); lyricsContainer.style.removeProperty("overflow"); const innerDiv = lyricsContainer.querySelector(":scope > div") as HTMLElement; if (innerDiv) { innerDiv.style.removeProperty("overflow"); innerDiv.style.removeProperty("position"); } lyricsContainer.querySelectorAll(".rl-wbw-line[data-current]").forEach((el) => { el.removeAttribute("data-current"); }); lyricsContainer.querySelector(".rl-wbw-container")?.remove(); } 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, ): { sides: string[]; isDualSide: boolean } => { const sides = new Array(data.length).fill(""); const personOrder: string[] = []; const singerSideMap = new Map(); 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[]; } => { const lines: LineEntry[] = []; if (!lyricsData) return { lines }; const lyricsContainer = document.querySelector( '[data-test="lyrics-lines"]', ) as HTMLElement; if (!lyricsContainer) return { lines }; const innerDiv = lyricsContainer.querySelector(":scope > div") as HTMLElement; if (!innerDiv) return { lines }; // remove existing container innerDiv.querySelector(".rl-wbw-container")?.remove(); // hide tidal spans + take over scroll lyricsContainer.classList.add("rl-wbw-active"); // force overflow visible to fix glow clipping (WIP doesnt work yet) lyricsContainer.style.setProperty("overflow", "visible", "important"); innerDiv.style.setProperty("overflow", "visible", "important"); // helper for setting !important styles (got sick of pathing all the time) const forceStyle = (el: HTMLElement, props: Record) => { for (const [k, v] of Object.entries(props)) { el.style.setProperty(k, v, "important"); } }; // 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"); forceStyle(wbwContainer, { display: "block", width: "100%", "box-sizing": "border-box", margin: "0", padding: "0", float: "none", flex: "none", "column-count": "auto", 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"); spacer.className = "rl-wbw-line rl-wbw-spacer"; forceStyle(spacer, { display: "block", height: "1rem", margin: "0 0 1rem 0", }); wbwContainer.appendChild(spacer); continue; } const lineDiv = document.createElement("div"); lineDiv.className = "rl-wbw-line"; forceStyle(lineDiv, { display: "block", "white-space": "normal", "word-spacing": "normal", "letter-spacing": "normal", "margin-bottom": "2rem", "padding-top": "0", "padding-bottom": "0", "font-size": "40px", "font-family": FONT_STACK, "font-weight": "700", color: "rgba(128, 128, 128, 0.4)", overflow: "visible", flex: "none", "column-count": "auto", gap: "0", "justify-content": "initial", "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 = { display: "inline-block", float: "none", flex: "none", margin: "0", padding: "0", "word-spacing": "normal", "letter-spacing": "normal", }; const makeSpan = (text: string, seekMs: number, bg: boolean): HTMLSpanElement => { const span = document.createElement("span"); span.className = "rl-wbw-word"; 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", () => { PlayState.seek(seekMs / 1000); if (!PlayState.playing) PlayState.play(); resync(); }); return span; }; // 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 = []; } } if (settings.lyricsStyle === 0) { // Line mode: one span per container (main / bg) — no word splitting const mainSyls = syllabus.filter(s => !splitBg || !s.isBackground); const bgSyls = splitBg ? syllabus.filter(s => s.isBackground) : []; if (mainSyls.length > 0) { const text = mainSyls.map(s => s.text).join("").trim(); const first = mainSyls[0]; const last = mainSyls[mainSyls.length - 1]; const span = makeSpan(text, first.time, false); mainContainer.appendChild(span); lineWords.push({ el: span, start: first.time, end: last.time + last.duration, duration: (last.time + last.duration) - first.time }); } if (bgSyls.length > 0 && bgContainer) { const text = bgSyls.map(s => s.text).join("").trim().replace(/[()]/g, ""); const first = bgSyls[0]; const last = bgSyls[bgSyls.length - 1]; const span = makeSpan(text, first.time, true); bgContainer.appendChild(span); lineBgWords.push({ el: span, start: first.time, end: last.time + last.duration, duration: (last.time + last.duration) - first.time }); } } else { for (const group of wordGroups) { const groupIsBg = splitBg && syllabus[group[0]].isBackground; const targetContainer = groupIsBg ? bgContainer! : mainContainer; const targetWords = groupIsBg ? lineBgWords : lineWords; if (isSylMode) { const wordStartMs = syllabus[group[0]].time; const groupSpans: HTMLSpanElement[] = []; for (const si of group) { const syl = syllabus[si]; const span = makeSpan(syl.text.trimEnd(), wordStartMs, syl.isBackground); span.addEventListener("mouseenter", () => { for (const s of groupSpans) s.classList.add("rl-wbw-word-hover"); }); span.addEventListener("mouseleave", () => { for (const s of groupSpans) s.classList.remove("rl-wbw-word-hover"); }); groupSpans.push(span); targetContainer.appendChild(span); const entry: WordEntry = { el: span, start: syl.time, end: syl.time + syl.duration, duration: syl.duration }; targetWords.push(entry); } } else { 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); targetContainer.appendChild(span); const entry: WordEntry = { el: span, start, end, duration: end - start }; targetWords.push(entry); } targetContainer.appendChild(document.createTextNode(" ")); } } wbwContainer.appendChild(lineDiv); 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: firstStart, endMs: lastEnd, words: allWords, bgWords: lineBgWords, isBg: allAreBg, }); } } // 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 const tidalSpans = Array.from( innerDiv.querySelectorAll('span[data-test="lyrics-line"]'), ) as HTMLElement[]; for (let i = 0; i < lines.length && i < tidalSpans.length; i++) { lines[i].tidalSpan = tidalSpans[i]; } sylTrace( `Matched ${Math.min(lines.length, tidalSpans.length)} word/syllable lines to Tidal spans (${lines.length} lines, ${tidalSpans.length} spans)`, ); // append lyrics container (yea ik i was gonan edit tidals but uhh shhhh) innerDiv.appendChild(wbwContainer); sylTrace( `Word-by-word DOM: ${lines.reduce((n, l) => n + l.words.length, 0)} word spans across ${lines.length} lines`, ); return { lines }; }; // watch for re-renders const watchForRerender = (): void => { unwatchRerender(); const lyricsContainer = document.querySelector( '[data-test="lyrics-lines"]', ) as HTMLElement; if (!lyricsContainer) return; rerenderObserver = new MutationObserver(() => { // tidal fire mutations in bursts if (rerenderDebounce !== null) { clearTimeout(rerenderDebounce); } rerenderDebounce = window.setTimeout(() => { rerenderDebounce = null; if (!isActive || !lyricsData) return; // check if our container has been nuked by a react re-render (thx react again again..) const existing = lyricsContainer.querySelector(".rl-wbw-container"); if (!existing) { sylTrace( "Word-by-word: re-applying after Tidal re-render", ); hideTidalLyrics(); const result = buildWordSpans(); lines = result.lines; } }, 100); }); rerenderObserver.observe(lyricsContainer, { childList: true, subtree: true, }); }; const unwatchRerender = (): void => { if (rerenderDebounce !== null) { clearTimeout(rerenderDebounce); rerenderDebounce = null; } if (rerenderObserver) { rerenderObserver.disconnect(); rerenderObserver = null; } }; const clearTickLoop = (): void => { if (tickLoopUnload !== null) { tickLoopUnload(); tickLoopUnload = null; } }; // teardown (cleanup) const teardown = (): void => { trackChangeToken++; clearTickLoop(); clearScrollAnim(); unwatchRerender(); unhookUserScroll(); unhookSyncButton(); unlockScroll(); scrollSynced = true; isActive = false; lyricsData = null; lyricsResponse = null; lines = []; activeWordEls.clear(); activeBgWordEls.clear(); activeLineIdxs.clear(); primaryLineIdx = -1; restoreTidalLyrics(); }; // find scrollable parent const findScroller = (el: HTMLElement): HTMLElement => { let parent = el.parentElement; while (parent) { const style = window.getComputedStyle(parent); if ( style.overflowY === "auto" || style.overflowY === "scroll" || style.overflow === "auto" || style.overflow === "scroll" ) { return parent; } parent = parent.parentElement; } return document.documentElement; }; // Lock scroll parent so tidal can't scroll to line spans const lockScroll = (parent: HTMLElement): void => { if (scrollParentRef === parent) return; unlockScroll(); scrollParentRef = parent; savedScrollTo = parent.scrollTo; savedScroll = parent.scroll; savedScrollBy = parent.scrollBy; // scroll gate to stop tidal scrolling to line spans const makeGated = (original: any) => function (this: HTMLElement, ...args: unknown[]) { if (scrollAllowed || !isActive) { original.apply(parent, args); } }; parent.scrollTo = makeGated(savedScrollTo); parent.scroll = makeGated(savedScroll); parent.scrollBy = makeGated(savedScrollBy); // gate the scrollTop setter const desc = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop"); if (desc?.set && desc.get) { const origGet = desc.get; const origSet = desc.set; Object.defineProperty(parent, "scrollTop", { get() { return origGet.call(this); }, set(value: number) { if (scrollAllowed || !isActive) { origSet.call(this, value); } }, configurable: true, }); } }; // Restore original scroll methods const unlockScroll = (): void => { if (!scrollParentRef) return; if (savedScrollTo) scrollParentRef.scrollTo = savedScrollTo as typeof Element.prototype.scrollTo; if (savedScroll) scrollParentRef.scroll = savedScroll as typeof Element.prototype.scroll; if (savedScrollBy) scrollParentRef.scrollBy = savedScrollBy as typeof Element.prototype.scrollBy; // Remove instance-level scrollTop override delete (scrollParentRef as any).scrollTop; scrollParentRef = null; savedScrollTo = null; savedScroll = null; savedScrollBy = null; }; // Scroll bypassing scroll lock (probably not the best way to do this) const scrollTo = (parent: HTMLElement, options: ScrollToOptions): void => { scrollAllowed = true; parent.scrollTo(options); scrollAllowed = false; }; // Scroll to active line (resync) const scrollToActiveLine = (): void => { 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; 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(); unhookSyncButton(); sylLog("[RL-Syllable] Scroll resynced"); }; // Hook user scroll const hookUserScroll = (parent: HTMLElement): void => { unhookUserScroll(); 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 }); parent.addEventListener("touchmove", onUserScroll, { passive: true }); userScrollListener = () => { parent.removeEventListener("wheel", onUserScroll); parent.removeEventListener("touchmove", onUserScroll); }; }; const unhookUserScroll = (): void => { if (userScrollListener) { userScrollListener(); userScrollListener = null; } }; // Hook lyric scroll sync button const hookSyncButton = (): void => { unhookSyncButton(); const btn = document.querySelector('div[class*="_syncButton"] button') as HTMLElement; if (!btn) return; syncButtonEl = btn; const handler = () => resync(); btn.addEventListener("click", handler); syncButtonListener = () => btn.removeEventListener("click", handler); }; const unhookSyncButton = (): void => { if (syncButtonListener) { syncButtonListener(); syncButtonListener = null; syncButtonEl = null; } }; // Tick Loop: determine active line and word const startTickLoop = (): void => { clearTickLoop(); sylLog("[RL-Syllable] Tick loop started"); let lastLogTime = 0; let lastTickMs = 0; tickLoopUnload = safeInterval(unloads, () => { if (!isActive || lines.length === 0) return; 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 const tidalCurrentSpans = document.querySelectorAll( 'span[data-test="lyrics-line"][data-current]', ); for (const span of tidalCurrentSpans) { span.removeAttribute("data-current"); } if (nowMs - lastLogTime >= 1000) { lastLogTime = nowMs; sylLog(`[RL-Syllable] Playback | ${nowMs.toFixed(0)} ms`); } // find all active lines (supports overlapping duet/adlib lines) const newActiveSet = new Set(); for (let i = 0; i < lines.length; i++) { 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) { 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++) { 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); } else { w.el.classList.remove(CLS_ACTIVE, CLS_FINISHED); if (isSyl) w.el.style.animation = ""; } } } 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"); } } 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 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); } } // 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]`, ); } } // 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); if (scrollSynced) { const lineRect = newLine.el.getBoundingClientRect(); const parentRect = scrollParent.getBoundingClientRect(); const targetOffset = parentRect.height * 0.2; const scrollTarget = scrollParent.scrollTop + (lineRect.top - parentRect.top) - targetOffset; // 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" }); } } // 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 if (!scrollSynced && !syncButtonEl) { hookSyncButton(); } // highlight words in all active lines if (activeLineIdxs.size === 0) return; for (const lineIdx of activeLineIdxs) { const currentLine = lines[lineIdx]; const prevActiveWord = activeWordEls.get(lineIdx) ?? null; let activeWordIdx = -1; for (let i = currentLine.words.length - 1; i >= 0; i--) { if (nowMs >= currentLine.words[i].start) { activeWordIdx = i; break; } } 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); } } 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); } } // 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); }; // Called by track change or style toggle const onTrackChange = async (): Promise => { teardown(); const token = ++trackChangeToken; const trackInfo = await getTrackInfo(); if (token !== trackChangeToken) return; if (!trackInfo) { trace.log("could not get track info from playback state"); return; } sylTrace( `RL API: looking up "${trackInfo.title}" by "${trackInfo.artist}"${trackInfo.isrc ? ` (ISRC: ${trackInfo.isrc})` : ""}`, ); const response = await fetchWordLyrics( trackInfo.title, trackInfo.artist, trackInfo.isrc, ); if (token !== trackChangeToken) return; if (!response) { trace.log("RL API: no word/syllable lyrics available"); return; } sylTrace( `RL API: loaded ${response.data.length} lines (source: ${response.metadata.source})`, ); sylLog( `[RL-Syllable] Loaded "${trackInfo.title}" by "${trackInfo.artist}" — ${response.data.length} lines`, ); // Store data lyricsData = response.data; lyricsResponse = response; isActive = true; // Remove Tidal classes hideTidalLyrics(); // Build word spans and line entries const result = buildWordSpans(); lines = result.lines; // Watch React re-renders watchForRerender(); // Start the highlight loop startTickLoop(); }; // Reapply word lyrics (for tab switch back) const reapplyWordLyrics = (): void => { if (!lyricsData) return; clearTickLoop(); clearScrollAnim(); unwatchRerender(); unhookUserScroll(); unhookSyncButton(); unlockScroll(); activeWordEls.clear(); activeBgWordEls.clear(); activeLineIdxs.clear(); primaryLineIdx = -1; isActive = true; hideTidalLyrics(); const result = buildWordSpans(); lines = result.lines; watchForRerender(); startTickLoop(); sylLog("[RL-Syllable] Reapplied word/syllable lyrics (cached)"); }; // Called by Settings or dropdown const toggle = (): void => { teardown(); onTrackChange(); }; const updateLyricsStyleFromSettings = (): void => { const segButtons = document.querySelectorAll(".rl-seg-btn"); for (const btn of segButtons) { const raw = (btn as HTMLElement).dataset.style; if (raw === undefined) continue; btn.classList.toggle("rl-seg-active", Number(raw) === settings.lyricsStyle); } toggle(); }; (window as any).updateLyricsStyle = updateLyricsStyleFromSettings; // Update lyrics on track change (wipe cache for new song) onGlobalTrackChange(() => { cachedLyricsKey = null; cachedLyricsData = null; onTrackChange(); }); unloads.add(() => teardown()); // MARKER: Observers const setupTrackChangeListener = (): void => { MediaItem.onMediaTransition(unloads, () => { for (const listener of trackChangeListeners) listener(); }); // Applies on app reopen (most ppl close the app while smthn playing) let hasFiredInitial = false; if (PlayState.playbackContext?.actualProductId) { hasFiredInitial = true; for (const listener of trackChangeListeners) listener(); } if (!hasFiredInitial) { PlayState.onState(unloads, (state) => { if (hasFiredInitial) return; if (state === "PLAYING" && PlayState.playbackContext?.actualProductId) { hasFiredInitial = true; for (const listener of trackChangeListeners) listener(); } }); } }; function setupHeaderObserver(): void { const existing = document.querySelector('[data-test="header-container"]'); if (existing && !document.querySelector(".hide-ui-button")) createHideUIButton(); observe(unloads, '[data-test="header-container"]', () => { if (!document.querySelector(".hide-ui-button")) createHideUIButton(); }); } function setupNowPlayingObserver(): void { const existing = document.querySelector('[class*="_nowPlayingContainer"]'); if (existing && !document.querySelector(".unhide-ui-button")) createUnhideUIButton(); observe(unloads, '[class*="_nowPlayingContainer"]', () => { if (!document.querySelector(".unhide-ui-button")) createUnhideUIButton(); }); } function setupTrackTitleObserver(): void { const trackTitleEl = document.querySelector( '[data-test="now-playing-track-title"]', ) as HTMLElement | null; if (trackTitleEl) { if (settings.trackTitleGlow && settings.lyricsGlowEnabled) { trackTitleEl.classList.remove("rl-title-glow-disabled"); } else { trackTitleEl.classList.add("rl-title-glow-disabled"); } } observe( unloads, '[data-test="now-playing-track-title"]', (el) => { if (!el) return; if (settings.trackTitleGlow && settings.lyricsGlowEnabled) { el.classList.remove("rl-title-glow-disabled"); } else { el.classList.add("rl-title-glow-disabled"); } }, ); } // Apply seeker color on track change onGlobalTrackChange(() => { updateCoverArtBackground(); if (settings.qualityProgressColor) applyQualityProgressColor(); }); // Init observers setupHeaderObserver(); setupNowPlayingObserver(); setupTrackTitleObserver(); setupStickyLyricsObserver(); setupTrackChangeListener();