diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx
index f88daaf..ca5256c 100644
--- a/client/src/pages/LiveCamera.tsx
+++ b/client/src/pages/LiveCamera.tsx
@@ -121,6 +121,15 @@ type RuntimeSnapshot = {
phase?: "idle" | "analyzing" | "saving" | "safe" | "failed";
startedAt?: number;
durationMs?: number;
+ title?: string;
+ sessionMode?: SessionMode;
+ qualityPreset?: CameraQualityPreset;
+ facingMode?: CameraFacing;
+ deviceKind?: "mobile" | "desktop";
+ avatarEnabled?: boolean;
+ avatarKey?: AvatarKey;
+ avatarLabel?: string;
+ updatedAt?: number;
currentAction?: ActionType;
rawAction?: ActionType;
feedback?: string[];
@@ -534,6 +543,20 @@ function getSessionBand(input: { overallScore: number; knownRatio: number; effec
return { label: "待加强", tone: "bg-amber-500/10 text-amber-700" };
}
+function getRuntimeSyncDelayMs(lastHeartbeatAt?: string | null) {
+ if (!lastHeartbeatAt) return null;
+ const heartbeatMs = new Date(lastHeartbeatAt).getTime();
+ if (Number.isNaN(heartbeatMs)) return null;
+ return Math.max(0, Date.now() - heartbeatMs);
+}
+
+function formatRuntimeSyncDelay(delayMs: number | null) {
+ if (delayMs == null) return "等待同步";
+ if (delayMs < 1500) return "同步中";
+ if (delayMs < 10_000) return `${(delayMs / 1000).toFixed(1)}s 延迟`;
+ return "同步较慢";
+}
+
export default function LiveCamera() {
const { user } = useAuth();
const utils = trpc.useUtils();
@@ -675,6 +698,13 @@ export default function LiveCamera() {
leaveStatusRef.current = leaveStatus;
}, [leaveStatus]);
+ useEffect(() => {
+ if (runtimeRole === "viewer") {
+ setShowSetupGuide(false);
+ setSetupStep(0);
+ }
+ }, [runtimeRole]);
+
useEffect(() => {
sessionModeRef.current = sessionMode;
}, [sessionMode]);
@@ -859,6 +889,15 @@ export default function LiveCamera() {
phase: phase ?? leaveStatusRef.current,
startedAt: sessionStartedAtRef.current || undefined,
durationMs: durationMsRef.current,
+ title: runtimeSession?.title ?? `实时分析 ${ACTION_META[currentActionRef.current].label}`,
+ sessionMode: sessionModeRef.current,
+ qualityPreset,
+ facingMode: facing,
+ deviceKind: mobile ? "mobile" : "desktop",
+ avatarEnabled: avatarRenderRef.current.enabled,
+ avatarKey: avatarRenderRef.current.avatarKey,
+ avatarLabel: getAvatarPreset(avatarRenderRef.current.avatarKey)?.label || "猩猩",
+ updatedAt: Date.now(),
currentAction: currentActionRef.current,
rawAction: rawActionRef.current,
feedback: feedbackRef.current,
@@ -868,7 +907,7 @@ export default function LiveCamera() {
unknownSegments: segmentsRef.current.filter((segment) => segment.isUnknown).length,
archivedVideoCount: archivedVideosRef.current.length,
recentSegments: segmentsRef.current.slice(-5),
- }), []);
+ }), [facing, mobile, qualityPreset, runtimeSession?.title]);
const closeBroadcastPeer = useCallback(() => {
broadcastSessionIdRef.current = null;
@@ -1644,6 +1683,7 @@ export default function LiveCamera() {
await startCamera(facing, zoomTargetRef.current, qualityPreset);
}, [facing, qualityPreset, startCamera]);
+ const displayLeaveStatus = runtimeRole === "viewer" ? (runtimeSnapshot?.phase ?? "idle") : leaveStatus;
const displayAction = runtimeRole === "viewer" ? (runtimeSnapshot?.currentAction ?? "unknown") : currentAction;
const displayRawAction = runtimeRole === "viewer" ? (runtimeSnapshot?.rawAction ?? "unknown") : rawAction;
const displayScore = runtimeRole === "viewer" ? (runtimeSnapshot?.liveScore ?? null) : liveScore;
@@ -1655,6 +1695,33 @@ export default function LiveCamera() {
...runtimeSnapshot?.stabilityMeta,
}
: stabilityMeta;
+ const displaySessionMode = runtimeRole === "viewer"
+ ? (runtimeSnapshot?.sessionMode ?? runtimeSession?.sessionMode ?? sessionMode)
+ : sessionMode;
+ const displayQualityPreset = runtimeRole === "viewer"
+ ? (runtimeSnapshot?.qualityPreset ?? qualityPreset)
+ : qualityPreset;
+ const displayFacing = runtimeRole === "viewer"
+ ? (runtimeSnapshot?.facingMode ?? facing)
+ : facing;
+ const displayDeviceKind = runtimeRole === "viewer"
+ ? (runtimeSnapshot?.deviceKind ?? (mobile ? "mobile" : "desktop"))
+ : (mobile ? "mobile" : "desktop");
+ const displayAvatarEnabled = runtimeRole === "viewer"
+ ? Boolean(runtimeSnapshot?.avatarEnabled)
+ : avatarEnabled;
+ const displayAvatarKey = runtimeRole === "viewer"
+ ? ((runtimeSnapshot?.avatarKey as AvatarKey | undefined) ?? resolvedAvatarKey)
+ : resolvedAvatarKey;
+ const displayAvatarPreset = getAvatarPreset(displayAvatarKey);
+ const displayAvatarLabel = runtimeRole === "viewer"
+ ? (runtimeSnapshot?.avatarLabel ?? displayAvatarPreset?.label ?? "猩猩")
+ : (displayAvatarPreset?.label || "猩猩");
+ const runtimeSyncDelayMs = runtimeRole === "viewer" ? getRuntimeSyncDelayMs(runtimeSession?.lastHeartbeatAt) : null;
+ const runtimeSyncLabel = runtimeRole === "viewer" ? formatRuntimeSyncDelay(runtimeSyncDelayMs) : "";
+ const displayRuntimeTitle = runtimeRole === "viewer"
+ ? (runtimeSnapshot?.title ?? runtimeSession?.title ?? "其他设备实时分析")
+ : (runtimeSession?.title ?? `实时分析 ${ACTION_META[currentAction].label}`);
const hasVideoFeed = cameraActive || viewerConnected;
const heroAction = ACTION_META[displayAction];
const rawActionMeta = ACTION_META[displayRawAction];
@@ -1665,7 +1732,7 @@ export default function LiveCamera() {
const fullBodyAvatarPresets = AVATAR_PRESETS.filter((preset) => preset.category === "full-body-3d");
const previewTitle = runtimeRole === "viewer"
? viewerConnected
- ? "同步观看中"
+ ? `${runtimeSyncLabel} · 同步观看中`
: "正在连接同步画面"
: analyzing
? displayStabilityMeta.pending && pendingActionMeta
@@ -1906,42 +1973,50 @@ export default function LiveCamera() {
- {leaveStatus === "analyzing" ? (
+ {displayLeaveStatus === "analyzing" ? (
- 摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;分析过程中会自动录制“视频画面 + 骨架/关键点叠层”的合成回放,并按 60 秒分段归档进视频库。开启虚拟形象后,画面中的人体可切换为 10 个轻量动物替身,或 4 个免费的全身 3D Avatar 示例覆盖显示。 + {runtimeRole === "viewer" + ? `当前正在同步 ${displayDeviceKind === "mobile" ? "移动端" : "桌面端"} ${displayFacing === "environment" ? "后置/主摄视角" : "前置视角"} 画面。视频、动作、评分、最近区间、虚拟形象和会话状态会自动跟随持有端刷新,允许少量网络延迟。` + : "摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;分析过程中会自动录制“视频画面 + 骨架/关键点叠层”的合成回放,并按 60 秒分段归档进视频库。开启虚拟形象后,画面中的人体可切换为 10 个轻量动物替身,或 4 个免费的全身 3D Avatar 示例覆盖显示。"}