diff --git a/src/main/index.ts b/src/main/index.ts index 0b13b54..9d643e3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -58,9 +58,9 @@ app.whenReady().then(() => { }) // IPC Handlers - ipcMain.handle('connect', async (_, { roomCode, displayName }) => { + ipcMain.handle('connect', async (_, { serverUrl, roomCode, displayName }) => { if (networkManager) { - return await networkManager.connect(roomCode, displayName); + return await networkManager.connect(serverUrl, roomCode, displayName); } }); @@ -108,6 +108,13 @@ app.whenReady().then(() => { } }); + // Stream Updates + ipcMain.on('update-stream', (_, { active, mediaType }) => { + if (networkManager) { + networkManager.updateStream(active, mediaType); + } + }); + createWindow() app.on('activate', function () { diff --git a/src/main/network.ts b/src/main/network.ts index 3c274d8..ae1c8fc 100644 --- a/src/main/network.ts +++ b/src/main/network.ts @@ -3,14 +3,10 @@ import * as dgram from 'dgram'; import WebSocket from 'ws'; import { BrowserWindow } from 'electron'; -// Constants - Configure SERVER_HOST for production -const SERVER_HOST = process.env.MEET_SERVER_HOST || '127.0.0.1'; -const SERVER_WS_URL = `ws://${SERVER_HOST}:5000/ws`; -const SERVER_UDP_HOST = SERVER_HOST; +// Constants const SERVER_UDP_PORT = 4000; // Packet Header Structure (22 bytes) -// version: u8, media_type: u8, user_id: u32, sequence: u32, timestamp: u64, frag_idx: u8, frag_cnt: u8, flags: u16 const HEADER_SIZE = 22; export enum MediaType { @@ -26,21 +22,33 @@ export class NetworkManager extends EventEmitter { private roomCode: string = ''; private videoSeq: number = 0; private audioSeq: number = 0; + private screenSeq = 0; private mainWindow: BrowserWindow; + private serverUdpHost: string = '127.0.0.1'; constructor(mainWindow: BrowserWindow) { super(); this.mainWindow = mainWindow; } - async connect(roomCode: string, displayName: string): Promise { + async connect(serverUrl: string, roomCode: string, displayName: string): Promise { this.roomCode = roomCode; // Store for UDP handshake return new Promise((resolve, reject) => { - this.ws = new WebSocket(`${SERVER_WS_URL}?room=${roomCode}&name=${displayName}`); + // Determine Host and Protocol + let host = serverUrl.replace(/^wss?:\/\//, '').replace(/\/$/, ''); + this.serverUdpHost = host.split(':')[0]; // Hostname only for UDP (strip port if present) + + // Auto-detect protocol: localhost/IP uses ws://, domains use wss:// (HTTPS) + const isLocal = host.includes('localhost') || host.includes('127.0.0.1'); + const protocol = isLocal ? 'ws' : 'wss'; + const wsUrl = `${protocol}://${host}/ws`; + + console.log(`[Network] Connecting to WS: ${wsUrl}, UDP Host: ${this.serverUdpHost}`); + + this.ws = new WebSocket(`${wsUrl}?room=${roomCode}&name=${displayName}`); this.ws.on('open', () => { console.log('WS Connected'); - // Send Join Message (serde adjacent tagging: type + data) const joinMsg = { type: 'Join', data: { @@ -92,6 +100,9 @@ export class NetworkManager extends EventEmitter { case 'ChatMessage': this.safeSend('chat-message', msg.data); break; + case 'UpdateStream': + this.safeSend('peer-stream-update', msg.data); + break; case 'Error': console.error('WS Error Msg:', msg.data); reject(msg.data); @@ -113,13 +124,29 @@ export class NetworkManager extends EventEmitter { this.ws.send(JSON.stringify(chatMsg)); } + updateStream(active: boolean, mediaType: MediaType) { + if (!this.ws) return; + const mediaTypeStr = mediaType === MediaType.Audio ? 'Audio' + : mediaType === MediaType.Video ? 'Video' + : 'Screen'; + const msg = { + type: 'UpdateStream', + data: { + user_id: this.userId, + stream_id: 0, + active, + media_type: mediaTypeStr + } + }; + this.ws.send(JSON.stringify(msg)); + } + setupUdp() { this.udp = dgram.createSocket('udp4'); this.udp.on('listening', () => { const addr = this.udp?.address(); console.log(`UDP Listening on ${addr?.port}`); - // Send UDP handshake so server can associate our UDP address with room/user this.sendHandshake(); }); @@ -133,15 +160,8 @@ export class NetworkManager extends EventEmitter { handleUdpMessage(msg: Buffer) { if (msg.length < HEADER_SIZE) return; - // Parse Header (Little Endian) - // const version = msg.readUInt8(0); // 1 const mediaType = msg.readUInt8(1); const userId = msg.readUInt32LE(2); - // const seq = msg.readUInt32LE(6); - // const ts = msg.readBigUInt64LE(10); - // const fidx = msg.readUInt8(18); - // const fcnt = msg.readUInt8(19); - // const flags = msg.readUInt16LE(20); const payload = msg.subarray(HEADER_SIZE); const sequence = msg.readUInt32LE(6); @@ -150,13 +170,6 @@ export class NetworkManager extends EventEmitter { const fragCnt = msg.readUInt8(19); if (mediaType === MediaType.Audio) { - // Forward audio? Or decode here? - // Electron renderer can use Web Audio API? - // Or we decode in Main and send PCM? - // Better to send encoded Opus to Renderer and decode there with WASM (libopus) - // 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. this.safeSend('audio-frame', { user_id: userId, data: payload }); } else if (mediaType === MediaType.Video) { this.safeSend('video-frame', { @@ -203,7 +216,6 @@ export class NetworkManager extends EventEmitter { const end = Math.min(start + MAX_PAYLOAD, buffer.length); const chunk = buffer.subarray(start, end); - // MediaType.Video = 1 const header = Buffer.alloc(HEADER_SIZE); header.writeUInt8(1, 0); // Version header.writeUInt8(MediaType.Video, 1); @@ -216,7 +228,7 @@ export class NetworkManager extends EventEmitter { const packet = Buffer.concat([header, chunk]); - this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => { + this.udp.send(packet, SERVER_UDP_PORT, this.serverUdpHost, (err) => { if (err) console.error('UDP Video Send Error', err); }); } @@ -225,7 +237,6 @@ export class NetworkManager extends EventEmitter { sendAudioFrame(frame: Uint8Array) { if (!this.udp) return; - // Construct Header (same format as video) const header = Buffer.alloc(HEADER_SIZE); header.writeUInt8(1, 0); // Version header.writeUInt8(MediaType.Audio, 1); @@ -238,18 +249,16 @@ export class NetworkManager extends EventEmitter { const packet = Buffer.concat([header, Buffer.from(frame)]); - this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => { + this.udp.send(packet, SERVER_UDP_PORT, this.serverUdpHost, (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 MAX_PAYLOAD = 1400; const fragCount = Math.ceil(buffer.length / MAX_PAYLOAD); const seq = this.screenSeq++; const ts = BigInt(Date.now()); @@ -259,7 +268,6 @@ export class NetworkManager extends EventEmitter { 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); @@ -272,7 +280,7 @@ export class NetworkManager extends EventEmitter { const packet = Buffer.concat([header, chunk]); - this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => { + this.udp.send(packet, SERVER_UDP_PORT, this.serverUdpHost, (err) => { if (err) console.error('UDP Screen Send Error', err); }); } @@ -284,18 +292,13 @@ export class NetworkManager extends EventEmitter { return; } - // Server expects bincode-serialized Handshake { user_id: u32, room_code: String } - // bincode format for String: u64 length prefix (LE) + UTF-8 bytes const roomCodeBytes = Buffer.from(this.roomCode, 'utf-8'); - - // Payload: | user_id (4 bytes LE) | room_code_len (8 bytes LE) | room_code (N bytes) | const payloadLen = 4 + 8 + roomCodeBytes.length; const payload = Buffer.alloc(payloadLen); payload.writeUInt32LE(this.userId, 0); // user_id payload.writeBigUInt64LE(BigInt(roomCodeBytes.length), 4); // string length roomCodeBytes.copy(payload, 12); // room_code - // Construct header with MediaType.Command (3) const header = Buffer.alloc(HEADER_SIZE); header.writeUInt8(1, 0); // Version header.writeUInt8(3, 1); // MediaType.Command = 3 @@ -309,7 +312,7 @@ export class NetworkManager extends EventEmitter { const packet = Buffer.concat([header, payload]); console.log(`[UDP] Sending Handshake: userId=${this.userId}, room=${this.roomCode}, ${packet.length} bytes`); - this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => { + this.udp.send(packet, SERVER_UDP_PORT, this.serverUdpHost, (err) => { if (err) console.error('UDP Handshake Send Error', err); }); } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6e2ff86..ee392fd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5,6 +5,7 @@ import { Lobby } from "./components/Lobby"; import { Stage } from "./components/Stage"; import { ControlBar } from "./components/ControlBar"; import { ChatPanel } from "./components/ChatPanel"; +import { NotificationToast } from "./components/NotificationToast"; import { PeerInfo, ChatMessage } from "./types"; const audioWorkletCode = ` @@ -50,6 +51,7 @@ function App() { // Chat State const [chatOpen, setChatOpen] = useState(false); const [chatMessages, setChatMessages] = useState([]); + const [chatNotifications, setChatNotifications] = useState<{ id: string; displayName: string; message: string; timestamp: number }[]>([]); // Video Handling const localVideoRef = useRef(null); @@ -95,6 +97,41 @@ function App() { } return newState; }); + setPeerScreenUrls(prev => { + const newState = { ...prev }; + if (newState[data.user_id]) { + URL.revokeObjectURL(newState[data.user_id]); + delete newState[data.user_id]; + } + return newState; + }); + } + }); + + // Update Stream Signaling (MediaType: "Video" or "Screen" from Rust enum) + // @ts-ignore + const removeStreamUpdate = window.electron.ipcRenderer.on("peer-stream-update", (_, data) => { + const { user_id, active, media_type } = data; + if (!active) { + if (media_type === 'Video') { + setPeerVideoUrls(prev => { + const newState = { ...prev }; + if (newState[user_id]) { + URL.revokeObjectURL(newState[user_id]); + delete newState[user_id]; + } + return newState; + }); + } else if (media_type === 'Screen') { + setPeerScreenUrls(prev => { + const newState = { ...prev }; + if (newState[user_id]) { + URL.revokeObjectURL(newState[user_id]); + delete newState[user_id]; + } + return newState; + }); + } } }); @@ -124,6 +161,23 @@ function App() { ); if (duplicate) return prev; + // Add notification + const notif = { + id: msg.id, + displayName: msg.displayName, + message: msg.message, + timestamp: msg.timestamp + }; + setChatNotifications(notifs => { + if (notifs.find(n => n.id === notif.id)) return notifs; + return [...notifs, notif]; + }); + + // Auto-dismiss after 5s + setTimeout(() => { + setChatNotifications(current => current.filter(n => n.id !== notif.id)); + }, 5000); + return [...prev, msg]; }); }); @@ -293,6 +347,7 @@ function App() { return () => { removePeerJoined(); removePeerLeft(); + removeStreamUpdate(); removeChatMessage(); removeVideo(); removeScreen(); @@ -355,10 +410,12 @@ function App() { if (localVideoRef.current) { localVideoRef.current.srcObject = stream; } + // Signal video ON + // @ts-ignore + window.electron.ipcRenderer.send('update-stream', { active: true, mediaType: 1 }); }) .catch(err => { - console.error("Error accessing camera:", err); - setVideoEnabled(false); + console.error("Camera access error:", err); setError("Failed to access camera"); }); } else { @@ -367,6 +424,9 @@ function App() { stream.getTracks().forEach(track => track.stop()); localVideoRef.current.srcObject = null; } + // Signal video OFF + // @ts-ignore + if (connected) window.electron.ipcRenderer.send('update-stream', { active: false, mediaType: 1 }); } }, [videoEnabled, connected]); @@ -456,6 +516,10 @@ function App() { screenVideo.onloadeddata = () => sendScreenFrame(); + // Signal screen ON + // @ts-ignore + window.electron.ipcRenderer.send('update-stream', { active: true, mediaType: 2 }); + } catch (err) { console.error('Screen share error:', err); setError('Failed to start screen sharing'); @@ -471,24 +535,17 @@ function App() { URL.revokeObjectURL(localScreenUrl); setLocalScreenUrl(null); } + // Signal screen OFF + // @ts-ignore + window.electron.ipcRenderer.send('update-stream', { active: false, mediaType: 2 }); 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]); + }, [screenEnabled, connected]); // Audio Capture const audioContextRef = useRef(null); @@ -638,15 +695,15 @@ function App() { }; }, [audioEnabled, connected, selectedAudioDevice]); - async function handleJoin(roomCode: string, name: string, initialVideo: boolean, initialAudio: boolean) { - if (!roomCode || !name) return; + async function handleJoin(roomCode: string, name: string, serverUrl: string, initialVideo: boolean, initialAudio: boolean) { + if (!roomCode || !name || !serverUrl) return; setDisplayName(name); setVideoEnabled(initialVideo); setAudioEnabled(initialAudio); setError(""); try { // @ts-ignore - const result = await window.electron.ipcRenderer.invoke("connect", { roomCode, displayName: name }); + const result = await window.electron.ipcRenderer.invoke("connect", { serverUrl, roomCode, displayName: name }); if (result) { addLog(`Connected: Self=${result.self_id}, Peers=${result.peers.length}`); setSelfId(result.self_id); @@ -735,6 +792,18 @@ function App() { /> )} + {/* Chat Notifications */} + {!chatOpen && ( + setChatNotifications(prev => prev.filter(n => n.id !== id))} + onOpenChat={() => { + setChatOpen(true); + setChatNotifications([]); + }} + /> + )} + {/* Hidden Canvas for capture */} diff --git a/src/renderer/src/components/Lobby.tsx b/src/renderer/src/components/Lobby.tsx index 3bb5b9c..9637e94 100644 --- a/src/renderer/src/components/Lobby.tsx +++ b/src/renderer/src/components/Lobby.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { Mic, MicOff, Camera, CameraOff } from 'lucide-react'; interface LobbyProps { - onJoin: (room: string, name: string, video: boolean, audio: boolean) => void; + onJoin: (room: string, name: string, serverUrl: string, video: boolean, audio: boolean) => void; initialName?: string; initialRoom?: string; } @@ -10,6 +10,7 @@ interface LobbyProps { export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps) { const [roomCode, setRoomCode] = useState(initialRoom); const [displayName, setDisplayName] = useState(initialName); + const [serverUrl, setServerUrl] = useState('meet.srtk.in'); const [videoEnabled, setVideoEnabled] = useState(false); const [audioEnabled, setAudioEnabled] = useState(false); const videoRef = useRef(null); @@ -39,8 +40,8 @@ export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps }, [videoEnabled]); const handleJoin = () => { - if (roomCode.trim() && displayName.trim()) { - onJoin(roomCode, displayName, videoEnabled, audioEnabled); + if (roomCode.trim() && displayName.trim() && serverUrl.trim()) { + onJoin(roomCode, displayName, serverUrl, videoEnabled, audioEnabled); } }; @@ -92,6 +93,17 @@ export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps

Ready to join?

+
+ + setServerUrl(e.target.value)} + placeholder="meet.srtk.in" + className="bg-[#202124] border border-[#5f6368] rounded-md px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-[#8ab4f8]" + /> +
+
Join now diff --git a/src/renderer/src/components/NotificationToast.tsx b/src/renderer/src/components/NotificationToast.tsx new file mode 100644 index 0000000..202c053 --- /dev/null +++ b/src/renderer/src/components/NotificationToast.tsx @@ -0,0 +1,59 @@ +import { MessageCircle, X } from 'lucide-react'; + +interface ChatNotification { + id: string; + displayName: string; + message: string; + timestamp: number; +} + +interface NotificationToastProps { + notifications: ChatNotification[]; + onDismiss: (id: string) => void; + onOpenChat: () => void; +} + +export function NotificationToast({ notifications, onDismiss, onOpenChat }: NotificationToastProps) { + if (notifications.length === 0) return null; + + return ( +
+ {notifications.slice(0, 3).map((notif) => ( +
+
+
+ +
+
+

+ {notif.displayName} +

+

+ {notif.message} +

+
+ +
+
+ ))} + + {notifications.length > 3 && ( +
+ +{notifications.length - 3} more messages +
+ )} +
+ ); +} diff --git a/src/renderer/src/components/Stage.tsx b/src/renderer/src/components/Stage.tsx index 61bc9c6..b4ca473 100644 --- a/src/renderer/src/components/Stage.tsx +++ b/src/renderer/src/components/Stage.tsx @@ -1,5 +1,6 @@ import { PeerInfo } from "../types"; import { VideoTile } from "./VideoTile"; +import { useState, useEffect } from "react"; interface StageProps { selfId: number | null; @@ -22,36 +23,85 @@ export function Stage({ localVideoRef, videoEnabled = false }: StageProps) { + // Track container dimensions for smart layout + const [containerSize, setContainerSize] = useState({ width: 800, height: 600 }); + + useEffect(() => { + const updateSize = () => { + const container = document.getElementById('stage-container'); + if (container) { + setContainerSize({ + width: container.clientWidth, + height: container.clientHeight + }); + } + }; + + updateSize(); + window.addEventListener('resize', updateSize); + return () => window.removeEventListener('resize', updateSize); + }, []); + // Check if self is sharing screen const isSelfSharing = !!localScreenUrl; // Filter peers who are sharing screen const peerScreens = peers.filter(p => !!peerScreenUrls[p.user_id]); - const participants = peers.filter(p => !peerScreenUrls[p.user_id]); + // All peers for webcam grid + const allParticipants = peers; const showScreenLayer = isSelfSharing || peerScreens.length > 0; - const totalParticipants = (selfId ? 1 : 0) + participants.length; + const totalParticipants = (selfId ? 1 : 0) + allParticipants.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; + // Smart layout: determine if we should use vertical or horizontal arrangement + const aspectRatio = containerSize.width / containerSize.height; + const isVertical = aspectRatio < 1; // Taller than wide + + // Calculate optimal grid layout based on aspect ratio and participant count + const getGridConfig = (count: number, isVertical: boolean) => { + if (count <= 1) { + return { cols: 1, rows: 1 }; + } + + if (isVertical) { + // Vertical window: prefer fewer columns, more rows + if (count === 2) return { cols: 1, rows: 2 }; + if (count <= 4) return { cols: 2, rows: 2 }; + if (count <= 6) return { cols: 2, rows: 3 }; + if (count <= 9) return { cols: 3, rows: 3 }; + return { cols: 3, rows: Math.ceil(count / 3) }; + } else { + // Horizontal window: prefer more columns + if (count === 2) return { cols: 2, rows: 1 }; + if (count <= 4) return { cols: 2, rows: 2 }; + if (count <= 6) return { cols: 3, rows: 2 }; + if (count <= 9) return { cols: 3, rows: 3 }; + return { cols: 4, rows: Math.ceil(count / 4) }; + } + }; + + const gridConfig = getGridConfig(totalParticipants, isVertical); + + // Screen share layout direction + const screenLayoutClass = isVertical + ? 'flex-col' // Stack screen above participants + : 'flex-row'; // Screen on left, participants on right return ( -
+
{/* Screen Share Layer */} {showScreenLayer && ( -
+
{/* Local Screen Share */} {isSelfSharing && (
@@ -72,18 +122,23 @@ export function Stage({ )} {/* Webcam Grid */} -
+
{/* Self Webcam */} {selfId && ( -
+
( -
+ {allParticipants.map(peer => ( +
diff --git a/src/renderer/src/components/VideoTile.tsx b/src/renderer/src/components/VideoTile.tsx index 80ff4e8..7b2bccd 100644 --- a/src/renderer/src/components/VideoTile.tsx +++ b/src/renderer/src/components/VideoTile.tsx @@ -19,28 +19,38 @@ export function VideoTile({ videoEnabled = false, isScreenShare = false }: VideoTileProps) { + // For self with video ref, use video element bound to the ref + // For remote peers, videoSrc contains blob URL of JPEG frames - use img + const showSelfVideo = isSelf && videoEnabled && videoRef; + const showRemoteMedia = !isSelf && videoSrc; + const showPlaceholder = !showSelfVideo && !showRemoteMedia; return ( -
- {/* Video / Image */} - {videoEnabled && videoRef ? ( +
+ {/* Self Video (webcam stream) */} + {showSelfVideo && (