From f7fa9184739960726f17c1eb7dd5e7471a50bc31 Mon Sep 17 00:00:00 2001 From: meowarex Date: Mon, 1 Jun 2026 20:16:11 +1000 Subject: [PATCH] Fix Audio Visualizer player hook <3 --- plugins/audio-visualizer-luna/src/audio.ts | 211 ++++++++++++++++----- plugins/audio-visualizer-luna/src/index.ts | 95 +--------- 2 files changed, 170 insertions(+), 136 deletions(-) diff --git a/plugins/audio-visualizer-luna/src/audio.ts b/plugins/audio-visualizer-luna/src/audio.ts index e194537..08c5cba 100644 --- a/plugins/audio-visualizer-luna/src/audio.ts +++ b/plugins/audio-visualizer-luna/src/audio.ts @@ -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( + "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; diff --git a/plugins/audio-visualizer-luna/src/index.ts b/plugins/audio-visualizer-luna/src/index.ts index a67282a..96bb89d 100644 --- a/plugins/audio-visualizer-luna/src/index.ts +++ b/plugins/audio-visualizer-luna/src/index.ts @@ -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 | 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");