diff --git a/packages/web/src/audio/AudioManager.js b/packages/web/src/audio/AudioManager.js index e2c2458d..e3d9913e 100644 --- a/packages/web/src/audio/AudioManager.js +++ b/packages/web/src/audio/AudioManager.js @@ -6,6 +6,7 @@ export class AudioManager { inputDestination = null; silentGain = null; analyser = null; + masterCompressor = null; currentInputDeviceId = 'default'; currentStream = null; isInitialized = false; @@ -29,6 +30,15 @@ export class AudioManager { this.analyser.fftSize = 256; this.silentGain = this.ctx.createGain(); this.silentGain.gain.value = 0; + // Master compressor/limiter — prevents clipping when multiple + // audio sources (voice + stream) sum at the output. + this.masterCompressor = this.ctx.createDynamicsCompressor(); + this.masterCompressor.threshold.value = -1; // only engage near digital clipping + this.masterCompressor.knee.value = 0.5; // hard knee — transparent below threshold + this.masterCompressor.ratio.value = 4; // gentle limiting, no ducking + this.masterCompressor.attack.value = 0.0005; // 0.5ms — catch transient peaks + this.masterCompressor.release.value = 0.01; // 10ms — recover quickly + this.masterCompressor.connect(this.ctx.destination); this.inputGain.connect(this.inputDestination); this.inputGain.connect(this.analyser); this.inputGain.connect(this.silentGain); @@ -93,7 +103,7 @@ export class AudioManager { const gainNode = this.ctx.createGain(); gainNode.gain.value = options.volume ?? 0.5; source.connect(gainNode); - gainNode.connect(this.ctx.destination); + gainNode.connect(this.masterCompressor); source.start(0); return source; } @@ -158,6 +168,30 @@ export class AudioManager { this.initContext(); return this.analyser; } + /** + * Ensures the AudioContext exists and returns it. + * Unlike getContext(), this will never return null — it lazily + * creates the context if it hasn't been initialised yet. + * The context may be in 'suspended' state but Web Audio nodes + * can be created and connected regardless; audio will flow + * once the context resumes. + */ + ensureContext() { + if (!this.ctx) + this.initContext(); + return this.ctx; + } + /** + * Returns the master output bus (DynamicsCompressorNode). + * All remote audio (voice, stream) should connect their GainNodes + * to this node instead of directly to ctx.destination. The compressor + * prevents clipping when multiple sources sum together. + */ + getMasterOutput() { + if (!this.ctx) + this.initContext(); + return this.masterCompressor; + } getContext() { return this.ctx; } diff --git a/packages/web/src/audio/AudioManager.ts b/packages/web/src/audio/AudioManager.ts index f1e0b054..cd1aac69 100644 --- a/packages/web/src/audio/AudioManager.ts +++ b/packages/web/src/audio/AudioManager.ts @@ -6,6 +6,7 @@ export class AudioManager { private inputDestination: MediaStreamAudioDestinationNode | null = null; private silentGain: GainNode | null = null; private analyser: AnalyserNode | null = null; + private masterCompressor: DynamicsCompressorNode | null = null; private currentInputDeviceId: string = 'default'; private currentStream: MediaStream | null = null; @@ -35,7 +36,17 @@ export class AudioManager { this.silentGain = this.ctx.createGain(); this.silentGain.gain.value = 0; - + + // Master compressor/limiter — prevents clipping when multiple + // audio sources (voice + stream) sum at the output. + this.masterCompressor = this.ctx.createDynamicsCompressor(); + this.masterCompressor.threshold.value = -1; // only engage near digital clipping + this.masterCompressor.knee.value = 0.5; // hard knee — transparent below threshold + this.masterCompressor.ratio.value = 4; // gentle limiting, no ducking + this.masterCompressor.attack.value = 0.0005; // 0.5ms — catch transient peaks + this.masterCompressor.release.value = 0.01; // 10ms — recover quickly + this.masterCompressor.connect(this.ctx.destination); + this.inputGain.connect(this.inputDestination); this.inputGain.connect(this.analyser); this.inputGain.connect(this.silentGain); @@ -107,8 +118,8 @@ export class AudioManager { gainNode.gain.value = options.volume ?? 0.5; source.connect(gainNode); - gainNode.connect(this.ctx.destination); - + gainNode.connect(this.masterCompressor!); + source.start(0); return source; } @@ -178,6 +189,30 @@ export class AudioManager { return this.analyser!; } + /** + * Ensures the AudioContext exists and returns it. + * Unlike getContext(), this will never return null — it lazily + * creates the context if it hasn't been initialised yet. + * The context may be in 'suspended' state but Web Audio nodes + * can be created and connected regardless; audio will flow + * once the context resumes. + */ + ensureContext(): AudioContext { + if (!this.ctx) this.initContext(); + return this.ctx!; + } + + /** + * Returns the master output bus (DynamicsCompressorNode). + * All remote audio (voice, stream) should connect their GainNodes + * to this node instead of directly to ctx.destination. The compressor + * prevents clipping when multiple sources sum together. + */ + getMasterOutput(): AudioNode { + if (!this.ctx) this.initContext(); + return this.masterCompressor!; + } + getContext(): AudioContext | null { return this.ctx; } diff --git a/packages/web/src/components/chat/FriendsPage.js b/packages/web/src/components/chat/FriendsPage.js index dc127679..8bce877f 100644 --- a/packages/web/src/components/chat/FriendsPage.js +++ b/packages/web/src/components/chat/FriendsPage.js @@ -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 })); diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 4a673f22..1098b095 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -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 + +
+ +
{renderTabContent()} diff --git a/packages/web/src/components/layout/AppLayout.js b/packages/web/src/components/layout/AppLayout.js index 4425b006..d9d7b614 100644 --- a/packages/web/src/components/layout/AppLayout.js +++ b/packages/web/src/components/layout/AppLayout.js @@ -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