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