// MARKER: Core Setup import { type LunaUnload, Tracer, reduxStore, buildActions } from "@luna/core"; import { MediaItem, observe, PlayState, StyleTag, safeInterval, safeTimeout, redux, } from "@luna/lib"; import { type ApiLine, type LineLyricsResponse, type LyricsApiResponse, type WordLine, type WordTiming, artworkTitleKey, fetchLyrics as fetchLyricsApi, flushLyrics as flushLyricsApi, romanizeLyrics as romanizeLyricsApi, } from "./api"; import { KawarpLayer } from "./backdrop"; import { AnimatedArtworkLayer, ART_TILE_SELECTOR } from "./artwork"; import { Settings, settings } from "./Settings"; import backdropStylesCss from "file://backdrop-styles.css?minify"; import coverEverywhereCss from "file://cover-everywhere.css?minify"; import floatingPlayerBarCss from "file://floating-player-bar.css?minify"; import lyricsGlow from "file://lyrics-glow.css?minify"; import playerBarHidden from "file://player-bar-hidden.css?minify"; // Import CSS files directly using Luna's file:// syntax - Took me a while to figure out <3 import baseStyles from "file://styles.css?minify"; // Core tracer and exports export const { trace } = Tracer("[Radiant Lyrics]"); export { Settings }; const toast = (msg: string) => reduxStore.dispatch( buildActions["message/MESSAGE_INFO"]?.({ message: msg, category: "OTHER", severity: "INFO" }), ); const toastErr = (msg: string) => reduxStore.dispatch( buildActions["message/MESSAGE_ERROR"]?.({ message: msg, category: "OTHER", severity: "ERROR" }), ); // clean up resources export const unloads = new Set(); // MARKER: Romanization Gate const romanizedText = ( item: { text?: string | null; romanized?: string | null }, fallback = "", ): string => (settings.romanizeLyrics && item.romanized ? item.romanized : item.text) ?? fallback; // MARKER: Player Market UI (Ensure new UI is enabled) function enablePlayerMarketUI() { const { flags, userOverrides } = redux.store.getState().featureFlags; const key = Object.keys(flags).find( (k) => k.toLowerCase().replace(/[\s_]/g, "-") === "player-market-ui", ); const flag = key ? flags[key] : undefined; if (!flag) { trace.warn(`Feature flag "player-market-ui" not found`); return; } const currentValue = key !== undefined && key in userOverrides ? userOverrides[key] : flag.value; if (currentValue) { trace.log(`"${flag.name}" already enabled`); return; } redux.actions["featureFlags/TOGGLE_USER_OVERRIDE"]({ ...flag, value: true }); trace.log(`Enabled "${flag.name}"`); } const { ready: flagsReady } = redux.store.getState().featureFlags; if (flagsReady) { enablePlayerMarketUI(); } else { redux.intercept("featureFlags/READY", unloads, () => enablePlayerMarketUI(), true); } // 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, ); // 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, }; }; /** CSS var for integrated seekbar top corners — matches Floating Bar Corner Radius when floating is on */ const applyIntegratedSeekbarChrome = (): void => { const footer = document.querySelector( '[data-test="footer-player"]', ) as HTMLElement | null; if (!footer) return; if (!settings.integratedSeekBar) { footer.style.removeProperty("--rl-integrated-seekbar-top-radius"); return; } const topR = settings.floatingPlayerBar ? settings.playerBarRadius : 0; footer.style.setProperty("--rl-integrated-seekbar-top-radius", `${topR}px`); }; // Apply inline styles to the player bar (tint + optional radius/spacing customisation) const applyPlayerBarTintToElement = (): void => { const footerPlayer = document.querySelector( '[data-test="footer-player"]', ) as HTMLElement; if (!footerPlayer) return; if (settings.playerBarTintEnabled) { const alpha = settings.playerBarTint / 10; const { r, g, b } = hexToRgb(settings.playerBarTintColor); footerPlayer.style.removeProperty("background"); footerPlayer.style.setProperty( "background-color", `rgba(${r}, ${g}, ${b}, ${alpha})`, "important", ); } else { footerPlayer.style.removeProperty("background-color"); footerPlayer.style.removeProperty("background"); } if (settings.playerBarBlur) { footerPlayer.style.setProperty( "backdrop-filter", `blur(${settings.playerBarBlurAmount}px)`, "important", ); footerPlayer.style.setProperty( "-webkit-backdrop-filter", `blur(${settings.playerBarBlurAmount}px)`, "important", ); } else { footerPlayer.style.removeProperty("backdrop-filter"); footerPlayer.style.removeProperty("-webkit-backdrop-filter"); } 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"); } // Now Playing centres its artwork in a row running to the viewport bottom but the player bar overlays it so i do some complicaated thing to reserve the space <3 document.documentElement.style.setProperty( "--rl-player-bar-inset", `${Math.round(footerPlayer.getBoundingClientRect().height) + (settings.floatingPlayerBar ? settings.playerBarSpacing : 0)}px`, ); applyIntegratedSeekbarChrome(); }; // When floating is disabled, inject square-bar CSS to override Tidal's native floating styles const applyFloatingPlayerBar = (): void => { if (settings.floatingPlayerBar) { floatingPlayerBarStyleTag.remove(); } else { floatingPlayerBarStyleTag.css = floatingPlayerBarCss; } applyPlayerBarTintToElement(); }; // Alias for settings callback const updateRadiantLyricsPlayerBarTint = applyFloatingPlayerBar; // Apply floating player bar on load (Fixes race condition with flush button) applyFloatingPlayerBar(); // MARKER: Quality-Based Seeker Color // Maps data-test-quality-badge-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-quality-badge-streaming-quality) const qualityButton = document.querySelector( "[data-test-quality-badge-streaming-quality]", ) as HTMLElement | null; if (!qualityButton) return; const quality = qualityButton.getAttribute( "data-test-quality-badge-streaming-quality", ) ?? ""; const color = qualityColors[quality]; if (!color) return; progressIndicator.style.setProperty("background-color", color, "important"); }; // Apply on load if (settings.qualityProgressColor) { applyQualityProgressColor(); } // MARKER: Integrated Seek Bar // Moves the seekbar to the top border of the player bar | Inspired by Tokyo Tidal-HiFi theme (ages ago) let integratedSeekbarIntervalId: ReturnType | null = null; const clearIntegratedSeekbarInterval = (): void => { if (integratedSeekbarIntervalId !== null) { clearInterval(integratedSeekbarIntervalId); integratedSeekbarIntervalId = null; } }; // Restore Original DOM Structure (cleanup) const unwrapIntegratedSeekbarTimes = (footerPlayer: HTMLElement): void => { const wrap = footerPlayer.querySelector(".rl-seekbar-times-wrap"); if (!wrap?.parentElement) return; const row = wrap.parentElement; const bar = (row.querySelector(".rl-seekbar-bar") as HTMLElement | null) ?? (row.querySelector('[data-test="progress-bar"]')?.parentElement as | HTMLElement | null); const p1 = wrap.querySelector('[data-test="current-time"]') ?.parentElement as HTMLElement | undefined; const p2 = wrap.querySelector('[data-test="duration"]') ?.parentElement as HTMLElement | undefined; wrap.querySelector(".rl-seekbar-time-sep")?.remove(); if (p1 && p2 && bar) { row.insertBefore(p1, wrap); row.insertBefore(p2, bar.nextSibling); } else if (p1 && p2) { row.insertBefore(p1, wrap); row.appendChild(p2); } wrap.remove(); }; const syncIntegratedSeekbarCombinedTime = (footerPlayer: HTMLElement): void => { const el = footerPlayer.querySelector( ".rl-seekbar-combined-time", ) as HTMLElement | null; if (!el) return; const row = footerPlayer.querySelector(".rl-seekbar-container"); if (!row) return; const cur = row.querySelector('[data-test="current-time"]')?.textContent?.trim() ?? ""; const dur = row.querySelector('[data-test="duration"]')?.textContent?.trim() ?? ""; el.textContent = `${cur} | ${dur}`; }; // Finds the Seekbar Row const getScrubberRowFromFooter = ( footerPlayer: HTMLElement, ): HTMLElement | null => { const pb = footerPlayer.querySelector('[data-test="progress-bar"]'); const bar = pb?.parentElement; return bar?.parentElement ?? null; }; const clearNativeScrubberTimeDisplay = (footerPlayer: HTMLElement): void => { const row = getScrubberRowFromFooter(footerPlayer); if (!row) return; for (const p of row.querySelectorAll("p")) { if ( p.querySelector('[data-test="current-time"]') || p.querySelector('[data-test="duration"]') ) { p.style.removeProperty("display"); } } }; const cleanupIntegratedSeekbarDisplay = (footerPlayer: HTMLElement): void => { clearIntegratedSeekbarInterval(); clearNativeScrubberTimeDisplay(footerPlayer); unwrapIntegratedSeekbarTimes(footerPlayer); footerPlayer.querySelectorAll(".rl-seekbar-combined-time").forEach((n) => { n.remove(); }); for (const el of footerPlayer.querySelectorAll(".rl-seekbar-native-time")) { el.classList.remove("rl-seekbar-native-time"); } footerPlayer.style.removeProperty("--rl-integrated-seekbar-top-radius"); }; const applyIntegratedSeekBar = (): void => { const footerPlayer = document.querySelector( '[data-test="footer-player"]', ) as HTMLElement | null; if (!footerPlayer) return; clearIntegratedSeekbarInterval(); // Cleanup for (const cls of ["rl-seekbar-container", "rl-seekbar-bar", "rl-seekbar-native-time"]) { footerPlayer.querySelectorAll(`.${cls}`).forEach((el) => { el.classList.remove(cls); }); } if (!settings.integratedSeekBar) { document.body.classList.remove("rl-integrated-seekbar"); cleanupIntegratedSeekbarDisplay(footerPlayer); return; } document.body.classList.add("rl-integrated-seekbar"); applyIntegratedSeekbarChrome(); const progressBar = footerPlayer.querySelector( '[data-test="progress-bar"]', ) as HTMLElement | null; if (!progressBar) return; const scrubberBar = progressBar.parentElement as HTMLElement | null; const scrubberRow = scrubberBar?.parentElement as HTMLElement | null; if (!scrubberRow) return; scrubberRow.classList.add("rl-seekbar-container"); // Mark the Seekbar (so it's moved in a sec) if (scrubberBar) { scrubberBar.classList.add("rl-seekbar-bar"); } const currentTime = scrubberRow.querySelector( '[data-test="current-time"]', ) as HTMLElement | null; const duration = scrubberRow.querySelector( '[data-test="duration"]', ) as HTMLElement | null; const p1 = currentTime?.parentElement as HTMLElement | null; const p2 = duration?.parentElement as HTMLElement | null; if (!p1 || !p2) return; unwrapIntegratedSeekbarTimes(footerPlayer); p1.classList.add("rl-seekbar-native-time"); p2.classList.add("rl-seekbar-native-time"); p1.style.setProperty("display", "none", "important"); p2.style.setProperty("display", "none", "important"); const combinedNodes = scrubberRow.querySelectorAll(".rl-seekbar-combined-time"); for (let i = 1; i < combinedNodes.length; i++) { combinedNodes[i]?.remove(); } let combined = scrubberRow.querySelector( ".rl-seekbar-combined-time", ) as HTMLElement | null; if (!combined) { combined = document.createElement("span"); combined.className = "rl-seekbar-combined-time"; combined.setAttribute("aria-live", "polite"); scrubberRow.insertBefore(combined, p1); } syncIntegratedSeekbarCombinedTime(footerPlayer); integratedSeekbarIntervalId = setInterval(() => { const fp = document.querySelector( '[data-test="footer-player"]', ) as HTMLElement | null; if (!fp || !settings.integratedSeekBar) { clearIntegratedSeekbarInterval(); return; } syncIntegratedSeekbarCombinedTime(fp); }, 250); }; // Apply integrated seek bar on load (fixes race condition with flush button) applyIntegratedSeekBar(); observe(unloads, '[data-test="footer-player"]', () => { applyPlayerBarTintToElement(); applyIntegratedSeekBar(); }); // Apply styles.css + backdrop keyframes (Now Playing needs these even with Cover Everywhere off) baseStyleTag.css = `${baseStyles}\n${backdropStylesCss}`; // Lyrics glow vars & lyrics-glow.css (when enabled) const updateRadiantLyricsTextGlow = function (): void { const root = document.documentElement; if (settings.lyricsGlowEnabled) { root.style.setProperty("--rl-glow-outer", `${settings.textGlow}px`); root.style.setProperty("--rl-glow-inner", "2px"); lyricsGlowStyleTag.css = lyricsGlow; } else { root.style.setProperty("--rl-glow-outer", "0px"); root.style.setProperty("--rl-glow-inner", "0px"); lyricsGlowStyleTag.css = ""; } root.style.setProperty("--rl-font-scale", `${settings.lyricsFontSize / 100}`); }; // Apply glow state immediately at startup updateRadiantLyricsTextGlow(); // 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(); // Lyrics glow vars & lyrics-glow.css (when enabled) updateRadiantLyricsTextGlow(); }; // MARKER: UI Visibility Control // UI state shared across features let isHidden = false; const EYE_OFF_SVG = ``; const EYE_ON_SVG = ``; const updateButtonStates = function (): void { const toggleButton = document.querySelector(".hide-ui-button") as HTMLElement; if (!toggleButton) return; if (settings.hideUIEnabled) { toggleButton.style.display = ""; const newSvg = isHidden ? EYE_ON_SVG : EYE_OFF_SVG; const label = isHidden ? "Show UI" : "Hide UI"; toggleButton.setAttribute("aria-label", label); toggleButton.setAttribute("title", label); const spanWrapper = toggleButton.querySelector("span"); if (spanWrapper) { const svgClass = spanWrapper.querySelector("svg")?.getAttribute("class") ?? ""; spanWrapper.innerHTML = newSvg; const svg = spanWrapper.querySelector("svg"); if (svg && svgClass) svg.setAttribute("class", svgClass); } } else { toggleButton.style.display = "none"; } }; // Toggle hide/unhide UI const toggleRadiantLyrics = function (): void { const nowPlayingContainer = document.querySelector( '[class*="_nowPlayingContainer"]', ) as HTMLElement; isHidden = !isHidden; updateButtonStates(); if (isHidden) { safeTimeout( unloads, () => { updateRadiantLyricsStyles(); if (nowPlayingContainer) nowPlayingContainer.classList.add("radiant-lyrics-ui-hidden"); document.body.classList.add("radiant-lyrics-ui-hidden"); }, 50, ); } else { if (nowPlayingContainer) nowPlayingContainer.classList.remove("radiant-lyrics-ui-hidden"); document.body.classList.remove("radiant-lyrics-ui-hidden"); safeTimeout( unloads, () => { if (!isHidden) { updateRadiantLyricsStyles(); } }, 500, ); } }; // Create buttons let flushLocked = false; const unlockFlush = (): void => { flushLocked = false; const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement; if (btn) { btn.disabled = false; btn.style.opacity = ""; btn.style.cursor = ""; btn.setAttribute("title", "Flush Lyrics"); btn.setAttribute("aria-label", "Flush Lyrics"); } }; const lockFlush = (): void => { flushLocked = true; const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement; if (btn) { btn.disabled = true; btn.style.opacity = "0.3"; btn.style.cursor = "not-allowed"; } }; const disableFlushNoLyrics = (): void => { flushLocked = true; const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement; if (btn) { btn.disabled = true; btn.style.opacity = "0.3"; btn.style.cursor = "not-allowed"; btn.setAttribute("title", "Track has no lyrics"); btn.setAttribute("aria-label", "Track has no lyrics"); } }; const flushLyrics = async (): Promise => { if (flushLocked) return; const trackInfo = await getTrackInfo(); if (!trackInfo) { trace.msg.err("Flush: could not get track info"); return; } lockFlush(); try { const result = await flushLyricsApi(trackInfo); if (!result.ok) { if (result.notFound) { toast("No lyrics found for this track"); return; } toastErr(`Flush failed (${result.status})`); return; } const data = result.data; const note = data?._flush ?? ""; const needsReload = note.startsWith("Created") || note.startsWith("Updated"); if (note) { toast(note); } else { toast("Lyrics flushed"); } if (needsReload || !note) { cachedLyricsKey = null; cachedLyricsData = null; onTrackChange(); } } catch (err) { toastErr(`Flush error: ${err instanceof Error ? err.message : String(err)}`); } }; const flushBtn = function (): void { const closeButton = document.querySelector( '[data-test="new-now-playing-close"]', ) as HTMLButtonElement; if (!closeButton?.parentElement) return; if (document.querySelector(".flush-lyrics-button")) return; const buttonContainer = closeButton.parentElement; const flushButton = closeButton.cloneNode(false) as HTMLButtonElement; flushButton.className = closeButton.className; flushButton.classList.add("flush-lyrics-button"); flushButton.removeAttribute("data-test"); flushButton.setAttribute("type", "button"); flushButton.setAttribute("aria-label", "Flush Lyrics"); flushButton.setAttribute("title", "Flush Lyrics"); flushButton.disabled = true; flushButton.style.opacity = "0.3"; flushButton.style.cursor = "not-allowed"; const iconSpan = closeButton.querySelector("span"); const iconSvg = closeButton.querySelector("svg"); const spanWrapper = document.createElement("span"); if (iconSpan) spanWrapper.className = iconSpan.className; spanWrapper.setAttribute("aria-hidden", "true"); const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svgEl.setAttribute("viewBox", "0 0 20 20"); if (iconSvg) svgEl.setAttribute("class", iconSvg.className.baseVal); const useEl = document.createElementNS("http://www.w3.org/2000/svg", "use"); useEl.setAttribute("href", "#general__lyrics-sync"); svgEl.appendChild(useEl); spanWrapper.appendChild(svgEl); flushButton.appendChild(spanWrapper); flushButton.onclick = () => flushLyrics(); const hideBtn = buttonContainer.querySelector(".hide-ui-button"); buttonContainer.insertBefore( flushButton, hideBtn ?? closeButton, ); }; const createHideUIButton = function (): void { if (!settings.hideUIEnabled) return; const closeButton = document.querySelector( '[data-test="new-now-playing-close"]', ) as HTMLButtonElement; if (!closeButton?.parentElement) return; if (document.querySelector(".hide-ui-button")) return; const buttonContainer = closeButton.parentElement; const hideUIButton = closeButton.cloneNode(false) as HTMLButtonElement; hideUIButton.className = closeButton.className; hideUIButton.classList.add("hide-ui-button"); hideUIButton.removeAttribute("data-test"); hideUIButton.setAttribute("type", "button"); hideUIButton.setAttribute("aria-label", isHidden ? "Show UI" : "Hide UI"); hideUIButton.setAttribute("title", isHidden ? "Show UI" : "Hide UI"); const iconSpan = closeButton.querySelector("span"); const iconSvg = closeButton.querySelector("svg"); const spanWrapper = document.createElement("span"); if (iconSpan) spanWrapper.className = iconSpan.className; spanWrapper.setAttribute("aria-hidden", "true"); const svgContent = isHidden ? EYE_ON_SVG : EYE_OFF_SVG; spanWrapper.innerHTML = svgContent; const svg = spanWrapper.querySelector("svg"); if (svg && iconSvg) svg.setAttribute("class", iconSvg.className.baseVal); hideUIButton.appendChild(spanWrapper); hideUIButton.onclick = toggleRadiantLyrics; buttonContainer.insertBefore(hideUIButton, closeButton); }; // MARKER: Background Rendering (Kawarp Shader) // cover art URL -> kawarp -> Animated Backdrop <3 let globalSpinningBgStyleTag: StyleTag | null = null; let globalBackgroundContainer: HTMLElement | null = null; let nowPlayingBackgroundContainer: HTMLElement | null = null; let currentCoverSrc: string | null = null; const kawarpLayers: { global: KawarpLayer | null; nowPlaying: KawarpLayer | null; } = { global: null, nowPlaying: null }; const disposeKawarpLayer = (slot: "global" | "nowPlaying"): void => { kawarpLayers[slot]?.dispose(); kawarpLayers[slot] = null; }; /** * Only ever run one shader chain */ // Now Playing view slides = ~1 second (stops frozen backdrop on reveal) [Super WIP] const PAUSE_GRACE_MS = 1500; const pauseHiddenSince: { global: number; nowPlaying: number } = { global: 0, nowPlaying: 0, }; const setLayerRunning = ( slot: "global" | "nowPlaying", layer: KawarpLayer | null, shouldRun: boolean, now: number, ): void => { if (!layer) return; if (shouldRun) { pauseHiddenSince[slot] = 0; layer.setPaused(false); return; } // window hidden [Untested dk why it wouldn't work tho] if (document.hidden) { pauseHiddenSince[slot] = 0; layer.setPaused(true); return; } if (pauseHiddenSince[slot] === 0) pauseHiddenSince[slot] = now; if (now - pauseHiddenSince[slot] >= PAUSE_GRACE_MS) layer.setPaused(true); }; // Cached so the 200ms tick skips a document-wide query. let cachedNowPlayingPanel: HTMLElement | null = null; // On screen, not just mounted. const artPanelVisible = (panel: HTMLElement): boolean => { if (typeof panel.checkVisibility === "function") { return panel.checkVisibility({ checkVisibilityCSS: true }); } return getComputedStyle(panel).visibility !== "hidden"; }; const syncBackdropActivity = (): void => { // Stock backdrop keeps current (no shader loop) if (stockContainer) applyStockCoverEverywhere(); const nowPlaying = kawarpLayers.nowPlaying; const global = kawarpLayers.global; const nowPlayingVisible = nowPlaying?.isVisible() ?? false; const now = Date.now(); // Ramp shader down when playback pauses (maybe not the best way but it does work sooo) const playing = settings.backdropPlaybackReactive ? reduxPlaybackIsPlaying() : true; nowPlaying?.setPlaying(playing); global?.setPlaying(playing); setLayerRunning( "nowPlaying", nowPlaying, !document.hidden && nowPlayingVisible && playing, now, ); // keep animating underneath for whole slide (stops freeze before hidden) [Super WIP] setLayerRunning( "global", global, !document.hidden && !nowPlayingVisible && playing && (global?.isVisible() ?? false), now, ); // Only runs while the Now Playing view is visible. Guarded: this ticks 5x/second. if (animatedArtworkLayer) { if (!cachedNowPlayingPanel?.isConnected) { cachedNowPlayingPanel = document.querySelector( '[class*="_nowPlayingContainer"]', ) as HTMLElement | null; } animatedArtworkLayer.setNowPlayingVisible( !!cachedNowPlayingPanel && artPanelVisible(cachedNowPlayingPanel), ); } }; /** cover art @ resolution worth sampling */ const getCoverArtSrc = (): string | null => { const targetRes = "160x160"; const img = document.querySelector( '[data-test="current-media-imagery"] img', ) as HTMLImageElement | null; if (img?.src) return img.src.replace(/\d+x\d+/, targetRes); const video = document.querySelector( '[data-test="current-media-imagery"] video', ) as HTMLVideoElement | null; const poster = video?.getAttribute("poster"); return poster ? poster.replace(/\d+x\d+/, targetRes) : null; }; /** Mount/remount kawarp canvas */ const ensureLayer = ( slot: "global" | "nowPlaying", container: HTMLElement, zIndex: string, ): KawarpLayer | null => { let layer = kawarpLayers[slot]; if (layer && !layer.isMountedIn(container)) { // Tidal rebuilt the container disposeKawarpLayer(slot); layer = null; } if (!layer) { layer = new KawarpLayer(container, slot, zIndex); kawarpLayers[slot] = layer; } return layer; }; // Apply backdrop across whole app (cover everywhere) const applyGlobalBackdrop = (coverArtImageSrc: string): void => { if (!settings.CoverEverywhere) { cleanUpGlobalBackground(); return; } const appContainer = document.querySelector( '[data-test="main"]', ) as HTMLElement | null; if (!appContainer) return; if (!globalSpinningBgStyleTag) { globalSpinningBgStyleTag = new StyleTag( "RadiantLyrics-global-backdrop", unloads, coverEverywhereCss, ); } if (!globalBackgroundContainer?.isConnected) { globalBackgroundContainer = document.createElement("div"); globalBackgroundContainer.className = "global-background-container"; appContainer.appendChild(globalBackgroundContainer); } ensureLayer("global", globalBackgroundContainer, "-1")?.apply( coverArtImageSrc, ); }; // Apply backdrop in Now Playing const applyNowPlayingBackdrop = (coverArtImageSrc: string): void => { const nowPlayingContainerElement = document.querySelector( '[class*="_nowPlayingContainer"]', ) as HTMLElement | null; if (!nowPlayingContainerElement) return; if ( !nowPlayingBackgroundContainer?.isConnected || !nowPlayingContainerElement.contains(nowPlayingBackgroundContainer) ) { nowPlayingBackgroundContainer = document.createElement("div"); nowPlayingBackgroundContainer.className = "now-playing-background-container"; nowPlayingContainerElement.appendChild(nowPlayingBackgroundContainer); } ensureLayer("nowPlaying", nowPlayingBackgroundContainer, "0")?.apply( coverArtImageSrc, ); }; // Feed cover art into both backdrops function updateCoverArtBackground(method: number = 0): void { if (method === 1) { safeTimeout( unloads, () => { updateCoverArtBackground(); }, 2000, ); return; } // Teardown <3 document.body.classList.toggle("rl-backdrop-active", settings.backdropEnabled); if (!settings.backdropEnabled) { cleanUpDynamicArt(); // Cover Everywhere still applies (Stock Tidal Backdrop) if (settings.CoverEverywhere) applyStockCoverEverywhere(); else cleanUpStockCoverEverywhere(); return; } cleanUpStockCoverEverywhere(); const coverArtImageSrc = getCoverArtSrc(); if (!coverArtImageSrc) { cleanUpDynamicArt(); return; } currentCoverSrc = coverArtImageSrc; applyGlobalBackdrop(coverArtImageSrc); applyNowPlayingBackdrop(coverArtImageSrc); syncBackdropActivity(); } // MARKER: Stock Backdrop (Cover Everywhere without the shader) const STOCK_MARKUP = '
' + '' + '
'; const STOCK_ART_RES = "320x320"; let stockBgStyleTag: StyleTag | null = null; let stockContainer: HTMLElement | null = null; const applyStockCoverEverywhere = (): void => { // Same sheet the shader uses stockBgStyleTag ??= new StyleTag( "RadiantLyrics-stock-backdrop", unloads, coverEverywhereCss, ); if (!stockContainer?.isConnected) { // Sweep first cleanUpStockContainers(); stockContainer = document.createElement("div"); stockContainer.className = "rl-stock-background-container"; stockContainer.innerHTML = STOCK_MARKUP; document.body.appendChild(stockContainer); } const source = document.querySelector( '[data-test="footer-player"] [data-test="current-media-imagery"] img', ); const layers = [ ...stockContainer.querySelectorAll(".rl-stock-art"), ]; // Which layer is showing lives in DOM (no state to track) const shown = layers.find((l) => l.classList.contains("rl-stock-art-active")) ?? layers[1]; const next = layers.find((l) => l !== shown); const cover = source?.src.replace(/\d+x\d+/, STOCK_ART_RES); if (next && cover && cover !== shown.src) { next.src = cover; // Swap only once decoded (otherwise the crossfade reveals a blank layer) const reveal = () => { next.classList.add("rl-stock-art-active"); shown.classList.remove("rl-stock-art-active"); }; if (next.complete) reveal(); else next.addEventListener("load", reveal, { once: true }); } // Tidal halts its own spin while paused stockContainer.classList.toggle( "rl-stock-paused", !reduxPlaybackIsPlaying(), ); }; const cleanUpStockContainers = (): void => { document .querySelectorAll(".rl-stock-background-container") .forEach((el) => el.remove()); }; const cleanUpStockCoverEverywhere = (): void => { stockContainer = null; cleanUpStockContainers(); stockBgStyleTag?.remove(); stockBgStyleTag = null; }; const cleanUpGlobalBackground = function (): void { // Release WebGL context before canvas is orphaned (otherwise death occurs) disposeKawarpLayer("global"); globalBackgroundContainer?.remove(); globalBackgroundContainer = null; if (globalSpinningBgStyleTag) { globalSpinningBgStyleTag.remove(); globalSpinningBgStyleTag = null; } }; const cleanUpDynamicArt = function (): void { disposeKawarpLayer("nowPlaying"); nowPlayingBackgroundContainer?.remove(); nowPlayingBackgroundContainer = null; currentCoverSrc = null; // more more teardown <3 document .querySelectorAll(".now-playing-background-container") .forEach((el) => { el.remove(); }); cleanUpGlobalBackground(); }; // Re-render both backdrops when settings change const updateRadiantLyricsBackdrop = function (): void { if (settings.performanceMode) { document.body.classList.add("performance-mode"); } else { document.body.classList.remove("performance-mode"); } // Gates the rules that hide Tidal now-playing background document.body.classList.toggle("rl-backdrop-active", settings.backdropEnabled); if (!settings.backdropEnabled) { cleanUpDynamicArt(); // Cover Everywhere still applies (Stock Tidal Backdrop) if (settings.CoverEverywhere) applyStockCoverEverywhere(); else cleanUpStockCoverEverywhere(); return; } cleanUpStockCoverEverywhere(); if (!settings.CoverEverywhere) cleanUpGlobalBackground(); if (currentCoverSrc) { if (settings.CoverEverywhere) applyGlobalBackdrop(currentCoverSrc); applyNowPlayingBackdrop(currentCoverSrc); syncBackdropActivity(); } else { updateCoverArtBackground(); } }; // MARKER: Animated Artwork (live artwork on the artwork tile) let animatedArtworkLayer: AnimatedArtworkLayer | null = null; let lastArtworkKey: string | null = null; let lastArtworkTile: Element | null = null; /** * Request key for an animated-artwork lookup: album-scoped when an album is * present (skips within one are free). A result may still be scoped tighter — * see fetchAnimatedArtwork() — so skipping compares lastArtworkKey, the * effective scope, not this request key. */ const artworkKey = (t: TrackInfo): string => t.album && t.album.trim() !== "" ? `${t.artist}\u0000${t.album.trim()}` : `${t.artist}\u0000\u0000${t.title}`; // Load + show animated art for the current (or given) track const updateAnimatedArtwork = async ( target?: TrackInfo | null, rehost = false, ): Promise => { if (!settings.animatedArtwork) { animatedArtworkLayer?.dispose(); animatedArtworkLayer = null; lastArtworkKey = null; lastArtworkTile = null; return; } if (!animatedArtworkLayer) animatedArtworkLayer = new AnimatedArtworkLayer(); target ??= await getTrackInfo(); if (!target) return; // Re-check after the await; another caller may have claimed this already. if (!settings.animatedArtwork || !animatedArtworkLayer) return; const key = artworkKey(target); // Free skip: the shown artwork's scope covers this track — it was matched // by album (album key), or it is the same track again (title key). if ( key === lastArtworkKey || artworkTitleKey(target.artist, target.title) === lastArtworkKey ) { // Same artwork, but the view may have remounted underneath us. if (rehost) animatedArtworkLayer.reattach(); return; } lastArtworkKey = key; const res = await animatedArtworkLayer.load( key, target.title, target.artist, target.album, target.isrc, ); // Adopt the effective scope — a title-matched result (or a miss) must not // stand in for the album's other tracks — unless a newer trigger claimed. if (lastArtworkKey === key) lastArtworkKey = res.key; // No tile on screen yet; clear the key so the tile observer retries. if (!res.done && lastArtworkKey === res.key) lastArtworkKey = null; }; (window as any).updateAnimatedArtwork = updateAnimatedArtwork; (window as any).updateAnimatedArtworkRate = (): void => animatedArtworkLayer?.refresh(); // Make these functions available globally so Settings can call them (window as any).updateRadiantLyricsStyles = updateRadiantLyricsStyles; (window as any).updateRadiantLyricsBackdrop = updateRadiantLyricsBackdrop; (window as any).updateRadiantLyricsTextGlow = updateRadiantLyricsTextGlow; (window as any).updateRadiantLyricsPlayerBarTint = updateRadiantLyricsPlayerBarTint; (window as any).updateQualityProgressColor = applyQualityProgressColor; (window as any).updateIntegratedSeekBar = applyIntegratedSeekBar; // halts the RAF loop (drives vissibility stuff) document.addEventListener("visibilitychange", syncBackdropActivity); // Tidal toggles the Now Playing view with visibility (dk why they don't unmount but oki) safeInterval(unloads, syncBackdropActivity, 200); // React the instant the slide starts/ends observe(unloads, '[class*="_nowPlayingContainer"]', (container) => { for (const event of ["transitionrun", "transitionstart", "transitionend"]) { container.addEventListener(event, syncBackdropActivity); } }); // Init performance mode if (settings.performanceMode) { document.body.classList.add("performance-mode"); } // Init text glow updateRadiantLyricsTextGlow(); // Init global background updateCoverArtBackground(1); // Deferred: getTrackInfo() is declared further down and would be uninitialised. queueMicrotask(() => void updateAnimatedArtwork()); // Cleanups unloads.add(() => { animatedArtworkLayer?.dispose(); animatedArtworkLayer = null; cachedNowPlayingPanel = null; cleanUpDynamicArt(); cleanUpStockCoverEverywhere(); document.body.classList.remove("rl-backdrop-active"); // 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"); footerPlayer.style.removeProperty("--rl-integrated-seekbar-top-radius"); } // Clean up integrated seekbar document.body.classList.remove("rl-integrated-seekbar"); clearIntegratedSeekbarInterval(); document .querySelectorAll(".rl-seekbar-container, .rl-seekbar-bar, .rl-seekbar-native-time") .forEach((el) => { el.classList.remove( "rl-seekbar-container", "rl-seekbar-bar", "rl-seekbar-native-time", ); }); if (footerPlayer) { cleanupIntegratedSeekbarDisplay(footerPlayer); } // Clean up action buttons for (const sel of [".hide-ui-button", ".flush-lyrics-button"]) { const btn = document.querySelector(sel); if (btn?.parentNode) btn.parentNode.removeChild(btn); } // Clean up lyrics menu elements document .querySelectorAll(".rl-lyrics-menu-trigger, .rl-lyrics-menu") .forEach((el) => { el.remove(); }); // even more more teardown <3 cleanUpDynamicArt(); }); // MARKER: Lyrics Menu (dropdown on the lyrics toggle) const LYRICS_MENU_ICON = ''; const applyLyricsMenuIcon = (): void => { const trigger = document.querySelector( ".rl-lyrics-menu-trigger", ) as HTMLElement; if (!trigger) return; trigger.innerHTML = LYRICS_MENU_ICON; trigger.style.paddingLeft = "5px"; }; // 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"}`, ); }, // Playback clock calibration in ms (i assume Lunar will break playback one day since MPV is wierd so i added this for that) get offset() { return settings.lyricsOffsetMs; }, set offset(value: number) { const ms = Math.round(Number(value)); if (!Number.isFinite(ms)) return; settings.lyricsOffsetMs = ms; console.log( `[Radiant Lyrics] Lyrics offset: ${ms > 0 ? "+" : ""}${ms} ms (${ms === 0 ? "no shift" : ms > 0 ? "earlier" : "later"})`, ); }, // 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 (isWordMode()) { 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}`, ); }, }; let lyricsMenuEl: HTMLElement | null = null; let lyricsMenuOpen = false; const positionDropdown = (): void => { if (!lyricsMenuEl) return; const toggle = document.querySelector('[data-test="toggle-lyrics"]') as HTMLElement; if (!toggle) return; const rect = toggle.getBoundingClientRect(); lyricsMenuEl.style.top = `${rect.bottom}px`; lyricsMenuEl.style.left = `${rect.left}px`; lyricsMenuEl.style.width = `${rect.width}px`; lyricsMenuEl.style.display = "block"; }; const openLyricsMenu = (toggle: HTMLElement): void => { lyricsMenuOpen = true; document.body.classList.add("rl-dropdown-open"); let positioned = false; const onWidened = (e: TransitionEvent) => { if (e.propertyName !== "min-width") return; toggle.removeEventListener("transitionend", onWidened); if (!positioned) { positioned = true; positionDropdown(); } }; toggle.addEventListener("transitionend", onWidened as EventListener); safeTimeout(unloads, () => { toggle.removeEventListener("transitionend", onWidened as EventListener); if (!positioned) { positioned = true; positionDropdown(); } }, 200); }; const closeLyricsMenu = (): void => { lyricsMenuOpen = false; document.body.classList.remove("rl-dropdown-open"); if (lyricsMenuEl) lyricsMenuEl.style.display = "none"; }; const ensureLyricsMenu = (): HTMLElement => { if (lyricsMenuEl) return lyricsMenuEl; const dropdown = document.createElement("div"); dropdown.className = "rl-lyrics-menu"; dropdown.style.display = "none"; dropdown.innerHTML = `
Romanization
AI Syllables WIP
`; // AI Generated Syllables (Radiant AI [RL API]) const synthCheckbox = dropdown.querySelector( 'input[data-setting="aiSyllables"]', ) as HTMLInputElement; synthCheckbox.addEventListener("change", () => { settings.aiSyllables = synthCheckbox.checked; (window as any).updateAiSyllablesSetting?.(synthCheckbox.checked); updateAiSyllablesFromSettings(); }); // Romanization (mirrors the Romanize Lyrics setting) const romanizeCheckbox = dropdown.querySelector( 'input[data-setting="romanizeLyrics"]', ) as HTMLInputElement; romanizeCheckbox.addEventListener("change", () => { settings.romanizeLyrics = romanizeCheckbox.checked; (window as any).updateRomanizeLyricsSetting?.(romanizeCheckbox.checked); updateRomanizeLyricsFromSettings(); }); 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(); }); } document.body.appendChild(dropdown); lyricsMenuEl = dropdown; const outsideHandler = (e: MouseEvent): void => { const trigger = document.querySelector(".rl-lyrics-menu-trigger"); if ( (!trigger || !trigger.contains(e.target as Node)) && !dropdown.contains(e.target as Node) ) { closeLyricsMenu(); } }; document.addEventListener("click", outsideHandler); unloads.add(() => { document.removeEventListener("click", outsideHandler); document.body.classList.remove("rl-dropdown-open"); dropdown.remove(); lyricsMenuEl = null; lyricsMenuOpen = false; }); return dropdown; }; const createLyricsMenu = (): void => { const lyricsToggle = document.querySelector( '[data-test="toggle-lyrics"]', ) as HTMLElement; if (!lyricsToggle) return; if (lyricsToggle.querySelector(".rl-lyrics-menu-trigger")) return; ensureLyricsMenu(); const trigger = document.createElement("div"); trigger.className = "rl-lyrics-menu-trigger"; trigger.setAttribute("title", "Lyrics Options"); trigger.innerHTML = LYRICS_MENU_ICON; for (const evtName of [ "pointerdown", "pointerup", "mousedown", "mouseup", ] as const) { trigger.addEventListener( evtName, (e: Event) => { e.stopPropagation(); }, true, ); } trigger.addEventListener( "click", (e: MouseEvent) => { e.stopPropagation(); const isActive = lyricsToggle.getAttribute("aria-pressed") === "true"; if (!isActive) { lyricsToggle.click(); safeTimeout(unloads, () => openLyricsMenu(lyricsToggle), 150); return; } if (lyricsMenuOpen) { closeLyricsMenu(); } else { openLyricsMenu(lyricsToggle); } }, true, ); lyricsToggle.appendChild(trigger); if (lyricsMenuOpen) { positionDropdown(); } }; // Nav for injected lyrics tab const syncNativeLyricsAvailability = (): void => { if (!syntheticNativeLyrics) return; notifyNativeLyricsStateChanged(); }; // Track change sequencing (used by onTrackChange) let isTrackChangeRunning = false; let trackChangeRunSeq = 0; // Observer: create dropdown when lyrics toggle appears & detect track changes function setupLyricsMenuObserver(): void { // Create dropdown if lyrics toggle already exists const existing = document.querySelector('[data-test="toggle-lyrics"]'); if (existing && !existing.querySelector(".rl-lyrics-menu-trigger")) { createLyricsMenu(); } // Re-create dropdown whenever lyrics toggle reappears observe(unloads, '[data-test="toggle-lyrics"]', () => { const toggle = document.querySelector('[data-test="toggle-lyrics"]'); syncNativeLyricsAvailability(); if (toggle && !toggle.querySelector(".rl-lyrics-menu-trigger")) { createLyricsMenu(); } }); // The tile only exists once the view is open, so load from here too. This fires // on nearly every DOM change, so bail synchronously unless the tile is new. observe(unloads, ART_TILE_SELECTOR, (tile) => { // Identity only; also stops us reacting to our own