RemiFabre commited on
Commit Β·
e278217
1
Parent(s): e4a2df9
Add camera picker dropdown to controls row
Browse filesAfter getUserMedia resolves we enumerateDevices and, if there's more
than one video input, expose a dropdown next to the mirror toggle.
Selecting a different camera stops the current stream and restarts
with deviceId: { exact: ... }. The tracking loop reads from the same
<video> element so the swap is transparent.
Hidden when there's only one camera (typical laptop case).
- index.html +7 -0
- main.js +58 -7
- style.css +17 -0
index.html
CHANGED
|
@@ -93,6 +93,13 @@
|
|
| 93 |
<input type="checkbox" id="toggleMirror" checked />
|
| 94 |
<span>Mirror mode</span>
|
| 95 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
<button class="btn btn-secondary" id="btnReset">Reset tuner</button>
|
| 97 |
<button class="btn btn-danger" id="btnStop">Stop</button>
|
| 98 |
</div>
|
|
|
|
| 93 |
<input type="checkbox" id="toggleMirror" checked />
|
| 94 |
<span>Mirror mode</span>
|
| 95 |
</label>
|
| 96 |
+
<!-- Hidden until camera permission resolves and we
|
| 97 |
+
enumerate at least 2 video devices; for a
|
| 98 |
+
single-camera laptop we don't bother showing it. -->
|
| 99 |
+
<label class="camera-select hidden" id="cameraSelectWrap">
|
| 100 |
+
<span>Camera</span>
|
| 101 |
+
<select id="cameraSelect"></select>
|
| 102 |
+
</label>
|
| 103 |
<button class="btn btn-secondary" id="btnReset">Reset tuner</button>
|
| 104 |
<button class="btn btn-danger" id="btnStop">Stop</button>
|
| 105 |
</div>
|
main.js
CHANGED
|
@@ -402,15 +402,18 @@ function setPickerHeader(text) {
|
|
| 402 |
}
|
| 403 |
|
| 404 |
// βββ Webcam ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 405 |
-
//
|
| 406 |
-
//
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
| 408 |
if (cameraStream) return cameraStream;
|
|
|
|
|
|
|
|
|
|
| 409 |
try {
|
| 410 |
-
cameraStream = await navigator.mediaDevices.getUserMedia({
|
| 411 |
-
video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 480 } },
|
| 412 |
-
audio: false,
|
| 413 |
-
});
|
| 414 |
} catch (e) {
|
| 415 |
console.error("getUserMedia failed:", e);
|
| 416 |
alert("Camera permission denied β Mime Bot needs the camera to track your face.");
|
|
@@ -424,9 +427,52 @@ async function startCamera() {
|
|
| 424 |
v.srcObject = cameraStream;
|
| 425 |
await v.play().catch(() => {});
|
| 426 |
}
|
|
|
|
| 427 |
return cameraStream;
|
| 428 |
}
|
| 429 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
function stopCamera() {
|
| 431 |
if (cameraStream) {
|
| 432 |
for (const t of cameraStream.getTracks()) t.stop();
|
|
@@ -1157,6 +1203,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
| 1157 |
if (!isMaster()) sendNeutral();
|
| 1158 |
});
|
| 1159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1160 |
// +Add mapping: appends a neutral rule to the channel.
|
| 1161 |
document.querySelectorAll(".btn-add[data-channel]").forEach((btn) => {
|
| 1162 |
btn.addEventListener("click", () => {
|
|
|
|
| 402 |
}
|
| 403 |
|
| 404 |
// βββ Webcam ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 405 |
+
// First call: no deviceId β we ask for `facingMode: "user"` to get
|
| 406 |
+
// the selfie cam on phones and fall back to whatever the browser
|
| 407 |
+
// chooses on laptops. Once permission resolves we can `enumerateDevices`
|
| 408 |
+
// for the camera-picker dropdown; on subsequent calls (camera switch)
|
| 409 |
+
// we pass an explicit deviceId.
|
| 410 |
+
async function startCamera(deviceId = null) {
|
| 411 |
if (cameraStream) return cameraStream;
|
| 412 |
+
const video = deviceId
|
| 413 |
+
? { deviceId: { exact: deviceId }, width: { ideal: 640 }, height: { ideal: 480 } }
|
| 414 |
+
: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 480 } };
|
| 415 |
try {
|
| 416 |
+
cameraStream = await navigator.mediaDevices.getUserMedia({ video, audio: false });
|
|
|
|
|
|
|
|
|
|
| 417 |
} catch (e) {
|
| 418 |
console.error("getUserMedia failed:", e);
|
| 419 |
alert("Camera permission denied β Mime Bot needs the camera to track your face.");
|
|
|
|
| 427 |
v.srcObject = cameraStream;
|
| 428 |
await v.play().catch(() => {});
|
| 429 |
}
|
| 430 |
+
populateCameraSelect();
|
| 431 |
return cameraStream;
|
| 432 |
}
|
| 433 |
|
| 434 |
+
// Populate the camera-picker dropdown. Browsers hide device LABELS
|
| 435 |
+
// until camera permission is granted, so this is only meaningful
|
| 436 |
+
// after a successful getUserMedia. If there's only one camera, hide
|
| 437 |
+
// the picker β no point showing a one-option dropdown.
|
| 438 |
+
async function populateCameraSelect() {
|
| 439 |
+
const select = $("cameraSelect");
|
| 440 |
+
const wrap = $("cameraSelectWrap");
|
| 441 |
+
if (!select || !wrap) return;
|
| 442 |
+
let devices;
|
| 443 |
+
try {
|
| 444 |
+
devices = await navigator.mediaDevices.enumerateDevices();
|
| 445 |
+
} catch (e) {
|
| 446 |
+
console.warn("enumerateDevices failed:", e);
|
| 447 |
+
return;
|
| 448 |
+
}
|
| 449 |
+
const cams = devices.filter((d) => d.kind === "videoinput");
|
| 450 |
+
if (cams.length <= 1) {
|
| 451 |
+
wrap.classList.add("hidden");
|
| 452 |
+
return;
|
| 453 |
+
}
|
| 454 |
+
wrap.classList.remove("hidden");
|
| 455 |
+
|
| 456 |
+
const currentId = cameraStream?.getVideoTracks?.()[0]?.getSettings?.().deviceId;
|
| 457 |
+
select.innerHTML = "";
|
| 458 |
+
for (const d of cams) {
|
| 459 |
+
const opt = document.createElement("option");
|
| 460 |
+
opt.value = d.deviceId;
|
| 461 |
+
opt.textContent = d.label || `Camera ${d.deviceId.slice(0, 8)}`;
|
| 462 |
+
if (d.deviceId === currentId) opt.selected = true;
|
| 463 |
+
select.appendChild(opt);
|
| 464 |
+
}
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
// Swap the active camera mid-session. The tracking loop and 20 Hz
|
| 468 |
+
// send loop don't care β they read from the same <video> element,
|
| 469 |
+
// which gets its srcObject reattached transparently.
|
| 470 |
+
async function switchCamera(deviceId) {
|
| 471 |
+
if (!deviceId) return;
|
| 472 |
+
stopCamera();
|
| 473 |
+
await startCamera(deviceId);
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
function stopCamera() {
|
| 477 |
if (cameraStream) {
|
| 478 |
for (const t of cameraStream.getTracks()) t.stop();
|
|
|
|
| 1203 |
if (!isMaster()) sendNeutral();
|
| 1204 |
});
|
| 1205 |
|
| 1206 |
+
// Camera picker β switch to the selected device on change.
|
| 1207 |
+
$("cameraSelect").addEventListener("change", (e) => {
|
| 1208 |
+
switchCamera(e.target.value);
|
| 1209 |
+
});
|
| 1210 |
+
|
| 1211 |
// +Add mapping: appends a neutral rule to the channel.
|
| 1212 |
document.querySelectorAll(".btn-add[data-channel]").forEach((btn) => {
|
| 1213 |
btn.addEventListener("click", () => {
|
style.css
CHANGED
|
@@ -321,6 +321,23 @@ body {
|
|
| 321 |
accent-color: var(--coral);
|
| 322 |
cursor: pointer;
|
| 323 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
/* βββ Sliders (head amplitudes + rule weights) βββββββββββββ */
|
| 326 |
.slider-row {
|
|
|
|
| 321 |
accent-color: var(--coral);
|
| 322 |
cursor: pointer;
|
| 323 |
}
|
| 324 |
+
.camera-select {
|
| 325 |
+
display: flex;
|
| 326 |
+
align-items: center;
|
| 327 |
+
gap: 6px;
|
| 328 |
+
font-size: 0.85em;
|
| 329 |
+
color: var(--text-secondary);
|
| 330 |
+
}
|
| 331 |
+
.camera-select select {
|
| 332 |
+
background: var(--card-light);
|
| 333 |
+
color: var(--text);
|
| 334 |
+
border: 1px solid var(--card-light);
|
| 335 |
+
border-radius: 6px;
|
| 336 |
+
padding: 4px 8px;
|
| 337 |
+
font-size: 0.85em;
|
| 338 |
+
max-width: 200px;
|
| 339 |
+
cursor: pointer;
|
| 340 |
+
}
|
| 341 |
|
| 342 |
/* βββ Sliders (head amplitudes + rule weights) βββββββββββββ */
|
| 343 |
.slider-row {
|