diff --git a/src/App.css b/src/App.css
index 3e22198..b3bacdc 100644
--- a/src/App.css
+++ b/src/App.css
@@ -94,11 +94,14 @@ body {
width: var(--channel-sidebar-width);
height: 100%;
flex-shrink: 0;
+ position: relative;
+ z-index: 100;
}
.channel-sidebar {
flex: 1;
overflow-y: auto;
+ overflow-x: visible;
display: flex;
flex-direction: column;
}
@@ -488,7 +491,7 @@ body {
right: 8px;
}
-.video-tile:hover .video-controls,
+.video-tile:hover .video-controls,
.video-tile:hover .tile-actions-right {
opacity: 1;
}
@@ -623,10 +626,10 @@ body {
/* Connection Popover */
.connection-popover {
position: absolute;
- top: 0;
- left: 100%;
- margin-left: 12px;
- width: 220px;
+ top: 100%;
+ left: 12px;
+ margin-top: 4px;
+ width: 216px;
background-color: var(--background-floating, #1e1f22);
border-radius: 8px;
padding: 12px;
@@ -640,8 +643,8 @@ body {
.connection-popover::before {
content: '';
position: absolute;
- top: 8px;
- left: -6px;
+ top: -6px;
+ left: 20px;
width: 12px;
height: 12px;
background-color: var(--background-floating);
diff --git a/src/App.tsx b/src/App.tsx
index 42f4395..b3731f9 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -7,7 +7,6 @@ import './App.css';
// import { Identity } from 'spacetimedb';
// import { useAuth } from "react-oidc-context";
// import { TOKEN_KEY } from './main';
-// import { useWebRTC } from './useWebRTC';
// Import the new ChatContainer component
import { ChatContainer } from './chat'; // Import from index.ts
diff --git a/src/chat/ChatContainer.tsx b/src/chat/ChatContainer.tsx
index 5373255..ee9790f 100644
--- a/src/chat/ChatContainer.tsx
+++ b/src/chat/ChatContainer.tsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
-import { useChat } from './services';
+import { useChat, useWebRTC } from './services';
import ServerList from './components/ServerList';
import ChannelList from './components/ChannelList';
import MessageList from './components/MessageList';
@@ -9,9 +9,7 @@ import ThreadView from './components/ThreadView';
import ServerDiscovery from './components/ServerDiscovery';
import { VideoGrid } from './components/VideoGrid';
import { SettingsPanel } from './components/SettingsPanel';
-import { useSpacetimeDB, useTable } from 'spacetimedb/react';
-import { tables } from '../module_bindings';
-import useWebRTC from '../useWebRTC';
+import { useSpacetimeDB } from 'spacetimedb/react';
const ChatContainer: React.FC = () => {
const chat = useChat();
@@ -19,12 +17,12 @@ const ChatContainer: React.FC = () => {
const [showSettings, setShowSettings] = useState(false);
const [showMemberList, setShowMemberList] = useState(true);
- const {
- peerStatuses,
- peers,
- localScreenStream,
- startScreenShare,
- stopScreenShare,
+ const {
+ peerStatuses,
+ peers,
+ localScreenStream,
+ startScreenShare,
+ stopScreenShare,
isSharingScreen,
startWatching,
stopWatching,
@@ -42,7 +40,7 @@ const ChatContainer: React.FC = () => {
return (
-
{
setShowDiscoveryModal={chat.setShowDiscoveryModal}
handleLeaveServer={chat.handleLeaveServer}
/>
-
+
- {
-
-
-
-
-
+
{chat.isActiveChannelVoice && chat.connectedVoiceChannel?.id === chat.activeChannel?.id && (
-
-
+
{chat.isActiveChannelVoice ? (
- {
/>
) : (
<>
- {
handleStartThread={chat.handleStartThread}
isFullyAuthenticated={chat.isFullyAuthenticated}
/>
- {
{chat.activeThreadId ? (
- {
/>
) : (
showMemberList && (
- {
)}
{chat.showDiscoveryModal && (
- chat.setShowDiscoveryModal(false)}
diff --git a/src/chat/components/ChannelList.tsx b/src/chat/components/ChannelList.tsx
index 783e145..7f1429b 100644
--- a/src/chat/components/ChannelList.tsx
+++ b/src/chat/components/ChannelList.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { Identity } from "spacetimedb";
import * as Types from "../../module_bindings/types";
-import { WebRTCStats } from "../../useWebRTC";
+import { WebRTCStats } from "../services";
interface ChannelListProps {
activeServerId: bigint | null;
@@ -58,7 +58,7 @@ const ConnectionPopover: React.FC<{ stats?: WebRTCStats, status: string, name: s
{name}
{status}
-
+
{isMe ? (
Local connection (sending only)
) : stats ? (
@@ -160,9 +160,9 @@ export const ChannelList: React.FC = ({
๐
{channel.name}
-
+
{/* Voice Channel Members */}
-
+
{voiceStates.filter(vs => vs.channelId === channel.id).map(vs => {
const peerIdHex = vs.identity.toHexString();
const isMe = identity?.isEqual(vs.identity);
@@ -181,27 +181,27 @@ export const ChannelList: React.FC
= ({
else if (videoStatusColor === 'yellow' && finalStatusColor === 'green') finalStatusColor = 'yellow';
return (
-
-
= ({
{getUsername(vs.identity, users)}
{isSharing && (
-
LIVE
)}
-
setHoveredPeer(peerIdHex)}
onMouseLeave={() => setHoveredPeer(null)}
>
@@ -240,7 +240,7 @@ export const ChannelList: React.FC = ({
{hoveredPeer === peerIdHex && (
-
void;
+ toggleDeafen: () => void;
+ startScreenShare: (peerManager: any) => Promise;
+ stopScreenShare: (peerManager: any) => void;
+ requestMic: () => Promise;
+ releaseMic: () => void;
+}
diff --git a/src/chat/services/webrtc/useLocalMedia.ts b/src/chat/services/webrtc/useLocalMedia.ts
new file mode 100644
index 0000000..4066f5a
--- /dev/null
+++ b/src/chat/services/webrtc/useLocalMedia.ts
@@ -0,0 +1,145 @@
+import { useState, useRef, useEffect, useCallback } from "react";
+import { useReducer } from "spacetimedb/react";
+import { reducers } from "../../../module_bindings";
+
+export const useLocalMedia = () => {
+ const [localStream, setLocalStream] = useState(null);
+ const [localScreenStream, setLocalScreenStream] = useState(null);
+ const [isMuted, setIsMuted] = useState(false);
+ const [isDeafened, setIsDeafened] = useState(false);
+ const [isTalking, setIsTalking] = useState(false);
+
+ const localStreamRef = useRef(null);
+ const localScreenStreamRef = useRef(null);
+ const isTalkingRef = useRef(false);
+
+ const setTalking = useReducer(reducers.setTalking);
+ const setSharingScreen = useReducer(reducers.setSharingScreen);
+
+ const toggleMute = useCallback(() => setIsMuted(prev => !prev), []);
+ const toggleDeafen = useCallback(() => setIsDeafened(prev => !prev), []);
+
+ const requestMic = useCallback(async () => {
+ if (localStreamRef.current) return;
+ try {
+ console.log("[WebRTC] Requesting mic permission...");
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
+ setLocalStream(stream);
+ localStreamRef.current = stream;
+ } catch (err) {
+ console.error("[WebRTC] Failed to get mic:", err);
+ }
+ }, []);
+
+ const releaseMic = useCallback(() => {
+ if (localStreamRef.current) {
+ localStreamRef.current.getTracks().forEach(track => track.stop());
+ setLocalStream(null);
+ localStreamRef.current = null;
+ }
+ }, []);
+
+ const startScreenShare = useCallback(async (onTrackReady: (track: MediaStreamTrack) => void) => {
+ try {
+ console.log("[WebRTC] Requesting screen share...");
+ const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
+ setLocalScreenStream(stream);
+ localScreenStreamRef.current = stream;
+ setSharingScreen({ sharing: true });
+
+ const videoTrack = stream.getVideoTracks()[0];
+ if (videoTrack) {
+ onTrackReady(videoTrack);
+ videoTrack.onended = () => stopScreenShare(() => onTrackReady(null as any));
+ }
+ } catch (err) {
+ console.error("[WebRTC] Failed to start screen share:", err);
+ }
+ }, [setSharingScreen]);
+
+ const stopScreenShare = useCallback((onTrackCleared: (track: MediaStreamTrack | null) => void) => {
+ if (localScreenStreamRef.current) {
+ localScreenStreamRef.current.getTracks().forEach(track => track.stop());
+ setLocalScreenStream(null);
+ localScreenStreamRef.current = null;
+ setSharingScreen({ sharing: false });
+ onTrackCleared(null);
+ }
+ }, [setSharingScreen]);
+
+ // Handle Mute/Deafen effect on tracks
+ useEffect(() => {
+ if (localStreamRef.current) {
+ localStreamRef.current.getAudioTracks().forEach(track => {
+ track.enabled = !isMuted && !isDeafened;
+ });
+ }
+ }, [isMuted, isDeafened]);
+
+ // Voice Activity Detection
+ useEffect(() => {
+ if (!localStream) {
+ if (isTalkingRef.current) {
+ setTalking({ talking: false });
+ isTalkingRef.current = false;
+ setIsTalking(false);
+ }
+ return;
+ }
+
+ const audioContext = new AudioContext();
+ const analyser = audioContext.createAnalyser();
+ const source = audioContext.createMediaStreamSource(localStream);
+ analyser.fftSize = 256;
+ source.connect(analyser);
+ const dataArray = new Uint8Array(analyser.frequencyBinCount);
+ const threshold = 15;
+ let silenceFrames = 0;
+ const maxSilenceFrames = 10;
+
+ const checkAudio = setInterval(() => {
+ analyser.getByteFrequencyData(dataArray);
+ let sum = 0;
+ for (let i = 0; i < dataArray.length; i++) sum += dataArray[i];
+ const average = sum / dataArray.length;
+
+ if (average > threshold) {
+ silenceFrames = 0;
+ if (!isTalkingRef.current) {
+ setTalking({ talking: true });
+ isTalkingRef.current = true;
+ setIsTalking(true);
+ }
+ } else {
+ silenceFrames++;
+ if (silenceFrames > maxSilenceFrames && isTalkingRef.current) {
+ setTalking({ talking: false });
+ isTalkingRef.current = false;
+ setIsTalking(false);
+ }
+ }
+ }, 50);
+
+ return () => {
+ clearInterval(checkAudio);
+ audioContext.close();
+ };
+ }, [localStream, setTalking]);
+
+ return {
+ localStream,
+ localScreenStream,
+ isMuted,
+ isDeafened,
+ isTalking,
+ isSharingScreen: !!localScreenStream,
+ toggleMute,
+ toggleDeafen,
+ startScreenShare,
+ stopScreenShare,
+ requestMic,
+ releaseMic,
+ localStreamRef,
+ localScreenStreamRef
+ };
+};
diff --git a/src/chat/services/webrtc/usePeerManager.ts b/src/chat/services/webrtc/usePeerManager.ts
new file mode 100644
index 0000000..8900d13
--- /dev/null
+++ b/src/chat/services/webrtc/usePeerManager.ts
@@ -0,0 +1,239 @@
+import { useState, useRef, useEffect, useCallback } from "react";
+import { Identity } from "spacetimedb";
+import { Peer, WebRTCStats } from "./types";
+
+const ICE_SERVERS: RTCConfiguration = {
+ iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
+};
+
+export const usePeerManager = (
+ identity: Identity | null,
+ isDeafened: boolean,
+ localStreamRef: React.MutableRefObject,
+ localScreenStreamRef: React.MutableRefObject,
+ onNegotiationNeeded: (peerIdHex: string, pc: RTCPeerConnection) => void,
+ onIceCandidate: (peerIdHex: string, candidate: RTCIceCandidate) => void
+) => {
+ const [peers, setPeers] = useState