diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 685a290..aee6c21 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -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) => { diff --git a/src/components/DanmakuPanel.tsx b/src/components/DanmakuPanel.tsx index 92e9ea1..97de8d7 100644 --- a/src/components/DanmakuPanel.tsx +++ b/src/components/DanmakuPanel.tsx @@ -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([]); @@ -31,6 +34,7 @@ export default function DanmakuPanel({ const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false); const [searchError, setSearchError] = useState(null); const initializedRef = useRef(false); // 标记是否已初始化过 + const fileInputRef = useRef(null); // 搜索弹幕 const handleSearch = useCallback(async (keyword: string) => { @@ -118,6 +122,38 @@ export default function DanmakuPanel({ [currentSelection] ); + // 处理文件上传 + const handleFileUpload = useCallback(async (event: React.ChangeEvent) => { + 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({ )} + + {/* 上传弹幕区域 - 固定在底部 */} + {onUploadDanmaku && ( +
+ + +
+ )} ); } diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 484ce8a..efca823 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -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 = ({ precomputedVideoInfo, onDanmakuSelect, currentDanmakuSelection, + onUploadDanmaku, isRoomMember = false, episodeFilterConfig = null, onFilterConfigUpdate, @@ -576,6 +578,7 @@ const EpisodeSelector: React.FC = ({ currentEpisodeIndex={value - 1} onDanmakuSelect={onDanmakuSelect} currentSelection={currentDanmakuSelection || null} + onUploadDanmaku={onUploadDanmaku} /> )} diff --git a/src/lib/danmaku/xml-parser.ts b/src/lib/danmaku/xml-parser.ts new file mode 100644 index 0000000..c8a1301 --- /dev/null +++ b/src/lib/danmaku/xml-parser.ts @@ -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; +}