diff --git a/packages/discord-types/src/components.d.ts b/packages/discord-types/src/components.d.ts index 76e03a9187a..e6f3e7bd8b0 100644 --- a/packages/discord-types/src/components.d.ts +++ b/packages/discord-types/src/components.d.ts @@ -1,4 +1,5 @@ import type { ComponentClass, ComponentPropsWithRef, ComponentType, CSSProperties, FunctionComponent, HtmlHTMLAttributes, HTMLProps, JSX, KeyboardEvent, MouseEvent, PointerEvent, PropsWithChildren, ReactNode, Ref, RefObject } from "react"; +import { GlobalShortcut } from "./utils"; // #region Old compability @@ -513,3 +514,9 @@ export type ColorPicker = ComponentType<{ label?: ReactNode; onChange(value: number | null): void; }>; + +export type GlobalKeybind = ComponentType<{ + defaultValue: GlobalShortcut; + disabled: boolean; + onChange(value: GlobalShortcut): void; +}>; diff --git a/packages/discord-types/src/utils.d.ts b/packages/discord-types/src/utils.d.ts index 14b8fa507e1..507d5cbc3d8 100644 --- a/packages/discord-types/src/utils.d.ts +++ b/packages/discord-types/src/utils.d.ts @@ -335,3 +335,33 @@ export interface CommandOptions { max_value?: number; autocomplete?: boolean; } + +// Global shortcut types +export enum GlobalShortcutKeyOS { + WINDOWS = 1, + MACOS = 2, + LINUX = 3, + BROWSER = 4 +} +export enum GlobalShortcutKeyType { + KEYBOARD_KEY = 0, + MOUSE_BUTTON = 1, + KEYBOARD_MODIFIER_KEY = 2, + GAMEPAD_BUTTON = 3 +} +export type GlobalShortcutKey = [GlobalShortcutKeyType, number] | [GlobalShortcutKeyType, number, GlobalShortcutKeyOS | `${number}:${number}`]; +export type GlobalShortcut = GlobalShortcutKey[]; +export type GlobalShortcutOptions = { + blurred: boolean; + focused: boolean; + keydown: boolean; + keyup: boolean; +}; + +export type DiscordUtils = { + inputCaptureRegisterElement(elementId: string, callback: (keys: GlobalShortcut) => void): () => void; + inputGetRegisteredEvents(callback: (keys: GlobalShortcut) => void): undefined; + inputEventRegister(id: number, keys: GlobalShortcut, callback: () => void, options: GlobalShortcutOptions): undefined; + inputEventUnregister(id: number): undefined; + inputSetFocused(focused: boolean): undefined; +}; diff --git a/src/api/Keybinds/globalManager.ts b/src/api/Keybinds/globalManager.ts new file mode 100644 index 00000000000..1c596265350 --- /dev/null +++ b/src/api/Keybinds/globalManager.ts @@ -0,0 +1,79 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { DiscordUtils, GlobalShortcut, GlobalShortcutOptions } from "@vencord/discord-types"; +import { findByCodeLazy } from "webpack"; + +import { KeybindManager } from "./types"; + +// Discord mapping from keycodes array to string (mouse, keyboard, gamepad) +const keycodesToString = IS_DISCORD_DESKTOP ? findByCodeLazy(".map(", ".KEYBOARD_KEY", ".KEYBOARD_MODIFIER_KEY", ".MOUSE_BUTTON", ".GAMEPAD_BUTTON") as (keys: GlobalShortcut) => string : (keys: GlobalShortcut) => keys.join("+"); + +export default new class GlobalManager implements KeybindManager { + private discordUtils: undefined | DiscordUtils; // TODO: Maybe check if IS_VESKTOP and use its global keybinds api + private lastGlobalId: number = 1000; + private mapIdToEvent: Map = new Map(); + + private initDiscordUtils() { + if (!IS_DISCORD_DESKTOP || this.discordUtils || !DiscordNative) return; + this.discordUtils = DiscordNative.nativeModules.requireModule("discord_utils"); + } + + // From discord key registration + private newKeysInstance(keys: GlobalShortcut): GlobalShortcut { + return keys.map(e => { + const [t, n, r] = e; + return typeof r === "string" ? [t, n, r] : [t, n]; + }); + } + + private getIdForEvent(event: string): number { + const found = this.mapIdToEvent.get(event); + if (!found) { + const id = this.lastGlobalId++; + this.mapIdToEvent.set(event, id); + return id; + } else { + return found; + } + } + + public isAvailable() { + this.initDiscordUtils(); + return !!this.discordUtils; + } + + public getDiscordUtils() { + this.initDiscordUtils(); + return this.discordUtils; + } + + public registerKeybind(event: string, keys: GlobalShortcut, callback: () => void, options: GlobalShortcutOptions) { + this.initDiscordUtils(); + if (!this.discordUtils) return; + const id = this.getIdForEvent(event); + if (!id) return; + this.discordUtils.inputEventRegister(id, this.newKeysInstance(keys), callback, options); + } + + public unregisterKeybind(event: string) { + this.initDiscordUtils(); + if (!this.discordUtils) return; + const id = this.mapIdToEvent.get(event); + if (!id) return; + this.discordUtils.inputEventUnregister(id); + } + + public inputCaptureKeys(inputId: string, callback: (keys: GlobalShortcut) => void): () => void { + this.initDiscordUtils(); + if (!this.discordUtils) return () => { }; + return this.discordUtils.inputCaptureRegisterElement(inputId, callback); + } + + public keysToString(keys: GlobalShortcut): string { + return keycodesToString(keys).toUpperCase(); + } +}; diff --git a/src/api/Keybinds/index.ts b/src/api/Keybinds/index.ts new file mode 100644 index 00000000000..5c8a89af880 --- /dev/null +++ b/src/api/Keybinds/index.ts @@ -0,0 +1,9 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import keybindsManager from "./keybindsManager"; + +export { keybindsManager }; diff --git a/src/api/Keybinds/keybindsManager.ts b/src/api/Keybinds/keybindsManager.ts new file mode 100644 index 00000000000..f2b54b84a93 --- /dev/null +++ b/src/api/Keybinds/keybindsManager.ts @@ -0,0 +1,94 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { GlobalShortcut, GlobalShortcutOptions } from "@vencord/discord-types"; + +import globalManager from "./globalManager"; +import { InternalKeybind, Keybind, KeybindShortcut, WindowShortcut } from "./types"; +import windowManager from "./windowManager"; + +export default new class KeybindsManager { + private keybindsGlobal: Map = new Map(); + private keybindsWindow: Map = new Map(); + + isAvailable(global: boolean) { + return global ? globalManager.isAvailable() : windowManager.isAvailable(); + } + + inputCaptureKeys(inputId: string, callback: (keys: KeybindShortcut) => void, global: boolean) { + return global ? globalManager.inputCaptureKeys(inputId, callback) : windowManager.inputCaptureKeys(inputId, callback); + } + + keysToString(keys: KeybindShortcut, global: boolean): string { + return global ? globalManager.keysToString(keys as GlobalShortcut) : windowManager.keysToString(keys as WindowShortcut); + } + + private getBinding(event: string, global: boolean): InternalKeybind | undefined { + return global ? this.keybindsGlobal.get(event) : this.keybindsWindow.get(event); + } + + private isEventAvailable(event: string, global: boolean): boolean { + return global ? !this.keybindsGlobal.has(event) : !this.keybindsWindow.has(event); + } + + registerKeybind(binding: Keybind, keys: KeybindShortcut = []) { + if (!this.isEventAvailable(binding.event, binding.global)) return false; + if (binding.global) { + this.keybindsGlobal.set(binding.event, { keys: (keys as GlobalShortcut), enabled: false, ...(binding as Keybind) }); + } else { + this.keybindsWindow.set(binding.event, { keys: (keys as WindowShortcut), enabled: false, ...(binding as Keybind) }); + } + return true; + } + + unregisterKeybind(event: string, global: boolean): boolean { + const binding = this.getBinding(event, global); + if (!binding) return false; + if (binding.enabled) { + this.disableKeybind(event, global); + } + return global ? this.keybindsGlobal.delete(event) : this.keybindsWindow.delete(event); + } + + updateKeybind(event: string, keys: KeybindShortcut, global: boolean) { + const binding = this.getBinding(event, global); + if (!binding) return; + binding.keys = keys; + if (binding.enabled) { + this.disableKeybind(event, global); + } + this.enableKeybind(event, global); + } + + enableKeybind(event: string, global: boolean) { + const binding = this.getBinding(event, global); + if (!binding) return; + if (binding.enabled || !binding.keys.length) return; + if (global) { + globalManager.registerKeybind(binding.event, binding.keys as GlobalShortcut, binding.function, binding.options as GlobalShortcutOptions); + } else { + windowManager.registerKeybind(binding.event, binding.keys as WindowShortcut, binding.function, binding.options); + } + binding.enabled = true; + } + + disableKeybind(event: string, global: boolean) { + if (global) { + if (!globalManager.isAvailable()) return; + const binding = this.getBinding(event, true); + if (!binding) return; + if (!binding.enabled) return; + globalManager.unregisterKeybind(binding.event); + binding.enabled = false; + } else { + const binding = this.getBinding(event, false); + if (!binding) return; + if (!binding.enabled) return; + windowManager.unregisterKeybind(binding.event); + binding.enabled = false; + } + } +}; diff --git a/src/api/Keybinds/types.d.ts b/src/api/Keybinds/types.d.ts new file mode 100644 index 00000000000..78ce9418333 --- /dev/null +++ b/src/api/Keybinds/types.d.ts @@ -0,0 +1,36 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { GlobalShortcut, GlobalShortcutOptions } from "@vencord/discord-types"; + +export type WindowShortcut = string[]; +export type WindowShortcutOptions = { + keydown: boolean; + keyup: boolean; +}; + +export type KeybindShortcut = GlobalShortcut | WindowShortcut; +export type KeybindOptions = GlobalShortcutOptions | WindowShortcutOptions; + +export type Keybind = { + event: string; + function: () => void; + options: KeybindOptions; + global: boolean; +}; + +export type InternalKeybind = Keybind & { + enabled: boolean; + keys: KeybindShortcut; +}; + +interface KeybindManager { + isAvailable(): boolean; + registerKeybind(event: string, keys: KeybindShortcut, callback: () => void, options: GlobalShortcutOptions): void; + unregisterKeybind(event: string): void; + inputCaptureKeys(inputId: string, callback: (keys: KeybindShortcut) => void): () => void; + keysToString(keys: KeybindShortcut): string; +} diff --git a/src/api/Keybinds/windowManager.ts b/src/api/Keybinds/windowManager.ts new file mode 100644 index 00000000000..a574829285d --- /dev/null +++ b/src/api/Keybinds/windowManager.ts @@ -0,0 +1,247 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { logger } from "@components/settings/tabs/plugins"; + +import { KeybindManager, WindowShortcut, WindowShortcutOptions } from "./types"; + +const keysDown = new Set(); +const keysUp = new Set(); + +window.addEventListener("keydown", (e: KeyboardEvent) => { + keysDown.add(e.key); + keysUp.delete(e.key); +}); +window.addEventListener("keyup", (e: KeyboardEvent) => { + keysUp.add(e.key); + keysDown.delete(e.key); +}); +window.addEventListener("mousedown", (e: MouseEvent) => { // TODO: find a way to dispatch mouse3 and mouse4 + keysDown.add("Mouse" + e.button); + keysUp.delete("Mouse" + e.button); +}); +window.addEventListener("mouseup", (e: MouseEvent) => { // TODO: find a way to dispatch mouse3 and mouse4 + keysUp.add("Mouse" + e.button); + keysDown.delete("Mouse" + e.button); +}); + +/* let gamepadIndex: number | null = null; // TODO: find a way to dispatch gamepad clicks, atm this not works +let polling = false; +const buttonStates: boolean[] = []; + +function pollGamepad() { + if (!polling || gamepadIndex === null) return; + + const gamepads = navigator.getGamepads(); + const gp = gamepads && gamepads[gamepadIndex]; + + if (!gp) { + requestAnimationFrame(pollGamepad); + return; + } + + if (buttonStates.length !== gp.buttons.length) { + for (let i = 0; i < gp.buttons.length; i++) { + buttonStates[i] = gp.buttons[i].pressed; + } + } + for (let i = 0; i < gp.buttons.length; i++) { + if (gp.buttons[i].pressed !== buttonStates[i]) { + document.dispatchEvent(new KeyboardEvent(gp.buttons[i].pressed ? "keydown" : "keyup", { + key: "Gamepad" + i, + code: "Gamepad" + i, + bubbles: true, + cancelable: true, + repeat: false, + }) + ); + buttonStates[i] = gp.buttons[i].pressed; + } + } + requestAnimationFrame(pollGamepad); +} + +window.addEventListener("gamepadconnected", (e: GamepadEvent) => { + if (gamepadIndex === null) { + gamepadIndex = e.gamepad.index; + const gp = navigator.getGamepads()[gamepadIndex]; + if (gp) { + for (let i = 0; i < gp.buttons.length; i++) { + buttonStates[i] = gp.buttons[i].pressed; + } + } + polling = true; + requestAnimationFrame(pollGamepad); + } +}); + +window.addEventListener("gamepaddisconnected", e => { + if (e.gamepad.index === gamepadIndex) { + gamepadIndex = null; + polling = false; + } +}, false); */ + + +type EventKeyChecks = { + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + keys: string[]; +}; + +export default new class WindowManager implements KeybindManager { + private mapCallbacks: Map void> = new Map(); + private getKeyChecks(keys: WindowShortcut): EventKeyChecks { + return { + ctrl: keys.includes("Control"), + shift: keys.includes("Shift"), + alt: keys.includes("Alt"), + meta: keys.includes("Meta"), + keys: keys.filter(key => !["Control", "Shift", "Alt", "Meta"].includes(key)) + }; + } + + isAvailable(): boolean { + return !!window; + } + + registerKeybind(event: string, keys: WindowShortcut, callback: () => void, options: WindowShortcutOptions): boolean { + if (this.mapCallbacks.has(event)) return false; + const keysToCheck = this.getKeyChecks(keys); + const types: Set = new Set(); + for (const key of keys) { + if (key.startsWith("Mouse")) { + types.add("mouse"); + } else if (key.startsWith("Gamepad")) { + types.add("gamepad"); + } else { + types.add("keyboard"); + } + } + const checkKeys = (event: KeyboardEvent | MouseEvent) => { // TODO: check for gamepad buttons + let { keydown } = options; + let { keyup } = options; + if (keysToCheck.alt === event.altKey && keysToCheck.ctrl === event.ctrlKey && keysToCheck.shift === event.shiftKey && keysToCheck.meta === event.metaKey) { + for (const key of keysToCheck.keys) { + if (options.keydown && !keysDown.has(key)) keydown = false; + if (options.keyup && !keysUp.has(key)) keyup = false; + } + if (keydown) callback(); + if (keyup) callback(); + } + }; + for (const type of types) { + if (type === "mouse") { + if (options.keydown) window.addEventListener("mousedown", checkKeys); + if (options.keyup) window.addEventListener("mouseup", checkKeys); + } else if (type === "gamepad") { + // TODO: implement gamepad support + } else { + if (options.keydown) window.addEventListener("keydown", checkKeys); + if (options.keyup) window.addEventListener("keyup", checkKeys); + } + } + this.mapCallbacks.set(event, checkKeys); + return true; + } + + unregisterKeybind(event: string): boolean { + if (!this.mapCallbacks.has(event)) return false; + const checkKeys = this.mapCallbacks.get(event)!; + window.removeEventListener("keydown", checkKeys); + window.removeEventListener("keyup", checkKeys); + window.removeEventListener("mousedown", checkKeys); + window.removeEventListener("mouseup", checkKeys); + this.mapCallbacks.delete(event); + return true; + } + + inputCaptureKeys( + inputId: string, + callback: (keys: WindowShortcut) => void + ) { + const keys: string[] = []; + const inputElement = document.getElementById(inputId) as HTMLInputElement; + let timeout: NodeJS.Timeout | undefined = undefined; + + const startRecording = () => { + inputElement.addEventListener("keydown", keydownHandler, { capture: true }); + inputElement.addEventListener("keyup", keyupHandler, { capture: true }); + inputElement.addEventListener("mousedown", keydownHandler, { capture: true }); + inputElement.addEventListener("mouseup", keyupHandler, { capture: true }); + }; + const stopRecording = () => { + stopTimeout(); + inputElement.removeEventListener("keydown", keydownHandler, { capture: true }); + inputElement.removeEventListener("keyup", keyupHandler, { capture: true }); + inputElement.removeEventListener("mousedown", keydownHandler, { capture: true }); + inputElement.removeEventListener("mouseup", keyupHandler, { capture: true }); + }; + + const startTimeout = () => { + timeout = setTimeout(() => { + invokeCallback([...keys]); + keys.length = 0; + }, 5 * 1000); + }; + const stopTimeout = () => { + clearTimeout(timeout); + keys.length = 0; + }; + + const keydownHandler = (event: KeyboardEvent | MouseEvent) => { // TODO: add gamepad detection + event.stopImmediatePropagation(); + event.stopPropagation(); + event.preventDefault(); + + if (event.type === "keydown") { + const e = event as KeyboardEvent; + if (e.repeat || keys.includes(e.key)) return; + keys.push(e.key); + } + if (event.type === "mousedown") { + const e = event as MouseEvent; + keys.push("Mouse" + e.button); + } + + if (keys.length === 4) { // Max 4 keys + invokeCallback([...keys]); + stopRecording(); + keys.length = 0; + } + }; + const keyupHandler = (event: KeyboardEvent | MouseEvent) => { // TODO: add gamepad detection + event.stopImmediatePropagation(); + event.stopPropagation(); + event.preventDefault(); + if (event.type === "keyup" && (event as KeyboardEvent).key === keys[keys.length - 1]) { + invokeCallback([...keys]); + } + if (event.type === "mouseup" && "Mouse" + (event as MouseEvent).button === keys[keys.length - 1]) { + invokeCallback([...keys]); + } + }; + const invokeCallback = (keys: WindowShortcut) => { + try { + callback(keys); + } catch (error) { + logger.error("Error in callback:", error); + } + }; + + inputElement.addEventListener("focus", () => startTimeout()); + inputElement.addEventListener("blur", () => stopTimeout()); + startRecording(); + + return stopRecording; + } + + keysToString(keys: WindowShortcut): string { + return keys.map(key => key === " " ? "SPACE" : key.toUpperCase()).join("+"); + } +}; diff --git a/src/components/settings/tabs/plugins/components/KeybindSetting.css b/src/components/settings/tabs/plugins/components/KeybindSetting.css new file mode 100644 index 00000000000..5cde8537a01 --- /dev/null +++ b/src/components/settings/tabs/plugins/components/KeybindSetting.css @@ -0,0 +1,20 @@ +.vc-plugins-setting-keybind-layout { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: .5em; +} + +.vc-plugins-setting-keybind-layout .vc-plugins-setting-keybind-icon > svg.disabled { + opacity: .3; +} + +.vc-plugins-setting-keybind-layout .vc-plugins-setting-keybind-discord > div > div { + position: unset; +} + +.vc-plugins-setting-keybind-layout .vc-plugins-setting-keybind-discord > div > div > input:focus { + border-radius: var(--radius-sm); + background-color: rgb(255 255 255 / 5%) !important; +} \ No newline at end of file diff --git a/src/components/settings/tabs/plugins/components/KeybindSetting.tsx b/src/components/settings/tabs/plugins/components/KeybindSetting.tsx new file mode 100644 index 00000000000..c25187fb793 --- /dev/null +++ b/src/components/settings/tabs/plugins/components/KeybindSetting.tsx @@ -0,0 +1,200 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 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 . +*/ + +import "./KeybindSetting.css"; + +import keybindsManager from "@api/Keybinds/keybindsManager"; +import { KeybindShortcut } from "@api/Keybinds/types"; +import { classNameFactory } from "@api/Styles"; +import { ScreenshareIcon, WebsiteIcon } from "@components/Icons"; +import { Switch } from "@components/settings"; +import { classes } from "@utils/index"; +import { OptionType, PluginOptionKeybind } from "@utils/types"; +import { findByPropsLazy } from "@webpack"; +import { React, Text, Tooltip, useEffect, useRef, useState } from "@webpack/common"; + +import { SettingProps, SettingsSection } from "./Common"; + +const ButtonClasses = findByPropsLazy("button", "sm", "secondary", "hasText", "buttonChildrenWrapper"); +const FlexClasses = findByPropsLazy("flex", "horizontalReverse"); +const ContainersClasses = findByPropsLazy("buttonContainer", "recorderContainer"); +const RecorderClasses = findByPropsLazy("recorderContainer", "keybindInput"); + +export const cl = classNameFactory("vc-plugins-setting-keybind"); + +export function KeybindSetting({ option, pluginSettings, definedSettings, id, onChange }: SettingProps) { + const inputId = "vc-key-recorder-" + id; + const { global } = option; + const available = (global ? IS_DISCORD_DESKTOP : window) && keybindsManager.isAvailable(global); + + const [state, setState] = useState(pluginSettings[id] ?? option.default ?? []); + const [enabled, setEnabled] = useState(state.length > 0); + const [error, setError] = useState(global && !IS_DISCORD_DESKTOP ? "Global keybinds are only available in the desktop app." : null); + + function handleChange(newValue: KeybindShortcut) { + if (!available) return; + const isValid = option.isValid?.call(definedSettings, newValue) ?? true; + if (option.type === OptionType.KEYBIND && newValue && isValid) { + setError(null); + keybindsManager.updateKeybind(id, newValue, global); + setState(newValue); + onChange(newValue); + } else { + setError("Invalid keybind format"); + } + } + + function toggleEnabled(enabled: boolean) { + if (!available) return; + toggleKeybind(enabled); + setEnabled(enabled); + } + + function toggleKeybind(enabled: boolean) { + if (enabled) { + keybindsManager.enableKeybind(id, global); + } else { + keybindsManager.disableKeybind(id, global); + clearKeybind(); + } + } + + function clearKeybind() { + keybindsManager.updateKeybind(id, [], global); + handleChange([]); + } + + return ( + +
+ + {({ onMouseEnter, onMouseLeave }) => ( +
+ {global + ? + : + } +
+ )} +
+ + {({ onMouseEnter, onMouseLeave }) => ( +
+ +
+ )} +
+ + {({ onMouseEnter, onMouseLeave }) => ( +
+ +
+ )} +
+
+
+ ); +} + +function KeybindInput({ id, defaultKeys, global, onChange, disabled }: { + id: string; + defaultKeys: KeybindShortcut; + global: boolean; + onChange: (value: KeybindShortcut) => void; + disabled: boolean; +}) { + const [recording, setRecording] = useState(false); + const stopCapture = useRef<() => void | undefined>(undefined); + + useEffect(() => { + return () => { + if (stopCapture.current) { + stopCapture.current(); + stopCapture.current = undefined; + } + }; + }, []); + + function updateRecording(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + if (!recording) { + startRecording(); + } else { + stopRecording(); + } + } + + function handleKeybindCapture(keys: KeybindShortcut) { + stopRecording(); + if (keys.length) { + onChange(keys); + } + } + + function startRecording() { + setRecording(true); + if (!stopCapture.current) { + stopCapture.current = keybindsManager.inputCaptureKeys(id, handleKeybindCapture, global); + } + } + + function stopRecording() { + setRecording(false); + } + + return ( +
+
+ +
+ +
+
+
+ ); +} + +function FocusedInput({ id, onBlur, recording, disabled, value }) { + const inputRef = useRef(null); + useEffect(() => { + if (recording) { + inputRef.current?.focus(); + } else { + inputRef.current?.blur(); + } + }, [recording]); + + return ( + + ); +} diff --git a/src/components/settings/tabs/plugins/components/index.ts b/src/components/settings/tabs/plugins/components/index.ts index 0f1dd40cc17..549acd178a7 100644 --- a/src/components/settings/tabs/plugins/components/index.ts +++ b/src/components/settings/tabs/plugins/components/index.ts @@ -24,6 +24,7 @@ import { ComponentType } from "react"; import { BooleanSetting } from "./BooleanSetting"; import { ComponentSettingProps, SettingProps } from "./Common"; import { ComponentSetting } from "./ComponentSetting"; +import { KeybindSetting } from "./KeybindSetting"; import { NumberSetting } from "./NumberSetting"; import { SelectSetting } from "./SelectSetting"; import { SliderSetting } from "./SliderSetting"; @@ -36,6 +37,7 @@ export const OptionComponentMap: Record null, }; diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 5bb60a13aab..4312f3ef1c7 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -20,6 +20,7 @@ import { addProfileBadge, removeProfileBadge } from "@api/Badges"; import { addChatBarButton, removeChatBarButton } from "@api/ChatButtons"; import { registerCommand, unregisterCommand } from "@api/Commands"; import { addContextMenuPatch, removeContextMenuPatch } from "@api/ContextMenu"; +import keybindsManager from "@api/Keybinds/keybindsManager"; import { addMemberListDecorator, removeMemberListDecorator } from "@api/MemberListDecorators"; import { addMessageAccessory, removeMessageAccessory } from "@api/MessageAccessories"; import { addMessageDecoration, removeMessageDecoration } from "@api/MessageDecorations"; @@ -258,7 +259,7 @@ export function subscribeAllPluginsFluxEvents(fluxDispatcher: typeof FluxDispatc export const startPlugin = traceFunction("startPlugin", function startPlugin(p: Plugin) { const { - name, commands, contextMenus, managedStyle, userProfileBadge, + name, commands, keybinds, contextMenus, managedStyle, userProfileBadge, onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, renderChatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton } = p; @@ -291,6 +292,29 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p: } } + if (keybinds && Object.keys(keybinds).length) { + logger.debug("Registering keybinds of plugin", name); + let warned = false; + for (const keybind of keybinds) { + try { + if (!IS_DISCORD_DESKTOP && keybind.global) { // TODO: maybe check for IS_VESKTOP + if (!warned) { + logger.warn(`${name}: Global keybinds are only supported on desktop`); + warned = true; + } + continue; + } + const keys = settings[name]?.[keybind.event] ?? []; + if (keybindsManager.registerKeybind(keybind, keys)) { + keybindsManager.enableKeybind(keybind.event, keybind.global); + } + } catch (e) { + logger.error(`Failed to register keybind ${keybind.event}\n`, e); + return false; + } + } + } + if (enabledPluginsSubscribedFlux) { subscribePluginFluxEvents(p, FluxDispatcher); } @@ -321,7 +345,7 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p: export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plugin) { const { - name, commands, contextMenus, managedStyle, userProfileBadge, + name, commands, keybinds, contextMenus, managedStyle, userProfileBadge, onBeforeMessageEdit, onBeforeMessageSend, onMessageClick, renderChatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton } = p; @@ -354,6 +378,21 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu } } + if (keybinds?.length) { + logger.debug("Unregistering keybinds of plugin", name); + for (const keybind of keybinds) { + try { + if (!IS_DISCORD_DESKTOP && keybind.global) { // TODO: maybe check for IS_VESKTOP + continue; + } + keybindsManager.unregisterKeybind(keybind.event, keybind.global); + } catch (e) { + logger.error(`Failed to unregister keybind ${keybind.event}\n`, e); + return false; + } + } + } + unsubscribePluginFluxEvents(p, FluxDispatcher); if (contextMenus) { diff --git a/src/plugins/shortcutScreenshareScreen/README.md b/src/plugins/shortcutScreenshareScreen/README.md new file mode 100644 index 00000000000..f606c390d62 --- /dev/null +++ b/src/plugins/shortcutScreenshareScreen/README.md @@ -0,0 +1,18 @@ +# ShortcutScreenshareScreen for Vencord + +This is a porting of the original BetterDiscord(BD) plugin [ShortcutScreenshareScreen](https://github.com/nicola02nb/BetterDiscord-Stuff/tree/main/Plugins/ShortcutScreenshareScreen). + +A Vencord(VC) plugin that let you screenshare screen from keyboard shortcut when no game is running. + +## Features: + +- Select which screen should be screen shared +- Set shortcut to Start/Stop screen sharing +- Set shortcut to toggle Game/Screen sharing +- Set shortcut to toggle On/Off audio sharing +- Set shortcut to start stream +- Set shortcut to stop stream +- Enable/Disable Preview +- Enable/Disable Audio sharing +- Always share the screen instead of a game +- Enable/Disable Showing toast diff --git a/src/plugins/shortcutScreenshareScreen/index.ts b/src/plugins/shortcutScreenshareScreen/index.ts new file mode 100644 index 00000000000..5599ced3750 --- /dev/null +++ b/src/plugins/shortcutScreenshareScreen/index.ts @@ -0,0 +1,30 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Devs } from "@utils/constants"; +import definePlugin from "@utils/types"; + +import { settings } from "./settings"; +import { startStreaming, stopStreaming, toggleAudio, toggleGameOrScreen, toggleStream } from "./streamManager"; + +export default definePlugin({ + name: "ShortcutScreenShare", + description: "Screenshare screen from keyboard shortcut when no game is running.", + authors: [Devs.nicola02nb], + settings, + keybinds: [ + { event: "testKeybind", global: false, function: () => console.log("Test keybind pressed!"), options: { keydown: true, keyup: false } }, + { event: "startStreaming", global: true, function: startStreaming, options: { blurred: false, focused: false, keydown: true, keyup: false } }, + { event: "stopStreaming", global: true, function: stopStreaming, options: { blurred: false, focused: false, keydown: true, keyup: false } }, + { event: "toggleAudio", global: true, function: toggleAudio, options: { blurred: false, focused: false, keydown: true, keyup: false } }, + { event: "toggleStream", global: true, function: toggleStream, options: { blurred: false, focused: false, keydown: true, keyup: false } }, + { event: "toggleGameOrScreen", global: true, function: toggleGameOrScreen, options: { blurred: false, focused: true, keydown: true, keyup: false } } + ], + start: () => { + }, + stop: () => { + } +}); diff --git a/src/plugins/shortcutScreenshareScreen/settings.ts b/src/plugins/shortcutScreenshareScreen/settings.ts new file mode 100644 index 00000000000..6fcb8ccdf4a --- /dev/null +++ b/src/plugins/shortcutScreenshareScreen/settings.ts @@ -0,0 +1,75 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; + +import { updateStream } from "./streamManager"; + + +export type ShikiSettings = typeof settings.store; +export const settings = definePluginSettings({ + testKeybind: { + type: OptionType.KEYBIND, + description: "Set the shortcut to test the keybind.", + global: false, + onChange: keys => console.log( + "Test keybind changed to:", keys + ) + }, + displayNumber: { + type: OptionType.NUMBER, + description: "Default themes", + default: 1 + }, + toggleStream: { + type: OptionType.KEYBIND, + description: "Set the shortcut to toggle the stream.", + global: true + }, + toggleGameOrScreen: { + type: OptionType.KEYBIND, + description: "Set the shortcut to toggle the game or screen.", + global: true + }, + toggleAudio: { + type: OptionType.KEYBIND, + description: "Set the shortcut to toggle the audio.", + global: true + }, + startStreaming: { + type: OptionType.KEYBIND, + description: "Set the shortcut to start the stream.", + global: true + }, + stopStreaming: { + type: OptionType.KEYBIND, + description: "Set the shortcut to stop the stream.", + global: true + }, + disablePreview: { + type: OptionType.BOOLEAN, + description: "If enabled, the preview will be disabled.", + default: false, + onChange: updateStream + }, + shareAudio: { + type: OptionType.BOOLEAN, + description: "If enabled, audio will be shared.", + default: true, + onChange: updateStream + }, + shareAlwaysScreen: { + type: OptionType.BOOLEAN, + description: "If enabled, the screen will always be shared.", + default: true + }, + showToast: { + type: OptionType.BOOLEAN, + description: "If enabled, toasts will be shown when the stream is started or stopped.", + default: true + } +}); diff --git a/src/plugins/shortcutScreenshareScreen/stores.ts b/src/plugins/shortcutScreenshareScreen/stores.ts new file mode 100644 index 00000000000..b0e8a0d5551 --- /dev/null +++ b/src/plugins/shortcutScreenshareScreen/stores.ts @@ -0,0 +1,15 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { findStoreLazy } from "@webpack"; + +import * as t from "./types/stores"; + +export const ApplicationStreamingStore: t.ApplicationStreamingStore = findStoreLazy("ApplicationStreamingStore"); +export const StreamRTCConnectionStore: t.StreamRTCConnectionStore = findStoreLazy("StreamRTCConnectionStore"); +export const RunningGameStore: t.RunningGameStore = findStoreLazy("RunningGameStore"); +export const RTCConnectionStore: t.RTCConnectionStore = findStoreLazy("RTCConnectionStore"); +export const MediaEngineStore: t.MediaEngineStore = findStoreLazy("MediaEngineStore"); diff --git a/src/plugins/shortcutScreenshareScreen/streamManager.ts b/src/plugins/shortcutScreenshareScreen/streamManager.ts new file mode 100644 index 00000000000..55068f0e211 --- /dev/null +++ b/src/plugins/shortcutScreenshareScreen/streamManager.ts @@ -0,0 +1,140 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { showToast, ToastPosition, ToastType } from "@webpack/common"; +import { findByCodeLazy } from "webpack"; + +import { settings } from "./settings"; +import { ApplicationStreamingStore, MediaEngineStore, RTCConnectionStore, RunningGameStore, StreamRTCConnectionStore } from "./stores"; + +const startParameters = { + streamGuildId: null, + streamChannelId: null, + streamOptions: { + audioSourceId: null, + goLiveModalDurationMs: 2000, + nativePickerStyleUsed: undefined, + pid: null, + previewDisabled: false, + sound: true, + sourceId: null, + sourceName: null, + } +}; + +const startStream = findByCodeLazy('type:"STREAM_START"'); +const stopStream = findByCodeLazy('type:"STREAM_STOP"'); + +export async function startStreaming() { + await initializeStreamSetting(); + startStream(startParameters.streamGuildId, startParameters.streamChannelId, startParameters.streamOptions); + showToastCheck("Screenshare started!", ToastType.SUCCESS); +} + +export function stopStreaming() { + const streamkey = getActiveStreamKey(); + if (streamkey === null) return; + stopStream(streamkey); + startParameters.streamChannelId = null; + startParameters.streamGuildId = null; + startParameters.streamOptions = getStreamOptions(null); + showToastCheck("Screenshare stopped!", ToastType.FAILURE); +} + +export async function toggleGameOrScreen() { + await updateStreamSetting(); + updateStream(); + showToastCheck(`Switched to ${isStreamingWindow() ? "screen" : "game"} sharing!`); +} + +export function toggleAudio() { + settings.store.shareAudio = !settings.store.shareAudio; + startParameters.streamOptions.sound = settings.store.shareAudio; + updateStream(); + showToastCheck(`Audio sharing ${settings.store.shareAudio ? "enabled" : "disabled"}!`); +} + +export function toggleStream() { + if (ApplicationStreamingStore.getCurrentUserActiveStream()) { + stopStreaming(); + } else { + startStreaming(); + } +} + +function getActiveStreamKey() { + const activeStream = ApplicationStreamingStore.getCurrentUserActiveStream(); + if (activeStream) { + return activeStream.streamType + ":" + activeStream.guildId + ":" + activeStream.channelId + ":" + activeStream.ownerId; + } + return null; +} + +function isStreamingWindow() { + const streamkey = getActiveStreamKey(); + if (streamkey === null) return false; + const streamSource = StreamRTCConnectionStore.getStreamSourceId(streamkey); + return streamSource === null || streamSource.startsWith("window"); +} + +async function getPreviews(functionName, width = 376, height = 212) { + const mediaEngine = MediaEngineStore.getMediaEngine(); + const previews = await mediaEngine[functionName](width, height); + if (functionName === "getScreenPreviews") { + settings.store.displayNumber = previews.length; + } + return previews; +} + +function getStreamOptions(surce) { + return { + audioSourceId: null, + goLiveModalDurationMs: 1858, + nativePickerStyleUsed: undefined, + pid: surce?.pid ? surce.pid : null, + previewDisabled: settings.store.disablePreview, + sound: settings.store.shareAudio, + sourceId: surce?.id ? surce.id : null, + sourceName: surce?.name ? surce.name : null, + }; +} + +async function initializeStreamSetting() { + await updateStreamSetting(true); +} + +async function updateStreamSetting(firstInit = false) { + const game = RunningGameStore.getVisibleGame(); + const streamGame = firstInit ? !settings.store.shareAlwaysScreen && game !== null : !isStreamingWindow() && game !== null; + let displayIndex = settings.store.displayNumber - 1; + const screenPreviews = await getPreviews("getScreenPreviews"); + const windowPreviews = await getPreviews("getWindowPreviews"); + + if (!streamGame && game && screenPreviews.length === 0) return; + if (displayIndex >= screenPreviews.length) { + settings.store.displayNumber = 1; + displayIndex = 1; + } + + const screenPreview = screenPreviews[displayIndex]; + const windowPreview = windowPreviews.find(window => window.id.endsWith(game?.windowHandle)); + + startParameters.streamChannelId = RTCConnectionStore.getChannelId(); + startParameters.streamGuildId = RTCConnectionStore.getGuildId(startParameters.streamChannelId); + + startParameters.streamOptions = getStreamOptions(windowPreview && streamGame ? windowPreview : screenPreview); +} + +export function updateStream() { + if (ApplicationStreamingStore.getCurrentUserActiveStream()) { + startStream(startParameters.streamGuildId, startParameters.streamChannelId, startParameters.streamOptions); + } +} + +function showToastCheck(message: string, type = ToastType.MESSAGE) { + if (!settings.store.showToast) return; + showToast(message, type, { position: ToastPosition.BOTTOM }); +} diff --git a/src/plugins/shortcutScreenshareScreen/types/stores.d.ts b/src/plugins/shortcutScreenshareScreen/types/stores.d.ts new file mode 100644 index 00000000000..b2d3b0e0895 --- /dev/null +++ b/src/plugins/shortcutScreenshareScreen/types/stores.d.ts @@ -0,0 +1,15 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2025 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export type ApplicationStreamingStore = any; + +export type StreamRTCConnectionStore = any; + +export type RunningGameStore = any; + +export type RTCConnectionStore = any; + +export type MediaEngineStore = any; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 92de3d9183d..4ef086894ca 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -606,6 +606,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "thororen", id: 848339671629299742n }, + nicola02nb: { + name: "nicola02nb", + id: 257900031351193600n + } } satisfies Record); // iife so #__PURE__ works correctly diff --git a/src/utils/types.ts b/src/utils/types.ts index 2d21eafff30..c7632ffc8e2 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -19,6 +19,7 @@ import { ProfileBadge } from "@api/Badges"; import { ChatBarButtonFactory } from "@api/ChatButtons"; import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { Keybind, KeybindShortcut } from "@api/Keybinds/types"; import { MemberListDecoratorFactory } from "@api/MemberListDecorators"; import { MessageAccessoryFactory } from "@api/MessageAccessories"; import { MessageDecorationFactory } from "@api/MessageDecorations"; @@ -91,6 +92,7 @@ export interface PluginAuthor { export interface Plugin extends PluginDef { patches?: Patch[]; + keybinds?: Keybind[]; started: boolean; isDependency?: boolean; } @@ -106,6 +108,10 @@ export interface PluginDef { * List of commands that your plugin wants to register */ commands?: Command[]; + /** + * List of keybinds that your plugin wants to register + */ + keybinds?: Keybind[]; /** * A list of other plugins that your plugin depends on. * These will automatically be enabled and loaded before your plugin @@ -213,6 +219,7 @@ export const enum OptionType { BOOLEAN, SELECT, SLIDER, + KEYBIND, COMPONENT, CUSTOM } @@ -231,6 +238,7 @@ export type PluginSettingDef = | PluginSettingBooleanDef | PluginSettingSelectDef | PluginSettingSliderDef + | PluginSettingKeybindDef | PluginSettingBigIntDef ) & PluginSettingCommon); @@ -316,6 +324,18 @@ export interface PluginSettingSliderDef { stickToMarkers?: boolean; } +export interface PluginSettingKeybindDef { + type: OptionType.KEYBIND; + /** + * If true, this keybind will be global (works outside of the app window). + */ + global: boolean; + /** + * If true, this keybind can be cleared by the user when it is disabled. + */ + default?: KeybindShortcut; +} + export interface IPluginOptionComponentProps { /** * Run this when the value changes. @@ -342,6 +362,7 @@ type PluginSettingType = O extends PluginSettingStri O extends PluginSettingBooleanDef ? boolean : O extends PluginSettingSelectDef ? O["options"][number]["value"] : O extends PluginSettingSliderDef ? number : + O extends PluginSettingKeybindDef ? KeybindShortcut : O extends PluginSettingComponentDef ? O extends { default: infer Default; } ? Default : any : O extends PluginSettingCustomDef ? O extends { default: infer Default; } ? Default : any : never; @@ -397,6 +418,7 @@ export type PluginOptionsItem = | PluginOptionBoolean | PluginOptionSelect | PluginOptionSlider + | PluginOptionKeybind | PluginOptionComponent | PluginOptionCustom; export type PluginOptionString = PluginSettingStringDef & PluginSettingCommon & IsDisabled & IsValid; @@ -404,6 +426,7 @@ export type PluginOptionNumber = (PluginSettingNumberDef | PluginSettingBigIntDe export type PluginOptionBoolean = PluginSettingBooleanDef & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionSelect = PluginSettingSelectDef & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionSlider = PluginSettingSliderDef & PluginSettingCommon & IsDisabled & IsValid; +export type PluginOptionKeybind = PluginSettingKeybindDef & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionComponent = PluginSettingComponentDef & Omit; export type PluginOptionCustom = PluginSettingCustomDef & Pick; diff --git a/src/webpack/common/components.ts b/src/webpack/common/components.ts index cc282fd918a..0312e4a4ad2 100644 --- a/src/webpack/common/components.ts +++ b/src/webpack/common/components.ts @@ -46,6 +46,7 @@ export const Switch = FormSwitchCompat as never; export const Card = waitForComponent("Card", filters.componentByCode(".editable),", ".outline:")); export const Checkbox = waitForComponent("Checkbox", filters.componentByCode(".checkboxWrapperDisabled:")); +export const Keybind = waitForComponent("Keybind", filters.componentByCode("=this.handleComboKeys")); const Tooltips = mapMangledModuleLazy(".tooltipTop,bottom:", { Tooltip: filters.componentByCode("this.renderTooltip()]"), diff --git a/src/webpack/common/utils.ts b/src/webpack/common/utils.ts index cae1ecd5a87..7ad5b3953ea 100644 --- a/src/webpack/common/utils.ts +++ b/src/webpack/common/utils.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import type * as t from "@vencord/discord-types"; +import * as t from "@vencord/discord-types"; import { _resolveReady, filters, findByCodeLazy, findByPropsLazy, findLazy, mapMangledModuleLazy, waitFor } from "@webpack"; export let FluxDispatcher: t.FluxDispatcher; @@ -66,7 +66,7 @@ waitFor("parseTopic", m => Parser = m); export let Alerts: t.Alerts; waitFor(["show", "close"], m => Alerts = m); -const ToastType = { +export const ToastType = { MESSAGE: "message", SUCCESS: "success", FAILURE: "failure", @@ -77,7 +77,7 @@ const ToastType = { BOOKMARK: "bookmark", CLOCK: "clock" }; -const ToastPosition = { +export const ToastPosition = { TOP: 0, BOTTOM: 1 };