Merge pull request #59 from meowarex/dev

Added Sticky Lyrics
This commit is contained in:
Meow Meow
2026-02-09 22:57:46 +11:00
committed by GitHub
3 changed files with 428 additions and 12 deletions
@@ -18,6 +18,9 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
backgroundBrightness: 40, backgroundBrightness: 40,
spinSpeed: 45, spinSpeed: 45,
settingsAffectNowPlaying: true, settingsAffectNowPlaying: true,
stickyLyricsFeature: true,
stickyLyrics: false,
stickyLyricsIcon: "chevron" as string,
}); });
export const Settings = () => { export const Settings = () => {
@@ -61,6 +64,9 @@ export const Settings = () => {
const [backgroundRadius, setBackgroundRadius] = React.useState( const [backgroundRadius, setBackgroundRadius] = React.useState(
settings.backgroundRadius, settings.backgroundRadius,
); );
const [stickyLyricsFeature, setStickyLyricsFeature] = React.useState(
settings.stickyLyricsFeature,
);
// Derive props and override onChange to accept a broader first param type // Derive props and override onChange to accept a broader first param type
type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>; type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>;
@@ -97,6 +103,17 @@ export const Settings = () => {
} }
}} }}
/> />
<AnySwitch
title="Sticky Lyrics"
desc="Adds a dropdown to the Lyrics tab that auto-switches to Play Queue when lyrics aren't available"
checked={stickyLyricsFeature}
onChange={(_: unknown, checked: boolean) => {
setStickyLyricsFeature((settings.stickyLyricsFeature = checked));
if ((window as any).updateStickyLyricsFeature) {
(window as any).updateStickyLyricsFeature();
}
}}
/>
<AnySwitch <AnySwitch
title="Hide UI Feature" title="Hide UI Feature"
desc="Enable hide/unhide UI functionality with toggle buttons" desc="Enable hide/unhide UI functionality with toggle buttons"
+255 -9
View File
@@ -815,11 +815,11 @@ const cleanUpDynamicArt = function (): void {
element.remove(); element.remove();
}); });
// Also clean up global spinning backgrounds // Clean up spinning background
cleanUpGlobalSpinningBackground(); cleanUpGlobalSpinningBackground();
}; };
// Reduce work when tab hidden: pause animations; restore on visible // I may or may not have forgotten what this does..
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
const isHiddenDoc = document.hidden; const isHiddenDoc = document.hidden;
const images = document.querySelectorAll( const images = document.querySelectorAll(
@@ -846,27 +846,28 @@ document.addEventListener("visibilitychange", () => {
}); });
}); });
// Apply initial performance mode class // Init performance mode
if (settings.performanceMode) { if (settings.performanceMode) {
document.body.classList.add("performance-mode"); document.body.classList.add("performance-mode");
} }
// Initialize text glow CSS variables on load // Init text glow
updateRadiantLyricsTextGlow(); updateRadiantLyricsTextGlow();
// Init global background
updateCoverArtBackground(1); updateCoverArtBackground(1);
// Add cleanup to unloads // Cleanups
unloads.add(() => { unloads.add(() => {
cleanUpDynamicArt(); cleanUpDynamicArt();
// Clean up auto-fade timeout // Clean up HideUI button auto-fade timeout
if (unhideButtonAutoFadeTimeout != null) { if (unhideButtonAutoFadeTimeout != null) {
window.clearTimeout(unhideButtonAutoFadeTimeout); window.clearTimeout(unhideButtonAutoFadeTimeout);
unhideButtonAutoFadeTimeout = null; unhideButtonAutoFadeTimeout = null;
} }
// Clean up our custom buttons // Clean up HideUI button
const hideButton = document.querySelector(".hide-ui-button"); const hideButton = document.querySelector(".hide-ui-button");
if (hideButton && hideButton.parentNode) { if (hideButton && hideButton.parentNode) {
hideButton.parentNode.removeChild(hideButton); hideButton.parentNode.removeChild(hideButton);
@@ -877,16 +878,260 @@ unloads.add(() => {
unhideButton.parentNode.removeChild(unhideButton); 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 // Clean up spin animations
const spinAnimationStyle = document.querySelector("#spinAnimation"); const spinAnimationStyle = document.querySelector("#spinAnimation");
if (spinAnimationStyle && spinAnimationStyle.parentNode) { if (spinAnimationStyle && spinAnimationStyle.parentNode) {
spinAnimationStyle.parentNode.removeChild(spinAnimationStyle); spinAnimationStyle.parentNode.removeChild(spinAnimationStyle);
} }
// Clean up global spinning backgrounds // Clean up spinning background
cleanUpGlobalSpinningBackground(); cleanUpGlobalSpinningBackground();
}); });
// MARKER: Sticky Lyrics Feature
const STICKY_ICONS: Record<string, string> = {
chevron: '<svg viewBox="0 0 24 24" width="10" height="10" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M4.29289 8.29289C4.68342 7.90237 5.31658 7.90237 5.70711 8.29289L12 14.5858L18.2929 8.29289C18.6834 7.90237 19.3166 7.90237 19.7071 8.29289C20.0976 8.68342 20.0976 9.31658 19.7071 9.70711L12.7071 16.7071C12.3166 17.0976 11.6834 17.0976 11.2929 16.7071L4.29289 9.70711C3.90237 9.31658 3.90237 8.68342 4.29289 8.29289Z" fill="currentColor"/></svg>',
sparkle: '<svg viewBox="0 0 512 512" width="12" height="12"><path fill="currentColor" d="M208,512a24.84,24.84,0,0,1-23.34-16l-39.84-103.6a16.06,16.06,0,0,0-9.19-9.19L32,343.34a25,25,0,0,1,0-46.68l103.6-39.84a16.06,16.06,0,0,0,9.19-9.19L184.66,144a25,25,0,0,1,46.68,0l39.84,103.6a16.06,16.06,0,0,0,9.19,9.19l103,39.63A25.49,25.49,0,0,1,400,320.52a24.82,24.82,0,0,1-16,22.82l-103.6,39.84a16.06,16.06,0,0,0-9.19,9.19L231.34,496A24.84,24.84,0,0,1,208,512Zm66.85-254.84h0Z"/><path fill="currentColor" d="M88,176a14.67,14.67,0,0,1-13.69-9.4L57.45,122.76a7.28,7.28,0,0,0-4.21-4.21L9.4,101.69a14.67,14.67,0,0,1,0-27.38L53.24,57.45a7.31,7.31,0,0,0,4.21-4.21L74.16,9.79A15,15,0,0,1,86.23.11,14.67,14.67,0,0,1,101.69,9.4l16.86,43.84a7.31,7.31,0,0,0,4.21,4.21L166.6,74.31a14.67,14.67,0,0,1,0,27.38l-43.84,16.86a7.28,7.28,0,0,0-4.21,4.21L101.69,166.6A14.67,14.67,0,0,1,88,176Z"/><path fill="currentColor" d="M400,256a16,16,0,0,1-14.93-10.26l-22.84-59.37a8,8,0,0,0-4.6-4.6l-59.37-22.84a16,16,0,0,1,0-29.86l59.37-22.84a8,8,0,0,0,4.6-4.6L384.9,42.68a16.45,16.45,0,0,1,13.17-10.57,16,16,0,0,1,16.86,10.15l22.84,59.37a8,8,0,0,0,4.6,4.6l59.37,22.84a16,16,0,0,1,0,29.86l-59.37,22.84a8,8,0,0,0-4.6,4.6l-22.84,59.37A16,16,0,0,1,400,256Z"/></svg>',
};
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 <li>
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 = `
<div class="sticky-lyrics-dropdown-row">
<span class="sticky-lyrics-label">Sticky Lyrics</span>
<label class="sticky-lyrics-switch">
<input type="checkbox" ${settings.stickyLyrics ? "checked" : ""}>
<span class="sticky-lyrics-slider"></span>
</label>
</div>
`;
// 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 <li> & dropdown goes in <body>
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<HTMLElement>(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 // Marker: Observers
// Shared observer-based hooks and polling fallbacks // Shared observer-based hooks and polling fallbacks
const observeTrackChanges = (): void => { const observeTrackChanges = (): void => {
@@ -957,8 +1202,9 @@ function setupTrackTitleObserver(): void {
); );
} }
// Initialize the button creation and observers (non-polling) // Init observers
setupHeaderObserver(); setupHeaderObserver();
setupNowPlayingObserver(); setupNowPlayingObserver();
setupTrackTitleObserver(); setupTrackTitleObserver();
observeTrackChanges(); observeTrackChanges();
setupStickyLyricsObserver();
+156 -3
View File
@@ -1,14 +1,14 @@
/* Sidebar with dynamic hash */ /* Sidebar */
[class*="_sidebar_"] { [class*="_sidebar_"] {
background-color: transparent !important; background-color: transparent !important;
} }
/* Section header with dynamic hash */ /* Section header */
[class*="_sectionHeader_"] { [class*="_sectionHeader_"] {
background-color: transparent !important; background-color: transparent !important;
} }
/* Rounded corners for various elements */ /* Rounded corners */
[class*="_thumbnail_"], [class*="_thumbnail_"],
[class*="_imageWrapper_"], [class*="_imageWrapper_"],
[class*="_coverImage_"], [class*="_coverImage_"],
@@ -17,6 +17,9 @@
border-radius: 5px !important; border-radius: 5px !important;
} }
/* MARKER: HideUI CSS*/
/* Only apply styles when UI is hidden */ /* Only apply styles when UI is hidden */
.radiant-lyrics-ui-hidden [class*="tabItems"] { .radiant-lyrics-ui-hidden [class*="tabItems"] {
opacity: 0 !important; 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 */ /* Fixes the new Sticky Header Tidal added.. in the shittest jankiest way possible */
[class*="_stickyHeader"] { [class*="_stickyHeader"] {
background: transparent !important; background: transparent !important;