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: "已上传",
|
||||
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 { 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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user