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:
@@ -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) => {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user