(function () { const BASE = '/realtimechat'; const params = new URLSearchParams(location.search); const roomId = params.get('room'); const password = params.get('password') || ''; const socket = io({ path: BASE + '/socket.io' }); let localStream = null; const peers = new Map(); const names = new Map(); // ลดเสียงสะท้อนและเสียงรบกวนจากไมค์ (ใช้ทั้งตอนเริ่มและตอนเปลี่ยนอุปกรณ์) function audioConstraints(deviceId) { const base = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; if (deviceId) base.deviceId = { exact: deviceId }; return base; } const pendingOffers = new Map(); const pendingIceByFrom = new Map(); if (!roomId) { document.getElementById('form-create')?.addEventListener('submit', (e) => { e.preventDefault(); const id = (document.getElementById('room-id').value || '').trim(); const pw = document.getElementById('room-password').value || ''; const nick = (document.getElementById('nick').value || '').trim(); socket.emit('create-room', { roomId: id || undefined, password: pw }, (res) => { if (res && res.ok) { let url = 'room.html?room=' + encodeURIComponent(res.roomId); if (pw) url += '&password=' + encodeURIComponent(pw); if (nick) url += '&nick=' + encodeURIComponent(nick); window.location.href = url; } else alert(res?.error || 'สร้างห้องไม่ได้'); }); }); document.getElementById('form-join')?.addEventListener('submit', (e) => { e.preventDefault(); const id = document.getElementById('join-id').value.trim(); const pw = document.getElementById('join-password').value || ''; const nick = (document.getElementById('nick').value || '').trim(); socket.emit('join-room', { roomId: id, password: pw }, (res) => { if (res && res.ok) { let url = 'room.html?room=' + encodeURIComponent(id); if (pw) url += '&password=' + encodeURIComponent(pw); if (nick) url += '&nick=' + encodeURIComponent(nick); window.location.href = url; } else alert(res?.error || 'เข้าร่วมไม่ได้'); }); }); return; } const nick = params.get('nick') || ''; const videosEl = document.getElementById('videos'); const roomLabel = document.getElementById('room-label'); const chatMessages = document.getElementById('chat-messages'); const chatForm = document.getElementById('chat-form'); const chatInput = document.getElementById('chat-input'); if (roomLabel) roomLabel.textContent = 'ห้อง: ' + roomId; document.getElementById('btn-copy')?.addEventListener('click', () => { const url = location.origin + BASE + '/room.html?room=' + encodeURIComponent(roomId); navigator.clipboard.writeText(url).then(() => alert('คัดลอกลิงก์แล้ว')); }); document.getElementById('btn-leave')?.addEventListener('click', (e) => { e.preventDefault(); location.href = BASE + '/index.html'; }); const btnMute = document.getElementById('btn-mute'); const btnVideo = document.getElementById('btn-video'); const micSelect = document.getElementById('mic-select'); const camSelect = document.getElementById('cam-select'); function updateMuteVideoLabels() { if (!localStream) return; const audioTrack = localStream.getAudioTracks()[0]; const videoTrack = localStream.getVideoTracks()[0]; if (btnMute) btnMute.textContent = audioTrack && !audioTrack.enabled ? 'เปิดไมค์' : 'ปิดไมค์'; if (btnVideo) btnVideo.textContent = videoTrack && !videoTrack.enabled ? 'เปิดกล้อง' : 'ปิดกล้อง'; } btnMute?.addEventListener('click', () => { if (!localStream) return; const t = localStream.getAudioTracks()[0]; if (t) { t.enabled = !t.enabled; updateMuteVideoLabels(); } }); btnVideo?.addEventListener('click', () => { if (!localStream) return; const t = localStream.getVideoTracks()[0]; if (t) { t.enabled = !t.enabled; updateMuteVideoLabels(); } }); async function fillDeviceSelects() { try { const devices = await navigator.mediaDevices.enumerateDevices(); const mics = devices.filter(d => d.kind === 'audioinput'); const cams = devices.filter(d => d.kind === 'videoinput'); const curAudio = localStream?.getAudioTracks()[0]?.getSettings()?.deviceId; const curVideo = localStream?.getVideoTracks()[0]?.getSettings()?.deviceId; if (micSelect) { micSelect.innerHTML = ''; mics.forEach(d => { const opt = document.createElement('option'); opt.value = d.deviceId; opt.textContent = d.label || 'ไมค์ ' + (micSelect.options.length + 1); if (d.deviceId === curAudio) opt.selected = true; micSelect.appendChild(opt); }); } if (camSelect) { camSelect.innerHTML = ''; cams.forEach(d => { const opt = document.createElement('option'); opt.value = d.deviceId; opt.textContent = d.label || 'กล้อง ' + (camSelect.options.length + 1); if (d.deviceId === curVideo) opt.selected = true; camSelect.appendChild(opt); }); } } catch (e) { console.error('enumerateDevices', e); } } async function switchDevice(kind, deviceId) { if (!localStream) return; try { const stream = await navigator.mediaDevices.getUserMedia( kind === 'audio' ? { audio: audioConstraints(deviceId), video: false } : { video: deviceId ? { deviceId: { exact: deviceId } } : true, audio: false } ); const newTrack = stream.getTracks()[0]; const oldTrack = kind === 'audio' ? localStream.getAudioTracks()[0] : localStream.getVideoTracks()[0]; if (oldTrack) { localStream.removeTrack(oldTrack); oldTrack.stop(); } localStream.addTrack(newTrack); peers.forEach((pc) => { const sender = pc.getSenders().find(s => s.track && s.track.kind === kind); if (sender) sender.replaceTrack(newTrack); }); const localWrap = videosEl.querySelector('[data-peer-id="' + socket.id + '"]'); const localVid = localWrap?.querySelector('video'); if (localVid) localVid.srcObject = localStream; updateMuteVideoLabels(); } catch (e) { console.error('switchDevice', e); } } micSelect?.addEventListener('change', () => switchDevice('audio', micSelect.value)); camSelect?.addEventListener('change', () => switchDevice('video', camSelect.value)); function addVideo(id, stream, isLocal) { const vid = document.createElement('video'); vid.autoplay = true; vid.playsInline = true; if (isLocal) { vid.muted = true; vid.volume = 0; } else { vid.muted = false; } if (stream) { vid.srcObject = stream; vid.play().catch(() => {}); } vid.style.border = isLocal ? '3px solid #7aa2f7' : '2px solid #9ece6a'; const wrap = document.createElement('div'); wrap.dataset.peerId = id; wrap.appendChild(vid); const label = names.get(id) || (isLocal ? (nick || 'คุณ') : id.slice(0, 8)); wrap.appendChild(document.createElement('span')).textContent = label; videosEl.appendChild(wrap); return vid; } async function startLocal() { try { localStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints(), video: true }); addVideo(socket.id, localStream, true); await fillDeviceSelects(); updateMuteVideoLabels(); } catch (e) { console.error('getUserMedia', e); } } function createPeer(remoteId) { const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ] }); if (localStream) localStream.getTracks().forEach(t => pc.addTrack(t, localStream)); pc.onicecandidate = (e) => { if (e.candidate) socket.emit('webrtc-ice', { to: remoteId, candidate: e.candidate }); }; pc.ontrack = (e) => { const wrap = videosEl.querySelector('[data-peer-id="' + remoteId + '"]'); const vid = wrap ? wrap.querySelector('video') : null; const stream = e.streams[0]; if (vid) { vid.srcObject = stream; vid.play().catch(() => {}); } else { addVideo(remoteId, stream, false); } }; return pc; } async function handleOffer(from, offer) { let pc = peers.get(from); if (!pc) { pc = createPeer(from); peers.set(from, pc); } await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); socket.emit('webrtc-answer', { to: from, answer: pc.localDescription }); const pending = pendingIceByFrom.get(from); if (pending) { pending.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c))); pendingIceByFrom.delete(from); } } async function handleAnswer(from, answer) { let pc = peers.get(from); if (!pc) return; await pc.setRemoteDescription(new RTCSessionDescription(answer)); const pending = pendingIceByFrom.get(from); if (pending) { pending.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c))); pendingIceByFrom.delete(from); } } socket.on('user-joined', async (data) => { if (data.nickname) names.set(data.id, data.nickname); addVideo(data.id, null, false); const pc = createPeer(data.id); peers.set(data.id, pc); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); if (!localStream) { pendingOffers.set(data.id, offer); return; } socket.emit('webrtc-offer', { to: data.id, offer }); }); socket.on('user-left', (data) => { const pc = peers.get(data.id); if (pc) { pc.close(); peers.delete(data.id); } pendingOffers.delete(data.id); pendingIceByFrom.delete(data.id); names.delete(data.id); const wrap = videosEl.querySelector('[data-peer-id="' + data.id + '"]'); if (wrap) wrap.remove(); }); socket.on('webrtc-offer', async (data) => { if (!localStream) { pendingOffers.set(data.from, data.offer); return; } await handleOffer(data.from, data.offer); }); socket.on('webrtc-answer', (data) => { if (pendingOffers.has(data.from)) pendingOffers.delete(data.from); handleAnswer(data.from, data.answer); }); socket.on('webrtc-ice', (data) => { let pc = peers.get(data.from); if (!pc) { if (!pendingIceByFrom.has(data.from)) pendingIceByFrom.set(data.from, []); pendingIceByFrom.get(data.from).push(data.candidate); return; } pc.addIceCandidate(new RTCIceCandidate(data.candidate)).catch(() => {}); }); socket.on('chat', (data) => { const div = document.createElement('div'); div.className = 'chat-msg'; const label = data.nickname || names.get(data.id) || (data.id === socket.id ? (nick || 'คุณ') : data.id.slice(0, 8)); div.textContent = label + ': ' + (data.text || ''); chatMessages.appendChild(div); chatMessages.scrollTop = chatMessages.scrollHeight; }); chatForm?.addEventListener('submit', (e) => { e.preventDefault(); const t = (chatInput.value || '').trim(); if (t) { socket.emit('chat', t); chatInput.value = ''; } }); async function doJoin() { // เปิดไมค์/กล้องให้พร้อมก่อน แล้วค่อย join ห้อง await startLocal(); socket.emit('join-room', { roomId, password, nickname: nick || undefined }, async (res) => { if (!res || !res.ok) { alert(res?.error || 'ไม่พบห้อง'); location.href = BASE + '/index.html'; return; } names.set(socket.id, nick || 'คุณ'); (res.peers || []).forEach((p) => { names.set(p.id, p.nickname || p.id.slice(0, 8)); }); for (const [from, offer] of pendingOffers) await handleOffer(from, offer); pendingOffers.clear(); }); } if (socket.connected) doJoin(); else socket.once('connect', doJoin); })();