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": {
|
||||
"dev": "electron-vite dev",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -35,11 +35,14 @@ export class NetworkManager extends EventEmitter {
|
|||
this.roomCode = roomCode; // Store for UDP handshake
|
||||
return new Promise((resolve, reject) => {
|
||||
// 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)
|
||||
|
||||
// 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 wsUrl = `${protocol}://${host}/ws`;
|
||||
|
||||
|
|
@ -141,16 +144,19 @@ export class NetworkManager extends EventEmitter {
|
|||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
setupUdp() {
|
||||
this.udp = dgram.createSocket('udp4');
|
||||
|
||||
this.udp.on('listening', () => {
|
||||
const addr = this.udp?.address();
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
@ -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() {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -311,13 +333,17 @@ export class NetworkManager extends EventEmitter {
|
|||
|
||||
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) => {
|
||||
if (err) console.error('UDP Handshake Send Error', err);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
if (this.ws) this.ws.close();
|
||||
if (this.udp) this.udp.close();
|
||||
this.ws = null;
|
||||
|
|
|
|||
|
|
@ -113,7 +113,8 @@ function App() {
|
|||
const removeStreamUpdate = window.electron.ipcRenderer.on("peer-stream-update", (_, data) => {
|
||||
const { user_id, active, media_type } = data;
|
||||
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 => {
|
||||
const newState = { ...prev };
|
||||
if (newState[user_id]) {
|
||||
|
|
@ -122,7 +123,7 @@ function App() {
|
|||
}
|
||||
return newState;
|
||||
});
|
||||
} else if (media_type === 'Screen') {
|
||||
} else if (media_type === 'Screen' || media_type === 2) {
|
||||
setPeerScreenUrls(prev => {
|
||||
const newState = { ...prev };
|
||||
if (newState[user_id]) {
|
||||
|
|
@ -369,8 +370,12 @@ function App() {
|
|||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx && video.readyState === 4) {
|
||||
canvas.width = 320; // Low res for MVP
|
||||
canvas.height = 240;
|
||||
// Use native video size, capped at 1080p for bandwidth sanity
|
||||
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);
|
||||
|
||||
canvas.toBlob(async (blob) => {
|
||||
|
|
@ -384,12 +389,12 @@ function App() {
|
|||
// Ignore send errors
|
||||
}
|
||||
}
|
||||
}, 'image/jpeg', 0.5);
|
||||
}, 'image/jpeg', 0.4); // Quality 0.4 (Good balance for 1080p UDP)
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (isActive) animationFrameId = requestAnimationFrame(sendFrame);
|
||||
}, 66); // ~15 FPS for lower latency
|
||||
}, 33); // 30 FPS
|
||||
};
|
||||
|
||||
if (videoEnabled) {
|
||||
|
|
@ -506,12 +511,12 @@ function App() {
|
|||
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(() => {
|
||||
if (isActive) requestAnimationFrame(sendScreenFrame);
|
||||
}, 16); // ~60 FPS for screen
|
||||
}, 33); // 30 FPS for Screen Share
|
||||
};
|
||||
|
||||
screenVideo.onloadeddata = () => sendScreenFrame();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ interface LobbyProps {
|
|||
export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps) {
|
||||
const [roomCode, setRoomCode] = useState(initialRoom);
|
||||
const [displayName, setDisplayName] = useState(initialName);
|
||||
const [serverUrl, setServerUrl] = useState('meet.srtk.in');
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
const [videoEnabled, setVideoEnabled] = useState(false);
|
||||
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
|
@ -99,7 +99,7 @@ export function Lobby({ onJoin, initialName = '', initialRoom = '' }: LobbyProps
|
|||
type="text"
|
||||
value={serverUrl}
|
||||
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]"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export function Stage({
|
|||
>
|
||||
{/* Self Webcam */}
|
||||
{selfId && (
|
||||
<div className="aspect-video min-h-0">
|
||||
<div className="aspect-video min-h-0 min-w-0">
|
||||
<VideoTile
|
||||
displayName={displayName}
|
||||
isSelf
|
||||
|
|
@ -151,7 +151,7 @@ export function Stage({
|
|||
|
||||
{/* Remote Webcam Peers */}
|
||||
{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
|
||||
displayName={peer.display_name}
|
||||
audioEnabled={true}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function VideoTile({
|
|||
autoPlay
|
||||
playsInline
|
||||
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
|
||||
src={videoSrc}
|
||||
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