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;
|
||||
}
|
||||
Reference in New Issue
Block a user