Repository: hervino-cell/video-app
Branch: main
Commit: c65f283cb5b4
Files: 14
Total size: 46.6 KB
Directory structure:
gitextract_zboip5dy/
├── .dockerignore
├── .gitignore
├── Dockerfile
├── README.md
├── fly.toml
├── package.json
├── public/
│ ├── client.js
│ ├── index.html
│ └── style.css
├── render.yaml
├── render_build.sh
└── server/
├── index.js
├── mediasoup.js
└── room.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
@'
node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
.DS_Store
README.md
build
dist
.vscode
.idea
'@ | Out-File -FilePath .dockerignore -Encoding UTF8 -NoNewline
================================================
FILE: .gitignore
================================================
node_modules/
================================================
FILE: Dockerfile
================================================
# Use official Node.js image
FROM node:18-slim
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (production only)
RUN npm install --omit=dev
# Copy application code
COPY . .
# Expose port
EXPOSE 3000
# Health check (optional)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
# Start the application
CMD ["npm", "start"]
================================================
FILE: fly.toml
================================================
app = "video-app-learning"
primary_region = "sjc"
[build]
image = "video-app-learning:deployment-xxxxx"
[env]
PORT = "3000"
NODE_ENV = "production"
MEDIASOUP_LISTEN_IP = "0.0.0.0"
# MEDIASOUP_ANNOUNCED_IP will be set dynamically after deployment
[[services]]
internal_port = 3000
processes = ["app"]
[services.tcp_checks]
grace_period = "5s"
interval = "15s"
timeout = "2s"
[[services.ports]]
port = 80
handlers = ["http"]
force_https = true
[[services.ports]]
port = 443
handlers = ["tls", "http"]
# Optional: UDP ports for Mediasoup RTC (if needed for direct peer connections)
# Uncomment if using custom TURN/STUN
# [[services]]
# protocol = "udp"
# internal_port = 40000
# [services.ports]
# port = 40000
================================================
FILE: package.json
================================================
{
"name": "video-app-learning",
"version": "1.0.0",
"description": "WebRTC video conferencing with Mediasoup",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "nodemon server/index.js",
"build": "esbuild --bundle --global-name=mediasoupClient --outfile=public/mediasoup-client.js node_modules/mediasoup-client/lib/index.js",
"render-build": "npm install && npm run build"
},
"engines": {
"node": "20.x"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.22.1",
"mediasoup": "^3.14.0",
"mediasoup-client": "^3.7.6",
"socket.io": "^4.8.3"
},
"devDependencies": {
"esbuild": "^0.21.0",
"nodemon": "^2.0.20"
}
}
================================================
FILE: public/client.js
================================================
// public/client.js
// mediasoup-client bundled by esbuild → window.mediasoupClient
if (!window.mediasoupClient) {
document.getElementById('status').textContent =
'❌ Bundle manquant. Lancez "npm run build" et redémarrez le serveur.';
throw new Error('window.mediasoupClient is undefined');
}
const socket = io({ transports: ['websocket', 'polling'], reconnection: true });
const state = {
roomId: null,
device: null,
sendTransport: null,
recvTransport: null,
localStream: null,
producers: { audio: null, video: null },
consumers: new Map(),
pendingProducers: [],
remoteStreams: new Map(), // NEW: Track remote video/audio elements
};
const el = {};
// ── JOIN ─────────────────────────────────────────────────────────────────────────────
async function joinRoom() {
const roomId = el.roomInput.value.trim() || 'room1';
state.roomId = roomId;
el.joinBtn.disabled = el.roomInput.disabled = true;
el.leaveBtn.disabled = false;
setStatus('⏳ Connexion en cours…', 'info');
try {
state.localStream = await getLocalStream();
setupSocketListeners();
socket.emit('join-room', roomId, { name: 'User' }, (res) => {
if (res?.error) {
setStatus('❌ ' + res.error, 'error');
resetUI();
}
});
} catch (err) {
console.error('joinRoom error:', err);
setStatus('❌ ' + err.message, 'error');
resetUI();
}
}
// ── DEVICE ─────────────────────────────────────────────────────────────────────────────
async function loadDevice(routerRtpCapabilities) {
state.device = new window.mediasoupClient.Device();
await state.device.load({ routerRtpCapabilities });
console.log('✅ Device loaded');
}
// ── TRANSPORT OPTIONS ─────────────────────────────────────────────────────────────────
function transportOptions(params) {
return {
id: params.id,
iceParameters: params.iceParameters,
iceCandidates: params.iceCandidates,
dtlsParameters: params.dtlsParameters,
iceServers: params.iceServers,
iceTransportPolicy: 'relay',
};
}
// ── SEND TRANSPORT ─────────────────────────────────────────────────────────────────────
async function createSendTransport(params) {
const t = state.device.createSendTransport(transportOptions(params));
t.on('connect', ({ dtlsParameters }, cb, errback) =>
socket.emit('transport-connect', { transportId: t.id, dtlsParameters },
r => r?.error ? errback(new Error(r.error)) : cb())
);
t.on('produce', ({ kind, rtpParameters, appData }, cb, errback) =>
socket.emit('produce', { kind, rtpParameters, appData },
r => r?.error ? errback(new Error(r.error)) : cb({ id: r.id }))
);
t.on('connectionstatechange', state =>
console.log('sendTransport connectionstate:', state)
);
state.sendTransport = t;
}
// ── RECV TRANSPORT ─────────────────────────────────────────────────────────────────────
async function createRecvTransport(params) {
const t = state.device.createRecvTransport(transportOptions(params));
t.on('connect', ({ dtlsParameters }, cb, errback) =>
socket.emit('transport-connect', { transportId: t.id, dtlsParameters },
r => r?.error ? errback(new Error(r.error)) : cb())
);
t.on('connectionstatechange', state =>
console.log('recvTransport connectionstate:', state)
);
state.recvTransport = t;
}
// ── PRODUCE ────────────────────────────────────────────────────────────────────────────
async function startProducing() {
const audioTrack = state.localStream?.getAudioTracks()[0];
if (audioTrack) {
state.producers.audio = await state.sendTransport.produce({
track: audioTrack,
codecOptions: { opusStereo: true, opusDtx: true },
});
console.log('🎤 Audio producer ready:', state.producers.audio.id);
}
const videoTrack = state.localStream?.getVideoTracks()[0];
if (videoTrack) {
state.producers.video = await state.sendTransport.produce({
track: videoTrack,
encodings: [
{ maxBitrate: 100000 },
{ maxBitrate: 300000 },
{ maxBitrate: 900000 },
],
codecOptions: { videoGoogleStartBitrate: 1000 },
});
console.log('📹 Video producer ready:', state.producers.video.id);
}
}
// ── CONSUME ────────────────────────────────────────────────────────────────────────────
async function consumeProducer(producerId) {
console.log('consumeProducer called for:', producerId);
if (!state.device || !state.recvTransport) {
console.log('Device or recvTransport not ready, queueing producer:', producerId);
if (!state.pendingProducers.includes(producerId))
state.pendingProducers.push(producerId);
return;
}
return new Promise(resolve => {
socket.emit('consume', {
producerId,
rtpCapabilities: state.device.rtpCapabilities,
}, async (params) => {
console.log('consume callback received:', params);
if (params.error) {
console.error('❌ consume error:', params.error);
return resolve();
}
try {
console.log('Creating consumer for producerId:', producerId, 'producerUserId:', params.producerUserId, 'kind:', params.kind);
const consumer = await state.recvTransport.consume({
id: params.id,
producerId: params.producerId,
kind: params.kind,
rtpParameters: params.rtpParameters,
});
console.log('✅ Consumer created:', consumer.id, 'kind:', consumer.kind);
const userId = params.producerUserId;
if (!userId) {
console.error('❌ No producerUserId in params!', params);
return resolve();
}
// Track this consumer
const consumerKey = `${userId}-${params.kind}`;
state.consumers.set(consumerKey, {
id: consumer.id,
consumer,
kind: params.kind,
userId,
producerId,
});
console.log('Stored consumer with key:', consumerKey);
// Attach track to DOM BEFORE resuming
attachTrack(userId, consumer, params.kind);
// Resume consumer
socket.emit('consumer-resume', { consumerId: consumer.id });
await consumer.resume();
console.log('▶️ Consumer resumed:', consumerKey);
updateParticipantCount();
} catch (err) {
console.error('❌ recvTransport.consume error:', err);
}
resolve();
});
});
}
// ── ATTACH TRACK TO DOM ────────────────────────────────────────────────────────────────
function attachTrack(userId, consumer, kind) {
console.log('attachTrack called - userId:', userId, 'kind:', kind, 'track:', consumer.track);
if (!consumer.track) {
console.error('❌ Consumer has no track!');
return;
}
const stream = new MediaStream([consumer.track]);
if (kind === 'audio') {
console.log('Creating audio element for user:', userId);
// FIX: Remove old audio if exists
const oldAudio = document.getElementById(`audio-${userId}`);
if (oldAudio) oldAudio.remove();
const audio = document.createElement('audio');
audio.id = `audio-${userId}`;
audio.autoplay = true;
audio.playsinline = true;
audio.style.display = 'none';
audio.srcObject = stream;
document.body.appendChild(audio);
console.log('Audio element created and added to DOM:', audio.id);
// FIX: Store reference to prevent garbage collection
state.remoteStreams.set(`audio-${userId}`, audio);
audio.play()
.then(() => {
console.log('✅ Audio playing:', userId);
})
.catch((err) => {
console.warn('⚠️ Audio autoplay blocked:', err.message);
// Try again on user interaction
const playAudio = () => {
audio.play().catch(e => console.error('Failed to play audio:', e));
document.removeEventListener('click', playAudio);
};
document.addEventListener('click', playAudio);
});
return;
}
// VIDEO
console.log('Creating video element for user:', userId);
// FIX: Ensure wrapper exists and stays in DOM
const wrapperId = `video-${userId}-video`;
let wrapper = document.getElementById(wrapperId);
if (!wrapper) {
console.log('Creating new video wrapper:', wrapperId);
wrapper = createVideoTile(userId, false);
wrapper.id = wrapperId;
el.videosContainer.appendChild(wrapper);
console.log('Video wrapper added to DOM');
} else {
console.log('Reusing existing video wrapper:', wrapperId);
}
const video = wrapper.querySelector('video');
const placeholder = wrapper.querySelector('.video-placeholder');
if (!video) {
console.error('❌ No video element found in wrapper!');
return;
}
console.log('Setting video srcObject');
video.srcObject = stream;
// FIX: Store reference to prevent garbage collection
state.remoteStreams.set(`video-${userId}`, video);
// Hide placeholder
if (placeholder) {
placeholder.style.display = 'none';
console.log('Placeholder hidden');
}
// Start playback with proper error handling
console.log('Attempting video play...');
video.play()
.then(() => {
console.log('✅ Video playing, unmuting:', userId);
video.muted = false;
})
.catch((err) => {
console.warn('⚠️ Video autoplay blocked:', err.message);
// Show click-to-play button
if (!wrapper.querySelector('.play-btn')) {
const btn = document.createElement('button');
btn.className = 'play-btn';
btn.textContent = '▶ Cliquez pour voir';
btn.style.cssText = [
'position:absolute', 'top:50%', 'left:50%',
'transform:translate(-50%,-50%)',
'padding:8px 16px', 'background:#4ade80',
'color:#000', 'border:none', 'border-radius:6px',
'cursor:pointer', 'font-weight:bold', 'z-index:10',
].join(';');
btn.onclick = () => {
video.muted = false;
video.play()
.then(() => {
btn.remove();
console.log('✅ Video playing after click');
})
.catch(e => console.error('Failed to play:', e));
};
wrapper.appendChild(btn);
console.log('Play button added');
}
});
}
// ── SOCKET LISTENERS ───────────────────────────────────────────────────────────────────
function setupSocketListeners() {
socket.off('router-capabilities');
socket.off('existing-producers');
socket.off('new-producer');
socket.off('user-disconnected');
socket.on('router-capabilities', async ({ routerRtpCapabilities }) => {
try {
console.log('📡 router-capabilities received');
await loadDevice(routerRtpCapabilities);
const [sendParams, recvParams] = await Promise.all([
new Promise((res, rej) =>
socket.emit('create-send-transport',
p => p.error ? rej(new Error(p.error)) : res(p))),
new Promise((res, rej) =>
socket.emit('create-recv-transport',
p => p.error ? rej(new Error(p.error)) : res(p))),
]);
console.log('📡 Transports params received');
await createSendTransport(sendParams);
await createRecvTransport(recvParams);
await startProducing();
const pending = [...state.pendingProducers];
state.pendingProducers = [];
if (pending.length > 0) {
console.log('🔄 Draining', pending.length, 'pending producer(s)');
for (const id of pending) {
await consumeProducer(id);
}
}
el.toggleAudio.disabled = el.toggleVideo.disabled = false;
setStatus('✅ Connecté à "' + state.roomId + '"', 'success');
} catch (err) {
console.error('❌ Setup error:', err);
setStatus('❌ ' + err.message, 'error');
}
});
socket.on('existing-producers', async (list) => {
console.log('📦 existing-producers:', list.length, 'producer(s)');
for (const { producerId, userId, kind } of list) {
console.log('Existing producer - producerId:', producerId, 'userId:', userId, 'kind:', kind);
await consumeProducer(producerId);
}
});
socket.on('new-producer', async ({ producerId, userId, kind }) => {
console.log('🆕 new-producer - producerId:', producerId, 'userId:', userId, 'kind:', kind);
if (userId === socket.id) {
console.log('Ignoring own producer');
return;
}
await consumeProducer(producerId);
});
socket.on('user-disconnected', (userId) => {
console.log('👋 user-disconnected:', userId);
// Remove video wrapper
const videoWrapper = document.getElementById(`video-${userId}-video`);
if (videoWrapper) {
videoWrapper.remove();
console.log('Video wrapper removed:', userId);
}
// Remove audio element
const audioElement = document.getElementById(`audio-${userId}`);
if (audioElement) {
audioElement.pause();
audioElement.srcObject = null;
audioElement.remove();
console.log('Audio element removed:', userId);
}
// Clean up stream references
state.remoteStreams.delete(`video-${userId}`);
state.remoteStreams.delete(`audio-${userId}`);
// Remove consumers
const keysToDelete = [];
for (const key of state.consumers.keys()) {
if (key.startsWith(`${userId}-`)) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => {
const consumer = state.consumers.get(key);
try {
consumer.consumer?.close();
console.log('Consumer closed:', key);
} catch (e) {
console.error('Error closing consumer:', e);
}
state.consumers.delete(key);
});
updateParticipantCount();
updateVideoLayout();
});
}
// ── LOCAL STREAM ───────────────────────────────────────────────────────────────────────
async function getLocalStream() {
console.log('Getting local stream...');
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true },
video: { width: { ideal: 1280 }, height: { ideal: 720 } },
});
console.log('✅ Local stream obtained');
el.videosContainer.innerHTML = '';
const wrapper = createVideoTile('local', true);
const video = wrapper.querySelector('video');
const placeholder = wrapper.querySelector('.video-placeholder');
video.srcObject = stream;
video.muted = true;
video.play().catch(() => {});
if (placeholder) placeholder.style.display = 'none';
el.videosContainer.appendChild(wrapper);
updateVideoLayout();
return stream;
}
// ── DOM HELPERS ────────────────────────────────────────────────────────────────────────
function createVideoTile(userId, isLocal) {
const wrapper = document.createElement('div');
wrapper.id = `video-${userId}-video`;
wrapper.className = 'video-wrapper ' + (isLocal ? 'local' : 'remote');
const video = document.createElement('video');
video.autoplay = true;
video.playsinline = true;
video.muted = isLocal;
const placeholder = document.createElement('div');
placeholder.className = 'video-placeholder';
placeholder.textContent = isLocal ? '👤' : '👥';
const overlay = document.createElement('div');
overlay.className = 'video-overlay';
overlay.innerHTML = '<span class="user-label">' +
(isLocal ? 'Moi' : 'User ' + userId.slice(-4)) + '</span>';
wrapper.append(video, placeholder, overlay);
return wrapper;
}
function updateVideoLayout() {
const n = el.videosContainer.querySelectorAll('.video-wrapper').length;
el.videosContainer.style.gridTemplateColumns =
n <= 1 ? '1fr' : n <= 4 ? 'repeat(2,1fr)' : 'repeat(3,1fr)';
console.log('Video layout updated, tiles:', n);
}
function updateParticipantCount() {
const users = new Set([...state.consumers.values()].map(c => c.userId));
const n = users.size + 1;
el.roomInfo.textContent = '👥 ' + n + ' participant' + (n > 1 ? 's' : '');
}
function setStatus(msg, type) {
el.status.textContent = msg;
el.status.className = 'status ' + (type || 'info');
}
function resetUI() {
el.joinBtn.disabled = el.roomInput.disabled = false;
el.leaveBtn.disabled = true;
}
// ── LEAVE ──────────────────────────────────────────────────────────────────────────────
function leaveRoom() {
console.log('Leaving room...');
state.localStream?.getTracks().forEach(t => t.stop());
state.producers.audio?.close();
state.producers.video?.close();
for (const { consumer } of state.consumers.values()) {
try {
consumer?.close();
} catch (e) {
console.error('Error closing consumer:', e);
}
}
state.sendTransport?.close();
state.recvTransport?.close();
// Clean up all remote streams
for (const stream of state.remoteStreams.values()) {
if (stream instanceof HTMLAudioElement || stream instanceof HTMLVideoElement) {
stream.pause();
stream.srcObject = null;
stream.remove();
}
}
state.remoteStreams.clear();
state.roomId = state.device = state.sendTransport = state.recvTransport =
state.localStream = null;
state.producers = { audio: null, video: null };
state.consumers.clear();
state.pendingProducers = [];
el.videosContainer.innerHTML = '';
el.toggleAudio.disabled = el.toggleVideo.disabled = true;
el.toggleAudio.textContent = '🔊 Audio ON';
el.toggleVideo.textContent = '📹 Vidéo ON';
el.toggleAudio.classList.remove('muted');
el.toggleVideo.classList.remove('muted');
el.roomInfo.textContent = '';
resetUI();
setStatus('🔴 Déconnecté', 'info');
}
// ── INIT ───────────────────────────────────────────────────────────────────────────────
function init() {
el.joinBtn = document.getElementById('joinBtn');
el.leaveBtn = document.getElementById('leaveBtn');
el.roomInput = document.getElementById('roomInput');
el.toggleAudio = document.getElementById('toggleAudio');
el.toggleVideo = document.getElementById('toggleVideo');
el.videosContainer = document.getElementById('videosContainer');
el.status = document.getElementById('status');
el.roomInfo = document.getElementById('roomInfo');
el.joinBtn.addEventListener('click', joinRoom);
el.leaveBtn.addEventListener('click', () => { socket.emit('leave-room'); leaveRoom(); });
el.toggleAudio.addEventListener('click', () => {
const t = state.localStream?.getAudioTracks()[0];
if (!t) return;
t.enabled = !t.enabled;
el.toggleAudio.textContent = '🔊 Audio ' + (t.enabled ? 'ON' : 'OFF');
el.toggleAudio.classList.toggle('muted', !t.enabled);
});
el.toggleVideo.addEventListener('click', () => {
const t = state.localStream?.getVideoTracks()[0];
if (!t) return;
t.enabled = !t.enabled;
el.toggleVideo.textContent = '📹 Vidéo ' + (t.enabled ? 'ON' : 'OFF');
el.toggleVideo.classList.toggle('muted', !t.enabled);
});
socket.on('connect', () => setStatus('🔌 Connecté — cliquez "Rejoindre"', 'success'));
socket.on('disconnect', () => {
if (state.roomId) leaveRoom();
setStatus('🔴 Déconnecté du serveur', 'error');
});
setStatus('🔌 Connexion…', 'info');
}
document.addEventListener('DOMContentLoaded', init);
================================================
FILE: public/index.html
================================================
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Powered by hervinism</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>🎥 Lets Meet</h1>
<div class="room-controls">
<input type="text" id="roomInput" placeholder="Nom de la salle" value="room1">
<button id="joinBtn">▶️ Rejoindre</button>
<button id="leaveBtn" disabled>⏹️ Quitter</button>
<div id="roomInfo"></div>
</div>
</header>
<div id="status" class="status info">🔌 Connecté au serveur - Cliquez sur "Rejoindre"</div>
<div id="videosContainer" class="videos"></div>
<div class="local-controls">
<button id="toggleAudio" disabled>🔊 Audio ON</button>
<button id="toggleVideo" disabled>📹 Vidéo ON</button>
</div>
</div>
<!-- Socket.IO -->
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<!-- mediasoup-client: built by esbuild at deploy time, served as static file -->
<!-- exposes window.mediasoupClient globally -->
<script src="/mediasoup-client.js"></script>
<script src="client.js"></script>
</body>
</html>
================================================
FILE: public/style.css
================================================
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #fff;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1600px;
margin: 0 auto;
}
/* === HEADER === */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: rgba(255,255,255,0.1);
border-radius: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 15px;
/* Vendor prefixes pour backdrop-filter */
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
header h1 {
font-size: 1.5rem;
background: linear-gradient(90deg, #4ade80, #60a5fa);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent; /* Fallback */
}
.room-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.room-controls input {
padding: 10px 15px;
border: none;
border-radius: 8px;
background: rgba(255,255,255,0.9);
font-size: 1rem;
min-width: 150px;
}
.room-controls button {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
#joinBtn {
background: linear-gradient(135deg, #4ade80, #22c55e);
color: #000;
}
#joinBtn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(74, 222, 128, 0.4);
}
#joinBtn:disabled {
background: #666;
cursor: not-allowed;
transform: none;
}
#leaveBtn {
background: linear-gradient(135deg, #f87171, #ef4444);
color: #fff;
}
#leaveBtn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(248, 113, 113, 0.4);
}
#leaveBtn:disabled {
background: #666;
cursor: not-allowed;
transform: none;
}
#roomInfo {
background: rgba(255,255,255,0.1);
padding: 8px 16px;
border-radius: 20px;
font-size: 0.9rem;
}
/* === STATUS === */
.status {
padding: 10px 15px;
background: rgba(255,255,255,0.1);
border-radius: 8px;
margin-bottom: 20px;
text-align: center;
font-size: 0.9rem;
transition: all 0.3s;
}
.status.error {
background: rgba(248, 113, 113, 0.3);
color: #fecaca;
}
.status.success {
background: rgba(74, 222, 128, 0.3);
color: #bbf7d0;
}
.status.info {
background: rgba(96, 165, 250, 0.3);
color: #bfdbfe;
}
/* === GRILLE VIDÉO === */
.videos {
display: grid;
grid-template-columns: repeat(3, minmax(280px, 1fr));
gap: 15px;
margin-bottom: 20px;
transition: grid-template-columns 0.3s ease;
}
/* === WRAPPER VIDÉO === */
.video-wrapper {
background: #000;
border-radius: 12px;
overflow: hidden;
position: relative;
aspect-ratio: 16/9;
border: 2px solid rgba(255,255,255,0.2);
transition: all 0.3s ease;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
.video-wrapper:hover {
border-color: rgba(255,255,255,0.5);
transform: scale(1.02);
box-shadow: 0 8px 16px rgba(0,0,0,0.4);
}
.video-wrapper.local {
border-color: #4ade80;
}
.video-wrapper.active-speaker {
border-color: #60a5fa;
border-width: 3px;
box-shadow: 0 0 20px rgba(96, 165, 250, 0.5);
transform: scale(1.05);
z-index: 10;
}
.video-wrapper.speaking {
border-color: #4ade80;
box-shadow: 0 0 15px rgba(74, 222, 128, 0.6);
}
.video-wrapper.hidden {
display: none;
}
/* === VIDÉO === */
.video-wrapper video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* === PLACEHOLDER === */
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2937, #374151);
color: #9ca3af;
font-size: 3rem;
position: absolute;
top: 0;
left: 0;
}
/* === OVERLAY === */
.video-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px;
background: linear-gradient(transparent, rgba(0,0,0,0.8));
display: flex;
align-items: flex-end;
gap: 10px;
opacity: 0;
transition: opacity 0.3s;
}
.video-wrapper:hover .video-overlay {
opacity: 1;
}
.user-label {
background: rgba(0,0,0,0.7);
padding: 5px 10px;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
}
/* === INDICATEUR DE PAROLE === */
.speaking-indicator {
width: 20px;
height: 20px;
background: #4ade80;
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s;
box-shadow: 0 0 10px #4ade80;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.2); opacity: 0.7; }
}
/* === STATUS MICRO === */
.mic-status {
background: rgba(0,0,0,0.7);
padding: 5px 8px;
border-radius: 6px;
font-size: 0.8rem;
opacity: 0;
transition: opacity 0.3s;
}
.mic-status[style*="opacity: 1"] {
opacity: 1 !important;
}
/* === CONTRÔLES LOCAUX === */
.local-controls {
display: flex;
gap: 10px;
justify-content: center;
padding: 15px;
background: rgba(255,255,255,0.1);
border-radius: 12px;
/* Vendor prefixes pour backdrop-filter */
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
.local-controls button {
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
transition: all 0.2s;
}
.local-controls button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.local-controls button:disabled {
background: #666;
cursor: not-allowed;
opacity: 0.6;
transform: none;
box-shadow: none;
}
.local-controls button.muted {
background: linear-gradient(135deg, #f87171, #ef4444);
}
/* === RESPONSIVE === */
@media (max-width: 1200px) {
.videos { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
header { flex-direction: column; text-align: center; }
.videos { grid-template-columns: 1fr; }
.local-controls { flex-wrap: wrap; }
.local-controls button { flex: 1; min-width: 120px; }
}
/* === FULLSCREEN === */
.video-wrapper:fullscreen {
background: #000;
border-radius: 0;
}
.video-wrapper:fullscreen video {
width: 100vw;
height: 100vh;
object-fit: contain;
}
/* === SCROLLBAR === */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255,255,255,0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.5);
}
================================================
FILE: render.yaml
================================================
services:
- type: web
name: video-app-learning
env: node
plan: free
buildCommand: npm run render-build
startCommand: npm start
autoDeploy: true
envVars:
- key: NODE_ENV
value: production
- key: MEDIASOUP_LISTEN_IP
value: 0.0.0.0
- key: PORT
value: 3000
- key: METERED_API_KEY
value: 35bc0752073676aafcc58f20471787c3a1ab
- key: MEDIASOUP_ANNOUNCED_IP
value: AUTO # Will be auto-detected by the server
================================================
FILE: render_build.sh
================================================
#!/usr/bin/env bash
set -e
echo "📦 Installing dependencies..."
npm install
echo "🔨 Building mediasoup-client bundle..."
npm run build
echo "✅ Build complete"
================================================
FILE: server/index.js
================================================
// server/index.js
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const express = require('express');
const http = require('http');
const path = require('path');
const fs = require('fs');
const { Server } = require('socket.io');
const mediasoup = require('mediasoup');
const { getOrCreateRoom, getRoom, addPeer, removePeer } = require('./room');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: { origin: '*', methods: ['GET', 'POST'], credentials: true },
transports: ['websocket', 'polling'],
});
// ── STATIC FILES ───────────────────────────────────────────────────────────
app.use(express.static(path.join(__dirname, '..', 'public')));
app.get('/mediasoup-client.js', (req, res) => {
const p = path.join(__dirname, '..', 'public', 'mediasoup-client.js');
fs.existsSync(p)
? res.sendFile(p)
: res.status(404).send('// Run "npm run build" first');
});
// ── TURN CREDENTIALS (Metered.ca, cached 1 h) ─────────────────────────────────
let cachedIce = null, iceFetchedAt = 0;
async function getIceServers() {
const now = Date.now();
if (cachedIce && now - iceFetchedAt < 3_600_000) return cachedIce;
try {
const r = await fetch(
'https://lenoir-jules.metered.live/api/v1/turn/credentials?apiKey=' +
process.env.METERED_API_KEY
);
cachedIce = await r.json();
iceFetchedAt = now;
console.log('✅ TURN credentials refreshed, servers:', cachedIce.length);
return cachedIce;
} catch (err) {
console.error('⚠️ TURN fetch failed:', err.message);
return [{ urls: 'stun:stun.l.google.com:19302' }];
}
}
// ── MEDIASOUP TRANSPORT OPTIONS ───────────────────────────────────────────────
// FIX: Proper configuration for WebRTC media transport
function makeTransportOptions() {
const announcedIp = process.env.MEDIASOUP_ANNOUNCED_IP;
return {
listenIps: [
{
ip: '0.0.0.0',
announcedIp: announcedIp || undefined,
}
],
enableUdp: false, // FIX: Disable UDP for Render (blocked anyway)
enableTcp: true,
preferTcp: true,
initialAvailableOutgoingBitrate: 1_000_000,
maxIncomingBitrate: 1_500_000,
};
}
// ── MEDIASOUP WORKER ─────────────────────────────────────────────────────────
let worker;
async function initMediasoup() {
worker = await mediasoup.createWorker({
logLevel: 'warn',
rtcMinPort: 40000,
rtcMaxPort: 49999,
});
worker.on('died', () => {
console.error('❌ mediasoup worker died');
process.exit(1);
});
console.log('✅ mediasoup worker ready');
}
// ── CLEANUP HELPER ──────────────────────────────────────────────────────────
function closePeer(roomId, socketId) {
const room = getRoom(roomId);
const peer = room?.peers.get(socketId);
if (!peer) return;
for (const p of peer.producers.values()) {
try { p.close(); } catch (_) {}
}
for (const c of peer.consumers.values()) {
try { c.close(); } catch (_) {}
}
try { peer.sendTransport?.close(); } catch (_) {}
try { peer.recvTransport?.close(); } catch (_) {}
}
// ── SOCKET HANDLERS ──────────────────────────────────────────────────────────
io.on('connection', (socket) => {
console.log('🔌 connected:', socket.id);
let roomId = null;
// JOIN
socket.on('join-room', async (rid, userData, cb) => {
try {
const room = await getOrCreateRoom(worker, rid);
roomId = rid;
socket.join(rid);
addPeer(rid, socket.id, {
id: socket.id,
userData,
sendTransport: null,
recvTransport: null,
producers: new Map(),
consumers: new Map(),
});
console.log(`✅ Peer ${socket.id} joined room ${rid}`);
socket.emit('router-capabilities', {
routerRtpCapabilities: room.router.rtpCapabilities,
});
// Tell new peer about everyone already in the room
const existing = [];
for (const [pid, peer] of room.peers) {
if (pid === socket.id) continue;
for (const [producerId, producer] of peer.producers)
existing.push({
producerId,
userId: pid,
kind: producer.kind
});
}
if (existing.length) {
console.log(`📢 Sending ${existing.length} existing producers to ${socket.id}`);
socket.emit('existing-producers', existing);
}
socket.to(rid).emit('user-joined', { userId: socket.id, userData });
console.log(`📍 Room ${rid} now has ${room.peers.size} peers`);
cb?.({ success: true });
} catch (err) {
console.error('❌ join-room error:', err);
cb?.({ error: err.message });
}
});
// CREATE SEND TRANSPORT
socket.on('create-send-transport', async (cb) => {
try {
if (!roomId) return cb({ error: 'Not in a room' });
const room = getRoom(roomId);
if (!room) return cb({ error: 'Room not found' });
const iceServers = await getIceServers();
const options = makeTransportOptions();
console.log(`Creating sendTransport for ${socket.id} with announcedIp: ${process.env.MEDIASOUP_ANNOUNCED_IP}`);
const t = await room.router.createWebRtcTransport(options);
const peer = room.peers.get(socket.id);
if (peer) peer.sendTransport = t;
console.log(`✅ Send transport created: ${t.id}`);
cb({
id: t.id,
iceParameters: t.iceParameters,
iceCandidates: t.iceCandidates,
dtlsParameters: t.dtlsParameters,
iceServers
});
} catch (err) {
console.error('❌ create-send-transport error:', err);
cb({ error: err.message });
}
});
// CREATE RECV TRANSPORT
socket.on('create-recv-transport', async (cb) => {
try {
if (!roomId) return cb({ error: 'Not in a room' });
const room = getRoom(roomId);
if (!room) return cb({ error: 'Room not found' });
const iceServers = await getIceServers();
const options = makeTransportOptions();
console.log(`Creating recvTransport for ${socket.id} with announcedIp: ${process.env.MEDIASOUP_ANNOUNCED_IP}`);
const t = await room.router.createWebRtcTransport(options);
const peer = room.peers.get(socket.id);
if (peer) peer.recvTransport = t;
console.log(`✅ Recv transport created: ${t.id}`);
cb({
id: t.id,
iceParameters: t.iceParameters,
iceCandidates: t.iceCandidates,
dtlsParameters: t.dtlsParameters,
iceServers
});
} catch (err) {
console.error('❌ create-recv-transport error:', err);
cb({ error: err.message });
}
});
// TRANSPORT CONNECT
socket.on('transport-connect', async ({ transportId, dtlsParameters }, cb) => {
try {
const peer = getRoom(roomId)?.peers.get(socket.id);
if (!peer) return cb?.({ error: 'Peer not found' });
const t = peer.sendTransport?.id === transportId
? peer.sendTransport
: peer.recvTransport?.id === transportId
? peer.recvTransport
: null;
if (!t) return cb?.({ error: 'Transport not found' });
console.log(`🔗 Connecting transport ${transportId}`);
await t.connect({ dtlsParameters });
console.log(`✅ Transport ${transportId} connected`);
cb?.({});
} catch (err) {
console.error('❌ transport-connect error:', err);
cb?.({ error: err.message });
}
});
// PRODUCE
socket.on('produce', async ({ kind, rtpParameters, appData }, cb) => {
try {
const peer = getRoom(roomId)?.peers.get(socket.id);
if (!peer?.sendTransport) return cb({ error: 'no send transport' });
const producer = await peer.sendTransport.produce({
kind,
rtpParameters,
appData
});
peer.producers.set(producer.id, producer);
console.log(`🎬 Producer created: ${producer.id} (${kind}) by ${socket.id}`);
// Notify others
socket.to(roomId).emit('new-producer', {
producerId: producer.id,
userId: socket.id,
kind
});
cb({ id: producer.id });
} catch (err) {
console.error('❌ produce error:', err);
cb({ error: err.message });
}
});
// CONSUME
socket.on('consume', async ({ producerId, rtpCapabilities }, cb) => {
try {
const room = getRoom(roomId);
if (!room) return cb({ error: 'Room not found' });
const peer = room.peers.get(socket.id);
if (!peer?.recvTransport) return cb({ error: 'no recv transport' });
if (!room.router.canConsume({ producerId, rtpCapabilities })) {
return cb({ error: 'cannot consume' });
}
const consumer = await peer.recvTransport.consume({
producerId,
rtpCapabilities,
paused: true,
});
peer.consumers.set(consumer.id, consumer);
// Find producer owner
let producerUserId = null;
for (const [uid, p] of room.peers) {
if (p.producers.has(producerId)) {
producerUserId = uid;
break;
}
}
console.log(`🍽 Consumer created: ${consumer.id} (${consumer.kind}) for ${socket.id} from ${producerUserId}`);
cb({
id: consumer.id,
producerId,
kind: consumer.kind,
rtpParameters: consumer.rtpParameters,
producerUserId
});
} catch (err) {
console.error('❌ consume error:', err);
cb({ error: err.message });
}
});
// CONSUMER RESUME
socket.on('consumer-resume', async ({ consumerId }) => {
try {
const peer = getRoom(roomId)?.peers.get(socket.id);
const consumer = peer?.consumers.get(consumerId);
if (!consumer) {
console.warn('⚠️ consumer-resume: consumer not found', consumerId);
return;
}
await consumer.resume();
console.log(`▶️ Consumer resumed: ${consumerId}`);
} catch (err) {
console.error('❌ consumer-resume error:', err);
}
});
// LEAVE
socket.on('leave-room', () => {
if (!roomId) return;
console.log(`👋 ${socket.id} leaving room ${roomId}`);
closePeer(roomId, socket.id);
socket.to(roomId).emit('user-disconnected', socket.id);
socket.leave(roomId);
removePeer(roomId, socket.id);
roomId = null;
});
// DISCONNECT
socket.on('disconnect', () => {
console.log('🔌 disconnected:', socket.id);
if (!roomId) return;
closePeer(roomId, socket.id);
socket.to(roomId).emit('user-disconnected', socket.id);
removePeer(roomId, socket.id);
});
});
// ── AUTO-DETECT PUBLIC IP ─────────────────────────────────────────────────────
async function detectPublicIp() {
if (process.env.MEDIASOUP_ANNOUNCED_IP) {
console.log('🌐 announced IP (from env):', process.env.MEDIASOUP_ANNOUNCED_IP);
return;
}
try {
const r = await fetch('https://api.ipify.org?format=json');
const { ip } = await r.json();
process.env.MEDIASOUP_ANNOUNCED_IP = ip;
console.log('🌐 announced IP (auto-detected):', ip);
} catch (err) {
console.error('⚠️ could not detect public IP:', err.message);
console.log('⚠️ Please set MEDIASOUP_ANNOUNCED_IP environment variable');
}
}
// ── START ─────────────────────────────────────────────────────────────────────
async function start() {
try {
await detectPublicIp();
await initMediasoup();
await getIceServers();
const PORT = process.env.PORT || 3000;
server.listen(PORT, '0.0.0.0', () => {
console.log('\n🚀 Server started successfully!');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📊 Configuration:');
console.log(' Port:', PORT);
console.log(' Announced IP:', process.env.MEDIASOUP_ANNOUNCED_IP || 'NOT SET ⚠️');
console.log(' Node ENV:', process.env.NODE_ENV || 'development');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
});
} catch (err) {
console.error('❌ Failed to start server:', err);
process.exit(1);
}
}
start();
================================================
FILE: server/mediasoup.js
================================================
const mediasoup = require('mediasoup');
let worker;
exports.createWorker = async () => {
worker = await mediasoup.createWorker({
logLevel: 'warn',
rtcMinPort: 40000,
rtcMaxPort: 49999,
});
worker.on('died', () => {
console.error('❌ Mediasoup worker died, exiting in 2s…');
setTimeout(() => process.exit(1), 2000);
});
return worker;
};
exports.createRouter = async (worker) => {
return await worker.createRouter({
mediaCodecs: [
{
kind: 'audio',
mimeType: 'audio/opus',
clockRate: 48000,
channels: 2
},
{
kind: 'video',
mimeType: 'video/VP8',
clockRate: 90000,
parameters: { 'x-google-start-bitrate': 1000 }
}
]
});
};
exports.getWorker = () => worker;
================================================
FILE: server/room.js
================================================
const { createRouter } = require('./mediasoup');
const rooms = new Map(); // roomId -> { router, peers: Map }
exports.getOrCreateRoom = async (worker, roomId) => {
if (!rooms.has(roomId)) {
const router = await createRouter(worker);
rooms.set(roomId, { router, peers: new Map() });
console.log(`✅ Room created: ${roomId}`);
}
return rooms.get(roomId);
};
// Returns the room object or undefined
exports.getRoom = (roomId) => rooms.get(roomId);
exports.addPeer = (roomId, peerId, peerData) => {
const room = rooms.get(roomId);
if (room) room.peers.set(peerId, peerData);
};
exports.removePeer = (roomId, peerId) => {
const room = rooms.get(roomId);
if (room) room.peers.delete(peerId);
};
exports.getPeers = (roomId) => {
const room = rooms.get(roomId);
return room ? Array.from(room.peers.keys()) : [];
};
gitextract_zboip5dy/
├── .dockerignore
├── .gitignore
├── Dockerfile
├── README.md
├── fly.toml
├── package.json
├── public/
│ ├── client.js
│ ├── index.html
│ └── style.css
├── render.yaml
├── render_build.sh
└── server/
├── index.js
├── mediasoup.js
└── room.js
SYMBOL INDEX (23 symbols across 2 files)
FILE: public/client.js
function joinRoom (line 27) | async function joinRoom() {
function loadDevice (line 51) | async function loadDevice(routerRtpCapabilities) {
function transportOptions (line 58) | function transportOptions(params) {
function createSendTransport (line 70) | async function createSendTransport(params) {
function createRecvTransport (line 91) | async function createRecvTransport(params) {
function startProducing (line 107) | async function startProducing() {
function consumeProducer (line 133) | async function consumeProducer(producerId) {
function attachTrack (line 204) | function attachTrack(userId, consumer, kind) {
function setupSocketListeners (line 326) | function setupSocketListeners() {
function getLocalStream (line 436) | async function getLocalStream() {
function createVideoTile (line 461) | function createVideoTile(userId, isLocal) {
function updateVideoLayout (line 484) | function updateVideoLayout() {
function updateParticipantCount (line 491) | function updateParticipantCount() {
function setStatus (line 497) | function setStatus(msg, type) {
function resetUI (line 502) | function resetUI() {
function leaveRoom (line 508) | function leaveRoom() {
function init (line 554) | function init() {
FILE: server/index.js
function getIceServers (line 31) | async function getIceServers() {
function makeTransportOptions (line 51) | function makeTransportOptions() {
function initMediasoup (line 71) | async function initMediasoup() {
function closePeer (line 85) | function closePeer(roomId, socketId) {
function detectPublicIp (line 362) | async function detectPublicIp() {
function start (line 379) | async function start() {
Condensed preview — 14 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (53K chars).
[
{
"path": ".dockerignore",
"chars": 170,
"preview": "@'\nnode_modules\nnpm-debug.log\n.git\n.gitignore\n.env\n.env.local\n.DS_Store\nREADME.md\nbuild\ndist\n.vscode\n.idea\n'@ | Out-File"
},
{
"path": ".gitignore",
"chars": 13,
"preview": "node_modules/"
},
{
"path": "Dockerfile",
"chars": 530,
"preview": "# Use official Node.js image\nFROM node:18-slim\n\n# Set working directory\nWORKDIR /app\n\n# Copy package files\nCOPY package*"
},
{
"path": "fly.toml",
"chars": 777,
"preview": "app = \"video-app-learning\"\nprimary_region = \"sjc\"\n\n[build]\n image = \"video-app-learning:deployment-xxxxx\"\n\n[env]\n PORT"
},
{
"path": "package.json",
"chars": 739,
"preview": "{\n \"name\": \"video-app-learning\",\n \"version\": \"1.0.0\",\n \"description\": \"WebRTC video conferencing with Mediasoup\",\n \""
},
{
"path": "public/client.js",
"chars": 21215,
"preview": "// public/client.js\n// mediasoup-client bundled by esbuild → window.mediasoupClient\n\nif (!window.mediasoupClient) {\n "
},
{
"path": "public/index.html",
"chars": 1353,
"preview": "<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width"
},
{
"path": "public/style.css",
"chars": 6573,
"preview": "* { box-sizing: border-box; margin: 0; padding: 0; }\n\nbody {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI"
},
{
"path": "render.yaml",
"chars": 500,
"preview": "services:\n - type: web\n name: video-app-learning\n env: node\n plan: free\n buildCommand: npm run render-build"
},
{
"path": "render_build.sh",
"chars": 157,
"preview": "#!/usr/bin/env bash\nset -e\necho \"📦 Installing dependencies...\"\nnpm install\necho \"🔨 Building mediasoup-client bundle...\"\n"
},
{
"path": "server/index.js",
"chars": 13911,
"preview": "// server/index.js\nrequire('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });\nconst express = r"
},
{
"path": "server/mediasoup.js",
"chars": 931,
"preview": "const mediasoup = require('mediasoup');\n\nlet worker;\n\nexports.createWorker = async () => {\n worker = await mediasoup."
},
{
"path": "server/room.js",
"chars": 871,
"preview": "const { createRouter } = require('./mediasoup');\n\nconst rooms = new Map(); // roomId -> { router, peers: Map }\n\nexports."
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the hervino-cell/video-app GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 14 files (46.6 KB), approximately 11.9k tokens, and a symbol index with 23 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.