Add media device selectors

This commit is contained in:
codex
2026-06-09 21:09:12 +02:00
parent 720e947e8c
commit b7444b9253
3 changed files with 187 additions and 11 deletions
+130 -4
View File
@@ -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);
});
+17 -5
View File
@@ -26,11 +26,23 @@
</section>
<footer class="controls">
<div class="control-actions" aria-label="Call controls">
<button id="camera-button" type="button" aria-pressed="true">Camera</button>
<button id="mic-button" type="button" aria-pressed="true">Mic</button>
<button id="share-button" type="button">Share Screen</button>
<button id="leave-button" type="button" class="danger">Leave</button>
<div class="control-panel">
<div class="device-controls" aria-label="Media devices">
<label>
Camera
<select id="camera-select"></select>
</label>
<label>
Microphone
<select id="mic-select"></select>
</label>
</div>
<div class="control-actions" aria-label="Call controls">
<button id="camera-button" type="button" aria-pressed="true">Camera</button>
<button id="mic-button" type="button" aria-pressed="true">Mic</button>
<button id="share-button" type="button">Share Screen</button>
<button id="leave-button" type="button" class="danger">Leave</button>
</div>
</div>
<article class="self-preview">
<video id="local-video" autoplay muted playsinline></video>
+40 -2
View File
@@ -20,7 +20,8 @@ body {
}
button,
input {
input,
select {
font: inherit;
}
@@ -66,7 +67,8 @@ h1 {
gap: 10px;
}
input {
input,
select {
width: min(42vw, 260px);
height: 42px;
border: 1px solid rgba(255, 255, 255, 0.16);
@@ -76,6 +78,11 @@ input {
padding: 0 12px;
}
select {
width: min(36vw, 260px);
cursor: pointer;
}
button {
min-width: 92px;
height: 42px;
@@ -170,6 +177,27 @@ button.danger {
background: rgba(16, 18, 20, 0.92);
}
.control-panel {
display: grid;
gap: 12px;
min-width: 0;
}
.device-controls {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 12px;
}
.device-controls label {
display: grid;
gap: 6px;
color: #aeb7c3;
font-size: 12px;
font-weight: 650;
}
.control-actions {
display: flex;
justify-content: center;
@@ -230,6 +258,10 @@ button.danger {
width: 100%;
}
select {
width: 100%;
}
button {
min-width: 76px;
}
@@ -253,6 +285,12 @@ button.danger {
order: 2;
}
.device-controls {
order: 1;
display: grid;
grid-template-columns: 1fr;
}
.control-actions button {
min-width: calc(50% - 6px);
}