467363d651
Made-with: Cursor
319 lines
12 KiB
JavaScript
319 lines
12 KiB
JavaScript
(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);
|
|
})();
|