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>
This commit is contained in:
Youssef Ayman 2026-09-02 16:00:55 +03:00 • committed by GitHub
parent 20d1f8fea7
commit 1c39a1e7e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 682 additions and 0 deletions

View file

@ -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: () => <WrappedVoiceMessagesInBackgroundPlayer />
},
flux: {
MEDIA_PLAYBACK_RATE_UPDATE({ playbackType, rate }: PlaybackRateUpdate) {
if (playbackType === "voice_message") handlePlaybackRateUpdate(rate);
}
},
useBackgroundPlayback,
stop: stopPlayback
});

View file

@ -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<SetStateAction<boolean>>];
interface ActivePlayback {
attached: boolean;
cacheKey?: string;
channelId?: string;
duration: number;
paused: boolean;
player: AudioPlayerInterface;
position: number;
setNativePlaying?: Dispatch<SetStateAction<boolean>>;
setNativePosition?: Dispatch<SetStateAction<number>>;
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<SetStateAction<boolean>>,
setNativePosition: Dispatch<SetStateAction<number>>
) {
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<SetStateAction<number>>
) {
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;
}

View file

@ -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<typeof Slider> & { value?: number; };
const ControlledSlider: ComponentType<ControlledSliderProps> = Slider;
export const VoiceMessageIcon = Microphone;
function PlayPauseIcon({ paused }: { paused: boolean; }) {
return (
<svg viewBox="0 0 24 24" width={18} height={18} aria-hidden="true">
<path fill="currentColor" d={paused ? "M8 5v14l11-7Z" : "M6 5h4v14H6Zm8 0h4v14h-4Z"} />
</svg>
);
}
function CloseIcon() {
return (
<svg viewBox="0 0 24 24" width={16} height={16} aria-hidden="true">
<path fill="currentColor" d="m6.7 5.3 5.3 5.29 5.3-5.3 1.4 1.42-5.29 5.3 5.3 5.29-1.42 1.4-5.3-5.29-5.29 5.3-1.4-1.42 5.29-5.3-5.3-5.29Z" />
</svg>
);
}
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 (
<div className={cl("player", { playing: !snapshot.paused })} aria-label="Voice Messages In-Background player">
<div className={cl("voice-icon")}>
<Microphone width={17} height={17} />
</div>
<Tooltip text={snapshot.paused ? "Resume voice message" : "Pause voice message"}>
{tooltipProps => (
<Button
{...tooltipProps}
aria-label={snapshot.paused ? "Resume voice message" : "Pause voice message"}
className={cl("control")}
size="iconOnly"
variant="none"
onClick={togglePlayback}
>
<PlayPauseIcon paused={snapshot.paused} />
</Button>
)}
</Tooltip>
<Button
aria-label={`Return to ${channelName}`}
className={cl("origin")}
disabled={!snapshot.channelId}
size="min"
variant="none"
onClick={() => snapshot.channelId && ChannelRouter.transitionToChannel(snapshot.channelId)}
>
<span className={cl("origin-copy")}>
<span className={cl("title")}>Voice message</span>
<span className={cl("channel")}>{channelName}</span>
</span>
</Button>
<div className={cl("timeline")}>
<span className={cl("time")}>{time(position)}</span>
<ControlledSlider
key={snapshot.src}
aria-label="Voice message position"
asValueChanges={seekPlayback}
className={cl("seek")}
handleSize={10}
hideBubble
initialValue={position}
maxValue={duration}
minValue={0}
mini
onValueChange={seekPlayback}
onValueRender={time}
value={position}
/>
<span className={cl("time")}>{time(snapshot.duration)}</span>
</div>
<Tooltip text="Change playback speed">
{tooltipProps => (
<Button
{...tooltipProps}
aria-label={`Playback speed ${snapshot.speed} times`}
className={cl("speed")}
size="min"
variant="none"
onClick={() => setPlaybackSpeed(nextSpeed)}
>
{snapshot.speed}×
</Button>
)}
</Tooltip>
<Tooltip text="Close voice message player">
{tooltipProps => (
<Button
{...tooltipProps}
aria-label="Close voice message player"
className={cl("control", "close")}
size="iconOnly"
variant="none"
onClick={stopPlayback}
>
<CloseIcon />
</Button>
)}
</Tooltip>
</div>
);
}

View file

@ -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;
}
}

View file

@ -1426,6 +1426,10 @@ export const EquicordDevs = Object.freeze({
name: "k304",
id: 255004979637649408n
},
ELJoOker: {
name: "ELJoOker",
id: 605894319408283678n
},
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly