Checkpoint: v2.0完整版本:新增社区排行榜、每日打卡、24种成就徽章、实时摄像头姿势分析、在线录制(稳定压缩流/断线重连/自动剪辑)、移动端全面适配。47个测试通过。包含完整开发文档。
这个提交包含在:
687
client/src/pages/Recorder.tsx
普通文件
687
client/src/pages/Recorder.tsx
普通文件
@@ -0,0 +1,687 @@
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Video, VideoOff, Circle, Square, Scissors, Download, Upload, Camera,
|
||||
FlipHorizontal, Settings, Wifi, WifiOff, AlertTriangle, CheckCircle2,
|
||||
Play, Pause, SkipForward, SkipBack, Trash2, Save, Loader2
|
||||
} from "lucide-react";
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
|
||||
type RecordingState = "idle" | "recording" | "paused" | "stopped" | "processing";
|
||||
|
||||
interface ClipSegment {
|
||||
id: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
duration: number;
|
||||
isKeyMoment: boolean;
|
||||
label: string;
|
||||
blob?: Blob;
|
||||
}
|
||||
|
||||
// Stable bitrate configs
|
||||
const QUALITY_PRESETS = {
|
||||
low: { videoBitsPerSecond: 500_000, label: "低画质 (500kbps)", desc: "适合弱网环境" },
|
||||
medium: { videoBitsPerSecond: 1_500_000, label: "中画质 (1.5Mbps)", desc: "推荐日常使用" },
|
||||
high: { videoBitsPerSecond: 3_000_000, label: "高画质 (3Mbps)", desc: "WiFi环境推荐" },
|
||||
ultra: { videoBitsPerSecond: 5_000_000, label: "超高画质 (5Mbps)", desc: "最佳分析效果" },
|
||||
};
|
||||
|
||||
export default function Recorder() {
|
||||
const { user } = useAuth();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const previewRef = useRef<HTMLVideoElement>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const recordingStartRef = useRef<number>(0);
|
||||
|
||||
const [state, setState] = useState<RecordingState>("idle");
|
||||
const [facing, setFacing] = useState<"user" | "environment">("environment");
|
||||
const [quality, setQuality] = useState<keyof typeof QUALITY_PRESETS>("medium");
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
||||
const [recordedUrl, setRecordedUrl] = useState<string>("");
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [hasMultipleCameras, setHasMultipleCameras] = useState(false);
|
||||
const [cameraActive, setCameraActive] = useState(false);
|
||||
|
||||
// Auto-clip state
|
||||
const [clips, setClips] = useState<ClipSegment[]>([]);
|
||||
const [showClipEditor, setShowClipEditor] = useState(false);
|
||||
const [clipRange, setClipRange] = useState<[number, number]>([0, 100]);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
const uploadMutation = trpc.video.upload.useMutation();
|
||||
|
||||
// Check cameras
|
||||
useEffect(() => {
|
||||
navigator.mediaDevices?.enumerateDevices().then(devices => {
|
||||
setHasMultipleCameras(devices.filter(d => d.kind === "videoinput").length > 1);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Online/offline detection for reconnect
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setIsOnline(true);
|
||||
if (reconnecting) {
|
||||
toast.success("网络已恢复");
|
||||
setReconnecting(false);
|
||||
attemptReconnect();
|
||||
}
|
||||
};
|
||||
const handleOffline = () => {
|
||||
setIsOnline(false);
|
||||
toast.warning("网络断开,录制数据已缓存");
|
||||
};
|
||||
window.addEventListener("online", handleOnline);
|
||||
window.addEventListener("offline", handleOffline);
|
||||
return () => {
|
||||
window.removeEventListener("online", handleOnline);
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
};
|
||||
}, [reconnecting]);
|
||||
|
||||
// Duration timer
|
||||
useEffect(() => {
|
||||
if (state !== "recording") return;
|
||||
const interval = setInterval(() => {
|
||||
setDuration(Math.floor((Date.now() - recordingStartRef.current) / 1000));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
|
||||
if (recordedUrl) URL.revokeObjectURL(recordedUrl);
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||||
};
|
||||
}, [recordedUrl]);
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
try {
|
||||
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
|
||||
|
||||
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: facing,
|
||||
width: { ideal: isMobile ? 1280 : 1920 },
|
||||
height: { ideal: isMobile ? 720 : 1080 },
|
||||
frameRate: { ideal: 30 },
|
||||
},
|
||||
audio: true,
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
}
|
||||
setCameraActive(true);
|
||||
} catch (err: any) {
|
||||
toast.error("摄像头启动失败: " + (err.message || "未知错误"));
|
||||
}
|
||||
}, [facing]);
|
||||
|
||||
const stopCamera = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(t => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
setCameraActive(false);
|
||||
}, []);
|
||||
|
||||
const switchCamera = useCallback(async () => {
|
||||
const newFacing = facing === "user" ? "environment" : "user";
|
||||
setFacing(newFacing);
|
||||
if (cameraActive && state === "idle") {
|
||||
stopCamera();
|
||||
setTimeout(() => startCamera(), 200);
|
||||
}
|
||||
}, [facing, cameraActive, state, stopCamera, startCamera]);
|
||||
|
||||
// Reconnect logic with exponential backoff
|
||||
const attemptReconnect = useCallback(async () => {
|
||||
const maxAttempts = 5;
|
||||
if (reconnectAttempts >= maxAttempts) {
|
||||
toast.error("重连失败,请手动重新开始");
|
||||
setReconnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setReconnecting(true);
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000);
|
||||
|
||||
reconnectTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
await startCamera();
|
||||
setReconnecting(false);
|
||||
setReconnectAttempts(0);
|
||||
toast.success("摄像头重连成功");
|
||||
|
||||
// Resume recording if was recording
|
||||
if (state === "recording") {
|
||||
startRecordingInternal();
|
||||
}
|
||||
} catch {
|
||||
setReconnectAttempts(prev => prev + 1);
|
||||
attemptReconnect();
|
||||
}
|
||||
}, delay);
|
||||
}, [reconnectAttempts, startCamera, state]);
|
||||
|
||||
const startRecordingInternal = useCallback(() => {
|
||||
if (!streamRef.current) return;
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported("video/webm;codecs=vp9,opus")
|
||||
? "video/webm;codecs=vp9,opus"
|
||||
: MediaRecorder.isTypeSupported("video/webm;codecs=vp8,opus")
|
||||
? "video/webm;codecs=vp8,opus"
|
||||
: "video/webm";
|
||||
|
||||
const recorder = new MediaRecorder(streamRef.current, {
|
||||
mimeType,
|
||||
videoBitsPerSecond: QUALITY_PRESETS[quality].videoBitsPerSecond,
|
||||
});
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
chunksRef.current.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onerror = () => {
|
||||
toast.error("录制出错,尝试重连...");
|
||||
attemptReconnect();
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||
setRecordedBlob(blob);
|
||||
const url = URL.createObjectURL(blob);
|
||||
setRecordedUrl(url);
|
||||
|
||||
// Auto-generate clips
|
||||
autoGenerateClips(blob);
|
||||
};
|
||||
|
||||
// Collect data every 1 second for stability
|
||||
recorder.start(1000);
|
||||
mediaRecorderRef.current = recorder;
|
||||
}, [quality, attemptReconnect]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (!cameraActive) await startCamera();
|
||||
|
||||
chunksRef.current = [];
|
||||
setRecordedBlob(null);
|
||||
setRecordedUrl("");
|
||||
setClips([]);
|
||||
recordingStartRef.current = Date.now();
|
||||
setDuration(0);
|
||||
|
||||
startRecordingInternal();
|
||||
setState("recording");
|
||||
toast.success("开始录制");
|
||||
}, [cameraActive, startCamera, startRecordingInternal]);
|
||||
|
||||
const pauseRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.pause();
|
||||
setState("paused");
|
||||
toast.info("录制已暂停");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resumeRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current?.state === "paused") {
|
||||
mediaRecorderRef.current.resume();
|
||||
setState("recording");
|
||||
toast.info("继续录制");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop();
|
||||
setState("stopped");
|
||||
toast.success("录制完成");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-generate clips based on duration
|
||||
const autoGenerateClips = useCallback((blob: Blob) => {
|
||||
const totalDuration = duration;
|
||||
if (totalDuration < 5) return;
|
||||
|
||||
const segmentLength = Math.min(15, Math.max(5, Math.floor(totalDuration / 4)));
|
||||
const generatedClips: ClipSegment[] = [];
|
||||
|
||||
for (let i = 0; i < totalDuration; i += segmentLength) {
|
||||
const end = Math.min(i + segmentLength, totalDuration);
|
||||
generatedClips.push({
|
||||
id: `clip-${i}`,
|
||||
startTime: i,
|
||||
endTime: end,
|
||||
duration: end - i,
|
||||
isKeyMoment: i === 0 || i === Math.floor(totalDuration / 2),
|
||||
label: `片段 ${generatedClips.length + 1}`,
|
||||
});
|
||||
}
|
||||
|
||||
setClips(generatedClips);
|
||||
}, [duration]);
|
||||
|
||||
// Trim/clip video using canvas
|
||||
const trimVideo = useCallback(async () => {
|
||||
if (!recordedBlob || !previewRef.current) return;
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
const totalDuration = duration;
|
||||
const startSec = (clipRange[0] / 100) * totalDuration;
|
||||
const endSec = (clipRange[1] / 100) * totalDuration;
|
||||
|
||||
// Use MediaSource approach - create trimmed blob from chunks
|
||||
const startChunk = Math.floor(startSec);
|
||||
const endChunk = Math.ceil(endSec);
|
||||
const trimmedChunks = chunksRef.current.slice(
|
||||
Math.max(0, startChunk),
|
||||
Math.min(chunksRef.current.length, endChunk)
|
||||
);
|
||||
|
||||
if (trimmedChunks.length > 0) {
|
||||
const trimmedBlob = new Blob(trimmedChunks, { type: recordedBlob.type });
|
||||
setRecordedBlob(trimmedBlob);
|
||||
if (recordedUrl) URL.revokeObjectURL(recordedUrl);
|
||||
setRecordedUrl(URL.createObjectURL(trimmedBlob));
|
||||
toast.success(`已裁剪: ${startSec.toFixed(1)}s - ${endSec.toFixed(1)}s`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("裁剪失败");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [recordedBlob, clipRange, duration, recordedUrl]);
|
||||
|
||||
// Upload video
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (!recordedBlob || !user) return;
|
||||
if (!title.trim()) {
|
||||
toast.error("请输入视频标题");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
// Convert to base64 in chunks for progress
|
||||
const reader = new FileReader();
|
||||
const base64Promise = new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1] || result;
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
setUploadProgress(Math.round((e.loaded / e.total) * 50));
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(recordedBlob);
|
||||
});
|
||||
|
||||
const base64 = await base64Promise;
|
||||
setUploadProgress(60);
|
||||
|
||||
await uploadMutation.mutateAsync({
|
||||
title: title.trim(),
|
||||
format: "webm",
|
||||
fileSize: recordedBlob.size,
|
||||
exerciseType: "recording",
|
||||
fileBase64: base64,
|
||||
});
|
||||
|
||||
setUploadProgress(100);
|
||||
toast.success("视频上传成功!");
|
||||
|
||||
// Reset
|
||||
setTimeout(() => {
|
||||
setRecordedBlob(null);
|
||||
setRecordedUrl("");
|
||||
setTitle("");
|
||||
setUploadProgress(0);
|
||||
setState("idle");
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
toast.error("上传失败: " + (err.message || "未知错误"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [recordedBlob, title, user, uploadMutation]);
|
||||
|
||||
const downloadVideo = useCallback(() => {
|
||||
if (!recordedUrl) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = recordedUrl;
|
||||
a.download = `tennis-${new Date().toISOString().slice(0, 10)}.webm`;
|
||||
a.click();
|
||||
}, [recordedUrl]);
|
||||
|
||||
const formatTime = (s: number) => {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">在线录制</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">录制训练视频,自动压缩和剪辑</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={isOnline ? "default" : "destructive"} className="gap-1">
|
||||
{isOnline ? <Wifi className="h-3 w-3" /> : <WifiOff className="h-3 w-3" />}
|
||||
{isOnline ? "在线" : "离线"}
|
||||
</Badge>
|
||||
{reconnecting && (
|
||||
<Badge variant="outline" className="gap-1 text-orange-600">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />重连中...
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setShowSettings(true)} className="gap-1.5">
|
||||
<Settings className="h-3.5 w-3.5" />设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings dialog */}
|
||||
<Dialog open={showSettings} onOpenChange={setShowSettings}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>录制设置</DialogTitle>
|
||||
<DialogDescription>调整录制画质和参数</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">画质选择</label>
|
||||
{Object.entries(QUALITY_PRESETS).map(([key, preset]) => (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => setQuality(key as keyof typeof QUALITY_PRESETS)}
|
||||
className={`p-3 rounded-lg mb-2 cursor-pointer border transition-colors ${
|
||||
quality === key ? "border-primary bg-primary/5" : "border-transparent bg-muted/30 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">{preset.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{preset.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setShowSettings(false)}>确定</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Camera/Preview */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="border-0 shadow-sm overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="relative bg-black aspect-video w-full">
|
||||
{/* Live camera */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={`absolute inset-0 w-full h-full object-contain ${state === "stopped" ? "hidden" : ""}`}
|
||||
playsInline
|
||||
muted
|
||||
autoPlay
|
||||
/>
|
||||
{/* Preview recorded */}
|
||||
{state === "stopped" && recordedUrl && (
|
||||
<video
|
||||
ref={previewRef}
|
||||
src={recordedUrl}
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
playsInline
|
||||
controls
|
||||
/>
|
||||
)}
|
||||
{!cameraActive && state === "idle" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-white/60">
|
||||
<VideoOff className="h-12 w-12 mb-3" />
|
||||
<p className="text-sm">点击启动摄像头开始录制</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recording indicator */}
|
||||
{state === "recording" && (
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2 bg-red-600 text-white px-3 py-1.5 rounded-full text-sm">
|
||||
<Circle className="h-3 w-3 fill-current animate-pulse" />
|
||||
{formatTime(duration)}
|
||||
</div>
|
||||
)}
|
||||
{state === "paused" && (
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2 bg-yellow-600 text-white px-3 py-1.5 rounded-full text-sm">
|
||||
<Pause className="h-3 w-3" />
|
||||
已暂停 · {formatTime(duration)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality badge */}
|
||||
{(state === "recording" || state === "paused") && (
|
||||
<div className="absolute top-3 right-3 bg-black/60 text-white text-xs px-2 py-1 rounded">
|
||||
{QUALITY_PRESETS[quality].label.split(" ")[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-center gap-2 p-3 bg-muted/30 flex-wrap">
|
||||
{state === "idle" && (
|
||||
<>
|
||||
{!cameraActive ? (
|
||||
<Button onClick={startCamera} className="gap-2">
|
||||
<Camera className="h-4 w-4" />启动摄像头
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={startRecording} className="gap-2 bg-red-600 hover:bg-red-700">
|
||||
<Circle className="h-4 w-4 fill-current" />开始录制
|
||||
</Button>
|
||||
{hasMultipleCameras && (
|
||||
<Button variant="outline" size="sm" onClick={switchCamera} className="gap-1.5">
|
||||
<FlipHorizontal className="h-3.5 w-3.5" />{facing === "user" ? "后置" : "前置"}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={stopCamera} className="gap-1.5">
|
||||
<VideoOff className="h-3.5 w-3.5" />关闭
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{state === "recording" && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={pauseRecording} className="gap-1.5">
|
||||
<Pause className="h-3.5 w-3.5" />暂停
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={stopRecording} className="gap-1.5">
|
||||
<Square className="h-3.5 w-3.5" />停止
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{state === "paused" && (
|
||||
<>
|
||||
<Button size="sm" onClick={resumeRecording} className="gap-1.5">
|
||||
<Play className="h-3.5 w-3.5" />继续
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={stopRecording} className="gap-1.5">
|
||||
<Square className="h-3.5 w-3.5" />停止
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{state === "stopped" && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => { setState("idle"); setRecordedBlob(null); setRecordedUrl(""); }} className="gap-1.5">
|
||||
<Trash2 className="h-3.5 w-3.5" />重录
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowClipEditor(true)} className="gap-1.5">
|
||||
<Scissors className="h-3.5 w-3.5" />剪辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={downloadVideo} className="gap-1.5">
|
||||
<Download className="h-3.5 w-3.5" />下载
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="space-y-4">
|
||||
{/* Upload card */}
|
||||
{state === "stopped" && recordedBlob && (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-primary" />上传视频
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Input
|
||||
placeholder="视频标题"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
大小: {(recordedBlob.size / 1024 / 1024).toFixed(2)} MB · 时长: {formatTime(duration)}
|
||||
</div>
|
||||
{uploading && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={uploadProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground text-center">{uploadProgress}%</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || !title.trim()}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
{uploading ? "上传中..." : "上传到视频库"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Auto-clips */}
|
||||
{clips.length > 0 && (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Scissors className="h-4 w-4 text-primary" />自动剪辑片段
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{clips.map((clip) => (
|
||||
<div key={clip.id} className={`p-2 rounded-lg text-xs flex items-center justify-between ${
|
||||
clip.isKeyMoment ? "bg-primary/5 border border-primary/20" : "bg-muted/30"
|
||||
}`}>
|
||||
<div>
|
||||
<p className="font-medium">{clip.label}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{formatTime(clip.startTime)} - {formatTime(clip.endTime)} ({clip.duration}s)
|
||||
</p>
|
||||
</div>
|
||||
{clip.isKeyMoment && <Badge variant="secondary" className="text-[10px]">关键</Badge>}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recording info */}
|
||||
<Card className="border-0 shadow-sm bg-blue-50/50">
|
||||
<CardContent className="py-3">
|
||||
<p className="text-xs font-medium text-blue-700 mb-1">📹 录制提示</p>
|
||||
<ul className="text-[11px] text-blue-600 space-y-1">
|
||||
<li>· 录制自动使用稳定压缩流技术</li>
|
||||
<li>· 断网时数据自动缓存,恢复后继续</li>
|
||||
<li>· 支持暂停/继续录制</li>
|
||||
<li>· 录制完成后可自动剪辑关键片段</li>
|
||||
<li>· 建议横屏录制以获得最佳效果</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clip editor dialog */}
|
||||
<Dialog open={showClipEditor} onOpenChange={setShowClipEditor}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Scissors className="h-5 w-5 text-primary" />视频剪辑
|
||||
</DialogTitle>
|
||||
<DialogDescription>拖动滑块选择要保留的片段范围</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>开始: {formatTime(Math.floor((clipRange[0] / 100) * duration))}</span>
|
||||
<span>结束: {formatTime(Math.floor((clipRange[1] / 100) * duration))}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={clipRange}
|
||||
onValueChange={(v) => setClipRange(v as [number, number])}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
保留时长: {formatTime(Math.floor(((clipRange[1] - clipRange[0]) / 100) * duration))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowClipEditor(false)}>取消</Button>
|
||||
<Button onClick={() => { trimVideo(); setShowClipEditor(false); }} disabled={processing} className="gap-2">
|
||||
{processing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Scissors className="h-4 w-4" />}
|
||||
裁剪
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户