Initial commit: Electron video/audio calling client
This commit is contained in:
commit
9eb33512f4
22 changed files with 8848 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
20
electron.vite.config.ts
Normal file
20
electron.vite.config.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { resolve } from 'path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src')
|
||||
}
|
||||
},
|
||||
plugins: [react()]
|
||||
}
|
||||
})
|
||||
7321
package-lock.json
generated
Normal file
7321
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
package.json
Normal file
35
package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "client-electron",
|
||||
"version": "1.0.0",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"lucide-react": "^0.300.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
"@types/node": "^18.19.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.0",
|
||||
"electron-vite": "^2.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"@tailwindcss/postcss": "^4.0.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
111
src/main/index.ts
Normal file
111
src/main/index.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { app, shell, BrowserWindow, ipcMain, session, desktopCapturer } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import { NetworkManager } from './network' // Import NetworkManager
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let networkManager: NetworkManager | null = null;
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 670,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
|
||||
networkManager = new NetworkManager(mainWindow);
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
|
||||
// Grant permissions for camera/mic/screen
|
||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
console.log(`[Main] Requesting permission: ${permission}`);
|
||||
// Grant all permissions for this valid local app
|
||||
callback(true);
|
||||
});
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
// IPC Handlers
|
||||
ipcMain.handle('connect', async (_, { roomCode, displayName }) => {
|
||||
if (networkManager) {
|
||||
return await networkManager.connect(roomCode, displayName);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('disconnect', async () => {
|
||||
if (networkManager) {
|
||||
networkManager.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('send-video-frame', (_, { frame }) => {
|
||||
if (networkManager) {
|
||||
networkManager.sendVideoFrame(frame);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('send-audio-frame', (_, { frame }) => {
|
||||
if (networkManager) {
|
||||
networkManager.sendAudioFrame(new Uint8Array(frame));
|
||||
}
|
||||
});
|
||||
|
||||
// Screen sharing: get available sources
|
||||
ipcMain.handle('get-screen-sources', async () => {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen', 'window'],
|
||||
thumbnailSize: { width: 150, height: 150 }
|
||||
});
|
||||
return sources.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
thumbnail: s.thumbnail.toDataURL()
|
||||
}));
|
||||
});
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
235
src/main/network.ts
Normal file
235
src/main/network.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import * as dgram from 'dgram';
|
||||
import WebSocket from 'ws';
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
// Constants - Configure SERVER_HOST for production
|
||||
const SERVER_HOST = process.env.MEET_SERVER_HOST || '127.0.0.1';
|
||||
const SERVER_WS_URL = `ws://${SERVER_HOST}:5000/ws`;
|
||||
const SERVER_UDP_HOST = SERVER_HOST;
|
||||
const SERVER_UDP_PORT = 4000;
|
||||
|
||||
// Packet Header Structure (22 bytes)
|
||||
// version: u8, media_type: u8, user_id: u32, sequence: u32, timestamp: u64, frag_idx: u8, frag_cnt: u8, flags: u16
|
||||
const HEADER_SIZE = 22;
|
||||
|
||||
export enum MediaType {
|
||||
Audio = 0,
|
||||
Video = 1,
|
||||
Screen = 2,
|
||||
}
|
||||
|
||||
export class NetworkManager extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private udp: dgram.Socket | null = null;
|
||||
private userId: number = 0;
|
||||
private roomCode: string = '';
|
||||
private videoSeq: number = 0;
|
||||
private audioSeq: number = 0;
|
||||
private mainWindow: BrowserWindow;
|
||||
|
||||
constructor(mainWindow: BrowserWindow) {
|
||||
super();
|
||||
this.mainWindow = mainWindow;
|
||||
}
|
||||
|
||||
async connect(roomCode: string, displayName: string): Promise<any> {
|
||||
this.roomCode = roomCode; // Store for UDP handshake
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws = new WebSocket(`${SERVER_WS_URL}?room=${roomCode}&name=${displayName}`);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('WS Connected');
|
||||
// Send Join Message (serde adjacent tagging: type + data)
|
||||
const joinMsg = {
|
||||
type: 'Join',
|
||||
data: {
|
||||
room_code: roomCode,
|
||||
display_name: displayName
|
||||
}
|
||||
};
|
||||
this.ws?.send(JSON.stringify(joinMsg));
|
||||
console.log('Sent Join:', joinMsg);
|
||||
});
|
||||
|
||||
this.ws.on('message', (data: WebSocket.Data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
this.handleWsMessage(msg, resolve, reject);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WS msg', e);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
console.error('WS Error', err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
console.log('WS Closed');
|
||||
this.emit('disconnected');
|
||||
if (this.udp) this.udp.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleWsMessage(msg: any, resolve: any, reject: any) {
|
||||
console.log('Received WS Message:', msg.type);
|
||||
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.setupUdp();
|
||||
resolve(msg.data); // Return joined data (peers, etc)
|
||||
break;
|
||||
case 'PeerJoined':
|
||||
this.mainWindow.webContents.send('peer-joined', msg.data);
|
||||
break;
|
||||
case 'PeerLeft':
|
||||
this.mainWindow.webContents.send('peer-left', msg.data);
|
||||
break;
|
||||
case 'Error':
|
||||
console.error('WS Error Msg:', msg.data);
|
||||
reject(msg.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setupUdp() {
|
||||
this.udp = dgram.createSocket('udp4');
|
||||
|
||||
this.udp.on('listening', () => {
|
||||
const addr = this.udp?.address();
|
||||
console.log(`UDP Listening on ${addr?.port}`);
|
||||
// Send UDP handshake so server can associate our UDP address with room/user
|
||||
this.sendHandshake();
|
||||
});
|
||||
|
||||
this.udp.on('message', (msg) => {
|
||||
this.handleUdpMessage(msg);
|
||||
});
|
||||
|
||||
this.udp.bind(0); // Bind random port
|
||||
}
|
||||
|
||||
handleUdpMessage(msg: Buffer) {
|
||||
if (msg.length < HEADER_SIZE) return;
|
||||
|
||||
// Parse Header (Little Endian)
|
||||
// const version = msg.readUInt8(0); // 1
|
||||
const mediaType = msg.readUInt8(1);
|
||||
const userId = msg.readUInt32LE(2);
|
||||
// const seq = msg.readUInt32LE(6);
|
||||
// const ts = msg.readBigUInt64LE(10);
|
||||
// const fidx = msg.readUInt8(18);
|
||||
// const fcnt = msg.readUInt8(19);
|
||||
// const flags = msg.readUInt16LE(20);
|
||||
|
||||
const payload = msg.subarray(HEADER_SIZE);
|
||||
|
||||
if (mediaType === MediaType.Audio) {
|
||||
// Forward audio? Or decode here?
|
||||
// Electron renderer can use Web Audio API?
|
||||
// Or we decode in Main and send PCM?
|
||||
// Better to send encoded Opus to Renderer and decode there with WASM (libopus)
|
||||
// OR decode here with `opus-native` binding.
|
||||
// For now, let's send payload to renderer via IPC.
|
||||
// Note: IPC with high frequency audio packets might be laggy.
|
||||
// But for MVP it's okay.
|
||||
this.mainWindow.webContents.send('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 });
|
||||
}
|
||||
}
|
||||
|
||||
sendVideoFrame(frame: Uint8Array) {
|
||||
if (!this.udp) 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 packet = Buffer.concat([header, Buffer.from(frame)]);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
sendAudioFrame(frame: Uint8Array) {
|
||||
if (!this.udp) return;
|
||||
|
||||
// Construct Header (same format as video)
|
||||
const header = Buffer.alloc(HEADER_SIZE);
|
||||
header.writeUInt8(1, 0); // Version
|
||||
header.writeUInt8(MediaType.Audio, 1);
|
||||
header.writeUInt32LE(this.userId, 2);
|
||||
header.writeUInt32LE(this.audioSeq++, 6);
|
||||
header.writeBigUInt64LE(BigInt(Date.now()), 10);
|
||||
header.writeUInt8(0, 18); // Frag idx
|
||||
header.writeUInt8(1, 19); // Frag cnt
|
||||
header.writeUInt16LE(0, 20); // Flags
|
||||
|
||||
const packet = Buffer.concat([header, Buffer.from(frame)]);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
sendHandshake() {
|
||||
if (!this.udp || !this.userId || !this.roomCode) {
|
||||
console.error('[UDP] Cannot send handshake: missing udp, userId, or roomCode');
|
||||
return;
|
||||
}
|
||||
|
||||
// Server expects bincode-serialized Handshake { user_id: u32, room_code: String }
|
||||
// bincode format for String: u64 length prefix (LE) + UTF-8 bytes
|
||||
const roomCodeBytes = Buffer.from(this.roomCode, 'utf-8');
|
||||
|
||||
// Payload: | user_id (4 bytes LE) | room_code_len (8 bytes LE) | room_code (N bytes) |
|
||||
const payloadLen = 4 + 8 + roomCodeBytes.length;
|
||||
const payload = Buffer.alloc(payloadLen);
|
||||
payload.writeUInt32LE(this.userId, 0); // user_id
|
||||
payload.writeBigUInt64LE(BigInt(roomCodeBytes.length), 4); // string length
|
||||
roomCodeBytes.copy(payload, 12); // room_code
|
||||
|
||||
// Construct header with MediaType.Command (3)
|
||||
const header = Buffer.alloc(HEADER_SIZE);
|
||||
header.writeUInt8(1, 0); // Version
|
||||
header.writeUInt8(3, 1); // MediaType.Command = 3
|
||||
header.writeUInt32LE(this.userId, 2);
|
||||
header.writeUInt32LE(0, 6); // Sequence
|
||||
header.writeBigUInt64LE(BigInt(Date.now()), 10);
|
||||
header.writeUInt8(0, 18); // Frag idx
|
||||
header.writeUInt8(1, 19); // Frag cnt
|
||||
header.writeUInt16LE(0, 20); // Flags
|
||||
|
||||
const packet = Buffer.concat([header, payload]);
|
||||
|
||||
console.log(`[UDP] Sending Handshake: userId=${this.userId}, room=${this.roomCode}, ${packet.length} bytes`);
|
||||
this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => {
|
||||
if (err) console.error('UDP Handshake Send Error', err);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) this.ws.close();
|
||||
if (this.udp) this.udp.close();
|
||||
this.ws = null;
|
||||
this.udp = null;
|
||||
}
|
||||
}
|
||||
22
src/preload/index.ts
Normal file
22
src/preload/index.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
// just add to the DOM global.
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api
|
||||
}
|
||||
17
src/renderer/index.html
Normal file
17
src/renderer/index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Electron App</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
503
src/renderer/src/App.tsx
Normal file
503
src/renderer/src/App.tsx
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
import { useState, useEffect, useRef } from "react";
|
||||
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";
|
||||
|
||||
const audioWorkletCode = `
|
||||
class PCMProcessor extends AudioWorkletProcessor {
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0];
|
||||
if (input && input.length > 0) {
|
||||
const inputChannel = input[0];
|
||||
// Post full buffer to main thread (renderer)
|
||||
// optimization: we could accumulate here if needed
|
||||
this.port.postMessage(inputChannel);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('pcm-processor', PCMProcessor);
|
||||
`;
|
||||
|
||||
interface JoinedPayload {
|
||||
self_id: number;
|
||||
room: string;
|
||||
peers: PeerInfo[];
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [connected, setConnected] = useState(false);
|
||||
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}`]);
|
||||
};
|
||||
|
||||
// Media State
|
||||
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||
const [videoEnabled, setVideoEnabled] = useState(false);
|
||||
const [screenEnabled, setScreenEnabled] = useState(false);
|
||||
const [selectedAudioDevice, setSelectedAudioDevice] = useState<string>("");
|
||||
|
||||
// Video Handling
|
||||
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [peerVideoUrls, setPeerVideoUrls] = useState<{ [key: number]: string }>({});
|
||||
|
||||
// Event Listeners
|
||||
useEffect(() => {
|
||||
// @ts-ignore
|
||||
const removeJoined = window.electron.ipcRenderer.on('connect-success', (_, data: JoinedPayload) => {
|
||||
// We might get this as return from invoke, but also good to have event.
|
||||
});
|
||||
|
||||
// Peer Joined
|
||||
// @ts-ignore
|
||||
const removePeerJoined = window.electron.ipcRenderer.on("peer-joined", (_, data) => {
|
||||
addLog(`PeerJoined: ${JSON.stringify(data)}`);
|
||||
if (data && data.user_id) {
|
||||
setPeers((prev) => {
|
||||
if (prev.find(p => p.user_id === data.user_id)) {
|
||||
console.log("Peer already exists:", data.user_id);
|
||||
return prev;
|
||||
}
|
||||
console.log("Adding peer:", data);
|
||||
return [...prev, { user_id: data.user_id, display_name: data.display_name }];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Peer Left
|
||||
// @ts-ignore
|
||||
const removePeerLeft = window.electron.ipcRenderer.on("peer-left", (_, data) => {
|
||||
addLog(`PeerLeft: ${JSON.stringify(data)}`);
|
||||
if (data && data.user_id) {
|
||||
setPeers((prev) => prev.filter(p => p.user_id !== data.user_id));
|
||||
setPeerVideoUrls(prev => {
|
||||
const newState = { ...prev };
|
||||
if (newState[data.user_id]) {
|
||||
URL.revokeObjectURL(newState[data.user_id]);
|
||||
delete newState[data.user_id];
|
||||
}
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Video Frame
|
||||
// @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);
|
||||
|
||||
setPeerVideoUrls(prev => {
|
||||
if (prev[user_id]) URL.revokeObjectURL(prev[user_id]);
|
||||
return { ...prev, [user_id]: url };
|
||||
});
|
||||
});
|
||||
|
||||
// Audio Frame Playback with proper jitter buffer
|
||||
const playbackCtxRef = { current: null as AudioContext | null };
|
||||
const nextPlayTimeRef = { current: 0 };
|
||||
const JITTER_BUFFER_MS = 80; // Buffer 80ms before starting playback
|
||||
const bufferQueue: Float32Array[] = [];
|
||||
let isStarted = false;
|
||||
|
||||
const scheduleBuffer = (float32: Float32Array) => {
|
||||
const ctx = playbackCtxRef.current;
|
||||
if (!ctx) return;
|
||||
|
||||
const buffer = ctx.createBuffer(1, float32.length, 48000);
|
||||
buffer.copyToChannel(float32 as any, 0);
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
|
||||
// Schedule at precise time to avoid gaps
|
||||
const now = ctx.currentTime;
|
||||
if (nextPlayTimeRef.current < now) {
|
||||
// We've fallen behind, reset
|
||||
nextPlayTimeRef.current = now + JITTER_BUFFER_MS / 1000;
|
||||
}
|
||||
source.start(nextPlayTimeRef.current);
|
||||
nextPlayTimeRef.current += buffer.duration;
|
||||
};
|
||||
|
||||
const flushBuffer = () => {
|
||||
while (bufferQueue.length > 0) {
|
||||
scheduleBuffer(bufferQueue.shift()!);
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const removeAudio = window.electron.ipcRenderer.on("audio-frame", (_, payload) => {
|
||||
try {
|
||||
const { data } = payload;
|
||||
if (!playbackCtxRef.current) {
|
||||
playbackCtxRef.current = new AudioContext({ sampleRate: 48000 });
|
||||
nextPlayTimeRef.current = playbackCtxRef.current.currentTime + JITTER_BUFFER_MS / 1000;
|
||||
}
|
||||
|
||||
const ctx = playbackCtxRef.current;
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
|
||||
// Convert Uint8Array (bytes) to Int16 PCM then to Float32
|
||||
const uint8 = new Uint8Array(data);
|
||||
const int16 = new Int16Array(uint8.buffer, uint8.byteOffset, uint8.length / 2);
|
||||
const float32 = new Float32Array(int16.length);
|
||||
|
||||
for (let i = 0; i < int16.length; i++) {
|
||||
float32[i] = int16[i] / 32768;
|
||||
}
|
||||
|
||||
if (!isStarted) {
|
||||
// Buffer a few packets before starting
|
||||
bufferQueue.push(float32);
|
||||
if (bufferQueue.length >= 3) {
|
||||
isStarted = true;
|
||||
flushBuffer();
|
||||
}
|
||||
} else {
|
||||
scheduleBuffer(float32);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Audio playback error:", e);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
removePeerJoined();
|
||||
removePeerLeft();
|
||||
removeVideo();
|
||||
removeAudio();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Frame Capture Loop
|
||||
useEffect(() => {
|
||||
let animationFrameId: number;
|
||||
let isActive = true;
|
||||
|
||||
const sendFrame = async () => {
|
||||
if (!isActive) return;
|
||||
|
||||
if (videoEnabled && localVideoRef.current && canvasRef.current) {
|
||||
const video = localVideoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx && video.readyState === 4) {
|
||||
canvas.width = 320; // Low res for MVP
|
||||
canvas.height = 240;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (blob && isActive && videoEnabled && connected) {
|
||||
try {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
// @ts-ignore
|
||||
window.electron.ipcRenderer.send("send-video-frame", { frame: uint8Array });
|
||||
} catch (e) {
|
||||
// Ignore send errors
|
||||
}
|
||||
}
|
||||
}, 'image/jpeg', 0.5);
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (isActive) animationFrameId = requestAnimationFrame(sendFrame);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
if (videoEnabled) {
|
||||
sendFrame();
|
||||
}
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [videoEnabled, connected]);
|
||||
|
||||
// Camera Access - re-trigger when connected changes
|
||||
useEffect(() => {
|
||||
if (videoEnabled) {
|
||||
navigator.mediaDevices.getUserMedia({ video: { width: 320, height: 240 }, audio: false })
|
||||
.then(stream => {
|
||||
if (localVideoRef.current) {
|
||||
localVideoRef.current.srcObject = stream;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error accessing camera:", err);
|
||||
setVideoEnabled(false);
|
||||
setError("Failed to access camera");
|
||||
});
|
||||
} else {
|
||||
if (localVideoRef.current && localVideoRef.current.srcObject) {
|
||||
const stream = localVideoRef.current.srcObject as MediaStream;
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
localVideoRef.current.srcObject = null;
|
||||
}
|
||||
}
|
||||
}, [videoEnabled, connected]);
|
||||
|
||||
// Audio Capture
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const audioStreamRef = useRef<MediaStream | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
// Stop tracks
|
||||
if (audioStreamRef.current) {
|
||||
audioStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
audioStreamRef.current = null;
|
||||
}
|
||||
// Close context
|
||||
if (audioContextRef.current) {
|
||||
if (audioContextRef.current.state !== 'closed') {
|
||||
audioContextRef.current.close().catch(e => console.error("Error closing ctx", e));
|
||||
}
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!audioEnabled || !connected) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
addLog(`Starting audio... Device: ${selectedAudioDevice || 'Default'}`);
|
||||
|
||||
async function startAudio() {
|
||||
try {
|
||||
// Short delay to allow previous cleanup to settle if rapid toggling
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
if (isCancelled) return;
|
||||
|
||||
const constraints = {
|
||||
audio: {
|
||||
deviceId: selectedAudioDevice ? { exact: selectedAudioDevice } : undefined,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
},
|
||||
video: false
|
||||
};
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
|
||||
if (isCancelled) {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
return;
|
||||
}
|
||||
|
||||
addLog(`Mic Gained: ${stream.getAudioTracks()[0].label}`);
|
||||
|
||||
audioStreamRef.current = stream;
|
||||
|
||||
// Create context (if allowed by browser policy - usually requires interaction, which we have via button click)
|
||||
const ctx = new AudioContext({ sampleRate: 48000, latencyHint: 'interactive' });
|
||||
audioContextRef.current = ctx;
|
||||
|
||||
// Load Worklet
|
||||
// Note: creating blob URL every time is fine, browsers handle it.
|
||||
const blob = new Blob([audioWorkletCode], { type: 'application/javascript' });
|
||||
const workletUrl = URL.createObjectURL(blob);
|
||||
|
||||
let useWorklet = false;
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(workletUrl);
|
||||
useWorklet = true;
|
||||
} catch (e) {
|
||||
console.warn("Worklet addModule failed", e);
|
||||
}
|
||||
URL.revokeObjectURL(workletUrl);
|
||||
|
||||
if (isCancelled) {
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
|
||||
if (useWorklet) {
|
||||
try {
|
||||
addLog("Creating AudioWorkletNode...");
|
||||
const workletNode = new AudioWorkletNode(ctx, 'pcm-processor');
|
||||
workletNode.port.onmessage = (e) => {
|
||||
if (!audioEnabled || !connected || isCancelled) return;
|
||||
const float32 = e.data;
|
||||
const pcm = new Int16Array(float32.length);
|
||||
for (let i = 0; i < float32.length; i++) {
|
||||
pcm[i] = Math.max(-32768, Math.min(32767, Math.floor(float32[i] * 32768)));
|
||||
}
|
||||
// @ts-ignore
|
||||
window.electron.ipcRenderer.send('send-audio-frame', { frame: pcm.buffer });
|
||||
};
|
||||
source.connect(workletNode);
|
||||
workletNode.connect(ctx.destination);
|
||||
addLog("Audio Worklet running");
|
||||
return; // Success!
|
||||
} catch (e: any) {
|
||||
console.error("Worklet Node creation failed", e);
|
||||
addLog(`Worklet Node failed: ${e.message}`);
|
||||
// Fall through to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to ScriptProcessor
|
||||
addLog("Falling back to ScriptProcessor...");
|
||||
// @ts-ignore
|
||||
const scriptNode = ctx.createScriptProcessor(4096, 1, 1);
|
||||
// @ts-ignore
|
||||
scriptNode.onaudioprocess = (e) => {
|
||||
if (!audioEnabled || !connected || isCancelled) return;
|
||||
const inputData = e.inputBuffer.getChannelData(0);
|
||||
const pcm = new Int16Array(inputData.length);
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
pcm[i] = Math.max(-32768, Math.min(32767, Math.floor(inputData[i] * 32768)));
|
||||
}
|
||||
// @ts-ignore
|
||||
window.electron.ipcRenderer.send('send-audio-frame', { frame: pcm.buffer });
|
||||
};
|
||||
source.connect(scriptNode);
|
||||
// @ts-ignore
|
||||
scriptNode.connect(ctx.destination);
|
||||
addLog("ScriptProcessor running");
|
||||
|
||||
} catch (err: any) {
|
||||
if (isCancelled) return; // Ignore errors if we cancelled
|
||||
console.error('Audio capture error:', err);
|
||||
if (err.name === 'AbortError' || err.name === 'NotAllowedError') {
|
||||
addLog(`Permission/Abort Error: ${err.message}`);
|
||||
setError(`Mic blocked/aborted: ${err.message}`);
|
||||
} else {
|
||||
addLog(`Mic Error: ${err.message}`);
|
||||
setError(`Mic Error: ${err.message}`);
|
||||
}
|
||||
setAudioEnabled(false); // Reset state
|
||||
}
|
||||
}
|
||||
|
||||
startAudio();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [audioEnabled, connected, selectedAudioDevice]);
|
||||
|
||||
async function handleJoin(roomCode: string, displayName: string, initialVideo: boolean, initialAudio: boolean) {
|
||||
if (!roomCode || !displayName) return;
|
||||
setVideoEnabled(initialVideo);
|
||||
setAudioEnabled(initialAudio);
|
||||
setError("");
|
||||
try {
|
||||
// @ts-ignore
|
||||
const result = await window.electron.ipcRenderer.invoke("connect", { roomCode, displayName });
|
||||
if (result) {
|
||||
addLog(`Connected: Self=${result.self_id}, Peers=${result.peers.length}`);
|
||||
setSelfId(result.self_id);
|
||||
setPeers(result.peers);
|
||||
setConnected(true);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
const errMsg = typeof e === 'string' ? e : JSON.stringify(e);
|
||||
addLog(`Error: ${errMsg}`);
|
||||
setError(errMsg);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLeave() {
|
||||
setVideoEnabled(false);
|
||||
// @ts-ignore
|
||||
await window.electron.ipcRenderer.invoke("disconnect");
|
||||
setConnected(false);
|
||||
setPeers([]);
|
||||
setSelfId(null);
|
||||
setPeerVideoUrls({});
|
||||
}
|
||||
|
||||
const toggleAudio = () => setAudioEnabled(!audioEnabled);
|
||||
const toggleVideo = () => setVideoEnabled(!videoEnabled);
|
||||
const toggleScreen = () => setScreenEnabled(!screenEnabled);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-[#202124] 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">
|
||||
<Stage
|
||||
selfId={selfId}
|
||||
displayName={"You"} // Or pass from state
|
||||
peers={peers}
|
||||
// @ts-ignore
|
||||
peerVideoUrls={peerVideoUrls}
|
||||
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">
|
||||
<ControlBar
|
||||
onLeave={handleLeave}
|
||||
audioEnabled={audioEnabled}
|
||||
toggleAudio={toggleAudio}
|
||||
videoEnabled={videoEnabled}
|
||||
toggleVideo={toggleVideo}
|
||||
/>
|
||||
<div className="absolute top-0 right-[-40px] translate-y-2">
|
||||
<DeviceSelector
|
||||
onDeviceChange={setSelectedAudioDevice}
|
||||
currentDeviceId={selectedAudioDevice}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
103
src/renderer/src/components/ChannelList.tsx
Normal file
103
src/renderer/src/components/ChannelList.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { Hash, Volume2, Mic, MicOff, Headphones, Settings } from "lucide-react";
|
||||
|
||||
interface ChannelListProps {
|
||||
roomCode: string;
|
||||
setRoomCode: (code: string) => void;
|
||||
displayName: string;
|
||||
setDisplayName: (name: string) => void;
|
||||
onJoin: () => void;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export function ChannelList({ roomCode, setRoomCode, displayName, setDisplayName, onJoin, connected }: ChannelListProps) {
|
||||
return (
|
||||
<div className="w-[240px] bg-[#2B2D31] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="h-[48px] border-b border-[#1F2023] flex items-center px-4 font-bold text-white shadow-sm hover:bg-[#35373C] transition cursor-pointer">
|
||||
Meet Server
|
||||
</div>
|
||||
|
||||
{/* Channels */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-4">
|
||||
{/* Category: Text Channels */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-2 text-xs font-bold text-[#949BA4] hover:text-white cursor-pointer mb-1">
|
||||
<span>TEXT CHANNELS</span>
|
||||
<span className="text-lg">+</span>
|
||||
</div>
|
||||
<div className="space-y-[2px]">
|
||||
{['general', 'off-topic'].map(c => (
|
||||
<div key={c} className="flex items-center px-2 py-1 rounded bg-[#35373C] text-gray-400 cursor-pointer hover:bg-[#3F4147] hover:text-white group transition">
|
||||
<Hash size={18} className="mr-1.5 text-gray-500 group-hover:text-gray-300" />
|
||||
<span className="font-medium">{c}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category: Voice Channels */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-2 text-xs font-bold text-[#949BA4] hover:text-white cursor-pointer mb-1">
|
||||
<span>VOICE CHANNELS</span>
|
||||
<span className="text-lg">+</span>
|
||||
</div>
|
||||
|
||||
{/* Active Room Input as a "Channel" */}
|
||||
<div className={`p-2 rounded ${connected ? 'bg-[#35373C]' : ''}`}>
|
||||
{!connected ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-gray-400 px-2">
|
||||
<Volume2 size={16} />
|
||||
<span className="text-sm font-bold">Join Room</span>
|
||||
</div>
|
||||
<input
|
||||
className="w-full bg-[#1E1F22] text-gray-200 text-sm rounded p-1.5 outline-none border border-transparent focus:border-blue-500 transition"
|
||||
placeholder="Room Code"
|
||||
value={roomCode}
|
||||
onChange={(e) => setRoomCode(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full bg-[#1E1F22] text-gray-200 text-sm rounded p-1.5 outline-none border border-transparent focus:border-blue-500 transition"
|
||||
placeholder="Display Name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={onJoin}
|
||||
className="w-full bg-[#23A559] hover:bg-[#1A8C48] text-white text-sm font-bold py-1.5 rounded transition">
|
||||
Join Voice
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center px-2 py-1 text-green-400 cursor-pointer bg-[#3F4147] rounded">
|
||||
<Volume2 size={18} className="mr-1.5" />
|
||||
<span className="font-medium text-white">{roomCode || 'Lobby'}</span>
|
||||
<div className="ml-auto flex -space-x-1">
|
||||
{/* Could show avatars here */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Area */}
|
||||
<div className="bg-[#232428] px-2 py-1.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 hover:bg-[#3F4147] p-1 rounded cursor-pointer transition">
|
||||
<div className="w-[32px] h-[32px] rounded-full bg-blue-500 relative">
|
||||
<div className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-[#232428]"></div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-bold text-white leading-tight">{displayName || 'User'}</div>
|
||||
<div className="text-xs text-gray-400 leading-tight">#{Math.floor(Math.random() * 9999)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button className="p-1.5 hover:bg-[#3F4147] rounded text-gray-400 hover:text-white transition"><Mic size={18} /></button>
|
||||
<button className="p-1.5 hover:bg-[#3F4147] rounded text-gray-400 hover:text-white transition"><Headphones size={18} /></button>
|
||||
<button className="p-1.5 hover:bg-[#3F4147] rounded text-gray-400 hover:text-white transition"><Settings size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/renderer/src/components/ControlBar.tsx
Normal file
47
src/renderer/src/components/ControlBar.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Mic, MicOff, PhoneOff, Camera, CameraOff } from "lucide-react";
|
||||
|
||||
interface ControlBarProps {
|
||||
onLeave: () => void;
|
||||
audioEnabled: boolean;
|
||||
toggleAudio: () => void;
|
||||
videoEnabled: boolean;
|
||||
toggleVideo: () => void;
|
||||
}
|
||||
|
||||
export function ControlBar({
|
||||
onLeave,
|
||||
audioEnabled,
|
||||
toggleAudio,
|
||||
videoEnabled,
|
||||
toggleVideo,
|
||||
}: ControlBarProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 bg-[#202124] px-6 py-3 rounded-full shadow-2xl border border-[#3C4043]">
|
||||
<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"}
|
||||
>
|
||||
{audioEnabled ? <Mic size={20} /> : <MicOff size={20} />}
|
||||
</button>
|
||||
|
||||
<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"}
|
||||
>
|
||||
{videoEnabled ? <Camera size={20} /> : <CameraOff size={20} />}
|
||||
</button>
|
||||
|
||||
<div className="w-[1px] h-[24px] bg-[#5f6368] mx-2"></div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<PhoneOff size={20} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/renderer/src/components/DeviceSelector.tsx
Normal file
74
src/renderer/src/components/DeviceSelector.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Settings, Mic } from "lucide-react";
|
||||
|
||||
interface DeviceSelectorProps {
|
||||
onDeviceChange: (deviceId: string) => void;
|
||||
currentDeviceId: string;
|
||||
}
|
||||
|
||||
export function DeviceSelector({ onDeviceChange, currentDeviceId }: DeviceSelectorProps) {
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const getDevices = async () => {
|
||||
try {
|
||||
const devs = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioInputs = devs.filter(d => d.kind === 'audioinput');
|
||||
setDevices(audioInputs);
|
||||
} catch (e) {
|
||||
console.error("Failed to list devices", e);
|
||||
}
|
||||
};
|
||||
|
||||
getDevices();
|
||||
navigator.mediaDevices.addEventListener('devicechange', getDevices);
|
||||
return () => navigator.mediaDevices.removeEventListener('devicechange', getDevices);
|
||||
}, []);
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="p-2 rounded-full hover:bg-[#3C4043] text-gray-400 hover:text-white transition-colors"
|
||||
title="Audio Settings"
|
||||
>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 bg-[#202124] border border-[#3C4043] rounded-lg shadow-xl p-4 w-64 z-50">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-sm font-bold text-gray-300 flex items-center gap-2">
|
||||
<Mic size={14} /> Microphone
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{devices.map((device, i) => (
|
||||
<button
|
||||
key={device.deviceId || i}
|
||||
onClick={() => {
|
||||
onDeviceChange(device.deviceId);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left text-sm px-3 py-2 rounded truncate ${currentDeviceId === device.deviceId
|
||||
? 'bg-[#8ab4f8] text-[#202124] font-medium'
|
||||
: 'text-gray-300 hover:bg-[#3C4043]'
|
||||
}`}
|
||||
>
|
||||
{device.label || `Microphone ${i + 1}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/renderer/src/components/Lobby.tsx
Normal file
128
src/renderer/src/components/Lobby.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Mic, MicOff, Camera, CameraOff } from 'lucide-react';
|
||||
|
||||
interface LobbyProps {
|
||||
onJoin: (room: string, name: string, video: boolean, audio: boolean) => void;
|
||||
initialName?: string;
|
||||
initialRoom?: string;
|
||||
}
|
||||
|
||||
export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps) {
|
||||
const [roomCode, setRoomCode] = useState(initialRoom);
|
||||
const [displayName, setDisplayName] = useState(initialName);
|
||||
const [videoEnabled, setVideoEnabled] = useState(false);
|
||||
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let stream: MediaStream | null = null;
|
||||
|
||||
if (videoEnabled) {
|
||||
navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 }, audio: false })
|
||||
.then(s => {
|
||||
stream = s;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Camera preview failed", err);
|
||||
setVideoEnabled(false);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
}
|
||||
};
|
||||
}, [videoEnabled]);
|
||||
|
||||
const handleJoin = () => {
|
||||
if (roomCode.trim() && displayName.trim()) {
|
||||
onJoin(roomCode, displayName, videoEnabled, audioEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
|
||||
{/* 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">
|
||||
{videoEnabled ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className="w-full h-full object-cover transform scale-x-[-1]"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-400">Camera is off</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-4">
|
||||
<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`}
|
||||
>
|
||||
{audioEnabled ? <Mic size={20} /> : <MicOff size={20} />}
|
||||
</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`}
|
||||
>
|
||||
{videoEnabled ? <Camera size={20} /> : <CameraOff size={20} />}
|
||||
</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>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-300">Display 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-gray-300">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"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/renderer/src/components/Sidebar.tsx
Normal file
41
src/renderer/src/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Server, Compass, Plus } from "lucide-react";
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<div className="w-[72px] bg-[#1E1F22] flex flex-col items-center py-3 gap-2 shrink-0 overflow-y-auto no-scrollbar">
|
||||
{/* Home / DM Button */}
|
||||
<div className="relative group cursor-pointer">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[4px] h-[8px] bg-white rounded-r-lg group-hover:h-[20px] transition-all duration-200 opacity-0 group-hover:opacity-100"></div>
|
||||
<div className="w-[48px] h-[48px] bg-[#313338] group-hover:bg-[#5865F2] group-hover:rounded-[16px] rounded-[24px] flex items-center justify-center text-green-500 group-hover:text-white transition-all duration-200">
|
||||
<Server size={28} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[32px] h-[2px] bg-[#35363C] rounded-lg my-1"></div>
|
||||
|
||||
{/* Example Server Icons */}
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="relative group cursor-pointer">
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[4px] h-[8px] bg-white rounded-r-lg group-hover:h-[20px] transition-all duration-200 opacity-0 group-hover:opacity-100"></div>
|
||||
<div className="w-[48px] h-[48px] bg-[#313338] group-hover:bg-[#5865F2] group-hover:rounded-[16px] rounded-[24px] flex items-center justify-center text-gray-400 group-hover:text-white transition-all duration-200 overflow-hidden">
|
||||
<img src={`https://picsum.photos/id/${i + 10}/200`} alt="Server" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add Server Button */}
|
||||
<div className="relative group cursor-pointer text-green-500 group-hover:text-white">
|
||||
<div className="w-[48px] h-[48px] bg-[#313338] group-hover:bg-green-600 group-hover:rounded-[16px] rounded-[24px] flex items-center justify-center transition-all duration-200">
|
||||
<Plus size={24} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discover Button */}
|
||||
<div className="relative group cursor-pointer text-green-500 group-hover:text-white">
|
||||
<div className="w-[48px] h-[48px] bg-[#313338] group-hover:bg-green-600 group-hover:rounded-[16px] rounded-[24px] flex items-center justify-center transition-all duration-200">
|
||||
<Compass size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/renderer/src/components/Stage.tsx
Normal file
60
src/renderer/src/components/Stage.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { PeerInfo } from "../types";
|
||||
import { VideoTile } from "./VideoTile";
|
||||
|
||||
interface StageProps {
|
||||
selfId: number | null;
|
||||
displayName: string;
|
||||
peers: PeerInfo[];
|
||||
peerVideoUrls?: { [key: number]: string };
|
||||
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;
|
||||
|
||||
// Basic grid calculation
|
||||
let gridCols = 1;
|
||||
if (participantCount > 1) gridCols = 2;
|
||||
if (participantCount > 4) gridCols = 3;
|
||||
if (participantCount > 9) gridCols = 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/renderer/src/components/VideoTile.tsx
Normal file
60
src/renderer/src/components/VideoTile.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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)
|
||||
audioEnabled?: boolean;
|
||||
videoEnabled?: 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.
|
||||
|
||||
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
|
||||
<video
|
||||
ref={videoRef as any}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted={isSelf}
|
||||
className="absolute inset-0 w-full h-full 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]"
|
||||
/>
|
||||
) : (
|
||||
/* 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]'}`}>
|
||||
{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" />}
|
||||
</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>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/renderer/src/index.css
Normal file
12
src/renderer/src/index.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
10
src/renderer/src/main.tsx
Normal file
10
src/renderer/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
10
src/renderer/src/types.ts
Normal file
10
src/renderer/src/types.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export interface PeerInfo {
|
||||
user_id: number;
|
||||
display_name: string;
|
||||
// Future: stream status
|
||||
}
|
||||
|
||||
export interface ControlMsg {
|
||||
type: string;
|
||||
data: any;
|
||||
}
|
||||
11
tailwind.config.js
Normal file
11
tailwind.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/renderer/index.html",
|
||||
"./src/renderer/src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "out",
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*",
|
||||
"src/renderer/**/*",
|
||||
"electron.vite.config.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue