增加自行上传弹幕
This commit is contained in:
+78
-1
@@ -46,7 +46,7 @@ import {
|
|||||||
saveDanmakuAnimeId,
|
saveDanmakuAnimeId,
|
||||||
getDanmakuAnimeId,
|
getDanmakuAnimeId,
|
||||||
} from '@/lib/danmaku/selection-memory';
|
} 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 { SearchResult, DanmakuFilterConfig, EpisodeFilterConfig } from '@/lib/types';
|
||||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
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) => {
|
const handleDanmakuSelect = async (selection: DanmakuSelection) => {
|
||||||
setCurrentDanmakuSelection(selection);
|
setCurrentDanmakuSelection(selection);
|
||||||
@@ -6421,6 +6497,7 @@ function PlayPageClient() {
|
|||||||
precomputedVideoInfo={precomputedVideoInfo}
|
precomputedVideoInfo={precomputedVideoInfo}
|
||||||
onDanmakuSelect={handleDanmakuSelect}
|
onDanmakuSelect={handleDanmakuSelect}
|
||||||
currentDanmakuSelection={currentDanmakuSelection}
|
currentDanmakuSelection={currentDanmakuSelection}
|
||||||
|
onUploadDanmaku={handleUploadDanmaku}
|
||||||
episodeFilterConfig={episodeFilterConfig}
|
episodeFilterConfig={episodeFilterConfig}
|
||||||
onFilterConfigUpdate={setEpisodeFilterConfig}
|
onFilterConfigUpdate={setEpisodeFilterConfig}
|
||||||
onShowToast={(message, type) => {
|
onShowToast={(message, type) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { getEpisodes, searchAnime } from '@/lib/danmaku/api';
|
import { getEpisodes, searchAnime } from '@/lib/danmaku/api';
|
||||||
import type {
|
import type {
|
||||||
DanmakuAnime,
|
DanmakuAnime,
|
||||||
|
DanmakuComment,
|
||||||
DanmakuEpisode,
|
DanmakuEpisode,
|
||||||
DanmakuSelection,
|
DanmakuSelection,
|
||||||
} from '@/lib/danmaku/types';
|
} from '@/lib/danmaku/types';
|
||||||
@@ -15,6 +16,7 @@ interface DanmakuPanelProps {
|
|||||||
currentEpisodeIndex: number;
|
currentEpisodeIndex: number;
|
||||||
onDanmakuSelect: (selection: DanmakuSelection) => void;
|
onDanmakuSelect: (selection: DanmakuSelection) => void;
|
||||||
currentSelection: DanmakuSelection | null;
|
currentSelection: DanmakuSelection | null;
|
||||||
|
onUploadDanmaku?: (comments: DanmakuComment[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DanmakuPanel({
|
export default function DanmakuPanel({
|
||||||
@@ -22,6 +24,7 @@ export default function DanmakuPanel({
|
|||||||
currentEpisodeIndex,
|
currentEpisodeIndex,
|
||||||
onDanmakuSelect,
|
onDanmakuSelect,
|
||||||
currentSelection,
|
currentSelection,
|
||||||
|
onUploadDanmaku,
|
||||||
}: DanmakuPanelProps) {
|
}: DanmakuPanelProps) {
|
||||||
const [searchKeyword, setSearchKeyword] = useState('');
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<DanmakuAnime[]>([]);
|
const [searchResults, setSearchResults] = useState<DanmakuAnime[]>([]);
|
||||||
@@ -31,6 +34,7 @@ export default function DanmakuPanel({
|
|||||||
const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false);
|
const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false);
|
||||||
const [searchError, setSearchError] = useState<string | null>(null);
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
const initializedRef = useRef(false); // 标记是否已初始化过
|
const initializedRef = useRef(false); // 标记是否已初始化过
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// 搜索弹幕
|
// 搜索弹幕
|
||||||
const handleSearch = useCallback(async (keyword: string) => {
|
const handleSearch = useCallback(async (keyword: string) => {
|
||||||
@@ -118,6 +122,38 @@ export default function DanmakuPanel({
|
|||||||
[currentSelection]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (videoTitle && !initializedRef.current) {
|
if (videoTitle && !initializedRef.current) {
|
||||||
@@ -369,6 +405,25 @@ export default function DanmakuPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Settings } from 'lucide-react';
|
|||||||
|
|
||||||
import DanmakuPanel from '@/components/DanmakuPanel';
|
import DanmakuPanel from '@/components/DanmakuPanel';
|
||||||
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
|
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 { SearchResult, EpisodeFilterConfig } from '@/lib/types';
|
||||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ interface EpisodeSelectorProps {
|
|||||||
/** 弹幕相关 */
|
/** 弹幕相关 */
|
||||||
onDanmakuSelect?: (selection: DanmakuSelection) => void;
|
onDanmakuSelect?: (selection: DanmakuSelection) => void;
|
||||||
currentDanmakuSelection?: DanmakuSelection | null;
|
currentDanmakuSelection?: DanmakuSelection | null;
|
||||||
|
onUploadDanmaku?: (comments: DanmakuComment[]) => void;
|
||||||
/** 观影室房员状态 - 禁用选集和换源,但保留弹幕 */
|
/** 观影室房员状态 - 禁用选集和换源,但保留弹幕 */
|
||||||
isRoomMember?: boolean;
|
isRoomMember?: boolean;
|
||||||
/** 集数过滤配置 */
|
/** 集数过滤配置 */
|
||||||
@@ -79,6 +80,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
precomputedVideoInfo,
|
precomputedVideoInfo,
|
||||||
onDanmakuSelect,
|
onDanmakuSelect,
|
||||||
currentDanmakuSelection,
|
currentDanmakuSelection,
|
||||||
|
onUploadDanmaku,
|
||||||
isRoomMember = false,
|
isRoomMember = false,
|
||||||
episodeFilterConfig = null,
|
episodeFilterConfig = null,
|
||||||
onFilterConfigUpdate,
|
onFilterConfigUpdate,
|
||||||
@@ -576,6 +578,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
currentEpisodeIndex={value - 1}
|
currentEpisodeIndex={value - 1}
|
||||||
onDanmakuSelect={onDanmakuSelect}
|
onDanmakuSelect={onDanmakuSelect}
|
||||||
currentSelection={currentDanmakuSelection || null}
|
currentSelection={currentDanmakuSelection || null}
|
||||||
|
onUploadDanmaku={onUploadDanmaku}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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