diff --git a/plugins/audio-visualizer-luna/src/index.ts b/plugins/audio-visualizer-luna/src/index.ts index 0a83bb3..2f63385 100644 --- a/plugins/audio-visualizer-luna/src/index.ts +++ b/plugins/audio-visualizer-luna/src/index.ts @@ -7,17 +7,11 @@ import visualizerStyles from "file://styles.css?minify"; export const { trace } = Tracer("[Audio Visualizer]"); -// Helper function for consistent logging const log = (message: string) => console.log(`[Audio Visualizer] ${message}`); -const warn = (message: string) => console.warn(`[Audio Visualizer] ${message}`); -const error = (message: string) => - console.error(`[Audio Visualizer] ${message}`); export { Settings }; -// Basic config with settings const config = { enabled: true, - position: "left" as "left" | "right", width: 200, height: 40, get barCount() { @@ -31,7 +25,6 @@ const config = { }, sensitivity: 1.5, smoothing: 0.8, - visualizerType: "bars" as "bars" | "waveform" | "circular", }; // Clean up resources @@ -49,10 +42,15 @@ let animationId: number | null = null; let currentAudioElement: HTMLAudioElement | null = null; let isSourceConnected: boolean = false; -// Canvas and container elements -let visualizerContainer: HTMLDivElement | null = null; -let canvas: HTMLCanvasElement | null = null; -let canvasContext: CanvasRenderingContext2D | null = null; +// Each placement gets its own container/canvas/context +interface VisualizerSlot { + container: HTMLDivElement | null; + canvas: HTMLCanvasElement | null; + ctx: CanvasRenderingContext2D | null; +} + +const navSlot: VisualizerSlot = { container: null, canvas: null, ctx: null }; +const npSlot: VisualizerSlot = { container: null, canvas: null, ctx: null }; // Find the audio element - this is a bit of a hack but it works const findAudioElement = (): HTMLAudioElement | null => { @@ -140,10 +138,7 @@ const initializeAudioVisualizer = async (): Promise => { audioContext.resume().catch(() => {}); // Fire and forget } - // Create UI only if it doesn't exist - if (!visualizerContainer) { - createVisualizerUI(); - } + createVisualizerUI(); // Start animation only if not already running if (!animationId) { @@ -155,120 +150,116 @@ const initializeAudioVisualizer = async (): Promise => { } }; -// Create the visualizer UI container and canvas -const createVisualizerUI = (): void => { - // Remove existing visualizer if it exists - removeVisualizerUI(); +const makeSlotElements = (): { container: HTMLDivElement; canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null => { + const container = document.createElement("div"); + container.className = "audio-visualizer-container"; + container.style.cssText = ` + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + padding: 4px; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + `; - if (!config.enabled) return; + const cvs = document.createElement("canvas"); + cvs.width = config.width; + cvs.height = config.height; + cvs.style.cssText = ` + width: ${config.width}px; + height: ${config.height}px; + border-radius: 4px; + `; - // Find the search bar - const searchField = document.querySelector( - 'input[class*="_searchField"]', - ) as HTMLInputElement; - if (!searchField) { - warn("Search field not found"); - return; - } - - const searchContainer = searchField.parentElement; - if (!searchContainer) { - warn("Search container not found"); - return; - } - - // Create visualizer container - visualizerContainer = document.createElement("div"); - visualizerContainer.id = "audio-visualizer-container"; - visualizerContainer.style.cssText = ` - display: flex; - align-items: center; - justify-content: center; - margin-${config.position === "left" ? "right" : "left"}: 12px; - background: rgba(0, 0, 0, 0.2); - border-radius: 8px; - padding: 4px; - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - `; - - // Create canvas - canvas = document.createElement("canvas"); - canvas.width = config.width; - canvas.height = config.height; - canvas.style.cssText = ` - width: ${config.width}px; - height: ${config.height}px; - border-radius: 4px; - `; - - visualizerContainer.appendChild(canvas); - canvasContext = canvas.getContext("2d"); - - // Insert visualizer next to search bar - if (config.position === "left") { - searchContainer.parentElement?.insertBefore( - visualizerContainer, - searchContainer, - ); - } else { - searchContainer.parentElement?.insertBefore( - visualizerContainer, - searchContainer.nextSibling, - ); - } + container.appendChild(cvs); + const ctx = cvs.getContext("2d"); + if (!ctx) return null; + return { container, canvas: cvs, ctx }; +}; + +const clearSlot = (slot: VisualizerSlot): void => { + slot.container?.remove(); + slot.container = null; + slot.canvas = null; + slot.ctx = null; +}; + +const ensureNavSlot = (): void => { + if (navSlot.container?.isConnected) return; + clearSlot(navSlot); + + const searchField = document.querySelector('input[class*="_searchField"]') as HTMLInputElement; + if (!searchField) return; + const searchContainer = searchField.parentElement; + if (!searchContainer?.parentElement) return; + + const els = makeSlotElements(); + if (!els) return; + els.container.style.marginRight = "12px"; + Object.assign(navSlot, els); + searchContainer.parentElement.insertBefore(els.container, searchContainer); +}; + +const ensureNpSlot = (): void => { + if (npSlot.container?.isConnected) return; + clearSlot(npSlot); + + const artistInfo = document.querySelector('[data-test="artist-info"]'); + if (!artistInfo) return; + const leftContent = artistInfo.parentElement; + if (!leftContent) return; + + const els = makeSlotElements(); + if (!els) return; + els.container.style.marginLeft = "12px"; + Object.assign(npSlot, els); + leftContent.insertBefore(els.container, artistInfo.nextSibling); +}; + +const createVisualizerUI = (): void => { + if (!config.enabled) return; + ensureNavSlot(); + ensureNpSlot(); }; -// Remove visualizer UI const removeVisualizerUI = (): void => { - if (visualizerContainer) { - visualizerContainer.remove(); - visualizerContainer = null; - canvas = null; - canvasContext = null; - } + clearSlot(navSlot); + clearSlot(npSlot); }; // Animation loop for rendering visualizer const animate = (): void => { - if (!canvasContext || !canvas) { - animationId = null; + // Re-attach slots that got disconnected from the DOM + createVisualizerUI(); + + const slots = [navSlot, npSlot].filter(s => s.ctx && s.canvas); + if (slots.length === 0) { + animationId = requestAnimationFrame(animate); return; } - // Update canvas color in case it changed - canvasContext.fillStyle = config.color; - canvasContext.strokeStyle = config.color; - - // Check if we have real audio data - this might not be needed but its a good idea let hasRealAudio = false; if (analyser && dataArray) { analyser.getByteFrequencyData(dataArray); - // Check if there's actual audio signal (not just silence) const avgVolume = dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length; - hasRealAudio = avgVolume > 5; // Threshold for detecting actual audio + hasRealAudio = avgVolume > 5; } - // Clear canvas - canvasContext.clearRect(0, 0, canvas.width, canvas.height); + for (const slot of slots) { + const ctx = slot.ctx!; + const cvs = slot.canvas!; + ctx.fillStyle = config.color; + ctx.strokeStyle = config.color; + ctx.clearRect(0, 0, cvs.width, cvs.height); - if (hasRealAudio && analyser && dataArray) { - // Draw real audio visualization - switch (config.visualizerType) { - case "bars": // Is implemented YAYYY (default) - drawBars(); - break; - case "waveform": // Not implemented yet - drawWaveform(); - break; - case "circular": // Not implemented yet - drawCircular(); - break; + if (hasRealAudio && analyser && dataArray) { + drawBars(ctx, cvs); + } else { + drawScrollingWave(ctx, cvs); } - } else { - // Draw cool scrolling wave effect when no audio - drawScrollingWave(); } animationId = requestAnimationFrame(animate); @@ -291,67 +282,54 @@ const drawRoundedRect = ( ctx.fill(); }; -// Draw scrolling wave effect when no audio is detected -const drawScrollingWave = (): void => { - if (!canvasContext || !canvas) return; - - waveTime += 0.05; // Speed of wave animation +const drawScrollingWave = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => { + waveTime += 0.05 / [navSlot, npSlot].filter(s => s.ctx).length; const barCount = config.barCount; - const barWidth = canvas.width / barCount; - const maxHeight = canvas.height * 0.6; + const barWidth = cvs.width / barCount; + const maxHeight = cvs.height * 0.6; - canvasContext.fillStyle = config.color; + ctx.fillStyle = config.color; for (let i = 0; i < barCount; i++) { - // Create a sine wave that scrolls back and forth const x = i / barCount; const wave1 = Math.sin(x * Math.PI * 2 + waveTime) * 0.3; const wave2 = Math.sin(x * Math.PI * 4 + waveTime * 1.3) * 0.2; const wave3 = Math.sin(x * Math.PI * 6 + waveTime * 0.7) * 0.1; - - // Combine waves for complex pattern - const combinedWave = (wave1 + wave2 + wave3 + 1) / 2; // Normalize to 0-1 - - // Add a traveling wave effect + const combinedWave = (wave1 + wave2 + wave3 + 1) / 2; const travelWave = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5; - - // Final height calculation - const barHeight = maxHeight * combinedWave * travelWave * 0.8 + 2; // Minimum height of 2px + const barHeight = maxHeight * combinedWave * travelWave * 0.8 + 2; const xPos = i * barWidth; - const yPos = (canvas.height - barHeight) / 2; + const yPos = (cvs.height - barHeight) / 2; - // Draw rounded or square bars based on setting if (config.barRounding) { - drawRoundedRect(canvasContext, xPos, yPos, barWidth - 1, barHeight, 2); + drawRoundedRect(ctx, xPos, yPos, barWidth - 1, barHeight, 2); } else { - canvasContext.fillRect(xPos, yPos, barWidth - 1, barHeight); + ctx.fillRect(xPos, yPos, barWidth - 1, barHeight); } } }; -// Draw frequency bars - default -const drawBars = (): void => { - if (!canvasContext || !dataArray || !canvas) return; +const drawBars = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => { + if (!dataArray) return; - const barWidth = canvas.width / config.barCount; - const heightScale = canvas.height / 255; + const barWidth = cvs.width / config.barCount; + const heightScale = cvs.height / 255; - canvasContext.fillStyle = config.color; + ctx.fillStyle = config.color; for (let i = 0; i < config.barCount; i++) { const dataIndex = Math.floor(i * (dataArray.length / config.barCount)); const barHeight = dataArray[dataIndex] * config.sensitivity * heightScale; const x = i * barWidth; - const y = canvas.height - barHeight; + const y = cvs.height - barHeight; - // Draw rounded or square bars based on setting if (config.barRounding) { - drawRoundedRect(canvasContext, x, y, barWidth - 1, barHeight, 2); + drawRoundedRect(ctx, x, y, barWidth - 1, barHeight, 2); } else { - canvasContext.fillRect(x, y, barWidth - 1, barHeight); + ctx.fillRect(x, y, barWidth - 1, barHeight); } } }; @@ -412,23 +390,23 @@ const drawBars = (): void => { // } // }; -// Update visualizer settings const updateAudioVisualizer = (): void => { if (analyser) { - // use a fixed size that provides enough frequency bins - analyser.fftSize = 512; // Fixed power of 2 - important + analyser.fftSize = 512; analyser.smoothingTimeConstant = config.smoothing; dataArray = new Uint8Array(analyser.frequencyBinCount); } - if (canvas) { - canvas.width = config.width; - canvas.height = config.height; - canvas.style.width = `${config.width}px`; - canvas.style.height = `${config.height}px`; + for (const slot of [navSlot, npSlot]) { + if (slot.canvas) { + slot.canvas.width = config.width; + slot.canvas.height = config.height; + slot.canvas.style.width = `${config.width}px`; + slot.canvas.style.height = `${config.height}px`; + } } - // Recreate UI if position changed + removeVisualizerUI(); createVisualizerUI(); }; diff --git a/plugins/audio-visualizer-luna/src/styles.css b/plugins/audio-visualizer-luna/src/styles.css index 2835ea2..e185565 100644 --- a/plugins/audio-visualizer-luna/src/styles.css +++ b/plugins/audio-visualizer-luna/src/styles.css @@ -1,50 +1,40 @@ -/* Audio Visualizer CSS - Only applies to the Visualizer */ +/* Audio Visualizer CSS */ -#audio-visualizer-container { +.audio-visualizer-container { transition: all 0.3s ease-in-out; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.1); + animation: av-fadeIn 0.5s ease-out; } -#audio-visualizer-container:hover { +.audio-visualizer-container:hover { transform: scale(1.02); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); } -#audio-visualizer-container canvas { +.audio-visualizer-container canvas { display: block; transition: all 0.3s ease-in-out; } /* Responsive adjustments */ @media (max-width: 768px) { - #audio-visualizer-container { + .audio-visualizer-container { margin: 4px; padding: 2px; } - #audio-visualizer-container canvas { + .audio-visualizer-container canvas { max-width: 150px; max-height: 30px; } } -/* Where to put the thingy */ -[class*="_searchField"] { - transition: all 0.3s ease-in-out; -} - -[data-type="search-field"] { - min-width: 220px !important; -} - -/* Shadow when active - doesnt seem to only apply when active but thats better */ -#audio-visualizer-container.active { +.audio-visualizer-container.active { box-shadow: 0 0 20px rgba(255, 255, 255, 0.3); } -/* Fade in animation */ -@keyframes fadeIn { +@keyframes av-fadeIn { from { opacity: 0; transform: scale(0.8); @@ -55,6 +45,6 @@ } } -#audio-visualizer-container { - animation: fadeIn 0.5s ease-out; +[data-type="search-field"] { + min-width: 220px !important; } diff --git a/plugins/colorama-lyrics-luna/src/Settings.tsx b/plugins/colorama-lyrics-luna/src/Settings.tsx index 5b8e408..60be903 100644 --- a/plugins/colorama-lyrics-luna/src/Settings.tsx +++ b/plugins/colorama-lyrics-luna/src/Settings.tsx @@ -8,53 +8,24 @@ declare global { } } -// Define a typed onChange signature for the switch type SwitchChangeHandler = ( event: React.ChangeEvent | null, checked: boolean, ) => void; -export type ColoramaMode = - | "single" - | "gradient-experimental" - | "cover" - | "cover-gradient"; - export const settings = await ReactiveStore.getPluginStorage("ColoramaLyrics", { enabled: true, - mode: "single" as ColoramaMode, - // Store colors as RGB hex (#RRGGBB) and opacity separately (0-100) singleColor: "#FFFFFF", singleAlpha: 100, - gradientStart: "#FFFFFF", - gradientStartAlpha: 100, - gradientEnd: "#AAFFFF", - gradientEndAlpha: 100, - gradientAngle: 0, customColors: [] as string[], excludeInactive: false, }); export const Settings = () => { - // const [enabled, setEnabled] = React.useState(settings.enabled); - const [mode, setMode] = React.useState(settings.mode); const [singleColor, setSingleColor] = React.useState(settings.singleColor); const [singleAlpha, setSingleAlpha] = React.useState( settings.singleAlpha ?? 100, ); - const [gradientStart, setGradientStart] = React.useState( - settings.gradientStart, - ); - const [gradientStartAlpha, setGradientStartAlpha] = React.useState( - settings.gradientStartAlpha ?? 100, - ); - const [gradientEnd, setGradientEnd] = React.useState(settings.gradientEnd); - const [gradientEndAlpha, setGradientEndAlpha] = React.useState( - settings.gradientEndAlpha ?? 100, - ); - const [gradientAngle, setGradientAngle] = React.useState( - settings.gradientAngle, - ); const [customInput, setCustomInput] = React.useState(settings.singleColor); const [customColors, setCustomColors] = React.useState(settings.customColors); const [showPicker, setShowPicker] = React.useState(false); @@ -63,9 +34,6 @@ export const Settings = () => { const [excludeInactive, setExcludeInactive] = React.useState( settings.excludeInactive, ); - const [activeEndpoint, setActiveEndpoint] = React.useState< - "single" | "start" | "end" - >("single"); const AnySwitch = LunaSwitchSetting as unknown as React.ComponentType<{ title: string; desc?: string; @@ -73,28 +41,23 @@ export const Settings = () => { onChange: SwitchChangeHandler; }>; - // Helper for HEX normalization const normalizeToRGB = ( hex: string, fallback: string = "#FFFFFF", ): string => { let v = hex.trim().toLowerCase(); if (!v.startsWith("#")) v = `#${v}`; - // #rgb or #rgba -> expand if (/^#([0-9a-f]{3,4})$/.test(v)) { const m = v.slice(1); const r = m[0]; const g = m[1]; const b = m[2]; - // ignore alpha if provided (#rgba) return `#${r}${r}${g}${g}${b}${b}`.toUpperCase(); } - // #aarrggbb -> strip alpha if (/^#([0-9a-f]{8})$/.test(v)) { const rrggbb = v.slice(3); return `#${rrggbb}`.toUpperCase(); } - // #rrggbb if (/^#([0-9a-f]{6})$/.test(v)) return v.toUpperCase(); return fallback; }; @@ -121,8 +84,7 @@ export const Settings = () => { "#1976D2", ]; - const openPicker = (endpoint: "single" | "start" | "end" = "single") => { - setActiveEndpoint(endpoint); + const openPicker = () => { setShowPicker(true); setShouldRender(true); setTimeout(() => setIsAnimatingIn(true), 10); @@ -140,22 +102,10 @@ export const Settings = () => { const applyCustomInputColor = (raw: string, updateInput: boolean): void => { const trimmed = raw.trim(); if (!hexColorRegex.test(trimmed)) return; - if (mode === "single") { - const next = normalizeToRGB(trimmed); - settings.singleColor = next; - setSingleColor(next); - if (updateInput) setCustomInput(next); - } else if (mode === "gradient-experimental") { - const next = normalizeToRGB(trimmed); - if (activeEndpoint === "end") { - settings.gradientEnd = next; - setGradientEnd(next); - } else { - settings.gradientStart = next; - setGradientStart(next); - } - if (updateInput) setCustomInput(next); - } + const next = normalizeToRGB(trimmed); + settings.singleColor = next; + setSingleColor(next); + if (updateInput) setCustomInput(next); requestApply(); }; @@ -172,12 +122,6 @@ export const Settings = () => { } }; - // const removeCustomColor = (color: string) => { - // const updated = customColors.filter((c) => c !== color); - // setCustomColors(updated); - // settings.customColors = updated; - // }; - const allColors = [...colorPresets, ...customColors]; const requestApply = () => { @@ -186,66 +130,11 @@ export const Settings = () => { return ( - {/* Mode selection via dropdown (aligned right) */} + {/* Single color picker button */}
-
-
Mode
-
- Choose how lyrics are colored -
-
- -
- - {/* Single color */} -
{ >
- {/* Gradient controls (open picker) */} -
-
-
- Gradient (Experimental) -
-
Set colors & angle
-
- -
- - {/* Cover gradient controls (open picker for angle) */} -
-
-
- Cover (Gradient) - Experimental -
-
Set angle
-
- -
- - {/* Modal for picking and managing colors (reused) */} + {/* Color picker modal */} {shouldRender && ( <> +
+ {allColors.map((color) => ( -
- )} - {mode !== "cover-gradient" && ( + /> + ))} + +
- {allColors.map((color) => ( -
- )} - {mode !== "cover-gradient" && ( -
-
- Custom Hex (#RRGGBB) -
-
- setCustomInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - applyCustomInputColor(customInput, true); - addCustomColor(); - } - }} - placeholder="#RRGGBB" - style={{ - flex: 1, - padding: "8px 12px", - borderRadius: 6, - border: "1px solid rgba(255,255,255,0.2)", - background: "rgba(255,255,255,0.1)", - color: "#fff", - fontSize: 14, - fontFamily: "monospace", - boxSizing: "border-box", - }} - /> - -
-
- )} - {/* Sliders inside picker based on mode */} - {mode === "single" && ( -
-
- Alpha -
- { - const value = Number(e.target.value); - settings.singleAlpha = value; - setSingleAlpha(value); - requestApply(); - }} - style={{ width: "100%" }} /> -
- )} - - {mode === "gradient-experimental" && ( -
-
-
-
-
- Start Alpha -
-
- { - const value = Number(e.target.value); - settings.gradientStartAlpha = value; - setGradientStartAlpha(value); - requestApply(); - }} - style={{ width: "100%" }} - /> -
-
-
-
-
- End Alpha -
-
- { - const value = Number(e.target.value); - settings.gradientEndAlpha = value; - setGradientEndAlpha(value); - requestApply(); - }} - style={{ width: "100%" }} - /> -
-
-
-
- Angle -
-
- {gradientAngle}° -
-
- { - const value = Number(e.target.value); - settings.gradientAngle = value; - setGradientAngle(value); - requestApply(); - }} - style={{ width: "100%" }} - /> -
-
- )} - - {mode === "cover-gradient" && ( -
-
{ + applyCustomInputColor(customInput, false); + addCustomColor(); + }} style={{ + width: 32, + height: 32, + borderRadius: 6, + border: "1px solid rgba(255,255,255,0.3)", + background: "rgba(255,255,255,0.15)", + color: "#fff", + cursor: "pointer", + fontSize: 16, display: "flex", alignItems: "center", - justifyContent: "space-between", - marginBottom: 6, + justifyContent: "center", + transition: "all 0.2s ease", }} + type="button" > -
- Angle -
-
- {gradientAngle}° -
-
- { - const value = Number(e.target.value); - settings.gradientAngle = value; - setGradientAngle(value); - requestApply(); - }} - style={{ width: "100%" }} - /> + + +
- )} +
+
+
+ Alpha +
+ { + const value = Number(e.target.value); + settings.singleAlpha = value; + setSingleAlpha(value); + requestApply(); + }} + style={{ width: "100%" }} + /> +
`; - // 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; @@ -1269,447 +1227,144 @@ const createStickyLyricsDropdown = (): void => { }); } - // Close dropdown when clicking outside trigger & dropdown - const handleOutsideClick = (e: MouseEvent): void => { + document.body.appendChild(dropdown); + stickyDropdownEl = dropdown; + + const outsideHandler = (e: MouseEvent): void => { + const trigger = document.querySelector(".sticky-lyrics-trigger"); if ( - !trigger.contains(e.target as Node) && + (!trigger || !trigger.contains(e.target as Node)) && !dropdown.contains(e.target as Node) ) { - closeDropdown(); + closeStickyDropdown(); } }; - document.addEventListener("click", handleOutsideClick); + document.addEventListener("click", outsideHandler); - // 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(); + document.removeEventListener("click", outsideHandler); + document.body.classList.remove("rl-dropdown-open"); dropdown.remove(); + stickyDropdownEl = null; + stickyDropdownOpen = false; }); + + return dropdown; +}; + +const createStickyLyricsDropdown = (): void => { + const lyricsToggle = document.querySelector( + '[data-test="toggle-lyrics"]', + ) as HTMLElement; + if (!lyricsToggle) return; + if (lyricsToggle.querySelector(".sticky-lyrics-trigger")) return; + + ensureStickyDropdown(); + + const trigger = document.createElement("div"); + trigger.className = "sticky-lyrics-trigger"; + trigger.setAttribute("title", "Sticky Lyrics"); + trigger.innerHTML = getStickyIcon(); + + 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, () => openStickyDropdown(lyricsToggle), 150); + return; + } + if (stickyDropdownOpen) { + closeStickyDropdown(); + } else { + openStickyDropdown(lyricsToggle); + } + }, + true, + ); + + lyricsToggle.appendChild(trigger); + + if (stickyDropdownOpen) { + positionDropdown(); + } }; // Sticky Lyrics nav for injected lyrics tab const tryActivateStickyLyricsTab = (): boolean => { if (!settings.stickyLyrics) return false; - const lyricsTab = document.querySelector( - '[data-test="tabs-lyrics"]', + const lyricsToggle = document.querySelector( + '[data-test="toggle-lyrics"]', ) as HTMLElement; - const playQueueTab = document.querySelector( - '[data-test="tabs-play-queue"]', - ) as HTMLElement; - - if (!lyricsTab) return false; - - // Already active — nothing to do - if (lyricsTab.getAttribute("aria-selected") === "true") return true; - - if (lyricsTab.getAttribute("data-rl-injected") === "true") { - showInjectedLyricsTab(); - } else { - lyricsTab.click(); + if (!lyricsToggle || lyricsToggle.getAttribute("aria-disabled") === "true") { + tryActivateSimilarTracksTab(); + return false; } - // Verify we actually stayed on lyrics after a short delay - safeTimeout( - unloads, - () => { - if (!settings.stickyLyrics) return; - const onLyrics = document.querySelector( - '[data-test="tabs-lyrics"][aria-selected="true"]', - ); - if (!onLyrics && playQueueTab) { - playQueueTab.click(); - } - }, - 800, - ); + if (syntheticNativeLyrics) { + notifyNativeLyricsStateChanged(); + } + if (lyricsToggle.getAttribute("aria-pressed") === "true") return true; + + lyricsToggle.click(); return true; }; -// Handle switching tabs on track change +const tryActivateSimilarTracksTab = (): void => { + const btn = document.querySelector( + '[data-test="toggle-similar-tracks"]', + ) as HTMLElement; + if (!btn) return; + if (btn.getAttribute("aria-pressed") === "true") return; + btn.click(); +}; + +const syncNativeLyricsAvailability = (): void => { + if (!syntheticNativeLyrics) return; + notifyNativeLyricsStateChanged(); +}; + 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; - - if (!tryActivateStickyLyricsTab()) { - const playQueueTab = document.querySelector( - '[data-test="tabs-play-queue"]', - ) as HTMLElement; - if (playQueueTab) playQueueTab.click(); - } - }, - 1200, - ); + tryActivateStickyLyricsTab(); }; -// MARKER: Injected API Lyrics (for non tidal lyric tracks) - -let injectedTablistClickCleanup: (() => void) | null = null; +// Track change sequencing (used by onTrackChange) let isTrackChangeRunning = false; let trackChangeRunSeq = 0; -const hiddenPanelsByInjected = new Set(); -const getTabsRoot = (): HTMLElement | null => { - const roots = Array.from( - document.querySelectorAll('.react-tabs[data-rttabs="true"]'), - ) as HTMLElement[]; - for (const root of roots) { - if (root.querySelector('[data-test="tabs-play-queue"]')) return root; - } - return null; -}; - -const hideInjectedLyricsTab = (): void => { - if (!injectedTabEl || !injectedPanelEl) return; - const root = getTabsRoot(); - if (root) { - for (const panel of hiddenPanelsByInjected) { - panel.style.removeProperty("display"); - } - hiddenPanelsByInjected.clear(); - - const nativePanels = Array.from( - root.querySelectorAll('div[role="tabpanel"]'), - ) as HTMLElement[]; - for (const panel of nativePanels) { - if (panel === injectedPanelEl) continue; - panel.style.removeProperty("display"); - } - } - - injectedTabEl.setAttribute("aria-selected", "false"); - injectedTabEl.setAttribute("tabindex", "-1"); - injectedTabEl.setAttribute("aria-expanded", "false"); - injectedTabEl.classList.remove("react-tabs__tab--selected"); - if (activeTabClass) { - injectedTabEl.classList.remove(activeTabClass); - } - - injectedPanelEl.classList.remove("react-tabs__tab-panel--selected"); - if (activePanelClass) { - injectedPanelEl.classList.remove(activePanelClass); - } - injectedPanelEl.setAttribute("aria-hidden", "true"); - injectedPanelEl.style.display = "none"; -}; - -const showInjectedLyricsTab = (): void => { - if (!injectedTabEl || !injectedPanelEl) return; - const root = getTabsRoot(); - if (!root) return; - - const tabs = Array.from( - root.querySelectorAll('ul[role="tablist"] > li[role="tab"]'), - ) as HTMLElement[]; - const panels = Array.from( - root.querySelectorAll('div[role="tabpanel"]'), - ) as HTMLElement[]; - - if (!activeTabClass) { - for (const tab of tabs) { - const cls = Array.from(tab.classList).find((c) => - c.includes("_activeTab_"), - ); - if (cls) { - activeTabClass = cls; - break; - } - } - } - if (!activePanelClass) { - for (const panel of panels) { - const cls = Array.from(panel.classList).find((c) => - c.includes("_isActive_"), - ); - if (cls) { - activePanelClass = cls; - break; - } - } - } - - const nativePanels = Array.from( - root.querySelectorAll('div[role="tabpanel"]'), - ) as HTMLElement[]; - for (const panel of hiddenPanelsByInjected) { - panel.style.removeProperty("display"); - } - hiddenPanelsByInjected.clear(); - - for (const tab of tabs) { - if (tab === injectedTabEl) continue; - if (activeTabClass) tab.classList.remove(activeTabClass); - } - for (const panel of nativePanels) { - if (panel === injectedPanelEl) continue; - if (panel.classList.contains("react-tabs__tab-panel--selected")) { - panel.style.display = "none"; - hiddenPanelsByInjected.add(panel); - } - } - - injectedTabEl.setAttribute("aria-selected", "true"); - injectedTabEl.setAttribute("tabindex", "0"); - injectedTabEl.setAttribute("aria-expanded", "true"); - injectedTabEl.classList.add("react-tabs__tab--selected"); - if (activeTabClass) injectedTabEl.classList.add(activeTabClass); - - injectedPanelEl.classList.add("react-tabs__tab-panel--selected"); - if (activePanelClass) { - injectedPanelEl.classList.add(activePanelClass); - } - injectedPanelEl.removeAttribute("aria-hidden"); - injectedPanelEl.style.removeProperty("display"); -}; - -const clearInjectedLyricsTab = (): void => { - hideInjectedLyricsTab(); - if (creditsTabEl) { - if (creditsPrevOrder) { - creditsTabEl.style.setProperty("order", creditsPrevOrder); - } else { - creditsTabEl.style.removeProperty("order"); - } - } - - if (injectedTablistClickCleanup) { - injectedTablistClickCleanup(); - injectedTablistClickCleanup = null; - } - - if (injectedTabEl) injectedTabEl.remove(); - if (injectedPanelEl) injectedPanelEl.remove(); - - injectedTabEl = null; - injectedPanelEl = null; - activeTabClass = ""; - activePanelClass = ""; - creditsTabEl = null; - creditsPrevOrder = ""; -}; - -const buildInjectedLyricsShell = (panel: HTMLElement): void => { - if (panel.querySelector('[data-test="lyrics-lines"]')) return; - - const trackLyrics = document.createElement("div"); - trackLyrics.setAttribute("data-test", "track-lyrics"); - - const lyricsContainer = document.createElement("div"); - lyricsContainer.className = "_lyricsContainer_fa37c08 _smoothScroll_05ef096"; - lyricsContainer.setAttribute("data-test", "lyrics"); - - const lyricsLines = document.createElement("div"); - lyricsLines.className = - "_lyricsText_bf0080e _lyrics_0537465 _hasCues_76b4841"; - lyricsLines.setAttribute("data-test", "lyrics-lines"); - - const placeholder = document.createElement("span"); - placeholder.textContent = "..."; - const linesInner = document.createElement("div"); - - lyricsLines.appendChild(placeholder); - lyricsLines.appendChild(linesInner); - lyricsContainer.appendChild(lyricsLines); - trackLyrics.appendChild(lyricsContainer); - - panel.replaceChildren(trackLyrics); -}; - -const waitForNativeTab = (): Promise => { - if ( - document.querySelector( - '[data-test="tabs-lyrics"]:not([data-rl-injected])', - ) - ) { - return Promise.resolve(true); - } - - return new Promise((resolve) => { - const localUnloads = new Set(); - let settled = false; - const settle = (result: boolean): void => { - if (settled) return; - settled = true; - for (const fn of localUnloads) fn(); - localUnloads.clear(); - resolve(result); - }; - - observe( - localUnloads, - '[data-test="tabs-lyrics"]:not([data-rl-injected])', - () => settle(true), - ); - - const timer = window.setTimeout(() => settle(false), 200); - localUnloads.add(() => clearTimeout(timer)); - }); -}; - -const ensureLyricsTab = async (): Promise => { - const existingLyricsTab = document.querySelector( - '[data-test="tabs-lyrics"]', - ) as HTMLElement; - if (existingLyricsTab && existingLyricsTab !== injectedTabEl) { - clearInjectedLyricsTab(); - return true; - } - if (injectedTabEl && injectedPanelEl) { - buildInjectedLyricsShell(injectedPanelEl); - return true; - } - - // resolves instantly if native tab already exists (fallback to 200ms for slow ass tidal re renders) - if (await waitForNativeTab()) return true; - - const root = getTabsRoot(); - if (!root) return false; - const tabList = root.querySelector('ul[role="tablist"]') as HTMLElement; - if (!tabList) return false; - - const sampleTab = tabList.querySelector('li[role="tab"]') as HTMLElement; - const tabItemClass = - Array.from(sampleTab?.classList ?? []).find((c) => - c.includes("_tabItem_"), - ) ?? ""; - - const samplePanel = root.querySelector('div[role="tabpanel"]') as HTMLElement; - const panelBaseClass = - Array.from(samplePanel?.classList ?? []).find((c) => - c.includes("_tabPanelStyles_"), - ) ?? ""; - - const panelId = `panel:rl:${Date.now().toString(36)}`; - const tabId = `tab:rl:${Date.now().toString(36)}`; - - const tabEl = document.createElement("li"); - tabEl.setAttribute("data-test", "tabs-lyrics"); - tabEl.setAttribute("data-rttab", "true"); - tabEl.setAttribute("data-rl-injected", "true"); - tabEl.setAttribute("role", "tab"); - tabEl.setAttribute("id", tabId); - tabEl.setAttribute("aria-selected", "false"); - tabEl.setAttribute("aria-disabled", "false"); - tabEl.setAttribute("aria-controls", panelId); - tabEl.setAttribute("tabindex", "-1"); - if (tabItemClass) tabEl.classList.add(tabItemClass); - - const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - icon.setAttribute("class", "_icon_77f3f89"); - icon.setAttribute("viewBox", "0 0 20 20"); - const use = document.createElementNS("http://www.w3.org/2000/svg", "use"); - use.setAttribute("href", "#general__lyrics"); - icon.appendChild(use); - - const label = document.createElement("span"); - label.className = "wave-text-description-demi"; - label.setAttribute("data-wave-color", "textDefault"); - label.textContent = "Lyrics"; - - tabEl.appendChild(icon); - tabEl.appendChild(label); - - const panelEl = document.createElement("div"); - panelEl.setAttribute("data-rl-injected", "true"); - panelEl.setAttribute("role", "tabpanel"); - panelEl.setAttribute("id", panelId); - panelEl.setAttribute("aria-labelledby", tabId); - if (panelBaseClass) panelEl.classList.add(panelBaseClass); - - buildInjectedLyricsShell(panelEl); - - const creditsTab = tabList.querySelector( - '[data-test="tabs-credits"]', - ) as HTMLElement | null; - if (creditsTab) { - creditsTabEl = creditsTab; - creditsPrevOrder = creditsTab.style.getPropertyValue("order") || ""; - creditsTab.style.setProperty("order", "1000"); - tabEl.style.setProperty("order", "999"); - } - - tabList.appendChild(tabEl); - - root.appendChild(panelEl); - - tabEl.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - ( - e as unknown as { stopImmediatePropagation?: () => void } - ).stopImmediatePropagation?.(); - showInjectedLyricsTab(); - }); - - const handleTabListClick = (e: Event): void => { - const target = e.target as Node; - const clickedTab = (target as HTMLElement)?.closest?.( - 'li[role="tab"]', - ) as HTMLElement; - if (!clickedTab || clickedTab === tabEl) return; - - const allTabs = Array.from( - tabList.querySelectorAll('li[role="tab"]'), - ) as HTMLElement[]; - for (const tab of allTabs) { - if (tab === tabEl) continue; - if (activeTabClass) tab.classList.remove(activeTabClass); - tab.classList.remove("react-tabs__tab--selected"); - } - if (activeTabClass) clickedTab.classList.add(activeTabClass); - clickedTab.classList.add("react-tabs__tab--selected"); - clickedTab.setAttribute("aria-selected", "true"); - clickedTab.setAttribute("tabindex", "0"); - - window.setTimeout(() => { - hideInjectedLyricsTab(); - }, 0); - }; - tabList.addEventListener("click", handleTabListClick); - injectedTablistClickCleanup = () => { - tabList.removeEventListener("click", handleTabListClick); - }; - - injectedTabEl = tabEl; - injectedPanelEl = panelEl; - return true; -}; - -// Observer: create dropdown when lyrics tab appears & detect track changes +// Observer: create dropdown when lyrics toggle appears & detect track changes function setupStickyLyricsObserver(): void { - // Create dropdown if lyrics tab already exists - const existing = document.querySelector('[data-test="tabs-lyrics"]'); + // Create dropdown if lyrics toggle already exists + const existing = document.querySelector('[data-test="toggle-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"]', () => { - // If a native lyrics tab appeared while an injected one exists, remove the duplicate - if (injectedTabEl) { - const nativeTab = document.querySelector( - '[data-test="tabs-lyrics"]:not([data-rl-injected])', - ); - if (nativeTab) { - clearInjectedLyricsTab(); - } - } - - const tab = document.querySelector('[data-test="tabs-lyrics"]'); - if (tab && !tab.querySelector(".sticky-lyrics-trigger")) { + // 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(".sticky-lyrics-trigger")) { createStickyLyricsDropdown(); if (settings.stickyLyrics) { tryActivateStickyLyricsTab(); @@ -1717,13 +1372,26 @@ function setupStickyLyricsObserver(): void { } }); + // When lyrics toggle becomes disabled → similar tracks; enabled → lyrics + observe(unloads, '[data-test="toggle-lyrics"][aria-disabled="true"]', () => { + if (settings.stickyLyrics) { + tryActivateSimilarTracksTab(); + } + }); + + observe(unloads, '[data-test="toggle-lyrics"]:not([aria-disabled])', () => { + if (settings.stickyLyrics) { + tryActivateStickyLyricsTab(); + } + }); + // Apply word lyrics when lyrics container appears or reappears - observe(unloads, '[data-test="lyrics-lines"]', () => { + observe(unloads, '[data-test="now-playing-lyrics"]', () => { if (isTrackChangeRunning) return; - const lyricsLines = document.querySelector( - '[data-test="lyrics-lines"]', - ) as HTMLElement; - if (lyricsLines?.querySelector(".rl-wbw-container")) return; + const panel = getNowPlayingLyricsPanel(); + if (panel?.querySelector(".rl-wbw-container")) return; + const lyricsContainer = findLyricsContainer(); + if (lyricsContainer?.querySelector(".rl-wbw-container")) return; if (lyricsMode === "line-tidal") { void reapplyTidalLines(); @@ -1821,6 +1489,23 @@ interface LineLyricsResponse { type LyricsApiResponse = WordLyricsResponse | LineLyricsResponse; type LyricsOverlayMode = "none" | "word" | "line-api" | "line-tidal"; +interface TrackInfo { + trackId: string; + title: string; + artist: string; + isrc?: string; +} + +interface SyntheticNativeLyricsState { + trackId: string; + lyricsId: string; + text: string; + lrcText: string; + providerName: string; + direction: "LEFT_TO_RIGHT" | "RIGHT_TO_LEFT"; + response: LyricsApiResponse; +} + // syllable state let trackChangeToken = 0; let lyricsData: WordLine[] | null = null; @@ -1830,16 +1515,378 @@ let tickLoopUnload: LunaUnload | null = null; let isActive = false; let savedTidalClasses: string[] | null = null; let tidalFollowObserver: MutationObserver | null = null; -let injectedTabEl: HTMLElement | null = null; -let injectedPanelEl: HTMLElement | null = null; -let activeTabClass = ""; -let activePanelClass = ""; -let creditsTabEl: HTMLElement | null = null; -let creditsPrevOrder = ""; +let nativeLyricsOverlayInstalled = false; +let originalReduxGetState: (() => ReturnType) | null = + null; +let syntheticNativeLyrics: SyntheticNativeLyricsState | null = null; +let cachedSyntheticEntry: SyntheticNativeLyricsState | null = null; +let cachedSyntheticEntity: any = null; +let cachedSrcTrackRef: any = null; +let cachedModifiedTrack: any = null; +let cachedSrcTracksSlice: any = null; +let cachedSrcLyricsSlice: any = null; +let cachedOvlTracksSlice: any = null; +let cachedOvlLyricsSlice: any = null; +let cachedSrcEntities: any = null; +let cachedOvlEntities: any = null; +let cachedSrcState: any = null; +let cachedOvlState: any = null; const isWordMode = (): boolean => lyricsMode === "word"; const getLyricsStyle = (): number => (isWordMode() ? settings.lyricsStyle : 0); +const getNowPlayingLyricsPanel = (): HTMLElement | null => + document.querySelector('[data-test="now-playing-lyrics"]') as HTMLElement | null; + +// Find the lyrics text container (wraps the individual lyrics-line spans). +// In the new player-market UI this element has no data-test; we locate it by +// walking up from the first lyrics-line span. +const findLyricsContainer = (): HTMLElement | null => { + const line = document.querySelector('span[data-test="lyrics-line"]'); + if (line?.parentElement?.parentElement) { + return line.parentElement.parentElement as HTMLElement; + } + return null; +}; + +// Check whether a tidal lyrics span is currently the active/highlighted line. +// Player-market UI uses a CSS class matching _current_*. +const isTidalSpanActive = (span: HTMLElement): boolean => { + return Array.from(span.classList).some((c) => c.startsWith("_current_")); +}; + +const getReduxState = (preferOriginal = false): any => { + if (preferOriginal && originalReduxGetState) { + return originalReduxGetState(); + } + return redux.store.getState() as any; +}; + +const getNativeTrackEntity = (trackId: string): any | null => + getReduxState(true)?.entities?.tracks?.entities?.[trackId] ?? null; + +const trackHasNativeLyrics = (trackId: string): boolean => { + const rel = getNativeTrackEntity(trackId)?.relationships?.lyrics?.data; + return Array.isArray(rel) && rel.length > 0; +}; + +const currentTrackWantsLyricsPanel = (): boolean => + (getReduxState()?.settings?.nowPlayingActiveView ?? null) === "lyrics"; + +const getSyntheticNativeLyricsEntity = ( + entry: SyntheticNativeLyricsState, +) => ({ + id: entry.lyricsId, + type: "lyrics", + attributes: { + text: entry.text, + lrcText: entry.lrcText, + technicalStatus: "OK", + provider: { + source: "THIRD_PARTY", + name: entry.providerName, + commonTrackId: "", + lyricsId: "", + }, + direction: entry.direction, + }, + relationships: { + owners: { + links: { + self: `/lyrics/${entry.lyricsId}/relationships/owners`, + }, + }, + track: { + links: { + self: `/lyrics/${entry.lyricsId}/relationships/track`, + }, + }, + }, +}); + +const invalidateOverlayCache = (): void => { + cachedSyntheticEntry = null; + cachedSyntheticEntity = null; + cachedSrcTrackRef = null; + cachedModifiedTrack = null; + cachedSrcTracksSlice = null; + cachedSrcLyricsSlice = null; + cachedOvlTracksSlice = null; + cachedOvlLyricsSlice = null; + cachedSrcEntities = null; + cachedOvlEntities = null; + cachedSrcState = null; + cachedOvlState = null; +}; + +const overlaySyntheticNativeLyricsState = (state: any): any => { + const entry = syntheticNativeLyrics; + if (!entry) return state; + + const entities = state?.entities; + const tracksSlice = entities?.tracks; + const lyricsSlice = entities?.lyrics; + if (!tracksSlice?.entities || !lyricsSlice?.entities) return state; + + const existingTrack = tracksSlice.entities[entry.trackId]; + if (!existingTrack) return state; + + const existingRel = existingTrack.relationships?.lyrics?.data; + if (Array.isArray(existingRel) && existingRel.length > 0) return state; + + if (cachedSyntheticEntry !== entry) { + cachedSyntheticEntry = entry; + cachedSyntheticEntity = getSyntheticNativeLyricsEntity(entry); + cachedSrcTrackRef = null; + } + + if (cachedSrcTrackRef !== existingTrack) { + cachedSrcTrackRef = existingTrack; + cachedModifiedTrack = { + ...existingTrack, + relationships: { + ...existingTrack.relationships, + lyrics: { + ...existingTrack.relationships?.lyrics, + data: [{ id: entry.lyricsId, type: "lyrics" }], + }, + }, + }; + cachedSrcTracksSlice = null; + } + + if (cachedSrcTracksSlice !== tracksSlice) { + cachedSrcTracksSlice = tracksSlice; + cachedOvlTracksSlice = { + ...tracksSlice, + entities: { ...tracksSlice.entities, [entry.trackId]: cachedModifiedTrack }, + }; + cachedSrcEntities = null; + } + + if (cachedSrcLyricsSlice !== lyricsSlice) { + cachedSrcLyricsSlice = lyricsSlice; + cachedOvlLyricsSlice = { + ...lyricsSlice, + ids: lyricsSlice.ids.includes(entry.lyricsId) + ? lyricsSlice.ids + : [...lyricsSlice.ids, entry.lyricsId], + entities: { ...lyricsSlice.entities, [entry.lyricsId]: cachedSyntheticEntity }, + }; + cachedSrcEntities = null; + } + + if (cachedSrcEntities !== entities) { + cachedSrcEntities = entities; + cachedOvlEntities = { + ...entities, + tracks: cachedOvlTracksSlice, + lyrics: cachedOvlLyricsSlice, + }; + cachedSrcState = null; + } + + if (cachedSrcState !== state) { + cachedSrcState = state; + cachedOvlState = { ...state, entities: cachedOvlEntities }; + } + + return cachedOvlState ?? state; +}; + +const installNativeLyricsOverlay = (): void => { + if (nativeLyricsOverlayInstalled) return; + const original = redux.store.getState.bind(redux.store); + originalReduxGetState = original; + (redux.store as any).getState = () => overlaySyntheticNativeLyricsState(original()); + nativeLyricsOverlayInstalled = true; + unloads.add(() => { + if (originalReduxGetState) { + (redux.store as any).getState = originalReduxGetState; + } + nativeLyricsOverlayInstalled = false; + originalReduxGetState = null; + syntheticNativeLyrics = null; + invalidateOverlayCache(); + }); +}; + +const setNowPlayingActiveView = (view: string): boolean => { + const action = redux.actions["settings/SET_NOW_PLAYING_ACTIVE_VIEW"] as + | ((nextView: string) => unknown) + | undefined; + if (typeof action !== "function") return false; + action(view); + return true; +}; + +const notifyNativeLyricsStateChanged = (): void => { + const currentView = getReduxState()?.settings?.nowPlayingActiveView ?? null; + if (currentView === "lyrics") { + if (setNowPlayingActiveView("credits")) { + safeTimeout(unloads, () => { + setNowPlayingActiveView("lyrics"); + }, 0); + } + return; + } + if (typeof currentView === "string" && currentView.length > 0) { + setNowPlayingActiveView(currentView); + } +}; + +const formatLrcTime = (timeSeconds: number): string => { + const safeSeconds = Number.isFinite(timeSeconds) ? Math.max(0, timeSeconds) : 0; + const totalMs = Math.round(safeSeconds * 1000); + const minutes = Math.floor(totalMs / 60000); + const seconds = Math.floor((totalMs % 60000) / 1000); + const hundredths = Math.floor((totalMs % 1000) / 10); + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(hundredths).padStart(2, "0")}`; +}; + +const buildSyntheticLyricsText = (response: LyricsApiResponse): string => + response.data + .map((line) => ("romanized" in line && line.romanized ? line.romanized : line.text)) + .filter((line) => line.trim().length > 0) + .join("\n"); + +const buildSyntheticLrcText = (response: LyricsApiResponse): string => + response.data + .map((line) => { + const text = + ("romanized" in line && line.romanized ? line.romanized : line.text) ?? ""; + return `[${formatLrcTime(line.startTime)}]${text}`; + }) + .join("\n"); + +const registerSyntheticNativeLyrics = ( + trackInfo: TrackInfo, + response: LyricsApiResponse, +): boolean => { + installNativeLyricsOverlay(); + const track = getNativeTrackEntity(trackInfo.trackId); + if (!track) return false; + + syntheticNativeLyrics = { + trackId: trackInfo.trackId, + lyricsId: `radiant-lyrics-${trackInfo.trackId}`, + text: buildSyntheticLyricsText(response), + lrcText: buildSyntheticLrcText(response), + providerName: `Radiant Lyrics (${response.metadata.source})`, + direction: "LEFT_TO_RIGHT", + response, + }; + invalidateOverlayCache(); + notifyNativeLyricsStateChanged(); + return true; +}; + +const clearSyntheticNativeLyrics = (): void => { + if (!syntheticNativeLyrics) return; + syntheticNativeLyrics = null; + invalidateOverlayCache(); + notifyNativeLyricsStateChanged(); +}; + +const muteRerenderObserver = (): void => { + suppressRerenderObserver = true; + if (rerenderObserverMuteTimeout !== null) { + window.clearTimeout(rerenderObserverMuteTimeout); + rerenderObserverMuteTimeout = null; + } +}; + +const unmuteRerenderObserverSoon = (): void => { + if (rerenderObserverMuteTimeout !== null) { + window.clearTimeout(rerenderObserverMuteTimeout); + } + rerenderObserverMuteTimeout = window.setTimeout(() => { + suppressRerenderObserver = false; + rerenderObserverMuteTimeout = null; + }, 0); +}; + +const runWithMutedRerenderObserver = (fn: () => void): void => { + muteRerenderObserver(); + try { + fn(); + } finally { + unmuteRerenderObserverSoon(); + } +}; + +const getLyricsRenderHost = (): { + container: HTMLElement; + inner: HTMLElement; +} | null => { + const tidalContainer = findLyricsContainer(); + if (tidalContainer) { + const innerDiv = tidalContainer.querySelector(":scope > div") as HTMLElement | null; + if (innerDiv) return { container: tidalContainer, inner: innerDiv }; + } + + const panel = getNowPlayingLyricsPanel(); + if (!panel) return null; + + const mountParent = panel; + let wrapper = Array.from(mountParent.children).find((el) => { + if (!(el instanceof HTMLElement) || el.tagName !== "DIV") return false; + return !Array.from(el.classList).some((cls) => cls.startsWith("os-scrollbar")); + }) as HTMLElement | null; + if (!wrapper) { + wrapper = document.createElement("div"); + wrapper.dataset.rlSyntheticCreated = "true"; + mountParent.insertBefore(wrapper, mountParent.firstChild); + } + + let host = wrapper.querySelector(":scope > .rl-native-lyrics-host") as + | HTMLElement + | null; + if (!host) { + host = wrapper.querySelector(':scope > [class*="_content_"]') as + | HTMLElement + | null; + if (!host) { + host = document.createElement("div"); + host.dataset.rlSyntheticCreated = "true"; + wrapper.appendChild(host); + } + } + host.classList.add("rl-native-lyrics-host"); + host.style.setProperty("display", "block", "important"); + host.style.setProperty("width", "100%", "important"); + host.style.setProperty("box-sizing", "border-box", "important"); + host.style.setProperty("overflow", "visible", "important"); + + let inner = host.querySelector(":scope > .rl-native-lyrics-inner") as + | HTMLElement + | null; + if (!inner) { + inner = Array.from(host.children).find((el) => { + if (!(el instanceof HTMLElement) || el.tagName !== "DIV") return false; + return !(el.className || "").toString().includes("_footer_"); + }) as + | HTMLElement + | null; + if (!inner) { + inner = document.createElement("div"); + inner.dataset.rlSyntheticCreated = "true"; + const footer = host.querySelector(':scope > [class*="_footer_"]'); + if (footer?.parentElement === host) host.insertBefore(inner, footer); + else host.appendChild(inner); + } + } + inner.classList.add("rl-native-lyrics-inner"); + inner.style.setProperty("display", "block", "important"); + inner.style.setProperty("width", "100%", "important"); + inner.style.setProperty("max-width", "none", "important"); + inner.style.setProperty("box-sizing", "border-box", "important"); + inner.style.setProperty("overflow", "visible", "important"); + inner.style.setProperty("flex", "none", "important"); + + return { container: host, inner }; +}; + interface WordEntry { el: HTMLSpanElement; start: number; // ms @@ -1860,6 +1907,8 @@ interface LineEntry { let lines: LineEntry[] = []; let rerenderObserver: MutationObserver | null = null; let rerenderDebounce: number | null = null; +let suppressRerenderObserver = false; +let rerenderObserverMuteTimeout: number | null = null; const activeWordEls = new Map(); const activeBgWordEls = new Map(); let activeLineIdxs = new Set(); @@ -2075,11 +2124,7 @@ const getPlaybackMs = (): number => { }; // get title + artist from media item (Used everywhere now <3) -const getTrackInfo = async (): Promise<{ - title: string; - artist: string; - isrc?: string; -} | null> => { +const getTrackInfo = async (): Promise => { const mi = await MediaItem.fromPlaybackContext(); if (!mi?.tidalItem) return null; @@ -2089,9 +2134,10 @@ const getTrackInfo = async (): Promise<{ const artist = mi.tidalItem.artist?.name ?? mi.tidalItem.artists?.[0]?.name ?? ""; // REMIX Detection const isrc = mi.tidalItem.isrc ?? undefined; + const trackId = String(mi.tidalItem.id ?? PlayState.playbackContext?.actualProductId ?? ""); - if (!baseTitle || !artist) return null; - return { title, artist, isrc }; + if (!baseTitle || !artist || !trackId) return null; + return { trackId, title, artist, isrc }; }; // fetch syllables from the API (wiped on track change) @@ -2110,16 +2156,16 @@ const fetchLyrics = async ( return cachedLyricsData; } - let params = `lyrics?title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`; + let params = `?title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`; if (isrc) params += `&isrc=${encodeURIComponent(isrc)}`; if (settings.romanizeLyrics) params += "&romanize=true"; const platformParam = "&platform=" + encodeURIComponent("Radiant Lyrics"); const primaryUrls = [ - `https://rl-api.atomix.one/${params}${platformParam}`, - `https://lyricsplus-api.atomix.one/${params}${platformParam}`, + `https://api.atomix.one/rl-api${params}${platformParam}`, + `https://lyricsplus-api.atomix.one/lyrics${params}${platformParam}`, ]; - const fallbackUrl = `https://rl-api.kineticsand.net/${params}`; + const fallbackUrl = `https://rl-api.kineticsand.net/lyrics${params}`; // "ok" = got a response (data may still be null if type is unsupported) // "404" = lyrics not found, stop all attempts immediately @@ -2131,10 +2177,17 @@ const fetchLyrics = async ( | { status: "500" } | { status: "err" }; + const rlApiHeaders: Record = { + "P-Access-Token-Id": "58hy4s86", + "P-Access-Token": "xjehy2lfg5h5mjwotoxrcqugam", + }; + const tryFetch = async (url: string): Promise => { try { sylTrace(`RL API: Fetching lyrics: ${url}`); - const res = await fetch(url); + const res = await fetch(url, { + headers: url.includes("api.atomix.one") ? rlApiHeaders : undefined, + }); if (!res.ok) { trace.log(`RL API: fetch failed: ${res.status} from ${url}`); if (res.status === 404) return { status: "404" }; @@ -2239,9 +2292,7 @@ const normalizeLineData = (data: ApiLine[]): WordLine[] => { // Scrapes Tidal Line Texts (For Romanization) const getTidalLines = (): string[] => { - const lyricsContainer = document.querySelector( - '[data-test="lyrics-lines"]', - ) as HTMLElement; + const lyricsContainer = findLyricsContainer(); if (!lyricsContainer) return []; const innerDiv = lyricsContainer.querySelector(":scope > div") as HTMLElement; if (!innerDiv) return []; @@ -2274,7 +2325,7 @@ const romanizeLines = async (lineTexts: string[]): Promise => { const romanizePlatform = "?platform=" + encodeURIComponent("Radiant Lyrics"); const urls = [ - `https://rl-api.atomix.one/romanize${romanizePlatform}`, + `https://api.atomix.one/rl-api/romanize${romanizePlatform}`, `https://lyricsplus-api.atomix.one/romanize${romanizePlatform}`, "https://rl-api.kineticsand.net/romanize", ]; @@ -2283,9 +2334,14 @@ const romanizeLines = async (lineTexts: string[]): Promise => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { + const romanizeHeaders: Record = { "content-type": "application/json" }; + if (url.includes("api.atomix.one")) { + romanizeHeaders["P-Access-Token-Id"] = "58hy4s86"; + romanizeHeaders["P-Access-Token"] = "xjehy2lfg5h5mjwotoxrcqugam"; + } const res = await fetch(url, { method: "POST", - headers: { "content-type": "application/json" }, + headers: romanizeHeaders, body: JSON.stringify(payload), signal: controller.signal, }); @@ -2323,10 +2379,8 @@ const romanizeLines = async (lineTexts: string[]): Promise => { // strip tidal css classes (prevent conflict) const hideTidalLyrics = (): boolean => { - const lyricsContainer = document.querySelector( - '[data-test="lyrics-lines"]', - ) as HTMLElement; - if (!lyricsContainer) return false; + const lyricsContainer = findLyricsContainer(); + if (!lyricsContainer) return !!getLyricsRenderHost(); // collect _ tidal classes const tidalClasses = Array.from(lyricsContainer.classList).filter((c) => @@ -2347,9 +2401,7 @@ const hideTidalLyrics = (): boolean => { // restore tidal classes (remove our container + cleanup) const restoreTidalLyrics = (): void => { - const lyricsContainer = document.querySelector( - '[data-test="lyrics-lines"]', - ) as HTMLElement; + const lyricsContainer = findLyricsContainer(); if (lyricsContainer) { // re-add the exact _ classes if (savedTidalClasses) { @@ -2380,6 +2432,33 @@ const restoreTidalLyrics = (): void => { lyricsContainer.querySelector(".rl-wbw-container")?.remove(); } + getNowPlayingLyricsPanel()?.querySelectorAll(".rl-native-lyrics-inner").forEach((el) => { + if (!(el instanceof HTMLElement)) return; + el.querySelector(".rl-wbw-container")?.remove(); + el.classList.remove("rl-native-lyrics-inner"); + el.style.removeProperty("display"); + el.style.removeProperty("width"); + el.style.removeProperty("max-width"); + el.style.removeProperty("box-sizing"); + el.style.removeProperty("overflow"); + el.style.removeProperty("flex"); + if (el.dataset.rlSyntheticCreated === "true") { + el.remove(); + } + delete el.dataset.rlSyntheticCreated; + }); + getNowPlayingLyricsPanel()?.querySelectorAll(".rl-native-lyrics-host").forEach((el) => { + if (!(el instanceof HTMLElement)) return; + el.classList.remove("rl-native-lyrics-host", "rl-wbw-active"); + el.style.removeProperty("display"); + el.style.removeProperty("width"); + el.style.removeProperty("box-sizing"); + el.style.removeProperty("overflow"); + if (el.dataset.rlSyntheticCreated === "true") { + el.remove(); + } + delete el.dataset.rlSyntheticCreated; + }); savedTidalClasses = null; }; @@ -2452,13 +2531,10 @@ const buildWordSpans = (): { 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 }; + const renderHost = getLyricsRenderHost(); + if (!renderHost) return { lines }; + const lyricsContainer = renderHost.container; + const innerDiv = renderHost.inner; // remove existing container innerDiv.querySelector(".rl-wbw-container")?.remove(); @@ -2480,7 +2556,7 @@ const buildWordSpans = (): { // create lyrics container for word/syllable lines const wbwContainer = document.createElement("div"); wbwContainer.className = "rl-wbw-container"; - if (settings.blurInactive && blurActivated) + if (settings.blurInactive && scrollSynced && blurActivated) wbwContainer.classList.add("rl-blur-active"); if (settings.bubbledLyrics) wbwContainer.classList.add("rl-bubbled"); const effectiveStyle = getLyricsStyle(); @@ -2546,7 +2622,7 @@ const buildWordSpans = (): { "font-size": "calc(40px * var(--rl-font-scale, 1))", "font-family": FONT_STACK, "font-weight": "700", - color: "rgba(128, 128, 128, 0.4)", + color: "rgba(255, 255, 255, 0.4)", overflow: "visible", flex: "none", "column-count": "auto", @@ -2792,9 +2868,7 @@ const buildTidalLines = ( romanizedLines: string[] | null = null, ): { lines: LineEntry[] } => { const lines: LineEntry[] = []; - const lyricsContainer = document.querySelector( - '[data-test="lyrics-lines"]', - ) as HTMLElement; + const lyricsContainer = findLyricsContainer(); if (!lyricsContainer) return { lines }; const innerDiv = lyricsContainer.querySelector(":scope > div") as HTMLElement; @@ -2813,7 +2887,7 @@ const buildTidalLines = ( const wbwContainer = document.createElement("div"); wbwContainer.className = "rl-wbw-container"; - if (settings.blurInactive && blurActivated) + if (settings.blurInactive && scrollSynced && blurActivated) wbwContainer.classList.add("rl-blur-active"); if (settings.bubbledLyrics) wbwContainer.classList.add("rl-bubbled"); forceStyle(wbwContainer, { @@ -2865,7 +2939,7 @@ const buildTidalLines = ( "font-family": '"AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif', "font-weight": "700", - color: "rgba(128, 128, 128, 0.4)", + color: "rgba(255, 255, 255, 0.4)", overflow: "visible", flex: "none", "column-count": "auto", @@ -2928,6 +3002,177 @@ const setTidalFallbackLineWordState = ( } }; +const getActiveWbwContainer = (): HTMLElement | null => { + const currentLine = + primaryLineIdx >= 0 && primaryLineIdx < lines.length + ? lines[primaryLineIdx]?.el + : lines[0]?.el; + if (currentLine) { + const container = currentLine.closest(".rl-wbw-container"); + if (container instanceof HTMLElement) return container; + } + const container = document.querySelector(".rl-wbw-container"); + return container instanceof HTMLElement ? container : null; +}; + +const clearInactiveBlurState = (): void => { + const container = getActiveWbwContainer(); + container?.classList.remove("rl-blur-active"); + for (const line of lines) { + line.el.classList.remove("rl-pos-1", "rl-pos-2", "rl-pos-3", "rl-gap-hold"); + } +}; + +const applyInactiveBlurState = ( + activeIndex: number, + holdLastActive = false, + activeSet: ReadonlySet | null = null, +): void => { + if (!settings.blurInactive) return; + if (!scrollSynced || !blurActivated) { + clearInactiveBlurState(); + return; + } + const container = getActiveWbwContainer(); + container?.classList.add("rl-blur-active"); + for (const line of lines) { + line.el.classList.remove("rl-pos-1", "rl-pos-2", "rl-pos-3", "rl-gap-hold"); + } + if (holdLastActive && primaryLineIdx >= 0 && primaryLineIdx < lines.length) { + lines[primaryLineIdx].el.classList.add("rl-gap-hold"); + return; + } + if (activeIndex < 0) return; + for (let dist = 1; dist <= 3; dist++) { + const before = activeIndex - dist; + const after = activeIndex + dist; + const cls = `rl-pos-${dist}`; + if (before >= 0 && !activeSet?.has(before)) lines[before].el.classList.add(cls); + if (after < lines.length && !activeSet?.has(after)) lines[after].el.classList.add(cls); + } +}; + +// Re-apply active line + word state to freshly-built DOM elements after a +// rebuild (reapply / re-render observer) WITHOUT triggering CSS transitions. +// This prevents the padding-left "swipe-shift" animation from replaying when +// the container is reconstructed during scroll-unlock, resync, or React +// re-renders. +const applyActiveLineStateNoTransition = (): void => { + if (primaryLineIdx < 0 || activeLineIdxs.size === 0 || lines.length === 0) return; + + const effectiveStyle = getLyricsStyle(); + const isSyl = effectiveStyle === 2; + const isLineStyle = effectiveStyle === 0; + const CLS_ACTIVE = isSyl ? "rl-syl-active" : "rl-wbw-active"; + const CLS_FINISHED = isSyl ? "rl-syl-finished" : "rl-wbw-finished"; + + // Mark words on past lines as finished (they render unstyled otherwise) + for (let li = 0; li < primaryLineIdx && li < lines.length; li++) { + for (const w of lines[li].words) w.el.classList.add(CLS_FINISHED); + for (const w of lines[li].bgWords) w.el.classList.add(CLS_FINISHED); + } + + // Apply active-line classes with transitions suppressed + for (const idx of activeLineIdxs) { + if (idx >= lines.length) continue; + const el = lines[idx].el; + el.style.setProperty("transition", "none", "important"); + el.classList.add("rl-wbw-line-active"); + el.classList.remove("rl-pos-1", "rl-pos-2", "rl-pos-3"); + el.setAttribute("data-current", "true"); + } + + // Restore word-level state on active lines so the tick loop doesn't flash + const nowMs = getPlaybackMs(); + activeWordEls.clear(); + activeBgWordEls.clear(); + + for (const lineIdx of activeLineIdxs) { + if (lineIdx >= lines.length) continue; + const currentLine = lines[lineIdx]; + + if (isLineStyle) { + for (const w of currentLine.words) { + w.el.style.setProperty("transition", "none", "important"); + w.el.classList.add(CLS_ACTIVE); + activeWordEls.set(lineIdx, w.el); + } + } else { + let activeWordIdx = -1; + for (let i = currentLine.words.length - 1; i >= 0; i--) { + if (nowMs >= currentLine.words[i].start) { + activeWordIdx = i; + break; + } + } + for (let i = 0; i < currentLine.words.length; i++) { + const w = currentLine.words[i]; + w.el.style.setProperty("transition", "none", "important"); + if (i < activeWordIdx) { + w.el.classList.add(CLS_FINISHED); + } else if (i === activeWordIdx) { + if (isSyl) { + const elapsed = nowMs - w.start; + if (elapsed >= w.duration) { + w.el.classList.add(CLS_FINISHED); + } else { + w.el.classList.add("rl-syl-active"); + w.el.style.animation = `rl-wipe ${w.duration}ms linear forwards`; + w.el.style.animationDelay = `-${elapsed}ms`; + } + } else { + w.el.classList.add(CLS_ACTIVE); + } + activeWordEls.set(lineIdx, w.el); + } + } + + // Background words + let activeBgIdx = -1; + for (let i = currentLine.bgWords.length - 1; i >= 0; i--) { + if (nowMs >= currentLine.bgWords[i].start) { + activeBgIdx = i; + break; + } + } + for (let i = 0; i < currentLine.bgWords.length; i++) { + const w = currentLine.bgWords[i]; + w.el.style.setProperty("transition", "none", "important"); + if (i < activeBgIdx) { + w.el.classList.add(CLS_FINISHED); + } else if (i === activeBgIdx) { + if (isSyl) { + const elapsed = nowMs - w.start; + if (elapsed >= w.duration) { + w.el.classList.add(CLS_FINISHED); + } else { + w.el.classList.add("rl-syl-active"); + w.el.style.animation = `rl-wipe ${w.duration}ms linear forwards`; + w.el.style.animationDelay = `-${elapsed}ms`; + } + } else { + w.el.classList.add(CLS_ACTIVE); + } + activeBgWordEls.set(lineIdx, w.el); + } + } + } + } + + // Force reflow so the suppressed transitions take effect, then restore them + void document.body.offsetHeight; + for (const line of lines) { + line.el.style.removeProperty("transition"); + for (const w of line.words) w.el.style.removeProperty("transition"); + for (const w of line.bgWords) w.el.style.removeProperty("transition"); + } + + // Re-apply blur positioning + if (settings.blurInactive && scrollSynced && blurActivated) { + applyInactiveBlurState(primaryLineIdx, false, activeLineIdxs); + } +}; + const updateTidalFollowActiveLine = (): void => { if (!isActive || lyricsMode !== "line-tidal" || lines.length === 0) return; @@ -2935,7 +3180,7 @@ const updateTidalFollowActiveLine = (): void => { for (let i = 0; i < lines.length; i++) { const tidalSpan = lines[i].tidalSpan; if (!tidalSpan) continue; - if (tidalSpan.getAttribute("data-current") === "true") { + if (isTidalSpanActive(tidalSpan)) { activeIndex = i; break; } @@ -2962,37 +3207,18 @@ const updateTidalFollowActiveLine = (): void => { primaryLineIdx = activeIndex; activeLineIdxs = newActiveSet; - if (settings.blurInactive && !blurActivated) { + if (settings.blurInactive && scrollSynced && !blurActivated) { blurActivated = true; - document - .querySelector(".rl-wbw-container") - ?.classList.add("rl-blur-active"); } - 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", - "rl-gap-hold", - ); - } - for (let dist = 1; dist <= 3; dist++) { - const before = activeIndex - dist; - const after = activeIndex + dist; - const cls = `rl-pos-${dist}`; - if (before >= 0) lines[before].el.classList.add(cls); - if (after < lines.length) lines[after].el.classList.add(cls); - } - } + applyInactiveBlurState(activeIndex); if (activeIndex !== prevPrimary) { const newLine = lines[activeIndex]; const scrollParent = findScroller(newLine.el); - lockScroll(scrollParent); - hookUserScroll(scrollParent); if (scrollSynced) { + lockScroll(scrollParent); + hookUserScroll(scrollParent); const lineRect = newLine.el.getBoundingClientRect(); const parentRect = scrollParent.getBoundingClientRect(); const targetOffset = parentRect.height * 0.2; @@ -3020,9 +3246,7 @@ const updateTidalFollowActiveLine = (): void => { const startTidalFollowLoop = (): void => { stopTidalFollowLoop(); - const lyricsContainer = document.querySelector( - '[data-test="lyrics-lines"]', - ) as HTMLElement; + const lyricsContainer = findLyricsContainer(); if (!lyricsContainer) return; tidalFollowObserver = new MutationObserver(() => { @@ -3032,7 +3256,7 @@ const startTidalFollowLoop = (): void => { subtree: true, childList: true, attributes: true, - attributeFilter: ["data-current"], + attributeFilter: ["class"], }); updateTidalFollowActiveLine(); @@ -3042,13 +3266,12 @@ const startTidalFollowLoop = (): void => { const watchForRerender = (): void => { unwatchRerender(); - const lyricsContainer = document.querySelector( - '[data-test="lyrics-lines"]', - ) as HTMLElement; + const lyricsContainer = + getLyricsRenderHost()?.container ?? getNowPlayingLyricsPanel(); if (!lyricsContainer) return; rerenderObserver = new MutationObserver(() => { - // tidal fire mutations in bursts + if (suppressRerenderObserver) return; if (rerenderDebounce !== null) { clearTimeout(rerenderDebounce); } @@ -3056,19 +3279,22 @@ const watchForRerender = (): void => { rerenderDebounce = null; if (!isActive || lyricsMode === "none") 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("Lyrics overlay: re-applying after Tidal re-render"); - hideTidalLyrics(); - if (lyricsMode === "line-tidal") { - const result = buildTidalLines(cachedTidalRomanizedLines); - lines = result.lines; - startTidalFollowLoop(); - } else if (lyricsData) { - const result = buildWordSpans(); - lines = result.lines; - } + runWithMutedRerenderObserver(() => { + hideTidalLyrics(); + if (lyricsMode === "line-tidal") { + const result = buildTidalLines(cachedTidalRomanizedLines); + lines = result.lines; + applyActiveLineStateNoTransition(); + startTidalFollowLoop(); + } else if (lyricsData) { + const result = buildWordSpans(); + lines = result.lines; + applyActiveLineStateNoTransition(); + } + }); } }, 100); }); @@ -3084,6 +3310,11 @@ const unwatchRerender = (): void => { clearTimeout(rerenderDebounce); rerenderDebounce = null; } + if (rerenderObserverMuteTimeout !== null) { + window.clearTimeout(rerenderObserverMuteTimeout); + rerenderObserverMuteTimeout = null; + } + suppressRerenderObserver = false; if (rerenderObserver) { rerenderObserver.disconnect(); rerenderObserver = null; @@ -3102,7 +3333,6 @@ const teardown = (): void => { trackChangeToken++; clearTickLoop(); stopTidalFollowLoop(); - clearInjectedLyricsTab(); clearScrollAnim(); unwatchRerender(); unhookUserScroll(); @@ -3120,26 +3350,37 @@ const teardown = (): void => { activeLineIdxs.clear(); primaryLineIdx = -1; clearLineSlideTimers(); - clearLineSlideTimers(); + clearSyntheticNativeLyrics(); restoreTidalLyrics(); }; -// find scrollable parent +// find scrollable parent — walk up but never past the now-playing boundary +// to avoid scrolling a shared ancestor that would shift the play queue const findScroller = (el: HTMLElement): HTMLElement => { + const lyricsPanel = el.closest( + '[data-test="now-playing-lyrics"]', + ) as HTMLElement | null; + if (lyricsPanel && lyricsPanel.scrollHeight > lyricsPanel.clientHeight) { + return lyricsPanel; + } + + const boundary = el.closest('[data-test="new-now-playing"]'); let parent = el.parentElement; while (parent) { + if (boundary && !boundary.contains(parent)) break; const style = window.getComputedStyle(parent); if ( style.overflowY === "auto" || style.overflowY === "scroll" || style.overflow === "auto" || - style.overflow === "scroll" + style.overflow === "scroll" || + parent.scrollHeight > parent.clientHeight + 1 ) { return parent; } parent = parent.parentElement; } - return document.documentElement; + return lyricsPanel ?? document.documentElement; }; // Lock scroll parent so tidal can't scroll to line spans @@ -3225,19 +3466,15 @@ const scrollToActiveLine = (): void => { }; // Resync lyric scroll (scrubbing and lyric jumps) -const resync = (): void => { +const resync = (syncNativeButton = true): void => { scrollSynced = true; - if (settings.blurInactive && blurActivated) { - document - .querySelector(".rl-wbw-container") - ?.classList.add("rl-blur-active"); - } + applyInactiveBlurState(primaryLineIdx, activeLineIdxs.size === 0, activeLineIdxs); scrollToActiveLine(); - const tidalSyncBtn = document.querySelector( - 'div[class*="_syncButton"] button', - ) as HTMLElement; - if (tidalSyncBtn) tidalSyncBtn.click(); + const nativeSyncButton = syncButtonEl; unhookSyncButton(); + if (syncNativeButton && nativeSyncButton?.isConnected) { + nativeSyncButton.click(); + } sylLog("[RL-Syllable] Scroll resynced"); }; @@ -3247,11 +3484,11 @@ const hookUserScroll = (parent: HTMLElement): void => { const onUserScroll = () => { if (!scrollSynced) return; scrollSynced = false; + clearScrollAnim(); if (settings.blurInactive) { - document - .querySelector(".rl-wbw-container") - ?.classList.remove("rl-blur-active"); + clearInactiveBlurState(); } + hookSyncButton(); sylLog("[RL-Syllable] User scrolled — auto-scroll unhooked"); }; parent.addEventListener("wheel", onUserScroll, { passive: true }); @@ -3277,7 +3514,7 @@ const hookSyncButton = (): void => { ) as HTMLElement; if (!btn) return; syncButtonEl = btn; - const handler = () => resync(); + const handler = () => resync(false); btn.addEventListener("click", handler); syncButtonListener = () => btn.removeEventListener("click", handler); }; @@ -3317,14 +3554,6 @@ const startTickLoop = (): void => { 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 (!isLineStyle && nowMs - lastLogTime >= 1000) { lastLogTime = nowMs; sylLog(`[RL-Syllable] Playback | ${nowMs.toFixed(0)} ms`); @@ -3436,25 +3665,18 @@ const startTickLoop = (): void => { } // activate blur on first lyric of the track - if (settings.blurInactive && !blurActivated && newActiveSet.size > 0) { + if ( + settings.blurInactive && + scrollSynced && + !blurActivated && + newActiveSet.size > 0 + ) { blurActivated = true; - document - .querySelector(".rl-wbw-container") - ?.classList.add("rl-blur-active"); } // 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"); - } + if (settings.blurInactive && newActiveSet.size === 0) { + applyInactiveBlurState(primaryLineIdx, true, newActiveSet); } activeLineIdxs = newActiveSet; @@ -3465,10 +3687,10 @@ const startTickLoop = (): void => { primaryLineIdx = newPrimary; const newLine = lines[primaryLineIdx]; const scrollParent = findScroller(newLine.el); - lockScroll(scrollParent); - hookUserScroll(scrollParent); if (scrollSynced) { + lockScroll(scrollParent); + hookUserScroll(scrollParent); const lineRect = newLine.el.getBoundingClientRect(); const parentRect = scrollParent.getBoundingClientRect(); const targetOffset = parentRect.height * 0.2; @@ -3497,20 +3719,7 @@ const startTickLoop = (): void => { } // 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); - } - } + applyInactiveBlurState(newPrimary, false, newActiveSet); } // hook lyric scroll sync button @@ -3663,6 +3872,7 @@ const onTrackChange = async (): Promise => { trace.log("could not get track info from playback state"); return; } + const nativeHasLyrics = trackHasNativeLyrics(trackInfo.trackId); sylTrace( `RL API: looking up "${trackInfo.title}" by "${trackInfo.artist}"${trackInfo.isrc ? ` (ISRC: ${trackInfo.isrc})` : ""}`, @@ -3700,6 +3910,20 @@ const onTrackChange = async (): Promise => { return; } + if (!nativeHasLyrics) { + const unlocked = registerSyntheticNativeLyrics(trackInfo, response); + if (!unlocked) { + trace.warn( + `RL API: found API lyrics for "${trackInfo.title}" but could not unlock native lyrics state`, + ); + teardown(); + return; + } + sylLog( + `[RL-Syllable] Registered synthetic native lyrics for "${trackInfo.title}"`, + ); + } + sylTrace( `RL API: loaded ${response.data.length} lines (source: ${response.metadata.source})`, ); @@ -3708,15 +3932,7 @@ const onTrackChange = async (): Promise => { ); lyricsMode = response.type === "Word" ? "word" : "line-api"; - if (!(await ensureLyricsTab())) { - trace.log("Could not create/find lyrics tab container"); - teardown(); - return; - } if (token !== trackChangeToken) return; - if (injectedTabEl && settings.stickyLyrics) { - showInjectedLyricsTab(); - } lyricsData = response.type === "Word" ? response.data @@ -3732,15 +3948,25 @@ const onTrackChange = async (): Promise => { // 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(); + // Build word spans only once the native panel has mounted. + const lyricsPanel = getNowPlayingLyricsPanel(); + if (lyricsPanel) { + const result = buildWordSpans(); + lines = result.lines; + watchForRerender(); + startTickLoop(); + } else { + watchForRerender(); + if (!nativeHasLyrics || settings.stickyLyrics || currentTrackWantsLyricsPanel()) { + safeTimeout(unloads, () => { + if (token !== trackChangeToken) return; + syncNativeLyricsAvailability(); + if (settings.stickyLyrics) { + tryActivateStickyLyricsTab(); + } + }, 0); + } + } } finally { if (runId === trackChangeRunSeq) { isTrackChangeRunning = false; @@ -3752,6 +3978,9 @@ const onTrackChange = async (): Promise => { const reapplyWordLyrics = (): void => { if (!lyricsData) return; + const savedPrimary = primaryLineIdx; + const savedActive = new Set(activeLineIdxs); + clearTickLoop(); clearScrollAnim(); unwatchRerender(); @@ -3769,12 +3998,20 @@ const reapplyWordLyrics = (): void => { hideTidalLyrics(); const result = buildWordSpans(); lines = result.lines; + + primaryLineIdx = savedPrimary; + activeLineIdxs = savedActive; + applyActiveLineStateNoTransition(); + watchForRerender(); startTickLoop(); sylLog("[RL-Syllable] Reapplied word/syllable lyrics (cached)"); }; const reapplyTidalLines = async (): Promise => { + const savedPrimary = primaryLineIdx; + const savedActive = new Set(activeLineIdxs); + clearTickLoop(); stopTidalFollowLoop(); clearScrollAnim(); @@ -3797,6 +4034,11 @@ const reapplyTidalLines = async (): Promise => { const result = buildTidalLines(romanized); lines = result.lines; if (lines.length === 0) return; + + primaryLineIdx = savedPrimary; + activeLineIdxs = savedActive; + applyActiveLineStateNoTransition(); + watchForRerender(); startTidalFollowLoop(); sylLog("[RL-Syllable] Reapplied TIDAL line lyrics (fallback)"); @@ -3862,10 +4104,10 @@ const setupTrackChangeListener = (): void => { }; function setupHeaderObserver(): void { - const existing = document.querySelector('[data-test="header-container"]'); + const existing = document.querySelector('[data-test="header"]'); if (existing && !document.querySelector(".hide-ui-button")) createHideUIButton(); - observe(unloads, '[data-test="header-container"]', () => { + observe(unloads, '[data-test="header"]', () => { if (!document.querySelector(".hide-ui-button")) createHideUIButton(); }); } @@ -3879,31 +4121,6 @@ function setupNowPlayingObserver(): void { }); } -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(); @@ -3913,6 +4130,5 @@ onGlobalTrackChange(() => { // Init observers setupHeaderObserver(); setupNowPlayingObserver(); -setupTrackTitleObserver(); setupStickyLyricsObserver(); setupTrackChangeListener(); diff --git a/plugins/radiant-lyrics-luna/src/lyrics-glow.css b/plugins/radiant-lyrics-luna/src/lyrics-glow.css index fbc4153..c39ea7b 100644 --- a/plugins/radiant-lyrics-luna/src/lyrics-glow.css +++ b/plugins/radiant-lyrics-luna/src/lyrics-glow.css @@ -28,7 +28,7 @@ } /* Enhanced lyrics styling with glow effects */ -[class*="_lyricsText"] > div > span[data-current="true"] { +[data-test="now-playing-lyrics"] span[data-test="lyrics-line"][class*="_current_"] { text-shadow: 0 0 var(--rl-glow-inner, 2px) var(--cl-glow1, #fff), /* biome-ignore lint: Required to override app glow strength */ @@ -44,12 +44,12 @@ font-weight: 700; } -[class*="_lyricsText"] > div > span { +[data-test="now-playing-lyrics"] span[data-test="lyrics-line"] { text-shadow: 0 0 0px transparent, 0 0 0px transparent; transition-duration: 0.25s; - color: rgba(128, 128, 128, 0.4); + color: rgba(255, 255, 255, 0.4); font-size: calc(40px * var(--rl-font-scale, 1)); font-family: "AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", @@ -57,7 +57,7 @@ font-weight: 700; } -[class*="_lyricsText"] > div > span:hover { +[data-test="now-playing-lyrics"] span[data-test="lyrics-line"]:hover { text-shadow: 0 0 var(--rl-glow-inner, 2px) lightgray, /* biome-ignore lint: Hover glow should override defaults */ @@ -68,31 +68,8 @@ transition-duration: 0.7s; } -/* Track title glow */ -[data-test="now-playing-track-title"] { - /* Title text color/gradient is left to default app styling; only glow is customized. */ - text-shadow: - 0 0 var(--rl-glow-inner, 1px) var(--cl-glow1, #fff), - /* biome-ignore lint: Title glow needs priority */ - 0 0 var(--rl-glow-outer, 30px) #fff !important; - /* biome-ignore lint: Reset vendor background clip */ - -webkit-background-clip: initial !important; - /* biome-ignore lint: Reset background clip */ - background-clip: initial !important; - /* biome-ignore lint: Reset vendor text fill */ - -webkit-text-fill-color: initial !important; - /* biome-ignore lint: Ensure inherited color takes precedence */ - color: inherit !important; -} - -/* When track title glow setting is disabled, remove glow regardless of Colorama */ -.rl-title-glow-disabled[data-test="now-playing-track-title"] { - /* biome-ignore lint: Full reset required */ - text-shadow: none !important; -} - /* Current line transitions */ -[class*="_lyricsText"] > div > span { +[data-test="now-playing-lyrics"] span[data-test="lyrics-line"] { transition: text-shadow 0.7s ease-in-out, color 0.7s ease-in-out, @@ -105,14 +82,11 @@ padding-left: var(--rl-glow-outer) !important; } -[data-rl-injected][role="tabpanel"] { - transform: translateX(calc(var(--rl-glow-outer) * -1)) !important; -} - /* Lyrics container styling */ -[class^="_lyricsContainer"] > div > div > span { +[data-test="now-playing-lyrics"] span[data-test="lyrics-line"] { margin-bottom: 2rem; - opacity: 1; + /* biome-ignore lint: Must beat Tidal _hasCues_ opacity */ + opacity: 1 !important; font-family: "AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; @@ -121,6 +95,11 @@ font-size: calc(38px * var(--rl-font-scale, 1)) !important; } +/* Hide the old Musixmatch attribution footer in the lyrics panel */ +[data-test="now-playing-lyrics"] [class*="_footer_"] { + display: none !important; +} + /* MARKER: WBW lyrics CSS */ /* hide tidal spans for wbw */ @@ -220,12 +199,14 @@ animation-delay: var(--rl-line-delay, 0ms); } -/* Word span */ +/* Word span — opacity override needed to beat Tidal's ._hasCues_ span { opacity:.35 } */ .rl-wbw-word { text-shadow: 0 0 0px transparent, 0 0 0px transparent; - color: rgba(128, 128, 128, 0.4); + color: rgba(255, 255, 255, 0.4); + /* biome-ignore lint: Must beat Tidal _hasCues_ opacity */ + opacity: 1 !important; transition: text-shadow 0.15s ease-out, color 0.15s ease-out; @@ -302,7 +283,7 @@ transparent 100% ), linear-gradient(90deg, var(--cl-glow1, #fff) 100%, transparent 100%), - linear-gradient(90deg, rgba(128, 128, 128, 0.4), rgba(128, 128, 128, 0.4)); + linear-gradient(90deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4)); background-size: 0.75em 100%, 0% 100%, @@ -379,7 +360,9 @@ "AbyssFont", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; font-weight: 700; - color: rgba(128, 128, 128, 0.4); + color: rgba(255, 255, 255, 0.4); + /* biome-ignore lint: Must beat Tidal _hasCues_ opacity */ + opacity: 1 !important; text-shadow: 0 0 0px transparent; margin-bottom: 2rem; } @@ -396,7 +379,7 @@ transition: max-height 0.3s ease, opacity 0.5s ease; - color: rgba(128, 128, 128, 0.4); + color: rgba(255, 255, 255, 0.4); } .rl-wbw-line.rl-wbw-line-active .rl-wbw-bg-container { @@ -434,8 +417,8 @@ } /* Reset glow when disabled */ -.lyrics-glow-disabled [class*="_lyricsText"] > div > span[data-current="true"], -.lyrics-glow-disabled [class*="_lyricsText"] > div > span:hover { +.lyrics-glow-disabled [data-test="now-playing-lyrics"] span[data-test="lyrics-line"][class*="_current_"], +.lyrics-glow-disabled [data-test="now-playing-lyrics"] span[data-test="lyrics-line"]:hover { /* biome-ignore lint: Kill glow on active/hover lines */ text-shadow: none !important; } diff --git a/plugins/radiant-lyrics-luna/src/styles.css b/plugins/radiant-lyrics-luna/src/styles.css index 38a2d15..1f83d40 100644 --- a/plugins/radiant-lyrics-luna/src/styles.css +++ b/plugins/radiant-lyrics-luna/src/styles.css @@ -11,8 +11,6 @@ /* Rounded corners */ [class*="_thumbnail_"], [class*="_imageWrapper_"], -[class*="_coverImage_"], -[class*="_overlayIconWrapperAlbum_"], [class*="_playButton_"] { border-radius: 5px !important; } @@ -20,18 +18,22 @@ /* MARKER: HideUI CSS*/ -/* Only apply styles when UI is hidden */ -.radiant-lyrics-ui-hidden [class*="tabItems"] { +/* Only apply styles when UI is hidden — hide toggle buttons */ +.radiant-lyrics-ui-hidden [data-test="toggle-lyrics"], +.radiant-lyrics-ui-hidden [data-test="toggle-credits"], +.radiant-lyrics-ui-hidden [data-test="toggle-similar-tracks"] { opacity: 0 !important; transition: opacity 0.4s ease-in-out; } -.radiant-lyrics-ui-hidden [class*="tabItems"]:hover { +.radiant-lyrics-ui-hidden [data-test="toggle-lyrics"]:hover, +.radiant-lyrics-ui-hidden [data-test="toggle-credits"]:hover, +.radiant-lyrics-ui-hidden [data-test="toggle-similar-tracks"]:hover { opacity: 1 !important; } /* Hide header container (search, minimize, fullscreen) when UI is hidden */ -.radiant-lyrics-ui-hidden [data-test="header-container"] { +.radiant-lyrics-ui-hidden [data-test="header"] { opacity: 0 !important; visibility: hidden !important; transition: opacity 0.4s ease-in-out, visibility 0s linear 0.4s; @@ -79,8 +81,8 @@ /* MARKER: Sticky Lyrics CSS */ -/* Lyrics tab */ -[data-test="tabs-lyrics"]:has(.sticky-lyrics-trigger) { +/* Lyrics toggle button */ +[data-test="toggle-lyrics"]:has(.sticky-lyrics-trigger) { position: relative !important; padding-right: 38px !important; } @@ -115,35 +117,41 @@ transition: background 0.2s ease; } -/* When Lyrics tab is active — show divider & make icon black*/ -[data-test="tabs-lyrics"][aria-selected="true"] .sticky-lyrics-trigger { - color: black; +/* When Lyrics toggle is pressed — show divider & adjust icon */ +[data-test="toggle-lyrics"][aria-pressed="true"] .sticky-lyrics-trigger { + color: rgb(30, 30, 30); cursor: pointer; } -[data-test="tabs-lyrics"][aria-selected="true"] .sticky-lyrics-trigger::before { - background: rgba(0, 0, 0, 0.25); +[data-test="toggle-lyrics"][aria-pressed="true"] .sticky-lyrics-trigger::before { + background: rgba(0, 0, 0, 0.15); } -[data-test="tabs-lyrics"][aria-selected="true"] .sticky-lyrics-trigger:hover { - color: rgba(0, 0, 0, 0.6); +[data-test="toggle-lyrics"][aria-pressed="true"] .sticky-lyrics-trigger:hover { + color: rgba(0, 0, 0, 0.5); } -/* Square the Lyrics button bottom corners when dropdown is open */ -[data-test="tabs-lyrics"].sticky-lyrics-open { - border-bottom-left-radius: 0 !important; - border-bottom-right-radius: 0 !important; +/* Animate widening when dropdown opens */ +[data-test="toggle-lyrics"]:has(.sticky-lyrics-trigger) { + transition: min-width 0.12s ease-out; } -/* Dropdown */ +/* Reduce top rounding, square bottom, and kill hover tint when dropdown is open */ +body.rl-dropdown-open [data-test="toggle-lyrics"] { + border-radius: 12px 12px 0 0 !important; + background-color: rgb(255, 255, 255) !important; + min-width: 150px !important; +} + +/* Dropdown — right-aligned under the Lyrics button */ .sticky-lyrics-dropdown { position: fixed; - background: white; - border-radius: 0 0 16px 16px; + background: rgb(255, 255, 255); + border-radius: 0 0 12px 12px; padding: 8px 12px 10px; box-sizing: border-box; z-index: 10000; - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); clip-path: inset(0 -20px -20px -20px); animation: stickyLyricsDropdownIn 0.12s ease-out; } @@ -151,11 +159,11 @@ @keyframes stickyLyricsDropdownIn { from { opacity: 0; - clip-path: inset(0 0 100% 0); + transform: translateY(-4px); } to { opacity: 1; - clip-path: inset(0 0 0 0); + transform: translateY(0); } } @@ -170,7 +178,7 @@ .sticky-lyrics-label { font-size: 11px; font-weight: 600; - color: rgba(0, 0, 0, 1); + color: rgba(0, 0, 0, 0.8); white-space: nowrap; } @@ -196,7 +204,7 @@ left: 0; right: 0; bottom: 0; - background-color: rgba(0, 0, 0, 0.2); + background-color: rgba(0, 0, 0, 0.15); transition: 0.3s; border-radius: 18px; } @@ -211,15 +219,16 @@ background-color: white; transition: 0.3s; border-radius: 50%; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); } .sticky-lyrics-switch input:checked + .sticky-lyrics-slider { - background-color: black; + background-color: rgb(30, 30, 30); } .sticky-lyrics-switch input:checked + .sticky-lyrics-slider::before { transform: translateX(16px); + background-color: rgb(255, 255, 255); } /* Segmented control (Line | Word | Syllable) */ @@ -230,7 +239,7 @@ .rl-seg-control { display: flex; - background: rgba(0, 0, 0, 0.08); + background: rgba(0, 0, 0, 0.06); border-radius: 10px; padding: 2px; gap: 2px; @@ -241,7 +250,7 @@ flex: 1; border: none; background: transparent; - color: rgba(0, 0, 0, 0.5); + color: rgba(0, 0, 0, 0.4); font-size: 10px; font-weight: 600; padding: 5px 0; @@ -253,99 +262,32 @@ .rl-seg-btn:hover { color: rgba(0, 0, 0, 0.7); - background: rgba(0, 0, 0, 0.05); + background: rgba(0, 0, 0, 0.06); } .rl-seg-btn.rl-seg-active { - background: white; - color: black; + background: rgb(30, 30, 30); + color: rgb(255, 255, 255); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); } + /* MARKER: PATCHES (Random Fixes for Tidals Changes) */ /* These change allot so i gave them their own section */ -/* Fixes the new Sticky Header Tidal added.. in the shittest jankiest way possible */ -/* [class*="_stickyHeader"] { - background: transparent !important; - backdrop-filter: blur(50px); - background-color: transparent !important; - width: fit-content !important; - padding-right: 3.5% !important; - -webkit-mask-image: - linear-gradient(to bottom, black 60%, transparent), - linear-gradient(to right, black 85%, transparent) !important; - mask-image: - linear-gradient(to bottom, black 60%, transparent), - linear-gradient(to right, black 85%, transparent) !important; - -webkit-mask-composite: source-in !important; - mask-composite: intersect !important; - padding-bottom: 5px !important; +/* Remove max-width cap on now-playing content */ +[class*="_contentInner"] { + max-width: none !important; } -[class*="_playQueueItems"]{ - border-radius: 2.5px 0 0 0 !important; +/* Round now-playing artwork corners */ +[data-test="now-playing-artwork"] { + /* biome-ignore lint: Override flat corners */ + border-radius: 10px !important; } -[data-test="playqueue-sticky-clear-active-items"] { - visibility: collapse !important; - width: 0px !important; -} - -[data-test="playqueue-sticky-clear-source-items"] { - visibility: collapse !important; - width: 0px !important; -} */ - - -/* Remove the background color from the small header */ -[class*="_smallHeader"]::before { - background-color: transparent !important; -} - -/* fixes Tidals broken mini cover art padding | Cheers Aya <3*/ -._imageBorder_110890a { - filter: opacity(0); -} -._container_14bcbd4._playingFrom_79b167e { - transform: scale(1.01) translatex(.1em); -} -._leftColumn_aaf28de { - min-height: 110%; - transform: translatey(-.23em); -} -._imageryContainer_f99fc07.image { - transform: scale(1.03) translatey(.2em) translatex(.1em); - background-color: #00000000; - padding: 0em !important; -} -._image_145331a._cellImage_0ef8dd3 { - border-radius: .7em !important; -} - -[data-test="footer-player"] { - ._container_14bcbd4._playingFrom_79b167e > ._text_15008b2._medium20_1lyag_192._marketText_1lyag_1 { - transform: translatey(-.2em); - } - [class="image _imageryContainer_f99fc07"] { - transform: translatey(.3em) !important; - } - ._image_145331a._cellImage_0ef8dd3 { - border-radius: .25em !important; - } - ._toggleButton_809eee8 { - transform: translateY(-.22em); - } - [class="image _imageryContainer_f99fc07"]:hover { - [class="_cellImage_0ef8dd3 _image_145331a"] { - filter: brightness(.3); - } - } - ._notFullscreenOverlay_1442d60 { - background: none !important; - transition: 0ms; - } - ._notFullscreenOverlay_1442d60 ._nowPlayingButton_c1a86fa { - background-color: rgba(245, 245, 220, 0); - } +/* Hide the Overlay Scrollbar (people just use mouse scroll) */ +.os-scrollbar { + display: none !important; + pointer-events: none !important; } \ No newline at end of file