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.
This commit is contained in:
parent
d1e3a68eae
commit
1546776a9a
19 changed files with 633 additions and 236 deletions
245
src/api/AudioPlayer.ts
Normal file
245
src/api/AudioPlayer.ts
Normal file
|
|
@ -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<string, AudioProcessor> = {};
|
||||
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<HTMLAudioElement>;
|
||||
_volume: number;
|
||||
_speed: number;
|
||||
outputChannel: string;
|
||||
type: AudioType;
|
||||
preload: boolean;
|
||||
persistent: boolean;
|
||||
onEnded?: AudioCallback;
|
||||
onError?: AudioErrorHandler;
|
||||
processAudio: () => void;
|
||||
ensureAudio(): Promise<HTMLAudioElement>;
|
||||
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<number> | null;
|
||||
/** The current time of the audio in seconds, or null if not yet loaded. */
|
||||
time: Promise<number> | null;
|
||||
/** The paused state of the audio, or null if not yet loaded. */
|
||||
paused: Promise<boolean> | null;
|
||||
/** The muted state of the audio, or null if not yet loaded. */
|
||||
muted: Promise<boolean> | 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<number> | 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<boolean> | 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<boolean> | 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<number> | 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;
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>;
|
||||
|
|
@ -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<HTMLInputElement>(null);
|
||||
const update = useForceUpdater();
|
||||
const sound = React.useRef<SoundPlayer | null>(null);
|
||||
const sound = React.useRef<AudioPlayerInterface | null>(null);
|
||||
const [files, setFiles] = React.useState<Record<string, StoredAudioFile>>({});
|
||||
|
||||
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();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -16,58 +16,75 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<string>();
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<typeof setInterval> = 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
});
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
179
src/plugins/_api/audioPlayer.ts
Normal file
179
src/plugins/_api/audioPlayer.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue