diff options
Diffstat (limited to 'src/plugins')
-rw-r--r-- | src/plugins/callTimer.tsx | 16 | ||||
-rw-r--r-- | src/plugins/voiceMessages/DesktopRecorder.tsx | 68 | ||||
-rw-r--r-- | src/plugins/voiceMessages/VoicePreview.tsx | 57 | ||||
-rw-r--r-- | src/plugins/voiceMessages/WebRecorder.tsx | 87 | ||||
-rw-r--r-- | src/plugins/voiceMessages/index.tsx | 235 | ||||
-rw-r--r-- | src/plugins/voiceMessages/settings.ts | 33 | ||||
-rw-r--r-- | src/plugins/voiceMessages/styles.css | 54 | ||||
-rw-r--r-- | src/plugins/voiceMessages/utils.ts | 21 |
8 files changed, 560 insertions, 11 deletions
diff --git a/src/plugins/callTimer.tsx b/src/plugins/callTimer.tsx index 9490eba..2e0aa96 100644 --- a/src/plugins/callTimer.tsx +++ b/src/plugins/callTimer.tsx @@ -19,6 +19,7 @@ import { Settings } from "@api/Settings"; import ErrorBoundary from "@components/ErrorBoundary"; import { Devs } from "@utils/constants"; +import { useTimer } from "@utils/react"; import definePlugin, { OptionType } from "@utils/types"; import { React } from "@webpack/common"; @@ -85,17 +86,10 @@ export default definePlugin({ }, Timer({ channelId }: { channelId: string; }) { - const [time, setTime] = React.useState(0); - const startTime = React.useMemo(() => Date.now(), [channelId]); + const time = useTimer({ + deps: [channelId] + }); - React.useEffect(() => { - const interval = setInterval(() => setTime(Date.now() - startTime), 1000); - return () => { - clearInterval(interval); - setTime(0); - }; - }, [channelId]); - - return <p style={{ margin: 0 }}>Connected for {formatDuration(time)}</p>; + return <p style={{ margin: 0 }}>Connected for <span style={{ fontFamily: "var(--font-code)" }}>{formatDuration(time)}</span></p>; } }); diff --git a/src/plugins/voiceMessages/DesktopRecorder.tsx b/src/plugins/voiceMessages/DesktopRecorder.tsx new file mode 100644 index 0000000..176faf3 --- /dev/null +++ b/src/plugins/voiceMessages/DesktopRecorder.tsx @@ -0,0 +1,68 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { Button, showToast, Toasts, useState } from "@webpack/common"; + +import type { VoiceRecorder } from "."; +import { settings } from "./settings"; + +export const VoiceRecorderDesktop: VoiceRecorder = ({ setAudioBlob, onRecordingChange }) => { + const [recording, setRecording] = useState(false); + + const changeRecording = (recording: boolean) => { + setRecording(recording); + onRecordingChange?.(recording); + }; + + function toggleRecording() { + const discordVoice = DiscordNative.nativeModules.requireModule("discord_voice"); + const nowRecording = !recording; + + if (nowRecording) { + discordVoice.startLocalAudioRecording( + { + echoCancellation: settings.store.echoCancellation, + noiseCancellation: settings.store.noiseSuppression, + }, + (success: boolean) => { + if (success) + changeRecording(true); + else + showToast("Failed to start recording", Toasts.Type.FAILURE); + } + ); + } else { + discordVoice.stopLocalAudioRecording(async (filePath: string) => { + if (filePath) { + const buf = await VencordNative.pluginHelpers.VoiceMessages.readRecording(); + if (buf) + setAudioBlob(new Blob([buf], { type: "audio/ogg; codecs=opus" })); + else + showToast("Failed to finish recording", Toasts.Type.FAILURE); + } + changeRecording(false); + }); + } + } + + return ( + <Button onClick={toggleRecording}> + {recording ? "Stop" : "Start"} recording + </Button> + ); +}; diff --git a/src/plugins/voiceMessages/VoicePreview.tsx b/src/plugins/voiceMessages/VoicePreview.tsx new file mode 100644 index 0000000..70830a9 --- /dev/null +++ b/src/plugins/voiceMessages/VoicePreview.tsx @@ -0,0 +1,57 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { LazyComponent, useTimer } from "@utils/react"; +import { findByCode } from "@webpack"; + +import { cl } from "./utils"; + +interface VoiceMessageProps { + src: string; + waveform: string; +} +const VoiceMessage = LazyComponent<VoiceMessageProps>(() => findByCode('["onVolumeChange","volume","onMute"]')); + +export type VoicePreviewOptions = { + src?: string; + waveform: string; + recording?: boolean; +}; +export const VoicePreview = ({ + src, + waveform, + recording, +}: VoicePreviewOptions) => { + const durationMs = useTimer({ + deps: [recording] + }); + + const durationSeconds = recording ? Math.floor(durationMs / 1000) : 0; + const durationDisplay = Math.floor(durationSeconds / 60) + ":" + (durationSeconds % 60).toString().padStart(2, "0"); + + if (src && !recording) + return <VoiceMessage key={src} src={src} waveform={waveform} />; + + return ( + <div className={cl("preview", recording ? "preview-recording" : [])}> + <div className={cl("preview-indicator")} /> + <div className={cl("preview-time")}>{durationDisplay}</div> + <div className={cl("preview-label")}>{recording ? "RECORDING" : "----"}</div> + </div> + ); +}; diff --git a/src/plugins/voiceMessages/WebRecorder.tsx b/src/plugins/voiceMessages/WebRecorder.tsx new file mode 100644 index 0000000..423a269 --- /dev/null +++ b/src/plugins/voiceMessages/WebRecorder.tsx @@ -0,0 +1,87 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { Button, useState } from "@webpack/common"; + +import type { VoiceRecorder } from "."; +import { settings } from "./settings"; + +export const VoiceRecorderWeb: VoiceRecorder = ({ setAudioBlob, onRecordingChange }) => { + const [recording, setRecording] = useState(false); + const [paused, setPaused] = useState(false); + const [recorder, setRecorder] = useState<MediaRecorder>(); + const [chunks, setChunks] = useState<Blob[]>([]); + + const changeRecording = (recording: boolean) => { + setRecording(recording); + onRecordingChange?.(recording); + }; + + function toggleRecording() { + const nowRecording = !recording; + + if (nowRecording) { + navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: settings.store.echoCancellation, + noiseSuppression: settings.store.noiseSuppression, + } + }).then(stream => { + const chunks = [] as Blob[]; + setChunks(chunks); + + const recorder = new MediaRecorder(stream); + setRecorder(recorder); + recorder.addEventListener("dataavailable", e => { + chunks.push(e.data); + }); + recorder.start(); + + changeRecording(true); + }); + } else { + if (recorder) { + recorder.addEventListener("stop", () => { + setAudioBlob(new Blob(chunks, { type: "audio/ogg; codecs=opus" })); + + changeRecording(false); + }); + recorder.stop(); + } + } + } + + return ( + <> + <Button onClick={toggleRecording}> + {recording ? "Stop" : "Start"} recording + </Button> + + <Button + disabled={!recording} + onClick={() => { + setPaused(!paused); + if (paused) recorder?.resume(); + else recorder?.pause(); + }} + > + {paused ? "Resume" : "Pause"} recording + </Button> + </> + ); +}; diff --git a/src/plugins/voiceMessages/index.tsx b/src/plugins/voiceMessages/index.tsx new file mode 100644 index 0000000..be5a3f3 --- /dev/null +++ b/src/plugins/voiceMessages/index.tsx @@ -0,0 +1,235 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import "./styles.css"; + +import { addContextMenuPatch, NavContextMenuPatchCallback, removeContextMenuPatch } from "@api/ContextMenu"; +import { Flex } from "@components/Flex"; +import { Microphone } from "@components/Icons"; +import { Devs } from "@utils/constants"; +import { ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, openModal } from "@utils/modal"; +import { useAwaiter } from "@utils/react"; +import definePlugin from "@utils/types"; +import { chooseFile } from "@utils/web"; +import { findLazy } from "@webpack"; +import { Button, Forms, Menu, PermissionsBits, PermissionStore, RestAPI, SelectedChannelStore, showToast, SnowflakeUtils, Toasts, useEffect, useState } from "@webpack/common"; +import { ComponentType } from "react"; + +import { VoiceRecorderDesktop } from "./DesktopRecorder"; +import { settings } from "./settings"; +import { cl } from "./utils"; +import { VoicePreview } from "./VoicePreview"; +import { VoiceRecorderWeb } from "./WebRecorder"; + +const CloudUpload = findLazy(m => m.prototype?.uploadFileToCloud); + +export type VoiceRecorder = ComponentType<{ + setAudioBlob(blob: Blob): void; + onRecordingChange?(recording: boolean): void; +}>; + +const VoiceRecorder = IS_DISCORD_DESKTOP ? VoiceRecorderDesktop : VoiceRecorderWeb; + +export default definePlugin({ + name: "VoiceMessages", + description: "Allows you to send voice messages like on mobile. To do so, right click the upload button and click Send Voice Message", + authors: [Devs.Ven, Devs.Vap], + settings, + + start() { + addContextMenuPatch("channel-attach", ctxMenuPatch); + }, + + stop() { + removeContextMenuPatch("channel-attach", ctxMenuPatch); + } +}); + +type AudioMetadata = { + waveform: string, + duration: number, +}; +const EMPTY_META: AudioMetadata = { + waveform: "AAAAAAAAAAAA", + duration: 1, +}; + +function sendAudio(blob: Blob, meta: AudioMetadata) { + const channelId = SelectedChannelStore.getChannelId(); + + const upload = new CloudUpload({ + file: new File([blob], "voice-message.ogg", { type: "audio/ogg; codecs=opus" }), + isClip: false, + isThumbnail: false, + platform: 1, + }, channelId, false, 0); + + upload.on("complete", () => { + RestAPI.post({ + url: `/channels/${channelId}/messages`, + body: { + flags: 1 << 13, + channel_id: channelId, + content: "", + nonce: SnowflakeUtils.fromTimestamp(Date.now()), + sticker_ids: [], + type: 0, + attachments: [{ + id: "0", + filename: upload.filename, + uploaded_filename: upload.uploadedFilename, + waveform: meta.waveform, + duration_secs: meta.duration, + }] + } + }); + }); + upload.on("error", () => showToast("Failed to upload voice message", Toasts.Type.FAILURE)); + + upload.upload(); +} + +function useObjectUrl() { + const [url, setUrl] = useState<string>(); + const setWithFree = (blob: Blob) => { + if (url) + URL.revokeObjectURL(url); + setUrl(URL.createObjectURL(blob)); + }; + + return [url, setWithFree] as const; +} + +function Modal({ modalProps }: { modalProps: ModalProps; }) { + const [isRecording, setRecording] = useState(false); + const [blob, setBlob] = useState<Blob>(); + const [blobUrl, setBlobUrl] = useObjectUrl(); + + useEffect(() => () => { + if (blobUrl) + URL.revokeObjectURL(blobUrl); + }, [blobUrl]); + + const [meta] = useAwaiter(async () => { + if (!blob) return EMPTY_META; + + const audioContext = new AudioContext(); + const audioBuffer = await audioContext.decodeAudioData(await blob.arrayBuffer()); + const channelData = audioBuffer.getChannelData(0); + + // average the samples into much lower resolution bins, maximum of 256 total bins + const bins = new Uint8Array(window._.clamp(Math.floor(audioBuffer.duration * 10), Math.min(32, channelData.length), 256)); + const samplesPerBin = Math.floor(channelData.length / bins.length); + + // Get root mean square of each bin + for (let binIdx = 0; binIdx < bins.length; binIdx++) { + let squares = 0; + for (let sampleOffset = 0; sampleOffset < samplesPerBin; sampleOffset++) { + const sampleIdx = binIdx * samplesPerBin + sampleOffset; + squares += channelData[sampleIdx] ** 2; + } + bins[binIdx] = ~~(Math.sqrt(squares / samplesPerBin) * 0xFF); + } + + // Normalize bins with easing + const maxBin = Math.max(...bins); + const ratio = 1 + (0xFF / maxBin - 1) * Math.min(1, 100 * (maxBin / 0xFF) ** 3); + for (let i = 0; i < bins.length; i++) bins[i] = Math.min(0xFF, ~~(bins[i] * ratio)); + + return { + waveform: window.btoa(String.fromCharCode(...bins)), + duration: audioBuffer.duration, + }; + }, { + deps: [blob], + fallbackValue: EMPTY_META, + }); + + return ( + <ModalRoot {...modalProps}> + <ModalHeader> + <Forms.FormTitle>Record Voice Message</Forms.FormTitle> + </ModalHeader> + + <ModalContent className={cl("modal")}> + <div className={cl("buttons")}> + <VoiceRecorder + setAudioBlob={blob => { + setBlob(blob); + setBlobUrl(blob); + }} + onRecordingChange={setRecording} + /> + + <Button + onClick={async () => { + const file = await chooseFile("audio/*"); + if (file) { + setBlob(file); + setBlobUrl(file); + } + }} + > + Upload File + </Button> + </div> + + <Forms.FormTitle>Preview</Forms.FormTitle> + <VoicePreview + src={blobUrl} + waveform={meta.waveform} + recording={isRecording} + /> + + </ModalContent> + + <ModalFooter> + <Button + disabled={!blob} + onClick={() => { + sendAudio(blob!, meta); + modalProps.onClose(); + showToast("Now sending voice message... Please be patient", Toasts.Type.MESSAGE); + }} + > + Send + </Button> + </ModalFooter> + </ModalRoot> + ); +} + +const ctxMenuPatch: NavContextMenuPatchCallback = (children, props) => () => { + if (props.channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_VOICE_MESSAGES, props.channel)) return; + + children.push( + <Menu.MenuItem + id="vc-send-vmsg" + label={ + <> + <Flex flexDirection="row" style={{ alignItems: "center", gap: 8 }}> + <Microphone height={24} width={24} /> + Send voice message + </Flex> + </> + } + action={() => openModal(modalProps => <Modal modalProps={modalProps} />)} + /> + ); +}; + diff --git a/src/plugins/voiceMessages/settings.ts b/src/plugins/voiceMessages/settings.ts new file mode 100644 index 0000000..7e34817 --- /dev/null +++ b/src/plugins/voiceMessages/settings.ts @@ -0,0 +1,33 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; + +export const settings = definePluginSettings({ + noiseSuppression: { + type: OptionType.BOOLEAN, + description: "Noise Suppression", + default: true, + }, + echoCancellation: { + type: OptionType.BOOLEAN, + description: "Echo Cancellation", + default: true, + }, +}); diff --git a/src/plugins/voiceMessages/styles.css b/src/plugins/voiceMessages/styles.css new file mode 100644 index 0000000..1e2b143 --- /dev/null +++ b/src/plugins/voiceMessages/styles.css @@ -0,0 +1,54 @@ +.vc-vmsg-modal { + padding: 1em; +} + +.vc-vmsg-buttons { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5em; + margin-bottom: 1em; +} + +.vc-vmsg-modal audio { + width: 100%; +} + +.vc-vmsg-preview { + color: var(--text-normal); + border-radius: 24px; + background-color: var(--background-secondary); + position: relative; + display: flex; + align-items: center; + padding: 0 16px; + height: 48px; +} + +.vc-vmsg-preview-indicator { + background: var(--button-secondary-background); + width: 16px; + height: 16px; + border-radius: 50%; + transition: background 0.2s ease-in-out; +} + +.vc-vmsg-preview-recording .vc-vmsg-preview-indicator { + background: var(--status-danger); +} + +.vc-vmsg-preview-time { + opacity: 0.8; + margin: 0 0.5em; + font-size: 80%; + + /* monospace so different digits have same size */ + font-family: var(--font-code); +} + +.vc-vmsg-preview-label { + opacity: 0.5; + letter-spacing: 0.125em; + font-weight: 600; + flex: 1; + text-align: center; +} diff --git a/src/plugins/voiceMessages/utils.ts b/src/plugins/voiceMessages/utils.ts new file mode 100644 index 0000000..dcfd6f2 --- /dev/null +++ b/src/plugins/voiceMessages/utils.ts @@ -0,0 +1,21 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { classNameFactory } from "@api/Styles"; + +export const cl = classNameFactory("vc-vmsg-"); |