增加自行上传弹幕
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
30
src/lib/danmaku/xml-parser.ts
Normal file
30
src/lib/danmaku/xml-parser.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user