Checkpoint: v2.0完整版本:新增社区排行榜、每日打卡、24种成就徽章、实时摄像头姿势分析、在线录制(稳定压缩流/断线重连/自动剪辑)、移动端全面适配。47个测试通过。包含完整开发文档。

这个提交包含在:
Manus
2026-03-14 08:04:00 -04:00
父节点 36907d1110
当前提交 2c418b482e
修改 22 个文件,包含 4370 行新增41 行删除

查看文件

@@ -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>
);
}