diff --git a/public/app.js b/public/app.js index 8f8ffe0..dbadb98 100644 --- a/public/app.js +++ b/public/app.js @@ -5,6 +5,8 @@ const remoteTemplate = document.querySelector("#remote-template"); const nameForm = document.querySelector("#name-form"); const nameInput = document.querySelector("#name-input"); const statusText = document.querySelector("#status"); +const cameraSelect = document.querySelector("#camera-select"); +const micSelect = document.querySelector("#mic-select"); const cameraButton = document.querySelector("#camera-button"); const micButton = document.querySelector("#mic-button"); const shareButton = document.querySelector("#share-button"); @@ -12,6 +14,8 @@ const leaveButton = document.querySelector("#leave-button"); const roomId = "main"; const nameStorageKey = "kkkmeet.displayName"; +const cameraStorageKey = "kkkmeet.cameraDeviceId"; +const micStorageKey = "kkkmeet.micDeviceId"; const rtcConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }; @@ -42,17 +46,119 @@ function labelForPeer(peer) { return peer?.name || `Participant ${peer?.id?.slice(0, 4) || ""}`.trim(); } +function mediaConstraints() { + const cameraDeviceId = localStorage.getItem(cameraStorageKey); + const micDeviceId = localStorage.getItem(micStorageKey); + + return { + audio: micDeviceId ? { deviceId: { exact: micDeviceId } } : true, + video: { + width: { ideal: 1280 }, + height: { ideal: 720 }, + ...(cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : {}) + } + }; +} + async function ensureLocalStream() { if (localStream) return localStream; - localStream = await navigator.mediaDevices.getUserMedia({ - audio: true, - video: { width: { ideal: 1280 }, height: { ideal: 720 } } - }); + try { + localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints()); + } catch (error) { + localStorage.removeItem(cameraStorageKey); + localStorage.removeItem(micStorageKey); + localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints()); + } localVideo.srcObject = localStream; + await refreshDeviceLists(); return localStream; } +function trackDeviceId(kind) { + return localStream?.getTracks().find((track) => track.kind === kind)?.getSettings().deviceId || ""; +} + +function fillDeviceSelect(select, devices, selectedDeviceId, fallbackLabel) { + select.replaceChildren(); + + if (devices.length === 0) { + const option = new Option(`No ${fallbackLabel.toLowerCase()} found`, ""); + select.add(option); + select.disabled = true; + return; + } + + select.disabled = false; + devices.forEach((device, index) => { + const label = device.label || `${fallbackLabel} ${index + 1}`; + select.add(new Option(label, device.deviceId)); + }); + select.value = selectedDeviceId && devices.some((device) => device.deviceId === selectedDeviceId) + ? selectedDeviceId + : devices[0].deviceId; +} + +async function refreshDeviceLists() { + if (!navigator.mediaDevices?.enumerateDevices) return; + + const devices = await navigator.mediaDevices.enumerateDevices(); + fillDeviceSelect( + cameraSelect, + devices.filter((device) => device.kind === "videoinput"), + trackDeviceId("video") || localStorage.getItem(cameraStorageKey), + "Camera" + ); + fillDeviceSelect( + micSelect, + devices.filter((device) => device.kind === "audioinput"), + trackDeviceId("audio") || localStorage.getItem(micStorageKey), + "Microphone" + ); +} + +async function replaceLocalTrack(kind, deviceId) { + if (!deviceId) return; + + if (!localStream) { + localStorage.setItem(kind === "video" ? cameraStorageKey : micStorageKey, deviceId); + return; + } + + const oldTrack = localStream?.getTracks().find((track) => track.kind === kind); + const wasEnabled = oldTrack?.enabled ?? true; + const constraints = kind === "video" + ? { video: { deviceId: { exact: deviceId }, width: { ideal: 1280 }, height: { ideal: 720 } } } + : { audio: { deviceId: { exact: deviceId } } }; + const stream = await navigator.mediaDevices.getUserMedia(constraints); + const newTrack = stream.getTracks()[0]; + newTrack.enabled = wasEnabled; + + if (!localStream) { + localStream = new MediaStream([newTrack]); + } else { + if (oldTrack) { + localStream.removeTrack(oldTrack); + oldTrack.stop(); + } + localStream.addTrack(newTrack); + } + + if (kind === "audio" || !screenStream) { + for (const { connection } of peers.values()) { + const sender = connection.getSenders().find((item) => item.track?.kind === kind); + await sender?.replaceTrack(newTrack); + } + } + + if (kind === "video" && !screenStream) { + localVideo.srcObject = localStream; + } + + localStorage.setItem(kind === "video" ? cameraStorageKey : micStorageKey, deviceId); + await refreshDeviceLists(); +} + function updateRemoteLabel(id, name) { const label = document.querySelector(`[data-peer-id="${id}"] span`); if (label) label.textContent = name || `Participant ${id.slice(0, 4)}`; @@ -246,6 +352,20 @@ micButton.addEventListener("click", () => { setTrackEnabled("audio", enabled); }); +cameraSelect.addEventListener("change", () => { + replaceLocalTrack("video", cameraSelect.value).catch((error) => { + console.error(error); + setStatus("Could not switch camera."); + }); +}); + +micSelect.addEventListener("change", () => { + replaceLocalTrack("audio", micSelect.value).catch((error) => { + console.error(error); + setStatus("Could not switch microphone."); + }); +}); + shareButton.addEventListener("click", async () => { if (!localStream) return; @@ -265,6 +385,7 @@ shareButton.addEventListener("click", async () => { await sender?.replaceTrack(cameraTrack); } localVideo.srcObject = localStream; + screenStream = undefined; }; }); @@ -279,4 +400,9 @@ if (savedName) { }); } else { nameInput.focus(); + refreshDeviceLists().catch(console.error); } + +navigator.mediaDevices?.addEventListener?.("devicechange", () => { + refreshDeviceLists().catch(console.error); +}); diff --git a/public/index.html b/public/index.html index 3d8f9e8..ed8ad5a 100644 --- a/public/index.html +++ b/public/index.html @@ -26,11 +26,23 @@