chore: remove 62 tsc emit artifacts from src/, add noEmit to tsconfig
tsc was emitting compiled .js files directly into packages/web/src/ alongside the .tsx source files because noEmit was not set. These artifacts were never used — Vite compiles from .tsx source directly. - Add noEmit: true to packages/web/tsconfig.json (tsc = type-check only) - Delete all 62 orphaned .js files from src/ (-6,129 lines) - Add packages/web/src/**/*.js to .gitignore as safeguard
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,61 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import React from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
|
||||
/**
|
||||
* Manages a Web Audio pipeline for a single audio track.
|
||||
* Renders a muted <audio> element as a Chrome keep-alive for the WebRTC track.
|
||||
* All actual audio output goes through Web Audio (GainNode -> ctx.destination).
|
||||
*/
|
||||
function AudioTrackElement({ track, globalVolume, perSourceVolume, isDeafened, isMuted, attenuate, someoneIsSpeaking, attenuationEnabled, attenuationStrength, }) {
|
||||
const globalScale = globalVolume / 100;
|
||||
const sourceScale = perSourceVolume / 100;
|
||||
let finalVolume = globalScale * sourceScale;
|
||||
// Stream attenuation: duck when someone is speaking
|
||||
if (attenuate && attenuationEnabled && someoneIsSpeaking) {
|
||||
finalVolume *= 1 - attenuationStrength / 100;
|
||||
}
|
||||
const shouldMute = isDeafened || isMuted;
|
||||
const audioRef = useAudioTrackPlayer({
|
||||
track,
|
||||
volume: finalVolume,
|
||||
muted: shouldMute,
|
||||
});
|
||||
// The <audio> element is always muted — it serves only as a Chrome
|
||||
// keep-alive so Chrome continues processing the WebRTC track.
|
||||
// Real audio output goes through the Web Audio pipeline.
|
||||
return _jsx("audio", { ref: audioRef, autoPlay: true, playsInline: true, "data-opencord": "keepalive" });
|
||||
}
|
||||
/**
|
||||
* Always-mounted component that manages Web Audio pipelines
|
||||
* for every remote participant's mic and screen audio tracks.
|
||||
*
|
||||
* Rendered in AppLayout alongside PictureInPicture and SoundController.
|
||||
* Never unmounts during navigation, so audio persists even when
|
||||
* VoiceGrid / VoiceUser / StreamTile are not rendered.
|
||||
*
|
||||
* All audio is routed through the Web Audio API (GainNodes connected
|
||||
* to a shared AudioContext.destination). Muted <audio> elements serve
|
||||
* as Chrome keep-alives for WebRTC tracks but produce no sound.
|
||||
*/
|
||||
export function GlobalAudioRenderer() {
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
|
||||
const streamMutes = useVoiceStore((s) => s.streamMutes);
|
||||
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
||||
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
|
||||
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
||||
// Determine if someone is currently speaking (for stream attenuation)
|
||||
const someoneIsSpeaking = participants.some((p) => !p.isLocal && p.isSpeaking);
|
||||
// Only render audio for remote participants
|
||||
const remoteParticipants = participants.filter((p) => !p.isLocal);
|
||||
return (_jsx(_Fragment, { children: remoteParticipants.map((p) => {
|
||||
const micVolume = participantVolumes.get(p.userId) ?? 100;
|
||||
const streamVol = streamVolumes.get(p.userId) ?? 100;
|
||||
const isStreamMuted = streamMutes.get(p.userId) ?? false;
|
||||
return (_jsxs(React.Fragment, { children: [p.audioTrack && (_jsx(AudioTrackElement, { track: p.audioTrack, globalVolume: outputVolume, perSourceVolume: micVolume, isDeafened: isDeafened, isMuted: false, attenuate: false, someoneIsSpeaking: false, attenuationEnabled: false, attenuationStrength: 0 })), p.screenAudioTrack && watchingStreams.has(p.userId) && (_jsx(AudioTrackElement, { track: p.screenAudioTrack, globalVolume: outputVolume, perSourceVolume: streamVol, isDeafened: isDeafened, isMuted: isStreamMuted, attenuate: true, someoneIsSpeaking: someoneIsSpeaking, attenuationEnabled: streamAttenuationEnabled, attenuationStrength: streamAttenuationStrength }))] }, p.identity));
|
||||
}) }));
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
export function IncomingCallModal() {
|
||||
const incomingCall = useVoiceStore((s) => s.incomingCall);
|
||||
const setIncomingCall = useVoiceStore((s) => s.setIncomingCall);
|
||||
const timerRef = useRef(null);
|
||||
// Auto-dismiss after 30 seconds
|
||||
useEffect(() => {
|
||||
if (incomingCall) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
// Auto-reject after timeout
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
|
||||
setIncomingCall(null);
|
||||
}, 30000);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [incomingCall, setIncomingCall]);
|
||||
if (!incomingCall)
|
||||
return null;
|
||||
const handleAccept = () => {
|
||||
if (timerRef.current)
|
||||
clearTimeout(timerRef.current);
|
||||
wsSend({ type: 'dm_call_accept', dmChannelId: incomingCall.dmChannelId });
|
||||
};
|
||||
const handleDecline = () => {
|
||||
if (timerRef.current)
|
||||
clearTimeout(timerRef.current);
|
||||
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId });
|
||||
setIncomingCall(null);
|
||||
};
|
||||
return (_jsxs("div", { className: "fixed inset-0 z-[100] flex items-center justify-center", children: [_jsx("div", { className: "absolute inset-0 bg-black/60" }), _jsxs("div", { className: "relative bg-[#1e1f22] rounded-lg shadow-2xl w-[340px] overflow-hidden", children: [_jsxs("div", { className: "absolute inset-0 overflow-hidden", children: [_jsx("div", { className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200px] h-[200px] rounded-full bg-discord-green/5 animate-ping", style: { animationDuration: '2s' } }), _jsx("div", { className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150px] h-[150px] rounded-full bg-discord-green/10 animate-ping", style: { animationDuration: '2s', animationDelay: '0.5s' } })] }), _jsxs("div", { className: "relative p-8 flex flex-col items-center gap-4", children: [_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-20 h-20 rounded-full bg-discord-blurple flex items-center justify-center text-white text-3xl font-bold", children: incomingCall.callerName.charAt(0).toUpperCase() }), _jsx("div", { className: "absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-discord-green flex items-center justify-center", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "white", children: _jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }) })] }), _jsxs("div", { className: "text-center", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header", children: incomingCall.callerName }), _jsx("p", { className: "text-[14px] text-discord-text-muted mt-1", children: "Incoming Voice Call..." })] }), _jsxs("div", { className: "flex items-center gap-6 mt-2", children: [_jsx("button", { onClick: handleDecline, className: "w-14 h-14 rounded-full bg-discord-red hover:bg-discord-red/80 flex items-center justify-center transition-colors group", title: "Decline", children: _jsx("svg", { width: "28", height: "28", viewBox: "0 0 24 24", fill: "white", className: "group-hover:scale-110 transition-transform", children: _jsx("path", { d: "M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" }) }) }), _jsx("button", { onClick: handleAccept, className: "w-14 h-14 rounded-full bg-discord-green hover:bg-discord-green/80 flex items-center justify-center transition-colors group", title: "Accept", children: _jsx("svg", { width: "28", height: "28", viewBox: "0 0 24 24", fill: "white", className: "group-hover:scale-110 transition-transform", children: _jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }) })] })] })] })] }));
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
const PIP_WIDTH = 320;
|
||||
const PIP_HEIGHT = 180;
|
||||
const PIP_MARGIN = 16;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
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' };
|
||||
}
|
||||
// Priority 2: Focused participant with camera
|
||||
if (focusedId) {
|
||||
const focused = participants.find(p => p.identity === focusedId);
|
||||
if (focused?.videoTrack) {
|
||||
return { participant: focused, track: focused.videoTrack, type: 'camera' };
|
||||
}
|
||||
}
|
||||
// Priority 3: Remote participant with camera
|
||||
const remoteWithCamera = participants.find(p => !p.isLocal && p.videoTrack !== null);
|
||||
if (remoteWithCamera?.videoTrack) {
|
||||
return { participant: remoteWithCamera, track: remoteWithCamera.videoTrack, type: 'camera' };
|
||||
}
|
||||
// Priority 4: Local participant with camera
|
||||
const localWithCamera = participants.find(p => p.isLocal && p.videoTrack !== null);
|
||||
if (localWithCamera?.videoTrack) {
|
||||
return { participant: localWithCamera, track: localWithCamera.videoTrack, type: 'camera' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function PictureInPicture() {
|
||||
const navigate = useNavigate();
|
||||
const videoRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
// Store state
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
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);
|
||||
const setPipCollapsed = useUIStore((s) => s.setPipCollapsed);
|
||||
const channelToServerMap = useServerStore((s) => s.channelToServerMap);
|
||||
const channels = useServerStore((s) => s.channels);
|
||||
// Drag state
|
||||
const [position, setPosition] = useState({ x: -1, y: -1 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
const dragStartPos = useRef({ x: 0, y: 0 });
|
||||
const hasMoved = useRef(false);
|
||||
// Reset pipCollapsed when joining a new call
|
||||
const prevVoiceChannel = useRef(currentVoiceChannelId);
|
||||
const prevDmCall = useRef(activeDmCall?.dmChannelId ?? null);
|
||||
useEffect(() => {
|
||||
const voiceChanged = currentVoiceChannelId !== prevVoiceChannel.current;
|
||||
const dmChanged = (activeDmCall?.dmChannelId ?? null) !== prevDmCall.current;
|
||||
prevVoiceChannel.current = currentVoiceChannelId;
|
||||
prevDmCall.current = activeDmCall?.dmChannelId ?? null;
|
||||
if ((voiceChanged && currentVoiceChannelId) || (dmChanged && activeDmCall)) {
|
||||
setPipCollapsed(false);
|
||||
}
|
||||
}, [currentVoiceChannelId, activeDmCall, setPipCollapsed]);
|
||||
// Visibility
|
||||
const isInServerVoice = currentVoiceChannelId !== null && currentChannelId !== currentVoiceChannelId;
|
||||
const isInDmCall = activeDmCall !== null && currentChannelId !== activeDmCall.dmChannelId;
|
||||
const shouldShow = (isInServerVoice || isInDmCall) && !voiceFullscreen && !pipCollapsed;
|
||||
// Stream selection
|
||||
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);
|
||||
if (speaking)
|
||||
return speaking;
|
||||
const remote = participants.find(p => !p.isLocal);
|
||||
if (remote)
|
||||
return remote;
|
||||
return participants[0] ?? null;
|
||||
}, [participants]);
|
||||
// Channel name for display
|
||||
const channelName = useMemo(() => {
|
||||
if (currentVoiceChannelId) {
|
||||
const ch = channels.find(c => c.id === currentVoiceChannelId);
|
||||
return ch?.name ?? 'Voice';
|
||||
}
|
||||
return 'Call';
|
||||
}, [currentVoiceChannelId, channels]);
|
||||
// Video track attachment
|
||||
// shouldShow in deps ensures re-run when PiP becomes visible (videoRef was null before)
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl)
|
||||
return;
|
||||
if (selectedStream?.track) {
|
||||
videoEl.srcObject = new MediaStream([selectedStream.track]);
|
||||
}
|
||||
else {
|
||||
videoEl.srcObject = null;
|
||||
}
|
||||
}, [selectedStream?.track, shouldShow]);
|
||||
// Initialize position to bottom-right
|
||||
useEffect(() => {
|
||||
if (shouldShow && position.x === -1) {
|
||||
setPosition({
|
||||
x: window.innerWidth - PIP_WIDTH - PIP_MARGIN,
|
||||
y: window.innerHeight - PIP_HEIGHT - PIP_MARGIN,
|
||||
});
|
||||
}
|
||||
}, [shouldShow, position.x]);
|
||||
// Window resize: keep PiP in bounds
|
||||
useEffect(() => {
|
||||
if (!shouldShow)
|
||||
return;
|
||||
const handleResize = () => {
|
||||
setPosition(prev => ({
|
||||
x: Math.max(PIP_MARGIN, Math.min(window.innerWidth - PIP_WIDTH - PIP_MARGIN, prev.x)),
|
||||
y: Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, prev.y)),
|
||||
}));
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [shouldShow]);
|
||||
// Snap to nearest horizontal edge
|
||||
const snapToEdge = useCallback((currentX, currentY) => {
|
||||
const centerX = currentX + PIP_WIDTH / 2;
|
||||
const screenMidX = window.innerWidth / 2;
|
||||
const targetX = centerX < screenMidX
|
||||
? PIP_MARGIN
|
||||
: window.innerWidth - PIP_WIDTH - PIP_MARGIN;
|
||||
const clampedY = Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, currentY));
|
||||
setPosition({ x: targetX, y: clampedY });
|
||||
}, []);
|
||||
// Drag handlers
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (e.target.closest('[data-pip-action]'))
|
||||
return;
|
||||
setIsDragging(true);
|
||||
hasMoved.current = false;
|
||||
dragOffset.current = { x: e.clientX - position.x, y: e.clientY - position.y };
|
||||
dragStartPos.current = { x: e.clientX, y: e.clientY };
|
||||
containerRef.current?.setPointerCapture(e.pointerId);
|
||||
}, [position]);
|
||||
const handlePointerMove = useCallback((e) => {
|
||||
if (!isDragging)
|
||||
return;
|
||||
const dx = Math.abs(e.clientX - dragStartPos.current.x);
|
||||
const dy = Math.abs(e.clientY - dragStartPos.current.y);
|
||||
if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
|
||||
hasMoved.current = true;
|
||||
}
|
||||
const newX = Math.max(PIP_MARGIN, Math.min(window.innerWidth - PIP_WIDTH - PIP_MARGIN, e.clientX - dragOffset.current.x));
|
||||
const newY = Math.max(PIP_MARGIN, Math.min(window.innerHeight - PIP_HEIGHT - PIP_MARGIN, e.clientY - dragOffset.current.y));
|
||||
setPosition({ x: newX, y: newY });
|
||||
}, [isDragging]);
|
||||
const handlePointerUp = useCallback((e) => {
|
||||
if (!isDragging)
|
||||
return;
|
||||
setIsDragging(false);
|
||||
containerRef.current?.releasePointerCapture(e.pointerId);
|
||||
if (!hasMoved.current) {
|
||||
// Click — navigate back to voice channel
|
||||
if (activeDmCall) {
|
||||
navigate(`/channels/@me/${activeDmCall.dmChannelId}`);
|
||||
}
|
||||
else if (currentVoiceChannelId) {
|
||||
const serverId = channelToServerMap.get(currentVoiceChannelId);
|
||||
if (serverId) {
|
||||
navigate(`/channels/${serverId}/${currentVoiceChannelId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Drag ended — snap to edge
|
||||
snapToEdge(position.x, position.y);
|
||||
}
|
||||
}, [isDragging, activeDmCall, currentVoiceChannelId, channelToServerMap, navigate, snapToEdge, position]);
|
||||
const handleClose = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
setPipCollapsed(true);
|
||||
}, [setPipCollapsed]);
|
||||
if (!shouldShow)
|
||||
return null;
|
||||
const displayParticipant = selectedStream?.participant ?? fallbackParticipant;
|
||||
const displayName = displayParticipant
|
||||
? (displayParticipant.isLocal ? `${displayParticipant.username} (You)` : displayParticipant.username)
|
||||
: channelName;
|
||||
const hasVideo = selectedStream !== null;
|
||||
const isScreen = selectedStream?.type === 'screen';
|
||||
return (_jsxs("div", { ref: containerRef, className: `fixed z-[40] overflow-hidden rounded-lg shadow-2xl ring-1 ring-white/10 bg-[#080a0b] select-none ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`, style: {
|
||||
width: PIP_WIDTH,
|
||||
height: PIP_HEIGHT,
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
transition: isDragging ? 'none' : 'left 0.2s ease, top 0.2s ease',
|
||||
touchAction: 'none',
|
||||
}, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, children: [hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-cover", style: { imageRendering: 'auto' } })) : (_jsx("div", { className: "w-full h-full flex items-center justify-center bg-[#1e1f22]", children: displayParticipant ? (_jsxs("div", { className: "relative", children: [_jsx(Avatar, { name: displayParticipant.username, size: 64 }), displayParticipant.isSpeaking && (_jsx("div", { className: "absolute -inset-1 rounded-full ring-2 ring-discord-green animate-pulse" }))] })) : (_jsxs("div", { className: "flex items-center gap-2 text-discord-text-muted", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("span", { className: "text-sm font-medium", children: channelName })] })) })), isScreen && (_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("button", { "data-pip-action": "close", onClick: handleClose, className: "absolute top-2 right-2 w-6 h-6 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-colors", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) }), _jsxs("div", { className: "absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 to-transparent", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "text-white text-xs font-semibold truncate", children: displayName }), displayParticipant?.isSpeaking && (_jsx("div", { className: "w-2 h-2 rounded-full bg-discord-green flex-shrink-0 animate-pulse" }))] }), _jsx("div", { className: "text-white/50 text-[10px] truncate", children: channelName })] }), _jsx("div", { className: "absolute bottom-2 right-2 w-5 h-5 flex items-center justify-center text-white/40", children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21 11V3h-8l3.29 3.29-10 10L3 13v8h8l-3.29-3.29 10-10z" }) }) })] }));
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
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 Real-Time Messages from any channel)
|
||||
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
||||
if (isInitialMount.current)
|
||||
return;
|
||||
// Only trigger on NEW realtimeMessageEvents entries (not API loads)
|
||||
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
|
||||
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
||||
for (const { message } of newEvents) {
|
||||
if (message.userId !== currentUser?.id) {
|
||||
audioManager.playSound('message');
|
||||
break; // one sound per batch
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
unsubscribeVoice();
|
||||
unsubscribeChat();
|
||||
if (incomingCallLoop.current)
|
||||
incomingCallLoop.current.stop();
|
||||
if (outgoingCallLoop.current)
|
||||
outgoingCallLoop.current.stop();
|
||||
};
|
||||
}, [audioManager, currentUser?.id]);
|
||||
return null;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
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 { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
export function StreamTile({ tile, large }) {
|
||||
const videoRef = useRef(null);
|
||||
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 { 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);
|
||||
// --- 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: [hasVideo && isWatching ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-contain bg-black" })) : (_jsxs("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: [_jsx("div", { className: "relative", children: _jsx(Avatar, { src: null, name: participant.username, size: large ? 80 : 48 }) }), _jsxs("div", { className: "text-center px-4", children: [_jsxs("p", { className: "text-discord-text-primary text-sm font-semibold", children: [participant.username, " is streaming"] }), !isLocal && (_jsx("button", { onClick: handleWatch, className: "mt-2 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple/80 rounded text-white text-xs font-semibold transition-colors", children: "Watch Stream" }))] })] })), _jsx("div", { className: "absolute top-2 left-2 px-1.5 py-0.5 bg-discord-red rounded text-[11px] font-bold text-white uppercase tracking-wide", children: "LIVE" }), qualityBadge && hasVideo && (_jsx("div", { className: "absolute top-2 right-2 px-1.5 py-0.5 bg-black/60 rounded text-[10px] font-bold text-white/70 uppercase tracking-wide", children: qualityBadge })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent", children: _jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-white/70 flex-shrink-0", children: _jsx("path", { d: "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" }) }), _jsx("span", { className: `font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/40 font-medium", children: "(you)" }))] }) }), contextMenu && (_jsx("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-2 min-w-[220px] border border-white/[0.06]", style: { left: contextMenu.x, top: contextMenu.y }, onClick: (e) => e.stopPropagation(), children: isLocal ? (
|
||||
/* Streamer context menu (own stream) */
|
||||
_jsxs(_Fragment, { children: [_jsxs("button", { onClick: () => {
|
||||
handleStopStreaming();
|
||||
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, "%"] })] })] }))] })) }))] }));
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { VideoPreset } from 'livekit-client';
|
||||
const PRESETS = [
|
||||
{ value: '1080p60', label: '1080p 60fps', desc: '1920x1080, 10000 kbps' },
|
||||
{ value: '1080p', label: '1080p 30fps', desc: '1920x1080, 5000 kbps' },
|
||||
{ value: '720p60', label: '720p 60fps', desc: '1280x720, 5000 kbps' },
|
||||
{ value: '720p', label: '720p 30fps', desc: '1280x720, 3000 kbps' },
|
||||
{ value: '540p', label: '540p 30fps', desc: '960x540, 1500 kbps' },
|
||||
{ value: '360p', label: '360p 30fps', desc: '640x360, 800 kbps' },
|
||||
];
|
||||
const QUALITY_MAP = {
|
||||
'1080p60': new VideoPreset(1920, 1080, 15_000_000, 60),
|
||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
||||
'720p': new VideoPreset(1280, 720, 5_000_000, 30),
|
||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
||||
};
|
||||
export function VideoQualityPopover({ open, onClose, anchorRect }) {
|
||||
const popoverRef = useRef(null);
|
||||
const videoQuality = useVoiceStore((s) => s.videoQuality);
|
||||
const setVideoQuality = useVoiceStore((s) => s.setVideoQuality);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return;
|
||||
const handleClick = (e) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open, onClose]);
|
||||
if (!open)
|
||||
return null;
|
||||
const handleSelect = async (quality) => {
|
||||
setVideoQuality(quality);
|
||||
onClose();
|
||||
};
|
||||
return (_jsxs("div", { ref: popoverRef, className: "absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[240px] bg-[#1e1f22] rounded-lg shadow-lg border border-[#111214] z-50 overflow-hidden", children: [_jsx("div", { className: "px-3 py-2 border-b border-[#111214]", children: _jsx("span", { className: "text-[14px] font-bold text-discord-text-primary", children: "Video Quality" }) }), _jsx("div", { className: "py-1", children: PRESETS.map((preset) => (_jsxs("button", { onClick: () => handleSelect(preset.value), className: `w-full px-3 py-2 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors ${videoQuality === preset.value ? 'text-discord-text-primary' : 'text-discord-text-secondary'}`, children: [_jsxs("div", { className: "text-left", children: [_jsx("div", { className: "text-[14px] font-medium", children: preset.label }), _jsx("div", { className: "text-[12px] text-discord-text-muted", children: preset.desc })] }), videoQuality === preset.value && (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-blurple flex-shrink-0 ml-2", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) }))] }, preset.value))) })] }));
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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';
|
||||
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
|
||||
? 'bg-discord-modifier-selected text-white'
|
||||
: '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 displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId;
|
||||
const avatar = member?.user.avatar ?? null;
|
||||
const status = member?.user.status;
|
||||
// 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: [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));
|
||||
}) }))] }));
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { MessageList } from '../chat/MessageList';
|
||||
import { MessageInput } from '../chat/MessageInput';
|
||||
import { TypingIndicator } from '../chat/TypingIndicator';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
export function VoiceChatPanel({ channelId, channelName }) {
|
||||
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
||||
return (_jsxs("div", { className: "w-[340px] flex-shrink-0 bg-discord-bg-primary flex flex-col border-l border-[#2b2d31]", children: [_jsxs("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0", children: [_jsx("span", { className: "font-bold text-discord-text-primary text-[16px]", children: "Chat" }), _jsx("button", { onClick: toggleVoiceChat, className: "w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded", title: "Close Chat", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] }), _jsx(MessageList, { channelId: channelId }), _jsx(TypingIndicator, { channelId: channelId }), _jsx(MessageInput, { channelId: channelId, channelName: channelName })] }));
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
import { VideoPreset } from 'livekit-client';
|
||||
const QUALITY_MAP = {
|
||||
'1080p60': new VideoPreset(1920, 1080, 15_000_000, 60),
|
||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
||||
'720p': new VideoPreset(1280, 720, 5_000_000, 30),
|
||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
||||
};
|
||||
const btnBase = 'w-10 h-10 flex items-center justify-center rounded-full transition-colors';
|
||||
const btnDefault = `${btnBase} bg-[#1e1f22] text-discord-text-secondary hover:bg-[#2b2d31] hover:text-discord-text-primary`;
|
||||
const btnActive = (color) => `${btnBase} bg-${color}/20 text-${color} hover:bg-${color}/30`;
|
||||
const btnGreen = `${btnBase} bg-[#1e1f22] text-discord-green hover:bg-[#2b2d31]`;
|
||||
export function VoiceControlBar() {
|
||||
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 toggleMic = useVoiceStore((s) => s.toggleMic);
|
||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||
const voiceChatOpen = useUIStore((s) => s.voiceChatOpen);
|
||||
const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat);
|
||||
const voiceFullscreen = useUIStore((s) => s.voiceFullscreen);
|
||||
const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen);
|
||||
const [qualityOpen, setQualityOpen] = useState(false);
|
||||
const handleMute = React.useCallback(async () => {
|
||||
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 {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}, [isDeafened, isMuted, toggleDeafen, toggleMic]);
|
||||
const handleCamera = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room)
|
||||
return;
|
||||
try {
|
||||
const willEnable = !isCameraOn;
|
||||
if (willEnable) {
|
||||
const videoQuality = useVoiceStore.getState().videoQuality;
|
||||
const preset = QUALITY_MAP[videoQuality];
|
||||
if (preset) {
|
||||
await room.localParticipant.setCameraEnabled(true, { resolution: preset.resolution }, {
|
||||
videoEncoding: preset.encoding,
|
||||
simulcast: videoQuality === '1080p' || videoQuality === '720p'
|
||||
});
|
||||
}
|
||||
else {
|
||||
await room.localParticipant.setCameraEnabled(true);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await room.localParticipant.setCameraEnabled(false);
|
||||
}
|
||||
toggleCamera();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle camera:', err);
|
||||
}
|
||||
};
|
||||
const handleScreenShare = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room)
|
||||
return;
|
||||
try {
|
||||
if (!isScreenSharing) {
|
||||
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
||||
}
|
||||
else {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
}
|
||||
toggleScreenShare();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControlBar] Failed to toggle screen share:', err);
|
||||
}
|
||||
};
|
||||
const handleDisconnect = () => {
|
||||
wsSend({ type: 'voice_leave' });
|
||||
useVoiceStore.getState().leaveVoice();
|
||||
if (voiceFullscreen) {
|
||||
useUIStore.getState().setVoiceFullscreen(false);
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => { });
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleFullscreen = () => {
|
||||
toggleVoiceFullscreen();
|
||||
};
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
|
||||
return;
|
||||
if (e.key === 'm' || e.key === 'M') {
|
||||
e.preventDefault();
|
||||
handleMute();
|
||||
}
|
||||
else if (e.key === 'd' || e.key === 'D') {
|
||||
e.preventDefault();
|
||||
handleDeafen();
|
||||
}
|
||||
else if (e.key === 'Escape' && voiceFullscreen) {
|
||||
useUIStore.getState().setVoiceFullscreen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleMute, handleDeafen, voiceFullscreen]);
|
||||
return (_jsx("div", { className: "absolute bottom-6 left-1/2 -translate-x-1/2 z-20 opacity-0 translate-y-4 group-hover/voice:opacity-100 group-hover/voice:translate-y-0 transition-all duration-300 ease-out", children: _jsxs("div", { className: "flex items-center gap-1.5 rounded-full px-3 py-2 bg-[#111214]/90 backdrop-blur-md ring-1 ring-white/[0.06] shadow-[0_8px_32px_rgba(0,0,0,0.5)]", children: [_jsx("button", { onClick: handleMute, className: isMuted || isDeafened
|
||||
? `${btnBase} bg-discord-red/20 text-discord-red hover:bg-discord-red/30`
|
||||
: btnDefault, title: isMuted ? 'Unmute (M)' : 'Mute (M)', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), (isMuted || isDeafened) && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: handleDeafen, className: isDeafened
|
||||
? `${btnBase} bg-discord-red/20 text-discord-red hover:bg-discord-red/30`
|
||||
: btnDefault, title: isDeafened ? 'Undeafen (D)' : 'Deafen (D)', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: handleCamera, className: isCameraOn ? btnGreen : btnDefault, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: isCameraOn ? (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) })) : (_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }), _jsx("line", { x1: "2", y1: "2", x2: "22", y2: "22", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) }), _jsx("button", { onClick: handleScreenShare, className: isScreenSharing ? btnGreen : btnDefault, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", 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 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }), _jsx("path", { d: "M15 11L11 14V12H9V10H11V8L15 11Z" })] }) }), _jsxs("div", { className: "relative", children: [_jsx("button", { onClick: () => setQualityOpen(!qualityOpen), className: qualityOpen
|
||||
? `${btnBase} bg-[#1e1f22] text-discord-text-primary`
|
||||
: btnDefault, title: "Video Quality", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) }), _jsx(VideoQualityPopover, { open: qualityOpen, onClose: () => setQualityOpen(false) })] }), _jsx("div", { className: "w-[1px] h-6 bg-white/10 mx-0.5" }), _jsx("button", { onClick: toggleVoiceChat, className: voiceChatOpen
|
||||
? `${btnBase} bg-[#1e1f22] text-discord-text-primary`
|
||||
: btnDefault, title: "Toggle Chat", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-6H6V6h12v2z" }) }) }), _jsx("button", { onClick: handleFullscreen, className: voiceFullscreen
|
||||
? `${btnBase} bg-[#1e1f22] text-discord-text-primary`
|
||||
: btnDefault, title: voiceFullscreen ? 'Exit Fullscreen (Esc)' : 'Fullscreen', children: voiceFullscreen ? (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" }) })) : (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" }) })) }), _jsx("div", { className: "w-[1px] h-6 bg-white/10 mx-0.5" }), _jsx("button", { onClick: handleDisconnect, className: `${btnBase} bg-discord-red hover:bg-discord-red-hover text-white`, title: "Disconnect", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] }) }));
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useState } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
/**
|
||||
* VoiceControls renders the voice status + button rows.
|
||||
* It has NO wrapper/card styling — the parent provides the container.
|
||||
*/
|
||||
export function VoiceControls() {
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
|
||||
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
|
||||
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
||||
const setRnnoiseEnabled = useVoiceStore((s) => s.setRnnoiseEnabled);
|
||||
const connectionError = useVoiceStore((s) => s.connectionError);
|
||||
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||
const channels = useServerStore((s) => s.channels);
|
||||
const [showVideoQuality, setShowVideoQuality] = useState(false);
|
||||
if (!currentVoiceChannelId)
|
||||
return null;
|
||||
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||
const channelName = channel?.name ?? 'Voice Channel';
|
||||
const handleCamera = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room)
|
||||
return;
|
||||
try {
|
||||
await room.localParticipant.setCameraEnabled(!isCameraOn);
|
||||
toggleCamera();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControls] Failed to toggle camera:', err);
|
||||
}
|
||||
};
|
||||
const handleScreenShare = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (!room)
|
||||
return;
|
||||
try {
|
||||
if (!isScreenSharing) {
|
||||
await room.localParticipant.setScreenShareEnabled(true, { audio: true });
|
||||
}
|
||||
else {
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
}
|
||||
toggleScreenShare();
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[VoiceControls] Failed to toggle screen share:', err);
|
||||
}
|
||||
};
|
||||
const handleDisconnect = () => {
|
||||
wsSend({ type: 'voice_leave' });
|
||||
useVoiceStore.getState().leaveVoice();
|
||||
};
|
||||
const statusColor = connectionError
|
||||
? 'text-discord-red'
|
||||
: isLiveKitConnected
|
||||
? 'text-discord-green'
|
||||
: 'text-discord-yellow';
|
||||
const statusBgColor = connectionError
|
||||
? 'bg-discord-red/20'
|
||||
: isLiveKitConnected
|
||||
? 'bg-discord-green/20'
|
||||
: 'bg-discord-yellow/20';
|
||||
const btnBase = 'flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors';
|
||||
const btnDefaultStyle = 'bg-[#111214] text-discord-text-muted hover:bg-[#1a1b1e] hover:text-discord-text-secondary';
|
||||
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center gap-2 px-3 pt-3 pb-1", children: [_jsx("div", { className: `w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0`, children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: statusColor, children: _jsx("path", { d: "M1.5 21.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM3.14 15.75a.75.75 0 01-.09-1.06A8.46 8.46 0 0112 11a8.46 8.46 0 018.95 3.69.75.75 0 01-1.15.97A6.96 6.96 0 0012 12.5a6.96 6.96 0 00-7.8 3.16.75.75 0 01-1.06.09zM6.37 18.3a.75.75 0 01-.08-1.06A5.46 5.46 0 0112 15a5.46 5.46 0 015.71 2.24.75.75 0 01-1.14.97A3.96 3.96 0 0012 16.5a3.96 3.96 0 00-4.57 1.71.75.75 0 01-1.06.09z" }) }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: `text-[13px] font-semibold leading-[18px] ${statusColor}`, children: connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...' }), _jsx("div", { className: "text-[12px] text-discord-channels-default truncate leading-[16px]", children: connectionError ? connectionError : channelName })] }), _jsxs("div", { className: "flex items-center gap-0.5 flex-shrink-0", children: [_jsx("button", { className: "w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded", title: "Connection Info", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M2 20h2V8H2v12zm5 0h2V4H7v16zm5 0h2v-8h-2v8zm5 0h2V12h-2v8z" }) }) }), _jsx("button", { onClick: handleDisconnect, className: "w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded", title: "Disconnect", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] })] }), _jsxs("div", { className: "relative flex items-center gap-1 px-3 pb-2 pt-1", children: [_jsx("button", { onClick: handleCamera, className: `${btnBase} ${isCameraOn
|
||||
? 'bg-[#111214] text-discord-green hover:bg-[#1a1b1e]'
|
||||
: btnDefaultStyle}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: isCameraOn ? (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) })) : (_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }), _jsx("line", { x1: "2", y1: "2", x2: "22", y2: "22", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) }), _jsx("button", { onClick: handleScreenShare, className: `${btnBase} ${isScreenSharing
|
||||
? 'bg-[#111214] text-discord-green hover:bg-[#1a1b1e]'
|
||||
: btnDefaultStyle}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", 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 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }), _jsx("path", { d: "M15 11L11 14V12H9V10H11V8L15 11Z" })] }) }), _jsx("button", { onClick: () => setShowVideoQuality(!showVideoQuality), className: `${btnBase} ${showVideoQuality
|
||||
? 'bg-[#111214] text-discord-blurple hover:bg-[#1a1b1e]'
|
||||
: btnDefaultStyle}`, title: "Video Quality", children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M3 5v14h18V5H3zm16 12H5V7h14v10z" }), _jsx("path", { d: "M8 15l2.5-3.21L13 15l2-2.5L18 17H6z" })] }) }), _jsx("button", { onClick: () => setRnnoiseEnabled(!rnnoiseEnabled), className: `${btnBase} ${rnnoiseEnabled
|
||||
? 'bg-[#111214] text-discord-green hover:bg-[#1a1b1e]'
|
||||
: btnDefaultStyle}`, title: rnnoiseEnabled ? 'Disable AI Noise Suppression' : 'Enable AI Noise Suppression', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z", opacity: rnnoiseEnabled ? 0.15 : 0.08 }), _jsx("path", { d: "M12 1a2 2 0 012 2v1a2 2 0 01-4 0V3a2 2 0 012-2z" }), _jsx("path", { d: "M12 7c-1.66 0-3 1.34-3 3v2c0 1.66 1.34 3 3 3s3-1.34 3-3v-2c0-1.66-1.34-3-3-3z" }), _jsx("path", { d: "M17 11v1c0 2.76-2.24 5-5 5s-5-2.24-5-5v-1H5v1c0 3.53 2.61 6.43 6 6.92V21h2v-2.08c3.39-.49 6-3.39 6-6.92v-1h-2z" }), rnnoiseEnabled ? (_jsxs(_Fragment, { children: [_jsx("circle", { cx: "18", cy: "5", r: "1.2", fill: "currentColor" }), _jsx("circle", { cx: "20", cy: "8", r: "0.9", fill: "currentColor", opacity: "0.7" }), _jsx("circle", { cx: "6", cy: "5", r: "1.2", fill: "currentColor" }), _jsx("circle", { cx: "4", cy: "8", r: "0.9", fill: "currentColor", opacity: "0.7" })] })) : (_jsx("line", { x1: "4", y1: "4", x2: "20", y2: "20", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", opacity: "0.4" }))] }) }), _jsx(VideoQualityPopover, { open: showVideoQuality, onClose: () => setShowVideoQuality(false) })] })] }));
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useEffect, useMemo, useState } 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 [stripHidden, setStripHidden] = useState(false);
|
||||
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
||||
// Reset strip visibility when focus target changes
|
||||
useEffect(() => {
|
||||
setStripHidden(false);
|
||||
}, [focusedParticipantId]);
|
||||
// Unfocus if the focused stream tile no longer exists
|
||||
useEffect(() => {
|
||||
const currentStreamKeys = new Set(tiles
|
||||
.filter((t) => t.kind === 'stream' && t.screenTrack?.readyState === 'live')
|
||||
.map((t) => t.key));
|
||||
if (focusedParticipantId &&
|
||||
focusedParticipantId.endsWith(':stream') &&
|
||||
!currentStreamKeys.has(focusedParticipantId)) {
|
||||
setFocusedParticipant(null);
|
||||
}
|
||||
}, [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 focusedTile = focusedParticipantId
|
||||
? tiles.find((t) => t.key === focusedParticipantId)
|
||||
: null;
|
||||
// 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 relative", onClick: () => setFocusedParticipant(null), title: "Click to return to grid view", children: [renderTile(focusedTile, true), _jsxs("button", { onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
setFocusedParticipant(null);
|
||||
}, className: "absolute top-4 right-4 z-10 px-3 py-1.5 bg-black/60 hover:bg-black/80 rounded-lg flex items-center gap-2 text-white/70 hover:text-white transition-colors", title: "Back to grid view", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" }) }), _jsx("span", { className: "text-xs font-medium", children: "Grid" })] })] }), otherTiles.length > 0 && (_jsx("div", { className: "flex justify-center flex-shrink-0 py-1", children: _jsxs("button", { onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
setStripHidden(!stripHidden);
|
||||
}, className: "px-4 py-1 bg-black/50 hover:bg-black/70 rounded-full flex items-center gap-2 text-white/60 hover:text-white transition-colors text-xs", title: stripHidden ? 'Show Members' : 'Hide Members', children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", children: stripHidden
|
||||
? _jsx("path", { d: "M7 14l5-5 5 5z" })
|
||||
: _jsx("path", { d: "M7 10l5 5 5-5z" }) }), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }), _jsx("span", { children: stripHidden ? 'Show Members' : 'Hide Members' })] }) })), !stripHidden && otherTiles.length > 0 && (_jsx("div", { className: "h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-[#111214]/50 overflow-x-auto no-scrollbar", children: otherTiles.map((t) => (_jsx("div", { onClick: () => setFocusedParticipant(t.key), className: "h-full aspect-video flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity", children: renderTile(t) }, t.key))) }))] }));
|
||||
}
|
||||
// Default grid mode
|
||||
const gridClass = (() => {
|
||||
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 (_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))) }) }));
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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({ tile, large }) {
|
||||
const videoRef = useRef(null);
|
||||
const { participant } = tile;
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||
const [, forceUpdate] = useState(0);
|
||||
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
||||
const isLocal = participant.isLocal;
|
||||
// --- 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);
|
||||
tile.videoTrack.addEventListener('ended', onEnded);
|
||||
return () => tile.videoTrack?.removeEventListener('ended', onEnded);
|
||||
}, [tile.videoTrack]);
|
||||
// Attach Video
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl)
|
||||
return;
|
||||
if (activeVideoTrack) {
|
||||
videoEl.srcObject = new MediaStream([activeVideoTrack]);
|
||||
}
|
||||
else {
|
||||
videoEl.srcObject = null;
|
||||
}
|
||||
}, [activeVideoTrack]);
|
||||
// Context Menu
|
||||
const [volumeMenu, setVolumeMenu] = useState(null);
|
||||
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
|
||||
const handleContextMenu = useCallback((e) => {
|
||||
if (isLocal)
|
||||
return;
|
||||
e.preventDefault();
|
||||
setVolumeMenu({ x: e.clientX, y: e.clientY });
|
||||
}, [isLocal]);
|
||||
useEffect(() => {
|
||||
if (!volumeMenu)
|
||||
return;
|
||||
const close = () => setVolumeMenu(null);
|
||||
window.addEventListener('click', close);
|
||||
return () => window.removeEventListener('click', close);
|
||||
}, [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 w-full' : 'h-full aspect-video'}`, onContextMenu: handleContextMenu, children: [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, "%"] })] })] }))] }));
|
||||
}
|
||||
Reference in New Issue
Block a user