mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-17 19:33:10 +10:00
Fix Audio Visualizer player hook <3
This commit is contained in:
@@ -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;
|
||||
const detachSource = (): void => {
|
||||
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") {
|
||||
log("captureStream() not available on video element");
|
||||
logCaptureFailureOnce("captureStream() not available on media element");
|
||||
return false;
|
||||
}
|
||||
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
disconnectSource();
|
||||
stream = capture.call(el);
|
||||
} catch (err) {
|
||||
logCaptureFailureOnce(`captureStream() failed: ${err}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const stream = capture.call(video);
|
||||
const tracks = stream.getAudioTracks();
|
||||
if (tracks.length === 0) {
|
||||
log("No audio tracks in captured stream");
|
||||
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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user