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,284 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from "vue";
import { createDanmakuEngine } from "./danmakuEngine";
import type { DanmakuEngine } from "./danmakuEngine";
import type { DanmakuEvent } from "@/types";
const props = withDefaults(
defineProps<{
videoSrc: string;
danmakuEvents: DanmakuEvent[];
autoplay?: boolean;
}>(),
{
autoplay: false,
}
);
const videoRef = ref<HTMLVideoElement | null>(null);
const overlayRef = ref<HTMLDivElement | null>(null);
let engine: DanmakuEngine | null = null;
const isPlaying = ref(false);
const hasError = ref(false);
const errorMessage = ref("");
function onPlay(): void {
isPlaying.value = true;
engine?.start();
}
function onPause(): void {
isPlaying.value = false;
engine?.stop();
}
function onSeeking(): void {
engine?.stop();
}
function onSeeked(): void {
if (!videoRef.value) return;
lastKnownTime = videoRef.value.currentTime;
if (isPlaying.value) {
engine?.start();
}
}
function onEnded(): void {
isPlaying.value = false;
engine?.stop();
}
function onVideoError(): void {
hasError.value = true;
errorMessage.value = "视频加载失败,请刷新预览票据后重试。";
}
function onLoadedMetadata(): void {
hasError.value = false;
errorMessage.value = "";
}
let lastKnownTime = 0;
function initEngine(): void {
if (!videoRef.value || !overlayRef.value) return;
// Clean up previous engine
engine?.destroy();
engine = createDanmakuEngine(
videoRef.value,
props.danmakuEvents,
overlayRef.value
);
if (isPlaying.value) {
engine.start();
}
}
watch(
() => props.danmakuEvents,
() => {
nextTick(() => {
if (videoRef.value && overlayRef.value) {
initEngine();
}
});
}
);
watch(
() => props.videoSrc,
() => {
hasError.value = false;
errorMessage.value = "";
isPlaying.value = false;
engine?.destroy();
engine = null;
}
);
onMounted(() => {
nextTick(() => {
if (videoRef.value && overlayRef.value) {
initEngine();
}
});
});
onUnmounted(() => {
engine?.destroy();
engine = null;
});
</script>
<template>
<div class="danmaku-player" :class="{ 'has-error': hasError }">
<!-- Error overlay -->
<div v-if="hasError" class="danmaku-player__error">
<p>{{ errorMessage }}</p>
</div>
<!-- Video element -->
<video
ref="videoRef"
:src="videoSrc"
:autoplay="autoplay"
:key="videoSrc"
class="danmaku-player__video"
controls
preload="auto"
crossorigin="anonymous"
@play="onPlay"
@pause="onPause"
@seeking="onSeeking"
@seeked="onSeeked"
@ended="onEnded"
@error="onVideoError"
@loadedmetadata="onLoadedMetadata"
/>
<!-- Danmaku overlay -->
<div
ref="overlayRef"
class="danmaku-player__overlay"
:class="{ 'is-paused': !isPlaying && !hasError }"
/>
</div>
</template>
<style scoped>
.danmaku-player {
position: relative;
width: 100%;
background: #09121d;
border-radius: 12px;
overflow: hidden;
box-shadow: var(--shadow-soft, 0 4px 16px rgba(0, 0, 0, 0.12));
}
.danmaku-player__video {
display: block;
width: 100%;
max-height: 72vh;
background: #09121d;
}
.danmaku-player__overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 10;
overflow: hidden;
}
.danmaku-player__overlay.is-paused :deep(.danmaku-chat) {
animation-play-state: paused !important;
transition: none !important;
}
.danmaku-player__error {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(9, 18, 29, 0.92);
color: #f0aa6b;
font-size: 14px;
text-align: center;
padding: 24px;
}
/* Danmaku chat messages (global styles since DOM elements are created by engine) */
:deep(.danmaku-chat) {
position: absolute;
white-space: nowrap;
text-shadow:
1px 1px 2px rgba(0, 0, 0, 0.9),
-1px -1px 2px rgba(0, 0, 0, 0.7);
pointer-events: auto;
font-weight: 700;
will-change: transform;
line-height: 1.2;
z-index: 11;
}
/* Danmaku event notifications (global styles since DOM elements are created by engine) */
:deep(.danmaku-event-notification) {
position: absolute;
bottom: 64px;
left: 50%;
transform: translateX(-50%);
padding: 8px 18px;
border-radius: 24px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
color: #fff;
font-size: 14px;
white-space: nowrap;
pointer-events: none;
z-index: 12;
animation: danmaku-event-in 0.3s ease-out, danmaku-event-out 0.4s ease-in 3.1s forwards;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.danmaku-event-notification--gift) {
border-left: 3px solid #f0aa6b;
}
:deep(.danmaku-event-notification--like) {
border-left: 3px solid #f25d8e;
}
:deep(.danmaku-event-notification--member) {
border-left: 3px solid #ffc53d;
}
:deep(.danmaku-event-notification--superchat) {
border-left: 3px solid #5dade2;
}
@keyframes danmaku-event-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(12px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes danmaku-event-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (max-width: 768px) {
.danmaku-player__video {
max-height: 50vh;
}
:deep(.danmaku-chat) {
font-size: 14px !important;
}
:deep(.danmaku-event-notification) {
bottom: 56px;
font-size: 12px;
padding: 6px 14px;
}
}
</style>
@@ -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<number>(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 ? `<strong>${escapeHtml(event.user)}</strong>` : "";
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;
}
@@ -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 };
}
+31
View File
@@ -777,3 +777,34 @@ export const uploadStatusLabelMap: Record<number, string> = {
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[];
}
+89 -2
View File
@@ -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<RecordPreviewTicket>(`/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) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<el-table-column label="操作" min-width="200">
<template #default="{ row }">
<el-button size="small" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
<el-button
size="small"
type="primary"
plain
:disabled="row.status !== 4 && row.status !== 6"
@click="openDanmakuReplay(row.recordTaskId, row.segmentIndex)"
>
弹幕回放
</el-button>
</template>
</el-table-column>
</el-table>
@@ -544,6 +597,24 @@ onMounted(loadDetail);
</div>
</el-card>
</template>
<!-- Danmaku Replay Dialog -->
<el-dialog
v-model="danmakuDialogVisible"
:title="danmakuDialogTitle"
width="90%"
:close-on-click-modal="false"
@close="closeDanmakuDialog"
>
<div v-if="danmakuPreviewLoading" class="preview-empty">正在准备预览资源…</div>
<div v-else-if="danmakuPreviewMessage" class="preview-empty">{{ danmakuPreviewMessage }}</div>
<DanmakuPlayer
v-else-if="danmakuPreviewUrl"
:video-src="danmakuPreviewUrl"
:danmaku-events="danmakuEvents"
/>
<div v-else class="preview-empty">无法加载该分片的预览</div>
</el-dialog>
</div>
</template>
@@ -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;
}
}
</style>
+50 -3
View File
@@ -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);
</script>
@@ -388,6 +422,14 @@ onMounted(loadDetailAndPreview);
<div class="preview-meta" v-if="previewExpiresAt">
票据有效至 {{ formatDate(previewExpiresAt) }}
</div>
<el-button
v-if="previewUrl && !previewLoading"
:type="showDanmaku ? 'primary' : 'default'"
:loading="danmakuLoading"
@click="toggleDanmaku"
>
{{ showDanmaku ? "关闭弹幕" : "弹幕回放" }}
</el-button>
<el-button
v-if="canManualTranscode"
type="primary"
@@ -400,9 +442,14 @@ onMounted(loadDetailAndPreview);
</div>
</div>
<div v-if="previewLoading" class="preview-empty">正在准备预览资源</div>
<div v-if="previewLoading || danmakuLoading" class="preview-empty">正在准备预览资源</div>
<DanmakuPlayer
v-else-if="previewUrl && showDanmaku"
:video-src="previewUrl"
:danmaku-events="danmakuEvents"
/>
<video
v-else-if="previewUrl"
v-else-if="previewUrl && !showDanmaku"
:key="previewUrl"
class="preview-player"
:src="previewUrl"
@@ -0,0 +1,22 @@
using LiveRecorder.Application.Models.RecordTasks;
namespace LiveRecorder.Application.Abstractions.Recording;
/// <summary>
/// Service for reading and parsing danmaku XML files produced by the recording system.
/// </summary>
public interface IDanmakuService
{
/// <summary>
/// Returns parsed danmaku events for a single recording task (one segment).
/// Returns null if the task does not exist or has no danmaku file.
/// </summary>
Task<DanmakuResponseDto?> GetTaskDanmakuAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
/// <summary>
/// Returns aggregated danmaku events for all tasks in a recording session.
/// Returns null if the session does not exist or has no tasks with danmaku.
/// Offsets for segments beyond the first are adjusted so they are relative to the session start.
/// </summary>
Task<SessionDanmakuResponseDto?> GetSessionDanmakuAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,96 @@
namespace LiveRecorder.Application.Models.RecordTasks;
/// <summary>
/// A single parsed danmaku event (chat message or non-chat event like gift/like/member/enter).
/// </summary>
public sealed class DanmakuEventDto
{
/// <summary>
/// Offset in seconds from the segment's video start time.
/// </summary>
public double OffsetSeconds { get; init; }
/// <summary>
/// Event type: "chat", "gift", "like", "member", "enter", "superchat", "live", "preparing", or platform-specific types.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// For chat: the message text. For non-chat events: a descriptive label (e.g., "gift: rose x1").
/// </summary>
public required string Content { get; init; }
public string? User { get; init; }
public string? UserId { get; init; }
/// <summary>
/// Hex color string for chat messages (e.g., "FFFFFF"). Only meaningful for chat events.
/// </summary>
public string? Color { get; init; }
/// <summary>
/// Font size for chat messages (e.g., 25). Only meaningful for chat events.
/// </summary>
public int? FontSize { get; init; }
/// <summary>
/// Display mode for chat messages (1 = scroll right-to-left). Only meaningful for chat events.
/// </summary>
public int? Mode { get; init; }
/// <summary>
/// Unix timestamp in milliseconds when the event occurred (from the platform or recorded time).
/// </summary>
public long? TimestampMs { get; init; }
/// <summary>
/// For gift events: the gift name (e.g., "rose").
/// </summary>
public string? GiftName { get; init; }
/// <summary>
/// For gift/like events: the repeat count.
/// </summary>
public int? Count { get; init; }
/// <summary>
/// Truncated raw payload from the platform (for debugging).
/// </summary>
public string? Raw { get; init; }
}
/// <summary>
/// Danmaku response for a single recording task (one segment).
/// </summary>
public sealed class DanmakuResponseDto
{
public Guid RecordTaskId { get; init; }
public int SegmentIndex { get; init; }
public string? Platform { get; init; }
public string? RoomId { get; init; }
public string? LiveRoomId { get; init; }
public Guid RecordSessionId { get; init; }
/// <summary>
/// The UTC time when this segment's recording started (anchor for offset calculation).
/// </summary>
public DateTimeOffset? StartedAt { get; init; }
public required IReadOnlyList<DanmakuEventDto> Events { get; init; }
}
/// <summary>
/// Aggregated danmaku response for an entire recording session (all segments).
/// </summary>
public sealed class SessionDanmakuResponseDto
{
public Guid RecordSessionId { get; init; }
public required IReadOnlyList<DanmakuResponseDto> Tasks { get; init; }
}
@@ -0,0 +1,318 @@
using System.Globalization;
using System.Xml;
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Domain.Entities;
namespace LiveRecorder.Infrastructure.Services;
public sealed class DanmakuService : IDanmakuService
{
private readonly IRecordTaskRepository _recordTaskRepository;
private readonly IRecordSessionRepository _recordSessionRepository;
public DanmakuService(
IRecordTaskRepository recordTaskRepository,
IRecordSessionRepository recordSessionRepository)
{
_recordTaskRepository = recordTaskRepository;
_recordSessionRepository = recordSessionRepository;
}
public async Task<DanmakuResponseDto?> GetTaskDanmakuAsync(
Guid recordTaskId,
CancellationToken cancellationToken = default)
{
var task = await _recordTaskRepository.GetByIdAsync(recordTaskId, cancellationToken);
if (task is null)
{
return null;
}
var danmakuPath = ResolveDanmakuPath(task);
if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath))
{
return null;
}
var taskStartedAt = task.StartedAt ?? task.CreatedAt;
var events = ParseDanmakuXml(danmakuPath);
return new DanmakuResponseDto
{
RecordTaskId = task.Id,
SegmentIndex = task.SegmentIndex,
Platform = task.LiveRoom?.Platform.ToString(),
RoomId = task.LiveRoom?.RoomId,
LiveRoomId = task.LiveRoomId.ToString(),
RecordSessionId = task.RecordSessionId,
StartedAt = taskStartedAt,
Events = events
};
}
public async Task<SessionDanmakuResponseDto?> GetSessionDanmakuAsync(
Guid recordSessionId,
CancellationToken cancellationToken = default)
{
var session = await _recordSessionRepository.GetByIdAsync(recordSessionId, cancellationToken);
if (session is null)
{
return null;
}
var tasks = await _recordTaskRepository.ListBySessionIdAsync(recordSessionId, cancellationToken);
if (tasks.Count == 0)
{
return null;
}
// Find the session anchor: the earliest task StartedAt (or CreatedAt)
var sessionAnchor = tasks
.Select(static task => task.StartedAt ?? task.CreatedAt)
.Min();
var taskResponses = new List<DanmakuResponseDto>(tasks.Count);
foreach (var task in tasks.OrderBy(static item => item.SegmentIndex).ThenBy(static item => item.CreatedAt))
{
var danmakuPath = ResolveDanmakuPath(task);
if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath))
{
continue;
}
var taskStartedAt = task.StartedAt ?? task.CreatedAt;
var events = ParseDanmakuXml(danmakuPath);
// Adjust offsets so they are relative to the session anchor (not the individual segment)
var segmentOffsetFromSessionAnchor = (taskStartedAt - sessionAnchor).TotalSeconds;
if (Math.Abs(segmentOffsetFromSessionAnchor) > 0.01)
{
events = events
.Select(item => new DanmakuEventDto
{
OffsetSeconds = item.OffsetSeconds + segmentOffsetFromSessionAnchor,
Type = item.Type,
Content = item.Content,
User = item.User,
UserId = item.UserId,
Color = item.Color,
FontSize = item.FontSize,
Mode = item.Mode,
TimestampMs = item.TimestampMs,
GiftName = item.GiftName,
Count = item.Count,
Raw = item.Raw
})
.ToList();
}
taskResponses.Add(new DanmakuResponseDto
{
RecordTaskId = task.Id,
SegmentIndex = task.SegmentIndex,
Platform = task.LiveRoom?.Platform.ToString(),
RoomId = task.LiveRoom?.RoomId,
LiveRoomId = task.LiveRoomId.ToString(),
RecordSessionId = task.RecordSessionId,
StartedAt = taskStartedAt,
Events = events
});
}
if (taskResponses.Count == 0)
{
return null;
}
return new SessionDanmakuResponseDto
{
RecordSessionId = recordSessionId,
Tasks = taskResponses
};
}
private static string? ResolveDanmakuPath(RecordTask task)
{
if (!string.IsNullOrWhiteSpace(task.Result?.DanmakuFilePath))
{
return task.Result.DanmakuFilePath;
}
if (string.IsNullOrWhiteSpace(task.OutputFilePath))
{
return null;
}
return Path.ChangeExtension(task.OutputFilePath, ".xml");
}
private static IReadOnlyList<DanmakuEventDto> ParseDanmakuXml(string danmakuPath)
{
var events = new List<DanmakuEventDto>();
try
{
var settings = new XmlReaderSettings
{
IgnoreComments = true,
IgnoreWhitespace = true,
DtdProcessing = DtdProcessing.Ignore
};
using var reader = XmlReader.Create(danmakuPath, settings);
while (reader.Read())
{
if (reader.NodeType != XmlNodeType.Element)
{
continue;
}
if (string.Equals(reader.Name, "d", StringComparison.OrdinalIgnoreCase))
{
var chatEvent = ParseChatElement(reader);
if (chatEvent is not null)
{
events.Add(chatEvent);
}
}
else if (string.Equals(reader.Name, "event", StringComparison.OrdinalIgnoreCase))
{
var nonChatEvent = ParseEventElement(reader);
if (nonChatEvent is not null)
{
events.Add(nonChatEvent);
}
}
}
}
catch
{
return [];
}
return events;
}
private static DanmakuEventDto? ParseChatElement(XmlReader reader)
{
// <d p="offsetSeconds,mode,fontSize,color,timestampMs,?,userId,?" user="..." type="chat" raw="...">content</d>
var payload = reader.GetAttribute("p");
var user = reader.GetAttribute("user");
var raw = reader.GetAttribute("raw");
double? offsetSeconds = null;
int? fontSize = null;
int? mode = null;
string? color = null;
long? timestampMs = null;
string? userId = null;
if (!string.IsNullOrWhiteSpace(payload))
{
var parts = payload.Split(',');
if (parts.Length >= 1 && double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedOffset))
{
offsetSeconds = parsedOffset;
}
if (parts.Length >= 2 && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedMode))
{
mode = parsedMode;
}
if (parts.Length >= 3 && int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedFontSize))
{
fontSize = parsedFontSize;
}
if (parts.Length >= 4)
{
color = string.IsNullOrWhiteSpace(parts[3]) ? null : parts[3].Trim();
}
if (parts.Length >= 5 && long.TryParse(parts[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedTs))
{
timestampMs = parsedTs;
}
if (parts.Length >= 7)
{
userId = string.IsNullOrWhiteSpace(parts[6]) ? null : parts[6].Trim();
}
}
if (!offsetSeconds.HasValue)
{
return null;
}
var content = reader.ReadInnerXml().Trim();
return new DanmakuEventDto
{
OffsetSeconds = Math.Max(0, offsetSeconds.Value),
Type = "chat",
Content = string.IsNullOrWhiteSpace(content) ? string.Empty : content,
User = NormalizeNullable(user),
UserId = NormalizeNullable(userId),
Color = NormalizeNullable(color) ?? "FFFFFF",
FontSize = fontSize ?? 25,
Mode = mode ?? 1,
TimestampMs = timestampMs,
Raw = NormalizeNullable(raw)
};
}
private static DanmakuEventDto? ParseEventElement(XmlReader reader)
{
// <event type="gift" ts="123" offset="12.3" user="name" userId="uid" content="desc" raw="..." [extraKey="extraValue"] ... />
var type = reader.GetAttribute("type");
var offset = reader.GetAttribute("offset");
var ts = reader.GetAttribute("ts");
var user = reader.GetAttribute("user");
var userId = reader.GetAttribute("userId");
var content = reader.GetAttribute("content");
var raw = reader.GetAttribute("raw");
if (!double.TryParse(offset, NumberStyles.Float, CultureInfo.InvariantCulture, out var offsetSeconds))
{
return null;
}
long? timestampMs = null;
if (long.TryParse(ts, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedTs))
{
timestampMs = parsedTs;
}
var giftName = reader.GetAttribute("giftName");
var count = reader.GetAttribute("count");
int? countValue = null;
if (int.TryParse(count, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedCount))
{
countValue = parsedCount;
}
return new DanmakuEventDto
{
OffsetSeconds = Math.Max(0, offsetSeconds),
Type = string.IsNullOrWhiteSpace(type) ? "other" : type.Trim(),
Content = NormalizeNullable(content) ?? string.Empty,
User = NormalizeNullable(user),
UserId = NormalizeNullable(userId),
Color = null,
FontSize = null,
Mode = null,
TimestampMs = timestampMs,
GiftName = NormalizeNullable(giftName),
Count = countValue,
Raw = NormalizeNullable(raw)
};
}
private static string? NormalizeNullable(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -1,6 +1,7 @@
using LiveRecorder.Application.Models.Cleanup;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
@@ -16,15 +17,18 @@ public sealed class RecordSessionsController : ControllerBase
private readonly RecordSessionService _recordSessionService;
private readonly RecordUploadService _recordUploadService;
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
private readonly IDanmakuService _danmakuService;
public RecordSessionsController(
RecordSessionService recordSessionService,
RecordUploadService recordUploadService,
CleanupOperationCoordinator cleanupOperationCoordinator)
CleanupOperationCoordinator cleanupOperationCoordinator,
IDanmakuService danmakuService)
{
_recordSessionService = recordSessionService;
_recordUploadService = recordUploadService;
_cleanupOperationCoordinator = cleanupOperationCoordinator;
_danmakuService = danmakuService;
}
[HttpGet]
@@ -100,4 +104,11 @@ public sealed class RecordSessionsController : ControllerBase
[HttpPost("{id:guid}/upload")]
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
Ok(await _recordUploadService.UploadSessionAsync(id, cancellationToken));
[HttpGet("{id:guid}/danmaku")]
public async Task<ActionResult<SessionDanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
{
var result = await _danmakuService.GetSessionDanmakuAsync(id, cancellationToken);
return result is null ? NotFound() : Ok(result);
}
}
@@ -13,17 +13,20 @@ public sealed class RecordTasksController : ControllerBase
private readonly RecordService _recordService;
private readonly RecordUploadService _recordUploadService;
private readonly IRecordMediaService _recordMediaService;
private readonly IDanmakuService _danmakuService;
private readonly LinkGenerator _linkGenerator;
public RecordTasksController(
RecordService recordService,
RecordUploadService recordUploadService,
IRecordMediaService recordMediaService,
IDanmakuService danmakuService,
LinkGenerator linkGenerator)
{
_recordService = recordService;
_recordUploadService = recordUploadService;
_recordMediaService = recordMediaService;
_danmakuService = danmakuService;
_linkGenerator = linkGenerator;
}
@@ -89,4 +92,11 @@ public sealed class RecordTasksController : ControllerBase
[HttpPost("{id:guid}/upload")]
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
Ok(await _recordUploadService.UploadTaskAsync(id, cancellationToken));
[HttpGet("{id:guid}/danmaku")]
public async Task<ActionResult<DanmakuResponseDto>> GetDanmaku(Guid id, CancellationToken cancellationToken)
{
var result = await _danmakuService.GetTaskDanmakuAsync(id, cancellationToken);
return result is null ? NotFound() : Ok(result);
}
}
+1
View File
@@ -183,6 +183,7 @@ builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
builder.Services.AddScoped<PlatformHttpClientFactory>();
builder.Services.AddScoped<PlatformHttpRequestService>();
builder.Services.AddScoped<RecordUploadService>();
builder.Services.AddScoped<IDanmakuService, DanmakuService>();
builder.Services.AddScoped<DatabaseInitializer>();
builder.Services.AddScoped<SqliteToPostgresMigrationService>();