From 1c39a1e7e89ef3ea8236b44393df73e017ddaacd Mon Sep 17 00:00:00 2001 From: Youssef Ayman <62572632+ELJoOker2004@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:00:55 +0300 Subject: [PATCH] feat: add Voice Messages In-Background plugin (#1331) * feat: add Voice Messages In-Background plugin * fix: clean up completed native voice messages * fix up patch --------- Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com> --- .../voiceMessagesInBackground/index.tsx | 53 ++++ .../voiceMessagesInBackground/playback.ts | 299 ++++++++++++++++++ .../voiceMessagesInBackground/player.tsx | 146 +++++++++ .../voiceMessagesInBackground/style.css | 180 +++++++++++ src/utils/constants.ts | 4 + 5 files changed, 682 insertions(+) create mode 100644 src/equicordplugins/voiceMessagesInBackground/index.tsx create mode 100644 src/equicordplugins/voiceMessagesInBackground/playback.ts create mode 100644 src/equicordplugins/voiceMessagesInBackground/player.tsx create mode 100644 src/equicordplugins/voiceMessagesInBackground/style.css diff --git a/src/equicordplugins/voiceMessagesInBackground/index.tsx b/src/equicordplugins/voiceMessagesInBackground/index.tsx new file mode 100644 index 000000000..6e8f080a2 --- /dev/null +++ b/src/equicordplugins/voiceMessagesInBackground/index.tsx @@ -0,0 +1,53 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./style.css"; + +import ErrorBoundary from "@components/ErrorBoundary"; +import { EquicordDevs } from "@utils/constants"; +import definePlugin from "@utils/types"; + +import { handlePlaybackRateUpdate, stopPlayback, useBackgroundPlayback } from "./playback"; +import { VoiceMessageIcon, VoiceMessagesInBackgroundPlayer } from "./player"; + +interface PlaybackRateUpdate { + playbackType: string; + rate: number; +} + +const WrappedVoiceMessagesInBackgroundPlayer = ErrorBoundary.wrap(VoiceMessagesInBackgroundPlayer, { noop: true }); + +export default definePlugin({ + name: "Voice Messages In-Background", + description: "Keeps voice messages playing across chats with a synchronized mini player.", + authors: [EquicordDevs.ELJoOker], + tags: ["Voice", "Media", "Chat"], + dependencies: ["AudioPlayerAPI", "HeaderBarAPI"], + + patches: [{ + find: "#{intl::PAUSE_VOICE_MESSAGE_A11Y_LABEL}", + replacement: { + match: /(?<=\i>0\),\[(\i),(\i)\].{0,50}useState\(!1\).{0,10})(\[\i,\i\]=)(\i\.useState\(!1\))(?=.{0,100}\("none"\))/, + replace: "$3$self.useBackgroundPlayback($4,$1,arguments[0]?.src,arguments[0]?.playbackCacheKey,$2)" + } + }], + + headerBarButton: { + icon: VoiceMessageIcon, + location: "channeltoolbar", + priority: 30, + render: () => + }, + + flux: { + MEDIA_PLAYBACK_RATE_UPDATE({ playbackType, rate }: PlaybackRateUpdate) { + if (playbackType === "voice_message") handlePlaybackRateUpdate(rate); + } + }, + + useBackgroundPlayback, + stop: stopPlayback +}); diff --git a/src/equicordplugins/voiceMessagesInBackground/playback.ts b/src/equicordplugins/voiceMessagesInBackground/playback.ts new file mode 100644 index 000000000..2c403969d --- /dev/null +++ b/src/equicordplugins/voiceMessagesInBackground/playback.ts @@ -0,0 +1,299 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { type AudioPlayerInterface, createAudioPlayer } from "@api/AudioPlayer"; +import { FluxDispatcher, SelectedChannelStore, useEffect, useRef } from "@webpack/common"; +import type { Dispatch, SetStateAction } from "react"; + +type NativePlayingState = [boolean, Dispatch>]; + +interface ActivePlayback { + attached: boolean; + cacheKey?: string; + channelId?: string; + duration: number; + paused: boolean; + player: AudioPlayerInterface; + position: number; + setNativePlaying?: Dispatch>; + setNativePosition?: Dispatch>; + speed: number; + src: string; + updatedAt: number; +} + +export interface PlaybackSnapshot { + channelId?: string; + duration: number; + paused: boolean; + position: number; + speed: number; + src: string; +} + +let active: ActivePlayback | undefined; +let lastCacheSync = 0; +let voicePlaybackSpeed = 1; + +const NATIVE_END_TOLERANCE = 0.05; + +function playbackPosition(current: ActivePlayback) { + if (current.paused) return current.position; + + const position = current.position + (Date.now() - current.updatedAt) / 1000 * current.speed; + return current.duration > 0 ? Math.min(position, current.duration) : position; +} + +function updatePosition(current: ActivePlayback, position: number) { + current.position = Math.max(0, position); + current.updatedAt = Date.now(); +} + +function syncPlaybackCache(current: ActivePlayback, force = false) { + if (!current.cacheKey || current.duration <= 0) return; + + const now = Date.now(); + if (!force && now - lastCacheSync < 1000) return; + lastCacheSync = now; + FluxDispatcher.dispatch({ + type: "MEDIA_PLAYBACK_POSITION_UPDATE", + cacheKey: current.cacheKey, + position: playbackPosition(current), + duration: current.duration + }); +} + +export function stopPlayback() { + const current = active; + if (!current) return; + + active = undefined; + current.setNativePlaying?.(false); + current.setNativePosition?.(0); + current.player.delete(); + if (current.cacheKey) { + FluxDispatcher.dispatch({ + type: "MEDIA_PLAYBACK_POSITION_UPDATE", + cacheKey: current.cacheKey, + position: 0, + duration: Math.max(current.duration, 1) + }); + } +} + +function createBackgroundPlayback(src: string, cacheKey: string | undefined, position: number) { + stopPlayback(); + + const player = createAudioPlayer(src, { + persistent: true, + preload: true, + speed: voicePlaybackSpeed, + onEnded: () => { + if (active?.player === player) stopPlayback(); + }, + onError: () => { + if (active?.player === player) stopPlayback(); + } + }); + const current: ActivePlayback = active = { + attached: true, + cacheKey, + channelId: SelectedChannelStore.getChannelId(), + duration: 0, + paused: false, + player, + position, + speed: voicePlaybackSpeed, + src, + updatedAt: Date.now() + }; + player.mute(); + player.seek(position); + + const { duration } = player; + if (duration) { + void duration.then(value => { + if (active !== current || !Number.isFinite(value)) return; + + current.duration = value; + if (current.paused && value > 0 && current.position >= value - NATIVE_END_TOLERANCE) stopPlayback(); + }).catch(() => { + if (active === current) stopPlayback(); + }); + } + + lastCacheSync = 0; + return current; +} + +function playFromNative( + src: string, + cacheKey: string | undefined, + position: number, + setNativePlaying: Dispatch>, + setNativePosition: Dispatch> +) { + const current = active?.src === src + ? active + : createBackgroundPlayback(src, cacheKey, position); + + current.attached = true; + current.cacheKey = cacheKey; + current.paused = false; + current.setNativePlaying = setNativePlaying; + current.setNativePosition = setNativePosition; + updatePosition(current, position); + current.player.mute(); + current.player.pause(); + syncPlaybackCache(current, true); +} + +function pauseFromNative(src: string, position: number) { + const current = active; + if (!current || current.src !== src || !current.attached) return; + + const latestPosition = Math.max(position, playbackPosition(current)); + if (current.duration > 0 && latestPosition >= current.duration - NATIVE_END_TOLERANCE) { + stopPlayback(); + return; + } + + updatePosition(current, latestPosition); + current.paused = true; + current.player.pause(); + syncPlaybackCache(current, true); +} + +export function useBackgroundPlayback( + nativeState: NativePlayingState, + nativePosition: number, + src: string, + cacheKey: string | undefined, + setNativePosition: Dispatch> +) { + const [nativePlaying, setNativePlaying] = nativeState; + const nativePlayingRef = useRef(nativePlaying); + const nativePositionRef = useRef(nativePosition); + nativePlayingRef.current = nativePlaying; + nativePositionRef.current = nativePosition; + + useEffect(() => { + const current = active; + if (current?.src === src && !current.attached) { + const position = playbackPosition(current); + const playing = !current.paused; + updatePosition(current, position); + current.attached = true; + current.setNativePlaying = setNativePlaying; + current.setNativePosition = setNativePosition; + current.player.mute(); + current.player.pause(); + current.player.seek(position); + nativePlayingRef.current = playing; + nativePositionRef.current = position; + setNativePosition(position); + setNativePlaying(playing); + syncPlaybackCache(current, true); + } + + return () => { + const current = active; + if (!current || current.src !== src || current.setNativePlaying !== setNativePlaying) return; + + updatePosition(current, nativePositionRef.current); + current.attached = false; + current.paused = !nativePlayingRef.current; + current.setNativePlaying = undefined; + current.setNativePosition = undefined; + current.player.seek(current.position); + current.player.unmute(); + current.paused ? current.player.pause() : current.player.play(); + syncPlaybackCache(current, true); + }; + }, [setNativePlaying, setNativePosition, src]); + + useEffect(() => { + nativePlayingRef.current + ? playFromNative(src, cacheKey, nativePositionRef.current, setNativePlaying, setNativePosition) + : pauseFromNative(src, nativePositionRef.current); + }, [cacheKey, nativePlaying, setNativePlaying, setNativePosition, src]); + + useEffect(() => { + const current = active; + if (!current || current.src !== src || current.setNativePosition !== setNativePosition) return; + + updatePosition(current, nativePositionRef.current); + syncPlaybackCache(current); + }, [nativePosition, setNativePosition, src]); + + return nativeState; +} + +export function getPlaybackSnapshot(): PlaybackSnapshot | undefined { + const current = active; + if (!current) return; + + const position = playbackPosition(current); + syncPlaybackCache(current); + return { + channelId: current.channelId, + duration: current.duration, + paused: current.paused, + position, + speed: current.speed, + src: current.src + }; +} + +export function togglePlayback() { + const current = active; + if (!current) return; + + if (current.attached && current.setNativePlaying) { + current.setNativePlaying(current.paused); + return; + } + + updatePosition(current, playbackPosition(current)); + current.paused = !current.paused; + current.paused ? current.player.pause() : current.player.play(); + syncPlaybackCache(current, true); +} + +export function seekPlayback(position: number) { + const current = active; + if (!current) return; + + const target = Math.max(0, Math.min(position, current.duration || position)); + updatePosition(current, target); + current.player.seek(target); + current.setNativePosition?.(target); + syncPlaybackCache(current, true); +} + +export function setPlaybackSpeed(speed: number) { + const current = active; + if (!current) return; + + updatePosition(current, playbackPosition(current)); + current.speed = speed; + current.player.speed = speed; + FluxDispatcher.dispatch({ + type: "MEDIA_PLAYBACK_RATE_UPDATE", + playbackType: "voice_message", + rate: speed + }); +} + +export function handlePlaybackRateUpdate(speed: number) { + voicePlaybackSpeed = speed; + const current = active; + if (!current || current.speed === speed) return; + + updatePosition(current, playbackPosition(current)); + current.speed = speed; + current.player.speed = speed; +} diff --git a/src/equicordplugins/voiceMessagesInBackground/player.tsx b/src/equicordplugins/voiceMessagesInBackground/player.tsx new file mode 100644 index 000000000..ef0f406ac --- /dev/null +++ b/src/equicordplugins/voiceMessagesInBackground/player.tsx @@ -0,0 +1,146 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Button } from "@components/Button"; +import { Microphone } from "@components/Icons"; +import { classNameFactory } from "@utils/css"; +import { useFixedTimer } from "@utils/react"; +import { formatDuration } from "@utils/text"; +import { ChannelRouter, ChannelStore, SelectedChannelStore, Slider, Tooltip, useStateFromStores } from "@webpack/common"; +import type { ComponentProps, ComponentType } from "react"; + +import { getPlaybackSnapshot, seekPlayback, setPlaybackSpeed, stopPlayback, togglePlayback } from "./playback"; + +const cl = classNameFactory("vc-vmib-"); +const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2]; +type ControlledSliderProps = ComponentProps & { value?: number; }; + +const ControlledSlider: ComponentType = Slider; + +export const VoiceMessageIcon = Microphone; + +function PlayPauseIcon({ paused }: { paused: boolean; }) { + return ( + + ); +} + +function CloseIcon() { + return ( + + ); +} + +function time(seconds: number) { + return formatDuration(Math.max(0, seconds) * 1000); +} + +export function VoiceMessagesInBackgroundPlayer() { + useFixedTimer({ interval: 50 }); + const snapshot = getPlaybackSnapshot(); + const selectedChannelId = useStateFromStores( + [SelectedChannelStore], + () => SelectedChannelStore.getChannelId() + ); + const channel = useStateFromStores( + [ChannelStore], + () => snapshot?.channelId ? ChannelStore.getChannel(snapshot.channelId) : undefined, + [snapshot?.channelId] + ); + + if (!snapshot || snapshot.channelId === selectedChannelId) return null; + + const channelName = channel?.name ?? "Direct message"; + const duration = Math.max(snapshot.duration, snapshot.position, 1); + const position = Math.min(snapshot.position, duration); + const nextSpeed = SPEEDS[(SPEEDS.findIndex(speed => speed === snapshot.speed) + 1) % SPEEDS.length]; + + return ( +
+
+ +
+ + {tooltipProps => ( + + )} + + +
+ {time(position)} + + {time(snapshot.duration)} +
+ + {tooltipProps => ( + + )} + + + {tooltipProps => ( + + )} + +
+ ); +} diff --git a/src/equicordplugins/voiceMessagesInBackground/style.css b/src/equicordplugins/voiceMessagesInBackground/style.css new file mode 100644 index 000000000..ec91bc6c4 --- /dev/null +++ b/src/equicordplugins/voiceMessagesInBackground/style.css @@ -0,0 +1,180 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +.vc-vmib-player { + position: relative; + display: flex; + flex: 0 1 560px; + align-items: center; + gap: 4px; + min-width: 330px; + max-width: min(560px, 48vw); + height: 36px; + margin: 0 6px; + padding: 0 5px; + overflow: hidden; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md, 10px); + background: var(--background-surface-high); + box-shadow: var(--shadow-low); + color: var(--text-default); + animation: vc-vmib-enter 180ms ease-out; +} + +.vc-vmib-player::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--brand-500); +} + +.vc-vmib-voice-icon { + position: relative; + display: grid; + flex: 0 0 28px; + width: 28px; + height: 28px; + margin-left: 2px; + place-items: center; + border-radius: 50%; + background: color-mix(in srgb, var(--brand-500) 18%, transparent); + color: var(--brand-500); +} + +.vc-vmib-player-playing .vc-vmib-voice-icon::after { + content: ""; + position: absolute; + inset: -2px; + border: 2px solid color-mix(in srgb, var(--brand-500) 45%, transparent); + border-radius: 50%; + animation: vc-vmib-pulse 1.4s ease-out infinite; +} + +.vc-vmib-control { + flex: 0 0 28px; + width: 28px; + height: 28px; +} + +.vc-vmib-origin { + flex: 0 1 112px; + justify-content: flex-start; + min-width: 76px; + padding: 2px 5px; +} + +.vc-vmib-origin-copy { + display: flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; + line-height: 1.15; +} + +.vc-vmib-title, +.vc-vmib-channel { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +.vc-vmib-title { + color: var(--text-default); + font-size: 12px; + font-weight: 600; +} + +.vc-vmib-channel { + color: var(--text-muted); + font-size: 10px; +} + +.vc-vmib-timeline { + display: flex; + flex: 1 1 220px; + align-items: center; + gap: 7px; + min-width: 120px; +} + +.vc-vmib-seek { + flex: 1 1 auto; + min-width: 80px; +} + +.vc-vmib-time { + min-width: 34px; + color: var(--text-muted); + font-family: var(--font-code); + font-size: 10px; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.vc-vmib-speed { + flex: 0 0 auto; + min-width: 34px; + color: var(--brand-500); + font-weight: 700; +} + +.vc-vmib-close { + color: var(--interactive-normal); +} + +@keyframes vc-vmib-enter { + from { + opacity: 0; + transform: translateY(-4px) scale(.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes vc-vmib-pulse { + from { + opacity: .65; + transform: scale(.85); + } + + to { + opacity: 0; + transform: scale(1.25); + } +} + +@media (width <= 1050px) { + .vc-vmib-player { + max-width: 410px; + } + + .vc-vmib-origin { + display: none; + } +} + +@media (width <= 760px) { + .vc-vmib-player { + min-width: 235px; + max-width: 300px; + } + + .vc-vmib-time:last-child, + .vc-vmib-voice-icon { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .vc-vmib-player, + .vc-vmib-player-playing .vc-vmib-voice-icon::after { + animation: none; + } +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index a6d452a22..7a4d864bc 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1426,6 +1426,10 @@ export const EquicordDevs = Object.freeze({ name: "k304", id: 255004979637649408n }, + ELJoOker: { + name: "ELJoOker", + id: 605894319408283678n + }, } satisfies Record); // iife so #__PURE__ works correctly