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
@@ -82,12 +82,22 @@ export function DmCallView() {
}
toggleCamera();
};
const handleScreenShare = () => {
const handleScreenShare = async () => {
const room = getActiveRoom();
if (room) {
room.localParticipant.setScreenShareEnabled(!isScreenSharing);
if (!room)
return;
try {
if (!isScreenSharing) {
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
}
else {
await room.localParticipant.setScreenShareEnabled(false);
}
toggleScreenShare();
}
catch (err) {
console.error('[DmCallView] Failed to toggle screen share:', err);
}
toggleScreenShare();
};
const handleEndCall = () => {
if (activeDmCall) {
@@ -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) {
+40 -27
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);
}
else if (!screenSharerId && prevScreenSharerRef.current) {
// Screen share ended — unfocus if we were focused on the sharer
if (focusedParticipantId === prevScreenSharerRef.current) {
setFocusedParticipant(null);
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;
}
}
prevScreenSharerRef.current = screenSharerId;
}, [participants, focusedParticipantId, setFocusedParticipant]);
if (participants.length === 0) {
// If the focused tile was a stream tile that no longer exists, unfocus
if (focusedParticipantId &&
focusedParticipantId.endsWith(':stream') &&
!currentStreamKeys.has(focusedParticipantId)) {
setFocusedParticipant(null);
}
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))) }) }));
}
+68 -40
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) {
setFocusedParticipant(null);
// Find newly appeared stream keys
for (const key of currentStreamKeys) {
if (!prevStreamKeysRef.current.has(key)) {
// New stream tile — auto-focus it
setFocusedParticipant(key);
break;
}
}
prevScreenSharerRef.current = screenSharerId;
}, [participants, focusedParticipantId, setFocusedParticipant]);
if (participants.length === 0) {
// If the focused tile was a stream tile that no longer exists, unfocus
if (
focusedParticipantId &&
focusedParticipantId.endsWith(':stream') &&
!currentStreamKeys.has(focusedParticipantId)
) {
setFocusedParticipant(null);
}
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,24 +69,30 @@ 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 */}
<div
<div
className="flex-1 p-2 min-h-0 cursor-pointer"
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, "%"] })] })] }))] }));
}
+95 -135
View File
@@ -2,29 +2,29 @@ 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);
const [, forceUpdate] = useState(0);
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
const isLocal = participant.isLocal;
// --- AUDIO PIPELINE: NATIVE FIRST ---
// Refs for the optional boost pipeline
const boostGainRef = useRef<GainNode | null>(null);
const boostSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
@@ -32,33 +32,31 @@ 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) {
audioEl.srcObject = stream;
// Aggressive play attempt for Chrome
const tryPlay = async () => {
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;
@@ -69,10 +67,10 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
return;
}
// Logic:
// 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;
@@ -83,23 +81,28 @@ 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);
boostGainRef.current = gain;
boostSourceRef.current = source;
}
// 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
@@ -108,11 +111,11 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
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(() => {});
@@ -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) => {
if (isLocal) return;
e.preventDefault();
setVolumeMenu({ x: e.clientX, y: e.clientY });
}, [isLocal]);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (isLocal) return;
e.preventDefault();
setVolumeMenu({ x: e.clientX, y: e.clientY });
},
[isLocal],
);
useEffect(() => {
if (!volumeMenu) return;
@@ -257,13 +187,12 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
} ${large ? 'h-full w-full' : 'h-full aspect-video'}`}
onContextMenu={handleContextMenu}
>
{/*
Native Audio Element
{/*
Native Audio Element
- AutoPlay is critical
- 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>
)}