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
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 () {

View file

@ -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');

View file

@ -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<number | null>(null);
const [peers, setPeers] = useState<PeerInfo[]>([]);
const [error, setError] = useState("");
const [logs, setLogs] = useState<string[]>([]);
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<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
const localVideoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [peerVideoUrls, setPeerVideoUrls] = useState<{ [key: number]: string }>({});
const [peerScreenUrls, setPeerScreenUrls] = useState<{ [key: number]: string }>({});
const [localScreenUrl, setLocalScreenUrl] = useState<string | null>(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<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 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<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
const audioContextRef = useRef<AudioContext | null>(null);
const audioStreamRef = useRef<MediaStream | null>(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 (
<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 ? (
<Lobby onJoin={handleJoin} />
) : (
<div className="relative w-full h-full flex flex-col">
{/* Main Stage */}
<div className="flex-1 overflow-hidden p-4">
<div className="relative w-full h-full flex">
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Stage */}
<Stage
selfId={selfId}
displayName={"You"} // Or pass from state
displayName={displayName || "You"}
peers={peers}
// @ts-ignore
peerVideoUrls={peerVideoUrls}
peerScreenUrls={peerScreenUrls}
localScreenUrl={localScreenUrl}
localVideoRef={localVideoRef}
videoEnabled={videoEnabled}
/>
</div>
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-end gap-2">
{/* Device Selector on the left or integrated? */}
{/* Make it float above or left of the mic button */}
<div className="relative">
{/* Control Bar */}
<div className="flex justify-center py-4 bg-[#1e1f22]">
<ControlBar
onLeave={handleLeave}
audioEnabled={audioEnabled}
toggleAudio={toggleAudio}
videoEnabled={videoEnabled}
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>
{/* Chat Panel */}
{chatOpen && (
<ChatPanel
messages={chatMessages}
onSendMessage={sendChatMessage}
onClose={() => setChatOpen(false)}
selfId={selfId}
/>
)}
{/* Hidden Canvas for capture */}
<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>
)}
{/* Error Toast */}
{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}
</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 {
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<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 (
<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
onClick={toggleAudio}
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'}`}
title={audioEnabled ? "Turn off microphone" : "Turn on microphone"}
onClick={toggleScreen}
className={`${buttonBase} ${screenEnabled ? 'bg-green-500 hover:bg-green-600' : 'bg-[#3C4043] hover:bg-[#4d5155]'}`}
title="Share screen"
>
{audioEnabled ? <Mic size={20} /> : <MicOff size={20} />}
<Monitor size={18} className="text-white" />
</button>
{/* Chat */}
<button
onClick={toggleVideo}
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'}`}
title={videoEnabled ? "Turn off camera" : "Turn on camera"}
onClick={toggleChat}
className={`${buttonBase} ${chatOpen ? 'bg-blue-500 hover:bg-blue-600' : 'bg-[#3C4043] hover:bg-[#4d5155]'}`}
title="Chat"
>
{videoEnabled ? <Camera size={20} /> : <CameraOff size={20} />}
<MessageSquare size={18} className="text-white" />
</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
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"
title="Leave call"
className={`${buttonBase} bg-red-500 hover:bg-red-600`}
>
<PhoneOff size={20} />
<PhoneOff size={18} className="text-white" />
</button>
</div>
);

View file

@ -45,12 +45,12 @@ export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps
};
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-[#202124] text-white font-sans">
<div className="w-full max-w-4xl p-8 grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
<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-8 items-center">
{/* Left: Preview */}
<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 ? (
<video
ref={videoRef}
@ -60,68 +60,69 @@ export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps
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
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
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>
</div>
</div>
<div className="text-center text-sm text-gray-400">
Check your audio and video before joining
</div>
</div>
{/* Right: Controls */}
<div className="flex flex-col gap-6">
<h1 className="text-3xl font-normal text-google-gray-100">Ready to join?</h1>
{/* Right: Form */}
<div className="flex flex-col gap-5">
<h1 className="text-2xl font-normal">Ready to join?</h1>
<div className="space-y-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-300">Display Name</label>
<div className="flex flex-col gap-1">
<label className="text-sm text-gray-400">Your name</label>
<input
type="text"
value={displayName}
onChange={e => 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]"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-300">Room Code</label>
<div className="flex flex-col gap-1">
<label className="text-sm text-gray-400">Room code</label>
<input
type="text"
value={roomCode}
onChange={e => 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]"
/>
</div>
</div>
<div className="flex gap-4 mt-2">
<button
onClick={handleJoin}
disabled={!roomCode || !displayName}
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
</button>
</div>
<button
onClick={handleJoin}
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"
>
Join now
</button>
</div>
</div>
</div>
);

View file

@ -6,54 +6,106 @@ interface StageProps {
displayName: string;
peers: PeerInfo[];
peerVideoUrls?: { [key: number]: string };
peerScreenUrls?: { [key: number]: string };
localScreenUrl?: string | null;
localVideoRef?: React.RefObject<HTMLVideoElement | null>;
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 (
<div className="flex-1 bg-[#202124] p-4 flex items-center justify-center overflow-hidden">
<div
className="grid gap-4 w-full h-full max-h-full"
style={{
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
gridAutoRows: '1fr'
}}
>
{/* Self Tile */}
{selfId && (
<div className="relative w-full h-full rounded-2xl overflow-hidden border border-[#3C4043] shadow-lg">
<VideoTile
peerId={selfId}
displayName={displayName}
isSelf
audioEnabled={true}
videoEnabled={videoEnabled}
videoRef={localVideoRef}
/>
</div>
)}
<div className="flex-1 bg-[#202124] p-4 flex gap-4 overflow-hidden">
{/* Screen Share Layer */}
{showScreenLayer && (
<div className="flex-[3] h-full flex flex-col gap-4">
{/* Local Screen Share */}
{isSelfSharing && (
<div className="flex-1 min-h-0">
<VideoTile
displayName={`${displayName} (Your Screen)`}
isSelf
videoEnabled={true}
videoSrc={localScreenUrl!}
isScreenShare={true}
/>
</div>
)}
{/* Remote Peers */}
{peers.map(peer => (
<div key={peer.user_id} className="relative w-full h-full rounded-2xl overflow-hidden border border-[#3C4043] shadow-lg">
<VideoTile
peerId={peer.user_id}
displayName={peer.display_name}
audioEnabled={true}
videoEnabled={true}
videoSrc={peerVideoUrls[peer.user_id]}
/>
</div>
))}
{/* Remote Screen Shares */}
{peerScreens.map(peer => (
<div key={`screen-${peer.user_id}`} className="flex-1 min-h-0">
<VideoTile
displayName={`${peer.display_name}'s Screen`}
videoEnabled={true}
videoSrc={peerScreenUrls[peer.user_id]}
isScreenShare={true}
/>
</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>
);

View file

@ -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<HTMLVideoElement | null>; // Direct ref (local)
videoSrc?: string;
videoRef?: React.RefObject<HTMLVideoElement | null>;
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 <video> element here.
export function VideoTile({
displayName,
isSelf,
videoSrc,
videoRef,
audioEnabled = true,
videoEnabled = false,
isScreenShare = false
}: VideoTileProps) {
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">
{/* Video Layer */}
{(videoEnabled && videoRef) ? (
// Local video with ref
<div className="relative bg-[#3C4043] rounded-lg overflow-hidden flex items-center justify-center h-full">
{/* Video / Image */}
{videoEnabled && videoRef ? (
<video
ref={videoRef as any}
autoPlay
playsInline
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 ? (
// Remote video - using img for JPEG frames
<img
src={videoSrc}
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="z-10 flex flex-col items-center gap-4 group-hover:-translate-y-2 transition-transform duration-300">
<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]'}`}>
<div className="flex flex-col items-center gap-2 z-10">
<div className={`w-16 h-16 rounded-full flex items-center justify-center text-2xl font-bold text-white ${isSelf ? 'bg-[#5f6368]' : 'bg-[#5865F2]'
}`}>
{displayName.charAt(0).toUpperCase()}
</div>
</div>
)}
{/* Overlays */}
<div className="absolute top-3 right-3 z-20">
<div className="bg-[#111214]/80 p-1.5 rounded-full backdrop-blur-sm text-white">
{audioEnabled ? <Mic size={16} /> : <MicOff size={16} className="text-red-400" />}
{/* Audio indicator */}
<div className="absolute top-2 right-2 z-20">
<div className={`p-1.5 rounded-full ${audioEnabled ? 'bg-black/50' : 'bg-red-500'}`}>
{audioEnabled ? <Mic size={14} className="text-white" /> : <MicOff size={14} className="text-white" />}
</div>
</div>
<div className="absolute bottom-3 left-3 z-20">
<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">
{displayName} {isSelf && <span className="text-[#949BA4] text-xs">(You)</span>}
{/* Name */}
<div className="absolute bottom-3 left-3 z-30">
<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>

View file

@ -9,4 +9,24 @@ body,
width: 100%;
margin: 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 {
user_id: number;
display_name: string;
// Future: stream status
}
export interface ChatMessage {
id: string;
userId: number;
displayName: string;
message: string;
timestamp: number;
}
export interface ControlMsg {