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:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -777,3 +777,34 @@ export const uploadStatusLabelMap: Record<number, string> = {
|
|||||||
1: "已上传",
|
1: "已上传",
|
||||||
2: "上传失败"
|
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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { computed, onMounted, ref, watch } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { useViewport } from "@/composables/useViewport";
|
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 {
|
import type {
|
||||||
RecordArtifactUploadBatchResult,
|
RecordArtifactUploadBatchResult,
|
||||||
|
RecordPreviewTicket,
|
||||||
RecordSessionDetail,
|
RecordSessionDetail,
|
||||||
RecordSessionTimelineEvent,
|
RecordSessionTimelineEvent,
|
||||||
RecordSessionHeatBucket,
|
RecordSessionHeatBucket,
|
||||||
@@ -28,6 +31,47 @@ const props = defineProps<{
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isMobile } = useViewport();
|
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 loading = ref(false);
|
||||||
const uploadLoading = ref(false);
|
const uploadLoading = ref(false);
|
||||||
const loadError = ref("");
|
const loadError = ref("");
|
||||||
@@ -502,9 +546,18 @@ onMounted(loadDetail);
|
|||||||
{{ formatDuration(row.durationSeconds) }}
|
{{ formatDuration(row.durationSeconds) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="120">
|
<el-table-column label="操作" min-width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -544,6 +597,24 @@ onMounted(loadDetail);
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -720,6 +791,17 @@ onMounted(loadDetail);
|
|||||||
color: var(--text-secondary);
|
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) {
|
@media (max-width: 768px) {
|
||||||
.header-actions {
|
.header-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -730,5 +812,10 @@ onMounted(loadDetail);
|
|||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
min-height: 180px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useRouter } from "vue-router";
|
|||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||||
import { useViewport } from "@/composables/useViewport";
|
import { useViewport } from "@/composables/useViewport";
|
||||||
|
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
|
||||||
|
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
|
||||||
import type {
|
import type {
|
||||||
ManualSegmentCompletedTriggerResult,
|
ManualSegmentCompletedTriggerResult,
|
||||||
RecordArtifactUploadItemResult,
|
RecordArtifactUploadItemResult,
|
||||||
@@ -35,6 +37,29 @@ const previewUrl = ref("");
|
|||||||
const previewExpiresAt = ref("");
|
const previewExpiresAt = ref("");
|
||||||
const previewMessage = ref("");
|
const previewMessage = ref("");
|
||||||
const { isMobile } = useViewport();
|
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 rowGutter = computed(() => (isMobile.value ? 14 : 18));
|
||||||
const logTableHeight = computed(() => (isMobile.value ? undefined : 420));
|
const logTableHeight = computed(() => (isMobile.value ? undefined : 420));
|
||||||
const activeTaskStatuses = new Set([1, 2, 3, 7]);
|
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)}%` : "-";
|
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);
|
onMounted(loadDetailAndPreview);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -388,6 +422,14 @@ onMounted(loadDetailAndPreview);
|
|||||||
<div class="preview-meta" v-if="previewExpiresAt">
|
<div class="preview-meta" v-if="previewExpiresAt">
|
||||||
票据有效至 {{ formatDate(previewExpiresAt) }}
|
票据有效至 {{ formatDate(previewExpiresAt) }}
|
||||||
</div>
|
</div>
|
||||||
|
<el-button
|
||||||
|
v-if="previewUrl && !previewLoading"
|
||||||
|
:type="showDanmaku ? 'primary' : 'default'"
|
||||||
|
:loading="danmakuLoading"
|
||||||
|
@click="toggleDanmaku"
|
||||||
|
>
|
||||||
|
{{ showDanmaku ? "关闭弹幕" : "弹幕回放" }}
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="canManualTranscode"
|
v-if="canManualTranscode"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -400,9 +442,14 @@ onMounted(loadDetailAndPreview);
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<video
|
||||||
v-else-if="previewUrl"
|
v-else-if="previewUrl && !showDanmaku"
|
||||||
:key="previewUrl"
|
:key="previewUrl"
|
||||||
class="preview-player"
|
class="preview-player"
|
||||||
:src="previewUrl"
|
: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.Cleanup;
|
||||||
using LiveRecorder.Application.Models.RecordTasks;
|
using LiveRecorder.Application.Models.RecordTasks;
|
||||||
using LiveRecorder.Application.Services;
|
using LiveRecorder.Application.Services;
|
||||||
|
using LiveRecorder.Application.Abstractions.Recording;
|
||||||
using LiveRecorder.Infrastructure.Services;
|
using LiveRecorder.Infrastructure.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -16,15 +17,18 @@ public sealed class RecordSessionsController : ControllerBase
|
|||||||
private readonly RecordSessionService _recordSessionService;
|
private readonly RecordSessionService _recordSessionService;
|
||||||
private readonly RecordUploadService _recordUploadService;
|
private readonly RecordUploadService _recordUploadService;
|
||||||
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
|
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
|
||||||
|
private readonly IDanmakuService _danmakuService;
|
||||||
|
|
||||||
public RecordSessionsController(
|
public RecordSessionsController(
|
||||||
RecordSessionService recordSessionService,
|
RecordSessionService recordSessionService,
|
||||||
RecordUploadService recordUploadService,
|
RecordUploadService recordUploadService,
|
||||||
CleanupOperationCoordinator cleanupOperationCoordinator)
|
CleanupOperationCoordinator cleanupOperationCoordinator,
|
||||||
|
IDanmakuService danmakuService)
|
||||||
{
|
{
|
||||||
_recordSessionService = recordSessionService;
|
_recordSessionService = recordSessionService;
|
||||||
_recordUploadService = recordUploadService;
|
_recordUploadService = recordUploadService;
|
||||||
_cleanupOperationCoordinator = cleanupOperationCoordinator;
|
_cleanupOperationCoordinator = cleanupOperationCoordinator;
|
||||||
|
_danmakuService = danmakuService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -100,4 +104,11 @@ public sealed class RecordSessionsController : ControllerBase
|
|||||||
[HttpPost("{id:guid}/upload")]
|
[HttpPost("{id:guid}/upload")]
|
||||||
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
||||||
Ok(await _recordUploadService.UploadSessionAsync(id, 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 RecordService _recordService;
|
||||||
private readonly RecordUploadService _recordUploadService;
|
private readonly RecordUploadService _recordUploadService;
|
||||||
private readonly IRecordMediaService _recordMediaService;
|
private readonly IRecordMediaService _recordMediaService;
|
||||||
|
private readonly IDanmakuService _danmakuService;
|
||||||
private readonly LinkGenerator _linkGenerator;
|
private readonly LinkGenerator _linkGenerator;
|
||||||
|
|
||||||
public RecordTasksController(
|
public RecordTasksController(
|
||||||
RecordService recordService,
|
RecordService recordService,
|
||||||
RecordUploadService recordUploadService,
|
RecordUploadService recordUploadService,
|
||||||
IRecordMediaService recordMediaService,
|
IRecordMediaService recordMediaService,
|
||||||
|
IDanmakuService danmakuService,
|
||||||
LinkGenerator linkGenerator)
|
LinkGenerator linkGenerator)
|
||||||
{
|
{
|
||||||
_recordService = recordService;
|
_recordService = recordService;
|
||||||
_recordUploadService = recordUploadService;
|
_recordUploadService = recordUploadService;
|
||||||
_recordMediaService = recordMediaService;
|
_recordMediaService = recordMediaService;
|
||||||
|
_danmakuService = danmakuService;
|
||||||
_linkGenerator = linkGenerator;
|
_linkGenerator = linkGenerator;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,4 +92,11 @@ public sealed class RecordTasksController : ControllerBase
|
|||||||
[HttpPost("{id:guid}/upload")]
|
[HttpPost("{id:guid}/upload")]
|
||||||
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
public async Task<ActionResult<RecordArtifactUploadItemResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
|
||||||
Ok(await _recordUploadService.UploadTaskAsync(id, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
|
|||||||
builder.Services.AddScoped<PlatformHttpClientFactory>();
|
builder.Services.AddScoped<PlatformHttpClientFactory>();
|
||||||
builder.Services.AddScoped<PlatformHttpRequestService>();
|
builder.Services.AddScoped<PlatformHttpRequestService>();
|
||||||
builder.Services.AddScoped<RecordUploadService>();
|
builder.Services.AddScoped<RecordUploadService>();
|
||||||
|
builder.Services.AddScoped<IDanmakuService, DanmakuService>();
|
||||||
builder.Services.AddScoped<DatabaseInitializer>();
|
builder.Services.AddScoped<DatabaseInitializer>();
|
||||||
builder.Services.AddScoped<SqliteToPostgresMigrationService>();
|
builder.Services.AddScoped<SqliteToPostgresMigrationService>();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user