From 9d6afcaaf5a4900e1918e6079373a4de486db09d Mon Sep 17 00:00:00 2001 From: meowarex Date: Sat, 28 Mar 2026 20:55:33 +1100 Subject: [PATCH] Lyrics Dropdown --- plugins/radiant-lyrics-luna/src/index.ts | 273 +++++++++++++-------- plugins/radiant-lyrics-luna/src/styles.css | 71 +++--- 2 files changed, 219 insertions(+), 125 deletions(-) diff --git a/plugins/radiant-lyrics-luna/src/index.ts b/plugins/radiant-lyrics-luna/src/index.ts index 28e4f42..ddb1596 100644 --- a/plugins/radiant-lyrics-luna/src/index.ts +++ b/plugins/radiant-lyrics-luna/src/index.ts @@ -1060,7 +1060,7 @@ const STICKY_ICONS: Record = { chevron: '', sparkle: - '', + '', }; const getStickyIcon = (): string => @@ -1144,36 +1144,40 @@ const updateStickyLyricsFeature = (): void => { }; (window as any).updateStickyLyricsFeature = updateStickyLyricsFeature; -const createStickyLyricsDropdown = (): void => { - const lyricsToggle = document.querySelector( - '[data-test="toggle-lyrics"]', - ) as HTMLElement; - if (!lyricsToggle) return; - if (lyricsToggle.querySelector(".sticky-lyrics-trigger")) return; +let stickyDropdownEl: HTMLElement | null = null; +let stickyDropdownOpen = false; +const DROPDOWN_WIDTH = 150; - // Trigger lives inside the toggle button - const trigger = document.createElement("div"); - trigger.className = "sticky-lyrics-trigger"; - trigger.setAttribute("title", "Sticky Lyrics"); - trigger.innerHTML = getStickyIcon(); +const positionDropdown = (): void => { + if (!stickyDropdownEl) return; + const toggle = document.querySelector('[data-test="toggle-lyrics"]') as HTMLElement; + if (!toggle) return; + const rect = toggle.getBoundingClientRect(); + stickyDropdownEl.style.top = `${rect.bottom}px`; + stickyDropdownEl.style.left = `${rect.left}px`; + stickyDropdownEl.style.width = `${rect.width}px`; + stickyDropdownEl.style.display = "block"; +}; - // Block non-click events from reaching the toggle button (capture phase) - for (const evtName of [ - "pointerdown", - "pointerup", - "mousedown", - "mouseup", - ] as const) { - trigger.addEventListener( - evtName, - (e: Event) => { - e.stopPropagation(); - }, - true, - ); - } +const openStickyDropdown = (toggle: HTMLElement): void => { + stickyDropdownOpen = true; + document.body.classList.add("rl-dropdown-open"); + const onWidened = () => { + toggle.removeEventListener("transitionend", onWidened); + positionDropdown(); + }; + toggle.addEventListener("transitionend", onWidened); +}; + +const closeStickyDropdown = (): void => { + stickyDropdownOpen = false; + document.body.classList.remove("rl-dropdown-open"); + if (stickyDropdownEl) stickyDropdownEl.style.display = "none"; +}; + +const ensureStickyDropdown = (): HTMLElement => { + if (stickyDropdownEl) return stickyDropdownEl; - // Dropdown lives in document.body const dropdown = document.createElement("div"); dropdown.className = "sticky-lyrics-dropdown"; dropdown.style.display = "none"; @@ -1195,39 +1199,6 @@ const createStickyLyricsDropdown = (): void => { `; - const openDropdown = (): void => { - const buttonRect = lyricsToggle.getBoundingClientRect(); - const dropWidth = Math.max(buttonRect.width, 150); - dropdown.style.top = `${buttonRect.bottom}px`; - dropdown.style.left = `${buttonRect.right - dropWidth}px`; - dropdown.style.width = `${dropWidth}px`; - dropdown.style.display = "block"; - lyricsToggle.classList.add("sticky-lyrics-open"); - }; - const closeDropdown = (): void => { - dropdown.style.display = "none"; - lyricsToggle.classList.remove("sticky-lyrics-open"); - }; - - trigger.addEventListener( - "click", - (e: MouseEvent) => { - e.stopPropagation(); - const isActive = lyricsToggle.getAttribute("aria-pressed") === "true"; - if (!isActive) { - lyricsToggle.click(); - safeTimeout(unloads, () => openDropdown(), 150); - return; - } - if (dropdown.style.display === "none") { - openDropdown(); - } else { - closeDropdown(); - } - }, - true, - ); - const stickyCheckbox = dropdown.querySelector( 'input[data-setting="stickyLyrics"]', ) as HTMLInputElement; @@ -1258,25 +1229,84 @@ const createStickyLyricsDropdown = (): void => { }); } - 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); - - lyricsToggle.appendChild(trigger); - document.body.appendChild(dropdown); + document.addEventListener("click", outsideHandler); unloads.add(() => { - document.removeEventListener("click", handleOutsideClick); - lyricsToggle.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 @@ -1286,37 +1316,38 @@ const tryActivateStickyLyricsTab = (): boolean => { const lyricsToggle = document.querySelector( '[data-test="toggle-lyrics"]', ) as HTMLElement; - if (!lyricsToggle) return false; + if (!lyricsToggle || lyricsToggle.getAttribute("aria-disabled") === "true") { + tryActivateSimilarTracksTab(); + return false; + } if (syntheticNativeLyrics) { notifyNativeLyricsStateChanged(); } - // Already pressed — nothing to do if (lyricsToggle.getAttribute("aria-pressed") === "true") return true; lyricsToggle.click(); return true; }; +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(); }; -// Handle ensuring lyrics panel stays open on track change const handleStickyLyricsTrackChange = (): void => { if (!settings.stickyLyrics) return; - - safeTimeout( - unloads, - () => { - if (!settings.stickyLyrics) return; - syncNativeLyricsAvailability(); - tryActivateStickyLyricsTab(); - }, - 1200, - ); + tryActivateStickyLyricsTab(); }; // Track change sequencing (used by onTrackChange) @@ -1343,6 +1374,19 @@ 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="now-playing-lyrics"]', () => { if (isTrackChangeRunning) return; @@ -1704,7 +1748,7 @@ const buildSyntheticLrcText = (response: LyricsApiResponse): string => .map((line) => { const text = ("romanized" in line && line.romanized ? line.romanized : line.text) ?? ""; - return `${formatLrcTime(line.startTime)}${text}`; + return `[${formatLrcTime(line.startTime)}]${text}`; }) .join("\n"); @@ -1737,6 +1781,33 @@ const clearSyntheticNativeLyrics = (): void => { 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; @@ -1829,6 +1900,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(); @@ -3179,6 +3252,7 @@ const watchForRerender = (): void => { if (!lyricsContainer) return; rerenderObserver = new MutationObserver(() => { + if (suppressRerenderObserver) return; if (rerenderDebounce !== null) { clearTimeout(rerenderDebounce); } @@ -3189,17 +3263,19 @@ const watchForRerender = (): void => { 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; - applyActiveLineStateNoTransition(); - startTidalFollowLoop(); - } else if (lyricsData) { - const result = buildWordSpans(); - lines = result.lines; - applyActiveLineStateNoTransition(); - } + 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); }); @@ -3215,6 +3291,11 @@ const unwatchRerender = (): void => { clearTimeout(rerenderDebounce); rerenderDebounce = null; } + if (rerenderObserverMuteTimeout !== null) { + window.clearTimeout(rerenderObserverMuteTimeout); + rerenderObserverMuteTimeout = null; + } + suppressRerenderObserver = false; if (rerenderObserver) { rerenderObserver.disconnect(); rerenderObserver = null; diff --git a/plugins/radiant-lyrics-luna/src/styles.css b/plugins/radiant-lyrics-luna/src/styles.css index edcc71f..cb15837 100644 --- a/plugins/radiant-lyrics-luna/src/styles.css +++ b/plugins/radiant-lyrics-luna/src/styles.css @@ -121,34 +121,39 @@ /* When Lyrics toggle is pressed — show divider & adjust icon */ [data-test="toggle-lyrics"][aria-pressed="true"] .sticky-lyrics-trigger { - color: white; + color: rgb(30, 30, 30); cursor: pointer; } [data-test="toggle-lyrics"][aria-pressed="true"] .sticky-lyrics-trigger::before { - background: rgba(255, 255, 255, 0.25); + background: rgba(0, 0, 0, 0.15); } [data-test="toggle-lyrics"][aria-pressed="true"] .sticky-lyrics-trigger:hover { - color: rgba(255, 255, 255, 0.6); + color: rgba(0, 0, 0, 0.5); } -/* Square the Lyrics button bottom-right corner when dropdown is open (right-aligned) */ -[data-test="toggle-lyrics"].sticky-lyrics-open { - 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; +} + +/* 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: rgba(30, 30, 30, 0.92); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 16px 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.5); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); clip-path: inset(0 -20px -20px -20px); animation: stickyLyricsDropdownIn 0.12s ease-out; } @@ -156,11 +161,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); } } @@ -175,7 +180,7 @@ .sticky-lyrics-label { font-size: 11px; font-weight: 600; - color: rgba(255, 255, 255, 0.9); + color: rgba(0, 0, 0, 0.8); white-space: nowrap; } @@ -201,7 +206,7 @@ left: 0; right: 0; bottom: 0; - background-color: rgba(255, 255, 255, 0.2); + background-color: rgba(0, 0, 0, 0.15); transition: 0.3s; border-radius: 18px; } @@ -216,16 +221,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: rgb(255, 255, 255); + background-color: rgb(30, 30, 30); } .sticky-lyrics-switch input:checked + .sticky-lyrics-slider::before { transform: translateX(16px); - background-color: rgb(30, 30, 30); + background-color: rgb(255, 255, 255); } /* Segmented control (Line | Word | Syllable) */ @@ -236,7 +241,7 @@ .rl-seg-control { display: flex; - background: rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.06); border-radius: 10px; padding: 2px; gap: 2px; @@ -247,7 +252,7 @@ flex: 1; border: none; background: transparent; - color: rgba(255, 255, 255, 0.5); + color: rgba(0, 0, 0, 0.4); font-size: 10px; font-weight: 600; padding: 5px 0; @@ -258,19 +263,27 @@ } .rl-seg-btn:hover { - color: rgba(255, 255, 255, 0.8); - background: rgba(255, 255, 255, 0.08); + color: rgba(0, 0, 0, 0.7); + background: rgba(0, 0, 0, 0.06); } .rl-seg-btn.rl-seg-active { - background: rgba(255, 255, 255, 0.9); - color: rgb(16, 16, 16); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + 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 */ +/* Remove max-width cap on now-playing content - NEW UI*/ +[class*="_contentInner"] { + max-width: none !important; +} + + +/* LEGACY UI */ + /* Fixes the new Sticky Header Tidal added.. in the shittest jankiest way possible */ /* [class*="_stickyHeader"] { background: transparent !important; @@ -303,13 +316,13 @@ width: 0px !important; } */ - -/* Remove the background color from the small header */ +/* +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*/ +Fixes Tidals broken mini cover art padding | Cheers Aya <3 ._imageBorder_110890a { filter: opacity(0); } @@ -354,4 +367,4 @@ ._notFullscreenOverlay_1442d60 ._nowPlayingButton_c1a86fa { background-color: rgba(245, 245, 220, 0); } -} \ No newline at end of file +} */ \ No newline at end of file