todo comment fix screen sharing and video aspect ratio

This commit is contained in:
srtk 2026-02-08 16:53:06 +05:30
parent 9eb33512f4
commit 2a9e80cc5a
10 changed files with 902 additions and 188 deletions

View file

@ -82,6 +82,12 @@ app.whenReady().then(() => {
} }
}); });
ipcMain.on('send-screen-frame', (_, { frame }) => {
if (networkManager) {
networkManager.sendScreenFrame(frame);
}
});
// Screen sharing: get available sources // Screen sharing: get available sources
ipcMain.handle('get-screen-sources', async () => { ipcMain.handle('get-screen-sources', async () => {
const sources = await desktopCapturer.getSources({ 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() createWindow()
app.on('activate', function () { app.on('activate', function () {

View file

@ -79,16 +79,18 @@ export class NetworkManager extends EventEmitter {
switch (msg.type) { switch (msg.type) {
case 'Joined': case 'Joined':
console.log('Joined Room:', msg.data); console.log('Joined Room:', msg.data);
this.userId = msg.data.self_id; // Server sends self_id, not user_id this.userId = msg.data.self_id;
// Init UDP
this.setupUdp(); this.setupUdp();
resolve(msg.data); // Return joined data (peers, etc) resolve(msg.data);
break; break;
case 'PeerJoined': case 'PeerJoined':
this.mainWindow.webContents.send('peer-joined', msg.data); this.safeSend('peer-joined', msg.data);
break; break;
case 'PeerLeft': 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; break;
case 'Error': case 'Error':
console.error('WS Error Msg:', msg.data); 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() { setupUdp() {
this.udp = dgram.createSocket('udp4'); this.udp = dgram.createSocket('udp4');
@ -128,6 +144,10 @@ export class NetworkManager extends EventEmitter {
// const flags = msg.readUInt16LE(20); // const flags = msg.readUInt16LE(20);
const payload = msg.subarray(HEADER_SIZE); 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) { if (mediaType === MediaType.Audio) {
// Forward audio? Or decode here? // Forward audio? Or decode here?
@ -137,35 +157,69 @@ export class NetworkManager extends EventEmitter {
// OR decode here with `opus-native` binding. // OR decode here with `opus-native` binding.
// For now, let's send payload to renderer via IPC. // For now, let's send payload to renderer via IPC.
// Note: IPC with high frequency audio packets might be laggy. // Note: IPC with high frequency audio packets might be laggy.
// But for MVP it's okay. this.safeSend('audio-frame', { user_id: userId, data: payload });
this.mainWindow.webContents.send('audio-frame', { user_id: userId, data: payload });
} else if (mediaType === MediaType.Video) { } else if (mediaType === MediaType.Video) {
// Send to renderer this.safeSend('video-frame', {
this.mainWindow.webContents.send('video-frame', { user_id: userId, data: payload }); 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) { sendVideoFrame(frame: Uint8Array) {
if (!this.udp) return; if (!this.udp || !this.userId) return;
// Construct Header const buffer = Buffer.from(frame);
const header = Buffer.alloc(HEADER_SIZE); const MAX_PAYLOAD = 1400;
header.writeUInt8(1, 0); // Version const fragCount = Math.ceil(buffer.length / MAX_PAYLOAD);
header.writeUInt8(MediaType.Video, 1); const seq = this.videoSeq++;
header.writeUInt32LE(this.userId, 2); const ts = BigInt(Date.now());
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 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}`); // MediaType.Video = 1
// Send const header = Buffer.alloc(HEADER_SIZE);
this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => { header.writeUInt8(1, 0); // Version
if (err) console.error('UDP Send Error', err); 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) { sendAudioFrame(frame: Uint8Array) {
@ -184,12 +238,46 @@ export class NetworkManager extends EventEmitter {
const packet = Buffer.concat([header, Buffer.from(frame)]); 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) => { this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => {
if (err) console.error('UDP Audio Send Error', 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() { sendHandshake() {
if (!this.udp || !this.userId || !this.roomCode) { if (!this.udp || !this.userId || !this.roomCode) {
console.error('[UDP] Cannot send handshake: missing udp, userId, or roomCode'); console.error('[UDP] Cannot send handshake: missing udp, userId, or roomCode');

View file

@ -4,8 +4,8 @@ import "./index.css";
import { Lobby } from "./components/Lobby"; import { Lobby } from "./components/Lobby";
import { Stage } from "./components/Stage"; import { Stage } from "./components/Stage";
import { ControlBar } from "./components/ControlBar"; import { ControlBar } from "./components/ControlBar";
import { DeviceSelector } from "./components/DeviceSelector"; import { ChatPanel } from "./components/ChatPanel";
import { PeerInfo } from "./types"; import { PeerInfo, ChatMessage } from "./types";
const audioWorkletCode = ` const audioWorkletCode = `
class PCMProcessor extends AudioWorkletProcessor { class PCMProcessor extends AudioWorkletProcessor {
@ -34,11 +34,9 @@ function App() {
const [selfId, setSelfId] = useState<number | null>(null); const [selfId, setSelfId] = useState<number | null>(null);
const [peers, setPeers] = useState<PeerInfo[]>([]); const [peers, setPeers] = useState<PeerInfo[]>([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [logs, setLogs] = useState<string[]>([]);
const addLog = (msg: string) => { const addLog = (msg: string) => {
console.log(msg); console.log(`[Meet] ${msg}`);
setLogs(prev => [...prev.slice(-19), `[${new Date().toLocaleTimeString()}] ${msg}`]);
}; };
// Media State // Media State
@ -46,11 +44,19 @@ function App() {
const [videoEnabled, setVideoEnabled] = useState(false); const [videoEnabled, setVideoEnabled] = useState(false);
const [screenEnabled, setScreenEnabled] = useState(false); const [screenEnabled, setScreenEnabled] = useState(false);
const [selectedAudioDevice, setSelectedAudioDevice] = useState<string>(""); const [selectedAudioDevice, setSelectedAudioDevice] = useState<string>("");
const [selectedVideoDevice, setSelectedVideoDevice] = useState<string>("");
const [displayName, setDisplayName] = useState<string>("");
// Chat State
const [chatOpen, setChatOpen] = useState(false);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
// Video Handling // Video Handling
const localVideoRef = useRef<HTMLVideoElement>(null); const localVideoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const [peerVideoUrls, setPeerVideoUrls] = useState<{ [key: number]: string }>({}); const [peerVideoUrls, setPeerVideoUrls] = useState<{ [key: number]: string }>({});
const [peerScreenUrls, setPeerScreenUrls] = useState<{ [key: number]: string }>({});
const [localScreenUrl, setLocalScreenUrl] = useState<string | null>(null);
// Event Listeners // Event Listeners
useEffect(() => { useEffect(() => {
@ -92,25 +98,131 @@ function App() {
} }
}); });
// Video Frame // Chat Message - skip own messages (we add them locally)
// @ts-ignore // @ts-ignore
const removeVideo = window.electron.ipcRenderer.on("video-frame", (_, payload) => { const removeChatMessage = window.electron.ipcRenderer.on("chat-message", (_, data) => {
const { user_id, data } = payload; // Skip own messages (already added locally)
console.log("Video frame from:", user_id, "size:", data?.length || 0); // We'll check by comparing user_id with current selfId later
const uint8Array = new Uint8Array(data); const msg: ChatMessage = {
const blob = new Blob([uint8Array], { type: 'image/jpeg' }); id: `${data.user_id}-${data.timestamp}`,
const url = URL.createObjectURL(blob); 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 => { return [...prev, msg];
if (prev[user_id]) URL.revokeObjectURL(prev[user_id]);
return { ...prev, [user_id]: url };
}); });
}); });
// Audio Frame Playback with proper jitter buffer // Video/Screen Reassembly logic
const fragmentMap = new Map<string, { chunks: Uint8Array[], count: number, total: number }>();
// 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 playbackCtxRef = { current: null as AudioContext | null };
const nextPlayTimeRef = { current: 0 }; 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[] = []; const bufferQueue: Float32Array[] = [];
let isStarted = false; let isStarted = false;
@ -164,9 +276,9 @@ function App() {
} }
if (!isStarted) { if (!isStarted) {
// Buffer a few packets before starting // Start immediately after just 1 packet for lowest latency
bufferQueue.push(float32); bufferQueue.push(float32);
if (bufferQueue.length >= 3) { if (bufferQueue.length >= 1) {
isStarted = true; isStarted = true;
flushBuffer(); flushBuffer();
} }
@ -181,7 +293,9 @@ function App() {
return () => { return () => {
removePeerJoined(); removePeerJoined();
removePeerLeft(); removePeerLeft();
removeChatMessage();
removeVideo(); removeVideo();
removeScreen();
removeAudio(); removeAudio();
}; };
}, []); }, []);
@ -220,7 +334,7 @@ function App() {
} }
setTimeout(() => { setTimeout(() => {
if (isActive) animationFrameId = requestAnimationFrame(sendFrame); if (isActive) animationFrameId = requestAnimationFrame(sendFrame);
}, 100); }, 66); // ~15 FPS for lower latency
}; };
if (videoEnabled) { if (videoEnabled) {
@ -236,7 +350,7 @@ function App() {
// Camera Access - re-trigger when connected changes // Camera Access - re-trigger when connected changes
useEffect(() => { useEffect(() => {
if (videoEnabled) { if (videoEnabled) {
navigator.mediaDevices.getUserMedia({ video: { width: 320, height: 240 }, audio: false }) navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720 }, audio: false })
.then(stream => { .then(stream => {
if (localVideoRef.current) { if (localVideoRef.current) {
localVideoRef.current.srcObject = stream; localVideoRef.current.srcObject = stream;
@ -256,6 +370,126 @@ function App() {
} }
}, [videoEnabled, connected]); }, [videoEnabled, connected]);
// Screen Sharing
const screenStreamRef = useRef<MediaStream | null>(null);
const screenVideoRef = useRef<HTMLVideoElement>(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 // Audio Capture
const audioContextRef = useRef<AudioContext | null>(null); const audioContextRef = useRef<AudioContext | null>(null);
const audioStreamRef = useRef<MediaStream | null>(null); const audioStreamRef = useRef<MediaStream | null>(null);
@ -364,8 +598,8 @@ function App() {
// Fallback to ScriptProcessor // Fallback to ScriptProcessor
addLog("Falling back to ScriptProcessor..."); addLog("Falling back to ScriptProcessor...");
// @ts-ignore // @ts-ignore - 2048 samples for lower latency
const scriptNode = ctx.createScriptProcessor(4096, 1, 1); const scriptNode = ctx.createScriptProcessor(2048, 1, 1);
// @ts-ignore // @ts-ignore
scriptNode.onaudioprocess = (e) => { scriptNode.onaudioprocess = (e) => {
if (!audioEnabled || !connected || isCancelled) return; if (!audioEnabled || !connected || isCancelled) return;
@ -404,14 +638,15 @@ function App() {
}; };
}, [audioEnabled, connected, selectedAudioDevice]); }, [audioEnabled, connected, selectedAudioDevice]);
async function handleJoin(roomCode: string, displayName: string, initialVideo: boolean, initialAudio: boolean) { async function handleJoin(roomCode: string, name: string, initialVideo: boolean, initialAudio: boolean) {
if (!roomCode || !displayName) return; if (!roomCode || !name) return;
setDisplayName(name);
setVideoEnabled(initialVideo); setVideoEnabled(initialVideo);
setAudioEnabled(initialAudio); setAudioEnabled(initialAudio);
setError(""); setError("");
try { try {
// @ts-ignore // @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) { if (result) {
addLog(`Connected: Self=${result.self_id}, Peers=${result.peers.length}`); addLog(`Connected: Self=${result.self_id}, Peers=${result.peers.length}`);
setSelfId(result.self_id); setSelfId(result.self_id);
@ -439,60 +674,75 @@ function App() {
const toggleAudio = () => setAudioEnabled(!audioEnabled); const toggleAudio = () => setAudioEnabled(!audioEnabled);
const toggleVideo = () => setVideoEnabled(!videoEnabled); const toggleVideo = () => setVideoEnabled(!videoEnabled);
const toggleScreen = () => setScreenEnabled(!screenEnabled); 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 ( return (
<div className="h-screen w-screen bg-[#202124] text-white overflow-hidden font-sans select-none"> <div className="h-screen w-screen bg-[#1e1f22] text-white overflow-hidden font-sans select-none">
{!connected ? ( {!connected ? (
<Lobby onJoin={handleJoin} /> <Lobby onJoin={handleJoin} />
) : ( ) : (
<div className="relative w-full h-full flex flex-col"> <div className="relative w-full h-full flex">
{/* Main Stage */} {/* Main Content Area */}
<div className="flex-1 overflow-hidden p-4"> <div className="flex-1 flex flex-col overflow-hidden">
{/* Stage */}
<Stage <Stage
selfId={selfId} selfId={selfId}
displayName={"You"} // Or pass from state displayName={displayName || "You"}
peers={peers} peers={peers}
// @ts-ignore
peerVideoUrls={peerVideoUrls} peerVideoUrls={peerVideoUrls}
peerScreenUrls={peerScreenUrls}
localScreenUrl={localScreenUrl}
localVideoRef={localVideoRef} localVideoRef={localVideoRef}
videoEnabled={videoEnabled} videoEnabled={videoEnabled}
/> />
</div>
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-end gap-2"> {/* Control Bar */}
{/* Device Selector on the left or integrated? */} <div className="flex justify-center py-4 bg-[#1e1f22]">
{/* Make it float above or left of the mic button */}
<div className="relative">
<ControlBar <ControlBar
onLeave={handleLeave} onLeave={handleLeave}
audioEnabled={audioEnabled} audioEnabled={audioEnabled}
toggleAudio={toggleAudio} toggleAudio={toggleAudio}
videoEnabled={videoEnabled} videoEnabled={videoEnabled}
toggleVideo={toggleVideo} toggleVideo={toggleVideo}
screenEnabled={screenEnabled}
toggleScreen={toggleScreen}
chatOpen={chatOpen}
toggleChat={toggleChat}
selectedAudioDevice={selectedAudioDevice}
onAudioDeviceChange={setSelectedAudioDevice}
selectedVideoDevice={selectedVideoDevice}
onVideoDeviceChange={setSelectedVideoDevice}
/> />
<div className="absolute top-0 right-[-40px] translate-y-2">
<DeviceSelector
onDeviceChange={setSelectedAudioDevice}
currentDeviceId={selectedAudioDevice}
/>
</div>
</div> </div>
</div> </div>
{/* Chat Panel */}
{chatOpen && (
<ChatPanel
messages={chatMessages}
onSendMessage={sendChatMessage}
onClose={() => setChatOpen(false)}
selfId={selfId}
/>
)}
{/* Hidden Canvas for capture */} {/* Hidden Canvas for capture */}
<canvas ref={canvasRef} className="hidden" /> <canvas ref={canvasRef} className="hidden" />
{/* Debug Console */}
<div className="absolute top-0 left-0 bg-black/80 text-green-400 p-2 text-xs font-mono h-32 w-full overflow-y-auto z-50 pointer-events-none opacity-50 hover:opacity-100">
{logs.map((log, i) => <div key={i}>{log}</div>)}
</div>
</div> </div>
)} )}
{/* Error Toast */} {/* Error Toast */}
{error && ( {error && (
<div className="absolute top-4 right-4 bg-red-500 text-white px-4 py-2 rounded shadow-lg animate-bounce z-50"> <div className="absolute top-4 right-4 bg-[#ed4245] text-white px-4 py-2 rounded-lg shadow-lg z-50">
{error} {error}
</div> </div>
)} )}

View file

@ -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<HTMLDivElement>(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 (
<div className="w-80 h-full bg-[#2b2d31] border-l border-[#1e1f22] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[#1e1f22]">
<h3 className="text-white font-semibold text-sm">In-call messages</h3>
<button
onClick={onClose}
className="p-1.5 hover:bg-[#35373c] rounded text-gray-400 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-2">
{messages.length === 0 ? (
<div className="text-center text-gray-500 text-sm py-8">
<p className="mb-1">No messages yet</p>
<p className="text-xs opacity-70">Messages are deleted when the call ends</p>
</div>
) : (
messageGroups.map((group, groupIdx) => {
const isSelf = group.userId === selfId;
return (
<div key={groupIdx} className="mb-4 hover:bg-[#2e3035] -mx-2 px-2 py-1 rounded">
{/* Header with name and time (only for first message in group) */}
<div className="flex items-center gap-2 mb-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white flex-shrink-0 ${isSelf ? 'bg-[#5865f2]' : 'bg-[#3ba55d]'
}`}>
{group.displayName.charAt(0).toUpperCase()}
</div>
<span className={`font-medium text-sm ${isSelf ? 'text-[#5865f2]' : 'text-[#3ba55d]'}`}>
{group.displayName}
{isSelf && <span className="text-gray-500 font-normal ml-1">(You)</span>}
</span>
<span className="text-xs text-gray-500">{formatTime(group.startTime)}</span>
</div>
{/* Messages in group */}
<div className="ml-10 space-y-0.5">
{group.messages.map((msg) => (
<p key={msg.id} className="text-gray-200 text-sm leading-relaxed break-words">
{msg.message}
</p>
))}
</div>
</div>
);
})
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 border-t border-[#1e1f22]">
<div className="flex items-center gap-2 bg-[#383a40] rounded-lg px-3 py-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message..."
className="flex-1 bg-transparent text-white text-sm placeholder-gray-400 outline-none"
/>
<button
onClick={handleSend}
disabled={!input.trim()}
className={`p-1.5 rounded transition-colors ${input.trim()
? 'text-[#5865f2] hover:text-[#7289da]'
: 'text-gray-600 cursor-not-allowed'
}`}
>
<Send size={18} />
</button>
</div>
</div>
</div>
);
}

View file

@ -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 { interface ControlBarProps {
onLeave: () => void; onLeave: () => void;
@ -6,6 +7,19 @@ interface ControlBarProps {
toggleAudio: () => void; toggleAudio: () => void;
videoEnabled: boolean; videoEnabled: boolean;
toggleVideo: () => void; 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({ export function ControlBar({
@ -14,33 +28,150 @@ export function ControlBar({
toggleAudio, toggleAudio,
videoEnabled, videoEnabled,
toggleVideo, toggleVideo,
screenEnabled,
toggleScreen,
chatOpen,
toggleChat,
selectedAudioDevice,
onAudioDeviceChange,
selectedVideoDevice,
onVideoDeviceChange
}: ControlBarProps) { }: ControlBarProps) {
const [audioDevices, setAudioDevices] = useState<DeviceInfo[]>([]);
const [videoDevices, setVideoDevices] = useState<DeviceInfo[]>([]);
const [showAudioMenu, setShowAudioMenu] = useState(false);
const [showVideoMenu, setShowVideoMenu] = useState(false);
const audioMenuRef = useRef<HTMLDivElement>(null);
const videoMenuRef = useRef<HTMLDivElement>(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 ( return (
<div className="flex items-center gap-4 bg-[#202124] px-6 py-3 rounded-full shadow-2xl border border-[#3C4043]"> <div className="flex items-center gap-2 bg-[#3C4043] rounded-full px-4 py-2">
{/* Mic + Dropdown */}
<div className="relative flex items-center" ref={audioMenuRef}>
<button
onClick={toggleAudio}
className={`${buttonBase} ${audioEnabled ? 'bg-[#3C4043] hover:bg-[#4d5155]' : 'bg-red-500 hover:bg-red-600'}`}
>
{audioEnabled ? <Mic size={18} className="text-white" /> : <MicOff size={18} className="text-white" />}
</button>
<button
onClick={() => { setShowAudioMenu(!showAudioMenu); setShowVideoMenu(false); }}
className="p-1 hover:bg-white/10 rounded ml-0.5"
>
<ChevronUp size={14} className="text-white" />
</button>
{showAudioMenu && (
<div className="absolute bottom-full left-0 mb-2 bg-[#292b2f] rounded-lg shadow-xl py-2 min-w-[200px] z-50">
<div className="px-3 py-1 text-xs text-gray-400 uppercase">Select Microphone</div>
{audioDevices.map(d => (
<button
key={d.deviceId}
onClick={() => { onAudioDeviceChange(d.deviceId); setShowAudioMenu(false); }}
className={`w-full text-left px-3 py-2 text-sm hover:bg-white/10 ${d.deviceId === selectedAudioDevice ? 'text-blue-400' : 'text-white'}`}
>
{d.label}
</button>
))}
</div>
)}
</div>
{/* Camera + Dropdown */}
<div className="relative flex items-center" ref={videoMenuRef}>
<button
onClick={toggleVideo}
className={`${buttonBase} ${videoEnabled ? 'bg-[#3C4043] hover:bg-[#4d5155]' : 'bg-red-500 hover:bg-red-600'}`}
>
{videoEnabled ? <Camera size={18} className="text-white" /> : <CameraOff size={18} className="text-white" />}
</button>
<button
onClick={() => { setShowVideoMenu(!showVideoMenu); setShowAudioMenu(false); }}
className="p-1 hover:bg-white/10 rounded ml-0.5"
>
<ChevronUp size={14} className="text-white" />
</button>
{showVideoMenu && (
<div className="absolute bottom-full left-0 mb-2 bg-[#292b2f] rounded-lg shadow-xl py-2 min-w-[200px] z-50">
<div className="px-3 py-1 text-xs text-gray-400 uppercase">Select Camera</div>
{videoDevices.map(d => (
<button
key={d.deviceId}
onClick={() => { onVideoDeviceChange(d.deviceId); setShowVideoMenu(false); }}
className={`w-full text-left px-3 py-2 text-sm hover:bg-white/10 ${d.deviceId === selectedVideoDevice ? 'text-blue-400' : 'text-white'}`}
>
{d.label}
</button>
))}
</div>
)}
</div>
{/* Divider */}
<div className="w-px h-6 bg-gray-600 mx-2" />
{/* Screen Share */}
<button <button
onClick={toggleAudio} onClick={toggleScreen}
className={`p-4 rounded-full transition-all duration-200 ${audioEnabled ? 'bg-[#3C4043] hover:bg-[#4d5155] text-white' : 'bg-red-500 hover:bg-red-600 text-white'}`} className={`${buttonBase} ${screenEnabled ? 'bg-green-500 hover:bg-green-600' : 'bg-[#3C4043] hover:bg-[#4d5155]'}`}
title={audioEnabled ? "Turn off microphone" : "Turn on microphone"} title="Share screen"
> >
{audioEnabled ? <Mic size={20} /> : <MicOff size={20} />} <Monitor size={18} className="text-white" />
</button> </button>
{/* Chat */}
<button <button
onClick={toggleVideo} onClick={toggleChat}
className={`p-4 rounded-full transition-all duration-200 ${videoEnabled ? 'bg-[#3C4043] hover:bg-[#4d5155] text-white' : 'bg-red-500 hover:bg-red-600 text-white'}`} className={`${buttonBase} ${chatOpen ? 'bg-blue-500 hover:bg-blue-600' : 'bg-[#3C4043] hover:bg-[#4d5155]'}`}
title={videoEnabled ? "Turn off camera" : "Turn on camera"} title="Chat"
> >
{videoEnabled ? <Camera size={20} /> : <CameraOff size={20} />} <MessageSquare size={18} className="text-white" />
</button> </button>
<div className="w-[1px] h-[24px] bg-[#5f6368] mx-2"></div> {/* Divider */}
<div className="w-px h-6 bg-gray-600 mx-2" />
{/* Leave */}
<button <button
onClick={onLeave} onClick={onLeave}
className="bg-red-600 hover:bg-red-700 text-white px-8 py-3 rounded-full font-medium flex items-center gap-2 transition-all duration-200" className={`${buttonBase} bg-red-500 hover:bg-red-600`}
title="Leave call"
> >
<PhoneOff size={20} /> <PhoneOff size={18} className="text-white" />
</button> </button>
</div> </div>
); );

View file

@ -45,12 +45,12 @@ export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps
}; };
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-[#202124] text-white font-sans"> <div className="flex flex-col items-center justify-center min-h-screen bg-[#202124] text-white">
<div className="w-full max-w-4xl p-8 grid grid-cols-1 md:grid-cols-2 gap-12 items-center"> <div className="w-full max-w-4xl p-8 grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
{/* Left: Preview */} {/* Left: Preview */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="relative aspect-video bg-[#3C4043] rounded-lg overflow-hidden flex items-center justify-center shadow-2xl"> <div className="relative aspect-video bg-[#3C4043] rounded-lg overflow-hidden flex items-center justify-center">
{videoEnabled ? ( {videoEnabled ? (
<video <video
ref={videoRef} ref={videoRef}
@ -60,68 +60,69 @@ export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps
className="w-full h-full object-cover transform scale-x-[-1]" className="w-full h-full object-cover transform scale-x-[-1]"
/> />
) : ( ) : (
<div className="text-gray-400">Camera is off</div> <div className="flex flex-col items-center gap-2">
<div className="w-16 h-16 rounded-full bg-[#5f6368] flex items-center justify-center text-2xl font-bold">
{displayName ? displayName.charAt(0).toUpperCase() : '?'}
</div>
<span className="text-gray-400 text-sm">Camera is off</span>
</div>
)} )}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-4"> <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-3">
<button <button
onClick={() => setAudioEnabled(!audioEnabled)} onClick={() => setAudioEnabled(!audioEnabled)}
className={`p-3 rounded-full ${audioEnabled ? 'bg-[#3C4043] hover:bg-[#4d5155]' : 'bg-red-500 hover:bg-red-600'} transition-colors border border-gray-600/30`} className={`p-3 rounded-full transition-colors ${audioEnabled ? 'bg-[#3C4043] hover:bg-[#4d5155]' : 'bg-red-500 hover:bg-red-600'
}`}
> >
{audioEnabled ? <Mic size={20} /> : <MicOff size={20} />} {audioEnabled ? <Mic size={18} /> : <MicOff size={18} />}
</button> </button>
<button <button
onClick={() => setVideoEnabled(!videoEnabled)} onClick={() => setVideoEnabled(!videoEnabled)}
className={`p-3 rounded-full ${videoEnabled ? 'bg-[#3C4043] hover:bg-[#4d5155]' : 'bg-red-500 hover:bg-red-600'} transition-colors border border-gray-600/30`} className={`p-3 rounded-full transition-colors ${videoEnabled ? 'bg-[#3C4043] hover:bg-[#4d5155]' : 'bg-red-500 hover:bg-red-600'
}`}
> >
{videoEnabled ? <Camera size={20} /> : <CameraOff size={20} />} {videoEnabled ? <Camera size={18} /> : <CameraOff size={18} />}
</button> </button>
</div> </div>
</div> </div>
<div className="text-center text-sm text-gray-400">
Check your audio and video before joining
</div>
</div> </div>
{/* Right: Controls */} {/* Right: Form */}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-5">
<h1 className="text-3xl font-normal text-google-gray-100">Ready to join?</h1> <h1 className="text-2xl font-normal">Ready to join?</h1>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-300">Display Name</label> <label className="text-sm text-gray-400">Your name</label>
<input <input
type="text" type="text"
value={displayName} value={displayName}
onChange={e => setDisplayName(e.target.value)} onChange={e => setDisplayName(e.target.value)}
placeholder="Your Name" placeholder="Enter 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" className="bg-[#202124] border border-[#5f6368] rounded-md px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-[#8ab4f8]"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-300">Room Code</label> <label className="text-sm text-gray-400">Room code</label>
<input <input
type="text" type="text"
value={roomCode} value={roomCode}
onChange={e => setRoomCode(e.target.value)} onChange={e => setRoomCode(e.target.value)}
placeholder="Enter code" placeholder="Enter room code"
className="bg-[#202124] border border-[#5f6368] rounded p-3 focus:outline-none focus:border-[#8ab4f8] focus:ring-1 focus:ring-[#8ab4f8] transition-all" className="bg-[#202124] border border-[#5f6368] rounded-md px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-[#8ab4f8]"
/> />
</div> </div>
</div> </div>
<div className="flex gap-4 mt-2"> <button
<button onClick={handleJoin}
onClick={handleJoin} disabled={!roomCode || !displayName}
disabled={!roomCode || !displayName} className="mt-2 px-6 py-3 bg-[#8ab4f8] text-[#202124] font-medium rounded-full hover:bg-[#aecbfa] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="px-6 py-3 bg-[#8ab4f8] text-[#202124] font-medium rounded-full hover:bg-[#aecbfa] disabled:opacity-50 disabled:cursor-not-allowed transition-colors" >
> Join now
Join now </button>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
); );

View file

@ -6,54 +6,106 @@ interface StageProps {
displayName: string; displayName: string;
peers: PeerInfo[]; peers: PeerInfo[];
peerVideoUrls?: { [key: number]: string }; peerVideoUrls?: { [key: number]: string };
peerScreenUrls?: { [key: number]: string };
localScreenUrl?: string | null;
localVideoRef?: React.RefObject<HTMLVideoElement | null>; localVideoRef?: React.RefObject<HTMLVideoElement | null>;
videoEnabled?: boolean; videoEnabled?: boolean;
} }
export function Stage({ selfId, displayName, peers, peerVideoUrls = {}, localVideoRef, videoEnabled = false }: StageProps) { export function Stage({
const participantCount = (selfId ? 1 : 0) + peers.length; selfId,
displayName,
peers,
peerVideoUrls = {},
peerScreenUrls = {},
localScreenUrl = null,
localVideoRef,
videoEnabled = false
}: StageProps) {
// Check if self is sharing screen
const isSelfSharing = !!localScreenUrl;
// Basic grid calculation // Filter peers who are sharing screen
let gridCols = 1; const peerScreens = peers.filter(p => !!peerScreenUrls[p.user_id]);
if (participantCount > 1) gridCols = 2; const participants = peers.filter(p => !peerScreenUrls[p.user_id]);
if (participantCount > 4) gridCols = 3;
if (participantCount > 9) gridCols = 4; 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 ( return (
<div className="flex-1 bg-[#202124] p-4 flex items-center justify-center overflow-hidden"> <div className="flex-1 bg-[#202124] p-4 flex gap-4 overflow-hidden">
<div {/* Screen Share Layer */}
className="grid gap-4 w-full h-full max-h-full" {showScreenLayer && (
style={{ <div className="flex-[3] h-full flex flex-col gap-4">
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`, {/* Local Screen Share */}
gridAutoRows: '1fr' {isSelfSharing && (
}} <div className="flex-1 min-h-0">
> <VideoTile
{/* Self Tile */} displayName={`${displayName} (Your Screen)`}
{selfId && ( isSelf
<div className="relative w-full h-full rounded-2xl overflow-hidden border border-[#3C4043] shadow-lg"> videoEnabled={true}
<VideoTile videoSrc={localScreenUrl!}
peerId={selfId} isScreenShare={true}
displayName={displayName} />
isSelf </div>
audioEnabled={true} )}
videoEnabled={videoEnabled}
videoRef={localVideoRef}
/>
</div>
)}
{/* Remote Peers */} {/* Remote Screen Shares */}
{peers.map(peer => ( {peerScreens.map(peer => (
<div key={peer.user_id} className="relative w-full h-full rounded-2xl overflow-hidden border border-[#3C4043] shadow-lg"> <div key={`screen-${peer.user_id}`} className="flex-1 min-h-0">
<VideoTile <VideoTile
peerId={peer.user_id} displayName={`${peer.display_name}'s Screen`}
displayName={peer.display_name} videoEnabled={true}
audioEnabled={true} videoSrc={peerScreenUrls[peer.user_id]}
videoEnabled={true} isScreenShare={true}
videoSrc={peerVideoUrls[peer.user_id]} />
/> </div>
</div> ))}
))} </div>
)}
{/* Webcam Grid */}
<div className={`${showScreenLayer ? 'flex-1 min-w-[300px]' : 'flex-1'} flex flex-col gap-3 h-full`}>
<div
className={`grid gap-3 ${showScreenLayer ? 'grid-cols-1 overflow-y-auto pr-1' : ''}`}
style={!showScreenLayer ? {
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gridAutoRows: '1fr',
height: '100%'
} : {}}
>
{/* Self Webcam */}
{selfId && (
<div className={showScreenLayer ? "aspect-video" : "h-full"}>
<VideoTile
displayName={displayName}
isSelf
audioEnabled={true}
videoEnabled={videoEnabled}
videoRef={localVideoRef}
/>
</div>
)}
{/* Remote Webcam Peers */}
{participants.map(peer => (
<div key={peer.user_id} className={showScreenLayer ? "aspect-video" : "h-full"}>
<VideoTile
displayName={peer.display_name}
audioEnabled={true}
videoEnabled={true}
videoSrc={peerVideoUrls[peer.user_id]}
/>
</div>
))}
</div>
</div> </div>
</div> </div>
); );

View file

@ -1,58 +1,63 @@
import { Mic, MicOff } from "lucide-react"; import { Mic, MicOff } from "lucide-react";
interface VideoTileProps { interface VideoTileProps {
peerId: number;
displayName: string; displayName: string;
isSelf?: boolean; isSelf?: boolean;
videoSrc?: string; // Blob URL videoSrc?: string;
videoRef?: React.RefObject<HTMLVideoElement | null>; // Direct ref (local) videoRef?: React.RefObject<HTMLVideoElement | null>;
audioEnabled?: boolean; audioEnabled?: boolean;
videoEnabled?: boolean; videoEnabled?: boolean;
isScreenShare?: boolean;
} }
export function VideoTile({ peerId, displayName, isSelf, videoSrc, videoRef, audioEnabled = true, videoEnabled = false }: VideoTileProps) { export function VideoTile({
// If we have a videoSrc, we don't need a ref to set srcObject, we just use src attribute. displayName,
// If we have videoRef, it's already attached to the video element by the parent (local). isSelf,
// Wait, if parent passes ref, we need to attach it to the <video> element here. videoSrc,
videoRef,
audioEnabled = true,
videoEnabled = false,
isScreenShare = false
}: VideoTileProps) {
return ( return (
<div className="relative bg-[#3C4043] rounded-2xl overflow-hidden shadow-lg group aspect-video flex flex-col items-center justify-center h-full w-full"> <div className="relative bg-[#3C4043] rounded-lg overflow-hidden flex items-center justify-center h-full">
{/* Video Layer */} {/* Video / Image */}
{(videoEnabled && videoRef) ? ( {videoEnabled && videoRef ? (
// Local video with ref
<video <video
ref={videoRef as any} ref={videoRef as any}
autoPlay autoPlay
playsInline playsInline
muted={isSelf} muted={isSelf}
className="absolute inset-0 w-full h-full object-cover transform scale-x-[-1]" className={`absolute inset-0 w-full h-full ${isScreenShare ? 'object-contain' : 'object-cover transform scale-x-[-1]'}`}
/> />
) : videoSrc ? ( ) : videoSrc ? (
// Remote video - using img for JPEG frames
<img <img
src={videoSrc} src={videoSrc}
alt="" alt=""
className="absolute inset-0 w-full h-full object-cover transform scale-x-[-1]" className={`absolute inset-0 w-full h-full ${isScreenShare ? 'object-contain' : 'object-cover transform scale-x-[-1]'}`}
/> />
) : ( ) : (
/* Avatar Layer (Fallback) */ <div className="flex flex-col items-center gap-2 z-10">
<div className="z-10 flex flex-col items-center gap-4 group-hover:-translate-y-2 transition-transform duration-300"> <div className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl font-bold text-white ${isSelf ? 'bg-[#5f6368]' : 'bg-[#5865F2]'
<div className={`w-20 h-20 rounded-full flex items-center justify-center text-3xl font-bold text-white shadow-lg ${isSelf ? 'bg-gradient-to-br from-indigo-500 to-purple-600' : 'bg-[#5865F2]'}`}> }`}>
{displayName.charAt(0).toUpperCase()} {displayName.charAt(0).toUpperCase()}
</div> </div>
</div> </div>
)} )}
{/* Overlays */} {/* Audio indicator */}
<div className="absolute top-3 right-3 z-20"> <div className="absolute top-2 right-2 z-20">
<div className="bg-[#111214]/80 p-1.5 rounded-full backdrop-blur-sm text-white"> <div className={`p-1.5 rounded-full ${audioEnabled ? 'bg-black/50' : 'bg-red-500'}`}>
{audioEnabled ? <Mic size={16} /> : <MicOff size={16} className="text-red-400" />} {audioEnabled ? <Mic size={14} className="text-white" /> : <MicOff size={14} className="text-white" />}
</div> </div>
</div> </div>
<div className="absolute bottom-3 left-3 z-20"> {/* Name */}
<div className="bg-[#111214]/80 px-3 py-1 rounded-md backdrop-blur-sm text-white font-medium text-sm shadow-sm flex items-center gap-2"> <div className="absolute bottom-3 left-3 z-30">
{displayName} {isSelf && <span className="text-[#949BA4] text-xs">(You)</span>} <div className="bg-black/60 backdrop-blur-sm px-3 py-1.5 rounded-md text-white text-sm font-medium border border-white/10 flex items-center gap-2">
<span className="truncate max-w-[150px]">{displayName}</span>
{isSelf && !isScreenShare && <span className="opacity-60 text-xs bg-white/10 px-1 rounded">(You)</span>}
</div> </div>
</div> </div>
</div> </div>

View file

@ -9,4 +9,24 @@ body,
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
background: #202124;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #5f6368;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #80868b;
} }

View file

@ -1,7 +1,14 @@
export interface PeerInfo { export interface PeerInfo {
user_id: number; user_id: number;
display_name: string; display_name: string;
// Future: stream status }
export interface ChatMessage {
id: string;
userId: number;
displayName: string;
message: string;
timestamp: number;
} }
export interface ControlMsg { export interface ControlMsg {