From a5c2cc32023ffeabbf49bb2429281214506aed6d Mon Sep 17 00:00:00 2001 From: nanxun Date: Wed, 3 Jun 2026 18:07:47 +0800 Subject: [PATCH] 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 --- .../src/components/player/DanmakuPlayer.vue | 284 +++++++++++++++ .../src/components/player/danmakuEngine.ts | 326 ++++++++++++++++++ frontend/src/composables/useDanmakuPlayer.ts | 66 ++++ frontend/src/types.ts | 31 ++ .../src/views/RecordSessionDetailView.vue | 91 ++++- frontend/src/views/RecordTaskDetailView.vue | 53 ++- .../Abstractions/Recording/IDanmakuService.cs | 22 ++ .../Models/RecordTasks/DanmakuModels.cs | 96 ++++++ .../Services/DanmakuService.cs | 318 +++++++++++++++++ .../Controllers/RecordSessionsController.cs | 13 +- .../Controllers/RecordTasksController.cs | 10 + src/LiveRecorder.WebApi/Program.cs | 1 + 12 files changed, 1305 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/player/DanmakuPlayer.vue create mode 100644 frontend/src/components/player/danmakuEngine.ts create mode 100644 frontend/src/composables/useDanmakuPlayer.ts create mode 100644 src/LiveRecorder.Application/Abstractions/Recording/IDanmakuService.cs create mode 100644 src/LiveRecorder.Application/Models/RecordTasks/DanmakuModels.cs create mode 100644 src/LiveRecorder.Infrastructure/Services/DanmakuService.cs diff --git a/frontend/src/components/player/DanmakuPlayer.vue b/frontend/src/components/player/DanmakuPlayer.vue new file mode 100644 index 0000000..95d1f47 --- /dev/null +++ b/frontend/src/components/player/DanmakuPlayer.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/frontend/src/components/player/danmakuEngine.ts b/frontend/src/components/player/danmakuEngine.ts new file mode 100644 index 0000000..51d9e3a --- /dev/null +++ b/frontend/src/components/player/danmakuEngine.ts @@ -0,0 +1,326 @@ +import type { DanmakuEvent } from "@/types"; + +const CHAT_TRACK_COUNT = 12; +const TRACK_HEIGHT_PX = 32; +const CHAT_SCROLL_DURATION_SECONDS = 8; +const MAX_ACTIVE_CHATS = 80; +const MAX_ACTIVE_EVENTS = 3; +const EVENT_DISPLAY_DURATION_MS = 3500; +const LOOKBACK_SECONDS = 0.15; +const LOOKAHEAD_SECONDS = 0.1; + +interface ActiveChat { + id: string; + element: HTMLSpanElement; + trackIndex: number; + spawnTime: number; +} + +interface ActiveEventNotification { + id: string; + element: HTMLDivElement; + spawnTime: number; +} + +export interface DanmakuEngine { + start(): void; + stop(): void; + reset(): void; + destroy(): void; +} + +export function createDanmakuEngine( + video: HTMLVideoElement, + events: DanmakuEvent[], + overlay: HTMLElement +): DanmakuEngine { + let animationId = 0; + let running = false; + + // Pre-sort events by offsetSeconds + const sortedEvents = [...events].sort( + (a, b) => a.offsetSeconds - b.offsetSeconds + ); + + // Separate chats and non-chat events + const chatEvents = sortedEvents.filter((e) => e.type === "chat"); + const nonChatEvents = sortedEvents.filter((e) => e.type !== "chat"); + + let nextChatIndex = 0; + let nextEventIndex = 0; + + // Track occupancy + const activeChats: ActiveChat[] = []; + const activeEventNotifications: ActiveEventNotification[] = []; + + let lastTime = 0; + + function spawnChat(event: DanmakuEvent, currentTime: number): void { + // Garbage collect finished chats first + while ( + activeChats.length > 0 && + currentTime - activeChats[0].spawnTime > CHAT_SCROLL_DURATION_SECONDS + ) { + const finished = activeChats.shift()!; + if (finished.element.parentNode) { + finished.element.remove(); + } + } + + // Cap active chats + if (activeChats.length >= MAX_ACTIVE_CHATS) { + const oldest = activeChats.shift()!; + if (oldest.element.parentNode) { + oldest.element.remove(); + } + } + + // Pick the least-occupied track + const trackUsage = new Array(CHAT_TRACK_COUNT).fill(0); + for (const chat of activeChats) { + if (chat.trackIndex < CHAT_TRACK_COUNT) { + trackUsage[chat.trackIndex]++; + } + } + let bestTrack = 0; + let minUsage = Infinity; + // Add some randomness to avoid all chats on the same "best" track + const startTrack = Math.floor(Math.random() * CHAT_TRACK_COUNT); + for (let offset = 0; offset < CHAT_TRACK_COUNT; offset++) { + const trackIndex = (startTrack + offset) % CHAT_TRACK_COUNT; + if (trackUsage[trackIndex] < minUsage) { + minUsage = trackUsage[trackIndex]; + bestTrack = trackIndex; + } + } + + const element = document.createElement("span"); + element.className = "danmaku-chat"; + element.textContent = event.content || ""; + + const color = event.color || "FFFFFF"; + const fontSize = event.fontSize || 25; + const topPx = bestTrack * TRACK_HEIGHT_PX; + + element.style.cssText = [ + `color: #${color}`, + `font-size: ${fontSize}px`, + `top: ${topPx}px`, + "position: absolute", + "white-space: nowrap", + "text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8)", + "pointer-events: auto", + "font-weight: 700", + "will-change: transform", + "left: 100%", + `transform: translateX(0)`, + ].join("; "); + + overlay.appendChild(element); + + activeChats.push({ + id: `chat-${event.offsetSeconds}-${Math.random().toString(36).slice(2, 8)}`, + element, + trackIndex: bestTrack, + spawnTime: currentTime, + }); + } + + function spawnEventNotification(event: DanmakuEvent): void { + // Garbage collect finished notifications + const now = performance.now(); + while ( + activeEventNotifications.length > 0 && + now - activeEventNotifications[0].spawnTime > EVENT_DISPLAY_DURATION_MS + ) { + const finished = activeEventNotifications.shift()!; + if (finished.element.parentNode) { + finished.element.remove(); + } + } + + // Cap active notifications + if (activeEventNotifications.length >= MAX_ACTIVE_EVENTS) { + const oldest = activeEventNotifications.shift()!; + if (oldest.element.parentNode) { + oldest.element.remove(); + } + } + + const element = document.createElement("div"); + element.className = `danmaku-event-notification danmaku-event-notification--${event.type}`; + + const userLabel = event.user ? `${escapeHtml(event.user)}` : ""; + const contentLabel = escapeHtml(event.content || ""); + + let typeIcon = ""; + switch (event.type) { + case "gift": + typeIcon = "🎁 "; + break; + case "like": + typeIcon = "❤️ "; + break; + case "member": + typeIcon = "⭐ "; + break; + case "enter": + typeIcon = "👤 "; + break; + case "superchat": + typeIcon = "💬 "; + break; + default: + typeIcon = "📌 "; + break; + } + + element.innerHTML = `${typeIcon}${userLabel} ${contentLabel}`; + + overlay.appendChild(element); + + activeEventNotifications.push({ + id: `event-${event.offsetSeconds}-${Math.random().toString(36).slice(2, 8)}`, + element, + spawnTime: performance.now(), + }); + } + + function binarySearchFirst( + arr: DanmakuEvent[], + startIndex: number, + target: number + ): number { + let low = startIndex; + let high = arr.length - 1; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + if (arr[mid].offsetSeconds < target) { + low = mid + 1; + } else { + high = mid - 1; + } + } + return low; + } + + function tick(): void { + if (!running) return; + + const currentTime = video.currentTime; + + // If seeking backwards, reset + if (currentTime < lastTime - 0.5) { + resetState(); + } + lastTime = currentTime; + + const lookback = currentTime - LOOKBACK_SECONDS; + const lookahead = currentTime + LOOKAHEAD_SECONDS; + + // Binary search to find the start of new chats + nextChatIndex = binarySearchFirst(chatEvents, nextChatIndex, lookback); + // Spawn chats within the window + while (nextChatIndex < chatEvents.length && chatEvents[nextChatIndex].offsetSeconds <= lookahead) { + spawnChat(chatEvents[nextChatIndex], currentTime); + nextChatIndex++; + } + + // Same for non-chat events + nextEventIndex = binarySearchFirst(nonChatEvents, nextEventIndex, lookback); + while (nextEventIndex < nonChatEvents.length && nonChatEvents[nextEventIndex].offsetSeconds <= lookahead) { + spawnEventNotification(nonChatEvents[nextEventIndex]); + nextEventIndex++; + } + + // Update chat positions based on elapsed time since spawn + const overlayWidth = overlay.clientWidth || video.clientWidth || 640; + for (let i = activeChats.length - 1; i >= 0; i--) { + const chat = activeChats[i]; + const elapsed = currentTime - chat.spawnTime; + const progress = Math.max(0, Math.min(1, elapsed / CHAT_SCROLL_DURATION_SECONDS)); + const translateX = -overlayWidth * progress; + chat.element.style.transform = `translateX(${translateX}px)`; + + // Remove finished chats + if (elapsed > CHAT_SCROLL_DURATION_SECONDS + 0.5) { + if (chat.element.parentNode) { + chat.element.remove(); + } + activeChats.splice(i, 1); + } + } + + // Garbage collect finished event notifications + const now = performance.now(); + for (let i = activeEventNotifications.length - 1; i >= 0; i--) { + const notif = activeEventNotifications[i]; + if (now - notif.spawnTime > EVENT_DISPLAY_DURATION_MS) { + if (notif.element.parentNode) { + notif.element.remove(); + } + activeEventNotifications.splice(i, 1); + } + } + + animationId = requestAnimationFrame(tick); + } + + function resetState(): void { + // Clear all active elements + for (const chat of activeChats) { + if (chat.element.parentNode) { + chat.element.remove(); + } + } + activeChats.length = 0; + + for (const notif of activeEventNotifications) { + if (notif.element.parentNode) { + notif.element.remove(); + } + } + activeEventNotifications.length = 0; + + // Reset indices to the beginning + nextChatIndex = 0; + nextEventIndex = 0; + // Reset lastTime so a seek to 0 doesn't trigger another reset + lastTime = 0; + } + + function start(): void { + if (running) return; + running = true; + resetState(); + lastTime = video.currentTime; + animationId = requestAnimationFrame(tick); + } + + function stop(): void { + running = false; + if (animationId) { + cancelAnimationFrame(animationId); + animationId = 0; + } + } + + function reset(): void { + stop(); + resetState(); + start(); + } + + function destroy(): void { + stop(); + resetState(); + } + + return { start, stop, reset, destroy }; +} + +function escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} diff --git a/frontend/src/composables/useDanmakuPlayer.ts b/frontend/src/composables/useDanmakuPlayer.ts new file mode 100644 index 0000000..73e72a6 --- /dev/null +++ b/frontend/src/composables/useDanmakuPlayer.ts @@ -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([]); + const loading = ref(false); + const error = ref(""); + + async function loadTaskDanmaku(taskId: string): Promise { + loading.value = true; + error.value = ""; + + try { + const { data } = await apiClient.get(`/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 { + loading.value = true; + error.value = ""; + + try { + const { data } = await apiClient.get(`/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 }; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7437ef1..893d714 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -777,3 +777,34 @@ export const uploadStatusLabelMap: Record = { 1: "已上传", 2: "上传失败" }; + +export interface DanmakuEvent { + offsetSeconds: number; + type: string; + content: string; + user?: string; + userId?: string; + color?: string; + fontSize?: number; + mode?: number; + timestampMs?: number; + giftName?: string; + count?: number; + raw?: string; +} + +export interface DanmakuResponse { + recordTaskId: string; + segmentIndex: number; + platform?: string; + roomId?: string; + liveRoomId?: string; + recordSessionId: string; + startedAt?: string; + events: DanmakuEvent[]; +} + +export interface SessionDanmakuResponse { + recordSessionId: string; + tasks: DanmakuResponse[]; +} diff --git a/frontend/src/views/RecordSessionDetailView.vue b/frontend/src/views/RecordSessionDetailView.vue index d6e03db..8852619 100644 --- a/frontend/src/views/RecordSessionDetailView.vue +++ b/frontend/src/views/RecordSessionDetailView.vue @@ -3,9 +3,12 @@ import { computed, onMounted, ref, watch } from "vue"; import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import { useViewport } from "@/composables/useViewport"; -import apiClient, { getApiErrorMessage } from "@/api/client"; +import apiClient, { getApiErrorMessage, buildApiUrl } from "@/api/client"; +import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer"; +import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue"; import type { RecordArtifactUploadBatchResult, + RecordPreviewTicket, RecordSessionDetail, RecordSessionTimelineEvent, RecordSessionHeatBucket, @@ -28,6 +31,47 @@ const props = defineProps<{ const router = useRouter(); const { isMobile } = useViewport(); +// Danmaku replay dialog +const { danmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer(); +const danmakuDialogVisible = ref(false); +const danmakuDialogTitle = ref(""); +const danmakuPreviewUrl = ref(""); +const danmakuPreviewLoading = ref(false); +const danmakuPreviewMessage = ref(""); + +async function openDanmakuReplay(recordTaskId: string, segmentIndex: number) { + danmakuDialogVisible.value = true; + danmakuDialogTitle.value = `弹幕回放 — 分片 #${segmentIndex}`; + danmakuPreviewUrl.value = ""; + danmakuPreviewMessage.value = ""; + clearDanmaku(); + + // Load preview ticket and danmaku in parallel + danmakuPreviewLoading.value = true; + try { + const [ticketResult] = await Promise.allSettled([ + apiClient.post(`/record-tasks/${recordTaskId}/preview-ticket`), + loadTaskDanmaku(recordTaskId) + ]); + + if (ticketResult.status === "fulfilled") { + danmakuPreviewUrl.value = ticketResult.value.data.url; + } else { + danmakuPreviewMessage.value = "无法获取视频预览票据,请稍后重试。"; + } + } catch { + danmakuPreviewMessage.value = "加载预览资源失败。"; + } finally { + danmakuPreviewLoading.value = false; + } +} + +function closeDanmakuDialog() { + danmakuDialogVisible.value = false; + danmakuPreviewUrl.value = ""; + clearDanmaku(); +} + const loading = ref(false); const uploadLoading = ref(false); const loadError = ref(""); @@ -502,9 +546,18 @@ onMounted(loadDetail); {{ formatDuration(row.durationSeconds) }} - + @@ -544,6 +597,24 @@ onMounted(loadDetail); + + + +
正在准备预览资源…
+
{{ danmakuPreviewMessage }}
+ +
无法加载该分片的预览。
+
@@ -720,6 +791,17 @@ onMounted(loadDetail); color: var(--text-secondary); } +.preview-empty { + display: grid; + place-items: center; + min-height: 260px; + padding: 24px; + border-radius: 12px; + border: 1px dashed var(--border-base); + color: var(--text-muted); + background: var(--surface-muted); +} + @media (max-width: 768px) { .header-actions { width: 100%; @@ -730,5 +812,10 @@ onMounted(loadDetail); flex: 1 1 0; margin: 0; } + + .preview-empty { + min-height: 180px; + padding: 18px; + } } diff --git a/frontend/src/views/RecordTaskDetailView.vue b/frontend/src/views/RecordTaskDetailView.vue index fc35cdf..292b5c4 100644 --- a/frontend/src/views/RecordTaskDetailView.vue +++ b/frontend/src/views/RecordTaskDetailView.vue @@ -4,6 +4,8 @@ import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import apiClient, { getApiErrorMessage } from "@/api/client"; import { useViewport } from "@/composables/useViewport"; +import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer"; +import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue"; import type { ManualSegmentCompletedTriggerResult, RecordArtifactUploadItemResult, @@ -35,6 +37,29 @@ const previewUrl = ref(""); const previewExpiresAt = ref(""); const previewMessage = ref(""); const { isMobile } = useViewport(); + +// Danmaku replay state +const { danmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer(); +const showDanmaku = ref(false); +const danmakuLoaded = ref(false); + +async function toggleDanmaku() { + if (showDanmaku.value) { + showDanmaku.value = false; + return; + } + + if (!danmakuLoaded.value) { + const hasEvents = await loadTaskDanmaku(props.id); + if (!hasEvents && danmakuError.value) { + ElMessage.warning(danmakuError.value); + return; + } + danmakuLoaded.value = true; + } + + showDanmaku.value = true; +} const rowGutter = computed(() => (isMobile.value ? 14 : 18)); const logTableHeight = computed(() => (isMobile.value ? undefined : 420)); const activeTaskStatuses = new Set([1, 2, 3, 7]); @@ -234,7 +259,16 @@ function formatProgress(value?: number) { return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(0)}%` : "-"; } -watch(() => props.id, loadDetailAndPreview); +function resetDanmakuState() { + showDanmaku.value = false; + danmakuLoaded.value = false; + clearDanmaku(); +} + +watch(() => props.id, () => { + resetDanmakuState(); + loadDetailAndPreview(); +}); onMounted(loadDetailAndPreview); @@ -388,6 +422,14 @@ onMounted(loadDetailAndPreview);
票据有效至 {{ formatDate(previewExpiresAt) }}
+ + {{ showDanmaku ? "关闭弹幕" : "弹幕回放" }} + -
正在准备预览资源…
+
正在准备预览资源…
+