From 43ebdc2064331a00f542ff61effb6cfad015a21b Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 28 Mar 2026 12:27:48 -0400 Subject: [PATCH] Rewrite BetterAudioPlayer(#935) * BetterAudioPlayer with patches * small fixes * some extra fixes, formatting, etc --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com> --- .../betterAudioPlayer/index.tsx | 432 +++++++++--------- .../{style.css => styles.css} | 5 +- 2 files changed, 207 insertions(+), 230 deletions(-) rename src/equicordplugins/betterAudioPlayer/{style.css => styles.css} (69%) diff --git a/src/equicordplugins/betterAudioPlayer/index.tsx b/src/equicordplugins/betterAudioPlayer/index.tsx index 7c0204ecd..a3034c6d0 100644 --- a/src/equicordplugins/betterAudioPlayer/index.tsx +++ b/src/equicordplugins/betterAudioPlayer/index.tsx @@ -4,316 +4,292 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import "./style.css"; +import "./styles.css"; import { definePluginSettings } from "@api/Settings"; import { EquicordDevs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; import definePlugin, { OptionType } from "@utils/types"; -import { showToast, Toasts } from "@webpack/common"; +import { ColorUtils, React, showToast, Toasts } from "@webpack/common"; -const fileSizeLimit = 12e6; +const cl = classNameFactory("vc-better-audio-player-"); +const CORS_PROXY = "https://cors.keiran0.workers.dev?url="; +const MAX_FILE_SIZE = 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; +interface PlayerInstance { + mediaRef: React.RefObject; + props: { src: string; type: string; }; } -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"); +function validateColor(value: string, key: string, fallback: string) { + const rgbPattern = /^\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}$/; + if (rgbPattern.test(value)) return; - 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 corsProxyUrl = "https://cors.keiran0.workers.dev?url=" + encodeURIComponent(madeURL.href); - const response = await fetch(corsProxyUrl); - 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); + if (value.startsWith("#")) { + const rgb = ColorUtils.hexToRgb(value); + if (rgb) { + settings.store[key] = `${rgb.r}, ${rgb.g}, ${rgb.b}`; + return; } - - 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(); - }); + showToast(`Invalid color format for ${key}, use "R, G, B" or "#RRGGBB"`, Toasts.Type.FAILURE); + settings.store[key] = fallback; } -function drawOscilloscope(canvasContext, canvas, dataArray, bufferLength) { - const sliceWidth = canvas.width / bufferLength; +function maxTypedArray(arr: Uint8Array): number { + let max = 0; + for (let i = 0; i < arr.length; i++) { + if (arr[i] > max) max = arr[i]; + } + return max; +} + +function drawOscilloscope(ctx: CanvasRenderingContext2D, w: number, h: number, dataArray: Uint8Array, bufferLength: number) { + const sliceWidth = w / bufferLength; + const [r, g, b] = settings.store.oscilloscopeColor.split(",").map(Number); + const amp = 3; 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(); + ctx.lineWidth = 2; + ctx.beginPath(); for (let i = 0; i < bufferLength; i++) { - const v = dataArray[i] / 128.0; - const y = (v * canvas.height) / 2; + const v = (dataArray[i] - 128) / 128; + const y = (h / 2) - (v * amp * h / 2); - if (oscilloscopeSolidColor) { - canvasContext.strokeStyle = `rgb(${r}, ${g}, ${b})`; + if (settings.store.oscilloscopeSolidColor) { + ctx.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); + const absV = Math.abs(v); + ctx.strokeStyle = `rgb(${Math.min(r + absV * 100 + (i / bufferLength) * 155, 255)}, ${Math.min(g + absV * 50 + (i / bufferLength) * 155, 255)}, ${Math.min(b + absV * 150 + (i / bufferLength) * 155, 255)})`; } + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); x += sliceWidth; } - - canvasContext.stroke(); + ctx.stroke(); } -function drawSpectrograph(canvasContext, canvas, frequencyData, bufferLength) { - const { spectrographSolidColor, spectrographColor } = settings.store; - const maxHeight = canvas.height; - const barWidth = canvas.width / bufferLength; +function drawSpectrograph(ctx: CanvasRenderingContext2D, w: number, h: number, frequencyData: Uint8Array, bufferLength: number) { + const barWidth = w / bufferLength; + const maxVal = maxTypedArray(frequencyData); + if (maxVal === 0) return; + + const [r, g, b] = settings.store.spectrographColor.split(",").map(Number); 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; + const barH = (frequencyData[i] / maxVal) * h; - if (spectrographSolidColor) { - canvasContext.fillStyle = `rgb(${spectrographColor})`; + if (settings.store.spectrographSolidColor) { + ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; } 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); + const gradient = ctx.createLinearGradient(x, h - barH, x, h); 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; + gradient.addColorStop(1, `rgb(${Math.max(red - 50, 0)}, ${Math.max(green - 50, 0)}, ${Math.max(blue - 50, 0)})`); + ctx.fillStyle = gradient; } - canvasContext.fillRect(x, canvas.height - normalizedHeight, barWidth, normalizedHeight); + ctx.fillRect(x, h - barH, barWidth, barH); 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); +async function fetchAudioBlob(src: string): Promise { + const url = new URL(src); + url.searchParams.set("t", Date.now().toString()); - if (!metadata) return; + const response = await fetch(CORS_PROXY + encodeURIComponent(url.href)); + const contentLength = response.headers.get("content-length"); + if (contentLength && Number(contentLength) > MAX_FILE_SIZE) return null; - addListeners(metadata.audio, metadata.url, metadata.parentBorderRadius); - }); + const blob = await response.blob(); + return URL.createObjectURL(blob); } -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); - } - }); +function Visualizer({ playerRef, src }: { playerRef: React.RefObject; src: string; }) { + const canvasRef = React.useRef(null); + const audioCtxRef = React.useRef(null); + const analyserRef = React.useRef(null); + const animFrameRef = React.useRef(0); + const setupDoneRef = React.useRef(false); + const blobUrlRef = React.useRef(null); + + React.useEffect(() => { + const audio = playerRef.current; + const canvas = canvasRef.current; + if (!audio || !canvas) return () => { }; + + let cancelled = false; + + const init = async () => { + const blobUrl = await fetchAudioBlob(src).catch(() => null); + if (cancelled || !blobUrl) return; + + blobUrlRef.current = blobUrl; + + const wasPlaying = !audio.paused; + const { currentTime } = audio; + audio.src = blobUrl; + audio.currentTime = currentTime; + + const audioCtx = new AudioContext(); + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 2048; + const source = audioCtx.createMediaElementSource(audio); + source.connect(analyser); + analyser.connect(audioCtx.destination); + audioCtxRef.current = audioCtx; + analyserRef.current = analyser; + setupDoneRef.current = true; + + if (wasPlaying) { + audio.play().catch(() => { }); } - }); - }); - observer.observe(targetNode, { - childList: true, - subtree: true, - }); - return observer; -} + }; -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; -} + const canvasCtx = canvas.getContext("2d"); + let dataArray: Uint8Array | null = null; + let frequencyData: Uint8Array | null = null; -function handleColorChange(value, settingKey, defaultValue) { - const rgbPattern = /^(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})$/; + const draw = () => { + const analyser = analyserRef.current; + if (!canvasCtx || !analyser) return; - if (!value.match(rgbPattern)) { - const rgb = tryHexToRgb(value); + if (!dataArray || !frequencyData) { + const bufferLength = analyser.frequencyBinCount; + dataArray = new Uint8Array(bufferLength); + frequencyData = new Uint8Array(bufferLength); + } - 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; - } + if (!audio.paused) animFrameRef.current = requestAnimationFrame(draw); + + analyser.getByteTimeDomainData(dataArray); + analyser.getByteFrequencyData(frequencyData); + + const { width, height } = canvas.getBoundingClientRect(); + canvasCtx.clearRect(0, 0, width, height); + if (settings.store.oscilloscope) drawOscilloscope(canvasCtx, width, height, dataArray, dataArray.length); + if (settings.store.spectrograph) drawSpectrograph(canvasCtx, width, height, frequencyData, frequencyData.length); + }; + + const onPlay = () => { + if (!setupDoneRef.current) return; + if (audioCtxRef.current?.state === "suspended") { + audioCtxRef.current.resume(); + } + draw(); + }; + + const onPause = () => { + audioCtxRef.current?.suspend(); + cancelAnimationFrame(animFrameRef.current); + }; + + audio.addEventListener("play", onPlay); + audio.addEventListener("pause", onPause); + init(); + + return () => { + cancelled = true; + audio.removeEventListener("play", onPlay); + audio.removeEventListener("pause", onPause); + cancelAnimationFrame(animFrameRef.current); + audioCtxRef.current?.close(); + audioCtxRef.current = null; + analyserRef.current = null; + setupDoneRef.current = false; + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, [playerRef]); + + React.useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return () => { }; + + const resize = () => { + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * window.devicePixelRatio; + canvas.height = rect.height * window.devicePixelRatio; + const ctx = canvas.getContext("2d"); + ctx?.scale(window.devicePixelRatio, window.devicePixelRatio); + }; + + resize(); + const observer = new ResizeObserver(resize); + observer.observe(canvas); + return () => observer.disconnect(); + }, []); + + return ( + + ); } const settings = definePluginSettings({ oscilloscope: { type: OptionType.BOOLEAN, - description: "Enable oscilloscope visualizer", + description: "Enable oscilloscope visualizer.", default: true, }, spectrograph: { type: OptionType.BOOLEAN, - description: "Enable spectrograph visualizer", + description: "Enable spectrograph visualizer.", default: true, }, oscilloscopeSolidColor: { type: OptionType.BOOLEAN, - description: "Use solid color for oscilloscope", + description: "Use a solid color for the oscilloscope.", default: false, }, oscilloscopeColor: { type: OptionType.STRING, - description: "Color for oscilloscope", + description: "Color for the oscilloscope (R, G, B or #hex).", default: "255, 255, 255", - onChange: value => handleColorChange(value, "oscilloscopeColor", "255, 255, 255"), + onChange: value => validateColor(value, "oscilloscopeColor", "255, 255, 255"), }, spectrographSolidColor: { type: OptionType.BOOLEAN, - description: "Use solid color for spectrograph", + description: "Use a solid color for the spectrograph.", default: false, }, spectrographColor: { type: OptionType.STRING, - description: "Color for spectrograph", + description: "Color for the spectrograph (R, G, B or #hex).", 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, + onChange: value => validateColor(value, "spectrographColor", "33, 150, 243"), }, }); -let observer: MutationObserver | null = null; - export default definePlugin({ name: "BetterAudioPlayer", - description: "Adds a spectrograph and oscilloscope visualizer to audio attachment players", + 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); - observer = createObserver(targetNode as HTMLElement); - } else { - requestAnimationFrame(waitForContent); - } - }; - waitForContent(); - }, - stop() { - observer?.disconnect(); - observer = null; + + patches: [ + { + find: "}renderPlayIcon(){", + replacement: { + match: /this\.renderAudio\(\):this\.renderVideo\(\)/, + replace: "$&,$self.renderVisualizer(this)", + }, + }, + ], + + renderVisualizer(player: PlayerInstance) { + if (player.props.type !== "AUDIO") return null; + return ; }, }); diff --git a/src/equicordplugins/betterAudioPlayer/style.css b/src/equicordplugins/betterAudioPlayer/styles.css similarity index 69% rename from src/equicordplugins/betterAudioPlayer/style.css rename to src/equicordplugins/betterAudioPlayer/styles.css index 7ce03332a..2bfaba0fd 100644 --- a/src/equicordplugins/betterAudioPlayer/style.css +++ b/src/equicordplugins/betterAudioPlayer/styles.css @@ -1,4 +1,4 @@ -.better-audio-visualizer { +.vc-better-audio-player-canvas { position: absolute; top: 0; left: 0; @@ -7,4 +7,5 @@ pointer-events: none; z-index: 1; border: none; -} + border-radius: inherit; +} \ No newline at end of file