增加自行上传弹幕

This commit is contained in:
mtvpls
2026-01-05 00:52:17 +08:00
parent bc49b60c0b
commit fdf14dbae0
4 changed files with 167 additions and 2 deletions

View File

@@ -46,7 +46,7 @@ import {
saveDanmakuAnimeId,
getDanmakuAnimeId,
} from '@/lib/danmaku/selection-memory';
import type { DanmakuAnime, DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types';
import type { DanmakuAnime, DanmakuSelection, DanmakuSettings, DanmakuComment } from '@/lib/danmaku/types';
import { SearchResult, DanmakuFilterConfig, EpisodeFilterConfig } from '@/lib/types';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
@@ -3033,6 +3033,82 @@ function PlayPageClient() {
}
};
// 处理上传弹幕
const handleUploadDanmaku = async (comments: DanmakuComment[]) => {
setDanmakuLoading(true);
try {
// 缓存到IndexedDB
const title = videoTitleRef.current;
const episodeIndex = currentEpisodeIndexRef.current;
if (title) {
const { saveDanmakuToCache } = await import('@/lib/danmaku/cache');
await saveDanmakuToCache(title, episodeIndex, comments);
}
// 转换弹幕格式
let danmakuData = convertDanmakuFormat(comments);
// 应用过滤规则
const filterConfig = danmakuFilterConfigRef.current;
if (filterConfig && filterConfig.rules.length > 0) {
danmakuData = danmakuData.filter((danmu) => {
for (const rule of filterConfig.rules) {
if (!rule.enabled) continue;
try {
if (rule.type === 'normal') {
if (danmu.text.includes(rule.keyword)) return false;
} else if (rule.type === 'regex') {
if (new RegExp(rule.keyword).test(danmu.text)) return false;
}
} catch (e) {
console.error('弹幕过滤规则错误:', e);
}
}
return true;
});
}
// 加载弹幕到播放器
if (danmakuPluginRef.current) {
danmakuPluginRef.current.hide();
danmakuPluginRef.current.config({ danmuku: [] });
danmakuPluginRef.current.load();
const currentSettings = danmakuSettingsRef.current;
danmakuPluginRef.current.config({
danmuku: danmakuData,
speed: currentSettings.speed,
opacity: currentSettings.opacity,
fontSize: currentSettings.fontSize,
margin: [currentSettings.marginTop, currentSettings.marginBottom],
synchronousPlayback: currentSettings.synchronousPlayback,
});
danmakuPluginRef.current.load();
if (currentSettings.enabled) {
danmakuPluginRef.current.show();
} else {
danmakuPluginRef.current.hide();
}
}
setDanmakuCount(danmakuData.length);
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = `上传成功,共 ${danmakuData.length} 条弹幕`;
}
await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) {
console.error('上传弹幕失败:', error);
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '弹幕加载失败';
}
} finally {
setDanmakuLoading(false);
}
};
// 处理弹幕选择
const handleDanmakuSelect = async (selection: DanmakuSelection) => {
setCurrentDanmakuSelection(selection);
@@ -6421,6 +6497,7 @@ function PlayPageClient() {
precomputedVideoInfo={precomputedVideoInfo}
onDanmakuSelect={handleDanmakuSelect}
currentDanmakuSelection={currentDanmakuSelection}
onUploadDanmaku={handleUploadDanmaku}
episodeFilterConfig={episodeFilterConfig}
onFilterConfigUpdate={setEpisodeFilterConfig}
onShowToast={(message, type) => {

View File

@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getEpisodes, searchAnime } from '@/lib/danmaku/api';
import type {
DanmakuAnime,
DanmakuComment,
DanmakuEpisode,
DanmakuSelection,
} from '@/lib/danmaku/types';
@@ -15,6 +16,7 @@ interface DanmakuPanelProps {
currentEpisodeIndex: number;
onDanmakuSelect: (selection: DanmakuSelection) => void;
currentSelection: DanmakuSelection | null;
onUploadDanmaku?: (comments: DanmakuComment[]) => void;
}
export default function DanmakuPanel({
@@ -22,6 +24,7 @@ export default function DanmakuPanel({
currentEpisodeIndex,
onDanmakuSelect,
currentSelection,
onUploadDanmaku,
}: DanmakuPanelProps) {
const [searchKeyword, setSearchKeyword] = useState('');
const [searchResults, setSearchResults] = useState<DanmakuAnime[]>([]);
@@ -31,6 +34,7 @@ export default function DanmakuPanel({
const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const initializedRef = useRef(false); // 标记是否已初始化过
const fileInputRef = useRef<HTMLInputElement>(null);
// 搜索弹幕
const handleSearch = useCallback(async (keyword: string) => {
@@ -118,6 +122,38 @@ export default function DanmakuPanel({
[currentSelection]
);
// 处理文件上传
const handleFileUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.xml')) {
setSearchError('请上传XML格式的弹幕文件');
return;
}
try {
const text = await file.text();
const { parseXmlDanmaku } = await import('@/lib/danmaku/xml-parser');
const comments = parseXmlDanmaku(text);
if (comments.length === 0) {
setSearchError('弹幕文件解析失败或文件为空');
return;
}
onUploadDanmaku?.(comments);
setSearchError(null);
} catch (error) {
console.error('上传弹幕失败:', error);
setSearchError('弹幕文件解析失败');
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}, [onUploadDanmaku]);
// 当视频标题首次加载时,初始化搜索关键词(仅执行一次)
useEffect(() => {
if (videoTitle && !initializedRef.current) {
@@ -369,6 +405,25 @@ export default function DanmakuPanel({
)}
</div>
</div>
{/* 上传弹幕区域 - 固定在底部 */}
{onUploadDanmaku && (
<div className='mt-3 flex-shrink-0 border-t border-gray-200 pt-3 dark:border-gray-700'>
<input
ref={fileInputRef}
type='file'
accept='.xml'
onChange={handleFileUpload}
className='hidden'
/>
<button
onClick={() => fileInputRef.current?.click()}
className='w-full text-center text-xs text-gray-500 dark:text-gray-400 hover:text-green-500 dark:hover:text-green-400 transition-colors py-2'
>
</button>
</div>
)}
</div>
);
}

View File

@@ -12,7 +12,7 @@ import { Settings } from 'lucide-react';
import DanmakuPanel from '@/components/DanmakuPanel';
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
import type { DanmakuSelection } from '@/lib/danmaku/types';
import type { DanmakuSelection, DanmakuComment } from '@/lib/danmaku/types';
import { SearchResult, EpisodeFilterConfig } from '@/lib/types';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
@@ -51,6 +51,7 @@ interface EpisodeSelectorProps {
/** 弹幕相关 */
onDanmakuSelect?: (selection: DanmakuSelection) => void;
currentDanmakuSelection?: DanmakuSelection | null;
onUploadDanmaku?: (comments: DanmakuComment[]) => void;
/** 观影室房员状态 - 禁用选集和换源,但保留弹幕 */
isRoomMember?: boolean;
/** 集数过滤配置 */
@@ -79,6 +80,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
precomputedVideoInfo,
onDanmakuSelect,
currentDanmakuSelection,
onUploadDanmaku,
isRoomMember = false,
episodeFilterConfig = null,
onFilterConfigUpdate,
@@ -576,6 +578,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
currentEpisodeIndex={value - 1}
onDanmakuSelect={onDanmakuSelect}
currentSelection={currentDanmakuSelection || null}
onUploadDanmaku={onUploadDanmaku}
/>
</div>
)}

View File

@@ -0,0 +1,30 @@
import type { DanmakuComment } from './types';
/**
* 解析XML格式的弹幕文件
* @param xmlContent XML文件内容
* @returns 弹幕评论数组
*/
export function parseXmlDanmaku(xmlContent: string): DanmakuComment[] {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlContent, 'text/xml');
const dElements = xmlDoc.getElementsByTagName('d');
const comments: DanmakuComment[] = [];
for (let i = 0; i < dElements.length; i++) {
const element = dElements[i];
const p = element.getAttribute('p');
const text = element.textContent;
if (p && text) {
comments.push({
p,
m: text,
cid: i,
});
}
}
return comments;
}