fix(player):修复直链播放m3u8时跨域错误及分片加载失败的问题
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ export const runtime = 'nodejs';
|
|||||||
* 用于外部播放器访问,会执行去广告逻辑并处理相对链接
|
* 用于外部播放器访问,会执行去广告逻辑并处理相对链接
|
||||||
* GET /api/proxy-m3u8?url=<原始m3u8地址>&source=<播放源>&token=<鉴权token>
|
* GET /api/proxy-m3u8?url=<原始m3u8地址>&source=<播放源>&token=<鉴权token>
|
||||||
*/
|
*/
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const m3u8Url = searchParams.get('url');
|
const m3u8Url = searchParams.get('url');
|
||||||
@@ -38,8 +38,11 @@ export async function GET(request: Request) {
|
|||||||
// 优先级:SITE_BASE 环境变量 > 从请求头构建
|
// 优先级:SITE_BASE 环境变量 > 从请求头构建
|
||||||
let origin = process.env.SITE_BASE;
|
let origin = process.env.SITE_BASE;
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
const requestUrl = new URL(request.url);
|
// 从请求头中获取 Host 和协议
|
||||||
origin = `${requestUrl.protocol}//${requestUrl.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 内容
|
// 获取原始 m3u8 内容
|
||||||
@@ -196,10 +199,15 @@ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string,
|
|||||||
} else {
|
} else {
|
||||||
keyUri = new URL(keyUri, baseDir).href;
|
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);
|
resolvedLines.push(line);
|
||||||
continue;
|
continue;
|
||||||
@@ -240,6 +248,9 @@ function resolveM3u8Links(m3u8Content: string, baseUrl: string, source: string,
|
|||||||
if (isM3u8) {
|
if (isM3u8) {
|
||||||
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '';
|
const tokenParam = token ? `&token=${encodeURIComponent(token)}` : '';
|
||||||
url = `${proxyOrigin}/api/proxy-m3u8?url=${encodeURIComponent(url)}${source ? `&source=${encodeURIComponent(source)}` : ''}${tokenParam}`;
|
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);
|
resolvedLines.push(url);
|
||||||
|
|||||||
@@ -19,16 +19,19 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing source' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查该视频源是否启用了代理模式
|
// 直链播放模式:跳过源站配置检查,直接代理
|
||||||
const config = await getConfig();
|
if (source !== 'directplay') {
|
||||||
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
// 检查该视频源是否启用了代理模式
|
||||||
|
const config = await getConfig();
|
||||||
|
const videoSource = config.SourceConfig?.find((s: any) => s.key === source);
|
||||||
|
|
||||||
if (!videoSource) {
|
if (!videoSource) {
|
||||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!videoSource.proxyMode) {
|
if (!videoSource.proxyMode) {
|
||||||
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
|
return NextResponse.json({ error: 'Proxy mode not enabled for this source' }, { status: 403 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: Response | null = null;
|
let response: Response | null = null;
|
||||||
|
|||||||
+1789
-1775
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client';
|
'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 { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
saveDanmakuSourceIndex,
|
saveDanmakuSourceIndex,
|
||||||
saveManualDanmakuSelection,
|
saveManualDanmakuSelection,
|
||||||
} from '@/lib/danmaku/selection-memory';
|
} 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 {
|
import {
|
||||||
deleteFavorite,
|
deleteFavorite,
|
||||||
deletePlayRecord,
|
deletePlayRecord,
|
||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
} from '@/lib/db.client';
|
} from '@/lib/db.client';
|
||||||
import { getDoubanDetail } from '@/lib/douban.client';
|
import { getDoubanDetail } from '@/lib/douban.client';
|
||||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
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 { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||||
import { useEnableComments } from '@/hooks/useEnableComments';
|
import { useEnableComments } from '@/hooks/useEnableComments';
|
||||||
import { usePlaySync } from '@/hooks/usePlaySync';
|
import { usePlaySync } from '@/hooks/usePlaySync';
|
||||||
@@ -1255,7 +1255,7 @@ function PlayPageClient() {
|
|||||||
const [videoUrl, setVideoUrl] = useState('');
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
|
|
||||||
// 视频清晰度列表
|
// 视频清晰度列表
|
||||||
const [videoQualities, setVideoQualities] = useState<Array<{name: string, url: string}>>([]);
|
const [videoQualities, setVideoQualities] = useState<Array<{ name: string, url: string }>>([]);
|
||||||
|
|
||||||
// Xiaoya链接刷新相关状态
|
// Xiaoya链接刷新相关状态
|
||||||
const [isRefreshingUrl, setIsRefreshingUrl] = useState(false); // 是否正在刷新链接
|
const [isRefreshingUrl, setIsRefreshingUrl] = useState(false); // 是否正在刷新链接
|
||||||
@@ -1571,10 +1571,8 @@ function PlayPageClient() {
|
|||||||
console.log('播放源评分排序结果:');
|
console.log('播放源评分排序结果:');
|
||||||
resultsWithScore.forEach((result, index) => {
|
resultsWithScore.forEach((result, index) => {
|
||||||
console.log(
|
console.log(
|
||||||
`${index + 1}. ${
|
`${index + 1}. ${result.source.source_name
|
||||||
result.source.source_name
|
} - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${result.testResult.loadSpeed
|
||||||
} - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${
|
|
||||||
result.testResult.loadSpeed
|
|
||||||
}, ${result.testResult.pingTime}ms)`
|
}, ${result.testResult.pingTime}ms)`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -2171,6 +2169,11 @@ function PlayPageClient() {
|
|||||||
// 如果视频源启用了代理模式,且不是本地下载,则通过代理播放
|
// 如果视频源启用了代理模式,且不是本地下载,则通过代理播放
|
||||||
newUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(newUrl)}&source=${encodeURIComponent(currentSource)}`;
|
newUrl = `/api/proxy/vod/m3u8?url=${encodeURIComponent(newUrl)}&source=${encodeURIComponent(currentSource)}`;
|
||||||
console.log('使用代理模式播放:', newUrl);
|
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
|
const proxyUrl = offlineMode
|
||||||
? episodeUrl // 离线下载不使用代理,直接使用原始URL
|
? episodeUrl // 离线下载不使用代理,直接使用原始URL
|
||||||
: (externalPlayerAdBlock
|
: (externalPlayerAdBlock
|
||||||
? `${origin}/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
? `${origin}/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||||
: episodeUrl);
|
: episodeUrl);
|
||||||
|
|
||||||
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u8') || episodeUrl.toLowerCase().includes('/m3u8/');
|
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u8') || episodeUrl.toLowerCase().includes('/m3u8/');
|
||||||
|
|
||||||
@@ -2478,7 +2481,7 @@ function PlayPageClient() {
|
|||||||
// 验证outputCanvas尺寸
|
// 验证outputCanvas尺寸
|
||||||
console.log('outputCanvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
|
console.log('outputCanvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
|
||||||
if (!outputCanvas.width || !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}`);
|
throw new Error(`outputCanvas尺寸无效: ${outputCanvas.width}x${outputCanvas.height}, scale: ${scale}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2523,7 +2526,7 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
// 验证sourceCanvas尺寸
|
// 验证sourceCanvas尺寸
|
||||||
if (!sourceCanvas.width || !sourceCanvas.height ||
|
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}`);
|
throw new Error(`sourceCanvas尺寸无效: ${sourceCanvas.width}x${sourceCanvas.height}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2906,7 +2909,7 @@ function PlayPageClient() {
|
|||||||
setSkipConfig(newConfig);
|
setSkipConfig(newConfig);
|
||||||
if (!newConfig.enable && !newConfig.intro_time && !newConfig.outro_time) {
|
if (!newConfig.enable && !newConfig.intro_time && !newConfig.outro_time) {
|
||||||
await deleteSkipConfig(currentSourceRef.current, currentIdRef.current);
|
await deleteSkipConfig(currentSourceRef.current, currentIdRef.current);
|
||||||
|
|
||||||
// 安全地更新播放器设置,仅在播放器存在时执行
|
// 安全地更新播放器设置,仅在播放器存在时执行
|
||||||
if (artPlayerRef.current && artPlayerRef.current.setting) {
|
if (artPlayerRef.current && artPlayerRef.current.setting) {
|
||||||
try {
|
try {
|
||||||
@@ -3060,13 +3063,13 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
// 2.1 明确包含"电影"或"movie"或"片"的,判断为电影
|
// 2.1 明确包含"电影"或"movie"或"片"的,判断为电影
|
||||||
if (typeName.includes('电影') || typeName.includes('movie') ||
|
if (typeName.includes('电影') || typeName.includes('movie') ||
|
||||||
typeName.endsWith('片') && !typeName.includes('动漫')) {
|
typeName.endsWith('片') && !typeName.includes('动漫')) {
|
||||||
return 'movie';
|
return 'movie';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2.2 包含"剧"、"动漫"、"综艺"等关键词的,判断为剧集
|
// 2.2 包含"剧"、"动漫"、"综艺"等关键词的,判断为剧集
|
||||||
if (typeName.includes('剧') || typeName.includes('动漫') ||
|
if (typeName.includes('剧') || typeName.includes('动漫') ||
|
||||||
typeName.includes('综艺') || typeName.includes('anime')) {
|
typeName.includes('综艺') || typeName.includes('anime')) {
|
||||||
return 'tv';
|
return 'tv';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3098,18 +3101,18 @@ function PlayPageClient() {
|
|||||||
const cachedData = JSON.parse(cached);
|
const cachedData = JSON.parse(cached);
|
||||||
|
|
||||||
// 处理缓存的搜索结果,根据规则过滤
|
// 处理缓存的搜索结果,根据规则过滤
|
||||||
results = cachedData.filter(
|
results = cachedData.filter(
|
||||||
(result: SearchResult) =>
|
(result: SearchResult) =>
|
||||||
normalizeTitle(result.title).toLowerCase() ===
|
normalizeTitle(result.title).toLowerCase() ===
|
||||||
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
||||||
(videoYearRef.current
|
(videoYearRef.current
|
||||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
|
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
|
||||||
!result.year ||
|
!result.year ||
|
||||||
result.year.trim() === '' ||
|
result.year.trim() === '' ||
|
||||||
result.year === 'unknown' ||
|
result.year === 'unknown' ||
|
||||||
!/^\d{4}$/.test(result.year)
|
!/^\d{4}$/.test(result.year)
|
||||||
: true) &&
|
: true) &&
|
||||||
(searchType
|
(searchType
|
||||||
? getType(result) === searchType
|
? getType(result) === searchType
|
||||||
: true)
|
: true)
|
||||||
);
|
);
|
||||||
@@ -3136,14 +3139,14 @@ function PlayPageClient() {
|
|||||||
results = data.results.filter(
|
results = data.results.filter(
|
||||||
(result: SearchResult) =>
|
(result: SearchResult) =>
|
||||||
normalizeTitle(result.title).toLowerCase() ===
|
normalizeTitle(result.title).toLowerCase() ===
|
||||||
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
normalizeTitle(videoTitleRef.current).toLowerCase() &&
|
||||||
(videoYearRef.current
|
(videoYearRef.current
|
||||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
|
? result.year.toLowerCase() === videoYearRef.current.toLowerCase() ||
|
||||||
!result.year ||
|
!result.year ||
|
||||||
result.year.trim() === '' ||
|
result.year.trim() === '' ||
|
||||||
result.year === 'unknown' ||
|
result.year === 'unknown' ||
|
||||||
!/^\d{4}$/.test(result.year)
|
!/^\d{4}$/.test(result.year)
|
||||||
: true) &&
|
: true) &&
|
||||||
(searchType
|
(searchType
|
||||||
? getType(result) === searchType
|
? getType(result) === searchType
|
||||||
: true)
|
: true)
|
||||||
@@ -5041,8 +5044,29 @@ function PlayPageClient() {
|
|||||||
return false;
|
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方法切换
|
// 非WebKit浏览器且播放器已存在,使用switch方法切换
|
||||||
if (!isWebkit && artPlayerRef.current) {
|
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.switch = videoUrl;
|
||||||
artPlayerRef.current.title = `${videoTitle} - ${playerEpisodeLabel}`;
|
artPlayerRef.current.title = `${videoTitle} - ${playerEpisodeLabel}`;
|
||||||
artPlayerRef.current.poster = videoCover;
|
artPlayerRef.current.poster = videoCover;
|
||||||
@@ -5103,460 +5127,461 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
artPlayerRef.current = new Artplayer({
|
artPlayerRef.current = new Artplayer({
|
||||||
container: artRef.current!,
|
container: artRef.current!,
|
||||||
url: videoUrl,
|
url: videoUrl,
|
||||||
poster: videoCover,
|
...(getVideoType(videoUrl) ? { type: getVideoType(videoUrl) } : {}),
|
||||||
volume: 0.7,
|
poster: videoCover,
|
||||||
isLive: false,
|
volume: 0.7,
|
||||||
muted: false,
|
isLive: false,
|
||||||
autoplay: true,
|
muted: false,
|
||||||
pip: true,
|
autoplay: true,
|
||||||
autoSize: false,
|
pip: true,
|
||||||
autoMini: false,
|
autoSize: false,
|
||||||
screenshot: true,
|
autoMini: false,
|
||||||
setting: true,
|
screenshot: true,
|
||||||
loop: false,
|
setting: true,
|
||||||
flip: false,
|
loop: false,
|
||||||
playbackRate: true,
|
flip: false,
|
||||||
aspectRatio: false,
|
playbackRate: true,
|
||||||
fullscreen: !isIOS, // iOS 禁用原生全屏按钮,避免触发系统播放器
|
aspectRatio: false,
|
||||||
fullscreenWeb: true, // 保留网页全屏按钮(所有平台)
|
fullscreen: !isIOS, // iOS 禁用原生全屏按钮,避免触发系统播放器
|
||||||
...(currentSubtitles.length > 0 ? {
|
fullscreenWeb: true, // 保留网页全屏按钮(所有平台)
|
||||||
subtitle: {
|
...(currentSubtitles.length > 0 ? {
|
||||||
url: currentSubtitles[0].url,
|
subtitle: {
|
||||||
type: 'vtt',
|
url: currentSubtitles[0].url,
|
||||||
style: {
|
type: 'vtt',
|
||||||
color: '#fff',
|
style: {
|
||||||
fontSize: savedSubtitleSize,
|
color: '#fff',
|
||||||
},
|
fontSize: savedSubtitleSize,
|
||||||
encoding: 'utf-8',
|
},
|
||||||
}
|
encoding: 'utf-8',
|
||||||
} : {}),
|
}
|
||||||
subtitleOffset: false,
|
} : {}),
|
||||||
miniProgressBar: false,
|
subtitleOffset: false,
|
||||||
mutex: true,
|
miniProgressBar: false,
|
||||||
playsInline: true,
|
mutex: 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: {
|
|
||||||
playsInline: true,
|
playsInline: true,
|
||||||
'webkit-playsinline': 'true',
|
autoPlayback: false,
|
||||||
referrerpolicy: 'no-referrer',
|
airplay: true,
|
||||||
} as any,
|
theme: '#22c55e',
|
||||||
// HLS 支持配置
|
lang: 'zh-cn',
|
||||||
customType: {
|
hotkey: false,
|
||||||
m3u8: function (video: HTMLVideoElement, url: string) {
|
fastForward: true,
|
||||||
if (!Hls) {
|
autoOrientation: true,
|
||||||
console.error('HLS.js 未加载');
|
lock: true,
|
||||||
return;
|
...(videoQualities.length > 0 ? {
|
||||||
}
|
quality: videoQualities.map((q, index) => ({
|
||||||
|
default: index === 0,
|
||||||
if (video.hls) {
|
html: q.name,
|
||||||
video.hls.destroy();
|
url: q.url,
|
||||||
}
|
})),
|
||||||
|
} : {}),
|
||||||
// 每次创建HLS实例时,都读取最新的blockAdEnabled状态
|
moreVideoAttr: {
|
||||||
const shouldUseCustomLoader = blockAdEnabledRef.current;
|
playsInline: true,
|
||||||
|
'webkit-playsinline': 'true',
|
||||||
// 从localStorage读取缓冲策略
|
referrerpolicy: 'no-referrer',
|
||||||
const bufferStrategy = typeof window !== 'undefined'
|
} as any,
|
||||||
? localStorage.getItem('bufferStrategy') || 'medium'
|
// HLS 支持配置
|
||||||
: 'medium';
|
customType: {
|
||||||
|
m3u8: function (video: HTMLVideoElement, url: string) {
|
||||||
// 根据缓冲策略配置不同的缓冲参数
|
if (!Hls) {
|
||||||
const getBufferConfig = (strategy: string) => {
|
console.error('HLS.js 未加载');
|
||||||
switch (strategy) {
|
return;
|
||||||
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);
|
if (video.hls) {
|
||||||
|
video.hls.destroy();
|
||||||
// 选择合适的 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) {
|
// 每次创建HLS实例时,都读取最新的blockAdEnabled状态
|
||||||
console.error('HLS Error:', event, data);
|
const shouldUseCustomLoader = blockAdEnabledRef.current;
|
||||||
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;
|
// 从localStorage读取缓冲策略
|
||||||
|
const bufferStrategy = typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem('bufferStrategy') || 'medium'
|
||||||
|
: 'medium';
|
||||||
|
|
||||||
// 如果是403且是xiaoya源的m3u8,尝试自动刷新
|
// 根据缓冲策略配置不同的缓冲参数
|
||||||
if (statusCode === 403 && currentXiaoyaUrlRef.current) {
|
const getBufferConfig = (strategy: string) => {
|
||||||
const isM3u8 = url.includes('.m3u8') || url.includes('m3u8');
|
switch (strategy) {
|
||||||
if (isM3u8) {
|
case 'low':
|
||||||
console.log('[HLS错误] 检测到403,尝试刷新链接');
|
return {
|
||||||
refreshXiaoyaUrl(hls, video, false);
|
maxBufferLength: 15,
|
||||||
return; // 不执行后续的错误处理
|
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();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
// 检查其他 HTTP 错误状态码
|
||||||
console.log('网络错误,尝试恢复...');
|
{
|
||||||
hls.startLoad();
|
const statusCode = data.response?.code || data.response?.status;
|
||||||
break;
|
if (statusCode && statusCode >= 400) {
|
||||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
console.log(`HTTP ${statusCode} 错误`);
|
||||||
console.log('媒体错误,尝试恢复...');
|
hls.destroy();
|
||||||
hls.recoverMediaError();
|
setVideoError(`HTTP ${statusCode} 错误`);
|
||||||
break;
|
return;
|
||||||
default:
|
}
|
||||||
console.log('无法恢复的错误');
|
}
|
||||||
hls.destroy();
|
console.log('网络错误,尝试恢复...');
|
||||||
setVideoError('视频加载错误');
|
hls.startLoad();
|
||||||
break;
|
break;
|
||||||
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
|
console.log('媒体错误,尝试恢复...');
|
||||||
|
hls.recoverMediaError();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log('无法恢复的错误');
|
||||||
|
hls.destroy();
|
||||||
|
setVideoError('视频加载错误');
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
},
|
||||||
},
|
},
|
||||||
},
|
// 弹幕插件
|
||||||
// 弹幕插件
|
plugins: [
|
||||||
plugins: [
|
artplayerPluginDanmuku({
|
||||||
artplayerPluginDanmuku({
|
danmuku: [],
|
||||||
danmuku: [],
|
speed: danmakuSettingsRef.current.speed,
|
||||||
speed: danmakuSettingsRef.current.speed,
|
opacity: danmakuSettingsRef.current.opacity,
|
||||||
opacity: danmakuSettingsRef.current.opacity,
|
fontSize: danmakuSettingsRef.current.fontSize,
|
||||||
fontSize: danmakuSettingsRef.current.fontSize,
|
color: '#FFFFFF',
|
||||||
color: '#FFFFFF',
|
mode: 0,
|
||||||
mode: 0,
|
margin: [danmakuSettingsRef.current.marginTop, danmakuSettingsRef.current.marginBottom],
|
||||||
margin: [danmakuSettingsRef.current.marginTop, danmakuSettingsRef.current.marginBottom],
|
antiOverlap: true,
|
||||||
antiOverlap: true,
|
synchronousPlayback: danmakuSettingsRef.current.synchronousPlayback,
|
||||||
synchronousPlayback: danmakuSettingsRef.current.synchronousPlayback,
|
emitter: false,
|
||||||
emitter: false,
|
heatmap: false, // 禁用 artplayer 自带热力图,使用自定义热力图
|
||||||
heatmap: false, // 禁用 artplayer 自带热力图,使用自定义热力图
|
// 主题
|
||||||
// 主题
|
theme: 'dark',
|
||||||
theme: 'dark',
|
// 根据保存的显示状态设置初始可见性
|
||||||
// 根据保存的显示状态设置初始可见性
|
visible: danmakuDisplayStateRef.current,
|
||||||
visible: danmakuDisplayStateRef.current,
|
filter: (danmu: any) => {
|
||||||
filter: (danmu: any) => {
|
// 应用过滤规则
|
||||||
// 应用过滤规则
|
const filterConfig = danmakuFilterConfigRef.current;
|
||||||
const filterConfig = danmakuFilterConfigRef.current;
|
if (filterConfig && filterConfig.rules.length > 0) {
|
||||||
if (filterConfig && filterConfig.rules.length > 0) {
|
for (const rule of filterConfig.rules) {
|
||||||
for (const rule of filterConfig.rules) {
|
// 跳过未启用的规则
|
||||||
// 跳过未启用的规则
|
if (!rule.enabled) continue;
|
||||||
if (!rule.enabled) continue;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (rule.type === 'normal') {
|
if (rule.type === 'normal') {
|
||||||
// 普通模式:字符串包含匹配
|
// 普通模式:字符串包含匹配
|
||||||
if (danmu.text.includes(rule.keyword)) {
|
if (danmu.text.includes(rule.keyword)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else if (rule.type === 'regex') {
|
} else if (rule.type === 'regex') {
|
||||||
// 正则模式:正则表达式匹配
|
// 正则模式:正则表达式匹配
|
||||||
if (new RegExp(rule.keyword).test(danmu.text)) {
|
if (new RegExp(rule.keyword).test(danmu.text)) {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('弹幕过滤规则错误:', e);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error('弹幕过滤规则错误:', e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return true;
|
||||||
return true;
|
},
|
||||||
},
|
}),
|
||||||
}),
|
],
|
||||||
],
|
icons: {
|
||||||
icons: {
|
loading:
|
||||||
loading:
|
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
|
||||||
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
|
|
||||||
},
|
|
||||||
settings: [
|
|
||||||
{
|
|
||||||
html: '去广告',
|
|
||||||
icon: '<text x="50%" y="50%" font-size="20" font-weight="bold" text-anchor="middle" dominant-baseline="middle" fill="#ffffff">AD</text>',
|
|
||||||
tooltip: blockAdEnabled ? '已开启' : '已关闭',
|
|
||||||
onClick() {
|
|
||||||
const newVal = !blockAdEnabled;
|
|
||||||
try {
|
|
||||||
localStorage.setItem('enable_blockad', String(newVal));
|
|
||||||
if (artPlayerRef.current) {
|
|
||||||
resumeTimeRef.current = artPlayerRef.current.currentTime;
|
|
||||||
if (
|
|
||||||
artPlayerRef.current.video &&
|
|
||||||
artPlayerRef.current.video.hls
|
|
||||||
) {
|
|
||||||
artPlayerRef.current.video.hls.destroy();
|
|
||||||
}
|
|
||||||
artPlayerRef.current.destroy();
|
|
||||||
artPlayerRef.current = null;
|
|
||||||
}
|
|
||||||
setBlockAdEnabled(newVal);
|
|
||||||
} catch (_) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return newVal ? '当前开启' : '当前关闭';
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
settings: [
|
||||||
html: '弹幕过滤',
|
|
||||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="#ffffff"/><path d="M8 12h8" stroke="#ffffff" stroke-width="2" stroke-linecap="round"/></svg>',
|
|
||||||
tooltip: '配置弹幕过滤规则',
|
|
||||||
onClick() {
|
|
||||||
// 如果播放器处于全屏状态,先退出全屏
|
|
||||||
if (artPlayerRef.current && artPlayerRef.current.fullscreen) {
|
|
||||||
artPlayerRef.current.fullscreen = false;
|
|
||||||
// 延迟一下再显示弹窗,确保全屏退出动画完成
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowDanmakuFilterSettings(true);
|
|
||||||
}, 300);
|
|
||||||
} else {
|
|
||||||
setShowDanmakuFilterSettings(true);
|
|
||||||
}
|
|
||||||
return '打开设置';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// 热力图开关(仅在未禁用时显示)
|
|
||||||
...(!danmakuHeatmapDisabledRef.current ? [{
|
|
||||||
name: '弹幕热力',
|
|
||||||
html: '弹幕热力',
|
|
||||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" fill="#ffffff"/></svg>',
|
|
||||||
switch: danmakuHeatmapEnabledRef.current,
|
|
||||||
onSwitch: function (item: any) {
|
|
||||||
const newVal = !item.switch;
|
|
||||||
try {
|
|
||||||
localStorage.setItem('danmaku_heatmap_enabled', String(newVal));
|
|
||||||
setDanmakuHeatmapEnabled(newVal);
|
|
||||||
console.log('弹幕热力已', newVal ? '开启' : '关闭');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('切换弹幕热力失败:', err);
|
|
||||||
}
|
|
||||||
return newVal;
|
|
||||||
},
|
|
||||||
}] : []),
|
|
||||||
...(webGPUSupported ? [
|
|
||||||
{
|
{
|
||||||
name: 'Anime4K超分',
|
html: '去广告',
|
||||||
html: 'Anime4K超分',
|
icon: '<text x="50%" y="50%" font-size="20" font-weight="bold" text-anchor="middle" dominant-baseline="middle" fill="#ffffff">AD</text>',
|
||||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5zm0 18c-4 0-7-3-7-7V9l7-3.5L19 9v4c0 4-3 7-7 7z" fill="#ffffff"/><path d="M10 12l2 2 4-4" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
tooltip: blockAdEnabled ? '已开启' : '已关闭',
|
||||||
switch: anime4kEnabledRef.current,
|
onClick() {
|
||||||
onSwitch: async function (item: any) {
|
const newVal = !blockAdEnabled;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('enable_blockad', String(newVal));
|
||||||
|
if (artPlayerRef.current) {
|
||||||
|
resumeTimeRef.current = artPlayerRef.current.currentTime;
|
||||||
|
if (
|
||||||
|
artPlayerRef.current.video &&
|
||||||
|
artPlayerRef.current.video.hls
|
||||||
|
) {
|
||||||
|
artPlayerRef.current.video.hls.destroy();
|
||||||
|
}
|
||||||
|
artPlayerRef.current.destroy();
|
||||||
|
artPlayerRef.current = null;
|
||||||
|
}
|
||||||
|
setBlockAdEnabled(newVal);
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return newVal ? '当前开启' : '当前关闭';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
html: '弹幕过滤',
|
||||||
|
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="#ffffff"/><path d="M8 12h8" stroke="#ffffff" stroke-width="2" stroke-linecap="round"/></svg>',
|
||||||
|
tooltip: '配置弹幕过滤规则',
|
||||||
|
onClick() {
|
||||||
|
// 如果播放器处于全屏状态,先退出全屏
|
||||||
|
if (artPlayerRef.current && artPlayerRef.current.fullscreen) {
|
||||||
|
artPlayerRef.current.fullscreen = false;
|
||||||
|
// 延迟一下再显示弹窗,确保全屏退出动画完成
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowDanmakuFilterSettings(true);
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
setShowDanmakuFilterSettings(true);
|
||||||
|
}
|
||||||
|
return '打开设置';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 热力图开关(仅在未禁用时显示)
|
||||||
|
...(!danmakuHeatmapDisabledRef.current ? [{
|
||||||
|
name: '弹幕热力',
|
||||||
|
html: '弹幕热力',
|
||||||
|
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z" fill="#ffffff"/></svg>',
|
||||||
|
switch: danmakuHeatmapEnabledRef.current,
|
||||||
|
onSwitch: function (item: any) {
|
||||||
const newVal = !item.switch;
|
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;
|
return newVal;
|
||||||
},
|
},
|
||||||
},
|
}] : []),
|
||||||
|
...(webGPUSupported ? [
|
||||||
|
{
|
||||||
|
name: 'Anime4K超分',
|
||||||
|
html: 'Anime4K超分',
|
||||||
|
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5zm0 18c-4 0-7-3-7-7V9l7-3.5L19 9v4c0 4-3 7-7 7z" fill="#ffffff"/><path d="M10 12l2 2 4-4" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||||
|
switch: anime4kEnabledRef.current,
|
||||||
|
onSwitch: async function (item: any) {
|
||||||
|
const newVal = !item.switch;
|
||||||
|
await toggleAnime4K(newVal);
|
||||||
|
return newVal;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '超分模式',
|
||||||
|
html: '超分模式',
|
||||||
|
selector: [
|
||||||
|
{
|
||||||
|
html: 'ModeA (快速)',
|
||||||
|
value: 'ModeA',
|
||||||
|
default: anime4kModeRef.current === 'ModeA',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
html: 'ModeB (平衡)',
|
||||||
|
value: 'ModeB',
|
||||||
|
default: anime4kModeRef.current === 'ModeB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
html: 'ModeC (质量)',
|
||||||
|
value: 'ModeC',
|
||||||
|
default: anime4kModeRef.current === 'ModeC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
html: 'ModeAA (增强快速)',
|
||||||
|
value: 'ModeAA',
|
||||||
|
default: anime4kModeRef.current === 'ModeAA',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
html: 'ModeBB (增强平衡)',
|
||||||
|
value: 'ModeBB',
|
||||||
|
default: anime4kModeRef.current === 'ModeBB',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
html: 'ModeCA (最高质量)',
|
||||||
|
value: 'ModeCA',
|
||||||
|
default: anime4kModeRef.current === 'ModeCA',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onSelect: async function (item: any) {
|
||||||
|
await changeAnime4KMode(item.value);
|
||||||
|
return item.html;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '超分倍数',
|
||||||
|
html: '超分倍数',
|
||||||
|
selector: [
|
||||||
|
{
|
||||||
|
html: '1.5x',
|
||||||
|
value: '1.5',
|
||||||
|
default: anime4kScaleRef.current === 1.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
html: '2.0x',
|
||||||
|
value: '2.0',
|
||||||
|
default: anime4kScaleRef.current === 2.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
html: '3.0x',
|
||||||
|
value: '3.0',
|
||||||
|
default: anime4kScaleRef.current === 3.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
html: '4.0x',
|
||||||
|
value: '4.0',
|
||||||
|
default: anime4kScaleRef.current === 4.0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onSelect: async function (item: any) {
|
||||||
|
await changeAnime4KScale(parseFloat(item.value));
|
||||||
|
return item.html;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
] : []),
|
||||||
{
|
{
|
||||||
name: '超分模式',
|
name: '跳过片头片尾',
|
||||||
html: '超分模式',
|
html: '跳过片头片尾',
|
||||||
selector: [
|
switch: skipConfigRef.current.enable,
|
||||||
{
|
onSwitch: function (item) {
|
||||||
html: 'ModeA (快速)',
|
const newConfig = {
|
||||||
value: 'ModeA',
|
...skipConfigRef.current,
|
||||||
default: anime4kModeRef.current === 'ModeA',
|
enable: !item.switch,
|
||||||
},
|
};
|
||||||
{
|
handleSkipConfigChange(newConfig);
|
||||||
html: 'ModeB (平衡)',
|
return !item.switch;
|
||||||
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: '超分倍数',
|
name: '跳过配置',
|
||||||
html: '超分倍数',
|
html: '跳过配置',
|
||||||
selector: [
|
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="12" r="2" fill="#ffffff"/><path d="M9 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
|
||||||
{
|
tooltip:
|
||||||
html: '1.5x',
|
skipConfigRef.current.intro_time === 0 && skipConfigRef.current.outro_time === 0
|
||||||
value: '1.5',
|
? '设置跳过配置'
|
||||||
default: anime4kScaleRef.current === 1.5,
|
: `片头: ${formatTime(skipConfigRef.current.intro_time)} | 片尾: ${formatTime(Math.abs(skipConfigRef.current.outro_time))}`,
|
||||||
},
|
onClick: async function () {
|
||||||
{
|
const player = artPlayerRef.current;
|
||||||
html: '2.0x',
|
if (player) {
|
||||||
value: '2.0',
|
// 如果处于全屏状态,先退出全屏
|
||||||
default: anime4kScaleRef.current === 2.0,
|
if (player.fullscreen) {
|
||||||
},
|
player.fullscreen = false;
|
||||||
{
|
// 等待全屏退出动画完成
|
||||||
html: '3.0x',
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
value: '3.0',
|
}
|
||||||
default: anime4kScaleRef.current === 3.0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
html: '4.0x',
|
|
||||||
value: '4.0',
|
|
||||||
default: anime4kScaleRef.current === 4.0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
onSelect: async function (item: any) {
|
|
||||||
await changeAnime4KScale(parseFloat(item.value));
|
|
||||||
return item.html;
|
|
||||||
},
|
|
||||||
}
|
|
||||||
] : []),
|
|
||||||
{
|
|
||||||
name: '跳过片头片尾',
|
|
||||||
html: '跳过片头片尾',
|
|
||||||
switch: skipConfigRef.current.enable,
|
|
||||||
onSwitch: function (item) {
|
|
||||||
const newConfig = {
|
|
||||||
...skipConfigRef.current,
|
|
||||||
enable: !item.switch,
|
|
||||||
};
|
|
||||||
handleSkipConfigChange(newConfig);
|
|
||||||
return !item.switch;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '跳过配置',
|
|
||||||
html: '跳过配置',
|
|
||||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="12" r="2" fill="#ffffff"/><path d="M9 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
|
|
||||||
tooltip:
|
|
||||||
skipConfigRef.current.intro_time === 0 && skipConfigRef.current.outro_time === 0
|
|
||||||
? '设置跳过配置'
|
|
||||||
: `片头: ${formatTime(skipConfigRef.current.intro_time)} | 片尾: ${formatTime(Math.abs(skipConfigRef.current.outro_time))}`,
|
|
||||||
onClick: async function () {
|
|
||||||
const player = artPlayerRef.current;
|
|
||||||
if (player) {
|
|
||||||
// 如果处于全屏状态,先退出全屏
|
|
||||||
if (player.fullscreen) {
|
|
||||||
player.fullscreen = false;
|
|
||||||
// 等待全屏退出动画完成
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 ArtPlayer 的 prompt 功能创建输入弹窗
|
// 使用 ArtPlayer 的 prompt 功能创建输入弹窗
|
||||||
const currentIntro = skipConfigRef.current.intro_time || 0;
|
const currentIntro = skipConfigRef.current.intro_time || 0;
|
||||||
const currentOutro = Math.abs(skipConfigRef.current.outro_time) || 0;
|
const currentOutro = Math.abs(skipConfigRef.current.outro_time) || 0;
|
||||||
|
|
||||||
// 创建一个自定义的提示框
|
// 创建一个自定义的提示框
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.style.cssText = `
|
container.style.cssText = `
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -5569,7 +5594,7 @@ function PlayPageClient() {
|
|||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="color: white; margin-bottom: 15px; font-size: 16px; font-weight: bold; border-bottom: 1px solid #444; padding-bottom: 10px;">
|
<div style="color: white; margin-bottom: 15px; font-size: 16px; font-weight: bold; border-bottom: 1px solid #444; padding-bottom: 10px;">
|
||||||
跳过配置
|
跳过配置
|
||||||
</div>
|
</div>
|
||||||
@@ -5625,110 +5650,110 @@ function PlayPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
|
|
||||||
const introInput = container.querySelector('#intro-input') as HTMLInputElement;
|
const introInput = container.querySelector('#intro-input') as HTMLInputElement;
|
||||||
const outroInput = container.querySelector('#outro-input') as HTMLInputElement;
|
const outroInput = container.querySelector('#outro-input') as HTMLInputElement;
|
||||||
const setIntroBtn = container.querySelector('#set-intro-btn');
|
const setIntroBtn = container.querySelector('#set-intro-btn');
|
||||||
const setOutroBtn = container.querySelector('#set-outro-btn');
|
const setOutroBtn = container.querySelector('#set-outro-btn');
|
||||||
const cancelBtn = container.querySelector('#cancel-btn');
|
const cancelBtn = container.querySelector('#cancel-btn');
|
||||||
const clearBtn = container.querySelector('#clear-btn');
|
const clearBtn = container.querySelector('#clear-btn');
|
||||||
const confirmBtn = container.querySelector('#confirm-btn');
|
const confirmBtn = container.querySelector('#confirm-btn');
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
document.body.removeChild(container);
|
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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) => {
|
setOutroBtn?.addEventListener('click', () => {
|
||||||
if (e.key === 'Enter') {
|
if (player.duration && player.currentTime) {
|
||||||
confirmBtn?.dispatchEvent(new Event('click'));
|
const outroTime = player.duration - player.currentTime;
|
||||||
} else if (e.key === 'Escape') {
|
if (outroTime > 0) {
|
||||||
cancelBtn?.dispatchEvent(new Event('click'));
|
outroInput.value = Math.floor(outroTime).toString();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
});
|
||||||
|
|
||||||
introInput.addEventListener('keydown', handleEnter);
|
cancelBtn?.addEventListener('click', cleanup);
|
||||||
outroInput.addEventListener('keydown', handleEnter);
|
|
||||||
}
|
clearBtn?.addEventListener('click', () => {
|
||||||
return '';
|
handleSkipConfigChange({
|
||||||
},
|
enable: false,
|
||||||
},
|
intro_time: 0,
|
||||||
],
|
outro_time: 0,
|
||||||
// 控制栏配置
|
});
|
||||||
controls: [
|
cleanup();
|
||||||
{
|
});
|
||||||
position: 'left',
|
|
||||||
index: 13,
|
confirmBtn?.addEventListener('click', () => {
|
||||||
html: '<i class="art-icon flex"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" fill="currentColor"/></svg></i>',
|
const introTime = parseFloat(introInput.value) || 0;
|
||||||
tooltip: '播放下一集',
|
const outroTime = parseFloat(outroInput.value) || 0;
|
||||||
click: function () {
|
|
||||||
// 房员禁用下一集按钮
|
const newConfig = {
|
||||||
if (playSync.shouldDisableControls) {
|
...skipConfigRef.current,
|
||||||
if (artPlayerRef.current) {
|
intro_time: introTime,
|
||||||
artPlayerRef.current.notice.show = '房员无法切换集数,请等待房主操作';
|
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;
|
return '';
|
||||||
}
|
},
|
||||||
handleNextEpisode();
|
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
// iOS 设备上添加自定义全屏按钮(横屏和竖屏都显示)
|
// 控制栏配置
|
||||||
...(isIOS ? [{
|
controls: [
|
||||||
position: 'right',
|
{
|
||||||
index: 100, // 大数字确保在设置按钮右边
|
position: 'left',
|
||||||
html: '<i class="art-icon ios-portrait-fullscreen"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" fill="currentColor"/></svg></i>',
|
index: 13,
|
||||||
tooltip: '全屏',
|
html: '<i class="art-icon flex"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" fill="currentColor"/></svg></i>',
|
||||||
style: {
|
tooltip: '播放下一集',
|
||||||
color: '#fff',
|
click: function () {
|
||||||
|
// 房员禁用下一集按钮
|
||||||
|
if (playSync.shouldDisableControls) {
|
||||||
|
if (artPlayerRef.current) {
|
||||||
|
artPlayerRef.current.notice.show = '房员无法切换集数,请等待房主操作';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleNextEpisode();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted: function($el: HTMLElement) {
|
// iOS 设备上添加自定义全屏按钮(横屏和竖屏都显示)
|
||||||
// 添加 CSS 样式:横屏和竖屏都显示
|
...(isIOS ? [{
|
||||||
const style = document.createElement('style');
|
position: 'right',
|
||||||
style.textContent = `
|
index: 100, // 大数字确保在设置按钮右边
|
||||||
|
html: '<i class="art-icon ios-portrait-fullscreen"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" fill="currentColor"/></svg></i>',
|
||||||
|
tooltip: '全屏',
|
||||||
|
style: {
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
mounted: function ($el: HTMLElement) {
|
||||||
|
// 添加 CSS 样式:横屏和竖屏都显示
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
/* iOS 自定义全屏按钮在所有方向都显示 */
|
/* iOS 自定义全屏按钮在所有方向都显示 */
|
||||||
.ios-portrait-fullscreen {
|
.ios-portrait-fullscreen {
|
||||||
display: inline-flex !important;
|
display: inline-flex !important;
|
||||||
@@ -5913,64 +5938,64 @@ function PlayPageClient() {
|
|||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
},
|
},
|
||||||
click: function () {
|
click: function () {
|
||||||
if (!artPlayerRef.current) return;
|
if (!artPlayerRef.current) return;
|
||||||
|
|
||||||
// 检测是否在 PWA 模式下
|
// 检测是否在 PWA 模式下
|
||||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
|
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
window.matchMedia('(display-mode: fullscreen)').matches ||
|
window.matchMedia('(display-mode: fullscreen)').matches ||
|
||||||
(window.navigator as any).standalone === true;
|
(window.navigator as any).standalone === true;
|
||||||
|
|
||||||
// 检查是否已经在原生全屏状态
|
// 检查是否已经在原生全屏状态
|
||||||
const isInNativeFullscreen = !!(document.fullscreenElement || (document as any).webkitFullscreenElement);
|
const isInNativeFullscreen = !!(document.fullscreenElement || (document as any).webkitFullscreenElement);
|
||||||
|
|
||||||
// 如果已经在原生全屏状态,退出原生全屏
|
// 如果已经在原生全屏状态,退出原生全屏
|
||||||
if (isInNativeFullscreen) {
|
if (isInNativeFullscreen) {
|
||||||
const exitFullscreen = (document as any).exitFullscreen ||
|
const exitFullscreen = (document as any).exitFullscreen ||
|
||||||
(document as any).webkitExitFullscreen ||
|
(document as any).webkitExitFullscreen ||
|
||||||
(document as any).mozCancelFullScreen ||
|
(document as any).mozCancelFullScreen ||
|
||||||
(document as any).msExitFullscreen;
|
(document as any).msExitFullscreen;
|
||||||
if (exitFullscreen) {
|
if (exitFullscreen) {
|
||||||
try {
|
try {
|
||||||
const result = exitFullscreen.call(document);
|
const result = exitFullscreen.call(document);
|
||||||
if (result && typeof result.catch === 'function') {
|
if (result && typeof result.catch === 'function') {
|
||||||
result.catch((err: Error) => console.error('退出全屏失败:', err));
|
result.catch((err: Error) => console.error('退出全屏失败:', err));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('退出全屏失败:', err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('退出全屏失败:', err);
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果已经在网页全屏状态,退出网页全屏
|
// 如果已经在网页全屏状态,退出网页全屏
|
||||||
if (artPlayerRef.current.fullscreenWeb) {
|
if (artPlayerRef.current.fullscreenWeb) {
|
||||||
artPlayerRef.current.fullscreenWeb = false;
|
artPlayerRef.current.fullscreenWeb = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果在 PWA 模式下,直接使用容器全屏(可以隐藏状态栏)
|
// 如果在 PWA 模式下,直接使用容器全屏(可以隐藏状态栏)
|
||||||
if (isPWA) {
|
if (isPWA) {
|
||||||
const container = artPlayerRef.current.template.$container;
|
const container = artPlayerRef.current.template.$container;
|
||||||
if (container && container.webkitEnterFullscreen) {
|
if (container && container.webkitEnterFullscreen) {
|
||||||
container.webkitEnterFullscreen().catch((err: Error) => {
|
container.webkitEnterFullscreen().catch((err: Error) => {
|
||||||
console.error('PWA 全屏失败:', err);
|
console.error('PWA 全屏失败:', err);
|
||||||
// 如果失败,降级使用网页全屏
|
// 如果失败,降级使用网页全屏
|
||||||
|
artPlayerRef.current.fullscreenWeb = true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 不支持原生全屏,使用网页全屏
|
||||||
artPlayerRef.current.fullscreenWeb = true;
|
artPlayerRef.current.fullscreenWeb = true;
|
||||||
});
|
}
|
||||||
} else {
|
return;
|
||||||
// 不支持原生全屏,使用网页全屏
|
|
||||||
artPlayerRef.current.fullscreenWeb = true;
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 非 PWA 模式:创建对话框(使用项目统一风格)
|
// 非 PWA 模式:创建对话框(使用项目统一风格)
|
||||||
const dialog = document.createElement('div');
|
const dialog = document.createElement('div');
|
||||||
dialog.className = 'ios-fullscreen-dialog';
|
dialog.className = 'ios-fullscreen-dialog';
|
||||||
dialog.innerHTML = `
|
dialog.innerHTML = `
|
||||||
<div class="ios-fullscreen-dialog-content">
|
<div class="ios-fullscreen-dialog-content">
|
||||||
<!-- 标题栏 -->
|
<!-- 标题栏 -->
|
||||||
<div class="ios-fullscreen-dialog-header">
|
<div class="ios-fullscreen-dialog-header">
|
||||||
@@ -6041,108 +6066,108 @@ function PlayPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 添加到页面
|
// 添加到页面
|
||||||
document.body.appendChild(dialog);
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
// 点击背景关闭
|
// 点击背景关闭
|
||||||
dialog.addEventListener('click', (e) => {
|
dialog.addEventListener('click', (e) => {
|
||||||
if (e.target === dialog) {
|
if (e.target === dialog) {
|
||||||
document.body.removeChild(dialog);
|
document.body.removeChild(dialog);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 按钮点击事件
|
// 按钮点击事件
|
||||||
const buttons = dialog.querySelectorAll('.ios-fullscreen-option');
|
const buttons = dialog.querySelectorAll('.ios-fullscreen-option');
|
||||||
buttons.forEach(button => {
|
buttons.forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const action = button.getAttribute('data-action');
|
const action = button.getAttribute('data-action');
|
||||||
|
|
||||||
if (action === 'web') {
|
if (action === 'web') {
|
||||||
// 网页全屏
|
// 网页全屏
|
||||||
if (artPlayerRef.current) {
|
if (artPlayerRef.current) {
|
||||||
artPlayerRef.current.fullscreenWeb = true;
|
artPlayerRef.current.fullscreenWeb = true;
|
||||||
}
|
}
|
||||||
} else if (action === 'native') {
|
} else if (action === 'native') {
|
||||||
// 原生全屏(尝试使用浏览器的全屏 API)
|
// 原生全屏(尝试使用浏览器的全屏 API)
|
||||||
if (artPlayerRef.current && artPlayerRef.current.template.$video) {
|
if (artPlayerRef.current && artPlayerRef.current.template.$video) {
|
||||||
const videoElement = artPlayerRef.current.template.$video;
|
const videoElement = artPlayerRef.current.template.$video;
|
||||||
if (videoElement.requestFullscreen) {
|
if (videoElement.requestFullscreen) {
|
||||||
videoElement.requestFullscreen();
|
videoElement.requestFullscreen();
|
||||||
} else if ((videoElement as any).webkitEnterFullscreen) {
|
} else if ((videoElement as any).webkitEnterFullscreen) {
|
||||||
(videoElement as any).webkitEnterFullscreen();
|
(videoElement as any).webkitEnterFullscreen();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
// 关闭对话框
|
||||||
document.body.removeChild(dialog);
|
document.body.removeChild(dialog);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
},
|
}] : []),
|
||||||
}] : []),
|
],
|
||||||
],
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// 监听播放器事件
|
// 监听播放器事件
|
||||||
artPlayerRef.current.on('ready', async () => {
|
artPlayerRef.current.on('ready', async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// 标记播放器已就绪,触发 usePlaySync 设置事件监听器
|
// 标记播放器已就绪,触发 usePlaySync 设置事件监听器
|
||||||
setPlayerReady(true);
|
setPlayerReady(true);
|
||||||
console.log('[PlayPage] Player ready, triggering sync setup');
|
console.log('[PlayPage] Player ready, triggering sync setup');
|
||||||
|
|
||||||
// 应用进度条图标配置 - 尽早执行
|
// 应用进度条图标配置 - 尽早执行
|
||||||
const applyProgressThumbConfig = () => {
|
const applyProgressThumbConfig = () => {
|
||||||
try {
|
try {
|
||||||
const config = (window as any).RUNTIME_CONFIG;
|
const config = (window as any).RUNTIME_CONFIG;
|
||||||
|
|
||||||
if (!config || config.PROGRESS_THUMB_TYPE === 'default') {
|
if (!config || config.PROGRESS_THUMB_TYPE === 'default') {
|
||||||
// 使用默认样式,移除自定义样式
|
// 使用默认样式,移除自定义样式
|
||||||
const oldStyle = document.getElementById('custom-progress-thumb-style');
|
const oldStyle = document.getElementById('custom-progress-thumb-style');
|
||||||
if (oldStyle) oldStyle.remove();
|
if (oldStyle) oldStyle.remove();
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
let thumbUrl = '';
|
|
||||||
let thumbColor = '#22c55e'; // 默认绿色
|
|
||||||
|
|
||||||
if (config.PROGRESS_THUMB_TYPE === 'preset' && config.PROGRESS_THUMB_PRESET_ID) {
|
|
||||||
const presetConfig: Record<string, { url: string; color: string }> = {
|
|
||||||
renako: { url: '/icons/q/renako.png', color: '#ec4899' }, // 粉色
|
|
||||||
irena: { url: '/icons/q/irena.png', color: '#f8fafc' }, // 雪白色
|
|
||||||
emilia: { url: '/icons/q/emilia.png', color: '#f8fafc' }, // 雪白色
|
|
||||||
};
|
|
||||||
const preset = presetConfig[config.PROGRESS_THUMB_PRESET_ID];
|
|
||||||
if (preset) {
|
|
||||||
thumbUrl = preset.url;
|
|
||||||
thumbColor = preset.color;
|
|
||||||
}
|
|
||||||
} else if (config.PROGRESS_THUMB_TYPE === 'custom' && config.PROGRESS_THUMB_CUSTOM_URL) {
|
|
||||||
thumbUrl = config.PROGRESS_THUMB_CUSTOM_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改 ArtPlayer 的主题色
|
|
||||||
if (artPlayerRef.current) {
|
|
||||||
artPlayerRef.current.theme = thumbColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thumbUrl) {
|
|
||||||
// 根据预设ID确定尺寸
|
|
||||||
let width = '30px';
|
|
||||||
let height = '30px';
|
|
||||||
let marginLeft = '-15px';
|
|
||||||
|
|
||||||
// renako 图标特殊处理(288x404比例,放大1.25倍)
|
|
||||||
if (config.PROGRESS_THUMB_TYPE === 'preset' && config.PROGRESS_THUMB_PRESET_ID === 'renako') {
|
|
||||||
width = '26.875px'; // 21.5 * 1.25
|
|
||||||
height = '37.5px'; // 30 * 1.25
|
|
||||||
marginLeft = '-13.4375px'; // 10.75 * 1.25
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动态设置背景图片
|
let thumbUrl = '';
|
||||||
const style = document.createElement('style');
|
let thumbColor = '#22c55e'; // 默认绿色
|
||||||
style.id = 'custom-progress-thumb-style';
|
|
||||||
style.textContent = `
|
if (config.PROGRESS_THUMB_TYPE === 'preset' && config.PROGRESS_THUMB_PRESET_ID) {
|
||||||
|
const presetConfig: Record<string, { url: string; color: string }> = {
|
||||||
|
renako: { url: '/icons/q/renako.png', color: '#ec4899' }, // 粉色
|
||||||
|
irena: { url: '/icons/q/irena.png', color: '#f8fafc' }, // 雪白色
|
||||||
|
emilia: { url: '/icons/q/emilia.png', color: '#f8fafc' }, // 雪白色
|
||||||
|
};
|
||||||
|
const preset = presetConfig[config.PROGRESS_THUMB_PRESET_ID];
|
||||||
|
if (preset) {
|
||||||
|
thumbUrl = preset.url;
|
||||||
|
thumbColor = preset.color;
|
||||||
|
}
|
||||||
|
} else if (config.PROGRESS_THUMB_TYPE === 'custom' && config.PROGRESS_THUMB_CUSTOM_URL) {
|
||||||
|
thumbUrl = config.PROGRESS_THUMB_CUSTOM_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改 ArtPlayer 的主题色
|
||||||
|
if (artPlayerRef.current) {
|
||||||
|
artPlayerRef.current.theme = thumbColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thumbUrl) {
|
||||||
|
// 根据预设ID确定尺寸
|
||||||
|
let width = '30px';
|
||||||
|
let height = '30px';
|
||||||
|
let marginLeft = '-15px';
|
||||||
|
|
||||||
|
// renako 图标特殊处理(288x404比例,放大1.25倍)
|
||||||
|
if (config.PROGRESS_THUMB_TYPE === 'preset' && config.PROGRESS_THUMB_PRESET_ID === 'renako') {
|
||||||
|
width = '26.875px'; // 21.5 * 1.25
|
||||||
|
height = '37.5px'; // 30 * 1.25
|
||||||
|
marginLeft = '-13.4375px'; // 10.75 * 1.25
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态设置背景图片
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'custom-progress-thumb-style';
|
||||||
|
style.textContent = `
|
||||||
/* 替换默认的进度条圆点为自定义图标 */
|
/* 替换默认的进度条圆点为自定义图标 */
|
||||||
.art-video-player .art-progress-indicator {
|
.art-video-player .art-progress-indicator {
|
||||||
width: ${width} !important;
|
width: ${width} !important;
|
||||||
@@ -6157,494 +6182,462 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 移除旧样式
|
// 移除旧样式
|
||||||
const oldStyle = document.getElementById('custom-progress-thumb-style');
|
const oldStyle = document.getElementById('custom-progress-thumb-style');
|
||||||
if (oldStyle) oldStyle.remove();
|
if (oldStyle) oldStyle.remove();
|
||||||
|
|
||||||
document.head.appendChild(style);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return item.html;
|
} catch (error) {
|
||||||
},
|
console.error('[进度条图标] 应用配置失败:', error);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加字幕大小设置
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听屏幕方向变化
|
applyProgressThumbConfig();
|
||||||
window.addEventListener('orientationchange', handleOrientationChange);
|
|
||||||
// 也监听 resize 事件(某些设备上更可靠)
|
|
||||||
window.addEventListener('resize', handleOrientationChange);
|
|
||||||
|
|
||||||
// 清理函数
|
// 添加字幕切换功能
|
||||||
artPlayerRef.current.on('destroy', () => {
|
const currentSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndex] || [];
|
||||||
window.removeEventListener('orientationchange', handleOrientationChange);
|
if (currentSubtitles.length > 0 && artPlayerRef.current) {
|
||||||
window.removeEventListener('resize', handleOrientationChange);
|
const subtitleOptions = [
|
||||||
});
|
{
|
||||||
}
|
html: '关闭',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
...currentSubtitles.map((sub: any) => ({
|
||||||
|
html: sub.label,
|
||||||
|
url: sub.url,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
// 从 art.storage 读取弹幕设置并应用
|
artPlayerRef.current.setting.add({
|
||||||
if (artPlayerRef.current) {
|
html: '字幕',
|
||||||
const storedDanmakuSettings = artPlayerRef.current.storage.get('danmaku_settings');
|
selector: subtitleOptions,
|
||||||
if (storedDanmakuSettings) {
|
onSelect: function (item: any) {
|
||||||
// 合并存储的设置到当前设置
|
if (artPlayerRef.current) {
|
||||||
const mergedSettings = {
|
if (item.url === '') {
|
||||||
...danmakuSettingsRef.current,
|
// 关闭字幕
|
||||||
...storedDanmakuSettings,
|
artPlayerRef.current.subtitle.show = false;
|
||||||
};
|
} else {
|
||||||
setDanmakuSettings(mergedSettings);
|
// 切换字幕
|
||||||
saveDanmakuSettings(mergedSettings);
|
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) {
|
if (artPlayerRef.current) {
|
||||||
// 监听弹幕显示/隐藏事件,保存开关状态到 localStorage
|
const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em';
|
||||||
artPlayerRef.current.on('artplayerPluginDanmuku:show', () => {
|
const defaultOption = savedSubtitleSize === '1em' ? '小' : savedSubtitleSize === '3em' ? '大' : savedSubtitleSize === '4em' ? '超大' : '中';
|
||||||
danmakuDisplayStateRef.current = true;
|
|
||||||
saveDanmakuDisplayState(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
artPlayerRef.current.on('artplayerPluginDanmuku:hide', () => {
|
artPlayerRef.current.setting.add({
|
||||||
danmakuDisplayStateRef.current = false;
|
html: '字幕大小',
|
||||||
saveDanmakuDisplayState(false);
|
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) {
|
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
||||||
requestWakeLock();
|
requestWakeLock();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// 监听播放状态变化,控制 Wake Lock
|
artPlayerRef.current.on('video:volumechange', () => {
|
||||||
artPlayerRef.current.on('play', () => {
|
lastVolumeRef.current = artPlayerRef.current.volume;
|
||||||
requestWakeLock();
|
});
|
||||||
});
|
artPlayerRef.current.on('video:ratechange', () => {
|
||||||
|
lastPlaybackRateRef.current = artPlayerRef.current.playbackRate;
|
||||||
|
});
|
||||||
|
|
||||||
artPlayerRef.current.on('pause', () => {
|
// 监听网页全屏事件,控制导航栏显示隐藏
|
||||||
releaseWakeLock();
|
artPlayerRef.current.on('fullscreenWeb', (isFullscreen: boolean) => {
|
||||||
saveCurrentPlayProgress();
|
console.log('网页全屏状态变化:', isFullscreen);
|
||||||
});
|
setIsWebFullscreen(isFullscreen);
|
||||||
|
});
|
||||||
|
|
||||||
artPlayerRef.current.on('video:ended', () => {
|
// 添加自定义热力图到播放器控制层
|
||||||
releaseWakeLock();
|
if (!danmakuHeatmapDisabledRef.current) {
|
||||||
});
|
artPlayerRef.current.controls.add({
|
||||||
|
name: 'custom-heatmap',
|
||||||
// 如果播放器初始化时已经在播放状态,则请求 Wake Lock
|
position: 'top',
|
||||||
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
html: '<canvas id="custom-heatmap-canvas" style="width: 100%; height: 100%; display: block;"></canvas>',
|
||||||
requestWakeLock();
|
style: {
|
||||||
}
|
position: 'absolute',
|
||||||
|
bottom: '5px',
|
||||||
artPlayerRef.current.on('video:volumechange', () => {
|
left: '0',
|
||||||
lastVolumeRef.current = artPlayerRef.current.volume;
|
height: '60px',
|
||||||
});
|
pointerEvents: 'none',
|
||||||
artPlayerRef.current.on('video:ratechange', () => {
|
zIndex: '30',
|
||||||
lastPlaybackRateRef.current = artPlayerRef.current.playbackRate;
|
display: danmakuHeatmapEnabledRef.current ? 'block' : 'none',
|
||||||
});
|
},
|
||||||
|
mounted: ($el: HTMLElement) => {
|
||||||
// 监听网页全屏事件,控制导航栏显示隐藏
|
const canvas = $el.querySelector('#custom-heatmap-canvas') as HTMLCanvasElement;
|
||||||
artPlayerRef.current.on('fullscreenWeb', (isFullscreen: boolean) => {
|
if (!canvas) {
|
||||||
console.log('网页全屏状态变化:', isFullscreen);
|
return;
|
||||||
setIsWebFullscreen(isFullscreen);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加自定义热力图到播放器控制层
|
|
||||||
if (!danmakuHeatmapDisabledRef.current) {
|
|
||||||
artPlayerRef.current.controls.add({
|
|
||||||
name: 'custom-heatmap',
|
|
||||||
position: 'top',
|
|
||||||
html: '<canvas id="custom-heatmap-canvas" style="width: 100%; height: 100%; display: block;"></canvas>',
|
|
||||||
style: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '5px',
|
|
||||||
left: '0',
|
|
||||||
height: '60px',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: '30',
|
|
||||||
display: danmakuHeatmapEnabledRef.current ? 'block' : 'none',
|
|
||||||
},
|
|
||||||
mounted: ($el: HTMLElement) => {
|
|
||||||
const canvas = $el.querySelector('#custom-heatmap-canvas') as HTMLCanvasElement;
|
|
||||||
if (!canvas) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据实际显示尺寸和设备像素比设置 canvas 分辨率
|
|
||||||
const updateCanvasSize = () => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
|
||||||
const newWidth = Math.round(rect.width * dpr);
|
|
||||||
const newHeight = Math.round(rect.height * dpr);
|
|
||||||
|
|
||||||
// 只在尺寸真正改变时才更新,避免闪烁
|
|
||||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
|
||||||
canvas.width = newWidth;
|
|
||||||
canvas.height = newHeight;
|
|
||||||
return true; // 返回 true 表示尺寸已更新
|
|
||||||
}
|
}
|
||||||
return false; // 返回 false 表示尺寸未变化
|
|
||||||
};
|
|
||||||
|
|
||||||
// 动态获取进度条的实际位置并调整热力图
|
// 根据实际显示尺寸和设备像素比设置 canvas 分辨率
|
||||||
const adjustHeatmapPosition = () => {
|
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;
|
const progressBar = document.querySelector('.art-control-progress') as HTMLElement;
|
||||||
|
let progressResizeObserver: ResizeObserver | null = null;
|
||||||
if (!progressBar) {
|
if (progressBar && typeof ResizeObserver !== 'undefined') {
|
||||||
return;
|
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 resizeHandler = () => {
|
||||||
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(() => {
|
|
||||||
adjustHeatmapPosition();
|
adjustHeatmapPosition();
|
||||||
// 进度条长度变化时也需要重新计算和绘制热力图
|
};
|
||||||
setTimeout(updateHeatmapData, 100);
|
window.addEventListener('resize', resizeHandler);
|
||||||
});
|
|
||||||
progressResizeObserver.observe(progressBar);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听全屏状态变化
|
let heatmapData: number[] = [];
|
||||||
if (artPlayerRef.current) {
|
let isHovering = false;
|
||||||
artPlayerRef.current.on('fullscreen', () => {
|
let hoverTime = 0;
|
||||||
setTimeout(adjustHeatmapPosition, 300);
|
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';
|
||||||
|
|
||||||
// 监听窗口大小变化
|
// 只在状态真正改变时才更新 DOM
|
||||||
const resizeHandler = () => {
|
if (enabled !== lastEnabled) {
|
||||||
adjustHeatmapPosition();
|
$el.style.display = enabled ? 'block' : 'none';
|
||||||
};
|
|
||||||
window.addEventListener('resize', resizeHandler);
|
|
||||||
|
|
||||||
let heatmapData: number[] = [];
|
// 如果从关闭变为打开,重新调整位置和尺寸
|
||||||
let isHovering = false;
|
if (enabled) {
|
||||||
let hoverTime = 0;
|
setTimeout(() => {
|
||||||
let tooltipEl: HTMLElement | null = null;
|
adjustHeatmapPosition();
|
||||||
|
drawHeatmap();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
// 监听热力图开关状态变化
|
lastEnabled = enabled;
|
||||||
let lastEnabled = localStorage.getItem('danmaku_heatmap_enabled') === 'true';
|
}
|
||||||
const updateVisibility = () => {
|
};
|
||||||
const enabled = localStorage.getItem('danmaku_heatmap_enabled') === 'true';
|
|
||||||
|
|
||||||
// 只在状态真正改变时才更新 DOM
|
// 定期检查开关状态
|
||||||
if (enabled !== lastEnabled) {
|
const visibilityInterval = setInterval(updateVisibility, 500);
|
||||||
$el.style.display = enabled ? 'block' : 'none';
|
|
||||||
|
|
||||||
// 如果从关闭变为打开,重新调整位置和尺寸
|
// 计算热力图数据(按视频长度的5%分段,使热力图更平滑)
|
||||||
if (enabled) {
|
const calculateHeatmapData = (danmakuList: any[], duration: number) => {
|
||||||
setTimeout(() => {
|
if (!duration || duration <= 0 || danmakuList.length === 0) {
|
||||||
adjustHeatmapPosition();
|
return [];
|
||||||
drawHeatmap();
|
|
||||||
}, 50);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
// 定期检查开关状态
|
danmakuList.forEach((danmaku: any) => {
|
||||||
const visibilityInterval = setInterval(updateVisibility, 500);
|
const segmentIndex = Math.floor(danmaku.time / segmentDuration);
|
||||||
|
if (segmentIndex >= 0 && segmentIndex < segments) {
|
||||||
|
heatData[segmentIndex]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 计算热力图数据(按视频长度的5%分段,使热力图更平滑)
|
const maxCount = Math.max(...heatData, 1);
|
||||||
const calculateHeatmapData = (danmakuList: any[], duration: number) => {
|
return heatData.map((count: number) => count / maxCount);
|
||||||
if (!duration || duration <= 0 || danmakuList.length === 0) {
|
};
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按视频长度的5%分段,最少20段
|
// 绘制热力图
|
||||||
const segments = Math.max(20, Math.ceil(duration * 0.05));
|
const drawHeatmap = () => {
|
||||||
const segmentDuration = duration / segments;
|
// 检查热力图是否启用(与初始状态逻辑保持一致)
|
||||||
const heatData = new Array(segments).fill(0);
|
const storedValue = localStorage.getItem('danmaku_heatmap_enabled');
|
||||||
|
const enabled = storedValue !== null ? storedValue === 'true' : true; // 默认开启
|
||||||
danmakuList.forEach((danmaku: any) => {
|
if (!enabled) {
|
||||||
const segmentIndex = Math.floor(danmaku.time / segmentDuration);
|
// 热力图已关闭,跳过绘制
|
||||||
if (segmentIndex >= 0 && segmentIndex < segments) {
|
return;
|
||||||
heatData[segmentIndex]++;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const maxCount = Math.max(...heatData, 1);
|
if (!artPlayerRef.current) {
|
||||||
return heatData.map((count: number) => count / maxCount);
|
return;
|
||||||
};
|
|
||||||
|
|
||||||
// 绘制热力图
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
ctx.lineTo(width, height);
|
if (heatmapData.length === 0) {
|
||||||
ctx.closePath();
|
return;
|
||||||
ctx.fillStyle = 'rgba(128, 128, 128, 0.3)';
|
}
|
||||||
ctx.fill();
|
|
||||||
|
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.save();
|
||||||
ctx.beginPath();
|
ctx.scale(dpr, dpr);
|
||||||
ctx.rect(0, 0, progressX, height);
|
ctx.clearRect(0, 0, width, height);
|
||||||
ctx.clip();
|
|
||||||
|
|
||||||
|
const progressRatio = duration > 0 ? currentTime / duration : 0;
|
||||||
|
const progressX = progressRatio * width;
|
||||||
|
|
||||||
|
// 绘制未播放部分的曲线
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(0, height);
|
ctx.moveTo(0, height);
|
||||||
|
|
||||||
@@ -6655,6 +6648,7 @@ function PlayPageClient() {
|
|||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
ctx.lineTo(x, y);
|
ctx.lineTo(x, y);
|
||||||
} else {
|
} else {
|
||||||
|
// 使用二次贝塞尔曲线使线条平滑
|
||||||
const prevX = ((index - 1) / heatmapData.length) * width;
|
const prevX = ((index - 1) / heatmapData.length) * width;
|
||||||
const prevY = height - (heatmapData[index - 1] * height);
|
const prevY = height - (heatmapData[index - 1] * height);
|
||||||
const cpX = (prevX + x) / 2;
|
const cpX = (prevX + x) / 2;
|
||||||
@@ -6666,63 +6660,94 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
ctx.lineTo(width, height);
|
ctx.lineTo(width, height);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fillStyle = 'rgba(128, 128, 128, 0.6)';
|
ctx.fillStyle = 'rgba(128, 128, 128, 0.3)';
|
||||||
ctx.fill();
|
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();
|
||||||
}
|
};
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
// 格式化时间
|
if (h > 0) {
|
||||||
const formatTime = (seconds: number): string => {
|
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||||
const h = Math.floor(seconds / 3600);
|
}
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
const s = Math.floor(seconds % 60);
|
};
|
||||||
|
|
||||||
if (h > 0) {
|
// 获取弹幕密度
|
||||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
const getDensity = (time: number): string => {
|
||||||
}
|
if (heatmapData.length === 0 || !artPlayerRef.current) return '';
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
const duration = artPlayerRef.current.duration || 0;
|
||||||
};
|
if (duration <= 0) return '';
|
||||||
|
|
||||||
// 获取弹幕密度
|
// 按视频长度的5%分段
|
||||||
const getDensity = (time: number): string => {
|
const segments = Math.max(20, Math.ceil(duration * 0.05));
|
||||||
if (heatmapData.length === 0 || !artPlayerRef.current) return '';
|
const segmentDuration = duration / segments;
|
||||||
const duration = artPlayerRef.current.duration || 0;
|
const segmentIndex = Math.floor(time / segmentDuration);
|
||||||
if (duration <= 0) return '';
|
|
||||||
|
|
||||||
// 按视频长度的5%分段
|
if (segmentIndex >= 0 && segmentIndex < heatmapData.length) {
|
||||||
const segments = Math.max(20, Math.ceil(duration * 0.05));
|
const density = heatmapData[segmentIndex];
|
||||||
const segmentDuration = duration / segments;
|
if (density < 0.2) return '低';
|
||||||
const segmentIndex = Math.floor(time / segmentDuration);
|
if (density < 0.5) return '中';
|
||||||
|
if (density < 0.8) return '高';
|
||||||
|
return '极高';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
if (segmentIndex >= 0 && segmentIndex < heatmapData.length) {
|
// 鼠标移动事件
|
||||||
const density = heatmapData[segmentIndex];
|
canvas.addEventListener('mousemove', (e: MouseEvent) => {
|
||||||
if (density < 0.2) return '低';
|
if (!artPlayerRef.current) return;
|
||||||
if (density < 0.5) return '中';
|
|
||||||
if (density < 0.8) return '高';
|
|
||||||
return '极高';
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 鼠标移动事件
|
const rect = canvas.getBoundingClientRect();
|
||||||
canvas.addEventListener('mousemove', (e: MouseEvent) => {
|
const x = e.clientX - rect.left;
|
||||||
if (!artPlayerRef.current) return;
|
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;
|
if (!tooltipEl) {
|
||||||
const percentage = x / rect.width;
|
tooltipEl = document.createElement('div');
|
||||||
const duration = artPlayerRef.current.duration || 0;
|
tooltipEl.style.cssText = `
|
||||||
hoverTime = percentage * duration;
|
|
||||||
isHovering = true;
|
|
||||||
|
|
||||||
// 创建或更新提示框
|
|
||||||
if (!tooltipEl) {
|
|
||||||
tooltipEl = document.createElement('div');
|
|
||||||
tooltipEl.style.cssText = `
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
bottom: 100%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
@@ -6736,120 +6761,120 @@ function PlayPageClient() {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
`;
|
`;
|
||||||
$el.appendChild(tooltipEl);
|
$el.appendChild(tooltipEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
tooltipEl.textContent = `${formatTime(hoverTime)} - 弹幕密度: ${getDensity(hoverTime)}`;
|
tooltipEl.textContent = `${formatTime(hoverTime)} - 弹幕密度: ${getDensity(hoverTime)}`;
|
||||||
tooltipEl.style.left = `${percentage * 100}%`;
|
tooltipEl.style.left = `${percentage * 100}%`;
|
||||||
tooltipEl.style.display = 'block';
|
tooltipEl.style.display = 'block';
|
||||||
});
|
});
|
||||||
|
|
||||||
// 鼠标离开事件
|
// 鼠标离开事件
|
||||||
canvas.addEventListener('mouseleave', () => {
|
canvas.addEventListener('mouseleave', () => {
|
||||||
isHovering = false;
|
isHovering = false;
|
||||||
if (tooltipEl) {
|
if (tooltipEl) {
|
||||||
tooltipEl.style.display = 'none';
|
tooltipEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 点击跳转
|
// 点击跳转
|
||||||
canvas.addEventListener('click', (e: MouseEvent) => {
|
canvas.addEventListener('click', (e: MouseEvent) => {
|
||||||
if (!artPlayerRef.current) return;
|
if (!artPlayerRef.current) return;
|
||||||
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
const percentage = x / rect.width;
|
const percentage = x / rect.width;
|
||||||
const duration = artPlayerRef.current.duration || 0;
|
const duration = artPlayerRef.current.duration || 0;
|
||||||
const time = percentage * duration;
|
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 = () => {
|
const updateHeatmapData = () => {
|
||||||
if (!artPlayerRef.current) {
|
if (!artPlayerRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!danmakuPluginRef.current) {
|
if (!danmakuPluginRef.current) {
|
||||||
return;
|
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) {
|
if (danmakuList.length > 0 && duration > 0) {
|
||||||
heatmapData = calculateHeatmapData(danmakuList, duration);
|
heatmapData = calculateHeatmapData(danmakuList, duration);
|
||||||
// 立即绘制热力图
|
// 立即绘制热力图
|
||||||
drawHeatmap();
|
drawHeatmap();
|
||||||
// 强制再次绘制,确保显示
|
// 强制再次绘制,确保显示
|
||||||
setTimeout(drawHeatmap, 100);
|
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;
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// 使用轮询机制等待弹幕插件准备好(替代固定延迟)
|
artPlayerRef.current.on('video:loadedmetadata', updateHeatmapData);
|
||||||
let pollAttempts = 0;
|
|
||||||
const maxPollAttempts = 120; // 最多尝试 120 次(60 秒)
|
|
||||||
const pollInterval = 500; // 每 500ms 检查一次
|
|
||||||
|
|
||||||
const pollForDanmakuPlugin = () => {
|
// 监听弹幕加载完成事件
|
||||||
if (danmakuPluginRef.current && danmakuPluginRef.current.option?.danmuku) {
|
artPlayerRef.current.on('danmaku:loaded', () => {
|
||||||
// 弹幕插件已准备好且有数据
|
|
||||||
updateHeatmapData();
|
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) {
|
let pollAttempts = 0;
|
||||||
// 继续轮询
|
const maxPollAttempts = 120; // 最多尝试 120 次(60 秒)
|
||||||
setTimeout(pollForDanmakuPlugin, pollInterval);
|
const pollInterval = 500; // 每 500ms 检查一次
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 开始轮询
|
const pollForDanmakuPlugin = () => {
|
||||||
setTimeout(pollForDanmakuPlugin, 500);
|
if (danmakuPluginRef.current && danmakuPluginRef.current.option?.danmuku) {
|
||||||
|
// 弹幕插件已准备好且有数据
|
||||||
|
updateHeatmapData();
|
||||||
|
return; // 成功,停止轮询
|
||||||
|
}
|
||||||
|
|
||||||
// 清理
|
pollAttempts++;
|
||||||
return () => {
|
if (pollAttempts < maxPollAttempts) {
|
||||||
clearInterval(visibilityInterval);
|
// 继续轮询
|
||||||
window.removeEventListener('resize', resizeHandler);
|
setTimeout(pollForDanmakuPlugin, pollInterval);
|
||||||
if (progressResizeObserver) {
|
}
|
||||||
progressResizeObserver.disconnect();
|
};
|
||||||
}
|
|
||||||
if (tooltipEl && tooltipEl.parentNode) {
|
|
||||||
tooltipEl.parentNode.removeChild(tooltipEl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加全屏快进快退按钮
|
// 开始轮询
|
||||||
artPlayerRef.current.layers.add({
|
setTimeout(pollForDanmakuPlugin, 500);
|
||||||
name: 'seek-buttons',
|
|
||||||
html: `
|
// 清理
|
||||||
|
return () => {
|
||||||
|
clearInterval(visibilityInterval);
|
||||||
|
window.removeEventListener('resize', resizeHandler);
|
||||||
|
if (progressResizeObserver) {
|
||||||
|
progressResizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
if (tooltipEl && tooltipEl.parentNode) {
|
||||||
|
tooltipEl.parentNode.removeChild(tooltipEl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加全屏快进快退按钮
|
||||||
|
artPlayerRef.current.layers.add({
|
||||||
|
name: 'seek-buttons',
|
||||||
|
html: `
|
||||||
<div class="seek-buttons-container" style="display: none;">
|
<div class="seek-buttons-container" style="display: none;">
|
||||||
<button class="seek-button seek-backward" style="position: fixed; left: 20px; top: 40%; transform: translateY(-50%); width: 48px; height: 48px; background: rgba(0,0,0,0.7); border: none; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 9999; transition: opacity 0.2s;">
|
<button class="seek-button seek-backward" style="position: fixed; left: 20px; top: 40%; transform: translateY(-50%); width: 48px; height: 48px; background: rgba(0,0,0,0.7); border: none; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 9999; transition: opacity 0.2s;">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -6863,302 +6888,302 @@ function PlayPageClient() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
mounted: ($el: HTMLElement) => {
|
mounted: ($el: HTMLElement) => {
|
||||||
const container = $el.querySelector('.seek-buttons-container') as HTMLElement;
|
const container = $el.querySelector('.seek-buttons-container') as HTMLElement;
|
||||||
const backwardBtn = $el.querySelector('.seek-backward') as HTMLElement;
|
const backwardBtn = $el.querySelector('.seek-backward') as HTMLElement;
|
||||||
const forwardBtn = $el.querySelector('.seek-forward') as HTMLElement;
|
const forwardBtn = $el.querySelector('.seek-forward') as HTMLElement;
|
||||||
|
|
||||||
// 快退5秒
|
// 快退5秒
|
||||||
backwardBtn.onclick = () => {
|
backwardBtn.onclick = () => {
|
||||||
if (artPlayerRef.current) {
|
if (artPlayerRef.current) {
|
||||||
artPlayerRef.current.currentTime = Math.max(0, artPlayerRef.current.currentTime - 5);
|
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秒
|
||||||
preloadNextEpisode();
|
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(() => {
|
||||||
const nextEpisodeDanmakuPreloadEnabled = typeof window !== 'undefined'
|
if (
|
||||||
? localStorage.getItem('nextEpisodeDanmakuPreload') === 'true'
|
Math.abs(artPlayerRef.current.volume - lastVolumeRef.current) > 0.01
|
||||||
: false;
|
) {
|
||||||
|
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;
|
setIsVideoLoading(false);
|
||||||
const duration = artPlayerRef.current?.duration || 0;
|
setVideoError(null);
|
||||||
const progress = duration > 0 ? currentTime / duration : 0;
|
});
|
||||||
|
|
||||||
// 检查是否已经到达90%播放进度
|
// 监听视频时间更新事件,实现跳过片头片尾
|
||||||
if (duration > 0 && progress >= 0.9 && !nextEpisodeDanmakuPreloadTriggeredRef.current) {
|
artPlayerRef.current.on('video:timeupdate', () => {
|
||||||
// 标记已触发,防止重复执行
|
if (!skipConfigRef.current.enable) return;
|
||||||
nextEpisodeDanmakuPreloadTriggeredRef.current = true;
|
|
||||||
|
|
||||||
// 异步执行弹幕预加载
|
const currentTime = artPlayerRef.current.currentTime || 0;
|
||||||
preloadNextEpisodeDanmaku();
|
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(
|
if (
|
||||||
artPlayerRef.current.video as HTMLVideoElement,
|
skipConfigRef.current.outro_time < 0 &&
|
||||||
videoUrl
|
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) {
|
} catch (err) {
|
||||||
console.error('创建播放器失败:', err);
|
console.error('创建播放器失败:', err);
|
||||||
setError('播放器初始化失败');
|
setError('播放器初始化失败');
|
||||||
@@ -7224,30 +7249,27 @@ function PlayPageClient() {
|
|||||||
<div className='mb-6 w-80 mx-auto'>
|
<div className='mb-6 w-80 mx-auto'>
|
||||||
<div className='flex justify-center space-x-2 mb-4'>
|
<div className='flex justify-center space-x-2 mb-4'>
|
||||||
<div
|
<div
|
||||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'searching' || loadingStage === 'fetching'
|
||||||
loadingStage === 'searching' || loadingStage === 'fetching'
|
? 'bg-green-500 scale-125'
|
||||||
? 'bg-green-500 scale-125'
|
: loadingStage === 'preferring' ||
|
||||||
: loadingStage === 'preferring' ||
|
loadingStage === 'ready'
|
||||||
loadingStage === 'ready'
|
|
||||||
? 'bg-green-500'
|
? 'bg-green-500'
|
||||||
: 'bg-gray-300'
|
: 'bg-gray-300'
|
||||||
}`}
|
}`}
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'preferring'
|
||||||
loadingStage === 'preferring'
|
? 'bg-green-500 scale-125'
|
||||||
? 'bg-green-500 scale-125'
|
: loadingStage === 'ready'
|
||||||
: loadingStage === 'ready'
|
|
||||||
? 'bg-green-500'
|
? 'bg-green-500'
|
||||||
: 'bg-gray-300'
|
: 'bg-gray-300'
|
||||||
}`}
|
}`}
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'ready'
|
||||||
loadingStage === 'ready'
|
? 'bg-green-500 scale-125'
|
||||||
? 'bg-green-500 scale-125'
|
: 'bg-gray-300'
|
||||||
: 'bg-gray-300'
|
}`}
|
||||||
}`}
|
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -7258,11 +7280,11 @@ function PlayPageClient() {
|
|||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
loadingStage === 'searching' ||
|
loadingStage === 'searching' ||
|
||||||
loadingStage === 'fetching'
|
loadingStage === 'fetching'
|
||||||
? '33%'
|
? '33%'
|
||||||
: loadingStage === 'preferring'
|
: loadingStage === 'preferring'
|
||||||
? '66%'
|
? '66%'
|
||||||
: '100%',
|
: '100%',
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -7412,9 +7434,9 @@ function PlayPageClient() {
|
|||||||
<div className='flex-shrink-0'>
|
<div className='flex-shrink-0'>
|
||||||
<svg className='w-6 h-6 text-gray-400 group-hover:text-green-500
|
<svg className='w-6 h-6 text-gray-400 group-hover:text-green-500
|
||||||
transition-colors duration-200'
|
transition-colors duration-200'
|
||||||
fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
||||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2}
|
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2}
|
||||||
d='M9 5l7 7-7 7' />
|
d='M9 5l7 7-7 7' />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -7519,11 +7541,10 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${status === 'completed'
|
||||||
status === 'completed'
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{status === 'completed' ? '已完结' : '连载中'}
|
{status === 'completed' ? '已完结' : '连载中'}
|
||||||
</span>
|
</span>
|
||||||
@@ -7545,9 +7566,8 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${
|
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
|
||||||
isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
|
}`}
|
||||||
}`}
|
|
||||||
fill='none'
|
fill='none'
|
||||||
stroke='currentColor'
|
stroke='currentColor'
|
||||||
viewBox='0 0 24 24'
|
viewBox='0 0 24 24'
|
||||||
@@ -7565,27 +7585,24 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
{/* 精致的状态指示点 */}
|
{/* 精致的状态指示点 */}
|
||||||
<div
|
<div
|
||||||
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${
|
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${isEpisodeSelectorCollapsed
|
||||||
isEpisodeSelectorCollapsed
|
? 'bg-orange-400 animate-pulse'
|
||||||
? 'bg-orange-400 animate-pulse'
|
: 'bg-green-400'
|
||||||
: 'bg-green-400'
|
}`}
|
||||||
}`}
|
|
||||||
></div>
|
></div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${
|
className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${isEpisodeSelectorCollapsed
|
||||||
isEpisodeSelectorCollapsed
|
? 'grid-cols-1'
|
||||||
? 'grid-cols-1'
|
: 'grid-cols-1 md:grid-cols-4'
|
||||||
: 'grid-cols-1 md:grid-cols-4'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* 播放器 */}
|
{/* 播放器 */}
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 flex flex-col ${
|
className={`transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 flex flex-col ${isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
|
||||||
isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* 播放器容器 */}
|
{/* 播放器容器 */}
|
||||||
<div className='relative w-full h-[300px] lg:flex-1 lg:min-h-0'>
|
<div className='relative w-full h-[300px] lg:flex-1 lg:min-h-0'>
|
||||||
@@ -7675,8 +7692,8 @@ function PlayPageClient() {
|
|||||||
<div className='absolute inset-0 flex items-center justify-center bg-black/50 z-50 pointer-events-none'>
|
<div className='absolute inset-0 flex items-center justify-center bg-black/50 z-50 pointer-events-none'>
|
||||||
<div className='bg-black/80 text-white px-6 py-3 rounded-lg flex items-center gap-3 backdrop-blur-sm border border-green-500/30'>
|
<div className='bg-black/80 text-white px-6 py-3 rounded-lg flex items-center gap-3 backdrop-blur-sm border border-green-500/30'>
|
||||||
<svg className='animate-spin h-5 w-5' viewBox='0 0 24 24'>
|
<svg className='animate-spin h-5 w-5' viewBox='0 0 24 24'>
|
||||||
<circle className='opacity-25' cx='12' cy='12' r='10' stroke='currentColor' strokeWidth='4' fill='none'/>
|
<circle className='opacity-25' cx='12' cy='12' r='10' stroke='currentColor' strokeWidth='4' fill='none' />
|
||||||
<path className='opacity-75' fill='currentColor' d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'/>
|
<path className='opacity-75' fill='currentColor' d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z' />
|
||||||
</svg>
|
</svg>
|
||||||
<span>正在刷新链接...</span>
|
<span>正在刷新链接...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -7797,12 +7814,12 @@ function PlayPageClient() {
|
|||||||
<svg
|
<svg
|
||||||
className='w-4 h-4 flex-shrink-0 text-white'
|
className='w-4 h-4 flex-shrink-0 text-white'
|
||||||
fill='none'
|
fill='none'
|
||||||
stroke='currentColor'
|
stroke='currentColor'
|
||||||
viewBox='0 0 24 24'
|
viewBox='0 0 24 24'
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
strokeLinecap='round'
|
strokeLinecap='round'
|
||||||
strokeLinejoin='round'
|
strokeLinejoin='round'
|
||||||
strokeWidth='2'
|
strokeWidth='2'
|
||||||
d='M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z'
|
d='M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z'
|
||||||
/>
|
/>
|
||||||
@@ -7873,172 +7890,171 @@ function PlayPageClient() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* VLC */}
|
{/* VLC */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||||
let urlToUse = videoUrl;
|
let urlToUse = videoUrl;
|
||||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||||
}
|
}
|
||||||
// 使用代理 URL
|
// 使用代理 URL
|
||||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||||
const proxyUrl = externalPlayerAdBlock
|
const proxyUrl = externalPlayerAdBlock
|
||||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||||
: urlToUse;
|
: urlToUse;
|
||||||
// URL encode 避免冒号被吃掉
|
// URL encode 避免冒号被吃掉
|
||||||
window.open(`vlc://${proxyUrl}`, '_blank');
|
window.open(`vlc://${proxyUrl}`, '_blank');
|
||||||
}}
|
}}
|
||||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||||
title='VLC'
|
title='VLC'
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/players/vlc.png'
|
src='/players/vlc.png'
|
||||||
alt='VLC'
|
alt='VLC'
|
||||||
className='w-4 h-4 flex-shrink-0'
|
className='w-4 h-4 flex-shrink-0'
|
||||||
/>
|
/>
|
||||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||||
VLC
|
VLC
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* MPV */}
|
{/* MPV */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||||
let urlToUse = videoUrl;
|
let urlToUse = videoUrl;
|
||||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||||
}
|
}
|
||||||
// 使用代理 URL
|
// 使用代理 URL
|
||||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||||
const proxyUrl = externalPlayerAdBlock
|
const proxyUrl = externalPlayerAdBlock
|
||||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||||
: urlToUse;
|
: urlToUse;
|
||||||
// URL encode 避免冒号被吃掉
|
// URL encode 避免冒号被吃掉
|
||||||
window.open(`mpv://${proxyUrl}`, '_blank');
|
window.open(`mpv://${proxyUrl}`, '_blank');
|
||||||
}}
|
}}
|
||||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||||
title='MPV'
|
title='MPV'
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/players/mpv.png'
|
src='/players/mpv.png'
|
||||||
alt='MPV'
|
alt='MPV'
|
||||||
className='w-4 h-4 flex-shrink-0'
|
className='w-4 h-4 flex-shrink-0'
|
||||||
/>
|
/>
|
||||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||||
MPV
|
MPV
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* MX Player */}
|
{/* MX Player */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||||
let urlToUse = videoUrl;
|
let urlToUse = videoUrl;
|
||||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||||
}
|
}
|
||||||
// 使用代理 URL
|
// 使用代理 URL
|
||||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||||
const proxyUrl = externalPlayerAdBlock
|
const proxyUrl = externalPlayerAdBlock
|
||||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||||
: urlToUse;
|
: urlToUse;
|
||||||
window.open(
|
window.open(
|
||||||
`intent://${proxyUrl}#Intent;package=com.mxtech.videoplayer.ad;S.title=${encodeURIComponent(
|
`intent://${proxyUrl}#Intent;package=com.mxtech.videoplayer.ad;S.title=${encodeURIComponent(
|
||||||
videoTitle
|
videoTitle
|
||||||
)};end`,
|
)};end`,
|
||||||
'_blank'
|
'_blank'
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||||
title='MX Player'
|
title='MX Player'
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/players/mxplayer.png'
|
src='/players/mxplayer.png'
|
||||||
alt='MX Player'
|
alt='MX Player'
|
||||||
className='w-4 h-4 flex-shrink-0'
|
className='w-4 h-4 flex-shrink-0'
|
||||||
/>
|
/>
|
||||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||||
MX Player
|
MX Player
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* nPlayer */}
|
{/* nPlayer */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||||
let urlToUse = videoUrl;
|
let urlToUse = videoUrl;
|
||||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||||
}
|
}
|
||||||
// 使用代理 URL
|
// 使用代理 URL
|
||||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||||
const proxyUrl = externalPlayerAdBlock
|
const proxyUrl = externalPlayerAdBlock
|
||||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||||
: urlToUse;
|
: urlToUse;
|
||||||
window.open(`nplayer-${proxyUrl}`, '_blank');
|
window.open(`nplayer-${proxyUrl}`, '_blank');
|
||||||
}}
|
}}
|
||||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||||
title='nPlayer'
|
title='nPlayer'
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/players/nplayer.png'
|
src='/players/nplayer.png'
|
||||||
alt='nPlayer'
|
alt='nPlayer'
|
||||||
className='w-4 h-4 flex-shrink-0'
|
className='w-4 h-4 flex-shrink-0'
|
||||||
/>
|
/>
|
||||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||||
nPlayer
|
nPlayer
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* IINA */}
|
{/* IINA */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
// 如果当前是代理播放模式,使用原始 URL;否则使用当前 videoUrl
|
||||||
let urlToUse = videoUrl;
|
let urlToUse = videoUrl;
|
||||||
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) {
|
||||||
urlToUse = detail.episodes[currentEpisodeIndex];
|
urlToUse = detail.episodes[currentEpisodeIndex];
|
||||||
}
|
}
|
||||||
// 使用代理 URL
|
// 使用代理 URL
|
||||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||||
const proxyUrl = externalPlayerAdBlock
|
const proxyUrl = externalPlayerAdBlock
|
||||||
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(urlToUse)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||||
: urlToUse;
|
: urlToUse;
|
||||||
window.open(
|
window.open(
|
||||||
`iina://weblink?url=${encodeURIComponent(
|
`iina://weblink?url=${encodeURIComponent(
|
||||||
proxyUrl
|
proxyUrl
|
||||||
)}`,
|
)}`,
|
||||||
'_blank'
|
'_blank'
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
|
||||||
title='IINA'
|
title='IINA'
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src='/players/iina.png'
|
src='/players/iina.png'
|
||||||
alt='IINA'
|
alt='IINA'
|
||||||
className='w-4 h-4 flex-shrink-0'
|
className='w-4 h-4 flex-shrink-0'
|
||||||
/>
|
/>
|
||||||
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
|
||||||
IINA
|
IINA
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 去广告开关 */}
|
{/* 去广告开关 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setExternalPlayerAdBlock(!externalPlayerAdBlock)}
|
onClick={() => setExternalPlayerAdBlock(!externalPlayerAdBlock)}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer border flex-shrink-0 ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer border flex-shrink-0 ${externalPlayerAdBlock
|
||||||
externalPlayerAdBlock
|
? 'bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 text-white border-blue-400'
|
||||||
? 'bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 text-white border-blue-400'
|
: 'bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600'
|
||||||
: 'bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600'
|
}`}
|
||||||
}`}
|
|
||||||
title={externalPlayerAdBlock ? '去广告已开启' : '去广告已关闭'}
|
title={externalPlayerAdBlock ? '去广告已开启' : '去广告已关闭'}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -8075,11 +8091,10 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
|
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
|
||||||
<div
|
<div
|
||||||
className={`relative z-10 h-[350px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${
|
className={`relative z-10 h-[350px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${isEpisodeSelectorCollapsed
|
||||||
isEpisodeSelectorCollapsed
|
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
|
||||||
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
|
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
|
||||||
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<EpisodeSelector
|
<EpisodeSelector
|
||||||
totalEpisodes={totalEpisodes}
|
totalEpisodes={totalEpisodes}
|
||||||
@@ -8273,9 +8288,8 @@ function PlayPageClient() {
|
|||||||
)}
|
)}
|
||||||
{detail?.source_name && (
|
{detail?.source_name && (
|
||||||
<span
|
<span
|
||||||
className={`relative group cursor-pointer border px-2 py-[1px] rounded ${
|
className={`relative group cursor-pointer border px-2 py-[1px] rounded ${detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
|
||||||
detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
|
}`}
|
||||||
}`}
|
|
||||||
onClick={fetchCurrentSourceVideoInfo}
|
onClick={fetchCurrentSourceVideoInfo}
|
||||||
>
|
>
|
||||||
{detail.source_name}
|
{detail.source_name}
|
||||||
@@ -8373,7 +8387,7 @@ function PlayPageClient() {
|
|||||||
<div className='px-3 md:px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
|
<div className='px-3 md:px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
|
||||||
<h3 className='text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2'>
|
<h3 className='text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2'>
|
||||||
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 24 24'>
|
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 24 24'>
|
||||||
<path d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z'/>
|
<path d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z' />
|
||||||
</svg>
|
</svg>
|
||||||
豆瓣评论
|
豆瓣评论
|
||||||
</h3>
|
</h3>
|
||||||
@@ -8537,18 +8551,18 @@ function PlayPageClient() {
|
|||||||
// 特殊源使用 tmdb,其他使用 cms(通过 doubanId)
|
// 特殊源使用 tmdb,其他使用 cms(通过 doubanId)
|
||||||
// 如果有豆瓣ID且不为0,传入doubanId
|
// 如果有豆瓣ID且不为0,传入doubanId
|
||||||
detail.source === 'openlist' ||
|
detail.source === 'openlist' ||
|
||||||
detail.source?.startsWith('emby') ||
|
detail.source?.startsWith('emby') ||
|
||||||
detail.source === 'xiaoya'
|
detail.source === 'xiaoya'
|
||||||
? undefined
|
? undefined
|
||||||
: detail.douban_id && detail.douban_id !== 0
|
: detail.douban_id && detail.douban_id !== 0
|
||||||
? detail.douban_id
|
? detail.douban_id
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
tmdbId={
|
tmdbId={
|
||||||
// 特殊源使用 tmdb
|
// 特殊源使用 tmdb
|
||||||
detail.source === 'openlist' ||
|
detail.source === 'openlist' ||
|
||||||
detail.source?.startsWith('emby') ||
|
detail.source?.startsWith('emby') ||
|
||||||
detail.source === 'xiaoya'
|
detail.source === 'xiaoya'
|
||||||
? detail.tmdb_id
|
? detail.tmdb_id
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -8558,14 +8572,14 @@ function PlayPageClient() {
|
|||||||
// 非特殊源使用 cms 数据
|
// 非特殊源使用 cms 数据
|
||||||
// 但如果有豆瓣ID且不为0,则不传入cmsData,优先使用豆瓣数据
|
// 但如果有豆瓣ID且不为0,则不传入cmsData,优先使用豆瓣数据
|
||||||
detail.source !== 'openlist' &&
|
detail.source !== 'openlist' &&
|
||||||
!detail.source?.startsWith('emby') &&
|
!detail.source?.startsWith('emby') &&
|
||||||
detail.source !== 'xiaoya' &&
|
detail.source !== 'xiaoya' &&
|
||||||
!(detail.douban_id && detail.douban_id !== 0)
|
!(detail.douban_id && detail.douban_id !== 0)
|
||||||
? {
|
? {
|
||||||
desc: detail.desc,
|
desc: detail.desc,
|
||||||
episodes: detail.episodes,
|
episodes: detail.episodes,
|
||||||
episodes_titles: detail.episodes_titles,
|
episodes_titles: detail.episodes_titles,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
sourceId={detail.id}
|
sourceId={detail.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user