Equicord/src/equicordplugins/voiceMessagesInBackground/playback.ts
Youssef Ayman 1c39a1e7e8
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>
2026-09-02 09:00:55 -04:00

299 lines
9.1 KiB
TypeScript

/*
* 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;
}