feat: add live camera multi-device viewer mode
这个提交包含在:
@@ -68,6 +68,16 @@ test("live camera starts analysis and produces scores", async ({ page }) => {
|
||||
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
|
||||
});
|
||||
|
||||
test("live camera switches into viewer mode when another device already owns analysis", async ({ page }) => {
|
||||
await installAppMocks(page, { authenticated: true, liveViewerMode: true });
|
||||
|
||||
await page.goto("/live-camera");
|
||||
await expect(page.getByText("同步观看模式")).toBeVisible();
|
||||
await expect(page.getByText(/同步观看|重新同步/).first()).toBeVisible();
|
||||
await expect(page.getByText("当前设备已锁定为观看模式")).toBeVisible();
|
||||
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible();
|
||||
});
|
||||
|
||||
test("live camera archives overlay videos into the library after analysis stops", async ({ page }) => {
|
||||
await installAppMocks(page, { authenticated: true, videos: [] });
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ type MockMediaSession = {
|
||||
uploadedBytes: number;
|
||||
durationMs: number;
|
||||
streamConnected: boolean;
|
||||
viewerCount?: number;
|
||||
playback: {
|
||||
webmUrl?: string;
|
||||
mp4Url?: string;
|
||||
@@ -92,6 +93,10 @@ type MockAppState = {
|
||||
adjustmentNotes: string | null;
|
||||
} | null;
|
||||
mediaSession: MockMediaSession | null;
|
||||
liveRuntime: {
|
||||
role: "idle" | "owner" | "viewer";
|
||||
runtimeSession: any | null;
|
||||
};
|
||||
nextVideoId: number;
|
||||
nextTaskId: number;
|
||||
authMeNullResponsesAfterLogin: number;
|
||||
@@ -428,6 +433,50 @@ async function handleTrpc(route: Route, state: MockAppState) {
|
||||
return trpcResult(state.analyses);
|
||||
case "analysis.liveSessionList":
|
||||
return trpcResult([]);
|
||||
case "analysis.runtimeGet":
|
||||
return trpcResult(state.liveRuntime);
|
||||
case "analysis.runtimeAcquire":
|
||||
if (state.liveRuntime.runtimeSession?.status === "active" && state.liveRuntime.role === "viewer") {
|
||||
return trpcResult(state.liveRuntime);
|
||||
}
|
||||
state.liveRuntime = {
|
||||
role: "owner",
|
||||
runtimeSession: {
|
||||
id: 501,
|
||||
title: "实时分析 正手",
|
||||
sessionMode: "practice",
|
||||
mediaSessionId: state.mediaSession?.id || null,
|
||||
status: "active",
|
||||
startedAt: nowIso(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: nowIso(),
|
||||
snapshot: {
|
||||
phase: "analyzing",
|
||||
currentAction: "forehand",
|
||||
rawAction: "forehand",
|
||||
visibleSegments: 1,
|
||||
unknownSegments: 0,
|
||||
durationMs: 1500,
|
||||
feedback: ["节奏稳定"],
|
||||
},
|
||||
},
|
||||
};
|
||||
return trpcResult(state.liveRuntime);
|
||||
case "analysis.runtimeHeartbeat": {
|
||||
const input = await readTrpcInput(route, operationIndex);
|
||||
if (state.liveRuntime.runtimeSession) {
|
||||
state.liveRuntime.runtimeSession = {
|
||||
...state.liveRuntime.runtimeSession,
|
||||
mediaSessionId: input?.mediaSessionId ?? state.liveRuntime.runtimeSession.mediaSessionId,
|
||||
snapshot: input?.snapshot ?? state.liveRuntime.runtimeSession.snapshot,
|
||||
lastHeartbeatAt: nowIso(),
|
||||
};
|
||||
}
|
||||
return trpcResult(state.liveRuntime);
|
||||
}
|
||||
case "analysis.runtimeRelease":
|
||||
state.liveRuntime = { role: "idle", runtimeSession: null };
|
||||
return trpcResult({ success: true, runtimeSession: null });
|
||||
case "analysis.liveSessionSave":
|
||||
return trpcResult({ sessionId: 1, trainingRecordId: 1 });
|
||||
case "task.list":
|
||||
@@ -594,6 +643,12 @@ async function handleMedia(route: Route, state: MockAppState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/viewer-signal")) {
|
||||
state.mediaSession.viewerCount = (state.mediaSession.viewerCount || 0) + 1;
|
||||
await fulfillJson(route, { viewerId: `viewer-${state.mediaSession.viewerCount}`, type: "answer", sdp: "mock-answer" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/segments")) {
|
||||
const buffer = (await route.request().postDataBuffer()) || Buffer.from("");
|
||||
state.mediaSession.uploadedSegments += 1;
|
||||
@@ -658,8 +713,10 @@ export async function installAppMocks(
|
||||
analyses?: any[];
|
||||
userName?: string;
|
||||
authMeNullResponsesAfterLogin?: number;
|
||||
liveViewerMode?: boolean;
|
||||
}
|
||||
) {
|
||||
const seededViewerSession = options?.liveViewerMode ? buildMediaSession(buildUser(options?.userName), "其他设备实时分析") : null;
|
||||
const state: MockAppState = {
|
||||
authenticated: options?.authenticated ?? false,
|
||||
user: buildUser(options?.userName),
|
||||
@@ -693,7 +750,70 @@ export async function installAppMocks(
|
||||
],
|
||||
tasks: [],
|
||||
activePlan: null,
|
||||
mediaSession: null,
|
||||
mediaSession: seededViewerSession,
|
||||
liveRuntime: options?.liveViewerMode
|
||||
? {
|
||||
role: "viewer",
|
||||
runtimeSession: {
|
||||
id: 777,
|
||||
title: "其他设备实时分析",
|
||||
sessionMode: "practice",
|
||||
mediaSessionId: seededViewerSession?.id || null,
|
||||
status: "active",
|
||||
startedAt: nowIso(),
|
||||
endedAt: null,
|
||||
lastHeartbeatAt: nowIso(),
|
||||
snapshot: {
|
||||
phase: "analyzing",
|
||||
currentAction: "forehand",
|
||||
rawAction: "forehand",
|
||||
durationMs: 3200,
|
||||
visibleSegments: 2,
|
||||
unknownSegments: 0,
|
||||
archivedVideoCount: 1,
|
||||
feedback: ["同步观看测试数据"],
|
||||
liveScore: {
|
||||
overall: 82,
|
||||
posture: 80,
|
||||
balance: 78,
|
||||
technique: 84,
|
||||
footwork: 76,
|
||||
consistency: 79,
|
||||
confidence: 88,
|
||||
},
|
||||
stabilityMeta: {
|
||||
windowFrames: 24,
|
||||
windowShare: 1,
|
||||
windowProgress: 1,
|
||||
switchCount: 1,
|
||||
stableMs: 1800,
|
||||
rawVolatility: 0.12,
|
||||
pending: false,
|
||||
candidateMs: 0,
|
||||
},
|
||||
recentSegments: [
|
||||
{
|
||||
actionType: "forehand",
|
||||
isUnknown: false,
|
||||
startMs: 800,
|
||||
endMs: 2800,
|
||||
durationMs: 2000,
|
||||
confidenceAvg: 0.82,
|
||||
score: 84,
|
||||
peakScore: 88,
|
||||
frameCount: 24,
|
||||
issueSummary: ["击球点略靠后"],
|
||||
keyFrames: [1000, 1800, 2600],
|
||||
clipLabel: "正手挥拍 00:00 - 00:02",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
role: "idle",
|
||||
runtimeSession: null,
|
||||
},
|
||||
nextVideoId: 100,
|
||||
nextTaskId: 1,
|
||||
authMeNullResponsesAfterLogin: options?.authMeNullResponsesAfterLogin ?? 0,
|
||||
|
||||
在新工单中引用
屏蔽一个用户