todo comment fix screen sharing and video aspect ratio
This commit is contained in:
parent
9eb33512f4
commit
2a9e80cc5a
10 changed files with 902 additions and 188 deletions
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
147
src/renderer/src/components/ChatPanel.tsx
Normal file
147
src/renderer/src/components/ChatPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue