/* * Vencord, a Discord client mod * Copyright (c) 2024 Vendicated and contributors * SPDX-License-Identifier: GPL-3.0-or-later */ import "./style.css"; import { definePluginSettings } from "@api/Settings"; import { EquicordDevs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; import { showToast, Toasts } from "@webpack/common"; const fileSizeLimit = 12e6; function parseFileSize(size: string) { const [value, unit] = size.split(" "); const multiplier = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4, }[unit]; if (!multiplier) return; return parseFloat(value) * multiplier; } function getMetadata(audioElement: HTMLElement) { const metadataElement = audioElement.querySelector("[class^='metadataContent_']"); const nameElement = metadataElement?.querySelector("a"); const sizeElement = audioElement.querySelector("[class^='metadataContent_'] [class^='metadataSize_']"); const url = nameElement?.getAttribute("href"); const audioElementLink = audioElement.querySelector("audio"); if (!sizeElement?.textContent || !nameElement?.textContent || !url || !audioElementLink) return false; const name = nameElement.textContent; const size = parseFileSize(sizeElement.textContent); if (size && size > fileSizeLimit) { return false; } const elements = [metadataElement?.parentElement, audioElement.querySelector("[class^='audioControls_']")]; const computedStyle = getComputedStyle(audioElement); const parentBorderRadius = computedStyle.borderRadius; if (settings.store.forceMoveBelow) { elements.forEach(element => { if (element) (element as HTMLElement).style.zIndex = "2"; }); } return { name, size, url, audio: audioElementLink, parentBorderRadius: parentBorderRadius, }; } async function addListeners(audioElement: HTMLAudioElement, url: string, parentBorderRadius: string) { const madeURL = new URL(url); madeURL.searchParams.set("t", Date.now().toString()); const { href } = madeURL; const response = await fetch(href); const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); const audioContext = new AudioContext(); const analyser = audioContext.createAnalyser(); analyser.fftSize = 2048; const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); const frequencyData = new Uint8Array(bufferLength); const source = audioContext.createMediaElementSource(audioElement); source.connect(analyser); analyser.connect(audioContext.destination); const canvas = document.createElement("canvas"); const canvasContext = canvas.getContext("2d"); if (!canvasContext) return; canvas.classList.add("better-audio-visualizer"); audioElement.parentElement?.appendChild(canvas); if (parentBorderRadius) canvas.style.borderRadius = parentBorderRadius; function drawVisualizer() { if (!audioElement.paused) { requestAnimationFrame(drawVisualizer); } analyser.getByteTimeDomainData(dataArray); analyser.getByteFrequencyData(frequencyData); if (!canvasContext) return; canvasContext.clearRect(0, 0, canvas.width, canvas.height); if (settings.store.oscilloscope) drawOscilloscope(canvasContext, canvas, dataArray, bufferLength); if (settings.store.spectrograph) drawSpectrograph(canvasContext, canvas, frequencyData, bufferLength); } audioElement.src = blobUrl; audioElement.addEventListener("play", () => { if (audioContext.state === "suspended") { audioContext.resume(); } drawVisualizer(); }); audioElement.addEventListener("pause", () => { audioContext.suspend(); }); } function drawOscilloscope(canvasContext, canvas, dataArray, bufferLength) { const sliceWidth = canvas.width / bufferLength; let x = 0; const { oscilloscopeSolidColor, oscilloscopeColor } = settings.store; const [r, g, b] = oscilloscopeColor.split(",").map(Number); canvasContext.lineWidth = 2; canvasContext.clearRect(0, 0, canvas.width, canvas.height); canvasContext.beginPath(); for (let i = 0; i < bufferLength; i++) { const v = dataArray[i] / 128.0; const y = (v * canvas.height) / 2; if (oscilloscopeSolidColor) { canvasContext.strokeStyle = `rgb(${r}, ${g}, ${b})`; } else { const red = Math.min(r + (v * 100) + (i / bufferLength) * 155, 255); const green = Math.min(g + (v * 50) + (i / bufferLength) * 155, 255); const blue = Math.min(b + (v * 150) + (i / bufferLength) * 155, 255); canvasContext.strokeStyle = `rgb(${red}, ${green}, ${blue})`; } if (i === 0) { canvasContext.moveTo(x, y); } else { canvasContext.lineTo(x, y); } x += sliceWidth; } canvasContext.stroke(); } function drawSpectrograph(canvasContext, canvas, frequencyData, bufferLength) { const { spectrographSolidColor, spectrographColor } = settings.store; const maxHeight = canvas.height; const barWidth = canvas.width / bufferLength; let x = 0; const maxFrequencyValue = Math.max(...frequencyData); if (maxFrequencyValue === 0 || !isFinite(maxFrequencyValue)) { return; } for (let i = 0; i < bufferLength; i++) { const normalizedHeight = (frequencyData[i] / maxFrequencyValue) * maxHeight; if (spectrographSolidColor) { canvasContext.fillStyle = `rgb(${spectrographColor})`; } else { const [r, g, b] = spectrographColor.split(",").map(Number); const red = Math.min(r + (i / bufferLength) * 155, 255); const green = Math.min(g + (i / bufferLength) * 155, 255); const blue = Math.min(b + (i / bufferLength) * 155, 255); const gradient = canvasContext.createLinearGradient(x, canvas.height - normalizedHeight, x, canvas.height); gradient.addColorStop(0, `rgb(${red}, ${green}, ${blue})`); const darkerColor = `rgb(${Math.max(red - 50, 0)},${Math.max(green - 50, 0)},${Math.max(blue - 50, 0)})`; gradient.addColorStop(1, darkerColor); canvasContext.fillStyle = gradient; } canvasContext.fillRect(x, canvas.height - normalizedHeight, barWidth, normalizedHeight); x += barWidth + 0.5; } } function scanForAudioElements(element: HTMLElement) { element.querySelectorAll("[class^='wrapperAudio_']:not([data-better-audio-processed])").forEach(audioElement => { (audioElement as HTMLElement).dataset.betterAudioProcessed = "true"; const metadata = getMetadata(audioElement as HTMLElement); if (!metadata) return; addListeners(metadata.audio, metadata.url, metadata.parentBorderRadius); }); } function createObserver(targetNode: HTMLElement) { const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.type === "childList") { mutation.addedNodes.forEach(addedNode => { if (addedNode instanceof HTMLElement) { scanForAudioElements(addedNode); } }); } }); }); observer.observe(targetNode, { childList: true, subtree: true, }); } function tryHexToRgb(hex) { if (hex.startsWith("#")) { const hexMatch = hex.match(/\w\w/g); if (hexMatch) { const [r, g, b] = hexMatch.map(x => parseInt(x, 16)); return `${r}, ${g}, ${b}`; } } return hex; } function handleColorChange(value, settingKey, defaultValue) { const rgbPattern = /^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/; if (!value.match(rgbPattern)) { const rgb = tryHexToRgb(value); if (rgb.match(rgbPattern)) { settings.store[settingKey] = rgb; } else { showToast(`Invalid color format for ${settingKey}, make sure it's in the format 'R, G, B' or '#RRGGBB'`, Toasts.Type.FAILURE); settings.store[settingKey] = defaultValue; } } else { settings.store[settingKey] = value; } } const settings = definePluginSettings({ oscilloscope: { type: OptionType.BOOLEAN, description: "Enable oscilloscope visualizer", default: true, }, spectrograph: { type: OptionType.BOOLEAN, description: "Enable spectrograph visualizer", default: true, }, oscilloscopeSolidColor: { type: OptionType.BOOLEAN, description: "Use solid color for oscilloscope", default: false, }, oscilloscopeColor: { type: OptionType.STRING, description: "Color for oscilloscope", default: "255, 255, 255", onChange: value => handleColorChange(value, "oscilloscopeColor", "255, 255, 255"), }, spectrographSolidColor: { type: OptionType.BOOLEAN, description: "Use solid color for spectrograph", default: false, }, spectrographColor: { type: OptionType.STRING, description: "Color for spectrograph", default: "33, 150, 243", onChange: value => handleColorChange(value, "spectrographColor", "33, 150, 243"), }, forceMoveBelow: { type: OptionType.BOOLEAN, description: "Force the visualizer below the audio player", default: true, }, }); export default definePlugin({ name: "BetterAudioPlayer", description: "Adds a spectrograph and oscilloscope visualizer to audio attachment players", authors: [EquicordDevs.creations], settings, start() { const waitForContent = () => { const targetNode = document.querySelector("[class^='content_']"); if (targetNode) { scanForAudioElements(targetNode as HTMLElement); createObserver(targetNode as HTMLElement); } else { requestAnimationFrame(waitForContent); } }; waitForContent(); }, });