diff --git a/packages/web/src/audio/AudioManager.js b/packages/web/src/audio/AudioManager.js new file mode 100644 index 00000000..e2c2458d --- /dev/null +++ b/packages/web/src/audio/AudioManager.js @@ -0,0 +1,164 @@ +export class AudioManager { + static instance = null; + ctx = null; + inputGain = null; + inputSource = null; + inputDestination = null; + silentGain = null; + analyser = null; + currentInputDeviceId = 'default'; + currentStream = null; + isInitialized = false; + listeners = new Set(); + soundBuffers = new Map(); + constructor() { } + static getInstance() { + if (!AudioManager.instance) { + AudioManager.instance = new AudioManager(); + } + return AudioManager.instance; + } + initContext() { + if (this.ctx) + return; + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + this.ctx = new AudioContextClass(); + this.inputGain = this.ctx.createGain(); + this.inputDestination = this.ctx.createMediaStreamDestination(); + this.analyser = this.ctx.createAnalyser(); + this.analyser.fftSize = 256; + this.silentGain = this.ctx.createGain(); + this.silentGain.gain.value = 0; + this.inputGain.connect(this.inputDestination); + this.inputGain.connect(this.analyser); + this.inputGain.connect(this.silentGain); + this.silentGain.connect(this.ctx.destination); + this.inputGain.gain.setValueAtTime(1, this.ctx.currentTime); + this.ctx.onstatechange = () => { + console.log(`[AudioManager] Context state: ${this.ctx?.state}`); + if (this.ctx?.state === 'running') { + this.notifyResumed(); + } + }; + this.isInitialized = true; + } + onResumed(cb) { + this.listeners.add(cb); + return () => this.listeners.delete(cb); + } + notifyResumed() { + this.listeners.forEach(cb => cb()); + } + async resumeContext() { + if (!this.ctx) + this.initContext(); + if (this.ctx && this.ctx.state === 'suspended') { + try { + await this.ctx.resume(); + console.log('[AudioManager] AudioContext resumed.'); + } + catch (err) { + console.error('[AudioManager] Failed to resume context:', err); + } + } + } + async loadSound(name) { + if (this.soundBuffers.has(name)) { + return this.soundBuffers.get(name); + } + if (!this.ctx) + this.initContext(); + try { + const response = await fetch(`/sounds/${name}.mp3`); + if (!response.ok) + throw new Error(`Failed to load sound: ${name}`); + const arrayBuffer = await response.arrayBuffer(); + const audioBuffer = await this.ctx.decodeAudioData(arrayBuffer); + this.soundBuffers.set(name, audioBuffer); + return audioBuffer; + } + catch (err) { + console.error(`[AudioManager] Error loading sound ${name}:`, err); + return null; + } + } + async playSound(name, options = {}) { + await this.resumeContext(); + const buffer = await this.loadSound(name); + if (!buffer || !this.ctx) + return null; + const source = this.ctx.createBufferSource(); + source.buffer = buffer; + source.loop = options.loop || false; + const gainNode = this.ctx.createGain(); + gainNode.gain.value = options.volume ?? 0.5; + source.connect(gainNode); + gainNode.connect(this.ctx.destination); + source.start(0); + return source; + } + async setInputDevice(deviceId) { + if (!this.isInitialized) + this.initContext(); + // Skip if already set and stream is active + if (this.currentInputDeviceId === deviceId && this.currentStream?.active) { + return this.currentStream; + } + try { + if (this.currentStream) { + this.currentStream.getTracks().forEach(t => t.stop()); + } + const constraints = { + audio: { + deviceId: deviceId === 'default' ? undefined : { exact: deviceId }, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }; + this.currentStream = await navigator.mediaDevices.getUserMedia(constraints); + this.currentInputDeviceId = deviceId; + if (this.ctx && this.inputGain) { + if (this.inputSource) { + this.inputSource.disconnect(); + } + this.inputSource = this.ctx.createMediaStreamSource(this.currentStream); + this.inputSource.connect(this.inputGain); + } + return this.currentStream; + } + catch (err) { + console.error('[AudioManager] Failed to set input device:', err); + throw err; + } + } + setInputVolume(volume) { + if (!this.isInitialized) + this.initContext(); + if (this.inputGain && this.ctx) { + const gainValue = volume / 100; + this.inputGain.gain.setTargetAtTime(gainValue, this.ctx.currentTime, 0.1); + } + } + /** + * CRITICAL: Always returns a CLONE of the destination track. + * This prevents LiveKit's cleanup from killing the main singleton track + * when switching rooms. + */ + getFreshTrack() { + if (!this.isInitialized) + this.initContext(); + const track = this.inputDestination.stream.getAudioTracks()[0]; + if (!track) + return null; + return track.clone(); + } + getAnalyserNode() { + if (!this.isInitialized) + this.initContext(); + return this.analyser; + } + getContext() { + return this.ctx; + } +} diff --git a/packages/web/src/components/layout/AppLayout.js b/packages/web/src/components/layout/AppLayout.js index f8eec4a4..bd9be8c8 100644 --- a/packages/web/src/components/layout/AppLayout.js +++ b/packages/web/src/components/layout/AppLayout.js @@ -1,5 +1,5 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { ServerSidebar } from './ServerSidebar'; import { ChannelSidebar } from './ChannelSidebar'; @@ -15,6 +15,7 @@ import { ServerSettingsModal } from '../modals/ServerSettings'; import { NewDmModal } from '../modals/NewDmModal'; import { IncomingCallModal } from '../voice/IncomingCallModal'; import { PictureInPicture } from '../voice/PictureInPicture'; +import { SoundController } from '../voice/SoundController'; import { UserProfilePopout } from '../ui/UserProfilePopout'; import { useAuth } from '../../hooks/useAuth'; import { useWebSocket } from '../../hooks/useWebSocket'; @@ -23,8 +24,33 @@ import { useServerStore } from '../../stores/serverStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; import { useVoiceStore } from '../../stores/voiceStore'; +import { AudioManager } from '../../audio/AudioManager'; export function AppLayout() { const { serverId, channelId, inviteCode } = useParams(); + // Global interaction handler to resume AudioContext + 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); + }); + }; + window.addEventListener('click', resume); + window.addEventListener('keydown', resume); + window.addEventListener('touchstart', resume); + return () => { + window.removeEventListener('click', resume); + window.removeEventListener('keydown', resume); + window.removeEventListener('touchstart', resume); + }; + }, []); const { user, isLoading } = useAuth(); const setCurrentServer = useServerStore((s) => s.setCurrentServer); const loadServerDetail = useServerStore((s) => s.loadServerDetail); @@ -40,31 +66,70 @@ export function AppLayout() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const activeDmCall = useVoiceStore((s) => s.activeDmCall); const setParticipants = useVoiceStore((s) => s.setParticipants); - const { connect: connectVoice, connectDm: connectDmVoice, disconnect: disconnectVoice, participants: voiceParticipants, } = useLiveKit(); + const { connect: connectVoice, connectDm: connectDmVoice, disconnect: disconnectVoice, participants: voiceParticipants, isConnected: isVoiceConnected, isConnecting: isVoiceConnecting, connectedChannelId, } = useLiveKit(); // Initialize WebSocket - useWebSocket(); + const { isConnected: isWsConnected } = useWebSocket(); // Sync participants to store useEffect(() => { setParticipants(voiceParticipants); }, [voiceParticipants, setParticipants]); - // Manage voice connection (server voice channels) + // Track the last channel we attempted to connect to, to prevent effect loops + const lastAttemptedRef = React.useRef(null); + // Manage voice connection useEffect(() => { - if (currentVoiceChannelId) { - connectVoice(currentVoiceChannelId); - } - else if (!activeDmCall) { - disconnectVoice(); - } - }, [currentVoiceChannelId, connectVoice, disconnectVoice, activeDmCall]); - // Manage DM call connection - useEffect(() => { - if (activeDmCall) { - connectDmVoice(activeDmCall.dmChannelId); - } - else if (!currentVoiceChannelId) { - disconnectVoice(); - } - }, [activeDmCall, connectDmVoice, disconnectVoice, currentVoiceChannelId]); + if (isLoading || !user || !isWsConnected) + return; + const manageConnection = async () => { + // Determine what we SHOULD be connected to + const targetChannelId = activeDmCall + ? `dm-${activeDmCall.dmChannelId}` + : currentVoiceChannelId; + // 1. If we have a target + if (targetChannelId) { + // If we're not connected to the RIGHT place, trigger connect. + // We IGNORE isVoiceConnecting here to allow "interrupting" a connection + // or switching rooms immediately. + if (connectedChannelId !== targetChannelId) { + // Prevent spamming the same connection attempt if React re-renders + if (lastAttemptedRef.current === targetChannelId && isVoiceConnecting) { + return; + } + console.log(`[AppLayout] Switching/Connecting to: ${targetChannelId}`); + lastAttemptedRef.current = targetChannelId; + if (activeDmCall) { + await connectDmVoice(activeDmCall.dmChannelId); + } + else { + await connectVoice(targetChannelId); + } + } + else { + // We are connected to the right place. Reset ref. + lastAttemptedRef.current = null; + } + return; + } + // 2. No target — ensure disconnected + if (connectedChannelId !== null || isVoiceConnected || isVoiceConnecting) { + console.log('[AppLayout] Leaving voice (no target)'); + lastAttemptedRef.current = null; + await disconnectVoice(); + } + }; + manageConnection(); + }, [ + currentVoiceChannelId, + activeDmCall, + connectedChannelId, + isVoiceConnected, + isVoiceConnecting, + isWsConnected, + isLoading, + user, + connectVoice, + connectDmVoice, + disconnectVoice + ]); // Responsive detection useEffect(() => { const checkMobile = () => setIsMobile(window.innerWidth < 768); @@ -101,5 +166,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, {}), 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' ? (_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, {}), 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 })] }))] })); } diff --git a/packages/web/src/components/layout/ChannelSidebar.js b/packages/web/src/components/layout/ChannelSidebar.js index 8bbc1b2b..e4d061da 100644 --- a/packages/web/src/components/layout/ChannelSidebar.js +++ b/packages/web/src/components/layout/ChannelSidebar.js @@ -11,6 +11,7 @@ import { useVoiceStore } from '../../stores/voiceStore'; import { Avatar } from '../ui/Avatar'; import { wsSend } from '../../hooks/useWebSocket'; import { getActiveRoom } from '../../hooks/useLiveKit'; +import { AudioManager } from '../../audio/AudioManager'; export function ChannelSidebar() { const servers = useServerStore((s) => s.servers); const currentServerId = useServerStore((s) => s.currentServerId); @@ -30,48 +31,33 @@ export function ChannelSidebar() { const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const navigate = useNavigate(); const handleMicToggle = async () => { - const room = getActiveRoom(); - if (room) { - try { - await room.localParticipant.setMicrophoneEnabled(isMuted); - } - catch (err) { - console.error('[ChannelSidebar] Failed to toggle mic:', err); - } - } toggleMic(); - // Broadcast via WebSocket - wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened }); + // Broadcast mute status via WebSocket so non-joined users can see it + const willBeMuted = !isMuted; + wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened }); }; const handleDeafenToggle = async () => { const room = getActiveRoom(); const willDeafen = !isDeafened; + // Update store FIRST so updateParticipants reads correct state when LiveKit events fire + toggleDeafen(); + if (willDeafen && !isMuted) + toggleMic(); + if (!willDeafen && isMuted) + toggleMic(); + // Broadcast status via WebSocket so non-joined users can see it + const willBeMuted = willDeafen ? true : false; + wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened: willDeafen }); if (room) { try { - if (willDeafen) { - // Deafen: mute mic + silence all remote audio - await room.localParticipant.setMicrophoneEnabled(false); - room.remoteParticipants.forEach((p) => p.setVolume(0)); - if (!isMuted) - toggleMic(); - } - else { - // Undeafen: restore mic + restore remote audio - const outputVolume = useVoiceStore.getState().outputVolume; - const scaled = outputVolume / 100; - room.remoteParticipants.forEach((p) => p.setVolume(scaled)); - await room.localParticipant.setMicrophoneEnabled(true); - if (isMuted) - toggleMic(); - } + // Broadcast deafen state to other participants via LiveKit data channel + const encoder = new TextEncoder(); + room.localParticipant.publishData(encoder.encode(JSON.stringify({ type: 'deafen', deafened: willDeafen })), { reliable: true }).catch(() => { }); } catch (err) { console.error('[ChannelSidebar] Failed to toggle deafen:', err); } } - toggleDeafen(); - // Broadcast via WebSocket - wsSend({ type: 'voice_status', isMuted: willDeafen, isDeafened: willDeafen }); }; const server = servers.find(s => s.id === currentServerId); const currentMember = members.find(m => m.userId === user?.id); @@ -94,8 +80,6 @@ export function ChannelSidebar() { } setCurrentVoiceChannel(channelId); wsSend({ type: 'voice_join', channelId }); - // Also broadcast current status immediately after joining - wsSend({ type: 'voice_status', isMuted: useVoiceStore.getState().isMuted, isDeafened: useVoiceStore.getState().isDeafened }); navigate(`/channels/${currentServerId}/${channelId}`); }; // Floating bottom panel — shared between DM view and server view @@ -137,8 +121,10 @@ function UserAreaPanel({ user, isMuted, isDeafened, onMicToggle, onDeafenToggle, const [openPanel, setOpenPanel] = useState(null); const [inputDevices, setInputDevices] = useState([]); const [outputDevices, setOutputDevices] = useState([]); - const [selectedInput, setSelectedInput] = useState('default'); - const [selectedOutput, setSelectedOutput] = useState('default'); + const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); + const outputDeviceId = useVoiceStore((s) => s.outputDeviceId); + const setInputDevice = useVoiceStore((s) => s.setInputDevice); + const setOutputDevice = useVoiceStore((s) => s.setOutputDevice); const [selectedInputLabel, setSelectedInputLabel] = useState('Default'); const [selectedOutputLabel, setSelectedOutputLabel] = useState('Default'); const inputVolume = useVoiceStore((s) => s.inputVolume); @@ -154,15 +140,25 @@ function UserAreaPanel({ user, isMuted, isDeafened, onMicToggle, onDeafenToggle, const loadDevices = useCallback(async () => { try { // Need to request permission first to get labels - await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); + if (!AudioManager.getInstance().getContext()) { + await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); + } const devices = await navigator.mediaDevices.enumerateDevices(); - setInputDevices(devices.filter(d => d.kind === 'audioinput')); - setOutputDevices(devices.filter(d => d.kind === 'audiooutput')); + const inputs = devices.filter(d => d.kind === 'audioinput'); + const outputs = devices.filter(d => d.kind === 'audiooutput'); + setInputDevices(inputs); + setOutputDevices(outputs); + const currentInput = inputs.find(d => d.deviceId === inputDeviceId); + if (currentInput) + setSelectedInputLabel(currentInput.label || 'Default'); + const currentOutput = outputs.find(d => d.deviceId === outputDeviceId); + if (currentOutput) + setSelectedOutputLabel(currentOutput.label || 'Default'); } catch { // permission denied } - }, []); + }, [inputDeviceId, outputDeviceId]); // Start mic level monitoring when input panel opens useEffect(() => { if (openPanel !== 'input') { @@ -172,16 +168,11 @@ function UserAreaPanel({ user, isMuted, isDeafened, onMicToggle, onDeafenToggle, setMicLevel(0); return; } - let stream = null; - let ctx = null; const start = async () => { try { - stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: selectedInput !== 'default' ? selectedInput : undefined } }); - ctx = new AudioContext(); - const source = ctx.createMediaStreamSource(stream); - const analyser = ctx.createAnalyser(); + await AudioManager.getInstance().resumeContext(); + const analyser = AudioManager.getInstance().getAnalyserNode(); analyser.fftSize = 256; - source.connect(analyser); analyserRef.current = analyser; const data = new Uint8Array(analyser.frequencyBinCount); const tick = () => { @@ -201,10 +192,8 @@ function UserAreaPanel({ user, isMuted, isDeafened, onMicToggle, onDeafenToggle, if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); analyserRef.current = null; - stream?.getTracks().forEach(t => t.stop()); - ctx?.close(); }; - }, [openPanel, selectedInput]); + }, [openPanel]); useEffect(() => { const handleClick = (e) => { if (panelRef.current && !panelRef.current.contains(e.target)) { @@ -225,18 +214,17 @@ function UserAreaPanel({ user, isMuted, isDeafened, onMicToggle, onDeafenToggle, setOpenPanel(panel); setShowInputDeviceList(false); setShowOutputDeviceList(false); + // Explicitly resume on interaction + AudioManager.getInstance().resumeContext(); } }; const selectInput = (device) => { - setSelectedInput(device.deviceId); + setInputDevice(device.deviceId); setSelectedInputLabel(device.label || 'Default'); setShowInputDeviceList(false); - const room = getActiveRoom(); - if (room) - room.switchActiveDevice('audioinput', device.deviceId).catch(() => { }); }; const selectOutput = (device) => { - setSelectedOutput(device.deviceId); + setOutputDevice(device.deviceId); setSelectedOutputLabel(device.label || 'Default'); setShowOutputDeviceList(false); const room = getActiveRoom(); @@ -246,33 +234,14 @@ function UserAreaPanel({ user, isMuted, isDeafened, onMicToggle, onDeafenToggle, // Generate mic level bars (20 bars like Discord) const micBars = 20; const activeBars = Math.round(micLevel * micBars * (inputVolume / 100)); - return (_jsxs("div", { className: "relative", ref: panelRef, children: [openPanel === 'input' && (_jsxs("div", { className: "absolute bottom-full left-0 right-0 mb-0 bg-[#1e1f22] rounded-t-lg shadow-lg z-50 border-t border-x border-discord-bg-tertiary", children: [_jsxs("div", { className: "relative", children: [_jsxs("button", { onClick: () => setShowInputDeviceList(!showInputDeviceList), className: "w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[15px] font-semibold text-discord-text-primary text-left", children: "Input Device" }), _jsx("div", { className: "text-[13px] text-discord-text-muted truncate text-left", children: selectedInputLabel })] }), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0 ml-2", children: _jsx("path", { d: "M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" }) })] }), showInputDeviceList && (_jsx("div", { className: "bg-discord-bg-floating rounded-lg shadow-lg mx-2 mb-2 py-1 border border-discord-bg-tertiary", children: inputDevices.map(d => (_jsxs("button", { onClick: () => selectInput(d), className: `w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${selectedInput === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'}`, children: [selectedInput === d.deviceId && (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-blurple flex-shrink-0", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) })), _jsx("span", { className: selectedInput === d.deviceId ? '' : 'pl-6', children: d.label || 'Default' })] }, d.deviceId))) }))] }), _jsx("div", { className: "mx-4 border-t border-[#2b2d31]" }), _jsxs("div", { className: "px-4 py-3", children: [_jsx("div", { className: "text-[15px] font-semibold text-discord-text-primary mb-2", children: "Input Volume" }), _jsx("input", { type: "range", min: 0, max: 200, value: inputVolume, onChange: (e) => { + return (_jsxs("div", { className: "relative", ref: panelRef, children: [openPanel === 'input' && (_jsxs("div", { className: "absolute bottom-full left-0 right-0 mb-0 bg-[#1e1f22] rounded-t-lg shadow-lg z-50 border-t border-x border-discord-bg-tertiary", children: [_jsxs("div", { className: "relative", children: [_jsxs("button", { onClick: () => setShowInputDeviceList(!showInputDeviceList), className: "w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[15px] font-semibold text-discord-text-primary text-left", children: "Input Device" }), _jsx("div", { className: "text-[13px] text-discord-text-muted truncate text-left", children: selectedInputLabel })] }), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0 ml-2", children: _jsx("path", { d: "M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" }) })] }), showInputDeviceList && (_jsx("div", { className: "bg-discord-bg-floating rounded-lg shadow-lg mx-2 mb-2 py-1 border border-discord-bg-tertiary", children: inputDevices.map(d => (_jsxs("button", { onClick: () => selectInput(d), className: `w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${inputDeviceId === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'}`, children: [inputDeviceId === d.deviceId && (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-blurple flex-shrink-0", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) })), _jsx("span", { className: inputDeviceId === d.deviceId ? '' : 'pl-6', children: d.label || 'Default' })] }, d.deviceId))) }))] }), _jsx("div", { className: "mx-4 border-t border-[#2b2d31]" }), _jsxs("div", { className: "px-4 py-3", children: [_jsx("div", { className: "text-[15px] font-semibold text-discord-text-primary mb-2", children: "Input Volume" }), _jsx("input", { type: "range", min: 0, max: 200, value: inputVolume, onChange: (e) => { const vol = Number(e.target.value); storeSetInputVolume(vol); - // Apply gain to mic: at 0 = mute, 100 = normal, 200 = 2x boost - const room = getActiveRoom(); - if (room && room.localParticipant.isMicrophoneEnabled) { - if (vol === 0) { - room.localParticipant.setMicrophoneEnabled(false).catch(() => { }); - } - else { - // Re-enable mic if it was muted by volume slider - room.localParticipant.setMicrophoneEnabled(true).catch(() => { }); - } - } }, className: "w-full h-1.5 rounded-full appearance-none cursor-pointer accent-discord-blurple bg-discord-bg-tertiary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md", style: { background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${inputVolume / 2}%, #4e5058 ${inputVolume / 2}%, #4e5058 100%)`, - } }), _jsx("div", { className: "flex items-center gap-[3px] mt-2.5", children: Array.from({ length: micBars }).map((_, i) => (_jsx("div", { className: `flex-1 h-[6px] rounded-[1px] transition-colors duration-75 ${i < activeBars ? 'bg-discord-text-muted' : 'bg-[#313338]'}` }, i))) })] }), _jsx("div", { className: "mx-4 border-t border-[#2b2d31]" }), _jsxs("button", { onClick: onSettingsClick, className: "w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors", children: [_jsx("span", { className: "text-[15px] font-semibold text-discord-text-primary", children: "Voice Settings" }), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) })] })] })), openPanel === 'output' && (_jsxs("div", { className: "absolute bottom-full left-0 right-0 mb-0 bg-[#1e1f22] rounded-t-lg shadow-lg z-50 border-t border-x border-discord-bg-tertiary", children: [_jsxs("div", { className: "relative", children: [_jsxs("button", { onClick: () => setShowOutputDeviceList(!showOutputDeviceList), className: "w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[15px] font-semibold text-discord-text-primary text-left", children: "Output Device" }), _jsx("div", { className: "text-[13px] text-discord-text-muted truncate text-left", children: selectedOutputLabel })] }), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0 ml-2", children: _jsx("path", { d: "M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" }) })] }), showOutputDeviceList && (_jsx("div", { className: "bg-discord-bg-floating rounded-lg shadow-lg mx-2 mb-2 py-1 border border-discord-bg-tertiary", children: outputDevices.map(d => (_jsxs("button", { onClick: () => selectOutput(d), className: `w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${selectedOutput === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'}`, children: [selectedOutput === d.deviceId && (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-blurple flex-shrink-0", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) })), _jsx("span", { className: selectedOutput === d.deviceId ? '' : 'pl-6', children: d.label || 'Default' })] }, d.deviceId))) }))] }), _jsx("div", { className: "mx-4 border-t border-[#2b2d31]" }), _jsxs("div", { className: "px-4 py-3", children: [_jsx("div", { className: "text-[15px] font-semibold text-discord-text-primary mb-2", children: "Output Volume" }), _jsx("input", { type: "range", min: 0, max: 200, value: outputVolume, onChange: (e) => { + } }), _jsx("div", { className: "flex items-center gap-[3px] mt-2.5", children: Array.from({ length: micBars }).map((_, i) => (_jsx("div", { className: `flex-1 h-[6px] rounded-[1px] transition-colors duration-75 ${i < activeBars ? 'bg-discord-text-muted' : 'bg-[#313338]'}` }, i))) })] }), _jsx("div", { className: "mx-4 border-t border-[#2b2d31]" }), _jsxs("button", { onClick: onSettingsClick, className: "w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors", children: [_jsx("span", { className: "text-[15px] font-semibold text-discord-text-primary", children: "Voice Settings" }), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) })] })] })), openPanel === 'output' && (_jsxs("div", { className: "absolute bottom-full left-0 right-0 mb-0 bg-[#1e1f22] rounded-t-lg shadow-lg z-50 border-t border-x border-discord-bg-tertiary", children: [_jsxs("div", { className: "relative", children: [_jsxs("button", { onClick: () => setShowOutputDeviceList(!showOutputDeviceList), className: "w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[15px] font-semibold text-discord-text-primary text-left", children: "Output Device" }), _jsx("div", { className: "text-[13px] text-discord-text-muted truncate text-left", children: selectedOutputLabel })] }), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0 ml-2", children: _jsx("path", { d: "M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" }) })] }), showOutputDeviceList && (_jsx("div", { className: "bg-discord-bg-floating rounded-lg shadow-lg mx-2 mb-2 py-1 border border-discord-bg-tertiary", children: outputDevices.map(d => (_jsxs("button", { onClick: () => selectOutput(d), className: `w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${outputDeviceId === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'}`, children: [outputDeviceId === d.deviceId && (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-blurple flex-shrink-0", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) })), _jsx("span", { className: outputDeviceId === d.deviceId ? '' : 'pl-6', children: d.label || 'Default' })] }, d.deviceId))) }))] }), _jsx("div", { className: "mx-4 border-t border-[#2b2d31]" }), _jsxs("div", { className: "px-4 py-3", children: [_jsx("div", { className: "text-[15px] font-semibold text-discord-text-primary mb-2", children: "Output Volume" }), _jsx("input", { type: "range", min: 0, max: 200, value: outputVolume, onChange: (e) => { const vol = Number(e.target.value); storeSetOutputVolume(vol); - // Apply volume to all remote participants - const room = getActiveRoom(); - if (room) { - const scaled = vol / 100; // 0-2 range (0%=0, 100%=1, 200%=2) - room.remoteParticipants.forEach((participant) => { - participant.setVolume(scaled); - }); - } }, className: "w-full h-1.5 rounded-full appearance-none cursor-pointer bg-discord-bg-tertiary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md", style: { background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${outputVolume / 2}%, #4e5058 ${outputVolume / 2}%, #4e5058 100%)`, } })] }), _jsx("div", { className: "mx-4 border-t border-[#2b2d31]" }), _jsxs("button", { onClick: onSettingsClick, className: "w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors", children: [_jsx("span", { className: "text-[15px] font-semibold text-discord-text-primary", children: "Voice Settings" }), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) })] })] })), _jsxs("div", { className: "h-[52px] px-2 flex items-center select-none", children: [_jsxs("div", { className: "p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status, user: user }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[13px] font-semibold text-discord-text-primary truncate leading-tight", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[11px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary", children: ["@", user.username] })] })] }), _jsxs("div", { className: "flex items-center", children: [_jsx("button", { onClick: onMicToggle, className: `w-8 h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-l-[4px] transition-colors ${isMuted ? 'text-discord-red' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: isMuted ? 'Unmute' : 'Mute', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), isMuted && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: () => togglePanel('input'), className: `w-[18px] h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-r-[4px] transition-colors ${openPanel === 'input' ? 'text-discord-text-primary bg-discord-modifier-hover' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: "Input Devices", children: _jsx("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "currentColor", className: `transition-transform ${openPanel === 'input' ? 'rotate-180' : ''}`, children: _jsx("path", { d: "M7 10l5 5 5-5z" }) }) }), _jsx("button", { onClick: onDeafenToggle, className: `w-8 h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-l-[4px] transition-colors ${isDeafened ? 'text-discord-red' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: isDeafened ? 'Undeafen' : 'Deafen', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: () => togglePanel('output'), className: `w-[18px] h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-r-[4px] transition-colors ${openPanel === 'output' ? 'text-discord-text-primary bg-discord-modifier-hover' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: "Output Devices", children: _jsx("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "currentColor", className: `transition-transform ${openPanel === 'output' ? 'rotate-180' : ''}`, children: _jsx("path", { d: "M7 10l5 5 5-5z" }) }) }), _jsx("button", { onClick: onSettingsClick, className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover rounded-[4px] transition-colors", title: "User Settings", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] })] })] })); diff --git a/packages/web/src/components/layout/MainContent.js b/packages/web/src/components/layout/MainContent.js index c4c8f9d6..72681c3e 100644 --- a/packages/web/src/components/layout/MainContent.js +++ b/packages/web/src/components/layout/MainContent.js @@ -1,4 +1,5 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; +import { useEffect, useRef } from 'react'; import { useServerStore } from '../../stores/serverStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; @@ -14,6 +15,7 @@ import { FriendsPage } from '../chat/FriendsPage'; import { useVoiceStore } from '../../stores/voiceStore'; import { wsSend } from '../../hooks/useWebSocket'; export function MainContent() { + // 1. ALL HOOKS AT THE TOP const channels = useServerStore((s) => s.channels); const currentChannelId = useChatStore((s) => s.currentChannelId); const currentServerId = useServerStore((s) => s.currentServerId); @@ -21,16 +23,36 @@ export function MainContent() { const memberListOpen = useUIStore((s) => s.memberListOpen); const voiceChatOpen = useUIStore((s) => s.voiceChatOpen); const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); + const setVoiceFullscreen = useUIStore((s) => s.setVoiceFullscreen); const participants = useVoiceStore((s) => s.participants); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const showDms = useUIStore((s) => s.showDms); const activeDmCall = useVoiceStore((s) => s.activeDmCall); const outgoingCall = useVoiceStore((s) => s.outgoingCall); - const channel = channels.find(c => c.id === currentChannelId); - const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video'; - // DM view or no server selected const dmChannels = useServerStore((s) => s.dmChannels); const authUser = useAuthStore((s) => s.user); + const voiceContainerRef = useRef(null); + // Handle actual browser fullscreen API + useEffect(() => { + const handleFullscreenChange = () => { + setVoiceFullscreen(!!document.fullscreenElement); + }; + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); + }, [setVoiceFullscreen]); + useEffect(() => { + if (voiceFullscreen && voiceContainerRef.current && !document.fullscreenElement) { + voiceContainerRef.current.requestFullscreen().catch(err => { + console.error('Error attempting to enable full-screen mode:', err); + }); + } + else if (!voiceFullscreen && document.fullscreenElement) { + document.exitFullscreen().catch(() => { }); + } + }, [voiceFullscreen]); + // 2. LOGIC AND EARLY RETURNS + const channel = channels.find(c => c.id === currentChannelId); + const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video'; if (showDms || !currentServerId) { if (!currentChannelId) { return _jsx(FriendsPage, {}); @@ -38,8 +60,6 @@ export function MainContent() { const dmChannel = dmChannels.find(dm => dm.id === currentChannelId); const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id); const dmName = otherUser?.displayName ?? otherUser?.username ?? 'Direct Message'; - const dmStatus = otherUser?.status; - // Show DmCallView if there's an active DM call for this channel const isInDmCall = activeDmCall?.dmChannelId === currentChannelId; const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId; const handleStartVoiceCall = () => { @@ -54,30 +74,23 @@ export function MainContent() { useVoiceStore.getState().setOutgoingCall(null); wsSend({ type: 'dm_call_end', dmChannelId: currentChannelId }); }; - // If in an active DM call, show the call view overlaid on top of the chat if (isInDmCall) { return (_jsx("div", { className: "flex-1 flex flex-col min-w-0 relative", children: _jsx(DmCallView, {}) })); } return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [isCallingThisDm && (_jsxs("div", { className: "bg-discord-green/10 border-b border-discord-green/20 px-4 py-3 flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-green animate-pulse", children: _jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }), _jsxs("span", { className: "text-discord-green text-sm font-medium", children: ["Calling ", dmName, "..."] })] }), _jsx("button", { onClick: handleCancelCall, className: "px-3 py-1 bg-discord-red hover:bg-discord-red/80 text-white text-xs font-medium rounded transition-colors", children: "Cancel" })] })), _jsxs("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M12.5 2A6.5 6.5 0 0 0 6 8.5c0 1.82.75 3.47 1.95 4.65A10.02 10.02 0 0 0 2 22h2c0-4.42 3.58-8 8-8 .35 0 .69.03 1.03.07A6.49 6.49 0 0 0 19 8.5 6.5 6.5 0 0 0 12.5 2Zm0 11A4.5 4.5 0 1 1 17 8.5a4.5 4.5 0 0 1-4.5 4.5Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary truncate", children: dmName })] }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [_jsx("button", { onClick: handleStartVoiceCall, disabled: !!outgoingCall || !!activeDmCall, 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 disabled:opacity-50 disabled:cursor-not-allowed", title: "Start Voice Call", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }) }), _jsx("button", { onClick: handleStartVoiceCall, disabled: !!outgoingCall || !!activeDmCall, 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 disabled:opacity-50 disabled:cursor-not-allowed", title: "Start Video Call", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21 6.5l-4 4V7c0-.55-.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.21 0 .39-.08.54-.18L19.73 21 21 19.73 3.27 2z" }) }) }), _jsx("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: "Pinned Messages", children: _jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z", transform: "rotate(45 12 12)" }), _jsx("path", { d: "M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z" })] }) }), _jsx("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: "Add Friends to DM", 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" }) }) }), _jsx("div", { className: "w-[1px] h-6 bg-discord-modifier-accent mx-1" }), _jsx("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", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21.707 20.293l-5.395-5.395A7.457 7.457 0 0018 10.5 7.5 7.5 0 1010.5 18c1.575 0 3.027-.486 4.228-1.31l5.476 5.476a.997.997 0 001.414 0l.089-.089a1 1 0 000-1.414l.001-.37zM10.5 16a5.5 5.5 0 110-11 5.5 5.5 0 010 11z" }) }) }), _jsx("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: "Inbox", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z" }) }) }), _jsx("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: "Help", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" }) }) })] })] }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: `@${dmName}` })] })); } - // No channel selected if (!currentChannelId || !channel) { return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center shadow-header", children: _jsx("span", { className: "text-discord-text-muted", children: "Select a channel" }) }), _jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsx("p", { children: "Select a text or voice channel to get started" }) })] })); } - // Voice/Video channel view if (isVoiceChannel) { const isInThisChannel = currentVoiceChannelId === currentChannelId; - // Not connected — show "Join Voice" prompt with gradient if (!isInThisChannel) { return (_jsxs("div", { className: "flex-1 flex flex-col bg-[#0b0c0e]", children: [_jsx("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#0b0c0e]", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: channel.name })] }) }), _jsxs("div", { className: "flex-1 flex flex-col items-center justify-center gap-8 relative", children: [_jsx("div", { className: "absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(88,101,242,0.12)_0%,transparent_70%)] animate-gradient-pulse pointer-events-none" }), _jsxs("div", { className: "text-center relative z-10", children: [_jsx("h2", { className: "text-[28px] font-bold text-white mb-3", children: channel.name }), _jsx("p", { className: "text-discord-text-muted text-[15px]", children: "No one is currently in this voice channel." })] }), _jsx("button", { onClick: () => { useVoiceStore.getState().setCurrentVoiceChannel(currentChannelId); wsSend({ type: 'voice_join', channelId: currentChannelId }); }, className: "relative z-10 px-8 py-3 bg-gradient-to-r from-[#5865f2] to-[#7b6cf6] hover:brightness-110 text-white font-semibold rounded-full transition-all text-[15px] shadow-[0_4px_20px_rgba(88,101,242,0.3)]", children: "Join Voice" })] })] })); } - // Connected — full voice view with grid + floating control bar - const voiceView = (_jsxs("div", { className: `flex-1 flex flex-col bg-[#0b0c0e] min-w-0 ${voiceFullscreen ? 'fixed inset-0 z-50' : ''} group/voice relative`, children: [_jsx("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#0b0c0e]", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: channel.name }), _jsx("span", { className: "text-xs text-discord-green font-medium ml-2", children: "Connected" }), _jsxs("span", { className: "text-xs text-discord-text-muted ml-1", children: [participants.length, " connected"] })] }) }), _jsxs("div", { className: "flex-1 flex overflow-hidden pb-20", children: [_jsx(VoiceGrid, { participants: participants }), voiceChatOpen && (_jsx(VoiceChatPanel, { channelId: currentChannelId, channelName: channel.name }))] }), _jsx(VoiceControlBar, {})] })); - return voiceView; + return (_jsxs("div", { ref: voiceContainerRef, className: `flex-1 flex flex-col bg-[#0b0c0e] min-w-0 group/voice relative ${voiceFullscreen ? 'h-screen' : ''}`, children: [_jsx("div", { className: `h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#0b0c0e] transition-opacity duration-300 ${voiceFullscreen ? 'opacity-0 hover:opacity-100' : ''}`, children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: channel.name }), _jsx("span", { className: "text-xs text-discord-green font-medium ml-2", children: "Connected" }), _jsxs("span", { className: "text-xs text-discord-text-muted ml-1", children: [participants.length, " connected"] })] }) }), _jsxs("div", { className: "flex-1 flex overflow-hidden pb-20", children: [_jsx(VoiceGrid, { participants: participants }), voiceChatOpen && !voiceFullscreen && (_jsx(VoiceChatPanel, { channelId: currentChannelId, channelName: channel.name }))] }), _jsx(VoiceControlBar, {})] })); } - // Text channel view return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [_jsxs("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary truncate leading-tight", children: channel.name }), channel.topic && (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsx("span", { className: "text-xs text-discord-text-muted truncate leading-tight", children: channel.topic })] }))] }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [_jsx("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: "Threads", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5.43 21a.996.996 0 01-.98-.8l-.79-4.34H2.5a1 1 0 110-2h.93l-.55-3H1.5a1 1 0 010-2h1.15L1.87 4.86a1 1 0 011.96-.72L4.6 8.86h3.32l-.78-4.72a1 1 0 011.96-.28l.84 5H13.5a1 1 0 110 2h-3.33l.55 3H13.5a1 1 0 110 2h-2.55l.72 3.94a1 1 0 01-.79 1.16 1.034 1.034 0 01-.18.02.996.996 0 01-.98-.82L8.95 15.86H5.63l.72 3.94A1 1 0 015.43 21zM5.86 10.86l.55 3h3.32l-.55-3H5.86z" }) }) }), _jsx("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: "Notification Settings", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" }) }) }), _jsx("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: "Pinned Messages", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z" }) }) }), _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 Member List", 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" }) }) }), _jsx("div", { className: "w-[1px] h-6 bg-discord-modifier-accent mx-1" }), _jsx("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", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21.707 20.293l-5.395-5.395A7.457 7.457 0 0018 10.5 7.5 7.5 0 1010.5 18c1.575 0 3.027-.486 4.228-1.31l5.476 5.476a.997.997 0 001.414 0l.089-.089a1 1 0 000-1.414l.001-.37zM10.5 16a5.5 5.5 0 110-11 5.5 5.5 0 010 11z" }) }) }), _jsx("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: "Inbox", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z" }) }) }), _jsx("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: "Help", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" }) }) })] })] }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: channel.name })] })); } diff --git a/packages/web/src/components/voice/DmCallView.js b/packages/web/src/components/voice/DmCallView.js index 388d34ec..9d103dc4 100644 --- a/packages/web/src/components/voice/DmCallView.js +++ b/packages/web/src/components/voice/DmCallView.js @@ -82,12 +82,22 @@ export function DmCallView() { } toggleCamera(); }; - const handleScreenShare = () => { + const handleScreenShare = async () => { const room = getActiveRoom(); - if (room) { - room.localParticipant.setScreenShareEnabled(!isScreenSharing); + if (!room) + return; + try { + if (!isScreenSharing) { + await room.localParticipant.setScreenShareEnabled(true, { audio: true }); + } + else { + await room.localParticipant.setScreenShareEnabled(false); + } + toggleScreenShare(); + } + catch (err) { + console.error('[DmCallView] Failed to toggle screen share:', err); } - toggleScreenShare(); }; const handleEndCall = () => { if (activeDmCall) { diff --git a/packages/web/src/components/voice/PictureInPicture.js b/packages/web/src/components/voice/PictureInPicture.js index 73b44ef6..f15171f6 100644 --- a/packages/web/src/components/voice/PictureInPicture.js +++ b/packages/web/src/components/voice/PictureInPicture.js @@ -10,9 +10,9 @@ const PIP_WIDTH = 320; const PIP_HEIGHT = 180; const PIP_MARGIN = 16; const DRAG_THRESHOLD = 5; -function selectPipStream(participants, focusedId) { - // Priority 1: Screen share (highest value content) - const screenSharer = participants.find(p => p.screenTrack !== null); +function selectPipStream(participants, focusedId, watchingStreams) { + // Priority 1: Screen share from a user we're watching + const screenSharer = participants.find(p => p.screenTrack !== null && watchingStreams.has(p.userId)); if (screenSharer?.screenTrack) { return { participant: screenSharer, track: screenSharer.screenTrack, type: 'screen' }; } @@ -44,6 +44,7 @@ export function PictureInPicture() { const activeDmCall = useVoiceStore((s) => s.activeDmCall); const participants = useVoiceStore((s) => s.participants); const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId); + const watchingStreams = useVoiceStore((s) => s.watchingStreams); const currentChannelId = useChatStore((s) => s.currentChannelId); const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); const pipCollapsed = useUIStore((s) => s.pipCollapsed); @@ -73,7 +74,7 @@ export function PictureInPicture() { const isInDmCall = activeDmCall !== null && currentChannelId !== activeDmCall.dmChannelId; const shouldShow = (isInServerVoice || isInDmCall) && !voiceFullscreen && !pipCollapsed; // Stream selection - const selectedStream = useMemo(() => selectPipStream(participants, focusedParticipantId), [participants, focusedParticipantId]); + const selectedStream = useMemo(() => selectPipStream(participants, focusedParticipantId, watchingStreams), [participants, focusedParticipantId, watchingStreams]); // Fallback participant for avatar (most relevant remote, or first participant) const fallbackParticipant = useMemo(() => { const speaking = participants.find(p => !p.isLocal && p.isSpeaking); diff --git a/packages/web/src/components/voice/PictureInPicture.tsx b/packages/web/src/components/voice/PictureInPicture.tsx index ee08daa4..295902ff 100644 --- a/packages/web/src/components/voice/PictureInPicture.tsx +++ b/packages/web/src/components/voice/PictureInPicture.tsx @@ -21,9 +21,12 @@ interface SelectedStream { function selectPipStream( participants: ParticipantInfo[], focusedId: string | null, + watchingStreams: Set, ): SelectedStream | null { - // Priority 1: Screen share (highest value content) - const screenSharer = participants.find(p => p.screenTrack !== null); + // Priority 1: Screen share from a user we're watching + const screenSharer = participants.find( + p => p.screenTrack !== null && watchingStreams.has(p.userId), + ); if (screenSharer?.screenTrack) { return { participant: screenSharer, track: screenSharer.screenTrack, type: 'screen' }; } @@ -61,6 +64,7 @@ export function PictureInPicture() { const activeDmCall = useVoiceStore((s) => s.activeDmCall); const participants = useVoiceStore((s) => s.participants); const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId); + const watchingStreams = useVoiceStore((s) => s.watchingStreams); const currentChannelId = useChatStore((s) => s.currentChannelId); const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); const pipCollapsed = useUIStore((s) => s.pipCollapsed); @@ -95,8 +99,8 @@ export function PictureInPicture() { // Stream selection const selectedStream = useMemo( - () => selectPipStream(participants, focusedParticipantId), - [participants, focusedParticipantId], + () => selectPipStream(participants, focusedParticipantId, watchingStreams), + [participants, focusedParticipantId, watchingStreams], ); // Fallback participant for avatar (most relevant remote, or first participant) diff --git a/packages/web/src/components/voice/SoundController.js b/packages/web/src/components/voice/SoundController.js new file mode 100644 index 00000000..c465df4a --- /dev/null +++ b/packages/web/src/components/voice/SoundController.js @@ -0,0 +1,154 @@ +import { useEffect, useRef } from 'react'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { useChatStore } from '../../stores/chatStore'; +import { useAuthStore } from '../../stores/authStore'; +import { useWebSocket } from '../../hooks/useWebSocket'; +import { AudioManager } from '../../audio/AudioManager'; +export function SoundController() { + const audioManager = AudioManager.getInstance(); + const currentUser = useAuthStore((s) => s.user); + const { isConnected: isWsConnected } = useWebSocket(); + // Refs to track previous states + const isInitialMount = useRef(true); + const prevIsWsConnected = useRef(false); + const prevIsMuted = useRef(useVoiceStore.getState().isMuted); + const prevIsDeafened = useRef(useVoiceStore.getState().isDeafened); + const prevIsCameraOn = useRef(useVoiceStore.getState().isCameraOn); + const prevIsScreenSharing = useRef(useVoiceStore.getState().isScreenSharing); + const prevIsConnected = useRef(useVoiceStore.getState().isLiveKitConnected); + const prevParticipantIds = useRef(new Set(useVoiceStore.getState().participants.map(p => p.userId))); + const prevScreenShareUserIds = useRef(new Set(useVoiceStore.getState().participants.filter(p => p.isScreenSharing).map(p => p.userId))); + const incomingCallLoop = useRef(null); + const outgoingCallLoop = useRef(null); + // WebSocket Reconnect Sound — suppress during active voice (LiveKit handles its own reconnection) + useEffect(() => { + if (isInitialMount.current) + return; + if (isWsConnected && !prevIsWsConnected.current) { + const isInActiveVoice = useVoiceStore.getState().isLiveKitConnected; + if (!isInActiveVoice) { + audioManager.playSound('reconnect'); + } + } + prevIsWsConnected.current = isWsConnected; + }, [isWsConnected, audioManager]); + useEffect(() => { + // Set initial mount flag to false after first run + const timer = setTimeout(() => { + isInitialMount.current = false; + prevIsWsConnected.current = isWsConnected; + }, 1000); + // 1. Listen to Voice State Changes + const unsubscribeVoice = useVoiceStore.subscribe((state) => { + if (isInitialMount.current) + return; + // Mute/Unmute + if (state.isMuted !== prevIsMuted.current) { + audioManager.playSound(state.isMuted ? 'mute' : 'unmute'); + prevIsMuted.current = state.isMuted; + } + // Deafen/Undeafen + if (state.isDeafened !== prevIsDeafened.current) { + audioManager.playSound(state.isDeafened ? 'deafen' : 'undeafen'); + prevIsDeafened.current = state.isDeafened; + } + // Camera Toggle + if (state.isCameraOn !== prevIsCameraOn.current) { + audioManager.playSound(state.isCameraOn ? 'camera_on' : 'camera_off'); + prevIsCameraOn.current = state.isCameraOn; + } + // Screen Share Toggle (Self) + if (state.isScreenSharing !== prevIsScreenSharing.current) { + audioManager.playSound(state.isScreenSharing ? 'stream_started' : 'stream_ended'); + prevIsScreenSharing.current = state.isScreenSharing; + } + // Disconnect (Self) + if (prevIsConnected.current && !state.isLiveKitConnected) { + audioManager.playSound('disconnect'); + } + // Connect (Self) + if (!prevIsConnected.current && state.isLiveKitConnected) { + audioManager.playSound('user_join'); + } + prevIsConnected.current = state.isLiveKitConnected; + // Participant Joins/Leaves & Screen Sharing + const currentParticipantIds = new Set(state.participants.map(p => p.userId)); + const currentScreenShareUserIds = new Set(state.participants.filter(p => p.isScreenSharing).map(p => p.userId)); + if (state.isLiveKitConnected) { + // Someone joined voice (Others only) + state.participants.forEach(p => { + if (!prevParticipantIds.current.has(p.userId) && p.userId !== currentUser?.id) { + audioManager.playSound('user_join'); + } + }); + // Someone left voice (Others only) + prevParticipantIds.current.forEach(userId => { + if (!currentParticipantIds.has(userId) && userId !== currentUser?.id) { + audioManager.playSound('user_leave'); + } + }); + // Someone started screen sharing (Others only) + state.participants.forEach(p => { + if (p.isScreenSharing && !prevScreenShareUserIds.current.has(p.userId) && p.userId !== currentUser?.id) { + audioManager.playSound('stream_user_joined'); + } + }); + // Someone stopped screen sharing (Others only) + prevScreenShareUserIds.current.forEach(userId => { + if (!currentScreenShareUserIds.has(userId) && userId !== currentUser?.id) { + audioManager.playSound('stream_user_left'); + } + }); + } + prevParticipantIds.current = currentParticipantIds; + prevScreenShareUserIds.current = currentScreenShareUserIds; + // Incoming Call (Ringing) + if (state.incomingCall && !incomingCallLoop.current) { + audioManager.playSound('call_ringing', { loop: true }).then(source => { + incomingCallLoop.current = source; + }); + } + else if (!state.incomingCall && incomingCallLoop.current) { + incomingCallLoop.current.stop(); + incomingCallLoop.current = null; + } + // Outgoing Call (Calling) + if (state.outgoingCall && !outgoingCallLoop.current) { + audioManager.playSound('call_calling', { loop: true }).then(source => { + outgoingCallLoop.current = source; + }); + } + else if (!state.outgoingCall && outgoingCallLoop.current) { + outgoingCallLoop.current.stop(); + outgoingCallLoop.current = null; + } + }); + // 2. Listen to Chat State Changes (New Messages) + const unsubscribeChat = useChatStore.subscribe((state, prevState) => { + if (isInitialMount.current) + return; + // Check for new messages in the current channel + if (state.currentChannelId) { + const messages = state.messages.get(state.currentChannelId) || []; + const prevMessages = prevState.messages.get(state.currentChannelId) || []; + if (messages.length > prevMessages.length) { + const lastMessage = messages[messages.length - 1]; + // Don't play sound for our own messages + if (lastMessage && lastMessage.userId !== currentUser?.id) { + audioManager.playSound('message'); + } + } + } + }); + return () => { + clearTimeout(timer); + unsubscribeVoice(); + unsubscribeChat(); + if (incomingCallLoop.current) + incomingCallLoop.current.stop(); + if (outgoingCallLoop.current) + outgoingCallLoop.current.stop(); + }; + }, [audioManager, currentUser?.id]); + return null; +} diff --git a/packages/web/src/components/voice/StreamTile.js b/packages/web/src/components/voice/StreamTile.js new file mode 100644 index 00000000..0b4ac938 --- /dev/null +++ b/packages/web/src/components/voice/StreamTile.js @@ -0,0 +1,224 @@ +import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; +import { useRef, useEffect, useState, useCallback } from 'react'; +import { Avatar } from '../ui/Avatar'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { AudioManager } from '../../audio/AudioManager'; +import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit'; +import { VideoQualityPopover } from './VideoQualityPopover'; +export function StreamTile({ tile, large }) { + const videoRef = useRef(null); + const screenAudioRef = useRef(null); + const isDeafened = useVoiceStore((s) => s.isDeafened); + const outputVolume = useVoiceStore((s) => s.outputVolume); + 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); + const participants = useVoiceStore((s) => s.participants); + const { participant } = tile; + const isLocal = participant.isLocal; + const userId = participant.userId; + const isWatching = watchingStreams.has(userId); + const streamVolume = streamVolumes.get(userId) ?? 100; + const isStreamMuted = streamMutes.get(userId) ?? false; + const liveScreenTrack = tile.screenTrack?.readyState === 'live' ? tile.screenTrack : null; + // Quality badge state + const [qualityBadge, setQualityBadge] = useState(''); + // Context menu state + const [contextMenu, setContextMenu] = useState(null); + const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false); + // --- AUDIO PIPELINE --- + const screenBoostGainRef = useRef(null); + const screenBoostSourceRef = useRef(null); + // Track attachment + useEffect(() => { + const audioEl = screenAudioRef.current; + if (isLocal || !audioEl || !tile.screenAudioTrack) { + if (audioEl) + audioEl.srcObject = null; + return; + } + const stream = new MediaStream([tile.screenAudioTrack]); + if (audioEl.srcObject?.id !== stream.id) { + audioEl.srcObject = stream; + audioEl.play().catch(() => { }); + } + }, [tile.screenAudioTrack, isLocal]); + // Volume management with stream attenuation + useEffect(() => { + const audioEl = screenAudioRef.current; + if (isLocal || !audioEl || !tile.screenAudioTrack) + return; + const globalScale = outputVolume / 100; + const userScale = streamVolume / 100; + let finalVolume = globalScale * userScale; + if (isDeafened || isStreamMuted) { + audioEl.muted = true; + return; + } + // Stream attenuation: duck when someone is speaking + if (streamAttenuationEnabled) { + const someoneIsSpeaking = participants.some((p) => !p.isLocal && p.isSpeaking); + if (someoneIsSpeaking) { + finalVolume *= 1 - streamAttenuationStrength / 100; + } + } + const audioManager = AudioManager.getInstance(); + const ctx = audioManager.getContext(); + const isBoosting = finalVolume > 1.0; + const isContextReady = ctx && ctx.state === 'running'; + if (isBoosting && isContextReady) { + if (!screenBoostGainRef.current && ctx) { + const gain = ctx.createGain(); + const source = ctx.createMediaStreamSource(new MediaStream([tile.screenAudioTrack])); + source.connect(gain); + gain.connect(ctx.destination); + screenBoostGainRef.current = gain; + screenBoostSourceRef.current = source; + } + if (screenBoostGainRef.current && ctx) { + screenBoostGainRef.current.gain.setTargetAtTime(finalVolume, ctx.currentTime, 0.01); + } + audioEl.muted = true; + } + else { + if (screenBoostSourceRef.current) { + screenBoostSourceRef.current.disconnect(); + screenBoostSourceRef.current = null; + screenBoostGainRef.current = null; + } + audioEl.muted = false; + audioEl.volume = Math.min(finalVolume, 1.0); + if (audioEl.paused) { + audioEl.play().catch(() => { }); + } + } + return () => { + if (screenBoostSourceRef.current) { + screenBoostSourceRef.current.disconnect(); + screenBoostSourceRef.current = null; + screenBoostGainRef.current = null; + } + }; + }, [ + outputVolume, + streamVolume, + isStreamMuted, + isDeafened, + isLocal, + tile.screenAudioTrack, + streamAttenuationEnabled, + streamAttenuationStrength, + participants, + ]); + // --- VIDEO --- + useEffect(() => { + const videoEl = videoRef.current; + if (!videoEl) + return; + if (liveScreenTrack) { + videoEl.srcObject = new MediaStream([liveScreenTrack]); + } + else { + videoEl.srcObject = null; + } + }, [liveScreenTrack]); + // Quality badge (poll every 3s) + useEffect(() => { + if (!liveScreenTrack) { + setQualityBadge(''); + return; + } + const update = () => { + const settings = liveScreenTrack.getSettings(); + const h = settings.height ?? 0; + const fps = Math.round(settings.frameRate ?? 0); + if (h > 0 && fps > 0) { + setQualityBadge(`${h}P ${fps}FPS`); + } + else if (h > 0) { + setQualityBadge(`${h}P`); + } + }; + update(); + const interval = setInterval(update, 3000); + return () => clearInterval(interval); + }, [liveScreenTrack]); + // Force re-render on track end + const [, forceUpdate] = useState(0); + useEffect(() => { + if (!tile.screenTrack) + return; + const onEnded = () => forceUpdate((n) => n + 1); + tile.screenTrack.addEventListener('ended', onEnded); + return () => tile.screenTrack?.removeEventListener('ended', onEnded); + }, [tile.screenTrack]); + // --- CONTEXT MENU --- + const handleContextMenu = useCallback((e) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, []); + useEffect(() => { + if (!contextMenu) + return; + const close = () => setContextMenu(null); + window.addEventListener('click', close); + return () => window.removeEventListener('click', close); + }, [contextMenu]); + const handleWatch = useCallback(() => { + useVoiceStore.getState().watchStream(userId); + setStreamSubscription(getActiveRoom(), participant.identity, true); + }, [userId, participant.identity]); + const handleUnwatch = useCallback(() => { + useVoiceStore.getState().unwatchStream(userId); + setStreamSubscription(getActiveRoom(), participant.identity, false); + }, [userId, participant.identity]); + const handleStopStreaming = useCallback(async () => { + const room = getActiveRoom(); + if (room) { + await room.localParticipant.setScreenShareEnabled(false); + useVoiceStore.getState().toggleScreenShare(); + } + }, []); + const handleChangeStream = useCallback(async () => { + const room = getActiveRoom(); + if (room) { + await room.localParticipant.setScreenShareEnabled(false); + // Small delay then re-start to re-trigger the source picker + setTimeout(async () => { + await room.localParticipant.setScreenShareEnabled(true, { + audio: true, + }); + }, 200); + } + }, []); + const setStreamVolumeAction = useVoiceStore((s) => s.setStreamVolume); + const setStreamMuteAction = useVoiceStore((s) => s.setStreamMute); + 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: [!isLocal && _jsx("audio", { ref: screenAudioRef, autoPlay: true, playsInline: true }), 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 ? ( + /* Streamer context menu (own stream) */ + _jsxs(_Fragment, { children: [_jsxs("button", { onClick: () => { + handleStopStreaming(); + setContextMenu(null); + }, className: "w-full flex items-center gap-2 px-3 py-2 text-discord-red hover:bg-discord-red/10 rounded text-sm transition-colors", children: [_jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", 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("line", { x1: "4", y1: "4", x2: "20", y2: "20", stroke: "currentColor", strokeWidth: "2" })] }), "Stop Streaming"] }), _jsxs("button", { onClick: () => { + handleChangeStream(); + setContextMenu(null); + }, className: "w-full flex items-center gap-2 px-3 py-2 text-discord-text-secondary hover:bg-discord-modifier-hover rounded text-sm transition-colors", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" }) }), "Change Stream"] }), _jsx("div", { className: "border-t border-white/[0.06] my-1" }), _jsxs("div", { className: "px-3 py-1", children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-1 font-medium uppercase tracking-wider", children: "Stream Quality" }), _jsxs("div", { className: "relative", children: [_jsxs("button", { onClick: () => setQualityPopoverOpen(!qualityPopoverOpen), className: "w-full flex items-center justify-between px-2 py-1.5 text-sm text-discord-text-secondary hover:bg-discord-modifier-hover rounded transition-colors", children: [_jsx("span", { children: useVoiceStore.getState().videoQuality }), _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M7 10l5 5 5-5z" }) })] }), qualityPopoverOpen && (_jsx(VideoQualityPopover, { open: qualityPopoverOpen, onClose: () => setQualityPopoverOpen(false) }))] })] })] })) : ( + /* Viewer context menu (remote stream) */ + _jsxs(_Fragment, { children: [isWatching ? (_jsxs("button", { onClick: () => { + handleUnwatch(); + setContextMenu(null); + }, className: "w-full flex items-center gap-2 px-3 py-2 text-discord-text-secondary hover:bg-discord-modifier-hover rounded text-sm transition-colors", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" }) }), "Stop Watching"] })) : (_jsxs("button", { onClick: () => { + handleWatch(); + setContextMenu(null); + }, className: "w-full flex items-center gap-2 px-3 py-2 text-discord-text-secondary hover:bg-discord-modifier-hover rounded text-sm transition-colors", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" }) }), "Watch Stream"] })), _jsx("div", { className: "border-t border-white/[0.06] my-1" }), _jsxs("button", { onClick: () => { + setStreamMuteAction(userId, !isStreamMuted); + }, className: "w-full flex items-center justify-between px-3 py-2 text-discord-text-secondary hover:bg-discord-modifier-hover rounded text-sm transition-colors", children: [_jsx("span", { children: "Mute Stream" }), _jsx("div", { className: `w-4 h-4 rounded border flex items-center justify-center transition-colors ${isStreamMuted + ? 'bg-discord-blurple border-discord-blurple' + : 'border-discord-text-muted'}`, children: isStreamMuted && (_jsx("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "white", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) })) })] }), _jsxs("div", { className: "px-3 py-2", children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "Stream Volume" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9H3z" }) }), _jsx("input", { type: "range", min: "0", max: "200", value: streamVolume, onChange: (e) => setStreamVolumeAction(userId, parseInt(e.target.value)), className: "flex-1 accent-discord-blurple h-1" }), _jsxs("span", { className: "text-xs text-discord-text-secondary min-w-[32px] text-right", children: [streamVolume, "%"] })] })] }), _jsx("div", { className: "border-t border-white/[0.06] my-1" }), _jsxs("button", { onClick: () => setAttenuationEnabled(!streamAttenuationEnabled), className: "w-full flex items-center justify-between px-3 py-2 text-discord-text-secondary hover:bg-discord-modifier-hover rounded text-sm transition-colors", children: [_jsx("span", { children: "Stream Attenuation" }), _jsx("div", { className: `w-4 h-4 rounded border flex items-center justify-center transition-colors ${streamAttenuationEnabled + ? 'bg-discord-blurple border-discord-blurple' + : 'border-discord-text-muted'}`, children: streamAttenuationEnabled && (_jsx("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "white", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) })) })] }), streamAttenuationEnabled && (_jsxs("div", { className: "px-3 py-2", children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "Attenuation Strength" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "range", min: "0", max: "100", value: streamAttenuationStrength, onChange: (e) => setAttenuationStrength(parseInt(e.target.value)), className: "flex-1 accent-discord-blurple h-1" }), _jsxs("span", { className: "text-xs text-discord-text-secondary min-w-[32px] text-right", children: [streamAttenuationStrength, "%"] })] })] }))] })) }))] })); +} diff --git a/packages/web/src/components/voice/StreamTile.tsx b/packages/web/src/components/voice/StreamTile.tsx new file mode 100644 index 00000000..bee8e441 --- /dev/null +++ b/packages/web/src/components/voice/StreamTile.tsx @@ -0,0 +1,549 @@ +import React, { useRef, useEffect, useState, useCallback } from 'react'; +import { Avatar } from '../ui/Avatar'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { AudioManager } from '../../audio/AudioManager'; +import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit'; +import { VideoQualityPopover } from './VideoQualityPopover'; +import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit'; + +interface StreamTileProps { + tile: StreamTileType; + large?: boolean; +} + +export function StreamTile({ tile, large }: StreamTileProps) { + const videoRef = useRef(null); + const screenAudioRef = useRef(null); + + const isDeafened = useVoiceStore((s) => s.isDeafened); + const outputVolume = useVoiceStore((s) => s.outputVolume); + 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); + const participants = useVoiceStore((s) => s.participants); + + const { participant } = tile; + const isLocal = participant.isLocal; + const userId = participant.userId; + + const isWatching = watchingStreams.has(userId); + const streamVolume = streamVolumes.get(userId) ?? 100; + const isStreamMuted = streamMutes.get(userId) ?? false; + + const liveScreenTrack = tile.screenTrack?.readyState === 'live' ? tile.screenTrack : null; + + // Quality badge state + const [qualityBadge, setQualityBadge] = useState(''); + + // Context menu state + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false); + + // --- AUDIO PIPELINE --- + const screenBoostGainRef = useRef(null); + const screenBoostSourceRef = useRef(null); + + // Track attachment + useEffect(() => { + const audioEl = screenAudioRef.current; + if (isLocal || !audioEl || !tile.screenAudioTrack) { + if (audioEl) audioEl.srcObject = null; + return; + } + + const stream = new MediaStream([tile.screenAudioTrack]); + if ((audioEl.srcObject as MediaStream)?.id !== stream.id) { + audioEl.srcObject = stream; + audioEl.play().catch(() => {}); + } + }, [tile.screenAudioTrack, isLocal]); + + // Volume management with stream attenuation + useEffect(() => { + const audioEl = screenAudioRef.current; + if (isLocal || !audioEl || !tile.screenAudioTrack) return; + + const globalScale = outputVolume / 100; + const userScale = streamVolume / 100; + let finalVolume = globalScale * userScale; + + if (isDeafened || isStreamMuted) { + audioEl.muted = true; + return; + } + + // Stream attenuation: duck when someone is speaking + if (streamAttenuationEnabled) { + const someoneIsSpeaking = participants.some( + (p) => !p.isLocal && p.isSpeaking, + ); + if (someoneIsSpeaking) { + finalVolume *= 1 - streamAttenuationStrength / 100; + } + } + + const audioManager = AudioManager.getInstance(); + const ctx = audioManager.getContext(); + const isBoosting = finalVolume > 1.0; + const isContextReady = ctx && ctx.state === 'running'; + + if (isBoosting && isContextReady) { + if (!screenBoostGainRef.current && ctx) { + const gain = ctx.createGain(); + const source = ctx.createMediaStreamSource( + new MediaStream([tile.screenAudioTrack]), + ); + source.connect(gain); + gain.connect(ctx.destination); + screenBoostGainRef.current = gain; + screenBoostSourceRef.current = source; + } + if (screenBoostGainRef.current && ctx) { + screenBoostGainRef.current.gain.setTargetAtTime( + finalVolume, + ctx.currentTime, + 0.01, + ); + } + audioEl.muted = true; + } else { + if (screenBoostSourceRef.current) { + screenBoostSourceRef.current.disconnect(); + screenBoostSourceRef.current = null; + screenBoostGainRef.current = null; + } + audioEl.muted = false; + audioEl.volume = Math.min(finalVolume, 1.0); + if (audioEl.paused) { + audioEl.play().catch(() => {}); + } + } + + return () => { + if (screenBoostSourceRef.current) { + screenBoostSourceRef.current.disconnect(); + screenBoostSourceRef.current = null; + screenBoostGainRef.current = null; + } + }; + }, [ + outputVolume, + streamVolume, + isStreamMuted, + isDeafened, + isLocal, + tile.screenAudioTrack, + streamAttenuationEnabled, + streamAttenuationStrength, + participants, + ]); + + // --- VIDEO --- + useEffect(() => { + const videoEl = videoRef.current; + if (!videoEl) return; + if (liveScreenTrack) { + videoEl.srcObject = new MediaStream([liveScreenTrack]); + } else { + videoEl.srcObject = null; + } + }, [liveScreenTrack]); + + // Quality badge (poll every 3s) + useEffect(() => { + if (!liveScreenTrack) { + setQualityBadge(''); + return; + } + const update = () => { + const settings = liveScreenTrack.getSettings(); + const h = settings.height ?? 0; + const fps = Math.round(settings.frameRate ?? 0); + if (h > 0 && fps > 0) { + setQualityBadge(`${h}P ${fps}FPS`); + } else if (h > 0) { + setQualityBadge(`${h}P`); + } + }; + update(); + const interval = setInterval(update, 3000); + return () => clearInterval(interval); + }, [liveScreenTrack]); + + // Force re-render on track end + const [, forceUpdate] = useState(0); + useEffect(() => { + if (!tile.screenTrack) return; + const onEnded = () => forceUpdate((n) => n + 1); + tile.screenTrack.addEventListener('ended', onEnded); + return () => tile.screenTrack?.removeEventListener('ended', onEnded); + }, [tile.screenTrack]); + + // --- CONTEXT MENU --- + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, + [], + ); + + useEffect(() => { + if (!contextMenu) return; + const close = () => setContextMenu(null); + window.addEventListener('click', close); + return () => window.removeEventListener('click', close); + }, [contextMenu]); + + const handleWatch = useCallback(() => { + useVoiceStore.getState().watchStream(userId); + setStreamSubscription(getActiveRoom(), participant.identity, true); + }, [userId, participant.identity]); + + const handleUnwatch = useCallback(() => { + useVoiceStore.getState().unwatchStream(userId); + setStreamSubscription(getActiveRoom(), participant.identity, false); + }, [userId, participant.identity]); + + const handleStopStreaming = useCallback(async () => { + const room = getActiveRoom(); + if (room) { + await room.localParticipant.setScreenShareEnabled(false); + useVoiceStore.getState().toggleScreenShare(); + } + }, []); + + const handleChangeStream = useCallback(async () => { + const room = getActiveRoom(); + if (room) { + await room.localParticipant.setScreenShareEnabled(false); + // Small delay then re-start to re-trigger the source picker + setTimeout(async () => { + await room.localParticipant.setScreenShareEnabled(true, { + audio: true, + }); + }, 200); + } + }, []); + + const setStreamVolumeAction = useVoiceStore((s) => s.setStreamVolume); + const setStreamMuteAction = useVoiceStore((s) => s.setStreamMute); + const setAttenuationEnabled = useVoiceStore((s) => s.setStreamAttenuationEnabled); + const setAttenuationStrength = useVoiceStore((s) => s.setStreamAttenuationStrength); + + const hasVideo = liveScreenTrack !== null; + + return ( +
+ {/* Screen share audio (remote only) */} + {!isLocal &&