- 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>
285 lines
5.5 KiB
Vue
285 lines
5.5 KiB
Vue
<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>
|