mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-18 03:43:10 +10:00
Overhaul Audio Visualizer & RL UI Improvements
This commit is contained in:
@@ -1,282 +1,475 @@
|
||||
import { type LunaUnload, Tracer } from "@luna/core";
|
||||
import { StyleTag, PlayState, MediaItem, observe } from "@luna/lib";
|
||||
import { settings, Settings } from "./Settings";
|
||||
import * as audio from "./audio";
|
||||
import type { AudioData } from "./audio";
|
||||
import { type Visualizer, type VisualizerType, VISUALIZER_DIMENSIONS, MINI_DIMENSIONS, ALL_SLOT_KEYS, ZONE_SLOTS, type SlotKey } from "./visualizers/types";
|
||||
import { createSpectrumLine } from "./visualizers/spectrum-line";
|
||||
import { createSpectrumBars } from "./visualizers/spectrum-bars";
|
||||
import { createOscilloscope } from "./visualizers/oscilloscope";
|
||||
import { createVectorscope } from "./visualizers/vectorscope";
|
||||
import { createLoudnessMeter } from "./visualizers/loudness-meter";
|
||||
|
||||
import visualizerStyles from "file://styles.css?minify";
|
||||
|
||||
export const { trace } = Tracer("[Audio Visualizer]");
|
||||
export { Settings };
|
||||
|
||||
const log = (message: string) => console.log(`[Audio Visualizer] ${message}`);
|
||||
|
||||
const config = {
|
||||
width: 200,
|
||||
height: 40,
|
||||
get barCount() {
|
||||
return settings.barCount;
|
||||
},
|
||||
get color() {
|
||||
return settings.barColor;
|
||||
},
|
||||
get barRounding() {
|
||||
return settings.barRounding;
|
||||
},
|
||||
sensitivity: 1.5,
|
||||
smoothing: 0.8,
|
||||
};
|
||||
const log = (msg: string) => console.log(`[Audio Visualizer] ${msg}`);
|
||||
|
||||
export const unloads = new Set<LunaUnload>();
|
||||
|
||||
new StyleTag("AudioVisualizer", unloads, visualizerStyles);
|
||||
|
||||
const FACTORIES: Record<Exclude<VisualizerType, "none">, () => Visualizer> = {
|
||||
"spectrum-line": createSpectrumLine,
|
||||
"spectrum-bars": createSpectrumBars,
|
||||
oscilloscope: createOscilloscope,
|
||||
vectorscope: createVectorscope,
|
||||
"loudness-meter": createLoudnessMeter,
|
||||
};
|
||||
|
||||
let audioContext: AudioContext | null = null;
|
||||
let analyser: AnalyserNode | null = null;
|
||||
let audioSource: MediaStreamAudioSourceNode | null = null;
|
||||
let dataArray: Uint8Array<ArrayBuffer> | null = null;
|
||||
let animationId: number | null = null;
|
||||
let isSourceConnected = false;
|
||||
// Slot Management
|
||||
|
||||
|
||||
interface VisualizerSlot {
|
||||
interface Slot {
|
||||
container: HTMLDivElement | null;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
ctx: CanvasRenderingContext2D | null;
|
||||
visualizer: Visualizer | null;
|
||||
currentType: VisualizerType;
|
||||
contextType: "webgl" | "canvas2d" | null;
|
||||
}
|
||||
|
||||
const navSlot: VisualizerSlot = { container: null, canvas: null, ctx: null };
|
||||
const npSlot: VisualizerSlot = { container: null, canvas: null, ctx: null };
|
||||
interface SlotGroup {
|
||||
groupContainer: HTMLDivElement;
|
||||
slots: Slot[];
|
||||
keys: readonly SlotKey[];
|
||||
}
|
||||
|
||||
const groups = new Map<string, SlotGroup>();
|
||||
let navArrowsEl: HTMLElement | null = null;
|
||||
|
||||
const connectAudio = (): boolean => {
|
||||
const video = document.getElementById("video-one") as HTMLVideoElement | null;
|
||||
const capture = (video as unknown as { captureStream?: () => MediaStream })?.captureStream;
|
||||
if (!video || typeof capture !== "function") return false;
|
||||
const getSlot = (key: SlotKey): VisualizerType =>
|
||||
(settings as unknown as Record<string, VisualizerType>)[key] ?? "none";
|
||||
|
||||
try {
|
||||
if (!audioContext) audioContext = new AudioContext();
|
||||
const isWebGLViz = (type: VisualizerType): boolean =>
|
||||
type === "spectrum-line" || type === "spectrum-bars";
|
||||
|
||||
if (!analyser) {
|
||||
analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
analyser.smoothingTimeConstant = config.smoothing;
|
||||
dataArray = new Uint8Array(analyser.frequencyBinCount) as Uint8Array<ArrayBuffer>;
|
||||
}
|
||||
const isMiniSlot = (key: SlotKey): boolean =>
|
||||
(settings.miniSlots ?? []).includes(key);
|
||||
|
||||
audioSource?.disconnect();
|
||||
const getSlotDims = (type: VisualizerType, key: SlotKey) =>
|
||||
isMiniSlot(key) && MINI_DIMENSIONS[type] ? MINI_DIMENSIONS[type] : VISUALIZER_DIMENSIONS[type];
|
||||
|
||||
const stream = capture.call(video);
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
if (audioTracks.length === 0) {
|
||||
log("No audio tracks in captured stream");
|
||||
return false;
|
||||
}
|
||||
const createSlotCanvas = (dims: { width: number; height: number }): HTMLCanvasElement => {
|
||||
const cvs = document.createElement("canvas");
|
||||
cvs.width = dims.width;
|
||||
cvs.height = dims.height;
|
||||
cvs.style.cssText = `width:${dims.width}px;height:${dims.height}px;border-radius:4px;display:block;`;
|
||||
return cvs;
|
||||
};
|
||||
|
||||
audioSource = audioContext.createMediaStreamSource(stream);
|
||||
audioSource.connect(analyser);
|
||||
const applySlotSize = (slot: Slot, dims: { width: number; height: number }): void => {
|
||||
if (!slot.container || !slot.canvas) return;
|
||||
slot.canvas.width = dims.width;
|
||||
slot.canvas.height = dims.height;
|
||||
slot.canvas.style.width = `${dims.width}px`;
|
||||
slot.canvas.style.height = `${dims.height}px`;
|
||||
slot.container.style.width = `${dims.width + 8}px`;
|
||||
slot.container.style.height = `${dims.height + 8}px`;
|
||||
slot.visualizer?.resize(dims.width, dims.height);
|
||||
};
|
||||
|
||||
if (audioContext.state === "suspended") {
|
||||
audioContext.resume().catch(() => {});
|
||||
}
|
||||
const switchVisualizer = (slot: Slot, type: VisualizerType, key: SlotKey): void => {
|
||||
if (slot.currentType === type) return;
|
||||
|
||||
log("Connected via captureStream()");
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`Audio connection failed: ${err}`);
|
||||
return false;
|
||||
slot.visualizer?.dispose();
|
||||
slot.visualizer = null;
|
||||
|
||||
if (type === "none") {
|
||||
if (slot.container) slot.container.style.display = "none";
|
||||
slot.currentType = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const dims = getSlotDims(type, key);
|
||||
if (slot.container) {
|
||||
slot.canvas?.remove();
|
||||
const cvs = createSlotCanvas(dims);
|
||||
slot.container.appendChild(cvs);
|
||||
slot.canvas = cvs;
|
||||
slot.contextType = isWebGLViz(type) ? "webgl" : "canvas2d";
|
||||
|
||||
slot.container.style.display = "flex";
|
||||
slot.container.style.width = `${dims.width + 8}px`;
|
||||
slot.container.style.height = `${dims.height + 8}px`;
|
||||
}
|
||||
|
||||
const factory = FACTORIES[type];
|
||||
const viz = factory();
|
||||
if (slot.canvas) {
|
||||
viz.init(slot.canvas, settings.barColor);
|
||||
}
|
||||
slot.visualizer = viz;
|
||||
slot.currentType = type;
|
||||
};
|
||||
|
||||
const syncGroupHeights = (group: SlotGroup): void => {
|
||||
let maxH = 0;
|
||||
for (let i = 0; i < group.keys.length; i++) {
|
||||
const slot = group.slots[i];
|
||||
if (slot.currentType === "none") continue;
|
||||
const dims = getSlotDims(slot.currentType, group.keys[i]);
|
||||
if (dims.height > maxH) maxH = dims.height;
|
||||
}
|
||||
if (maxH === 0) return;
|
||||
|
||||
for (let i = 0; i < group.keys.length; i++) {
|
||||
const slot = group.slots[i];
|
||||
if (!slot.container || !slot.canvas || slot.currentType === "none") continue;
|
||||
const dims = getSlotDims(slot.currentType, group.keys[i]);
|
||||
const targetH = Math.max(dims.height, maxH);
|
||||
applySlotSize(slot, { width: dims.width, height: targetH });
|
||||
}
|
||||
};
|
||||
|
||||
// Canvas things
|
||||
const updateGroupVisibility = (group: SlotGroup): void => {
|
||||
const allNone = group.slots.every(s => s.currentType === "none");
|
||||
group.groupContainer.style.display = allNone ? "none" : "flex";
|
||||
if (!allNone) syncGroupHeights(group);
|
||||
|
||||
const makeSlotElements = (): { container: HTMLDivElement; canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null => {
|
||||
const container = document.createElement("div");
|
||||
container.className = "audio-visualizer-container";
|
||||
container.style.cssText = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
`;
|
||||
|
||||
const cvs = document.createElement("canvas");
|
||||
cvs.width = config.width;
|
||||
cvs.height = config.height;
|
||||
cvs.style.cssText = `
|
||||
width: ${config.width}px;
|
||||
height: ${config.height}px;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
container.appendChild(cvs);
|
||||
const ctx = cvs.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
return { container, canvas: cvs, ctx };
|
||||
if (group === groups.get("topNav-left") && navArrowsEl) {
|
||||
navArrowsEl.style.marginRight = allNone ? "" : "0";
|
||||
}
|
||||
};
|
||||
|
||||
const clearSlot = (slot: VisualizerSlot): void => {
|
||||
slot.container?.remove();
|
||||
slot.container = null;
|
||||
slot.canvas = null;
|
||||
slot.ctx = null;
|
||||
const createGroup = (keys: readonly SlotKey[], zone: string, position: string): SlotGroup => {
|
||||
const groupContainer = document.createElement("div");
|
||||
groupContainer.className = "av-slot-group";
|
||||
groupContainer.dataset.zone = zone;
|
||||
groupContainer.dataset.position = position;
|
||||
|
||||
const slots: Slot[] = [];
|
||||
for (const _key of keys) {
|
||||
const slotContainer = document.createElement("div");
|
||||
slotContainer.className = "audio-visualizer-container";
|
||||
slotContainer.style.display = "none";
|
||||
groupContainer.appendChild(slotContainer);
|
||||
slots.push({
|
||||
container: slotContainer,
|
||||
canvas: null,
|
||||
visualizer: null,
|
||||
currentType: "none",
|
||||
contextType: null,
|
||||
});
|
||||
}
|
||||
|
||||
return { groupContainer, slots, keys };
|
||||
};
|
||||
|
||||
// UI Placement with Luna Observer
|
||||
const initGroupVisualizers = (group: SlotGroup): void => {
|
||||
for (let i = 0; i < group.keys.length; i++) {
|
||||
const key = group.keys[i];
|
||||
const type = getSlot(key);
|
||||
if (type !== "none") {
|
||||
switchVisualizer(group.slots[i], type, key);
|
||||
}
|
||||
}
|
||||
updateGroupVisibility(group);
|
||||
};
|
||||
|
||||
const attachNavSlot = (anchor: Element): void => {
|
||||
if (navSlot.container?.isConnected) return;
|
||||
clearSlot(navSlot);
|
||||
const initAllGroups = (): void => {
|
||||
for (const [zoneId, positions] of Object.entries(ZONE_SLOTS)) {
|
||||
for (const [posId, keys] of Object.entries(positions)) {
|
||||
if (!keys) continue;
|
||||
const groupId = `${zoneId}-${posId}`;
|
||||
const group = createGroup(keys, zoneId, posId);
|
||||
groups.set(groupId, group);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// UI Attachment
|
||||
|
||||
const attachNavGroups = (anchor: Element): void => {
|
||||
const parent = anchor.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const els = makeSlotElements();
|
||||
if (!els) return;
|
||||
els.container.style.marginRight = "12px";
|
||||
Object.assign(navSlot, els);
|
||||
parent.insertBefore(els.container, anchor);
|
||||
const navLeft = groups.get("topNav-left");
|
||||
if (navLeft && !navLeft.groupContainer.isConnected) {
|
||||
const navArrows = parent.querySelector('[data-test="navigation-arrows"]') as HTMLElement | null;
|
||||
if (navArrows) {
|
||||
navArrowsEl = navArrows;
|
||||
navArrows.after(navLeft.groupContainer);
|
||||
} else {
|
||||
parent.prepend(navLeft.groupContainer);
|
||||
}
|
||||
navLeft.groupContainer.style.marginRight = "auto";
|
||||
initGroupVisualizers(navLeft);
|
||||
}
|
||||
|
||||
const navRight = groups.get("topNav-right");
|
||||
if (navRight && !navRight.groupContainer.isConnected) {
|
||||
parent.insertBefore(navRight.groupContainer, anchor);
|
||||
initGroupVisualizers(navRight);
|
||||
}
|
||||
};
|
||||
|
||||
const attachNpSlot = (anchor: Element): void => {
|
||||
if (npSlot.container?.isConnected) return;
|
||||
clearSlot(npSlot);
|
||||
const attachNpGroups = (anchor: Element): void => {
|
||||
const leftContent = anchor.parentElement;
|
||||
if (!leftContent) return;
|
||||
const header = leftContent.parentElement as HTMLElement | null;
|
||||
if (!header) return;
|
||||
|
||||
const parent = anchor.parentElement;
|
||||
if (!parent) return;
|
||||
const npLeft = groups.get("nowPlaying-left");
|
||||
if (npLeft && !npLeft.groupContainer.isConnected) {
|
||||
leftContent.insertBefore(npLeft.groupContainer, anchor.nextSibling);
|
||||
initGroupVisualizers(npLeft);
|
||||
}
|
||||
|
||||
const els = makeSlotElements();
|
||||
if (!els) return;
|
||||
els.container.style.marginLeft = "12px";
|
||||
Object.assign(npSlot, els);
|
||||
parent.insertBefore(els.container, anchor.nextSibling);
|
||||
const buttonsDiv = header.querySelector(':scope > [class*="buttons"]') as HTMLElement | null;
|
||||
const npRight = groups.get("nowPlaying-right");
|
||||
if (npRight && !npRight.groupContainer.isConnected) {
|
||||
if (buttonsDiv) {
|
||||
header.insertBefore(npRight.groupContainer, buttonsDiv);
|
||||
} else {
|
||||
header.appendChild(npRight.groupContainer);
|
||||
}
|
||||
npRight.groupContainer.style.marginLeft = "auto";
|
||||
initGroupVisualizers(npRight);
|
||||
}
|
||||
};
|
||||
|
||||
observe(unloads, '[data-test="search-popover-container"]', attachNavSlot);
|
||||
observe(unloads, '[data-test="artist-info"]', attachNpSlot);
|
||||
const attachPbGroups = (anchor: Element): void => {
|
||||
const trackInfo = anchor.querySelector('[data-test="track-info"]');
|
||||
const utilityContainer = anchor.querySelector('[class*="utilityContainer"]');
|
||||
|
||||
// Rendering things
|
||||
const pbLeft = groups.get("playerBar-left");
|
||||
if (pbLeft && !pbLeft.groupContainer.isConnected && trackInfo) {
|
||||
trackInfo.appendChild(pbLeft.groupContainer);
|
||||
initGroupVisualizers(pbLeft);
|
||||
}
|
||||
|
||||
const pbRight = groups.get("playerBar-right");
|
||||
if (pbRight && !pbRight.groupContainer.isConnected && utilityContainer) {
|
||||
utilityContainer.prepend(pbRight.groupContainer);
|
||||
initGroupVisualizers(pbRight);
|
||||
}
|
||||
};
|
||||
|
||||
initAllGroups();
|
||||
|
||||
observe(unloads, '[data-test="search-popover-container"]', attachNavGroups);
|
||||
observe(unloads, '[data-test="artist-info"]', attachNpGroups);
|
||||
observe(unloads, '[data-test="footer-player"]', attachPbGroups);
|
||||
|
||||
const existingSearch = document.querySelector('[data-test="search-popover-container"]');
|
||||
if (existingSearch) attachNavGroups(existingSearch);
|
||||
const existingArtist = document.querySelector('[data-test="artist-info"]');
|
||||
if (existingArtist) attachNpGroups(existingArtist);
|
||||
const existingFooter = document.querySelector('[data-test="footer-player"]');
|
||||
if (existingFooter) attachPbGroups(existingFooter);
|
||||
|
||||
// Audio Connection stuff
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
PlayState.onState(unloads, (state) => {
|
||||
if (state === "PLAYING") {
|
||||
silentFrames = 0;
|
||||
if (!audio.isConnected() || audio.videoChanged()) {
|
||||
if (!tryReconnect()) scheduleRetry();
|
||||
}
|
||||
} else {
|
||||
clearRetry();
|
||||
}
|
||||
});
|
||||
|
||||
MediaItem.onMediaTransition(unloads, () => {
|
||||
log("Media transition");
|
||||
silentFrames = 0;
|
||||
setTimeout(() => {
|
||||
if (PlayState.playing) {
|
||||
if (!tryReconnect()) scheduleRetry();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Idle Animation Synthetic Data
|
||||
|
||||
let waveTime = 0;
|
||||
const IDLE_SIZE = 1024;
|
||||
const idleByteFreq = new Uint8Array(IDLE_SIZE);
|
||||
const idleByteTime = new Uint8Array(IDLE_SIZE);
|
||||
const idleFloatFreq = new Float32Array(IDLE_SIZE);
|
||||
const idleFloatTime = new Float32Array(IDLE_SIZE);
|
||||
const idleLeftTime = new Float32Array(IDLE_SIZE);
|
||||
const idleRightTime = new Float32Array(IDLE_SIZE);
|
||||
|
||||
const drawRoundedRect = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
): void => {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, width, height, radius);
|
||||
ctx.fill();
|
||||
};
|
||||
|
||||
const drawScrollingWave = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => {
|
||||
const barCount = config.barCount;
|
||||
const barWidth = cvs.width / barCount;
|
||||
const maxHeight = cvs.height * 0.6;
|
||||
|
||||
ctx.fillStyle = config.color;
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const x = i / barCount;
|
||||
const generateIdleData = (): AudioData => {
|
||||
for (let i = 0; i < IDLE_SIZE; i++) {
|
||||
const x = i / IDLE_SIZE;
|
||||
const wave1 = Math.sin(x * Math.PI * 2 + waveTime) * 0.3;
|
||||
const wave2 = Math.sin(x * Math.PI * 4 + waveTime * 1.3) * 0.2;
|
||||
const wave3 = Math.sin(x * Math.PI * 6 + waveTime * 0.7) * 0.1;
|
||||
const combinedWave = (wave1 + wave2 + wave3 + 1) / 2;
|
||||
const travelWave = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5;
|
||||
const barHeight = maxHeight * combinedWave * travelWave * 0.8 + 2;
|
||||
const combined = (wave1 + wave2 + wave3 + 1) / 2;
|
||||
const travel = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5;
|
||||
|
||||
const xPos = i * barWidth;
|
||||
const yPos = (cvs.height - barHeight) / 2;
|
||||
const byteVal = Math.floor(combined * travel * 140 + 20);
|
||||
idleByteFreq[i] = byteVal;
|
||||
idleFloatFreq[i] = -40 + byteVal * 0.3;
|
||||
|
||||
if (config.barRounding) {
|
||||
drawRoundedRect(ctx, xPos, yPos, barWidth - 1, barHeight, 2);
|
||||
} else {
|
||||
ctx.fillRect(xPos, yPos, barWidth - 1, barHeight);
|
||||
}
|
||||
const timeSample = Math.sin(x * Math.PI * 8 + waveTime * 3) * 0.15;
|
||||
idleByteTime[i] = 128 + Math.floor(timeSample * 127);
|
||||
idleFloatTime[i] = timeSample;
|
||||
idleLeftTime[i] = timeSample;
|
||||
idleRightTime[i] = Math.sin(x * Math.PI * 8 + waveTime * 3 + 0.3) * 0.15;
|
||||
}
|
||||
};
|
||||
|
||||
const drawBars = (ctx: CanvasRenderingContext2D, cvs: HTMLCanvasElement): void => {
|
||||
if (!dataArray) return;
|
||||
|
||||
const barWidth = cvs.width / config.barCount;
|
||||
const heightScale = cvs.height / 255;
|
||||
|
||||
ctx.fillStyle = config.color;
|
||||
|
||||
for (let i = 0; i < config.barCount; i++) {
|
||||
const dataIndex = Math.floor(i * (dataArray.length / config.barCount));
|
||||
const barHeight = dataArray[dataIndex] * config.sensitivity * heightScale;
|
||||
|
||||
const x = i * barWidth;
|
||||
const y = cvs.height - barHeight;
|
||||
|
||||
if (config.barRounding) {
|
||||
drawRoundedRect(ctx, x, y, barWidth - 1, barHeight, 2);
|
||||
} else {
|
||||
ctx.fillRect(x, y, barWidth - 1, barHeight);
|
||||
}
|
||||
}
|
||||
return {
|
||||
byteFrequency: idleByteFreq,
|
||||
byteTimeDomain: idleByteTime,
|
||||
floatFrequency: idleFloatFreq,
|
||||
floatTimeDomain: idleFloatTime,
|
||||
leftTimeDomain: idleLeftTime,
|
||||
rightTimeDomain: idleRightTime,
|
||||
sampleRate: 44100,
|
||||
fftSize: IDLE_SIZE * 2,
|
||||
binCount: IDLE_SIZE,
|
||||
};
|
||||
};
|
||||
|
||||
// Animation Loop
|
||||
|
||||
let animationId: number | null = null;
|
||||
const lastSlotTypes = new Map<SlotKey, VisualizerType>();
|
||||
const lastMiniState = new Map<SlotKey, boolean>();
|
||||
|
||||
for (const key of ALL_SLOT_KEYS) {
|
||||
lastSlotTypes.set(key, getSlot(key));
|
||||
lastMiniState.set(key, isMiniSlot(key));
|
||||
}
|
||||
|
||||
const animate = (): void => {
|
||||
const slots = [navSlot, npSlot].filter(s => s.ctx && s.canvas);
|
||||
|
||||
if (slots.length > 0) {
|
||||
waveTime += 0.05;
|
||||
|
||||
let hasRealAudio = false;
|
||||
if (analyser && dataArray) {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
const avgVolume = dataArray.reduce((sum, val) => sum + val, 0) / dataArray.length;
|
||||
hasRealAudio = avgVolume > 5;
|
||||
}
|
||||
|
||||
for (const slot of slots) {
|
||||
const { ctx, canvas: cvs } = slot;
|
||||
if (!ctx || !cvs) continue;
|
||||
ctx.clearRect(0, 0, cvs.width, cvs.height);
|
||||
|
||||
if (hasRealAudio) {
|
||||
drawBars(ctx, cvs);
|
||||
} else {
|
||||
drawScrollingWave(ctx, cvs);
|
||||
for (const group of groups.values()) {
|
||||
let changed = false;
|
||||
for (let i = 0; i < group.keys.length; i++) {
|
||||
const key = group.keys[i];
|
||||
const currentType = getSlot(key);
|
||||
const lastType = lastSlotTypes.get(key) ?? "none";
|
||||
const mini = isMiniSlot(key);
|
||||
const wasMini = lastMiniState.get(key) ?? false;
|
||||
if (currentType !== lastType) {
|
||||
switchVisualizer(group.slots[i], currentType, key);
|
||||
lastSlotTypes.set(key, currentType);
|
||||
lastMiniState.set(key, mini);
|
||||
changed = true;
|
||||
} else if (mini !== wasMini && currentType !== "none") {
|
||||
const dims = getSlotDims(currentType, key);
|
||||
applySlotSize(group.slots[i], dims);
|
||||
lastMiniState.set(key, mini);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) updateGroupVisibility(group);
|
||||
}
|
||||
|
||||
const currentReactivity = settings.reactivity ?? 30;
|
||||
if (currentReactivity !== lastReactivity) {
|
||||
audio.setSmoothing(reactivityToSmoothing(currentReactivity));
|
||||
lastReactivity = currentReactivity;
|
||||
}
|
||||
|
||||
waveTime += 0.05;
|
||||
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();
|
||||
}
|
||||
|
||||
const renderData = hasSignal ? data : generateIdleData();
|
||||
|
||||
for (const group of groups.values()) {
|
||||
for (const slot of group.slots) {
|
||||
if (!slot.canvas || slot.currentType === "none" || !slot.visualizer) continue;
|
||||
slot.visualizer.render(renderData, settings.barColor);
|
||||
}
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
// Initialization (events)
|
||||
|
||||
PlayState.onState(unloads, (state) => {
|
||||
if (state === "PLAYING" && !isSourceConnected) {
|
||||
isSourceConnected = connectAudio();
|
||||
}
|
||||
});
|
||||
|
||||
MediaItem.onMediaTransition(unloads, () => {
|
||||
isSourceConnected = false;
|
||||
if (PlayState.playing) {
|
||||
isSourceConnected = connectAudio();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialization (startup)
|
||||
// Init
|
||||
|
||||
log("Initializing...");
|
||||
|
||||
if (PlayState.playing) {
|
||||
isSourceConnected = connectAudio();
|
||||
if (!tryConnect()) scheduleRetry();
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(animate);
|
||||
@@ -285,24 +478,24 @@ animationId = requestAnimationFrame(animate);
|
||||
|
||||
unloads.add(() => {
|
||||
log("Plugin unloading");
|
||||
clearRetry();
|
||||
|
||||
if (navArrowsEl) {
|
||||
navArrowsEl.style.marginRight = "";
|
||||
navArrowsEl = null;
|
||||
}
|
||||
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
animationId = null;
|
||||
}
|
||||
|
||||
clearSlot(navSlot);
|
||||
clearSlot(npSlot);
|
||||
|
||||
try { audioSource?.disconnect(); } catch {}
|
||||
|
||||
if (audioContext && audioContext.state !== "closed") {
|
||||
audioContext.close();
|
||||
for (const group of groups.values()) {
|
||||
for (const slot of group.slots) {
|
||||
slot.visualizer?.dispose();
|
||||
}
|
||||
group.groupContainer.remove();
|
||||
}
|
||||
|
||||
audioContext = null;
|
||||
analyser = null;
|
||||
audioSource = null;
|
||||
dataArray = null;
|
||||
isSourceConnected = false;
|
||||
groups.clear();
|
||||
audio.dispose();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user