From 1546776a9a14112240b7632b33b6b02b7dedbc09 Mon Sep 17 00:00:00 2001 From: Etorix <92535668+EtorixDev@users.noreply.github.com> Date: Sat, 20 Sep 2025 09:51:28 -0700 Subject: [PATCH] AudioPlayerAPI (#404) * Add AudioPlayerAPI. * Separate preload and persistent. * Edge case on restarting in quick succession. * Pass type to processors. * Add more clicks to make faster typing clearer. * Simplify stopAudio. * Fix. * Missing patch. * Relocate patch. --- src/api/AudioPlayer.ts | 245 ++++++++++++++++++ src/api/index.ts | 60 +++-- src/equicordplugins/bypassStatus/index.tsx | 4 +- .../customSounds/SoundOverrideComponent.tsx | 39 +-- src/equicordplugins/customSounds/index.tsx | 77 +++--- src/equicordplugins/customSounds/types.ts | 7 - src/equicordplugins/demonstration/index.tsx | 13 +- src/equicordplugins/equissant/index.ts | 11 +- src/equicordplugins/jumpscare/index.tsx | 13 +- src/equicordplugins/keyboardSounds/index.ts | 87 ++++--- src/equicordplugins/questify/index.tsx | 25 +- src/equicordplugins/questify/settings.tsx | 18 +- src/equicordplugins/questify/utils/misc.tsx | 7 +- src/equicordplugins/soggy/index.tsx | 48 ++-- .../soundBoardLogger/index.tsx | 2 +- src/equicordplugins/soundBoardLogger/utils.ts | 5 +- src/equicordplugins/tiktokTTS/index.ts | 13 +- .../vcNarratorCustom/index.tsx | 16 +- src/plugins/_api/audioPlayer.ts | 179 +++++++++++++ 19 files changed, 633 insertions(+), 236 deletions(-) create mode 100644 src/api/AudioPlayer.ts create mode 100644 src/plugins/_api/audioPlayer.ts diff --git a/src/api/AudioPlayer.ts b/src/api/AudioPlayer.ts new file mode 100644 index 000000000..efba1b838 --- /dev/null +++ b/src/api/AudioPlayer.ts @@ -0,0 +1,245 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import { findByCodeLazy, findLazy } from "webpack"; + +let defaultSounds: null | string[] = null; +const findDefaultSounds = findLazy(module => module.resolve && module.id && module.keys().some(key => key.endsWith(".mp3")), false); +const AudioPlayerConstructor = findByCodeLazy("could not play audio"); + +export type AudioProcessor = (data: PreprocessAudioData) => void; +export type AudioCallback = (() => void); +export type AudioErrorHandler = ((error: Error) => void); +export const audioProcessorFunctions: Record = {}; +export const AudioPlayerAPILogger = new Logger("AudioPlayerAPI"); + +export enum AudioType { + URL = "url", + DATA = "data-uri", + BLOB = "blob", + PATH = "file-path", + OTHER = "other", + DISCORD = "discord" +} + +export interface PreprocessAudioData { + audio: string; + readonly type: AudioType; + volume: number; + speed: number; +} + +export interface AudioPlayerInternal { + preprocessDataOriginal: PreprocessAudioData; + preprocessDataPrevious: PreprocessAudioData | null; + preprocessDataCurrent: PreprocessAudioData; + audio: string; + _audio: null | Promise; + _volume: number; + _speed: number; + outputChannel: string; + type: AudioType; + preload: boolean; + persistent: boolean; + onEnded?: AudioCallback; + onError?: AudioErrorHandler; + processAudio: () => void; + ensureAudio(): Promise; + destroyAudio(): void; + loop(): void; + play(): void; + pause(): void; + stop(restart?: boolean): void; +} + +export interface AudioPlayerInterface { + /** The internal Discord audio filename (e.g. "discodo"), a data URI, or an external URL that follows the CSP. */ + audio: string; + /** The read-only type of audio determined during processing. */ + readonly type: AudioType; + /** The duration of the audio in seconds, or null if not yet loaded. */ + readonly duration: Promise | null; + /** The current time of the audio in seconds, or null if not yet loaded. */ + time: Promise | null; + /** The paused state of the audio, or null if not yet loaded. */ + paused: Promise | null; + /** The muted state of the audio, or null if not yet loaded. */ + muted: Promise | null; + /** The volume of the audio between 0 and 100. */ + volume: number; + /** The playback speed of the audio between 0.0625 and 16. */ + speed: number; + /** Whether to load the audio immediately. If persistent is false, this will only apply until the first playback. */ + preload?: boolean; + /** Whether the audio element is persistent and not recreated for every playback. */ + persistent: boolean; + /** Preloads the audio before playback. Automatically called when persistent is true. */ + load(): void; + /** Sets the audio to loop until paused or stopped. */ + loop(): void; + /** Plays the audio. */ + play(): void; + /** Pauses the audio. */ + pause(): void; + /** Stops the audio. */ + stop(): void; + /** Plays the audio from the beginning. */ + restart(): void; + /** Seeks to a specific time in seconds. */ + seek(time: number): void; + /** Mutes the audio. */ + mute(): void; + /** Unmutes the audio. */ + unmute(): void; + /** Deletes the audio element. Necessary if persistent is true. */ + delete(): void; +} + +export interface AudioPlayerOptions { + volume?: number; + speed?: number; + preload?: boolean; + persistent?: boolean; + onEnded?: AudioCallback; + onError?: AudioErrorHandler; +} + +// Wrap the player to allow reprocessing the audio when properties are changed and to alleviate +// the confusion between the public API accepting 0-100 volume while the internal API uses 0-1 volume. +class AudioPlayerWrapper implements AudioPlayerInterface { + private internalPlayer: AudioPlayerInternal; + constructor(internalPlayer: AudioPlayerInternal) { this.internalPlayer = internalPlayer; } + + get audio(): string { return this.internalPlayer.audio; } + set audio(value: string) { this.internalPlayer.preprocessDataOriginal.audio = value; this.internalPlayer.processAudio(); } + + get volume(): number { return this.internalPlayer._volume * 100; } + set volume(value: number) { this.internalPlayer.preprocessDataOriginal.volume = Math.max(0, Math.min(1, value / 100)); this.internalPlayer.processAudio(); } + + get speed(): number { return this.internalPlayer._speed; } + set speed(value: number) { this.internalPlayer.preprocessDataOriginal.speed = Math.max(0.0625, Math.min(16, value)); this.internalPlayer.processAudio(); } + + get time(): Promise | null { return this.internalPlayer._audio?.then(audio => audio.currentTime) ?? null; } + set time(value: number) { this.internalPlayer.ensureAudio().then(audio => audio.currentTime = value); } + + get persistent(): boolean { return this.internalPlayer.persistent; } + set persistent(value: boolean) { this.internalPlayer.persistent = value; } + + get muted(): Promise | null { return this.internalPlayer._audio?.then(audio => audio.muted) ?? null; } + set muted(value: boolean) { this.internalPlayer.ensureAudio().then(audio => audio.muted = value); } + + get paused(): Promise | null { return this.internalPlayer._audio?.then(audio => audio.paused) ?? null; } + set paused(value: boolean) { value ? this.internalPlayer.pause() : this.internalPlayer.play(); } + + get type(): AudioType { return this.internalPlayer.type; } + get duration(): Promise | null { return this.internalPlayer._audio?.then(audio => audio.duration) ?? null; } + + load(): void { this.internalPlayer.ensureAudio(); } + loop(): void { this.internalPlayer.loop(); } + play(): void { this.internalPlayer.play(); } + pause(): void { this.internalPlayer.pause(); } + stop(restart?: boolean): void { this.internalPlayer.stop(restart); } + restart(): void { this.internalPlayer.stop(true); } + seek(time: number): void { this.internalPlayer.ensureAudio().then(audio => audio.currentTime = time); } + mute(): void { this.internalPlayer.ensureAudio().then(audio => audio.muted = true); } + unmute(): void { this.internalPlayer.ensureAudio().then(audio => audio.muted = false); } + delete(): void { this.internalPlayer.destroyAudio(); } +} + +/** + * Creates an audio player. + * @param audio The internal Discord audio filename (e.g. "discodo"), a data URI, or an external URL that follows the CSP. + * @param options Additional options for the audio player. + * @param options.volume The volume of the audio, between 0 and 100, defaulting to 100. + * @param options.speed The playback speed of the audio, between 0.0625 and 16, defaulting to 1. + * @param options.preload Whether to load the audio immediately. If persistent is false, this will only apply until the first playback. + * @param options.persistent Whether the audio element is persistent and not recreated for every playback. If persistent, you must call delete() to free the memory. Defaults to false. + * @param options.onEnded An optional callback that is called every time the audio finishes playing. + * @param options.onError An optional error handler that is passed an Error object when an error occurs during audio playback. + * @return The created audio player. + */ +export function createAudioPlayer( + audio: string, + options: AudioPlayerOptions = {} +): AudioPlayerInterface { + const internalPlayer: AudioPlayerInternal = new AudioPlayerConstructor( + audio, + null, + null, + "default", + options + ); + + return new AudioPlayerWrapper(internalPlayer); +} + +/** + * Plays an audio instantly and returns the player. + * @param audio The internal Discord audio filename (e.g. "discodo"), a data URI, or an external URL that follows the CSP. + * @param options Additional options for the audio player. + * @param options.volume The volume of the audio, between 0 and 100, defaulting to 100. + * @param options.speed The playback speed of the audio, between 0.0625 and 16, defaulting to 1. + * @param options.preload Whether to load the audio immediately. If persistent is false, this will only apply until the first playback. + * @param options.persistent Whether the audio element is persistent and not recreated for every playback. If persistent, you must call delete() to free the memory. Defaults to false. + * @param options.onEnded An optional callback that is called every time the audio finishes playing. + * @param options.onError An optional error handler that is passed an Error object when an error occurs during audio playback. + * @return The created audio player. + */ +export function playAudio(audio: string, options: AudioPlayerOptions = {}): AudioPlayerInterface { + const player = createAudioPlayer(audio, options); + player.play(); + return player; +} + +/** + * Identifies the type of audio based on its string. + * @param audio The audio string to identify. + * @returns The identified AudioType. + */ +export function identifyAudioType(audio: string): AudioType { + if (defaultAudioNames().includes(audio)) return AudioType.DISCORD; + + try { + const url = new URL(audio); + if (url.protocol === "http:" || url.protocol === "https:") return AudioType.URL; + if (url.protocol === "data:") return AudioType.DATA; + if (url.protocol === "blob:") return AudioType.BLOB; + if (url.protocol === "file:") return AudioType.PATH; + return AudioType.OTHER; + } catch { + return AudioType.OTHER; + } +} + +/** + * Adds a function to process an audio before it is played. + * @param key A unique identifier for this audio processor. Plugin name is recommended. + * @param processor A function that takes an audio, volume (0-100), and type (AudioType), and returns the replacement audio and volume. + */ +export function addAudioProcessor(key: string, processor: AudioProcessor): void { + AudioPlayerAPILogger.log("Adding Audio Processor:", { key, processor }); + audioProcessorFunctions[key] = processor; +} + +/** + * Removes an audio processor by its key. + * @param key The unique identifier of the audio processor to remove. + */ +export function removeAudioProcessor(key: string): void { + AudioPlayerAPILogger.log("Removing Audio Processor:", { key }); + delete audioProcessorFunctions[key]; +} + +/** Returns an array of all internal Discord audio filenames. */ +export function defaultAudioNames(): string[] { + defaultSounds ??= (findDefaultSounds.keys() || []).map(key => { + const match = key.match(/((?:\w|-)+)\.mp3$/); + return match ? match[1] : null; + }).filter(Boolean) as string[]; + + return defaultSounds; +} diff --git a/src/api/index.ts b/src/api/index.ts index 8af6a49f6..d3cb0ed38 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +import * as $AudioPlayer from "./AudioPlayer"; import * as $Badges from "./Badges"; import * as $ChatButtons from "./ChatButtons"; import * as $Commands from "./Commands"; @@ -37,27 +38,30 @@ import * as $UserSettings from "./UserSettings"; /** * An API allowing you to listen to Message Clicks or run your own logic - * before a message is sent + * before a message is sent. * - * If your plugin uses this, you must add MessageEventsAPI to its dependencies + * If your plugin uses this, you must add MessageEventsAPI to its dependencies. */ export const MessageEvents = $MessageEventsAPI; + /** * An API allowing you to create custom notices - * (snackbars on the top, like the Update prompt) + * (snackbars on the top, like the Update prompt). */ export const Notices = $Notices; + /** - * An API allowing you to register custom commands + * An API allowing you to register custom commands. */ export const Commands = $Commands; + /** * A wrapper around IndexedDB. This can store arbitrarily * large data and supports a lot of datatypes (Blob, Map, ...). - * For a full list, see the mdn link below + * For a full list, see the mdn link below. * * This should always be preferred over the Settings API if possible, as - * localstorage has very strict size restrictions and blocks the event loop + * localstorage has very strict size restrictions and blocks the event loop. * * Make sure your keys are unique (tip: prefix them with ur plugin name) * and please clean up no longer needed entries. @@ -66,70 +70,84 @@ export const Commands = $Commands; * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#supported_types} */ export const DataStore = $DataStore; + /** - * An API allowing you to add custom components as message accessories + * An API allowing you to add custom components as message accessories. */ export const MessageAccessories = $MessageAccessories; + /** - * An API allowing you to add custom buttons in the message popover + * An API allowing you to add custom buttons in the message popover. */ export const MessagePopover = $MessagePopover; + /** - * An API allowing you to add badges to user profiles + * An API allowing you to add badges to user profiles. */ export const Badges = $Badges; + /** - * An API allowing you to add custom elements to the server list + * An API allowing you to add custom elements to the server list. */ export const ServerList = $ServerList; + /** - * An API allowing you to add components as message accessories + * An API allowing you to add components as message accessories. */ export const MessageDecorations = $MessageDecorations; + /** - * An API allowing you to add components to member list users, in both DM's and servers + * An API allowing you to add components to member list users, in both DM's and servers. */ export const MemberListDecorators = $MemberListDecorators; + /** - * An API allowing you to persist data + * An API allowing you to persist data. */ export const Settings = $Settings; + /** - * An API allowing you to dynamically load styles + * An API allowing you to dynamically load styles. * a */ export const Styles = $Styles; + /** - * An API allowing you to display notifications + * An API allowing you to display notifications. */ export const Notifications = $Notifications; /** - * An api allowing you to patch and add/remove items to/from context menus + * An api allowing you to patch and add/remove items to/from context menus. */ export const ContextMenu = $ContextMenu; /** - * An API allowing you to add buttons to the chat input + * An API allowing you to add buttons to the chat input. */ export const ChatButtons = $ChatButtons; /** - * An API allowing you to update and re-render messages + * An API allowing you to update and re-render messages. */ export const MessageUpdater = $MessageUpdater; /** - * An API allowing you to get an user setting + * An API allowing you to get an user setting. */ export const UserSettings = $UserSettings; /** - * An API allowing you to add icons to the nickname, in profiles + * An API allowing you to add icons to the nickname, in profiles. */ export const NicknameIcons = $NicknameIcons; /** - * Just used to identify if user is on Equicord as Vencord doesnt have this + * An API allowing you to play internal Discord audio files or external audio URLs/URIs. + */ +export const AudioPlayer = $AudioPlayer; + +/** + * Just used to identify if user is on Equicord as Vencord doesn't have this. */ export const isEquicord = true; diff --git a/src/equicordplugins/bypassStatus/index.tsx b/src/equicordplugins/bypassStatus/index.tsx index bc91399b4..37cafab2e 100644 --- a/src/equicordplugins/bypassStatus/index.tsx +++ b/src/equicordplugins/bypassStatus/index.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { playAudio } from "@api/AudioPlayer"; import { type NavContextMenuPatchCallback } from "@api/ContextMenu"; import { Notifications } from "@api/index"; import { definePluginSettings } from "@api/Settings"; @@ -59,7 +60,7 @@ async function showNotification(message: Message, guildId: string | undefined): }); if (settings.store.notificationSound) { - new Audio("https://discord.com/assets/9422aef94aa931248105.mp3").play(); + playAudio("message1"); } } catch (error) { new Logger("BypassStatus").error("Failed to notify user: ", error); @@ -150,6 +151,7 @@ export default definePlugin({ name: "BypassStatus", description: "Still get notifications from specific sources when in do not disturb mode. Right-click on users/channels/guilds to set them to bypass do not disturb mode.", authors: [Devs.Inbestigator], + dependencies: ["AudioPlayerAPI"], flux: { async MESSAGE_CREATE({ message, guildId, channelId }: IMessageCreate): Promise { try { diff --git a/src/equicordplugins/customSounds/SoundOverrideComponent.tsx b/src/equicordplugins/customSounds/SoundOverrideComponent.tsx index 246cf691d..5d7aa402a 100644 --- a/src/equicordplugins/customSounds/SoundOverrideComponent.tsx +++ b/src/equicordplugins/customSounds/SoundOverrideComponent.tsx @@ -4,17 +4,18 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { AudioPlayerInterface, playAudio } from "@api/AudioPlayer"; import { classNameFactory } from "@api/Styles"; import { Margins } from "@utils/margins"; import { useForceUpdater } from "@utils/react"; import { makeRange } from "@utils/types"; -import { findByCodeLazy, findLazy } from "@webpack"; +import { findLazy } from "@webpack"; import { Button, Card, Forms, React, Select, showToast, Slider, Switch } from "@webpack/common"; import { ComponentType, Ref, SyntheticEvent } from "react"; import { deleteAudio, getAllAudio, saveAudio, StoredAudioFile } from "./audioStore"; import { ensureDataURICached } from "./index"; -import { SoundOverride, SoundPlayer, SoundType } from "./types"; +import { SoundOverride, SoundType } from "./types"; type FileInput = ComponentType<{ ref: Ref; @@ -25,7 +26,6 @@ type FileInput = ComponentType<{ const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "m4a", "aac", "flac", "webm", "wma", "mp4"]; const cl = classNameFactory("vc-custom-sounds-"); -const playSound: (id: string) => SoundPlayer = findByCodeLazy(".playWithListener().then"); const FileInput: FileInput = findLazy(m => m.prototype?.activateUploadDialogue && m.prototype.setRef); const capitalizeWords = (str: string) => @@ -38,7 +38,7 @@ export function SoundOverrideComponent({ type, override, onChange }: { }) { const fileInputRef = React.useRef(null); const update = useForceUpdater(); - const sound = React.useRef(null); + const sound = React.useRef(null); const [files, setFiles] = React.useState>({}); React.useEffect(() => { @@ -54,7 +54,7 @@ export function SoundOverrideComponent({ type, override, onChange }: { sound.current?.stop(); if (!override.enabled) { - sound.current = playSound(type.id); + sound.current = playAudio(type.id); return; } @@ -69,32 +69,20 @@ export function SoundOverrideComponent({ type, override, onChange }: { return; } - const audio = new Audio(dataUri); - audio.volume = override.volume / 100; - - audio.onerror = e => { - console.error("[CustomSounds] Error playing custom audio:", e); - showToast("Error playing custom sound. File may be corrupted."); - }; - - await audio.play(); - sound.current = { - play: () => audio.play(), - pause: () => audio.pause(), - stop: () => { - audio.pause(); - audio.currentTime = 0; - }, - loop: () => { audio.loop = true; } - }; + sound.current = playAudio(dataUri, { + volume: override.volume, onError: e => { + console.error("[CustomSounds] Error playing custom audio:", e); + showToast("Error playing custom sound. File may be corrupted."); + } + }); } catch (error) { console.error("[CustomSounds] Error in previewSound:", error); showToast("Error playing sound."); } } else if (selectedSound === "default") { - sound.current = playSound(type.id); + sound.current = playAudio(type.id); } else { - sound.current = playSound(selectedSound); + sound.current = playAudio(selectedSound); } }; @@ -207,6 +195,7 @@ export function SoundOverrideComponent({ type, override, onChange }: { markers={makeRange(0, 100, 10)} initialValue={override.volume} onValueChange={val => { + sound.current && (sound.current.volume = val); override.volume = val; saveAndNotify(); }} diff --git a/src/equicordplugins/customSounds/index.tsx b/src/equicordplugins/customSounds/index.tsx index 3458a0554..3b0624411 100644 --- a/src/equicordplugins/customSounds/index.tsx +++ b/src/equicordplugins/customSounds/index.tsx @@ -6,6 +6,7 @@ import "./styles.css"; +import { addAudioProcessor, AudioProcessor, PreprocessAudioData, removeAudioProcessor } from "@api/AudioPlayer"; import { get as getFromDataStore } from "@api/DataStore"; import { definePluginSettings } from "@api/Settings"; import { classNameFactory } from "@api/Styles"; @@ -42,42 +43,55 @@ function setOverride(id: string, override: SoundOverride) { settings.store[id] = JSON.stringify(override); } -export function getCustomSoundURL(id: string): string | null { - const override = getOverride(id); +export const getCustomSoundURL: AudioProcessor = (data: PreprocessAudioData) => { + let audioOverride = data.audio; + + if (data.audio in seasonalSounds) { + audioOverride = soundTypes.find(sound => sound.seasonal?.includes(data.audio))?.id || data.audio; + } + + const override = getOverride(audioOverride); if (!override?.enabled) { - return null; + return; } if (override.selectedSound === "custom" && override.selectedFileId) { const dataUri = dataUriCache.get(override.selectedFileId); if (dataUri) { - console.log(`[CustomSounds] Returning cached data URI for ${id}`); - return dataUri; + data.audio = dataUri; + data.volume = override.volume; + return; } else { - console.warn(`[CustomSounds] No cached data URI for ${id} with file ID ${override.selectedFileId}`); - return null; + return; } } if (override.selectedSound !== "default" && override.selectedSound !== "custom") { if (override.selectedSound in seasonalSounds) { - return seasonalSounds[override.selectedSound]; + data.audio = seasonalSounds[override.selectedSound]; + data.volume = override.volume; + return; } - const soundType = allSoundTypes.find(t => t.id === id); + const soundType = allSoundTypes.find(t => t.id === data.audio); + if (soundType?.seasonal) { const seasonalId = soundType.seasonal.find(seasonalId => seasonalId.startsWith(`${override.selectedSound}_`) ); + if (seasonalId && seasonalId in seasonalSounds) { - return seasonalSounds[seasonalId]; + data.audio = seasonalSounds[seasonalId]; + data.volume = override.volume; + return; } } } - return null; -} + data.volume = override.volume; + return; +}; export async function ensureDataURICached(fileId: string): Promise { if (dataUriCache.has(fileId)) { @@ -379,43 +393,15 @@ export default definePlugin({ name: "CustomSounds", description: "Customize Discord's sounds.", authors: [Devs.ScattrdBlade, Devs.TheKodeToad], - patches: [ - { - find: 'Error("could not play audio")', - replacement: [ - { - match: /(?<=new Audio;\i\.src=).{0,75}.concat\(this\.name,"\.mp3"\)\)/, - replace: "$self.getSoundUrl(this.name,$&)" - }, - { - match: /Math.min\(\i\.\i\.getOutputVolume\(\).{0,20}volume/, - replace: "$& * ($self.findOverride(this.name)?.volume ?? 100) / 100" - } - ] - }, - { - find: ".playWithListener().then", - replacement: { - match: /\i\.\i\.getSoundpack\(\)/, - replace: '$self.isOverriden(arguments[0]) ? "classic" : $&' - } - } - ], - getSoundUrl(name, extra) { - const customUrl = this.getCustomSoundURL(name); - return customUrl || extra; - }, + dependencies: ["AudioPlayerAPI"], + settings, - findOverride, - isOverriden, getCustomSoundURL, - refreshDataURI, - ensureDataURICached, - debugCustomSounds, startAt: StartAt.Init, async start() { console.log("[CustomSounds] Plugin starting..."); + addAudioProcessor("CustomSounds", this.getCustomSoundURL); try { await preloadDataURIs(); @@ -423,5 +409,10 @@ export default definePlugin({ } catch (error) { console.error("[CustomSounds] Startup failed:", error); } + }, + + stop() { + removeAudioProcessor("CustomSounds"); + console.log("[CustomSounds] Plugin stopped"); } }); diff --git a/src/equicordplugins/customSounds/types.ts b/src/equicordplugins/customSounds/types.ts index 570a8b838..a586ae361 100644 --- a/src/equicordplugins/customSounds/types.ts +++ b/src/equicordplugins/customSounds/types.ts @@ -18,13 +18,6 @@ export interface SoundOverride { selectedFileId?: string; } -export interface SoundPlayer { - loop(): void; - play(): void; - pause(): void; - stop(): void; -} - export const seasonalSounds = { "halloween_call_calling": "https://canary.discord.com/assets/0950a7ea4f1dd037870b.mp3", "winter_call_calling": "https://canary.discord.com/assets/7b945e7be3f86c5b7c82.mp3", diff --git a/src/equicordplugins/demonstration/index.tsx b/src/equicordplugins/demonstration/index.tsx index 54c2b459e..bb50917ba 100644 --- a/src/equicordplugins/demonstration/index.tsx +++ b/src/equicordplugins/demonstration/index.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { playAudio } from "@api/AudioPlayer"; import { definePluginSettings } from "@api/Settings"; import { classNameFactory } from "@api/Styles"; import { Devs, EquicordDevs } from "@utils/constants"; @@ -72,6 +73,7 @@ function ToggleModal() { } function handleToggle() { + console.log("Toggling demonstration mode"); const style = document.getElementById("DemonstrationStyle"); if (style != null) { style.remove(); @@ -118,18 +120,19 @@ function handleKeydown(event: KeyboardEvent) { } async function playSound(url: string) { - const audio = new Audio(url); - audio.volume = settings.store.soundVolume; - await audio.play().catch(error => { - console.error("Error playing sound:", error); + playAudio(url, { + volume: settings.store.soundVolume * 100, + onError: error => { + console.error("Error playing sound:", error); + } }); - audio.remove(); } export default definePlugin({ name: "Demonstration", description: "Plugin for taking theme screenshots - censors identifying images and text.", authors: [Devs.Samwich, EquicordDevs.Panniku], + dependencies: ["AudioPlayerAPI"], settings, toolboxActions: { "Toggle Demonstration": (() => handleToggle()) diff --git a/src/equicordplugins/equissant/index.ts b/src/equicordplugins/equissant/index.ts index 849d1ad04..188316213 100644 --- a/src/equicordplugins/equissant/index.ts +++ b/src/equicordplugins/equissant/index.ts @@ -4,20 +4,20 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer"; import { definePluginSettings } from "@api/Settings"; import { Devs, EquicordDevs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; let clickCount = 0; +let croissant: AudioPlayerInterface | null = null; function play() { + const triggerAmount = settings.store.amount; clickCount++; - const triggerAmount = settings.store.amount; - if (clickCount % triggerAmount === 0) { - const audio = new Audio("https://github.com/Equicord/Equibored/raw/main/sounds/equissant/croissant.mp3"); - audio.play(); + croissant?.play(); clickCount = 0; } } @@ -34,11 +34,14 @@ export default definePlugin({ name: "Equissant", description: "Crossant every specified amount of clicks :trolley:", authors: [EquicordDevs.SomeAspy, Devs.thororen], + dependencies: ["AudioPlayerAPI"], settings, start() { + croissant = createAudioPlayer("https://github.com/Equicord/Equibored/raw/main/sounds/equissant/croissant.mp3", { persistent: true }); document.addEventListener("click", play); }, stop() { + croissant?.delete(); document.removeEventListener("click", play); } }); diff --git a/src/equicordplugins/jumpscare/index.tsx b/src/equicordplugins/jumpscare/index.tsx index 67ddcf7c0..3357ecb97 100644 --- a/src/equicordplugins/jumpscare/index.tsx +++ b/src/equicordplugins/jumpscare/index.tsx @@ -6,6 +6,7 @@ import "./styles.css"; +import { createAudioPlayer } from "@api/AudioPlayer"; import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; @@ -48,6 +49,7 @@ export default definePlugin({ name: "Jumpscare", description: "Adds a configurable chance of jumpscaring you whenever you open a channel. Inspired by Geometry Dash Mega Hack", authors: [Devs.surgedevs], + dependencies: ["AudioPlayerAPI"], settings, start() { @@ -63,8 +65,7 @@ export default definePlugin({ JumpscareComponent() { const [isPlaying, setIsPlaying] = useState(false); - - const audio = new Audio(settings.store.audioSource); + const jumpscareAudio = createAudioPlayer(settings.store.audioSource, { volume: 100, onEnded: () => { setIsPlaying(false); } }); const jumpscare = event => { if (isPlaying) return; @@ -73,13 +74,7 @@ export default definePlugin({ if (Math.random() > chance) return; setIsPlaying(true); - audio.play(); - - console.log(isPlaying); - - setTimeout(() => { - setIsPlaying(false); - }, 1000); + jumpscareAudio.play(); }; useEffect(() => { diff --git a/src/equicordplugins/keyboardSounds/index.ts b/src/equicordplugins/keyboardSounds/index.ts index 2b6d292aa..7f1361a74 100644 --- a/src/equicordplugins/keyboardSounds/index.ts +++ b/src/equicordplugins/keyboardSounds/index.ts @@ -16,58 +16,75 @@ * along with this program. If not, see . */ +import { AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer"; +import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; -let click1, click2, click3, backspace; -let sounds = { - click1, - click2, - click3, - backspace -}; +let backspace: AudioPlayerInterface; +let clicks: Array<{ playing: boolean; player: AudioPlayerInterface; }> = []; +const keysCurrentlyPressed = new Set(); +let previousSoundIndex = 0; const ignoredKeys = ["CapsLock", "ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight", "ArrowUp", "ArrowRight", "ArrowLeft", "ArrowDown", "MediaPlayPause", "MediaStop", "MediaTrackNext", "MediaTrackPrevious", "MediaSelect", "MediaEject", "MediaVolumeUp", "MediaVolumeDown", "AudioVolumeUp", "AudioVolumeDown"]; +const keyup = (e: KeyboardEvent) => { keysCurrentlyPressed.delete(e.code); }; + const keydown = (e: KeyboardEvent) => { if (ignoredKeys.includes(e.code)) return; - for (const sound of Object.values(sounds)) sound.pause(); + if (!clicks.length || !backspace) return; + if (keysCurrentlyPressed.has(e.code)) return; + keysCurrentlyPressed.add(e.code); + if (e.code === "Backspace") { - sounds.backspace.currentTime = 0; - sounds.backspace.play(); + backspace.restart(); } else { - const click = sounds[`click${Math.floor(Math.random() * 3) + 1}`]; - click.currentTime = 0; - click.play(); + const nonplayingClicks = clicks.filter(click => !click.playing); + const randomIndex = Math.floor(Math.random() * nonplayingClicks.length); + const chosenClick = nonplayingClicks.length ? nonplayingClicks[randomIndex] : clicks[previousSoundIndex]; + previousSoundIndex = randomIndex; + chosenClick.playing = true; + chosenClick.player.restart(); } }; +function assignSounds(volume: number) { + backspace = createAudioPlayer("https://github.com/Equicord/Equibored/raw/main/sounds/keyboard/backspace.wav", { volume, preload: true, persistent: true }); + clicks = []; + + for (let i = 0; i < 3; i++) { + const baseIndex = i * 3; + clicks.push({ playing: false, player: createAudioPlayer("https://github.com/Equicord/Equibored/raw/main/sounds/keyboard/click1.wav", { volume, preload: true, persistent: true, onEnded: () => { clicks[baseIndex].playing = false; } }) }); + clicks.push({ playing: false, player: createAudioPlayer("https://github.com/Equicord/Equibored/raw/main/sounds/keyboard/click2.wav", { volume, preload: true, persistent: true, onEnded: () => { clicks[baseIndex + 1].playing = false; } }) }); + clicks.push({ playing: false, player: createAudioPlayer("https://github.com/Equicord/Equibored/raw/main/sounds/keyboard/click3.wav", { volume, preload: true, persistent: true, onEnded: () => { clicks[baseIndex + 2].playing = false; } }) }); + } +} + +const settings = definePluginSettings({ + volume: { + description: "Volume of the keyboard sounds.", + type: OptionType.SLIDER, + markers: [0, 25, 50, 75, 100], + stickToMarkers: false, + default: 100, + onChange: value => { assignSounds(value); } + } +}); + export default definePlugin({ name: "KeyboardSounds", description: "Adds the Opera GX Keyboard Sounds to Discord", authors: [Devs.HypedDomi], - start: () => { - click1 = new Audio("https://github.com/Equicord/Equibored/raw/main/sounds/keyboard/click1.wav"); - click2 = new Audio("https://github.com/Equicord/Equibored/raw/main/sounds/keyboard/click2.wav"); - click3 = new Audio("https://github.com/Equicord/Equibored/raw/main/sounds/keyboard/click3.wav"); - backspace = new Audio("https://github.com/Equicord/Equibored/raw/main/sounds/keyboard/backspace.wav"); - sounds = { - click1, - click2, - click3, - backspace, - }; + dependencies: ["AudioPlayerAPI"], + settings, + start() { + assignSounds(settings.store.volume); + document.addEventListener("keyup", keyup); document.addEventListener("keydown", keydown); }, - stop: () => document.removeEventListener("keydown", keydown), - options: { - volume: { - description: "Volume", - type: OptionType.SLIDER, - markers: [0, 100], - stickToMarkers: false, - default: 100, - onChange: value => { for (const sound of Object.values(sounds)) sound.volume = value / 100; } - } - } + stop: () => { + [...clicks, { player: backspace }].forEach(sound => sound.player.delete()); + document.removeEventListener("keyup", keyup); + document.removeEventListener("keydown", keydown); + }, }); diff --git a/src/equicordplugins/questify/index.tsx b/src/equicordplugins/questify/index.tsx index e0a4a9646..835943aa9 100644 --- a/src/equicordplugins/questify/index.tsx +++ b/src/equicordplugins/questify/index.tsx @@ -790,7 +790,7 @@ export default definePlugin({ name: "Questify", description: "Enhance your Quest experience with a suite of features, or disable them entirely if they're not your thing.", authors: [EquicordDevs.Etorix], - dependencies: ["ServerListAPI"], + dependencies: ["AudioPlayerAPI", "ServerListAPI"], startAt: StartAt.Init, // Needed in order to beat Read All Messages to inserting above the server list. settings, @@ -812,29 +812,6 @@ export default definePlugin({ activeQuestIntervals, patches: [ - { - find: "could not play audio", - group: true, - replacement: [ - { - // Enables external audio sources for playing audio. - match: /(?<=new Audio;\i\.src=)/, - replace: "this.name.startsWith('https')?this.name:" - }, - { - // Adds an optional callback to the audio player. This is needed to detect - // when the audio has finished playing as playWithListener() relies on a duration - // variable which is never present. - match: /(constructor\(\i,\i,\i,\i)(\){)/, - replace: "$1,callback$2this.callback=callback||null," - }, - { - // Makes use of the callback if provided. - match: /(?<=.onended=\(\)=>)(this.destroyAudio\(\))/, - replace: "{this.callback?this.callback():null;$1;}" - } - ] - }, { // Hides the notice in the gift inventory that Quests have been relocated to the Discovery tab. find: "quests-wumpus-hikes-mountain-transparent-background", diff --git a/src/equicordplugins/questify/settings.tsx b/src/equicordplugins/questify/settings.tsx index 51c82ebb7..0625d2c3f 100644 --- a/src/equicordplugins/questify/settings.tsx +++ b/src/equicordplugins/questify/settings.tsx @@ -4,19 +4,18 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { defaultAudioNames, playAudio } from "@api/AudioPlayer"; import { definePluginSettings } from "@api/Settings"; import { ErrorBoundary } from "@components/index"; import { Logger } from "@utils/Logger"; import { OptionType } from "@utils/types"; -import { findLazy } from "@webpack"; import { Button, ColorPicker, ContextMenuApi, Forms, Menu, Select, TextInput, useEffect, useRef, useState } from "@webpack/common"; import { JSX } from "react"; import { activeQuestIntervals, getQuestTileClasses, getQuestTileStyle } from "./index"; import { DynamicDropdown, DynamicDropdownSettingOption, ExcludedQuest, GuildlessServerListItem, Quest, QuestIcon, QuestStatus, QuestTile, RadioGroup, RadioOption, SelectOption, SoundIcon } from "./utils/components"; -import { AudioPlayer, decimalToRGB, fetchAndDispatchQuests, getFormattedNow, getIgnoredQuestIDs, getQuestStatus, isDarkish, isSoundAllowed, leftClick, middleClick, q, QuestifyLogger, QuestsStore, rightClick, setIgnoredQuestIDs, validCommaSeparatedList } from "./utils/misc"; +import { decimalToRGB, fetchAndDispatchQuests, getFormattedNow, getIgnoredQuestIDs, getQuestStatus, isDarkish, isSoundAllowed, leftClick, middleClick, q, QuestifyLogger, QuestsStore, rightClick, setIgnoredQuestIDs, validCommaSeparatedList } from "./utils/misc"; -let defaultSounds: string[] | null = null; let autoFetchInterval: null | ReturnType = null; const defaultLeftClickAction = "open-quests"; const defaultMiddleClickAction = "plugin-settings"; @@ -30,7 +29,6 @@ const defaultIgnoredColor = 8334124; const defaultExpiredColor = 2368553; const defaultRestyleQuestsGradient = "intense"; const defaultFetchQuestsAlert = "discodo"; -const findDefaultSounds = findLazy(module => module.resolve && module.id && module.keys().some(key => key.endsWith(".mp3")), false); export const minimumAutoFetchIntervalValue = 30 * 60; export const maximumAutoFetchIntervalValue = 12 * 60 * 60; @@ -51,7 +49,7 @@ export function fetchAndAlertQuests(source: string, logger: Logger): void { if (shouldAlert) { logger.info(`[${getFormattedNow()}] New Quests detected. Playing alert sound.`); - AudioPlayer(shouldAlert, 1).play(); + playAudio(shouldAlert); } else { logger.info(`[${getFormattedNow()}] New Quests detected.`); } @@ -1112,12 +1110,7 @@ function FetchingQuestsSetting(): JSX.Element { { value: 60 * 60 * 6, label: "12 Hours" }, ]; - defaultSounds ??= (findDefaultSounds.keys() || []).map(key => { - const match = key.match(/((?:\w|-)+)\.mp3$/); - return match ? match[1] : null; - }).filter(Boolean) as string[]; - - const resolvedSounds: SelectOption[] = defaultSounds.map(sound => { + const resolvedSounds: SelectOption[] = defaultAudioNames().map(sound => { const label = sound.toUpperCase().replace(/_/g, " ").replace(/(\d+)/g, " $1"); return { value: sound, label }; }); @@ -1392,8 +1385,7 @@ function FetchingQuestsSetting(): JSX.Element { if (activePlayer.current) { clearActivePlayer(); } else { - activePlayer.current = AudioPlayer(currentAlertSelection.value as string, 1, clearActivePlayer); - activePlayer.current?.play(); + activePlayer.current = playAudio(currentAlertSelection.value as string, { onEnded: clearActivePlayer }); setIsPlaying(true); } } diff --git a/src/equicordplugins/questify/utils/misc.tsx b/src/equicordplugins/questify/utils/misc.tsx index 5f62452b5..8db0ec06e 100644 --- a/src/equicordplugins/questify/utils/misc.tsx +++ b/src/equicordplugins/questify/utils/misc.tsx @@ -6,7 +6,7 @@ import { classNameFactory } from "@api/Styles"; import { Logger } from "@utils/Logger"; -import { findByCodeLazy, findStoreLazy } from "@webpack"; +import { findStoreLazy } from "@webpack"; import { FluxDispatcher, RestAPI, UserStore } from "@webpack/common"; import { questIsIgnored, settings } from "../settings"; @@ -14,11 +14,6 @@ import { Quest, QuestStatus, RGB } from "./components"; export const q = classNameFactory("questify-"); export const QuestifyLogger = new Logger("Questify"); -// Only takes an audio name or URL. Plays instantly. -export const PlayAudio = findByCodeLazy("Unable to find sound for pack name:"); -// Takes an audio name or URL, volume, and a callback function. Plays when play() is called. -const AudioPlayerConstructor = findByCodeLazy("sound has no duration"); -export function AudioPlayer(name: string, volume: number = 1, callback?: () => void): any { return new AudioPlayerConstructor(name, null, volume, "default", callback ?? (() => { })); } export const QuestsStore = findStoreLazy("QuestsStore"); export const questPath = "/discovery/quests"; export const leftClick = 0; diff --git a/src/equicordplugins/soggy/index.tsx b/src/equicordplugins/soggy/index.tsx index 5c37506a9..d1f6fba72 100644 --- a/src/equicordplugins/soggy/index.tsx +++ b/src/equicordplugins/soggy/index.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer"; import { definePluginSettings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { EquicordDevs } from "@utils/constants"; @@ -13,19 +14,28 @@ import { findComponentByCodeLazy } from "@webpack"; import { React } from "@webpack/common"; const HeaderBarIcon = findComponentByCodeLazy(".HEADER_BAR_BADGE_TOP:", '.iconBadge,"top"'); +let boopSound: AudioPlayerInterface; +let song: AudioPlayerInterface; -let preloadSong, preloadBoopSound, song, boopSound; +function assignSong(url: string, volume: number) { + song?.delete(); + song = createAudioPlayer(url, { volume, preload: true, persistent: true }); + song.load(); +} + +function assignBoop(url: string, volume: number) { + boopSound?.delete(); + boopSound = createAudioPlayer(url, { volume, preload: true, persistent: true }); + boopSound.load(); +} function SoggyModal(props: ModalProps) { if (settings.store.songVolume !== 0) { React.useEffect(() => { - song = new Audio(preloadSong.src); - song.volume = settings.store.songVolume; - song.play(); + song?.loop(); return () => { - song.pause(); - song.remove(); + song?.stop(); }; }, []); } @@ -42,9 +52,7 @@ function SoggyModal(props: ModalProps) { offsetY >= region.y && offsetY <= region.y + region.height ) { - boopSound = new Audio(preloadBoopSound.src); - boopSound.volume = settings.store.boopVolume; - boopSound.play(); + boopSound?.play(); } }; @@ -115,16 +123,16 @@ const settings = definePluginSettings({ description: "URL for the song to play", type: OptionType.STRING, default: "https://github.com/Equicord/Equibored/raw/main/sounds/soggy/song.mp3?raw=true", - onChange: (value: string) => { - song = new Audio(value); - } + onChange(newValue) { + assignSong(newValue, settings.store.songVolume * 100); + }, }, boopLink: { description: "URL for the boop sound", type: OptionType.STRING, default: "https://github.com/Equicord/Equibored/raw/main/sounds/soggy/honk.wav?raw=true", - onChange: (value: string) => { - boopSound = new Audio(value); + onChange(newValue) { + assignBoop(newValue, settings.store.boopVolume * 100); } } }); @@ -134,6 +142,7 @@ export default definePlugin({ description: "Adds a soggy button to the toolbox", authors: [EquicordDevs.sliwka], settings, + dependencies: ["AudioPlayerAPI"], patches: [ { find: ".controlButtonWrapper,", @@ -144,9 +153,14 @@ export default definePlugin({ } ], - start: () => { - preloadSong = new Audio(settings.store.songLink); - preloadBoopSound = new Audio(settings.store.boopLink); + start() { + assignBoop(settings.store.boopLink, settings.store.boopVolume * 100); + assignSong(settings.store.songLink, settings.store.songVolume * 100); + }, + + stop() { + boopSound?.delete(); + song?.delete(); }, // taken from message logger lol diff --git a/src/equicordplugins/soundBoardLogger/index.tsx b/src/equicordplugins/soundBoardLogger/index.tsx index 369b2137e..705bc9498 100644 --- a/src/equicordplugins/soundBoardLogger/index.tsx +++ b/src/equicordplugins/soundBoardLogger/index.tsx @@ -22,7 +22,7 @@ export default definePlugin({ name: "SoundBoardLogger", authors: [Devs.Moxxie, EquicordDevs.Fres, Devs.amy, Devs.thororen], description: "Logs all soundboards that are played in a voice chat and allows you to download them", - dependencies: ["ChatInputButtonAPI"], + dependencies: ["AudioPlayerAPI", "ChatInputButtonAPI"], patches: [ { predicate: () => settings.store.IconLocation === "toolbar", diff --git a/src/equicordplugins/soundBoardLogger/utils.ts b/src/equicordplugins/soundBoardLogger/utils.ts index 9bb91868d..f25200a8f 100644 --- a/src/equicordplugins/soundBoardLogger/utils.ts +++ b/src/equicordplugins/soundBoardLogger/utils.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { playAudio } from "@api/AudioPlayer"; import { classNameFactory } from "@api/Styles"; import { proxyLazy } from "@utils/lazy"; import { LazyComponent } from "@utils/react"; @@ -40,9 +41,7 @@ export function getEmojiUrl(emoji) { } export const playSound = id => { - const audio = new Audio(`https://cdn.discordapp.com/soundboard-sounds/${id}`); - audio.volume = settings.store.soundVolume; - audio.play(); + playAudio(`https://cdn.discordapp.com/soundboard-sounds/${id}`, { volume: settings.store.soundVolume * 100 }); }; export async function downloadAudio(id: string): Promise { diff --git a/src/equicordplugins/tiktokTTS/index.ts b/src/equicordplugins/tiktokTTS/index.ts index 41f988f30..7362fcff4 100644 --- a/src/equicordplugins/tiktokTTS/index.ts +++ b/src/equicordplugins/tiktokTTS/index.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { playAudio } from "@api/AudioPlayer"; import { EquicordDevs } from "@utils/constants"; import { getCurrentChannel } from "@utils/discord"; import definePlugin from "@utils/types"; @@ -16,22 +17,14 @@ async function readOutText(text: string): Promise { }); const mp3JSON = await mp3Response.json(); - - const audioElement = new Audio(); - - audioElement.src = `data:audio/mpeg;base64,${mp3JSON.data}`; - audioElement.volume = 0.5; - audioElement.onended = () => audioElement.remove(); - - document.body.appendChild(audioElement); - - audioElement.play(); + playAudio(`data:audio/mpeg;base64,${mp3JSON.data}`, { volume: 50 }); } export default definePlugin({ name: "TiktokTTS", description: "Reads out chat messages with the good ol' Tiktok TTS voice :sob:", authors: [EquicordDevs.VillainsRule], + dependencies: ["AudioPlayerAPI"], flux: { async MESSAGE_CREATE({ optimistic, type, message }) { if (optimistic || type !== "MESSAGE_CREATE") return; diff --git a/src/equicordplugins/vcNarratorCustom/index.tsx b/src/equicordplugins/vcNarratorCustom/index.tsx index d4a84b9c9..f11c12daf 100644 --- a/src/equicordplugins/vcNarratorCustom/index.tsx +++ b/src/equicordplugins/vcNarratorCustom/index.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { playAudio } from "@api/AudioPlayer"; import { definePluginSettings } from "@api/Settings"; import { Devs, EquicordDevs } from "@utils/constants"; import { Margins } from "@utils/margins"; @@ -90,10 +91,7 @@ async function speak(text: string, { volume, rate, customVoice } = settings.stor // 1. Check the in-memory cache (fast check) if (ttsCache.has(cacheKey)) { const cachedUrl = ttsCache.get(cacheKey)!; - const audio = new Audio(cachedUrl); - audio.volume = volume; - audio.playbackRate = rate; - audio.play(); + playAudio(cachedUrl, { volume: volume * 100, speed: rate }); return; } @@ -105,10 +103,7 @@ async function speak(text: string, { volume, rate, customVoice } = settings.stor const url = URL.createObjectURL(cachedBlob); // Save it in the in-memory cache for next time. ttsCache.set(cacheKey, url); - const audio = new Audio(url); - audio.volume = volume; - audio.playbackRate = rate; - audio.play(); + playAudio(url, { volume: volume * 100, speed: rate }); return; } } catch (err) { @@ -155,10 +150,7 @@ async function speak(text: string, { volume, rate, customVoice } = settings.stor console.error("Error storing in IndexedDB:", err); } - const audio = new Audio(url); - audio.volume = volume; - audio.playbackRate = rate; - audio.play(); + playAudio(url, { volume: volume * 100, speed: rate }); } function clean(str: string) { diff --git a/src/plugins/_api/audioPlayer.ts b/src/plugins/_api/audioPlayer.ts new file mode 100644 index 000000000..649db5315 --- /dev/null +++ b/src/plugins/_api/audioPlayer.ts @@ -0,0 +1,179 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { AudioPlayerAPILogger, AudioPlayerInternal, AudioPlayerOptions, audioProcessorFunctions, AudioType, identifyAudioType, playAudio } from "@api/AudioPlayer"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin from "@utils/types"; + +export default definePlugin({ + name: "AudioPlayerAPI", + description: "API to play internal Discord audio files or external audio links.", + authors: [EquicordDevs.Etorix], + AudioType, + playAudio, + + patches: [ + { + find: "could not play audio", + group: true, + replacement: [ + { + // Uses the audio as-is if external, otherwise checks for an internal Discord sound. + // Also force loads the internal sounds module to account for the second patch group below, + // as well as accounting for not calling the module in this patch when this.type is not DISCORD. + match: /(let \i=class.{0,900}?new Audio;\i.src=)((\i\(\d+\)).{0,50}concat\()this.name(,".mp3"\)\))/, + replace: "$3;$1this.type!==$self.AudioType.DISCORD?this.audio:$2this.audio$4" + }, + { + // Adds an optional persistent boolean as well as a callback and error handler to the + // audio player which is called after the audio finishes playing and when an error occurs. + // Also processes the audio before playing to apply override functions set by plugins. + match: /(?<=constructor\((\i,\i,\i,\i)).{0,200}outputChannel=\i/, + replace: ",options){$self.buildPlayer(this,$1,options);" + }, + { + // Prevents an error from the source being cleared during destroyAudio(). + match: /(\i.pause\(\),(\i).src="".{0,20}?null)/, + replace: "$2.onerror=()=>{},$1" + }, + { + // Applies the playback rate passed through options. + match: /(?<=(\i).onloadeddata=\(\)=>{)/, + replace: "$1.playbackRate=this._speed," + }, + { + // Makes use of the error handler if an error occurs during playback. + match: /(onerror=\()(\)=>)(\i\(Error\("[^"]+"\)\)),/, + replace: "$1error$2{this.onError?.(error);$3;}," + }, + { + // Makes use of the onEnded callback and persists flag once the audio ends. + match: /(?<=onended=\(\)=>)(.{0,40}?),/, + replace: "{$self.stopAudio(this);this.onEnded?.();}," + }, + { + // Makes use of the persists flag once the audio is stopped. + match: /(stop\()(\){)this.destroyAudio\(\)/, + replace: "$1restart$2$self.stopAudio(this,restart);" + }, + { + // Use our playAudio function in place of the default playGiftSound. + match: /let \i=new Audio\((\(0,\i.\i\)\(\i\)).{0,35}?play\(\)/, + replace: "$self.playAudio($1)" + } + ] + }, + { + // Prevents Discord from forcing full volume for the "discodo" effect on client load. + // The internal sounds module being loaded on startup relies on one of these calls to volume + // regardless of if the "discodo" effect is enabled or not. This is due to the volume setter + // internally calling the ensureAudio function which is where the internal sounds module is loaded + // by default. To account for this, the module is force loaded in the first patch in the above group. + find: "UPDATE_OPEN_ON_STARTUP", + group: true, + replacement: [ + { + // Pass the unprocessed volume as 1 instead of overwriting it to 1 afterwards. + match: /(?<=discodo",\i)(\);return )\i.volume=1,/, + replace: ",1$1" + }, + { + // Don't re-set volume since it was already set during initialization. + match: /,(this._connectedSound.volume)=1/, + replace: ";" + } + ] + } + ], + + stopAudio(player: AudioPlayerInternal, restart?: boolean) { + if (restart) { + player.ensureAudio().then(audio => { + audio.currentTime = 0; + audio.play(); + }); + } else { + if (!player.persistent) { + player.destroyAudio(); + } else { + // Use _audio instead of ensureAudio() to avoid + // loading the audio if there is none loaded. + player._audio?.then(audio => { + audio.pause(); + audio.currentTime = 0; + }); + } + } + }, + + processAudio(player: AudioPlayerInternal) { + AudioPlayerAPILogger.log("Processing Audio:", player.preprocessDataOriginal); + + player.preprocessDataPrevious = player.preprocessDataCurrent ? structuredClone(player.preprocessDataCurrent) : null; + player.preprocessDataCurrent = structuredClone(player.preprocessDataOriginal); + player.preprocessDataCurrent.volume *= 100; + + for (const processor of Object.values(audioProcessorFunctions)) { + processor(player.preprocessDataCurrent); + } + + player.preprocessDataCurrent.volume /= 100; + player.audio = player.preprocessDataCurrent.audio; + player.type = identifyAudioType(player.preprocessDataCurrent.audio); + player._volume = Math.max(0, Math.min(1, player.preprocessDataCurrent.volume)); + player._speed = Math.max(0.0625, Math.min(16, player.preprocessDataCurrent.speed)); + + if (player.preprocessDataCurrent.audio !== player.preprocessDataPrevious?.audio) { + player.destroyAudio(); + player.persistent && player.ensureAudio(); + } + + if (player.preprocessDataCurrent.volume !== player.preprocessDataPrevious?.volume) { + player._audio?.then(audio => { + audio.volume = player._volume; + }); + } + + if (player.preprocessDataCurrent.speed !== player.preprocessDataPrevious?.speed) { + player._audio?.then(audio => { + audio.playbackRate = player._speed; + }); + } + + AudioPlayerAPILogger.log("Processed Audio:", player.preprocessDataCurrent); + }, + + buildPlayer( + player: AudioPlayerInternal, + audio: string, + unused: any, + internalVolume: number, + channel: string, + options: AudioPlayerOptions = {} + ) { + player.preprocessDataOriginal = { + audio: audio, + type: identifyAudioType(audio), + volume: Math.max(0, Math.min(1, (internalVolume || (options.volume ? options.volume / 100 : 1)))), + speed: Math.max(0.0625, Math.min(16, options.speed ?? 1)), + }; + + player.audio = player.preprocessDataOriginal.audio; + player._audio = null; + player._volume = player.preprocessDataOriginal.volume; + player._speed = player.preprocessDataOriginal.speed; + player.preload = options.preload ?? false; + player.persistent = options.persistent ?? false; + player.type = identifyAudioType(audio); + player.outputChannel = channel; + player.onEnded = options.onEnded; + player.onError = options.onError; + + player.processAudio = () => this.processAudio(player); + player.processAudio(); + player.preload && player.ensureAudio(); + } +});