mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-17 19:33:10 +10:00
557 lines
16 KiB
TypeScript
557 lines
16 KiB
TypeScript
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 = (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,
|
|
};
|
|
|
|
// Slot Management
|
|
|
|
interface Slot {
|
|
container: HTMLDivElement | null;
|
|
canvas: HTMLCanvasElement | null;
|
|
visualizer: Visualizer | null;
|
|
currentType: VisualizerType;
|
|
contextType: "webgl" | "canvas2d" | null;
|
|
}
|
|
|
|
interface SlotGroup {
|
|
groupContainer: HTMLDivElement;
|
|
slots: Slot[];
|
|
keys: readonly SlotKey[];
|
|
}
|
|
|
|
const groups = new Map<string, SlotGroup>();
|
|
let navArrowsEl: HTMLElement | null = null;
|
|
let idleHidden = false;
|
|
|
|
const getSlot = (key: SlotKey): VisualizerType =>
|
|
(settings as unknown as Record<string, VisualizerType>)[key] ?? "none";
|
|
|
|
const isWebGLViz = (type: VisualizerType): boolean =>
|
|
type === "spectrum-line" || type === "spectrum-bars";
|
|
|
|
const isMiniSlot = (key: SlotKey): boolean =>
|
|
(settings.miniSlots ?? []).includes(key);
|
|
|
|
const getSlotDims = (type: VisualizerType, key: SlotKey) =>
|
|
isMiniSlot(key) && MINI_DIMENSIONS[type] ? MINI_DIMENSIONS[type] : VISUALIZER_DIMENSIONS[type];
|
|
|
|
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;
|
|
};
|
|
|
|
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);
|
|
};
|
|
|
|
const switchVisualizer = (slot: Slot, type: VisualizerType, key: SlotKey): void => {
|
|
if (slot.currentType === type) return;
|
|
|
|
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 });
|
|
}
|
|
};
|
|
|
|
const updateGroupVisibility = (group: SlotGroup): void => {
|
|
const activeCount = group.slots.filter(s => s.currentType !== "none").length;
|
|
const allNone = activeCount === 0;
|
|
const hidden = allNone || idleHidden;
|
|
group.groupContainer.style.display = hidden ? "none" : "flex";
|
|
if (!hidden) syncGroupHeights(group);
|
|
|
|
group.groupContainer.classList.toggle(
|
|
"av-grouped",
|
|
settings.groupedSlots && activeCount >= 2,
|
|
);
|
|
|
|
if (group === groups.get("topNav-left") && navArrowsEl) {
|
|
navArrowsEl.style.marginRight = hidden ? "" : "0";
|
|
}
|
|
};
|
|
|
|
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 };
|
|
};
|
|
|
|
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 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 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 attachNpGroups = (anchor: Element): void => {
|
|
const leftContent = anchor.parentElement;
|
|
if (!leftContent) return;
|
|
const header = leftContent.parentElement as HTMLElement | null;
|
|
if (!header) return;
|
|
|
|
const npLeft = groups.get("nowPlaying-left");
|
|
if (npLeft && !npLeft.groupContainer.isConnected) {
|
|
leftContent.insertBefore(npLeft.groupContainer, anchor.nextSibling);
|
|
initGroupVisualizers(npLeft);
|
|
}
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const attachPbGroups = (anchor: Element): void => {
|
|
const trackInfo = anchor.querySelector('[data-test="track-info"]');
|
|
const utilityContainer = anchor.querySelector('[class*="utilityContainer"]');
|
|
|
|
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 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 combined = (wave1 + wave2 + wave3 + 1) / 2;
|
|
const travel = Math.sin(x * Math.PI * 3 - waveTime * 2) * 0.5 + 0.5;
|
|
|
|
const byteVal = Math.floor(combined * travel * 140 + 20);
|
|
idleByteFreq[i] = byteVal;
|
|
idleFloatFreq[i] = -40 + byteVal * 0.3;
|
|
|
|
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;
|
|
}
|
|
|
|
return {
|
|
byteFrequency: idleByteFreq,
|
|
byteTimeDomain: idleByteTime,
|
|
floatFrequency: idleFloatFreq,
|
|
floatTimeDomain: idleFloatTime,
|
|
leftTimeDomain: idleLeftTime,
|
|
rightTimeDomain: idleRightTime,
|
|
sampleRate: 44100,
|
|
fftSize: IDLE_SIZE * 2,
|
|
binCount: IDLE_SIZE,
|
|
};
|
|
};
|
|
|
|
// Static idle data — flat, silent, no movement
|
|
const STATIC_IDLE_DATA: AudioData = {
|
|
byteFrequency: new Uint8Array(IDLE_SIZE),
|
|
byteTimeDomain: new Uint8Array(IDLE_SIZE).fill(128),
|
|
floatFrequency: new Float32Array(IDLE_SIZE).fill(-100),
|
|
floatTimeDomain: new Float32Array(IDLE_SIZE),
|
|
leftTimeDomain: new Float32Array(IDLE_SIZE),
|
|
rightTimeDomain: new Float32Array(IDLE_SIZE),
|
|
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>();
|
|
let lastGrouped = settings.groupedSlots;
|
|
let lastChromeless = settings.transparentContainers;
|
|
|
|
const syncChromelessClass = (): void => {
|
|
document.body.classList.toggle("av-chromeless", !!settings.transparentContainers);
|
|
};
|
|
|
|
syncChromelessClass();
|
|
|
|
for (const key of ALL_SLOT_KEYS) {
|
|
lastSlotTypes.set(key, getSlot(key));
|
|
lastMiniState.set(key, isMiniSlot(key));
|
|
}
|
|
|
|
const animate = (): void => {
|
|
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 grouped = settings.groupedSlots;
|
|
if (grouped !== lastGrouped) {
|
|
for (const group of groups.values()) updateGroupVisibility(group);
|
|
lastGrouped = grouped;
|
|
}
|
|
|
|
const chromeless = !!settings.transparentContainers;
|
|
if (chromeless !== lastChromeless) {
|
|
syncChromelessClass();
|
|
lastChromeless = chromeless;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// idleMode: 0 = animated, 1 = hide when idle, 2 = static when idle
|
|
const idleMode = settings.idleMode ?? 0;
|
|
const newIdleHidden = !hasSignal && idleMode === 1;
|
|
if (newIdleHidden !== idleHidden) {
|
|
idleHidden = newIdleHidden;
|
|
for (const group of groups.values()) updateGroupVisibility(group);
|
|
}
|
|
|
|
if (!idleHidden) {
|
|
let renderData: AudioData;
|
|
if (hasSignal) renderData = data as AudioData;
|
|
else if (idleMode === 2) renderData = STATIC_IDLE_DATA;
|
|
else renderData = 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);
|
|
};
|
|
|
|
// Init
|
|
|
|
log("Initializing...");
|
|
|
|
if (PlayState.playing) {
|
|
if (!tryConnect()) scheduleRetry();
|
|
}
|
|
|
|
animationId = requestAnimationFrame(animate);
|
|
|
|
// Cleanup
|
|
|
|
unloads.add(() => {
|
|
log("Plugin unloading");
|
|
clearRetry();
|
|
|
|
document.body.classList.remove("av-chromeless");
|
|
|
|
if (navArrowsEl) {
|
|
navArrowsEl.style.marginRight = "";
|
|
navArrowsEl = null;
|
|
}
|
|
|
|
if (animationId) {
|
|
cancelAnimationFrame(animationId);
|
|
animationId = null;
|
|
}
|
|
|
|
for (const group of groups.values()) {
|
|
for (const slot of group.slots) {
|
|
slot.visualizer?.dispose();
|
|
}
|
|
group.groupContainer.remove();
|
|
}
|
|
groups.clear();
|
|
audio.dispose();
|
|
});
|