feat: implement Discord-like stream widget system with separate tiles

Streams now appear as separate tiles in the voice grid alongside the
user's camera/avatar tile, matching Discord's model. Each stream tile
has independent volume, mute, watch/unwatch controls, quality badges,
and stream attenuation that ducks audio when someone speaks.
This commit is contained in:
Jannis Braun
2026-02-20 03:59:28 +01:00
parent a8656e6a3b
commit 1aa40cca15
23 changed files with 2216 additions and 482 deletions
+164
View File
@@ -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;
}
}
+82 -17
View File
@@ -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);
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;
}
else if (!activeDmCall) {
disconnectVoice();
}
}, [currentVoiceChannelId, connectVoice, disconnectVoice, activeDmCall]);
// Manage DM call connection
useEffect(() => {
console.log(`[AppLayout] Switching/Connecting to: ${targetChannelId}`);
lastAttemptedRef.current = targetChannelId;
if (activeDmCall) {
connectDmVoice(activeDmCall.dmChannelId);
await connectDmVoice(activeDmCall.dmChannelId);
}
else if (!currentVoiceChannelId) {
disconnectVoice();
else {
await connectVoice(targetChannelId);
}
}, [activeDmCall, connectDmVoice, disconnectVoice, currentVoiceChannelId]);
}
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 })] }))] }));
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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);
}
};
const handleEndCall = () => {
if (activeDmCall) {
@@ -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);
@@ -21,9 +21,12 @@ interface SelectedStream {
function selectPipStream(
participants: ParticipantInfo[],
focusedId: string | null,
watchingStreams: Set<string>,
): 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)
@@ -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;
}
@@ -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, "%"] })] })] }))] })) }))] }));
}
@@ -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<HTMLVideoElement>(null);
const screenAudioRef = useRef<HTMLAudioElement>(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<string>('');
// Context menu state
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false);
// --- AUDIO PIPELINE ---
const screenBoostGainRef = useRef<GainNode | null>(null);
const screenBoostSourceRef = useRef<MediaStreamAudioSourceNode | null>(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 (
<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}
>
{/* Screen share audio (remote only) */}
{!isLocal && <audio ref={screenAudioRef} autoPlay playsInline />}
{hasVideo && isWatching ? (
<video
ref={videoRef}
autoPlay
playsInline
muted={isLocal}
className="w-full h-full object-contain bg-black"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]">
<div className="relative">
<Avatar src={null} name={participant.username} size={large ? 80 : 48} />
</div>
<div className="text-center px-4">
<p className="text-discord-text-primary text-sm font-semibold">
{participant.username} is streaming
</p>
{!isLocal && (
<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"
>
Watch Stream
</button>
)}
</div>
</div>
)}
{/* LIVE badge — top left */}
<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">
LIVE
</div>
{/* Quality badge — top right */}
{qualityBadge && hasVideo && (
<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">
{qualityBadge}
</div>
)}
{/* Bottom overlay */}
<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">
<div className="flex items-center gap-1.5 min-w-0">
{/* Screen icon */}
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
className="text-white/70 flex-shrink-0"
>
<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" />
</svg>
<span
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
>
{participant.username}
</span>
{isLocal && (
<span className="text-[10px] text-white/40 font-medium">(you)</span>
)}
</div>
</div>
{/* Context Menu */}
{contextMenu && (
<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()}
>
{isLocal ? (
/* Streamer context menu (own stream) */
<>
<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"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<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" />
<line
x1="4"
y1="4"
x2="20"
y2="20"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
Stop Streaming
</button>
<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"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<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" />
</svg>
Change Stream
</button>
<div className="border-t border-white/[0.06] my-1" />
<div className="px-3 py-1">
<div className="text-xs text-discord-text-muted mb-1 font-medium uppercase tracking-wider">
Stream Quality
</div>
<div className="relative">
<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"
>
<span>{useVoiceStore.getState().videoQuality}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7 10l5 5 5-5z" />
</svg>
</button>
{qualityPopoverOpen && (
<VideoQualityPopover
open={qualityPopoverOpen}
onClose={() => setQualityPopoverOpen(false)}
/>
)}
</div>
</div>
</>
) : (
/* Viewer context menu (remote stream) */
<>
{isWatching ? (
<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"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<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" />
</svg>
Stop Watching
</button>
) : (
<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"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<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" />
</svg>
Watch Stream
</button>
)}
<div className="border-t border-white/[0.06] my-1" />
{/* Mute toggle */}
<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"
>
<span>Mute Stream</span>
<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'
}`}
>
{isStreamMuted && (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="white"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
)}
</div>
</button>
{/* Stream Volume slider */}
<div className="px-3 py-2">
<div className="text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider">
Stream Volume
</div>
<div className="flex items-center gap-2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
className="text-discord-text-muted flex-shrink-0"
>
<path d="M3 9v6h4l5 5V4L7 9H3z" />
</svg>
<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"
/>
<span className="text-xs text-discord-text-secondary min-w-[32px] text-right">
{streamVolume}%
</span>
</div>
</div>
<div className="border-t border-white/[0.06] my-1" />
{/* Stream Attenuation toggle */}
<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"
>
<span>Stream Attenuation</span>
<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'
}`}
>
{streamAttenuationEnabled && (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="white"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
)}
</div>
</button>
{/* Attenuation Strength slider */}
{streamAttenuationEnabled && (
<div className="px-3 py-2">
<div className="text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider">
Attenuation Strength
</div>
<div className="flex items-center gap-2">
<input
type="range"
min="0"
max="100"
value={streamAttenuationStrength}
onChange={(e) =>
setAttenuationStrength(parseInt(e.target.value))
}
className="flex-1 accent-discord-blurple h-1"
/>
<span className="text-xs text-discord-text-secondary min-w-[32px] text-right">
{streamAttenuationStrength}%
</span>
</div>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
@@ -1,5 +1,6 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useVoiceStore } from '../../stores/voiceStore';
import { useAuthStore } from '../../stores/authStore';
const EMPTY_VOICE_USERS = [];
import { useServerStore } from '../../stores/serverStore';
import { Avatar } from '../ui/Avatar';
@@ -7,7 +8,10 @@ export function VoiceChannel({ channelId, channelName, onClick }) {
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants);
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
const localIsMuted = useVoiceStore((s) => s.isMuted);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const currentUserId = useAuthStore((s) => s.user?.id);
const members = useServerStore((s) => s.members);
const isActive = currentVoiceChannel === channelId;
return (_jsxs("div", { children: [_jsxs("button", { onClick: onClick, className: `w-full flex items-center gap-1.5 px-2 h-8 rounded-[4px] group transition-colors ${isActive
@@ -15,14 +19,20 @@ export function VoiceChannel({ channelId, channelName, onClick }) {
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'}`, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", 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.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" }) }), _jsx("span", { className: "truncate text-[15px] font-medium", children: channelName })] }), voiceUsers.length > 0 && (_jsx("div", { className: "ml-6 mt-0.5 space-y-0.5", children: voiceUsers.map((userId) => {
const member = members.find(m => m.userId === userId);
const participant = participants.find(p => p.userId === userId);
const voiceState = voiceUserStates.get(userId);
const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId;
const avatar = member?.user.avatar ?? null;
const status = member?.user.status;
const isParticipantDeafened = voiceState?.isDeafened ?? participant?.isDeafened ?? false;
const isMuted = voiceState?.isMuted ?? participant?.isMuted ?? false;
// Resolve status: for local user use store directly, for remote users
// try LiveKit participant first, then fall back to WebSocket voiceUserStates
const wsStatus = voiceUserStates.get(userId);
const isParticipantDeafened = userId === currentUserId
? localIsDeafened
: (participant?.isDeafened ?? wsStatus?.isDeafened ?? false);
const isMuted = userId === currentUserId
? localIsMuted
: (participant?.isMuted ?? wsStatus?.isMuted ?? false);
const hasCamera = participant?.isCameraOn ?? false;
const isScreenSharing = participant?.isScreenSharing ?? false;
return (_jsxs("div", { className: "flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-modifier-hover transition-colors", children: [_jsx(Avatar, { src: avatar, name: displayName, size: 20, status: status }), _jsx("span", { className: "text-[13px] text-discord-text-secondary truncate flex-1 min-w-0", children: displayName }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [isParticipantDeafened ? (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-red", 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" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) : isMuted ? (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-red", 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" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) : null, hasCamera && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" }) })), isScreenSharing && (_jsx("span", { className: "bg-discord-green text-white text-[9px] font-bold px-1 rounded leading-[14px]", children: "LIVE" }))] })] }, userId));
return (_jsxs("div", { className: "flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-modifier-hover transition-colors", children: [_jsx(Avatar, { src: avatar, name: displayName, size: 20, status: status }), _jsx("span", { className: "text-[13px] text-discord-text-secondary truncate flex-1 min-w-0", children: displayName }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [isMuted && (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-red", 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" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })), isParticipantDeafened && (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-red", 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" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })), hasCamera && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" }) })), isScreenSharing && (_jsx("span", { className: "bg-discord-red text-white text-[9px] font-bold px-1 rounded leading-[14px]", children: "LIVE" }))] })] }, userId));
}) }))] }));
}
@@ -90,7 +90,7 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
</svg>
)}
{isScreenSharing && (
<span className="bg-discord-green text-white text-[9px] font-bold px-1 rounded leading-[14px]">LIVE</span>
<span className="bg-discord-red text-white text-[9px] font-bold px-1 rounded leading-[14px]">LIVE</span>
)}
</div>
</div>
@@ -33,41 +33,31 @@ export function VoiceControlBar() {
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
const [qualityOpen, setQualityOpen] = useState(false);
const handleMute = React.useCallback(async () => {
const room = getActiveRoom();
if (room) {
try {
await room.localParticipant.setMicrophoneEnabled(isMuted);
}
catch (err) {
console.error('[VoiceControlBar] Failed to toggle mic:', err);
}
}
toggleMic();
}, [isMuted, toggleMic]);
// Broadcast via WebSocket so sidebar shows status without joining
wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened });
}, [isMuted, isDeafened, toggleMic]);
const handleDeafen = React.useCallback(async () => {
const room = getActiveRoom();
const willDeafen = !isDeafened;
// Update store FIRST so updateParticipants reads correct state
toggleDeafen();
if (willDeafen && !isMuted)
toggleMic();
if (!willDeafen && isMuted)
toggleMic();
// Broadcast via WebSocket
wsSend({ type: 'voice_status', isMuted: willDeafen, isDeafened: willDeafen });
if (room) {
try {
const willDeafen = !isDeafened;
if (willDeafen) {
await room.localParticipant.setMicrophoneEnabled(false);
room.remoteParticipants.forEach((p) => p.setVolume(0));
if (!isMuted)
toggleMic();
}
else {
const outputVolume = useVoiceStore.getState().outputVolume;
room.remoteParticipants.forEach((p) => p.setVolume(outputVolume / 100));
await room.localParticipant.setMicrophoneEnabled(true);
if (isMuted)
toggleMic();
}
// Broadcast deafen state via LiveKit data channel for in-room users
const encoder = new TextEncoder();
room.localParticipant.publishData(encoder.encode(JSON.stringify({ type: 'deafen', deafened: willDeafen })), { reliable: true }).catch(() => { });
}
catch (err) {
console.error('[VoiceControlBar] Failed to toggle deafen:', err);
}
}
toggleDeafen();
}, [isDeafened, isMuted, toggleDeafen, toggleMic]);
const handleCamera = async () => {
const room = getActiveRoom();
@@ -102,7 +92,12 @@ export function VoiceControlBar() {
if (!room)
return;
try {
await room.localParticipant.setScreenShareEnabled(!isScreenSharing);
if (!isScreenSharing) {
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
}
else {
await room.localParticipant.setScreenShareEnabled(false);
}
toggleScreenShare();
}
catch (err) {
@@ -120,14 +115,6 @@ export function VoiceControlBar() {
}
};
const handleFullscreen = () => {
if (!voiceFullscreen) {
document.documentElement.requestFullscreen?.().catch(() => { });
}
else {
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => { });
}
}
toggleVoiceFullscreen();
};
// Keyboard shortcuts
@@ -42,7 +42,12 @@ export function VoiceControls() {
if (!room)
return;
try {
await room.localParticipant.setScreenShareEnabled(!isScreenSharing);
if (!isScreenSharing) {
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
}
else {
await room.localParticipant.setScreenShareEnabled(false);
}
toggleScreenShare();
}
catch (err) {
+39 -26
View File
@@ -1,49 +1,62 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useMemo } from 'react';
import { VoiceUser } from './VoiceUser';
import { StreamTile } from './StreamTile';
import { useVoiceStore } from '../../stores/voiceStore';
import { deriveGridTiles } from '../../hooks/useLiveKit';
export function VoiceGrid({ participants }) {
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
const prevScreenSharerRef = useRef(null);
// Auto-focus when someone starts screen sharing
const prevStreamKeysRef = useRef(new Set());
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
// Auto-focus when a new stream tile appears
useEffect(() => {
const screenSharer = participants.find((p) => p.screenTrack?.readyState === 'live');
const screenSharerId = screenSharer?.identity ?? null;
if (screenSharerId && screenSharerId !== prevScreenSharerRef.current) {
// New screen share started — auto-focus
setFocusedParticipant(screenSharerId);
const currentStreamKeys = new Set(tiles
.filter((t) => t.kind === 'stream' && t.screenTrack?.readyState === 'live')
.map((t) => t.key));
// Find newly appeared stream keys
for (const key of currentStreamKeys) {
if (!prevStreamKeysRef.current.has(key)) {
// New stream tile — auto-focus it
setFocusedParticipant(key);
break;
}
else if (!screenSharerId && prevScreenSharerRef.current) {
// Screen share ended — unfocus if we were focused on the sharer
if (focusedParticipantId === prevScreenSharerRef.current) {
}
// If the focused tile was a stream tile that no longer exists, unfocus
if (focusedParticipantId &&
focusedParticipantId.endsWith(':stream') &&
!currentStreamKeys.has(focusedParticipantId)) {
setFocusedParticipant(null);
}
}
prevScreenSharerRef.current = screenSharerId;
}, [participants, focusedParticipantId, setFocusedParticipant]);
if (participants.length === 0) {
prevStreamKeysRef.current = currentStreamKeys;
}, [tiles, focusedParticipantId, setFocusedParticipant]);
if (tiles.length === 0) {
return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsxs("div", { className: "text-center", children: [_jsx("svg", { width: "48", height: "48", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted/40 mx-auto mb-3", 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("p", { className: "text-discord-text-muted text-sm", children: "Waiting for others to join..." })] }) }));
}
const focusedParticipant = focusedParticipantId
? participants.find((p) => p.identity === focusedParticipantId)
const focusedTile = focusedParticipantId
? tiles.find((t) => t.key === focusedParticipantId)
: null;
// Focus mode: one large tile + sidebar strip
if (focusedParticipant) {
const otherParticipants = participants.filter((p) => p.identity !== focusedParticipantId);
return (_jsxs("div", { className: "flex-1 flex overflow-hidden", children: [_jsxs("div", { className: "flex-1 p-2 relative", children: [_jsx(VoiceUser, { participant: focusedParticipant, large: true }), _jsxs("button", { onClick: () => setFocusedParticipant(null), className: "absolute top-4 right-4 z-10 px-3 py-1.5 bg-black/60 hover:bg-black/80 rounded-lg flex items-center gap-2 text-white/70 hover:text-white transition-colors", title: "Back to grid view", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" }) }), _jsx("span", { className: "text-xs font-medium", children: "Grid" })] })] }), otherParticipants.length > 0 && (_jsx("div", { className: "w-[200px] flex-shrink-0 overflow-y-auto p-2 space-y-2 bg-[#111214]/50", children: otherParticipants.map((p) => (_jsx("div", { onClick: () => setFocusedParticipant(p.identity), className: "cursor-pointer hover:opacity-80 transition-opacity", children: _jsx(VoiceUser, { participant: p }) }, p.identity))) }))] }));
// Render a single tile polymorphically
const renderTile = (tile, large) => tile.kind === 'user' ? (_jsx(VoiceUser, { tile: tile, large: large })) : (_jsx(StreamTile, { tile: tile, large: large }));
// Focus mode: one large tile + bottom strip
if (focusedTile) {
const otherTiles = tiles.filter((t) => t.key !== focusedParticipantId);
return (_jsxs("div", { className: "flex-1 flex flex-col overflow-hidden relative", children: [_jsxs("div", { className: "flex-1 p-2 min-h-0 cursor-pointer", onClick: () => setFocusedParticipant(null), title: "Click to return to grid view", children: [renderTile(focusedTile, true), _jsxs("button", { onClick: (e) => {
e.stopPropagation();
setFocusedParticipant(null);
}, className: "absolute top-4 right-4 z-10 px-3 py-1.5 bg-black/60 hover:bg-black/80 rounded-lg flex items-center gap-2 text-white/70 hover:text-white transition-colors", title: "Back to grid view", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" }) }), _jsx("span", { className: "text-xs font-medium", children: "Grid" })] })] }), otherTiles.length > 0 && (_jsx("div", { className: "h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-[#111214]/50 overflow-x-auto no-scrollbar", children: otherTiles.map((t) => (_jsx("div", { onClick: () => setFocusedParticipant(t.key), className: "h-full aspect-video flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity", children: renderTile(t) }, t.key))) }))] }));
}
// Default grid mode
const gridClass = (() => {
if (participants.length === 1)
if (tiles.length === 1)
return 'grid-cols-1 max-w-2xl mx-auto';
if (participants.length === 2)
if (tiles.length === 2)
return 'grid-cols-2 max-w-4xl mx-auto';
if (participants.length <= 4)
if (tiles.length <= 4)
return 'grid-cols-2';
if (participants.length <= 9)
if (tiles.length <= 9)
return 'grid-cols-3';
return 'grid-cols-4';
})();
return (_jsx("div", { className: "flex-1 p-3 overflow-auto flex items-center", children: _jsx("div", { className: `grid ${gridClass} gap-2 w-full`, children: participants.map((p) => (_jsx("div", { onClick: () => setFocusedParticipant(p.identity), className: "cursor-pointer hover:opacity-90 transition-opacity", children: _jsx(VoiceUser, { participant: p }) }, p.identity))) }) }));
return (_jsx("div", { className: "flex-1 p-3 overflow-auto flex items-center min-h-0", children: _jsx("div", { className: `grid ${gridClass} gap-2 w-full max-h-full`, children: tiles.map((t) => (_jsx("div", { onClick: () => setFocusedParticipant(t.key), className: "cursor-pointer hover:opacity-90 transition-opacity h-full", children: renderTile(t) }, t.key))) }) }));
}
+67 -39
View File
@@ -1,7 +1,9 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useMemo } from 'react';
import { VoiceUser } from './VoiceUser';
import { StreamTile } from './StreamTile';
import { useVoiceStore } from '../../stores/voiceStore';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
import { deriveGridTiles } from '../../hooks/useLiveKit';
import type { ParticipantInfo, GridTile } from '../../hooks/useLiveKit';
interface VoiceGridProps {
participants: ParticipantInfo[];
@@ -10,28 +12,43 @@ interface VoiceGridProps {
export function VoiceGrid({ participants }: VoiceGridProps) {
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
const prevScreenSharerRef = useRef<string | null>(null);
const prevStreamKeysRef = useRef<Set<string>>(new Set());
// Auto-focus when someone starts screen sharing
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
// Auto-focus when a new stream tile appears
useEffect(() => {
const screenSharer = participants.find(
(p) => p.screenTrack?.readyState === 'live',
const currentStreamKeys = new Set(
tiles
.filter(
(t): t is GridTile & { kind: 'stream' } =>
t.kind === 'stream' && t.screenTrack?.readyState === 'live',
)
.map((t) => t.key),
);
const screenSharerId = screenSharer?.identity ?? null;
if (screenSharerId && screenSharerId !== prevScreenSharerRef.current) {
// New screen share started — auto-focus
setFocusedParticipant(screenSharerId);
} else if (!screenSharerId && prevScreenSharerRef.current) {
// Screen share ended — unfocus if we were focused on the sharer
if (focusedParticipantId === prevScreenSharerRef.current) {
// Find newly appeared stream keys
for (const key of currentStreamKeys) {
if (!prevStreamKeysRef.current.has(key)) {
// New stream tile — auto-focus it
setFocusedParticipant(key);
break;
}
}
// If the focused tile was a stream tile that no longer exists, unfocus
if (
focusedParticipantId &&
focusedParticipantId.endsWith(':stream') &&
!currentStreamKeys.has(focusedParticipantId)
) {
setFocusedParticipant(null);
}
}
prevScreenSharerRef.current = screenSharerId;
}, [participants, focusedParticipantId, setFocusedParticipant]);
if (participants.length === 0) {
prevStreamKeysRef.current = currentStreamKeys;
}, [tiles, focusedParticipantId, setFocusedParticipant]);
if (tiles.length === 0) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
@@ -52,15 +69,21 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
);
}
const focusedParticipant = focusedParticipantId
? participants.find((p) => p.identity === focusedParticipantId)
const focusedTile = focusedParticipantId
? tiles.find((t) => t.key === focusedParticipantId)
: null;
// Focus mode: one large tile + bottom strip
if (focusedParticipant) {
const otherParticipants = participants.filter(
(p) => p.identity !== focusedParticipantId,
// Render a single tile polymorphically
const renderTile = (tile: GridTile, large?: boolean) =>
tile.kind === 'user' ? (
<VoiceUser tile={tile} large={large} />
) : (
<StreamTile tile={tile} large={large} />
);
// Focus mode: one large tile + bottom strip
if (focusedTile) {
const otherTiles = tiles.filter((t) => t.key !== focusedParticipantId);
return (
<div className="flex-1 flex flex-col overflow-hidden relative">
{/* Main focused view */}
@@ -69,7 +92,7 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
onClick={() => setFocusedParticipant(null)}
title="Click to return to grid view"
>
<VoiceUser participant={focusedParticipant} large />
{renderTile(focusedTile, true)}
{/* Back to grid button */}
<button
onClick={(e) => {
@@ -79,23 +102,28 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
className="absolute top-4 right-4 z-10 px-3 py-1.5 bg-black/60 hover:bg-black/80 rounded-lg flex items-center gap-2 text-white/70 hover:text-white transition-colors"
title="Back to grid view"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" />
</svg>
<span className="text-xs font-medium">Grid</span>
</button>
</div>
{/* Bottom strip of other participants */}
{otherParticipants.length > 0 && (
{/* Bottom strip of other tiles */}
{otherTiles.length > 0 && (
<div className="h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-[#111214]/50 overflow-x-auto no-scrollbar">
{otherParticipants.map((p) => (
{otherTiles.map((t) => (
<div
key={p.identity}
onClick={() => setFocusedParticipant(p.identity)}
key={t.key}
onClick={() => setFocusedParticipant(t.key)}
className="h-full aspect-video flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity"
>
<VoiceUser participant={p} />
{renderTile(t)}
</div>
))}
</div>
@@ -106,23 +134,23 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
// Default grid mode
const gridClass = (() => {
if (participants.length === 1) return 'grid-cols-1 max-w-2xl mx-auto';
if (participants.length === 2) return 'grid-cols-2 max-w-4xl mx-auto';
if (participants.length <= 4) return 'grid-cols-2';
if (participants.length <= 9) return 'grid-cols-3';
if (tiles.length === 1) return 'grid-cols-1 max-w-2xl mx-auto';
if (tiles.length === 2) return 'grid-cols-2 max-w-4xl mx-auto';
if (tiles.length <= 4) return 'grid-cols-2';
if (tiles.length <= 9) return 'grid-cols-3';
return 'grid-cols-4';
})();
return (
<div className="flex-1 p-3 overflow-auto flex items-center min-h-0">
<div className={`grid ${gridClass} gap-2 w-full max-h-full`}>
{participants.map((p) => (
{tiles.map((t) => (
<div
key={p.identity}
onClick={() => setFocusedParticipant(p.identity)}
key={t.key}
onClick={() => setFocusedParticipant(t.key)}
className="cursor-pointer hover:opacity-90 transition-opacity h-full"
>
<VoiceUser participant={p} />
{renderTile(t)}
</div>
))}
</div>
+101 -38
View File
@@ -2,31 +2,116 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useRef, useEffect, useState, useCallback } from 'react';
import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore';
export function VoiceUser({ participant, large }) {
import { AudioManager } from '../../audio/AudioManager';
export function VoiceUser({ tile, large }) {
const videoRef = useRef(null);
const audioRef = useRef(null);
const { participant } = tile;
const isDeafened = useVoiceStore((s) => s.isDeafened);
const outputVolume = useVoiceStore((s) => s.outputVolume);
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
const [, forceUpdate] = useState(0);
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
const isLocal = participant.isLocal;
// Determine active video track — prioritize screen share, check both enabled flag and readyState
const liveScreen = participant.isScreenSharing && participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null;
const liveCamera = participant.isCameraOn && participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null;
const activeVideoTrack = liveScreen ?? liveCamera;
const hasVideo = activeVideoTrack !== null;
const isScreenShare = liveScreen !== null;
// Listen for track 'ended' events to force re-render when a stream stops
// --- AUDIO PIPELINE: NATIVE FIRST ---
// Refs for the optional boost pipeline
const boostGainRef = useRef(null);
const boostSourceRef = useRef(null);
// 1. Basic Track Attachment (The Rock-Solid Foundation)
useEffect(() => {
const tracks = [participant.videoTrack, participant.screenTrack].filter((t) => t !== null);
if (tracks.length === 0)
const audioEl = audioRef.current;
if (isLocal || !audioEl || !tile.audioTrack)
return;
// Direct attachment.
const stream = new MediaStream([tile.audioTrack]);
// Only update if changed to prevent interruptions
if (audioEl.srcObject?.id !== stream.id) {
audioEl.srcObject = stream;
// Aggressive play attempt for Chrome
const tryPlay = async () => {
try {
await audioEl.play();
}
catch (err) {
console.warn('[Audio] Autoplay blocked, retrying...', err);
}
};
tryPlay();
}
}, [tile.audioTrack, isLocal]);
// 2. Volume Management (Hybrid)
useEffect(() => {
const audioEl = audioRef.current;
if (isLocal || !audioEl || !tile.audioTrack)
return;
const globalScale = outputVolume / 100;
const userScale = perUserVolume / 100;
const finalVolume = globalScale * userScale;
if (isDeafened) {
audioEl.muted = true;
return;
}
// Logic:
// If we are boosting (>100%) AND context is running, use Web Audio.
// Otherwise, stick to the native element for maximum reliability.
const audioManager = AudioManager.getInstance();
const ctx = audioManager.getContext();
const isBoosting = finalVolume > 1.0;
const isContextReady = ctx && ctx.state === 'running';
if (isBoosting && isContextReady) {
// --- BOOST MODE (>100%) ---
// Setup pipeline if missing
if (!boostGainRef.current && ctx) {
const gain = ctx.createGain();
const source = ctx.createMediaStreamSource(new MediaStream([tile.audioTrack]));
source.connect(gain);
gain.connect(ctx.destination);
boostGainRef.current = gain;
boostSourceRef.current = source;
}
// Apply boosted gain
if (boostGainRef.current && ctx) {
boostGainRef.current.gain.setTargetAtTime(finalVolume, ctx.currentTime, 0.01);
}
// MUTE the element so we don't double audio
audioEl.muted = true;
}
else {
// --- STANDARD MODE (0% - 100%) ---
// Clean up boost pipeline if it exists
if (boostSourceRef.current) {
boostSourceRef.current.disconnect();
boostSourceRef.current = null;
boostGainRef.current = null;
}
// Use the element
audioEl.muted = false;
audioEl.volume = Math.min(finalVolume, 1.0);
// Ensure it's playing (in case it was paused/blocked earlier)
if (audioEl.paused) {
audioEl.play().catch(() => { });
}
}
return () => {
if (boostSourceRef.current) {
boostSourceRef.current.disconnect();
boostSourceRef.current = null;
boostGainRef.current = null;
}
};
}, [outputVolume, perUserVolume, isDeafened, isLocal, tile.audioTrack]);
// --- VIDEO & UI ---
const activeVideoTrack = tile.videoTrack;
const hasVideo = activeVideoTrack !== null;
// Force re-render when tracks end/mute
useEffect(() => {
if (!tile.videoTrack)
return;
const onEnded = () => forceUpdate((n) => n + 1);
tracks.forEach((t) => t.addEventListener('ended', onEnded));
return () => tracks.forEach((t) => t.removeEventListener('ended', onEnded));
}, [participant.videoTrack, participant.screenTrack]);
// Attach video track
tile.videoTrack.addEventListener('ended', onEnded);
return () => tile.videoTrack?.removeEventListener('ended', onEnded);
}, [tile.videoTrack]);
// Attach Video
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl)
@@ -38,29 +123,7 @@ export function VoiceUser({ participant, large }) {
videoEl.srcObject = null;
}
}, [activeVideoTrack]);
// Attach audio track
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl || !participant.audioTrack)
return;
audioEl.srcObject = new MediaStream([participant.audioTrack]);
}, [participant.audioTrack]);
// Apply volume: combine outputVolume and per-participant volume, or mute if deafened
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl)
return;
if (isDeafened) {
audioEl.volume = 0;
audioEl.muted = true;
}
else {
const combined = (outputVolume / 100) * (perUserVolume / 100);
audioEl.volume = Math.min(Math.max(combined, 0), 1);
audioEl.muted = false;
}
}, [isDeafened, outputVolume, perUserVolume]);
// Volume context menu
// Context Menu
const [volumeMenu, setVolumeMenu] = useState(null);
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
const handleContextMenu = useCallback((e) => {
@@ -78,5 +141,5 @@ export function VoiceUser({ participant, large }) {
}, [volumeMenu]);
return (_jsxs("div", { className: `relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${participant.isSpeaking
? 'ring-[3px] ring-discord-green shadow-[0_0_12px_rgba(35,165,90,0.25)]'
: 'ring-1 ring-white/[0.06] hover:ring-white/10'} ${large ? 'h-full' : ''}`, style: large ? undefined : { aspectRatio: '16/9', minHeight: '140px' }, onContextMenu: handleContextMenu, children: [!isLocal && _jsx("audio", { ref: audioRef, autoPlay: true }), hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: `w-full h-full ${large || isScreenShare ? 'object-contain bg-black' : 'object-cover'}` })) : (_jsx("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: _jsxs("div", { className: "relative", children: [_jsx(Avatar, { src: null, name: participant.username, size: large ? 100 : 64 }), participant.isSpeaking && (_jsx("div", { className: "absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" }))] }) })), isScreenShare && hasVideo && (_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" })), _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 justify-between", children: [_jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_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)" }))] }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [participant.isDeafened ? (_jsx("div", { className: "w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", 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" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })) : participant.isMuted ? (_jsx("div", { className: "w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })) : null, participant.isScreenSharing && !isScreenShare && (_jsx("div", { className: "w-5 h-5 bg-discord-blurple/90 rounded-full flex items-center justify-center", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20Z" }) }) }))] })] }) }), volumeMenu && !isLocal && (_jsxs("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px] border border-white/[0.06]", style: { left: volumeMenu.x, top: volumeMenu.y }, onClick: (e) => e.stopPropagation(), children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "User 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: perUserVolume, onChange: (e) => setParticipantVolume(participant.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: [perUserVolume, "%"] })] })] }))] }));
: '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: audioRef, autoPlay: true, playsInline: true }), hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: `w-full h-full ${large ? 'object-contain bg-black' : 'object-cover'}` })) : (_jsx("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: _jsxs("div", { className: "relative", children: [_jsx(Avatar, { src: null, name: participant.username, size: large ? 100 : 64 }), participant.isSpeaking && (_jsx("div", { className: "absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" }))] }) })), _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 justify-between", children: [_jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_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)" }))] }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })), (isLocal ? isDeafened : participant.isDeafened) && (_jsx("div", { className: "w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", 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" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) }))] })] }) }), volumeMenu && !isLocal && (_jsxs("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px] border border-white/[0.06]", style: { left: volumeMenu.x, top: volumeMenu.y }, onClick: (e) => e.stopPropagation(), children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "User 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: perUserVolume, onChange: (e) => setParticipantVolume(participant.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: [perUserVolume, "%"] })] })] }))] }));
}
+78 -118
View File
@@ -2,18 +2,18 @@ import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore';
import { AudioManager } from '../../audio/AudioManager';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
import type { UserTile } from '../../hooks/useLiveKit';
interface VoiceUserProps {
participant: ParticipantInfo;
tile: UserTile;
large?: boolean;
}
export function VoiceUser({ participant, large }: VoiceUserProps) {
export function VoiceUser({ tile, large }: VoiceUserProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const screenAudioRef = useRef<HTMLAudioElement>(null);
const { participant } = tile;
const isDeafened = useVoiceStore((s) => s.isDeafened);
const outputVolume = useVoiceStore((s) => s.outputVolume);
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
@@ -32,10 +32,10 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
// 1. Basic Track Attachment (The Rock-Solid Foundation)
useEffect(() => {
const audioEl = audioRef.current;
if (isLocal || !audioEl || !participant.audioTrack) return;
if (isLocal || !audioEl || !tile.audioTrack) return;
// Direct attachment.
const stream = new MediaStream([participant.audioTrack]);
const stream = new MediaStream([tile.audioTrack]);
// Only update if changed to prevent interruptions
if ((audioEl.srcObject as MediaStream)?.id !== stream.id) {
@@ -46,19 +46,17 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
try {
await audioEl.play();
} catch (err) {
console.warn("[Audio] Autoplay blocked, retrying...", err);
// If blocked, we rely on the global interaction listener to resume context,
// but we can also retry play() on the element itself on next click.
console.warn('[Audio] Autoplay blocked, retrying...', err);
}
};
tryPlay();
}
}, [participant.audioTrack, isLocal]);
}, [tile.audioTrack, isLocal]);
// 2. Volume Management (Hybrid)
useEffect(() => {
const audioEl = audioRef.current;
if (isLocal || !audioEl || !participant.audioTrack) return;
if (isLocal || !audioEl || !tile.audioTrack) return;
const globalScale = outputVolume / 100;
const userScale = perUserVolume / 100;
@@ -83,7 +81,9 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
// Setup pipeline if missing
if (!boostGainRef.current && ctx) {
const gain = ctx.createGain();
const source = ctx.createMediaStreamSource(new MediaStream([participant.audioTrack]));
const source = ctx.createMediaStreamSource(
new MediaStream([tile.audioTrack]),
);
source.connect(gain);
gain.connect(ctx.destination);
@@ -94,12 +94,15 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
// Apply boosted gain
if (boostGainRef.current && ctx) {
boostGainRef.current.gain.setTargetAtTime(finalVolume, ctx.currentTime, 0.01);
boostGainRef.current.gain.setTargetAtTime(
finalVolume,
ctx.currentTime,
0.01,
);
}
// MUTE the element so we don't double audio
audioEl.muted = true;
} else {
// --- STANDARD MODE (0% - 100%) ---
// Clean up boost pipeline if it exists
@@ -126,99 +129,20 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
boostGainRef.current = null;
}
};
}, [outputVolume, perUserVolume, isDeafened, isLocal, participant.audioTrack]);
// --- SCREEN SHARE AUDIO PIPELINE ---
const screenBoostGainRef = useRef<GainNode | null>(null);
const screenBoostSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
// Screen share audio: track attachment
useEffect(() => {
const audioEl = screenAudioRef.current;
if (isLocal || !audioEl || !participant.screenAudioTrack) {
if (audioEl) audioEl.srcObject = null;
return;
}
const stream = new MediaStream([participant.screenAudioTrack]);
if ((audioEl.srcObject as MediaStream)?.id !== stream.id) {
audioEl.srcObject = stream;
audioEl.play().catch(() => {});
}
}, [participant.screenAudioTrack, isLocal]);
// Screen share audio: volume management (mirrors mic audio pipeline)
useEffect(() => {
const audioEl = screenAudioRef.current;
if (isLocal || !audioEl || !participant.screenAudioTrack) return;
const globalScale = outputVolume / 100;
const userScale = perUserVolume / 100;
const finalVolume = globalScale * userScale;
if (isDeafened) {
audioEl.muted = true;
return;
}
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([participant.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, perUserVolume, isDeafened, isLocal, participant.screenAudioTrack]);
}, [outputVolume, perUserVolume, isDeafened, isLocal, tile.audioTrack]);
// --- VIDEO & UI ---
const liveScreen = participant.isScreenSharing && participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null;
const liveCamera = participant.isCameraOn && participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null;
const activeVideoTrack = liveScreen ?? liveCamera;
const activeVideoTrack = tile.videoTrack;
const hasVideo = activeVideoTrack !== null;
const isScreenShare = liveScreen !== null;
// Force re-render when tracks end/mute
useEffect(() => {
const tracks = [participant.videoTrack, participant.screenTrack].filter((t): t is MediaStreamTrack => t !== null);
if (tracks.length === 0) return;
if (!tile.videoTrack) return;
const onEnded = () => forceUpdate((n) => n + 1);
tracks.forEach((t) => t.addEventListener('ended', onEnded));
return () => tracks.forEach((t) => t.removeEventListener('ended', onEnded));
}, [participant.videoTrack, participant.screenTrack]);
tile.videoTrack.addEventListener('ended', onEnded);
return () => tile.videoTrack?.removeEventListener('ended', onEnded);
}, [tile.videoTrack]);
// Attach Video
useEffect(() => {
@@ -232,14 +156,20 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
}, [activeVideoTrack]);
// Context Menu
const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null);
const [volumeMenu, setVolumeMenu] = useState<{
x: number;
y: number;
} | null>(null);
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (isLocal) return;
e.preventDefault();
setVolumeMenu({ x: e.clientX, y: e.clientY });
}, [isLocal]);
},
[isLocal],
);
useEffect(() => {
if (!volumeMenu) return;
@@ -263,7 +193,6 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
- PlaysInline is critical for mobile
*/}
{!isLocal && <audio ref={audioRef} autoPlay playsInline />}
{!isLocal && <audio ref={screenAudioRef} autoPlay playsInline />}
{hasVideo ? (
<video
@@ -271,12 +200,16 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
autoPlay
playsInline
muted={isLocal}
className={`w-full h-full ${large || isScreenShare ? 'object-contain bg-black' : 'object-cover'}`}
className={`w-full h-full ${large ? 'object-contain bg-black' : 'object-cover'}`}
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]">
<div className="relative">
<Avatar src={null} name={participant.username} size={large ? 100 : 64} />
<Avatar
src={null}
name={participant.username}
size={large ? 100 : 64}
/>
{participant.isSpeaking && (
<div className="absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" />
)}
@@ -284,26 +217,33 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
</div>
)}
{isScreenShare && hasVideo && (
<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">
LIVE
</div>
)}
<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">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0">
<span className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}>
<span
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
>
{participant.username}
</span>
{isLocal && <span className="text-[10px] text-white/40 font-medium">(you)</span>}
{isLocal && (
<span className="text-[10px] text-white/40 font-medium">
(you)
</span>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{participant.isMuted && (
<div className="w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
<path d="M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="white" strokeWidth="2" />
<line
x1="3"
y1="3"
x2="21"
y2="21"
stroke="white"
strokeWidth="2"
/>
</svg>
</div>
)}
@@ -311,7 +251,14 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
<div className="w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
<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" />
<line x1="3" y1="3" x2="21" y2="21" stroke="white" strokeWidth="2" />
<line
x1="3"
y1="3"
x2="21"
y2="21"
stroke="white"
strokeWidth="2"
/>
</svg>
</div>
)}
@@ -329,7 +276,13 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
User Volume
</div>
<div className="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
className="text-discord-text-muted flex-shrink-0"
>
<path d="M3 9v6h4l5 5V4L7 9H3z" />
</svg>
<input
@@ -337,10 +290,17 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
min="0"
max="200"
value={perUserVolume}
onChange={(e) => setParticipantVolume(participant.userId, parseInt(e.target.value))}
onChange={(e) =>
setParticipantVolume(
participant.userId,
parseInt(e.target.value),
)
}
className="flex-1 accent-discord-blurple h-1"
/>
<span className="text-xs text-discord-text-secondary min-w-[32px] text-right">{perUserVolume}%</span>
<span className="text-xs text-discord-text-secondary min-w-[32px] text-right">
{perUserVolume}%
</span>
</div>
</div>
)}
+273 -62
View File
@@ -2,9 +2,9 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import { Room, RoomEvent, Track, ConnectionState, VideoPresets, VideoPreset, } from 'livekit-client';
import { api } from '../api/client';
import { useVoiceStore } from '../stores/voiceStore';
import { AudioManager } from '../audio/AudioManager';
/**
* OPENCORD NATIVE OVERDRIVE PIPELINE v22
* "Soft-Launch Protocol": Always starts low to clear handshake, then upgrades to target.
* OPENCORD NATIVE OVERDRIVE PIPELINE v32
*/
const QUALITY_MAP = {
'1080p60': new VideoPreset(1920, 1080, 12_000_000, 60),
@@ -19,6 +19,40 @@ let _activeRoom = null;
export function getActiveRoom() {
return _activeRoom;
}
export function deriveGridTiles(participants) {
const tiles = [];
for (const p of participants) {
tiles.push({
kind: 'user',
key: p.identity,
participant: p,
videoTrack: (p.isCameraOn && p.videoTrack?.readyState === 'live') ? p.videoTrack : null,
audioTrack: p.audioTrack,
});
if (p.isScreenSharing) {
tiles.push({
kind: 'stream',
key: `${p.identity}:stream`,
participant: p,
screenTrack: p.screenTrack,
screenAudioTrack: p.screenAudioTrack,
});
}
}
return tiles;
}
export function setStreamSubscription(room, targetIdentity, subscribed) {
if (!room)
return;
const rp = room.remoteParticipants.get(targetIdentity);
if (!rp)
return;
rp.trackPublications.forEach((pub) => {
if (pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) {
pub.setSubscribed(subscribed);
}
});
}
function parseIdentity(identity) {
const parts = identity.split(':');
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
@@ -33,13 +67,11 @@ async function applyOverdriveHammer(room, source, preset) {
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc;
if (pc) {
const senders = pc.getSenders();
const sender = senders.find(s => s.track?.id === pub.track?.mediaStreamTrack?.id);
const sender = senders.find(s => s.track?.id === pub.track.mediaStreamTrack?.id);
if (sender) {
const params = sender.getParameters();
if (params.encodings && params.encodings[0]) {
console.log(`[Overdrive] Upgrading ${source} to ${preset.encoding.maxBitrate}bps`);
params.encodings[0].maxBitrate = preset.encoding.maxBitrate;
// Gentle floor to keep stable
params.encodings[0].minBitrate = 2_000_000;
params.encodings[0].maxFramerate = preset.encoding.maxFramerate;
params.encodings[0].networkPriority = 'high';
@@ -49,9 +81,6 @@ async function applyOverdriveHammer(room, source, preset) {
}
}
}
if (pub.track.mediaStreamTrack) {
pub.track.mediaStreamTrack.contentHint = 'motion';
}
}
catch (err) { }
}
@@ -60,27 +89,43 @@ export function useLiveKit() {
const [participants, setParticipants] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [connectionState, setConnectionState] = useState(ConnectionState.Disconnected);
const [connectedChannelId, setConnectedChannelId] = useState(null);
const [connectionError, setConnectionError] = useState(null);
const roomRef = useRef(null);
const connectedChannelRef = useRef(null);
const isMuted = useVoiceStore((s) => s.isMuted);
const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const videoQuality = useVoiceStore((s) => s.videoQuality);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const inputVolume = useVoiceStore((s) => s.inputVolume);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const updateParticipants = useCallback(() => {
const r = roomRef.current;
if (!r)
return;
const allParticipants = [];
const processParticipant = (p, isLocal) => {
if (!p.identity)
return;
const { userId, username } = parseIdentity(p.identity);
let audioTrack = null;
let videoTrack = null;
let screenTrack = null;
let screenAudioTrack = null;
let hasScreenSharePublication = false;
p.trackPublications.forEach((pub) => {
// Detect screen share publication even if unsubscribed
if (pub.source === Track.Source.ScreenShare)
hasScreenSharePublication = true;
const track = pub.track;
if (!track)
return;
// Strict check: Track must be subscribed AND not muted to be considered "active"
if (pub.isMuted || !pub.isSubscribed)
return;
const mt = track.mediaStreamTrack;
if (!mt || mt.readyState !== 'live')
return;
@@ -88,46 +133,150 @@ export function useLiveKit() {
audioTrack = mt;
else if (pub.source === Track.Source.Camera && p.isCameraEnabled)
videoTrack = mt;
else if (pub.source === Track.Source.ScreenShare && p.isScreenShareEnabled)
else if (pub.source === Track.Source.ScreenShare)
screenTrack = mt;
else if (pub.source === Track.Source.ScreenShareAudio)
screenAudioTrack = mt;
});
let isDeafened = false;
try {
if (p.metadata) {
const meta = JSON.parse(p.metadata);
isDeafened = meta.deafened === true;
const userState = useVoiceStore.getState().voiceUserStates.get(userId);
let isPartDeafened = false;
let isPartMuted = !p.isMicrophoneEnabled;
if (isLocal) {
isPartDeafened = useVoiceStore.getState().isDeafened;
isPartMuted = useVoiceStore.getState().isMuted;
}
else {
isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
if (userState)
isPartMuted = userState.isMuted;
}
catch { }
allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isDeafened, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack });
allParticipants.push({
identity: p.identity,
userId,
username,
isSpeaking: p.isSpeaking,
isMuted: isPartMuted,
isDeafened: isPartDeafened,
isCameraOn: !!videoTrack,
isScreenSharing: hasScreenSharePublication, // True even when unsubscribed
isLocal,
audioTrack,
videoTrack,
screenTrack,
screenAudioTrack,
});
};
processParticipant(r.localParticipant, true);
r.remoteParticipants.forEach((p) => processParticipant(p, false));
setParticipants(allParticipants);
}, []);
const connect = useCallback(async (channelId) => {
if (connectedChannelRef.current === channelId && roomRef.current)
return;
const gen = ++_connectGeneration;
if (roomRef.current) {
const handleDataReceived = useCallback((payload, participant) => {
try {
roomRef.current.disconnect();
const text = new TextDecoder().decode(payload);
const msg = JSON.parse(text);
if (msg.type === 'deafen' && participant) {
const { userId } = parseIdentity(participant.identity);
useVoiceStore.getState().setUserDeafened(userId, msg.deafened === true);
updateParticipants();
}
}
catch { }
roomRef.current = null;
}, [updateParticipants]);
// Handle Input Device & Mute Logic via AudioManager
useEffect(() => {
const r = roomRef.current;
if (!r || !isConnected)
return;
const syncMic = async () => {
try {
const audioManager = AudioManager.getInstance();
// If muted or deafened, unpublish mic
if (isMuted || isDeafened) {
const pub = r.localParticipant.getTrackPublications().find(p => p.source === Track.Source.Microphone);
if (pub) {
await r.localParticipant.unpublishTrack(pub.track);
}
return;
}
// Ensure device is set and volume is sync'd
await audioManager.setInputDevice(inputDeviceId);
audioManager.setInputVolume(inputVolume);
// Check if already published
const existingPub = r.localParticipant.getTrackPublications().find(p => p.source === Track.Source.Microphone);
if (existingPub && existingPub.track) {
// If track is alive, we are good.
if (existingPub.track.mediaStreamTrack?.readyState === 'live') {
return;
}
// If track died, unpublish so we can republish
await r.localParticipant.unpublishTrack(existingPub.track);
}
// Get a FRESH track (clone) for this specific publication
const audioTrack = audioManager.getFreshTrack();
if (!audioTrack)
return;
console.log('[LiveKit] Publishing fresh microphone track');
await r.localParticipant.publishTrack(audioTrack, {
name: 'microphone',
source: Track.Source.Microphone,
});
}
catch (err) {
console.error('[LiveKit] Failed to sync mic state:', err);
}
};
syncMic();
// Re-sync when AudioManager resumes
const unsubscribe = AudioManager.getInstance().onResumed(() => {
syncMic();
});
return () => {
unsubscribe();
};
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected]);
const connect = useCallback(async (channelId) => {
if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected)
return;
const gen = ++_connectGeneration;
// 1. Reset state immediately to reflect "Loading/Switching" in UI
setRoom(null);
setParticipants([]);
setIsConnected(false);
setIsConnecting(true);
setConnectionState(ConnectionState.Connecting);
setConnectionError(null);
setConnectedChannelId(null); // Clear this so AppLayout knows we are transitioning
useVoiceStore.getState().setIsLiveKitConnected(false);
// 2. Strictly disconnect previous room (Local Ref OR Global Ref)
// This handles cases where AppLayout might have remounted, losing roomRef but leaving _activeRoom alive.
const roomToDisconnect = roomRef.current || _activeRoom;
if (roomToDisconnect) {
try {
console.log('[LiveKit] Disconnecting previous room:', roomToDisconnect.name);
await roomToDisconnect.disconnect();
}
catch (err) {
console.warn('Error disconnecting from previous room:', err);
}
roomRef.current = null;
_activeRoom = null;
}
try {
const { token, url } = await api.livekit.token(channelId);
if (gen !== _connectGeneration)
return;
// Disable simulcast for better 60fps stability on local networks
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
roomRef.current = newRoom;
const guardedUpdate = () => { if (roomRef.current === newRoom)
updateParticipants(); };
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
// ... existing event listeners ...
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
guardedUpdate();
if (useVoiceStore.getState().isDeafened) {
const encoder = new TextEncoder();
newRoom.localParticipant.publishData(encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })), { reliable: true }).catch(() => { });
}
});
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
@@ -137,16 +286,42 @@ export function useLiveKit() {
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate);
newRoom.on(RoomEvent.TrackPublished, (publication, participant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnpublished, (publication, participant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
const state = useVoiceStore.getState();
state.unwatchStream(userId);
state.clearStreamVolume(userId);
state.clearStreamMute(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.DataReceived, handleDataReceived);
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
setConnectionState(state);
const connected = state === ConnectionState.Connected;
const connecting = state === ConnectionState.Connecting || state === ConnectionState.Reconnecting;
setIsConnected(connected);
setIsConnecting(connecting);
useVoiceStore.getState().setIsLiveKitConnected(connected);
if (connected) {
updateParticipants();
}
}
});
newRoom.on(RoomEvent.Disconnected, () => {
if (roomRef.current !== newRoom)
return;
setConnectionState(ConnectionState.Disconnected);
setConnectedChannelId(null);
roomRef.current = null;
_activeRoom = null;
setIsConnected(false);
@@ -161,19 +336,19 @@ export function useLiveKit() {
}
_activeRoom = newRoom;
connectedChannelRef.current = channelId;
setConnectedChannelId(channelId);
setRoom(newRoom);
setIsConnected(true);
useVoiceStore.getState().setIsLiveKitConnected(true);
updateParticipants();
useVoiceStore.setState({ isMuted: false, isCameraOn: false, isScreenSharing: false });
try {
await newRoom.localParticipant.setMicrophoneEnabled(true);
// Initial mute state check
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
if (wasDeafened) {
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
}
updateParticipants();
}
catch {
useVoiceStore.setState({ isMuted: true });
}
}
catch (err) {
if (gen === _connectGeneration)
setConnectionError('Failed to connect');
@@ -182,17 +357,30 @@ export function useLiveKit() {
if (gen === _connectGeneration)
setIsConnecting(false);
}
}, [updateParticipants]);
}, [updateParticipants, handleDataReceived]);
const connectDm = useCallback(async (dmChannelId) => {
const gen = ++_connectGeneration;
if (roomRef.current) {
try {
roomRef.current.disconnect();
}
catch { }
roomRef.current = null;
}
// 1. Reset state immediately
setRoom(null);
setParticipants([]);
setIsConnected(false);
setIsConnecting(true);
setConnectionState(ConnectionState.Connecting);
setConnectionError(null);
setConnectedChannelId(null);
// 2. Strictly disconnect previous room (Local Ref OR Global Ref)
const roomToDisconnect = roomRef.current || _activeRoom;
if (roomToDisconnect) {
try {
console.log('[LiveKit] Disconnecting previous room (DM):', roomToDisconnect.name);
await roomToDisconnect.disconnect();
}
catch (err) {
console.warn('Error disconnecting from previous room:', err);
}
roomRef.current = null;
_activeRoom = null;
}
try {
const { token, url } = await api.livekit.dmToken(dmChannelId);
if (gen !== _connectGeneration)
@@ -210,11 +398,34 @@ export function useLiveKit() {
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
newRoom.on(RoomEvent.TrackPublished, (publication, participant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnpublished, (publication, participant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
const state = useVoiceStore.getState();
state.unwatchStream(userId);
state.clearStreamVolume(userId);
state.clearStreamMute(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
setConnectionState(state);
const connected = state === ConnectionState.Connected;
const connecting = state === ConnectionState.Connecting || state === ConnectionState.Reconnecting;
setIsConnected(connected);
setIsConnecting(connecting);
useVoiceStore.getState().setIsLiveKitConnected(connected);
if (connected) {
updateParticipants();
}
}
});
await newRoom.connect(url, token);
@@ -222,21 +433,21 @@ export function useLiveKit() {
newRoom.disconnect();
return;
}
const fullId = `dm-${dmChannelId}`;
_activeRoom = newRoom;
connectedChannelRef.current = `dm-${dmChannelId}`;
connectedChannelRef.current = fullId;
setConnectedChannelId(fullId);
setRoom(newRoom);
setIsConnected(true);
useVoiceStore.getState().setIsLiveKitConnected(true);
updateParticipants();
useVoiceStore.setState({ isMuted: false, isCameraOn: false, isScreenSharing: false });
try {
await newRoom.localParticipant.setMicrophoneEnabled(true);
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
if (wasDeafened) {
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
}
updateParticipants();
}
catch {
useVoiceStore.setState({ isMuted: true });
}
}
catch (err) {
if (gen === _connectGeneration)
setConnectionError('Failed to connect');
@@ -245,30 +456,32 @@ export function useLiveKit() {
if (gen === _connectGeneration)
setIsConnecting(false);
}
}, [updateParticipants]);
}, [updateParticipants, handleDataReceived]);
const disconnect = useCallback(async () => {
_connectGeneration++;
connectedChannelRef.current = null;
setConnectedChannelId(null);
if (roomRef.current) {
await roomRef.current.disconnect();
roomRef.current = null;
_activeRoom = null;
connectedChannelRef.current = null;
setRoom(null);
setIsConnected(false);
setIsConnecting(false);
setConnectionState(ConnectionState.Disconnected);
setParticipants([]);
useVoiceStore.getState().setIsLiveKitConnected(false);
}
}, []);
const toggleMic = useCallback(async () => { if (roomRef.current) {
await roomRef.current.localParticipant.setMicrophoneEnabled(isMuted);
updateParticipants();
} }, [isMuted, updateParticipants]);
const toggleMic = useCallback(async () => {
await AudioManager.getInstance().resumeContext();
useVoiceStore.getState().toggleMic();
}, []);
const toggleCamera = useCallback(async () => {
if (roomRef.current) {
if (!isCameraOn) {
const preset = QUALITY_MAP[videoQuality] || VideoPresets.h720;
await roomRef.current.localParticipant.setCameraEnabled(true, { resolution: preset.resolution, frameRate: preset.encoding.maxFramerate }, { videoCodec: 'h264', videoEncoding: preset.encoding, simulcast: false });
// Soft Start Camera
setTimeout(() => { if (roomRef.current)
applyOverdriveHammer(roomRef.current, Track.Source.Camera, preset); }, 2000);
}
@@ -282,9 +495,8 @@ export function useLiveKit() {
if (roomRef.current) {
if (!isScreenSharing) {
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
console.log('[LiveKit] Soft-Launching Screen Share (360p start)...');
// SOFT LAUNCH: Start at 360p 30fps to clear handshake
const track = await roomRef.current.localParticipant.setScreenShareEnabled(true, {
audio: true,
resolution: VideoPresets.h360.resolution,
// @ts-ignore
frameRate: 30,
@@ -292,10 +504,8 @@ export function useLiveKit() {
videoCodec: 'h264', videoEncoding: VideoPresets.h360.encoding, simulcast: false, priority: 'very-high'
});
if (track) {
// UPGRADE: After 2 seconds, switch to full 60fps quality
setTimeout(async () => {
if (roomRef.current && isScreenSharing) {
console.log('[LiveKit] Upgrading to Target Quality...');
const screenPub = roomRef.current.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
if (screenPub?.track?.mediaStreamTrack) {
await screenPub.track.mediaStreamTrack.applyConstraints({
@@ -307,7 +517,6 @@ export function useLiveKit() {
}
}
}, 2000);
// Re-apply hammer
setTimeout(() => applyOverdriveHammer(roomRef.current, Track.Source.ScreenShare, preset), 5000);
}
}
@@ -317,7 +526,9 @@ export function useLiveKit() {
updateParticipants();
}
}, [isScreenSharing, videoQuality, updateParticipants]);
// Sync quality changes
useEffect(() => {
updateParticipants();
}, [voiceUserStates, isMuted, isDeafened, updateParticipants]);
useEffect(() => {
if (!room)
return;
@@ -371,5 +582,5 @@ export function useLiveKit() {
_activeRoom = null;
} };
}, []);
return { room, participants, isConnected, isConnecting, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare };
return { room, participants, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare };
}
+93 -2
View File
@@ -5,6 +5,7 @@ import {
Track,
Participant,
RemoteParticipant,
RemoteTrackPublication,
ConnectionState,
VideoPresets,
VideoPreset,
@@ -52,6 +53,58 @@ export interface ParticipantInfo {
screenAudioTrack: MediaStreamTrack | null;
}
export interface UserTile {
kind: 'user';
key: string; // participant.identity
participant: ParticipantInfo;
videoTrack: MediaStreamTrack | null; // camera only
audioTrack: MediaStreamTrack | null; // mic
}
export interface StreamTile {
kind: 'stream';
key: string; // `${identity}:stream`
participant: ParticipantInfo;
screenTrack: MediaStreamTrack | null;
screenAudioTrack: MediaStreamTrack | null;
}
export type GridTile = UserTile | StreamTile;
export function deriveGridTiles(participants: ParticipantInfo[]): GridTile[] {
const tiles: GridTile[] = [];
for (const p of participants) {
tiles.push({
kind: 'user',
key: p.identity,
participant: p,
videoTrack: (p.isCameraOn && p.videoTrack?.readyState === 'live') ? p.videoTrack : null,
audioTrack: p.audioTrack,
});
if (p.isScreenSharing) {
tiles.push({
kind: 'stream',
key: `${p.identity}:stream`,
participant: p,
screenTrack: p.screenTrack,
screenAudioTrack: p.screenAudioTrack,
});
}
}
return tiles;
}
export function setStreamSubscription(room: Room | null, targetIdentity: string, subscribed: boolean) {
if (!room) return;
const rp = room.remoteParticipants.get(targetIdentity);
if (!rp) return;
rp.trackPublications.forEach((pub) => {
if (pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) {
(pub as RemoteTrackPublication).setSubscribed(subscribed);
}
});
}
function parseIdentity(identity: string): { userId: string; username: string } {
const parts = identity.split(':');
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
@@ -116,7 +169,11 @@ export function useLiveKit() {
let videoTrack: MediaStreamTrack | null = null;
let screenTrack: MediaStreamTrack | null = null;
let screenAudioTrack: MediaStreamTrack | null = null;
let hasScreenSharePublication = false;
p.trackPublications.forEach((pub) => {
// Detect screen share publication even if unsubscribed
if (pub.source === Track.Source.ScreenShare) hasScreenSharePublication = true;
const track = pub.track;
if (!track) return;
// Strict check: Track must be subscribed AND not muted to be considered "active"
@@ -150,8 +207,8 @@ export function useLiveKit() {
isSpeaking: p.isSpeaking,
isMuted: isPartMuted,
isDeafened: isPartDeafened,
isCameraOn: !!videoTrack, // Strictly derived from active track
isScreenSharing: !!screenTrack, // Strictly derived from active track
isCameraOn: !!videoTrack,
isScreenSharing: hasScreenSharePublication, // True even when unsubscribed
isLocal,
audioTrack,
videoTrack,
@@ -294,6 +351,23 @@ export function useLiveKit() {
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate);
newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnpublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
const state = useVoiceStore.getState();
state.unwatchStream(userId);
state.clearStreamVolume(userId);
state.clearStreamMute(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.DataReceived, handleDataReceived);
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
@@ -384,6 +458,23 @@ export function useLiveKit() {
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnpublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
const state = useVoiceStore.getState();
state.unwatchStream(userId);
state.clearStreamVolume(userId);
state.clearStreamMute(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
setConnectionState(state);
+27 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { useAuthStore } from '../stores/authStore';
import { useServerStore } from '../stores/serverStore';
import { useChatStore } from '../stores/chatStore';
@@ -13,7 +13,7 @@ function handleEvent(event) {
const { setUser } = useAuthStore.getState();
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers } = useVoiceStore.getState();
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
switch (event.type) {
case 'ready':
setUser(event.user);
@@ -41,6 +41,22 @@ function handleEvent(event) {
setVoiceUsers(channelId, userIds);
}
}
// Populate voice user statuses (mute/deafen) from server
if (event.voiceUserStates) {
for (const [uid, status] of Object.entries(event.voiceUserStates)) {
setVoiceUserStatus(uid, status.isMuted, status.isDeafened);
}
}
// Re-register in voice channel if we're still connected to LiveKit
// (WebSocket reconnect causes server to drop our voice tracking)
{
const { currentVoiceChannelId, isMuted: curMuted, isDeafened: curDeafened } = useVoiceStore.getState();
if (currentVoiceChannelId) {
console.log('[WebSocket] Re-syncing voice status on reconnect:', { currentVoiceChannelId, curMuted, curDeafened });
wsSend({ type: 'voice_join', channelId: currentVoiceChannelId });
wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened });
}
}
break;
case 'message_created':
addMessage(event.message.channelId, event.message);
@@ -71,6 +87,9 @@ function handleEvent(event) {
removeVoiceUser(event.channelId, event.userId);
}
break;
case 'voice_status_update':
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened);
break;
case 'member_joined':
addMember(event.member);
break;
@@ -226,6 +245,7 @@ export function wsSend(event) {
export function useWebSocket() {
const token = useAuthStore((s) => s.token);
const prevToken = useRef(token);
const [isConnected, setIsConnected] = React.useState(false);
useEffect(() => {
if (token && (!isInitialized || token !== prevToken.current)) {
currentToken = token;
@@ -238,9 +258,13 @@ export function useWebSocket() {
prevToken.current = token;
}, [token]);
useEffect(() => {
const checkStatus = setInterval(() => {
setIsConnected(!!globalWs && globalWs.readyState === WebSocket.OPEN);
}, 500);
return () => {
clearInterval(checkStatus);
disconnect();
};
}, []);
return { send: wsSend };
return { send: wsSend, isConnected };
}
+123 -8
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand';
export const useVoiceStore = create((set, get) => ({
import { persist, createJSONStorage } from 'zustand/middleware';
import { AudioManager } from '../audio/AudioManager';
export const useVoiceStore = create()(persist((set, get) => ({
voiceUsers: new Map(),
currentVoiceChannelId: null,
isMuted: false,
@@ -11,6 +13,8 @@ export const useVoiceStore = create((set, get) => ({
isLiveKitConnected: false,
inputVolume: 100,
outputVolume: 100,
inputDeviceId: 'default',
outputDeviceId: 'default',
focusedParticipantId: null,
videoQuality: '720p60',
participantVolumes: new Map(),
@@ -22,6 +26,56 @@ export const useVoiceStore = create((set, get) => ({
});
},
getParticipantVolume: (userId) => get().participantVolumes.get(userId) ?? 100,
// Stream widget state
streamVolumes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
streamAttenuationEnabled: true,
streamAttenuationStrength: 50,
setStreamVolume: (userId, volume) => {
set((state) => {
const newMap = new Map(state.streamVolumes);
newMap.set(userId, volume);
return { streamVolumes: newMap };
});
},
setStreamMute: (userId, muted) => {
set((state) => {
const newMap = new Map(state.streamMutes);
newMap.set(userId, muted);
return { streamMutes: newMap };
});
},
watchStream: (userId) => {
set((state) => {
const newSet = new Set(state.watchingStreams);
newSet.add(userId);
return { watchingStreams: newSet };
});
},
unwatchStream: (userId) => {
set((state) => {
const newSet = new Set(state.watchingStreams);
newSet.delete(userId);
return { watchingStreams: newSet };
});
},
clearStreamVolume: (userId) => {
set((state) => {
const newMap = new Map(state.streamVolumes);
newMap.delete(userId);
return { streamVolumes: newMap };
});
},
clearStreamMute: (userId) => {
set((state) => {
const newMap = new Map(state.streamMutes);
newMap.delete(userId);
return { streamMutes: newMap };
});
},
setStreamAttenuationEnabled: (enabled) => set({ streamAttenuationEnabled: enabled }),
setStreamAttenuationStrength: (strength) => set({ streamAttenuationStrength: strength }),
incomingCall: null,
outgoingCall: null,
activeDmCall: null,
@@ -53,12 +107,23 @@ export const useVoiceStore = create((set, get) => ({
return { voiceUsers: newMap };
});
},
setCurrentVoiceChannel: (channelId) => set({ currentVoiceChannelId: channelId }),
setCurrentVoiceChannel: (channelId) => set({
currentVoiceChannelId: channelId,
activeDmCall: null // Clear active DM call when joining a server channel
}),
setParticipants: (participants) => set({ participants }),
setConnectionError: (error) => set({ connectionError: error }),
setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }),
setInputVolume: (volume) => set({ inputVolume: volume }),
setInputVolume: (volume) => {
set({ inputVolume: volume });
AudioManager.getInstance().setInputVolume(volume);
},
setOutputVolume: (volume) => set({ outputVolume: volume }),
setInputDevice: async (deviceId) => {
set({ inputDeviceId: deviceId });
await AudioManager.getInstance().setInputDevice(deviceId);
},
setOutputDevice: (deviceId) => set({ outputDeviceId: deviceId }),
toggleMic: () => set((state) => ({ isMuted: !state.isMuted })),
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
@@ -67,23 +132,49 @@ export const useVoiceStore = create((set, get) => ({
setVideoQuality: (quality) => set({ videoQuality: quality }),
noiseSuppression: true,
toggleNoiseSuppression: () => set((state) => ({ noiseSuppression: !state.noiseSuppression })),
deafenedUserIds: new Set(),
setUserDeafened: (userId, deafened) => {
set((state) => {
const newSet = new Set(state.deafenedUserIds);
if (deafened)
newSet.add(userId);
else
newSet.delete(userId);
return { deafenedUserIds: newSet };
});
},
voiceUserStates: new Map(),
setVoiceUserStatus: (userId, isMuted, isDeafened) => {
set((state) => {
const newMap = new Map(state.voiceUserStates);
newMap.set(userId, { isMuted, isDeafened });
return { voiceUserStates: newMap };
});
},
clearVoiceUserStatus: (userId) => {
set((state) => {
const newMap = new Map(state.voiceUserStates);
newMap.delete(userId);
return { voiceUserStates: newMap };
});
},
getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
clearAllVoiceUsers: () => set({ voiceUsers: new Map() }),
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
// Leave voice without wiping the voiceUsers map (so sidebar still shows others)
leaveVoice: () => set({
currentVoiceChannelId: null,
isMuted: false,
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
participants: [],
connectionError: null,
isLiveKitConnected: false,
inputVolume: 100,
outputVolume: 100,
focusedParticipantId: null,
activeDmCall: null,
outgoingCall: null,
deafenedUserIds: new Set(),
streamVolumes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
}),
reset: () => set({
voiceUsers: new Map(),
@@ -97,10 +188,34 @@ export const useVoiceStore = create((set, get) => ({
isLiveKitConnected: false,
inputVolume: 100,
outputVolume: 100,
inputDeviceId: 'default',
outputDeviceId: 'default',
focusedParticipantId: null,
participantVolumes: new Map(),
incomingCall: null,
outgoingCall: null,
activeDmCall: null,
deafenedUserIds: new Set(),
voiceUserStates: new Map(),
streamVolumes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
}),
}), {
name: 'opencord-voice-settings',
storage: createJSONStorage(() => localStorage),
// Only persist these keys. Maps and Sets are complex to serialize.
partialize: (state) => ({
currentVoiceChannelId: state.currentVoiceChannelId,
isMuted: state.isMuted,
isDeafened: state.isDeafened,
inputVolume: state.inputVolume,
outputVolume: state.outputVolume,
inputDeviceId: state.inputDeviceId,
outputDeviceId: state.outputDeviceId,
videoQuality: state.videoQuality,
noiseSuppression: state.noiseSuppression,
streamAttenuationEnabled: state.streamAttenuationEnabled,
streamAttenuationStrength: state.streamAttenuationStrength,
}),
}));
+74
View File
@@ -23,6 +23,20 @@ interface VoiceState {
participantVolumes: Map<string, number>;
setParticipantVolume: (userId: string, volume: number) => void;
getParticipantVolume: (userId: string) => number;
// Stream widget state
streamVolumes: Map<string, number>; // userId → 0-200 (100 default)
streamMutes: Map<string, boolean>; // userId → muted?
watchingStreams: Set<string>; // userIds we're watching
streamAttenuationEnabled: boolean; // global toggle, default true
streamAttenuationStrength: number; // 0-100, default 50
setStreamVolume: (userId: string, volume: number) => void;
setStreamMute: (userId: string, muted: boolean) => void;
watchStream: (userId: string) => void;
unwatchStream: (userId: string) => void;
clearStreamVolume: (userId: string) => void;
clearStreamMute: (userId: string) => void;
setStreamAttenuationEnabled: (enabled: boolean) => void;
setStreamAttenuationStrength: (strength: number) => void;
// DM call state
incomingCall: { dmChannelId: string; callerId: string; callerName: string } | null;
outgoingCall: { dmChannelId: string } | null;
@@ -89,6 +103,58 @@ export const useVoiceStore = create<VoiceState>()(
},
getParticipantVolume: (userId) => get().participantVolumes.get(userId) ?? 100,
// Stream widget state
streamVolumes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
streamAttenuationEnabled: true,
streamAttenuationStrength: 50,
setStreamVolume: (userId, volume) => {
set((state) => {
const newMap = new Map(state.streamVolumes);
newMap.set(userId, volume);
return { streamVolumes: newMap };
});
},
setStreamMute: (userId, muted) => {
set((state) => {
const newMap = new Map(state.streamMutes);
newMap.set(userId, muted);
return { streamMutes: newMap };
});
},
watchStream: (userId) => {
set((state) => {
const newSet = new Set(state.watchingStreams);
newSet.add(userId);
return { watchingStreams: newSet };
});
},
unwatchStream: (userId) => {
set((state) => {
const newSet = new Set(state.watchingStreams);
newSet.delete(userId);
return { watchingStreams: newSet };
});
},
clearStreamVolume: (userId) => {
set((state) => {
const newMap = new Map(state.streamVolumes);
newMap.delete(userId);
return { streamVolumes: newMap };
});
},
clearStreamMute: (userId) => {
set((state) => {
const newMap = new Map(state.streamMutes);
newMap.delete(userId);
return { streamMutes: newMap };
});
},
setStreamAttenuationEnabled: (enabled) => set({ streamAttenuationEnabled: enabled }),
setStreamAttenuationStrength: (strength) => set({ streamAttenuationStrength: strength }),
incomingCall: null,
outgoingCall: null,
activeDmCall: null,
@@ -197,6 +263,9 @@ export const useVoiceStore = create<VoiceState>()(
activeDmCall: null,
outgoingCall: null,
deafenedUserIds: new Set(),
streamVolumes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
}),
reset: () => set({
@@ -220,6 +289,9 @@ export const useVoiceStore = create<VoiceState>()(
activeDmCall: null,
deafenedUserIds: new Set(),
voiceUserStates: new Map(),
streamVolumes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
}),
}),
{
@@ -236,6 +308,8 @@ export const useVoiceStore = create<VoiceState>()(
outputDeviceId: state.outputDeviceId,
videoQuality: state.videoQuality,
noiseSuppression: state.noiseSuppression,
streamAttenuationEnabled: state.streamAttenuationEnabled,
streamAttenuationStrength: state.streamAttenuationStrength,
}),
}
)