diff --git a/plugins/radiant-lyrics-luna/src/Settings.tsx b/plugins/radiant-lyrics-luna/src/Settings.tsx index cbec929..15bcfe7 100644 --- a/plugins/radiant-lyrics-luna/src/Settings.tsx +++ b/plugins/radiant-lyrics-luna/src/Settings.tsx @@ -18,6 +18,9 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", { backgroundBrightness: 40, spinSpeed: 45, settingsAffectNowPlaying: true, + stickyLyricsFeature: true, + stickyLyrics: false, + stickyLyricsIcon: "chevron" as string, }); export const Settings = () => { @@ -61,6 +64,9 @@ export const Settings = () => { const [backgroundRadius, setBackgroundRadius] = React.useState( settings.backgroundRadius, ); + const [stickyLyricsFeature, setStickyLyricsFeature] = React.useState( + settings.stickyLyricsFeature, + ); // Derive props and override onChange to accept a broader first param type type BaseSwitchProps = React.ComponentProps; @@ -97,6 +103,17 @@ export const Settings = () => { } }} /> + { + setStickyLyricsFeature((settings.stickyLyricsFeature = checked)); + if ((window as any).updateStickyLyricsFeature) { + (window as any).updateStickyLyricsFeature(); + } + }} + /> { const isHiddenDoc = document.hidden; const images = document.querySelectorAll( @@ -846,27 +846,28 @@ document.addEventListener("visibilitychange", () => { }); }); -// Apply initial performance mode class +// Init performance mode if (settings.performanceMode) { document.body.classList.add("performance-mode"); } -// Initialize text glow CSS variables on load +// Init text glow updateRadiantLyricsTextGlow(); +// Init global background updateCoverArtBackground(1); -// Add cleanup to unloads +// Cleanups unloads.add(() => { cleanUpDynamicArt(); - // Clean up auto-fade timeout + // Clean up HideUI button auto-fade timeout if (unhideButtonAutoFadeTimeout != null) { window.clearTimeout(unhideButtonAutoFadeTimeout); unhideButtonAutoFadeTimeout = null; } - // Clean up our custom buttons + // Clean up HideUI button const hideButton = document.querySelector(".hide-ui-button"); if (hideButton && hideButton.parentNode) { hideButton.parentNode.removeChild(hideButton); @@ -877,16 +878,260 @@ unloads.add(() => { unhideButton.parentNode.removeChild(unhideButton); } + // Clean up sticky lyrics elements + document.querySelectorAll(".sticky-lyrics-trigger, .sticky-lyrics-dropdown").forEach((el) => { + el.remove(); + }); + // Clean up spin animations const spinAnimationStyle = document.querySelector("#spinAnimation"); if (spinAnimationStyle && spinAnimationStyle.parentNode) { spinAnimationStyle.parentNode.removeChild(spinAnimationStyle); } - // Clean up global spinning backgrounds + // Clean up spinning background cleanUpGlobalSpinningBackground(); }); + +// MARKER: Sticky Lyrics Feature + +const STICKY_ICONS: Record = { + chevron: '', + sparkle: '', +}; + +const getStickyIcon = (): string => STICKY_ICONS[settings.stickyLyricsIcon] ?? STICKY_ICONS.chevron; + +const applyStickyIcon = (): void => { + const trigger = document.querySelector(".sticky-lyrics-trigger") as HTMLElement; + if (!trigger) return; + trigger.innerHTML = getStickyIcon(); + trigger.style.paddingLeft = settings.stickyLyricsIcon === "sparkle" ? "5px" : "5px"; +}; + +// Console: StickyLyrics.icon = "sparkle" or "chevron" +// I'm picky and prefer the Sparkle.. shhh +(window as any).StickyLyrics = { + get icon() { return settings.stickyLyricsIcon; }, + set icon(value: string) { + const key = value.toLowerCase(); + if (!STICKY_ICONS[key]) { + console.log(`[Radiant Lyrics] Unknown icon "${value}". Available: ${Object.keys(STICKY_ICONS).join(", ")}`); + return; + } + settings.stickyLyricsIcon = key; + applyStickyIcon(); + console.log(`[Radiant Lyrics] Sticky Lyrics icon set to "${key}"`); + }, +}; + +// Tear down all sticky lyrics UI (trigger + dropdown + classes) +// For when the feature is disabled in plugin settings +const teardownStickyLyrics = (): void => { + document.querySelectorAll(".sticky-lyrics-trigger").forEach((el) => el.remove()); + document.querySelectorAll(".sticky-lyrics-dropdown").forEach((el) => el.remove()); + const lyricsTab = document.querySelector('[data-test="tabs-lyrics"]'); + if (lyricsTab) lyricsTab.classList.remove("sticky-lyrics-open"); +}; + +// Called from Settings +const updateStickyLyricsFeature = (): void => { + if (settings.stickyLyricsFeature) { + // Feature enabled - inject the dropdown + const tab = document.querySelector('[data-test="tabs-lyrics"]'); + if (tab && !tab.querySelector(".sticky-lyrics-trigger")) { + createStickyLyricsDropdown(); + } + } else { + // Feature disabled — remove everything & disable inner toggle + settings.stickyLyrics = false; + teardownStickyLyrics(); + } +}; +(window as any).updateStickyLyricsFeature = updateStickyLyricsFeature; + +const createStickyLyricsDropdown = (): void => { + if (!settings.stickyLyricsFeature) return; + const lyricsTab = document.querySelector( + '[data-test="tabs-lyrics"]', + ) as HTMLElement; + if (!lyricsTab) return; + if (lyricsTab.querySelector(".sticky-lyrics-trigger")) return; + + // Trigger + // lives inside the Lyrics
  • + const trigger = document.createElement("div"); + trigger.className = "sticky-lyrics-trigger"; + trigger.setAttribute("title", "Sticky Lyrics"); + + // Set the icon & it's styling + // is only needed because i'm picky and prefer the Sparkle.. shhh + trigger.innerHTML = getStickyIcon(); + //trigger.style.paddingLeft = settings.stickyLyricsIcon === "sparkle" ? "5px" : "5px"; + + // Block non-click events on trigger from reaching the Lyrics tab (capture phase) + // (capture phase stops the tab from activating & runs the toggle before the event is consumed by the SVG child) - Thx React.. again.. + for (const evtName of ["pointerdown", "pointerup", "mousedown", "mouseup"] as const) { + trigger.addEventListener(evtName, (e: Event) => { + e.stopPropagation(); + }, true); + } + + // Dropdown + // lives in document.body so its events never touch the Lyrics tab - Thx React.. + const dropdown = document.createElement("div"); + dropdown.className = "sticky-lyrics-dropdown"; + dropdown.style.display = "none"; + + dropdown.innerHTML = ` +
    + Sticky Lyrics + +
    + `; + + // Toggle dropdown on trigger click + const openDropdown = (): void => { + const buttonRect = lyricsTab.getBoundingClientRect(); + dropdown.style.top = `${buttonRect.bottom}px`; + dropdown.style.left = `${buttonRect.left}px`; + dropdown.style.width = `${buttonRect.width}px`; + dropdown.style.display = "block"; + lyricsTab.classList.add("sticky-lyrics-open"); + }; + const closeDropdown = (): void => { + dropdown.style.display = "none"; + lyricsTab.classList.remove("sticky-lyrics-open"); + }; + + trigger.addEventListener("click", (e: MouseEvent) => { + e.stopPropagation(); + const isActive = lyricsTab.getAttribute("aria-selected") === "true"; + if (!isActive) { + // Navigate to Lyrics & open dropdown + lyricsTab.click(); + // Delay to let the tab activate + setTimeout(() => openDropdown(), 150); + return; + } + // Toggle dropdown + if (dropdown.style.display === "none") { + openDropdown(); + } else { + closeDropdown(); + } + }, true); + + // Handle toggle switch change + const checkbox = dropdown.querySelector( + 'input[type="checkbox"]', + ) as HTMLInputElement; + checkbox.addEventListener("change", () => { + settings.stickyLyrics = checkbox.checked; + if (settings.stickyLyrics) { + handleStickyLyricsTrackChange(); + } + }); + + // Close dropdown when clicking outside trigger & dropdown + const handleOutsideClick = (e: MouseEvent): void => { + if (!trigger.contains(e.target as Node) && !dropdown.contains(e.target as Node)) { + closeDropdown(); + } + }; + document.addEventListener("click", handleOutsideClick); + + // Trigger goes inside the Lyrics
  • & dropdown goes in + lyricsTab.appendChild(trigger); + document.body.appendChild(dropdown); + + // Register cleanup + unloads.add(() => { + document.removeEventListener("click", handleOutsideClick); + lyricsTab.classList.remove("sticky-lyrics-open"); + trigger.remove(); + dropdown.remove(); + }); +}; + +// Handle switching tabs on track change +const handleStickyLyricsTrackChange = (): void => { + if (!settings.stickyLyricsFeature || !settings.stickyLyrics) return; + + // Process the track change and update tab state + // Tidal takes a while to process the track change sometimes :( + setTimeout(() => { + if (!settings.stickyLyricsFeature || !settings.stickyLyrics) return; + + const lyricsTab = document.querySelector( + '[data-test="tabs-lyrics"]', + ) as HTMLElement; + const playQueueTab = document.querySelector( + '[data-test="tabs-play-queue"]', + ) as HTMLElement; + + if (!lyricsTab) { + // fall back to play queue + if (playQueueTab) playQueueTab.click(); + return; + } + + // Attempt to switch to lyrics + lyricsTab.click(); + + // Verify we actually stayed on lyrics after a short delay + // TODO: Make not shitty (one day maybe) + setTimeout(() => { + if (!settings.stickyLyrics) return; + const onLyrics = document.querySelector( + '[data-test="tabs-lyrics"][aria-selected="true"]', + ); + if (!onLyrics && playQueueTab) { + // Got redirected away from lyrics - fall back to play queue + playQueueTab.click(); + } + }, 800); + }, 1200); +}; + +// Observer: create dropdown when lyrics tab appears & detect track changes +function setupStickyLyricsObserver(): void { + // Create dropdown if lyrics tab already exists + if (settings.stickyLyricsFeature) { + const existing = document.querySelector('[data-test="tabs-lyrics"]'); + if (existing && !existing.querySelector(".sticky-lyrics-trigger")) { + createStickyLyricsDropdown(); + } + } + + // Re-create dropdown whenever lyrics tab is back from the ether + observe(unloads, '[data-test="tabs-lyrics"]', () => { + if (!settings.stickyLyricsFeature) return; + const tab = document.querySelector('[data-test="tabs-lyrics"]'); + if (tab && !tab.querySelector(".sticky-lyrics-trigger")) { + createStickyLyricsDropdown(); + } + }); + + // Detect track changes & trigger sticky lyrics switching + let stickyLastTrackId: string | null = + PlayState.playbackContext?.actualProductId ?? null; + const checkStickyTrackChange = (): void => { + if (!settings.stickyLyricsFeature || !settings.stickyLyrics) return; + const currentTrackId = PlayState.playbackContext?.actualProductId; + if (currentTrackId && currentTrackId !== stickyLastTrackId) { + stickyLastTrackId = currentTrackId; + handleStickyLyricsTrackChange(); + } + }; + const stickyIntervalId = setInterval(checkStickyTrackChange, 500); + unloads.add(() => clearInterval(stickyIntervalId)); +} + // Marker: Observers // Shared observer-based hooks and polling fallbacks const observeTrackChanges = (): void => { @@ -957,8 +1202,9 @@ function setupTrackTitleObserver(): void { ); } -// Initialize the button creation and observers (non-polling) +// Init observers setupHeaderObserver(); setupNowPlayingObserver(); setupTrackTitleObserver(); observeTrackChanges(); +setupStickyLyricsObserver(); diff --git a/plugins/radiant-lyrics-luna/src/styles.css b/plugins/radiant-lyrics-luna/src/styles.css index a94d983..2586df1 100644 --- a/plugins/radiant-lyrics-luna/src/styles.css +++ b/plugins/radiant-lyrics-luna/src/styles.css @@ -1,14 +1,14 @@ -/* Sidebar with dynamic hash */ +/* Sidebar */ [class*="_sidebar_"] { background-color: transparent !important; } -/* Section header with dynamic hash */ +/* Section header */ [class*="_sectionHeader_"] { background-color: transparent !important; } -/* Rounded corners for various elements */ +/* Rounded corners */ [class*="_thumbnail_"], [class*="_imageWrapper_"], [class*="_coverImage_"], @@ -17,6 +17,9 @@ border-radius: 5px !important; } + +/* MARKER: HideUI CSS*/ + /* Only apply styles when UI is hidden */ .radiant-lyrics-ui-hidden [class*="tabItems"] { opacity: 0 !important; @@ -74,6 +77,156 @@ } +/* MARKER: Sticky Lyrics CSS */ + +/* Lyrics tab */ +[data-test="tabs-lyrics"]:has(.sticky-lyrics-trigger) { + position: relative !important; + padding-right: 38px !important; +} + +/* Trigger */ +.sticky-lyrics-trigger { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 38px; + display: flex; + align-items: center; + justify-content: center; + padding-left: 5px; + padding-right: 0px; + box-sizing: border-box; + cursor: default; + color: #CCCCD1; + transition: color 0.2s ease; +} + +/* Divider line */ +.sticky-lyrics-trigger::before { + content: ""; + position: absolute; + left: 5px; + top: 4px; + bottom: 4px; + width: 1px; + background: transparent; + 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; + cursor: pointer; +} + +[data-test="tabs-lyrics"][aria-selected="true"] .sticky-lyrics-trigger::before { + background: rgba(0, 0, 0, 0.25); +} + +[data-test="tabs-lyrics"][aria-selected="true"] .sticky-lyrics-trigger:hover { + color: rgba(0, 0, 0, 0.6); +} + +/* 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; +} + +/* Dropdown */ +.sticky-lyrics-dropdown { + position: fixed; + background: white; + border-radius: 0 0 16px 16px; + padding: 8px 12px 10px; + box-sizing: border-box; + z-index: 10000; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); + clip-path: inset(0 -20px -20px -20px); + animation: stickyLyricsDropdownIn 0.12s ease-out; +} + +@keyframes stickyLyricsDropdownIn { + from { + opacity: 0; + clip-path: inset(0 0 100% 0); + } + to { + opacity: 1; + clip-path: inset(0 0 0 0); + } +} + +/* Row containing label + toggle */ +.sticky-lyrics-dropdown-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.sticky-lyrics-label { + font-size: 11px; + font-weight: 600; + color: rgba(0, 0, 0, 1); + white-space: nowrap; +} + +/* Toggle switch */ +.sticky-lyrics-switch { + position: relative; + display: inline-block; + width: 34px; + height: 18px; + flex-shrink: 0; +} + +.sticky-lyrics-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.sticky-lyrics-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.2); + transition: 0.3s; + border-radius: 18px; +} + +.sticky-lyrics-slider::before { + position: absolute; + content: ""; + height: 14px; + width: 14px; + left: 2px; + bottom: 2px; + background-color: white; + transition: 0.3s; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.sticky-lyrics-switch input:checked + .sticky-lyrics-slider { + background-color: black; +} + +.sticky-lyrics-switch input:checked + .sticky-lyrics-slider::before { + transform: translateX(16px); +} + + + +/* 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;