Improve mobile recorder focus mode

这个提交包含在:
cryptocommuniums-afk
2026-03-14 22:44:46 +08:00
父节点 bcdd790d91
当前提交 bc01a40564
修改 2 个文件,包含 276 行新增89 行删除

查看文件

@@ -27,6 +27,8 @@ import {
Download,
FlipHorizontal,
Loader2,
Maximize2,
Minimize2,
MonitorUp,
Scissors,
ShieldAlert,
@@ -95,7 +97,10 @@ function waitForIceGathering(peer: RTCPeerConnection) {
}
function isMobileDevice() {
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
return (
/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
window.matchMedia("(max-width: 768px)").matches
);
}
function getArchiveProgress(session: MediaSession | null) {
@@ -164,6 +169,7 @@ export default function Recorder() {
const [mediaSession, setMediaSession] = useState<MediaSession | null>(null);
const [markers, setMarkers] = useState<MediaMarker[]>([]);
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>("new");
const [immersivePreview, setImmersivePreview] = useState(false);
const mobile = useMemo(() => isMobileDevice(), []);
const mimeType = useMemo(() => pickRecorderMimeType(), []);
@@ -712,7 +718,40 @@ export default function Recorder() {
return;
}
playbackVideoRef.current.load();
}, [currentPlaybackUrl, mode]);
}, [currentPlaybackUrl, immersivePreview, mode]);
useEffect(() => {
if (mode === "archived") {
return;
}
const liveVideo = liveVideoRef.current;
if (!liveVideo || !streamRef.current) {
return;
}
if (liveVideo.srcObject !== streamRef.current) {
liveVideo.srcObject = streamRef.current;
}
liveVideo.play().catch(() => {});
}, [cameraActive, immersivePreview, mode]);
useEffect(() => {
if (!mobile) {
setImmersivePreview(false);
return;
}
if (!immersivePreview) {
document.body.style.overflow = "";
return;
}
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, [immersivePreview, mobile]);
useEffect(() => {
return () => {
@@ -751,6 +790,156 @@ export default function Recorder() {
const previewTitle = mode === "archived" ? "归档回放" : "实时取景";
const StatusIcon = statusBadge.icon;
const previewVideoClassName = immersivePreview && mobile
? "h-full w-full object-contain"
: "h-full w-full object-contain";
const railButtonClassName =
"h-16 w-16 flex-col gap-1 rounded-[20px] px-2 text-[11px] leading-tight shadow-lg shadow-black/20";
const renderPrimaryActions = (layout: "toolbar" | "rail" = "toolbar") => {
const rail = layout === "rail";
const labelFor = (full: string, short: string) => (rail ? short : full);
const buttonClass = (tone: "default" | "outline" | "destructive" | "record" = "default") => {
if (!rail) {
switch (tone) {
case "outline":
return "h-12 rounded-2xl px-4";
case "destructive":
return "h-12 rounded-2xl px-5";
case "record":
return "h-12 rounded-2xl bg-red-600 px-5 hover:bg-red-700";
default:
return "h-12 rounded-2xl px-5";
}
}
switch (tone) {
case "outline":
return `${railButtonClassName} border-white/15 bg-white/8 text-white hover:bg-white/14`;
case "destructive":
return `${railButtonClassName} bg-red-600 text-white hover:bg-red-700`;
case "record":
return `${railButtonClassName} bg-red-600 text-white hover:bg-red-700`;
default:
return `${railButtonClassName} bg-white text-slate-950 hover:bg-white/90`;
}
};
const iconClass = rail ? "h-5 w-5" : "mr-2 h-4 w-4";
return (
<>
{mode === "idle" && (
<>
{!cameraActive ? (
<Button
data-testid="recorder-start-camera-button"
onClick={() => void startCamera()}
className={buttonClass()}
>
<Camera className={iconClass} />
{labelFor("启动摄像头", "启动")}
</Button>
) : (
<>
<Button
data-testid="recorder-start-recording-button"
onClick={() => void beginRecording()}
className={buttonClass("record")}
>
<Circle className={`${iconClass} ${rail ? "fill-current" : "fill-current"}`} />
{labelFor("开始录制", "录制")}
</Button>
<Button variant="outline" onClick={stopCamera} className={buttonClass("outline")}>
<VideoOff className={iconClass} />
{labelFor("关闭摄像头", "关闭")}
</Button>
</>
)}
{hasMultipleCameras && (
<Button variant="outline" onClick={() => void flipCamera()} className={buttonClass("outline")}>
<FlipHorizontal className={iconClass} />
{labelFor("切换摄像头", "切换")}
</Button>
)}
</>
)}
{(mode === "recording" || mode === "reconnecting") && (
<>
<Button
data-testid="recorder-marker-button"
variant="outline"
onClick={() => void createManualMarker("manual", "手动剪辑点")}
className={buttonClass("outline")}
>
<Scissors className={iconClass} />
{labelFor("标记剪辑点", "标记")}
</Button>
<Button
data-testid="recorder-finish-button"
variant="destructive"
onClick={() => void finishRecording()}
className={buttonClass("destructive")}
>
<CloudUpload className={iconClass} />
{labelFor("结束并归档", "完成")}
</Button>
</>
)}
{mode === "archived" && (
<>
{currentPlaybackUrl && (
<Button asChild variant="outline" className={buttonClass("outline")}>
<a href={currentPlaybackUrl} download target="_blank" rel="noreferrer">
<Download className={iconClass} />
{labelFor("下载回放", "下载")}
</a>
</Button>
)}
<Button
data-testid="recorder-reset-button"
onClick={() => void resetRecorder()}
className={buttonClass()}
>
{rail ? null : null}
{labelFor("重新录制", "重来")}
</Button>
</>
)}
{mode === "finalizing" && (
<Button disabled className={rail ? `${railButtonClassName} bg-white/15 text-white` : "h-12 rounded-2xl px-5"}>
<Loader2 className={rail ? "h-5 w-5 animate-spin" : "mr-2 h-4 w-4 animate-spin"} />
{labelFor("正在生成回放", "处理中")}
</Button>
)}
</>
);
};
const renderPreviewMedia = () => (
<>
{mode === "archived" && currentPlaybackUrl ? (
<video
ref={playbackVideoRef}
className={previewVideoClassName}
src={currentPlaybackUrl}
controls
playsInline
/>
) : (
<video
ref={liveVideoRef}
className={previewVideoClassName}
playsInline
muted
autoPlay
/>
)}
</>
);
return (
<div className="no-overscroll mobile-safe-bottom mobile-safe-inline mobile-bottom-spacing space-y-4">
@@ -804,23 +993,7 @@ export default function Recorder() {
<Card className="overflow-hidden border-0 shadow-lg">
<CardContent className="p-0">
<div className="relative aspect-[16/10] overflow-hidden bg-black sm:aspect-video">
{mode === "archived" && currentPlaybackUrl ? (
<video
ref={playbackVideoRef}
className="h-full w-full object-contain"
src={currentPlaybackUrl}
controls
playsInline
/>
) : (
<video
ref={liveVideoRef}
className="h-full w-full object-contain"
playsInline
muted
autoPlay
/>
)}
{!immersivePreview && renderPreviewMedia()}
{!cameraActive && mode === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-[radial-gradient(circle_at_center,_rgba(16,185,129,0.12),_rgba(0,0,0,0.75))] px-6 text-center text-white/80">
@@ -858,6 +1031,19 @@ export default function Recorder() {
{formatRecordingTime(durationMs)}
</div>
)}
{mobile && (
<Button
type="button"
size="icon"
variant="secondary"
data-testid="recorder-mobile-focus-button"
onClick={() => setImmersivePreview(true)}
className="absolute right-3 top-3 z-20 h-11 w-11 rounded-full border border-white/10 bg-black/60 text-white shadow-lg hover:bg-black/75"
>
<Maximize2 className="h-4 w-4" />
</Button>
)}
</div>
<div className="border-t border-border/60 bg-card/80 p-4">
@@ -869,69 +1055,7 @@ export default function Recorder() {
className="h-12 rounded-2xl border-border/60"
/>
<div className="flex flex-wrap gap-2">
{mode === "idle" && (
<>
{!cameraActive ? (
<Button data-testid="recorder-start-camera-button" onClick={() => void startCamera()} className="h-12 rounded-2xl px-5">
<Camera className="mr-2 h-4 w-4" />
</Button>
) : (
<>
<Button data-testid="recorder-start-recording-button" onClick={() => void beginRecording()} className="h-12 rounded-2xl bg-red-600 px-5 hover:bg-red-700">
<Circle className="mr-2 h-4 w-4 fill-current" />
</Button>
<Button variant="outline" onClick={stopCamera} className="h-12 rounded-2xl px-4">
<VideoOff className="mr-2 h-4 w-4" />
</Button>
</>
)}
{hasMultipleCameras && (
<Button variant="outline" onClick={() => void flipCamera()} className="h-12 rounded-2xl px-4">
<FlipHorizontal className="mr-2 h-4 w-4" />
</Button>
)}
</>
)}
{(mode === "recording" || mode === "reconnecting") && (
<>
<Button data-testid="recorder-marker-button" variant="outline" onClick={() => void createManualMarker("manual", "手动剪辑点")} className="h-12 rounded-2xl px-4">
<Scissors className="mr-2 h-4 w-4" />
</Button>
<Button data-testid="recorder-finish-button" variant="destructive" onClick={() => void finishRecording()} className="h-12 rounded-2xl px-5">
<CloudUpload className="mr-2 h-4 w-4" />
</Button>
</>
)}
{mode === "archived" && (
<>
{currentPlaybackUrl && (
<Button asChild variant="outline" className="h-12 rounded-2xl px-4">
<a href={currentPlaybackUrl} download target="_blank" rel="noreferrer">
<Download className="mr-2 h-4 w-4" />
</a>
</Button>
)}
<Button data-testid="recorder-reset-button" onClick={() => void resetRecorder()} className="h-12 rounded-2xl px-5">
</Button>
</>
)}
{mode === "finalizing" && (
<Button disabled className="h-12 rounded-2xl px-5">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
</Button>
)}
{renderPrimaryActions()}
</div>
</div>
</div>
@@ -1088,6 +1212,66 @@ export default function Recorder() {
</Card>
</aside>
</div>
{mobile && immersivePreview && (
<div
className="mobile-safe-top mobile-safe-bottom mobile-safe-inline fixed inset-0 z-[80] bg-black/95 px-3 py-4"
data-testid="recorder-mobile-focus-shell"
>
<div className="grid h-full grid-cols-[minmax(0,1fr)_72px] gap-3">
<div className="relative flex min-h-0 items-center justify-center">
<div className="relative h-full w-full overflow-hidden rounded-[32px] border border-white/10 bg-black shadow-2xl shadow-black/40">
{renderPreviewMedia()}
{!cameraActive && mode === "idle" && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-[radial-gradient(circle_at_center,_rgba(16,185,129,0.12),_rgba(0,0,0,0.8))] px-6 text-center text-white/80">
<VideoOff className="h-14 w-14" />
<div className="space-y-1">
<h2 className="text-xl font-medium"></h2>
<p className="text-sm text-white/60"></p>
</div>
</div>
)}
<div className="pointer-events-none absolute left-3 top-3 flex flex-wrap gap-2">
<Badge className="gap-1.5 bg-black/55 text-white shadow-sm">
<Sparkles className="h-3.5 w-3.5" />
{previewTitle}
</Badge>
<Badge className="gap-1.5 bg-black/55 text-white shadow-sm">
<Zap className="h-3.5 w-3.5" />
{QUALITY_PRESETS[qualityPreset].subtitle}
</Badge>
</div>
{(mode === "recording" || mode === "reconnecting") && (
<div className="absolute bottom-3 left-3 flex items-center gap-2 rounded-full bg-black/65 px-3 py-2 text-sm text-white shadow-lg">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500" />
</span>
{formatRecordingTime(durationMs)}
</div>
)}
<Button
type="button"
size="icon"
variant="secondary"
onClick={() => setImmersivePreview(false)}
className="absolute right-3 top-3 z-20 h-11 w-11 rounded-full border border-white/10 bg-black/60 text-white shadow-lg hover:bg-black/75"
>
<Minimize2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-col items-center justify-center gap-3">
{renderPrimaryActions("rail")}
</div>
</div>
</div>
)}
</div>
);
}

查看文件

@@ -46,18 +46,21 @@ test("recorder flow archives a session and exposes it in videos", async ({ page
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/recorder");
await expect(page.getByTestId("recorder-title")).toBeVisible();
await page.getByTestId("recorder-mobile-focus-button").click();
const focusShell = page.getByTestId("recorder-mobile-focus-shell");
await expect(focusShell).toBeVisible();
await page.getByTestId("recorder-start-camera-button").click();
await expect(page.getByTestId("recorder-start-recording-button")).toBeVisible();
await focusShell.getByTestId("recorder-start-camera-button").click();
await expect(focusShell.getByTestId("recorder-start-recording-button")).toBeVisible();
await page.getByTestId("recorder-start-recording-button").click();
await expect(page.getByTestId("recorder-marker-button")).toBeVisible();
await focusShell.getByTestId("recorder-start-recording-button").click();
await expect(focusShell.getByTestId("recorder-marker-button")).toBeVisible();
await page.getByTestId("recorder-marker-button").click();
await focusShell.getByTestId("recorder-marker-button").click();
await expect(page.getByText("手动标记")).toBeVisible();
await page.getByTestId("recorder-finish-button").click();
await expect(page.getByTestId("recorder-reset-button")).toBeVisible({ timeout: 8_000 });
await focusShell.getByTestId("recorder-finish-button").click();
await expect(focusShell.getByTestId("recorder-reset-button")).toBeVisible({ timeout: 8_000 });
await page.goto("/videos");
await expect(page.getByTestId("video-card")).toHaveCount(1);