diff --git a/plugins/radiant-lyrics-luna/src/index.ts b/plugins/radiant-lyrics-luna/src/index.ts index 569f62f..94b6d4c 100644 --- a/plugins/radiant-lyrics-luna/src/index.ts +++ b/plugins/radiant-lyrics-luna/src/index.ts @@ -536,14 +536,59 @@ const toggleRadiantLyrics = function (): void { // Create buttons let flushLocked = false; +let flushSpinning = false; + +// Swap the flush button icon between the lyrics-sync glyph and a loading spinner +const setFlushSpinner = (on: boolean): void => { + flushSpinning = on; + const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement | null; + if (!btn) return; + const svg = btn.querySelector("svg"); + if (!svg) return; + const svgClass = (svg.getAttribute("class") ?? "") + .split(/\s+/) + .filter((c) => c && c !== "rl-spinner"); + if (on) { + if (svg.classList.contains("rl-spinner")) return; + btn.setAttribute("data-tip", "Loading lyrics…"); + btn.setAttribute("aria-label", "Loading lyrics…"); + const spinner = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + if (svgClass.length) spinner.setAttribute("class", svgClass.join(" ")); + spinner.classList.add("rl-spinner"); + spinner.setAttribute("viewBox", "0 0 20 20"); + spinner.setAttribute("aria-hidden", "true"); + const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + circle.setAttribute("cx", "10"); + circle.setAttribute("cy", "10"); + circle.setAttribute("r", "8"); + circle.setAttribute( + "style", + "fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-dasharray:42 8", + ); + spinner.appendChild(circle); + svg.replaceWith(spinner); + } else { + const spinner = btn.querySelector("svg.rl-spinner"); + if (!spinner) return; + const syncSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + if (svgClass.length) syncSvg.setAttribute("class", svgClass.join(" ")); + syncSvg.setAttribute("viewBox", "0 0 20 20"); + const use = document.createElementNS("http://www.w3.org/2000/svg", "use"); + use.setAttribute("href", "#general__lyrics-sync"); + syncSvg.appendChild(use); + spinner.replaceWith(syncSvg); + } +}; + const unlockFlush = (): void => { flushLocked = false; + setFlushSpinner(false); const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement; if (btn) { btn.disabled = false; btn.style.opacity = ""; btn.style.cursor = ""; - btn.setAttribute("title", "Flush Lyrics"); + btn.setAttribute("data-tip", "Flush Lyrics"); btn.setAttribute("aria-label", "Flush Lyrics"); } }; @@ -555,15 +600,17 @@ const lockFlush = (): void => { btn.style.opacity = "0.3"; btn.style.cursor = "not-allowed"; } + setFlushSpinner(true); }; const disableFlushNoLyrics = (): void => { flushLocked = true; + setFlushSpinner(false); const btn = document.querySelector(".flush-lyrics-button") as HTMLButtonElement; if (btn) { btn.disabled = true; btn.style.opacity = "0.3"; btn.style.cursor = "not-allowed"; - btn.setAttribute("title", "Track has no lyrics"); + btn.setAttribute("data-tip", "Track has no lyrics"); btn.setAttribute("aria-label", "Track has no lyrics"); } }; @@ -584,9 +631,11 @@ const flushLyrics = async (): Promise => { if (!result.ok) { if (result.notFound) { toast("No lyrics found for this track"); + unlockFlush(); return; } toastErr(`Flush failed (${result.status})`); + unlockFlush(); return; } const data = result.data; @@ -602,9 +651,12 @@ const flushLyrics = async (): Promise => { cachedLyricsKey = null; cachedLyricsData = null; onTrackChange(); + } else { + unlockFlush(); } } catch (err) { toastErr(`Flush error: ${err instanceof Error ? err.message : String(err)}`); + unlockFlush(); } }; @@ -622,7 +674,7 @@ const flushBtn = function (): void { flushButton.removeAttribute("data-test"); flushButton.setAttribute("type", "button"); flushButton.setAttribute("aria-label", "Flush Lyrics"); - flushButton.setAttribute("title", "Flush Lyrics"); + flushButton.setAttribute("data-tip", "Flush Lyrics"); flushButton.disabled = true; flushButton.style.opacity = "0.3"; flushButton.style.cursor = "not-allowed"; @@ -640,6 +692,12 @@ const flushBtn = function (): void { svgEl.appendChild(useEl); spanWrapper.appendChild(svgEl); flushButton.appendChild(spanWrapper); + // Button recreated while lyrics are still loading — show the spinner again + if (flushSpinning) { + flushButton.setAttribute("data-tip", "Loading lyrics…"); + flushButton.setAttribute("aria-label", "Loading lyrics…"); + setFlushSpinner(true); + } flushButton.onclick = () => flushLyrics(); @@ -4009,6 +4067,7 @@ const onTrackChange = async (): Promise => { if (token !== trackChangeToken) return; if (!trackInfo) { trace.log("could not get track info from playback state"); + setFlushSpinner(false); return; } const nativeHasLyrics = trackHasNativeLyrics(trackInfo.trackId); diff --git a/plugins/radiant-lyrics-luna/src/styles.css b/plugins/radiant-lyrics-luna/src/styles.css index c84b9e2..083128d 100644 --- a/plugins/radiant-lyrics-luna/src/styles.css +++ b/plugins/radiant-lyrics-luna/src/styles.css @@ -49,6 +49,48 @@ /* MARKER: Lyrics Menu CSS */ /* Lyrics toggle button */ + +/* Flush lyrics button — loading spinner while waiting for lyrics */ +.flush-lyrics-button svg.rl-spinner { + animation: rl-spinner-rotate 0.9s linear infinite; +} + +@keyframes rl-spinner-rotate { + to { + transform: rotate(360deg); + } +} + +/* Flush lyrics button — hover tooltip (native title doesn't show reliably) */ +.flush-lyrics-button { + position: relative !important; +} + +.flush-lyrics-button::after { + content: attr(data-tip); + position: absolute; + top: calc(100% + 6px); + right: 0; + padding: 4px 8px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.85); + color: #fff; + font-size: 12px; + font-weight: 500; + line-height: 1.3; + white-space: nowrap; + pointer-events: none; + z-index: 10000; + opacity: 0; + transform: translateY(-2px); + transition: opacity 0.12s ease, transform 0.12s ease; +} + +.flush-lyrics-button:hover::after { + opacity: 1; + transform: translateY(0); +} + [data-test="toggle-lyrics"]:has(.rl-lyrics-menu-trigger) { position: relative !important; padding-right: 38px !important;