Fix VideoTile bug and improve screen sharing
This commit is contained in:
parent
d489873060
commit
cade45d16d
7 changed files with 124 additions and 20 deletions
70
README.md
Normal file
70
README.md
Normal 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.
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue