From 3563d805104272c5e10357f22b2d52cfe0f40ac9 Mon Sep 17 00:00:00 2001 From: Troray Date: Tue, 10 Mar 2026 17:28:48 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(player):=E4=BF=AE=E5=A4=8D=E7=9B=B4?= =?UTF-8?q?=E9=93=BE=E6=92=AD=E6=94=BEm3u8=E6=97=B6=E8=B7=A8=E5=9F=9F?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=8F=8A=E5=88=86=E7=89=87=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/proxy-m3u8/route.ts | 25 +- src/app/api/proxy/vod/segment/route.ts | 19 +- src/app/play/page.tsx | 3564 ++++++++++++------------ 3 files changed, 1818 insertions(+), 1790 deletions(-) diff --git a/src/app/api/proxy-m3u8/route.ts b/src/app/api/proxy-m3u8/route.ts index 6d52a03..489c7c8 100644 --- a/src/app/api/proxy-m3u8/route.ts +++ b/src/app/api/proxy-m3u8/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; @@ -9,7 +9,7 @@ export const runtime = 'nodejs'; * 用于外部播放器访问,会执行去广告逻辑并处理相对链接 * 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'); @@ -38,8 +38,11 @@ export async function GET(request: Request) { // 优先级:SITE_BASE 环境变量 > 从请求头构建 let origin = process.env.SITE_BASE; if (!origin) { - const requestUrl = new URL(request.url); - origin = `${requestUrl.protocol}//${requestUrl.host}`; + // 从请求头中获取 Host 和协议 + const host = request.headers.get('host') || request.headers.get('x-forwarded-host'); + const proto = request.headers.get('x-forwarded-proto') || + (host?.includes('localhost') || host?.includes('127.0.0.1') ? 'http' : 'https'); + origin = `${proto}://${host}`; } // 获取原始 m3u8 内容 @@ -196,10 +199,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 +248,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); diff --git a/src/app/api/proxy/vod/segment/route.ts b/src/app/api/proxy/vod/segment/route.ts index 8b8a734..a0d7140 100644 --- a/src/app/api/proxy/vod/segment/route.ts +++ b/src/app/api/proxy/vod/segment/route.ts @@ -19,16 +19,19 @@ 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); + // 直链播放模式:跳过源站配置检查,直接代理 + if (source !== 'directplay') { + // 检查该视频源是否启用了代理模式 + const config = await getConfig(); + const videoSource = config.SourceConfig?.find((s: any) => s.key === source); - if (!videoSource) { - return NextResponse.json({ error: 'Source not found' }, { status: 404 }); - } + 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 }); + if (!videoSource.proxyMode) { + return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 }); + } } let response: Response | null = null; diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index c76c1eb..f44aabc 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -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,7 +47,7 @@ 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 { usePlaySync } from '@/hooks/usePlaySync'; @@ -1255,7 +1255,7 @@ function PlayPageClient() { const [videoUrl, setVideoUrl] = useState(''); // 视频清晰度列表 - const [videoQualities, setVideoQualities] = useState>([]); + const [videoQualities, setVideoQualities] = useState>([]); // Xiaoya链接刷新相关状态 const [isRefreshingUrl, setIsRefreshingUrl] = useState(false); // 是否正在刷新链接 @@ -1571,10 +1571,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)` ); }); @@ -2171,6 +2169,11 @@ function PlayPageClient() { // 如果视频源启用了代理模式,且不是本地下载,则通过代理播放 newUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(newUrl)}&source=${encodeURIComponent(currentSource)}`; console.log('使用代理模式播放:', newUrl); + } else if (currentSource === 'directplay' && newUrl) { + // 直链播放模式:通过 proxy-m3u8 代理播放,避免 CORS 问题 + const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : ''; + newUrl = `/api/proxy-m3u8?url=${encodeURIComponent(newUrl)}&source=directplay${tokenParam}`; + console.log('直链播放使用代理模式:', newUrl); } } @@ -2207,8 +2210,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 +2481,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 +2526,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 +2909,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 +3063,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 +3101,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 +3139,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) @@ -5041,8 +5044,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 +5127,461 @@ 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) { + setVideoError(`HTTP ${statusCode} 错误`); + } else { + // CORS 错误或其他网络错误 + 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: - '', - }, - settings: [ - { - html: '去广告', - icon: 'AD', - 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: + '', }, - { - html: '弹幕过滤', - icon: '', - 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: '', - 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: '', - switch: anime4kEnabledRef.current, - onSwitch: async function (item: any) { + html: '去广告', + icon: 'AD', + 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: '', + 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: '', + 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: '', + 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: '', - 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: '', + 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 +5594,7 @@ function PlayPageClient() { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); `; - container.innerHTML = ` + container.innerHTML = `
跳过配置
@@ -5625,110 +5650,110 @@ function PlayPageClient() { `; - 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: '', - 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: '', - tooltip: '全屏', - style: { - color: '#fff', + ], + // 控制栏配置 + controls: [ + { + position: 'left', + index: 13, + html: '', + 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: '', + 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 +5938,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 = `
@@ -6041,108 +6066,108 @@ function PlayPageClient() {
`; - // 添加到页面 - 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 = { - 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 = { + 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 +6182,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: '', - 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: '', + 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 +6648,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 +6660,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 +6761,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: ` `, - 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); + }); - // 检查是否已经到达90%播放进度 - if (duration > 0 && progress >= 0.9 && !nextEpisodeDanmakuPreloadTriggeredRef.current) { - // 标记已触发,防止重复执行 - nextEpisodeDanmakuPreloadTriggeredRef.current = true; + // 监听视频时间更新事件,实现跳过片头片尾 + artPlayerRef.current.on('video:timeupdate', () => { + if (!skipConfigRef.current.enable) return; - // 异步执行弹幕预加载 - preloadNextEpisodeDanmaku(); + 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.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) { + // 静默处理预缓冲失败 + } + }; + + // 异步执行预缓冲 + 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 +7249,27 @@ function PlayPageClient() {
@@ -7258,11 +7280,11 @@ function PlayPageClient() { style={{ width: loadingStage === 'searching' || - loadingStage === 'fetching' + loadingStage === 'fetching' ? '33%' : loadingStage === 'preferring' - ? '66%' - : '100%', + ? '66%' + : '100%', }} >
@@ -7412,9 +7434,9 @@ function PlayPageClient() {
+ fill='none' stroke='currentColor' viewBox='0 0 24 24'> + d='M9 5l7 7-7 7' />
@@ -7519,11 +7541,10 @@ function PlayPageClient() { return ( {status === 'completed' ? '已完结' : '连载中'} @@ -7545,9 +7566,8 @@ function PlayPageClient() { } >
{/* 播放器 */}
{/* 播放器容器 */}
@@ -7675,8 +7692,8 @@ function PlayPageClient() {
- - + + 正在刷新链接...
@@ -7797,12 +7814,12 @@ function PlayPageClient() { @@ -7873,172 +7890,171 @@ function PlayPageClient() { - {/* VLC */} - VLC - - VLC - - + {/* VLC */} + - {/* MPV */} - + {/* MPV */} + - {/* MX Player */} - + {/* MX Player */} + - {/* nPlayer */} - + {/* nPlayer */} + - {/* IINA */} - + {/* IINA */} +
{/* 去广告开关 */} + {/* 直链播放 CORS 失败时,显示"使用代理播放"按钮 */} + {!proxyAttemptedRef.current && (corsFailedUrl || (isDirectPlay && videoUrl && !videoUrl.includes('/api/proxy-m3u8'))) && ( + + )}
) : ( diff --git a/src/lib/utils.ts b/src/lib/utils.ts index fccc96c..83ec841 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -316,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(); From 3e9fdbb7a046f7629d5975d0c232ec02d4242543 Mon Sep 17 00:00:00 2001 From: Troray Date: Wed, 11 Mar 2026 21:07:46 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E5=BC=BA=E5=8C=96=20SSRF=20?= =?UTF-8?q?=E9=98=B2=E6=8A=A4=E3=80=81=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E5=AE=89=E5=85=A8=E6=BC=8F=E6=B4=9E=E4=B8=8E?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=B4=A8=E9=87=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 Gemini Code Assist 审查建议,对代理链路进行全面安全加固与代码优化。 - [新增] `src/lib/server/ssrf.ts`: 使用 `dns.promises.lookup` 进行真实 IP 解析, 替代原有的正则匹配,防御 DNS 重绑定、非十进制 IP 等绕过手段 - [新增] 为 `proxy/vod/m3u8`、`proxy/vod/key`、`video-proxy` 三个接口补齐 SSRF 校验, 此前仅 `proxy-m3u8` 和 `proxy/vod/segment` 有防护 - [删除] `utils.ts` 中已弃用的 `isValidUrlForProxy` 函数 - 所有代理接口统一强制 SSRF 校验,不再仅限于 `source=directplay` - 修复 `proxy/vod/segment` 中 `isCancelled` 为 `const` 导致流取消信号失效的问题 - 修复 `proxy-m3u8/route.ts` 中导入不存在的函数(`extractResolutionFromM3u8`, `filterAdsFromM3U8Default`, `resolveM3u8Links`)的构建错误 - 修复直链直连模式下 `fetchCurrentSourceVideoInfo` 使用 HLS.js (XHR) 探测视频分辨率 触发 CORS 误报的问题,改为直接跳过探测 - 移除 `proxy/vod/segment` 中未使用的 `NextRequest` 导入 - [新增] `src/lib/server/proxy-headers.ts`: 抽取 CORS 响应头为共享工具函数, 消除 `proxy/vod/segment`、`proxy/vod/key`、`proxy/vod/m3u8` 中重复代码 - 统一使用 `DIRECT_PLAY_SOURCE` 常量替代硬编码 `'directplay'` 字符串 - `src/lib/server/ssrf.ts` - `src/lib/server/proxy-headers.ts` - `/app/api/proxy-m3u8/route` - `/app/api/proxy/vod/key/route` - `/app/api/proxy/vod/m3u8/route` - `/app/api/proxy/vod/segment/route` - `/app/api/video-proxy/route` - `/app/play/page`x - `/lib/utils` --- src/app/api/proxy-m3u8/route.ts | 21 ++++-- src/app/api/proxy/vod/key/route.ts | 18 +++-- src/app/api/proxy/vod/m3u8/route.ts | 25 ++++--- src/app/api/proxy/vod/segment/route.ts | 31 ++++----- src/app/api/video-proxy/route.ts | 7 ++ src/app/play/page.tsx | 7 ++ src/lib/server/proxy-headers.ts | 39 +++++++++++ src/lib/server/ssrf.ts | 94 ++++++++++++++++++++++++++ src/lib/utils.ts | 44 ------------ 9 files changed, 200 insertions(+), 86 deletions(-) create mode 100644 src/lib/server/proxy-headers.ts create mode 100644 src/lib/server/ssrf.ts diff --git a/src/app/api/proxy-m3u8/route.ts b/src/app/api/proxy-m3u8/route.ts index 1889a12..58300a0 100644 --- a/src/app/api/proxy-m3u8/route.ts +++ b/src/app/api/proxy-m3u8/route.ts @@ -1,10 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { getConfig } from '@/lib/config'; -import { isValidUrlForProxy } from '@/lib/utils'; +import { validateProxyUrlServerSide } from '@/lib/server/ssrf'; export const runtime = 'nodejs'; +export const maxDuration = 60; // 设置最大执行时间为 60 秒 + /** * M3U8 代理接口 * 用于外部播放器访问,会执行去广告逻辑并处理相对链接 @@ -36,8 +38,9 @@ export async function GET(request: NextRequest) { } const DIRECT_PLAY_SOURCE = 'directplay'; - // 安全校验:防 SSRF,只允许合法的公网 URL - if (source === DIRECT_PLAY_SOURCE && !isValidUrlForProxy(m3u8Url)) { + // 安全校验:防 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 } @@ -102,7 +105,7 @@ export async function GET(request: NextRequest) { ); if (!isTextType) { - if (source === 'directplay') { + if (source === DIRECT_PLAY_SOURCE) { console.log(`[Proxy-M3U8] 检测到非文本媒体流 (Content-Type: ${contentType}), 针对 directplay 直链代理模式,直接透传二进制流, URL: ${m3u8Url}`); // 构造一个新的 Response 对象用于二进制直接透传,确保包含了支持跨域的 header const newHeaders = new Headers(response.headers); @@ -189,7 +192,10 @@ export async function GET(request: NextRequest) { } /** - * 默认去广告规则 + * 默认去广告规则(服务端版本) + * 注意:前端 page.tsx 中的 filterAdsFromM3U8 是客户端侧的去广告逻辑(用于直连模式下由 HLS.js 的自定义 loader 拦截)。 + * 本函数用于代理模式下,在服务端对 m3u8 内容进行去广告处理后再返回给客户端。 + * 两套逻辑需要保持同步更新。 */ function filterAdsFromM3U8Default(type: string, m3u8Content: string): string { if (!m3u8Content) return ''; @@ -245,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'); diff --git a/src/app/api/proxy/vod/key/route.ts b/src/app/api/proxy/vod/key/route.ts index eb7762d..8223e51 100644 --- a/src/app/api/proxy/vod/key/route.ts +++ b/src/app/api/proxy/vod/key/route.ts @@ -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) { diff --git a/src/app/api/proxy/vod/m3u8/route.ts b/src/app/api/proxy/vod/m3u8/route.ts index 9e3590e..b1fab92 100644 --- a/src/app/api/proxy/vod/m3u8/route.ts +++ b/src/app/api/proxy/vod/m3u8/route.ts @@ -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, { diff --git a/src/app/api/proxy/vod/segment/route.ts b/src/app/api/proxy/vod/segment/route.ts index 2c8d3d3..4682d2e 100644 --- a/src/app/api/proxy/vod/segment/route.ts +++ b/src/app/api/proxy/vod/segment/route.ts @@ -1,9 +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 { isValidUrlForProxy } from "@/lib/utils"; +import { getConfig } from '@/lib/config'; +import { validateProxyUrlServerSide } from '@/lib/server/ssrf'; +import { buildProxyStreamHeaders } from '@/lib/server/proxy-headers'; export const runtime = 'nodejs'; @@ -44,8 +45,9 @@ export async function GET(request: Request) { try { const decodedUrl = decodeURIComponent(url); - // 安全校验:防 SSRF 拦截请求内网或非法 URL - if (source === DIRECT_PLAY_SOURCE && !isValidUrlForProxy(decodedUrl)) { + // 安全校验:防 SSRF 拦截请求内网或非法 URL (强制检查所有代理请求) + const isSafeUrl = await validateProxyUrlServerSide(decodedUrl); + if (!isSafeUrl) { return NextResponse.json({ error: 'Proxy request to local or invalid network is forbidden' }, { status: 403 }); } @@ -59,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) { @@ -80,7 +77,6 @@ export async function GET(request: Request) { } reader = response.body.getReader(); - const isCancelled = false; function pump() { if (isCancelled || !reader) { @@ -122,6 +118,7 @@ export async function GET(request: Request) { pump(); }, cancel() { + isCancelled = true; // 当流被取消时,确保释放所有资源 if (reader) { try { diff --git a/src/app/api/video-proxy/route.ts b/src/app/api/video-proxy/route.ts index ddf85e1..7df8bb8 100644 --- a/src/app/api/video-proxy/route.ts +++ b/src/app/api/video-proxy/route.ts @@ -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'); diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index ceda03e..ec0f0da 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1452,6 +1452,13 @@ function PlayPageClient() { 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 限制,探测必然失败。实际播放器通过