Improve camera drawer UX: layout, device switching, default selection

- Grid layout for source controls: camera + switch on top row (equal
  width), upload full-width below; preview area uses aspect-ratio 4/3
- "使用摄像头" no longer changes label text; uses is-active highlight only
- "切换摄像头" now actually cycles through real video devices via
  enumerateDevices(); falls back to facingMode flip on mobile/single-cam;
  button disabled until camera is active and ≥2 devices are detected
- Default-selects first sample image when drawer opens so "拍摄完成"
  is immediately pressable without requiring a capture/upload first
- Fix: camera prompt text is now locked to CAMERA_STATE_PROMPTS[state]
  and no longer overwritten by assistant replies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Xin Wang
2026-06-02 10:43:18 +08:00
parent 6cf31c7062
commit 85baf5698e
3 changed files with 70 additions and 15 deletions

View File

@@ -148,6 +148,8 @@ const state = {
cameraStream: null, cameraStream: null,
cameraActive: false, cameraActive: false,
cameraFacing: "environment", cameraFacing: "environment",
videoDevices: [],
videoDeviceIndex: 0,
pendingImage: null, pendingImage: null,
samplesRendered: false, samplesRendered: false,
@@ -255,6 +257,7 @@ function syncCameraDrawer(value) {
els.cameraState.textContent = `State ${value}`; els.cameraState.textContent = `State ${value}`;
els.cameraQuestion.textContent = prompt; els.cameraQuestion.textContent = prompt;
renderSampleThumbnails(); renderSampleThumbnails();
selectDefaultImage();
} else { } else {
els.cameraState.textContent = "State -"; els.cameraState.textContent = "State -";
els.cameraQuestion.textContent = ""; els.cameraQuestion.textContent = "";
@@ -912,15 +915,27 @@ function setPendingImage(payload) {
setCameraButtonEnabled(); setCameraButtonEnabled();
} }
async function startCamera() { async function refreshVideoDevices() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
state.videoDevices = devices.filter((d) => d.kind === "videoinput");
} catch (_) {
state.videoDevices = [];
}
}
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");
return; return;
} }
stopCameraStream(); stopCameraStream();
const video = deviceId
? { deviceId: { exact: deviceId } }
: { facingMode: state.cameraFacing };
try { try {
state.cameraStream = await navigator.mediaDevices.getUserMedia({ state.cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: state.cameraFacing }, video,
audio: false, audio: false,
}); });
} catch (err) { } catch (err) {
@@ -937,12 +952,33 @@ async function startCamera() {
state.pendingImage = null; state.pendingImage = null;
setPreviewMode("camera"); setPreviewMode("camera");
els.cameraStartBtn.classList.add("is-active"); els.cameraStartBtn.classList.add("is-active");
els.cameraStartBtn.textContent = "重新拍摄";
els.cameraFlipBtn.hidden = false;
clearSampleSelection(); clearSampleSelection();
// Device labels become available only after permission is granted.
await refreshVideoDevices();
if (deviceId) {
const idx = state.videoDevices.findIndex((d) => d.deviceId === deviceId);
if (idx >= 0) state.videoDeviceIndex = idx;
}
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());
@@ -951,8 +987,7 @@ 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.cameraStartBtn.textContent = "使用摄像头"; els.cameraFlipBtn.disabled = true;
els.cameraFlipBtn.hidden = true;
} }
function captureFromCamera() { function captureFromCamera() {
@@ -1037,6 +1072,16 @@ function resetCameraInput() {
setCameraButtonEnabled(); setCameraButtonEnabled();
} }
// Pre-select the first sample image so "拍摄完成" is immediately pressable when
// the drawer opens, without requiring the user to capture or pick first.
function selectDefaultImage() {
if (state.pendingImage || state.cameraActive) return;
const first = els.cameraSamples.querySelector(".camera-drawer__sample");
if (first && SAMPLE_IMAGES[0]) {
selectSampleImage(SAMPLE_IMAGES[0].src, first);
}
}
function sendImage(payload, text) { function sendImage(payload, text) {
if (!payload) return false; if (!payload) return false;
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false; if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return false;
@@ -1415,9 +1460,7 @@ els.cameraStartBtn.addEventListener("click", () => {
}); });
els.cameraFlipBtn.addEventListener("click", () => { els.cameraFlipBtn.addEventListener("click", () => {
state.cameraFacing = switchCamera();
state.cameraFacing === "environment" ? "user" : "environment";
if (state.cameraActive) startCamera();
}); });
els.cameraUpload.addEventListener("change", (event) => { els.cameraUpload.addEventListener("change", (event) => {

View File

@@ -120,11 +120,13 @@
id="camera-flip-btn" id="camera-flip-btn"
class="btn btn--ghost camera-drawer__source" class="btn btn--ghost camera-drawer__source"
type="button" type="button"
hidden disabled
> >
切换摄像头 切换摄像头
</button> </button>
<label class="btn btn--ghost camera-drawer__source"> <label
class="btn btn--ghost camera-drawer__source camera-drawer__source--upload"
>
上传图片 上传图片
<input <input
id="camera-upload" id="camera-upload"

View File

@@ -136,7 +136,8 @@ body {
.camera-drawer__preview { .camera-drawer__preview {
position: relative; position: relative;
min-height: 210px; aspect-ratio: 4 / 3;
min-height: 200px;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(149, 160, 187, 0.28); border: 1px solid rgba(149, 160, 187, 0.28);
border-radius: 14px; border-radius: 14px;
@@ -270,13 +271,12 @@ body {
} }
.camera-drawer__sources { .camera-drawer__sources {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 8px;
} }
.camera-drawer__source { .camera-drawer__source {
flex: 1 1 auto;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -287,11 +287,21 @@ body {
cursor: pointer; cursor: pointer;
} }
/* Upload spans the full width on its own row below camera + switch. */
.camera-drawer__source--upload {
grid-column: 1 / -1;
}
.camera-drawer__source.is-active { .camera-drawer__source.is-active {
border-color: var(--success); border-color: var(--success);
color: var(--success); color: var(--success);
} }
.camera-drawer__source:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.camera-drawer__samples { .camera-drawer__samples {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);