mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-18 03:43:10 +10:00
Merge pull request #129 from meowarex/dev
Fix Romanization & Audio Visualizer <3
This commit is contained in:
@@ -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!)
|
|
||||||
|
|||||||
@@ -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 (typeof capture !== "function") {
|
if (audioSource) {
|
||||||
log("captureStream() not available on video element");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
disconnectSource();
|
audioSource.disconnect();
|
||||||
|
} catch {}
|
||||||
|
audioSource = null;
|
||||||
|
}
|
||||||
|
capturedTrack = null;
|
||||||
|
trackedEl = null;
|
||||||
|
};
|
||||||
|
|
||||||
const stream = capture.call(video);
|
// captureFromEl() is retried after log spam to stop it <3
|
||||||
const tracks = stream.getAudioTracks();
|
let loggedCaptureFailure = false;
|
||||||
if (tracks.length === 0) {
|
const logCaptureFailureOnce = (message: string): void => {
|
||||||
log("No audio tracks in captured stream");
|
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;
|
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 = 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const reconnect = (fftSize = 2048, smoothing = 0.8): boolean => {
|
// Captured tracks start muted until samples flow; wait for "unmute" instead of guessing.
|
||||||
disconnectSource();
|
loggedCaptureFailure = false;
|
||||||
trackedVideo = null;
|
setState(track.muted ? "pending" : "live");
|
||||||
return connect(fftSize, smoothing);
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isConnected = (): boolean => connected;
|
// An element is worth capturing from when it's actually advancing audio.
|
||||||
|
const isPlayingAudio = (el: HTMLMediaElement): boolean => !el.paused && !el.ended && el.readyState >= 2;
|
||||||
|
|
||||||
export const videoChanged = (): boolean => {
|
|
||||||
const video = document.getElementById("video-one") as HTMLVideoElement | null;
|
const captureFrom = (el: HTMLMediaElement): void => {
|
||||||
if (!video) return false;
|
if (el === trackedEl) {
|
||||||
return video !== trackedVideo;
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMediaReset = (e: Event): void => {
|
||||||
|
if (e.target === trackedEl) {
|
||||||
|
detachSource();
|
||||||
|
setState("pending");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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 => {
|
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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user