Equicord/src/plugins/_api/audioPlayer.ts
Etorix 1546776a9a
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.
2025-09-20 12:51:28 -04:00

179 lines
7.8 KiB
TypeScript

/*
* 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();
}
});