mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-18 03:43:10 +10:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58ce360bd5 | |||
| 5a7d4f9c07 | |||
| 962170077a | |||
| 8a86de1b39 | |||
| f7fa918473 | |||
| fa273705ad | |||
| 8ad574e611 | |||
| f069d7eae2 | |||
| 497f3a95b0 | |||
| ecdb91604d | |||
| 734e0012cc | |||
| c70423bc80 | |||
| 3d8a755c0f | |||
| 06c4adf54b | |||
| fe2accbd4a | |||
| e2614d1b68 | |||
| 7f88f7d9ed | |||
| 4a5ec64de7 | |||
| ef9100fb9f | |||
| 4403de2cda | |||
| 6d1fdf2dff | |||
| 8f9bb1b228 | |||
| c592a08c0a | |||
| eb18e342f1 | |||
| 8eaa73b59b | |||
| 090381fa59 | |||
| f502fdd028 | |||
| 345f0ad3c2 | |||
| 0f096e81f0 | |||
| f1ab0cc3e4 | |||
| 0050c9fff3 | |||
| 50c40e6978 | |||
| ec42f5c287 | |||
| db6310cef4 | |||
| ee2243443e | |||
| 2deda8aed1 | |||
| b403c3a80c | |||
| 48c8738bcd | |||
| 7627bd7051 | |||
| 20a2c2b7f7 | |||
| f0139165a9 | |||
| e4df0a8c64 | |||
| 8ee9717f25 | |||
| 5ead825b3d | |||
| 1a2e25c717 | |||
| a2cb822a2c | |||
| e223f933c6 | |||
| 031bb107f8 | |||
| a6371240ef | |||
| 92697d7396 | |||
| 5e6e897395 | |||
| 4749f50b95 | |||
| b48d248cda | |||
| 4af872133e | |||
| 0f9d5a75d8 | |||
| 764cb1aa96 | |||
| e062b4bd02 | |||
| 9f01ecd1ff | |||
| e59121968d | |||
| 8fbb48f8fe | |||
| b351fa859a | |||
| 353b72e1e1 | |||
| c648f3df95 | |||
| e376fb745b | |||
| ca085ce31b | |||
| 34e0a51bcd | |||
| 8fdfff10e7 | |||
| fbd0c2b696 | |||
| 4ad4b5879c | |||
| 764c71b45f | |||
| 1876a37185 | |||
| 8c27eebd88 | |||
| 9fd8208996 | |||
| a1ddb0ede6 | |||
| 411e20b9f7 | |||
| 50215fa0f5 | |||
| 5761c01973 | |||
| 62e15b0d3d | |||
| 13cbe01bd8 |
@@ -13,8 +13,7 @@ jobs:
|
||||
|
||||
- name: Install pnpm 📥
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
# Version is read from `packageManager` in package.json for reproducible builds.
|
||||
|
||||
- name: Install Node.js 📥
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
# 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
|
||||
|
||||
### 🎨 Obsidian
|
||||
**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
|
||||
### Radiant Lyrics
|
||||
**Location:** `plugins/radiant-lyrics-luna/`
|
||||
|
||||
A radiant and beautiful lyrics view for TIDAL with dynamic visual effects.
|
||||
|
||||
**Features:**
|
||||
- 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
|
||||
|
||||
### 📋 Copy Lyrics
|
||||
### Copy Lyrics
|
||||
**Location:** `plugins/copy-lyrics-luna/`
|
||||
|
||||
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
|
||||
- Smart lyric span detection
|
||||
|
||||
### 🧽 Element Hider
|
||||
### Element Hider
|
||||
**Location:** `plugins/element-hider-luna/`
|
||||
|
||||
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
|
||||
- Allows for elements to be restored
|
||||
|
||||
### 🎶 Audio Visualizer
|
||||
### Audio Visualizer
|
||||
**Location:** `plugins/audio-visualizer-luna/`
|
||||
|
||||
⚠️ **Work in Progress** - Audio visualization plugin that displays real-time audio frequency data.
|
||||
@@ -115,4 +109,3 @@ This project is made for:
|
||||
## Credits
|
||||
|
||||
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!)
|
||||
|
||||
+1
-5
@@ -2,6 +2,7 @@
|
||||
"name": "@meowarex/TidalLuna-Plugins",
|
||||
"description": "A Collection of Plugins for TidalLuna",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.2",
|
||||
"scripts": {
|
||||
"watch": "concurrently \"pnpm:build --watch\" pnpm:serve",
|
||||
"build": "rimraf ./dist && tsx esbuild.config.ts",
|
||||
@@ -18,10 +19,5 @@
|
||||
"rimraf": "^6.0.1",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,13 @@ let leftAnalyser: AnalyserNode | null = null;
|
||||
let rightAnalyser: AnalyserNode | null = null;
|
||||
let splitter: ChannelSplitterNode | null = null;
|
||||
let audioSource: MediaStreamAudioSourceNode | null = null;
|
||||
let trackedVideo: HTMLVideoElement | null = null;
|
||||
let connected = false;
|
||||
let trackedEl: HTMLMediaElement | null = null;
|
||||
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 monoByteTime: Uint8Array | null = null;
|
||||
@@ -28,7 +33,33 @@ export interface AudioData {
|
||||
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 => {
|
||||
desiredFFT = size;
|
||||
if (monoAnalyser) monoAnalyser.fftSize = size;
|
||||
if (leftAnalyser) leftAnalyser.fftSize = size;
|
||||
if (rightAnalyser) rightAnalyser.fftSize = size;
|
||||
@@ -36,6 +67,7 @@ export const setFFTSize = (size: number): void => {
|
||||
};
|
||||
|
||||
export const setSmoothing = (value: number): void => {
|
||||
desiredSmoothing = value;
|
||||
if (monoAnalyser) monoAnalyser.smoothingTimeConstant = value;
|
||||
if (leftAnalyser) leftAnalyser.smoothingTimeConstant = value;
|
||||
if (rightAnalyser) rightAnalyser.smoothingTimeConstant = value;
|
||||
@@ -64,16 +96,16 @@ const createAnalyser = (ctx: AudioContext, fftSize: number, smoothing: number):
|
||||
return a;
|
||||
};
|
||||
|
||||
const ensureContext = (fftSize: number, smoothing: number): boolean => {
|
||||
const ensureContext = (): boolean => {
|
||||
try {
|
||||
if (!audioContext || audioContext.state === "closed") {
|
||||
audioContext = new AudioContext();
|
||||
}
|
||||
|
||||
if (!monoAnalyser) {
|
||||
monoAnalyser = createAnalyser(audioContext, fftSize, smoothing);
|
||||
leftAnalyser = createAnalyser(audioContext, fftSize, smoothing);
|
||||
rightAnalyser = createAnalyser(audioContext, fftSize, smoothing);
|
||||
monoAnalyser = createAnalyser(audioContext, desiredFFT, desiredSmoothing);
|
||||
leftAnalyser = createAnalyser(audioContext, desiredFFT, desiredSmoothing);
|
||||
rightAnalyser = createAnalyser(audioContext, desiredFFT, desiredSmoothing);
|
||||
splitter = audioContext.createChannelSplitter(2);
|
||||
splitter.connect(leftAnalyser, 0);
|
||||
splitter.connect(rightAnalyser, 1);
|
||||
@@ -91,69 +123,144 @@ const ensureContext = (fftSize: number, smoothing: number): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
const disconnectSource = (): void => {
|
||||
if (audioSource) {
|
||||
try { audioSource.disconnect(); } catch {}
|
||||
audioSource = null;
|
||||
}
|
||||
connected = false;
|
||||
// Source / track wiring
|
||||
|
||||
const clearTrackListeners = (): void => {
|
||||
trackCleanup?.();
|
||||
trackCleanup = null;
|
||||
};
|
||||
|
||||
const captureFromVideo = (video: HTMLVideoElement): boolean => {
|
||||
const capture = (video as unknown as { captureStream?: () => MediaStream }).captureStream;
|
||||
if (typeof capture !== "function") {
|
||||
log("captureStream() not available on video element");
|
||||
return false;
|
||||
}
|
||||
|
||||
const detachSource = (): void => {
|
||||
clearTrackListeners();
|
||||
if (audioSource) {
|
||||
try {
|
||||
disconnectSource();
|
||||
audioSource.disconnect();
|
||||
} catch {}
|
||||
audioSource = null;
|
||||
}
|
||||
capturedTrack = null;
|
||||
trackedEl = null;
|
||||
};
|
||||
|
||||
const stream = capture.call(video);
|
||||
const tracks = stream.getAudioTracks();
|
||||
if (tracks.length === 0) {
|
||||
log("No audio tracks in captured stream");
|
||||
// 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") {
|
||||
logCaptureFailureOnce("captureStream() not available on media element");
|
||||
return false;
|
||||
}
|
||||
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = capture.call(el);
|
||||
} catch (err) {
|
||||
logCaptureFailureOnce(`captureStream() failed: ${err}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const tracks = stream.getAudioTracks();
|
||||
// No audio track yet
|
||||
if (tracks.length === 0) return false;
|
||||
if (!ensureContext()) return false;
|
||||
|
||||
detachSource();
|
||||
|
||||
const track = tracks[0];
|
||||
try {
|
||||
audioSource = audioContext!.createMediaStreamSource(stream);
|
||||
audioSource.connect(monoAnalyser!);
|
||||
audioSource.connect(splitter!);
|
||||
|
||||
trackedVideo = video;
|
||||
connected = true;
|
||||
log("Audio connected via captureStream()");
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`captureStream() failed: ${err}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
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");
|
||||
log(`Failed to connect captured stream: ${err}`);
|
||||
audioSource = null;
|
||||
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 => {
|
||||
disconnectSource();
|
||||
trackedVideo = null;
|
||||
return connect(fftSize, smoothing);
|
||||
// An element is worth capturing from when it's actually advancing audio.
|
||||
const isPlayingAudio = (el: HTMLMediaElement): boolean => !el.paused && !el.ended && el.readyState >= 2;
|
||||
|
||||
|
||||
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 => {
|
||||
const video = document.getElementById("video-one") as HTMLVideoElement | null;
|
||||
if (!video) return false;
|
||||
return video !== trackedVideo;
|
||||
/** global media listeners */
|
||||
export const init = (): void => {
|
||||
ensureContext();
|
||||
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 => {
|
||||
@@ -190,7 +297,12 @@ export const hasSignal = (data: AudioData): boolean => {
|
||||
};
|
||||
|
||||
export const dispose = (): void => {
|
||||
disconnectSource();
|
||||
docCleanup?.();
|
||||
docCleanup = null;
|
||||
detachSource();
|
||||
setState("disconnected");
|
||||
onStateChange = null;
|
||||
|
||||
if (audioContext && audioContext.state !== "closed") {
|
||||
audioContext.close().catch(() => {});
|
||||
}
|
||||
@@ -199,7 +311,6 @@ export const dispose = (): void => {
|
||||
leftAnalyser = null;
|
||||
rightAnalyser = null;
|
||||
splitter = null;
|
||||
trackedVideo = null;
|
||||
monoByteFreq = null;
|
||||
monoByteTime = null;
|
||||
monoFloatFreq = null;
|
||||
|
||||
@@ -276,85 +276,23 @@ if (existingArtist) attachNpGroups(existingArtist);
|
||||
const existingFooter = document.querySelector('[data-test="footer-player"]');
|
||||
if (existingFooter) attachPbGroups(existingFooter);
|
||||
|
||||
// Audio Connection stuff
|
||||
// Audio Connection
|
||||
|
||||
const fft = () => settings.fftSize ?? 2048;
|
||||
const reactivityToSmoothing = (r: number) => Math.max(0, Math.min(0.95, (100 - r) / 100));
|
||||
const smooth = () => reactivityToSmoothing(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 => {
|
||||
if (retryTimer !== null) {
|
||||
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();
|
||||
}
|
||||
audio.setOnStateChange((state) => {
|
||||
if (state === "live") log("Audio connected");
|
||||
});
|
||||
|
||||
PlayState.onState(unloads, (state) => {
|
||||
if (state === "PLAYING") {
|
||||
silentFrames = 0;
|
||||
if (!audio.isConnected() || audio.videoChanged()) {
|
||||
if (!tryReconnect()) scheduleRetry();
|
||||
}
|
||||
} else {
|
||||
clearRetry();
|
||||
}
|
||||
if (state === "PLAYING") audio.scan();
|
||||
});
|
||||
|
||||
MediaItem.onMediaTransition(unloads, () => {
|
||||
log("Media transition");
|
||||
silentFrames = 0;
|
||||
setTimeout(() => {
|
||||
if (PlayState.playing) {
|
||||
if (!tryReconnect()) scheduleRetry();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
MediaItem.onMediaTransition(unloads, () => audio.scan());
|
||||
|
||||
// Idle Animation Synthetic Data
|
||||
|
||||
@@ -478,21 +416,6 @@ const animate = (): void => {
|
||||
const data = audio.sample();
|
||||
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
|
||||
const idleMode = settings.idleMode ?? 0;
|
||||
const newIdleHidden = !hasSignal && idleMode === 1;
|
||||
@@ -521,9 +444,10 @@ const animate = (): void => {
|
||||
|
||||
log("Initializing...");
|
||||
|
||||
if (PlayState.playing) {
|
||||
if (!tryConnect()) scheduleRetry();
|
||||
}
|
||||
audio.setFFTSize(fft());
|
||||
audio.setSmoothing(smooth());
|
||||
audio.init();
|
||||
audio.scan();
|
||||
|
||||
animationId = requestAnimationFrame(animate);
|
||||
|
||||
@@ -531,7 +455,6 @@ animationId = requestAnimationFrame(animate);
|
||||
|
||||
unloads.add(() => {
|
||||
log("Plugin unloading");
|
||||
clearRetry();
|
||||
|
||||
document.body.classList.remove("av-chromeless");
|
||||
|
||||
|
||||
@@ -15,7 +15,11 @@ new StyleTag("Copy-Lyrics", unloads, unlockSelection);
|
||||
function SetClipboard(text: string): void {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed"; // Avoid scrolling to bottom
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "0";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
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 => {
|
||||
isSelecting = true;
|
||||
const OVERLAY_LINE_SELECTOR = ".rl-wbw-container .rl-wbw-line";
|
||||
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 => {
|
||||
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
|
||||
if (container && container.nodeType === Node.TEXT_NODE) {
|
||||
container = (container.parentElement ?? container.parentNode) as Node | null;
|
||||
}
|
||||
|
||||
// If parent has data-current, treat as single-line copy case
|
||||
if (
|
||||
container &&
|
||||
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) {
|
||||
if (selection?.toString().trim()) {
|
||||
const text = getSelectedLyricsText(selection);
|
||||
if (text.length > 0) {
|
||||
SetClipboard(text);
|
||||
trace.msg.log("Copied to clipboard!");
|
||||
selection.removeAllRanges();
|
||||
suppressUpcomingClick();
|
||||
}
|
||||
}
|
||||
isSelecting = false;
|
||||
}
|
||||
|
||||
isPointerDownInLyrics = false;
|
||||
};
|
||||
|
||||
const onClickHooked = (event: MouseEvent): boolean | undefined => {
|
||||
if (!isSelecting) return;
|
||||
if (!suppressNextClick) return;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
target.tagName.toLowerCase() === "span" &&
|
||||
target.hasAttribute("data-current")
|
||||
) {
|
||||
// Prevent default behavior and stop event propagation
|
||||
suppressNextClick = false;
|
||||
if (suppressClickResetTimer !== null) {
|
||||
window.clearTimeout(suppressClickResetTimer);
|
||||
suppressClickResetTimer = null;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 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("mousedown", onMouseDown);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
if (suppressClickResetTimer !== null) {
|
||||
window.clearTimeout(suppressClickResetTimer);
|
||||
suppressClickResetTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,13 @@ new StyleTag("Element-Hider", unloads, styles);
|
||||
// State management
|
||||
let targetElement: HTMLElement | null = null;
|
||||
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
|
||||
let elementObserver: MutationObserver | null = null;
|
||||
@@ -179,7 +185,6 @@ function hideElementDirectly(element: HTMLElement): void {
|
||||
|
||||
element.classList.add("element-hider-hidden");
|
||||
hiddenElements.add(element);
|
||||
hiddenElementsArray.push(element);
|
||||
trace.log(
|
||||
`Hidden element: ${element.tagName}${element.className ? "." + element.className.split(" ")[0] : ""}`,
|
||||
);
|
||||
@@ -210,7 +215,6 @@ function hideTargetElement(): void {
|
||||
"element-hider-target",
|
||||
);
|
||||
hiddenElements.add(elementToHide);
|
||||
hiddenElementsArray.push(elementToHide);
|
||||
}, 300);
|
||||
|
||||
// Clear target reference
|
||||
@@ -220,20 +224,19 @@ function hideTargetElement(): void {
|
||||
// Unhide all elements permanently (remove from storage)
|
||||
function unhideAllElements(): void {
|
||||
trace.log(
|
||||
`Permanently unhiding ${settings.hiddenElements.length} saved elements`,
|
||||
`Permanently unhiding ${settings.hiddenElements.length} saved selectors`,
|
||||
);
|
||||
|
||||
// Show all currently hidden elements
|
||||
hiddenElementsArray.forEach((element) => {
|
||||
if (document.body.contains(element)) {
|
||||
document
|
||||
.querySelectorAll(".element-hider-hidden, .element-hider-hiding")
|
||||
.forEach((element) => {
|
||||
element.classList.remove("element-hider-hidden", "element-hider-hiding");
|
||||
}
|
||||
});
|
||||
|
||||
// Clear both storage and runtime collections
|
||||
settings.hiddenElements = [];
|
||||
hiddenElements = new WeakSet<HTMLElement>();
|
||||
hiddenElementsArray = [];
|
||||
}
|
||||
|
||||
// Process all elements in the document to hide matching ones (with strict matching)
|
||||
@@ -334,7 +337,7 @@ window.showAllElementsFromSettings = unhideAllElements;
|
||||
window.debugElementHider = () => {
|
||||
trace.log(`=== Element Hider Debug Info ===`);
|
||||
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`);
|
||||
settings.hiddenElements.forEach((element, index) => {
|
||||
trace.log(`${index + 1}. ${element.selector} (${element.tagName})`);
|
||||
@@ -472,7 +475,7 @@ function createCustomMenu(): HTMLElement {
|
||||
// Unhide All Elements option
|
||||
const unhideAllItem = document.createElement("button");
|
||||
unhideAllItem.className = "element-hider-menu-item";
|
||||
unhideAllItem.innerHTML = `Unhide All Elements (${hiddenElementsArray.length})`;
|
||||
unhideAllItem.innerHTML = `Unhide All Elements (${getHiddenCount()})`;
|
||||
unhideAllItem.addEventListener("click", () => {
|
||||
unhideAllElements();
|
||||
closeCustomMenu();
|
||||
@@ -593,7 +596,7 @@ function addElementHiderOptions(contextMenu: HTMLElement): void {
|
||||
const unhideAllButton = document.createElement("button");
|
||||
unhideAllButton.className = "element-hider-menu-item";
|
||||
unhideAllButton.style.cssText = hideButton.style.cssText;
|
||||
unhideAllButton.innerHTML = `Unhide All Elements (${hiddenElementsArray.length})`;
|
||||
unhideAllButton.innerHTML = `Unhide All Elements (${getHiddenCount()})`;
|
||||
|
||||
unhideAllButton.addEventListener("click", unhideAllElements);
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ export const Settings = () => {
|
||||
}}
|
||||
/>
|
||||
<AnySwitch
|
||||
title="Romanize Lyrics | Beta"
|
||||
title="Romanize Lyrics"
|
||||
desc="Display romanized (latin) text for non-latin lyrics (e.g. Korean, Japanese, Chinese)"
|
||||
checked={romanizeLyrics}
|
||||
onChange={(_: unknown, checked: boolean) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ const sylTrace = (...args: unknown[]) => {
|
||||
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 = "xjehy2lfg5h5mjwotoxrcqugam";
|
||||
|
||||
@@ -49,6 +49,14 @@ const toastErr = (msg: string) =>
|
||||
// clean up resources
|
||||
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)
|
||||
|
||||
function enablePlayerMarketUI() {
|
||||
@@ -1852,17 +1860,13 @@ const formatLrcTime = (timeSeconds: number): string => {
|
||||
|
||||
const buildSyntheticLyricsText = (response: LyricsApiResponse): string =>
|
||||
response.data
|
||||
.map((line) => ("romanized" in line && line.romanized ? line.romanized : line.text))
|
||||
.map((line) => romanizedText(line))
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.join("\n");
|
||||
|
||||
const buildSyntheticLrcText = (response: LyricsApiResponse): string =>
|
||||
response.data
|
||||
.map((line) => {
|
||||
const text =
|
||||
("romanized" in line && line.romanized ? line.romanized : line.text) ?? "";
|
||||
return `[${formatLrcTime(line.startTime)}]${text}`;
|
||||
})
|
||||
.map((line) => `[${formatLrcTime(line.startTime)}]${romanizedText(line)}`)
|
||||
.join("\n");
|
||||
|
||||
const registerSyntheticNativeLyrics = (
|
||||
@@ -2432,19 +2436,19 @@ const normalizeLineData = (data: ApiLine[]): WordLine[] => {
|
||||
: startMs + durationMs;
|
||||
const safeSinger = line.element?.singer ?? "v1000";
|
||||
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 {
|
||||
text,
|
||||
text: romanizedText(line),
|
||||
startTime: startMs / 1000,
|
||||
duration: durationMs / 1000,
|
||||
endTime: endMs / 1000,
|
||||
syllabus: [
|
||||
{
|
||||
text: `${text} `,
|
||||
text: `${line.text} `,
|
||||
time: startMs,
|
||||
duration: Math.max(1, endMs - startMs),
|
||||
isBackground: false,
|
||||
romanized: line.romanized ? `${line.romanized} ` : undefined,
|
||||
},
|
||||
],
|
||||
element: {
|
||||
@@ -2805,9 +2809,7 @@ const buildWordSpans = (): {
|
||||
return span;
|
||||
};
|
||||
|
||||
const useRomanized = settings.romanizeLyrics;
|
||||
const sylDisplay = (s: WordTiming) =>
|
||||
useRomanized && s.romanized != null ? s.romanized : s.text;
|
||||
const sylDisplay = (s: WordTiming) => romanizedText(s);
|
||||
|
||||
// Group syllables into words: trailing whitespace in syl.text marks a word boundary
|
||||
const wordGroups: number[][] = [];
|
||||
@@ -3028,10 +3030,10 @@ const buildTidalLines = (
|
||||
let textIdx = 0;
|
||||
for (const tidalSpan of tidalSpans) {
|
||||
const rawText = tidalSpan.textContent ?? "";
|
||||
const text =
|
||||
settings.romanizeLyrics && romanizedLines?.[textIdx]
|
||||
? romanizedLines[textIdx]
|
||||
: rawText;
|
||||
const text = romanizedText({
|
||||
text: rawText,
|
||||
romanized: romanizedLines?.[textIdx],
|
||||
});
|
||||
if (rawText.trim().length > 0) textIdx++;
|
||||
if (rawText.trim().length === 0) {
|
||||
const spacer = document.createElement("div");
|
||||
|
||||
@@ -887,3 +887,9 @@ body.rl-integrated-seekbar [data-test="footer-player"] [class*="playerContent"]
|
||||
._glowEffect_74c5e85 {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Make the small header red */
|
||||
[class*="_smallHeader_"] {
|
||||
background-color: rgba(0, 0, 0, .3) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
packages:
|
||||
- "plugins/*"
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
# pnpm 11 renamed `onlyBuiltDependencies` (list) to `allowBuilds` (map of name -> bool).
|
||||
# This whitelists postinstall/build scripts non-interactively in CI.
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
Reference in New Issue
Block a user