RemiFabre commited on
Commit
e278217
Β·
1 Parent(s): e4a2df9

Add camera picker dropdown to controls row

Browse files

After 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).

Files changed (3) hide show
  1. index.html +7 -0
  2. main.js +58 -7
  3. 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
- // Prefer the front-facing ("user") camera on phones. On laptops the
406
- // constraint is ignored and the only camera is used.
407
- async function startCamera() {
 
 
 
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 {