fix: eliminate voice ducking caused by rogue LiveKit audio elements

Add MutationObserver to neutralize LiveKit's re-attached <audio> elements,
mark our keep-alive elements with data-opencord, detach tracks on unsubscribe,
remove dangerous blanket .play(), and soften compressor to transparent limiter.
This commit is contained in:
Jannis Braun
2026-02-20 07:03:41 +01:00
parent 6f777f97ce
commit 5c38f076d3
22 changed files with 511 additions and 254 deletions
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSocialStore } from '../../stores/socialStore';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
import { LoadingSpinner } from '../ui/LoadingSpinner';
import { api } from '../../api/client';
@@ -12,6 +13,8 @@ export function FriendsPage() {
const [addStatus, setAddStatus] = useState(null);
const navigate = useNavigate();
const addDmChannel = useServerStore((s) => s.addDmChannel);
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const { friends, requests, isLoading, loadFriends, loadRequests, sendFriendRequest, updateFriendRequest, cancelFriendRequest, removeFriend } = useSocialStore();
useEffect(() => {
loadFriends();
@@ -58,7 +61,7 @@ export function FriendsPage() {
return (_jsxs("div", { className: "flex-1 p-8", children: [_jsx("h2", { className: "text-base font-bold text-discord-text-primary uppercase mb-2", children: "Add Friend" }), _jsx("p", { className: "text-sm text-discord-text-muted mb-4", children: "You can add friends with their Opencord username." }), _jsxs("form", { onSubmit: handleAddFriend, className: "relative mb-8", children: [_jsx("input", { type: "text", placeholder: "You can add a friend with their username", value: addUsername, onChange: (e) => setAddUsername(e.target.value), className: "w-full bg-discord-bg-tertiary text-discord-text-primary px-4 py-3 rounded-lg border border-transparent focus:border-discord-text-link outline-none transition-all placeholder:text-discord-text-muted/50" }), _jsx("button", { type: "submit", disabled: !addUsername.trim() || isLoading, className: "absolute right-2 top-1.5 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple-hover disabled:opacity-50 disabled:bg-discord-blurple text-white text-sm font-medium rounded transition-colors", children: "Send Friend Request" })] }), addStatus && (_jsx("div", { className: `text-sm p-3 rounded-lg border ${addStatus.type === 'success' ? 'text-discord-text-positive border-discord-green/20 bg-discord-green/5' : 'text-discord-text-danger border-discord-red/20 bg-discord-red/5'}`, children: addStatus.message }))] }));
}
};
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary h-full", children: [_jsxs("div", { className: "h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 mr-4", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: "Friends" })] }), _jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsxs("div", { className: "flex items-center gap-4 ml-2", children: [_jsx(TabButton, { active: activeTab === 'online', onClick: () => setActiveTab('online'), children: "Online" }), _jsx(TabButton, { active: activeTab === 'all', onClick: () => setActiveTab('all'), children: "All" }), _jsxs(TabButton, { active: activeTab === 'pending', onClick: () => setActiveTab('pending'), children: ["Pending", (pendingIncoming.length > 0) && (_jsx("span", { className: "ml-2 px-1.5 py-0.5 bg-discord-red text-white text-[10px] rounded-full leading-none", children: pendingIncoming.length }))] }), _jsx("button", { onClick: () => setActiveTab('add'), className: `px-2 py-0.5 rounded text-[14px] font-medium transition-all ${activeTab === 'add' ? 'text-discord-green bg-transparent' : 'bg-discord-green text-white hover:bg-discord-green/90'}`, children: "Add Friend" })] })] }), renderTabContent()] }));
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary h-full", children: [_jsxs("div", { className: "h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 mr-4", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: "Friends" })] }), _jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsxs("div", { className: "flex items-center gap-4 ml-2", children: [_jsx(TabButton, { active: activeTab === 'online', onClick: () => setActiveTab('online'), children: "Online" }), _jsx(TabButton, { active: activeTab === 'all', onClick: () => setActiveTab('all'), children: "All" }), _jsxs(TabButton, { active: activeTab === 'pending', onClick: () => setActiveTab('pending'), children: ["Pending", (pendingIncoming.length > 0) && (_jsx("span", { className: "ml-2 px-1.5 py-0.5 bg-discord-red text-white text-[10px] rounded-full leading-none", children: pendingIncoming.length }))] }), _jsx("button", { onClick: () => setActiveTab('add'), className: `px-2 py-0.5 rounded text-[14px] font-medium transition-all ${activeTab === 'add' ? 'text-discord-green bg-transparent' : 'bg-discord-green text-white hover:bg-discord-green/90'}`, children: "Add Friend" })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsx("button", { onClick: toggleMemberList, className: `w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'}`, title: "Toggle Activity Panel", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }) }) })] }), renderTabContent()] }));
}
function TabButton({ children, active, onClick }) {
return (_jsx("button", { onClick: onClick, className: `px-2 py-0.5 rounded-[4px] text-[16px] font-medium transition-colors ${active ? 'bg-discord-modifier-selected text-white' : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: children }));
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSocialStore } from '../../stores/socialStore';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
import { LoadingSpinner } from '../ui/LoadingSpinner';
import { api } from '../../api/client';
@@ -15,6 +16,8 @@ export function FriendsPage() {
const [addStatus, setAddStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
const navigate = useNavigate();
const addDmChannel = useServerStore((s) => s.addDmChannel);
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const {
friends,
@@ -202,6 +205,20 @@ export function FriendsPage() {
Add Friend
</button>
</div>
<div className="ml-auto flex items-center gap-1">
<button
onClick={toggleMemberList}
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${
memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'
}`}
title="Toggle Activity Panel"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg>
</button>
</div>
</div>
{renderTabContent()}
@@ -32,12 +32,6 @@ export function AppLayout() {
useEffect(() => {
const resume = () => {
AudioManager.getInstance().resumeContext().then(() => {
// Wake up all audio/video elements that might be blocked by Autoplay
document.querySelectorAll('audio, video').forEach(el => {
el.play().catch(() => {
// Silently fail if still blocked or no source
});
});
window.removeEventListener('click', resume);
window.removeEventListener('keydown', resume);
window.removeEventListener('touchstart', resume);
@@ -52,6 +46,25 @@ export function AppLayout() {
window.removeEventListener('touchstart', resume);
};
}, []);
// MutationObserver: neutralize rogue LiveKit <audio> elements that bypass our Web Audio pipeline.
// LiveKit can re-attach hidden <audio> elements after .detach(), causing full-volume playback
// that ignores our volume/mute controls. Any <audio> without data-opencord is immediately killed.
useEffect(() => {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (node instanceof HTMLAudioElement && !node.dataset.opencord) {
node.muted = true;
node.volume = 0;
node.pause();
node.srcObject = null;
}
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, []);
const { user, isLoading } = useAuth();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
@@ -60,6 +73,7 @@ export function AppLayout() {
const setIsMobile = useUIStore((s) => s.setIsMobile);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const isMobile = useUIStore((s) => s.isMobile);
const userProfilePopout = useUIStore((s) => s.userProfilePopout);
@@ -167,5 +181,5 @@ export function AppLayout() {
if (isLoading || !user) {
return (_jsx("div", { className: "h-screen flex items-center justify-center bg-discord-bg-primary", children: _jsxs("div", { className: "text-center", children: [_jsxs("svg", { className: "animate-spin w-10 h-10 text-discord-blurple mx-auto mb-4", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }), _jsx("p", { className: "text-discord-text-muted", children: "Loading Opencord..." })] }) }));
}
return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), serverId === '@me' ? (_jsx("div", { className: "w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col", children: _jsxs("div", { className: "p-4", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4", children: "Active Now" }), _jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1 text-center", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we\u2019ll show it here!" })] })] }) })) : (_jsx(MemberSidebar, {}))] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(IncomingCallModal, {}), _jsx(ImagePreview, {}), _jsx(PictureInPicture, {}), _jsx(SoundController, {}), _jsx(GlobalAudioRenderer, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] }));
return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), serverId === '@me' ? (memberListOpen ? (_jsx("div", { className: "w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col", children: _jsxs("div", { className: "p-4", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4", children: "Active Now" }), _jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1 text-center", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we'll show it here!" })] })] }) })) : null) : (_jsx(MemberSidebar, {}))] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(IncomingCallModal, {}), _jsx(ImagePreview, {}), _jsx(PictureInPicture, {}), _jsx(SoundController, {}), _jsx(GlobalAudioRenderer, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] }));
}
@@ -34,13 +34,6 @@ export function AppLayout() {
useEffect(() => {
const resume = () => {
AudioManager.getInstance().resumeContext().then(() => {
// Wake up all audio/video elements that might be blocked by Autoplay
document.querySelectorAll('audio, video').forEach(el => {
(el as HTMLMediaElement).play().catch(() => {
// Silently fail if still blocked or no source
});
});
window.removeEventListener('click', resume);
window.removeEventListener('keydown', resume);
window.removeEventListener('touchstart', resume);
@@ -56,6 +49,26 @@ export function AppLayout() {
};
}, []);
// MutationObserver: neutralize rogue LiveKit <audio> elements that bypass our Web Audio pipeline.
// LiveKit can re-attach hidden <audio> elements after .detach(), causing full-volume playback
// that ignores our volume/mute controls. Any <audio> without data-opencord is immediately killed.
useEffect(() => {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (node instanceof HTMLAudioElement && !node.dataset.opencord) {
node.muted = true;
node.volume = 0;
node.pause();
node.srcObject = null;
}
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, []);
const { user, isLoading } = useAuth();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
@@ -64,6 +77,7 @@ export function AppLayout() {
const setIsMobile = useUIStore((s) => s.setIsMobile);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const isMobile = useUIStore((s) => s.isMobile);
const userProfilePopout = useUIStore((s) => s.userProfilePopout);
@@ -213,17 +227,19 @@ export function AppLayout() {
<div className="flex-1 flex min-w-0 bg-discord-bg-primary relative">
<MainContent />
{serverId === '@me' ? (
<div className="w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col">
<div className="p-4">
<h3 className="text-[20px] font-bold text-discord-text-header mb-4">Active Now</h3>
<div className="text-center py-8">
<div className="text-[16px] font-bold text-discord-text-header mb-1 text-center">It's quiet for now...</div>
<div className="text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto">
When a friend starts an activitylike playing a game or hanging out on voicewell show it here!
memberListOpen ? (
<div className="w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col">
<div className="p-4">
<h3 className="text-[20px] font-bold text-discord-text-header mb-4">Active Now</h3>
<div className="text-center py-8">
<div className="text-[16px] font-bold text-discord-text-header mb-1 text-center">It's quiet for now...</div>
<div className="text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto">
When a friend starts an activity&#8212;like playing a game or hanging out on voice&#8212;we'll show it here!
</div>
</div>
</div>
</div>
</div>
) : null
) : (
<MemberSidebar />
)}
File diff suppressed because one or more lines are too long
@@ -147,6 +147,17 @@ export function MainContent() {
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg>
</button>
<button
onClick={toggleMemberList}
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${
memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'
}`}
title="Toggle Activity Panel"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg>
</button>
<div className="w-[1px] h-6 bg-discord-modifier-accent mx-1" />
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Search">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
@@ -3,8 +3,9 @@ import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
/**
* Renders an invisible <audio> element for a single audio track.
* Uses the shared useAudioTrackPlayer hook for the hybrid native/Web Audio pipeline.
* Manages a Web Audio pipeline for a single audio track.
* Renders a muted <audio> element as a Chrome keep-alive for the WebRTC track.
* All actual audio output goes through Web Audio (GainNode -> ctx.destination).
*/
function AudioTrackElement({ track, globalVolume, perSourceVolume, isDeafened, isMuted, attenuate, someoneIsSpeaking, attenuationEnabled, attenuationStrength, }) {
const globalScale = globalVolume / 100;
@@ -20,15 +21,22 @@ function AudioTrackElement({ track, globalVolume, perSourceVolume, isDeafened, i
volume: finalVolume,
muted: shouldMute,
});
return _jsx("audio", { ref: audioRef, autoPlay: true, playsInline: true });
// The <audio> element is always muted — it serves only as a Chrome
// keep-alive so Chrome continues processing the WebRTC track.
// Real audio output goes through the Web Audio pipeline.
return _jsx("audio", { ref: audioRef, autoPlay: true, playsInline: true, "data-opencord": "keepalive" });
}
/**
* Always-mounted component that renders invisible <audio> elements
* Always-mounted component that manages Web Audio pipelines
* for every remote participant's mic and screen audio tracks.
*
* Rendered in AppLayout alongside PictureInPicture and SoundController.
* Never unmounts during navigation, so audio persists even when
* VoiceGrid / VoiceUser / StreamTile are not rendered.
*
* All audio is routed through the Web Audio API (GainNodes connected
* to a shared AudioContext.destination). Muted <audio> elements serve
* as Chrome keep-alives for WebRTC tracks but produce no sound.
*/
export function GlobalAudioRenderer() {
const participants = useVoiceStore((s) => s.participants);
@@ -37,6 +45,7 @@ export function GlobalAudioRenderer() {
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
const streamMutes = useVoiceStore((s) => s.streamMutes);
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
// Determine if someone is currently speaking (for stream attenuation)
@@ -47,6 +56,6 @@ export function GlobalAudioRenderer() {
const micVolume = participantVolumes.get(p.userId) ?? 100;
const streamVol = streamVolumes.get(p.userId) ?? 100;
const isStreamMuted = streamMutes.get(p.userId) ?? false;
return (_jsxs(React.Fragment, { children: [p.audioTrack && (_jsx(AudioTrackElement, { track: p.audioTrack, globalVolume: outputVolume, perSourceVolume: micVolume, isDeafened: isDeafened, isMuted: false, attenuate: false, someoneIsSpeaking: false, attenuationEnabled: false, attenuationStrength: 0 })), p.screenAudioTrack && (_jsx(AudioTrackElement, { track: p.screenAudioTrack, globalVolume: outputVolume, perSourceVolume: streamVol, isDeafened: isDeafened, isMuted: isStreamMuted, attenuate: true, someoneIsSpeaking: someoneIsSpeaking, attenuationEnabled: streamAttenuationEnabled, attenuationStrength: streamAttenuationStrength }))] }, p.identity));
return (_jsxs(React.Fragment, { children: [p.audioTrack && (_jsx(AudioTrackElement, { track: p.audioTrack, globalVolume: outputVolume, perSourceVolume: micVolume, isDeafened: isDeafened, isMuted: false, attenuate: false, someoneIsSpeaking: false, attenuationEnabled: false, attenuationStrength: 0 })), p.screenAudioTrack && watchingStreams.has(p.userId) && (_jsx(AudioTrackElement, { track: p.screenAudioTrack, globalVolume: outputVolume, perSourceVolume: streamVol, isDeafened: isDeafened, isMuted: isStreamMuted, attenuate: true, someoneIsSpeaking: someoneIsSpeaking, attenuationEnabled: streamAttenuationEnabled, attenuationStrength: streamAttenuationStrength }))] }, p.identity));
}) }));
}
@@ -4,8 +4,9 @@ import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
/**
* Renders an invisible <audio> element for a single audio track.
* Uses the shared useAudioTrackPlayer hook for the hybrid native/Web Audio pipeline.
* Manages a Web Audio pipeline for a single audio track.
* Renders a muted <audio> element as a Chrome keep-alive for the WebRTC track.
* All actual audio output goes through Web Audio (GainNode -> ctx.destination).
*/
function AudioTrackElement({
track,
@@ -45,16 +46,23 @@ function AudioTrackElement({
muted: shouldMute,
});
return <audio ref={audioRef} autoPlay playsInline />;
// The <audio> element is always muted — it serves only as a Chrome
// keep-alive so Chrome continues processing the WebRTC track.
// Real audio output goes through the Web Audio pipeline.
return <audio ref={audioRef} autoPlay playsInline data-opencord="keepalive" />;
}
/**
* Always-mounted component that renders invisible <audio> elements
* Always-mounted component that manages Web Audio pipelines
* for every remote participant's mic and screen audio tracks.
*
* Rendered in AppLayout alongside PictureInPicture and SoundController.
* Never unmounts during navigation, so audio persists even when
* VoiceGrid / VoiceUser / StreamTile are not rendered.
*
* All audio is routed through the Web Audio API (GainNodes connected
* to a shared AudioContext.destination). Muted <audio> elements serve
* as Chrome keep-alives for WebRTC tracks but produce no sound.
*/
export function GlobalAudioRenderer() {
const participants = useVoiceStore((s) => s.participants);
@@ -63,6 +71,7 @@ export function GlobalAudioRenderer() {
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
const streamMutes = useVoiceStore((s) => s.streamMutes);
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
@@ -96,8 +105,8 @@ export function GlobalAudioRenderer() {
/>
)}
{/* Screen share audio */}
{p.screenAudioTrack && (
{/* Screen share audio — only when user opted in to watch */}
{p.screenAudioTrack && watchingStreams.has(p.userId) && (
<AudioTrackElement
track={p.screenAudioTrack}
globalVolume={outputVolume}
@@ -109,7 +109,7 @@ export function StreamTile({ tile, large }) {
const setAttenuationEnabled = useVoiceStore((s) => s.setStreamAttenuationEnabled);
const setAttenuationStrength = useVoiceStore((s) => s.setStreamAttenuationStrength);
const hasVideo = liveScreenTrack !== null;
return (_jsxs("div", { className: `relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ring-1 ring-white/[0.06] hover:ring-white/10 ${large ? 'h-full w-full' : 'h-full aspect-video'}`, onContextMenu: handleContextMenu, children: [hasVideo && isWatching ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: "w-full h-full object-contain bg-black" })) : (_jsxs("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: [_jsx("div", { className: "relative", children: _jsx(Avatar, { src: null, name: participant.username, size: large ? 80 : 48 }) }), _jsxs("div", { className: "text-center px-4", children: [_jsxs("p", { className: "text-discord-text-primary text-sm font-semibold", children: [participant.username, " is streaming"] }), !isLocal && (_jsx("button", { onClick: handleWatch, className: "mt-2 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple/80 rounded text-white text-xs font-semibold transition-colors", children: "Watch Stream" }))] })] })), _jsx("div", { className: "absolute top-2 left-2 px-1.5 py-0.5 bg-discord-red rounded text-[11px] font-bold text-white uppercase tracking-wide", children: "LIVE" }), qualityBadge && hasVideo && (_jsx("div", { className: "absolute top-2 right-2 px-1.5 py-0.5 bg-black/60 rounded text-[10px] font-bold text-white/70 uppercase tracking-wide", children: qualityBadge })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent", children: _jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-white/70 flex-shrink-0", children: _jsx("path", { d: "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" }) }), _jsx("span", { className: `font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/40 font-medium", children: "(you)" }))] }) }), contextMenu && (_jsx("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-2 min-w-[220px] border border-white/[0.06]", style: { left: contextMenu.x, top: contextMenu.y }, onClick: (e) => e.stopPropagation(), children: isLocal ? (
return (_jsxs("div", { className: `relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ring-1 ring-white/[0.06] hover:ring-white/10 ${large ? 'h-full w-full' : 'h-full aspect-video'}`, onContextMenu: handleContextMenu, children: [hasVideo && isWatching ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-contain bg-black" })) : (_jsxs("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: [_jsx("div", { className: "relative", children: _jsx(Avatar, { src: null, name: participant.username, size: large ? 80 : 48 }) }), _jsxs("div", { className: "text-center px-4", children: [_jsxs("p", { className: "text-discord-text-primary text-sm font-semibold", children: [participant.username, " is streaming"] }), !isLocal && (_jsx("button", { onClick: handleWatch, className: "mt-2 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple/80 rounded text-white text-xs font-semibold transition-colors", children: "Watch Stream" }))] })] })), _jsx("div", { className: "absolute top-2 left-2 px-1.5 py-0.5 bg-discord-red rounded text-[11px] font-bold text-white uppercase tracking-wide", children: "LIVE" }), qualityBadge && hasVideo && (_jsx("div", { className: "absolute top-2 right-2 px-1.5 py-0.5 bg-black/60 rounded text-[10px] font-bold text-white/70 uppercase tracking-wide", children: qualityBadge })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent", children: _jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-white/70 flex-shrink-0", children: _jsx("path", { d: "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" }) }), _jsx("span", { className: `font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/40 font-medium", children: "(you)" }))] }) }), contextMenu && (_jsx("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-2 min-w-[220px] border border-white/[0.06]", style: { left: contextMenu.x, top: contextMenu.y }, onClick: (e) => e.stopPropagation(), children: isLocal ? (
/* Streamer context menu (own stream) */
_jsxs(_Fragment, { children: [_jsxs("button", { onClick: () => {
handleStopStreaming();
@@ -143,7 +143,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
ref={videoRef}
autoPlay
playsInline
muted={isLocal}
muted
className="w-full h-full object-contain bg-black"
/>
) : (
+13 -3
View File
@@ -1,5 +1,5 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { VoiceUser } from './VoiceUser';
import { StreamTile } from './StreamTile';
import { useVoiceStore } from '../../stores/voiceStore';
@@ -7,7 +7,12 @@ import { deriveGridTiles } from '../../hooks/useLiveKit';
export function VoiceGrid({ participants }) {
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
const [stripHidden, setStripHidden] = useState(false);
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
// Reset strip visibility when focus target changes
useEffect(() => {
setStripHidden(false);
}, [focusedParticipantId]);
// Unfocus if the focused stream tile no longer exists
useEffect(() => {
const currentStreamKeys = new Set(tiles
@@ -30,10 +35,15 @@ export function VoiceGrid({ participants }) {
// Focus mode: one large tile + bottom strip
if (focusedTile) {
const otherTiles = tiles.filter((t) => t.key !== focusedParticipantId);
return (_jsxs("div", { className: "flex-1 flex flex-col overflow-hidden relative", children: [_jsxs("div", { className: "flex-1 p-2 min-h-0 cursor-pointer", onClick: () => setFocusedParticipant(null), title: "Click to return to grid view", children: [renderTile(focusedTile, true), _jsxs("button", { onClick: (e) => {
return (_jsxs("div", { className: "flex-1 flex flex-col overflow-hidden relative", children: [_jsxs("div", { className: "flex-1 p-2 min-h-0 cursor-pointer relative", onClick: () => setFocusedParticipant(null), title: "Click to return to grid view", children: [renderTile(focusedTile, true), _jsxs("button", { onClick: (e) => {
e.stopPropagation();
setFocusedParticipant(null);
}, className: "absolute top-4 right-4 z-10 px-3 py-1.5 bg-black/60 hover:bg-black/80 rounded-lg flex items-center gap-2 text-white/70 hover:text-white transition-colors", title: "Back to grid view", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" }) }), _jsx("span", { className: "text-xs font-medium", children: "Grid" })] })] }), otherTiles.length > 0 && (_jsx("div", { className: "h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-[#111214]/50 overflow-x-auto no-scrollbar", children: otherTiles.map((t) => (_jsx("div", { onClick: () => setFocusedParticipant(t.key), className: "h-full aspect-video flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity", children: renderTile(t) }, t.key))) }))] }));
}, className: "absolute top-4 right-4 z-10 px-3 py-1.5 bg-black/60 hover:bg-black/80 rounded-lg flex items-center gap-2 text-white/70 hover:text-white transition-colors", title: "Back to grid view", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" }) }), _jsx("span", { className: "text-xs font-medium", children: "Grid" })] })] }), otherTiles.length > 0 && (_jsx("div", { className: "flex justify-center flex-shrink-0 py-1", children: _jsxs("button", { onClick: (e) => {
e.stopPropagation();
setStripHidden(!stripHidden);
}, className: "px-4 py-1 bg-black/50 hover:bg-black/70 rounded-full flex items-center gap-2 text-white/60 hover:text-white transition-colors text-xs", title: stripHidden ? 'Show Members' : 'Hide Members', children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", children: stripHidden
? _jsx("path", { d: "M7 14l5-5 5 5z" })
: _jsx("path", { d: "M7 10l5 5 5-5z" }) }), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }), _jsx("span", { children: stripHidden ? 'Show Members' : 'Hide Members' })] }) })), !stripHidden && otherTiles.length > 0 && (_jsx("div", { className: "h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-[#111214]/50 overflow-x-auto no-scrollbar", children: otherTiles.map((t) => (_jsx("div", { onClick: () => setFocusedParticipant(t.key), className: "h-full aspect-video flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity", children: renderTile(t) }, t.key))) }))] }));
}
// Default grid mode
const gridClass = (() => {
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { VoiceUser } from './VoiceUser';
import { StreamTile } from './StreamTile';
import { useVoiceStore } from '../../stores/voiceStore';
@@ -12,9 +12,15 @@ interface VoiceGridProps {
export function VoiceGrid({ participants }: VoiceGridProps) {
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
const [stripHidden, setStripHidden] = useState(false);
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
// Reset strip visibility when focus target changes
useEffect(() => {
setStripHidden(false);
}, [focusedParticipantId]);
// Unfocus if the focused stream tile no longer exists
useEffect(() => {
const currentStreamKeys = new Set(
@@ -75,12 +81,12 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
<div className="flex-1 flex flex-col overflow-hidden relative">
{/* Main focused view */}
<div
className="flex-1 p-2 min-h-0 cursor-pointer"
className="flex-1 p-2 min-h-0 cursor-pointer relative"
onClick={() => setFocusedParticipant(null)}
title="Click to return to grid view"
>
{renderTile(focusedTile, true)}
{/* Back to grid button */}
{/* Grid button — top-right */}
<button
onClick={(e) => {
e.stopPropagation();
@@ -101,8 +107,33 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
</button>
</div>
{/* Bottom strip of other tiles */}
{/* Centered Hide/Show Members button — divider between focused tile and strip */}
{otherTiles.length > 0 && (
<div className="flex justify-center flex-shrink-0 py-1">
<button
onClick={(e) => {
e.stopPropagation();
setStripHidden(!stripHidden);
}}
className="px-4 py-1 bg-black/50 hover:bg-black/70 rounded-full flex items-center gap-2 text-white/60 hover:text-white transition-colors text-xs"
title={stripHidden ? 'Show Members' : 'Hide Members'}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
{stripHidden
? <path d="M7 14l5-5 5 5z" />
: <path d="M7 10l5 5 5-5z" />
}
</svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg>
<span>{stripHidden ? 'Show Members' : 'Hide Members'}</span>
</button>
</div>
)}
{/* Bottom strip of other tiles */}
{!stripHidden && otherTiles.length > 0 && (
<div className="h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-[#111214]/50 overflow-x-auto no-scrollbar">
{otherTiles.map((t) => (
<div