Replace camera switch button with device list dropdown
The camera drawer now offers a select of available video input devices instead of a cycle button. "使用摄像头" starts the chosen camera and changing the dropdown switches the live stream; the list is populated from enumerateDevices (real labels after permission is granted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -78,7 +78,7 @@ const els = {
|
|||||||
cameraPhoto: document.getElementById("camera-photo"),
|
cameraPhoto: document.getElementById("camera-photo"),
|
||||||
cameraCanvas: document.getElementById("camera-canvas"),
|
cameraCanvas: document.getElementById("camera-canvas"),
|
||||||
cameraStartBtn: document.getElementById("camera-start-btn"),
|
cameraStartBtn: document.getElementById("camera-start-btn"),
|
||||||
cameraFlipBtn: document.getElementById("camera-flip-btn"),
|
cameraDeviceSelect: document.getElementById("camera-device-select"),
|
||||||
cameraUpload: document.getElementById("camera-upload"),
|
cameraUpload: document.getElementById("camera-upload"),
|
||||||
cameraSamples: document.getElementById("camera-samples"),
|
cameraSamples: document.getElementById("camera-samples"),
|
||||||
clearBtn: document.getElementById("clear-btn"),
|
clearBtn: document.getElementById("clear-btn"),
|
||||||
@@ -149,7 +149,6 @@ const state = {
|
|||||||
cameraActive: false,
|
cameraActive: false,
|
||||||
cameraFacing: "environment",
|
cameraFacing: "environment",
|
||||||
videoDevices: [],
|
videoDevices: [],
|
||||||
videoDeviceIndex: 0,
|
|
||||||
pendingImage: null,
|
pendingImage: null,
|
||||||
samplesRendered: false,
|
samplesRendered: false,
|
||||||
|
|
||||||
@@ -258,6 +257,9 @@ function syncCameraDrawer(value) {
|
|||||||
els.cameraQuestion.textContent = prompt;
|
els.cameraQuestion.textContent = prompt;
|
||||||
renderSampleThumbnails();
|
renderSampleThumbnails();
|
||||||
selectDefaultImage();
|
selectDefaultImage();
|
||||||
|
refreshVideoDevices().then(() => {
|
||||||
|
if (!state.cameraActive) populateDeviceSelect();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
els.cameraState.textContent = "State -";
|
els.cameraState.textContent = "State -";
|
||||||
els.cameraQuestion.textContent = "";
|
els.cameraQuestion.textContent = "";
|
||||||
@@ -924,6 +926,30 @@ async function refreshVideoDevices() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fill the camera dropdown from the enumerated devices. Labels are only exposed
|
||||||
|
// after camera permission has been granted, so before that we show generic
|
||||||
|
// names ("摄像头 1", …) or just the default option.
|
||||||
|
function populateDeviceSelect(activeDeviceId) {
|
||||||
|
const sel = els.cameraDeviceSelect;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
if (state.videoDevices.length === 0) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "";
|
||||||
|
opt.textContent = "默认摄像头";
|
||||||
|
sel.appendChild(opt);
|
||||||
|
sel.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.videoDevices.forEach((device, index) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = device.deviceId;
|
||||||
|
opt.textContent = device.label || `摄像头 ${index + 1}`;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
sel.disabled = false;
|
||||||
|
if (activeDeviceId) sel.value = activeDeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
async function startCamera(deviceId) {
|
async function startCamera(deviceId) {
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
addWsLog("system", "getUserMedia not available in this browser");
|
addWsLog("system", "getUserMedia not available in this browser");
|
||||||
@@ -954,31 +980,16 @@ async function startCamera(deviceId) {
|
|||||||
els.cameraStartBtn.classList.add("is-active");
|
els.cameraStartBtn.classList.add("is-active");
|
||||||
clearSampleSelection();
|
clearSampleSelection();
|
||||||
|
|
||||||
// Device labels become available only after permission is granted.
|
// Device labels become available only after permission is granted; refresh
|
||||||
|
// the dropdown now and select whichever camera is actually streaming.
|
||||||
await refreshVideoDevices();
|
await refreshVideoDevices();
|
||||||
if (deviceId) {
|
const activeId =
|
||||||
const idx = state.videoDevices.findIndex((d) => d.deviceId === deviceId);
|
state.cameraStream.getVideoTracks?.()[0]?.getSettings?.().deviceId ||
|
||||||
if (idx >= 0) state.videoDeviceIndex = idx;
|
deviceId;
|
||||||
}
|
populateDeviceSelect(activeId);
|
||||||
els.cameraFlipBtn.disabled = state.videoDevices.length < 2;
|
|
||||||
setCameraButtonEnabled();
|
setCameraButtonEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cycle to the next available camera, or flip facingMode when devices are
|
|
||||||
// unlabeled (e.g. before permission, or on mobile with front/back only).
|
|
||||||
async function switchCamera() {
|
|
||||||
if (!state.cameraActive) return;
|
|
||||||
if (state.videoDevices.length > 1) {
|
|
||||||
state.videoDeviceIndex =
|
|
||||||
(state.videoDeviceIndex + 1) % state.videoDevices.length;
|
|
||||||
await startCamera(state.videoDevices[state.videoDeviceIndex].deviceId);
|
|
||||||
} else {
|
|
||||||
state.cameraFacing =
|
|
||||||
state.cameraFacing === "environment" ? "user" : "environment";
|
|
||||||
await startCamera();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopCameraStream() {
|
function stopCameraStream() {
|
||||||
if (state.cameraStream) {
|
if (state.cameraStream) {
|
||||||
state.cameraStream.getTracks().forEach((track) => track.stop());
|
state.cameraStream.getTracks().forEach((track) => track.stop());
|
||||||
@@ -987,7 +998,6 @@ function stopCameraStream() {
|
|||||||
els.cameraVideo.srcObject = null;
|
els.cameraVideo.srcObject = null;
|
||||||
state.cameraActive = false;
|
state.cameraActive = false;
|
||||||
els.cameraStartBtn.classList.remove("is-active");
|
els.cameraStartBtn.classList.remove("is-active");
|
||||||
els.cameraFlipBtn.disabled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureFromCamera() {
|
function captureFromCamera() {
|
||||||
@@ -1456,11 +1466,15 @@ els.cameraDoneBtn.addEventListener("click", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
els.cameraStartBtn.addEventListener("click", () => {
|
els.cameraStartBtn.addEventListener("click", () => {
|
||||||
startCamera();
|
startCamera(els.cameraDeviceSelect.value || undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
els.cameraFlipBtn.addEventListener("click", () => {
|
els.cameraDeviceSelect.addEventListener("change", () => {
|
||||||
switchCamera();
|
// Switching device only restarts the stream when the camera is already live;
|
||||||
|
// otherwise the choice is applied when "使用摄像头" is pressed.
|
||||||
|
if (state.cameraActive) {
|
||||||
|
startCamera(els.cameraDeviceSelect.value || undefined);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
els.cameraUpload.addEventListener("change", (event) => {
|
els.cameraUpload.addEventListener("change", (event) => {
|
||||||
|
|||||||
@@ -116,14 +116,14 @@
|
|||||||
>
|
>
|
||||||
使用摄像头
|
使用摄像头
|
||||||
</button>
|
</button>
|
||||||
<button
|
<select
|
||||||
id="camera-flip-btn"
|
id="camera-device-select"
|
||||||
class="btn btn--ghost camera-drawer__source"
|
class="camera-drawer__select"
|
||||||
type="button"
|
aria-label="选择摄像头"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
切换摄像头
|
<option value="">默认摄像头</option>
|
||||||
</button>
|
</select>
|
||||||
<label
|
<label
|
||||||
class="btn btn--ghost camera-drawer__source camera-drawer__source--upload"
|
class="btn btn--ghost camera-drawer__source camera-drawer__source--upload"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -287,7 +287,27 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Upload spans the full width on its own row below camera + switch. */
|
.camera-drawer__select {
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 7px 28px 7px 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-drawer__select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload spans the full width on its own row below camera + device list. */
|
||||||
.camera-drawer__source--upload {
|
.camera-drawer__source--upload {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user