Add media device selectors
This commit is contained in:
+130
-4
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user