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

View file

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

View file

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

View file

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

View file

@ -1,110 +1,110 @@
/* /*
* Vencord, a Discord client mod * Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors * Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons"; import { ChatBarButton, ChatBarButtonFactory } from "@api/ChatButtons";
import { JSX, SVGProps } from "react"; import { JSX, SVGProps } from "react";
import { openVoiceFiltersModal } from "./VoiceFiltersModal"; import { openVoiceFiltersModal } from "./VoiceFiltersModal";
interface IconProps extends SVGProps<SVGSVGElement> { } interface IconProps extends SVGProps<SVGSVGElement> { }
export function DownloadIcon(props: IconProps): JSX.Element { export function DownloadIcon(props: IconProps): JSX.Element {
return ( 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}> <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" /> <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> </svg>
); );
} }
export function DownloadingIcon(props: IconProps): JSX.Element { export function DownloadingIcon(props: IconProps): JSX.Element {
return ( return (
<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" {...props}> <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" /> <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" /> <animateTransform attributeName="transform" type="rotate" from="0 0 0" to="360 0 0" dur="3s" repeatCount="indefinite" />
</svg> </svg>
); );
} }
export function PlayIcon(props: IconProps): JSX.Element { export function PlayIcon(props: IconProps): JSX.Element {
return ( 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}> <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" /> <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> </svg>
); );
} }
export function PauseIcon(props: IconProps): JSX.Element { export function PauseIcon(props: IconProps): JSX.Element {
return ( 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}> <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" /> <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> </svg>
); );
} }
export function ChevronIcon(props: IconProps): JSX.Element { export function ChevronIcon(props: IconProps): JSX.Element {
return ( 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}> <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" /> <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> </svg>
); );
} }
export function RefreshIcon(props: IconProps): JSX.Element { export function RefreshIcon(props: IconProps): JSX.Element {
return ( return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> <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" /> <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> </svg>
); );
} }
export function TrashIcon(props: IconProps): JSX.Element { export function TrashIcon(props: IconProps): JSX.Element {
return ( return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> <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" 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" /> <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> </svg>
); );
} }
export function PencilIcon(props: IconProps): JSX.Element { export function PencilIcon(props: IconProps): JSX.Element {
return ( return (
<svg aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" {...props}> <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" /> <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> </svg>
); );
} }
// Custom Voice Filter Icon // Custom Voice Filter Icon
export function CustomVoiceFilterIcon(props: IconProps) { export function CustomVoiceFilterIcon(props: IconProps) {
return ( 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}> <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="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="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" /> <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> </svg>
); );
} }
// Custom Voice Filter Chat Bar Icon // Custom Voice Filter Chat Bar Icon
export const CustomVoiceFilterChatBarIcon: ChatBarButtonFactory = ({ isMainChat }) => { export const CustomVoiceFilterChatBarIcon: ChatBarButtonFactory = ({ isMainChat }) => {
if (!isMainChat) return null; if (!isMainChat) return null;
return ( return (
<ChatBarButton <ChatBarButton
tooltip="Open Custom Voice Filter Menu" tooltip="Open Custom Voice Filter Menu"
onClick={openVoiceFiltersModal} onClick={openVoiceFiltersModal}
buttonProps={{ buttonProps={{
"aria-haspopup": "dialog" "aria-haspopup": "dialog"
}} }}
> >
<CustomVoiceFilterIcon /> <CustomVoiceFilterIcon />
</ChatBarButton> </ChatBarButton>
); );
}; };

View file

@ -1,107 +1,107 @@
/* /*
* Vencord, a Discord client mod * Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors * Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { proxyLazy } from "@utils/lazy"; import { proxyLazy } from "@utils/lazy";
import { findByCode, findByProps, findByPropsLazy } from "@webpack"; import { findByCode, findByProps, findByPropsLazy } from "@webpack";
import { Parser } from "@webpack/common"; import { Parser } from "@webpack/common";
import { JSX } from "react"; import { JSX } from "react";
import { openCreateVoiceModal } from "./CreateVoiceFilterModal"; import { openCreateVoiceModal } from "./CreateVoiceFilterModal";
import { openHelpModal } from "./HelpModal"; import { openHelpModal } from "./HelpModal";
import { cl } from "./utils"; import { cl } from "./utils";
import { openVoiceFiltersModal } from "./VoiceFiltersModal"; import { openVoiceFiltersModal } from "./VoiceFiltersModal";
interface MarkdownRules { interface MarkdownRules {
allowDevLinks: boolean; allowDevLinks: boolean;
allowEmojiLinks: boolean; allowEmojiLinks: boolean;
allowHeading: boolean; allowHeading: boolean;
allowLinks: boolean; allowLinks: boolean;
allowList: boolean; allowList: boolean;
channelId: string; channelId: string;
disableAnimatedEmoji: boolean; disableAnimatedEmoji: boolean;
disableAutoBlockNewlines: boolean; disableAutoBlockNewlines: boolean;
forceWhite: boolean; forceWhite: boolean;
formatInline: boolean; formatInline: boolean;
isInteracting: boolean; isInteracting: boolean;
mentionChannels: string[]; mentionChannels: string[];
messageId: string; messageId: string;
muted: boolean; muted: boolean;
noStyleAndInteraction: boolean; noStyleAndInteraction: boolean;
previewLinkTarget: boolean; previewLinkTarget: boolean;
soundboardSounds: string[]; soundboardSounds: string[];
unknownUserMentionPlaceholder: boolean; unknownUserMentionPlaceholder: boolean;
viewingChannelId: string; viewingChannelId: string;
} }
const defaultRules: Partial<MarkdownRules> = { allowLinks: true, allowList: true, allowHeading: true }; const defaultRules: Partial<MarkdownRules> = { allowLinks: true, allowList: true, allowHeading: true };
const MarkdownContainerClasses = findByPropsLazy("markup", "codeContainer"); const MarkdownContainerClasses = findByPropsLazy("markup", "codeContainer");
const modalLinkRegex = /^<vf:(help|createVoice|main)>/; const modalLinkRegex = /^<vf:(help|createVoice|main)>/;
const imageRegex = /^!\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*((?:\([^)]*\)|[^\s\\]|\\.)*?)\)/; const imageRegex = /^!\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*((?:\([^)]*\)|[^\s\\]|\\.)*?)\)/;
const actions: Record<string, { action: () => string, name: string; }> = { const actions: Record<string, { action: () => string, name: string; }> = {
help: { help: {
action: openHelpModal, action: openHelpModal,
name: "Help menu" name: "Help menu"
}, },
createVoice: { createVoice: {
action: () => openCreateVoiceModal(), action: () => openCreateVoiceModal(),
name: "Voice pack creator menu" name: "Voice pack creator menu"
}, },
main: { main: {
action: openVoiceFiltersModal, action: openVoiceFiltersModal,
name: "Main menu" name: "Main menu"
}, },
}; };
const parser: typeof Parser.parse = proxyLazy(() => { const parser: typeof Parser.parse = proxyLazy(() => {
const DiscordRules = findByProps("AUTO_MODERATION_SYSTEM_MESSAGE_RULES").RULES; const DiscordRules = findByProps("AUTO_MODERATION_SYSTEM_MESSAGE_RULES").RULES;
const AdvancedRules = findByCode("channelMention:")({}); const AdvancedRules = findByCode("channelMention:")({});
const customRules = { const customRules = {
modalLink: { modalLink: {
order: DiscordRules.staticRouteLink, order: DiscordRules.staticRouteLink,
match: source => modalLinkRegex.exec(source), match: source => modalLinkRegex.exec(source),
parse: ([, target]) => (actions[target]), parse: ([, target]) => (actions[target]),
react: ({ action, name }) => ( react: ({ action, name }) => (
<span className="channelMention interactive vc-voice-filters-modal-link" role="link" onClick={action}>{name}</span> <span className="channelMention interactive vc-voice-filters-modal-link" role="link" onClick={action}>{name}</span>
), ),
requiredFirstCharacters: ["<"] requiredFirstCharacters: ["<"]
}, },
image: { image: {
...Parser.defaultRules.link, ...Parser.defaultRules.link,
match: source => imageRegex.exec(source), match: source => imageRegex.exec(source),
parse: ([, title, target]) => ({ title, target }), parse: ([, title, target]) => ({ title, target }),
react: ({ title, target }) => <div className="vc-voice-filters-md-image"> react: ({ title, target }) => <div className="vc-voice-filters-md-image">
<img src={target} alt={title} /> <img src={target} alt={title} />
</div>, </div>,
requiredFirstCharacters: ["!"] requiredFirstCharacters: ["!"]
} }
}; };
const builtinRules = new Set([...Object.keys(DiscordRules), ...Object.keys(AdvancedRules)]); const builtinRules = new Set([...Object.keys(DiscordRules), ...Object.keys(AdvancedRules)]);
for (const rule of builtinRules) { for (const rule of builtinRules) {
customRules[rule] = { customRules[rule] = {
...DiscordRules[rule], ...DiscordRules[rule],
...AdvancedRules[rule], ...AdvancedRules[rule],
}; };
} }
return (Parser as any).reactParserFor(customRules); return (Parser as any).reactParserFor(customRules);
}); });
interface MarkdownProps extends Omit<JSX.IntrinsicElements["div"], "children"> { interface MarkdownProps extends Omit<JSX.IntrinsicElements["div"], "children"> {
content: string; content: string;
markdownRules?: Partial<MarkdownRules>; markdownRules?: Partial<MarkdownRules>;
} }
export function Markdown({ content, markdownRules = defaultRules, className, ...props }: MarkdownProps) { export function Markdown({ content, markdownRules = defaultRules, className, ...props }: MarkdownProps) {
return <div className={cl(MarkdownContainerClasses.markup, "vc-voice-filters-md", className)} {...props}> return <div className={cl(MarkdownContainerClasses.markup, "vc-voice-filters-md", className)} {...props}>
{parser(content, false, markdownRules)} {parser(content, false, markdownRules)}
</div>; </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 { openHelpModal } from "./HelpModal";
import { DownloadIcon, DownloadingIcon, PauseIcon, PlayIcon, RefreshIcon, TrashIcon } from "./Icons"; import { DownloadIcon, DownloadingIcon, PauseIcon, PlayIcon, RefreshIcon, TrashIcon } from "./Icons";
import { downloadCustomVoiceModel, getClient, IVoiceFilter, useVoiceFiltersStore, VoiceFilterStyles } from "./index"; import { downloadCustomVoiceModel, getClient, IVoiceFilter, useVoiceFiltersStore, VoiceFilterStyles } from "./index";
import { openSettingsModal } from "./SettingsModal";
import { cl, useAudio } from "./utils"; import { cl, useAudio } from "./utils";
import { openWikiHomeModal } from "./WikiHomeModal"; import { openWikiHomeModal } from "./WikiHomeModal";
@ -86,6 +87,7 @@ function VoiceFiltersModal({ modalProps, close, accept }: VoiceFiltersModalProps
</ModalContent> </ModalContent>
<ModalFooter> <ModalFooter>
<Flex style={{ gap: "0.5rem" }} justify={Flex.Justify.END} align={Flex.Align.CENTER}> <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={openHelpModal}>Learn how to build your own voicepack</Button>
<Button color={Button.Colors.TRANSPARENT} onClick={() => openCreateVoiceModal()}>Create Voicepack</Button> <Button color={Button.Colors.TRANSPARENT} onClick={() => openCreateVoiceModal()}>Create Voicepack</Button>
<Button color={Button.Colors.GREEN} onClick={openWikiHomeModal}>Wiki</Button> <Button color={Button.Colors.GREEN} onClick={openWikiHomeModal}>Wiki</Button>

View file

@ -1,115 +1,115 @@
/* /*
* Vencord, a Discord client mod * Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors * Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { closeModal, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { Button, Card, Flex, Forms, Text, useState } from "@webpack/common"; import { Button, Card, Flex, Forms, Text, useState } from "@webpack/common";
import { ChevronIcon } from "./Icons"; import { ChevronIcon } from "./Icons";
import { Markdown } from "./Markdown"; import { Markdown } from "./Markdown";
import { cl } from "./utils"; import { cl } from "./utils";
interface Section { interface Section {
title: string; title: string;
content: string; content: string;
} }
const sections: Section[] = [ const sections: Section[] = [
{ {
title: "How to install a voicepack", title: "How to install a voicepack",
content: "To install a voicepack, you need to paste the voicepack url in the <vf:main>" content: "To install a voicepack, you need to paste the voicepack url in the <vf:main>"
}, },
{ {
title: "How to create a voicepack", title: "How to create a voicepack",
content: `You have two methods to create a voicepack: content: `You have two methods to create a voicepack:
1. Use the <vf:createVoice> (recommended) 1. Use the <vf:createVoice> (recommended)
2. Use the <vf:help> (advanced)` 2. Use the <vf:help> (advanced)`
}, },
{ {
title: "How does it work?", 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. 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. 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. 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)` ![img](https://fox3000foxy.com/voicepacks/assets/working.png)`
}, },
{ {
title: "How to create an ONNX from an existing RVC model?", 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/). 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). 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. Thats the actual software that does exports RVC models to ONNX files.
Just load your model inside MMVCServerSio, and click on "Export ONNX": 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) ![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!` Enjoy you now have a ONNX model file for your voicepack!`
}, },
{ {
title: "How to train my own voice model?", 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." content: "Refers to [this video](https://www.youtube.com/watch?v=tnfqIQ11Qek&ab_channel=AISearch) and convert it to ONNX."
} }
]; ];
interface WikiHomeModalProps { interface WikiHomeModalProps {
modalProps: ModalProps; modalProps: ModalProps;
close: () => void; close: () => void;
accept: () => void; accept: () => void;
} }
export function WikiHomeModal({ modalProps, close, accept }: WikiHomeModalProps) { export function WikiHomeModal({ modalProps, close, accept }: WikiHomeModalProps) {
return ( return (
<ModalRoot {...modalProps} size={ModalSize.LARGE} className="vc-voice-filters-wiki"> <ModalRoot {...modalProps} size={ModalSize.LARGE} className="vc-voice-filters-wiki">
<ModalHeader> <ModalHeader>
<Forms.FormTitle tag="h2" className="modalTitle"> <Forms.FormTitle tag="h2" className="modalTitle">
Wiki Home Wiki Home
</Forms.FormTitle> </Forms.FormTitle>
<ModalCloseButton onClick={close} /> <ModalCloseButton onClick={close} />
</ModalHeader> </ModalHeader>
<ModalContent className="vc-voice-filters-modal"> <ModalContent className="vc-voice-filters-modal">
<Flex style={{ gap: "0.5rem" }} direction={Flex.Direction.VERTICAL}> <Flex style={{ gap: "0.5rem" }} direction={Flex.Direction.VERTICAL}>
<Text>Here are some tutorials and guides about the Custom Voice Filter Plugin:</Text> <Text>Here are some tutorials and guides about the Custom Voice Filter Plugin:</Text>
{sections.map((section, index) => ( {sections.map((section, index) => (
<CollapsibleCard key={index} title={section.title} content={section.content} /> <CollapsibleCard key={index} title={section.title} content={section.content} />
))} ))}
</Flex> </Flex>
</ModalContent> </ModalContent>
<ModalFooter> <ModalFooter>
<Button onClick={close}>Close</Button> <Button onClick={close}>Close</Button>
</ModalFooter> </ModalFooter>
</ModalRoot> </ModalRoot>
); );
} }
export function openWikiHomeModal(): string { export function openWikiHomeModal(): string {
const key = openModal(modalProps => ( const key = openModal(modalProps => (
<WikiHomeModal <WikiHomeModal
modalProps={modalProps} modalProps={modalProps}
close={() => closeModal(key)} close={() => closeModal(key)}
accept={() => { accept={() => {
// console.warn("accepted", url); // console.warn("accepted", url);
closeModal(key); closeModal(key);
}} }}
/> />
)); ));
return key; return key;
} }
interface CollapsibleCardProps { interface CollapsibleCardProps {
title: string; title: string;
content: string; content: string;
} }
function CollapsibleCard({ title, content }: CollapsibleCardProps) { function CollapsibleCard({ title, content }: CollapsibleCardProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<Card className={cl("vc-voice-filters-card", isOpen && "vc-voice-filters-card-open")} style={{ background: "var(--background-secondary)" }}> <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)}> <Card className="vc-voice-filters-card-title" onClick={() => setIsOpen(!isOpen)}>
<Text variant="heading-md/semibold">{title}</Text> <Text variant="heading-md/semibold">{title}</Text>
<ChevronIcon className="vc-voice-filters-card-icon" /> <ChevronIcon className="vc-voice-filters-card-icon" />
</Card> </Card>
<Markdown content={content} className="vc-voice-filters-details" /> <Markdown content={content} className="vc-voice-filters-details" />
</Card > </Card >
); );
} }

View file

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

View file

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

View file

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