Fix VideoTile bug and improve screen sharing

This commit is contained in:
srtk 2026-02-08 23:26:40 +05:30
parent d489873060
commit cade45d16d
7 changed files with 124 additions and 20 deletions

70
README.md Normal file
View file

@ -0,0 +1,70 @@
# Meet Client (Electron)
The desktop client for the Meet application. Built with **Electron**, **React**, **TypeScript**, and **Vite**.
## Features
- **High Quality Video**: 1080p @ 30 FPS video calling.
- **Screen Sharing**: Low-latency 1080p screen sharing.
- **Chat**: Real-time text chat.
- **Protocol**: Custom UDP-based media transport for minimal latency.
## Prerequisites
- **Node.js**: v18.0.0 or higher.
- **NPM**: v9.0.0 or higher.
## Installation
1. **Clone the Repository**:
```bash
git clone <your-client-repo-url>
cd client-electron
```
2. **Install Dependencies**:
```bash
npm install
```
## Development
Start the app in development mode with Hot Module Replacement (HMR):
```bash
npm run dev
```
## Building for Production
To create a distributable installer/executable for your OS:
### Linux
```bash
npm run build:linux
```
Output: `dist/*.AppImage` (or configured target)
### Windows
```bash
npm run build:win
```
Output: `dist/*.exe`
### macOS
```bash
npm run build:mac
```
Output: `dist/*.dmg`
## Configuration
### Connection Settings
- **Server URL**: When launching the app, enter your server's domain or IP (e.g., `meet.srtk.in` or `192.168.1.8`).
- **UDP Port**: The client hardcodes the target UDP port to **4000**. Ensure your server's firewall allows inbound UDP on port 4000.
### Troubleshooting
- **Black Video**: Ensure "NAT Loopback" is enabled if testing locally with a public domain, or use the local IP.
- **Connection Drops**: Check your router's UDP flood protection / firewall settings if video freezes after a few seconds.
## Project Structure
- `src/main/`: Electron Main process (Window creation, UDP socket handling).
- `src/renderer/`: React frontend (UI, Video rendering).
- `src/preload/`: IPC bridge between Main and Renderer.

View file

@ -5,6 +5,9 @@
"scripts": { "scripts": {
"dev": "electron-vite dev", "dev": "electron-vite dev",
"build": "electron-vite build", "build": "electron-vite build",
"build:linux": "electron-vite build && electron-builder --linux",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"preview": "electron-vite preview" "preview": "electron-vite preview"
}, },
"dependencies": { "dependencies": {

View file

@ -35,11 +35,14 @@ export class NetworkManager extends EventEmitter {
this.roomCode = roomCode; // Store for UDP handshake this.roomCode = roomCode; // Store for UDP handshake
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Determine Host and Protocol // Determine Host and Protocol
let host = serverUrl.replace(/^wss?:\/\//, '').replace(/\/$/, ''); let host = serverUrl.trim().replace(/^wss?:\/\//, '').replace(/\/$/, '');
this.serverUdpHost = host.split(':')[0]; // Hostname only for UDP (strip port if present) this.serverUdpHost = host.split(':')[0]; // Hostname only for UDP (strip port if present)
// Auto-detect protocol: localhost/IP uses ws://, domains use wss:// (HTTPS) // Auto-detect protocol: localhost/IP uses ws://, domains use wss:// (HTTPS)
const isLocal = host.includes('localhost') || host.includes('127.0.0.1'); const isLocal = host.includes('localhost') ||
host.includes('127.0.0.1') ||
host.startsWith('192.168.') ||
host.startsWith('10.');
const protocol = isLocal ? 'ws' : 'wss'; const protocol = isLocal ? 'ws' : 'wss';
const wsUrl = `${protocol}://${host}/ws`; const wsUrl = `${protocol}://${host}/ws`;
@ -141,16 +144,19 @@ export class NetworkManager extends EventEmitter {
this.ws.send(JSON.stringify(msg)); this.ws.send(JSON.stringify(msg));
} }
private heartbeatInterval: NodeJS.Timeout | null = null;
setupUdp() { setupUdp() {
this.udp = dgram.createSocket('udp4'); this.udp = dgram.createSocket('udp4');
this.udp.on('listening', () => { this.udp.on('listening', () => {
const addr = this.udp?.address(); const addr = this.udp?.address();
console.log(`UDP Listening on ${addr?.port}`); console.log(`UDP Listening on ${addr?.port}`);
this.sendHandshake(); this.startHeartbeat();
}); });
this.udp.on('message', (msg) => { this.udp.on('message', (msg, rinfo) => {
console.log(`[UDP] Msg from ${rinfo.address}:${rinfo.port} - ${msg.length} bytes`);
this.handleUdpMessage(msg); this.handleUdpMessage(msg);
}); });
@ -286,9 +292,25 @@ export class NetworkManager extends EventEmitter {
} }
} }
startHeartbeat() {
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
// Send immediately
this.sendHandshake();
// Send 3 bursts to ensure traversal
setTimeout(() => this.sendHandshake(), 500);
setTimeout(() => this.sendHandshake(), 1000);
// Keep-alive every 3 seconds
this.heartbeatInterval = setInterval(() => {
this.sendHandshake();
}, 3000);
}
sendHandshake() { sendHandshake() {
if (!this.udp || !this.userId || !this.roomCode) { if (!this.udp || !this.userId || !this.roomCode) {
console.error('[UDP] Cannot send handshake: missing udp, userId, or roomCode'); // console.error('[UDP] Cannot send handshake: missing udp, userId, or roomCode');
return; return;
} }
@ -311,13 +333,17 @@ export class NetworkManager extends EventEmitter {
const packet = Buffer.concat([header, payload]); const packet = Buffer.concat([header, payload]);
console.log(`[UDP] Sending Handshake: userId=${this.userId}, room=${this.roomCode}, ${packet.length} bytes`); console.log(`[UDP] Sending Handshake: userId=${this.userId}, room=${this.roomCode}, ${packet.length} bytes to ${this.serverUdpHost}:${SERVER_UDP_PORT}`);
this.udp.send(packet, SERVER_UDP_PORT, this.serverUdpHost, (err) => { this.udp.send(packet, SERVER_UDP_PORT, this.serverUdpHost, (err) => {
if (err) console.error('UDP Handshake Send Error', err); if (err) console.error('UDP Handshake Send Error', err);
}); });
} }
disconnect() { disconnect() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (this.ws) this.ws.close(); if (this.ws) this.ws.close();
if (this.udp) this.udp.close(); if (this.udp) this.udp.close();
this.ws = null; this.ws = null;

View file

@ -113,7 +113,8 @@ function App() {
const removeStreamUpdate = window.electron.ipcRenderer.on("peer-stream-update", (_, data) => { const removeStreamUpdate = window.electron.ipcRenderer.on("peer-stream-update", (_, data) => {
const { user_id, active, media_type } = data; const { user_id, active, media_type } = data;
if (!active) { if (!active) {
if (media_type === 'Video') { // Support both legacy string and numeric enum (1=Video, 2=Screen)
if (media_type === 'Video' || media_type === 1) {
setPeerVideoUrls(prev => { setPeerVideoUrls(prev => {
const newState = { ...prev }; const newState = { ...prev };
if (newState[user_id]) { if (newState[user_id]) {
@ -122,7 +123,7 @@ function App() {
} }
return newState; return newState;
}); });
} else if (media_type === 'Screen') { } else if (media_type === 'Screen' || media_type === 2) {
setPeerScreenUrls(prev => { setPeerScreenUrls(prev => {
const newState = { ...prev }; const newState = { ...prev };
if (newState[user_id]) { if (newState[user_id]) {
@ -369,8 +370,12 @@ function App() {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (ctx && video.readyState === 4) { if (ctx && video.readyState === 4) {
canvas.width = 320; // Low res for MVP // Use native video size, capped at 1080p for bandwidth sanity
canvas.height = 240; const width = Math.min(video.videoWidth, 1920);
const height = Math.min(video.videoHeight, 1080);
canvas.width = width;
canvas.height = height;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob(async (blob) => { canvas.toBlob(async (blob) => {
@ -384,12 +389,12 @@ function App() {
// Ignore send errors // Ignore send errors
} }
} }
}, 'image/jpeg', 0.5); }, 'image/jpeg', 0.4); // Quality 0.4 (Good balance for 1080p UDP)
} }
} }
setTimeout(() => { setTimeout(() => {
if (isActive) animationFrameId = requestAnimationFrame(sendFrame); if (isActive) animationFrameId = requestAnimationFrame(sendFrame);
}, 66); // ~15 FPS for lower latency }, 33); // 30 FPS
}; };
if (videoEnabled) { if (videoEnabled) {
@ -506,12 +511,12 @@ function App() {
window.electron.ipcRenderer.send('send-screen-frame', { frame: Array.from(new Uint8Array(buf)) }); window.electron.ipcRenderer.send('send-screen-frame', { frame: Array.from(new Uint8Array(buf)) });
}); });
} }
}, 'image/jpeg', 0.8); }, 'image/jpeg', 0.4); // Quality 0.4 for speed
} }
setTimeout(() => { setTimeout(() => {
if (isActive) requestAnimationFrame(sendScreenFrame); if (isActive) requestAnimationFrame(sendScreenFrame);
}, 16); // ~60 FPS for screen }, 33); // 30 FPS for Screen Share
}; };
screenVideo.onloadeddata = () => sendScreenFrame(); screenVideo.onloadeddata = () => sendScreenFrame();

View file

@ -10,7 +10,7 @@ interface LobbyProps {
export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps) { export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps) {
const [roomCode, setRoomCode] = useState(initialRoom); const [roomCode, setRoomCode] = useState(initialRoom);
const [displayName, setDisplayName] = useState(initialName); const [displayName, setDisplayName] = useState(initialName);
const [serverUrl, setServerUrl] = useState('meet.srtk.in'); const [serverUrl, setServerUrl] = useState('');
const [videoEnabled, setVideoEnabled] = useState(false); const [videoEnabled, setVideoEnabled] = useState(false);
const [audioEnabled, setAudioEnabled] = useState(false); const [audioEnabled, setAudioEnabled] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
@ -99,7 +99,7 @@ export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps
type="text" type="text"
value={serverUrl} value={serverUrl}
onChange={e => setServerUrl(e.target.value)} onChange={e => setServerUrl(e.target.value)}
placeholder="meet.srtk.in" placeholder="Enter your server URL"
className="bg-[#202124] border border-[#5f6368] rounded-md px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-[#8ab4f8]" 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>

View file

@ -138,7 +138,7 @@ export function Stage({
> >
{/* Self Webcam */} {/* Self Webcam */}
{selfId && ( {selfId && (
<div className="aspect-video min-h-0"> <div className="aspect-video min-h-0 min-w-0">
<VideoTile <VideoTile
displayName={displayName} displayName={displayName}
isSelf isSelf
@ -151,7 +151,7 @@ export function Stage({
{/* Remote Webcam Peers */} {/* Remote Webcam Peers */}
{allParticipants.map(peer => ( {allParticipants.map(peer => (
<div key={peer.user_id} className="aspect-video min-h-0"> <div key={peer.user_id} className="aspect-video min-h-0 min-w-0">
<VideoTile <VideoTile
displayName={peer.display_name} displayName={peer.display_name}
audioEnabled={true} audioEnabled={true}

View file

@ -34,7 +34,7 @@ export function VideoTile({
autoPlay autoPlay
playsInline playsInline
muted={true} muted={true}
className={`w-full h-full object-cover ${!isScreenShare ? 'scale-x-[-1]' : ''}`} className={`w-full h-full object-contain bg-black ${!isScreenShare ? 'scale-x-[-1]' : ''}`}
/> />
)} )}
@ -43,7 +43,7 @@ export function VideoTile({
<img <img
src={videoSrc} src={videoSrc}
alt={displayName} alt={displayName}
className={`w-full h-full ${isScreenShare ? 'object-contain bg-black' : 'object-cover'}`} className={`w-full h-full object-contain bg-black`}
/> />
)} )}