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 = '' + (isLocal ? 'Moi' : 'User ' + userId.slice(-4)) + ''; 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 ================================================ Powered by hervinism

🎥 Lets Meet

🔌 Connecté au serveur - Cliquez sur "Rejoindre"
================================================ 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()) : []; };