Improve mobile recorder focus mode
这个提交包含在:
@@ -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);
|
||||
|
||||
在新工单中引用
屏蔽一个用户