feat: add danmaku replay player integration

- Add IDanmakuService interface and DanmakuService implementation to parse danmaku XML files
- Add GET /api/record-tasks/{id}/danmaku and GET /api/record-sessions/{id}/danmaku endpoints
- Add DanmakuPlayer Vue component with native video + CSS overlay danmaku rendering
- Add danmakuEngine.ts pure-TypeScript animation loop with binary search, track management, and event notifications
- Add useDanmakuPlayer composable for reusable danmaku data loading
- Integrate danmaku toggle button into RecordTaskDetailView
- Integrate danmaku replay modal dialog into RecordSessionDetailView segment table

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 18:09:43 +08:00
co-authored by Claude Opus 4.8
parent b2aaef093d
commit a5c2cc3202
12 changed files with 1305 additions and 6 deletions
@@ -0,0 +1,66 @@
import { ref } from "vue";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type { DanmakuEvent, DanmakuResponse, SessionDanmakuResponse } from "@/types";
export function useDanmakuPlayer() {
const danmakuEvents = ref<DanmakuEvent[]>([]);
const loading = ref(false);
const error = ref("");
async function loadTaskDanmaku(taskId: string): Promise<boolean> {
loading.value = true;
error.value = "";
try {
const { data } = await apiClient.get<DanmakuResponse>(`/record-tasks/${taskId}/danmaku`);
danmakuEvents.value = data.events;
return data.events.length > 0;
} catch (err) {
if ((err as { response?: { status?: number } })?.response?.status === 404) {
error.value = "该分片没有弹幕数据。";
} else {
error.value = getApiErrorMessage(err, "弹幕数据加载失败。");
}
danmakuEvents.value = [];
return false;
} finally {
loading.value = false;
}
}
async function loadSessionDanmaku(sessionId: string): Promise<boolean> {
loading.value = true;
error.value = "";
try {
const { data } = await apiClient.get<SessionDanmakuResponse>(`/record-sessions/${sessionId}/danmaku`);
// Flatten all task events into a single list with session-level offsets
const allEvents: DanmakuEvent[] = [];
for (const task of data.tasks) {
allEvents.push(...task.events);
}
// Sort by offset for proper playback order
allEvents.sort((a, b) => a.offsetSeconds - b.offsetSeconds);
danmakuEvents.value = allEvents;
return allEvents.length > 0;
} catch (err) {
if ((err as { response?: { status?: number } })?.response?.status === 404) {
error.value = "该场次没有弹幕数据。";
} else {
error.value = getApiErrorMessage(err, "弹幕数据加载失败。");
}
danmakuEvents.value = [];
return false;
} finally {
loading.value = false;
}
}
function clear(): void {
danmakuEvents.value = [];
error.value = "";
loading.value = false;
}
return { danmakuEvents, loading, error, loadTaskDanmaku, loadSessionDanmaku, clear };
}