Updated customVoicePlugins: Added settingsModal

This commit is contained in:
fox3000foxy 2025-02-22 16:31:57 +01:00 committed by GitHub
parent 254e63be64
commit 91e6f2dbed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1331 additions and 1234 deletions

View file

@ -1,51 +1,51 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Flex, Forms, Text } from "@webpack/common";
import { JSX } from "react";
// Open Confirm Modal
export function openConfirmModal(message: string, accept: (key: string) => void): string {
const key = openModal(modalProps => (
<ConfirmModal
modalProps={modalProps}
message={message}
accept={() => accept(key)}
close={() => closeModal(key)}
/>
));
return key;
}
interface ConfirmModalProps {
modalProps: ModalProps;
message: string;
accept: () => void;
close: () => void;
}
function ConfirmModal({ modalProps, message, accept, close }: ConfirmModalProps): JSX.Element {
return (
<ModalRoot {...modalProps} size={ModalSize.SMALL}>
<ModalHeader separator={false}>
<Forms.FormTitle tag="h2" className="modalTitle">
Confirm
</Forms.FormTitle>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className="vc-voice-filters-modal">
<Text>{message}</Text>
</ModalContent>
<ModalFooter>
<Flex style={{ gap: "10px" }}>
<Button color={Button.Colors.RED} onClick={() => { accept(); close(); }}>Accept</Button>
<Button color={Button.Colors.PRIMARY} onClick={close}>Cancel</Button>
</Flex>
</ModalFooter>
</ModalRoot >
);
}
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Flex, Forms, Text } from "@webpack/common";
import { JSX } from "react";
// Open Confirm Modal
export function openConfirmModal(message: string, accept: (key: string) => void): string {
const key = openModal(modalProps => (
<ConfirmModal
modalProps={modalProps}
message={message}
accept={() => accept(key)}
close={() => closeModal(key)}
/>
));
return key;
}
interface ConfirmModalProps {
modalProps: ModalProps;
message: string;
accept: () => void;
close: () => void;
}
function ConfirmModal({ modalProps, message, accept, close }: ConfirmModalProps): JSX.Element {
return (
<ModalRoot {...modalProps} size={ModalSize.SMALL}>
<ModalHeader separator={false}>
<Forms.FormTitle tag="h2" className="modalTitle">
Confirm
</Forms.FormTitle>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className="vc-voice-filters-modal">
<Text>{message}</Text>
</ModalContent>
<ModalFooter>
<Flex style={{ gap: "10px" }}>
<Button color={Button.Colors.RED} onClick={() => { accept(); close(); }}>Accept</Button>
<Button color={Button.Colors.PRIMARY} onClick={close}>Cancel</Button>
</Flex>
</ModalFooter>
</ModalRoot >
);
}

View file

@ -1,111 +1,111 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Flex, Forms, Select, TextInput, useCallback, useMemo, UserStore, useState } from "@webpack/common";
import { SelectOption } from "@webpack/types";
import { JSX } from "react";
import { voices } from ".";
import { openErrorModal } from "./ErrorModal";
import { IVoiceFilter, useVoiceFiltersStore } from "./index";
const requiredFields = ["name", "iconURL", "onnxFileUrl", "previewSoundURLs"] as const satisfies readonly (keyof IVoiceFilter)[];
export function openCreateVoiceModal(defaultValue?: Partial<IVoiceFilter>): string {
const key = openModal(modalProps => (
<CreateVoiceFilterModal modalProps={modalProps} close={() => closeModal(key)} defaultValue={defaultValue} />
));
return key;
}
interface CreateVoiceFilterModalProps {
modalProps: ModalProps;
close: () => void;
defaultValue?: Partial<IVoiceFilter>;
}
// Create Voice Filter Modal
function CreateVoiceFilterModal({ modalProps, close, defaultValue }: CreateVoiceFilterModalProps): JSX.Element {
const currentUser = useMemo(() => UserStore.getCurrentUser(), []);
const [voiceFilter, setVoiceFilter] = useState(() => (
{ author: currentUser.id, name: "", iconURL: "", styleKey: "", onnxFileUrl: "", ...defaultValue }
));
const update = useCallback(<K extends keyof IVoiceFilter>(value: IVoiceFilter[K], key: K) => {
setVoiceFilter(prev => ({ ...prev, [key]: value }));
}, []);
const submit = useCallback(() => {
if (requiredFields.every(field => voiceFilter[field])) {
useVoiceFiltersStore.getState().downloadVoicepack(JSON.stringify({
id: voiceFilter.author + "-" + voiceFilter.name.toLowerCase().replace(/ /g, "-"),
available: true,
temporarilyAvailable: false,
custom: true,
splashGradient: "radial-gradient(circle, #d9a5a2 0%, rgba(0,0,0,0) 100%)",
baseColor: "#d9a5a2",
...voiceFilter
} satisfies IVoiceFilter));
close();
} else {
openErrorModal("Please fill in all required fields");
}
}, [voiceFilter]);
const keyOptions: SelectOption[] = useMemo(() =>
[{ value: "", label: "(empty)" }, ...(voices ? Object.keys(voices).map(name => ({ value: name, label: name })) : [])],
[]);
return (
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
<ModalHeader>
<Forms.FormTitle tag="h2" className="modalTitle">
{voiceFilter.id ? "Edit a voice filter" : "Create a voice filter"}
</Forms.FormTitle>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className="vc-voice-filters-modal">
<Flex style={{ gap: "1rem" }} direction={Flex.Direction.VERTICAL}>
<Forms.FormSection>
<Forms.FormTitle>Name<span style={{ color: "var(--text-danger)" }}>*</span></Forms.FormTitle>
<TextInput placeholder="Model" onChange={update} style={{ width: "100%" }} value={voiceFilter.name} name="name" required />
</Forms.FormSection>
<Forms.FormSection>
<Forms.FormTitle>Icon URL<span style={{ color: "var(--text-danger)" }}>*</span></Forms.FormTitle>
<TextInput placeholder="https://example.com/voicepacks/model/icon.png" onChange={update} style={{ width: "100%" }} value={voiceFilter.iconURL} name="iconURL" required />
</Forms.FormSection>
<Forms.FormSection>
<Forms.FormTitle>Style Key</Forms.FormTitle>
<Select
options={keyOptions}
placeholder={"Select an option"}
maxVisibleItems={5}
closeOnSelect={true}
select={value => update(value, "styleKey")}
isSelected={v => v === voiceFilter.styleKey}
serialize={String}
/>
</Forms.FormSection>
<Forms.FormSection>
<Forms.FormTitle>ONNX File URL<span style={{ color: "var(--text-danger)" }}>*</span></Forms.FormTitle>
<TextInput placeholder="https://example.com/voicepacks/model/model.onnx" onChange={update} style={{ width: "100%" }} value={voiceFilter.onnxFileUrl} name="onnxFileUrl" required />
</Forms.FormSection>
<Forms.FormSection>
<Forms.FormTitle>Preview Sound URL<span style={{ color: "var(--text-danger)" }}>*</span></Forms.FormTitle>
<TextInput placeholder="https://example.com/voicepacks/model/preview.mp3" onChange={value => update(value ? [value] : undefined, "previewSoundURLs")} style={{ width: "100%" }} value={voiceFilter.previewSoundURLs?.[0] ?? ""} required />
</Forms.FormSection>
</Flex>
</ModalContent>
<ModalFooter>
<Flex style={{ gap: "0.5rem" }} justify={Flex.Justify.END} align={Flex.Align.CENTER}>
<Button color={Button.Colors.TRANSPARENT} onClick={close} >Cancel</Button>
<Button color={Button.Colors.GREEN} onClick={submit}>{voiceFilter.id ? "Save" : "Create"}</Button>
</Flex>
</ModalFooter>
</ModalRoot>
);
}
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Flex, Forms, Select, TextInput, useCallback, useMemo, UserStore, useState } from "@webpack/common";
import { SelectOption } from "@webpack/types";
import { JSX } from "react";
import { voices } from ".";
import { openErrorModal } from "./ErrorModal";
import { IVoiceFilter, useVoiceFiltersStore } from "./index";
const requiredFields = ["name", "iconURL", "onnxFileUrl", "previewSoundURLs"] as const satisfies readonly (keyof IVoiceFilter)[];
export function openCreateVoiceModal(defaultValue?: Partial<IVoiceFilter>): string {
const key = openModal(modalProps => (
<CreateVoiceFilterModal modalProps={modalProps} close={() => closeModal(key)} defaultValue={defaultValue} />
));
return key;
}
interface CreateVoiceFilterModalProps {
modalProps: ModalProps;
close: () => void;
defaultValue?: Partial<IVoiceFilter>;
}
// Create Voice Filter Modal
function CreateVoiceFilterModal({ modalProps, close, defaultValue }: CreateVoiceFilterModalProps): JSX.Element {
const currentUser = useMemo(() => UserStore.getCurrentUser(), []);
const [voiceFilter, setVoiceFilter] = useState(() => (
{ author: currentUser.id, name: "", iconURL: "", styleKey: "", onnxFileUrl: "", ...defaultValue }
));
const update = useCallback(<K extends keyof IVoiceFilter>(value: IVoiceFilter[K], key: K) => {
setVoiceFilter(prev => ({ ...prev, [key]: value }));
}, []);
const submit = useCallback(() => {
if (requiredFields.every(field => voiceFilter[field])) {
useVoiceFiltersStore.getState().downloadVoicepack(JSON.stringify({
id: voiceFilter.author + "-" + voiceFilter.name.toLowerCase().replace(/ /g, "-"),
available: true,
temporarilyAvailable: false,
custom: true,
splashGradient: "radial-gradient(circle, #d9a5a2 0%, rgba(0,0,0,0) 100%)",
baseColor: "#d9a5a2",
...voiceFilter
} satisfies IVoiceFilter));
close();
} else {
openErrorModal("Please fill in all required fields");
}
}, [voiceFilter]);
const keyOptions: SelectOption[] = useMemo(() =>
[{ value: "", label: "(empty)" }, ...(voices ? Object.keys(voices).map(name => ({ value: name, label: name })) : [])],
[]);
return (
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
<ModalHeader>
<Forms.FormTitle tag="h2" className="modalTitle">
{voiceFilter.id ? "Edit a voice filter" : "Create a voice filter"}
</Forms.FormTitle>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className="vc-voice-filters-modal">
<Flex style={{ gap: "1rem" }} direction={Flex.Direction.VERTICAL}>
<Forms.FormSection>
<Forms.FormTitle>Name<span style={{ color: "var(--text-danger)" }}>*</span></Forms.FormTitle>
<TextInput placeholder="Model" onChange={update} style={{ width: "100%" }} value={voiceFilter.name} name="name" required />
</Forms.FormSection>
<Forms.FormSection>
<Forms.FormTitle>Icon URL<span style={{ color: "var(--text-danger)" }}>*</span></Forms.FormTitle>
<TextInput placeholder="https://example.com/voicepacks/model/icon.png" onChange={update} style={{ width: "100%" }} value={voiceFilter.iconURL} name="iconURL" required />
</Forms.FormSection>
<Forms.FormSection>
<Forms.FormTitle>Style Key</Forms.FormTitle>
<Select
options={keyOptions}
placeholder={"Select an option"}
maxVisibleItems={5}
closeOnSelect={true}
select={value => update(value, "styleKey")}
isSelected={v => v === voiceFilter.styleKey}
serialize={String}
/>
</Forms.FormSection>
<Forms.FormSection>
<Forms.FormTitle>ONNX File URL<span style={{ color: "var(--text-danger)" }}>*</span></Forms.FormTitle>
<TextInput placeholder="https://example.com/voicepacks/model/model.onnx" onChange={update} style={{ width: "100%" }} value={voiceFilter.onnxFileUrl} name="onnxFileUrl" required />
</Forms.FormSection>
<Forms.FormSection>
<Forms.FormTitle>Preview Sound URL<span style={{ color: "var(--text-danger)" }}>*</span></Forms.FormTitle>
<TextInput placeholder="https://example.com/voicepacks/model/preview.mp3" onChange={value => update(value ? [value] : undefined, "previewSoundURLs")} style={{ width: "100%" }} value={voiceFilter.previewSoundURLs?.[0] ?? ""} required />
</Forms.FormSection>
</Flex>
</ModalContent>
<ModalFooter>
<Flex style={{ gap: "0.5rem" }} justify={Flex.Justify.END} align={Flex.Align.CENTER}>
<Button color={Button.Colors.TRANSPARENT} onClick={close} >Cancel</Button>
<Button color={Button.Colors.GREEN} onClick={submit}>{voiceFilter.id ? "Save" : "Create"}</Button>
</Flex>
</ModalFooter>
</ModalRoot>
);
}

View file

@ -1,47 +1,47 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Forms, Text } from "@webpack/common";
import { JSX } from "react";
// Open Error Modal
export function openErrorModal(message: string): string {
const key = openModal(modalProps => (
<ErrorModal
modalProps={modalProps}
message={message}
close={() => closeModal(key)}
/>
));
return key;
}
interface ErrorModalProps {
modalProps: ModalProps;
message: string;
close: () => void;
}
function ErrorModal({ modalProps, close, message }: ErrorModalProps): JSX.Element {
return (
<ModalRoot {...modalProps} size={ModalSize.SMALL}>
<ModalHeader separator={false}>
<Forms.FormTitle tag="h2" className="modalTitle">
Error
</Forms.FormTitle>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className="vc-voice-filters-modal">
<Text>{message}</Text>
</ModalContent>
<ModalFooter>
<Button onClick={close}>Close</Button>
</ModalFooter>
</ModalRoot>
);
}
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Forms, Text } from "@webpack/common";
import { JSX } from "react";
// Open Error Modal
export function openErrorModal(message: string): string {
const key = openModal(modalProps => (
<ErrorModal
modalProps={modalProps}
message={message}
close={() => closeModal(key)}
/>
));
return key;
}
interface ErrorModalProps {
modalProps: ModalProps;
message: string;
close: () => void;
}
function ErrorModal({ modalProps, close, message }: ErrorModalProps): JSX.Element {
return (
<ModalRoot {...modalProps} size={ModalSize.SMALL}>
<ModalHeader separator={false}>
<Forms.FormTitle tag="h2" className="modalTitle">
Error
</Forms.FormTitle>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className="vc-voice-filters-modal">
<Text>{message}</Text>
</ModalContent>
<ModalFooter>
<Button onClick={close}>Close</Button>
</ModalFooter>
</ModalRoot>
);
}

View file

@ -1,62 +1,62 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Flex, Forms } from "@webpack/common";
import { JSX } from "react";
import { templateVoicepack, voices } from ".";
import { Markdown } from "./Markdown";
import { downloadFile } from "./utils";
export function openHelpModal(): string {
const key = openModal(modalProps => (
<HelpModal
modalProps={modalProps}
close={() => closeModal(key)}
/>
));
return key;
}
interface HelpModalProps {
modalProps: ModalProps;
close: () => void;
}
function HelpModal({ modalProps, close }: HelpModalProps): JSX.Element {
const description = `To build your own voicepack, you need to have a voicepack file. You can download one from the template or look at this tutorial.
The voicepack file is a json file that contains the voicepack data.
A voicepack may have one or multiple voices. Each voice is an object with the following properties:
\`\`\`json
${templateVoicepack}
\`\`\`*Style Key must be "" or one of the following: ${voices ? [...new Set(Object.values(voices).map(({ styleKey }) => styleKey))].join(", ") : ""}*
Once you have the voicepack file, you can use the <vf:main> to manage your voicepacks.`;
return (
<ModalRoot {...modalProps} size={ModalSize.LARGE}>
<ModalHeader>
<Forms.FormTitle tag="h2" className="modalTitle">
Help with voicepacks
</Forms.FormTitle>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className="vc-voice-filters-modal">
<Markdown content={description} />
</ModalContent>
<ModalFooter>
<Flex style={{ gap: "0.5rem" }} justify={Flex.Justify.BETWEEN} align={Flex.Align.CENTER}>
<Button onClick={() => downloadFile("voicepack-template.json", templateVoicepack)} color={Button.Colors.BRAND_NEW}>
Download template file
</Button>
<Button onClick={close} color={Button.Colors.TRANSPARENT}>Close</Button>
</Flex>
</ModalFooter>
</ModalRoot>
);
}
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Flex, Forms } from "@webpack/common";
import { JSX } from "react";
import { templateVoicepack, voices } from ".";
import { Markdown } from "./Markdown";
import { downloadFile } from "./utils";
export function openHelpModal(): string {
const key = openModal(modalProps => (
<HelpModal
modalProps={modalProps}
close={() => closeModal(key)}
/>
));
return key;
}
interface HelpModalProps {
modalProps: ModalProps;
close: () => void;
}
function HelpModal({ modalProps, close }: HelpModalProps): JSX.Element {
const description = `To build your own voicepack, you need to have a voicepack file. You can download one from the template or look at this tutorial.
The voicepack file is a json file that contains the voicepack data.
A voicepack may have one or multiple voices. Each voice is an object with the following properties:
\`\`\`json
${templateVoicepack}
\`\`\`*Style Key must be "" or one of the following: ${voices ? [...new Set(Object.values(voices).map(({ styleKey }) => styleKey))].join(", ") : ""}*
Once you have the voicepack file, you can use the <vf:main> to manage your voicepacks.`;
return (
<ModalRoot {...modalProps} size={ModalSize.LARGE}>
<ModalHeader>
<Forms.FormTitle tag="h2" className="modalTitle">
Help with voicepacks
</Forms.FormTitle>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className="vc-voice-filters-modal">
<Markdown content={description} />
</ModalContent>
<ModalFooter>
<Flex style={{ gap: "0.5rem" }} justify={Flex.Justify.BETWEEN} align={Flex.Align.CENTER}>
<Button onClick={() => downloadFile("voicepack-template.json", templateVoicepack)} color={Button.Colors.BRAND_NEW}>
Download template file
</Button>
<Button onClick={close} color={Button.Colors.TRANSPARENT}>Close</Button>
</Flex>
</ModalFooter>
</ModalRoot>
);
}

View file

@ -1,110 +1,110 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
import { JSX, SVGProps } from "react";
import { openVoiceFiltersModal } from "./VoiceFiltersModal";
interface IconProps extends SVGProps<SVGSVGElement> { }
export function DownloadIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1ZM3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2H3Z" />
</svg>
);
}
export function DownloadingIcon(props: IconProps): JSX.Element {
return (
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" {...props}>
<path fill="currentColor" stroke="black" strokeWidth="1" d="M1023.849566 529.032144C1022.533495 457.744999 1007.544916 386.64064 979.907438 321.641387 952.343075 256.605575 912.349158 197.674868 863.252422 148.980264 814.192243 100.249102 755.992686 61.717486 693.004095 36.310016 630.052062 10.792874 562.347552-1.380777 495.483865 0.081523 428.620178 1.470709 362.012394 16.495846 301.144139 44.206439 240.202769 71.807359 185.000928 111.874391 139.377154 161.044242 93.753381 210.177537 57.707676 268.450209 33.945294 331.475357 10.073239 394.463948-1.296147 462.1319 0.166154 529.032144 1.482224 595.968946 15.593423 662.503615 41.549256 723.371871 67.468531 784.240126 105.013094 839.405409 151.075558 884.956067 197.101464 930.579841 251.645269 966.552431 310.612534 990.241698 369.543241 1014.040637 432.860849 1025.336908 495.483865 1023.874608 558.143438 1022.485422 620.291206 1008.337666 677.174693 982.381833 734.094737 956.462558 785.677384 918.954552 828.230327 872.892089 870.819826 826.902741 904.416179 772.395492 926.533473 713.5379 939.986637 677.85777 949.089457 640.605667 953.915048 602.841758 955.194561 602.951431 956.510631 602.987988 957.790144 602.987988 994.27454 602.987988 1023.849566 572.425909 1023.849566 534.735116 1023.849566 532.834125 1023.739893 530.933135 1023.593663 529.032144L1023.849566 529.032144 1023.849566 529.032144ZM918.892953 710.284282C894.691881 767.021538 859.596671 818.421398 816.568481 860.82811 773.540291 903.307938 722.652236 936.75806 667.706298 958.729124 612.760359 980.773303 553.902767 991.192193 495.483865 989.729893 437.064963 988.377265 379.304096 975.106889 326.441936 950.832702 273.543218 926.668187 225.616322 891.682649 186.097653 848.764132 146.542426 805.91873 115.35887 755.176905 94.959779 700.486869 74.451015 645.796833 64.799833 587.195144 66.189018 529.032144 67.541646 470.869145 79.934642 413.437296 102.563741 360.867595 125.119725 308.297895 157.765582 260.663459 197.759499 221.364135 237.716858 182.064811 284.985719 151.137157 335.910331 130.884296 386.834944 110.55832 441.305634 101.01681 495.483865 102.47911 549.662096 103.868296 603.036061 116.261292 651.876895 138.780718 700.754287 161.22703 745.025432 193.690099 781.509828 233.428113 818.067339 273.166127 846.764984 320.142529 865.518987 370.665008 884.346105 421.224045 893.156465 475.256046 891.76728 529.032144L891.986625 529.032144C891.840395 530.933135 891.76728 532.797568 891.76728 534.735116 891.76728 569.939999 917.540325 598.893547 950.66143 602.585856 944.227308 639.728286 933.589072 675.956779 918.892953 710.284282Z" />
<animateTransform attributeName="transform" type="rotate" from="0 0 0" to="360 0 0" dur="3s" repeatCount="indefinite" />
</svg>
);
}
export function PlayIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="M9.25 3.35C7.87 2.45 6 3.38 6 4.96v14.08c0 1.58 1.87 2.5 3.25 1.61l10.85-7.04a1.9 1.9 0 0 0 0-3.22L9.25 3.35Z" />
</svg>
);
}
export function PauseIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="M6 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H6ZM15 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3Z" />
</svg>
);
}
export function ChevronIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z" />
</svg>
);
}
export function RefreshIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="M4 12a8 8 0 0 1 14.93-4H15a1 1 0 1 0 0 2h6a1 1 0 0 0 1-1V3a1 1 0 1 0-2 0v3a9.98 9.98 0 0 0-18 6 10 10 0 0 0 16.29 7.78 1 1 0 0 0-1.26-1.56A8 8 0 0 1 4 12Z" />
</svg>
);
}
export function TrashIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="M14.25 1c.41 0 .75.34.75.75V3h5.25c.41 0 .75.34.75.75v.5c0 .41-.34.75-.75.75H3.75A.75.75 0 0 1 3 4.25v-.5c0-.41.34-.75.75-.75H9V1.75c0-.41.34-.75.75-.75h4.5Z" />
<path fill="currentColor" fillRule="evenodd" d="M5.06 7a1 1 0 0 0-1 1.06l.76 12.13a3 3 0 0 0 3 2.81h8.36a3 3 0 0 0 3-2.81l.75-12.13a1 1 0 0 0-1-1.06H5.07ZM11 12a1 1 0 1 0-2 0v6a1 1 0 1 0 2 0v-6Zm3-1a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1Z" clipRule="evenodd" />
</svg>
);
}
export function PencilIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="m13.96 5.46 4.58 4.58a1 1 0 0 0 1.42 0l1.38-1.38a2 2 0 0 0 0-2.82l-3.18-3.18a2 2 0 0 0-2.82 0l-1.38 1.38a1 1 0 0 0 0 1.42ZM2.11 20.16l.73-4.22a3 3 0 0 1 .83-1.61l7.87-7.87a1 1 0 0 1 1.42 0l4.58 4.58a1 1 0 0 1 0 1.42l-7.87 7.87a3 3 0 0 1-1.6.83l-4.23.73a1.5 1.5 0 0 1-1.73-1.73Z" />
</svg>
);
}
// Custom Voice Filter Icon
export function CustomVoiceFilterIcon(props: IconProps) {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path d="m19.7.3 4 4a1 1 0 0 1 0 1.4l-4 4a1 1 0 0 1-1.4-1.4L20.58 6H15a1 1 0 1 1 0-2h5.59l-2.3-2.3A1 1 0 0 1 19.71.3Z" fill="currentColor" />
<path d="M12.62 2.05c.41.06.46.61.17.92A3 3 0 0 0 15 8h.51c.28 0 .5.22.5.5V10a4 4 0 1 1-8 0V6a4 4 0 0 1 4.62-3.95Z" fill="currentColor" />
<path d="M17.56 12.27a.63.63 0 0 1 .73-.35c.21.05.43.08.65.08.38 0 .72.35.6.7A8 8 0 0 1 13 17.94V20h2a1 1 0 1 1 0 2H9a1 1 0 1 1 0-2h2v-2.06A8 8 0 0 1 4 10a1 1 0 0 1 2 0 6 6 0 0 0 11.56 2.27Z" fill="currentColor" />
</svg>
);
}
// Custom Voice Filter Chat Bar Icon
export const CustomVoiceFilterChatBarIcon: ChatBarButtonFactory = ({ isMainChat }) => {
if (!isMainChat) return null;
return (
<ChatBarButton
tooltip="Open Custom Voice Filter Menu"
onClick={openVoiceFiltersModal}
buttonProps={{
"aria-haspopup": "dialog"
}}
>
<CustomVoiceFilterIcon />
</ChatBarButton>
);
};
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
import { JSX, SVGProps } from "react";
import { openVoiceFiltersModal } from "./VoiceFiltersModal";
interface IconProps extends SVGProps<SVGSVGElement> { }
export function DownloadIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1ZM3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2H3Z" />
</svg>
);
}
export function DownloadingIcon(props: IconProps): JSX.Element {
return (
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" {...props}>
<path fill="currentColor" stroke="black" strokeWidth="1" d="M1023.849566 529.032144C1022.533495 457.744999 1007.544916 386.64064 979.907438 321.641387 952.343075 256.605575 912.349158 197.674868 863.252422 148.980264 814.192243 100.249102 755.992686 61.717486 693.004095 36.310016 630.052062 10.792874 562.347552-1.380777 495.483865 0.081523 428.620178 1.470709 362.012394 16.495846 301.144139 44.206439 240.202769 71.807359 185.000928 111.874391 139.377154 161.044242 93.753381 210.177537 57.707676 268.450209 33.945294 331.475357 10.073239 394.463948-1.296147 462.1319 0.166154 529.032144 1.482224 595.968946 15.593423 662.503615 41.549256 723.371871 67.468531 784.240126 105.013094 839.405409 151.075558 884.956067 197.101464 930.579841 251.645269 966.552431 310.612534 990.241698 369.543241 1014.040637 432.860849 1025.336908 495.483865 1023.874608 558.143438 1022.485422 620.291206 1008.337666 677.174693 982.381833 734.094737 956.462558 785.677384 918.954552 828.230327 872.892089 870.819826 826.902741 904.416179 772.395492 926.533473 713.5379 939.986637 677.85777 949.089457 640.605667 953.915048 602.841758 955.194561 602.951431 956.510631 602.987988 957.790144 602.987988 994.27454 602.987988 1023.849566 572.425909 1023.849566 534.735116 1023.849566 532.834125 1023.739893 530.933135 1023.593663 529.032144L1023.849566 529.032144 1023.849566 529.032144ZM918.892953 710.284282C894.691881 767.021538 859.596671 818.421398 816.568481 860.82811 773.540291 903.307938 722.652236 936.75806 667.706298 958.729124 612.760359 980.773303 553.902767 991.192193 495.483865 989.729893 437.064963 988.377265 379.304096 975.106889 326.441936 950.832702 273.543218 926.668187 225.616322 891.682649 186.097653 848.764132 146.542426 805.91873 115.35887 755.176905 94.959779 700.486869 74.451015 645.796833 64.799833 587.195144 66.189018 529.032144 67.541646 470.869145 79.934642 413.437296 102.563741 360.867595 125.119725 308.297895 157.765582 260.663459 197.759499 221.364135 237.716858 182.064811 284.985719 151.137157 335.910331 130.884296 386.834944 110.55832 441.305634 101.01681 495.483865 102.47911 549.662096 103.868296 603.036061 116.261292 651.876895 138.780718 700.754287 161.22703 745.025432 193.690099 781.509828 233.428113 818.067339 273.166127 846.764984 320.142529 865.518987 370.665008 884.346105 421.224045 893.156465 475.256046 891.76728 529.032144L891.986625 529.032144C891.840395 530.933135 891.76728 532.797568 891.76728 534.735116 891.76728 569.939999 917.540325 598.893547 950.66143 602.585856 944.227308 639.728286 933.589072 675.956779 918.892953 710.284282Z" />
<animateTransform attributeName="transform" type="rotate" from="0 0 0" to="360 0 0" dur="3s" repeatCount="indefinite" />
</svg>
);
}
export function PlayIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="M9.25 3.35C7.87 2.45 6 3.38 6 4.96v14.08c0 1.58 1.87 2.5 3.25 1.61l10.85-7.04a1.9 1.9 0 0 0 0-3.22L9.25 3.35Z" />
</svg>
);
}
export function PauseIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="M6 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H6ZM15 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3Z" />
</svg>
);
}
export function ChevronIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z" />
</svg>
);
}
export function RefreshIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="M4 12a8 8 0 0 1 14.93-4H15a1 1 0 1 0 0 2h6a1 1 0 0 0 1-1V3a1 1 0 1 0-2 0v3a9.98 9.98 0 0 0-18 6 10 10 0 0 0 16.29 7.78 1 1 0 0 0-1.26-1.56A8 8 0 0 1 4 12Z" />
</svg>
);
}
export function TrashIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="M14.25 1c.41 0 .75.34.75.75V3h5.25c.41 0 .75.34.75.75v.5c0 .41-.34.75-.75.75H3.75A.75.75 0 0 1 3 4.25v-.5c0-.41.34-.75.75-.75H9V1.75c0-.41.34-.75.75-.75h4.5Z" />
<path fill="currentColor" fillRule="evenodd" d="M5.06 7a1 1 0 0 0-1 1.06l.76 12.13a3 3 0 0 0 3 2.81h8.36a3 3 0 0 0 3-2.81l.75-12.13a1 1 0 0 0-1-1.06H5.07ZM11 12a1 1 0 1 0-2 0v6a1 1 0 1 0 2 0v-6Zm3-1a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-6a1 1 0 0 1 1-1Z" clipRule="evenodd" />
</svg>
);
}
export function PencilIcon(props: IconProps): JSX.Element {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}>
<path fill="currentColor" d="m13.96 5.46 4.58 4.58a1 1 0 0 0 1.42 0l1.38-1.38a2 2 0 0 0 0-2.82l-3.18-3.18a2 2 0 0 0-2.82 0l-1.38 1.38a1 1 0 0 0 0 1.42ZM2.11 20.16l.73-4.22a3 3 0 0 1 .83-1.61l7.87-7.87a1 1 0 0 1 1.42 0l4.58 4.58a1 1 0 0 1 0 1.42l-7.87 7.87a3 3 0 0 1-1.6.83l-4.23.73a1.5 1.5 0 0 1-1.73-1.73Z" />
</svg>
);
}
// Custom Voice Filter Icon
export function CustomVoiceFilterIcon(props: IconProps) {
return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path d="m19.7.3 4 4a1 1 0 0 1 0 1.4l-4 4a1 1 0 0 1-1.4-1.4L20.58 6H15a1 1 0 1 1 0-2h5.59l-2.3-2.3A1 1 0 0 1 19.71.3Z" fill="currentColor" />
<path d="M12.62 2.05c.41.06.46.61.17.92A3 3 0 0 0 15 8h.51c.28 0 .5.22.5.5V10a4 4 0 1 1-8 0V6a4 4 0 0 1 4.62-3.95Z" fill="currentColor" />
<path d="M17.56 12.27a.63.63 0 0 1 .73-.35c.21.05.43.08.65.08.38 0 .72.35.6.7A8 8 0 0 1 13 17.94V20h2a1 1 0 1 1 0 2H9a1 1 0 1 1 0-2h2v-2.06A8 8 0 0 1 4 10a1 1 0 0 1 2 0 6 6 0 0 0 11.56 2.27Z" fill="currentColor" />
</svg>
);
}
// Custom Voice Filter Chat Bar Icon
export const CustomVoiceFilterChatBarIcon: ChatBarButtonFactory = ({ isMainChat }) => {
if (!isMainChat) return null;
return (
<ChatBarButton
tooltip="Open Custom Voice Filter Menu"
onClick={openVoiceFiltersModal}
buttonProps={{
"aria-haspopup": "dialog"
}}
>
<CustomVoiceFilterIcon />
</ChatBarButton>
);
};

View file

@ -1,107 +1,107 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { proxyLazy } from "@utils/lazy";
import { findByCode, findByProps, findByPropsLazy } from "@webpack";
import { Parser } from "@webpack/common";
import { JSX } from "react";
import { openCreateVoiceModal } from "./CreateVoiceFilterModal";
import { openHelpModal } from "./HelpModal";
import { cl } from "./utils";
import { openVoiceFiltersModal } from "./VoiceFiltersModal";
interface MarkdownRules {
allowDevLinks: boolean;
allowEmojiLinks: boolean;
allowHeading: boolean;
allowLinks: boolean;
allowList: boolean;
channelId: string;
disableAnimatedEmoji: boolean;
disableAutoBlockNewlines: boolean;
forceWhite: boolean;
formatInline: boolean;
isInteracting: boolean;
mentionChannels: string[];
messageId: string;
muted: boolean;
noStyleAndInteraction: boolean;
previewLinkTarget: boolean;
soundboardSounds: string[];
unknownUserMentionPlaceholder: boolean;
viewingChannelId: string;
}
const defaultRules: Partial<MarkdownRules> = { allowLinks: true, allowList: true, allowHeading: true };
const MarkdownContainerClasses = findByPropsLazy("markup", "codeContainer");
const modalLinkRegex = /^<vf:(help|createVoice|main)>/;
const imageRegex = /^!\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*((?:\([^)]*\)|[^\s\\]|\\.)*?)\)/;
const actions: Record<string, { action: () => string, name: string; }> = {
help: {
action: openHelpModal,
name: "Help menu"
},
createVoice: {
action: () => openCreateVoiceModal(),
name: "Voice pack creator menu"
},
main: {
action: openVoiceFiltersModal,
name: "Main menu"
},
};
const parser: typeof Parser.parse = proxyLazy(() => {
const DiscordRules = findByProps("AUTO_MODERATION_SYSTEM_MESSAGE_RULES").RULES;
const AdvancedRules = findByCode("channelMention:")({});
const customRules = {
modalLink: {
order: DiscordRules.staticRouteLink,
match: source => modalLinkRegex.exec(source),
parse: ([, target]) => (actions[target]),
react: ({ action, name }) => (
<span className="channelMention interactive vc-voice-filters-modal-link" role="link" onClick={action}>{name}</span>
),
requiredFirstCharacters: ["<"]
},
image: {
...Parser.defaultRules.link,
match: source => imageRegex.exec(source),
parse: ([, title, target]) => ({ title, target }),
react: ({ title, target }) => <div className="vc-voice-filters-md-image">
<img src={target} alt={title} />
</div>,
requiredFirstCharacters: ["!"]
}
};
const builtinRules = new Set([...Object.keys(DiscordRules), ...Object.keys(AdvancedRules)]);
for (const rule of builtinRules) {
customRules[rule] = {
...DiscordRules[rule],
...AdvancedRules[rule],
};
}
return (Parser as any).reactParserFor(customRules);
});
interface MarkdownProps extends Omit<JSX.IntrinsicElements["div"], "children"> {
content: string;
markdownRules?: Partial<MarkdownRules>;
}
export function Markdown({ content, markdownRules = defaultRules, className, ...props }: MarkdownProps) {
return <div className={cl(MarkdownContainerClasses.markup, "vc-voice-filters-md", className)} {...props}>
{parser(content, false, markdownRules)}
</div>;
}
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { proxyLazy } from "@utils/lazy";
import { findByCode, findByProps, findByPropsLazy } from "@webpack";
import { Parser } from "@webpack/common";
import { JSX } from "react";
import { openCreateVoiceModal } from "./CreateVoiceFilterModal";
import { openHelpModal } from "./HelpModal";
import { cl } from "./utils";
import { openVoiceFiltersModal } from "./VoiceFiltersModal";
interface MarkdownRules {
allowDevLinks: boolean;
allowEmojiLinks: boolean;
allowHeading: boolean;
allowLinks: boolean;
allowList: boolean;
channelId: string;
disableAnimatedEmoji: boolean;
disableAutoBlockNewlines: boolean;
forceWhite: boolean;
formatInline: boolean;
isInteracting: boolean;
mentionChannels: string[];
messageId: string;
muted: boolean;
noStyleAndInteraction: boolean;
previewLinkTarget: boolean;
soundboardSounds: string[];
unknownUserMentionPlaceholder: boolean;
viewingChannelId: string;
}
const defaultRules: Partial<MarkdownRules> = { allowLinks: true, allowList: true, allowHeading: true };
const MarkdownContainerClasses = findByPropsLazy("markup", "codeContainer");
const modalLinkRegex = /^<vf:(help|createVoice|main)>/;
const imageRegex = /^!\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*((?:\([^)]*\)|[^\s\\]|\\.)*?)\)/;
const actions: Record<string, { action: () => string, name: string; }> = {
help: {
action: openHelpModal,
name: "Help menu"
},
createVoice: {
action: () => openCreateVoiceModal(),
name: "Voice pack creator menu"
},
main: {
action: openVoiceFiltersModal,
name: "Main menu"
},
};
const parser: typeof Parser.parse = proxyLazy(() => {
const DiscordRules = findByProps("AUTO_MODERATION_SYSTEM_MESSAGE_RULES").RULES;
const AdvancedRules = findByCode("channelMention:")({});
const customRules = {
modalLink: {
order: DiscordRules.staticRouteLink,
match: source => modalLinkRegex.exec(source),
parse: ([, target]) => (actions[target]),
react: ({ action, name }) => (
<span className="channelMention interactive vc-voice-filters-modal-link" role="link" onClick={action}>{name}</span>
),
requiredFirstCharacters: ["<"]
},
image: {
...Parser.defaultRules.link,
match: source => imageRegex.exec(source),
parse: ([, title, target]) => ({ title, target }),
react: ({ title, target }) => <div className="vc-voice-filters-md-image">
<img src={target} alt={title} />
</div>,
requiredFirstCharacters: ["!"]
}
};
const builtinRules = new Set([...Object.keys(DiscordRules), ...Object.keys(AdvancedRules)]);
for (const rule of builtinRules) {
customRules[rule] = {
...DiscordRules[rule],
...AdvancedRules[rule],
};
}
return (Parser as any).reactParserFor(customRules);
});
interface MarkdownProps extends Omit<JSX.IntrinsicElements["div"], "children"> {
content: string;
markdownRules?: Partial<MarkdownRules>;
}
export function Markdown({ content, markdownRules = defaultRules, className, ...props }: MarkdownProps) {
return <div className={cl(MarkdownContainerClasses.markup, "vc-voice-filters-md", className)} {...props}>
{parser(content, false, markdownRules)}
</div>;
}

View file

@ -0,0 +1,78 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Margins } from "@utils/margins";
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Flex, Forms, Slider } from "@webpack/common";
import { JSX } from "react";
import plugin, { settings } from "./index";
export function openSettingsModal(): string {
const key = openModal(modalProps => (
<SettingsModal modalProps={modalProps} close={() => closeModal(key)} />
));
return key;
}
interface SettingsModalProps {
modalProps: ModalProps;
close: () => void;
}
// Create Voice Filter Modal
function SettingsModal({ modalProps, close }: SettingsModalProps): JSX.Element {
const settingsState = settings.use();
const { settings: { def } } = plugin;
return (
<ModalRoot {...modalProps} size={ModalSize.MEDIUM}>
<ModalHeader>
<Forms.FormTitle tag="h2" className="modalTitle">
Settings
</Forms.FormTitle>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className="vc-voice-filters-modal">
<Flex style={{ gap: "1rem" }} direction={Flex.Direction.VERTICAL}>
<Forms.FormSection>
<Forms.FormTitle>Pitch</Forms.FormTitle>
<Forms.FormText className={Margins.bottom20} type="description">{def.pitch.description}</Forms.FormText>
<Slider
markers={def.pitch.markers}
minValue={def.pitch.markers[0]}
maxValue={def.pitch.markers.at(-1)}
initialValue={settingsState.pitch ?? def.pitch.default}
onValueChange={value => settingsState.pitch = value}
onValueRender={value => `${value}`}
stickToMarkers={true}
/>
</Forms.FormSection>
<Forms.FormSection>
<Forms.FormTitle>Frequency</Forms.FormTitle>
<Forms.FormText className={Margins.bottom20} type="description">{def.frequency.description}</Forms.FormText>
<Slider
markers={def.frequency.markers}
minValue={def.frequency.markers[0]}
maxValue={def.frequency.markers.at(-1)}
initialValue={settingsState.frequency ?? def.frequency.default}
onValueChange={value => settingsState.frequency = value}
onValueRender={value => `${value}Hz`}
stickToMarkers={true}
/>
</Forms.FormSection>
</Flex>
</ModalContent>
<ModalFooter>
<Flex style={{ gap: "0.5rem" }} justify={Flex.Justify.END} align={Flex.Align.CENTER}>
<Button color={Button.Colors.GREEN} onClick={close} >Save & Exit</Button>
</Flex>
</ModalFooter>
</ModalRoot>
);
}

View file

@ -14,6 +14,7 @@ import { openCreateVoiceModal } from "./CreateVoiceFilterModal";
import { openHelpModal } from "./HelpModal";
import { DownloadIcon, DownloadingIcon, PauseIcon, PlayIcon, RefreshIcon, TrashIcon } from "./Icons";
import { downloadCustomVoiceModel, getClient, IVoiceFilter, useVoiceFiltersStore, VoiceFilterStyles } from "./index";
import { openSettingsModal } from "./SettingsModal";
import { cl, useAudio } from "./utils";
import { openWikiHomeModal } from "./WikiHomeModal";
@ -86,6 +87,7 @@ function VoiceFiltersModal({ modalProps, close, accept }: VoiceFiltersModalProps
</ModalContent>
<ModalFooter>
<Flex style={{ gap: "0.5rem" }} justify={Flex.Justify.END} align={Flex.Align.CENTER}>
<Button color={Button.Colors.TRANSPARENT} onClick={openSettingsModal}>Settings</Button>
<Button color={Button.Colors.TRANSPARENT} onClick={openHelpModal}>Learn how to build your own voicepack</Button>
<Button color={Button.Colors.TRANSPARENT} onClick={() => openCreateVoiceModal()}>Create Voicepack</Button>
<Button color={Button.Colors.GREEN} onClick={openWikiHomeModal}>Wiki</Button>

View file

@ -1,115 +1,115 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Card, Flex, Forms, Text, useState } from "@webpack/common";
import { ChevronIcon } from "./Icons";
import { Markdown } from "./Markdown";
import { cl } from "./utils";
interface Section {
title: string;
content: string;
}
const sections: Section[] = [
{
title: "How to install a voicepack",
content: "To install a voicepack, you need to paste the voicepack url in the <vf:main>"
},
{
title: "How to create a voicepack",
content: `You have two methods to create a voicepack:
1. Use the <vf:createVoice> (recommended)
2. Use the <vf:help> (advanced)`
},
{
title: "How does it work?",
content: `Discord actually uses a Python project named [Retrieval-based Voice Conversion](https://github.com/RVC-Project/Retrieval-based-Voice-Conversion) to convert your voice into the voice model you picked.
This voice cloning technology allows an audio input to be converted into a different voice, with a high degree of accuracy.
Actually, Discord uses ONNX files to run the model, for a better performance and less CPU usage.
![img](https://fox3000foxy.com/voicepacks/assets/working.png)`
},
{
title: "How to create an ONNX from an existing RVC model?",
content: `RVC models can be converted to ONNX files using the [W-Okada Software](https://github.com/w-okada/voice-changer/).
MMVCServerSio is software that is issued from W-Okada Software, and can be downloaded [here](https://huggingface.co/datasets/Derur/all-portable-ai-in-one-url/blob/main/HZ/MMVCServerSIO.7z).
Thats the actual software that does exports RVC models to ONNX files.
Just load your model inside MMVCServerSio, and click on "Export ONNX":
![img](https://fox3000foxy.com/voicepacks/assets/export-1.png)![img](https://fox3000foxy.com/voicepacks/assets/export-2.png)
Enjoy you now have a ONNX model file for your voicepack!`
},
{
title: "How to train my own voice model?",
content: "Refers to [this video](https://www.youtube.com/watch?v=tnfqIQ11Qek&ab_channel=AISearch) and convert it to ONNX."
}
];
interface WikiHomeModalProps {
modalProps: ModalProps;
close: () => void;
accept: () => void;
}
export function WikiHomeModal({ modalProps, close, accept }: WikiHomeModalProps) {
return (
<ModalRoot {...modalProps} size={ModalSize.LARGE} className="vc-voice-filters-wiki">
<ModalHeader>
<Forms.FormTitle tag="h2" className="modalTitle">
Wiki Home
</Forms.FormTitle>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className="vc-voice-filters-modal">
<Flex style={{ gap: "0.5rem" }} direction={Flex.Direction.VERTICAL}>
<Text>Here are some tutorials and guides about the Custom Voice Filter Plugin:</Text>
{sections.map((section, index) => (
<CollapsibleCard key={index} title={section.title} content={section.content} />
))}
</Flex>
</ModalContent>
<ModalFooter>
<Button onClick={close}>Close</Button>
</ModalFooter>
</ModalRoot>
);
}
export function openWikiHomeModal(): string {
const key = openModal(modalProps => (
<WikiHomeModal
modalProps={modalProps}
close={() => closeModal(key)}
accept={() => {
// console.warn("accepted", url);
closeModal(key);
}}
/>
));
return key;
}
interface CollapsibleCardProps {
title: string;
content: string;
}
function CollapsibleCard({ title, content }: CollapsibleCardProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<Card className={cl("vc-voice-filters-card", isOpen && "vc-voice-filters-card-open")} style={{ background: "var(--background-secondary)" }}>
<Card className="vc-voice-filters-card-title" onClick={() => setIsOpen(!isOpen)}>
<Text variant="heading-md/semibold">{title}</Text>
<ChevronIcon className="vc-voice-filters-card-icon" />
</Card>
<Markdown content={content} className="vc-voice-filters-details" />
</Card >
);
}
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Card, Flex, Forms, Text, useState } from "@webpack/common";
import { ChevronIcon } from "./Icons";
import { Markdown } from "./Markdown";
import { cl } from "./utils";
interface Section {
title: string;
content: string;
}
const sections: Section[] = [
{
title: "How to install a voicepack",
content: "To install a voicepack, you need to paste the voicepack url in the <vf:main>"
},
{
title: "How to create a voicepack",
content: `You have two methods to create a voicepack:
1. Use the <vf:createVoice> (recommended)
2. Use the <vf:help> (advanced)`
},
{
title: "How does it work?",
content: `Discord actually uses a Python project named [Retrieval-based Voice Conversion](https://github.com/RVC-Project/Retrieval-based-Voice-Conversion) to convert your voice into the voice model you picked.
This voice cloning technology allows an audio input to be converted into a different voice, with a high degree of accuracy.
Actually, Discord uses ONNX files to run the model, for a better performance and less CPU usage.
![img](https://fox3000foxy.com/voicepacks/assets/working.png)`
},
{
title: "How to create an ONNX from an existing RVC model?",
content: `RVC models can be converted to ONNX files using the [W-Okada Software](https://github.com/w-okada/voice-changer/).
MMVCServerSio is software that is issued from W-Okada Software, and can be downloaded [here](https://huggingface.co/datasets/Derur/all-portable-ai-in-one-url/blob/main/HZ/MMVCServerSIO.7z).
Thats the actual software that does exports RVC models to ONNX files.
Just load your model inside MMVCServerSio, and click on "Export ONNX":
![img](https://fox3000foxy.com/voicepacks/assets/export-1.png)![img](https://fox3000foxy.com/voicepacks/assets/export-2.png)
Enjoy you now have a ONNX model file for your voicepack!`
},
{
title: "How to train my own voice model?",
content: "Refers to [this video](https://www.youtube.com/watch?v=tnfqIQ11Qek&ab_channel=AISearch) and convert it to ONNX."
}
];
interface WikiHomeModalProps {
modalProps: ModalProps;
close: () => void;
accept: () => void;
}
export function WikiHomeModal({ modalProps, close, accept }: WikiHomeModalProps) {
return (
<ModalRoot {...modalProps} size={ModalSize.LARGE} className="vc-voice-filters-wiki">
<ModalHeader>
<Forms.FormTitle tag="h2" className="modalTitle">
Wiki Home
</Forms.FormTitle>
<ModalCloseButton onClick={close} />
</ModalHeader>
<ModalContent className="vc-voice-filters-modal">
<Flex style={{ gap: "0.5rem" }} direction={Flex.Direction.VERTICAL}>
<Text>Here are some tutorials and guides about the Custom Voice Filter Plugin:</Text>
{sections.map((section, index) => (
<CollapsibleCard key={index} title={section.title} content={section.content} />
))}
</Flex>
</ModalContent>
<ModalFooter>
<Button onClick={close}>Close</Button>
</ModalFooter>
</ModalRoot>
);
}
export function openWikiHomeModal(): string {
const key = openModal(modalProps => (
<WikiHomeModal
modalProps={modalProps}
close={() => closeModal(key)}
accept={() => {
// console.warn("accepted", url);
closeModal(key);
}}
/>
));
return key;
}
interface CollapsibleCardProps {
title: string;
content: string;
}
function CollapsibleCard({ title, content }: CollapsibleCardProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<Card className={cl("vc-voice-filters-card", isOpen && "vc-voice-filters-card-open")} style={{ background: "var(--background-secondary)" }}>
<Card className="vc-voice-filters-card-title" onClick={() => setIsOpen(!isOpen)}>
<Text variant="heading-md/semibold">{title}</Text>
<ChevronIcon className="vc-voice-filters-card-icon" />
</Card>
<Markdown content={content} className="vc-voice-filters-details" />
</Card >
);
}

View file

@ -1,369 +1,386 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
// Imports
import "./style.css";
import { DataStore } from "@api/index";
import { Devs } from "@utils/constants";
import { proxyLazy } from "@utils/lazy";
import { closeModal } from "@utils/modal";
import definePlugin, { PluginNative } from "@utils/types";
import { filters, findAll, findByProps, findStore } from "@webpack";
import { zustandCreate, zustandPersist } from "@webpack/common";
import { openConfirmModal } from "./ConfirmModal";
import { openErrorModal } from "./ErrorModal";
import { CustomVoiceFilterChatBarIcon } from "./Icons";
import { downloadFile } from "./utils";
export let voices: Record<string, IVoiceFilter> | null = null;
export let VoiceFilterStyles: any = null; // still 'skye'
export let VoiceFilterStore: any = null;
// Variables
export const templateVoicepack = JSON.stringify({
"name": "Reyna",
"iconURL": "https://cdn.discordapp.com/emojis/1340353599858806785.webp?size=512",
"splashGradient": "radial-gradient(circle, #d9a5a2 0%, rgba(0,0,0,0) 100%)",
"baseColor": "#d9a5a2",
"previewSoundURLs": [
"https://cdn.discordapp.com/soundboard-sounds/1340357897451995146"
],
"available": true,
"styleKey": "",
"temporarilyAvailable": false,
"id": "724847846897221642-reyna",
"author": "724847846897221642",
"onnxFileUrl": "https://fox3000foxy.com/voices_models/reyna_simple.onnx"
} satisfies IVoiceFilter, null, 2);
const STORAGE_KEY = "vencordVoiceFilters";
function indexedDBStorageFactory<T>() {
return {
async getItem(name: string): Promise<T | null> {
return (await DataStore.get(name)) ?? null;
},
async setItem(name: string, value: T): Promise<void> {
await DataStore.set(name, value);
},
async removeItem(name: string): Promise<void> {
await DataStore.del(name);
},
};
}
export interface CustomVoiceFilterStore {
voiceFilters: IVoiceFilterMap;
modulePath: string;
set: (voiceFilters: IVoiceFilterMap) => void;
updateById: (id: string) => void;
deleteById: (id: string) => void;
deleteAll: () => void;
exportVoiceFilters: () => void;
exportIndividualVoice: (id: string) => void;
importVoiceFilters: () => void;
downloadVoicepack: (url: string) => void;
// downloadVoiceModel: (voiceFilter: IVoiceFilter) => Promise<{ success: boolean, voiceFilter: IVoiceFilter, path: string | null; }>;
// deleteVoiceModel: (voiceFilter: IVoiceFilter) => Promise<void>;
// deleteAllVoiceModels: () => Promise<void>;
// getVoiceModelState: (voiceFilter: IVoiceFilter) => Promise<{ status: string, downloadedBytes: number; }>;
updateVoicesList: () => void;
}
export interface ZustandStore<StoreType> {
(): StoreType;
getState: () => StoreType;
subscribe: (cb: (value: StoreType) => void) => void;
}
export const useVoiceFiltersStore: ZustandStore<CustomVoiceFilterStore> = proxyLazy(() => zustandCreate()(
zustandPersist(
(set: any, get: () => CustomVoiceFilterStore) => ({
voiceFilters: {},
modulePath: "",
set: (voiceFilters: IVoiceFilterMap) => set({ voiceFilters }),
updateById: (id: string) => {
console.warn("updating voice filter:", id);
openConfirmModal("Are you sure you want to update this voicepack?", async key => {
console.warn("accepted to update voice filter:", id);
closeModal(key);
const { downloadUrl } = get().voiceFilters[id];
const hash = downloadUrl?.includes("?") ? "&" : "?";
get().downloadVoicepack(downloadUrl + hash + "v=" + Date.now());
});
},
deleteById: (id: string) => {
console.warn("deleting voice filter:", id);
openConfirmModal("Are you sure you want to delete this voicepack?", async key => {
console.warn("accepted to delete voice filter:", id);
closeModal(key);
const { voiceFilters } = get();
delete voiceFilters[id];
set({ voiceFilters });
});
},
deleteAll: () => {
openConfirmModal("Are you sure you want to delete all voicepacks?", () => {
set({ voiceFilters: {} });
get().updateVoicesList();
});
},
exportVoiceFilters: () => {
const { voiceFilters } = get();
const exportData = JSON.stringify(voiceFilters, null, 2);
const exportFileName = findByProps("getCurrentUser").getCurrentUser().username + "_voice_filters_export.json";
downloadFile(exportFileName, exportData);
},
exportIndividualVoice: (id: string) => {
const { voiceFilters } = get();
const exportData = JSON.stringify(voiceFilters[id], null, 2);
const exportFileName = voiceFilters[id].name + "_voice_filter_export.json";
downloadFile(exportFileName, exportData);
},
importVoiceFilters: () => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".json";
fileInput.onchange = e => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async e => {
try {
const data = JSON.parse(e.target?.result as string);
set({ voiceFilters: data });
} catch (error) {
openErrorModal("Invalid voice filters file");
}
};
reader.readAsText(file);
};
fileInput.click();
},
downloadVoicepack: async (url: string) => {
try {
// Parse input - either URL or JSON string
let data: any;
if (url.startsWith('{"') || url.startsWith("[{")) {
// Input is JSON string
data = JSON.parse(url);
} else {
// Input is URL - ensure HTTPS
const secureUrl = url.replace(/^http:/, "https:");
if (!secureUrl.startsWith("https://")) {
throw new Error("Invalid URL: Must use HTTPS protocol");
}
const date = new Date().getTime();
const downloadUrl = secureUrl.includes("?") ? "&v=" + date : "?v=" + date;
console.log("Downloading voice model from URL:", secureUrl + downloadUrl);
const response = await fetch(secureUrl + downloadUrl);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
}
data = await response.json();
}
// Handle single voice or array of voices
const voices = Array.isArray(data) ? data : [data];
const { voiceFilters } = get();
// Process each voice
for (const voice of voices) {
// Validate required fields
const missingFields = requiredFields.filter(field =>
voice[field] === undefined || voice[field] === null
);
if (missingFields.length > 0) {
throw new Error(`Invalid voice data. Missing fields: ${missingFields.join(", ")}`);
}
// Store voice with download source
voiceFilters[voice.id] = {
...voice,
downloadUrl: url
};
}
// Save and update UI
set({ voiceFilters });
} catch (error) {
openErrorModal(error instanceof Error ? error.message : "Failed to process voice pack");
}
},
// downloadVoiceModel: async (voiceFilter: IVoiceFilter) => {
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
// return Native.downloadCustomVoiceFilter(DiscordNative.fileManager.getModulePath(), voiceFilter);
// },
// deleteVoiceModel: async (voiceFilter: IVoiceFilter) => {
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
// return Native.deleteModel(DiscordNative.fileManager.getModulePath(), voiceFilter.id);
// },
// deleteAllVoiceModels: async () => {
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
// return Native.deleteAllModels(DiscordNative.fileManager.getModulePath());
// },
// getVoiceModelState: async (voiceFilter: IVoiceFilter) => {
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
// return Native.getModelState(voiceFilter.id, DiscordNative.fileManager.getModulePath());
// },
updateVoicesList: async () => {
// Move the object declaration to a separate variable first
const voiceFilterState = {
"nativeVoiceFilterModuleState": "uninitialized",
"models": {} as Record<string, any>,
"modelState": {} as Record<string, any>,
"voiceFilters": {} as Record<string, any>,
"sortedVoiceFilters": [] as string[],
"catalogUpdateTime": 0,
"limitedTimeVoices": [] as string[]
};
let i = 0;
if (voices)
for (const [, val] of Object.entries(voices) as [string, IVoiceFilter][]) {
if (!Object.values(voiceFilterState.voiceFilters).find(x => x.name === val.name))
voiceFilterState.voiceFilters[++i] = { ...val, id: i, available: true, temporarilyAvailable: false };
}
const { voiceFilters } = get();
Object.values(voiceFilters).forEach(voice => {
voiceFilterState.voiceFilters[++i] = { ...voice, id: i, temporarilyAvailable: false, previewSoundURLs: voice.available ? voice.previewSoundURLs : [] };
});
voiceFilterState.sortedVoiceFilters = Object.keys(voiceFilterState.voiceFilters);
console.log(voiceFilterState);
// Update store methods using voiceFilterState
VoiceFilterStore.getVoiceFilters = () => voiceFilterState.voiceFilters;
VoiceFilterStore.getVoiceFilter = id => voiceFilterState.voiceFilters[id];
VoiceFilterStore.getVoiceFilterModels = () => voiceFilterState.models;
VoiceFilterStore.getModelState = id => voiceFilterState.modelState[id];
VoiceFilterStore.getSortedVoiceFilters = () => voiceFilterState.sortedVoiceFilters.map(e => voiceFilterState.voiceFilters[e]);
VoiceFilterStore.getCatalogUpdateTime = () => voiceFilterState.catalogUpdateTime;
VoiceFilterStore.getLimitedTimeVoices = () => voiceFilterState.limitedTimeVoices;
}
} satisfies CustomVoiceFilterStore),
{
name: STORAGE_KEY,
storage: indexedDBStorageFactory<IVoiceFilterMap>(),
partialize: ({ voiceFilters }) => ({ voiceFilters }),
}
)
));
// Interfaces
export interface IVoiceFilter {
name: string;
author: string;
onnxFileUrl: string;
iconURL: string;
id: string;
styleKey: string;
available: boolean;
temporarilyAvailable: boolean;
custom?: boolean;
splashGradient?: string;
baseColor?: string;
previewSoundURLs?: string[];
downloadUrl?: string;
}
export type IVoiceFilterMap = Record<string, IVoiceFilter>;
// Required fields for validation
export const requiredFields = [
"name",
"author",
"onnxFileUrl",
"iconURL",
"id",
"styleKey",
"available",
"temporarilyAvailable"
] as const;
export default definePlugin({
name: "CustomVoiceFilters",
description: "Custom voice filters for your voice channels.",
authors: [
Devs.fox3000foxy,
Devs.davr1,
],
renderChatBarButton: CustomVoiceFilterChatBarIcon,
async start() {
console.log("CustomVoiceFilters started");
VoiceFilterStyles = findByProps("skye");
VoiceFilterStore = findStore("VoiceFilterStore");
voices = findAll(filters.byProps("skye")).find(m => m.skye?.name);
useVoiceFiltersStore.subscribe(store => store.updateVoicesList());
if (getClient().client === "desktop") {
const modulePath = await DiscordNative.fileManager.getModulePath();
useVoiceFiltersStore.getState().modulePath = modulePath;
}
// // ============ DEMO ============
// const templaceVoicePackObject: IVoiceFilter = JSON.parse(templateVoicepack);
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
// console.log("Natives modules:", Native, DiscordNative);
// console.log("Module path:", modulePath);
// console.log("Downloading template voice model...");
// const { success, voiceFilter, path } = await Native.downloadCustomVoiceFilter(modulePath, templaceVoicePackObject);
// console.log("Voice model debug output:", { success, voiceFilter, path });
// if (success) {
// console.log("Voice model downloaded to:", path);
// } else {
// console.error("Failed to download voice model");
// }
// console.log("Getting model state...");
// const modelState = await Native.getModelState(templaceVoicePackObject.id, modulePath);
// console.log("Model state:", modelState);
// console.log("Getting dummy model state...");
// const dummyModelState = await Native.getModelState("dummy", modulePath);
// console.log("Dummy model state:", dummyModelState);
// // ============ DEMO ============
},
stop() {
console.log("CustomVoiceFilters stopped");
},
});
export async function downloadCustomVoiceModel(voiceFilter: IVoiceFilter) {
const modulePath = await DiscordNative.fileManager.getModulePath();
const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
const { status } = await Native.getModelState(voiceFilter.id, modulePath);
if (status === "downloaded") {
return { success: true, voiceFilter, path: modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx", response: null };
} else {
console.log("Downloading voice model from URL:", voiceFilter.onnxFileUrl);
const response = await fetch(voiceFilter.onnxFileUrl);
const buffer = await response.arrayBuffer();
console.log("Downloading voice model from buffer:", buffer);
const response2 = await Native.downloadCustomVoiceFilterFromBuffer(modulePath, voiceFilter, buffer);
return { success: response2.success, voiceFilter, path: response2.path };
}
}
export function getClient() {
const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
try {
if (Native !== undefined) {
return { success: true, client: "desktop" };
} else {
return { success: true, client: "web" };
}
} catch (error) {
return { success: false, client: null };
}
}
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
// Imports
import "./style.css";
import { DataStore } from "@api/index";
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { proxyLazy } from "@utils/lazy";
import { closeModal } from "@utils/modal";
import definePlugin, { OptionType, PluginNative } from "@utils/types";
import { filters, findAll, findByProps, findStore } from "@webpack";
import { zustandCreate, zustandPersist } from "@webpack/common";
import { openConfirmModal } from "./ConfirmModal";
import { openErrorModal } from "./ErrorModal";
import { CustomVoiceFilterChatBarIcon } from "./Icons";
import { downloadFile } from "./utils";
export let voices: Record<string, IVoiceFilter> | null = null;
export let VoiceFilterStyles: any = null; // still 'skye'
export let VoiceFilterStore: any = null;
// Variables
export const templateVoicepack = JSON.stringify({
"name": "Reyna",
"iconURL": "https://cdn.discordapp.com/emojis/1340353599858806785.webp?size=512",
"splashGradient": "radial-gradient(circle, #d9a5a2 0%, rgba(0,0,0,0) 100%)",
"baseColor": "#d9a5a2",
"previewSoundURLs": [
"https://cdn.discordapp.com/soundboard-sounds/1340357897451995146"
],
"available": true,
"styleKey": "",
"temporarilyAvailable": false,
"id": "724847846897221642-reyna",
"author": "724847846897221642",
"onnxFileUrl": "https://fox3000foxy.com/voices_models/reyna_simple.onnx"
} satisfies IVoiceFilter, null, 2);
const STORAGE_KEY = "vencordVoiceFilters";
function indexedDBStorageFactory<T>() {
return {
async getItem(name: string): Promise<T | null> {
return (await DataStore.get(name)) ?? null;
},
async setItem(name: string, value: T): Promise<void> {
await DataStore.set(name, value);
},
async removeItem(name: string): Promise<void> {
await DataStore.del(name);
},
};
}
export interface CustomVoiceFilterStore {
voiceFilters: IVoiceFilterMap;
modulePath: string;
set: (voiceFilters: IVoiceFilterMap) => void;
updateById: (id: string) => void;
deleteById: (id: string) => void;
deleteAll: () => void;
exportVoiceFilters: () => void;
exportIndividualVoice: (id: string) => void;
importVoiceFilters: () => void;
downloadVoicepack: (url: string) => void;
// downloadVoiceModel: (voiceFilter: IVoiceFilter) => Promise<{ success: boolean, voiceFilter: IVoiceFilter, path: string | null; }>;
// deleteVoiceModel: (voiceFilter: IVoiceFilter) => Promise<void>;
// deleteAllVoiceModels: () => Promise<void>;
// getVoiceModelState: (voiceFilter: IVoiceFilter) => Promise<{ status: string, downloadedBytes: number; }>;
updateVoicesList: () => void;
}
export interface ZustandStore<StoreType> {
(): StoreType;
getState: () => StoreType;
subscribe: (cb: (value: StoreType) => void) => void;
}
export const useVoiceFiltersStore: ZustandStore<CustomVoiceFilterStore> = proxyLazy(() => zustandCreate()(
zustandPersist(
(set: any, get: () => CustomVoiceFilterStore) => ({
voiceFilters: {},
modulePath: "",
set: (voiceFilters: IVoiceFilterMap) => set({ voiceFilters }),
updateById: (id: string) => {
console.warn("updating voice filter:", id);
openConfirmModal("Are you sure you want to update this voicepack?", async key => {
console.warn("accepted to update voice filter:", id);
closeModal(key);
const { downloadUrl } = get().voiceFilters[id];
const hash = downloadUrl?.includes("?") ? "&" : "?";
get().downloadVoicepack(downloadUrl + hash + "v=" + Date.now());
});
},
deleteById: (id: string) => {
console.warn("deleting voice filter:", id);
openConfirmModal("Are you sure you want to delete this voicepack?", async key => {
console.warn("accepted to delete voice filter:", id);
closeModal(key);
const { voiceFilters } = get();
delete voiceFilters[id];
set({ voiceFilters });
});
},
deleteAll: () => {
openConfirmModal("Are you sure you want to delete all voicepacks?", () => {
set({ voiceFilters: {} });
get().updateVoicesList();
});
},
exportVoiceFilters: () => {
const { voiceFilters } = get();
const exportData = JSON.stringify(voiceFilters, null, 2);
const exportFileName = findByProps("getCurrentUser").getCurrentUser().username + "_voice_filters_export.json";
downloadFile(exportFileName, exportData);
},
exportIndividualVoice: (id: string) => {
const { voiceFilters } = get();
const exportData = JSON.stringify(voiceFilters[id], null, 2);
const exportFileName = voiceFilters[id].name + "_voice_filter_export.json";
downloadFile(exportFileName, exportData);
},
importVoiceFilters: () => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".json";
fileInput.onchange = e => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async e => {
try {
const data = JSON.parse(e.target?.result as string);
set({ voiceFilters: data });
} catch (error) {
openErrorModal("Invalid voice filters file");
}
};
reader.readAsText(file);
};
fileInput.click();
},
downloadVoicepack: async (url: string) => {
try {
// Parse input - either URL or JSON string
let data: any;
if (url.startsWith('{"') || url.startsWith("[{")) {
// Input is JSON string
data = JSON.parse(url);
} else {
// Input is URL - ensure HTTPS
const secureUrl = url.replace(/^http:/, "https:");
if (!secureUrl.startsWith("https://")) {
throw new Error("Invalid URL: Must use HTTPS protocol");
}
const date = new Date().getTime();
const downloadUrl = secureUrl.includes("?") ? "&v=" + date : "?v=" + date;
console.log("Downloading voice model from URL:", secureUrl + downloadUrl);
const response = await fetch(secureUrl + downloadUrl);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
}
data = await response.json();
}
// Handle single voice or array of voices
const voices = Array.isArray(data) ? data : [data];
const { voiceFilters } = get();
// Process each voice
for (const voice of voices) {
// Validate required fields
const missingFields = requiredFields.filter(field =>
voice[field] === undefined || voice[field] === null
);
if (missingFields.length > 0) {
throw new Error(`Invalid voice data. Missing fields: ${missingFields.join(", ")}`);
}
// Store voice with download source
voiceFilters[voice.id] = {
...voice,
downloadUrl: url
};
}
// Save and update UI
set({ voiceFilters });
} catch (error) {
openErrorModal(error instanceof Error ? error.message : "Failed to process voice pack");
}
},
// downloadVoiceModel: async (voiceFilter: IVoiceFilter) => {
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
// return Native.downloadCustomVoiceFilter(DiscordNative.fileManager.getModulePath(), voiceFilter);
// },
// deleteVoiceModel: async (voiceFilter: IVoiceFilter) => {
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
// return Native.deleteModel(DiscordNative.fileManager.getModulePath(), voiceFilter.id);
// },
// deleteAllVoiceModels: async () => {
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
// return Native.deleteAllModels(DiscordNative.fileManager.getModulePath());
// },
// getVoiceModelState: async (voiceFilter: IVoiceFilter) => {
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
// return Native.getModelState(voiceFilter.id, DiscordNative.fileManager.getModulePath());
// },
updateVoicesList: async () => {
// Move the object declaration to a separate variable first
const voiceFilterState = {
"nativeVoiceFilterModuleState": "uninitialized",
"models": {} as Record<string, any>,
"modelState": {} as Record<string, any>,
"voiceFilters": {} as Record<string, any>,
"sortedVoiceFilters": [] as string[],
"catalogUpdateTime": 0,
"limitedTimeVoices": [] as string[]
};
let i = 0;
if (voices)
for (const [, val] of Object.entries(voices) as [string, IVoiceFilter][]) {
if (!Object.values(voiceFilterState.voiceFilters).find(x => x.name === val.name))
voiceFilterState.voiceFilters[++i] = { ...val, id: i, available: true, temporarilyAvailable: false };
}
const { voiceFilters } = get();
Object.values(voiceFilters).forEach(voice => {
voiceFilterState.voiceFilters[++i] = { ...voice, id: i, temporarilyAvailable: false, previewSoundURLs: voice.available ? voice.previewSoundURLs : [] };
});
voiceFilterState.sortedVoiceFilters = Object.keys(voiceFilterState.voiceFilters);
console.log(voiceFilterState);
// Update store methods using voiceFilterState
VoiceFilterStore.getVoiceFilters = () => voiceFilterState.voiceFilters;
VoiceFilterStore.getVoiceFilter = id => voiceFilterState.voiceFilters[id];
VoiceFilterStore.getVoiceFilterModels = () => voiceFilterState.models;
VoiceFilterStore.getModelState = id => voiceFilterState.modelState[id];
VoiceFilterStore.getSortedVoiceFilters = () => voiceFilterState.sortedVoiceFilters.map(e => voiceFilterState.voiceFilters[e]);
VoiceFilterStore.getCatalogUpdateTime = () => voiceFilterState.catalogUpdateTime;
VoiceFilterStore.getLimitedTimeVoices = () => voiceFilterState.limitedTimeVoices;
}
} satisfies CustomVoiceFilterStore),
{
name: STORAGE_KEY,
storage: indexedDBStorageFactory<IVoiceFilterMap>(),
partialize: ({ voiceFilters }) => ({ voiceFilters }),
}
)
));
// Interfaces
export interface IVoiceFilter {
name: string;
author: string;
onnxFileUrl: string;
iconURL: string;
id: string;
styleKey: string;
available: boolean;
temporarilyAvailable: boolean;
custom?: boolean;
splashGradient?: string;
baseColor?: string;
previewSoundURLs?: string[];
downloadUrl?: string;
}
export type IVoiceFilterMap = Record<string, IVoiceFilter>;
// Required fields for validation
export const requiredFields = [
"name",
"author",
"onnxFileUrl",
"iconURL",
"id",
"styleKey",
"available",
"temporarilyAvailable"
] as const;
export const settings = definePluginSettings({
pitch: {
type: OptionType.SLIDER,
markers: Array.from({ length: 25 }, (_, i) => i - 12),
default: 0,
description: "Pitch of the voice",
},
frequency: {
type: OptionType.SLIDER,
markers: Array.from({ length: 13 }, (_, i) => 4000 * i),
default: 24000,
description: "Frequency of the voice",
}
});
export default definePlugin({
name: "CustomVoiceFilters",
description: "Custom voice filters for your voice channels.",
authors: [
Devs.fox3000foxy,
Devs.davr1,
],
settings,
renderChatBarButton: CustomVoiceFilterChatBarIcon,
async start() {
console.log("CustomVoiceFilters started");
VoiceFilterStyles = findByProps("skye");
VoiceFilterStore = findStore("VoiceFilterStore");
voices = findAll(filters.byProps("skye")).find(m => m.skye?.name);
useVoiceFiltersStore.subscribe(store => store.updateVoicesList());
if (getClient().client === "desktop") {
const modulePath = await DiscordNative.fileManager.getModulePath();
useVoiceFiltersStore.getState().modulePath = modulePath;
}
// // ============ DEMO ============
// const templaceVoicePackObject: IVoiceFilter = JSON.parse(templateVoicepack);
// const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
// console.log("Natives modules:", Native, DiscordNative);
// console.log("Module path:", modulePath);
// console.log("Downloading template voice model...");
// const { success, voiceFilter, path } = await Native.downloadCustomVoiceFilter(modulePath, templaceVoicePackObject);
// console.log("Voice model debug output:", { success, voiceFilter, path });
// if (success) {
// console.log("Voice model downloaded to:", path);
// } else {
// console.error("Failed to download voice model");
// }
// console.log("Getting model state...");
// const modelState = await Native.getModelState(templaceVoicePackObject.id, modulePath);
// console.log("Model state:", modelState);
// console.log("Getting dummy model state...");
// const dummyModelState = await Native.getModelState("dummy", modulePath);
// console.log("Dummy model state:", dummyModelState);
// // ============ DEMO ============
},
stop() {
console.log("CustomVoiceFilters stopped");
},
});
export async function downloadCustomVoiceModel(voiceFilter: IVoiceFilter) {
const modulePath = await DiscordNative.fileManager.getModulePath();
const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
const { status } = await Native.getModelState(voiceFilter.id, modulePath);
if (status === "downloaded") {
return { success: true, voiceFilter, path: modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx", response: null };
} else {
console.log("Downloading voice model from URL:", voiceFilter.onnxFileUrl);
const response = await fetch(voiceFilter.onnxFileUrl);
const buffer = await response.arrayBuffer();
console.log("Downloading voice model from buffer:", buffer);
const response2 = await Native.downloadCustomVoiceFilterFromBuffer(modulePath, voiceFilter, buffer);
return { success: response2.success, voiceFilter, path: response2.path };
}
}
export function getClient() {
const Native = VencordNative.pluginHelpers.CustomVoiceFilters as PluginNative<typeof import("./native")>;
try {
if (Native !== undefined) {
return { success: true, client: "desktop" };
} else {
return { success: true, client: "web" };
}
} catch (error) {
return { success: false, client: null };
}
}

View file

@ -1,89 +1,89 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { IpcMainInvokeEvent } from "electron";
interface IVoiceFilter {
name: string;
author: string;
onnxFileUrl: string;
iconURL: string;
id: string;
styleKey: string;
available: boolean;
temporarilyAvailable: boolean;
custom?: boolean;
splashGradient?: string;
baseColor?: string;
previewSoundURLs?: string[];
downloadUrl?: string;
}
const fs = require("fs");
export async function downloadCustomVoiceFilter(_: IpcMainInvokeEvent, modulePath: string, voiceFilter: IVoiceFilter): Promise<{ success: boolean, voiceFilter: IVoiceFilter, path: string | null, response: Response | null; }> {
if (!fs.existsSync(modulePath + "/discord_voice_filters")) {
fs.mkdirSync(modulePath + "/discord_voice_filters");
}
if (!voiceFilter.onnxFileUrl ||
fs.existsSync(modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx") ||
!voiceFilter.onnxFileUrl.endsWith(".onnx")
) {
return {
success: false,
response: null,
voiceFilter: voiceFilter,
path: null
};
}
const response = await fetch(voiceFilter.onnxFileUrl);
if (!response.ok) {
return {
success: false,
response: response,
voiceFilter: voiceFilter,
path: null
};
}
const arrayBuffer = await response.arrayBuffer();
fs.writeFileSync(modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx", Buffer.from(arrayBuffer));
return {
success: true,
response: response,
voiceFilter: voiceFilter,
path: modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx"
};
}
export async function downloadCustomVoiceFilterFromBuffer(_: IpcMainInvokeEvent, modulePath: string, voiceFilter: IVoiceFilter, buffer: ArrayBuffer) {
if (!fs.existsSync(modulePath + "/discord_voice_filters")) {
fs.mkdirSync(modulePath + "/discord_voice_filters");
}
fs.writeFileSync(modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx", Buffer.from(buffer));
return {
success: true,
voiceFilter: voiceFilter,
path: modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx"
};
}
export async function getModelState(_: IpcMainInvokeEvent, id: string, modulePath: string) {
const modelPath = modulePath + "/discord_voice_filters/";
return {
status: fs.existsSync(modelPath + id + ".onnx") ? "downloaded" : "not_downloaded",
downloadedBytes: fs.existsSync(modelPath + id + ".onnx") ? fs.statSync(modelPath + id + ".onnx").size : 0
};
}
export async function deleteModel(_: IpcMainInvokeEvent, modulePath: string, id: string) {
const modelPath = modulePath + "/discord_voice_filters/";
fs.unlinkSync(modelPath + id + ".onnx");
}
export async function deleteAllModels(_: IpcMainInvokeEvent, modulePath: string) {
const modelPath = modulePath + "/discord_voice_filters/";
fs.rmSync(modelPath, { recursive: true, force: true });
}
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { IpcMainInvokeEvent } from "electron";
interface IVoiceFilter {
name: string;
author: string;
onnxFileUrl: string;
iconURL: string;
id: string;
styleKey: string;
available: boolean;
temporarilyAvailable: boolean;
custom?: boolean;
splashGradient?: string;
baseColor?: string;
previewSoundURLs?: string[];
downloadUrl?: string;
}
const fs = require("fs");
export async function downloadCustomVoiceFilter(_: IpcMainInvokeEvent, modulePath: string, voiceFilter: IVoiceFilter): Promise<{ success: boolean, voiceFilter: IVoiceFilter, path: string | null, response: Response | null; }> {
if (!fs.existsSync(modulePath + "/discord_voice_filters")) {
fs.mkdirSync(modulePath + "/discord_voice_filters");
}
if (!voiceFilter.onnxFileUrl ||
fs.existsSync(modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx") ||
!voiceFilter.onnxFileUrl.endsWith(".onnx")
) {
return {
success: false,
response: null,
voiceFilter: voiceFilter,
path: null
};
}
const response = await fetch(voiceFilter.onnxFileUrl);
if (!response.ok) {
return {
success: false,
response: response,
voiceFilter: voiceFilter,
path: null
};
}
const arrayBuffer = await response.arrayBuffer();
fs.writeFileSync(modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx", Buffer.from(arrayBuffer));
return {
success: true,
response: response,
voiceFilter: voiceFilter,
path: modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx"
};
}
export async function downloadCustomVoiceFilterFromBuffer(_: IpcMainInvokeEvent, modulePath: string, voiceFilter: IVoiceFilter, buffer: ArrayBuffer) {
if (!fs.existsSync(modulePath + "/discord_voice_filters")) {
fs.mkdirSync(modulePath + "/discord_voice_filters");
}
fs.writeFileSync(modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx", Buffer.from(buffer));
return {
success: true,
voiceFilter: voiceFilter,
path: modulePath + "/discord_voice_filters/" + voiceFilter.id + ".onnx"
};
}
export async function getModelState(_: IpcMainInvokeEvent, id: string, modulePath: string) {
const modelPath = modulePath + "/discord_voice_filters/";
return {
status: fs.existsSync(modelPath + id + ".onnx") ? "downloaded" : "not_downloaded",
downloadedBytes: fs.existsSync(modelPath + id + ".onnx") ? fs.statSync(modelPath + id + ".onnx").size : 0
};
}
export async function deleteModel(_: IpcMainInvokeEvent, modulePath: string, id: string) {
const modelPath = modulePath + "/discord_voice_filters/";
fs.unlinkSync(modelPath + id + ".onnx");
}
export async function deleteAllModels(_: IpcMainInvokeEvent, modulePath: string) {
const modelPath = modulePath + "/discord_voice_filters/";
fs.rmSync(modelPath, { recursive: true, force: true });
}

View file

@ -1,77 +1,77 @@
.vc-voice-filters-wiki {
max-width: var(--modal-width-large);
}
.vc-voice-filters-md {
display: flex;
flex-direction: column;
align-items: stretch;
}
.vc-voice-filters-md-image {
width: 100%;
max-height: 20rem;
overflow: hidden;
display: flex;
margin-block: 0.5rem;
img {
width: 100%;
background: var(--background-tertiary);
border-radius: 0.5rem;
object-fit: contain;
}
}
.vc-voice-filters-card {
width: 100%;
.vc-voice-filters-card-title {
cursor: pointer;
background: var(--background-primary);
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.vc-voice-filters-card-icon {
transition: all 100ms ease-in-out;
}
&.vc-voice-filters-card-open .vc-voice-filters-card-icon {
transform: rotate(180deg);
}
}
.vc-voice-filters-modal-link {
border-radius: 3px;
padding: 0 2px;
font-weight: 500;
unicode-bidi: plaintext;
color: var(--mention-foreground);
background: var(--mention-background);
text-overflow: ellipsis;
overflow: hidden;
}
.vc-voice-filters-details {
interpolate-size: allow-keywords;
transition: all 100ms ease-in-out;
overflow-y: hidden;
height: 0;
padding-inline: 1.2rem;
.vc-voice-filters-card-open & {
height: auto;
}
}
.vc-voice-filters-modal {
padding-block: 0.5rem;
color: var(--text-normal);
}
.vc-voice-filters-voice-filter:not(.vc-voice-filters-voice-filter-available) img {
filter: brightness(0.5);
}
.vc-voice-filters-wiki {
max-width: var(--modal-width-large);
}
.vc-voice-filters-md {
display: flex;
flex-direction: column;
align-items: stretch;
}
.vc-voice-filters-md-image {
width: 100%;
max-height: 20rem;
overflow: hidden;
display: flex;
margin-block: 0.5rem;
img {
width: 100%;
background: var(--background-tertiary);
border-radius: 0.5rem;
object-fit: contain;
}
}
.vc-voice-filters-card {
width: 100%;
.vc-voice-filters-card-title {
cursor: pointer;
background: var(--background-primary);
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.vc-voice-filters-card-icon {
transition: all 100ms ease-in-out;
}
&.vc-voice-filters-card-open .vc-voice-filters-card-icon {
transform: rotate(180deg);
}
}
.vc-voice-filters-modal-link {
border-radius: 3px;
padding: 0 2px;
font-weight: 500;
unicode-bidi: plaintext;
color: var(--mention-foreground);
background: var(--mention-background);
text-overflow: ellipsis;
overflow: hidden;
}
.vc-voice-filters-details {
interpolate-size: allow-keywords;
transition: all 100ms ease-in-out;
overflow-y: hidden;
height: 0;
padding-inline: 1.2rem;
.vc-voice-filters-card-open & {
height: auto;
}
}
.vc-voice-filters-modal {
padding-block: 0.5rem;
color: var(--text-normal);
}
.vc-voice-filters-voice-filter:not(.vc-voice-filters-voice-filter-available) img {
filter: brightness(0.5);
}

View file

@ -1,96 +1,96 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { useCallback, useEffect, useRef, useState } from "@webpack/common";
export function downloadFile(name: string, data: string): void {
const file = new File([data], name, { type: "application/json" });
const url = URL.createObjectURL(file);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
}
type AudioKey = string | symbol;
const globalAudio: Record<AudioKey, HTMLAudioElement | null> = {};
const defaultKey = Symbol("default_audio_key");
interface UseAudioOptions {
source?: string; // audio url
key?: AudioKey; // specify a different key to allow playback of multiple sounds at once
}
interface PlaySoundOptions {
volume?: number;
continuePlayback?: boolean;
}
export function useAudio({ source, key = defaultKey }: UseAudioOptions = {}) {
const audioRef = useRef<HTMLAudioElement | null>(globalAudio[key] ?? null);
const [isPlaying, setIsPlaying] = useState<boolean>(
!!audioRef.current && !audioRef.current.paused && audioRef.current.src === source
);
useEffect(() => {
if (globalAudio[key] && isPlaying) {
globalAudio[key].addEventListener("pause", () => setIsPlaying(false), { once: true });
}
}, [isPlaying]);
useEffect(() => {
if (isPlaying && globalAudio[key] && globalAudio[key].src !== source) {
audioRef.current?.pause();
playSound();
}
}, [key, source, isPlaying]);
const preloadSound = useCallback(() => {
if (!source) {
audioRef.current = null;
return;
}
if (audioRef.current && audioRef.current.src === source)
return;
audioRef.current = new Audio(source);
audioRef.current.preload = "auto";
}, [source]);
const playSound = useCallback(
({ volume = 1, continuePlayback }: PlaySoundOptions = {}) => {
preloadSound();
if (!audioRef.current) {
delete globalAudio[key];
return;
}
if (globalAudio[key]?.src !== audioRef.current.src || !continuePlayback) {
globalAudio[key]?.pause();
globalAudio[key] = audioRef.current;
globalAudio[key].currentTime = 0;
}
globalAudio[key].volume = volume;
globalAudio[key].play()
.then(() => setIsPlaying(true))
.catch(error => {
console.error("Error playing audio:", error);
setIsPlaying(false);
});
},
[key, preloadSound]
);
const stopSound = useCallback(() => globalAudio[key]?.pause(), [key]);
return { isPlaying, playSound, stopSound, preloadSound };
}
export const cl = classNameFactory();
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { useCallback, useEffect, useRef, useState } from "@webpack/common";
export function downloadFile(name: string, data: string): void {
const file = new File([data], name, { type: "application/json" });
const url = URL.createObjectURL(file);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
}
type AudioKey = string | symbol;
const globalAudio: Record<AudioKey, HTMLAudioElement | null> = {};
const defaultKey = Symbol("default_audio_key");
interface UseAudioOptions {
source?: string; // audio url
key?: AudioKey; // specify a different key to allow playback of multiple sounds at once
}
interface PlaySoundOptions {
volume?: number;
continuePlayback?: boolean;
}
export function useAudio({ source, key = defaultKey }: UseAudioOptions = {}) {
const audioRef = useRef<HTMLAudioElement | null>(globalAudio[key] ?? null);
const [isPlaying, setIsPlaying] = useState<boolean>(
!!audioRef.current && !audioRef.current.paused && audioRef.current.src === source
);
useEffect(() => {
if (globalAudio[key] && isPlaying) {
globalAudio[key].addEventListener("pause", () => setIsPlaying(false), { once: true });
}
}, [isPlaying]);
useEffect(() => {
if (isPlaying && globalAudio[key] && globalAudio[key].src !== source) {
audioRef.current?.pause();
playSound();
}
}, [key, source, isPlaying]);
const preloadSound = useCallback(() => {
if (!source) {
audioRef.current = null;
return;
}
if (audioRef.current && audioRef.current.src === source)
return;
audioRef.current = new Audio(source);
audioRef.current.preload = "auto";
}, [source]);
const playSound = useCallback(
({ volume = 1, continuePlayback }: PlaySoundOptions = {}) => {
preloadSound();
if (!audioRef.current) {
delete globalAudio[key];
return;
}
if (globalAudio[key]?.src !== audioRef.current.src || !continuePlayback) {
globalAudio[key]?.pause();
globalAudio[key] = audioRef.current;
globalAudio[key].currentTime = 0;
}
globalAudio[key].volume = volume;
globalAudio[key].play()
.then(() => setIsPlaying(true))
.catch(error => {
console.error("Error playing audio:", error);
setIsPlaying(false);
});
},
[key, preloadSound]
);
const stopSound = useCallback(() => globalAudio[key]?.pause(), [key]);
return { isPlaying, playSound, stopSound, preloadSound };
}
export const cl = classNameFactory();