8 Commits

Author SHA1 Message Date
meoware.exe 5a7d4f9c07 updated platform for DX 2026-06-15 23:59:35 +10:00
meoware.exe 8a86de1b39 Literally just changed the README 2026-06-01 20:24:32 +10:00
meoware.exe f7fa918473 Fix Audio Visualizer player hook <3 2026-06-01 20:16:11 +10:00
meoware.exe fa273705ad Fix Romanization <3 2026-06-01 19:17:02 +10:00
meoware.exe f069d7eae2 Fix Copy Lyrics <3 2026-05-29 16:22:17 +10:00
meoware.exe 497f3a95b0 Fix Tidals Playlist control header style 2026-05-29 15:57:13 +10:00
meoware.exe 734e0012cc Fixed Element hider counter leak 2026-05-21 00:18:03 +10:00
Meow Meow 3d8a755c0f Merge pull request #121 from meowarex/fix/pnpm-11-allow-builds
fix(ci): use pnpm 11 `allowBuilds` syntax and pin pnpm version
2026-05-17 23:31:11 +10:00
10 changed files with 372 additions and 268 deletions
+9 -16
View File
@@ -1,29 +1,23 @@
# Luna Plugins Collection # Luna Plugins Collection
A collection of Luna plugins for Tidal, ported from Neptune framework. A collection of [TidaLuna](https://github.com/Inrixia/TidaLuna) plugins that enhance and personalize the TIDAL Desktop experience. Build them yourself or grab them straight from the Plugin Store. Made with <3
## Plugins ## Plugins
### 🎨 Obsidian ### Radiant Lyrics
**Location:** `plugins/obsidian-theme-luna/`
A dark OLED-friendly theme that transforms Tidal Luna's appearance.
**Features:**
- Applies a dark, OLED-optimized theme
- Reduces battery consumption on OLED displays.. i guess <3
- Modern, sleek dark interface
### 🎵 Radiant Lyrics
**Location:** `plugins/radiant-lyrics-luna/` **Location:** `plugins/radiant-lyrics-luna/`
A radiant and beautiful lyrics view for TIDAL with dynamic visual effects. A radiant and beautiful lyrics view for TIDAL with dynamic visual effects.
**Features:** **Features:**
- Dynamic cover art backgrounds with blur and rotation effects - Dynamic cover art backgrounds with blur and rotation effects
- Complete overhaul of tidals UI
- Syllable level lyric highlighting
- Romanization of lyrics
- Fully customizable
- Glowing Animated Lyrics with clean scrolling - Glowing Animated Lyrics with clean scrolling
### 📋 Copy Lyrics ### Copy Lyrics
**Location:** `plugins/copy-lyrics-luna/` **Location:** `plugins/copy-lyrics-luna/`
Allows users to copy song lyrics by selecting them directly in the interface. Allows users to copy song lyrics by selecting them directly in the interface.
@@ -33,7 +27,7 @@ Allows users to copy song lyrics by selecting them directly in the interface.
- Automatic clipboard copying of selected lyrics - Automatic clipboard copying of selected lyrics
- Smart lyric span detection - Smart lyric span detection
### 🧽 Element Hider ### Element Hider
**Location:** `plugins/element-hider-luna/` **Location:** `plugins/element-hider-luna/`
Allows users to hide/remove UI elements by right clicking on them. Allows users to hide/remove UI elements by right clicking on them.
@@ -43,7 +37,7 @@ Allows users to hide/remove UI elements by right clicking on them.
- Automagically saves hidden elements - Automagically saves hidden elements
- Allows for elements to be restored - Allows for elements to be restored
### 🎶 Audio Visualizer ### Audio Visualizer
**Location:** `plugins/audio-visualizer-luna/` **Location:** `plugins/audio-visualizer-luna/`
⚠️ **Work in Progress** - Audio visualization plugin that displays real-time audio frequency data. ⚠️ **Work in Progress** - Audio visualization plugin that displays real-time audio frequency data.
@@ -115,4 +109,3 @@ This project is made for:
## Credits ## Credits
Inrixia | [TidalLuna](https://github.com/Inrixia/TidaLuna) Plugin Framework (The successor Neptune) Inrixia | [TidalLuna](https://github.com/Inrixia/TidaLuna) Plugin Framework (The successor Neptune)
ItzzExcel | The Original Neptune version of "Radiant Lyrics" (Which was ported to Luna and Rewritten by me!)
+161 -50
View File
@@ -6,8 +6,13 @@ let leftAnalyser: AnalyserNode | null = null;
let rightAnalyser: AnalyserNode | null = null; let rightAnalyser: AnalyserNode | null = null;
let splitter: ChannelSplitterNode | null = null; let splitter: ChannelSplitterNode | null = null;
let audioSource: MediaStreamAudioSourceNode | null = null; let audioSource: MediaStreamAudioSourceNode | null = null;
let trackedVideo: HTMLVideoElement | null = null; let trackedEl: HTMLMediaElement | null = null;
let connected = false; let capturedTrack: MediaStreamTrack | null = null;
let trackCleanup: (() => void) | null = null;
let docCleanup: (() => void) | null = null;
let desiredFFT = 2048;
let desiredSmoothing = 0.8;
let monoByteFreq: Uint8Array | null = null; let monoByteFreq: Uint8Array | null = null;
let monoByteTime: Uint8Array | null = null; let monoByteTime: Uint8Array | null = null;
@@ -28,7 +33,33 @@ export interface AudioData {
binCount: number; binCount: number;
} }
// Connection states
// disconnected - no audio source
// pending - wired but audio isn't detected (track muted / loading)
// live - audio is detected
export type ConnectionState = "disconnected" | "pending" | "live";
let state: ConnectionState = "disconnected";
let onStateChange: ((state: ConnectionState) => void) | null = null;
export const setOnStateChange = (cb: ((state: ConnectionState) => void) | null): void => {
onStateChange = cb;
};
export const getState = (): ConnectionState => state;
export const isLive = (): boolean => state === "live";
const setState = (next: ConnectionState): void => {
if (next === state) return;
state = next;
try {
onStateChange?.(next);
} catch {}
};
// Analyser / buffer setup
export const setFFTSize = (size: number): void => { export const setFFTSize = (size: number): void => {
desiredFFT = size;
if (monoAnalyser) monoAnalyser.fftSize = size; if (monoAnalyser) monoAnalyser.fftSize = size;
if (leftAnalyser) leftAnalyser.fftSize = size; if (leftAnalyser) leftAnalyser.fftSize = size;
if (rightAnalyser) rightAnalyser.fftSize = size; if (rightAnalyser) rightAnalyser.fftSize = size;
@@ -36,6 +67,7 @@ export const setFFTSize = (size: number): void => {
}; };
export const setSmoothing = (value: number): void => { export const setSmoothing = (value: number): void => {
desiredSmoothing = value;
if (monoAnalyser) monoAnalyser.smoothingTimeConstant = value; if (monoAnalyser) monoAnalyser.smoothingTimeConstant = value;
if (leftAnalyser) leftAnalyser.smoothingTimeConstant = value; if (leftAnalyser) leftAnalyser.smoothingTimeConstant = value;
if (rightAnalyser) rightAnalyser.smoothingTimeConstant = value; if (rightAnalyser) rightAnalyser.smoothingTimeConstant = value;
@@ -64,16 +96,16 @@ const createAnalyser = (ctx: AudioContext, fftSize: number, smoothing: number):
return a; return a;
}; };
const ensureContext = (fftSize: number, smoothing: number): boolean => { const ensureContext = (): boolean => {
try { try {
if (!audioContext || audioContext.state === "closed") { if (!audioContext || audioContext.state === "closed") {
audioContext = new AudioContext(); audioContext = new AudioContext();
} }
if (!monoAnalyser) { if (!monoAnalyser) {
monoAnalyser = createAnalyser(audioContext, fftSize, smoothing); monoAnalyser = createAnalyser(audioContext, desiredFFT, desiredSmoothing);
leftAnalyser = createAnalyser(audioContext, fftSize, smoothing); leftAnalyser = createAnalyser(audioContext, desiredFFT, desiredSmoothing);
rightAnalyser = createAnalyser(audioContext, fftSize, smoothing); rightAnalyser = createAnalyser(audioContext, desiredFFT, desiredSmoothing);
splitter = audioContext.createChannelSplitter(2); splitter = audioContext.createChannelSplitter(2);
splitter.connect(leftAnalyser, 0); splitter.connect(leftAnalyser, 0);
splitter.connect(rightAnalyser, 1); splitter.connect(rightAnalyser, 1);
@@ -91,69 +123,144 @@ const ensureContext = (fftSize: number, smoothing: number): boolean => {
} }
}; };
const disconnectSource = (): void => { // Source / track wiring
if (audioSource) {
try { audioSource.disconnect(); } catch {} const clearTrackListeners = (): void => {
audioSource = null; trackCleanup?.();
} trackCleanup = null;
connected = false;
}; };
const captureFromVideo = (video: HTMLVideoElement): boolean => { const detachSource = (): void => {
const capture = (video as unknown as { captureStream?: () => MediaStream }).captureStream; clearTrackListeners();
if (audioSource) {
try {
audioSource.disconnect();
} catch {}
audioSource = null;
}
capturedTrack = null;
trackedEl = null;
};
// captureFromEl() is retried after log spam to stop it <3
let loggedCaptureFailure = false;
const logCaptureFailureOnce = (message: string): void => {
if (loggedCaptureFailure) return;
loggedCaptureFailure = true;
log(message);
};
const captureFromEl = (el: HTMLMediaElement): boolean => {
const capture = (el as unknown as { captureStream?: () => MediaStream }).captureStream;
if (typeof capture !== "function") { if (typeof capture !== "function") {
log("captureStream() not available on video element"); logCaptureFailureOnce("captureStream() not available on media element");
return false; return false;
} }
let stream: MediaStream;
try { try {
disconnectSource(); stream = capture.call(el);
} catch (err) {
logCaptureFailureOnce(`captureStream() failed: ${err}`);
return false;
}
const stream = capture.call(video); const tracks = stream.getAudioTracks();
const tracks = stream.getAudioTracks(); // No audio track yet
if (tracks.length === 0) { if (tracks.length === 0) return false;
log("No audio tracks in captured stream"); if (!ensureContext()) return false;
return false;
}
detachSource();
const track = tracks[0];
try {
audioSource = audioContext!.createMediaStreamSource(stream); audioSource = audioContext!.createMediaStreamSource(stream);
audioSource.connect(monoAnalyser!); audioSource.connect(monoAnalyser!);
audioSource.connect(splitter!); audioSource.connect(splitter!);
trackedVideo = video;
connected = true;
log("Audio connected via captureStream()");
return true;
} catch (err) { } catch (err) {
log(`captureStream() failed: ${err}`); log(`Failed to connect captured stream: ${err}`);
return false; audioSource = null;
}
};
export const connect = (fftSize = 2048, smoothing = 0.8): boolean => {
if (!ensureContext(fftSize, smoothing)) return false;
const video = document.getElementById("video-one") as HTMLVideoElement | null;
if (!video) {
log("video-one element not found");
return false; return false;
} }
return captureFromVideo(video); trackedEl = el;
capturedTrack = track;
const onUnmute = () => setState("live");
const onMute = () => {
if (state === "live") setState("pending");
};
const onEnded = () => {
detachSource();
setState("pending");
};
track.addEventListener("unmute", onUnmute);
track.addEventListener("mute", onMute);
track.addEventListener("ended", onEnded);
trackCleanup = () => {
track.removeEventListener("unmute", onUnmute);
track.removeEventListener("mute", onMute);
track.removeEventListener("ended", onEnded);
};
// Captured tracks start muted until samples flow; wait for "unmute" instead of guessing.
loggedCaptureFailure = false;
setState(track.muted ? "pending" : "live");
return true;
}; };
export const reconnect = (fftSize = 2048, smoothing = 0.8): boolean => { // An element is worth capturing from when it's actually advancing audio.
disconnectSource(); const isPlayingAudio = (el: HTMLMediaElement): boolean => !el.paused && !el.ended && el.readyState >= 2;
trackedVideo = null;
return connect(fftSize, smoothing);
const captureFrom = (el: HTMLMediaElement): void => {
if (el === trackedEl) {
if (state === "live") return;
if (capturedTrack && capturedTrack.readyState === "live" && !capturedTrack.muted) {
setState("live"); // healthy track, we just missed its unmute event
return;
}
}
captureFromEl(el);
};
// Capture media events (timeupdate & playing etc..)
const MEDIA_ACTIVE_EVENTS = ["playing", "timeupdate"] as const;
const MEDIA_RESET_EVENTS = ["pause", "ended", "emptied", "abort"] as const;
const onMediaActive = (e: Event): void => {
const el = e.target;
if (el instanceof HTMLMediaElement && isPlayingAudio(el)) captureFrom(el);
}; };
export const isConnected = (): boolean => connected; const onMediaReset = (e: Event): void => {
if (e.target === trackedEl) {
detachSource();
setState("pending");
}
};
export const videoChanged = (): boolean => { /** global media listeners */
const video = document.getElementById("video-one") as HTMLVideoElement | null; export const init = (): void => {
if (!video) return false; ensureContext();
return video !== trackedVideo; if (docCleanup) return;
for (const ev of MEDIA_ACTIVE_EVENTS) document.addEventListener(ev, onMediaActive, true);
for (const ev of MEDIA_RESET_EVENTS) document.addEventListener(ev, onMediaReset, true);
docCleanup = () => {
for (const ev of MEDIA_ACTIVE_EVENTS) document.removeEventListener(ev, onMediaActive, true);
for (const ev of MEDIA_RESET_EVENTS) document.removeEventListener(ev, onMediaReset, true);
};
};
/** capture from whatever is already playing (plugin loaded mid-playback) */
export const scan = (): void => {
if (!ensureContext()) return;
for (const el of document.querySelectorAll<HTMLMediaElement>(
"video, audio",
)) {
if (isPlayingAudio(el)) {
captureFrom(el);
if (state === "live") return;
}
}
}; };
export const sample = (): AudioData | null => { export const sample = (): AudioData | null => {
@@ -190,7 +297,12 @@ export const hasSignal = (data: AudioData): boolean => {
}; };
export const dispose = (): void => { export const dispose = (): void => {
disconnectSource(); docCleanup?.();
docCleanup = null;
detachSource();
setState("disconnected");
onStateChange = null;
if (audioContext && audioContext.state !== "closed") { if (audioContext && audioContext.state !== "closed") {
audioContext.close().catch(() => {}); audioContext.close().catch(() => {});
} }
@@ -199,7 +311,6 @@ export const dispose = (): void => {
leftAnalyser = null; leftAnalyser = null;
rightAnalyser = null; rightAnalyser = null;
splitter = null; splitter = null;
trackedVideo = null;
monoByteFreq = null; monoByteFreq = null;
monoByteTime = null; monoByteTime = null;
monoFloatFreq = null; monoFloatFreq = null;
+9 -86
View File
@@ -276,85 +276,23 @@ if (existingArtist) attachNpGroups(existingArtist);
const existingFooter = document.querySelector('[data-test="footer-player"]'); const existingFooter = document.querySelector('[data-test="footer-player"]');
if (existingFooter) attachPbGroups(existingFooter); if (existingFooter) attachPbGroups(existingFooter);
// Audio Connection stuff // Audio Connection
const fft = () => settings.fftSize ?? 2048; const fft = () => settings.fftSize ?? 2048;
const reactivityToSmoothing = (r: number) => Math.max(0, Math.min(0.95, (100 - r) / 100)); const reactivityToSmoothing = (r: number) => Math.max(0, Math.min(0.95, (100 - r) / 100));
const smooth = () => reactivityToSmoothing(settings.reactivity ?? 30); const smooth = () => reactivityToSmoothing(settings.reactivity ?? 30);
let lastReactivity = settings.reactivity ?? 30; let lastReactivity = settings.reactivity ?? 30;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let retryDelay = 500;
const MAX_RETRY_DELAY = 5000;
let silentFrames = 0;
const SILENT_THRESHOLD = 120;
const clearRetry = (): void => { audio.setOnStateChange((state) => {
if (retryTimer !== null) { if (state === "live") log("Audio connected");
clearTimeout(retryTimer);
retryTimer = null;
}
retryDelay = 500;
};
const tryConnect = (): boolean => {
const ok = audio.connect(fft(), smooth());
if (ok) {
clearRetry();
silentFrames = 0;
}
return ok;
};
const tryReconnect = (): boolean => {
const ok = audio.reconnect(fft(), smooth());
if (ok) {
clearRetry();
silentFrames = 0;
}
return ok;
};
const scheduleRetry = (): void => {
if (retryTimer !== null) return;
retryTimer = setTimeout(() => {
retryTimer = null;
if (!PlayState.playing) return;
if (!tryConnect()) {
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY);
scheduleRetry();
}
}, retryDelay);
};
observe(unloads, "#video-one", () => {
log("video-one element observed in DOM");
silentFrames = 0;
if (PlayState.playing) {
if (!tryReconnect()) scheduleRetry();
}
}); });
PlayState.onState(unloads, (state) => { PlayState.onState(unloads, (state) => {
if (state === "PLAYING") { if (state === "PLAYING") audio.scan();
silentFrames = 0;
if (!audio.isConnected() || audio.videoChanged()) {
if (!tryReconnect()) scheduleRetry();
}
} else {
clearRetry();
}
}); });
MediaItem.onMediaTransition(unloads, () => { MediaItem.onMediaTransition(unloads, () => audio.scan());
log("Media transition");
silentFrames = 0;
setTimeout(() => {
if (PlayState.playing) {
if (!tryReconnect()) scheduleRetry();
}
}, 300);
});
// Idle Animation Synthetic Data // Idle Animation Synthetic Data
@@ -478,21 +416,6 @@ const animate = (): void => {
const data = audio.sample(); const data = audio.sample();
const hasSignal = data && audio.hasSignal(data); const hasSignal = data && audio.hasSignal(data);
if (PlayState.playing && audio.isConnected()) {
if (!hasSignal) {
silentFrames++;
if (silentFrames >= SILENT_THRESHOLD) {
log("Silent for too long, reconnecting...");
silentFrames = 0;
if (!tryReconnect()) scheduleRetry();
}
} else {
silentFrames = 0;
}
} else if (PlayState.playing && !audio.isConnected() && retryTimer === null) {
scheduleRetry();
}
// idleMode: 0 = animated, 1 = hide when idle, 2 = static when idle // idleMode: 0 = animated, 1 = hide when idle, 2 = static when idle
const idleMode = settings.idleMode ?? 0; const idleMode = settings.idleMode ?? 0;
const newIdleHidden = !hasSignal && idleMode === 1; const newIdleHidden = !hasSignal && idleMode === 1;
@@ -521,9 +444,10 @@ const animate = (): void => {
log("Initializing..."); log("Initializing...");
if (PlayState.playing) { audio.setFFTSize(fft());
if (!tryConnect()) scheduleRetry(); audio.setSmoothing(smooth());
} audio.init();
audio.scan();
animationId = requestAnimationFrame(animate); animationId = requestAnimationFrame(animate);
@@ -531,7 +455,6 @@ animationId = requestAnimationFrame(animate);
unloads.add(() => { unloads.add(() => {
log("Plugin unloading"); log("Plugin unloading");
clearRetry();
document.body.classList.remove("av-chromeless"); document.body.classList.remove("av-chromeless");
+142 -83
View File
@@ -15,7 +15,11 @@ new StyleTag("Copy-Lyrics", unloads, unlockSelection);
function SetClipboard(text: string): void { function SetClipboard(text: string): void {
const textarea = document.createElement("textarea"); const textarea = document.createElement("textarea");
textarea.value = text; textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed"; // Avoid scrolling to bottom textarea.style.position = "fixed"; // Avoid scrolling to bottom
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.opacity = "0";
document.body.appendChild(textarea); document.body.appendChild(textarea);
textarea.select(); textarea.select();
@@ -29,101 +33,152 @@ function SetClipboard(text: string): void {
} }
} }
let isSelecting = false; const LINE_SELECTORS = [
".rl-wbw-container .rl-wbw-line",
'[data-test="now-playing-lyrics"] span[data-test="lyrics-line"]',
'[class*="_lyricsText"] > div > span',
].join(",");
const onMouseDown = (): void => { const OVERLAY_LINE_SELECTOR = ".rl-wbw-container .rl-wbw-line";
isSelecting = true; const LYRICS_ROOT_SELECTOR = [
'[data-test="now-playing-lyrics"]',
'[class*="_lyricsText"]',
".rl-wbw-container",
].join(",");
let isPointerDownInLyrics = false;
let suppressNextClick = false;
let suppressClickResetTimer: number | null = null;
const isElement = (node: Node | null): node is Element =>
Boolean(node && node.nodeType === Node.ELEMENT_NODE);
const getElementFromNode = (node: Node | null): Element | null => {
if (!node) return null;
return isElement(node) ? node : node.parentElement;
};
const isInLyrics = (node: Node | null): boolean =>
Boolean(getElementFromNode(node)?.closest(LYRICS_ROOT_SELECTOR));
const rangeIntersectsNode = (range: Range, node: Node): boolean => {
try {
return range.intersectsNode(node);
} catch {
return false;
}
};
const normalizeLineText = (text: string): string =>
text
.replace(/\u00a0/g, " ")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n[ \t]+/g, "\n")
.replace(/[ \t]{2,}/g, " ")
.trim();
const getTextInsideRange = (line: HTMLElement, range: Range): string => {
if (
!line.contains(range.startContainer) &&
!line.contains(range.endContainer)
) {
return normalizeLineText(line.textContent ?? "");
}
const selected = document.createRange();
selected.selectNodeContents(line);
if (line.contains(range.startContainer)) {
selected.setStart(range.startContainer, range.startOffset);
}
if (line.contains(range.endContainer)) {
selected.setEnd(range.endContainer, range.endOffset);
}
return normalizeLineText(selected.toString());
};
const getSelectedLines = (range: Range, selector: string): HTMLElement[] =>
Array.from(document.querySelectorAll(selector)).filter(
(node): node is HTMLElement =>
node instanceof HTMLElement && rangeIntersectsNode(range, node),
);
const getLyricsTextFromRange = (range: Range): string => {
const overlayLines = getSelectedLines(range, OVERLAY_LINE_SELECTOR);
const lines =
overlayLines.length > 0
? overlayLines
: getSelectedLines(range, LINE_SELECTORS);
if (lines.length === 0) {
return isInLyrics(range.commonAncestorContainer)
? normalizeLineText(range.toString())
: "";
}
return lines
.map((line) =>
line.classList.contains("rl-wbw-spacer")
? ""
: getTextInsideRange(line, range),
)
.join("\n")
.trim();
};
const getSelectedLyricsText = (selection: Selection): string => {
const chunks: string[] = [];
for (let i = 0; i < selection.rangeCount; i++) {
const text = getLyricsTextFromRange(selection.getRangeAt(i));
if (text.length > 0) chunks.push(text);
}
return chunks.join("\n").trim();
};
const suppressUpcomingClick = (): void => {
suppressNextClick = true;
if (suppressClickResetTimer !== null) {
window.clearTimeout(suppressClickResetTimer);
}
suppressClickResetTimer = window.setTimeout(() => {
suppressNextClick = false;
suppressClickResetTimer = null;
}, 250);
};
const onMouseDown = (event: MouseEvent): void => {
isPointerDownInLyrics = isInLyrics(event.target as Node | null);
}; };
const onMouseUp = (): void => { const onMouseUp = (): void => {
if (isSelecting) { if (!isPointerDownInLyrics) return;
const selection = window.getSelection();
if (selection?.toString().length > 0) {
const selectedSpans: HTMLSpanElement[] = [];
const range = selection.getRangeAt(0);
let container: Node | null = range.commonAncestorContainer;
// Normalize container: if it's a text node, use its parent element/node const selection = window.getSelection();
if (container && container.nodeType === Node.TEXT_NODE) { if (selection?.toString().trim()) {
container = (container.parentElement ?? container.parentNode) as Node | null; const text = getSelectedLyricsText(selection);
} if (text.length > 0) {
SetClipboard(text);
// If parent has data-current, treat as single-line copy case trace.msg.log("Copied to clipboard!");
if ( selection.removeAllRanges();
container && suppressUpcomingClick();
container.nodeType === Node.ELEMENT_NODE &&
(container as Element).hasAttribute("data-current")
) {
const text_ = selection.toString().trim();
SetClipboard(text_);
trace.msg.log("Copied to clipboard!");
return;
}
// Ensure we have an Element or Document before querying
if (
!container ||
(container.nodeType !== Node.ELEMENT_NODE &&
container.nodeType !== Node.DOCUMENT_NODE)
) {
isSelecting = false;
return;
}
// Get all the spans inside the container.
const spans = (container as Element | Document).getElementsByTagName(
"span",
);
for (const span of spans) {
if (selection.containsNode(span, true)) {
selectedSpans.push(span as HTMLSpanElement);
}
}
// Concat the text of the selected spans.
let hasCorrectAttribute = false;
let text = "";
selectedSpans.forEach((span) => {
if (span.hasAttribute("data-current")) {
hasCorrectAttribute = true;
text += span.textContent + "\n";
if (
[...span.classList].some((className) =>
className.startsWith("endOfStanza--"),
)
) {
text += "\n";
}
}
});
text = text.trim();
if (hasCorrectAttribute) {
SetClipboard(text);
trace.msg.log("Copied to clipboard!");
selection.removeAllRanges();
}
} }
isSelecting = false;
} }
isPointerDownInLyrics = false;
}; };
const onClickHooked = (event: MouseEvent): boolean | undefined => { const onClickHooked = (event: MouseEvent): boolean | undefined => {
if (!isSelecting) return; if (!suppressNextClick) return;
const target = event.target as HTMLElement; suppressNextClick = false;
if ( if (suppressClickResetTimer !== null) {
target.tagName.toLowerCase() === "span" && window.clearTimeout(suppressClickResetTimer);
target.hasAttribute("data-current") suppressClickResetTimer = null;
) {
// Prevent default behavior and stop event propagation
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
return false;
} }
return undefined; event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
return false;
}; };
// Add event listener with capture phase to intercept events before they reach other handlers // Add event listener with capture phase to intercept events before they reach other handlers
@@ -140,4 +195,8 @@ unloads.add((): void => {
document.removeEventListener("click", onClickHooked, true); document.removeEventListener("click", onClickHooked, true);
document.removeEventListener("mousedown", onMouseDown); document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("mouseup", onMouseUp); document.removeEventListener("mouseup", onMouseUp);
if (suppressClickResetTimer !== null) {
window.clearTimeout(suppressClickResetTimer);
suppressClickResetTimer = null;
}
}); });
+8 -1
View File
@@ -1,4 +1,11 @@
[class^="_lyricsText"] > div > span { [data-test="now-playing-lyrics"],
[data-test="now-playing-lyrics"] span[data-test="lyrics-line"],
[class*="_lyricsText"] > div > span,
.rl-wbw-container,
.rl-wbw-line,
.rl-wbw-word,
.rl-wbw-main,
.rl-wbw-bg-container {
user-select: text; user-select: text;
cursor: text; cursor: text;
} }
+15 -12
View File
@@ -19,7 +19,13 @@ new StyleTag("Element-Hider", unloads, styles);
// State management // State management
let targetElement: HTMLElement | null = null; let targetElement: HTMLElement | null = null;
let hiddenElements = new WeakSet<HTMLElement>(); let hiddenElements = new WeakSet<HTMLElement>();
let hiddenElementsArray: HTMLElement[] = [];
// Count of elements currently hidden in the live DOM. The `.element-hider-hidden`
// class is the source of truth — querying it avoids retaining detached nodes
// across SPA navigations.
function getHiddenCount(): number {
return document.querySelectorAll(".element-hider-hidden").length;
}
// MutationObserver for reactive element detection // MutationObserver for reactive element detection
let elementObserver: MutationObserver | null = null; let elementObserver: MutationObserver | null = null;
@@ -179,7 +185,6 @@ function hideElementDirectly(element: HTMLElement): void {
element.classList.add("element-hider-hidden"); element.classList.add("element-hider-hidden");
hiddenElements.add(element); hiddenElements.add(element);
hiddenElementsArray.push(element);
trace.log( trace.log(
`Hidden element: ${element.tagName}${element.className ? "." + element.className.split(" ")[0] : ""}`, `Hidden element: ${element.tagName}${element.className ? "." + element.className.split(" ")[0] : ""}`,
); );
@@ -210,7 +215,6 @@ function hideTargetElement(): void {
"element-hider-target", "element-hider-target",
); );
hiddenElements.add(elementToHide); hiddenElements.add(elementToHide);
hiddenElementsArray.push(elementToHide);
}, 300); }, 300);
// Clear target reference // Clear target reference
@@ -220,20 +224,19 @@ function hideTargetElement(): void {
// Unhide all elements permanently (remove from storage) // Unhide all elements permanently (remove from storage)
function unhideAllElements(): void { function unhideAllElements(): void {
trace.log( trace.log(
`Permanently unhiding ${settings.hiddenElements.length} saved elements`, `Permanently unhiding ${settings.hiddenElements.length} saved selectors`,
); );
// Show all currently hidden elements // Show all currently hidden elements
hiddenElementsArray.forEach((element) => { document
if (document.body.contains(element)) { .querySelectorAll(".element-hider-hidden, .element-hider-hiding")
.forEach((element) => {
element.classList.remove("element-hider-hidden", "element-hider-hiding"); element.classList.remove("element-hider-hidden", "element-hider-hiding");
} });
});
// Clear both storage and runtime collections // Clear both storage and runtime collections
settings.hiddenElements = []; settings.hiddenElements = [];
hiddenElements = new WeakSet<HTMLElement>(); hiddenElements = new WeakSet<HTMLElement>();
hiddenElementsArray = [];
} }
// Process all elements in the document to hide matching ones (with strict matching) // Process all elements in the document to hide matching ones (with strict matching)
@@ -334,7 +337,7 @@ window.showAllElementsFromSettings = unhideAllElements;
window.debugElementHider = () => { window.debugElementHider = () => {
trace.log(`=== Element Hider Debug Info ===`); trace.log(`=== Element Hider Debug Info ===`);
trace.log(`Stored elements: ${settings.hiddenElements.length}`); trace.log(`Stored elements: ${settings.hiddenElements.length}`);
trace.log(`Currently hidden elements: ${hiddenElementsArray.length}`); trace.log(`Currently hidden elements: ${getHiddenCount()}`);
trace.log(`Reactive hiding enabled: true`); trace.log(`Reactive hiding enabled: true`);
settings.hiddenElements.forEach((element, index) => { settings.hiddenElements.forEach((element, index) => {
trace.log(`${index + 1}. ${element.selector} (${element.tagName})`); trace.log(`${index + 1}. ${element.selector} (${element.tagName})`);
@@ -472,7 +475,7 @@ function createCustomMenu(): HTMLElement {
// Unhide All Elements option // Unhide All Elements option
const unhideAllItem = document.createElement("button"); const unhideAllItem = document.createElement("button");
unhideAllItem.className = "element-hider-menu-item"; unhideAllItem.className = "element-hider-menu-item";
unhideAllItem.innerHTML = `Unhide All Elements (${hiddenElementsArray.length})`; unhideAllItem.innerHTML = `Unhide All Elements (${getHiddenCount()})`;
unhideAllItem.addEventListener("click", () => { unhideAllItem.addEventListener("click", () => {
unhideAllElements(); unhideAllElements();
closeCustomMenu(); closeCustomMenu();
@@ -593,7 +596,7 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
const unhideAllButton = document.createElement("button"); const unhideAllButton = document.createElement("button");
unhideAllButton.className = "element-hider-menu-item"; unhideAllButton.className = "element-hider-menu-item";
unhideAllButton.style.cssText = hideButton.style.cssText; unhideAllButton.style.cssText = hideButton.style.cssText;
unhideAllButton.innerHTML = `Unhide All Elements (${hiddenElementsArray.length})`; unhideAllButton.innerHTML = `Unhide All Elements (${getHiddenCount()})`;
unhideAllButton.addEventListener("click", unhideAllElements); unhideAllButton.addEventListener("click", unhideAllElements);
+1 -1
View File
@@ -281,7 +281,7 @@ export const Settings = () => {
}} }}
/> />
<AnySwitch <AnySwitch
title="Romanize Lyrics | Beta" title="Romanize Lyrics"
desc="Display romanized (latin) text for non-latin lyrics (e.g. Korean, Japanese, Chinese)" desc="Display romanized (latin) text for non-latin lyrics (e.g. Korean, Japanese, Chinese)"
checked={romanizeLyrics} checked={romanizeLyrics}
onChange={(_: unknown, checked: boolean) => { onChange={(_: unknown, checked: boolean) => {
+1 -1
View File
@@ -8,7 +8,7 @@ const sylTrace = (...args: unknown[]) => {
if (settings.syllableLogging) trace.log(...args); if (settings.syllableLogging) trace.log(...args);
}; };
export const RL_PLATFORM = "Radiant Lyrics"; export const RL_PLATFORM = "rl";
const RL_ACCESS_TOKEN_ID = "58hy4s86"; const RL_ACCESS_TOKEN_ID = "58hy4s86";
const RL_ACCESS_TOKEN = "xjehy2lfg5h5mjwotoxrcqugam"; const RL_ACCESS_TOKEN = "xjehy2lfg5h5mjwotoxrcqugam";
+19 -17
View File
@@ -49,6 +49,14 @@ const toastErr = (msg: string) =>
// clean up resources // clean up resources
export const unloads = new Set<LunaUnload>(); export const unloads = new Set<LunaUnload>();
// MARKER: Romanization Gate
const romanizedText = (
item: { text?: string | null; romanized?: string | null },
fallback = "",
): string =>
(settings.romanizeLyrics && item.romanized ? item.romanized : item.text) ??
fallback;
// MARKER: Player Market UI (Ensure new UI is enabled) // MARKER: Player Market UI (Ensure new UI is enabled)
function enablePlayerMarketUI() { function enablePlayerMarketUI() {
@@ -1852,17 +1860,13 @@ const formatLrcTime = (timeSeconds: number): string => {
const buildSyntheticLyricsText = (response: LyricsApiResponse): string => const buildSyntheticLyricsText = (response: LyricsApiResponse): string =>
response.data response.data
.map((line) => ("romanized" in line && line.romanized ? line.romanized : line.text)) .map((line) => romanizedText(line))
.filter((line) => line.trim().length > 0) .filter((line) => line.trim().length > 0)
.join("\n"); .join("\n");
const buildSyntheticLrcText = (response: LyricsApiResponse): string => const buildSyntheticLrcText = (response: LyricsApiResponse): string =>
response.data response.data
.map((line) => { .map((line) => `[${formatLrcTime(line.startTime)}]${romanizedText(line)}`)
const text =
("romanized" in line && line.romanized ? line.romanized : line.text) ?? "";
return `[${formatLrcTime(line.startTime)}]${text}`;
})
.join("\n"); .join("\n");
const registerSyntheticNativeLyrics = ( const registerSyntheticNativeLyrics = (
@@ -2432,19 +2436,19 @@ const normalizeLineData = (data: ApiLine[]): WordLine[] => {
: startMs + durationMs; : startMs + durationMs;
const safeSinger = line.element?.singer ?? "v1000"; const safeSinger = line.element?.singer ?? "v1000";
const safeKey = line.element?.key ?? `line-${idx}`; const safeKey = line.element?.key ?? `line-${idx}`;
const text = line.romanized ?? line.text; // Romanization Gate now decides which text to show (romanized or original)
return { return {
text, text: romanizedText(line),
startTime: startMs / 1000, startTime: startMs / 1000,
duration: durationMs / 1000, duration: durationMs / 1000,
endTime: endMs / 1000, endTime: endMs / 1000,
syllabus: [ syllabus: [
{ {
text: `${text} `, text: `${line.text} `,
time: startMs, time: startMs,
duration: Math.max(1, endMs - startMs), duration: Math.max(1, endMs - startMs),
isBackground: false, isBackground: false,
romanized: line.romanized ? `${line.romanized} ` : undefined,
}, },
], ],
element: { element: {
@@ -2805,9 +2809,7 @@ const buildWordSpans = (): {
return span; return span;
}; };
const useRomanized = settings.romanizeLyrics; const sylDisplay = (s: WordTiming) => romanizedText(s);
const sylDisplay = (s: WordTiming) =>
useRomanized && s.romanized != null ? s.romanized : s.text;
// Group syllables into words: trailing whitespace in syl.text marks a word boundary // Group syllables into words: trailing whitespace in syl.text marks a word boundary
const wordGroups: number[][] = []; const wordGroups: number[][] = [];
@@ -3028,10 +3030,10 @@ const buildTidalLines = (
let textIdx = 0; let textIdx = 0;
for (const tidalSpan of tidalSpans) { for (const tidalSpan of tidalSpans) {
const rawText = tidalSpan.textContent ?? ""; const rawText = tidalSpan.textContent ?? "";
const text = const text = romanizedText({
settings.romanizeLyrics && romanizedLines?.[textIdx] text: rawText,
? romanizedLines[textIdx] romanized: romanizedLines?.[textIdx],
: rawText; });
if (rawText.trim().length > 0) textIdx++; if (rawText.trim().length > 0) textIdx++;
if (rawText.trim().length === 0) { if (rawText.trim().length === 0) {
const spacer = document.createElement("div"); const spacer = document.createElement("div");
+7 -1
View File
@@ -886,4 +886,10 @@ body.rl-integrated-seekbar [data-test="footer-player"] [class*="playerContent"]
._glowEffect_74c5e85 { ._glowEffect_74c5e85 {
display: none !important; display: none !important;
} }
/* Make the small header red */
[class*="_smallHeader_"] {
background-color: rgba(0, 0, 0, .3) !important;
backdrop-filter: blur(10px) !important;
}