Merge branch 'dev'
This commit is contained in:
@@ -1,3 +1,27 @@
|
||||
## [215.0.0] - 2026-03-20
|
||||
### Added
|
||||
- 增加主动恢复进度按钮
|
||||
- 新增播放记录面板
|
||||
- 弹幕搜索面板标题增加tooltip
|
||||
- 影视搜索新增列表视图
|
||||
- pansou增加重试按钮
|
||||
- 新增ai评论生成
|
||||
- 增加一键render部署
|
||||
- 电视直播增加三种代理模式
|
||||
|
||||
### Changed
|
||||
- 优化直链播放m3u8体验
|
||||
- 搜索页面海量数据下使用虚拟滚动提高性能
|
||||
- 优化emby代理内存泄漏问题
|
||||
- 电视直播代理控制权从用户端改为管理端
|
||||
- 获取视频源详情不再依赖title
|
||||
|
||||
### Fixed
|
||||
- 修复search页面僵尸历史记录tag
|
||||
- 修复弹幕搜索框挤压
|
||||
- 修复搜索页面加载条显示顺序错误
|
||||
- 修复继续观看渐进式加载的一些问题
|
||||
|
||||
## [214.1.0] - 2026-03-10
|
||||
### Changed
|
||||
- emby兼容jellyfin
|
||||
|
||||
@@ -90,10 +90,14 @@
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/mtvpls/MoonTVPlus)
|
||||
|
||||
**一键部署到zeabur**
|
||||
**一键部署到 Zeabur**
|
||||
|
||||
[](https://zeabur.com/templates/SCHCAY/deploy)
|
||||
|
||||
**一键部署到 Render**
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/mtvpls/MoonTVPlus)
|
||||
|
||||
|
||||
|
||||
### Cloudflare Workers 部署(通过 GitHub Actions)
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
214.1.0
|
||||
215.0.0
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
- type: web
|
||||
name: moontvplus
|
||||
runtime: image
|
||||
plan: free
|
||||
image:
|
||||
url: ghcr.io/mtvpls/moontvplus:latest
|
||||
envVars:
|
||||
- key: USERNAME
|
||||
sync: false
|
||||
- key: PASSWORD
|
||||
sync: false
|
||||
- key: NEXT_PUBLIC_SITE_NAME
|
||||
value: MoonTVPlus
|
||||
- key: CRON_PASSWORD
|
||||
value: mtvpls
|
||||
@@ -380,6 +380,7 @@ interface LiveDataSource {
|
||||
channelNumber?: number;
|
||||
disabled?: boolean;
|
||||
from: 'config' | 'custom';
|
||||
proxyMode?: 'full' | 'm3u8-only' | 'direct'; // 代理模式
|
||||
}
|
||||
|
||||
// 自定义分类数据类型
|
||||
@@ -10327,6 +10328,7 @@ const AIConfigComponent = ({
|
||||
const [enableHomepageEntry, setEnableHomepageEntry] = useState(true);
|
||||
const [enableVideoCardEntry, setEnableVideoCardEntry] = useState(true);
|
||||
const [enablePlayPageEntry, setEnablePlayPageEntry] = useState(true);
|
||||
const [enableAIComments, setEnableAIComments] = useState(false);
|
||||
|
||||
// 权限控制
|
||||
const [allowRegularUsers, setAllowRegularUsers] = useState(true);
|
||||
@@ -10357,6 +10359,7 @@ const AIConfigComponent = ({
|
||||
setEnableHomepageEntry(config.AIConfig.EnableHomepageEntry !== false);
|
||||
setEnableVideoCardEntry(config.AIConfig.EnableVideoCardEntry !== false);
|
||||
setEnablePlayPageEntry(config.AIConfig.EnablePlayPageEntry !== false);
|
||||
setEnableAIComments(config.AIConfig.EnableAIComments || false);
|
||||
setAllowRegularUsers(config.AIConfig.AllowRegularUsers !== false);
|
||||
setTemperature(config.AIConfig.Temperature ?? 0.7);
|
||||
setMaxTokens(config.AIConfig.MaxTokens ?? 1000);
|
||||
@@ -10390,6 +10393,7 @@ const AIConfigComponent = ({
|
||||
EnableHomepageEntry: enableHomepageEntry,
|
||||
EnableVideoCardEntry: enableVideoCardEntry,
|
||||
EnablePlayPageEntry: enablePlayPageEntry,
|
||||
EnableAIComments: enableAIComments,
|
||||
AllowRegularUsers: allowRegularUsers,
|
||||
Temperature: temperature,
|
||||
MaxTokens: maxTokens,
|
||||
@@ -10659,6 +10663,7 @@ const AIConfigComponent = ({
|
||||
{ key: 'homepage', label: '首页入口', desc: '在首页显示AI问片入口', state: enableHomepageEntry, setState: setEnableHomepageEntry },
|
||||
{ key: 'videocard', label: '视频卡片入口', desc: '在视频卡片菜单中显示AI问片选项', state: enableVideoCardEntry, setState: setEnableVideoCardEntry },
|
||||
{ key: 'playpage', label: '播放页入口', desc: '在视频播放页显示AI问片功能', state: enablePlayPageEntry, setState: setEnablePlayPageEntry },
|
||||
{ key: 'aicomments', label: 'AI评论功能', desc: '在播放页生成AI评论(独立于豆瓣评论)', state: enableAIComments, setState: setEnableAIComments },
|
||||
].map((item) => (
|
||||
<div key={item.key} className='flex items-center justify-between py-2'>
|
||||
<div>
|
||||
@@ -10932,6 +10937,53 @@ const LiveSourceConfig = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetProxyMode = (key: string, mode: 'full' | 'm3u8-only' | 'direct') => {
|
||||
withLoading(`setLiveProxyMode_${key}`, async () => {
|
||||
// 保存旧值用于回滚
|
||||
const oldMode = liveSources.find((s) => s.key === key)?.proxyMode;
|
||||
|
||||
// 乐观更新本地状态
|
||||
setLiveSources((prev) =>
|
||||
prev.map((s) =>
|
||||
s.key === key ? { ...s, proxyMode: mode } : s
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/live', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'set_proxy_mode',
|
||||
key,
|
||||
proxyMode: mode,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('设置代理模式失败');
|
||||
}
|
||||
|
||||
// 成功后刷新配置
|
||||
await refreshConfig();
|
||||
} catch (error) {
|
||||
// 失败时回滚本地状态
|
||||
setLiveSources((prev) =>
|
||||
prev.map((s) =>
|
||||
s.key === key ? { ...s, proxyMode: oldMode } : s
|
||||
)
|
||||
);
|
||||
showError(
|
||||
error instanceof Error ? error.message : '设置代理模式失败',
|
||||
showAlert
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}).catch(() => {
|
||||
console.error('操作失败', 'set_proxy_mode', key);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (key: string) => {
|
||||
withLoading(`deleteLiveSource_${key}`, () =>
|
||||
callLiveSourceApi({ action: 'delete', key })
|
||||
@@ -11108,6 +11160,24 @@ const LiveSourceConfig = ({
|
||||
{!liveSource.disabled ? '启用中' : '已禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap'>
|
||||
<select
|
||||
value={liveSource.proxyMode || 'full'}
|
||||
onChange={(e) => {
|
||||
handleSetProxyMode(liveSource.key, e.target.value as 'full' | 'm3u8-only' | 'direct');
|
||||
}}
|
||||
disabled={isLoading(`setLiveProxyMode_${liveSource.key}`)}
|
||||
className={`px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 ${
|
||||
isLoading(`setLiveProxyMode_${liveSource.key}`)
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<option value='full'>全量代理</option>
|
||||
<option value='m3u8-only'>仅代理m3u8</option>
|
||||
<option value='direct'>直连</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
|
||||
<button
|
||||
onClick={() => handleToggleEnable(liveSource.key)}
|
||||
@@ -11415,6 +11485,9 @@ const LiveSourceConfig = ({
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
状态
|
||||
</th>
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
代理模式
|
||||
</th>
|
||||
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
操作
|
||||
</th>
|
||||
|
||||
@@ -57,6 +57,7 @@ export async function POST(request: NextRequest) {
|
||||
EnableHomepageEntry,
|
||||
EnableVideoCardEntry,
|
||||
EnablePlayPageEntry,
|
||||
EnableAIComments,
|
||||
AllowRegularUsers,
|
||||
Temperature,
|
||||
MaxTokens,
|
||||
@@ -93,6 +94,7 @@ export async function POST(request: NextRequest) {
|
||||
EnableHomepageEntry: boolean;
|
||||
EnableVideoCardEntry: boolean;
|
||||
EnablePlayPageEntry: boolean;
|
||||
EnableAIComments: boolean;
|
||||
AllowRegularUsers: boolean;
|
||||
Temperature?: number;
|
||||
MaxTokens?: number;
|
||||
@@ -132,6 +134,7 @@ export async function POST(request: NextRequest) {
|
||||
typeof EnableHomepageEntry !== 'boolean' ||
|
||||
typeof EnableVideoCardEntry !== 'boolean' ||
|
||||
typeof EnablePlayPageEntry !== 'boolean' ||
|
||||
typeof EnableAIComments !== 'boolean' ||
|
||||
typeof AllowRegularUsers !== 'boolean' ||
|
||||
(Temperature !== undefined && typeof Temperature !== 'number') ||
|
||||
(MaxTokens !== undefined && typeof MaxTokens !== 'number') ||
|
||||
@@ -181,6 +184,7 @@ export async function POST(request: NextRequest) {
|
||||
EnableHomepageEntry,
|
||||
EnableVideoCardEntry,
|
||||
EnablePlayPageEntry,
|
||||
EnableAIComments,
|
||||
AllowRegularUsers,
|
||||
Temperature,
|
||||
MaxTokens,
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, key, name, url, ua, epg } = body;
|
||||
const { action, key, name, url, ua, epg, proxyMode } = body;
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
||||
@@ -153,6 +153,18 @@ export async function POST(request: NextRequest) {
|
||||
config.LiveConfig = sortedLiveConfig;
|
||||
break;
|
||||
|
||||
case 'set_proxy_mode':
|
||||
// 设置代理模式
|
||||
const setProxySource = config.LiveConfig.find((l) => l.key === key);
|
||||
if (!setProxySource) {
|
||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||
}
|
||||
if (!proxyMode || !['full', 'm3u8-only', 'direct'].includes(proxyMode)) {
|
||||
return NextResponse.json({ error: '无效的代理模式' }, { status: 400 });
|
||||
}
|
||||
setProxySource.proxyMode = proxyMode as 'full' | 'm3u8-only' | 'direct';
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { generateAIComments, AIComment } from '@/lib/ai-comment-generator';
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
interface AICommentsResponse {
|
||||
comments: AIComment[];
|
||||
total: number;
|
||||
movieName: string;
|
||||
isAiGenerated: true;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const movieName = searchParams.get('name');
|
||||
const movieInfo = searchParams.get('info');
|
||||
const count = parseInt(searchParams.get('count') || '10');
|
||||
|
||||
// 参数验证
|
||||
if (!movieName) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少影片名称参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (count < 1 || count > 50) {
|
||||
return NextResponse.json(
|
||||
{ error: '评论数量必须在1-50之间' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 读取AI配置
|
||||
const config = await getConfig();
|
||||
const aiConfig = config.AIConfig;
|
||||
|
||||
// 检查AI功能是否启用
|
||||
if (!aiConfig?.Enabled) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AI功能未启用' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查AI评论功能是否启用
|
||||
if (!aiConfig?.EnableAIComments) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AI评论功能未启用' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查必要的配置
|
||||
if (!aiConfig.CustomApiKey || !aiConfig.CustomBaseURL || !aiConfig.CustomModel) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AI配置不完整,请在管理面板配置' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 生成AI评论
|
||||
const comments = await generateAIComments({
|
||||
movieName,
|
||||
movieInfo: movieInfo || undefined,
|
||||
count,
|
||||
aiConfig: {
|
||||
CustomApiKey: aiConfig.CustomApiKey,
|
||||
CustomBaseURL: aiConfig.CustomBaseURL,
|
||||
CustomModel: aiConfig.CustomModel,
|
||||
Temperature: aiConfig.Temperature,
|
||||
MaxTokens: aiConfig.MaxTokens,
|
||||
EnableWebSearch: aiConfig.EnableWebSearch,
|
||||
WebSearchProvider: aiConfig.WebSearchProvider,
|
||||
TavilyApiKey: aiConfig.TavilyApiKey,
|
||||
SerperApiKey: aiConfig.SerperApiKey,
|
||||
SerpApiKey: aiConfig.SerpApiKey,
|
||||
},
|
||||
});
|
||||
|
||||
// 返回结果
|
||||
const response: AICommentsResponse = {
|
||||
comments,
|
||||
total: comments.length,
|
||||
movieName,
|
||||
isAiGenerated: true,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('AI评论生成失败:', error);
|
||||
|
||||
// 返回友好的错误信息
|
||||
const errorMessage = error instanceof Error ? error.message : 'AI评论生成失败';
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: errorMessage,
|
||||
details: process.env.NODE_ENV === 'development' ? String(error) : undefined
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -84,10 +84,19 @@ export async function GET(
|
||||
'User-Agent': client.getUserAgent(),
|
||||
};
|
||||
|
||||
// 请求图片
|
||||
const imageResponse = await fetch(imageUrl, {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
// 创建 AbortController 用于超时控制
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), 20000); // 20秒超时
|
||||
|
||||
try {
|
||||
// 请求图片
|
||||
const imageResponse = await fetch(imageUrl, {
|
||||
headers: requestHeaders,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// 清除超时定时器
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
console.error('[Emby Image] 获取图片失败:', {
|
||||
@@ -123,6 +132,19 @@ export async function GET(
|
||||
status: imageResponse.status,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
// 清除超时定时器
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.error('[Emby Image] 请求超时');
|
||||
return NextResponse.json(
|
||||
{ error: '请求超时' },
|
||||
{ status: 504 }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Emby Image] 错误:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -92,22 +92,42 @@ export async function GET(
|
||||
requestHeaders['Range'] = rangeHeader;
|
||||
}
|
||||
|
||||
// 流式代理视频内容
|
||||
let videoResponse = await fetch(embyStreamUrl, {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
// 创建 AbortController 用于超时控制
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), 300000); // 5分钟超时
|
||||
|
||||
// 如果返回 401,尝试重新认证并重试
|
||||
if (videoResponse.status === 401) {
|
||||
console.log('[Emby Play] 收到 401 错误,尝试重新认证');
|
||||
const { embyManager } = await import('@/lib/emby-manager');
|
||||
embyManager.clearCache();
|
||||
client = await getEmbyClient(embyKey);
|
||||
embyStreamUrl = await client.getStreamUrl(itemId, true, true);
|
||||
videoResponse = await fetch(embyStreamUrl, {
|
||||
try {
|
||||
// 流式代理视频内容
|
||||
let videoResponse = await fetch(embyStreamUrl, {
|
||||
headers: requestHeaders,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
}
|
||||
|
||||
// 如果返回 401,尝试重新认证并重试
|
||||
if (videoResponse.status === 401) {
|
||||
console.log('[Emby Play] 收到 401 错误,尝试重新认证');
|
||||
const { embyManager } = await import('@/lib/emby-manager');
|
||||
embyManager.clearCache();
|
||||
client = await getEmbyClient(embyKey);
|
||||
embyStreamUrl = await client.getStreamUrl(itemId, true, true);
|
||||
|
||||
// 重置超时
|
||||
clearTimeout(timeoutId);
|
||||
const retryAbortController = new AbortController();
|
||||
const retryTimeoutId = setTimeout(() => retryAbortController.abort(), 300000);
|
||||
|
||||
try {
|
||||
videoResponse = await fetch(embyStreamUrl, {
|
||||
headers: requestHeaders,
|
||||
signal: retryAbortController.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(retryTimeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除超时定时器
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!videoResponse.ok) {
|
||||
console.error('[Emby Play] 获取视频流失败:', {
|
||||
@@ -147,11 +167,64 @@ export async function GET(
|
||||
// 使用 URL 中的文件名
|
||||
headers.set('Content-Disposition', `inline; filename="${params.filename}"`);
|
||||
|
||||
// 流式返回视频内容,不等待下载完成
|
||||
return new NextResponse(videoResponse.body, {
|
||||
// 创建一个可以被中断的流
|
||||
const { readable, writable } = new TransformStream();
|
||||
const reader = videoResponse.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
return NextResponse.json(
|
||||
{ error: '无法读取视频流' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 异步管道传输,确保在客户端断开时清理资源
|
||||
(async () => {
|
||||
const writer = writable.getWriter();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
await writer.write(value);
|
||||
}
|
||||
} catch (error) {
|
||||
// 客户端断开连接或其他错误
|
||||
console.log('[Emby Play] 流传输中断:', error instanceof Error ? error.message : 'Unknown error');
|
||||
// 取消上游 fetch,停止继续下载
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch (e) {
|
||||
// 忽略取消错误
|
||||
}
|
||||
} finally {
|
||||
// 确保资源被释放
|
||||
try {
|
||||
reader.releaseLock();
|
||||
await writer.close();
|
||||
} catch (e) {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// 流式返回视频内容
|
||||
return new NextResponse(readable, {
|
||||
status: videoResponse.status,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
// 清除超时定时器
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.error('[Emby Play] 请求超时');
|
||||
return NextResponse.json(
|
||||
{ error: '请求超时' },
|
||||
{ status: 504 }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Emby Play] 错误:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export const maxDuration = 60; // 设置最大执行时间为 60 秒
|
||||
|
||||
/**
|
||||
* M3U8 代理接口
|
||||
* 用于外部播放器访问,会执行去广告逻辑并处理相对链接
|
||||
* GET /api/proxy-m3u8?url=<原始m3u8地址>&source=<播放源>&token=<鉴权token>
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const m3u8Url = searchParams.get('url');
|
||||
@@ -34,12 +37,40 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const DIRECT_PLAY_SOURCE = 'directplay';
|
||||
// 安全校验:防 SSRF / 域名重绑定,只允许合法的公网 URL。对所有经过 proxy-m3u8 的请求强制校验,不仅限于 directplay
|
||||
const isSafeUrl = await validateProxyUrlServerSide(m3u8Url);
|
||||
if (!isSafeUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Proxy request to local or invalid network is forbidden' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取当前请求的 origin
|
||||
// 优先级:SITE_BASE 环境变量 > 从请求头构建
|
||||
let origin = process.env.SITE_BASE;
|
||||
if (!origin) {
|
||||
const requestUrl = new URL(request.url);
|
||||
origin = `${requestUrl.protocol}//${requestUrl.host}`;
|
||||
// 从请求头中获取 Host 和协议
|
||||
let host = request.headers.get('host') || request.headers.get('x-forwarded-host');
|
||||
|
||||
// 安全校验:防 Host 头注入漏洞 (要求仅包含合法域名或 IP 格式字符)
|
||||
if (host && !/^[a-zA-Z0-9.-]+(:\d+)?$/.test(host)) {
|
||||
host = null;
|
||||
}
|
||||
|
||||
// Fallback:如果以上 Header 无效或未提供,回退到 request.url 获取
|
||||
if (!host) {
|
||||
try {
|
||||
host = new URL(request.url).host;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid Request Host' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const proto = request.headers.get('x-forwarded-proto') ||
|
||||
(host.includes('localhost') || host.includes('127.0.0.1') ? 'http' : 'https');
|
||||
origin = `${proto}://${host}`;
|
||||
}
|
||||
|
||||
// 获取原始 m3u8 内容
|
||||
@@ -61,8 +92,58 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// 后端 MIME Sniffing: 防御伪装成 m3u8 的大文件二进制流
|
||||
// 使用白名单策略:只有明确属于文本/m3u8 类型的才放行解析
|
||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||
const isTextType = (
|
||||
contentType === '' || // 无 Content-Type 时保守放行(后续有内容校验兜底)
|
||||
contentType.includes('application/vnd.apple.mpegurl') || // 标准 m3u8
|
||||
contentType.includes('application/x-mpegurl') || // 兼容 m3u8
|
||||
contentType.includes('audio/mpegurl') || // 兼容 m3u8
|
||||
contentType.includes('text/') || // text/plain 等
|
||||
contentType.includes('application/json') // 部分 API 返回 JSON 格式的错误
|
||||
);
|
||||
|
||||
if (!isTextType) {
|
||||
if (source === DIRECT_PLAY_SOURCE) {
|
||||
console.log(`[Proxy-M3U8] 检测到非文本媒体流 (Content-Type: ${contentType}), 针对 directplay 直链代理模式,直接透传二进制流, URL: ${m3u8Url}`);
|
||||
// 构造一个新的 Response 对象用于二进制直接透传,确保包含了支持跨域的 header
|
||||
const newHeaders = new Headers(response.headers);
|
||||
newHeaders.set('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// 如果源站返回了跨站相关的禁止头,尽量移除它们
|
||||
newHeaders.delete('X-Frame-Options');
|
||||
newHeaders.delete('Content-Security-Policy');
|
||||
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
console.warn(`[Proxy-M3U8] 拦截到非文本媒体流 (Content-Type: ${contentType}), 拒绝按文本解析, URL: ${m3u8Url}`);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unsupported Media Type',
|
||||
details: `The source returned Content-Type "${contentType}", which is not a text m3u8 playlist.`,
|
||||
fallbackToDirect: true,
|
||||
originalUrl: m3u8Url
|
||||
},
|
||||
{ status: 415, headers: { 'Access-Control-Allow-Origin': '*' } }
|
||||
);
|
||||
}
|
||||
|
||||
let m3u8Content = await response.text();
|
||||
|
||||
// 二次内容校验:即使 Content-Type 通过了白名单,检查实际内容是否为有效的 m3u8
|
||||
// 有些服务器返回 text/plain 但实际内容是 HTML 错误页或其他格式
|
||||
const trimmedContent = m3u8Content.trimStart();
|
||||
if (trimmedContent.length > 0 && !trimmedContent.startsWith('#EXTM3U') && !trimmedContent.startsWith('#EXT')) {
|
||||
console.warn(`[Proxy-M3U8] 内容校验失败:响应体不以 #EXTM3U 或 #EXT 开头, 可能非有效 m3u8, URL: ${m3u8Url}`);
|
||||
// 不直接拒绝(可能是不规范但仍可播放的 m3u8),仅打印警告继续处理
|
||||
}
|
||||
|
||||
// 执行去广告逻辑
|
||||
const config = await getConfig();
|
||||
const customAdFilterCode = config.SiteConfig?.CustomAdFilterCode || '';
|
||||
@@ -111,7 +192,10 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认去广告规则
|
||||
* 默认去广告规则(服务端版本)
|
||||
* 注意:前端 page.tsx 中的 filterAdsFromM3U8 是客户端侧的去广告逻辑(用于直连模式下由 HLS.js 的自定义 loader 拦截)。
|
||||
* 本函数用于代理模式下,在服务端对 m3u8 内容进行去广告处理后再返回给客户端。
|
||||
* 两套逻辑需要保持同步更新。
|
||||
*/
|
||||
function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
|
||||
if (!m3u8Content) return '';
|
||||
@@ -167,7 +251,10 @@ function filterAdsFromM3U8Default(type: string, m3u8Content: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接
|
||||
* 将 m3u8 中的相对链接转换为绝对链接,并将子 m3u8 链接转为代理链接。
|
||||
* 此函数仅在代理模式下由服务端调用。
|
||||
* - 子 m3u8 链接 → 指向 /api/proxy-m3u8(递归代理)
|
||||
* - ts 分片/密钥 → directplay 模式指向 /api/proxy/vod/segment(解决 CORS)
|
||||
*/
|
||||
function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string, proxyOrigin: string, token: string): string {
|
||||
const lines = m3u8Content.split('\n');
|
||||
@@ -196,10 +283,15 @@ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string,
|
||||
} else {
|
||||
keyUri = new URL(keyUri, baseDir).href;
|
||||
}
|
||||
|
||||
// 替换原来的 URI
|
||||
line = line.replace(/URI="[^"]+"/, `URI="${keyUri}"`);
|
||||
}
|
||||
|
||||
// 直链播放模式:通过代理访问密钥,避免 CORS 问题
|
||||
if (source === 'directplay') {
|
||||
keyUri = `${proxyOrigin}/api/proxy/vod/segment?url=${encodeURIComponent(keyUri)}&source=directplay`;
|
||||
}
|
||||
|
||||
// 替换原来的 URI
|
||||
line = line.replace(/URI="[^"]+"/, `URI="${keyUri}"`);
|
||||
}
|
||||
resolvedLines.push(line);
|
||||
continue;
|
||||
@@ -240,6 +332,9 @@ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string,
|
||||
if (isM3u8) {
|
||||
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '';
|
||||
url = `${proxyOrigin}/api/proxy-m3u8?url=${encodeURIComponent(url)}${source ? `&source=${encodeURIComponent(source)}` : ''}${tokenParam}`;
|
||||
} else if (source === 'directplay') {
|
||||
// 直链播放模式:通过代理访问媒体分片(ts/jpeg/png 等),避免 CORS 问题
|
||||
url = `${proxyOrigin}/api/proxy/vod/segment?url=${encodeURIComponent(url)}&source=directplay`;
|
||||
}
|
||||
|
||||
resolvedLines.push(url);
|
||||
|
||||
@@ -110,6 +110,10 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo
|
||||
const host = req.headers.get('host');
|
||||
const proxyBase = `${protocol}://${host}/api/proxy`;
|
||||
|
||||
// 获取 moontv-source 参数
|
||||
const reqUrl = new URL(req.url);
|
||||
const source = reqUrl.searchParams.get('moontv-source') || '';
|
||||
|
||||
const lines = content.split('\n');
|
||||
const rewrittenLines: string[] = [];
|
||||
|
||||
@@ -119,19 +123,19 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo
|
||||
// 处理 TS 片段 URL 和其他媒体文件
|
||||
if (line && !line.startsWith('#')) {
|
||||
const resolvedUrl = resolveUrl(baseUrl, line);
|
||||
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
|
||||
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`;
|
||||
rewrittenLines.push(proxyUrl);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理 EXT-X-MAP 标签中的 URI
|
||||
if (line.startsWith('#EXT-X-MAP:')) {
|
||||
line = rewriteMapUri(line, baseUrl, proxyBase);
|
||||
line = rewriteMapUri(line, baseUrl, proxyBase, allowCORS, source);
|
||||
}
|
||||
|
||||
// 处理 EXT-X-KEY 标签中的 URI
|
||||
if (line.startsWith('#EXT-X-KEY:')) {
|
||||
line = rewriteKeyUri(line, baseUrl, proxyBase);
|
||||
line = rewriteKeyUri(line, baseUrl, proxyBase, allowCORS, source);
|
||||
}
|
||||
|
||||
// 处理嵌套的 M3U8 文件 (EXT-X-STREAM-INF)
|
||||
@@ -143,7 +147,7 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo
|
||||
const nextLine = lines[i].trim();
|
||||
if (nextLine && !nextLine.startsWith('#')) {
|
||||
const resolvedUrl = resolveUrl(baseUrl, nextLine);
|
||||
const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}`;
|
||||
const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`;
|
||||
rewrittenLines.push(proxyUrl);
|
||||
} else {
|
||||
rewrittenLines.push(nextLine);
|
||||
@@ -158,23 +162,23 @@ function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allo
|
||||
return rewrittenLines.join('\n');
|
||||
}
|
||||
|
||||
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string) {
|
||||
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string, allowCORS: boolean, source: string) {
|
||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||
if (uriMatch) {
|
||||
const originalUri = uriMatch[1];
|
||||
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
||||
const proxyUrl = `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
|
||||
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`;
|
||||
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string) {
|
||||
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string, allowCORS: boolean, source: string) {
|
||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||
if (uriMatch) {
|
||||
const originalUri = uriMatch[1];
|
||||
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
||||
const proxyUrl = `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}`;
|
||||
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}&moontv-source=${source}`;
|
||||
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
||||
}
|
||||
return line;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getConfig } from "@/lib/config";
|
||||
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||
import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -33,6 +35,13 @@ export async function GET(request: Request) {
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
|
||||
// 安全校验:防 SSRF 拦截请求内网或非法 URL
|
||||
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
|
||||
if (!isSafeUrl) {
|
||||
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const response = await fetch(decodedUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
@@ -44,12 +53,9 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream');
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
const headers = buildProxyStreamHeaders(
|
||||
response.headers.get('Content-Type') || 'application/octet-stream'
|
||||
);
|
||||
|
||||
return new Response(response.body, { headers });
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { getConfig } from "@/lib/config";
|
||||
import { getBaseUrl, resolveUrl } from "@/lib/live";
|
||||
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||
import { buildProxyM3u8Headers, buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -38,6 +40,12 @@ export async function GET(request: Request) {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
|
||||
// 安全校验:防 SSRF 拦截请求内网或非法 URL
|
||||
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
|
||||
if (!isSafeUrl) {
|
||||
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
response = await fetch(decodedUrl, {
|
||||
cache: 'no-cache',
|
||||
redirect: 'follow',
|
||||
@@ -66,23 +74,14 @@ export async function GET(request: Request) {
|
||||
// 重写 M3U8 内容
|
||||
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, source);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', contentType || 'application/vnd.apple.mpegurl');
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
headers.set('Cache-Control', 'no-cache');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
const headers = buildProxyM3u8Headers(contentType || undefined);
|
||||
return new Response(modifiedContent, { headers });
|
||||
}
|
||||
// just proxy
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl');
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
const headers = buildProxyStreamHeaders(
|
||||
response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl'
|
||||
);
|
||||
headers.set('Cache-Control', 'no-cache');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
|
||||
// 直接返回视频流
|
||||
return new Response(response.body, {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from "@/lib/config";
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||
import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -19,16 +21,22 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 检查该视频源是否启用了代理模式
|
||||
const config = await getConfig();
|
||||
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
||||
// 定义直链播放模式常量
|
||||
const DIRECT_PLAY_SOURCE = 'directplay';
|
||||
|
||||
if (!videoSource) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||
}
|
||||
// 直链播放模式:跳过源站配置检查,直接代理
|
||||
if (source !== DIRECT_PLAY_SOURCE) {
|
||||
// 检查该视频源是否启用了代理模式
|
||||
const config = await getConfig();
|
||||
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
||||
|
||||
if (!videoSource.proxyMode) {
|
||||
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
|
||||
if (!videoSource) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!videoSource.proxyMode) {
|
||||
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
let response: Response | null = null;
|
||||
@@ -36,6 +44,13 @@ export async function GET(request: Request) {
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
|
||||
// 安全校验:防 SSRF 拦截请求内网或非法 URL (强制检查所有代理请求)
|
||||
const isSafeUrl = await validateProxyUrlServerSide(decodedUrl);
|
||||
if (!isSafeUrl) {
|
||||
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
response = await fetch(decodedUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
@@ -46,19 +61,14 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'video/mp2t');
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
headers.set('Content-Length', contentLength);
|
||||
}
|
||||
const headers = buildProxyStreamHeaders(
|
||||
response.headers.get('Content-Type') || 'video/mp2t',
|
||||
response.headers.get('content-length')
|
||||
);
|
||||
|
||||
// 使用流式传输,避免占用内存
|
||||
let isCancelled = false;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
if (!response?.body) {
|
||||
@@ -67,7 +77,6 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
reader = response.body.getReader();
|
||||
const isCancelled = false;
|
||||
|
||||
function pump() {
|
||||
if (isCancelled || !reader) {
|
||||
@@ -109,6 +118,7 @@ export async function GET(request: Request) {
|
||||
pump();
|
||||
},
|
||||
cancel() {
|
||||
isCancelled = true;
|
||||
// 当流被取消时,确保释放所有资源
|
||||
if (reader) {
|
||||
try {
|
||||
|
||||
@@ -4,13 +4,13 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
import { getDetailFromApiV2 } from '@/lib/downstream';
|
||||
import { getProxyToken } from '@/lib/emby-token';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* 根据 source 和 id 从搜索结果中精确匹配获取视频详情
|
||||
* 根据 source 和 id 直接获取视频详情
|
||||
* 这个API专门用于play页面快速获取当前源的详情
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -22,10 +22,9 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
const sourceCode = searchParams.get('source');
|
||||
const title = searchParams.get('title'); // 用于搜索的标题
|
||||
const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名
|
||||
|
||||
if (!id || !sourceCode || !title) {
|
||||
if (!id || !sourceCode) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -385,7 +384,11 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// 对于其他源,通过搜索API获取,然后精确匹配
|
||||
if (!/^[\w-]+$/.test(id)) {
|
||||
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 对于其他采集源,直接按 id 获取详情。
|
||||
try {
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
const apiSite = apiSites.find((site) => site.key === sourceCode);
|
||||
@@ -394,26 +397,11 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: '无效的API来源' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 调用搜索API
|
||||
const searchResults = await searchFromApi(apiSite, title.trim());
|
||||
|
||||
// 从搜索结果中精确匹配 source 和 id
|
||||
const exactMatch = searchResults.find(
|
||||
(item: any) =>
|
||||
item.source?.toString() === sourceCode.toString() &&
|
||||
item.id?.toString() === id.toString()
|
||||
);
|
||||
|
||||
if (!exactMatch) {
|
||||
return NextResponse.json(
|
||||
{ error: '未找到匹配的视频源' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
const result = await getDetailFromApiV2(apiSite, id);
|
||||
|
||||
// 添加 proxyMode 到返回结果
|
||||
const resultWithProxy = {
|
||||
...exactMatch,
|
||||
...result,
|
||||
proxyMode: apiSite.proxyMode || false,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { validateProxyUrlServerSide } from '@/lib/server/ssrf';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -11,6 +12,12 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing video URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 安全校验:防 SSRF,只允许合法的公网 URL
|
||||
const isSafeUrl = await validateProxyUrlServerSide(videoUrl);
|
||||
if (!isSafeUrl) {
|
||||
return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取客户端的Range请求头
|
||||
const range = request.headers.get('range');
|
||||
|
||||
@@ -84,6 +84,7 @@ export default async function RootLayout({
|
||||
let aiEnableHomepageEntry = false;
|
||||
let aiEnableVideoCardEntry = false;
|
||||
let aiEnablePlayPageEntry = false;
|
||||
let aiEnableComments = false;
|
||||
let aiDefaultMessageNoVideo = '';
|
||||
let aiDefaultMessageWithVideo = '';
|
||||
let enableMovieRequest = true;
|
||||
@@ -133,6 +134,7 @@ export default async function RootLayout({
|
||||
aiEnableHomepageEntry = config.AIConfig?.EnableHomepageEntry || false;
|
||||
aiEnableVideoCardEntry = config.AIConfig?.EnableVideoCardEntry || false;
|
||||
aiEnablePlayPageEntry = config.AIConfig?.EnablePlayPageEntry || false;
|
||||
aiEnableComments = config.AIConfig?.EnableAIComments || false;
|
||||
aiDefaultMessageNoVideo = config.AIConfig?.DefaultMessageNoVideo || '';
|
||||
aiDefaultMessageWithVideo = config.AIConfig?.DefaultMessageWithVideo || '';
|
||||
// 求片功能配置
|
||||
@@ -198,6 +200,9 @@ export default async function RootLayout({
|
||||
AI_ENABLE_HOMEPAGE_ENTRY: aiEnableHomepageEntry,
|
||||
AI_ENABLE_VIDEOCARD_ENTRY: aiEnableVideoCardEntry,
|
||||
AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry,
|
||||
AIConfig: {
|
||||
EnableAIComments: aiEnableComments,
|
||||
},
|
||||
AI_DEFAULT_MESSAGE_NO_VIDEO: aiDefaultMessageNoVideo,
|
||||
AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo,
|
||||
ENABLE_MOVIE_REQUEST: enableMovieRequest,
|
||||
|
||||
+92
-45
@@ -53,6 +53,7 @@ interface LiveSource {
|
||||
from: 'config' | 'custom';
|
||||
channelNumber?: number;
|
||||
disabled?: boolean;
|
||||
proxyMode?: 'full' | 'm3u8-only' | 'direct'; // 代理模式
|
||||
}
|
||||
|
||||
function LivePageClient() {
|
||||
@@ -295,6 +296,12 @@ function LivePageClient() {
|
||||
// 工具函数(Utils)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// 获取 logo URL(始终使用代理)
|
||||
const getLogoUrl = (logoUrl: string, sourceKey: string) => {
|
||||
if (!logoUrl) return '';
|
||||
return `/api/proxy/logo?url=${encodeURIComponent(logoUrl)}&source=${sourceKey}`;
|
||||
};
|
||||
|
||||
// 获取直播源列表
|
||||
const fetchLiveSources = async () => {
|
||||
try {
|
||||
@@ -435,7 +442,7 @@ function LivePageClient() {
|
||||
title: selectedChannel.name,
|
||||
source_name: source.name,
|
||||
year: '',
|
||||
cover: `/api/proxy/logo?url=${encodeURIComponent(selectedChannel.logo)}&source=${source.key}`,
|
||||
cover: getLogoUrl(selectedChannel.logo, source.key),
|
||||
index: 1,
|
||||
total_episodes: 1,
|
||||
play_time: 0,
|
||||
@@ -626,7 +633,7 @@ function LivePageClient() {
|
||||
title: channel.name,
|
||||
source_name: currentSource.name,
|
||||
year: '',
|
||||
cover: `/api/proxy/logo?url=${encodeURIComponent(channel.logo)}&source=${currentSource.key}`,
|
||||
cover: getLogoUrl(channel.logo, currentSource.key),
|
||||
index: 1,
|
||||
total_episodes: 1,
|
||||
play_time: 0,
|
||||
@@ -1203,7 +1210,7 @@ function LivePageClient() {
|
||||
title: currentChannelRef.current.name,
|
||||
source_name: currentSourceRef.current.name,
|
||||
year: '',
|
||||
cover: `/api/proxy/logo?url=${encodeURIComponent(currentChannelRef.current.logo)}&source=${currentSourceRef.current.key}`,
|
||||
cover: getLogoUrl(currentChannelRef.current.logo, currentSourceRef.current.key),
|
||||
total_episodes: 1,
|
||||
save_time: Date.now(),
|
||||
search_title: '',
|
||||
@@ -1331,32 +1338,54 @@ function LivePageClient() {
|
||||
super(config);
|
||||
const load = this.load.bind(this);
|
||||
this.load = function (context: any, config: any, callbacks: any) {
|
||||
// 所有的请求都带一个 source 参数
|
||||
try {
|
||||
const url = new URL(context.url);
|
||||
url.searchParams.set('moontv-source', currentSourceRef.current?.key || '');
|
||||
context.url = url.toString();
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
// 判断当前直播源的代理模式
|
||||
const currentLiveSource = currentSourceRef.current;
|
||||
const proxyMode = currentLiveSource?.proxyMode || 'full';
|
||||
|
||||
// 拦截manifest和level请求
|
||||
if (
|
||||
(context as any).type === 'manifest' ||
|
||||
(context as any).type === 'level'
|
||||
) {
|
||||
// 判断是否浏览器直连
|
||||
const isLiveDirectConnectStr = localStorage.getItem('liveDirectConnect');
|
||||
const isLiveDirectConnect = isLiveDirectConnectStr === 'true';
|
||||
if (isLiveDirectConnect) {
|
||||
// 浏览器直连,使用 URL 对象处理参数
|
||||
try {
|
||||
const url = new URL(context.url);
|
||||
url.searchParams.set('allowCORS', 'true');
|
||||
context.url = url.toString();
|
||||
} catch (error) {
|
||||
// 如果 URL 解析失败,回退到字符串拼接
|
||||
context.url = context.url + '&allowCORS=true';
|
||||
// manifest 请求处理
|
||||
if ((context as any).type === 'manifest') {
|
||||
if (proxyMode === 'full') {
|
||||
// 全量代理:添加 source 参数
|
||||
try {
|
||||
const url = new URL(context.url);
|
||||
url.searchParams.set('moontv-source', currentSourceRef.current?.key || '');
|
||||
context.url = url.toString();
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
} else if (proxyMode === 'm3u8-only') {
|
||||
// 仅代理m3u8模式:添加 source 参数和 allowCORS 参数
|
||||
try {
|
||||
const url = new URL(context.url);
|
||||
url.searchParams.set('moontv-source', currentSourceRef.current?.key || '');
|
||||
url.searchParams.set('allowCORS', 'true');
|
||||
context.url = url.toString();
|
||||
} catch (error) {
|
||||
context.url = context.url + '&allowCORS=true';
|
||||
}
|
||||
}
|
||||
// direct 模式:直接使用原始 URL,不添加任何参数
|
||||
}
|
||||
|
||||
// level 请求(ts 分片)处理
|
||||
if ((context as any).type === 'level') {
|
||||
if (proxyMode === 'full') {
|
||||
// 全量代理:添加 source 参数
|
||||
try {
|
||||
const url = new URL(context.url);
|
||||
url.searchParams.set('moontv-source', currentSourceRef.current?.key || '');
|
||||
context.url = url.toString();
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
// m3u8-only 模式:ts 分片 URL 已经被代理服务器重写为原始 URL,不需要添加参数
|
||||
// direct 模式:ts 分片直接使用原始 URL,不添加任何参数
|
||||
}
|
||||
}
|
||||
// 执行原始load方法
|
||||
@@ -1463,26 +1492,34 @@ function LivePageClient() {
|
||||
|
||||
// precheck type
|
||||
let type = 'm3u8';
|
||||
try {
|
||||
const precheckUrl = `/api/live/precheck?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`;
|
||||
const precheckResponse = await fetch(precheckUrl);
|
||||
if (!precheckResponse.ok) {
|
||||
console.error('预检查失败:', precheckResponse.statusText);
|
||||
const proxyMode = currentSourceRef.current?.proxyMode || 'full';
|
||||
|
||||
// 直连模式:跳过服务器预检查,直接使用 m3u8
|
||||
if (proxyMode === 'direct') {
|
||||
type = 'm3u8';
|
||||
} else {
|
||||
// 全量代理或仅代理m3u8:通过服务器预检查
|
||||
try {
|
||||
const precheckUrl = `/api/live/precheck?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`;
|
||||
const precheckResponse = await fetch(precheckUrl);
|
||||
if (!precheckResponse.ok) {
|
||||
console.error('预检查失败:', precheckResponse.statusText);
|
||||
setIsVideoLoading(false);
|
||||
return;
|
||||
}
|
||||
const precheckResult = await precheckResponse.json();
|
||||
if (precheckResult?.success && precheckResult?.type) {
|
||||
type = precheckResult.type;
|
||||
} else {
|
||||
console.error('预检查返回无效结果:', precheckResult);
|
||||
setIsVideoLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('预检查异常:', err);
|
||||
setIsVideoLoading(false);
|
||||
return;
|
||||
}
|
||||
const precheckResult = await precheckResponse.json();
|
||||
if (precheckResult?.success && precheckResult?.type) {
|
||||
type = precheckResult.type;
|
||||
} else {
|
||||
console.error('预检查返回无效结果:', precheckResult);
|
||||
setIsVideoLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('预检查异常:', err);
|
||||
setIsVideoLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果不是 m3u8、flv 或 mp4 类型,设置不支持的类型并返回
|
||||
@@ -1496,9 +1533,19 @@ function LivePageClient() {
|
||||
setUnsupportedType(null);
|
||||
|
||||
const customType = { m3u8: m3u8Loader, flv: flvLoader };
|
||||
const targetUrl = (type === 'flv' || type === 'mp4')
|
||||
? videoUrl
|
||||
: `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`;
|
||||
|
||||
// 根据代理模式决定 URL
|
||||
let targetUrl = videoUrl;
|
||||
if (type === 'm3u8') {
|
||||
if (proxyMode === 'direct') {
|
||||
// 直连模式:直接使用原始 URL
|
||||
targetUrl = videoUrl;
|
||||
} else {
|
||||
// 全量代理或仅代理m3u8:使用代理 URL
|
||||
targetUrl = `/api/proxy/m3u8?url=${encodeURIComponent(videoUrl)}&moontv-source=${currentSourceRef.current?.key || ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建新的播放器实例
|
||||
Artplayer.USE_RAF = true;
|
||||
@@ -2338,7 +2385,7 @@ function LivePageClient() {
|
||||
<div className='w-10 h-10 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
|
||||
{channel.logo ? (
|
||||
<img
|
||||
src={`/api/proxy/logo?url=${encodeURIComponent(channel.logo)}&source=${currentSource?.key || ''}`}
|
||||
src={getLogoUrl(channel.logo, currentSource?.key || '')}
|
||||
alt={channel.name}
|
||||
className='w-full h-full rounded object-contain'
|
||||
loading="lazy"
|
||||
@@ -2462,7 +2509,7 @@ function LivePageClient() {
|
||||
<div className='w-20 h-20 bg-gray-300 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden'>
|
||||
{currentChannel.logo ? (
|
||||
<img
|
||||
src={`/api/proxy/logo?url=${encodeURIComponent(currentChannel.logo)}&source=${currentSource?.key || ''}`}
|
||||
src={getLogoUrl(currentChannel.logo, currentSource?.key || '')}
|
||||
alt={currentChannel.name}
|
||||
className='w-full h-full rounded object-contain'
|
||||
loading="lazy"
|
||||
|
||||
+2139
-1781
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { AlertCircle,Cloud, Heart, Sparkles, X } from 'lucide-react';
|
||||
import { AlertCircle, Cloud, Heart, Sparkles, X } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
saveDanmakuSourceIndex,
|
||||
saveManualDanmakuSelection,
|
||||
} from '@/lib/danmaku/selection-memory';
|
||||
import type { DanmakuAnime, DanmakuComment,DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types';
|
||||
import type { DanmakuAnime, DanmakuComment, DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types';
|
||||
import {
|
||||
deleteFavorite,
|
||||
deletePlayRecord,
|
||||
@@ -47,12 +47,14 @@ import {
|
||||
} from '@/lib/db.client';
|
||||
import { getDoubanDetail } from '@/lib/douban.client';
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||
import { DanmakuFilterConfig, EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
||||
import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types';
|
||||
import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||
import { useEnableComments } from '@/hooks/useEnableComments';
|
||||
import { useEnableAIComments } from '@/hooks/useEnableAIComments';
|
||||
import { usePlaySync } from '@/hooks/usePlaySync';
|
||||
|
||||
import AIChatPanel from '@/components/AIChatPanel';
|
||||
import AIComments from '@/components/AIComments';
|
||||
import CorrectDialog from '@/components/CorrectDialog';
|
||||
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
|
||||
import DetailPanel from '@/components/DetailPanel';
|
||||
@@ -87,6 +89,7 @@ function PlayPageClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const enableComments = useEnableComments();
|
||||
const enableAIComments = useEnableAIComments();
|
||||
const { addDownloadTask } = useDownload();
|
||||
const { siteName } = useSite();
|
||||
|
||||
@@ -619,6 +622,24 @@ function PlayPageClient() {
|
||||
}
|
||||
}, [searchParams, currentEpisodeIndex]);
|
||||
|
||||
// 监听集数变化,移除已显示的跳转按钮
|
||||
useEffect(() => {
|
||||
// 移除已显示的跳转按钮
|
||||
if (playRecordJumpLayerRef.current && artPlayerRef.current) {
|
||||
try {
|
||||
artPlayerRef.current.layers.remove('play-record-jump');
|
||||
playRecordJumpLayerRef.current = null;
|
||||
} catch (err) {
|
||||
console.warn('[PlayRecordJump] 移除跳转按钮失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是首次检查,标记为已关闭,不再显示跳转按钮
|
||||
if (!playRecordJumpInitialCheckRef.current) {
|
||||
playRecordJumpDismissedRef.current = true;
|
||||
}
|
||||
}, [currentEpisodeIndex]);
|
||||
|
||||
// 监听 URL 参数变化,当切换到不同视频时重新加载页面
|
||||
useEffect(() => {
|
||||
const urlTitle = searchParams.get('title') || '';
|
||||
@@ -1255,7 +1276,7 @@ function PlayPageClient() {
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
|
||||
// 视频清晰度列表
|
||||
const [videoQualities, setVideoQualities] = useState<Array<{name: string, url: string}>>([]);
|
||||
const [videoQualities, setVideoQualities] = useState<Array<{ name: string, url: string }>>([]);
|
||||
|
||||
// Xiaoya链接刷新相关状态
|
||||
const [isRefreshingUrl, setIsRefreshingUrl] = useState(false); // 是否正在刷新链接
|
||||
@@ -1281,6 +1302,10 @@ function PlayPageClient() {
|
||||
|
||||
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
||||
const resumeTimeRef = useRef<number | null>(null);
|
||||
// 播放记录跳转按钮状态
|
||||
const playRecordJumpDismissedRef = useRef(false); // 记录用户是否已经关闭过跳转按钮
|
||||
const playRecordJumpLayerRef = useRef<any>(null); // 保存跳转按钮层的引用
|
||||
const playRecordJumpInitialCheckRef = useRef(true); // 记录是否是首次检查播放记录
|
||||
// 上次使用的音量,默认 0.7
|
||||
const lastVolumeRef = useRef<number>(0.7);
|
||||
// 上次使用的播放速率,默认 1.0
|
||||
@@ -1335,6 +1360,30 @@ function PlayPageClient() {
|
||||
'initing' | 'sourceChanging'
|
||||
>('initing');
|
||||
const [videoError, setVideoError] = useState<string | null>(null);
|
||||
// 直链播放时 CORS 失败的原始 URL,用于显示"使用代理播放"按钮
|
||||
const [corsFailedUrl, setCorsFailedUrl] = useState<string | null>(null);
|
||||
// 标记当前视频是否已经尝试过代理(防止 415→直连→失败→代理 的无限循环)
|
||||
const proxyAttemptedRef = useRef(false);
|
||||
|
||||
// 直链代理域名记忆:检查某个域名是否需要代理
|
||||
const isDirectplayDomainProxied = (url: string): boolean => {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
const domains: string[] = JSON.parse(localStorage.getItem('directplay_proxy_domains') || '[]');
|
||||
return domains.includes(domain);
|
||||
} catch { return false; }
|
||||
};
|
||||
// 将域名记录到代理列表
|
||||
const addDirectplayProxyDomain = (url: string) => {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
const domains: string[] = JSON.parse(localStorage.getItem('directplay_proxy_domains') || '[]');
|
||||
if (!domains.includes(domain)) {
|
||||
domains.push(domain);
|
||||
localStorage.setItem('directplay_proxy_domains', JSON.stringify(domains));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
// 播放器就绪状态(用于触发 usePlaySync 的事件监听器设置)
|
||||
const [playerReady, setPlayerReady] = useState(false);
|
||||
@@ -1415,11 +1464,31 @@ function PlayPageClient() {
|
||||
}
|
||||
|
||||
// 获取当前集数的播放地址
|
||||
const episodeUrl = detail.episodes[currentEpisodeIndex];
|
||||
let episodeUrl = detail.episodes[currentEpisodeIndex];
|
||||
if (!episodeUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 简单的正则或者后缀判断,如果明确不是 m3u8 (比如 mp4),则不走 m3u8 代理
|
||||
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u') || !episodeUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/);
|
||||
|
||||
if (currentSource === 'directplay' && isM3u8) {
|
||||
// 仅当 localStorage 记忆了该域名需要代理时才走代理
|
||||
if (isDirectplayDomainProxied(episodeUrl)) {
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`;
|
||||
} else {
|
||||
// 直链模式且未走代理:跳过 HLS.js 探测。
|
||||
// getVideoResolutionFromM3u8 内部使用 HLS.js (XMLHttpRequest) 加载,
|
||||
// 而 XHR 受 CORS 限制,探测必然失败。实际播放器通过 <video src> 加载不受 CORS 影响。
|
||||
console.log('[视频信息] 直链直连模式,跳过分辨率探测(避免 CORS 误报)');
|
||||
setCurrentSourceVideoInfo(null);
|
||||
return;
|
||||
}
|
||||
} else if (sourceProxyMode && isM3u8) {
|
||||
episodeUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(currentSource)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await getVideoResolutionFromM3u8(episodeUrl, 4000);
|
||||
setCurrentSourceVideoInfo(info);
|
||||
@@ -1469,10 +1538,22 @@ function PlayPageClient() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const episodeUrl =
|
||||
let episodeUrl =
|
||||
source.episodes.length > 1
|
||||
? source.episodes[1]
|
||||
: source.episodes[0];
|
||||
|
||||
// 对优选源进行测速时也需要考虑代理情况
|
||||
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u') || !episodeUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/);
|
||||
if (source.source === 'directplay' && isM3u8) {
|
||||
if (isDirectplayDomainProxied(episodeUrl)) {
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`;
|
||||
}
|
||||
} else if (source.proxyMode && isM3u8) {
|
||||
episodeUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(source.source)}`;
|
||||
}
|
||||
|
||||
const testResult = await getVideoResolutionFromM3u8(episodeUrl);
|
||||
|
||||
return {
|
||||
@@ -1571,10 +1652,8 @@ function PlayPageClient() {
|
||||
console.log('播放源评分排序结果:');
|
||||
resultsWithScore.forEach((result, index) => {
|
||||
console.log(
|
||||
`${index + 1}. ${
|
||||
result.source.source_name
|
||||
} - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${
|
||||
result.testResult.loadSpeed
|
||||
`${index + 1}. ${result.source.source_name
|
||||
} - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${result.testResult.loadSpeed
|
||||
}, ${result.testResult.pingTime}ms)`
|
||||
);
|
||||
});
|
||||
@@ -2167,10 +2246,25 @@ function PlayPageClient() {
|
||||
// 使用本地代理接口,URL以.m3u8结尾以便Artplayer自动识别
|
||||
newUrl = `/api/offline-download/local/${currentSource}/${currentId}/${episodeIndex}/playlist.m3u8`;
|
||||
console.log('使用服务器端本地下载文件播放:', newUrl);
|
||||
} else if (sourceProxyMode && newUrl) {
|
||||
// 如果视频源启用了代理模式,且不是本地下载,则通过代理播放
|
||||
newUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(newUrl)}&source=${encodeURIComponent(currentSource)}`;
|
||||
console.log('使用代理模式播放:', newUrl);
|
||||
} else {
|
||||
const isM3u8 = newUrl.toLowerCase().includes('.m3u') || !newUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/);
|
||||
|
||||
if (sourceProxyMode && newUrl && isM3u8) {
|
||||
// 如果视频源启用了代理模式,且不是本地下载,则通过代理播放
|
||||
newUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(newUrl)}&source=${encodeURIComponent(currentSource)}`;
|
||||
console.log('使用代理模式播放:', newUrl);
|
||||
} else if (currentSource === 'directplay' && newUrl && isM3u8) {
|
||||
// 直链播放模式:检查 localStorage 是否记录了该域名需要代理
|
||||
if (isDirectplayDomainProxied(newUrl)) {
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
newUrl = `/api/proxy-m3u8?url=${encodeURIComponent(newUrl)}&source=directplay${tokenParam}`;
|
||||
console.log('直链播放(域名已记忆)使用代理模式:', newUrl);
|
||||
} else {
|
||||
console.log('直链播放默认直连模式,不使用代理:', newUrl);
|
||||
}
|
||||
} else if (!isM3u8) {
|
||||
console.log('非 m3u8 格式,豁免代理框架,直接播放原始URL:', newUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2207,8 +2301,8 @@ function PlayPageClient() {
|
||||
const proxyUrl = offlineMode
|
||||
? episodeUrl // 离线下载不使用代理,直接使用原始URL
|
||||
: (externalPlayerAdBlock
|
||||
? `${origin}/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: episodeUrl);
|
||||
? `${origin}/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: episodeUrl);
|
||||
|
||||
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u8') || episodeUrl.toLowerCase().includes('/m3u8/');
|
||||
|
||||
@@ -2478,7 +2572,7 @@ function PlayPageClient() {
|
||||
// 验证outputCanvas尺寸
|
||||
console.log('outputCanvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
|
||||
if (!outputCanvas.width || !outputCanvas.height ||
|
||||
!isFinite(outputCanvas.width) || !isFinite(outputCanvas.height)) {
|
||||
!isFinite(outputCanvas.width) || !isFinite(outputCanvas.height)) {
|
||||
throw new Error(`outputCanvas尺寸无效: ${outputCanvas.width}x${outputCanvas.height}, scale: ${scale}`);
|
||||
}
|
||||
|
||||
@@ -2523,7 +2617,7 @@ function PlayPageClient() {
|
||||
|
||||
// 验证sourceCanvas尺寸
|
||||
if (!sourceCanvas.width || !sourceCanvas.height ||
|
||||
!isFinite(sourceCanvas.width) || !isFinite(sourceCanvas.height)) {
|
||||
!isFinite(sourceCanvas.width) || !isFinite(sourceCanvas.height)) {
|
||||
throw new Error(`sourceCanvas尺寸无效: ${sourceCanvas.width}x${sourceCanvas.height}`);
|
||||
}
|
||||
|
||||
@@ -2906,7 +3000,7 @@ function PlayPageClient() {
|
||||
setSkipConfig(newConfig);
|
||||
if (!newConfig.enable && !newConfig.intro_time && !newConfig.outro_time) {
|
||||
await deleteSkipConfig(currentSourceRef.current, currentIdRef.current);
|
||||
|
||||
|
||||
// 安全地更新播放器设置,仅在播放器存在时执行
|
||||
if (artPlayerRef.current && artPlayerRef.current.setting) {
|
||||
try {
|
||||
@@ -3060,13 +3154,13 @@ function PlayPageClient() {
|
||||
|
||||
// 2.1 明确包含"电影"或"movie"或"片"的,判断为电影
|
||||
if (typeName.includes('电影') || typeName.includes('movie') ||
|
||||
typeName.endsWith('片') && !typeName.includes('动漫')) {
|
||||
typeName.endsWith('片') && !typeName.includes('动漫')) {
|
||||
return 'movie';
|
||||
}
|
||||
|
||||
// 2.2 包含"剧"、"动漫"、"综艺"等关键词的,判断为剧集
|
||||
if (typeName.includes('剧') || typeName.includes('动漫') ||
|
||||
typeName.includes('综艺') || typeName.includes('anime')) {
|
||||
typeName.includes('综艺') || typeName.includes('anime')) {
|
||||
return 'tv';
|
||||
}
|
||||
|
||||
@@ -3098,18 +3192,18 @@ function PlayPageClient() {
|
||||
const cachedData = JSON.parse(cached);
|
||||
|
||||
// 处理缓存的搜索结果,根据规则过滤
|
||||
results = cachedData.filter(
|
||||
results = cachedData.filter(
|
||||
(result: SearchResult) =>
|
||||
normalizeTitle(result.title).toLowerCase() ===
|
||||
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
||||
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
||||
(videoYearRef.current
|
||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
|
||||
!result.year ||
|
||||
result.year.trim() === '' ||
|
||||
result.year === 'unknown' ||
|
||||
!/^\d{4}$/.test(result.year)
|
||||
: true) &&
|
||||
(searchType
|
||||
!result.year ||
|
||||
result.year.trim() === '' ||
|
||||
result.year === 'unknown' ||
|
||||
!/^\d{4}$/.test(result.year)
|
||||
: true) &&
|
||||
(searchType
|
||||
? getType(result) === searchType
|
||||
: true)
|
||||
);
|
||||
@@ -3136,14 +3230,14 @@ function PlayPageClient() {
|
||||
results = data.results.filter(
|
||||
(result: SearchResult) =>
|
||||
normalizeTitle(result.title).toLowerCase() ===
|
||||
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
||||
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
||||
(videoYearRef.current
|
||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
|
||||
!result.year ||
|
||||
result.year.trim() === '' ||
|
||||
result.year === 'unknown' ||
|
||||
!/^\d{4}$/.test(result.year)
|
||||
: true) &&
|
||||
!result.year ||
|
||||
result.year.trim() === '' ||
|
||||
result.year === 'unknown' ||
|
||||
!/^\d{4}$/.test(result.year)
|
||||
: true) &&
|
||||
(searchType
|
||||
? getType(result) === searchType
|
||||
: true)
|
||||
@@ -3635,6 +3729,8 @@ function PlayPageClient() {
|
||||
setVideoLoadingStage('sourceChanging');
|
||||
setIsVideoLoading(true);
|
||||
setVideoError(null);
|
||||
setCorsFailedUrl(null);
|
||||
proxyAttemptedRef.current = false;
|
||||
|
||||
// 记录当前播放进度(仅在同一集数切换时恢复)
|
||||
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
|
||||
@@ -5041,8 +5137,29 @@ function PlayPageClient() {
|
||||
return false;
|
||||
})();
|
||||
|
||||
// 辅助函数:检测代理 URL 是否需要显式声明 m3u8 类型
|
||||
// Artplayer 通过 URL 扩展名自动检测类型,但代理 URL(如 /api/proxy-m3u8?url=...)没有 .m3u8 扩展名
|
||||
const getVideoType = (url: string): string | undefined => {
|
||||
if (!url) return undefined;
|
||||
// 如果 URL 路径中已包含 .m3u8 扩展名,Artplayer 可自动检测,无需显式设置
|
||||
const urlPath = url.split('?')[0];
|
||||
if (urlPath.includes('.m3u8')) return undefined;
|
||||
// 代理 URL 返回的是 m3u8 内容,需要显式声明类型
|
||||
if (url.includes('/api/proxy-m3u8') || url.includes('/api/proxy/vod/m3u8')) {
|
||||
return 'm3u8';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 非WebKit浏览器且播放器已存在,使用switch方法切换
|
||||
if (!isWebkit && artPlayerRef.current) {
|
||||
// 显式设置类型,确保代理 URL 能被 HLS.js 正确处理
|
||||
const videoType = getVideoType(videoUrl);
|
||||
if (videoType) {
|
||||
artPlayerRef.current.option.type = videoType;
|
||||
} else {
|
||||
artPlayerRef.current.option.type = '';
|
||||
}
|
||||
artPlayerRef.current.switch = videoUrl;
|
||||
artPlayerRef.current.title = `${videoTitle} - ${playerEpisodeLabel}`;
|
||||
artPlayerRef.current.poster = videoCover;
|
||||
@@ -5103,460 +5220,467 @@ function PlayPageClient() {
|
||||
|
||||
artPlayerRef.current = new Artplayer({
|
||||
container: artRef.current!,
|
||||
url: videoUrl,
|
||||
poster: videoCover,
|
||||
volume: 0.7,
|
||||
isLive: false,
|
||||
muted: false,
|
||||
autoplay: true,
|
||||
pip: true,
|
||||
autoSize: false,
|
||||
autoMini: false,
|
||||
screenshot: true,
|
||||
setting: true,
|
||||
loop: false,
|
||||
flip: false,
|
||||
playbackRate: true,
|
||||
aspectRatio: false,
|
||||
fullscreen: !isIOS, // iOS 禁用原生全屏按钮,避免触发系统播放器
|
||||
fullscreenWeb: true, // 保留网页全屏按钮(所有平台)
|
||||
...(currentSubtitles.length > 0 ? {
|
||||
subtitle: {
|
||||
url: currentSubtitles[0].url,
|
||||
type: 'vtt',
|
||||
style: {
|
||||
color: '#fff',
|
||||
fontSize: savedSubtitleSize,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
}
|
||||
} : {}),
|
||||
subtitleOffset: false,
|
||||
miniProgressBar: false,
|
||||
mutex: true,
|
||||
playsInline: true,
|
||||
autoPlayback: false,
|
||||
airplay: true,
|
||||
theme: '#22c55e',
|
||||
lang: 'zh-cn',
|
||||
hotkey: false,
|
||||
fastForward: true,
|
||||
autoOrientation: true,
|
||||
lock: true,
|
||||
...(videoQualities.length > 0 ? {
|
||||
quality: videoQualities.map((q, index) => ({
|
||||
default: index === 0,
|
||||
html: q.name,
|
||||
url: q.url,
|
||||
})),
|
||||
} : {}),
|
||||
moreVideoAttr: {
|
||||
url: videoUrl,
|
||||
...(getVideoType(videoUrl) ? { type: getVideoType(videoUrl) } : {}),
|
||||
poster: videoCover,
|
||||
volume: 0.7,
|
||||
isLive: false,
|
||||
muted: false,
|
||||
autoplay: true,
|
||||
pip: true,
|
||||
autoSize: false,
|
||||
autoMini: false,
|
||||
screenshot: true,
|
||||
setting: true,
|
||||
loop: false,
|
||||
flip: false,
|
||||
playbackRate: true,
|
||||
aspectRatio: false,
|
||||
fullscreen: !isIOS, // iOS 禁用原生全屏按钮,避免触发系统播放器
|
||||
fullscreenWeb: true, // 保留网页全屏按钮(所有平台)
|
||||
...(currentSubtitles.length > 0 ? {
|
||||
subtitle: {
|
||||
url: currentSubtitles[0].url,
|
||||
type: 'vtt',
|
||||
style: {
|
||||
color: '#fff',
|
||||
fontSize: savedSubtitleSize,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
}
|
||||
} : {}),
|
||||
subtitleOffset: false,
|
||||
miniProgressBar: false,
|
||||
mutex: true,
|
||||
playsInline: true,
|
||||
'webkit-playsinline': 'true',
|
||||
referrerpolicy: 'no-referrer',
|
||||
} as any,
|
||||
// HLS 支持配置
|
||||
customType: {
|
||||
m3u8: function (video: HTMLVideoElement, url: string) {
|
||||
if (!Hls) {
|
||||
console.error('HLS.js 未加载');
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.hls) {
|
||||
video.hls.destroy();
|
||||
}
|
||||
|
||||
// 每次创建HLS实例时,都读取最新的blockAdEnabled状态
|
||||
const shouldUseCustomLoader = blockAdEnabledRef.current;
|
||||
|
||||
// 从localStorage读取缓冲策略
|
||||
const bufferStrategy = typeof window !== 'undefined'
|
||||
? localStorage.getItem('bufferStrategy') || 'medium'
|
||||
: 'medium';
|
||||
|
||||
// 根据缓冲策略配置不同的缓冲参数
|
||||
const getBufferConfig = (strategy: string) => {
|
||||
switch (strategy) {
|
||||
case 'low':
|
||||
return {
|
||||
maxBufferLength: 15,
|
||||
backBufferLength: 15,
|
||||
maxBufferSize: 30 * 1000 * 1000, // ~30MB
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
maxBufferLength: 30,
|
||||
backBufferLength: 30,
|
||||
maxBufferSize: 60 * 1000 * 1000, // ~60MB
|
||||
};
|
||||
case 'high':
|
||||
return {
|
||||
maxBufferLength: 60,
|
||||
backBufferLength: 40,
|
||||
maxBufferSize: 120 * 1000 * 1000, // ~120MB
|
||||
};
|
||||
case 'ultra':
|
||||
return {
|
||||
maxBufferLength: 120,
|
||||
backBufferLength: 60,
|
||||
maxBufferSize: 240 * 1000 * 1000, // ~240MB
|
||||
};
|
||||
default:
|
||||
return {
|
||||
maxBufferLength: 30,
|
||||
backBufferLength: 30,
|
||||
maxBufferSize: 60 * 1000 * 1000,
|
||||
};
|
||||
autoPlayback: false,
|
||||
airplay: true,
|
||||
theme: '#22c55e',
|
||||
lang: 'zh-cn',
|
||||
hotkey: false,
|
||||
fastForward: true,
|
||||
autoOrientation: true,
|
||||
lock: true,
|
||||
...(videoQualities.length > 0 ? {
|
||||
quality: videoQualities.map((q, index) => ({
|
||||
default: index === 0,
|
||||
html: q.name,
|
||||
url: q.url,
|
||||
})),
|
||||
} : {}),
|
||||
moreVideoAttr: {
|
||||
playsInline: true,
|
||||
'webkit-playsinline': 'true',
|
||||
referrerpolicy: 'no-referrer',
|
||||
} as any,
|
||||
// HLS 支持配置
|
||||
customType: {
|
||||
m3u8: function (video: HTMLVideoElement, url: string) {
|
||||
if (!Hls) {
|
||||
console.error('HLS.js 未加载');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const bufferConfig = getBufferConfig(bufferStrategy);
|
||||
|
||||
// 选择合适的 Loader
|
||||
let loaderClass;
|
||||
if (shouldUseCustomLoader) {
|
||||
// 使用自定义广告过滤 Loader
|
||||
loaderClass = CustomHlsJsLoader;
|
||||
} else {
|
||||
// 使用默认 Loader
|
||||
loaderClass = Hls.DefaultConfig.loader;
|
||||
}
|
||||
|
||||
const hls = new Hls({
|
||||
debug: false, // 关闭日志
|
||||
enableWorker: true, // WebWorker 解码,降低主线程压力
|
||||
lowLatencyMode: true, // 开启低延迟 LL-HLS
|
||||
|
||||
/* 缓冲/内存相关 - 根据用户设置的缓冲策略动态调整 */
|
||||
maxBufferLength: bufferConfig.maxBufferLength, // 前向缓冲长度
|
||||
backBufferLength: bufferConfig.backBufferLength, // 已播放内容保留长度
|
||||
maxBufferSize: bufferConfig.maxBufferSize, // 最大缓冲大小
|
||||
|
||||
/* 自定义loader */
|
||||
loader: loaderClass as any,
|
||||
});
|
||||
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
video.hls = hls;
|
||||
|
||||
ensureVideoSource(video, url);
|
||||
|
||||
// 额外确保 iOS 内联播放属性(防止全屏时使用系统播放器)
|
||||
video.setAttribute('playsinline', 'true');
|
||||
video.setAttribute('webkit-playsinline', 'true');
|
||||
(video as any).playsInline = true;
|
||||
(video as any).webkitPlaysInline = true;
|
||||
|
||||
// 监听Manifest加载完成事件,启动xiaoya链接定时刷新
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[HLS] Manifest解析完成');
|
||||
|
||||
// 只在首次加载时启动定时器(后续刷新会在refreshXiaoyaUrl中启动)
|
||||
if (isInitialLoadRef.current && currentXiaoyaUrlRef.current && url.includes('.m3u8')) {
|
||||
isInitialLoadRef.current = false; // 标记已完成首次加载
|
||||
startRefreshTimer(hls, video);
|
||||
if (video.hls) {
|
||||
video.hls.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, function (event: any, data: any) {
|
||||
console.error('HLS Error:', event, data);
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
// 检查是否是 manifest 加载错误(通常是 403/404/CORS 错误)
|
||||
if (data.details === 'manifestLoadError') {
|
||||
console.log('Manifest 加载失败:可能是 403/404 或 CORS 错误');
|
||||
// 每次创建HLS实例时,都读取最新的blockAdEnabled状态
|
||||
const shouldUseCustomLoader = blockAdEnabledRef.current;
|
||||
|
||||
const statusCode = data.response?.code || data.response?.status;
|
||||
// 从localStorage读取缓冲策略
|
||||
const bufferStrategy = typeof window !== 'undefined'
|
||||
? localStorage.getItem('bufferStrategy') || 'medium'
|
||||
: 'medium';
|
||||
|
||||
// 如果是403且是xiaoya源的m3u8,尝试自动刷新
|
||||
if (statusCode === 403 && currentXiaoyaUrlRef.current) {
|
||||
const isM3u8 = url.includes('.m3u8') || url.includes('m3u8');
|
||||
if (isM3u8) {
|
||||
console.log('[HLS错误] 检测到403,尝试刷新链接');
|
||||
refreshXiaoyaUrl(hls, video, false);
|
||||
return; // 不执行后续的错误处理
|
||||
// 根据缓冲策略配置不同的缓冲参数
|
||||
const getBufferConfig = (strategy: string) => {
|
||||
switch (strategy) {
|
||||
case 'low':
|
||||
return {
|
||||
maxBufferLength: 15,
|
||||
backBufferLength: 15,
|
||||
maxBufferSize: 30 * 1000 * 1000, // ~30MB
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
maxBufferLength: 30,
|
||||
backBufferLength: 30,
|
||||
maxBufferSize: 60 * 1000 * 1000, // ~60MB
|
||||
};
|
||||
case 'high':
|
||||
return {
|
||||
maxBufferLength: 60,
|
||||
backBufferLength: 40,
|
||||
maxBufferSize: 120 * 1000 * 1000, // ~120MB
|
||||
};
|
||||
case 'ultra':
|
||||
return {
|
||||
maxBufferLength: 120,
|
||||
backBufferLength: 60,
|
||||
maxBufferSize: 240 * 1000 * 1000, // ~240MB
|
||||
};
|
||||
default:
|
||||
return {
|
||||
maxBufferLength: 30,
|
||||
backBufferLength: 30,
|
||||
maxBufferSize: 60 * 1000 * 1000,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const bufferConfig = getBufferConfig(bufferStrategy);
|
||||
|
||||
// 选择合适的 Loader
|
||||
let loaderClass;
|
||||
if (shouldUseCustomLoader) {
|
||||
// 使用自定义广告过滤 Loader
|
||||
loaderClass = CustomHlsJsLoader;
|
||||
} else {
|
||||
// 使用默认 Loader
|
||||
loaderClass = Hls.DefaultConfig.loader;
|
||||
}
|
||||
|
||||
const hls = new Hls({
|
||||
debug: false, // 关闭日志
|
||||
enableWorker: true, // WebWorker 解码,降低主线程压力
|
||||
lowLatencyMode: true, // 开启低延迟 LL-HLS
|
||||
|
||||
/* 缓冲/内存相关 - 根据用户设置的缓冲策略动态调整 */
|
||||
maxBufferLength: bufferConfig.maxBufferLength, // 前向缓冲长度
|
||||
backBufferLength: bufferConfig.backBufferLength, // 已播放内容保留长度
|
||||
maxBufferSize: bufferConfig.maxBufferSize, // 最大缓冲大小
|
||||
|
||||
/* 自定义loader */
|
||||
loader: loaderClass as any,
|
||||
});
|
||||
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
video.hls = hls;
|
||||
|
||||
ensureVideoSource(video, url);
|
||||
|
||||
// 额外确保 iOS 内联播放属性(防止全屏时使用系统播放器)
|
||||
video.setAttribute('playsinline', 'true');
|
||||
video.setAttribute('webkit-playsinline', 'true');
|
||||
(video as any).playsInline = true;
|
||||
(video as any).webkitPlaysInline = true;
|
||||
|
||||
// 监听Manifest加载完成事件,启动xiaoya链接定时刷新
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[HLS] Manifest解析完成');
|
||||
|
||||
// 只在首次加载时启动定时器(后续刷新会在refreshXiaoyaUrl中启动)
|
||||
if (isInitialLoadRef.current && currentXiaoyaUrlRef.current && url.includes('.m3u8')) {
|
||||
isInitialLoadRef.current = false; // 标记已完成首次加载
|
||||
startRefreshTimer(hls, video);
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, function (event: any, data: any) {
|
||||
console.error('HLS Error:', event, data);
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
// 检查是否是 manifest 加载错误(通常是 403/404/CORS 错误)
|
||||
if (data.details === 'manifestLoadError') {
|
||||
console.log('Manifest 加载失败:可能是 403/404 或 CORS 错误');
|
||||
|
||||
const statusCode = data.response?.code || data.response?.status;
|
||||
|
||||
// 如果是403且是xiaoya源的m3u8,尝试自动刷新
|
||||
if (statusCode === 403 && currentXiaoyaUrlRef.current) {
|
||||
const isM3u8 = url.includes('.m3u8') || url.includes('m3u8');
|
||||
if (isM3u8) {
|
||||
console.log('[HLS错误] 检测到403,尝试刷新链接');
|
||||
refreshXiaoyaUrl(hls, video, false);
|
||||
return; // 不执行后续的错误处理
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 原有的错误处理逻辑
|
||||
hls.destroy();
|
||||
if (statusCode === 403) {
|
||||
setVideoError('访问被拒绝 (403)');
|
||||
} else if (statusCode === 404) {
|
||||
setVideoError('视频不存在 (404)');
|
||||
} else if (statusCode) {
|
||||
setVideoError(`HTTP ${statusCode} 错误`);
|
||||
} else {
|
||||
// CORS 错误或其他网络错误
|
||||
setVideoError('无法访问视频源(可能是跨域限制或访问被拒绝)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 检查其他 HTTP 错误状态码
|
||||
{
|
||||
const statusCode = data.response?.code || data.response?.status;
|
||||
if (statusCode && statusCode >= 400) {
|
||||
console.log(`HTTP ${statusCode} 错误`);
|
||||
// 原有的错误处理逻辑
|
||||
hls.destroy();
|
||||
setVideoError(`HTTP ${statusCode} 错误`);
|
||||
if (statusCode === 403) {
|
||||
setVideoError('访问被拒绝 (403)');
|
||||
} else if (statusCode === 404) {
|
||||
setVideoError('视频不存在 (404)');
|
||||
} else if (statusCode === 415) {
|
||||
setVideoError('视频格式不兼容 (415)');
|
||||
} else if (statusCode) {
|
||||
setVideoError(`HTTP ${statusCode} 错误`);
|
||||
} else {
|
||||
// CORS 错误或其他网络错误
|
||||
// 如果是直链直连模式(URL 不含代理前缀),记录原始 URL 以便用户一键启用代理
|
||||
if (currentSourceRef.current === 'directplay' && !url.includes('/api/proxy-m3u8') && !url.includes('/api/proxy/vod/m3u8')) {
|
||||
setCorsFailedUrl(url);
|
||||
}
|
||||
setVideoError('无法访问视频源(可能是跨域限制或访问被拒绝)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.log('网络错误,尝试恢复...');
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.log('媒体错误,尝试恢复...');
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
console.log('无法恢复的错误');
|
||||
hls.destroy();
|
||||
setVideoError('视频加载错误');
|
||||
break;
|
||||
// 检查其他 HTTP 错误状态码
|
||||
{
|
||||
const statusCode = data.response?.code || data.response?.status;
|
||||
if (statusCode && statusCode >= 400) {
|
||||
console.log(`HTTP ${statusCode} 错误`);
|
||||
hls.destroy();
|
||||
setVideoError(`HTTP ${statusCode} 错误`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.log('网络错误,尝试恢复...');
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.log('媒体错误,尝试恢复...');
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
console.log('无法恢复的错误');
|
||||
hls.destroy();
|
||||
setVideoError('视频加载错误');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
// 弹幕插件
|
||||
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,
|
||||
emitter: false,
|
||||
heatmap: false, // 禁用 artplayer 自带热力图,使用自定义热力图
|
||||
// 主题
|
||||
theme: 'dark',
|
||||
// 根据保存的显示状态设置初始可见性
|
||||
visible: danmakuDisplayStateRef.current,
|
||||
filter: (danmu: any) => {
|
||||
// 应用过滤规则
|
||||
const filterConfig = danmakuFilterConfigRef.current;
|
||||
if (filterConfig && filterConfig.rules.length > 0) {
|
||||
for (const rule of filterConfig.rules) {
|
||||
// 跳过未启用的规则
|
||||
if (!rule.enabled) continue;
|
||||
// 弹幕插件
|
||||
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,
|
||||
emitter: false,
|
||||
heatmap: false, // 禁用 artplayer 自带热力图,使用自定义热力图
|
||||
// 主题
|
||||
theme: 'dark',
|
||||
// 根据保存的显示状态设置初始可见性
|
||||
visible: danmakuDisplayStateRef.current,
|
||||
filter: (danmu: any) => {
|
||||
// 应用过滤规则
|
||||
const filterConfig = danmakuFilterConfigRef.current;
|
||||
if (filterConfig && filterConfig.rules.length > 0) {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('弹幕过滤规则错误:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
],
|
||||
icons: {
|
||||
loading:
|
||||
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
|
||||
},
|
||||
settings: [
|
||||
{
|
||||
html: '去广告',
|
||||
icon: '<text x="50%" y="50%" font-size="20" font-weight="bold" text-anchor="middle" dominant-baseline="middle" fill="#ffffff">AD</text>',
|
||||
tooltip: blockAdEnabled ? '已开启' : '已关闭',
|
||||
onClick() {
|
||||
const newVal = !blockAdEnabled;
|
||||
try {
|
||||
localStorage.setItem('enable_blockad', String(newVal));
|
||||
if (artPlayerRef.current) {
|
||||
resumeTimeRef.current = artPlayerRef.current.currentTime;
|
||||
if (
|
||||
artPlayerRef.current.video &&
|
||||
artPlayerRef.current.video.hls
|
||||
) {
|
||||
artPlayerRef.current.video.hls.destroy();
|
||||
}
|
||||
artPlayerRef.current.destroy();
|
||||
artPlayerRef.current = null;
|
||||
}
|
||||
setBlockAdEnabled(newVal);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
return newVal ? '当前开启' : '当前关闭';
|
||||
},
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
],
|
||||
icons: {
|
||||
loading:
|
||||
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
|
||||
},
|
||||
{
|
||||
html: '弹幕过滤',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="#ffffff"/><path d="M8 12h8" stroke="#ffffff" stroke-width="2" stroke-linecap="round"/></svg>',
|
||||
tooltip: '配置弹幕过滤规则',
|
||||
onClick() {
|
||||
// 如果播放器处于全屏状态,先退出全屏
|
||||
if (artPlayerRef.current && artPlayerRef.current.fullscreen) {
|
||||
artPlayerRef.current.fullscreen = false;
|
||||
// 延迟一下再显示弹窗,确保全屏退出动画完成
|
||||
setTimeout(() => {
|
||||
setShowDanmakuFilterSettings(true);
|
||||
}, 300);
|
||||
} else {
|
||||
setShowDanmakuFilterSettings(true);
|
||||
}
|
||||
return '打开设置';
|
||||
},
|
||||
},
|
||||
// 热力图开关(仅在未禁用时显示)
|
||||
...(!danmakuHeatmapDisabledRef.current ? [{
|
||||
name: '弹幕热力',
|
||||
html: '弹幕热力',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" fill="#ffffff"/></svg>',
|
||||
switch: danmakuHeatmapEnabledRef.current,
|
||||
onSwitch: function (item: any) {
|
||||
const newVal = !item.switch;
|
||||
try {
|
||||
localStorage.setItem('danmaku_heatmap_enabled', String(newVal));
|
||||
setDanmakuHeatmapEnabled(newVal);
|
||||
console.log('弹幕热力已', newVal ? '开启' : '关闭');
|
||||
} catch (err) {
|
||||
console.error('切换弹幕热力失败:', err);
|
||||
}
|
||||
return newVal;
|
||||
},
|
||||
}] : []),
|
||||
...(webGPUSupported ? [
|
||||
settings: [
|
||||
{
|
||||
name: 'Anime4K超分',
|
||||
html: 'Anime4K超分',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5zm0 18c-4 0-7-3-7-7V9l7-3.5L19 9v4c0 4-3 7-7 7z" fill="#ffffff"/><path d="M10 12l2 2 4-4" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||
switch: anime4kEnabledRef.current,
|
||||
onSwitch: async function (item: any) {
|
||||
html: '去广告',
|
||||
icon: '<text x="50%" y="50%" font-size="20" font-weight="bold" text-anchor="middle" dominant-baseline="middle" fill="#ffffff">AD</text>',
|
||||
tooltip: blockAdEnabled ? '已开启' : '已关闭',
|
||||
onClick() {
|
||||
const newVal = !blockAdEnabled;
|
||||
try {
|
||||
localStorage.setItem('enable_blockad', String(newVal));
|
||||
if (artPlayerRef.current) {
|
||||
resumeTimeRef.current = artPlayerRef.current.currentTime;
|
||||
if (
|
||||
artPlayerRef.current.video &&
|
||||
artPlayerRef.current.video.hls
|
||||
) {
|
||||
artPlayerRef.current.video.hls.destroy();
|
||||
}
|
||||
artPlayerRef.current.destroy();
|
||||
artPlayerRef.current = null;
|
||||
}
|
||||
setBlockAdEnabled(newVal);
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
return newVal ? '当前开启' : '当前关闭';
|
||||
},
|
||||
},
|
||||
{
|
||||
html: '弹幕过滤',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="#ffffff"/><path d="M8 12h8" stroke="#ffffff" stroke-width="2" stroke-linecap="round"/></svg>',
|
||||
tooltip: '配置弹幕过滤规则',
|
||||
onClick() {
|
||||
// 如果播放器处于全屏状态,先退出全屏
|
||||
if (artPlayerRef.current && artPlayerRef.current.fullscreen) {
|
||||
artPlayerRef.current.fullscreen = false;
|
||||
// 延迟一下再显示弹窗,确保全屏退出动画完成
|
||||
setTimeout(() => {
|
||||
setShowDanmakuFilterSettings(true);
|
||||
}, 300);
|
||||
} else {
|
||||
setShowDanmakuFilterSettings(true);
|
||||
}
|
||||
return '打开设置';
|
||||
},
|
||||
},
|
||||
// 热力图开关(仅在未禁用时显示)
|
||||
...(!danmakuHeatmapDisabledRef.current ? [{
|
||||
name: '弹幕热力',
|
||||
html: '弹幕热力',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" fill="#ffffff"/></svg>',
|
||||
switch: danmakuHeatmapEnabledRef.current,
|
||||
onSwitch: function (item: any) {
|
||||
const newVal = !item.switch;
|
||||
await toggleAnime4K(newVal);
|
||||
try {
|
||||
localStorage.setItem('danmaku_heatmap_enabled', String(newVal));
|
||||
setDanmakuHeatmapEnabled(newVal);
|
||||
console.log('弹幕热力已', newVal ? '开启' : '关闭');
|
||||
} catch (err) {
|
||||
console.error('切换弹幕热力失败:', err);
|
||||
}
|
||||
return newVal;
|
||||
},
|
||||
},
|
||||
}] : []),
|
||||
...(webGPUSupported ? [
|
||||
{
|
||||
name: 'Anime4K超分',
|
||||
html: 'Anime4K超分',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5zm0 18c-4 0-7-3-7-7V9l7-3.5L19 9v4c0 4-3 7-7 7z" fill="#ffffff"/><path d="M10 12l2 2 4-4" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||
switch: anime4kEnabledRef.current,
|
||||
onSwitch: async function (item: any) {
|
||||
const newVal = !item.switch;
|
||||
await toggleAnime4K(newVal);
|
||||
return newVal;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '超分模式',
|
||||
html: '超分模式',
|
||||
selector: [
|
||||
{
|
||||
html: 'ModeA (快速)',
|
||||
value: 'ModeA',
|
||||
default: anime4kModeRef.current === 'ModeA',
|
||||
},
|
||||
{
|
||||
html: 'ModeB (平衡)',
|
||||
value: 'ModeB',
|
||||
default: anime4kModeRef.current === 'ModeB',
|
||||
},
|
||||
{
|
||||
html: 'ModeC (质量)',
|
||||
value: 'ModeC',
|
||||
default: anime4kModeRef.current === 'ModeC',
|
||||
},
|
||||
{
|
||||
html: 'ModeAA (增强快速)',
|
||||
value: 'ModeAA',
|
||||
default: anime4kModeRef.current === 'ModeAA',
|
||||
},
|
||||
{
|
||||
html: 'ModeBB (增强平衡)',
|
||||
value: 'ModeBB',
|
||||
default: anime4kModeRef.current === 'ModeBB',
|
||||
},
|
||||
{
|
||||
html: 'ModeCA (最高质量)',
|
||||
value: 'ModeCA',
|
||||
default: anime4kModeRef.current === 'ModeCA',
|
||||
},
|
||||
],
|
||||
onSelect: async function (item: any) {
|
||||
await changeAnime4KMode(item.value);
|
||||
return item.html;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '超分倍数',
|
||||
html: '超分倍数',
|
||||
selector: [
|
||||
{
|
||||
html: '1.5x',
|
||||
value: '1.5',
|
||||
default: anime4kScaleRef.current === 1.5,
|
||||
},
|
||||
{
|
||||
html: '2.0x',
|
||||
value: '2.0',
|
||||
default: anime4kScaleRef.current === 2.0,
|
||||
},
|
||||
{
|
||||
html: '3.0x',
|
||||
value: '3.0',
|
||||
default: anime4kScaleRef.current === 3.0,
|
||||
},
|
||||
{
|
||||
html: '4.0x',
|
||||
value: '4.0',
|
||||
default: anime4kScaleRef.current === 4.0,
|
||||
},
|
||||
],
|
||||
onSelect: async function (item: any) {
|
||||
await changeAnime4KScale(parseFloat(item.value));
|
||||
return item.html;
|
||||
},
|
||||
}
|
||||
] : []),
|
||||
{
|
||||
name: '超分模式',
|
||||
html: '超分模式',
|
||||
selector: [
|
||||
{
|
||||
html: 'ModeA (快速)',
|
||||
value: 'ModeA',
|
||||
default: anime4kModeRef.current === 'ModeA',
|
||||
},
|
||||
{
|
||||
html: 'ModeB (平衡)',
|
||||
value: 'ModeB',
|
||||
default: anime4kModeRef.current === 'ModeB',
|
||||
},
|
||||
{
|
||||
html: 'ModeC (质量)',
|
||||
value: 'ModeC',
|
||||
default: anime4kModeRef.current === 'ModeC',
|
||||
},
|
||||
{
|
||||
html: 'ModeAA (增强快速)',
|
||||
value: 'ModeAA',
|
||||
default: anime4kModeRef.current === 'ModeAA',
|
||||
},
|
||||
{
|
||||
html: 'ModeBB (增强平衡)',
|
||||
value: 'ModeBB',
|
||||
default: anime4kModeRef.current === 'ModeBB',
|
||||
},
|
||||
{
|
||||
html: 'ModeCA (最高质量)',
|
||||
value: 'ModeCA',
|
||||
default: anime4kModeRef.current === 'ModeCA',
|
||||
},
|
||||
],
|
||||
onSelect: async function (item: any) {
|
||||
await changeAnime4KMode(item.value);
|
||||
return item.html;
|
||||
name: '跳过片头片尾',
|
||||
html: '跳过片头片尾',
|
||||
switch: skipConfigRef.current.enable,
|
||||
onSwitch: function (item) {
|
||||
const newConfig = {
|
||||
...skipConfigRef.current,
|
||||
enable: !item.switch,
|
||||
};
|
||||
handleSkipConfigChange(newConfig);
|
||||
return !item.switch;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '超分倍数',
|
||||
html: '超分倍数',
|
||||
selector: [
|
||||
{
|
||||
html: '1.5x',
|
||||
value: '1.5',
|
||||
default: anime4kScaleRef.current === 1.5,
|
||||
},
|
||||
{
|
||||
html: '2.0x',
|
||||
value: '2.0',
|
||||
default: anime4kScaleRef.current === 2.0,
|
||||
},
|
||||
{
|
||||
html: '3.0x',
|
||||
value: '3.0',
|
||||
default: anime4kScaleRef.current === 3.0,
|
||||
},
|
||||
{
|
||||
html: '4.0x',
|
||||
value: '4.0',
|
||||
default: anime4kScaleRef.current === 4.0,
|
||||
},
|
||||
],
|
||||
onSelect: async function (item: any) {
|
||||
await changeAnime4KScale(parseFloat(item.value));
|
||||
return item.html;
|
||||
},
|
||||
}
|
||||
] : []),
|
||||
{
|
||||
name: '跳过片头片尾',
|
||||
html: '跳过片头片尾',
|
||||
switch: skipConfigRef.current.enable,
|
||||
onSwitch: function (item) {
|
||||
const newConfig = {
|
||||
...skipConfigRef.current,
|
||||
enable: !item.switch,
|
||||
};
|
||||
handleSkipConfigChange(newConfig);
|
||||
return !item.switch;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '跳过配置',
|
||||
html: '跳过配置',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="12" r="2" fill="#ffffff"/><path d="M9 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
|
||||
tooltip:
|
||||
skipConfigRef.current.intro_time === 0 && skipConfigRef.current.outro_time === 0
|
||||
? '设置跳过配置'
|
||||
: `片头: ${formatTime(skipConfigRef.current.intro_time)} | 片尾: ${formatTime(Math.abs(skipConfigRef.current.outro_time))}`,
|
||||
onClick: async function () {
|
||||
const player = artPlayerRef.current;
|
||||
if (player) {
|
||||
// 如果处于全屏状态,先退出全屏
|
||||
if (player.fullscreen) {
|
||||
player.fullscreen = false;
|
||||
// 等待全屏退出动画完成
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
name: '跳过配置',
|
||||
html: '跳过配置',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="12" r="2" fill="#ffffff"/><path d="M9 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
|
||||
tooltip:
|
||||
skipConfigRef.current.intro_time === 0 && skipConfigRef.current.outro_time === 0
|
||||
? '设置跳过配置'
|
||||
: `片头: ${formatTime(skipConfigRef.current.intro_time)} | 片尾: ${formatTime(Math.abs(skipConfigRef.current.outro_time))}`,
|
||||
onClick: async function () {
|
||||
const player = artPlayerRef.current;
|
||||
if (player) {
|
||||
// 如果处于全屏状态,先退出全屏
|
||||
if (player.fullscreen) {
|
||||
player.fullscreen = false;
|
||||
// 等待全屏退出动画完成
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
// 使用 ArtPlayer 的 prompt 功能创建输入弹窗
|
||||
const currentIntro = skipConfigRef.current.intro_time || 0;
|
||||
const currentOutro = Math.abs(skipConfigRef.current.outro_time) || 0;
|
||||
// 使用 ArtPlayer 的 prompt 功能创建输入弹窗
|
||||
const currentIntro = skipConfigRef.current.intro_time || 0;
|
||||
const currentOutro = Math.abs(skipConfigRef.current.outro_time) || 0;
|
||||
|
||||
// 创建一个自定义的提示框
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = `
|
||||
// 创建一个自定义的提示框
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
@@ -5569,7 +5693,7 @@ function PlayPageClient() {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
container.innerHTML = `
|
||||
container.innerHTML = `
|
||||
<div style="color: white; margin-bottom: 15px; font-size: 16px; font-weight: bold; border-bottom: 1px solid #444; padding-bottom: 10px;">
|
||||
跳过配置
|
||||
</div>
|
||||
@@ -5625,110 +5749,110 @@ function PlayPageClient() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(container);
|
||||
document.body.appendChild(container);
|
||||
|
||||
const introInput = container.querySelector('#intro-input') as HTMLInputElement;
|
||||
const outroInput = container.querySelector('#outro-input') as HTMLInputElement;
|
||||
const setIntroBtn = container.querySelector('#set-intro-btn');
|
||||
const setOutroBtn = container.querySelector('#set-outro-btn');
|
||||
const cancelBtn = container.querySelector('#cancel-btn');
|
||||
const clearBtn = container.querySelector('#clear-btn');
|
||||
const confirmBtn = container.querySelector('#confirm-btn');
|
||||
const introInput = container.querySelector('#intro-input') as HTMLInputElement;
|
||||
const outroInput = container.querySelector('#outro-input') as HTMLInputElement;
|
||||
const setIntroBtn = container.querySelector('#set-intro-btn');
|
||||
const setOutroBtn = container.querySelector('#set-outro-btn');
|
||||
const cancelBtn = container.querySelector('#cancel-btn');
|
||||
const clearBtn = container.querySelector('#clear-btn');
|
||||
const confirmBtn = container.querySelector('#confirm-btn');
|
||||
|
||||
const cleanup = () => {
|
||||
document.body.removeChild(container);
|
||||
};
|
||||
|
||||
// 设置片头为当前时间
|
||||
setIntroBtn?.addEventListener('click', () => {
|
||||
const currentTime = player.currentTime || 0;
|
||||
if (currentTime > 0) {
|
||||
introInput.value = Math.floor(currentTime).toString();
|
||||
}
|
||||
});
|
||||
|
||||
// 设置片尾为当前时间到结束的时长
|
||||
setOutroBtn?.addEventListener('click', () => {
|
||||
if (player.duration && player.currentTime) {
|
||||
const outroTime = player.duration - player.currentTime;
|
||||
if (outroTime > 0) {
|
||||
outroInput.value = Math.floor(outroTime).toString();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
cancelBtn?.addEventListener('click', cleanup);
|
||||
|
||||
clearBtn?.addEventListener('click', () => {
|
||||
handleSkipConfigChange({
|
||||
enable: false,
|
||||
intro_time: 0,
|
||||
outro_time: 0,
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
|
||||
confirmBtn?.addEventListener('click', () => {
|
||||
const introTime = parseFloat(introInput.value) || 0;
|
||||
const outroTime = parseFloat(outroInput.value) || 0;
|
||||
|
||||
const newConfig = {
|
||||
...skipConfigRef.current,
|
||||
intro_time: introTime,
|
||||
outro_time: outroTime > 0 ? -outroTime : 0,
|
||||
const cleanup = () => {
|
||||
document.body.removeChild(container);
|
||||
};
|
||||
|
||||
handleSkipConfigChange(newConfig);
|
||||
cleanup();
|
||||
});
|
||||
// 设置片头为当前时间
|
||||
setIntroBtn?.addEventListener('click', () => {
|
||||
const currentTime = player.currentTime || 0;
|
||||
if (currentTime > 0) {
|
||||
introInput.value = Math.floor(currentTime).toString();
|
||||
}
|
||||
});
|
||||
|
||||
// 支持 Enter 键确认
|
||||
const handleEnter = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
confirmBtn?.dispatchEvent(new Event('click'));
|
||||
} else if (e.key === 'Escape') {
|
||||
cancelBtn?.dispatchEvent(new Event('click'));
|
||||
}
|
||||
};
|
||||
// 设置片尾为当前时间到结束的时长
|
||||
setOutroBtn?.addEventListener('click', () => {
|
||||
if (player.duration && player.currentTime) {
|
||||
const outroTime = player.duration - player.currentTime;
|
||||
if (outroTime > 0) {
|
||||
outroInput.value = Math.floor(outroTime).toString();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
introInput.addEventListener('keydown', handleEnter);
|
||||
outroInput.addEventListener('keydown', handleEnter);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
],
|
||||
// 控制栏配置
|
||||
controls: [
|
||||
{
|
||||
position: 'left',
|
||||
index: 13,
|
||||
html: '<i class="art-icon flex"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" fill="currentColor"/></svg></i>',
|
||||
tooltip: '播放下一集',
|
||||
click: function () {
|
||||
// 房员禁用下一集按钮
|
||||
if (playSync.shouldDisableControls) {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '房员无法切换集数,请等待房主操作';
|
||||
cancelBtn?.addEventListener('click', cleanup);
|
||||
|
||||
clearBtn?.addEventListener('click', () => {
|
||||
handleSkipConfigChange({
|
||||
enable: false,
|
||||
intro_time: 0,
|
||||
outro_time: 0,
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
|
||||
confirmBtn?.addEventListener('click', () => {
|
||||
const introTime = parseFloat(introInput.value) || 0;
|
||||
const outroTime = parseFloat(outroInput.value) || 0;
|
||||
|
||||
const newConfig = {
|
||||
...skipConfigRef.current,
|
||||
intro_time: introTime,
|
||||
outro_time: outroTime > 0 ? -outroTime : 0,
|
||||
};
|
||||
|
||||
handleSkipConfigChange(newConfig);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// 支持 Enter 键确认
|
||||
const handleEnter = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
confirmBtn?.dispatchEvent(new Event('click'));
|
||||
} else if (e.key === 'Escape') {
|
||||
cancelBtn?.dispatchEvent(new Event('click'));
|
||||
}
|
||||
};
|
||||
|
||||
introInput.addEventListener('keydown', handleEnter);
|
||||
outroInput.addEventListener('keydown', handleEnter);
|
||||
}
|
||||
return;
|
||||
}
|
||||
handleNextEpisode();
|
||||
return '';
|
||||
},
|
||||
},
|
||||
},
|
||||
// iOS 设备上添加自定义全屏按钮(横屏和竖屏都显示)
|
||||
...(isIOS ? [{
|
||||
position: 'right',
|
||||
index: 100, // 大数字确保在设置按钮右边
|
||||
html: '<i class="art-icon ios-portrait-fullscreen"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" fill="currentColor"/></svg></i>',
|
||||
tooltip: '全屏',
|
||||
style: {
|
||||
color: '#fff',
|
||||
],
|
||||
// 控制栏配置
|
||||
controls: [
|
||||
{
|
||||
position: 'left',
|
||||
index: 13,
|
||||
html: '<i class="art-icon flex"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" fill="currentColor"/></svg></i>',
|
||||
tooltip: '播放下一集',
|
||||
click: function () {
|
||||
// 房员禁用下一集按钮
|
||||
if (playSync.shouldDisableControls) {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '房员无法切换集数,请等待房主操作';
|
||||
}
|
||||
return;
|
||||
}
|
||||
handleNextEpisode();
|
||||
},
|
||||
},
|
||||
mounted: function($el: HTMLElement) {
|
||||
// 添加 CSS 样式:横屏和竖屏都显示
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
// iOS 设备上添加自定义全屏按钮(横屏和竖屏都显示)
|
||||
...(isIOS ? [{
|
||||
position: 'right',
|
||||
index: 100, // 大数字确保在设置按钮右边
|
||||
html: '<i class="art-icon ios-portrait-fullscreen"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" fill="currentColor"/></svg></i>',
|
||||
tooltip: '全屏',
|
||||
style: {
|
||||
color: '#fff',
|
||||
},
|
||||
mounted: function ($el: HTMLElement) {
|
||||
// 添加 CSS 样式:横屏和竖屏都显示
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
/* iOS 自定义全屏按钮在所有方向都显示 */
|
||||
.ios-portrait-fullscreen {
|
||||
display: inline-flex !important;
|
||||
@@ -5913,64 +6037,64 @@ function PlayPageClient() {
|
||||
stroke: currentColor;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
click: function () {
|
||||
if (!artPlayerRef.current) return;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
click: function () {
|
||||
if (!artPlayerRef.current) return;
|
||||
|
||||
// 检测是否在 PWA 模式下
|
||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.matchMedia('(display-mode: fullscreen)').matches ||
|
||||
(window.navigator as any).standalone === true;
|
||||
// 检测是否在 PWA 模式下
|
||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.matchMedia('(display-mode: fullscreen)').matches ||
|
||||
(window.navigator as any).standalone === true;
|
||||
|
||||
// 检查是否已经在原生全屏状态
|
||||
const isInNativeFullscreen = !!(document.fullscreenElement || (document as any).webkitFullscreenElement);
|
||||
// 检查是否已经在原生全屏状态
|
||||
const isInNativeFullscreen = !!(document.fullscreenElement || (document as any).webkitFullscreenElement);
|
||||
|
||||
// 如果已经在原生全屏状态,退出原生全屏
|
||||
if (isInNativeFullscreen) {
|
||||
const exitFullscreen = (document as any).exitFullscreen ||
|
||||
(document as any).webkitExitFullscreen ||
|
||||
(document as any).mozCancelFullScreen ||
|
||||
(document as any).msExitFullscreen;
|
||||
if (exitFullscreen) {
|
||||
try {
|
||||
const result = exitFullscreen.call(document);
|
||||
if (result && typeof result.catch === 'function') {
|
||||
result.catch((err: Error) => console.error('退出全屏失败:', err));
|
||||
// 如果已经在原生全屏状态,退出原生全屏
|
||||
if (isInNativeFullscreen) {
|
||||
const exitFullscreen = (document as any).exitFullscreen ||
|
||||
(document as any).webkitExitFullscreen ||
|
||||
(document as any).mozCancelFullScreen ||
|
||||
(document as any).msExitFullscreen;
|
||||
if (exitFullscreen) {
|
||||
try {
|
||||
const result = exitFullscreen.call(document);
|
||||
if (result && typeof result.catch === 'function') {
|
||||
result.catch((err: Error) => console.error('退出全屏失败:', err));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('退出全屏失败:', err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('退出全屏失败:', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已经在网页全屏状态,退出网页全屏
|
||||
if (artPlayerRef.current.fullscreenWeb) {
|
||||
artPlayerRef.current.fullscreenWeb = false;
|
||||
return;
|
||||
}
|
||||
// 如果已经在网页全屏状态,退出网页全屏
|
||||
if (artPlayerRef.current.fullscreenWeb) {
|
||||
artPlayerRef.current.fullscreenWeb = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果在 PWA 模式下,直接使用容器全屏(可以隐藏状态栏)
|
||||
if (isPWA) {
|
||||
const container = artPlayerRef.current.template.$container;
|
||||
if (container && container.webkitEnterFullscreen) {
|
||||
container.webkitEnterFullscreen().catch((err: Error) => {
|
||||
console.error('PWA 全屏失败:', err);
|
||||
// 如果失败,降级使用网页全屏
|
||||
// 如果在 PWA 模式下,直接使用容器全屏(可以隐藏状态栏)
|
||||
if (isPWA) {
|
||||
const container = artPlayerRef.current.template.$container;
|
||||
if (container && container.webkitEnterFullscreen) {
|
||||
container.webkitEnterFullscreen().catch((err: Error) => {
|
||||
console.error('PWA 全屏失败:', err);
|
||||
// 如果失败,降级使用网页全屏
|
||||
artPlayerRef.current.fullscreenWeb = true;
|
||||
});
|
||||
} else {
|
||||
// 不支持原生全屏,使用网页全屏
|
||||
artPlayerRef.current.fullscreenWeb = true;
|
||||
});
|
||||
} else {
|
||||
// 不支持原生全屏,使用网页全屏
|
||||
artPlayerRef.current.fullscreenWeb = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 非 PWA 模式:创建对话框(使用项目统一风格)
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'ios-fullscreen-dialog';
|
||||
dialog.innerHTML = `
|
||||
// 非 PWA 模式:创建对话框(使用项目统一风格)
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'ios-fullscreen-dialog';
|
||||
dialog.innerHTML = `
|
||||
<div class="ios-fullscreen-dialog-content">
|
||||
<!-- 标题栏 -->
|
||||
<div class="ios-fullscreen-dialog-header">
|
||||
@@ -6041,108 +6165,108 @@ function PlayPageClient() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 添加到页面
|
||||
document.body.appendChild(dialog);
|
||||
// 添加到页面
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
// 点击背景关闭
|
||||
dialog.addEventListener('click', (e) => {
|
||||
if (e.target === dialog) {
|
||||
document.body.removeChild(dialog);
|
||||
}
|
||||
});
|
||||
// 点击背景关闭
|
||||
dialog.addEventListener('click', (e) => {
|
||||
if (e.target === dialog) {
|
||||
document.body.removeChild(dialog);
|
||||
}
|
||||
});
|
||||
|
||||
// 按钮点击事件
|
||||
const buttons = dialog.querySelectorAll('.ios-fullscreen-option');
|
||||
buttons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const action = button.getAttribute('data-action');
|
||||
// 按钮点击事件
|
||||
const buttons = dialog.querySelectorAll('.ios-fullscreen-option');
|
||||
buttons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const action = button.getAttribute('data-action');
|
||||
|
||||
if (action === 'web') {
|
||||
// 网页全屏
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.fullscreenWeb = true;
|
||||
}
|
||||
} else if (action === 'native') {
|
||||
// 原生全屏(尝试使用浏览器的全屏 API)
|
||||
if (artPlayerRef.current && artPlayerRef.current.template.$video) {
|
||||
const videoElement = artPlayerRef.current.template.$video;
|
||||
if (videoElement.requestFullscreen) {
|
||||
videoElement.requestFullscreen();
|
||||
} else if ((videoElement as any).webkitEnterFullscreen) {
|
||||
(videoElement as any).webkitEnterFullscreen();
|
||||
if (action === 'web') {
|
||||
// 网页全屏
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.fullscreenWeb = true;
|
||||
}
|
||||
} else if (action === 'native') {
|
||||
// 原生全屏(尝试使用浏览器的全屏 API)
|
||||
if (artPlayerRef.current && artPlayerRef.current.template.$video) {
|
||||
const videoElement = artPlayerRef.current.template.$video;
|
||||
if (videoElement.requestFullscreen) {
|
||||
videoElement.requestFullscreen();
|
||||
} else if ((videoElement as any).webkitEnterFullscreen) {
|
||||
(videoElement as any).webkitEnterFullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
document.body.removeChild(dialog);
|
||||
// 关闭对话框
|
||||
document.body.removeChild(dialog);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
}] : []),
|
||||
],
|
||||
});
|
||||
},
|
||||
}] : []),
|
||||
],
|
||||
});
|
||||
|
||||
// 监听播放器事件
|
||||
artPlayerRef.current.on('ready', async () => {
|
||||
setError(null);
|
||||
// 监听播放器事件
|
||||
artPlayerRef.current.on('ready', async () => {
|
||||
setError(null);
|
||||
|
||||
// 标记播放器已就绪,触发 usePlaySync 设置事件监听器
|
||||
setPlayerReady(true);
|
||||
console.log('[PlayPage] Player ready, triggering sync setup');
|
||||
// 标记播放器已就绪,触发 usePlaySync 设置事件监听器
|
||||
setPlayerReady(true);
|
||||
console.log('[PlayPage] Player ready, triggering sync setup');
|
||||
|
||||
// 应用进度条图标配置 - 尽早执行
|
||||
const applyProgressThumbConfig = () => {
|
||||
try {
|
||||
const config = (window as any).RUNTIME_CONFIG;
|
||||
// 应用进度条图标配置 - 尽早执行
|
||||
const applyProgressThumbConfig = () => {
|
||||
try {
|
||||
const config = (window as any).RUNTIME_CONFIG;
|
||||
|
||||
if (!config || config.PROGRESS_THUMB_TYPE === 'default') {
|
||||
// 使用默认样式,移除自定义样式
|
||||
const oldStyle = document.getElementById('custom-progress-thumb-style');
|
||||
if (oldStyle) oldStyle.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
let thumbUrl = '';
|
||||
let thumbColor = '#22c55e'; // 默认绿色
|
||||
|
||||
if (config.PROGRESS_THUMB_TYPE === 'preset' && config.PROGRESS_THUMB_PRESET_ID) {
|
||||
const presetConfig: Record<string, { url: string; color: string }> = {
|
||||
renako: { url: '/icons/q/renako.png', color: '#ec4899' }, // 粉色
|
||||
irena: { url: '/icons/q/irena.png', color: '#f8fafc' }, // 雪白色
|
||||
emilia: { url: '/icons/q/emilia.png', color: '#f8fafc' }, // 雪白色
|
||||
};
|
||||
const preset = presetConfig[config.PROGRESS_THUMB_PRESET_ID];
|
||||
if (preset) {
|
||||
thumbUrl = preset.url;
|
||||
thumbColor = preset.color;
|
||||
}
|
||||
} else if (config.PROGRESS_THUMB_TYPE === 'custom' && config.PROGRESS_THUMB_CUSTOM_URL) {
|
||||
thumbUrl = config.PROGRESS_THUMB_CUSTOM_URL;
|
||||
}
|
||||
|
||||
// 修改 ArtPlayer 的主题色
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.theme = thumbColor;
|
||||
}
|
||||
|
||||
if (thumbUrl) {
|
||||
// 根据预设ID确定尺寸
|
||||
let width = '30px';
|
||||
let height = '30px';
|
||||
let marginLeft = '-15px';
|
||||
|
||||
// renako 图标特殊处理(288x404比例,放大1.25倍)
|
||||
if (config.PROGRESS_THUMB_TYPE === 'preset' && config.PROGRESS_THUMB_PRESET_ID === 'renako') {
|
||||
width = '26.875px'; // 21.5 * 1.25
|
||||
height = '37.5px'; // 30 * 1.25
|
||||
marginLeft = '-13.4375px'; // 10.75 * 1.25
|
||||
if (!config || config.PROGRESS_THUMB_TYPE === 'default') {
|
||||
// 使用默认样式,移除自定义样式
|
||||
const oldStyle = document.getElementById('custom-progress-thumb-style');
|
||||
if (oldStyle) oldStyle.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// 动态设置背景图片
|
||||
const style = document.createElement('style');
|
||||
style.id = 'custom-progress-thumb-style';
|
||||
style.textContent = `
|
||||
let thumbUrl = '';
|
||||
let thumbColor = '#22c55e'; // 默认绿色
|
||||
|
||||
if (config.PROGRESS_THUMB_TYPE === 'preset' && config.PROGRESS_THUMB_PRESET_ID) {
|
||||
const presetConfig: Record<string, { url: string; color: string }> = {
|
||||
renako: { url: '/icons/q/renako.png', color: '#ec4899' }, // 粉色
|
||||
irena: { url: '/icons/q/irena.png', color: '#f8fafc' }, // 雪白色
|
||||
emilia: { url: '/icons/q/emilia.png', color: '#f8fafc' }, // 雪白色
|
||||
};
|
||||
const preset = presetConfig[config.PROGRESS_THUMB_PRESET_ID];
|
||||
if (preset) {
|
||||
thumbUrl = preset.url;
|
||||
thumbColor = preset.color;
|
||||
}
|
||||
} else if (config.PROGRESS_THUMB_TYPE === 'custom' && config.PROGRESS_THUMB_CUSTOM_URL) {
|
||||
thumbUrl = config.PROGRESS_THUMB_CUSTOM_URL;
|
||||
}
|
||||
|
||||
// 修改 ArtPlayer 的主题色
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.theme = thumbColor;
|
||||
}
|
||||
|
||||
if (thumbUrl) {
|
||||
// 根据预设ID确定尺寸
|
||||
let width = '30px';
|
||||
let height = '30px';
|
||||
let marginLeft = '-15px';
|
||||
|
||||
// renako 图标特殊处理(288x404比例,放大1.25倍)
|
||||
if (config.PROGRESS_THUMB_TYPE === 'preset' && config.PROGRESS_THUMB_PRESET_ID === 'renako') {
|
||||
width = '26.875px'; // 21.5 * 1.25
|
||||
height = '37.5px'; // 30 * 1.25
|
||||
marginLeft = '-13.4375px'; // 10.75 * 1.25
|
||||
}
|
||||
|
||||
// 动态设置背景图片
|
||||
const style = document.createElement('style');
|
||||
style.id = 'custom-progress-thumb-style';
|
||||
style.textContent = `
|
||||
/* 替换默认的进度条圆点为自定义图标 */
|
||||
.art-video-player .art-progress-indicator {
|
||||
width: ${width} !important;
|
||||
@@ -6157,494 +6281,462 @@ function PlayPageClient() {
|
||||
}
|
||||
`;
|
||||
|
||||
// 移除旧样式
|
||||
const oldStyle = document.getElementById('custom-progress-thumb-style');
|
||||
if (oldStyle) oldStyle.remove();
|
||||
// 移除旧样式
|
||||
const oldStyle = document.getElementById('custom-progress-thumb-style');
|
||||
if (oldStyle) oldStyle.remove();
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[进度条图标] 应用配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
applyProgressThumbConfig();
|
||||
|
||||
// 添加字幕切换功能
|
||||
const currentSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndex] || [];
|
||||
if (currentSubtitles.length > 0 && artPlayerRef.current) {
|
||||
const subtitleOptions = [
|
||||
{
|
||||
html: '关闭',
|
||||
url: '',
|
||||
},
|
||||
...currentSubtitles.map((sub: any) => ({
|
||||
html: sub.label,
|
||||
url: sub.url,
|
||||
})),
|
||||
];
|
||||
|
||||
artPlayerRef.current.setting.add({
|
||||
html: '字幕',
|
||||
selector: subtitleOptions,
|
||||
onSelect: function (item: any) {
|
||||
if (artPlayerRef.current) {
|
||||
if (item.url === '') {
|
||||
// 关闭字幕
|
||||
artPlayerRef.current.subtitle.show = false;
|
||||
} else {
|
||||
// 切换字幕
|
||||
artPlayerRef.current.subtitle.switch(item.url, {
|
||||
name: item.html,
|
||||
});
|
||||
artPlayerRef.current.subtitle.show = true;
|
||||
}
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
return item.html;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 添加字幕大小设置
|
||||
if (artPlayerRef.current) {
|
||||
const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em';
|
||||
const defaultOption = savedSubtitleSize === '1em' ? '小' : savedSubtitleSize === '3em' ? '大' : savedSubtitleSize === '4em' ? '超大' : '中';
|
||||
|
||||
artPlayerRef.current.setting.add({
|
||||
html: '字幕大小',
|
||||
selector: [
|
||||
{ html: '小', size: '1em' },
|
||||
{ html: '中', size: '2em' },
|
||||
{ html: '大', size: '3em' },
|
||||
{ html: '超大', size: '4em' },
|
||||
],
|
||||
onSelect: function (item: any) {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.subtitle.style({
|
||||
fontSize: item.size,
|
||||
});
|
||||
// 保存到 localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('subtitleSize', item.size);
|
||||
}
|
||||
}
|
||||
return item.html;
|
||||
},
|
||||
default: defaultOption,
|
||||
});
|
||||
}
|
||||
|
||||
// 控制截图按钮在小屏幕竖屏时隐藏
|
||||
const updateScreenshotVisibility = () => {
|
||||
const screenshotBtn = document.querySelector('.art-control-screenshot') as HTMLElement;
|
||||
if (screenshotBtn) {
|
||||
const isPortrait = window.innerHeight > window.innerWidth;
|
||||
const isSmallScreen = window.innerWidth < 768;
|
||||
screenshotBtn.style.display = (isPortrait && isSmallScreen) ? 'none' : '';
|
||||
}
|
||||
};
|
||||
updateScreenshotVisibility();
|
||||
window.addEventListener('resize', updateScreenshotVisibility);
|
||||
artPlayerRef.current.on('fullscreen', updateScreenshotVisibility);
|
||||
artPlayerRef.current.on('fullscreenWeb', updateScreenshotVisibility);
|
||||
|
||||
// iOS 设备:动态调整弹幕设置面板位置,避免被遮挡
|
||||
if (isIOS && artPlayerRef.current) {
|
||||
// 使用 MutationObserver 监听弹幕设置面板的显示
|
||||
let isAdjusting = false; // 防止重复调整的标记
|
||||
const observer = new MutationObserver(() => {
|
||||
if (isAdjusting) return; // 如果正在调整,跳过
|
||||
|
||||
const panel = document.querySelector('.apd-config-panel') as HTMLElement;
|
||||
if (panel && panel.style.display !== 'none') {
|
||||
// 获取当前的 left 值
|
||||
const currentLeft = parseInt(panel.style.left || '0', 10);
|
||||
|
||||
// 如果 left 值异常小(iOS 上只有 -5px),调整为正常值(-246px,比标准位置再往左 100px)
|
||||
if (currentLeft > -50) {
|
||||
isAdjusting = true; // 设置标记,防止重复触发
|
||||
const adjustedLeft = -246;
|
||||
panel.style.left = `${adjustedLeft}px`;
|
||||
console.log('[iOS] 已调整弹幕设置面板位置: 从', currentLeft, '调整为', adjustedLeft);
|
||||
|
||||
// 延迟重置标记
|
||||
setTimeout(() => {
|
||||
isAdjusting = false;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听整个播放器容器的 DOM 变化
|
||||
if (artRef.current) {
|
||||
observer.observe(artRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class']
|
||||
});
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
artPlayerRef.current.on('destroy', () => {
|
||||
observer.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
// iOS 设备:监听屏幕方向变化,自动调整全屏状态
|
||||
if (isIOS && artPlayerRef.current) {
|
||||
const handleOrientationChange = () => {
|
||||
if (!artPlayerRef.current) return;
|
||||
|
||||
// 获取当前屏幕方向
|
||||
const isLandscape = window.matchMedia('(orientation: landscape)').matches;
|
||||
const isPortrait = window.matchMedia('(orientation: portrait)').matches;
|
||||
|
||||
console.log('[iOS] 屏幕方向变化:', {
|
||||
isLandscape,
|
||||
isPortrait,
|
||||
fullscreenWeb: artPlayerRef.current.fullscreenWeb
|
||||
});
|
||||
|
||||
// 如果在网页全屏状态下旋转到横屏,切换到正常全屏
|
||||
if (artPlayerRef.current.fullscreenWeb && isLandscape) {
|
||||
console.log('[iOS] 横屏模式:从网页全屏切换到正常全屏');
|
||||
// 先退出网页全屏
|
||||
artPlayerRef.current.fullscreenWeb = false;
|
||||
// 延迟一下再进入正常全屏,确保布局已更新
|
||||
setTimeout(() => {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.fullscreenWeb = true;
|
||||
}
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('[进度条图标] 应用配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听屏幕方向变化
|
||||
window.addEventListener('orientationchange', handleOrientationChange);
|
||||
// 也监听 resize 事件(某些设备上更可靠)
|
||||
window.addEventListener('resize', handleOrientationChange);
|
||||
applyProgressThumbConfig();
|
||||
|
||||
// 清理函数
|
||||
artPlayerRef.current.on('destroy', () => {
|
||||
window.removeEventListener('orientationchange', handleOrientationChange);
|
||||
window.removeEventListener('resize', handleOrientationChange);
|
||||
});
|
||||
}
|
||||
// 添加字幕切换功能
|
||||
const currentSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndex] || [];
|
||||
if (currentSubtitles.length > 0 && artPlayerRef.current) {
|
||||
const subtitleOptions = [
|
||||
{
|
||||
html: '关闭',
|
||||
url: '',
|
||||
},
|
||||
...currentSubtitles.map((sub: any) => ({
|
||||
html: sub.label,
|
||||
url: sub.url,
|
||||
})),
|
||||
];
|
||||
|
||||
// 从 art.storage 读取弹幕设置并应用
|
||||
if (artPlayerRef.current) {
|
||||
const storedDanmakuSettings = artPlayerRef.current.storage.get('danmaku_settings');
|
||||
if (storedDanmakuSettings) {
|
||||
// 合并存储的设置到当前设置
|
||||
const mergedSettings = {
|
||||
...danmakuSettingsRef.current,
|
||||
...storedDanmakuSettings,
|
||||
};
|
||||
setDanmakuSettings(mergedSettings);
|
||||
saveDanmakuSettings(mergedSettings);
|
||||
artPlayerRef.current.setting.add({
|
||||
html: '字幕',
|
||||
selector: subtitleOptions,
|
||||
onSelect: function (item: any) {
|
||||
if (artPlayerRef.current) {
|
||||
if (item.url === '') {
|
||||
// 关闭字幕
|
||||
artPlayerRef.current.subtitle.show = false;
|
||||
} else {
|
||||
// 切换字幕
|
||||
artPlayerRef.current.subtitle.switch(item.url, {
|
||||
name: item.html,
|
||||
});
|
||||
artPlayerRef.current.subtitle.show = true;
|
||||
}
|
||||
}
|
||||
return item.html;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 保存弹幕插件引用
|
||||
if (artPlayerRef.current?.plugins?.artplayerPluginDanmuku) {
|
||||
danmakuPluginRef.current = artPlayerRef.current.plugins.artplayerPluginDanmuku;
|
||||
|
||||
// 监听弹幕配置变化事件
|
||||
artPlayerRef.current.on('artplayerPluginDanmuku:config', () => {
|
||||
if (danmakuPluginRef.current?.option) {
|
||||
const newSettings = {
|
||||
...danmakuSettingsRef.current,
|
||||
opacity: danmakuPluginRef.current.option.opacity || danmakuSettingsRef.current.opacity,
|
||||
fontSize: danmakuPluginRef.current.option.fontSize || danmakuSettingsRef.current.fontSize,
|
||||
speed: danmakuPluginRef.current.option.speed || danmakuSettingsRef.current.speed,
|
||||
marginTop: (danmakuPluginRef.current.option.margin && danmakuPluginRef.current.option.margin[0]) ?? danmakuSettingsRef.current.marginTop,
|
||||
marginBottom: (danmakuPluginRef.current.option.margin && danmakuPluginRef.current.option.margin[1]) ?? danmakuSettingsRef.current.marginBottom,
|
||||
};
|
||||
|
||||
// 保存到 localStorage 和 art.storage
|
||||
setDanmakuSettings(newSettings);
|
||||
saveDanmakuSettings(newSettings);
|
||||
if (artPlayerRef.current?.storage) {
|
||||
artPlayerRef.current.storage.set('danmaku_settings', newSettings);
|
||||
}
|
||||
|
||||
console.log('弹幕设置已更新并保存:', newSettings);
|
||||
}
|
||||
});
|
||||
|
||||
// 自动搜索并加载弹幕
|
||||
await autoSearchDanmaku();
|
||||
|
||||
|
||||
// 添加字幕大小设置
|
||||
if (artPlayerRef.current) {
|
||||
// 监听弹幕显示/隐藏事件,保存开关状态到 localStorage
|
||||
artPlayerRef.current.on('artplayerPluginDanmuku:show', () => {
|
||||
danmakuDisplayStateRef.current = true;
|
||||
saveDanmakuDisplayState(true);
|
||||
});
|
||||
const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em';
|
||||
const defaultOption = savedSubtitleSize === '1em' ? '小' : savedSubtitleSize === '3em' ? '大' : savedSubtitleSize === '4em' ? '超大' : '中';
|
||||
|
||||
artPlayerRef.current.on('artplayerPluginDanmuku:hide', () => {
|
||||
danmakuDisplayStateRef.current = false;
|
||||
saveDanmakuDisplayState(false);
|
||||
artPlayerRef.current.setting.add({
|
||||
html: '字幕大小',
|
||||
selector: [
|
||||
{ html: '小', size: '1em' },
|
||||
{ html: '中', size: '2em' },
|
||||
{ html: '大', size: '3em' },
|
||||
{ html: '超大', size: '4em' },
|
||||
],
|
||||
onSelect: function (item: any) {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.subtitle.style({
|
||||
fontSize: item.size,
|
||||
});
|
||||
// 保存到 localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('subtitleSize', item.size);
|
||||
}
|
||||
}
|
||||
return item.html;
|
||||
},
|
||||
default: defaultOption,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
// 控制截图按钮在小屏幕竖屏时隐藏
|
||||
const updateScreenshotVisibility = () => {
|
||||
const screenshotBtn = document.querySelector('.art-control-screenshot') as HTMLElement;
|
||||
if (screenshotBtn) {
|
||||
const isPortrait = window.innerHeight > window.innerWidth;
|
||||
const isSmallScreen = window.innerWidth < 768;
|
||||
screenshotBtn.style.display = (isPortrait && isSmallScreen) ? 'none' : '';
|
||||
}
|
||||
};
|
||||
updateScreenshotVisibility();
|
||||
window.addEventListener('resize', updateScreenshotVisibility);
|
||||
artPlayerRef.current.on('fullscreen', updateScreenshotVisibility);
|
||||
artPlayerRef.current.on('fullscreenWeb', updateScreenshotVisibility);
|
||||
|
||||
// 播放器就绪后,如果正在播放则请求 Wake Lock
|
||||
// iOS 设备:动态调整弹幕设置面板位置,避免被遮挡
|
||||
if (isIOS && artPlayerRef.current) {
|
||||
// 使用 MutationObserver 监听弹幕设置面板的显示
|
||||
let isAdjusting = false; // 防止重复调整的标记
|
||||
const observer = new MutationObserver(() => {
|
||||
if (isAdjusting) return; // 如果正在调整,跳过
|
||||
|
||||
const panel = document.querySelector('.apd-config-panel') as HTMLElement;
|
||||
if (panel && panel.style.display !== 'none') {
|
||||
// 获取当前的 left 值
|
||||
const currentLeft = parseInt(panel.style.left || '0', 10);
|
||||
|
||||
// 如果 left 值异常小(iOS 上只有 -5px),调整为正常值(-246px,比标准位置再往左 100px)
|
||||
if (currentLeft > -50) {
|
||||
isAdjusting = true; // 设置标记,防止重复触发
|
||||
const adjustedLeft = -246;
|
||||
panel.style.left = `${adjustedLeft}px`;
|
||||
console.log('[iOS] 已调整弹幕设置面板位置: 从', currentLeft, '调整为', adjustedLeft);
|
||||
|
||||
// 延迟重置标记
|
||||
setTimeout(() => {
|
||||
isAdjusting = false;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听整个播放器容器的 DOM 变化
|
||||
if (artRef.current) {
|
||||
observer.observe(artRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class']
|
||||
});
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
artPlayerRef.current.on('destroy', () => {
|
||||
observer.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
// iOS 设备:监听屏幕方向变化,自动调整全屏状态
|
||||
if (isIOS && artPlayerRef.current) {
|
||||
const handleOrientationChange = () => {
|
||||
if (!artPlayerRef.current) return;
|
||||
|
||||
// 获取当前屏幕方向
|
||||
const isLandscape = window.matchMedia('(orientation: landscape)').matches;
|
||||
const isPortrait = window.matchMedia('(orientation: portrait)').matches;
|
||||
|
||||
console.log('[iOS] 屏幕方向变化:', {
|
||||
isLandscape,
|
||||
isPortrait,
|
||||
fullscreenWeb: artPlayerRef.current.fullscreenWeb
|
||||
});
|
||||
|
||||
// 如果在网页全屏状态下旋转到横屏,切换到正常全屏
|
||||
if (artPlayerRef.current.fullscreenWeb && isLandscape) {
|
||||
console.log('[iOS] 横屏模式:从网页全屏切换到正常全屏');
|
||||
// 先退出网页全屏
|
||||
artPlayerRef.current.fullscreenWeb = false;
|
||||
// 延迟一下再进入正常全屏,确保布局已更新
|
||||
setTimeout(() => {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.fullscreenWeb = true;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听屏幕方向变化
|
||||
window.addEventListener('orientationchange', handleOrientationChange);
|
||||
// 也监听 resize 事件(某些设备上更可靠)
|
||||
window.addEventListener('resize', handleOrientationChange);
|
||||
|
||||
// 清理函数
|
||||
artPlayerRef.current.on('destroy', () => {
|
||||
window.removeEventListener('orientationchange', handleOrientationChange);
|
||||
window.removeEventListener('resize', handleOrientationChange);
|
||||
});
|
||||
}
|
||||
|
||||
// 从 art.storage 读取弹幕设置并应用
|
||||
if (artPlayerRef.current) {
|
||||
const storedDanmakuSettings = artPlayerRef.current.storage.get('danmaku_settings');
|
||||
if (storedDanmakuSettings) {
|
||||
// 合并存储的设置到当前设置
|
||||
const mergedSettings = {
|
||||
...danmakuSettingsRef.current,
|
||||
...storedDanmakuSettings,
|
||||
};
|
||||
setDanmakuSettings(mergedSettings);
|
||||
saveDanmakuSettings(mergedSettings);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存弹幕插件引用
|
||||
if (artPlayerRef.current?.plugins?.artplayerPluginDanmuku) {
|
||||
danmakuPluginRef.current = artPlayerRef.current.plugins.artplayerPluginDanmuku;
|
||||
|
||||
// 监听弹幕配置变化事件
|
||||
artPlayerRef.current.on('artplayerPluginDanmuku:config', () => {
|
||||
if (danmakuPluginRef.current?.option) {
|
||||
const newSettings = {
|
||||
...danmakuSettingsRef.current,
|
||||
opacity: danmakuPluginRef.current.option.opacity || danmakuSettingsRef.current.opacity,
|
||||
fontSize: danmakuPluginRef.current.option.fontSize || danmakuSettingsRef.current.fontSize,
|
||||
speed: danmakuPluginRef.current.option.speed || danmakuSettingsRef.current.speed,
|
||||
marginTop: (danmakuPluginRef.current.option.margin && danmakuPluginRef.current.option.margin[0]) ?? danmakuSettingsRef.current.marginTop,
|
||||
marginBottom: (danmakuPluginRef.current.option.margin && danmakuPluginRef.current.option.margin[1]) ?? danmakuSettingsRef.current.marginBottom,
|
||||
};
|
||||
|
||||
// 保存到 localStorage 和 art.storage
|
||||
setDanmakuSettings(newSettings);
|
||||
saveDanmakuSettings(newSettings);
|
||||
if (artPlayerRef.current?.storage) {
|
||||
artPlayerRef.current.storage.set('danmaku_settings', newSettings);
|
||||
}
|
||||
|
||||
console.log('弹幕设置已更新并保存:', newSettings);
|
||||
}
|
||||
});
|
||||
|
||||
// 自动搜索并加载弹幕
|
||||
await autoSearchDanmaku();
|
||||
|
||||
|
||||
if (artPlayerRef.current) {
|
||||
// 监听弹幕显示/隐藏事件,保存开关状态到 localStorage
|
||||
artPlayerRef.current.on('artplayerPluginDanmuku:show', () => {
|
||||
danmakuDisplayStateRef.current = true;
|
||||
saveDanmakuDisplayState(true);
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('artplayerPluginDanmuku:hide', () => {
|
||||
danmakuDisplayStateRef.current = false;
|
||||
saveDanmakuDisplayState(false);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 播放器就绪后,如果正在播放则请求 Wake Lock
|
||||
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
||||
requestWakeLock();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听播放状态变化,控制 Wake Lock
|
||||
artPlayerRef.current.on('play', () => {
|
||||
requestWakeLock();
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('pause', () => {
|
||||
releaseWakeLock();
|
||||
saveCurrentPlayProgress();
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('video:ended', () => {
|
||||
releaseWakeLock();
|
||||
});
|
||||
|
||||
// 如果播放器初始化时已经在播放状态,则请求 Wake Lock
|
||||
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
||||
requestWakeLock();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听播放状态变化,控制 Wake Lock
|
||||
artPlayerRef.current.on('play', () => {
|
||||
requestWakeLock();
|
||||
});
|
||||
artPlayerRef.current.on('video:volumechange', () => {
|
||||
lastVolumeRef.current = artPlayerRef.current.volume;
|
||||
});
|
||||
artPlayerRef.current.on('video:ratechange', () => {
|
||||
lastPlaybackRateRef.current = artPlayerRef.current.playbackRate;
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('pause', () => {
|
||||
releaseWakeLock();
|
||||
saveCurrentPlayProgress();
|
||||
});
|
||||
// 监听网页全屏事件,控制导航栏显示隐藏
|
||||
artPlayerRef.current.on('fullscreenWeb', (isFullscreen: boolean) => {
|
||||
console.log('网页全屏状态变化:', isFullscreen);
|
||||
setIsWebFullscreen(isFullscreen);
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('video:ended', () => {
|
||||
releaseWakeLock();
|
||||
});
|
||||
|
||||
// 如果播放器初始化时已经在播放状态,则请求 Wake Lock
|
||||
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
||||
requestWakeLock();
|
||||
}
|
||||
|
||||
artPlayerRef.current.on('video:volumechange', () => {
|
||||
lastVolumeRef.current = artPlayerRef.current.volume;
|
||||
});
|
||||
artPlayerRef.current.on('video:ratechange', () => {
|
||||
lastPlaybackRateRef.current = artPlayerRef.current.playbackRate;
|
||||
});
|
||||
|
||||
// 监听网页全屏事件,控制导航栏显示隐藏
|
||||
artPlayerRef.current.on('fullscreenWeb', (isFullscreen: boolean) => {
|
||||
console.log('网页全屏状态变化:', isFullscreen);
|
||||
setIsWebFullscreen(isFullscreen);
|
||||
});
|
||||
|
||||
// 添加自定义热力图到播放器控制层
|
||||
if (!danmakuHeatmapDisabledRef.current) {
|
||||
artPlayerRef.current.controls.add({
|
||||
name: 'custom-heatmap',
|
||||
position: 'top',
|
||||
html: '<canvas id="custom-heatmap-canvas" style="width: 100%; height: 100%; display: block;"></canvas>',
|
||||
style: {
|
||||
position: 'absolute',
|
||||
bottom: '5px',
|
||||
left: '0',
|
||||
height: '60px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '30',
|
||||
display: danmakuHeatmapEnabledRef.current ? 'block' : 'none',
|
||||
},
|
||||
mounted: ($el: HTMLElement) => {
|
||||
const canvas = $el.querySelector('#custom-heatmap-canvas') as HTMLCanvasElement;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据实际显示尺寸和设备像素比设置 canvas 分辨率
|
||||
const updateCanvasSize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const newWidth = Math.round(rect.width * dpr);
|
||||
const newHeight = Math.round(rect.height * dpr);
|
||||
|
||||
// 只在尺寸真正改变时才更新,避免闪烁
|
||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
return true; // 返回 true 表示尺寸已更新
|
||||
// 添加自定义热力图到播放器控制层
|
||||
if (!danmakuHeatmapDisabledRef.current) {
|
||||
artPlayerRef.current.controls.add({
|
||||
name: 'custom-heatmap',
|
||||
position: 'top',
|
||||
html: '<canvas id="custom-heatmap-canvas" style="width: 100%; height: 100%; display: block;"></canvas>',
|
||||
style: {
|
||||
position: 'absolute',
|
||||
bottom: '5px',
|
||||
left: '0',
|
||||
height: '60px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '30',
|
||||
display: danmakuHeatmapEnabledRef.current ? 'block' : 'none',
|
||||
},
|
||||
mounted: ($el: HTMLElement) => {
|
||||
const canvas = $el.querySelector('#custom-heatmap-canvas') as HTMLCanvasElement;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
return false; // 返回 false 表示尺寸未变化
|
||||
};
|
||||
|
||||
// 动态获取进度条的实际位置并调整热力图
|
||||
const adjustHeatmapPosition = () => {
|
||||
// 根据实际显示尺寸和设备像素比设置 canvas 分辨率
|
||||
const updateCanvasSize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const newWidth = Math.round(rect.width * dpr);
|
||||
const newHeight = Math.round(rect.height * dpr);
|
||||
|
||||
// 只在尺寸真正改变时才更新,避免闪烁
|
||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
return true; // 返回 true 表示尺寸已更新
|
||||
}
|
||||
return false; // 返回 false 表示尺寸未变化
|
||||
};
|
||||
|
||||
// 动态获取进度条的实际位置并调整热力图
|
||||
const adjustHeatmapPosition = () => {
|
||||
const progressBar = document.querySelector('.art-control-progress') as HTMLElement;
|
||||
|
||||
if (!progressBar) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$el.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (progressBar && $el.parentElement) {
|
||||
const rect = progressBar.getBoundingClientRect();
|
||||
const parentRect = $el.parentElement.getBoundingClientRect();
|
||||
|
||||
// 调整热力图位置以完全匹配进度条
|
||||
$el.style.left = `${rect.left - parentRect.left}px`;
|
||||
$el.style.bottom = `${parentRect.bottom - rect.bottom + 5}px`;
|
||||
$el.style.width = `${rect.width}px`;
|
||||
|
||||
// 更新 canvas 分辨率
|
||||
updateCanvasSize();
|
||||
}
|
||||
};
|
||||
|
||||
// 初始调整
|
||||
setTimeout(adjustHeatmapPosition, 500);
|
||||
|
||||
// 监听进度条尺寸变化
|
||||
const progressBar = document.querySelector('.art-control-progress') as HTMLElement;
|
||||
|
||||
if (!progressBar) {
|
||||
return;
|
||||
let progressResizeObserver: ResizeObserver | null = null;
|
||||
if (progressBar && typeof ResizeObserver !== 'undefined') {
|
||||
progressResizeObserver = new ResizeObserver(() => {
|
||||
adjustHeatmapPosition();
|
||||
// 进度条长度变化时也需要重新计算和绘制热力图
|
||||
setTimeout(updateHeatmapData, 100);
|
||||
});
|
||||
progressResizeObserver.observe(progressBar);
|
||||
}
|
||||
|
||||
if (!$el.parentElement) {
|
||||
return;
|
||||
// 监听全屏状态变化
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.on('fullscreen', () => {
|
||||
setTimeout(adjustHeatmapPosition, 300);
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('fullscreenWeb', () => {
|
||||
setTimeout(adjustHeatmapPosition, 300);
|
||||
});
|
||||
}
|
||||
|
||||
if (progressBar && $el.parentElement) {
|
||||
const rect = progressBar.getBoundingClientRect();
|
||||
const parentRect = $el.parentElement.getBoundingClientRect();
|
||||
|
||||
// 调整热力图位置以完全匹配进度条
|
||||
$el.style.left = `${rect.left - parentRect.left}px`;
|
||||
$el.style.bottom = `${parentRect.bottom - rect.bottom + 5}px`;
|
||||
$el.style.width = `${rect.width}px`;
|
||||
|
||||
// 更新 canvas 分辨率
|
||||
updateCanvasSize();
|
||||
}
|
||||
};
|
||||
|
||||
// 初始调整
|
||||
setTimeout(adjustHeatmapPosition, 500);
|
||||
|
||||
// 监听进度条尺寸变化
|
||||
const progressBar = document.querySelector('.art-control-progress') as HTMLElement;
|
||||
let progressResizeObserver: ResizeObserver | null = null;
|
||||
if (progressBar && typeof ResizeObserver !== 'undefined') {
|
||||
progressResizeObserver = new ResizeObserver(() => {
|
||||
// 监听窗口大小变化
|
||||
const resizeHandler = () => {
|
||||
adjustHeatmapPosition();
|
||||
// 进度条长度变化时也需要重新计算和绘制热力图
|
||||
setTimeout(updateHeatmapData, 100);
|
||||
});
|
||||
progressResizeObserver.observe(progressBar);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
|
||||
// 监听全屏状态变化
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.on('fullscreen', () => {
|
||||
setTimeout(adjustHeatmapPosition, 300);
|
||||
});
|
||||
let heatmapData: number[] = [];
|
||||
let isHovering = false;
|
||||
let hoverTime = 0;
|
||||
let tooltipEl: HTMLElement | null = null;
|
||||
|
||||
artPlayerRef.current.on('fullscreenWeb', () => {
|
||||
setTimeout(adjustHeatmapPosition, 300);
|
||||
});
|
||||
}
|
||||
// 监听热力图开关状态变化
|
||||
let lastEnabled = localStorage.getItem('danmaku_heatmap_enabled') === 'true';
|
||||
const updateVisibility = () => {
|
||||
const enabled = localStorage.getItem('danmaku_heatmap_enabled') === 'true';
|
||||
|
||||
// 监听窗口大小变化
|
||||
const resizeHandler = () => {
|
||||
adjustHeatmapPosition();
|
||||
};
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
// 只在状态真正改变时才更新 DOM
|
||||
if (enabled !== lastEnabled) {
|
||||
$el.style.display = enabled ? 'block' : 'none';
|
||||
|
||||
let heatmapData: number[] = [];
|
||||
let isHovering = false;
|
||||
let hoverTime = 0;
|
||||
let tooltipEl: HTMLElement | null = null;
|
||||
// 如果从关闭变为打开,重新调整位置和尺寸
|
||||
if (enabled) {
|
||||
setTimeout(() => {
|
||||
adjustHeatmapPosition();
|
||||
drawHeatmap();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// 监听热力图开关状态变化
|
||||
let lastEnabled = localStorage.getItem('danmaku_heatmap_enabled') === 'true';
|
||||
const updateVisibility = () => {
|
||||
const enabled = localStorage.getItem('danmaku_heatmap_enabled') === 'true';
|
||||
lastEnabled = enabled;
|
||||
}
|
||||
};
|
||||
|
||||
// 只在状态真正改变时才更新 DOM
|
||||
if (enabled !== lastEnabled) {
|
||||
$el.style.display = enabled ? 'block' : 'none';
|
||||
// 定期检查开关状态
|
||||
const visibilityInterval = setInterval(updateVisibility, 500);
|
||||
|
||||
// 如果从关闭变为打开,重新调整位置和尺寸
|
||||
if (enabled) {
|
||||
setTimeout(() => {
|
||||
adjustHeatmapPosition();
|
||||
drawHeatmap();
|
||||
}, 50);
|
||||
// 计算热力图数据(按视频长度的5%分段,使热力图更平滑)
|
||||
const calculateHeatmapData = (danmakuList: any[], duration: number) => {
|
||||
if (!duration || duration <= 0 || danmakuList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
lastEnabled = enabled;
|
||||
}
|
||||
};
|
||||
// 按视频长度的5%分段,最少20段
|
||||
const segments = Math.max(20, Math.ceil(duration * 0.05));
|
||||
const segmentDuration = duration / segments;
|
||||
const heatData = new Array(segments).fill(0);
|
||||
|
||||
// 定期检查开关状态
|
||||
const visibilityInterval = setInterval(updateVisibility, 500);
|
||||
danmakuList.forEach((danmaku: any) => {
|
||||
const segmentIndex = Math.floor(danmaku.time / segmentDuration);
|
||||
if (segmentIndex >= 0 && segmentIndex < segments) {
|
||||
heatData[segmentIndex]++;
|
||||
}
|
||||
});
|
||||
|
||||
// 计算热力图数据(按视频长度的5%分段,使热力图更平滑)
|
||||
const calculateHeatmapData = (danmakuList: any[], duration: number) => {
|
||||
if (!duration || duration <= 0 || danmakuList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const maxCount = Math.max(...heatData, 1);
|
||||
return heatData.map((count: number) => count / maxCount);
|
||||
};
|
||||
|
||||
// 按视频长度的5%分段,最少20段
|
||||
const segments = Math.max(20, Math.ceil(duration * 0.05));
|
||||
const segmentDuration = duration / segments;
|
||||
const heatData = new Array(segments).fill(0);
|
||||
|
||||
danmakuList.forEach((danmaku: any) => {
|
||||
const segmentIndex = Math.floor(danmaku.time / segmentDuration);
|
||||
if (segmentIndex >= 0 && segmentIndex < segments) {
|
||||
heatData[segmentIndex]++;
|
||||
// 绘制热力图
|
||||
const drawHeatmap = () => {
|
||||
// 检查热力图是否启用(与初始状态逻辑保持一致)
|
||||
const storedValue = localStorage.getItem('danmaku_heatmap_enabled');
|
||||
const enabled = storedValue !== null ? storedValue === 'true' : true; // 默认开启
|
||||
if (!enabled) {
|
||||
// 热力图已关闭,跳过绘制
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const maxCount = Math.max(...heatData, 1);
|
||||
return heatData.map((count: number) => count / maxCount);
|
||||
};
|
||||
|
||||
// 绘制热力图
|
||||
const drawHeatmap = () => {
|
||||
// 检查热力图是否启用(与初始状态逻辑保持一致)
|
||||
const storedValue = localStorage.getItem('danmaku_heatmap_enabled');
|
||||
const enabled = storedValue !== null ? storedValue === 'true' : true; // 默认开启
|
||||
if (!enabled) {
|
||||
// 热力图已关闭,跳过绘制
|
||||
return;
|
||||
}
|
||||
|
||||
if (!artPlayerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (heatmapData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = canvas.width / dpr;
|
||||
const height = canvas.height / dpr;
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
const currentTime = artPlayerRef.current.currentTime || 0;
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const progressRatio = duration > 0 ? currentTime / duration : 0;
|
||||
const progressX = progressRatio * width;
|
||||
|
||||
// 绘制未播放部分的曲线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, height);
|
||||
|
||||
heatmapData.forEach((value: number, index: number) => {
|
||||
const x = (index / heatmapData.length) * width;
|
||||
const y = height - (value * height);
|
||||
|
||||
if (index === 0) {
|
||||
ctx.lineTo(x, y);
|
||||
} else {
|
||||
// 使用二次贝塞尔曲线使线条平滑
|
||||
const prevX = ((index - 1) / heatmapData.length) * width;
|
||||
const prevY = height - (heatmapData[index - 1] * height);
|
||||
const cpX = (prevX + x) / 2;
|
||||
const cpY = (prevY + y) / 2;
|
||||
ctx.quadraticCurveTo(prevX, prevY, cpX, cpY);
|
||||
ctx.lineTo(x, y);
|
||||
if (!artPlayerRef.current) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
ctx.lineTo(width, height);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(128, 128, 128, 0.3)';
|
||||
ctx.fill();
|
||||
if (heatmapData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = canvas.width / dpr;
|
||||
const height = canvas.height / dpr;
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
const currentTime = artPlayerRef.current.currentTime || 0;
|
||||
|
||||
// 绘制已播放部分的曲线(深色)
|
||||
if (progressRatio > 0) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, 0, progressX, height);
|
||||
ctx.clip();
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const progressRatio = duration > 0 ? currentTime / duration : 0;
|
||||
const progressX = progressRatio * width;
|
||||
|
||||
// 绘制未播放部分的曲线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, height);
|
||||
|
||||
@@ -6655,6 +6747,7 @@ function PlayPageClient() {
|
||||
if (index === 0) {
|
||||
ctx.lineTo(x, y);
|
||||
} else {
|
||||
// 使用二次贝塞尔曲线使线条平滑
|
||||
const prevX = ((index - 1) / heatmapData.length) * width;
|
||||
const prevY = height - (heatmapData[index - 1] * height);
|
||||
const cpX = (prevX + x) / 2;
|
||||
@@ -6666,63 +6759,94 @@ function PlayPageClient() {
|
||||
|
||||
ctx.lineTo(width, height);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(128, 128, 128, 0.6)';
|
||||
ctx.fillStyle = 'rgba(128, 128, 128, 0.3)';
|
||||
ctx.fill();
|
||||
|
||||
// 绘制已播放部分的曲线(深色)
|
||||
if (progressRatio > 0) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, 0, progressX, height);
|
||||
ctx.clip();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, height);
|
||||
|
||||
heatmapData.forEach((value: number, index: number) => {
|
||||
const x = (index / heatmapData.length) * width;
|
||||
const y = height - (value * height);
|
||||
|
||||
if (index === 0) {
|
||||
ctx.lineTo(x, y);
|
||||
} else {
|
||||
const prevX = ((index - 1) / heatmapData.length) * width;
|
||||
const prevY = height - (heatmapData[index - 1] * height);
|
||||
const cpX = (prevX + x) / 2;
|
||||
const cpY = (prevY + y) / 2;
|
||||
ctx.quadraticCurveTo(prevX, prevY, cpX, cpY);
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.lineTo(width, height);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(128, 128, 128, 0.6)';
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
// 格式化时间
|
||||
const formatTime = (seconds: number): string => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (seconds: number): string => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) {
|
||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (h > 0) {
|
||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
// 获取弹幕密度
|
||||
const getDensity = (time: number): string => {
|
||||
if (heatmapData.length === 0 || !artPlayerRef.current) return '';
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
if (duration <= 0) return '';
|
||||
|
||||
// 获取弹幕密度
|
||||
const getDensity = (time: number): string => {
|
||||
if (heatmapData.length === 0 || !artPlayerRef.current) return '';
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
if (duration <= 0) return '';
|
||||
// 按视频长度的5%分段
|
||||
const segments = Math.max(20, Math.ceil(duration * 0.05));
|
||||
const segmentDuration = duration / segments;
|
||||
const segmentIndex = Math.floor(time / segmentDuration);
|
||||
|
||||
// 按视频长度的5%分段
|
||||
const segments = Math.max(20, Math.ceil(duration * 0.05));
|
||||
const segmentDuration = duration / segments;
|
||||
const segmentIndex = Math.floor(time / segmentDuration);
|
||||
if (segmentIndex >= 0 && segmentIndex < heatmapData.length) {
|
||||
const density = heatmapData[segmentIndex];
|
||||
if (density < 0.2) return '低';
|
||||
if (density < 0.5) return '中';
|
||||
if (density < 0.8) return '高';
|
||||
return '极高';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
if (segmentIndex >= 0 && segmentIndex < heatmapData.length) {
|
||||
const density = heatmapData[segmentIndex];
|
||||
if (density < 0.2) return '低';
|
||||
if (density < 0.5) return '中';
|
||||
if (density < 0.8) return '高';
|
||||
return '极高';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
// 鼠标移动事件
|
||||
canvas.addEventListener('mousemove', (e: MouseEvent) => {
|
||||
if (!artPlayerRef.current) return;
|
||||
|
||||
// 鼠标移动事件
|
||||
canvas.addEventListener('mousemove', (e: MouseEvent) => {
|
||||
if (!artPlayerRef.current) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
hoverTime = percentage * duration;
|
||||
isHovering = true;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
hoverTime = percentage * duration;
|
||||
isHovering = true;
|
||||
|
||||
// 创建或更新提示框
|
||||
if (!tooltipEl) {
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.style.cssText = `
|
||||
// 创建或更新提示框
|
||||
if (!tooltipEl) {
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.style.cssText = `
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
transform: translateX(-50%);
|
||||
@@ -6736,120 +6860,120 @@ function PlayPageClient() {
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
`;
|
||||
$el.appendChild(tooltipEl);
|
||||
}
|
||||
$el.appendChild(tooltipEl);
|
||||
}
|
||||
|
||||
tooltipEl.textContent = `${formatTime(hoverTime)} - 弹幕密度: ${getDensity(hoverTime)}`;
|
||||
tooltipEl.style.left = `${percentage * 100}%`;
|
||||
tooltipEl.style.display = 'block';
|
||||
});
|
||||
tooltipEl.textContent = `${formatTime(hoverTime)} - 弹幕密度: ${getDensity(hoverTime)}`;
|
||||
tooltipEl.style.left = `${percentage * 100}%`;
|
||||
tooltipEl.style.display = 'block';
|
||||
});
|
||||
|
||||
// 鼠标离开事件
|
||||
canvas.addEventListener('mouseleave', () => {
|
||||
isHovering = false;
|
||||
if (tooltipEl) {
|
||||
tooltipEl.style.display = 'none';
|
||||
}
|
||||
});
|
||||
// 鼠标离开事件
|
||||
canvas.addEventListener('mouseleave', () => {
|
||||
isHovering = false;
|
||||
if (tooltipEl) {
|
||||
tooltipEl.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// 点击跳转
|
||||
canvas.addEventListener('click', (e: MouseEvent) => {
|
||||
if (!artPlayerRef.current) return;
|
||||
// 点击跳转
|
||||
canvas.addEventListener('click', (e: MouseEvent) => {
|
||||
if (!artPlayerRef.current) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
const time = percentage * duration;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = x / rect.width;
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
const time = percentage * duration;
|
||||
|
||||
artPlayerRef.current.currentTime = time;
|
||||
});
|
||||
artPlayerRef.current.currentTime = time;
|
||||
});
|
||||
|
||||
// 监听时间更新
|
||||
artPlayerRef.current.on('video:timeupdate', drawHeatmap);
|
||||
// 监听时间更新
|
||||
artPlayerRef.current.on('video:timeupdate', drawHeatmap);
|
||||
|
||||
// 监听弹幕数据更新
|
||||
const updateHeatmapData = () => {
|
||||
if (!artPlayerRef.current) {
|
||||
return;
|
||||
}
|
||||
// 监听弹幕数据更新
|
||||
const updateHeatmapData = () => {
|
||||
if (!artPlayerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!danmakuPluginRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!danmakuPluginRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
|
||||
// 直接从弹幕插件获取弹幕数据
|
||||
const danmakuList = danmakuPluginRef.current.option?.danmuku || [];
|
||||
// 直接从弹幕插件获取弹幕数据
|
||||
const danmakuList = danmakuPluginRef.current.option?.danmuku || [];
|
||||
|
||||
if (danmakuList.length > 0 && duration > 0) {
|
||||
heatmapData = calculateHeatmapData(danmakuList, duration);
|
||||
// 立即绘制热力图
|
||||
drawHeatmap();
|
||||
// 强制再次绘制,确保显示
|
||||
setTimeout(drawHeatmap, 100);
|
||||
}
|
||||
};
|
||||
|
||||
artPlayerRef.current.on('video:loadedmetadata', updateHeatmapData);
|
||||
|
||||
// 监听弹幕加载完成事件
|
||||
artPlayerRef.current.on('danmaku:loaded', () => {
|
||||
updateHeatmapData();
|
||||
});
|
||||
|
||||
// 监听弹幕插件的配置变化
|
||||
if (danmakuPluginRef.current) {
|
||||
const originalConfig = danmakuPluginRef.current.config;
|
||||
danmakuPluginRef.current.config = function(...args: any[]) {
|
||||
const result = originalConfig.apply(this, args);
|
||||
setTimeout(updateHeatmapData, 100);
|
||||
return result;
|
||||
if (danmakuList.length > 0 && duration > 0) {
|
||||
heatmapData = calculateHeatmapData(danmakuList, duration);
|
||||
// 立即绘制热力图
|
||||
drawHeatmap();
|
||||
// 强制再次绘制,确保显示
|
||||
setTimeout(drawHeatmap, 100);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 使用轮询机制等待弹幕插件准备好(替代固定延迟)
|
||||
let pollAttempts = 0;
|
||||
const maxPollAttempts = 120; // 最多尝试 120 次(60 秒)
|
||||
const pollInterval = 500; // 每 500ms 检查一次
|
||||
artPlayerRef.current.on('video:loadedmetadata', updateHeatmapData);
|
||||
|
||||
const pollForDanmakuPlugin = () => {
|
||||
if (danmakuPluginRef.current && danmakuPluginRef.current.option?.danmuku) {
|
||||
// 弹幕插件已准备好且有数据
|
||||
// 监听弹幕加载完成事件
|
||||
artPlayerRef.current.on('danmaku:loaded', () => {
|
||||
updateHeatmapData();
|
||||
return; // 成功,停止轮询
|
||||
});
|
||||
|
||||
// 监听弹幕插件的配置变化
|
||||
if (danmakuPluginRef.current) {
|
||||
const originalConfig = danmakuPluginRef.current.config;
|
||||
danmakuPluginRef.current.config = function (...args: any[]) {
|
||||
const result = originalConfig.apply(this, args);
|
||||
setTimeout(updateHeatmapData, 100);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
pollAttempts++;
|
||||
if (pollAttempts < maxPollAttempts) {
|
||||
// 继续轮询
|
||||
setTimeout(pollForDanmakuPlugin, pollInterval);
|
||||
}
|
||||
};
|
||||
// 使用轮询机制等待弹幕插件准备好(替代固定延迟)
|
||||
let pollAttempts = 0;
|
||||
const maxPollAttempts = 120; // 最多尝试 120 次(60 秒)
|
||||
const pollInterval = 500; // 每 500ms 检查一次
|
||||
|
||||
// 开始轮询
|
||||
setTimeout(pollForDanmakuPlugin, 500);
|
||||
const pollForDanmakuPlugin = () => {
|
||||
if (danmakuPluginRef.current && danmakuPluginRef.current.option?.danmuku) {
|
||||
// 弹幕插件已准备好且有数据
|
||||
updateHeatmapData();
|
||||
return; // 成功,停止轮询
|
||||
}
|
||||
|
||||
// 清理
|
||||
return () => {
|
||||
clearInterval(visibilityInterval);
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
if (progressResizeObserver) {
|
||||
progressResizeObserver.disconnect();
|
||||
}
|
||||
if (tooltipEl && tooltipEl.parentNode) {
|
||||
tooltipEl.parentNode.removeChild(tooltipEl);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
pollAttempts++;
|
||||
if (pollAttempts < maxPollAttempts) {
|
||||
// 继续轮询
|
||||
setTimeout(pollForDanmakuPlugin, pollInterval);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加全屏快进快退按钮
|
||||
artPlayerRef.current.layers.add({
|
||||
name: 'seek-buttons',
|
||||
html: `
|
||||
// 开始轮询
|
||||
setTimeout(pollForDanmakuPlugin, 500);
|
||||
|
||||
// 清理
|
||||
return () => {
|
||||
clearInterval(visibilityInterval);
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
if (progressResizeObserver) {
|
||||
progressResizeObserver.disconnect();
|
||||
}
|
||||
if (tooltipEl && tooltipEl.parentNode) {
|
||||
tooltipEl.parentNode.removeChild(tooltipEl);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 添加全屏快进快退按钮
|
||||
artPlayerRef.current.layers.add({
|
||||
name: 'seek-buttons',
|
||||
html: `
|
||||
<div class="seek-buttons-container" style="display: none;">
|
||||
<button class="seek-button seek-backward" style="position: fixed; left: 20px; top: 40%; transform: translateY(-50%); width: 48px; height: 48px; background: rgba(0,0,0,0.7); border: none; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 9999; transition: opacity 0.2s;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -6863,302 +6987,503 @@ function PlayPageClient() {
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
mounted: ($el: HTMLElement) => {
|
||||
const container = $el.querySelector('.seek-buttons-container') as HTMLElement;
|
||||
const backwardBtn = $el.querySelector('.seek-backward') as HTMLElement;
|
||||
const forwardBtn = $el.querySelector('.seek-forward') as HTMLElement;
|
||||
mounted: ($el: HTMLElement) => {
|
||||
const container = $el.querySelector('.seek-buttons-container') as HTMLElement;
|
||||
const backwardBtn = $el.querySelector('.seek-backward') as HTMLElement;
|
||||
const forwardBtn = $el.querySelector('.seek-forward') as HTMLElement;
|
||||
|
||||
// 快退5秒
|
||||
backwardBtn.onclick = () => {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.currentTime = Math.max(0, artPlayerRef.current.currentTime - 5);
|
||||
}
|
||||
};
|
||||
|
||||
// 快进5秒
|
||||
forwardBtn.onclick = () => {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.currentTime = Math.min(artPlayerRef.current.duration, artPlayerRef.current.currentTime + 5);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听全屏状态变化
|
||||
const updateVisibility = () => {
|
||||
const isFullscreen = artPlayerRef.current?.fullscreen || artPlayerRef.current?.fullscreenWeb || !!document.fullscreenElement;
|
||||
const isMobile = Math.min(window.innerWidth, window.innerHeight) < 768;
|
||||
const controlsVisible = !artPlayerRef.current?.template?.$player?.classList.contains('art-hide-cursor');
|
||||
|
||||
if (container) {
|
||||
const shouldShow = isFullscreen && isMobile && controlsVisible;
|
||||
container.style.display = shouldShow ? 'block' : 'none';
|
||||
}
|
||||
};
|
||||
|
||||
artPlayerRef.current.on('fullscreen', updateVisibility);
|
||||
artPlayerRef.current.on('fullscreenWeb', updateVisibility);
|
||||
document.addEventListener('fullscreenchange', updateVisibility);
|
||||
window.addEventListener('resize', updateVisibility);
|
||||
|
||||
// 监听鼠标移动和视频事件来检测控件显示/隐藏
|
||||
artPlayerRef.current.on('video:timeupdate', updateVisibility);
|
||||
if (artPlayerRef.current.template?.$player) {
|
||||
const observer = new MutationObserver(updateVisibility);
|
||||
observer.observe(artPlayerRef.current.template.$player, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
updateVisibility();
|
||||
},
|
||||
});
|
||||
|
||||
// 监听视频可播放事件,这时恢复播放进度更可靠
|
||||
artPlayerRef.current.on('video:canplay', () => {
|
||||
// 若存在需要恢复的播放进度,则跳转
|
||||
if (resumeTimeRef.current && resumeTimeRef.current > 0) {
|
||||
try {
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
let target = resumeTimeRef.current;
|
||||
if (duration && target >= duration - 2) {
|
||||
target = Math.max(0, duration - 5);
|
||||
}
|
||||
artPlayerRef.current.currentTime = target;
|
||||
console.log('成功恢复播放进度到:', resumeTimeRef.current);
|
||||
} catch (err) {
|
||||
console.warn('恢复播放进度失败:', err);
|
||||
}
|
||||
}
|
||||
resumeTimeRef.current = null;
|
||||
|
||||
setTimeout(() => {
|
||||
if (
|
||||
Math.abs(artPlayerRef.current.volume - lastVolumeRef.current) > 0.01
|
||||
) {
|
||||
artPlayerRef.current.volume = lastVolumeRef.current;
|
||||
}
|
||||
if (
|
||||
Math.abs(
|
||||
artPlayerRef.current.playbackRate - lastPlaybackRateRef.current
|
||||
) > 0.01 &&
|
||||
isWebkit
|
||||
) {
|
||||
artPlayerRef.current.playbackRate = lastPlaybackRateRef.current;
|
||||
}
|
||||
artPlayerRef.current.notice.show = '';
|
||||
}, 0);
|
||||
|
||||
// 隐藏换源加载状态
|
||||
setIsVideoLoading(false);
|
||||
setVideoError(null);
|
||||
});
|
||||
|
||||
// 监听视频时间更新事件,实现跳过片头片尾
|
||||
artPlayerRef.current.on('video:timeupdate', () => {
|
||||
if (!skipConfigRef.current.enable) return;
|
||||
|
||||
const currentTime = artPlayerRef.current.currentTime || 0;
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
const now = Date.now();
|
||||
|
||||
// 限制跳过检查频率为1.5秒一次
|
||||
if (now - lastSkipCheckRef.current < 1500) return;
|
||||
lastSkipCheckRef.current = now;
|
||||
|
||||
// 跳过片头
|
||||
if (
|
||||
skipConfigRef.current.intro_time > 0 &&
|
||||
currentTime < skipConfigRef.current.intro_time
|
||||
) {
|
||||
artPlayerRef.current.currentTime = skipConfigRef.current.intro_time;
|
||||
artPlayerRef.current.notice.show = `已跳过片头 (${formatTime(
|
||||
skipConfigRef.current.intro_time
|
||||
)})`;
|
||||
}
|
||||
|
||||
// 跳过片尾
|
||||
if (
|
||||
skipConfigRef.current.outro_time < 0 &&
|
||||
duration > 0 &&
|
||||
currentTime >
|
||||
artPlayerRef.current.duration + skipConfigRef.current.outro_time
|
||||
) {
|
||||
if (
|
||||
currentEpisodeIndexRef.current <
|
||||
(detailRef.current?.episodes?.length || 1) - 1
|
||||
) {
|
||||
handleNextEpisode();
|
||||
} else {
|
||||
artPlayerRef.current.pause();
|
||||
}
|
||||
artPlayerRef.current.notice.show = `已跳过片尾 (${formatTime(
|
||||
skipConfigRef.current.outro_time
|
||||
)})`;
|
||||
}
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('error', (err: any) => {
|
||||
console.error('播放器错误:', err);
|
||||
if (artPlayerRef.current.currentTime > 0) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听视频播放结束事件,自动播放下一集(房员禁用)
|
||||
artPlayerRef.current.on('video:ended', () => {
|
||||
// 房员禁用自动播放下一集
|
||||
if (playSync.shouldDisableControls) {
|
||||
console.log('[PlayPage] Member cannot auto-play next episode');
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '等待房主切换下一集';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const d = detailRef.current;
|
||||
const idx = currentEpisodeIndexRef.current;
|
||||
|
||||
if (!d || !d.episodes || idx >= d.episodes.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找下一个未被过滤的集数
|
||||
let nextIdx = idx + 1;
|
||||
while (nextIdx < d.episodes.length) {
|
||||
const episodeTitle = d.episodes_titles?.[nextIdx];
|
||||
const isFiltered = episodeTitle && isEpisodeFilteredByTitle(episodeTitle);
|
||||
|
||||
if (!isFiltered) {
|
||||
setTimeout(() => {
|
||||
setCurrentEpisodeIndex(nextIdx);
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
nextIdx++;
|
||||
}
|
||||
|
||||
// 所有后续集数都被屏蔽
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '后续集数均已屏蔽,已自动停止';
|
||||
}
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('video:timeupdate', () => {
|
||||
const now = Date.now();
|
||||
let interval = 5000;
|
||||
if (process.env.NEXT_PUBLIC_STORAGE_TYPE === 'upstash') {
|
||||
interval = 20000;
|
||||
}
|
||||
if (now - lastSaveTimeRef.current > interval) {
|
||||
saveCurrentPlayProgress();
|
||||
lastSaveTimeRef.current = now;
|
||||
}
|
||||
|
||||
// 下集预缓冲逻辑
|
||||
const nextEpisodePreCacheEnabled = typeof window !== 'undefined'
|
||||
? localStorage.getItem('nextEpisodePreCache') === 'true'
|
||||
: false;
|
||||
|
||||
if (nextEpisodePreCacheEnabled) {
|
||||
const currentTime = artPlayerRef.current?.currentTime || 0;
|
||||
const duration = artPlayerRef.current?.duration || 0;
|
||||
const progress = duration > 0 ? currentTime / duration : 0;
|
||||
|
||||
// 检查是否已经到达90%播放进度
|
||||
if (duration > 0 && progress >= 0.9 && !nextEpisodePreCacheTriggeredRef.current) {
|
||||
// 标记已触发,防止重复执行
|
||||
nextEpisodePreCacheTriggeredRef.current = true;
|
||||
|
||||
// 获取下一集信息
|
||||
const currentIdx = currentEpisodeIndexRef.current;
|
||||
const episodes = detailRef.current?.episodes;
|
||||
|
||||
if (!episodes || currentIdx >= episodes.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEpisodeIndex = currentIdx + 1;
|
||||
const nextEpisodeUrl = episodes[nextEpisodeIndex];
|
||||
|
||||
if (!nextEpisodeUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 fetch 预加载资源,利用浏览器缓存
|
||||
const preloadNextEpisode = async () => {
|
||||
try {
|
||||
// 判断是否是m3u8流
|
||||
if (nextEpisodeUrl.includes('.m3u8') || nextEpisodeUrl.includes('m3u8')) {
|
||||
// 1. 先fetch m3u8文件
|
||||
const m3u8Response = await fetch(nextEpisodeUrl);
|
||||
const m3u8Text = await m3u8Response.text();
|
||||
|
||||
// 2. 解析m3u8,提取ts分片URL
|
||||
const lines = m3u8Text.split('\n');
|
||||
const tsUrls: string[] = [];
|
||||
const baseUrl = nextEpisodeUrl.substring(0, nextEpisodeUrl.lastIndexOf('/') + 1);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
// 跳过注释和空行
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
// 构建完整的ts URL
|
||||
const tsUrl = trimmedLine.startsWith('http')
|
||||
? trimmedLine
|
||||
: baseUrl + trimmedLine;
|
||||
tsUrls.push(tsUrl);
|
||||
}
|
||||
|
||||
// 3. 预加载前20个ts分片
|
||||
const maxFragmentsToPreload = Math.min(20, tsUrls.length);
|
||||
|
||||
for (let i = 0; i < maxFragmentsToPreload; i++) {
|
||||
try {
|
||||
await fetch(tsUrls[i]);
|
||||
} catch (err) {
|
||||
// 静默处理分片加载失败
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默处理预缓冲失败
|
||||
// 快退5秒
|
||||
backwardBtn.onclick = () => {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.currentTime = Math.max(0, artPlayerRef.current.currentTime - 5);
|
||||
}
|
||||
};
|
||||
|
||||
// 异步执行预缓冲
|
||||
preloadNextEpisode();
|
||||
// 快进5秒
|
||||
forwardBtn.onclick = () => {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.currentTime = Math.min(artPlayerRef.current.duration, artPlayerRef.current.currentTime + 5);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听全屏状态变化
|
||||
const updateVisibility = () => {
|
||||
const isFullscreen = artPlayerRef.current?.fullscreen || artPlayerRef.current?.fullscreenWeb || !!document.fullscreenElement;
|
||||
const isMobile = Math.min(window.innerWidth, window.innerHeight) < 768;
|
||||
const controlsVisible = !artPlayerRef.current?.template?.$player?.classList.contains('art-hide-cursor');
|
||||
|
||||
if (container) {
|
||||
const shouldShow = isFullscreen && isMobile && controlsVisible;
|
||||
container.style.display = shouldShow ? 'block' : 'none';
|
||||
}
|
||||
};
|
||||
|
||||
artPlayerRef.current.on('fullscreen', updateVisibility);
|
||||
artPlayerRef.current.on('fullscreenWeb', updateVisibility);
|
||||
document.addEventListener('fullscreenchange', updateVisibility);
|
||||
window.addEventListener('resize', updateVisibility);
|
||||
|
||||
// 监听鼠标移动和视频事件来检测控件显示/隐藏
|
||||
artPlayerRef.current.on('video:timeupdate', updateVisibility);
|
||||
if (artPlayerRef.current.template?.$player) {
|
||||
const observer = new MutationObserver(updateVisibility);
|
||||
observer.observe(artPlayerRef.current.template.$player, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
}
|
||||
|
||||
updateVisibility();
|
||||
},
|
||||
});
|
||||
|
||||
// 监听视频可播放事件,这时恢复播放进度更可靠
|
||||
artPlayerRef.current.on('video:canplay', () => {
|
||||
// 若存在需要恢复的播放进度,则跳转
|
||||
if (resumeTimeRef.current && resumeTimeRef.current > 0) {
|
||||
try {
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
let target = resumeTimeRef.current;
|
||||
if (duration && target >= duration - 2) {
|
||||
target = Math.max(0, duration - 5);
|
||||
}
|
||||
artPlayerRef.current.currentTime = target;
|
||||
console.log('成功恢复播放进度到:', resumeTimeRef.current);
|
||||
} catch (err) {
|
||||
console.warn('恢复播放进度失败:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
resumeTimeRef.current = null;
|
||||
|
||||
// 下集弹幕预加载逻辑
|
||||
const nextEpisodeDanmakuPreloadEnabled = typeof window !== 'undefined'
|
||||
? localStorage.getItem('nextEpisodeDanmakuPreload') === 'true'
|
||||
: false;
|
||||
setTimeout(() => {
|
||||
if (
|
||||
Math.abs(artPlayerRef.current.volume - lastVolumeRef.current) > 0.01
|
||||
) {
|
||||
artPlayerRef.current.volume = lastVolumeRef.current;
|
||||
}
|
||||
if (
|
||||
Math.abs(
|
||||
artPlayerRef.current.playbackRate - lastPlaybackRateRef.current
|
||||
) > 0.01 &&
|
||||
isWebkit
|
||||
) {
|
||||
artPlayerRef.current.playbackRate = lastPlaybackRateRef.current;
|
||||
}
|
||||
artPlayerRef.current.notice.show = '';
|
||||
}, 0);
|
||||
|
||||
if (nextEpisodeDanmakuPreloadEnabled) {
|
||||
const currentTime = artPlayerRef.current?.currentTime || 0;
|
||||
const duration = artPlayerRef.current?.duration || 0;
|
||||
const progress = duration > 0 ? currentTime / duration : 0;
|
||||
// 隐藏换源加载状态
|
||||
setIsVideoLoading(false);
|
||||
setVideoError(null);
|
||||
setCorsFailedUrl(null);
|
||||
});
|
||||
|
||||
// 检查是否已经到达90%播放进度
|
||||
if (duration > 0 && progress >= 0.9 && !nextEpisodeDanmakuPreloadTriggeredRef.current) {
|
||||
// 标记已触发,防止重复执行
|
||||
nextEpisodeDanmakuPreloadTriggeredRef.current = true;
|
||||
// 监听视频播放事件,检查是否需要显示播放记录跳转按钮
|
||||
artPlayerRef.current.on('video:playing', () => {
|
||||
// 检查是否需要显示播放记录跳转按钮
|
||||
// 条件:当前播放时间 < 10秒 且 播放记录时间 > 10秒
|
||||
const checkPlayRecordJump = async () => {
|
||||
try {
|
||||
// 如果用户已经关闭过跳转按钮,不再显示
|
||||
if (playRecordJumpDismissedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 异步执行弹幕预加载
|
||||
preloadNextEpisodeDanmaku();
|
||||
const currentTime = artPlayerRef.current?.currentTime || 0;
|
||||
|
||||
// 如果当前播放时间已经大于等于10秒,不显示跳转按钮
|
||||
if (currentTime >= 10) {
|
||||
// 标记已经进行过首次检查,避免切集后再显示
|
||||
playRecordJumpInitialCheckRef.current = false;
|
||||
if (playRecordJumpLayerRef.current) {
|
||||
artPlayerRef.current.layers.remove('play-record-jump');
|
||||
playRecordJumpLayerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取播放记录
|
||||
const allRecords = await getAllPlayRecords();
|
||||
const key = generateStorageKey(
|
||||
currentSourceRef.current,
|
||||
currentIdRef.current
|
||||
);
|
||||
const record = allRecords[key];
|
||||
|
||||
if (record) {
|
||||
const recordIndex = record.index - 1;
|
||||
const recordTime = record.play_time;
|
||||
|
||||
// 检查是否是当前集数且播放记录时间大于10秒且当前时间小于10秒
|
||||
if (
|
||||
recordIndex === currentEpisodeIndexRef.current &&
|
||||
recordTime > 10 &&
|
||||
currentTime < 10
|
||||
) {
|
||||
// 如果已经添加过,不重复添加
|
||||
if (playRecordJumpLayerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记已经进行过首次检查
|
||||
playRecordJumpInitialCheckRef.current = false;
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (seconds: number): string => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) {
|
||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 添加到播放器 layers
|
||||
playRecordJumpLayerRef.current = artPlayerRef.current.layers.add({
|
||||
name: 'play-record-jump',
|
||||
html: `
|
||||
<div id="play-record-jump-container" style="
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 60px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
pointer-events: auto;
|
||||
">
|
||||
<span style="margin-right: 4px;">
|
||||
上次播放到 ${formatTime(recordTime)}
|
||||
</span>
|
||||
<button id="play-record-jump-btn" style="
|
||||
padding: 4px 12px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
">
|
||||
跳转
|
||||
</button>
|
||||
<button id="play-record-dismiss-btn" style="
|
||||
padding: 4px 8px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
" title="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
style: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
// 绑定事件
|
||||
const jumpBtn = document.getElementById('play-record-jump-btn');
|
||||
const dismissBtn = document.getElementById('play-record-dismiss-btn');
|
||||
|
||||
if (jumpBtn) {
|
||||
jumpBtn.addEventListener('mouseenter', () => {
|
||||
jumpBtn.style.backgroundColor = 'rgba(255, 255, 255, 0.3)';
|
||||
});
|
||||
jumpBtn.addEventListener('mouseleave', () => {
|
||||
jumpBtn.style.backgroundColor = 'rgba(255, 255, 255, 0.2)';
|
||||
});
|
||||
jumpBtn.addEventListener('click', () => {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.currentTime = recordTime;
|
||||
artPlayerRef.current.notice.show = `已跳转到 ${formatTime(recordTime)}`;
|
||||
}
|
||||
playRecordJumpDismissedRef.current = true;
|
||||
if (playRecordJumpLayerRef.current) {
|
||||
artPlayerRef.current.layers.remove('play-record-jump');
|
||||
playRecordJumpLayerRef.current = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (dismissBtn) {
|
||||
dismissBtn.addEventListener('mouseenter', () => {
|
||||
dismissBtn.style.color = 'white';
|
||||
});
|
||||
dismissBtn.addEventListener('mouseleave', () => {
|
||||
dismissBtn.style.color = 'rgba(255, 255, 255, 0.7)';
|
||||
});
|
||||
dismissBtn.addEventListener('click', () => {
|
||||
playRecordJumpDismissedRef.current = true;
|
||||
if (playRecordJumpLayerRef.current) {
|
||||
artPlayerRef.current.layers.remove('play-record-jump');
|
||||
playRecordJumpLayerRef.current = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[PlayRecordJump] 显示跳转按钮,当前时间:', currentTime, '记录时间:', recordTime);
|
||||
} else {
|
||||
// 不满足显示条件,也标记为已检查过
|
||||
playRecordJumpInitialCheckRef.current = false;
|
||||
}
|
||||
} else {
|
||||
// 没有播放记录,也标记为已检查过
|
||||
playRecordJumpInitialCheckRef.current = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PlayRecordJump] 检查播放记录失败:', err);
|
||||
// 即使出错也标记为已检查过
|
||||
playRecordJumpInitialCheckRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟检查,确保播放器已经稳定
|
||||
setTimeout(checkPlayRecordJump, 500);
|
||||
});
|
||||
|
||||
// 监听视频时间更新事件,实现跳过片头片尾
|
||||
artPlayerRef.current.on('video:timeupdate', () => {
|
||||
if (!skipConfigRef.current.enable) return;
|
||||
|
||||
const currentTime = artPlayerRef.current.currentTime || 0;
|
||||
const duration = artPlayerRef.current.duration || 0;
|
||||
const now = Date.now();
|
||||
|
||||
// 限制跳过检查频率为1.5秒一次
|
||||
if (now - lastSkipCheckRef.current < 1500) return;
|
||||
lastSkipCheckRef.current = now;
|
||||
|
||||
// 跳过片头
|
||||
if (
|
||||
skipConfigRef.current.intro_time > 0 &&
|
||||
currentTime < skipConfigRef.current.intro_time
|
||||
) {
|
||||
artPlayerRef.current.currentTime = skipConfigRef.current.intro_time;
|
||||
artPlayerRef.current.notice.show = `已跳过片头 (${formatTime(
|
||||
skipConfigRef.current.intro_time
|
||||
)})`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (artPlayerRef.current?.video) {
|
||||
ensureVideoSource(
|
||||
artPlayerRef.current.video as HTMLVideoElement,
|
||||
videoUrl
|
||||
);
|
||||
}
|
||||
// 跳过片尾
|
||||
if (
|
||||
skipConfigRef.current.outro_time < 0 &&
|
||||
duration > 0 &&
|
||||
currentTime >
|
||||
artPlayerRef.current.duration + skipConfigRef.current.outro_time
|
||||
) {
|
||||
if (
|
||||
currentEpisodeIndexRef.current <
|
||||
(detailRef.current?.episodes?.length || 1) - 1
|
||||
) {
|
||||
handleNextEpisode();
|
||||
} else {
|
||||
artPlayerRef.current.pause();
|
||||
}
|
||||
artPlayerRef.current.notice.show = `已跳过片尾 (${formatTime(
|
||||
skipConfigRef.current.outro_time
|
||||
)})`;
|
||||
}
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('error', (err: any) => {
|
||||
console.error('播放器错误:', err);
|
||||
// 如果已经成功播放过一段时间,忽略后续错误(可能是短暂网络波动)
|
||||
if (artPlayerRef.current && artPlayerRef.current.currentTime > 0) {
|
||||
return;
|
||||
}
|
||||
// 原生 <video> 播放失败(非 HLS.js 管理的场景,如无后缀的直链)
|
||||
// 需要触发播放失败 UI,否则会永远卡在"加载中"
|
||||
const currentUrl = artPlayerRef.current?.option?.url || videoUrl;
|
||||
const isUsingHls = currentUrl.includes('/api/proxy-m3u8') || currentUrl.includes('/api/proxy/vod/m3u8') || currentUrl.toLowerCase().includes('.m3u8') || currentUrl.toLowerCase().includes('.m3u');
|
||||
if (!isUsingHls) {
|
||||
// 非 HLS 场景下的原生视频错误,显示错误 UI
|
||||
if (proxyAttemptedRef.current) {
|
||||
// 代理已经尝试过(走了 415→直连 的路径),直连也失败了,不再提供代理按钮
|
||||
setVideoError('视频无法在浏览器中播放(已尝试代理,格式不兼容)');
|
||||
} else if (currentSourceRef.current === 'directplay' && !currentUrl.includes('/api/proxy-m3u8')) {
|
||||
setCorsFailedUrl(currentUrl);
|
||||
setVideoError('视频播放失败(格式不支持或跨域限制)');
|
||||
} else {
|
||||
setVideoError('视频播放失败(格式不支持或跨域限制)');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听视频播放结束事件,自动播放下一集(房员禁用)
|
||||
artPlayerRef.current.on('video:ended', () => {
|
||||
// 房员禁用自动播放下一集
|
||||
if (playSync.shouldDisableControls) {
|
||||
console.log('[PlayPage] Member cannot auto-play next episode');
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '等待房主切换下一集';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const d = detailRef.current;
|
||||
const idx = currentEpisodeIndexRef.current;
|
||||
|
||||
if (!d || !d.episodes || idx >= d.episodes.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找下一个未被过滤的集数
|
||||
let nextIdx = idx + 1;
|
||||
while (nextIdx < d.episodes.length) {
|
||||
const episodeTitle = d.episodes_titles?.[nextIdx];
|
||||
const isFiltered = episodeTitle && isEpisodeFilteredByTitle(episodeTitle);
|
||||
|
||||
if (!isFiltered) {
|
||||
setTimeout(() => {
|
||||
setCurrentEpisodeIndex(nextIdx);
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
nextIdx++;
|
||||
}
|
||||
|
||||
// 所有后续集数都被屏蔽
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '后续集数均已屏蔽,已自动停止';
|
||||
}
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('video:timeupdate', () => {
|
||||
const now = Date.now();
|
||||
let interval = 5000;
|
||||
if (process.env.NEXT_PUBLIC_STORAGE_TYPE === 'upstash') {
|
||||
interval = 20000;
|
||||
}
|
||||
if (now - lastSaveTimeRef.current > interval) {
|
||||
saveCurrentPlayProgress();
|
||||
lastSaveTimeRef.current = now;
|
||||
}
|
||||
|
||||
// 下集预缓冲逻辑
|
||||
const nextEpisodePreCacheEnabled = typeof window !== 'undefined'
|
||||
? localStorage.getItem('nextEpisodePreCache') === 'true'
|
||||
: false;
|
||||
|
||||
if (nextEpisodePreCacheEnabled) {
|
||||
const currentTime = artPlayerRef.current?.currentTime || 0;
|
||||
const duration = artPlayerRef.current?.duration || 0;
|
||||
const progress = duration > 0 ? currentTime / duration : 0;
|
||||
|
||||
// 检查是否已经到达90%播放进度
|
||||
if (duration > 0 && progress >= 0.9 && !nextEpisodePreCacheTriggeredRef.current) {
|
||||
// 标记已触发,防止重复执行
|
||||
nextEpisodePreCacheTriggeredRef.current = true;
|
||||
|
||||
// 获取下一集信息
|
||||
const currentIdx = currentEpisodeIndexRef.current;
|
||||
const episodes = detailRef.current?.episodes;
|
||||
|
||||
if (!episodes || currentIdx >= episodes.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEpisodeIndex = currentIdx + 1;
|
||||
const nextEpisodeUrl = episodes[nextEpisodeIndex];
|
||||
|
||||
if (!nextEpisodeUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 fetch 预加载资源,利用浏览器缓存
|
||||
const preloadNextEpisode = async () => {
|
||||
try {
|
||||
// 判断是否是m3u8流
|
||||
if (nextEpisodeUrl.includes('.m3u8') || nextEpisodeUrl.includes('m3u8')) {
|
||||
// 1. 先fetch m3u8文件
|
||||
const m3u8Response = await fetch(nextEpisodeUrl);
|
||||
const m3u8Text = await m3u8Response.text();
|
||||
|
||||
// 2. 解析m3u8,提取ts分片URL
|
||||
const lines = m3u8Text.split('\n');
|
||||
const tsUrls: string[] = [];
|
||||
const baseUrl = nextEpisodeUrl.substring(0, nextEpisodeUrl.lastIndexOf('/') + 1);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
// 跳过注释和空行
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
// 构建完整的ts URL
|
||||
const tsUrl = trimmedLine.startsWith('http')
|
||||
? trimmedLine
|
||||
: baseUrl + trimmedLine;
|
||||
tsUrls.push(tsUrl);
|
||||
}
|
||||
|
||||
// 3. 预加载前20个ts分片
|
||||
const maxFragmentsToPreload = Math.min(20, tsUrls.length);
|
||||
|
||||
for (let i = 0; i < maxFragmentsToPreload; i++) {
|
||||
try {
|
||||
await fetch(tsUrls[i]);
|
||||
} catch (err) {
|
||||
// 静默处理分片加载失败
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默处理预缓冲失败
|
||||
}
|
||||
};
|
||||
|
||||
// 异步执行预缓冲
|
||||
preloadNextEpisode();
|
||||
}
|
||||
}
|
||||
|
||||
// 下集弹幕预加载逻辑
|
||||
const nextEpisodeDanmakuPreloadEnabled = typeof window !== 'undefined'
|
||||
? localStorage.getItem('nextEpisodeDanmakuPreload') === 'true'
|
||||
: false;
|
||||
|
||||
if (nextEpisodeDanmakuPreloadEnabled) {
|
||||
const currentTime = artPlayerRef.current?.currentTime || 0;
|
||||
const duration = artPlayerRef.current?.duration || 0;
|
||||
const progress = duration > 0 ? currentTime / duration : 0;
|
||||
|
||||
// 检查是否已经到达90%播放进度
|
||||
if (duration > 0 && progress >= 0.9 && !nextEpisodeDanmakuPreloadTriggeredRef.current) {
|
||||
// 标记已触发,防止重复执行
|
||||
nextEpisodeDanmakuPreloadTriggeredRef.current = true;
|
||||
|
||||
// 异步执行弹幕预加载
|
||||
preloadNextEpisodeDanmaku();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (artPlayerRef.current?.video) {
|
||||
ensureVideoSource(
|
||||
artPlayerRef.current.video as HTMLVideoElement,
|
||||
videoUrl
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建播放器失败:', err);
|
||||
setError('播放器初始化失败');
|
||||
@@ -7224,30 +7549,27 @@ function PlayPageClient() {
|
||||
<div className='mb-6 w-80 mx-auto'>
|
||||
<div className='flex justify-center space-x-2 mb-4'>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||
loadingStage === 'searching' || loadingStage === 'fetching'
|
||||
? 'bg-green-500 scale-125'
|
||||
: loadingStage === 'preferring' ||
|
||||
loadingStage === 'ready'
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'searching' || loadingStage === 'fetching'
|
||||
? 'bg-green-500 scale-125'
|
||||
: loadingStage === 'preferring' ||
|
||||
loadingStage === 'ready'
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||
loadingStage === 'preferring'
|
||||
? 'bg-green-500 scale-125'
|
||||
: loadingStage === 'ready'
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'preferring'
|
||||
? 'bg-green-500 scale-125'
|
||||
: loadingStage === 'ready'
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||
loadingStage === 'ready'
|
||||
? 'bg-green-500 scale-125'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'ready'
|
||||
? 'bg-green-500 scale-125'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@@ -7258,11 +7580,11 @@ function PlayPageClient() {
|
||||
style={{
|
||||
width:
|
||||
loadingStage === 'searching' ||
|
||||
loadingStage === 'fetching'
|
||||
loadingStage === 'fetching'
|
||||
? '33%'
|
||||
: loadingStage === 'preferring'
|
||||
? '66%'
|
||||
: '100%',
|
||||
? '66%'
|
||||
: '100%',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
@@ -7412,9 +7734,9 @@ function PlayPageClient() {
|
||||
<div className='flex-shrink-0'>
|
||||
<svg className='w-6 h-6 text-gray-400 group-hover:text-green-500
|
||||
transition-colors duration-200'
|
||||
fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2}
|
||||
d='M9 5l7 7-7 7' />
|
||||
d='M9 5l7 7-7 7' />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7519,11 +7841,10 @@ function PlayPageClient() {
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
status === 'completed'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
}`}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${status === 'completed'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
}`}
|
||||
>
|
||||
{status === 'completed' ? '已完结' : '连载中'}
|
||||
</span>
|
||||
@@ -7545,9 +7866,8 @@ function PlayPageClient() {
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${
|
||||
isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
|
||||
}`}
|
||||
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
|
||||
}`}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
@@ -7565,27 +7885,24 @@ function PlayPageClient() {
|
||||
|
||||
{/* 精致的状态指示点 */}
|
||||
<div
|
||||
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${
|
||||
isEpisodeSelectorCollapsed
|
||||
? 'bg-orange-400 animate-pulse'
|
||||
: 'bg-green-400'
|
||||
}`}
|
||||
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${isEpisodeSelectorCollapsed
|
||||
? 'bg-orange-400 animate-pulse'
|
||||
: 'bg-green-400'
|
||||
}`}
|
||||
></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${
|
||||
isEpisodeSelectorCollapsed
|
||||
? 'grid-cols-1'
|
||||
: 'grid-cols-1 md:grid-cols-4'
|
||||
}`}
|
||||
className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${isEpisodeSelectorCollapsed
|
||||
? 'grid-cols-1'
|
||||
: 'grid-cols-1 md:grid-cols-4'
|
||||
}`}
|
||||
>
|
||||
{/* 播放器 */}
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 flex flex-col ${
|
||||
isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
|
||||
}`}
|
||||
className={`transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 flex flex-col ${isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
|
||||
}`}
|
||||
>
|
||||
{/* 播放器容器 */}
|
||||
<div className='relative w-full h-[300px] lg:flex-1 lg:min-h-0'>
|
||||
@@ -7629,6 +7946,28 @@ function PlayPageClient() {
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
{/* 直链播放 CORS 失败时,显示"使用代理播放"按钮 */}
|
||||
{!proxyAttemptedRef.current && (corsFailedUrl || (isDirectPlay && videoUrl && !videoUrl.includes('/api/proxy-m3u8'))) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const originalUrl = corsFailedUrl || videoUrl;
|
||||
// 记忆域名到 localStorage
|
||||
addDirectplayProxyDomain(originalUrl);
|
||||
// 构建代理 URL
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const proxyUrl = `/api/proxy-m3u8?url=${encodeURIComponent(originalUrl)}&source=directplay${tokenParam}`;
|
||||
// 清除错误状态并重新播放
|
||||
setVideoError(null);
|
||||
setCorsFailedUrl(null);
|
||||
setIsVideoLoading(true);
|
||||
proxyAttemptedRef.current = true;
|
||||
setVideoUrl(proxyUrl);
|
||||
}}
|
||||
className='mt-4 ml-3 px-6 py-2 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-lg hover:from-blue-600 hover:to-indigo-700 transition-all duration-200'
|
||||
>
|
||||
使用代理播放
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -7675,8 +8014,8 @@ function PlayPageClient() {
|
||||
<div className='absolute inset-0 flex items-center justify-center bg-black/50 z-50 pointer-events-none'>
|
||||
<div className='bg-black/80 text-white px-6 py-3 rounded-lg flex items-center gap-3 backdrop-blur-sm border border-green-500/30'>
|
||||
<svg className='animate-spin h-5 w-5' viewBox='0 0 24 24'>
|
||||
<circle className='opacity-25' cx='12' cy='12' r='10' stroke='currentColor' strokeWidth='4' fill='none'/>
|
||||
<path className='opacity-75' fill='currentColor' d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'/>
|
||||
<circle className='opacity-25' cx='12' cy='12' r='10' stroke='currentColor' strokeWidth='4' fill='none' />
|
||||
<path className='opacity-75' fill='currentColor' d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z' />
|
||||
</svg>
|
||||
<span>正在刷新链接...</span>
|
||||
</div>
|
||||
@@ -7797,12 +8136,12 @@ function PlayPageClient() {
|
||||
<svg
|
||||
className='w-4 h-4 flex-shrink-0 text-white'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z'
|
||||
/>
|
||||
@@ -7873,172 +8212,171 @@ function PlayPageClient() {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* VLC */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||
let urlToUse = videoUrl;
|
||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||
}
|
||||
// 使用代理 URL
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: urlToUse;
|
||||
// URL encode 避免冒号被吃掉
|
||||
window.open(`vlc://${proxyUrl}`, '_blank');
|
||||
}}
|
||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||
title='VLC'
|
||||
>
|
||||
<img
|
||||
src='/players/vlc.png'
|
||||
alt='VLC'
|
||||
className='w-4 h-4 flex-shrink-0'
|
||||
/>
|
||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||
VLC
|
||||
</span>
|
||||
</button>
|
||||
{/* VLC */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||
let urlToUse = videoUrl;
|
||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||
}
|
||||
// 使用代理 URL
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: urlToUse;
|
||||
// URL encode 避免冒号被吃掉
|
||||
window.open(`vlc://${proxyUrl}`, '_blank');
|
||||
}}
|
||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||
title='VLC'
|
||||
>
|
||||
<img
|
||||
src='/players/vlc.png'
|
||||
alt='VLC'
|
||||
className='w-4 h-4 flex-shrink-0'
|
||||
/>
|
||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||
VLC
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* MPV */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||
let urlToUse = videoUrl;
|
||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||
}
|
||||
// 使用代理 URL
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: urlToUse;
|
||||
// URL encode 避免冒号被吃掉
|
||||
window.open(`mpv://${proxyUrl}`, '_blank');
|
||||
}}
|
||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||
title='MPV'
|
||||
>
|
||||
<img
|
||||
src='/players/mpv.png'
|
||||
alt='MPV'
|
||||
className='w-4 h-4 flex-shrink-0'
|
||||
/>
|
||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||
MPV
|
||||
</span>
|
||||
</button>
|
||||
{/* MPV */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||
let urlToUse = videoUrl;
|
||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||
}
|
||||
// 使用代理 URL
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: urlToUse;
|
||||
// URL encode 避免冒号被吃掉
|
||||
window.open(`mpv://${proxyUrl}`, '_blank');
|
||||
}}
|
||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||
title='MPV'
|
||||
>
|
||||
<img
|
||||
src='/players/mpv.png'
|
||||
alt='MPV'
|
||||
className='w-4 h-4 flex-shrink-0'
|
||||
/>
|
||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||
MPV
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* MX Player */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||
let urlToUse = videoUrl;
|
||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||
}
|
||||
// 使用代理 URL
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: urlToUse;
|
||||
window.open(
|
||||
`intent://${proxyUrl}#Intent;package=com.mxtech.videoplayer.ad;S.title=${encodeURIComponent(
|
||||
videoTitle
|
||||
)};end`,
|
||||
'_blank'
|
||||
);
|
||||
}}
|
||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||
title='MX Player'
|
||||
>
|
||||
<img
|
||||
src='/players/mxplayer.png'
|
||||
alt='MX Player'
|
||||
className='w-4 h-4 flex-shrink-0'
|
||||
/>
|
||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||
MX Player
|
||||
</span>
|
||||
</button>
|
||||
{/* MX Player */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||
let urlToUse = videoUrl;
|
||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||
}
|
||||
// 使用代理 URL
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: urlToUse;
|
||||
window.open(
|
||||
`intent://${proxyUrl}#Intent;package=com.mxtech.videoplayer.ad;S.title=${encodeURIComponent(
|
||||
videoTitle
|
||||
)};end`,
|
||||
'_blank'
|
||||
);
|
||||
}}
|
||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||
title='MX Player'
|
||||
>
|
||||
<img
|
||||
src='/players/mxplayer.png'
|
||||
alt='MX Player'
|
||||
className='w-4 h-4 flex-shrink-0'
|
||||
/>
|
||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||
MX Player
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* nPlayer */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||
let urlToUse = videoUrl;
|
||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||
}
|
||||
// 使用代理 URL
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: urlToUse;
|
||||
window.open(`nplayer-${proxyUrl}`, '_blank');
|
||||
}}
|
||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||
title='nPlayer'
|
||||
>
|
||||
<img
|
||||
src='/players/nplayer.png'
|
||||
alt='nPlayer'
|
||||
className='w-4 h-4 flex-shrink-0'
|
||||
/>
|
||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||
nPlayer
|
||||
</span>
|
||||
</button>
|
||||
{/* nPlayer */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||
let urlToUse = videoUrl;
|
||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||
}
|
||||
// 使用代理 URL
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: urlToUse;
|
||||
window.open(`nplayer-${proxyUrl}`, '_blank');
|
||||
}}
|
||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||
title='nPlayer'
|
||||
>
|
||||
<img
|
||||
src='/players/nplayer.png'
|
||||
alt='nPlayer'
|
||||
className='w-4 h-4 flex-shrink-0'
|
||||
/>
|
||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||
nPlayer
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* IINA */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||
let urlToUse = videoUrl;
|
||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||
}
|
||||
// 使用代理 URL
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: urlToUse;
|
||||
window.open(
|
||||
`iina://weblink?url=${encodeURIComponent(
|
||||
proxyUrl
|
||||
)}`,
|
||||
'_blank'
|
||||
);
|
||||
}}
|
||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||
title='IINA'
|
||||
>
|
||||
<img
|
||||
src='/players/iina.png'
|
||||
alt='IINA'
|
||||
className='w-4 h-4 flex-shrink-0'
|
||||
/>
|
||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||
IINA
|
||||
</span>
|
||||
</button>
|
||||
{/* IINA */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||
let urlToUse = videoUrl;
|
||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||
}
|
||||
// 使用代理 URL
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: urlToUse;
|
||||
window.open(
|
||||
`iina://weblink?url=${encodeURIComponent(
|
||||
proxyUrl
|
||||
)}`,
|
||||
'_blank'
|
||||
);
|
||||
}}
|
||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||
title='IINA'
|
||||
>
|
||||
<img
|
||||
src='/players/iina.png'
|
||||
alt='IINA'
|
||||
className='w-4 h-4 flex-shrink-0'
|
||||
/>
|
||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||
IINA
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 去广告开关 */}
|
||||
<button
|
||||
onClick={() => setExternalPlayerAdBlock(!externalPlayerAdBlock)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer border flex-shrink-0 ${
|
||||
externalPlayerAdBlock
|
||||
? 'bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 text-white border-blue-400'
|
||||
: 'bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer border flex-shrink-0 ${externalPlayerAdBlock
|
||||
? 'bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 text-white border-blue-400'
|
||||
: 'bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
title={externalPlayerAdBlock ? '去广告已开启' : '去广告已关闭'}
|
||||
>
|
||||
<svg
|
||||
@@ -8075,11 +8413,10 @@ function PlayPageClient() {
|
||||
|
||||
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
|
||||
<div
|
||||
className={`relative z-10 h-[350px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
isEpisodeSelectorCollapsed
|
||||
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
|
||||
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
|
||||
}`}
|
||||
className={`relative z-10 h-[350px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${isEpisodeSelectorCollapsed
|
||||
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
|
||||
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
|
||||
}`}
|
||||
>
|
||||
<EpisodeSelector
|
||||
totalEpisodes={totalEpisodes}
|
||||
@@ -8273,9 +8610,8 @@ function PlayPageClient() {
|
||||
)}
|
||||
{detail?.source_name && (
|
||||
<span
|
||||
className={`relative group cursor-pointer border px-2 py-[1px] rounded ${
|
||||
detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
|
||||
}`}
|
||||
className={`relative group cursor-pointer border px-2 py-[1px] rounded ${detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
|
||||
}`}
|
||||
onClick={fetchCurrentSourceVideoInfo}
|
||||
>
|
||||
{detail.source_name}
|
||||
@@ -8373,7 +8709,7 @@ function PlayPageClient() {
|
||||
<div className='px-3 md:px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
|
||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2'>
|
||||
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 24 24'>
|
||||
<path d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z'/>
|
||||
<path d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z' />
|
||||
</svg>
|
||||
豆瓣评论
|
||||
</h3>
|
||||
@@ -8386,6 +8722,28 @@ function PlayPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI评论区域 */}
|
||||
{videoTitle && enableAIComments && (
|
||||
<div className='mt-6 -mx-3 md:mx-0 md:px-4'>
|
||||
<div className='bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-xl border border-blue-200/50 dark:border-blue-700/50 overflow-hidden'>
|
||||
{/* 标题 */}
|
||||
<div className='px-3 md:px-6 py-4 border-b border-blue-200 dark:border-blue-700'>
|
||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2'>
|
||||
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='currentColor' viewBox='0 0 24 24'>
|
||||
<path d='M13 10V3L4 14h7v7l9-11h-7z' />
|
||||
</svg>
|
||||
AI生成评论
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 评论内容 */}
|
||||
<div className='p-3 md:p-6'>
|
||||
<AIComments movieName={videoTitle} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -8537,18 +8895,18 @@ function PlayPageClient() {
|
||||
// 特殊源使用 tmdb,其他使用 cms(通过 doubanId)
|
||||
// 如果有豆瓣ID且不为0,传入doubanId
|
||||
detail.source === 'openlist' ||
|
||||
detail.source?.startsWith('emby') ||
|
||||
detail.source === 'xiaoya'
|
||||
detail.source?.startsWith('emby') ||
|
||||
detail.source === 'xiaoya'
|
||||
? undefined
|
||||
: detail.douban_id && detail.douban_id !== 0
|
||||
? detail.douban_id
|
||||
: undefined
|
||||
? detail.douban_id
|
||||
: undefined
|
||||
}
|
||||
tmdbId={
|
||||
// 特殊源使用 tmdb
|
||||
detail.source === 'openlist' ||
|
||||
detail.source?.startsWith('emby') ||
|
||||
detail.source === 'xiaoya'
|
||||
detail.source?.startsWith('emby') ||
|
||||
detail.source === 'xiaoya'
|
||||
? detail.tmdb_id
|
||||
: undefined
|
||||
}
|
||||
@@ -8558,14 +8916,14 @@ function PlayPageClient() {
|
||||
// 非特殊源使用 cms 数据
|
||||
// 但如果有豆瓣ID且不为0,则不传入cmsData,优先使用豆瓣数据
|
||||
detail.source !== 'openlist' &&
|
||||
!detail.source?.startsWith('emby') &&
|
||||
detail.source !== 'xiaoya' &&
|
||||
!(detail.douban_id && detail.douban_id !== 0)
|
||||
!detail.source?.startsWith('emby') &&
|
||||
detail.source !== 'xiaoya' &&
|
||||
!(detail.douban_id && detail.douban_id !== 0)
|
||||
? {
|
||||
desc: detail.desc,
|
||||
episodes: detail.episodes,
|
||||
episodes_titles: detail.episodes_titles,
|
||||
}
|
||||
desc: detail.desc,
|
||||
episodes: detail.episodes,
|
||||
episodes_titles: detail.episodes_titles,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
sourceId={detail.id}
|
||||
|
||||
+872
-298
@@ -1,9 +1,26 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any,@typescript-eslint/no-non-null-assertion,no-empty */
|
||||
'use client';
|
||||
|
||||
import { ChevronUp, Film, HardDrive, Magnet,RefreshCw, Search, X } from 'lucide-react';
|
||||
import {
|
||||
ChevronUp,
|
||||
Film,
|
||||
Grid2x2,
|
||||
HardDrive,
|
||||
List,
|
||||
Magnet,
|
||||
RefreshCw,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, {
|
||||
startTransition,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
import {
|
||||
@@ -14,12 +31,16 @@ import {
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { processImageUrl } from '@/lib/utils';
|
||||
|
||||
import AcgSearch from '@/components/AcgSearch';
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import ImageViewer from '@/components/ImageViewer';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import PansouSearch from '@/components/PansouSearch';
|
||||
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
||||
import SearchResultFilter, {
|
||||
SearchFilterCategory,
|
||||
} from '@/components/SearchResultFilter';
|
||||
import SearchSuggestions from '@/components/SearchSuggestions';
|
||||
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
|
||||
import VirtualScrollableGrid from '@/components/VirtualScrollableGrid';
|
||||
@@ -30,13 +51,17 @@ function SearchPageClient() {
|
||||
// 返回顶部按钮显示状态
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
// 选项卡状态: 'video' 或 'pansou' 或 'acg'
|
||||
const [activeTab, setActiveTab] = useState<'video' | 'pansou' | 'acg'>('video');
|
||||
const [activeTab, setActiveTab] = useState<'video' | 'pansou' | 'acg'>(
|
||||
'video'
|
||||
);
|
||||
// Pansou 搜索触发标志
|
||||
const [triggerPansouSearch, setTriggerPansouSearch] = useState(false);
|
||||
// ACG 搜索触发标志
|
||||
const [triggerAcgSearch, setTriggerAcgSearch] = useState(false);
|
||||
// 用户权限
|
||||
const [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>(null);
|
||||
const [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>(
|
||||
null
|
||||
);
|
||||
// 繁体转简体转换器
|
||||
const converterRef = useRef<((text: string) => string) | null>(null);
|
||||
// 转换器是否已初始化
|
||||
@@ -57,8 +82,15 @@ function SearchPageClient() {
|
||||
const flushTimerRef = useRef<number | null>(null);
|
||||
const [useFluidSearch, setUseFluidSearch] = useState(true);
|
||||
// 聚合卡片 refs 与聚合统计缓存
|
||||
const groupRefs = useRef<Map<string, React.RefObject<VideoCardHandle>>>(new Map());
|
||||
const groupStatsRef = useRef<Map<string, { douban_id?: number; episodes?: number; source_names: string[] }>>(new Map());
|
||||
const groupRefs = useRef<Map<string, React.RefObject<VideoCardHandle>>>(
|
||||
new Map()
|
||||
);
|
||||
const groupStatsRef = useRef<
|
||||
Map<
|
||||
string,
|
||||
{ douban_id?: number; episodes?: number; source_names: string[] }
|
||||
>
|
||||
>(new Map());
|
||||
// 强制刷新状态
|
||||
const [forceRefresh, setForceRefresh] = useState(false);
|
||||
// 是否使用了缓存结果
|
||||
@@ -127,11 +159,16 @@ function SearchPageClient() {
|
||||
let max = 0;
|
||||
let res = 0;
|
||||
countMap.forEach((v, k) => {
|
||||
if (v > max) { max = v; res = k; }
|
||||
if (v > max) {
|
||||
max = v;
|
||||
res = k;
|
||||
}
|
||||
});
|
||||
return res;
|
||||
})();
|
||||
const source_names = Array.from(new Set(group.map((g) => g.source_name).filter(Boolean))) as string[];
|
||||
const source_names = Array.from(
|
||||
new Set(group.map((g) => g.source_name).filter(Boolean))
|
||||
) as string[];
|
||||
|
||||
const douban_id = (() => {
|
||||
const countMap = new Map<number, number>();
|
||||
@@ -143,7 +180,10 @@ function SearchPageClient() {
|
||||
let max = 0;
|
||||
let res: number | undefined;
|
||||
countMap.forEach((v, k) => {
|
||||
if (v > max) { max = v; res = k; }
|
||||
if (v > max) {
|
||||
max = v;
|
||||
res = k;
|
||||
}
|
||||
});
|
||||
return res;
|
||||
})();
|
||||
@@ -151,13 +191,23 @@ function SearchPageClient() {
|
||||
return { episodes, source_names, douban_id };
|
||||
};
|
||||
// 过滤器:非聚合与聚合
|
||||
const [filterAll, setFilterAll] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({
|
||||
const [filterAll, setFilterAll] = useState<{
|
||||
source: string;
|
||||
title: string;
|
||||
year: string;
|
||||
yearOrder: 'none' | 'asc' | 'desc';
|
||||
}>({
|
||||
source: 'all',
|
||||
title: 'all',
|
||||
year: 'all',
|
||||
yearOrder: 'none',
|
||||
});
|
||||
const [filterAgg, setFilterAgg] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({
|
||||
const [filterAgg, setFilterAgg] = useState<{
|
||||
source: string;
|
||||
title: string;
|
||||
year: string;
|
||||
yearOrder: 'none' | 'asc' | 'desc';
|
||||
}>({
|
||||
source: 'all',
|
||||
title: 'all',
|
||||
year: 'all',
|
||||
@@ -178,6 +228,24 @@ function SearchPageClient() {
|
||||
const [viewMode, setViewMode] = useState<'agg' | 'all'>(() => {
|
||||
return getDefaultAggregate() ? 'agg' : 'all';
|
||||
});
|
||||
const [resultDisplayMode, setResultDisplayMode] = useState<'card' | 'list'>(
|
||||
() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('searchResultDisplayMode');
|
||||
if (saved === 'card' || saved === 'list') {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
return 'card';
|
||||
}
|
||||
);
|
||||
const [expandedSourceTags, setExpandedSourceTags] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [previewImage, setPreviewImage] = useState<{
|
||||
url: string;
|
||||
alt: string;
|
||||
} | null>(null);
|
||||
|
||||
// 在“无排序”场景用于每个源批次的预排序:完全匹配标题优先,其次年份倒序,未知年份最后
|
||||
const sortBatchForNoOrder = (items: SearchResult[]) => {
|
||||
@@ -200,7 +268,11 @@ function SearchPageClient() {
|
||||
};
|
||||
|
||||
// 简化的年份排序:unknown/空值始终在最后
|
||||
const compareYear = (aYear: string, bYear: string, order: 'none' | 'asc' | 'desc') => {
|
||||
const compareYear = (
|
||||
aYear: string,
|
||||
bYear: string,
|
||||
order: 'none' | 'asc' | 'desc'
|
||||
) => {
|
||||
// 如果是无排序状态,返回0(保持原顺序)
|
||||
if (order === 'none') return 0;
|
||||
|
||||
@@ -230,7 +302,11 @@ function SearchPageClient() {
|
||||
// 辅助函数:获取视频类型
|
||||
const getType = (item: SearchResult): 'movie' | 'tv' => {
|
||||
// 1. Emby 和 OpenList 源:使用 type_name(基于 TMDB,最可靠)
|
||||
if (item.source === 'emby' || item.source?.startsWith('emby_') || item.source === 'openlist') {
|
||||
if (
|
||||
item.source === 'emby' ||
|
||||
item.source?.startsWith('emby_') ||
|
||||
item.source === 'openlist'
|
||||
) {
|
||||
return item.type_name === '电影' ? 'movie' : 'tv';
|
||||
}
|
||||
|
||||
@@ -238,14 +314,21 @@ function SearchPageClient() {
|
||||
const typeName = item.type_name?.toLowerCase() || '';
|
||||
|
||||
// 2.1 明确包含"电影"或"movie"或"片"的,判断为电影
|
||||
if (typeName.includes('电影') || typeName.includes('movie') ||
|
||||
typeName.endsWith('片') && !typeName.includes('动漫')) {
|
||||
if (
|
||||
typeName.includes('电影') ||
|
||||
typeName.includes('movie') ||
|
||||
(typeName.endsWith('片') && !typeName.includes('动漫'))
|
||||
) {
|
||||
return 'movie';
|
||||
}
|
||||
|
||||
// 2.2 包含"剧"、"动漫"、"综艺"等关键词的,判断为剧集
|
||||
if (typeName.includes('剧') || typeName.includes('动漫') ||
|
||||
typeName.includes('综艺') || typeName.includes('anime')) {
|
||||
if (
|
||||
typeName.includes('剧') ||
|
||||
typeName.includes('动漫') ||
|
||||
typeName.includes('综艺') ||
|
||||
typeName.includes('anime')
|
||||
) {
|
||||
return 'tv';
|
||||
}
|
||||
|
||||
@@ -275,7 +358,9 @@ function SearchPageClient() {
|
||||
const aggregatedResults = useMemo(() => {
|
||||
// 首先应用精确搜索过滤
|
||||
const filteredResults = exactSearch
|
||||
? searchResults.filter(item => titleContainsQuery(item.title, currentQueryRef.current))
|
||||
? searchResults.filter((item) =>
|
||||
titleContainsQuery(item.title, currentQueryRef.current)
|
||||
)
|
||||
: searchResults;
|
||||
|
||||
//===== 阶段1:按 normalizedTitle-type 初步分组 =====
|
||||
@@ -297,16 +382,21 @@ function SearchPageClient() {
|
||||
|
||||
preliminaryMap.forEach((group, preliminaryKey) => {
|
||||
// 分离有年份和无年份的结果
|
||||
const withYear = new Map<string, SearchResult[]>();
|
||||
const withYear = new Map<string, SearchResult[]>();
|
||||
const withoutYear: SearchResult[] = [];
|
||||
|
||||
group.forEach((item) => {
|
||||
const year = item.year;
|
||||
|
||||
// 判断是否为有效年份:必须是4位数字,且不能是空字符串或'unknown'
|
||||
if (year && year.trim() !== '' && year !== 'unknown' && /^\d{4}$/.test(year)) {
|
||||
if (
|
||||
year &&
|
||||
year.trim() !== '' &&
|
||||
year !== 'unknown' &&
|
||||
/^\d{4}$/.test(year)
|
||||
) {
|
||||
// 有有效年份
|
||||
const arr = withYear.get(year) || [];
|
||||
const arr = withYear.get(year) || [];
|
||||
arr.push(item);
|
||||
withYear.set(year, arr);
|
||||
} else {
|
||||
@@ -334,7 +424,9 @@ function SearchPageClient() {
|
||||
});
|
||||
|
||||
// 按出现顺序返回聚合结果
|
||||
return keyOrder.map(key => [key, finalMap.get(key)!] as [string, SearchResult[]]);
|
||||
return keyOrder.map(
|
||||
(key) => [key, finalMap.get(key)!] as [string, SearchResult[]]
|
||||
);
|
||||
}, [searchResults, exactSearch]);
|
||||
|
||||
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新
|
||||
@@ -368,75 +460,181 @@ function SearchPageClient() {
|
||||
|
||||
// 构建筛选选项
|
||||
const filterOptions = useMemo(() => {
|
||||
const sourcesSet = new Map<string, string>();
|
||||
const titlesSet = new Set<string>();
|
||||
const yearsSet = new Set<string>();
|
||||
const exactSearchFiltered = exactSearch
|
||||
? searchResults.filter((item) =>
|
||||
titleContainsQuery(item.title, currentQueryRef.current)
|
||||
)
|
||||
: searchResults;
|
||||
|
||||
searchResults.forEach((item) => {
|
||||
if (item.source && item.source_name && item.source.trim() !== '' && item.source_name.trim() !== '') {
|
||||
sourcesSet.set(item.source, item.source_name);
|
||||
}
|
||||
if (item.title && item.title.trim() !== '') titlesSet.add(item.title);
|
||||
if (item.year && item.year.trim() !== '') yearsSet.add(item.year);
|
||||
});
|
||||
|
||||
const sourceOptions: { label: string; value: string }[] = [
|
||||
const buildSourceOptions = (
|
||||
sourceEntries: Array<{ source: string; source_name: string }>
|
||||
) => [
|
||||
{ label: '全部来源', value: 'all' },
|
||||
...Array.from(sourcesSet.entries())
|
||||
...Array.from(
|
||||
new Map(
|
||||
sourceEntries
|
||||
.filter(
|
||||
(item) =>
|
||||
item.source &&
|
||||
item.source_name &&
|
||||
item.source.trim() !== '' &&
|
||||
item.source_name.trim() !== ''
|
||||
)
|
||||
.map((item) => [item.source, item.source_name])
|
||||
).entries()
|
||||
)
|
||||
.sort((a, b) => {
|
||||
// 判断是否为 openlist
|
||||
const aIsOpenList = a[0] === 'openlist';
|
||||
const bIsOpenList = b[0] === 'openlist';
|
||||
|
||||
// 判断是否为 emby 源(包括 emby 和 emby_xxx 格式)
|
||||
const aIsEmby = a[0] === 'emby' || a[0].startsWith('emby_');
|
||||
const bIsEmby = b[0] === 'emby' || b[0].startsWith('emby_');
|
||||
|
||||
// 优先级:OpenList(100) > Emby(90) > 其他(0)
|
||||
const aPriority = aIsOpenList ? 100 : aIsEmby ? 90 : 0;
|
||||
const bPriority = bIsOpenList ? 100 : bIsEmby ? 90 : 0;
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority; // 降序排列
|
||||
return bPriority - aPriority;
|
||||
}
|
||||
|
||||
// 同优先级内按名称排序
|
||||
return a[1].localeCompare(b[1]);
|
||||
})
|
||||
.map(([value, label]) => ({ label, value })),
|
||||
];
|
||||
|
||||
const titleOptions: { label: string; value: string }[] = [
|
||||
const buildTitleOptions = (titles: string[]) => [
|
||||
{ label: '全部标题', value: 'all' },
|
||||
...Array.from(titlesSet.values())
|
||||
...Array.from(new Set(titles))
|
||||
.filter((title) => title && title.trim() !== '')
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((t) => ({ label: t, value: t })),
|
||||
.map((title) => ({ label: title, value: title })),
|
||||
];
|
||||
|
||||
// 年份: 将 unknown 放末尾
|
||||
const years = Array.from(yearsSet.values());
|
||||
const knownYears = years.filter((y) => y !== 'unknown').sort((a, b) => parseInt(b) - parseInt(a));
|
||||
const hasUnknown = years.includes('unknown');
|
||||
const yearOptions: { label: string; value: string }[] = [
|
||||
{ label: '全部年份', value: 'all' },
|
||||
...knownYears.map((y) => ({ label: y, value: y })),
|
||||
...(hasUnknown ? [{ label: '未知', value: 'unknown' }] : []),
|
||||
];
|
||||
const buildYearOptions = (years: string[]) => {
|
||||
const yearSet = Array.from(
|
||||
new Set(years.filter((year) => year && year.trim() !== ''))
|
||||
);
|
||||
const knownYears = yearSet
|
||||
.filter((year) => year !== 'unknown')
|
||||
.sort((a, b) => parseInt(b) - parseInt(a));
|
||||
const hasUnknown = yearSet.includes('unknown');
|
||||
|
||||
return [
|
||||
{ label: '全部年份', value: 'all' },
|
||||
...knownYears.map((year) => ({ label: year, value: year })),
|
||||
...(hasUnknown ? [{ label: '未知', value: 'unknown' }] : []),
|
||||
];
|
||||
};
|
||||
|
||||
const allForSourceOptions = exactSearchFiltered.filter((item) => {
|
||||
if (filterAll.title !== 'all' && item.title !== filterAll.title)
|
||||
return false;
|
||||
if (filterAll.year !== 'all' && item.year !== filterAll.year)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const allForTitleOptions = exactSearchFiltered.filter((item) => {
|
||||
if (filterAll.source !== 'all' && item.source !== filterAll.source)
|
||||
return false;
|
||||
if (filterAll.year !== 'all' && item.year !== filterAll.year)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const allForYearOptions = exactSearchFiltered.filter((item) => {
|
||||
if (filterAll.source !== 'all' && item.source !== filterAll.source)
|
||||
return false;
|
||||
if (filterAll.title !== 'all' && item.title !== filterAll.title)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const aggForSourceOptions = aggregatedResults.filter(([_, group]) => {
|
||||
const gTitle = group[0]?.title ?? '';
|
||||
const gYear = group[0]?.year ?? 'unknown';
|
||||
if (filterAgg.title !== 'all' && gTitle !== filterAgg.title) return false;
|
||||
if (filterAgg.year !== 'all' && gYear !== filterAgg.year) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const aggForTitleOptions = aggregatedResults.filter(([_, group]) => {
|
||||
const gYear = group[0]?.year ?? 'unknown';
|
||||
const hasSource =
|
||||
filterAgg.source === 'all'
|
||||
? true
|
||||
: group.some((item) => item.source === filterAgg.source);
|
||||
if (!hasSource) return false;
|
||||
if (filterAgg.year !== 'all' && gYear !== filterAgg.year) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const aggForYearOptions = aggregatedResults.filter(([_, group]) => {
|
||||
const gTitle = group[0]?.title ?? '';
|
||||
const hasSource =
|
||||
filterAgg.source === 'all'
|
||||
? true
|
||||
: group.some((item) => item.source === filterAgg.source);
|
||||
if (!hasSource) return false;
|
||||
if (filterAgg.title !== 'all' && gTitle !== filterAgg.title) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const categoriesAll: SearchFilterCategory[] = [
|
||||
{ key: 'source', label: '来源', options: sourceOptions },
|
||||
{ key: 'title', label: '标题', options: titleOptions },
|
||||
{ key: 'year', label: '年份', options: yearOptions },
|
||||
{
|
||||
key: 'source',
|
||||
label: '来源',
|
||||
options: buildSourceOptions(
|
||||
allForSourceOptions.map((item) => ({
|
||||
source: item.source,
|
||||
source_name: item.source_name,
|
||||
}))
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: '标题',
|
||||
options: buildTitleOptions(
|
||||
allForTitleOptions.map((item) => item.title)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'year',
|
||||
label: '年份',
|
||||
options: buildYearOptions(allForYearOptions.map((item) => item.year)),
|
||||
},
|
||||
];
|
||||
|
||||
const categoriesAgg: SearchFilterCategory[] = [
|
||||
{ key: 'source', label: '来源', options: sourceOptions },
|
||||
{ key: 'title', label: '标题', options: titleOptions },
|
||||
{ key: 'year', label: '年份', options: yearOptions },
|
||||
{
|
||||
key: 'source',
|
||||
label: '来源',
|
||||
options: buildSourceOptions(
|
||||
aggForSourceOptions.flatMap(([_, group]) =>
|
||||
group.map((item) => ({
|
||||
source: item.source,
|
||||
source_name: item.source_name,
|
||||
}))
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: '标题',
|
||||
options: buildTitleOptions(
|
||||
aggForTitleOptions.map(([_, group]) => group[0]?.title ?? '')
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'year',
|
||||
label: '年份',
|
||||
options: buildYearOptions(
|
||||
aggForYearOptions.map(([_, group]) => group[0]?.year ?? 'unknown')
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return { categoriesAll, categoriesAgg };
|
||||
}, [searchResults]);
|
||||
}, [searchResults, aggregatedResults, exactSearch, filterAll, filterAgg]);
|
||||
|
||||
// 非聚合:应用筛选与排序
|
||||
const filteredAllResults = useMemo(() => {
|
||||
@@ -444,7 +642,9 @@ function SearchPageClient() {
|
||||
|
||||
// 首先应用精确搜索过滤
|
||||
const exactSearchFiltered = exactSearch
|
||||
? searchResults.filter(item => titleContainsQuery(item.title, currentQueryRef.current))
|
||||
? searchResults.filter((item) =>
|
||||
titleContainsQuery(item.title, currentQueryRef.current)
|
||||
)
|
||||
: searchResults;
|
||||
|
||||
const filtered = exactSearchFiltered.filter((item) => {
|
||||
@@ -472,9 +672,9 @@ function SearchPageClient() {
|
||||
if (!aExactMatch && bExactMatch) return 1;
|
||||
|
||||
// 最后按标题排序,正序时字母序,倒序时反字母序
|
||||
return yearOrder === 'asc' ?
|
||||
a.title.localeCompare(b.title) :
|
||||
b.title.localeCompare(a.title);
|
||||
return yearOrder === 'asc'
|
||||
? a.title.localeCompare(b.title)
|
||||
: b.title.localeCompare(a.title);
|
||||
});
|
||||
}, [searchResults, filterAll, searchQuery, exactSearch]);
|
||||
|
||||
@@ -484,7 +684,8 @@ function SearchPageClient() {
|
||||
const filtered = aggregatedResults.filter(([_, group]) => {
|
||||
const gTitle = group[0]?.title ?? '';
|
||||
const gYear = group[0]?.year ?? 'unknown';
|
||||
const hasSource = source === 'all' ? true : group.some((item) => item.source === source);
|
||||
const hasSource =
|
||||
source === 'all' ? true : group.some((item) => item.source === source);
|
||||
if (!hasSource) return false;
|
||||
if (title !== 'all' && gTitle !== title) return false;
|
||||
if (year !== 'all' && gYear !== year) return false;
|
||||
@@ -513,26 +714,228 @@ function SearchPageClient() {
|
||||
// 最后按标题排序,正序时字母序,倒序时反字母序
|
||||
const aTitle = a[1][0].title;
|
||||
const bTitle = b[1][0].title;
|
||||
return yearOrder === 'asc' ?
|
||||
aTitle.localeCompare(bTitle) :
|
||||
bTitle.localeCompare(aTitle);
|
||||
return yearOrder === 'asc'
|
||||
? aTitle.localeCompare(bTitle)
|
||||
: bTitle.localeCompare(aTitle);
|
||||
});
|
||||
}, [aggregatedResults, filterAgg, searchQuery]);
|
||||
|
||||
const useVirtualGrid = useMemo(() => {
|
||||
const cardCount = viewMode === 'agg' ? filteredAggResults.length : filteredAllResults.length;
|
||||
return cardCount >= 100;
|
||||
}, [viewMode, filteredAggResults.length, filteredAllResults.length]);
|
||||
const cardCount =
|
||||
viewMode === 'agg'
|
||||
? filteredAggResults.length
|
||||
: filteredAllResults.length;
|
||||
return resultDisplayMode === 'card' && cardCount >= 100;
|
||||
}, [
|
||||
viewMode,
|
||||
resultDisplayMode,
|
||||
filteredAggResults.length,
|
||||
filteredAllResults.length,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('searchResultDisplayMode', resultDisplayMode);
|
||||
}
|
||||
}, [resultDisplayMode]);
|
||||
|
||||
const getSearchResultUrl = (params: {
|
||||
title: string;
|
||||
year?: string;
|
||||
type?: string;
|
||||
source?: string;
|
||||
id?: string;
|
||||
query?: string;
|
||||
isAggregate?: boolean;
|
||||
}) => {
|
||||
const yearParam =
|
||||
params.year && params.year !== 'unknown' ? `&year=${params.year}` : '';
|
||||
const queryParam = params.query
|
||||
? `&stitle=${encodeURIComponent(params.query.trim())}`
|
||||
: '';
|
||||
const typeParam = params.type ? `&stype=${params.type}` : '';
|
||||
const preferParam = params.isAggregate ? '&prefer=true' : '';
|
||||
|
||||
if (params.isAggregate || !params.source || !params.id) {
|
||||
return `/play?title=${encodeURIComponent(
|
||||
params.title.trim()
|
||||
)}${yearParam}${typeParam}${preferParam}${queryParam}`;
|
||||
}
|
||||
|
||||
return `/play?source=${params.source}&id=${
|
||||
params.id
|
||||
}&title=${encodeURIComponent(
|
||||
params.title.trim()
|
||||
)}${yearParam}${preferParam}${queryParam}${typeParam}`;
|
||||
};
|
||||
|
||||
const renderTag = (label: string, className: string) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ${className}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderListItem = (item: {
|
||||
key: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
year?: string;
|
||||
type: 'movie' | 'tv';
|
||||
episodes?: number;
|
||||
sourceName?: string;
|
||||
sourceNames?: string[];
|
||||
doubanId?: number;
|
||||
desc?: string;
|
||||
vodRemarks?: string;
|
||||
isAggregate?: boolean;
|
||||
source?: string;
|
||||
id?: string;
|
||||
query?: string;
|
||||
}) => {
|
||||
const yearText = item.year && item.year !== 'unknown' ? item.year : '';
|
||||
const sourceTags = item.isAggregate
|
||||
? Array.from(new Set(item.sourceNames || []))
|
||||
: item.sourceName
|
||||
? [item.sourceName]
|
||||
: [];
|
||||
const isExpanded = !!expandedSourceTags[item.key];
|
||||
const maxVisibleSourceTags = 3;
|
||||
const visibleSourceTags = isExpanded
|
||||
? sourceTags
|
||||
: sourceTags.slice(0, maxVisibleSourceTags);
|
||||
const hiddenSourceCount = Math.max(
|
||||
0,
|
||||
sourceTags.length - visibleSourceTags.length
|
||||
);
|
||||
const description = (item.desc || '').trim();
|
||||
const itemUrl = getSearchResultUrl({
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
type: item.type,
|
||||
source: item.source,
|
||||
id: item.id,
|
||||
query: item.query,
|
||||
isAggregate: item.isAggregate,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type='button'
|
||||
onClick={() => router.push(itemUrl)}
|
||||
className='group w-full rounded-2xl border border-gray-200/80 bg-white/90 p-3 text-left shadow-sm transition-all hover:border-green-300 hover:shadow-md dark:border-gray-700 dark:bg-gray-900/70 dark:hover:border-green-700'
|
||||
>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='relative h-32 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800'>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={processImageUrl(item.poster)}
|
||||
alt={item.title}
|
||||
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]'
|
||||
loading='lazy'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewImage({
|
||||
url: processImageUrl(item.poster),
|
||||
alt: item.title,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<h3 className='line-clamp-2 text-base font-semibold text-gray-900 dark:text-gray-100'>
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className='mt-2 flex flex-wrap gap-2'>
|
||||
{renderTag(
|
||||
item.type === 'movie' ? '电影' : '剧集',
|
||||
'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
)}
|
||||
{yearText &&
|
||||
renderTag(
|
||||
yearText,
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||
)}
|
||||
{item.episodes &&
|
||||
item.episodes > 0 &&
|
||||
renderTag(
|
||||
`${item.episodes}集`,
|
||||
'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
)}
|
||||
{item.vodRemarks &&
|
||||
renderTag(
|
||||
item.vodRemarks,
|
||||
'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
)}
|
||||
{item.doubanId &&
|
||||
item.doubanId > 0 &&
|
||||
renderTag(
|
||||
'豆瓣',
|
||||
'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className='mt-3 line-clamp-3 text-sm leading-6 text-gray-600 dark:text-gray-400'>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sourceTags.length > 0 && (
|
||||
<div
|
||||
className={`mt-3 flex gap-2 ${
|
||||
isExpanded ? 'flex-wrap' : 'flex-nowrap overflow-hidden'
|
||||
}`}
|
||||
>
|
||||
{visibleSourceTags.map((sourceName) => (
|
||||
<span
|
||||
key={`${item.key}-${sourceName}`}
|
||||
className='inline-flex max-w-full shrink-0 items-center truncate rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||
title={sourceName}
|
||||
>
|
||||
{sourceName}
|
||||
</span>
|
||||
))}
|
||||
{hiddenSourceCount > 0 && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedSourceTags((prev) => ({
|
||||
...prev,
|
||||
[item.key]: true,
|
||||
}));
|
||||
}}
|
||||
className='inline-flex shrink-0 items-center rounded-full border border-green-200 bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 transition-colors hover:bg-green-100 dark:border-green-800 dark:bg-green-900/30 dark:text-green-300 dark:hover:bg-green-900/50'
|
||||
aria-label={`展开剩余${hiddenSourceCount}个来源`}
|
||||
>
|
||||
+{hiddenSourceCount}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// 监听选项卡切换,自动执行搜索
|
||||
useEffect(() => {
|
||||
// 如果切换到网盘搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
|
||||
if (activeTab === 'pansou' && searchQuery.trim() && showResults) {
|
||||
setTriggerPansouSearch(prev => !prev);
|
||||
setTriggerPansouSearch((prev) => !prev);
|
||||
}
|
||||
// 如果切换到 ACG 磁力搜索选项卡,且有搜索关键词,且已显示结果,则触发搜索
|
||||
if (activeTab === 'acg' && searchQuery.trim() && showResults) {
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
setTriggerAcgSearch((prev) => !prev);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
@@ -552,9 +955,9 @@ function SearchPageClient() {
|
||||
// 延迟触发搜索,确保组件已经切换到正确的标签页
|
||||
setTimeout(() => {
|
||||
if (typeParam === 'pansou') {
|
||||
setTriggerPansouSearch(prev => !prev);
|
||||
setTriggerPansouSearch((prev) => !prev);
|
||||
} else if (typeParam === 'acg') {
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
setTriggerAcgSearch((prev) => !prev);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
@@ -574,20 +977,22 @@ function SearchPageClient() {
|
||||
|
||||
// 初始化繁体转简体转换器
|
||||
if (typeof window !== 'undefined') {
|
||||
import('opencc-js').then((module) => {
|
||||
try {
|
||||
const OpenCC = module.default || module;
|
||||
const converter = OpenCC.Converter({ from: 'hk', to: 'cn' });
|
||||
converterRef.current = converter;
|
||||
setConverterReady(true);
|
||||
} catch (error) {
|
||||
console.error('初始化繁体转简体转换器失败:', error);
|
||||
import('opencc-js')
|
||||
.then((module) => {
|
||||
try {
|
||||
const OpenCC = module.default || module;
|
||||
const converter = OpenCC.Converter({ from: 'hk', to: 'cn' });
|
||||
converterRef.current = converter;
|
||||
setConverterReady(true);
|
||||
} catch (error) {
|
||||
console.error('初始化繁体转简体转换器失败:', error);
|
||||
setConverterReady(true); // 即使失败也设置为 true,避免阻塞
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('加载 opencc-js 失败:', error);
|
||||
setConverterReady(true); // 即使失败也设置为 true,避免阻塞
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('加载 opencc-js 失败:', error);
|
||||
setConverterReady(true); // 即使失败也设置为 true,避免阻塞
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setConverterReady(true);
|
||||
}
|
||||
@@ -670,7 +1075,9 @@ function SearchPageClient() {
|
||||
|
||||
// 如果开启了繁体转简体,进行转换
|
||||
if (query && typeof window !== 'undefined') {
|
||||
const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
|
||||
const searchTraditionalToSimplified = localStorage.getItem(
|
||||
'searchTraditionalToSimplified'
|
||||
);
|
||||
|
||||
if (searchTraditionalToSimplified === 'true' && converterRef.current) {
|
||||
try {
|
||||
@@ -681,7 +1088,13 @@ function SearchPageClient() {
|
||||
if (originalQuery !== query) {
|
||||
const trimmedConverted = query.trim();
|
||||
// 使用 replace 而不是 push,避免在历史记录中留下繁体版本
|
||||
router.replace(`/search?q=${encodeURIComponent(trimmedConverted)}${searchParams.get('type') ? `&type=${searchParams.get('type')}` : ''}`);
|
||||
router.replace(
|
||||
`/search?q=${encodeURIComponent(trimmedConverted)}${
|
||||
searchParams.get('type')
|
||||
? `&type=${searchParams.get('type')}`
|
||||
: ''
|
||||
}`
|
||||
);
|
||||
return; // 等待 URL 更新后重新触发此 effect
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -696,7 +1109,7 @@ function SearchPageClient() {
|
||||
setSearchQuery(query);
|
||||
|
||||
const trimmed = query.trim();
|
||||
|
||||
|
||||
// 检查是否有缓存且不是强制刷新
|
||||
if (!forceRefresh) {
|
||||
const cachedResults = getCachedResults(trimmed);
|
||||
@@ -714,21 +1127,26 @@ function SearchPageClient() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果是强制刷新,清除缓存
|
||||
if (forceRefresh) {
|
||||
clearCachedResults(trimmed);
|
||||
setForceRefresh(false);
|
||||
}
|
||||
|
||||
|
||||
// 开始新搜索时,重置缓存标记
|
||||
setIsFromCache(false);
|
||||
|
||||
|
||||
// 新搜索:关闭旧连接并清空结果
|
||||
if (eventSourceRef.current) {
|
||||
try { eventSourceRef.current.close(); } catch { }
|
||||
try {
|
||||
eventSourceRef.current.close();
|
||||
} catch {}
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
// 先设置加载状态,再清空结果,避免短暂显示"暂无搜索结果"
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
setSearchResults([]);
|
||||
setTotalSources(0);
|
||||
setCompletedSources(0);
|
||||
@@ -738,8 +1156,6 @@ function SearchPageClient() {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
|
||||
// 每次搜索时重新读取设置,确保使用最新的配置
|
||||
let currentFluidSearch = useFluidSearch;
|
||||
@@ -748,7 +1164,8 @@ function SearchPageClient() {
|
||||
if (savedFluidSearch !== null) {
|
||||
currentFluidSearch = JSON.parse(savedFluidSearch);
|
||||
} else {
|
||||
const defaultFluidSearch = (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
const defaultFluidSearch =
|
||||
(window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
currentFluidSearch = defaultFluidSearch;
|
||||
}
|
||||
}
|
||||
@@ -760,7 +1177,9 @@ function SearchPageClient() {
|
||||
|
||||
if (currentFluidSearch) {
|
||||
// 流式搜索:打开新的流式连接
|
||||
const es = new EventSource(`/api/search/ws?q=${encodeURIComponent(trimmed)}`);
|
||||
const es = new EventSource(
|
||||
`/api/search/ws?q=${encodeURIComponent(trimmed)}`
|
||||
);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
@@ -775,9 +1194,15 @@ function SearchPageClient() {
|
||||
break;
|
||||
case 'source_result': {
|
||||
setCompletedSources((prev) => prev + 1);
|
||||
if (Array.isArray(payload.results) && payload.results.length > 0) {
|
||||
if (
|
||||
Array.isArray(payload.results) &&
|
||||
payload.results.length > 0
|
||||
) {
|
||||
// 缓冲新增结果,节流刷入,避免频繁重渲染导致闪烁
|
||||
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
|
||||
const activeYearOrder =
|
||||
viewMode === 'agg'
|
||||
? filterAgg.yearOrder
|
||||
: filterAll.yearOrder;
|
||||
const incoming: SearchResult[] =
|
||||
activeYearOrder === 'none'
|
||||
? sortBatchForNoOrder(payload.results as SearchResult[])
|
||||
@@ -825,13 +1250,15 @@ function SearchPageClient() {
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
try { es.close(); } catch { }
|
||||
try {
|
||||
es.close();
|
||||
} catch {}
|
||||
if (eventSourceRef.current === es) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
@@ -848,7 +1275,9 @@ function SearchPageClient() {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
}
|
||||
try { es.close(); } catch { }
|
||||
try {
|
||||
es.close();
|
||||
} catch {}
|
||||
if (eventSourceRef.current === es) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
@@ -856,12 +1285,13 @@ function SearchPageClient() {
|
||||
} else {
|
||||
// 传统搜索:使用普通接口
|
||||
fetch(`/api/search?q=${encodeURIComponent(trimmed)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (currentQueryRef.current !== trimmed) return;
|
||||
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
|
||||
const activeYearOrder =
|
||||
viewMode === 'agg' ? filterAgg.yearOrder : filterAll.yearOrder;
|
||||
const results: SearchResult[] =
|
||||
activeYearOrder === 'none'
|
||||
? sortBatchForNoOrder(data.results as SearchResult[])
|
||||
@@ -893,7 +1323,9 @@ function SearchPageClient() {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
try { eventSourceRef.current.close(); } catch { }
|
||||
try {
|
||||
eventSourceRef.current.close();
|
||||
} catch {}
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (flushTimerRef.current) {
|
||||
@@ -931,7 +1363,9 @@ function SearchPageClient() {
|
||||
|
||||
// 如果开启了繁体转简体,进行转换
|
||||
if (typeof window !== 'undefined') {
|
||||
const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
|
||||
const searchTraditionalToSimplified = localStorage.getItem(
|
||||
'searchTraditionalToSimplified'
|
||||
);
|
||||
if (searchTraditionalToSimplified === 'true' && converterRef.current) {
|
||||
try {
|
||||
trimmed = converterRef.current(trimmed);
|
||||
@@ -945,6 +1379,8 @@ function SearchPageClient() {
|
||||
setSearchQuery(trimmed);
|
||||
setShowResults(true);
|
||||
setShowSuggestions(false);
|
||||
// 立即设置加载状态,避免显示"未找到相关结果"
|
||||
setIsLoading(true);
|
||||
|
||||
// 根据当前选项卡执行不同的搜索
|
||||
if (activeTab === 'video') {
|
||||
@@ -954,11 +1390,11 @@ function SearchPageClient() {
|
||||
} else if (activeTab === 'pansou') {
|
||||
// 网盘搜索 - 触发搜索
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}&type=pansou`);
|
||||
setTriggerPansouSearch(prev => !prev); // 切换状态来触发搜索
|
||||
setTriggerPansouSearch((prev) => !prev); // 切换状态来触发搜索
|
||||
} else if (activeTab === 'acg') {
|
||||
// ACG 磁力搜索 - 触发搜索
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}&type=acg`);
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
setTriggerAcgSearch((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -967,7 +1403,9 @@ function SearchPageClient() {
|
||||
|
||||
// 如果开启了繁体转简体,进行转换
|
||||
if (typeof window !== 'undefined') {
|
||||
const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
|
||||
const searchTraditionalToSimplified = localStorage.getItem(
|
||||
'searchTraditionalToSimplified'
|
||||
);
|
||||
if (searchTraditionalToSimplified === 'true' && converterRef.current) {
|
||||
try {
|
||||
processedSuggestion = converterRef.current(suggestion);
|
||||
@@ -982,20 +1420,28 @@ function SearchPageClient() {
|
||||
|
||||
// 自动执行搜索
|
||||
setShowResults(true);
|
||||
// 立即设置加载状态,避免显示"未找到相关结果"
|
||||
setIsLoading(true);
|
||||
|
||||
// 根据当前选项卡执行不同的搜索
|
||||
if (activeTab === 'video') {
|
||||
// 影视搜索
|
||||
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=video`);
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(processedSuggestion)}&type=video`
|
||||
);
|
||||
// 其余由 searchParams 变化的 effect 处理
|
||||
} else if (activeTab === 'pansou') {
|
||||
// 网盘搜索 - 触发搜索
|
||||
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=pansou`);
|
||||
setTriggerPansouSearch(prev => !prev);
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(processedSuggestion)}&type=pansou`
|
||||
);
|
||||
setTriggerPansouSearch((prev) => !prev);
|
||||
} else if (activeTab === 'acg') {
|
||||
// ACG 磁力搜索 - 触发搜索
|
||||
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=acg`);
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(processedSuggestion)}&type=acg`
|
||||
);
|
||||
setTriggerAcgSearch((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1020,7 +1466,9 @@ function SearchPageClient() {
|
||||
// 如果有搜索关键词,更新 URL
|
||||
const currentQuery = searchParams.get('q');
|
||||
if (currentQuery) {
|
||||
router.push(`/search?q=${encodeURIComponent(currentQuery)}&type=${newTab}`);
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(currentQuery)}&type=${newTab}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1039,7 +1487,7 @@ function SearchPageClient() {
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
placeholder='搜索电影、电视剧...'
|
||||
autoComplete="off"
|
||||
autoComplete='off'
|
||||
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-12 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500 dark:focus:bg-gray-700 dark:border-gray-700'
|
||||
/>
|
||||
|
||||
@@ -1107,7 +1555,9 @@ function SearchPageClient() {
|
||||
: []),
|
||||
]}
|
||||
active={activeTab}
|
||||
onChange={(value) => handleTabChange(value as 'video' | 'pansou' | 'acg')}
|
||||
onChange={(value) =>
|
||||
handleTabChange(value as 'video' | 'pansou' | 'acg')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1121,178 +1571,284 @@ function SearchPageClient() {
|
||||
{/* 影视搜索结果 */}
|
||||
{/* 标题 */}
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
搜索结果
|
||||
{isFromCache ? (
|
||||
<span className='ml-2 px-2 py-0.5 text-xs font-medium text-green-600 bg-green-50 rounded-md dark:text-green-400 dark:bg-green-900/30'>
|
||||
缓存
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{totalSources > 0 && useFluidSearch && (
|
||||
<span className='ml-2 text-sm font-normal text-gray-500 dark:text-gray-400'>
|
||||
{completedSources}/{totalSources}
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
搜索结果
|
||||
{isFromCache ? (
|
||||
<span className='ml-2 rounded-md bg-green-50 px-2 py-0.5 text-xs font-medium text-green-600 dark:bg-green-900/30 dark:text-green-400'>
|
||||
缓存
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{totalSources > 0 && useFluidSearch && (
|
||||
<span className='ml-2 text-sm font-normal text-gray-500 dark:text-gray-400'>
|
||||
{completedSources}/{totalSources}
|
||||
</span>
|
||||
)}
|
||||
{isLoading && useFluidSearch && (
|
||||
<span className='ml-2 inline-block align-middle'>
|
||||
<span className='inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-300 border-t-green-500'></span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isLoading && useFluidSearch && (
|
||||
<span className='ml-2 inline-block align-middle'>
|
||||
<span className='inline-block h-3 w-3 border-2 border-gray-300 border-t-green-500 rounded-full animate-spin'></span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
{/* 强制刷新按钮 */}
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setForceRefresh(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className='flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-400 dark:hover:text-green-400 dark:hover:bg-gray-700/50'
|
||||
aria-label='强制刷新搜索结果'
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 筛选器 + 聚合开关 同行 */}
|
||||
<div className='mb-8 flex items-center justify-between gap-3'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
{viewMode === 'agg' ? (
|
||||
<SearchResultFilter
|
||||
categories={filterOptions.categoriesAgg}
|
||||
values={filterAgg}
|
||||
onChange={(v) => setFilterAgg(v as any)}
|
||||
/>
|
||||
) : (
|
||||
<SearchResultFilter
|
||||
categories={filterOptions.categoriesAll}
|
||||
values={filterAll}
|
||||
onChange={(v) => setFilterAll(v as any)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 聚合开关 */}
|
||||
<label className='flex items-center gap-2 cursor-pointer select-none shrink-0'>
|
||||
<span className='text-xs sm:text-sm text-gray-700 dark:text-gray-300'>聚合</span>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={viewMode === 'agg'}
|
||||
onChange={() => setViewMode(viewMode === 'agg' ? 'all' : 'agg')}
|
||||
/>
|
||||
<div className='w-9 h-5 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{searchResults.length === 0 ? (
|
||||
isLoading ? (
|
||||
<div className='flex justify-center items-center h-40'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center text-gray-500 py-8 dark:text-gray-400'>
|
||||
未找到相关结果
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
(() => {
|
||||
const gridClassName =
|
||||
'justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8';
|
||||
|
||||
const gridChildren =
|
||||
viewMode === 'agg'
|
||||
? filteredAggResults.map(([mapKey, group]) => {
|
||||
const title = group[0]?.title || '';
|
||||
const poster = group[0]?.poster || '';
|
||||
const year = group[0]?.year || 'unknown';
|
||||
const { episodes, source_names, douban_id } = computeGroupStats(group);
|
||||
|
||||
// 从 mapKey 中提取类型(mapKey 格式:normalizedTitle-type-year)
|
||||
// 找到最后一个 '-' 之前的部分,再找倒数第二个 '-'
|
||||
const lastDashIndex = mapKey.lastIndexOf('-');
|
||||
const secondLastDashIndex = mapKey.lastIndexOf('-', lastDashIndex - 1);
|
||||
const type = secondLastDashIndex > 0
|
||||
? mapKey.substring(secondLastDashIndex + 1, lastDashIndex) as 'movie' | 'tv'
|
||||
: (episodes === 1 ? 'movie' : 'tv'); // 兜底
|
||||
|
||||
// 如果该聚合第一次出现,写入初始统计
|
||||
if (!groupStatsRef.current.has(mapKey)) {
|
||||
groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`agg-${mapKey}`} className='w-full'>
|
||||
<VideoCard
|
||||
ref={getGroupRef(mapKey)}
|
||||
from='search'
|
||||
isAggregate={true}
|
||||
title={title}
|
||||
poster={poster}
|
||||
year={year}
|
||||
episodes={episodes}
|
||||
source_names={source_names}
|
||||
douban_id={douban_id}
|
||||
query={
|
||||
searchQuery.trim() !== title
|
||||
? searchQuery.trim()
|
||||
: ''
|
||||
}
|
||||
type={type}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: filteredAllResults.map((item) => (
|
||||
<div
|
||||
key={`all-${item.source}-${item.id}`}
|
||||
className='w-full'
|
||||
>
|
||||
<VideoCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
episodes={item.episodes.length}
|
||||
source={item.source}
|
||||
source_name={item.source_name}
|
||||
douban_id={item.douban_id}
|
||||
query={
|
||||
searchQuery.trim() !== item.title
|
||||
? searchQuery.trim()
|
||||
: ''
|
||||
}
|
||||
year={item.year}
|
||||
from='search'
|
||||
type={item.episodes.length > 1 ? 'tv' : 'movie'}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
if (useVirtualGrid) {
|
||||
return (
|
||||
<VirtualScrollableGrid
|
||||
key={`search-results-virtual-${viewMode}`}
|
||||
gridClassName={gridClassName}
|
||||
</h2>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setForceRefresh(true);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className='flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-gray-600 transition-colors hover:bg-green-50 hover:text-green-600 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-700/50 dark:hover:text-green-400'
|
||||
aria-label='强制刷新搜索结果'
|
||||
>
|
||||
{gridChildren}
|
||||
</VirtualScrollableGrid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`search-results-${viewMode}`}
|
||||
className={gridClassName}
|
||||
>
|
||||
{gridChildren}
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${
|
||||
isLoading ? 'animate-spin' : ''
|
||||
}`}
|
||||
/>
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className='mb-4 flex items-center gap-3'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
{viewMode === 'agg' ? (
|
||||
<SearchResultFilter
|
||||
categories={filterOptions.categoriesAgg}
|
||||
values={filterAgg}
|
||||
onChange={(v) => setFilterAgg(v as any)}
|
||||
/>
|
||||
) : (
|
||||
<SearchResultFilter
|
||||
categories={filterOptions.categoriesAll}
|
||||
values={filterAll}
|
||||
onChange={(v) => setFilterAll(v as any)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
<div className='flex shrink-0 items-center justify-end self-center'>
|
||||
<label className='flex shrink-0 cursor-pointer select-none items-center gap-2'>
|
||||
<span className='text-xs text-gray-700 dark:text-gray-300 sm:text-sm'>
|
||||
聚合
|
||||
</span>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='peer sr-only'
|
||||
checked={viewMode === 'agg'}
|
||||
onChange={() =>
|
||||
setViewMode(viewMode === 'agg' ? 'all' : 'agg')
|
||||
}
|
||||
/>
|
||||
<div className='h-5 w-9 rounded-full bg-gray-300 transition-colors peer-checked:bg-green-500 dark:bg-gray-600'></div>
|
||||
<div className='absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition-transform peer-checked:translate-x-4'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mb-8 flex justify-center'>
|
||||
<div className='inline-flex items-center rounded-xl border border-gray-200 bg-white p-1 shadow-sm dark:border-gray-700 dark:bg-gray-900'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setResultDisplayMode('card')}
|
||||
className={`inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
||||
resultDisplayMode === 'card'
|
||||
? 'bg-green-500 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
aria-label='切换为卡片视图'
|
||||
>
|
||||
<Grid2x2 className='h-4 w-4' />
|
||||
<span>卡片</span>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setResultDisplayMode('list')}
|
||||
className={`inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
||||
resultDisplayMode === 'list'
|
||||
? 'bg-green-500 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
aria-label='切换为列表视图'
|
||||
>
|
||||
<List className='h-4 w-4' />
|
||||
<span>列表</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{searchResults.length === 0 ? (
|
||||
isLoading ? (
|
||||
<div className='flex justify-center items-center h-40'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center text-gray-500 py-8 dark:text-gray-400'>
|
||||
未找到相关结果
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
(() => {
|
||||
const gridClassName =
|
||||
'justify-start grid grid-cols-3 gap-x-2 gap-y-14 px-0 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8 sm:gap-y-20 sm:px-2';
|
||||
|
||||
const listClassName = 'space-y-4';
|
||||
|
||||
const resultChildren =
|
||||
viewMode === 'agg'
|
||||
? filteredAggResults.map(([mapKey, group]) => {
|
||||
const title = group[0]?.title || '';
|
||||
const poster = group[0]?.poster || '';
|
||||
const year = group[0]?.year || 'unknown';
|
||||
const desc =
|
||||
group.find((entry) => entry.desc?.trim())
|
||||
?.desc || '';
|
||||
const vodRemarks =
|
||||
group.find((entry) => entry.vod_remarks?.trim())
|
||||
?.vod_remarks || '';
|
||||
const { episodes, source_names, douban_id } =
|
||||
computeGroupStats(group);
|
||||
|
||||
const lastDashIndex = mapKey.lastIndexOf('-');
|
||||
const secondLastDashIndex = mapKey.lastIndexOf(
|
||||
'-',
|
||||
lastDashIndex - 1
|
||||
);
|
||||
const type =
|
||||
secondLastDashIndex > 0
|
||||
? (mapKey.substring(
|
||||
secondLastDashIndex + 1,
|
||||
lastDashIndex
|
||||
) as 'movie' | 'tv')
|
||||
: episodes === 1
|
||||
? 'movie'
|
||||
: 'tv';
|
||||
|
||||
if (!groupStatsRef.current.has(mapKey)) {
|
||||
groupStatsRef.current.set(mapKey, {
|
||||
episodes,
|
||||
source_names,
|
||||
douban_id,
|
||||
});
|
||||
}
|
||||
|
||||
if (resultDisplayMode === 'list') {
|
||||
return renderListItem({
|
||||
key: `agg-${mapKey}`,
|
||||
title,
|
||||
poster,
|
||||
year,
|
||||
type,
|
||||
episodes,
|
||||
sourceNames: source_names,
|
||||
doubanId: douban_id,
|
||||
desc,
|
||||
vodRemarks,
|
||||
isAggregate: true,
|
||||
query:
|
||||
searchQuery.trim() !== title
|
||||
? searchQuery.trim()
|
||||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`agg-${mapKey}`} className='w-full'>
|
||||
<VideoCard
|
||||
ref={getGroupRef(mapKey)}
|
||||
from='search'
|
||||
isAggregate={true}
|
||||
title={title}
|
||||
poster={poster}
|
||||
year={year}
|
||||
episodes={episodes}
|
||||
source_names={source_names}
|
||||
douban_id={douban_id}
|
||||
query={
|
||||
searchQuery.trim() !== title
|
||||
? searchQuery.trim()
|
||||
: ''
|
||||
}
|
||||
type={type}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: filteredAllResults.map((item) => {
|
||||
const type =
|
||||
item.episodes.length > 1 ? 'tv' : 'movie';
|
||||
|
||||
if (resultDisplayMode === 'list') {
|
||||
return renderListItem({
|
||||
key: `all-${item.source}-${item.id}`,
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.poster,
|
||||
episodes: item.episodes.length,
|
||||
source: item.source,
|
||||
sourceName: item.source_name,
|
||||
doubanId: item.douban_id,
|
||||
query:
|
||||
searchQuery.trim() !== item.title
|
||||
? searchQuery.trim()
|
||||
: '',
|
||||
year: item.year,
|
||||
type,
|
||||
desc: item.desc,
|
||||
vodRemarks: item.vod_remarks,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`all-${item.source}-${item.id}`}
|
||||
className='w-full'
|
||||
>
|
||||
<VideoCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
episodes={item.episodes.length}
|
||||
source={item.source}
|
||||
source_name={item.source_name}
|
||||
douban_id={item.douban_id}
|
||||
query={
|
||||
searchQuery.trim() !== item.title
|
||||
? searchQuery.trim()
|
||||
: ''
|
||||
}
|
||||
year={item.year}
|
||||
from='search'
|
||||
type={type}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
if (useVirtualGrid) {
|
||||
return (
|
||||
<VirtualScrollableGrid
|
||||
key={`search-results-virtual-${viewMode}`}
|
||||
gridClassName={gridClassName}
|
||||
>
|
||||
{resultChildren}
|
||||
</VirtualScrollableGrid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`search-results-${viewMode}-${resultDisplayMode}`}
|
||||
className={
|
||||
resultDisplayMode === 'list'
|
||||
? listClassName
|
||||
: gridClassName
|
||||
}
|
||||
>
|
||||
{resultChildren}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</>
|
||||
) : activeTab === 'pansou' ? (
|
||||
<>
|
||||
@@ -1345,25 +1901,33 @@ function SearchPageClient() {
|
||||
onClick={() => {
|
||||
setSearchQuery(item);
|
||||
setShowResults(true);
|
||||
// 立即设置加载状态,避免显示"未找到相关结果"
|
||||
setIsLoading(true);
|
||||
|
||||
// 根据当前选项卡执行不同的搜索
|
||||
if (activeTab === 'video') {
|
||||
// 影视搜索
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}&type=video`
|
||||
`/search?q=${encodeURIComponent(
|
||||
item.trim()
|
||||
)}&type=video`
|
||||
);
|
||||
} else if (activeTab === 'pansou') {
|
||||
// 网盘搜索
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}&type=pansou`
|
||||
`/search?q=${encodeURIComponent(
|
||||
item.trim()
|
||||
)}&type=pansou`
|
||||
);
|
||||
setTriggerPansouSearch(prev => !prev);
|
||||
setTriggerPansouSearch((prev) => !prev);
|
||||
} else if (activeTab === 'acg') {
|
||||
// ACG 磁力搜索
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}&type=acg`
|
||||
`/search?q=${encodeURIComponent(
|
||||
item.trim()
|
||||
)}&type=acg`
|
||||
);
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
setTriggerAcgSearch((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300'
|
||||
@@ -1390,13 +1954,23 @@ function SearchPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewImage && (
|
||||
<ImageViewer
|
||||
isOpen={!!previewImage}
|
||||
onClose={() => setPreviewImage(null)}
|
||||
imageUrl={previewImage.url}
|
||||
alt={previewImage.alt}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 返回顶部悬浮按钮 */}
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-20 md:bottom-6 right-6 z-[500] w-12 h-12 bg-green-500/90 hover:bg-green-500 text-white rounded-full shadow-lg backdrop-blur-sm transition-all duration-300 ease-in-out flex items-center justify-center group ${showBackToTop
|
||||
? 'opacity-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 translate-y-4 pointer-events-none'
|
||||
}`}
|
||||
className={`fixed bottom-20 md:bottom-6 right-6 z-[500] w-12 h-12 bg-green-500/90 hover:bg-green-500 text-white rounded-full shadow-lg backdrop-blur-sm transition-all duration-300 ease-in-out flex items-center justify-center group ${
|
||||
showBackToTop
|
||||
? 'opacity-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 translate-y-4 pointer-events-none'
|
||||
}`}
|
||||
aria-label='返回顶部'
|
||||
>
|
||||
<ChevronUp className='w-6 h-6 transition-transform group-hover:scale-110' />
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
interface AIComment {
|
||||
id: string;
|
||||
userName: string;
|
||||
userAvatar: string;
|
||||
rating: number | null;
|
||||
content: string;
|
||||
time: string;
|
||||
votes: number;
|
||||
isAiGenerated: true;
|
||||
}
|
||||
|
||||
interface AICommentsProps {
|
||||
movieName: string;
|
||||
movieInfo?: string;
|
||||
}
|
||||
|
||||
export default function AIComments({ movieName, movieInfo }: AICommentsProps) {
|
||||
const [comments, setComments] = useState<AIComment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasStartedLoading, setHasStartedLoading] = useState(false);
|
||||
|
||||
const fetchComments = useCallback(async () => {
|
||||
try {
|
||||
console.log('正在生成AI评论...');
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
name: movieName,
|
||||
count: '10',
|
||||
_t: Date.now().toString(), // 添加时间戳防止缓存
|
||||
});
|
||||
|
||||
if (movieInfo) {
|
||||
params.append('info', movieInfo);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/ai-comments?${params.toString()}`, {
|
||||
cache: 'no-store', // 禁用缓存
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || '生成AI评论失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('AI评论生成成功:', data.comments.length);
|
||||
|
||||
setComments(data.comments);
|
||||
} catch (err) {
|
||||
console.error('生成AI评论失败:', err);
|
||||
setError(err instanceof Error ? err.message : '生成AI评论失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [movieName, movieInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
// 重置状态当 movieName 变化时
|
||||
setHasStartedLoading(false);
|
||||
setComments([]);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
}, [movieName]);
|
||||
|
||||
const startLoading = () => {
|
||||
console.log('开始生成AI评论');
|
||||
setHasStartedLoading(true);
|
||||
fetchComments();
|
||||
};
|
||||
|
||||
const regenerate = () => {
|
||||
console.log('重新生成AI评论');
|
||||
fetchComments();
|
||||
};
|
||||
|
||||
// 星级渲染
|
||||
const renderStars = (rating: number | null) => {
|
||||
if (rating === null) return null;
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-0.5'>
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg
|
||||
key={star}
|
||||
className='w-4 h-4'
|
||||
fill={star <= rating ? '#3b82f6' : '#e0e0e0'}
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path d='M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z' />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 初始状态:显示生成按钮
|
||||
if (!hasStartedLoading) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center py-12'>
|
||||
<div className='text-gray-500 dark:text-gray-400 mb-4'>
|
||||
<svg
|
||||
className='w-16 h-16 mx-auto mb-4 opacity-50'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={1.5}
|
||||
d='M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z'
|
||||
/>
|
||||
</svg>
|
||||
<p className='text-center'>点击生成AI评论</p>
|
||||
<p className='text-xs text-center mt-2 text-gray-400'>
|
||||
基于影片信息和网络资料生成
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={startLoading}
|
||||
className='px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2'
|
||||
>
|
||||
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M13 10V3L4 14h7v7l9-11h-7z'
|
||||
/>
|
||||
</svg>
|
||||
生成AI评论
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && comments.length === 0) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center py-12'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mb-3'></div>
|
||||
<span className='text-gray-600 dark:text-gray-400'>AI正在生成评论...</span>
|
||||
<span className='text-xs text-gray-500 dark:text-gray-500 mt-2'>
|
||||
这可能需要几秒钟
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && comments.length === 0) {
|
||||
return (
|
||||
<div className='text-center py-12'>
|
||||
<div className='text-red-500 mb-2'>❌</div>
|
||||
<p className='text-gray-600 dark:text-gray-400 mb-1'>{error}</p>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-500 mb-4'>
|
||||
请检查管理面板的AI配置是否正确
|
||||
</p>
|
||||
<button
|
||||
onClick={startLoading}
|
||||
className='px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors'
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{/* 头部统计和操作 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
已生成 {comments.length} 条AI评论
|
||||
</div>
|
||||
<button
|
||||
onClick={regenerate}
|
||||
disabled={loading}
|
||||
className='text-sm px-3 py-1 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1'
|
||||
>
|
||||
<svg
|
||||
className='w-4 h-4'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15'
|
||||
/>
|
||||
</svg>
|
||||
{loading ? '生成中...' : '重新生成'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 评论列表 */}
|
||||
<div className='space-y-4'>
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className='bg-blue-50/50 dark:bg-blue-900/10 rounded-lg p-4 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors border border-blue-100 dark:border-blue-900/30'
|
||||
>
|
||||
{/* 用户信息 */}
|
||||
<div className='flex items-start gap-3 mb-3'>
|
||||
{/* 头像 */}
|
||||
<div className='flex-shrink-0'>
|
||||
<img
|
||||
src={comment.userAvatar}
|
||||
alt={comment.userName}
|
||||
className='w-10 h-10 rounded-full'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 用户名和评分 */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
<span className='font-medium text-gray-900 dark:text-white'>
|
||||
{comment.userName}
|
||||
</span>
|
||||
{renderStars(comment.rating)}
|
||||
{/* AI标识 */}
|
||||
<span className='inline-flex items-center gap-1 px-2 py-0.5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-xs rounded-full'>
|
||||
<svg className='w-3 h-3' fill='currentColor' viewBox='0 0 24 24'>
|
||||
<path d='M13 10V3L4 14h7v7l9-11h-7z' />
|
||||
</svg>
|
||||
AI生成
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 时间 */}
|
||||
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
{comment.time}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 有用数 */}
|
||||
{comment.votes > 0 && (
|
||||
<div className='flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
<svg
|
||||
className='w-4 h-4'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5'
|
||||
/>
|
||||
</svg>
|
||||
<span>{comment.votes}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 评论内容 */}
|
||||
<div className='text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap'>
|
||||
{comment.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className='text-center text-xs text-gray-500 dark:text-gray-400 py-2 border-t border-gray-200 dark:border-gray-700'>
|
||||
以上评论由AI基于影片信息和网络资料生成,仅供参考
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+211
-198
@@ -1,17 +1,19 @@
|
||||
/* eslint-disable no-console */
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { AlertTriangle, ChevronRight } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { PlayRecord } from '@/lib/db.client';
|
||||
import {
|
||||
clearAllPlayRecords,
|
||||
getCachedPlayRecordsSnapshot,
|
||||
getAllPlayRecords,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
|
||||
import PlayRecordsPanel from '@/components/PlayRecordsPanel';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
import VirtualScrollableRow from '@/components/VirtualScrollableRow';
|
||||
|
||||
@@ -19,47 +21,58 @@ interface ContinueWatchingProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type PlayRecordItem = PlayRecord & { key: string };
|
||||
|
||||
export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||
const [playRecords, setPlayRecords] = useState<
|
||||
(PlayRecord & { key: string })[]
|
||||
>([]);
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
const cachedDisplayLimit = storageType !== 'localstorage' ? 10 : undefined;
|
||||
const [playRecords, setPlayRecords] = useState<PlayRecordItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [showPlayRecordsPanel, setShowPlayRecordsPanel] = useState(false);
|
||||
|
||||
// 处理播放记录数据更新的函数
|
||||
const updatePlayRecords = (allRecords: Record<string, PlayRecord>, limit?: number) => {
|
||||
// 将记录转换为数组并根据 save_time 由近到远排序
|
||||
const updatePlayRecords = (
|
||||
allRecords: Record<string, PlayRecord>,
|
||||
limit?: number
|
||||
) => {
|
||||
const recordsArray = Object.entries(allRecords).map(([key, record]) => ({
|
||||
...record,
|
||||
key,
|
||||
}));
|
||||
|
||||
// 按 save_time 降序排序(最新的在前面)
|
||||
const sortedRecords = recordsArray.sort(
|
||||
(a, b) => b.save_time - a.save_time
|
||||
);
|
||||
const sortedRecords = recordsArray.sort((a, b) => b.save_time - a.save_time);
|
||||
setPlayRecords(limit ? sortedRecords.slice(0, limit) : sortedRecords);
|
||||
};
|
||||
|
||||
// 如果指定了 limit,只取前 N 条
|
||||
const finalRecords = limit ? sortedRecords.slice(0, limit) : sortedRecords;
|
||||
const applyCachedSnapshot = () => {
|
||||
const cachedRecords = getCachedPlayRecordsSnapshot();
|
||||
if (Object.keys(cachedRecords).length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setPlayRecords(finalRecords);
|
||||
updatePlayRecords(cachedRecords, cachedDisplayLimit);
|
||||
setLoading(false);
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'playRecordsUpdated',
|
||||
(newRecords: Record<string, PlayRecord>) => {
|
||||
updatePlayRecords(newRecords);
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
|
||||
const fetchPlayRecords = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 从缓存或API获取所有播放记录
|
||||
const allRecords = await getAllPlayRecords();
|
||||
|
||||
// 非 localStorage 模式下,先只显示前 10 条记录
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType !== 'localstorage') {
|
||||
updatePlayRecords(allRecords, 10);
|
||||
} else {
|
||||
updatePlayRecords(allRecords);
|
||||
const hasCachedSnapshot = applyCachedSnapshot();
|
||||
if (!hasCachedSnapshot) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const allRecords = await getAllPlayRecords();
|
||||
updatePlayRecords(allRecords);
|
||||
} catch (error) {
|
||||
console.error('获取播放记录失败:', error);
|
||||
setPlayRecords([]);
|
||||
@@ -69,37 +82,23 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||
};
|
||||
|
||||
fetchPlayRecords();
|
||||
|
||||
// 监听播放记录更新事件
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'playRecordsUpdated',
|
||||
(newRecords: Record<string, PlayRecord>) => {
|
||||
// 同步完成后,加载完整数据(不限制数量)
|
||||
updatePlayRecords(newRecords);
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
}, [cachedDisplayLimit]);
|
||||
|
||||
// 如果没有播放记录,则不渲染组件
|
||||
if (!loading && playRecords.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算播放进度百分比
|
||||
const getProgress = (record: PlayRecord) => {
|
||||
if (record.total_time === 0) return 0;
|
||||
return (record.play_time / record.total_time) * 100;
|
||||
};
|
||||
|
||||
// 从 key 中解析 source 和 id
|
||||
const parseKey = (key: string) => {
|
||||
const [source, id] = key.split('+');
|
||||
return { source, id };
|
||||
};
|
||||
|
||||
// 处理清空确认
|
||||
const handleClearConfirm = async () => {
|
||||
await clearAllPlayRecords();
|
||||
setPlayRecords([]);
|
||||
@@ -114,173 +113,187 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||
继续观看
|
||||
</h2>
|
||||
{!loading && playRecords.length > 0 && (
|
||||
<button
|
||||
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
onClick={() => setShowConfirmDialog(true)}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<div className='flex items-center gap-1'>
|
||||
<button
|
||||
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
onClick={() => setShowConfirmDialog(true)}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
className='inline-flex h-8 w-8 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200'
|
||||
onClick={() => setShowPlayRecordsPanel(true)}
|
||||
aria-label='查看全部播放记录'
|
||||
>
|
||||
<ChevronRight className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
// 加载状态显示灰色占位数据(使用原始 ScrollableRow)
|
||||
<div className="flex gap-2 overflow-x-auto scrollbar-hide pt-2 pb-2">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
|
||||
>
|
||||
<div className='relative aspect-[3/2] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
{loading ? (
|
||||
<div className='flex gap-2 overflow-x-auto scrollbar-hide pb-2 pt-2'>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
|
||||
>
|
||||
<div className='relative aspect-[3/2] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700' />
|
||||
</div>
|
||||
<div className='mt-1 h-1 rounded bg-gray-200 animate-pulse dark:bg-gray-800' />
|
||||
<div className='mt-2 h-4 w-3/4 rounded bg-gray-200 animate-pulse dark:bg-gray-800' />
|
||||
</div>
|
||||
<div className='mt-1 h-1 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800 w-3/4'></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 使用虚拟滚动显示真实数据
|
||||
<div>
|
||||
<VirtualScrollableRow>
|
||||
{playRecords.map((record) => {
|
||||
const { source, id } = parseKey(record.key);
|
||||
return (
|
||||
<div
|
||||
key={record.key}
|
||||
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<VideoCard
|
||||
id={id}
|
||||
title={record.title}
|
||||
poster={record.cover}
|
||||
year={record.year}
|
||||
source={source}
|
||||
source_name={record.source_name}
|
||||
progress={getProgress(record)}
|
||||
episodes={record.total_episodes}
|
||||
currentEpisode={record.index}
|
||||
query={record.search_title}
|
||||
from='playrecord'
|
||||
onDelete={() =>
|
||||
setPlayRecords((prev) =>
|
||||
prev.filter((r) => r.key !== record.key)
|
||||
)
|
||||
}
|
||||
type={record.total_episodes > 1 ? 'tv' : ''}
|
||||
origin={record.origin}
|
||||
orientation='horizontal'
|
||||
playTime={record.play_time}
|
||||
totalTime={record.total_time}
|
||||
/>
|
||||
{/* 新增剧集提示 - 完全独立于 VideoCard */}
|
||||
{record.new_episodes && record.new_episodes > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-6px',
|
||||
right: '-6px',
|
||||
zIndex: 100,
|
||||
pointerEvents: 'none',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
}}
|
||||
>
|
||||
{/* 水波纹动画 - 第一层 */}
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<VirtualScrollableRow>
|
||||
{playRecords.map((record) => {
|
||||
const { source, id } = parseKey(record.key);
|
||||
return (
|
||||
<div
|
||||
key={record.key}
|
||||
className='min-w-[180px] w-48 sm:min-w-[200px] sm:w-52'
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<VideoCard
|
||||
id={id}
|
||||
title={record.title}
|
||||
poster={record.cover}
|
||||
year={record.year}
|
||||
source={source}
|
||||
source_name={record.source_name}
|
||||
progress={getProgress(record)}
|
||||
episodes={record.total_episodes}
|
||||
currentEpisode={record.index}
|
||||
query={record.search_title}
|
||||
from='playrecord'
|
||||
onDelete={() =>
|
||||
setPlayRecords((prev) =>
|
||||
prev.filter((item) => item.key !== record.key)
|
||||
)
|
||||
}
|
||||
type={record.total_episodes > 1 ? 'tv' : ''}
|
||||
origin={record.origin}
|
||||
orientation='horizontal'
|
||||
playTime={record.play_time}
|
||||
totalTime={record.total_time}
|
||||
/>
|
||||
{record.new_episodes && record.new_episodes > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: 'rgb(14 165 233)',
|
||||
animation: 'ping-scale 1.5s cubic-bezier(0, 0, 0.2, 1) infinite',
|
||||
}}
|
||||
/>
|
||||
{/* 水波纹动画 - 第二层 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: 'rgb(14 165 233)',
|
||||
animation: 'pulse-scale 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
}}
|
||||
/>
|
||||
{/* 主体徽章 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
borderRadius: '9999px',
|
||||
background: 'linear-gradient(to bottom right, rgb(14 165 233), rgb(2 132 199))',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
animation: 'badge-scale 2s ease-in-out infinite',
|
||||
top: '-6px',
|
||||
right: '-6px',
|
||||
zIndex: 100,
|
||||
pointerEvents: 'none',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
}}
|
||||
>
|
||||
+{record.new_episodes}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: 'rgb(14 165 233)',
|
||||
animation:
|
||||
'ping-scale 1.5s cubic-bezier(0, 0, 0.2, 1) infinite',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: 'rgb(14 165 233)',
|
||||
animation:
|
||||
'pulse-scale 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
borderRadius: '9999px',
|
||||
background:
|
||||
'linear-gradient(to bottom right, rgb(14 165 233), rgb(2 132 199))',
|
||||
color: 'white',
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow:
|
||||
'0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
animation: 'badge-scale 2s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
+{record.new_episodes}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</VirtualScrollableRow>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 确认对话框 */}
|
||||
{showConfirmDialog && createPortal(
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 z-[9999] flex items-center justify-center p-4 transition-opacity duration-300'
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
>
|
||||
<div
|
||||
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border border-red-200 dark:border-red-800 transition-all duration-300'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6">
|
||||
{/* 图标和标题 */}
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
清空播放记录
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
确定要清空所有播放记录吗?此操作不可恢复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮组 */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearConfirm}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
|
||||
>
|
||||
确定清空
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</VirtualScrollableRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{showConfirmDialog &&
|
||||
createPortal(
|
||||
<div
|
||||
className='fixed inset-0 z-[9999] flex items-center justify-center bg-black bg-opacity-50 p-4 transition-opacity duration-300'
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
>
|
||||
<div
|
||||
className='max-w-md w-full rounded-lg border border-red-200 bg-white shadow-xl transition-all duration-300 dark:border-red-800 dark:bg-gray-800'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className='p-6'>
|
||||
<div className='mb-4 flex items-start gap-4'>
|
||||
<div className='flex-shrink-0'>
|
||||
<AlertTriangle className='h-8 w-8 text-red-500' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<h3 className='mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100'>
|
||||
清空播放记录
|
||||
</h3>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
确定要清空所有播放记录吗?此操作不可恢复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex gap-3'>
|
||||
<button
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
className='flex-1 rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearConfirm}
|
||||
className='flex-1 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700'
|
||||
>
|
||||
确定清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{showPlayRecordsPanel &&
|
||||
createPortal(
|
||||
<PlayRecordsPanel
|
||||
isOpen={showPlayRecordsPanel}
|
||||
onClose={() => setShowPlayRecordsPanel(false)}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function DanmakuPanel({
|
||||
<div className='flex h-full flex-col overflow-hidden'>
|
||||
{/* 搜索区域 - 固定在顶部 */}
|
||||
<div className='mb-4 flex-shrink-0'>
|
||||
<div className='flex gap-2'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<input
|
||||
type='text'
|
||||
value={searchKeyword}
|
||||
@@ -183,7 +183,7 @@ export default function DanmakuPanel({
|
||||
spellCheck='false'
|
||||
data-form-type='other'
|
||||
data-lpignore='true'
|
||||
className='flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm
|
||||
className='flex-1 min-w-[220px] rounded-lg border border-gray-300 px-3 py-2 text-sm
|
||||
transition-colors focus:border-green-500 focus:outline-none
|
||||
focus:ring-2 focus:ring-green-500/20
|
||||
dark:border-gray-600 dark:bg-gray-800 dark:text-white
|
||||
@@ -193,18 +193,18 @@ export default function DanmakuPanel({
|
||||
<button
|
||||
onClick={() => handleSearch(searchKeyword)}
|
||||
disabled={isSearching}
|
||||
className='flex items-center justify-center gap-2 rounded-lg bg-green-500 px-3 py-2
|
||||
className='flex flex-shrink-0 items-center justify-center gap-2 rounded-lg bg-green-500 px-3 py-2
|
||||
text-sm font-medium text-white transition-colors
|
||||
hover:bg-green-600 disabled:cursor-not-allowed
|
||||
disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700
|
||||
sm:px-4 md:gap-2'
|
||||
lg:px-4 min-w-[44px]'
|
||||
>
|
||||
{isSearching ? (
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent' />
|
||||
) : (
|
||||
<MagnifyingGlassIcon className='h-4 w-4' />
|
||||
)}
|
||||
<span className='hidden sm:inline'>
|
||||
<span className='hidden lg:inline'>
|
||||
{isSearching ? '搜索中...' : '搜索'}
|
||||
</span>
|
||||
</button>
|
||||
@@ -382,9 +382,18 @@ export default function DanmakuPanel({
|
||||
|
||||
{/* 信息 */}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='truncate font-semibold text-gray-800 dark:text-white'>
|
||||
{anime.animeTitle}
|
||||
</p>
|
||||
<div className='relative'>
|
||||
<p className='truncate font-semibold text-gray-800 dark:text-white peer'>
|
||||
{anime.animeTitle}
|
||||
</p>
|
||||
{/* 自定义 tooltip */}
|
||||
<div
|
||||
className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap pointer-events-none z-[100]'
|
||||
>
|
||||
{anime.animeTitle}
|
||||
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-1 flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-400'>
|
||||
<span className='rounded bg-gray-200 px-2 py-0.5 dark:bg-gray-700'>
|
||||
{anime.typeDescription || anime.type}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, Copy, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertCircle, Copy, ExternalLink, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
import { PansouLink, PansouSearchResult } from '@/lib/pansou.client';
|
||||
|
||||
@@ -57,51 +57,52 @@ export default function PansouSearch({
|
||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<string>('all'); // 'all' 表示显示全部
|
||||
|
||||
// 提取搜索函数,以便在重试时调用
|
||||
const searchPansou = useCallback(async () => {
|
||||
const currentKeyword = keyword.trim();
|
||||
if (!currentKeyword) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResults(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pansou/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
keyword: currentKeyword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '搜索失败');
|
||||
}
|
||||
|
||||
const data: PansouSearchResult = await response.json();
|
||||
setResults(data);
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.message || '搜索失败,请检查配置';
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [keyword, onError]);
|
||||
|
||||
useEffect(() => {
|
||||
// triggerSearch 变化时触发搜索(无论是 true 还是 false)
|
||||
if (triggerSearch === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentKeyword = keyword.trim();
|
||||
if (!currentKeyword) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchPansou = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResults(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/pansou/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
keyword: currentKeyword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || '搜索失败');
|
||||
}
|
||||
|
||||
const data: PansouSearchResult = await response.json();
|
||||
setResults(data);
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.message || '搜索失败,请检查配置';
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
searchPansou();
|
||||
}, [triggerSearch, onError]); // 移除 keyword 依赖,只依赖 triggerSearch
|
||||
}, [triggerSearch, searchPansou]); // 依赖 triggerSearch 和 searchPansou
|
||||
|
||||
const handleCopy = async (text: string, url: string) => {
|
||||
try {
|
||||
@@ -136,6 +137,13 @@ export default function PansouSearch({
|
||||
<div className='text-center'>
|
||||
<AlertCircle className='mx-auto h-12 w-12 text-red-500 dark:text-red-400' />
|
||||
<p className='mt-4 text-sm text-red-600 dark:text-red-400'>{error}</p>
|
||||
<button
|
||||
onClick={searchPansou}
|
||||
className='mt-4 inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors'
|
||||
>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle, History, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { PlayRecord } from '@/lib/db.client';
|
||||
import {
|
||||
clearAllPlayRecords,
|
||||
getAllPlayRecords,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
type PlayRecordItem = PlayRecord & {
|
||||
key: string;
|
||||
};
|
||||
|
||||
interface PlayRecordsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const parseKey = (key: string) => {
|
||||
const [source, id] = key.split('+');
|
||||
return { source, id };
|
||||
};
|
||||
|
||||
const getProgress = (record: PlayRecord) => {
|
||||
if (record.total_time === 0) return 0;
|
||||
return (record.play_time / record.total_time) * 100;
|
||||
};
|
||||
|
||||
export default function PlayRecordsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: PlayRecordsPanelProps) {
|
||||
const [playRecords, setPlayRecords] = useState<PlayRecordItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
const loadPlayRecords = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const allRecords = await getAllPlayRecords();
|
||||
const sorted = Object.entries(allRecords)
|
||||
.map(([key, record]) => ({
|
||||
...record,
|
||||
key,
|
||||
}))
|
||||
.sort((a, b) => b.save_time - a.save_time);
|
||||
setPlayRecords(sorted);
|
||||
} catch (error) {
|
||||
console.error('加载播放记录失败:', error);
|
||||
setPlayRecords([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await clearAllPlayRecords();
|
||||
setPlayRecords([]);
|
||||
setShowConfirmDialog(false);
|
||||
} catch (error) {
|
||||
console.error('清空播放记录失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
loadPlayRecords();
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'playRecordsUpdated',
|
||||
(newRecords: Record<string, PlayRecord>) => {
|
||||
if (!isOpen) return;
|
||||
const sorted = Object.entries(newRecords)
|
||||
.map(([key, record]) => ({
|
||||
...record,
|
||||
key,
|
||||
}))
|
||||
.sort((a, b) => b.save_time - a.save_time);
|
||||
setPlayRecords(sorted);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-4xl max-h-[85vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] flex flex-col overflow-hidden'>
|
||||
<div className='flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<History className='w-5 h-5 text-sky-500' />
|
||||
<h3 className='text-lg font-bold text-gray-800 dark:text-gray-200'>
|
||||
播放记录
|
||||
</h3>
|
||||
{playRecords.length > 0 && (
|
||||
<span className='px-2 py-0.5 text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300 rounded-full'>
|
||||
{playRecords.length} 项
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{playRecords.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowConfirmDialog(true)}
|
||||
className='text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors'
|
||||
>
|
||||
清空全部
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||
aria-label='Close'
|
||||
>
|
||||
<X className='w-full h-full' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex-1 overflow-y-auto p-6'>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<div className='w-8 h-8 border-4 border-sky-500 border-t-transparent rounded-full animate-spin'></div>
|
||||
</div>
|
||||
) : playRecords.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400'>
|
||||
<History className='w-12 h-12 mb-3 opacity-30' />
|
||||
<p className='text-sm'>暂无播放记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'>
|
||||
{playRecords.map((record) => {
|
||||
const { source, id } = parseKey(record.key);
|
||||
|
||||
return (
|
||||
<div key={record.key} className='w-full'>
|
||||
<VideoCard
|
||||
id={id}
|
||||
title={record.title}
|
||||
poster={record.cover}
|
||||
year={record.year}
|
||||
source={source}
|
||||
source_name={record.source_name}
|
||||
progress={getProgress(record)}
|
||||
episodes={record.total_episodes}
|
||||
currentEpisode={record.index}
|
||||
query={record.search_title}
|
||||
from='playrecord'
|
||||
onDelete={() =>
|
||||
setPlayRecords((prev) =>
|
||||
prev.filter((item) => item.key !== record.key)
|
||||
)
|
||||
}
|
||||
type={record.total_episodes > 1 ? 'tv' : ''}
|
||||
origin={record.origin}
|
||||
playTime={record.play_time}
|
||||
totalTime={record.total_time}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showConfirmDialog &&
|
||||
createPortal(
|
||||
<div
|
||||
className='fixed inset-0 bg-black bg-opacity-50 z-[9999] flex items-center justify-center p-4 transition-opacity duration-300'
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
>
|
||||
<div
|
||||
className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border border-red-200 dark:border-red-800 transition-all duration-300'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='p-6'>
|
||||
<div className='flex items-start gap-4 mb-4'>
|
||||
<div className='flex-shrink-0'>
|
||||
<AlertTriangle className='w-8 h-8 text-red-500' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2'>
|
||||
清空播放记录
|
||||
</h3>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
确定要清空所有播放记录吗?此操作不可恢复。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-3 mt-6'>
|
||||
<button
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors'
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors'
|
||||
>
|
||||
确定清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +114,6 @@ export const UserMenu: React.FC = () => {
|
||||
const [enableOptimization, setEnableOptimization] = useState(true);
|
||||
const [speedTestTimeout, setSpeedTestTimeout] = useState(4000); // 测速超时时间(毫秒)
|
||||
const [fluidSearch, setFluidSearch] = useState(true);
|
||||
const [liveDirectConnect, setLiveDirectConnect] = useState(false);
|
||||
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
|
||||
const [enableTrailers, setEnableTrailers] = useState(false);
|
||||
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
|
||||
@@ -486,11 +485,6 @@ export const UserMenu: React.FC = () => {
|
||||
setFluidSearch(defaultFluidSearch);
|
||||
}
|
||||
|
||||
const savedLiveDirectConnect = localStorage.getItem('liveDirectConnect');
|
||||
if (savedLiveDirectConnect !== null) {
|
||||
setLiveDirectConnect(JSON.parse(savedLiveDirectConnect));
|
||||
}
|
||||
|
||||
const savedTmdbBackdropDisabled = localStorage.getItem('tmdb_backdrop_disabled');
|
||||
if (savedTmdbBackdropDisabled !== null) {
|
||||
setTmdbBackdropDisabled(savedTmdbBackdropDisabled === 'true');
|
||||
@@ -1040,13 +1034,6 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLiveDirectConnectToggle = (value: boolean) => {
|
||||
setLiveDirectConnect(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('liveDirectConnect', JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTmdbBackdropDisabledToggle = (value: boolean) => {
|
||||
setTmdbBackdropDisabled(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -1255,7 +1242,6 @@ export const UserMenu: React.FC = () => {
|
||||
setDefaultAggregateSearch(true);
|
||||
setEnableOptimization(true);
|
||||
setFluidSearch(defaultFluidSearch);
|
||||
setLiveDirectConnect(false);
|
||||
setTmdbBackdropDisabled(false);
|
||||
setEnableTrailers(false);
|
||||
setDoubanProxyUrl(defaultDoubanProxy);
|
||||
@@ -2031,30 +2017,6 @@ export const UserMenu: React.FC = () => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 直播视频浏览器直连 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
IPTV 视频浏览器直连
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
开启 IPTV 视频浏览器直连时,需要自备 Allow CORS 插件
|
||||
</p>
|
||||
</div>
|
||||
<label className='flex items-center cursor-pointer'>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={liveDirectConnect}
|
||||
onChange={(e) => handleLiveDirectConnectToggle(e.target.checked)}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 禁用背景图渲染 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
|
||||
@@ -1077,7 +1077,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute bottom-2 right-2 opacity-0 transition-all duration-300 ease-in-out delay-75 sm:group-hover:opacity-100'
|
||||
className={`absolute bottom-1 right-1 sm:bottom-2 sm:right-2 transition-all duration-300 ease-in-out delay-75 ${
|
||||
from === 'search' ? 'opacity-100' : 'opacity-0 sm:group-hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface RuntimeConfig {
|
||||
AIConfig?: {
|
||||
EnableAIComments?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function useEnableAIComments(): boolean {
|
||||
const [enableAIComments, setEnableAIComments] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 在客户端获取运行时配置
|
||||
if (typeof window !== 'undefined') {
|
||||
const runtimeConfig = (window as any).RUNTIME_CONFIG as RuntimeConfig;
|
||||
setEnableAIComments(runtimeConfig?.AIConfig?.EnableAIComments ?? false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return enableAIComments;
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export interface AdminConfig {
|
||||
from: 'config' | 'custom';
|
||||
channelNumber?: number;
|
||||
disabled?: boolean;
|
||||
proxyMode?: 'full' | 'm3u8-only' | 'direct'; // 代理模式:full=全量代理,m3u8-only=仅代理m3u8,direct=直连
|
||||
}[];
|
||||
WebLiveConfig?: {
|
||||
key: string;
|
||||
@@ -169,6 +170,7 @@ export interface AdminConfig {
|
||||
EnableHomepageEntry: boolean; // 首页入口开关
|
||||
EnableVideoCardEntry: boolean; // VideoCard入口开关
|
||||
EnablePlayPageEntry: boolean; // 播放页入口开关
|
||||
EnableAIComments: boolean; // AI评论生成开关
|
||||
// 权限控制
|
||||
AllowRegularUsers: boolean; // 是否允许普通用户使用AI问片(关闭后仅站长和管理员可用)
|
||||
// 高级设置
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// AI评论生成核心逻辑
|
||||
|
||||
export interface AIComment {
|
||||
id: string;
|
||||
userName: string;
|
||||
userAvatar: string;
|
||||
rating: number | null;
|
||||
content: string;
|
||||
time: string;
|
||||
votes: number;
|
||||
isAiGenerated: true;
|
||||
}
|
||||
|
||||
interface GenerateCommentsParams {
|
||||
movieName: string;
|
||||
movieInfo?: string;
|
||||
count?: number;
|
||||
aiConfig: {
|
||||
CustomApiKey: string;
|
||||
CustomBaseURL: string;
|
||||
CustomModel: string;
|
||||
Temperature?: number;
|
||||
MaxTokens?: number;
|
||||
EnableWebSearch?: boolean;
|
||||
WebSearchProvider?: 'tavily' | 'serper' | 'serpapi';
|
||||
TavilyApiKey?: string;
|
||||
SerperApiKey?: string;
|
||||
SerpApiKey?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CommentData {
|
||||
content: string;
|
||||
rating: number | null;
|
||||
sentiment: 'positive' | 'neutral' | 'negative';
|
||||
}
|
||||
|
||||
// 生成评论的Prompt
|
||||
function buildCommentPrompt(
|
||||
movieName: string,
|
||||
movieInfo?: string,
|
||||
searchResults?: string,
|
||||
count: number = 10
|
||||
): string {
|
||||
return `你是一个影评生成助手。请生成真实自然的观众评论。
|
||||
|
||||
影片:${movieName}
|
||||
${movieInfo ? `简介:${movieInfo}` : ''}
|
||||
${searchResults ? `\n网络评价参考:\n${searchResults}` : ''}
|
||||
|
||||
任务要求:
|
||||
1. 生成${count}条观众评论
|
||||
2. 每条评论50-200字,口语化、自然
|
||||
3. 观点多样化:有好评、中评、差评,比例大约6:3:1
|
||||
4. 可以包含:
|
||||
- 个人观影感受和情感共鸣
|
||||
- 对演员演技的评价
|
||||
- 对剧情、节奏、画面的看法
|
||||
- 与其他作品的对比
|
||||
- 推荐或不推荐的理由
|
||||
5. 避免:
|
||||
- 过于专业的影评术语
|
||||
- 千篇一律的表达
|
||||
- 明显的AI痕迹
|
||||
- 重复的内容
|
||||
|
||||
请直接输出JSON数组格式,不要有其他文字:
|
||||
[
|
||||
{
|
||||
"content": "评论内容",
|
||||
"rating": 4,
|
||||
"sentiment": "positive"
|
||||
}
|
||||
]
|
||||
|
||||
注意:rating为1-5的整数或null(表示未评分),sentiment为positive/neutral/negative之一。`;
|
||||
}
|
||||
|
||||
// 联网搜索影片资料
|
||||
async function searchMovieInfo(
|
||||
movieName: string,
|
||||
aiConfig: GenerateCommentsParams['aiConfig']
|
||||
): Promise<string> {
|
||||
if (!aiConfig.EnableWebSearch) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = aiConfig.WebSearchProvider || 'tavily';
|
||||
let searchResults = '';
|
||||
|
||||
if (provider === 'tavily' && aiConfig.TavilyApiKey) {
|
||||
const response = await fetch('https://api.tavily.com/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_key: aiConfig.TavilyApiKey,
|
||||
query: `${movieName} 影评 评价`,
|
||||
max_results: 5,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
searchResults = data.results
|
||||
?.map((r: any) => r.content)
|
||||
.join('\n')
|
||||
.slice(0, 1000);
|
||||
}
|
||||
} else if (provider === 'serper' && aiConfig.SerperApiKey) {
|
||||
const response = await fetch('https://google.serper.dev/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-KEY': aiConfig.SerperApiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
q: `${movieName} 影评 评价`,
|
||||
num: 5,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
searchResults = data.organic
|
||||
?.map((r: any) => r.snippet)
|
||||
.join('\n')
|
||||
.slice(0, 1000);
|
||||
}
|
||||
} else if (provider === 'serpapi' && aiConfig.SerpApiKey) {
|
||||
const response = await fetch(
|
||||
`https://serpapi.com/search?q=${encodeURIComponent(movieName + ' 影评 评价')}&api_key=${aiConfig.SerpApiKey}&num=5`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
searchResults = data.organic_results
|
||||
?.map((r: any) => r.snippet)
|
||||
.join('\n')
|
||||
.slice(0, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
return searchResults;
|
||||
} catch (error) {
|
||||
console.error('搜索影片资料失败:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 调用AI生成评论
|
||||
export async function generateAIComments(
|
||||
params: GenerateCommentsParams
|
||||
): Promise<AIComment[]> {
|
||||
const { movieName, movieInfo, count = 10, aiConfig } = params;
|
||||
|
||||
try {
|
||||
// 1. 联网搜索影片资料(如果启用)
|
||||
const searchResults = await searchMovieInfo(movieName, aiConfig);
|
||||
|
||||
// 2. 构建Prompt
|
||||
const prompt = buildCommentPrompt(movieName, movieInfo, searchResults, count);
|
||||
|
||||
// 3. 调用AI API
|
||||
const response = await fetch(`${aiConfig.CustomBaseURL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${aiConfig.CustomApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: aiConfig.CustomModel,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是一个专业的影评生成助手,擅长生成真实自然的观众评论。',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
temperature: aiConfig.Temperature ?? 0.8,
|
||||
max_tokens: aiConfig.MaxTokens ?? 2000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`AI API调用失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error('AI返回内容为空');
|
||||
}
|
||||
|
||||
// 4. 解析AI返回的JSON
|
||||
let commentsData: CommentData[];
|
||||
try {
|
||||
// 尝试提取JSON(可能被markdown代码块包裹)
|
||||
const jsonMatch = content.match(/\[[\s\S]*\]/);
|
||||
if (jsonMatch) {
|
||||
commentsData = JSON.parse(jsonMatch[0]);
|
||||
} else {
|
||||
commentsData = JSON.parse(content);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('解析AI返回的JSON失败:', content);
|
||||
throw new Error('AI返回格式错误');
|
||||
}
|
||||
|
||||
// 5. 转换为AIComment格式
|
||||
const aiComments: AIComment[] = commentsData.map((comment, index) => {
|
||||
const timestamp = Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000; // 随机过去30天内
|
||||
const date = new Date(timestamp);
|
||||
|
||||
return {
|
||||
id: `ai-${Date.now()}-${index}`,
|
||||
userName: generateUserName(index),
|
||||
userAvatar: generateAvatar(index),
|
||||
rating: comment.rating,
|
||||
content: comment.content,
|
||||
time: formatTime(date),
|
||||
votes: generateVotes(comment.sentiment),
|
||||
isAiGenerated: true,
|
||||
};
|
||||
});
|
||||
|
||||
return aiComments;
|
||||
} catch (error) {
|
||||
console.error('AI评论生成失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成虚拟用户名
|
||||
function generateUserName(index: number): string {
|
||||
const prefixes = [
|
||||
'影迷',
|
||||
'观众',
|
||||
'电影爱好者',
|
||||
'剧迷',
|
||||
'路人',
|
||||
'网友',
|
||||
'看客',
|
||||
];
|
||||
const prefix = prefixes[index % prefixes.length];
|
||||
return `${prefix}${Math.floor(Math.random() * 9000) + 1000}`;
|
||||
}
|
||||
|
||||
// 生成头像URL(使用DiceBear API)
|
||||
function generateAvatar(seed: number): string {
|
||||
const styles = ['avataaars', 'bottts', 'personas', 'micah'];
|
||||
const style = styles[seed % styles.length];
|
||||
return `https://api.dicebear.com/7.x/${style}/svg?seed=${seed}`;
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
// 根据情感生成点赞数
|
||||
function generateVotes(sentiment: string): number {
|
||||
if (sentiment === 'positive') {
|
||||
return Math.floor(Math.random() * 100) + 20; // 20-120
|
||||
} else if (sentiment === 'neutral') {
|
||||
return Math.floor(Math.random() * 50) + 5; // 5-55
|
||||
} else {
|
||||
return Math.floor(Math.random() * 30); // 0-30
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,33 @@ export interface ChangelogEntry {
|
||||
}
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
{
|
||||
version: "215.0.0",
|
||||
date: "2026-03-20",
|
||||
added: [
|
||||
"增加主动恢复进度按钮",
|
||||
"新增播放记录面板",
|
||||
"弹幕搜索面板标题增加tooltip",
|
||||
"影视搜索新增列表视图",
|
||||
"pansou增加重试按钮",
|
||||
"新增ai评论生成",
|
||||
"增加一键render部署",
|
||||
"电视直播增加三种代理模式"
|
||||
],
|
||||
changed: [
|
||||
"优化直链播放m3u8体验",
|
||||
"搜索页面海量数据下使用虚拟滚动提高性能",
|
||||
"优化emby代理内存泄漏问题",
|
||||
"电视直播代理控制权从用户端改为管理端",
|
||||
"获取视频源详情不再依赖title"
|
||||
],
|
||||
fixed: [
|
||||
"修复search页面僵尸历史记录tag",
|
||||
"修复弹幕搜索框挤压",
|
||||
"修复搜索页面加载条显示顺序错误",
|
||||
"修复继续观看渐进式加载的一些问题"
|
||||
]
|
||||
},
|
||||
{
|
||||
version: "214.1.0",
|
||||
date: "2026-03-10",
|
||||
|
||||
@@ -716,6 +716,42 @@ export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedPlayRecordsSnapshot(): Record<string, PlayRecord> {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cachedRecords = cacheManager.getCachedPlayRecords();
|
||||
if (cachedRecords) {
|
||||
return cachedRecords;
|
||||
}
|
||||
|
||||
try {
|
||||
const username = getAuthInfoFromBrowserCookie()?.username;
|
||||
if (!username) return {};
|
||||
|
||||
const raw = localStorage.getItem(`${CACHE_PREFIX}${username}`);
|
||||
if (!raw) return {};
|
||||
|
||||
const userCache = JSON.parse(raw) as UserCacheStore;
|
||||
return userCache.playRecords?.data || {};
|
||||
} catch (err) {
|
||||
console.error('读取用户播放记录快照失败:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(PLAY_RECORDS_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, PlayRecord>;
|
||||
} catch (err) {
|
||||
console.error('读取本地播放记录快照失败:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存播放记录。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
|
||||
|
||||
@@ -293,6 +293,90 @@ export async function getDetailFromApi(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDetailFromApiV2(
|
||||
apiSite: ApiSite,
|
||||
id: string
|
||||
): Promise<SearchResult> {
|
||||
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(detailUrl, {
|
||||
headers: API_CONFIG.detail.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`详情请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (
|
||||
!data ||
|
||||
!data.list ||
|
||||
!Array.isArray(data.list) ||
|
||||
data.list.length === 0
|
||||
) {
|
||||
throw new Error('获取到的详情内容无效');
|
||||
}
|
||||
|
||||
const videoDetail = data.list[0];
|
||||
let episodes: string[] = [];
|
||||
let titles: string[] = [];
|
||||
|
||||
if (videoDetail.vod_play_url) {
|
||||
const vodPlayUrlArray = videoDetail.vod_play_url.split('$$$');
|
||||
vodPlayUrlArray.forEach((url: string) => {
|
||||
const matchEpisodes: string[] = [];
|
||||
const matchTitles: string[] = [];
|
||||
const titleUrlArray = url.split('#');
|
||||
titleUrlArray.forEach((titleUrl: string) => {
|
||||
const episodeTitleUrl = titleUrl.split('$');
|
||||
if (
|
||||
episodeTitleUrl.length === 2 &&
|
||||
episodeTitleUrl[1].endsWith('.m3u8')
|
||||
) {
|
||||
matchTitles.push(episodeTitleUrl[0]);
|
||||
matchEpisodes.push(episodeTitleUrl[1]);
|
||||
}
|
||||
});
|
||||
if (matchEpisodes.length > episodes.length) {
|
||||
episodes = matchEpisodes;
|
||||
titles = matchTitles;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (episodes.length === 0 && videoDetail.vod_content) {
|
||||
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
|
||||
episodes = matches.map((link: string) => link.replace(/^\$/, ''));
|
||||
}
|
||||
|
||||
return {
|
||||
id: id.toString(),
|
||||
title: videoDetail.vod_name,
|
||||
poster: videoDetail.vod_pic,
|
||||
episodes,
|
||||
episodes_titles: titles,
|
||||
source: apiSite.key,
|
||||
source_name: apiSite.name,
|
||||
class: videoDetail.vod_class,
|
||||
year: videoDetail.vod_year
|
||||
? videoDetail.vod_year.match(/\d{4}/)?.[0] || ''
|
||||
: 'unknown',
|
||||
desc: cleanHtmlTags(videoDetail.vod_content),
|
||||
type_name: videoDetail.type_name,
|
||||
douban_id: videoDetail.vod_douban_id,
|
||||
vod_remarks: videoDetail.vod_remarks,
|
||||
vod_total: videoDetail.vod_total,
|
||||
proxyMode: apiSite.proxyMode || false,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSpecialSourceDetail(
|
||||
id: string,
|
||||
apiSite: ApiSite
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getAvailableApiSites } from '@/lib/config';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
import { getDetailFromApi, searchFromApi } from './downstream';
|
||||
import { getDetailFromApiV2 } from './downstream';
|
||||
import { getSpecialSourceDetail, isSpecialSource } from './special-sources-detail';
|
||||
|
||||
interface FetchVideoDetailOptions {
|
||||
@@ -13,13 +13,12 @@ interface FetchVideoDetailOptions {
|
||||
/**
|
||||
* 根据 source 与 id 获取视频详情。
|
||||
* 1. 如果是特殊源(emby、openlist、xiaoya),直接调用对应的获取函数。
|
||||
* 2. 若传入 fallbackTitle,则先调用 /api/search 搜索精确匹配。
|
||||
* 3. 若搜索未命中或未提供 fallbackTitle,则直接调用 /api/detail。
|
||||
* 2. 其他采集源直接调用详情接口,避免依赖搜索接口。
|
||||
*/
|
||||
export async function fetchVideoDetail({
|
||||
source,
|
||||
id,
|
||||
fallbackTitle = '',
|
||||
fallbackTitle: _fallbackTitle = '',
|
||||
}: FetchVideoDetailOptions): Promise<SearchResult> {
|
||||
// 检查是否是特殊源(emby、openlist、xiaoya)
|
||||
if (isSpecialSource(source)) {
|
||||
@@ -30,30 +29,13 @@ export async function fetchVideoDetail({
|
||||
// 如果特殊源返回 null,继续使用标准流程
|
||||
}
|
||||
|
||||
// 优先通过搜索接口查找精确匹配
|
||||
const apiSites = await getAvailableApiSites();
|
||||
const apiSite = apiSites.find((site) => site.key === source);
|
||||
if (!apiSite) {
|
||||
throw new Error('无效的API来源');
|
||||
}
|
||||
if (fallbackTitle) {
|
||||
try {
|
||||
const searchData = await searchFromApi(apiSite, fallbackTitle.trim());
|
||||
const exactMatch = searchData.find(
|
||||
(item: SearchResult) =>
|
||||
item.source.toString() === source.toString() &&
|
||||
item.id.toString() === id.toString()
|
||||
);
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
} catch (error) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
// 调用 /api/detail 接口
|
||||
const detail = await getDetailFromApi(apiSite, id);
|
||||
const detail = await getDetailFromApiV2(apiSite, id);
|
||||
if (!detail) {
|
||||
throw new Error('获取视频详情失败');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 代理接口共享工具函数
|
||||
* 用于构建标准化的 CORS 响应头,避免各代理路由中的重复代码。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 构建标准的代理流式响应头 (用于 ts 分片、密钥、二进制流等)
|
||||
* 包含 CORS、Accept-Ranges 和 Content-Length 等标准头。
|
||||
*/
|
||||
export function buildProxyStreamHeaders(
|
||||
contentType: string,
|
||||
contentLength?: string | null
|
||||
): Headers {
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', contentType);
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
if (contentLength) {
|
||||
headers.set('Content-Length', contentLength);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建标准的代理 M3U8 播放列表响应头
|
||||
*/
|
||||
export function buildProxyM3u8Headers(contentType?: string): Headers {
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', contentType || 'application/vnd.apple.mpegurl');
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
headers.set('Cache-Control', 'no-cache');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
return headers;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import dns from 'dns';
|
||||
|
||||
/**
|
||||
* 判断 IP 地址是否为内网/本地私有地址
|
||||
* 覆盖 IPv4 和 IPv6,彻底杜绝所有变体绕过。
|
||||
*/
|
||||
export function isPrivateIP(ip: string): boolean {
|
||||
// IPv4 私有地址和环回地址
|
||||
if (ip.includes('.')) {
|
||||
const parts = ip.split('.').map(Number);
|
||||
if (parts.length !== 4) return false;
|
||||
|
||||
return (
|
||||
parts[0] === 10 || // 10.x.x.x
|
||||
parts[0] === 127 || // 127.x.x.x (Loopback)
|
||||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.x.x - 172.31.x.x
|
||||
(parts[0] === 192 && parts[1] === 168) || // 192.168.x.x
|
||||
(parts[0] === 169 && parts[1] === 254) || // 169.254.x.x (Link-local)
|
||||
parts[0] === 0 // 0.x.x.x ("This network")
|
||||
);
|
||||
}
|
||||
|
||||
// IPv6 私有地址和环回地址
|
||||
if (ip.includes(':')) {
|
||||
// ::1 环回地址 (Loopback)
|
||||
if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') return true;
|
||||
|
||||
// 0::0 / :: 未指定地址
|
||||
if (ip === '::' || ip === '0:0:0:0:0:0:0:0') return true;
|
||||
|
||||
const lowerIp = ip.toLowerCase();
|
||||
|
||||
// IPv4 映射到 IPv6 的地址 (例如 ::ffff:127.0.0.1)
|
||||
if (lowerIp.startsWith('::ffff:')) {
|
||||
return isPrivateIP(lowerIp.substring(7));
|
||||
}
|
||||
|
||||
// 唯一本地地址 (Unique Local Addresses, fc00::/7)
|
||||
if (lowerIp.startsWith('fc') || lowerIp.startsWith('fd')) return true;
|
||||
|
||||
// 链路本地地址 (Link-Local Addresses, fe80::/10)
|
||||
if (lowerIp.startsWith('fe8') || lowerIp.startsWith('fe9') || lowerIp.startsWith('fea') || lowerIp.startsWith('feb')) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验代理 URL 是否安全 (防止 SSRF / DNS 重绑定漏洞)
|
||||
* 只在 Node.js 服务端运行。
|
||||
*/
|
||||
export async function validateProxyUrlServerSide(urlStr: string): Promise<boolean> {
|
||||
if (!urlStr) return false;
|
||||
try {
|
||||
const parsed = new URL(urlStr);
|
||||
|
||||
// 1. 协议检查
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 剥离认证信息防止混淆
|
||||
if (parsed.username || parsed.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let { hostname } = parsed;
|
||||
|
||||
// 清洗 IPv6 括号边界
|
||||
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
||||
hostname = hostname.substring(1, hostname.length - 1);
|
||||
}
|
||||
|
||||
// 3. DNS 真实解析 (获取底层物理 IP)
|
||||
// 这一步能彻底打碎各种形式的短格式 IP (127.1)、八/十六进制 IP (0x7f.0.0.1)、或者指向 127.0.0.1 的恶意外部域名 DNS 重绑定。
|
||||
const lookupResult = await dns.promises.lookup(hostname);
|
||||
|
||||
if (!lookupResult || !lookupResult.address) {
|
||||
return false; // 解析不出 IP 则拒绝
|
||||
}
|
||||
|
||||
// 4. 对物理 IP 进行内网校验
|
||||
if (isPrivateIP(lookupResult.address)) {
|
||||
console.warn(`[SSRF 防护] 拦截到尝试访问内部网络的请求 URL: ${urlStr} (解析出的底层 IP: ${lookupResult.address})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// 凡是报错(无论是 URL 解析失败,还是 DNS 解析失败,还是域名不存在),均作为不安全拒绝
|
||||
console.warn(`[SSRF 防护] URL解析失败或不合法, 拒绝代理请求: ${urlStr}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+68
-58
@@ -155,7 +155,7 @@ export function processVideoUrl(originalUrl: string): string {
|
||||
*/
|
||||
export async function getVideoResolutionFromM3u8(
|
||||
m3u8Url: string,
|
||||
timeoutMs = 4000
|
||||
timeoutMs = 6000
|
||||
): Promise<{
|
||||
quality: string; // 如720p、1080p等
|
||||
loadSpeed: string; // 自动转换为KB/s或MB/s
|
||||
@@ -185,11 +185,55 @@ export async function getVideoResolutionFromM3u8(
|
||||
// 固定使用hls.js加载
|
||||
const hls = new Hls();
|
||||
|
||||
// 设置超时处理 - 使用传入的超时时间
|
||||
const timeout = setTimeout(() => {
|
||||
let actualLoadSpeed = '未知';
|
||||
let hasSpeedCalculated = false;
|
||||
let hasMetadataLoaded = false;
|
||||
let estimatedBitrate = 0; // 估算的码率(bps)
|
||||
|
||||
// 提取核心返回逻辑供 resolve 和 timeout 共同调用
|
||||
const resolveCurrentState = () => {
|
||||
const width = video.videoWidth;
|
||||
const quality =
|
||||
width >= 3840
|
||||
? '4K'
|
||||
: width >= 2560
|
||||
? '2K'
|
||||
: width >= 1920
|
||||
? '1080p'
|
||||
: width >= 1280
|
||||
? '720p'
|
||||
: width >= 854
|
||||
? '480p'
|
||||
: width > 0
|
||||
? 'SD'
|
||||
: '未知';
|
||||
|
||||
const bitrateStr = estimatedBitrate > 0
|
||||
? estimatedBitrate >= 1000000
|
||||
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
||||
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
||||
: '未知';
|
||||
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
reject(new Error('Timeout loading video metadata'));
|
||||
|
||||
resolve({
|
||||
quality,
|
||||
loadSpeed: actualLoadSpeed,
|
||||
pingTime: Math.round(pingTime),
|
||||
bitrate: bitrateStr,
|
||||
});
|
||||
};
|
||||
|
||||
// 设置超时处理 - 如果部分数据已拿到,则宽容返回
|
||||
const timeout = setTimeout(() => {
|
||||
if (hasMetadataLoaded || hasSpeedCalculated) {
|
||||
resolveCurrentState();
|
||||
} else {
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
reject(new Error('Timeout loading video metadata'));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
video.onerror = () => {
|
||||
@@ -199,67 +243,16 @@ export async function getVideoResolutionFromM3u8(
|
||||
reject(new Error('Failed to load video metadata'));
|
||||
};
|
||||
|
||||
let actualLoadSpeed = '未知';
|
||||
let hasSpeedCalculated = false;
|
||||
let hasMetadataLoaded = false;
|
||||
let estimatedBitrate = 0; // 估算的码率(bps)
|
||||
|
||||
let fragmentStartTime = 0;
|
||||
|
||||
// 检查是否可以返回结果
|
||||
// 检查是否可以相互满足要求
|
||||
const checkAndResolve = () => {
|
||||
if (
|
||||
hasMetadataLoaded &&
|
||||
(hasSpeedCalculated || actualLoadSpeed !== '未知')
|
||||
) {
|
||||
clearTimeout(timeout);
|
||||
const width = video.videoWidth;
|
||||
if (width && width > 0) {
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
|
||||
// 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点
|
||||
const quality =
|
||||
width >= 3840
|
||||
? '4K' // 4K: 3840x2160
|
||||
: width >= 2560
|
||||
? '2K' // 2K: 2560x1440
|
||||
: width >= 1920
|
||||
? '1080p' // 1080p: 1920x1080
|
||||
: width >= 1280
|
||||
? '720p' // 720p: 1280x720
|
||||
: width >= 854
|
||||
? '480p'
|
||||
: 'SD'; // 480p: 854x480
|
||||
|
||||
// 格式化码率
|
||||
const bitrateStr = estimatedBitrate > 0
|
||||
? estimatedBitrate >= 1000000
|
||||
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
||||
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
||||
: '未知';
|
||||
|
||||
resolve({
|
||||
quality,
|
||||
loadSpeed: actualLoadSpeed,
|
||||
pingTime: Math.round(pingTime),
|
||||
bitrate: bitrateStr,
|
||||
});
|
||||
} else {
|
||||
// webkit 无法获取尺寸,直接返回
|
||||
const bitrateStr = estimatedBitrate > 0
|
||||
? estimatedBitrate >= 1000000
|
||||
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
||||
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
||||
: '未知';
|
||||
|
||||
resolve({
|
||||
quality: '未知',
|
||||
loadSpeed: actualLoadSpeed,
|
||||
pingTime: Math.round(pingTime),
|
||||
bitrate: bitrateStr,
|
||||
});
|
||||
}
|
||||
resolveCurrentState();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -309,7 +302,7 @@ export async function getVideoResolutionFromM3u8(
|
||||
});
|
||||
|
||||
// 为分片请求添加时间戳参数破除浏览器缓存
|
||||
hls.config.xhrSetup = function(xhr: XMLHttpRequest, url: string) {
|
||||
hls.config.xhrSetup = function (xhr: XMLHttpRequest, url: string) {
|
||||
const urlWithTimestamp = url.includes('?')
|
||||
? `${url}&_t=${Date.now()}`
|
||||
: `${url}?_t=${Date.now()}`;
|
||||
@@ -323,6 +316,22 @@ export async function getVideoResolutionFromM3u8(
|
||||
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
|
||||
console.error('HLS错误:', data);
|
||||
if (data.fatal) {
|
||||
const statusCode = data.response?.code || data.response?.status;
|
||||
// 防止 415 代理兜底熔断导致正常的二进制源在优选逻辑中被剔除
|
||||
if (statusCode === 415 && (m3u8Url.includes('/api/proxy-m3u8') || m3u8Url.includes('/api/proxy/vod/m3u8'))) {
|
||||
console.log('[测速] 测速通道嗅探到这是底层的媒体流文件,免测速通过');
|
||||
clearTimeout(timeout);
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
resolve({
|
||||
quality: '原生画质',
|
||||
loadSpeed: '直连',
|
||||
pingTime: 10,
|
||||
bitrate: '未知',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
hls.destroy();
|
||||
video.remove();
|
||||
@@ -397,3 +406,4 @@ export function base58Decode(encoded: string): string {
|
||||
// 在 Node.js 环境中使用 Buffer
|
||||
return Buffer.from(bytes).toString('utf-8');
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const CURRENT_VERSION = '214.1.0';
|
||||
const CURRENT_VERSION = '215.0.0';
|
||||
|
||||
// 导出当前版本号供其他地方使用
|
||||
export { CURRENT_VERSION };
|
||||
|
||||
Reference in New Issue
Block a user