Add camera zoom and data saver controls

这个提交包含在:
cryptocommuniums-afk
2026-03-15 16:17:34 +08:00
父节点 bd8998166b
当前提交 c4ec397ed3
修改 4 个文件,包含 577 行新增39 行删除

查看文件

@@ -19,9 +19,11 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Slider } from "@/components/ui/slider";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { toast } from "sonner";
import { applyTrackZoom, getCameraVideoConstraints, readTrackZoomState } from "@/lib/camera";
import {
Activity,
Camera,
@@ -31,8 +33,10 @@ import {
FlipHorizontal,
Loader2,
Maximize2,
Minus,
Minimize2,
MonitorUp,
Plus,
Scissors,
ShieldAlert,
Smartphone,
@@ -182,9 +186,10 @@ export default function Recorder() {
const reconnectAttemptsRef = useRef(0);
const facingModeRef = useRef<"user" | "environment">("environment");
const suppressTrackEndedRef = useRef(false);
const zoomTargetRef = useRef(1);
const [mode, setMode] = useState<RecorderMode>("idle");
const [qualityPreset, setQualityPreset] = useState<keyof typeof QUALITY_PRESETS>("balanced");
const [qualityPreset, setQualityPreset] = useState<keyof typeof QUALITY_PRESETS>("economy");
const [facingMode, setFacingMode] = useState<"user" | "environment">("environment");
const [cameraActive, setCameraActive] = useState(false);
const [hasMultipleCameras, setHasMultipleCameras] = useState(false);
@@ -203,6 +208,7 @@ export default function Recorder() {
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
const [immersivePreview, setImmersivePreview] = useState(false);
const [archiveTaskId, setArchiveTaskId] = useState<string | null>(null);
const [zoomState, setZoomState] = useState(() => readTrackZoomState(null));
const mobile = useMemo(() => isMobileDevice(), []);
const mimeType = useMemo(() => pickRecorderMimeType(), []);
@@ -290,10 +296,59 @@ export default function Recorder() {
if (liveVideoRef.current) {
liveVideoRef.current.srcObject = null;
}
setZoomState(readTrackZoomState(null));
setCameraActive(false);
}, []);
const startCamera = useCallback(async (nextFacingMode = facingMode) => {
const syncZoomState = useCallback(async (preferredZoom?: number, providedTrack?: MediaStreamTrack | null) => {
const track = providedTrack || streamRef.current?.getVideoTracks()[0] || null;
if (!track) {
zoomTargetRef.current = 1;
setZoomState(readTrackZoomState(null));
return;
}
let nextState = readTrackZoomState(track);
if (nextState.supported && preferredZoom != null && Math.abs(preferredZoom - nextState.current) > nextState.step / 2) {
try {
nextState = await applyTrackZoom(track, preferredZoom);
} catch {
nextState = readTrackZoomState(track);
}
}
zoomTargetRef.current = nextState.current;
setZoomState(nextState);
}, []);
const updateZoom = useCallback(async (nextZoom: number) => {
const track = streamRef.current?.getVideoTracks()[0] || null;
if (!track) return;
try {
const nextState = await applyTrackZoom(track, nextZoom);
zoomTargetRef.current = nextState.current;
setZoomState(nextState);
} catch (error: any) {
toast.error(`镜头缩放调整失败: ${error?.message || "当前设备不支持"}`);
}
}, []);
const stepZoom = useCallback((direction: -1 | 1) => {
if (!zoomState.supported) return;
const nextZoom = Math.max(
zoomState.min,
Math.min(zoomState.max, zoomState.current + zoomState.step * direction),
);
void updateZoom(nextZoom);
}, [updateZoom, zoomState]);
const startCamera = useCallback((
async (
nextFacingMode = facingMode,
preferredZoom = zoomTargetRef.current,
preset: keyof typeof QUALITY_PRESETS = qualityPreset,
) => {
try {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
@@ -301,12 +356,7 @@ export default function Recorder() {
}
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: nextFacingMode,
width: { ideal: mobile ? 1280 : 1920 },
height: { ideal: mobile ? 720 : 1080 },
frameRate: { ideal: 30, max: 30 },
},
video: getCameraVideoConstraints(nextFacingMode, mobile, preset),
audio: {
echoCancellation: true,
noiseSuppression: true,
@@ -327,6 +377,7 @@ export default function Recorder() {
liveVideoRef.current.srcObject = stream;
await liveVideoRef.current.play();
}
await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null);
setCameraError("");
setCameraActive(true);
return stream;
@@ -336,7 +387,7 @@ export default function Recorder() {
toast.error(`摄像头启动失败: ${message}`);
throw error;
}
}, [facingMode, mobile]);
}), [facingMode, mobile, qualityPreset, syncZoomState]);
const ensurePreviewStream = useCallback(async () => {
if (streamRef.current) {
@@ -344,12 +395,13 @@ export default function Recorder() {
liveVideoRef.current.srcObject = streamRef.current;
await liveVideoRef.current.play().catch(() => {});
}
await syncZoomState(zoomTargetRef.current);
setCameraActive(true);
return streamRef.current;
}
return startCamera(facingModeRef.current);
}, [startCamera]);
return startCamera(facingModeRef.current, zoomTargetRef.current, qualityPreset);
}, [startCamera, syncZoomState]);
const refreshDevices = useCallback(async () => {
try {
@@ -713,9 +765,16 @@ export default function Recorder() {
setFacingMode(nextFacingMode);
if (mode === "idle" && cameraActive) {
stopCamera();
await startCamera(nextFacingMode);
await startCamera(nextFacingMode, zoomTargetRef.current, qualityPreset);
}
}, [cameraActive, facingMode, mode, startCamera, stopCamera]);
}, [cameraActive, facingMode, mode, qualityPreset, startCamera, stopCamera]);
const handleQualityPresetChange = useCallback(async (nextPreset: keyof typeof QUALITY_PRESETS) => {
setQualityPreset(nextPreset);
if (cameraActive && mode === "idle") {
await startCamera(facingModeRef.current, zoomTargetRef.current, nextPreset);
}
}, [cameraActive, mode, startCamera]);
useEffect(() => {
void refreshDevices();
@@ -968,6 +1027,35 @@ export default function Recorder() {
</>
);
const renderZoomOverlay = () => (
<div className="absolute right-3 bottom-3 z-20 flex items-center gap-2 rounded-2xl border border-white/10 bg-black/65 px-2 py-2 text-white shadow-lg">
<Button
type="button"
size="icon"
variant="secondary"
onClick={() => stepZoom(-1)}
disabled={!zoomState.supported}
className="h-10 w-10 rounded-xl border border-white/10 bg-white/10 text-white hover:bg-white/20 disabled:opacity-40"
>
<Minus className="h-4 w-4" />
</Button>
<div className="min-w-[78px] text-center">
<div className="text-[10px] uppercase tracking-[0.16em] text-white/50"></div>
<div className="mt-1 text-sm font-semibold">{zoomState.supported ? `${zoomState.current.toFixed(1)}x` : "自动"}</div>
</div>
<Button
type="button"
size="icon"
variant="secondary"
onClick={() => stepZoom(1)}
disabled={!zoomState.supported}
className="h-10 w-10 rounded-xl border border-white/10 bg-white/10 text-white hover:bg-white/20 disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</Button>
</div>
);
return (
<div className="no-overscroll mobile-safe-bottom mobile-safe-inline mobile-bottom-spacing space-y-4">
<canvas ref={motionCanvasRef} className="hidden" />
@@ -1112,6 +1200,8 @@ export default function Recorder() {
<Maximize2 className="h-4 w-4" />
</Button>
)}
{cameraActive && zoomState.supported ? renderZoomOverlay() : null}
</div>
<div className="border-t border-border/60 bg-card/80 p-4">
@@ -1145,19 +1235,21 @@ export default function Recorder() {
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 lg:grid-cols-3">
{Object.entries(QUALITY_PRESETS).map(([key, preset]) => {
const active = qualityPreset === key;
return (
<button
key={key}
type="button"
onClick={() => setQualityPreset(key as keyof typeof QUALITY_PRESETS)}
className={`rounded-2xl border px-4 py-4 text-left transition ${
active
? "border-primary/60 bg-primary/5 shadow-sm"
: "border-border/60 hover:border-primary/30 hover:bg-muted/50"
}`}
>
{Object.entries(QUALITY_PRESETS).map(([key, preset]) => {
const active = qualityPreset === key;
const disabled = mode !== "idle" && mode !== "archived";
return (
<button
key={key}
type="button"
onClick={() => void handleQualityPresetChange(key as keyof typeof QUALITY_PRESETS)}
disabled={disabled}
className={`rounded-2xl border px-4 py-4 text-left transition ${
active
? "border-primary/60 bg-primary/5 shadow-sm"
: "border-border/60 hover:border-primary/30 hover:bg-muted/50"
} ${disabled ? "cursor-not-allowed opacity-60" : ""}`}
>
<div className="text-sm font-semibold">{preset.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{preset.subtitle}</div>
<p className="mt-3 text-sm leading-6 text-muted-foreground">{preset.description}</p>
@@ -1180,6 +1272,43 @@ export default function Recorder() {
</div>
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/25 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-sm font-medium"> / </div>
<div className="mt-2 text-sm text-muted-foreground">
{zoomState.supported
? `当前 ${zoomState.current.toFixed(1)}x,可在录制过程中直接调整;设备焦点模式为 ${zoomState.focusMode}`
: "当前设备或浏览器未开放镜头缩放能力,仍会保持自动对焦。Chrome 安卓和部分后置摄像头通常支持此能力。"}
</div>
</div>
<Badge variant="outline" className="w-fit">
{QUALITY_PRESETS.economy.label}
</Badge>
</div>
{zoomState.supported ? (
<div className="mt-4 space-y-3">
<Slider
value={[zoomState.current]}
min={zoomState.min}
max={zoomState.max}
step={zoomState.step}
onValueChange={(value) => {
if (typeof value[0] === "number") {
void updateZoom(value[0]);
}
}}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{zoomState.min.toFixed(1)}x</span>
<span> 1.0x-1.5x</span>
<span>{zoomState.max.toFixed(1)}x</span>
</div>
</div>
) : null}
</div>
</CardContent>
</Card>
</section>
@@ -1379,6 +1508,8 @@ export default function Recorder() {
>
<Minimize2 className="h-4 w-4" />
</Button>
{cameraActive && zoomState.supported ? renderZoomOverlay() : null}
</div>
</div>