新增弹幕功能
This commit is contained in:
+57
-1
@@ -267,6 +267,8 @@ interface SiteConfig {
|
||||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
DanmakuApiBase: string;
|
||||
DanmakuApiToken: string;
|
||||
}
|
||||
|
||||
// 视频源数据类型
|
||||
@@ -3393,6 +3395,8 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
||||
DoubanImageProxy: '',
|
||||
DisableYellowFilter: false,
|
||||
FluidSearch: true,
|
||||
DanmakuApiBase: 'http://localhost:9321',
|
||||
DanmakuApiToken: '87654321',
|
||||
});
|
||||
|
||||
// 豆瓣数据源相关状态
|
||||
@@ -3455,6 +3459,8 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
||||
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
|
||||
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
|
||||
FluidSearch: config.SiteConfig.FluidSearch || true,
|
||||
DanmakuApiBase: config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
|
||||
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
@@ -3903,10 +3909,60 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
启用后搜索结果将实时流式返回,提升用户体验。
|
||||
启用后搜索结果将实时流式返回,提升用户体验。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 弹幕 API 配置 */}
|
||||
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||
弹幕配置
|
||||
</h3>
|
||||
|
||||
{/* 弹幕 API 地址 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
弹幕 API 地址
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='http://localhost:9321'
|
||||
value={siteSettings.DanmakuApiBase}
|
||||
onChange={(e) =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
DanmakuApiBase: e.target.value,
|
||||
}))
|
||||
}
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
弹幕服务器的 API 地址,默认为 http://localhost:9321
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 弹幕 API Token */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
弹幕 API Token
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='87654321'
|
||||
value={siteSettings.DanmakuApiToken}
|
||||
onChange={(e) =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
DanmakuApiToken: e.target.value,
|
||||
}))
|
||||
}
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
弹幕服务器的访问令牌,默认为 87654321
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex justify-end'>
|
||||
|
||||
@@ -39,6 +39,8 @@ export async function POST(request: NextRequest) {
|
||||
DoubanImageProxy,
|
||||
DisableYellowFilter,
|
||||
FluidSearch,
|
||||
DanmakuApiBase,
|
||||
DanmakuApiToken,
|
||||
} = body as {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
@@ -50,6 +52,8 @@ export async function POST(request: NextRequest) {
|
||||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
DanmakuApiBase: string;
|
||||
DanmakuApiToken: string;
|
||||
};
|
||||
|
||||
// 参数校验
|
||||
@@ -63,7 +67,9 @@ export async function POST(request: NextRequest) {
|
||||
typeof DoubanImageProxyType !== 'string' ||
|
||||
typeof DoubanImageProxy !== 'string' ||
|
||||
typeof DisableYellowFilter !== 'boolean' ||
|
||||
typeof FluidSearch !== 'boolean'
|
||||
typeof FluidSearch !== 'boolean' ||
|
||||
typeof DanmakuApiBase !== 'string' ||
|
||||
typeof DanmakuApiToken !== 'string'
|
||||
) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
@@ -93,6 +99,8 @@ export async function POST(request: NextRequest) {
|
||||
DoubanImageProxy,
|
||||
DisableYellowFilter,
|
||||
FluidSearch,
|
||||
DanmakuApiBase,
|
||||
DanmakuApiToken,
|
||||
};
|
||||
|
||||
// 写入数据库
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// 获取弹幕 API 路由
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 解析弹幕 XML 为 JSON
|
||||
function parseXmlDanmaku(xmlText: string): Array<{ p: string; m: string; cid: number }> {
|
||||
const comments: Array<{ p: string; m: string; cid: number }> = [];
|
||||
|
||||
// 使用正则表达式提取所有 <d> 标签
|
||||
const dTagRegex = /<d\s+p="([^"]+)"[^>]*>([^<]*)<\/d>/g;
|
||||
let match;
|
||||
|
||||
while ((match = dTagRegex.exec(xmlText)) !== null) {
|
||||
const p = match[1];
|
||||
const m = match[2];
|
||||
|
||||
// 从 p 属性中提取 cid(弹幕ID)
|
||||
const pParts = p.split(',');
|
||||
const cid = pParts[7] ? parseInt(pParts[7]) : 0;
|
||||
|
||||
comments.push({
|
||||
p,
|
||||
m,
|
||||
cid,
|
||||
});
|
||||
}
|
||||
|
||||
return comments;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const episodeId = searchParams.get('episodeId');
|
||||
const url = searchParams.get('url');
|
||||
|
||||
// 至少需要一个参数
|
||||
if (!episodeId && !url) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
count: 0,
|
||||
comments: [],
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 从数据库读取弹幕配置
|
||||
const config = await getConfig();
|
||||
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
||||
|
||||
// 构建 API URL
|
||||
const baseUrl =
|
||||
DanmakuApiToken === '87654321'
|
||||
? DanmakuApiBase
|
||||
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
||||
|
||||
let apiUrl: string;
|
||||
|
||||
if (episodeId) {
|
||||
// 通过剧集 ID 获取弹幕 - 使用 XML 格式
|
||||
apiUrl = `${baseUrl}/api/v2/comment/${episodeId}?format=xml`;
|
||||
} else {
|
||||
// 通过视频 URL 获取弹幕 - 使用 XML 格式
|
||||
apiUrl = `${baseUrl}/api/v2/comment?url=${encodeURIComponent(url!)}&format=xml`;
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/xml, text/xml',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// 获取 XML 文本
|
||||
const xmlText = await response.text();
|
||||
|
||||
// 解析 XML 为 JSON
|
||||
const comments = parseXmlDanmaku(xmlText);
|
||||
|
||||
return NextResponse.json({
|
||||
count: comments.length,
|
||||
comments,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取弹幕代理错误:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
count: 0,
|
||||
comments: [],
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// 获取剧集列表 API 路由
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const animeId = searchParams.get('animeId');
|
||||
|
||||
if (!animeId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
errorCode: -1,
|
||||
success: false,
|
||||
errorMessage: '缺少动漫ID参数',
|
||||
bangumi: {
|
||||
bangumiId: '',
|
||||
animeTitle: '',
|
||||
episodes: [],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 从数据库读取弹幕配置
|
||||
const config = await getConfig();
|
||||
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
||||
|
||||
// 构建 API URL
|
||||
const baseUrl =
|
||||
DanmakuApiToken === '87654321'
|
||||
? DanmakuApiBase
|
||||
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
||||
|
||||
const apiUrl = `${baseUrl}/api/v2/bangumi/${animeId}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('获取剧集列表代理错误:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
errorCode: -1,
|
||||
success: false,
|
||||
errorMessage:
|
||||
error instanceof Error ? error.message : '获取剧集列表失败',
|
||||
bangumi: {
|
||||
bangumiId: '',
|
||||
animeTitle: '',
|
||||
episodes: [],
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// 自动匹配 API 路由
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { fileName } = body;
|
||||
|
||||
if (!fileName) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
errorCode: -1,
|
||||
success: false,
|
||||
errorMessage: '缺少文件名参数',
|
||||
isMatched: false,
|
||||
matches: [],
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 从数据库读取弹幕配置
|
||||
const config = await getConfig();
|
||||
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
||||
|
||||
// 构建 API URL
|
||||
const baseUrl =
|
||||
DanmakuApiToken === '87654321'
|
||||
? DanmakuApiBase
|
||||
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
||||
|
||||
const apiUrl = `${baseUrl}/api/v2/match`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ fileName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('自动匹配代理错误:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
errorCode: -1,
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : '匹配失败',
|
||||
isMatched: false,
|
||||
matches: [],
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// 弹幕搜索 API 路由
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const keyword = searchParams.get('keyword');
|
||||
|
||||
if (!keyword) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
errorCode: -1,
|
||||
success: false,
|
||||
errorMessage: '缺少关键词参数',
|
||||
animes: [],
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 从数据库读取弹幕配置
|
||||
const config = await getConfig();
|
||||
const { DanmakuApiBase, DanmakuApiToken } = config.SiteConfig;
|
||||
|
||||
// 构建 API URL
|
||||
const baseUrl =
|
||||
DanmakuApiToken === '87654321'
|
||||
? DanmakuApiBase
|
||||
: `${DanmakuApiBase}/${DanmakuApiToken}`;
|
||||
|
||||
const apiUrl = `${baseUrl}/api/v2/search/anime?keyword=${encodeURIComponent(keyword)}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('弹幕搜索代理错误:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
errorCode: -1,
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : '搜索失败',
|
||||
animes: [],
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+398
-18
@@ -3,6 +3,7 @@
|
||||
'use client';
|
||||
|
||||
import Artplayer from 'artplayer';
|
||||
import artplayerPluginDanmuku from 'artplayer-plugin-danmuku';
|
||||
import Hls from 'hls.js';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
@@ -23,6 +24,17 @@ import {
|
||||
saveSkipConfig,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
import {
|
||||
convertDanmakuFormat,
|
||||
getDanmakuById,
|
||||
getEpisodes,
|
||||
loadDanmakuMemory,
|
||||
loadDanmakuSettings,
|
||||
saveDanmakuMemory,
|
||||
saveDanmakuSettings,
|
||||
searchAnime,
|
||||
} from '@/lib/danmaku/api';
|
||||
import type { DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||
|
||||
@@ -153,6 +165,23 @@ function PlayPageClient() {
|
||||
checkWebGPUSupport();
|
||||
}, []);
|
||||
|
||||
// 弹幕相关状态
|
||||
const [danmakuSettings, setDanmakuSettings] = useState<DanmakuSettings>(
|
||||
loadDanmakuSettings()
|
||||
);
|
||||
const [currentDanmakuSelection, setCurrentDanmakuSelection] =
|
||||
useState<DanmakuSelection | null>(null);
|
||||
const [danmakuEpisodesList, setDanmakuEpisodesList] = useState<
|
||||
Array<{ episodeId: number; episodeTitle: string }>
|
||||
>([]);
|
||||
const [danmakuLoading, setDanmakuLoading] = useState(false);
|
||||
const danmakuPluginRef = useRef<any>(null);
|
||||
const danmakuSettingsRef = useRef(danmakuSettings);
|
||||
|
||||
useEffect(() => {
|
||||
danmakuSettingsRef.current = danmakuSettings;
|
||||
}, [danmakuSettings]);
|
||||
|
||||
// 视频基本信息
|
||||
const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || '');
|
||||
const [videoYear, setVideoYear] = useState(searchParams.get('year') || '');
|
||||
@@ -203,6 +232,31 @@ function PlayPageClient() {
|
||||
videoYear,
|
||||
]);
|
||||
|
||||
// 监听剧集切换,自动加载对应的弹幕
|
||||
useEffect(() => {
|
||||
// 只有在有弹幕选择且有剧集列表时才自动切换
|
||||
if (
|
||||
currentDanmakuSelection &&
|
||||
danmakuEpisodesList.length > 0 &&
|
||||
currentEpisodeIndex < danmakuEpisodesList.length
|
||||
) {
|
||||
const episode = danmakuEpisodesList[currentEpisodeIndex];
|
||||
if (episode && episode.episodeId !== currentDanmakuSelection.episodeId) {
|
||||
// 自动加载新集数的弹幕
|
||||
const newSelection: DanmakuSelection = {
|
||||
animeId: currentDanmakuSelection.animeId,
|
||||
episodeId: episode.episodeId,
|
||||
animeTitle: currentDanmakuSelection.animeTitle,
|
||||
episodeTitle: episode.episodeTitle,
|
||||
};
|
||||
setCurrentDanmakuSelection(newSelection);
|
||||
loadDanmaku(episode.episodeId);
|
||||
console.log(`自动切换弹幕到第 ${currentEpisodeIndex + 1} 集`);
|
||||
}
|
||||
}
|
||||
}, [currentEpisodeIndex]);
|
||||
|
||||
|
||||
// 视频播放地址
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
|
||||
@@ -1091,24 +1145,10 @@ function PlayPageClient() {
|
||||
setLoadingStage('ready');
|
||||
setLoadingMessage('✨ 准备就绪,即将开始播放...');
|
||||
|
||||
// 短暂延迟让用户看到完成状态
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
initAll();
|
||||
}, []);
|
||||
|
||||
// 播放记录处理
|
||||
useEffect(() => {
|
||||
// 仅在初次挂载时检查播放记录
|
||||
const initFromHistory = async () => {
|
||||
if (!currentSource || !currentId) return;
|
||||
|
||||
// 加载播放记录
|
||||
try {
|
||||
const allRecords = await getAllPlayRecords();
|
||||
const key = generateStorageKey(currentSource, currentId);
|
||||
const key = generateStorageKey(detailData.source, detailData.id);
|
||||
const record = allRecords[key];
|
||||
|
||||
if (record) {
|
||||
@@ -1116,8 +1156,9 @@ function PlayPageClient() {
|
||||
const targetTime = record.play_time;
|
||||
|
||||
// 更新当前选集索引
|
||||
if (targetIndex !== currentEpisodeIndex) {
|
||||
if (targetIndex < detailData.episodes.length && targetIndex >= 0) {
|
||||
setCurrentEpisodeIndex(targetIndex);
|
||||
currentEpisodeIndexRef.current = targetIndex;
|
||||
}
|
||||
|
||||
// 保存待恢复的播放进度,待播放器就绪后跳转
|
||||
@@ -1126,9 +1167,14 @@ function PlayPageClient() {
|
||||
} catch (err) {
|
||||
console.error('读取播放记录失败:', err);
|
||||
}
|
||||
|
||||
// 短暂延迟让用户看到完成状态
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
initFromHistory();
|
||||
initAll();
|
||||
}, []);
|
||||
|
||||
// 跳过片头片尾配置处理
|
||||
@@ -1282,6 +1328,175 @@ function PlayPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 弹幕处理函数
|
||||
// ---------------------------------------------------------------------------
|
||||
// 加载弹幕到播放器
|
||||
const loadDanmaku = async (episodeId: number) => {
|
||||
if (!danmakuPluginRef.current) {
|
||||
console.warn('弹幕插件未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
setDanmakuLoading(true);
|
||||
|
||||
try {
|
||||
// 先清空当前弹幕
|
||||
danmakuPluginRef.current.config({
|
||||
danmuku: [],
|
||||
});
|
||||
danmakuPluginRef.current.load();
|
||||
|
||||
// 获取弹幕数据
|
||||
const comments = await getDanmakuById(episodeId);
|
||||
|
||||
if (comments.length === 0) {
|
||||
console.warn('未获取到弹幕数据');
|
||||
setDanmakuLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 转换弹幕格式
|
||||
const danmakuData = convertDanmakuFormat(comments);
|
||||
|
||||
// 加载弹幕到插件
|
||||
danmakuPluginRef.current.config({
|
||||
danmuku: danmakuData,
|
||||
});
|
||||
danmakuPluginRef.current.load();
|
||||
|
||||
console.log(`弹幕加载成功,共 ${comments.length} 条`);
|
||||
} catch (error) {
|
||||
console.error('加载弹幕失败:', error);
|
||||
} finally {
|
||||
setDanmakuLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理弹幕选择
|
||||
const handleDanmakuSelect = async (selection: DanmakuSelection) => {
|
||||
setCurrentDanmakuSelection(selection);
|
||||
|
||||
// 保存选择记忆
|
||||
saveDanmakuMemory(
|
||||
videoTitleRef.current,
|
||||
selection.animeId,
|
||||
selection.episodeId,
|
||||
selection.animeTitle,
|
||||
selection.episodeTitle
|
||||
);
|
||||
|
||||
// 获取该动漫的所有剧集列表
|
||||
try {
|
||||
const episodesResult = await getEpisodes(selection.animeId);
|
||||
if (episodesResult.success && episodesResult.bangumi.episodes.length > 0) {
|
||||
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取弹幕剧集列表失败:', error);
|
||||
}
|
||||
|
||||
// 加载弹幕
|
||||
await loadDanmaku(selection.episodeId);
|
||||
};
|
||||
|
||||
// 自动搜索并加载弹幕
|
||||
const autoSearchDanmaku = async () => {
|
||||
const title = videoTitleRef.current;
|
||||
if (!title) {
|
||||
console.warn('视频标题为空,无法自动搜索弹幕');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有记忆
|
||||
const memory = loadDanmakuMemory(title);
|
||||
if (memory) {
|
||||
console.log('使用记忆的弹幕选择:', memory);
|
||||
setCurrentDanmakuSelection({
|
||||
animeId: memory.animeId,
|
||||
episodeId: memory.episodeId,
|
||||
animeTitle: memory.animeTitle,
|
||||
episodeTitle: memory.episodeTitle,
|
||||
});
|
||||
|
||||
// 获取该动漫的所有剧集列表
|
||||
try {
|
||||
const episodesResult = await getEpisodes(memory.animeId);
|
||||
if (episodesResult.success && episodesResult.bangumi.episodes.length > 0) {
|
||||
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取弹幕剧集列表失败:', error);
|
||||
}
|
||||
|
||||
await loadDanmaku(memory.episodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动搜索弹幕
|
||||
setDanmakuLoading(true);
|
||||
|
||||
try {
|
||||
const searchResult = await searchAnime(title);
|
||||
|
||||
if (searchResult.success && searchResult.animes.length > 0) {
|
||||
// 使用第一个搜索结果
|
||||
const anime = searchResult.animes[0];
|
||||
|
||||
// 获取剧集列表
|
||||
const episodesResult = await getEpisodes(anime.animeId);
|
||||
|
||||
if (
|
||||
episodesResult.success &&
|
||||
episodesResult.bangumi.episodes.length > 0
|
||||
) {
|
||||
// 保存剧集列表
|
||||
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
|
||||
|
||||
// 根据当前集数选择对应的弹幕
|
||||
const currentEp = currentEpisodeIndexRef.current;
|
||||
const episode =
|
||||
episodesResult.bangumi.episodes[
|
||||
Math.min(currentEp, episodesResult.bangumi.episodes.length - 1)
|
||||
];
|
||||
|
||||
if (episode) {
|
||||
const selection: DanmakuSelection = {
|
||||
animeId: anime.animeId,
|
||||
episodeId: episode.episodeId,
|
||||
animeTitle: anime.animeTitle,
|
||||
episodeTitle: episode.episodeTitle,
|
||||
};
|
||||
|
||||
setCurrentDanmakuSelection(selection);
|
||||
|
||||
// 保存选择记忆
|
||||
saveDanmakuMemory(
|
||||
title,
|
||||
selection.animeId,
|
||||
selection.episodeId,
|
||||
selection.animeTitle,
|
||||
selection.episodeTitle
|
||||
);
|
||||
|
||||
// 加载弹幕
|
||||
await loadDanmaku(episode.episodeId);
|
||||
|
||||
console.log('自动搜索弹幕成功:', selection);
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到剧集信息');
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到匹配的弹幕');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('自动搜索弹幕失败:', error);
|
||||
} finally {
|
||||
setDanmakuLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 键盘快捷键
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1676,6 +1891,35 @@ function PlayPageClient() {
|
||||
});
|
||||
},
|
||||
},
|
||||
// 弹幕插件
|
||||
plugins: [
|
||||
artplayerPluginDanmuku({
|
||||
danmuku: [],
|
||||
speed: danmakuSettingsRef.current.speed,
|
||||
opacity: danmakuSettingsRef.current.opacity,
|
||||
fontSize: danmakuSettingsRef.current.fontSize,
|
||||
color: '#FFFFFF',
|
||||
mode: 0,
|
||||
margin: [danmakuSettingsRef.current.marginTop, danmakuSettingsRef.current.marginBottom],
|
||||
antiOverlap: true,
|
||||
synchronousPlayback: danmakuSettingsRef.current.synchronousPlayback,
|
||||
filter: (danmu: any) => {
|
||||
// 应用过滤规则
|
||||
if (danmakuSettingsRef.current.filterRules.length > 0) {
|
||||
for (const rule of danmakuSettingsRef.current.filterRules) {
|
||||
try {
|
||||
if (new RegExp(rule).test(danmu.text)) {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('弹幕过滤规则错误:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
],
|
||||
icons: {
|
||||
loading:
|
||||
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
|
||||
@@ -1707,6 +1951,115 @@ function PlayPageClient() {
|
||||
return newVal ? '当前开启' : '当前关闭';
|
||||
},
|
||||
},
|
||||
// 弹幕开关
|
||||
{
|
||||
name: '弹幕开关',
|
||||
html: '弹幕开关',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z" fill="#ffffff"/><text x="12" y="13" font-size="8" text-anchor="middle" fill="#ffffff">弹</text></svg>',
|
||||
switch: danmakuSettingsRef.current.enabled,
|
||||
onSwitch: function (item: any) {
|
||||
const newSettings = {
|
||||
...danmakuSettingsRef.current,
|
||||
enabled: !item.switch,
|
||||
};
|
||||
setDanmakuSettings(newSettings);
|
||||
saveDanmakuSettings(newSettings);
|
||||
|
||||
// 切换弹幕显示/隐藏
|
||||
if (danmakuPluginRef.current) {
|
||||
if (newSettings.enabled) {
|
||||
danmakuPluginRef.current.show();
|
||||
} else {
|
||||
danmakuPluginRef.current.hide();
|
||||
}
|
||||
}
|
||||
|
||||
return !item.switch;
|
||||
},
|
||||
},
|
||||
// 弹幕不透明度
|
||||
{
|
||||
name: '弹幕不透明度',
|
||||
html: '弹幕不透明度',
|
||||
selector: [
|
||||
{ html: '10%', value: '0.1' },
|
||||
{ html: '25%', value: '0.25' },
|
||||
{ html: '50%', value: '0.5' },
|
||||
{ html: '75%', value: '0.75', default: true },
|
||||
{ html: '100%', value: '1.0' },
|
||||
],
|
||||
onSelect: function (item: any) {
|
||||
const opacity = parseFloat(item.value);
|
||||
const newSettings = {
|
||||
...danmakuSettingsRef.current,
|
||||
opacity,
|
||||
};
|
||||
setDanmakuSettings(newSettings);
|
||||
saveDanmakuSettings(newSettings);
|
||||
|
||||
// 更新弹幕插件配置
|
||||
if (danmakuPluginRef.current) {
|
||||
danmakuPluginRef.current.config({ opacity });
|
||||
}
|
||||
|
||||
return item.html;
|
||||
},
|
||||
},
|
||||
// 弹幕字体大小
|
||||
{
|
||||
name: '弹幕字体大小',
|
||||
html: '弹幕字体大小',
|
||||
selector: [
|
||||
{ html: '小', value: '20' },
|
||||
{ html: '中', value: '25', default: true },
|
||||
{ html: '大', value: '30' },
|
||||
{ html: '特大', value: '35' },
|
||||
],
|
||||
onSelect: function (item: any) {
|
||||
const fontSize = parseInt(item.value);
|
||||
const newSettings = {
|
||||
...danmakuSettingsRef.current,
|
||||
fontSize,
|
||||
};
|
||||
setDanmakuSettings(newSettings);
|
||||
saveDanmakuSettings(newSettings);
|
||||
|
||||
// 更新弹幕插件配置
|
||||
if (danmakuPluginRef.current) {
|
||||
danmakuPluginRef.current.config({ fontSize });
|
||||
}
|
||||
|
||||
return item.html;
|
||||
},
|
||||
},
|
||||
// 弹幕速度
|
||||
{
|
||||
name: '弹幕速度',
|
||||
html: '弹幕速度',
|
||||
selector: [
|
||||
{ html: '很慢', value: '3' },
|
||||
{ html: '慢', value: '5', default: true },
|
||||
{ html: '正常', value: '7' },
|
||||
{ html: '快', value: '10' },
|
||||
{ html: '很快', value: '15' },
|
||||
],
|
||||
onSelect: function (item: any) {
|
||||
const speed = parseInt(item.value);
|
||||
const newSettings = {
|
||||
...danmakuSettingsRef.current,
|
||||
speed,
|
||||
};
|
||||
setDanmakuSettings(newSettings);
|
||||
saveDanmakuSettings(newSettings);
|
||||
|
||||
// 更新弹幕插件配置
|
||||
if (danmakuPluginRef.current) {
|
||||
danmakuPluginRef.current.config({ speed });
|
||||
}
|
||||
|
||||
return item.html;
|
||||
},
|
||||
},
|
||||
...(webGPUSupported ? [
|
||||
{
|
||||
name: 'Anime4K超分',
|
||||
@@ -1877,6 +2230,21 @@ function PlayPageClient() {
|
||||
artPlayerRef.current.on('ready', async () => {
|
||||
setError(null);
|
||||
|
||||
// 保存弹幕插件引用
|
||||
if (artPlayerRef.current?.plugins?.artplayerPluginDanmuku) {
|
||||
danmakuPluginRef.current = artPlayerRef.current.plugins.artplayerPluginDanmuku;
|
||||
|
||||
// 根据设置显示或隐藏弹幕
|
||||
if (danmakuSettingsRef.current.enabled) {
|
||||
danmakuPluginRef.current.show();
|
||||
} else {
|
||||
danmakuPluginRef.current.hide();
|
||||
}
|
||||
|
||||
// 自动搜索并加载弹幕
|
||||
await autoSearchDanmaku();
|
||||
}
|
||||
|
||||
// 播放器就绪后,如果正在播放则请求 Wake Lock
|
||||
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
||||
requestWakeLock();
|
||||
@@ -2334,6 +2702,16 @@ function PlayPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 弹幕加载蒙层 */}
|
||||
{danmakuLoading && (
|
||||
<div className='absolute top-0 right-0 m-4 bg-black/80 backdrop-blur-sm rounded-lg px-4 py-2 z-[600] flex items-center gap-2 border border-green-500/30'>
|
||||
<div className='w-4 h-4 border-2 border-green-500 border-t-transparent rounded-full animate-spin'></div>
|
||||
<span className='text-sm font-medium text-green-400'>
|
||||
加载弹幕中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 第三方应用打开按钮 */}
|
||||
@@ -2547,6 +2925,8 @@ function PlayPageClient() {
|
||||
sourceSearchLoading={sourceSearchLoading}
|
||||
sourceSearchError={sourceSearchError}
|
||||
precomputedVideoInfo={precomputedVideoInfo}
|
||||
onDanmakuSelect={handleDanmakuSelect}
|
||||
currentDanmakuSelection={currentDanmakuSelection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user