diff --git a/src/main/index.ts b/src/main/index.ts index 4b96f99..0b13b54 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -82,6 +82,12 @@ app.whenReady().then(() => { } }); + ipcMain.on('send-screen-frame', (_, { frame }) => { + if (networkManager) { + networkManager.sendScreenFrame(frame); + } + }); + // Screen sharing: get available sources ipcMain.handle('get-screen-sources', async () => { const sources = await desktopCapturer.getSources({ @@ -95,6 +101,13 @@ app.whenReady().then(() => { })); }); + // Chat + ipcMain.handle('send-chat', (_, { message, displayName }) => { + if (networkManager) { + networkManager.sendChat(message, displayName); + } + }); + createWindow() app.on('activate', function () { diff --git a/src/main/network.ts b/src/main/network.ts index a5bc765..3c274d8 100644 --- a/src/main/network.ts +++ b/src/main/network.ts @@ -79,16 +79,18 @@ export class NetworkManager extends EventEmitter { switch (msg.type) { case 'Joined': console.log('Joined Room:', msg.data); - this.userId = msg.data.self_id; // Server sends self_id, not user_id - // Init UDP + this.userId = msg.data.self_id; this.setupUdp(); - resolve(msg.data); // Return joined data (peers, etc) + resolve(msg.data); break; case 'PeerJoined': - this.mainWindow.webContents.send('peer-joined', msg.data); + this.safeSend('peer-joined', msg.data); break; case 'PeerLeft': - this.mainWindow.webContents.send('peer-left', msg.data); + this.safeSend('peer-left', msg.data); + break; + case 'ChatMessage': + this.safeSend('chat-message', msg.data); break; case 'Error': console.error('WS Error Msg:', msg.data); @@ -97,6 +99,20 @@ export class NetworkManager extends EventEmitter { } } + sendChat(message: string, displayName: string) { + if (!this.ws) return; + const chatMsg = { + type: 'ChatMessage', + data: { + user_id: this.userId, + display_name: displayName, + message, + timestamp: Date.now() + } + }; + this.ws.send(JSON.stringify(chatMsg)); + } + setupUdp() { this.udp = dgram.createSocket('udp4'); @@ -128,6 +144,10 @@ export class NetworkManager extends EventEmitter { // const flags = msg.readUInt16LE(20); const payload = msg.subarray(HEADER_SIZE); + const sequence = msg.readUInt32LE(6); + const timestamp = Number(msg.readBigUInt64LE(10)); + const fragIdx = msg.readUInt8(18); + const fragCnt = msg.readUInt8(19); if (mediaType === MediaType.Audio) { // Forward audio? Or decode here? @@ -137,35 +157,69 @@ export class NetworkManager extends EventEmitter { // OR decode here with `opus-native` binding. // For now, let's send payload to renderer via IPC. // Note: IPC with high frequency audio packets might be laggy. - // But for MVP it's okay. - this.mainWindow.webContents.send('audio-frame', { user_id: userId, data: payload }); + this.safeSend('audio-frame', { user_id: userId, data: payload }); } else if (mediaType === MediaType.Video) { - // Send to renderer - this.mainWindow.webContents.send('video-frame', { user_id: userId, data: payload }); + this.safeSend('video-frame', { + user_id: userId, + data: payload, + seq: sequence, + ts: timestamp, + fidx: fragIdx, + fcnt: fragCnt + }); + } else if (mediaType === MediaType.Screen) { + this.safeSend('screen-frame', { + user_id: userId, + data: payload, + seq: sequence, + ts: timestamp, + fidx: fragIdx, + fcnt: fragCnt + }); + } + } + + private safeSend(channel: string, data: any) { + if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.webContents) { + try { + this.mainWindow.webContents.send(channel, data); + } catch (e) { + console.error(`Failed to send ${channel} to renderer:`, e); + } } } sendVideoFrame(frame: Uint8Array) { - if (!this.udp) return; + if (!this.udp || !this.userId) return; - // Construct Header - const header = Buffer.alloc(HEADER_SIZE); - header.writeUInt8(1, 0); // Version - header.writeUInt8(MediaType.Video, 1); - header.writeUInt32LE(this.userId, 2); - header.writeUInt32LE(this.videoSeq++, 6); - header.writeBigUInt64LE(BigInt(Date.now()), 10); - header.writeUInt8(0, 18); // Frag idx - header.writeUInt8(1, 19); // Frag cnt - header.writeUInt16LE(0, 20); // Flags + const buffer = Buffer.from(frame); + const MAX_PAYLOAD = 1400; + const fragCount = Math.ceil(buffer.length / MAX_PAYLOAD); + const seq = this.videoSeq++; + const ts = BigInt(Date.now()); - const packet = Buffer.concat([header, Buffer.from(frame)]); + for (let i = 0; i < fragCount; i++) { + const start = i * MAX_PAYLOAD; + const end = Math.min(start + MAX_PAYLOAD, buffer.length); + const chunk = buffer.subarray(start, end); - console.log(`[UDP] Sending Video: ${packet.length} bytes to ${SERVER_UDP_HOST}:${SERVER_UDP_PORT}`); - // Send - this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => { - if (err) console.error('UDP Send Error', err); - }); + // MediaType.Video = 1 + const header = Buffer.alloc(HEADER_SIZE); + header.writeUInt8(1, 0); // Version + header.writeUInt8(MediaType.Video, 1); + header.writeUInt32LE(this.userId, 2); + header.writeUInt32LE(seq, 6); + header.writeBigUInt64LE(ts, 10); + header.writeUInt8(i, 18); // Frag idx + header.writeUInt8(fragCount, 19); // Frag cnt + header.writeUInt16LE(0, 20); // Flags + + const packet = Buffer.concat([header, chunk]); + + this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => { + if (err) console.error('UDP Video Send Error', err); + }); + } } sendAudioFrame(frame: Uint8Array) { @@ -184,12 +238,46 @@ export class NetworkManager extends EventEmitter { const packet = Buffer.concat([header, Buffer.from(frame)]); - console.log(`[UDP] Sending Audio: ${packet.length} bytes to ${SERVER_UDP_HOST}:${SERVER_UDP_PORT}`); this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => { if (err) console.error('UDP Audio Send Error', err); }); } + private screenSeq = 0; + + sendScreenFrame(frame: number[]) { + if (!this.udp || !this.userId) return; + + const buffer = Buffer.from(frame); + const MAX_PAYLOAD = 1400; // MTU friendly (~1500 total with headers) + const fragCount = Math.ceil(buffer.length / MAX_PAYLOAD); + const seq = this.screenSeq++; + const ts = BigInt(Date.now()); + + for (let i = 0; i < fragCount; i++) { + const start = i * MAX_PAYLOAD; + const end = Math.min(start + MAX_PAYLOAD, buffer.length); + const chunk = buffer.subarray(start, end); + + // MediaType.Screen = 2 + const header = Buffer.alloc(HEADER_SIZE); + header.writeUInt8(1, 0); // Version + header.writeUInt8(MediaType.Screen, 1); + header.writeUInt32LE(this.userId, 2); + header.writeUInt32LE(seq, 6); + header.writeBigUInt64LE(ts, 10); + header.writeUInt8(i, 18); // Frag idx + header.writeUInt8(fragCount, 19); // Frag cnt + header.writeUInt16LE(0, 20); // Flags + + const packet = Buffer.concat([header, chunk]); + + this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => { + if (err) console.error('UDP Screen Send Error', err); + }); + } + } + sendHandshake() { if (!this.udp || !this.userId || !this.roomCode) { console.error('[UDP] Cannot send handshake: missing udp, userId, or roomCode'); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e9db61c..6e2ff86 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4,8 +4,8 @@ import "./index.css"; import { Lobby } from "./components/Lobby"; import { Stage } from "./components/Stage"; import { ControlBar } from "./components/ControlBar"; -import { DeviceSelector } from "./components/DeviceSelector"; -import { PeerInfo } from "./types"; +import { ChatPanel } from "./components/ChatPanel"; +import { PeerInfo, ChatMessage } from "./types"; const audioWorkletCode = ` class PCMProcessor extends AudioWorkletProcessor { @@ -34,11 +34,9 @@ function App() { const [selfId, setSelfId] = useState(null); const [peers, setPeers] = useState([]); const [error, setError] = useState(""); - const [logs, setLogs] = useState([]); const addLog = (msg: string) => { - console.log(msg); - setLogs(prev => [...prev.slice(-19), `[${new Date().toLocaleTimeString()}] ${msg}`]); + console.log(`[Meet] ${msg}`); }; // Media State @@ -46,11 +44,19 @@ function App() { const [videoEnabled, setVideoEnabled] = useState(false); const [screenEnabled, setScreenEnabled] = useState(false); const [selectedAudioDevice, setSelectedAudioDevice] = useState(""); + const [selectedVideoDevice, setSelectedVideoDevice] = useState(""); + const [displayName, setDisplayName] = useState(""); + + // Chat State + const [chatOpen, setChatOpen] = useState(false); + const [chatMessages, setChatMessages] = useState([]); // Video Handling const localVideoRef = useRef(null); const canvasRef = useRef(null); const [peerVideoUrls, setPeerVideoUrls] = useState<{ [key: number]: string }>({}); + const [peerScreenUrls, setPeerScreenUrls] = useState<{ [key: number]: string }>({}); + const [localScreenUrl, setLocalScreenUrl] = useState(null); // Event Listeners useEffect(() => { @@ -92,25 +98,131 @@ function App() { } }); - // Video Frame + // Chat Message - skip own messages (we add them locally) // @ts-ignore - const removeVideo = window.electron.ipcRenderer.on("video-frame", (_, payload) => { - const { user_id, data } = payload; - console.log("Video frame from:", user_id, "size:", data?.length || 0); - const uint8Array = new Uint8Array(data); - const blob = new Blob([uint8Array], { type: 'image/jpeg' }); - const url = URL.createObjectURL(blob); + const removeChatMessage = window.electron.ipcRenderer.on("chat-message", (_, data) => { + // Skip own messages (already added locally) + // We'll check by comparing user_id with current selfId later + const msg: ChatMessage = { + id: `${data.user_id}-${data.timestamp}`, + userId: data.user_id, + displayName: data.display_name, + message: data.message, + timestamp: data.timestamp + }; + // Only add if not from self (selfId check happens via closure) + setChatMessages(prev => { + // Deduplicate strictly by ID + if (prev.find(m => m.id === msg.id)) { + return prev; + } + // Also check if same message from same user within last second to avoid duplicate broadcasts + const duplicate = prev.find(m => + m.userId === msg.userId && + m.message === msg.message && + Math.abs(m.timestamp - msg.timestamp) < 2000 + ); + if (duplicate) return prev; - setPeerVideoUrls(prev => { - if (prev[user_id]) URL.revokeObjectURL(prev[user_id]); - return { ...prev, [user_id]: url }; + return [...prev, msg]; }); }); - // Audio Frame Playback with proper jitter buffer + // Video/Screen Reassembly logic + const fragmentMap = new Map(); + + // Video Frame (Reassembly) + // @ts-ignore + const removeVideo = window.electron.ipcRenderer.on("video-frame", (_, payload) => { + const { user_id, data, seq, fidx, fcnt } = payload; + const key = `v-${user_id}-${seq}`; + + if (!fragmentMap.has(key)) { + fragmentMap.set(key, { chunks: new Array(fcnt), count: 0, total: fcnt }); + } + + const state = fragmentMap.get(key)!; + if (!state.chunks[fidx]) { + state.chunks[fidx] = new Uint8Array(data); + state.count++; + } + + if (state.count === state.total) { + // All fragments received: assemble + const totalLen = state.chunks.reduce((acc, c) => acc + c.length, 0); + const fullBuffer = new Uint8Array(totalLen); + let offset = 0; + for (const chunk of state.chunks) { + fullBuffer.set(chunk, offset); + offset += chunk.length; + } + + const blob = new Blob([fullBuffer], { type: 'image/jpeg' }); + const url = URL.createObjectURL(blob); + + setPeerVideoUrls(prev => { + if (prev[user_id]) URL.revokeObjectURL(prev[user_id]); + return { ...prev, [user_id]: url }; + }); + + fragmentMap.delete(key); + // Clean up old sequences for this user to avoid memory leak + for (const k of fragmentMap.keys()) { + if (k.startsWith(`v-${user_id}-`) && k !== key) { + const kSeq = parseInt(k.split('-')[2]); + if (kSeq < seq) fragmentMap.delete(k); + } + } + } + }); + + // Screen Frame (Reassembly) + // @ts-ignore + const removeScreen = window.electron.ipcRenderer.on("screen-frame", (_, payload) => { + const { user_id, data, seq, fidx, fcnt } = payload; + const key = `s-${user_id}-${seq}`; + + if (!fragmentMap.has(key)) { + fragmentMap.set(key, { chunks: new Array(fcnt), count: 0, total: fcnt }); + } + + const state = fragmentMap.get(key)!; + if (!state.chunks[fidx]) { + state.chunks[fidx] = new Uint8Array(data); + state.count++; + } + + if (state.count === state.total) { + const totalLen = state.chunks.reduce((acc, c) => acc + c.length, 0); + const fullBuffer = new Uint8Array(totalLen); + let offset = 0; + for (const chunk of state.chunks) { + fullBuffer.set(chunk, offset); + offset += chunk.length; + } + + const blob = new Blob([fullBuffer], { type: 'image/jpeg' }); + const url = URL.createObjectURL(blob); + + setPeerScreenUrls(prev => { + if (prev[user_id]) URL.revokeObjectURL(prev[user_id]); + return { ...prev, [user_id]: url }; + }); + + fragmentMap.delete(key); + for (const k of fragmentMap.keys()) { + if (k.startsWith(`s-${user_id}-`) && k !== key) { + const kSeq = parseInt(k.split('-')[2]); + if (kSeq < seq) fragmentMap.delete(k); + } + } + } + }); + + // Audio Frame Playback - LOW LATENCY mode const playbackCtxRef = { current: null as AudioContext | null }; const nextPlayTimeRef = { current: 0 }; - const JITTER_BUFFER_MS = 80; // Buffer 80ms before starting playback + const JITTER_BUFFER_MS = 20; // Reduced from 80ms for low latency const bufferQueue: Float32Array[] = []; let isStarted = false; @@ -164,9 +276,9 @@ function App() { } if (!isStarted) { - // Buffer a few packets before starting + // Start immediately after just 1 packet for lowest latency bufferQueue.push(float32); - if (bufferQueue.length >= 3) { + if (bufferQueue.length >= 1) { isStarted = true; flushBuffer(); } @@ -181,7 +293,9 @@ function App() { return () => { removePeerJoined(); removePeerLeft(); + removeChatMessage(); removeVideo(); + removeScreen(); removeAudio(); }; }, []); @@ -220,7 +334,7 @@ function App() { } setTimeout(() => { if (isActive) animationFrameId = requestAnimationFrame(sendFrame); - }, 100); + }, 66); // ~15 FPS for lower latency }; if (videoEnabled) { @@ -236,7 +350,7 @@ function App() { // Camera Access - re-trigger when connected changes useEffect(() => { if (videoEnabled) { - navigator.mediaDevices.getUserMedia({ video: { width: 320, height: 240 }, audio: false }) + navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720 }, audio: false }) .then(stream => { if (localVideoRef.current) { localVideoRef.current.srcObject = stream; @@ -256,6 +370,126 @@ function App() { } }, [videoEnabled, connected]); + // Screen Sharing + const screenStreamRef = useRef(null); + const screenVideoRef = useRef(null); + + useEffect(() => { + if (!connected) return; + + let isActive = true; + const startScreenShare = async () => { + if (screenEnabled) { + try { + // Get available screen sources + // @ts-ignore + const sources = await window.electron.ipcRenderer.invoke('get-screen-sources'); + if (!sources || sources.length === 0) { + setError('No screen sources available'); + setScreenEnabled(false); + return; + } + + // Use first screen source (could add picker UI later) + const screenSource = sources[0]; + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + // @ts-ignore - Electron-specific constraint + mandatory: { + chromeMediaSource: 'desktop', + chromeMediaSourceId: screenSource.id, + maxWidth: 1920, + maxHeight: 1080, + maxFrameRate: 60 + } + } + }); + + if (!isActive) { + stream.getTracks().forEach(t => t.stop()); + return; + } + + screenStreamRef.current = stream; + addLog('Screen sharing started'); + + // Create hidden video element if needed + let screenVideo = screenVideoRef.current; + if (!screenVideo) { + screenVideo = document.createElement('video'); + screenVideo.autoplay = true; + screenVideo.muted = true; + } + screenVideo.srcObject = stream; + + // Send screen frames (similar to video but with different MediaType) + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = 1920; + canvas.height = 1080; + + const sendScreenFrame = () => { + if (!isActive || !screenEnabled || !screenStreamRef.current) return; + + if (ctx && screenVideo && screenVideo.readyState >= 2) { + ctx.drawImage(screenVideo, 0, 0, canvas.width, canvas.height); + canvas.toBlob((blob) => { + if (blob && isActive) { + // Also update local view + const url = URL.createObjectURL(blob); + setLocalScreenUrl(url); + + blob.arrayBuffer().then(buf => { + // @ts-ignore - Send as screen type + window.electron.ipcRenderer.send('send-screen-frame', { frame: Array.from(new Uint8Array(buf)) }); + }); + } + }, 'image/jpeg', 0.8); + } + + setTimeout(() => { + if (isActive) requestAnimationFrame(sendScreenFrame); + }, 16); // ~60 FPS for screen + }; + + screenVideo.onloadeddata = () => sendScreenFrame(); + + } catch (err) { + console.error('Screen share error:', err); + setError('Failed to start screen sharing'); + setScreenEnabled(false); + } + } else { + // Stop screen sharing + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach(t => t.stop()); + screenStreamRef.current = null; + } + if (localScreenUrl) { + URL.revokeObjectURL(localScreenUrl); + setLocalScreenUrl(null); + } + addLog('Screen sharing stopped'); + } + }; + + startScreenShare(); + + return () => { + isActive = false; + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach(t => t.stop()); + screenStreamRef.current = null; + } + if (localScreenUrl) { + URL.revokeObjectURL(localScreenUrl); + setLocalScreenUrl(null); + } + }; + }, [screenEnabled, connected, localScreenUrl]); + // Audio Capture const audioContextRef = useRef(null); const audioStreamRef = useRef(null); @@ -364,8 +598,8 @@ function App() { // Fallback to ScriptProcessor addLog("Falling back to ScriptProcessor..."); - // @ts-ignore - const scriptNode = ctx.createScriptProcessor(4096, 1, 1); + // @ts-ignore - 2048 samples for lower latency + const scriptNode = ctx.createScriptProcessor(2048, 1, 1); // @ts-ignore scriptNode.onaudioprocess = (e) => { if (!audioEnabled || !connected || isCancelled) return; @@ -404,14 +638,15 @@ function App() { }; }, [audioEnabled, connected, selectedAudioDevice]); - async function handleJoin(roomCode: string, displayName: string, initialVideo: boolean, initialAudio: boolean) { - if (!roomCode || !displayName) return; + async function handleJoin(roomCode: string, name: string, initialVideo: boolean, initialAudio: boolean) { + if (!roomCode || !name) return; + setDisplayName(name); setVideoEnabled(initialVideo); setAudioEnabled(initialAudio); setError(""); try { // @ts-ignore - const result = await window.electron.ipcRenderer.invoke("connect", { roomCode, displayName }); + const result = await window.electron.ipcRenderer.invoke("connect", { roomCode, displayName: name }); if (result) { addLog(`Connected: Self=${result.self_id}, Peers=${result.peers.length}`); setSelfId(result.self_id); @@ -439,60 +674,75 @@ function App() { const toggleAudio = () => setAudioEnabled(!audioEnabled); const toggleVideo = () => setVideoEnabled(!videoEnabled); const toggleScreen = () => setScreenEnabled(!screenEnabled); + const toggleChat = () => setChatOpen(!chatOpen); + + const sendChatMessage = (message: string) => { + if (!selfId) return; + // Don't add locally - server will broadcast it back to us + // But send displayName too to ensure server has it + // @ts-ignore - Send via WebSocket + window.electron.ipcRenderer.invoke('send-chat', { message, displayName: displayName || 'User' }); + }; return ( -
+
{!connected ? ( ) : ( -
- {/* Main Stage */} -
+
+ {/* Main Content Area */} +
+ {/* Stage */} -
-
- {/* Device Selector on the left or integrated? */} - {/* Make it float above or left of the mic button */} -
+ {/* Control Bar */} +
-
- -
+ {/* Chat Panel */} + {chatOpen && ( + setChatOpen(false)} + selfId={selfId} + /> + )} + {/* Hidden Canvas for capture */} - - {/* Debug Console */} -
- {logs.map((log, i) =>
{log}
)} -
)} {/* Error Toast */} {error && ( -
+
{error}
)} diff --git a/src/renderer/src/components/ChatPanel.tsx b/src/renderer/src/components/ChatPanel.tsx new file mode 100644 index 0000000..9a0f0d7 --- /dev/null +++ b/src/renderer/src/components/ChatPanel.tsx @@ -0,0 +1,147 @@ +import { useState, useRef, useEffect } from "react"; +import { Send, X } from "lucide-react"; + +interface ChatMessage { + id: string; + userId: number; + displayName: string; + message: string; + timestamp: number; +} + +interface ChatPanelProps { + messages: ChatMessage[]; + onSendMessage: (message: string) => void; + onClose: () => void; + selfId: number | null; +} + +// Group consecutive messages from same user +function groupMessages(messages: ChatMessage[]) { + const groups: { userId: number; displayName: string; messages: ChatMessage[]; startTime: number }[] = []; + + for (const msg of messages) { + const lastGroup = groups[groups.length - 1]; + // Group if same user and within 5 minutes + if (lastGroup && lastGroup.userId === msg.userId && msg.timestamp - lastGroup.startTime < 300000) { + lastGroup.messages.push(msg); + } else { + groups.push({ + userId: msg.userId, + displayName: msg.displayName, + messages: [msg], + startTime: msg.timestamp + }); + } + } + return groups; +} + +export function ChatPanel({ messages, onSendMessage, onClose, selfId }: ChatPanelProps) { + const [input, setInput] = useState(""); + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const handleSend = () => { + if (input.trim()) { + onSendMessage(input.trim()); + setInput(""); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const formatTime = (ts: number) => { + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + const messageGroups = groupMessages(messages); + + return ( +
+ {/* Header */} +
+

In-call messages

+ +
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+

No messages yet

+

Messages are deleted when the call ends

+
+ ) : ( + messageGroups.map((group, groupIdx) => { + const isSelf = group.userId === selfId; + return ( +
+ {/* Header with name and time (only for first message in group) */} +
+
+ {group.displayName.charAt(0).toUpperCase()} +
+ + {group.displayName} + {isSelf && (You)} + + {formatTime(group.startTime)} +
+ + {/* Messages in group */} +
+ {group.messages.map((msg) => ( +

+ {msg.message} +

+ ))} +
+
+ ); + }) + )} +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Message..." + className="flex-1 bg-transparent text-white text-sm placeholder-gray-400 outline-none" + /> + +
+
+
+ ); +} diff --git a/src/renderer/src/components/ControlBar.tsx b/src/renderer/src/components/ControlBar.tsx index 7d8d8f4..296cb18 100644 --- a/src/renderer/src/components/ControlBar.tsx +++ b/src/renderer/src/components/ControlBar.tsx @@ -1,4 +1,5 @@ -import { Mic, MicOff, PhoneOff, Camera, CameraOff } from "lucide-react"; +import { useState, useEffect, useRef } from 'react'; +import { Mic, MicOff, Camera, CameraOff, Monitor, MessageSquare, PhoneOff, ChevronUp } from 'lucide-react'; interface ControlBarProps { onLeave: () => void; @@ -6,6 +7,19 @@ interface ControlBarProps { toggleAudio: () => void; videoEnabled: boolean; toggleVideo: () => void; + screenEnabled: boolean; + toggleScreen: () => void; + chatOpen: boolean; + toggleChat: () => void; + selectedAudioDevice: string; + onAudioDeviceChange: (deviceId: string) => void; + selectedVideoDevice: string; + onVideoDeviceChange: (deviceId: string) => void; +} + +interface DeviceInfo { + deviceId: string; + label: string; } export function ControlBar({ @@ -14,33 +28,150 @@ export function ControlBar({ toggleAudio, videoEnabled, toggleVideo, + screenEnabled, + toggleScreen, + chatOpen, + toggleChat, + selectedAudioDevice, + onAudioDeviceChange, + selectedVideoDevice, + onVideoDeviceChange }: ControlBarProps) { + const [audioDevices, setAudioDevices] = useState([]); + const [videoDevices, setVideoDevices] = useState([]); + const [showAudioMenu, setShowAudioMenu] = useState(false); + const [showVideoMenu, setShowVideoMenu] = useState(false); + + const audioMenuRef = useRef(null); + const videoMenuRef = useRef(null); + + useEffect(() => { + loadDevices(); + }, []); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (audioMenuRef.current && !audioMenuRef.current.contains(e.target as Node)) { + setShowAudioMenu(false); + } + if (videoMenuRef.current && !videoMenuRef.current.contains(e.target as Node)) { + setShowVideoMenu(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const loadDevices = async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + setAudioDevices(devices.filter(d => d.kind === 'audioinput').map(d => ({ + deviceId: d.deviceId, + label: d.label || `Microphone ${d.deviceId.slice(0, 4)}` + }))); + setVideoDevices(devices.filter(d => d.kind === 'videoinput').map(d => ({ + deviceId: d.deviceId, + label: d.label || `Camera ${d.deviceId.slice(0, 4)}` + }))); + } catch (e) { + console.error('Failed to enumerate devices', e); + } + }; + + const buttonBase = "p-3 rounded-full transition-colors"; + return ( -
+
+ {/* Mic + Dropdown */} +
+ + + {showAudioMenu && ( +
+
Select Microphone
+ {audioDevices.map(d => ( + + ))} +
+ )} +
+ + {/* Camera + Dropdown */} +
+ + + {showVideoMenu && ( +
+
Select Camera
+ {videoDevices.map(d => ( + + ))} +
+ )} +
+ + {/* Divider */} +
+ + {/* Screen Share */} + {/* Chat */} -
+ {/* Divider */} +
+ {/* Leave */}
); diff --git a/src/renderer/src/components/Lobby.tsx b/src/renderer/src/components/Lobby.tsx index 95b58f0..3bb5b9c 100644 --- a/src/renderer/src/components/Lobby.tsx +++ b/src/renderer/src/components/Lobby.tsx @@ -45,12 +45,12 @@ export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps }; return ( -
-
+
+
{/* Left: Preview */}
-
+
{videoEnabled ? (
- {/* Right: Controls */} -
-

Ready to join?

+ {/* Right: Form */} +
+

Ready to join?

-
- +
+ setDisplayName(e.target.value)} - placeholder="Your Name" - className="bg-[#202124] border border-[#5f6368] rounded p-3 focus:outline-none focus:border-[#8ab4f8] focus:ring-1 focus:ring-[#8ab4f8] transition-all" + placeholder="Enter your name" + className="bg-[#202124] border border-[#5f6368] rounded-md px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-[#8ab4f8]" />
-
- +
+ setRoomCode(e.target.value)} - placeholder="Enter code" - className="bg-[#202124] border border-[#5f6368] rounded p-3 focus:outline-none focus:border-[#8ab4f8] focus:ring-1 focus:ring-[#8ab4f8] transition-all" + placeholder="Enter room code" + className="bg-[#202124] border border-[#5f6368] rounded-md px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-[#8ab4f8]" />
-
- -
+
-
); diff --git a/src/renderer/src/components/Stage.tsx b/src/renderer/src/components/Stage.tsx index 4609f57..61bc9c6 100644 --- a/src/renderer/src/components/Stage.tsx +++ b/src/renderer/src/components/Stage.tsx @@ -6,54 +6,106 @@ interface StageProps { displayName: string; peers: PeerInfo[]; peerVideoUrls?: { [key: number]: string }; + peerScreenUrls?: { [key: number]: string }; + localScreenUrl?: string | null; localVideoRef?: React.RefObject; videoEnabled?: boolean; } -export function Stage({ selfId, displayName, peers, peerVideoUrls = {}, localVideoRef, videoEnabled = false }: StageProps) { - const participantCount = (selfId ? 1 : 0) + peers.length; +export function Stage({ + selfId, + displayName, + peers, + peerVideoUrls = {}, + peerScreenUrls = {}, + localScreenUrl = null, + localVideoRef, + videoEnabled = false +}: StageProps) { + // Check if self is sharing screen + const isSelfSharing = !!localScreenUrl; - // Basic grid calculation - let gridCols = 1; - if (participantCount > 1) gridCols = 2; - if (participantCount > 4) gridCols = 3; - if (participantCount > 9) gridCols = 4; + // Filter peers who are sharing screen + const peerScreens = peers.filter(p => !!peerScreenUrls[p.user_id]); + const participants = peers.filter(p => !peerScreenUrls[p.user_id]); + + const showScreenLayer = isSelfSharing || peerScreens.length > 0; + const totalParticipants = (selfId ? 1 : 0) + participants.length; + + // Calculate grid layout for webcams + let cols = 1; + if (totalParticipants === 2) cols = 2; + else if (totalParticipants <= 4) cols = 2; + else if (totalParticipants <= 9) cols = 3; + else cols = 4; return ( -
-
- {/* Self Tile */} - {selfId && ( -
- -
- )} +
+ {/* Screen Share Layer */} + {showScreenLayer && ( +
+ {/* Local Screen Share */} + {isSelfSharing && ( +
+ +
+ )} - {/* Remote Peers */} - {peers.map(peer => ( -
- -
- ))} + {/* Remote Screen Shares */} + {peerScreens.map(peer => ( +
+ +
+ ))} +
+ )} + + {/* Webcam Grid */} +
+
+ {/* Self Webcam */} + {selfId && ( +
+ +
+ )} + + {/* Remote Webcam Peers */} + {participants.map(peer => ( +
+ +
+ ))} +
); diff --git a/src/renderer/src/components/VideoTile.tsx b/src/renderer/src/components/VideoTile.tsx index 40a7deb..80ff4e8 100644 --- a/src/renderer/src/components/VideoTile.tsx +++ b/src/renderer/src/components/VideoTile.tsx @@ -1,58 +1,63 @@ import { Mic, MicOff } from "lucide-react"; interface VideoTileProps { - peerId: number; displayName: string; isSelf?: boolean; - videoSrc?: string; // Blob URL - videoRef?: React.RefObject; // Direct ref (local) + videoSrc?: string; + videoRef?: React.RefObject; audioEnabled?: boolean; videoEnabled?: boolean; + isScreenShare?: boolean; } -export function VideoTile({ peerId, displayName, isSelf, videoSrc, videoRef, audioEnabled = true, videoEnabled = false }: VideoTileProps) { - // If we have a videoSrc, we don't need a ref to set srcObject, we just use src attribute. - // If we have videoRef, it's already attached to the video element by the parent (local). - // Wait, if parent passes ref, we need to attach it to the