Files
MoonTVPlus/src/app/play/page.tsx

5143 lines
197 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */
'use client';
import { Heart } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useRef, useState } from 'react';
import { usePlaySync } from '@/hooks/usePlaySync';
import { getDoubanDetail } from '@/lib/douban.client';
import { useDownload } from '@/contexts/DownloadContext';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import {
deleteFavorite,
deletePlayRecord,
deleteSkipConfig,
generateStorageKey,
getAllPlayRecords,
getSkipConfig,
isFavorited,
saveFavorite,
savePlayRecord,
saveSkipConfig,
subscribeToDataUpdates,
getDanmakuFilterConfig,
getEpisodeFilterConfig,
} from '@/lib/db.client';
import {
convertDanmakuFormat,
getDanmakuById,
getEpisodes,
loadDanmakuMemory,
loadDanmakuSettings,
saveDanmakuMemory,
saveDanmakuSettings,
searchAnime,
initDanmakuModule,
} from '@/lib/danmaku/api';
import type { DanmakuAnime, DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types';
import { SearchResult, DanmakuFilterConfig, EpisodeFilterConfig } from '@/lib/types';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
import EpisodeSelector from '@/components/EpisodeSelector';
import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector';
import PageLayout from '@/components/PageLayout';
import DoubanComments from '@/components/DoubanComments';
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
import Toast, { ToastProps } from '@/components/Toast';
import { useEnableComments } from '@/hooks/useEnableComments';
// 扩展 HTMLVideoElement 类型以支持 hls 属性
declare global {
interface HTMLVideoElement {
hls?: any;
}
}
// Wake Lock API 类型声明
interface WakeLockSentinel {
released: boolean;
release(): Promise<void>;
addEventListener(type: 'release', listener: () => void): void;
removeEventListener(type: 'release', listener: () => void): void;
}
function PlayPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const enableComments = useEnableComments();
const { addDownloadTask } = useDownload();
// 获取 Proxy M3U8 Token
const proxyToken = typeof window !== 'undefined' ? process.env.NEXT_PUBLIC_PROXY_M3U8_TOKEN || '' : '';
// 获取用户认证信息
const authInfo = typeof window !== 'undefined' ? getAuthInfoFromBrowserCookie() : null;
// 离线下载功能配置
const enableOfflineDownload = typeof window !== 'undefined'
? (window as any).RUNTIME_CONFIG?.ENABLE_OFFLINE_DOWNLOAD || false
: false;
const hasOfflinePermission = authInfo?.role === 'owner' || authInfo?.role === 'admin';
// -----------------------------------------------------------------------------
// 状态变量State
// -----------------------------------------------------------------------------
const [loading, setLoading] = useState(true);
const [loadingStage, setLoadingStage] = useState<
'searching' | 'preferring' | 'fetching' | 'ready'
>('searching');
const [loadingMessage, setLoadingMessage] = useState('正在搜索播放源...');
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<SearchResult | null>(null);
// 收藏状态
const [favorited, setFavorited] = useState(false);
// 跳过片头片尾配置
const [skipConfig, setSkipConfig] = useState<{
enable: boolean;
intro_time: number;
outro_time: number;
}>({
enable: false,
intro_time: 0,
outro_time: 0,
});
const skipConfigRef = useRef(skipConfig);
useEffect(() => {
skipConfigRef.current = skipConfig;
}, [
skipConfig,
skipConfig.enable,
skipConfig.intro_time,
skipConfig.outro_time,
]);
// 跳过检查的时间间隔控制
const lastSkipCheckRef = useRef(0);
// 去广告开关(从 localStorage 继承,默认 true
const [blockAdEnabled, setBlockAdEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const v = localStorage.getItem('enable_blockad');
if (v !== null) return v === 'true';
}
return true;
});
const blockAdEnabledRef = useRef(blockAdEnabled);
useEffect(() => {
blockAdEnabledRef.current = blockAdEnabled;
}, [blockAdEnabled]);
// 外部播放器去广告开关(独立状态,默认 false
const [externalPlayerAdBlock, setExternalPlayerAdBlock] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const v = localStorage.getItem('external_player_adblock');
if (v !== null) return v === 'true';
}
return false;
});
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('external_player_adblock', String(externalPlayerAdBlock));
}
}, [externalPlayerAdBlock]);
// 自定义去广告代码(从服务器获取并缓存)
const customAdFilterCodeRef = useRef<string>('');
// 初始化时获取自定义去广告代码
useEffect(() => {
const fetchAdFilterCode = async () => {
if (typeof window === 'undefined') return;
try {
// 先从 localStorage 获取缓存的代码,立即可用
const cachedCode = localStorage.getItem('custom_ad_filter_code_cache');
const cachedVersion = localStorage.getItem('custom_ad_filter_version_cache');
if (cachedCode) {
customAdFilterCodeRef.current = cachedCode;
console.log('使用缓存的去广告代码');
}
// 第一步:先只获取版本号,检查是否需要更新
const versionResponse = await fetch('/api/ad-filter');
if (!versionResponse.ok) {
console.warn('获取去广告代码版本失败,使用缓存');
return;
}
const { version } = await versionResponse.json();
// 如果版本号为 0说明去广告未设置清空缓存并跳过
if (version === 0) {
console.log('去广告代码未设置(版本 0清空缓存');
localStorage.removeItem('custom_ad_filter_code_cache');
localStorage.removeItem('custom_ad_filter_version_cache');
customAdFilterCodeRef.current = '';
return;
}
// 如果版本号不一致或没有缓存,才获取完整代码
if (!cachedVersion || parseInt(cachedVersion) !== version) {
console.log('检测到去广告代码更新(版本 ' + version + '),获取最新代码');
// 第二步:获取完整代码
const fullResponse = await fetch('/api/ad-filter?full=true');
if (!fullResponse.ok) {
console.warn('获取完整去广告代码失败,使用缓存');
return;
}
const { code } = await fullResponse.json();
if (code) {
localStorage.setItem('custom_ad_filter_code_cache', code);
localStorage.setItem('custom_ad_filter_version_cache', version.toString());
customAdFilterCodeRef.current = code;
} else if (!cachedCode) {
// 如果服务器没有代码且本地也没有缓存,清空缓存
localStorage.removeItem('custom_ad_filter_code_cache');
localStorage.removeItem('custom_ad_filter_version_cache');
}
} else {
console.log('去广告代码已是最新版本(版本 ' + version + '');
}
} catch (error) {
console.error('获取去广告代码配置失败:', error);
// 失败时已经使用了缓存,无需额外处理
}
};
fetchAdFilterCode();
}, []);
// Anime4K超分相关状态
const [webGPUSupported, setWebGPUSupported] = useState<boolean>(false);
const [anime4kEnabled, setAnime4kEnabled] = useState<boolean>(false);
const [anime4kMode, setAnime4kMode] = useState<string>(() => {
if (typeof window !== 'undefined') {
const v = localStorage.getItem('anime4k_mode');
if (v !== null) return v;
}
return 'ModeA';
});
const [anime4kScale, setAnime4kScale] = useState<number>(() => {
if (typeof window !== 'undefined') {
const v = localStorage.getItem('anime4k_scale');
if (v !== null) return parseFloat(v);
}
return 2.0;
});
const anime4kRef = useRef<any>(null);
const anime4kEnabledRef = useRef(anime4kEnabled);
const anime4kModeRef = useRef(anime4kMode);
const anime4kScaleRef = useRef(anime4kScale);
useEffect(() => {
anime4kEnabledRef.current = anime4kEnabled;
anime4kModeRef.current = anime4kMode;
anime4kScaleRef.current = anime4kScale;
}, [anime4kEnabled, anime4kMode, anime4kScale]);
// 检测WebGPU支持
useEffect(() => {
const checkWebGPUSupport = async () => {
if (typeof navigator === 'undefined' || !('gpu' in navigator)) {
setWebGPUSupported(false);
console.log('WebGPU不支持浏览器不支持WebGPU API');
return;
}
try {
const adapter = await (navigator as any).gpu.requestAdapter();
if (!adapter) {
setWebGPUSupported(false);
console.log('WebGPU不支持无法获取GPU适配器');
return;
}
setWebGPUSupported(true);
console.log('WebGPU支持检测✅ 支持');
} catch (err) {
setWebGPUSupported(false);
console.log('WebGPU不支持', err);
}
};
checkWebGPUSupport();
}, []);
// 弹幕相关状态
const [danmakuSettings, setDanmakuSettings] = useState<DanmakuSettings>(
loadDanmakuSettings()
);
const [danmakuFilterConfig, setDanmakuFilterConfig] = useState<DanmakuFilterConfig | null>(null);
const danmakuFilterConfigRef = useRef<DanmakuFilterConfig | null>(null);
const [episodeFilterConfig, setEpisodeFilterConfig] = useState<EpisodeFilterConfig | null>(null);
const episodeFilterConfigRef = useRef<EpisodeFilterConfig | null>(null);
const [currentDanmakuSelection, setCurrentDanmakuSelection] =
useState<DanmakuSelection | null>(null);
const [danmakuEpisodesList, setDanmakuEpisodesList] = useState<
Array<{ episodeId: number; episodeTitle: string }>
>([]);
const [danmakuLoading, setDanmakuLoading] = useState(false);
const [danmakuCount, setDanmakuCount] = useState(0);
const danmakuPluginRef = useRef<any>(null);
const danmakuSettingsRef = useRef(danmakuSettings);
// 多条弹幕匹配结果
const [danmakuMatches, setDanmakuMatches] = useState<DanmakuAnime[]>([]);
const [showDanmakuSourceSelector, setShowDanmakuSourceSelector] = useState(false);
const [showDanmakuFilterSettings, setShowDanmakuFilterSettings] = useState(false);
const [currentSearchKeyword, setCurrentSearchKeyword] = useState<string>(''); // 当前搜索使用的关键词
const [toast, setToast] = useState<ToastProps | null>(null);
useEffect(() => {
danmakuSettingsRef.current = danmakuSettings;
}, [danmakuSettings]);
// 初始化弹幕模块(清理过期缓存)
useEffect(() => {
initDanmakuModule();
}, []);
// 加载弹幕过滤配置
useEffect(() => {
const loadFilterConfig = async () => {
try {
const config = await getDanmakuFilterConfig();
if (config) {
setDanmakuFilterConfig(config);
danmakuFilterConfigRef.current = config;
} else {
// 如果没有配置,设置默认空配置
const defaultConfig: DanmakuFilterConfig = { rules: [] };
setDanmakuFilterConfig(defaultConfig);
danmakuFilterConfigRef.current = defaultConfig;
}
// 加载集数过滤配置
const episodeConfig = await getEpisodeFilterConfig();
if (episodeConfig) {
setEpisodeFilterConfig(episodeConfig);
episodeFilterConfigRef.current = episodeConfig;
} else {
const defaultEpisodeConfig: EpisodeFilterConfig = { rules: [] };
setEpisodeFilterConfig(defaultEpisodeConfig);
episodeFilterConfigRef.current = defaultEpisodeConfig;
}
} catch (error) {
console.error('加载过滤配置失败:', error);
}
};
loadFilterConfig();
}, []);
// 同步弹幕过滤配置到ref
useEffect(() => {
danmakuFilterConfigRef.current = danmakuFilterConfig;
}, [danmakuFilterConfig]);
// 同步集数过滤配置到ref
useEffect(() => {
episodeFilterConfigRef.current = episodeFilterConfig;
}, [episodeFilterConfig]);
// 视频基本信息
const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || '');
const [videoYear, setVideoYear] = useState(searchParams.get('year') || '');
const [videoCover, setVideoCover] = useState('');
const [videoDoubanId, setVideoDoubanId] = useState(0);
// 豆瓣评分数据
const [doubanRating, setDoubanRating] = useState<{
value: number;
count: number;
star_count: number;
} | null>(null);
// 豆瓣额外信息
const [doubanCardSubtitle, setDoubanCardSubtitle] = useState<string>('');
const [doubanAka, setDoubanAka] = useState<string[]>([]);
const [doubanYear, setDoubanYear] = useState<string>(''); // 从 pubdate 提取的年份
// 当前源和ID
const [currentSource, setCurrentSource] = useState(
searchParams.get('source') || ''
);
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
// 搜索所需信息
const [searchTitle] = useState(searchParams.get('stitle') || '');
const [searchType] = useState(searchParams.get('stype') || '');
// 是否需要优选
const [needPrefer, setNeedPrefer] = useState(
searchParams.get('prefer') === 'true'
);
const needPreferRef = useRef(needPrefer);
useEffect(() => {
needPreferRef.current = needPrefer;
}, [needPrefer]);
// 集数相关
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(() => {
const episodeParam = searchParams.get('episode');
if (episodeParam) {
const episode = parseInt(episodeParam, 10);
return episode > 0 ? episode - 1 : 0; // URL 中是 1-based内部是 0-based
}
return 0;
});
// 监听 URL 参数变化,更新集数索引(用于房员跟随换集)
useEffect(() => {
const episodeParam = searchParams.get('episode');
if (episodeParam) {
const episode = parseInt(episodeParam, 10);
const newIndex = episode > 0 ? episode - 1 : 0;
console.log('[PlayPage] Checking episode from URL:', { urlEpisode: episode, currentIndex: currentEpisodeIndex, newIndex });
if (newIndex !== currentEpisodeIndex) {
console.log('[PlayPage] URL episode changed, updating index to:', newIndex);
setCurrentEpisodeIndex(newIndex);
}
}
}, [searchParams, currentEpisodeIndex]);
const currentSourceRef = useRef(currentSource);
const currentIdRef = useRef(currentId);
const videoTitleRef = useRef(videoTitle);
const videoYearRef = useRef(videoYear);
const detailRef = useRef<SearchResult | null>(detail);
const currentEpisodeIndexRef = useRef(currentEpisodeIndex);
// 同步最新值到 refs
useEffect(() => {
currentSourceRef.current = currentSource;
currentIdRef.current = currentId;
detailRef.current = detail;
currentEpisodeIndexRef.current = currentEpisodeIndex;
videoTitleRef.current = videoTitle;
videoYearRef.current = videoYear;
}, [
currentSource,
currentId,
detail,
currentEpisodeIndex,
videoTitle,
videoYear,
]);
// 监听剧集切换,自动加载对应的弹幕
const lastLoadedEpisodeIdRef = useRef<number | null>(null);
useEffect(() => {
// 只有在有弹幕选择且有剧集列表时才自动切换
if (
currentDanmakuSelection &&
danmakuEpisodesList.length > 0 &&
currentEpisodeIndex < danmakuEpisodesList.length
) {
const episode = danmakuEpisodesList[currentEpisodeIndex];
if (episode && episode.episodeId !== currentDanmakuSelection.episodeId) {
// 避免重复加载同一集的弹幕
if (lastLoadedEpisodeIdRef.current === episode.episodeId) {
console.log(`跳过重复加载弹幕: episodeId=${episode.episodeId}`);
return;
}
// 自动加载新集数的弹幕
const newSelection: DanmakuSelection = {
animeId: currentDanmakuSelection.animeId,
episodeId: episode.episodeId,
animeTitle: currentDanmakuSelection.animeTitle,
episodeTitle: episode.episodeTitle,
};
setCurrentDanmakuSelection(newSelection);
lastLoadedEpisodeIdRef.current = episode.episodeId;
loadDanmaku(episode.episodeId);
console.log(`自动切换弹幕到第 ${currentEpisodeIndex + 1}`);
}
}
}, [currentEpisodeIndex]);
// 获取豆瓣评分数据
useEffect(() => {
const fetchDoubanRating = async () => {
if (!videoDoubanId || videoDoubanId === 0) {
setDoubanRating(null);
setDoubanCardSubtitle('');
setDoubanAka([]);
setDoubanYear('');
return;
}
try {
const doubanData = await getDoubanDetail(videoDoubanId.toString());
// 设置评分
if (doubanData.rating) {
setDoubanRating({
value: doubanData.rating.value,
count: doubanData.rating.count,
star_count: doubanData.rating.star_count,
});
} else {
setDoubanRating(null);
}
// 设置 card_subtitle
if (doubanData.card_subtitle) {
setDoubanCardSubtitle(doubanData.card_subtitle);
}
// 设置 aka别名
if (doubanData.aka && doubanData.aka.length > 0) {
setDoubanAka(doubanData.aka);
}
// 处理 pubdate 获取年份
if (doubanData.pubdate && doubanData.pubdate.length > 0) {
const pubdateStr = doubanData.pubdate[0];
// 删除括号中的内容,包括括号
const yearMatch = pubdateStr.replace(/\([^)]*\)/g, '').trim();
if (yearMatch) {
setDoubanYear(yearMatch);
}
}
} catch (error) {
console.error('获取豆瓣评分失败:', error);
setDoubanRating(null);
setDoubanCardSubtitle('');
setDoubanAka([]);
setDoubanYear('');
}
};
fetchDoubanRating();
}, [videoDoubanId]);
// 视频播放地址
const [videoUrl, setVideoUrl] = useState('');
// 总集数
const totalEpisodes = detail?.episodes?.length || 0;
// 用于记录是否需要在播放器 ready 后跳转到指定进度
const resumeTimeRef = useRef<number | null>(null);
// 上次使用的音量,默认 0.7
const lastVolumeRef = useRef<number>(0.7);
// 上次使用的播放速率,默认 1.0
const lastPlaybackRateRef = useRef<number>(1.0);
// 换源相关状态
const [availableSources, setAvailableSources] = useState<SearchResult[]>([]);
const [sourceSearchLoading, setSourceSearchLoading] = useState(false);
const [sourceSearchError, setSourceSearchError] = useState<string | null>(
null
);
// 优选和测速开关
const [optimizationEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('enableOptimization');
if (saved !== null) {
try {
return JSON.parse(saved);
} catch {
/* ignore */
}
}
}
return true;
});
// 保存优选时的测速结果避免EpisodeSelector重复测速
const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState<
Map<string, { quality: string; loadSpeed: string; pingTime: number }>
>(new Map());
// 折叠状态(仅在 lg 及以上屏幕有效)
const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] =
useState(false);
// 下载选集面板显示状态
const [showDownloadSelector, setShowDownloadSelector] = useState(false);
// 换源加载状态
const [isVideoLoading, setIsVideoLoading] = useState(true);
const [videoLoadingStage, setVideoLoadingStage] = useState<
'initing' | 'sourceChanging'
>('initing');
const [videoError, setVideoError] = useState<string | null>(null);
// 播放器就绪状态(用于触发 usePlaySync 的事件监听器设置)
const [playerReady, setPlayerReady] = useState(false);
// 播放进度保存相关
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastSaveTimeRef = useRef<number>(0);
const artPlayerRef = useRef<any>(null);
const artRef = useRef<HTMLDivElement | null>(null);
// Wake Lock 相关
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
// 观影室同步功能
const playSync = usePlaySync({
artPlayerRef,
videoId: currentId || '', // 使用 currentId 状态而不是 searchParams
videoName: videoTitle || detail?.title || '正在加载...',
videoYear: videoYear || detail?.year || '',
searchTitle: searchTitle || '',
currentEpisode: currentEpisodeIndex + 1,
currentSource: currentSource || '',
videoUrl: videoUrl || '',
playerReady: playerReady, // 传递播放器就绪状态
});
// -----------------------------------------------------------------------------
// 工具函数Utils
// -----------------------------------------------------------------------------
// 判断剧集是否已完结
const isSeriesCompleted = (detail: SearchResult | null): boolean => {
if (!detail) return false;
// 方法1通过 vod_remarks 判断
if (detail.vod_remarks) {
const remarks = detail.vod_remarks.toLowerCase();
// 判定为完结的关键词
const completedKeywords = ['全', '完结', '大结局', 'end', '完'];
// 判定为连载的关键词
const ongoingKeywords = ['更新至', '连载', '第', '更新到'];
// 如果包含连载关键词,则为连载中
if (ongoingKeywords.some(keyword => remarks.includes(keyword))) {
return false;
}
// 如果包含完结关键词,则为已完结
if (completedKeywords.some(keyword => remarks.includes(keyword))) {
return true;
}
}
// 方法2通过 vod_total 和实际集数对比判断
if (detail.vod_total && detail.vod_total > 0 && detail.episodes && detail.episodes.length > 0) {
// 如果实际集数 >= 总集数,则为已完结
return detail.episodes.length >= detail.vod_total;
}
// 无法判断,默认返回 false连载中
return false;
};
// 播放源优选函数
const preferBestSource = async (
sources: SearchResult[]
): Promise<SearchResult> => {
if (sources.length === 1) return sources[0];
// 将播放源均分为两批,并发测速各批,避免一次性过多请求
const batchSize = Math.ceil(sources.length / 2);
const allResults: Array<{
source: SearchResult;
testResult: { quality: string; loadSpeed: string; pingTime: number };
} | null> = [];
for (let start = 0; start < sources.length; start += batchSize) {
const batchSources = sources.slice(start, start + batchSize);
const batchResults = await Promise.all(
batchSources.map(async (source) => {
try {
// 检查是否有第一集的播放地址
if (!source.episodes || source.episodes.length === 0) {
console.warn(`播放源 ${source.source_name} 没有可用的播放地址`);
return null;
}
const episodeUrl =
source.episodes.length > 1
? source.episodes[1]
: source.episodes[0];
const testResult = await getVideoResolutionFromM3u8(episodeUrl);
return {
source,
testResult,
};
} catch (error) {
return null;
}
})
);
allResults.push(...batchResults);
}
// 等待所有测速完成,包含成功和失败的结果
// 保存所有测速结果到 precomputedVideoInfo供 EpisodeSelector 使用(包含错误结果)
const newVideoInfoMap = new Map<
string,
{
quality: string;
loadSpeed: string;
pingTime: number;
hasError?: boolean;
}
>();
allResults.forEach((result, index) => {
const source = sources[index];
const sourceKey = `${source.source}-${source.id}`;
if (result) {
// 成功的结果
newVideoInfoMap.set(sourceKey, result.testResult);
}
});
// 过滤出成功的结果用于优选计算
const successfulResults = allResults.filter(Boolean) as Array<{
source: SearchResult;
testResult: { quality: string; loadSpeed: string; pingTime: number };
}>;
setPrecomputedVideoInfo(newVideoInfoMap);
if (successfulResults.length === 0) {
console.warn('所有播放源测速都失败,使用第一个播放源');
return sources[0];
}
// 找出所有有效速度的最大值,用于线性映射
const validSpeeds = successfulResults
.map((result) => {
const speedStr = result.testResult.loadSpeed;
if (speedStr === '未知' || speedStr === '测量中...') return 0;
const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = match[2];
return unit === 'MB/s' ? value * 1024 : value; // 统一转换为 KB/s
})
.filter((speed) => speed > 0);
const maxSpeed = validSpeeds.length > 0 ? Math.max(...validSpeeds) : 1024; // 默认1MB/s作为基准
// 找出所有有效延迟的最小值和最大值,用于线性映射
const validPings = successfulResults
.map((result) => result.testResult.pingTime)
.filter((ping) => ping > 0);
const minPing = validPings.length > 0 ? Math.min(...validPings) : 50;
const maxPing = validPings.length > 0 ? Math.max(...validPings) : 1000;
// 计算每个结果的评分
const resultsWithScore = successfulResults.map((result) => ({
...result,
score: calculateSourceScore(
result.testResult,
maxSpeed,
minPing,
maxPing
),
}));
// 按综合评分排序,选择最佳播放源
resultsWithScore.sort((a, b) => b.score - a.score);
console.log('播放源评分排序结果:');
resultsWithScore.forEach((result, index) => {
console.log(
`${index + 1}. ${
result.source.source_name
} - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${
result.testResult.loadSpeed
}, ${result.testResult.pingTime}ms)`
);
});
return resultsWithScore[0].source;
};
// 计算播放源综合评分
const calculateSourceScore = (
testResult: {
quality: string;
loadSpeed: string;
pingTime: number;
},
maxSpeed: number,
minPing: number,
maxPing: number
): number => {
let score = 0;
// 分辨率评分 (40% 权重)
const qualityScore = (() => {
switch (testResult.quality) {
case '4K':
return 100;
case '2K':
return 85;
case '1080p':
return 75;
case '720p':
return 60;
case '480p':
return 40;
case 'SD':
return 20;
default:
return 0;
}
})();
score += qualityScore * 0.4;
// 下载速度评分 (40% 权重) - 基于最大速度线性映射
const speedScore = (() => {
const speedStr = testResult.loadSpeed;
if (speedStr === '未知' || speedStr === '测量中...') return 30;
// 解析速度值
const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/);
if (!match) return 30;
const value = parseFloat(match[1]);
const unit = match[2];
const speedKBps = unit === 'MB/s' ? value * 1024 : value;
// 基于最大速度线性映射最高100分
const speedRatio = speedKBps / maxSpeed;
return Math.min(100, Math.max(0, speedRatio * 100));
})();
score += speedScore * 0.4;
// 网络延迟评分 (20% 权重) - 基于延迟范围线性映射
const pingScore = (() => {
const ping = testResult.pingTime;
if (ping <= 0) return 0; // 无效延迟给默认分
// 如果所有延迟都相同,给满分
if (maxPing === minPing) return 100;
// 线性映射:最低延迟=100分最高延迟=0分
const pingRatio = (maxPing - ping) / (maxPing - minPing);
return Math.min(100, Math.max(0, pingRatio * 100));
})();
score += pingScore * 0.2;
return Math.round(score * 100) / 100; // 保留两位小数
};
// 检查是否有本地下载的视频
const checkLocalDownload = async (
source: string,
videoId: string,
episodeIndex: number
): Promise<boolean> => {
if (!enableOfflineDownload || !hasOfflinePermission) {
return false;
}
try {
const response = await fetch(
`/api/offline-download?action=check&source=${encodeURIComponent(source)}&videoId=${encodeURIComponent(videoId)}&episodeIndex=${episodeIndex}`
);
if (response.ok) {
const data = await response.json();
return data.downloaded || false;
}
} catch (error) {
console.error('检查本地下载失败:', error);
}
return false;
};
// 更新视频地址
const updateVideoUrl = async (
detailData: SearchResult | null,
episodeIndex: number
) => {
if (
!detailData ||
!detailData.episodes ||
episodeIndex >= detailData.episodes.length
) {
setVideoUrl('');
return;
}
let newUrl = detailData?.episodes[episodeIndex] || '';
// 检查是否有本地下载的文件
const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex);
if (hasLocalFile) {
// 使用本地代理接口URL以.m3u8结尾以便Artplayer自动识别
newUrl = `/api/offline-download/local/${currentSource}/${currentId}/${episodeIndex}/playlist.m3u8`;
console.log('使用本地下载文件播放:', newUrl);
}
if (newUrl !== videoUrl) {
setVideoUrl(newUrl);
}
};
// 处理下载指定集数(支持批量下载)
const handleDownloadEpisode = async (episodeIndexes: number[], offlineMode = false) => {
if (!detail || !detail.episodes || episodeIndexes.length === 0) {
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '无法获取视频地址';
}
return;
}
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
const origin = `${window.location.protocol}//${window.location.host}`;
let successCount = 0;
let failCount = 0;
// 批量处理下载
for (const episodeIndex of episodeIndexes) {
if (episodeIndex >= detail.episodes.length) {
failCount++;
continue;
}
const episodeUrl = detail.episodes[episodeIndex];
// 离线下载模式:无论是否开启去广告,都走非去广告逻辑
const proxyUrl = offlineMode
? episodeUrl // 离线下载不使用代理直接使用原始URL
: (externalPlayerAdBlock
? `${origin}/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
: episodeUrl);
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u8') || episodeUrl.toLowerCase().includes('/m3u8/');
if (offlineMode && isM3u8) {
// 离线下载模式 - 调用服务器API
try {
const downloadTitle = `${videoTitle}_第${episodeIndex + 1}`;
const response = await fetch('/api/offline-download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: currentSource,
videoId: currentId,
episodeIndex,
title: downloadTitle,
m3u8Url: proxyUrl,
metadata: detail ? {
videoTitle: detail.title,
cover: detail.poster,
description: detail.desc,
year: detail.year,
rating: undefined, // SearchResult 没有 rating 字段
totalEpisodes: detail.episodes?.length,
} : undefined,
}),
});
const data = await response.json();
if (response.ok) {
successCount++;
} else {
console.error(`离线下载任务创建失败 (第${episodeIndex + 1}集):`, data.error);
failCount++;
}
} catch (error) {
console.error(`离线下载任务创建失败 (第${episodeIndex + 1}集):`, error);
failCount++;
}
} else if (isM3u8) {
// M3U8格式 - 使用新的下载器TS 格式
try {
const downloadTitle = `${videoTitle}_第${episodeIndex + 1}`;
await addDownloadTask(proxyUrl, downloadTitle, 'TS');
successCount++;
} catch (error) {
console.error(`添加下载任务失败 (第${episodeIndex + 1}集):`, error);
failCount++;
}
} else {
// 普通视频格式 - 直接下载
try {
const a = document.createElement('a');
a.href = proxyUrl;
a.download = `${videoTitle}_第${episodeIndex + 1}集.mp4`;
a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
successCount++;
// 添加延迟避免浏览器阻止多个下载
await new Promise(resolve => setTimeout(resolve, 300));
} catch (error) {
console.error(`下载失败 (第${episodeIndex + 1}集):`, error);
failCount++;
}
}
}
// 显示结果通知
if (artPlayerRef.current) {
if (failCount === 0) {
artPlayerRef.current.notice.show = offlineMode
? `已创建 ${successCount} 个离线下载任务!`
: `已添加 ${successCount} 个下载任务!`;
} else if (successCount === 0) {
artPlayerRef.current.notice.show = '下载失败,请重试';
} else {
artPlayerRef.current.notice.show = `成功 ${successCount} 个,失败 ${failCount}`;
}
}
};
const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => {
if (!video || !url) return;
const sources = Array.from(video.getElementsByTagName('source'));
const existed = sources.some((s) => s.src === url);
if (!existed) {
// 移除旧的 source保持唯一
sources.forEach((s) => s.remove());
const sourceEl = document.createElement('source');
sourceEl.src = url;
video.appendChild(sourceEl);
}
// 始终允许远程播放AirPlay / Cast
video.disableRemotePlayback = false;
// 如果曾经有禁用属性,移除之
if (video.hasAttribute('disableRemotePlayback')) {
video.removeAttribute('disableRemotePlayback');
}
// 确保 playsinline 属性存在iOS 兼容性)
video.setAttribute('playsinline', 'true');
video.setAttribute('webkit-playsinline', 'true');
// 使用 property 方式也设置一次,确保兼容性
(video as any).playsInline = true;
(video as any).webkitPlaysInline = true;
};
// Wake Lock 相关函数
const requestWakeLock = async () => {
try {
if ('wakeLock' in navigator) {
wakeLockRef.current = await (navigator as any).wakeLock.request(
'screen'
);
console.log('Wake Lock 已启用');
}
} catch (err) {
console.warn('Wake Lock 请求失败:', err);
}
};
const releaseWakeLock = async () => {
try {
if (wakeLockRef.current) {
await wakeLockRef.current.release();
wakeLockRef.current = null;
console.log('Wake Lock 已释放');
}
} catch (err) {
console.warn('Wake Lock 释放失败:', err);
}
};
// 清理播放器资源的统一函数
const cleanupPlayer = () => {
// 先清理Anime4K避免GPU纹理错误
cleanupAnime4K();
if (artPlayerRef.current) {
try {
// 在销毁前从弹幕插件读取最新配置并保存
if (danmakuPluginRef.current?.option && artPlayerRef.current.storage) {
// 获取当前弹幕设置的快照,避免循环引用
const currentDanmakuSettings = danmakuSettingsRef.current;
const danmakuPluginOption = danmakuPluginRef.current.option;
const currentSettings = {
...currentDanmakuSettings,
opacity: danmakuPluginOption.opacity || currentDanmakuSettings.opacity,
fontSize: danmakuPluginOption.fontSize || currentDanmakuSettings.fontSize,
speed: danmakuPluginOption.speed || currentDanmakuSettings.speed,
marginTop: (danmakuPluginOption.margin && danmakuPluginOption.margin[0]) ?? currentDanmakuSettings.marginTop,
marginBottom: (danmakuPluginOption.margin && danmakuPluginOption.margin[1]) ?? currentDanmakuSettings.marginBottom,
};
// 保存到 localStorage 和 art.storage
saveDanmakuSettings(currentSettings);
artPlayerRef.current.storage.set('danmaku_settings', currentSettings);
console.log('播放器销毁前保存弹幕设置:', currentSettings);
}
// 销毁 HLS 实例
if (artPlayerRef.current.video && artPlayerRef.current.video.hls) {
artPlayerRef.current.video.hls.destroy();
}
// 销毁 ArtPlayer 实例
artPlayerRef.current.destroy();
artPlayerRef.current = null;
// 清空 DOM 容器,确保没有残留元素
if (artRef.current) {
artRef.current.innerHTML = '';
}
console.log('播放器资源已清理');
} catch (err) {
console.warn('清理播放器资源时出错:', err);
artPlayerRef.current = null;
// 即使出错也要清空容器
if (artRef.current) {
artRef.current.innerHTML = '';
}
}
}
};
// 初始化Anime4K超分
const initAnime4K = async () => {
if (!artPlayerRef.current?.video) return;
let frameRequestId: number | null = null; // 在外层声明,以便错误处理中使用
let outputCanvas: HTMLCanvasElement | null = null; // 在外层声明,以便错误处理中清理
try {
if (anime4kRef.current) {
anime4kRef.current.stop?.();
anime4kRef.current = null;
}
const video = artPlayerRef.current.video as HTMLVideoElement;
// 等待视频元数据加载完成
if (!video.videoWidth || !video.videoHeight) {
console.warn('视频尺寸未就绪等待loadedmetadata事件');
await new Promise<void>((resolve) => {
const handler = () => {
video.removeEventListener('loadedmetadata', handler);
resolve();
};
video.addEventListener('loadedmetadata', handler);
// 如果已经加载过了立即resolve
if (video.videoWidth && video.videoHeight) {
video.removeEventListener('loadedmetadata', handler);
resolve();
}
});
}
// 再次检查视频尺寸
if (!video.videoWidth || !video.videoHeight) {
throw new Error('无法获取视频尺寸');
}
// 检查视频是否正在播放
console.log('视频播放状态:', {
paused: video.paused,
ended: video.ended,
readyState: video.readyState,
currentTime: video.currentTime,
});
// 检测是否为Firefox
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
console.log('浏览器检测:', isFirefox ? 'Firefox' : 'Chrome/Edge/其他');
// 创建输出canvas显示给用户的
outputCanvas = document.createElement('canvas');
const container = artPlayerRef.current.template.$video.parentElement;
// 使用用户选择的超分倍数
const scale = anime4kScaleRef.current;
outputCanvas.width = Math.floor(video.videoWidth * scale); // 确保是整数
outputCanvas.height = Math.floor(video.videoHeight * scale);
// 验证outputCanvas尺寸
console.log('outputCanvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
if (!outputCanvas.width || !outputCanvas.height ||
!isFinite(outputCanvas.width) || !isFinite(outputCanvas.height)) {
throw new Error(`outputCanvas尺寸无效: ${outputCanvas.width}x${outputCanvas.height}, scale: ${scale}`);
}
outputCanvas.style.position = 'absolute';
outputCanvas.style.top = '0';
outputCanvas.style.left = '0';
outputCanvas.style.width = '100%';
outputCanvas.style.height = '100%';
outputCanvas.style.objectFit = 'contain';
outputCanvas.style.cursor = 'pointer';
outputCanvas.style.zIndex = '1';
// 确保canvas背景透明避免Firefox中的渲染问题
outputCanvas.style.backgroundColor = 'transparent';
// Firefox兼容性处理创建中间canvas
let sourceCanvas: HTMLCanvasElement | null = null;
let sourceCtx: CanvasRenderingContext2D | null = null;
if (isFirefox) {
// Firefox的WebGPU不支持直接使用HTMLVideoElement
// 使用标准HTMLCanvasElement更好的兼容性
sourceCanvas = document.createElement('canvas');
// 获取视频尺寸并记录
const videoW = video.videoWidth;
const videoH = video.videoHeight;
console.log('Firefox准备创建canvas - 视频尺寸:', videoW, 'x', videoH);
// 设置canvas尺寸
const canvasW = Math.floor(videoW);
const canvasH = Math.floor(videoH);
console.log('Firefox计算后的canvas尺寸:', canvasW, 'x', canvasH);
sourceCanvas.width = canvasW;
sourceCanvas.height = canvasH;
// 立即验证赋值结果
console.log('FirefoxCanvas创建后立即检查:');
console.log(' - sourceCanvas.width:', sourceCanvas.width);
console.log(' - sourceCanvas.height:', sourceCanvas.height);
console.log(' - 赋值是否成功:', sourceCanvas.width === canvasW && sourceCanvas.height === canvasH);
// 验证sourceCanvas尺寸
if (!sourceCanvas.width || !sourceCanvas.height ||
!isFinite(sourceCanvas.width) || !isFinite(sourceCanvas.height)) {
throw new Error(`sourceCanvas尺寸无效: ${sourceCanvas.width}x${sourceCanvas.height}`);
}
if (sourceCanvas.width !== canvasW || sourceCanvas.height !== canvasH) {
throw new Error(`sourceCanvas尺寸赋值异常: 期望 ${canvasW}x${canvasH}, 实际 ${sourceCanvas.width}x${sourceCanvas.height}`);
}
sourceCtx = sourceCanvas.getContext('2d', {
willReadFrequently: true,
alpha: false // 禁用alpha通道提高性能
});
if (!sourceCtx) {
throw new Error('无法创建2D上下文');
}
// 先绘制一帧到canvas确保有内容
if (video.readyState >= video.HAVE_CURRENT_DATA) {
sourceCtx.drawImage(video, 0, 0, sourceCanvas.width, sourceCanvas.height);
console.log('Firefox已绘制初始帧到sourceCanvas');
}
console.log('Firefox检测使用HTMLCanvasElement中转方案');
}
// 在outputCanvas上监听点击事件触发播放器的暂停/播放切换
const handleCanvasClick = () => {
if (artPlayerRef.current) {
artPlayerRef.current.toggle();
}
};
outputCanvas.addEventListener('click', handleCanvasClick);
// 在outputCanvas上监听双击事件触发全屏切换
const handleCanvasDblClick = () => {
if (artPlayerRef.current) {
artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen;
}
};
outputCanvas.addEventListener('dblclick', handleCanvasDblClick);
// 隐藏原始video元素使用opacity而不是display:none以保持视频解码
// Firefox在display:none时可能会停止视频解码导致黑屏
video.style.opacity = '0';
video.style.pointerEvents = 'none';
video.style.position = 'absolute';
video.style.zIndex = '-1';
// 插入outputCanvas到容器
container.insertBefore(outputCanvas, video);
// Firefox兼容性创建视频帧捕获循环
if (isFirefox && sourceCtx && sourceCanvas) {
const captureVideoFrame = () => {
if (sourceCtx && sourceCanvas && video.readyState >= video.HAVE_CURRENT_DATA) {
sourceCtx.drawImage(video, 0, 0, sourceCanvas.width, sourceCanvas.height);
}
frameRequestId = requestAnimationFrame(captureVideoFrame);
};
captureVideoFrame();
console.log('Firefox视频帧捕获循环已启动');
}
// 动态导入 anime4k-webgpu 及对应的模式
const { render: anime4kRender, ModeA, ModeB, ModeC, ModeAA, ModeBB, ModeCA } = await import('anime4k-webgpu');
let ModeClass: any;
const modeName = anime4kModeRef.current;
switch (modeName) {
case 'ModeA':
ModeClass = ModeA;
break;
case 'ModeB':
ModeClass = ModeB;
break;
case 'ModeC':
ModeClass = ModeC;
break;
case 'ModeAA':
ModeClass = ModeAA;
break;
case 'ModeBB':
ModeClass = ModeBB;
break;
case 'ModeCA':
ModeClass = ModeCA;
break;
default:
ModeClass = ModeA;
}
// 使用anime4k-webgpu的render函数
// Firefox使用sourceCanvas其他浏览器直接使用video
const renderConfig: any = {
video: isFirefox ? sourceCanvas : video, // Firefox使用canvas中转其他浏览器直接使用video
canvas: outputCanvas,
pipelineBuilder: (device: GPUDevice, inputTexture: GPUTexture) => {
if (!outputCanvas) {
throw new Error('outputCanvas is null in pipelineBuilder');
}
const mode = new ModeClass({
device,
inputTexture,
nativeDimensions: {
width: Math.floor(video.videoWidth), // 确保是整数
height: Math.floor(video.videoHeight),
},
targetDimensions: {
width: Math.floor(outputCanvas.width), // 确保是整数
height: Math.floor(outputCanvas.height),
},
});
return [mode];
},
};
console.log('开始初始化Anime4K渲染器...');
console.log('输入源:', isFirefox ? 'HTMLCanvasElement (Firefox兼容)' : 'video (原生)');
console.log('视频尺寸:', video.videoWidth, 'x', video.videoHeight);
console.log('输出Canvas尺寸:', outputCanvas.width, 'x', outputCanvas.height);
console.log('nativeDimensions:', Math.floor(video.videoWidth), 'x', Math.floor(video.videoHeight));
console.log('targetDimensions:', Math.floor(outputCanvas.width), 'x', Math.floor(outputCanvas.height));
// Firefox调试检查sourceCanvas状态
if (isFirefox && sourceCanvas) {
console.log('sourceCanvas详细信息:');
console.log(' - width:', sourceCanvas.width, 'height:', sourceCanvas.height);
console.log(' - clientWidth:', sourceCanvas.clientWidth, 'clientHeight:', sourceCanvas.clientHeight);
console.log(' - offsetWidth:', sourceCanvas.offsetWidth, 'offsetHeight:', sourceCanvas.offsetHeight);
// 尝试读取一个像素确认canvas有内容
if (sourceCtx) {
try {
const imageData = sourceCtx.getImageData(0, 0, 1, 1);
console.log(' - 像素数据可读:', imageData.data.length > 0);
} catch (err) {
console.error(' - 无法读取像素数据:', err);
}
}
}
const controller = await anime4kRender(renderConfig);
console.log('Anime4K渲染器初始化成功');
anime4kRef.current = {
controller,
canvas: outputCanvas,
sourceCanvas: isFirefox ? sourceCanvas : null,
frameRequestId: isFirefox ? frameRequestId : null,
handleCanvasClick,
handleCanvasDblClick,
};
console.log('Anime4K超分已启用模式:', anime4kModeRef.current, '倍数:', scale);
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = `超分已启用 (${anime4kModeRef.current}, ${scale}x)`;
}
} catch (err) {
console.error('初始化Anime4K失败:', err);
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '超分启用失败:' + (err instanceof Error ? err.message : '未知错误');
}
// 停止帧捕获循环
if (frameRequestId) {
cancelAnimationFrame(frameRequestId);
}
// 移除outputCanvas如果已创建
if (outputCanvas && outputCanvas.parentNode) {
outputCanvas.parentNode.removeChild(outputCanvas);
}
// 恢复video显示
if (artPlayerRef.current?.video) {
artPlayerRef.current.video.style.opacity = '1';
artPlayerRef.current.video.style.pointerEvents = 'auto';
artPlayerRef.current.video.style.position = '';
artPlayerRef.current.video.style.zIndex = '';
}
}
};
// 清理Anime4K
const cleanupAnime4K = async () => {
if (anime4kRef.current) {
try {
// 停止帧捕获循环仅Firefox
if (anime4kRef.current.frameRequestId) {
cancelAnimationFrame(anime4kRef.current.frameRequestId);
console.log('Firefox帧捕获循环已停止');
}
// 停止渲染循环
anime4kRef.current.controller?.stop?.();
// 移除canvas事件监听器
if (anime4kRef.current.canvas) {
if (anime4kRef.current.handleCanvasClick) {
anime4kRef.current.canvas.removeEventListener('click', anime4kRef.current.handleCanvasClick);
}
if (anime4kRef.current.handleCanvasDblClick) {
anime4kRef.current.canvas.removeEventListener('dblclick', anime4kRef.current.handleCanvasDblClick);
}
}
// 移除canvas
if (anime4kRef.current.canvas && anime4kRef.current.canvas.parentNode) {
anime4kRef.current.canvas.parentNode.removeChild(anime4kRef.current.canvas);
}
// 清理sourceCanvas仅Firefox
if (anime4kRef.current.sourceCanvas) {
if (anime4kRef.current.sourceCanvas instanceof OffscreenCanvas) {
// OffscreenCanvas的清理
const ctx = anime4kRef.current.sourceCanvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, anime4kRef.current.sourceCanvas.width, anime4kRef.current.sourceCanvas.height);
}
console.log('FirefoxOffscreenCanvas已清理');
} else {
// HTMLCanvasElement的清理
const ctx = anime4kRef.current.sourceCanvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, anime4kRef.current.sourceCanvas.width, anime4kRef.current.sourceCanvas.height);
}
console.log('FirefoxHTMLCanvasElement已清理');
}
}
anime4kRef.current = null;
// 恢复原始video显示
if (artPlayerRef.current?.video) {
artPlayerRef.current.video.style.opacity = '1';
artPlayerRef.current.video.style.pointerEvents = 'auto';
artPlayerRef.current.video.style.position = '';
artPlayerRef.current.video.style.zIndex = '';
}
console.log('Anime4K已清理');
} catch (err) {
console.warn('清理Anime4K时出错:', err);
}
}
};
// 切换Anime4K状态
const toggleAnime4K = async (enabled: boolean) => {
try {
if (enabled) {
await initAnime4K();
} else {
await cleanupAnime4K();
}
setAnime4kEnabled(enabled);
localStorage.setItem('enable_anime4k', String(enabled));
} catch (err) {
console.error('切换超分状态失败:', err);
}
};
// 更改Anime4K模式
const changeAnime4KMode = async (mode: string) => {
try {
setAnime4kMode(mode);
localStorage.setItem('anime4k_mode', mode);
if (anime4kEnabledRef.current) {
await cleanupAnime4K();
await initAnime4K();
}
} catch (err) {
console.error('更改超分模式失败:', err);
}
};
// 更改Anime4K分辨率倍数
const changeAnime4KScale = async (scale: number) => {
try {
setAnime4kScale(scale);
localStorage.setItem('anime4k_scale', scale.toString());
if (anime4kEnabledRef.current) {
await cleanupAnime4K();
await initAnime4K();
}
} catch (err) {
console.error('更改超分倍数失败:', err);
}
};
function filterAdsFromM3U8(type: string, m3u8Content: string): string {
// 尝试使用缓存的自定义去广告代码
if (customAdFilterCodeRef.current && customAdFilterCodeRef.current.trim()) {
try {
// 移除 TypeScript 类型注解,转换为纯 JavaScript
const jsCode = customAdFilterCodeRef.current
// 移除函数参数的类型注解name: type
.replace(/(\w+)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*([,)])/g, '$1$3')
// 移除函数返回值类型注解:): type {
.replace(/\)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*\{/g, ') {')
// 移除变量声明的类型注解const name: type =
.replace(/(const|let|var)\s+(\w+)\s*:\s*(string|number|boolean|any|void|never|unknown|object)\s*=/g, '$1 $2 =');
// 创建并执行自定义函数
const customFunction = new Function('type', 'm3u8Content',
jsCode + '\nreturn filterAdsFromM3U8(type, m3u8Content);'
);
return customFunction(type, m3u8Content);
} catch (err) {
console.error('执行自定义去广告代码失败,使用默认规则:', err);
// 如果自定义代码执行失败,继续使用默认规则
}
}
// 默认去广告规则
if (!m3u8Content) return '';
// 按行分割M3U8内容
const lines = m3u8Content.split('\n');
const filteredLines = [];
let nextdelete = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (nextdelete) {
nextdelete = false;
continue;
}
// 只过滤#EXT-X-DISCONTINUITY标识
if (!line.includes('#EXT-X-DISCONTINUITY')) {
if (
type == 'ruyi' &&
(line.includes('EXTINF:5.640000') ||
line.includes('EXTINF:2.960000') ||
line.includes('EXTINF:3.480000') ||
line.includes('EXTINF:4.000000') ||
line.includes('EXTINF:0.960000') ||
line.includes('EXTINF:10.000000') ||
line.includes('EXTINF:1.266667'))
) {
nextdelete = true;
continue;
}
filteredLines.push(line);
}
}
return filteredLines.join('\n');
}
// 跳过片头片尾配置相关函数
const handleSkipConfigChange = async (newConfig: {
enable: boolean;
intro_time: number;
outro_time: number;
}) => {
if (!currentSourceRef.current || !currentIdRef.current) return;
try {
setSkipConfig(newConfig);
if (!newConfig.enable && !newConfig.intro_time && !newConfig.outro_time) {
await deleteSkipConfig(currentSourceRef.current, currentIdRef.current);
// 安全地更新播放器设置,仅在播放器存在时执行
if (artPlayerRef.current && artPlayerRef.current.setting) {
try {
artPlayerRef.current.setting.update({
name: '跳过片头片尾',
html: '跳过片头片尾',
switch: skipConfigRef.current.enable,
onSwitch: function (item: any) {
const newConfig = {
...skipConfigRef.current,
enable: !item.switch,
};
handleSkipConfigChange(newConfig);
return !item.switch;
},
});
artPlayerRef.current.setting.update({
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 12L17 12" stroke="#ffffff" stroke-width="2"/><path d="M17 6L17 18" stroke="#ffffff" stroke-width="2"/></svg>',
tooltip:
skipConfigRef.current.intro_time === 0
? '设置片头时间'
: `${formatTime(skipConfigRef.current.intro_time)}`,
onClick: function () {
const currentTime = artPlayerRef.current?.currentTime || 0;
if (currentTime > 0) {
const newConfig = {
...skipConfigRef.current,
intro_time: currentTime,
};
handleSkipConfigChange(newConfig);
return `${formatTime(currentTime)}`;
}
},
});
artPlayerRef.current.setting.update({
name: '设置片尾',
html: '设置片尾',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 6L7 18" stroke="#ffffff" stroke-width="2"/><path d="M7 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
tooltip:
skipConfigRef.current.outro_time >= 0
? '设置片尾时间'
: `-${formatTime(-skipConfigRef.current.outro_time)}`,
onClick: function () {
const outroTime =
-(
artPlayerRef.current?.duration -
artPlayerRef.current?.currentTime
) || 0;
if (outroTime < 0) {
const newConfig = {
...skipConfigRef.current,
outro_time: outroTime,
};
handleSkipConfigChange(newConfig);
return `-${formatTime(-outroTime)}`;
}
},
});
} catch (settingErr) {
console.warn('更新播放器设置失败:', settingErr);
}
}
} else {
await saveSkipConfig(
currentSourceRef.current,
currentIdRef.current,
newConfig
);
}
console.log('跳过片头片尾配置已保存:', newConfig);
} catch (err) {
console.error('保存跳过片头片尾配置失败:', err);
}
};
const formatTime = (seconds: number): string => {
if (seconds === 0) return '00:00';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.round(seconds % 60);
if (hours === 0) {
// 不到一小时,格式为 00:00
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
.toString()
.padStart(2, '0')}`;
} else {
// 超过一小时,格式为 00:00:00
return `${hours.toString().padStart(2, '0')}:${minutes
.toString()
.padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
};
// 创建自定义 HLS loader 的工厂函数
const createCustomHlsLoader = (HlsLib: any) => {
return class CustomHlsJsLoader extends HlsLib.DefaultConfig.loader {
constructor(config: any) {
super(config);
const load = this.load.bind(this);
this.load = function (context: any, config: any, callbacks: any) {
// 拦截manifest和level请求
if (
(context as any).type === 'manifest' ||
(context as any).type === 'level'
) {
const onSuccess = callbacks.onSuccess;
callbacks.onSuccess = function (
response: any,
stats: any,
context: any
) {
// 如果是m3u8文件处理内容以移除广告分段
if (response.data && typeof response.data === 'string') {
// 过滤掉广告段 - 实现更精确的广告过滤逻辑
response.data = filterAdsFromM3U8(
currentSourceRef.current,
response.data
);
}
return onSuccess(response, stats, context, null);
};
}
// 执行原始load方法
load(context, config, callbacks);
};
}
};
};
// 当集数索引变化时自动更新视频地址
useEffect(() => {
updateVideoUrl(detail, currentEpisodeIndex);
}, [detail, currentEpisodeIndex]);
// 进入页面时直接获取全部源信息
useEffect(() => {
const fetchSourceDetail = async (
source: string,
id: string
): Promise<SearchResult[]> => {
try {
const detailResponse = await fetch(
`/api/detail?source=${source}&id=${id}`
);
if (!detailResponse.ok) {
throw new Error('获取视频详情失败');
}
const detailData = (await detailResponse.json()) as SearchResult;
setAvailableSources([detailData]);
return [detailData];
} catch (err) {
console.error('获取视频详情失败:', err);
return [];
} finally {
setSourceSearchLoading(false);
}
};
const fetchSourcesData = async (query: string): Promise<SearchResult[]> => {
// 根据搜索词获取全部源信息
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query.trim())}`
);
if (!response.ok) {
throw new Error('搜索失败');
}
const data = await response.json();
// 处理搜索结果,根据规则过滤
const results = data.results.filter(
(result: SearchResult) =>
result.title.replaceAll(' ', '').toLowerCase() ===
videoTitleRef.current.replaceAll(' ', '').toLowerCase() &&
(videoYearRef.current
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
: true) &&
(searchType
? (searchType === 'tv' && result.episodes.length > 1) ||
(searchType === 'movie' && result.episodes.length === 1)
: true)
);
setAvailableSources(results);
return results;
} catch (err) {
setSourceSearchError(err instanceof Error ? err.message : '搜索失败');
setAvailableSources([]);
return [];
} finally {
setSourceSearchLoading(false);
}
};
const initAll = async () => {
if (!currentSource && !currentId && !videoTitle && !searchTitle) {
setError('缺少必要参数');
setLoading(false);
return;
}
setLoading(true);
setLoadingStage(currentSource && currentId ? 'fetching' : 'searching');
setLoadingMessage(
currentSource && currentId
? '🎬 正在获取视频详情...'
: '🔍 正在搜索播放源...'
);
let sourcesInfo = await fetchSourcesData(searchTitle || videoTitle);
if (
currentSource &&
currentId &&
!sourcesInfo.some(
(source) => source.source === currentSource && source.id === currentId
)
) {
sourcesInfo = await fetchSourceDetail(currentSource, currentId);
}
if (sourcesInfo.length === 0) {
setError('未找到匹配结果');
setLoading(false);
return;
}
let detailData: SearchResult = sourcesInfo[0];
// 指定源和id且无需优选
if (currentSource && currentId && !needPreferRef.current) {
const target = sourcesInfo.find(
(source) => source.source === currentSource && source.id === currentId
);
if (target) {
detailData = target;
} else {
setError('未找到匹配结果');
setLoading(false);
return;
}
}
// 未指定源和 id 或需要优选,且开启优选开关
if (
(!currentSource || !currentId || needPreferRef.current) &&
optimizationEnabled
) {
setLoadingStage('preferring');
setLoadingMessage('⚡ 正在优选最佳播放源...');
detailData = await preferBestSource(sourcesInfo);
}
console.log(detailData.source, detailData.id);
setNeedPrefer(false);
setCurrentSource(detailData.source);
setCurrentId(detailData.id);
setVideoYear(detailData.year);
setVideoTitle(detailData.title || videoTitleRef.current);
setVideoCover(detailData.poster);
setVideoDoubanId(detailData.douban_id || 0);
setDetail(detailData);
if (currentEpisodeIndex >= detailData.episodes.length) {
setCurrentEpisodeIndex(0);
}
// 规范URL参数
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('source', detailData.source);
newUrl.searchParams.set('id', detailData.id);
newUrl.searchParams.set('year', detailData.year);
newUrl.searchParams.set('title', detailData.title);
newUrl.searchParams.delete('prefer');
window.history.replaceState({}, '', newUrl.toString());
setLoadingStage('ready');
setLoadingMessage('✨ 准备就绪,即将开始播放...');
// 加载播放记录
try {
const allRecords = await getAllPlayRecords();
const key = generateStorageKey(detailData.source, detailData.id);
const record = allRecords[key];
if (record) {
const targetIndex = record.index - 1;
const targetTime = record.play_time;
// 更新当前选集索引
if (targetIndex < detailData.episodes.length && targetIndex >= 0) {
setCurrentEpisodeIndex(targetIndex);
currentEpisodeIndexRef.current = targetIndex;
}
// 保存待恢复的播放进度,待播放器就绪后跳转
resumeTimeRef.current = targetTime;
}
} catch (err) {
console.error('读取播放记录失败:', err);
}
// 短暂延迟让用户看到完成状态
setTimeout(() => {
setLoading(false);
}, 1000);
};
initAll();
}, []);
// 跳过片头片尾配置处理
useEffect(() => {
// 仅在初次挂载时检查跳过片头片尾配置
const initSkipConfig = async () => {
if (!currentSource || !currentId) return;
try {
const config = await getSkipConfig(currentSource, currentId);
if (config) {
setSkipConfig(config);
}
} catch (err) {
console.error('读取跳过片头片尾配置失败:', err);
}
};
initSkipConfig();
}, []);
// 监听 URL 参数变化,处理换源和换视频(用于房员跟随房主操作)
useEffect(() => {
const urlSource = searchParams.get('source');
const urlId = searchParams.get('id');
// 只在URL参数存在且与当前状态不同时才处理
if (urlSource && urlId && (urlSource !== currentSource || urlId !== currentId)) {
console.log('[PlayPage] Detected source/id change from URL:', {
urlSource,
urlId,
currentSource,
currentId
});
// 检查新的source和id是否在可用源列表中
const targetSource = availableSources.find(
(source) => source.source === urlSource && source.id === urlId
);
if (targetSource) {
console.log('[PlayPage] Found matching source in available sources, updating...');
// 记录当前播放进度
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
// 获取URL中的episode参数
const episodeParam = searchParams.get('episode');
const targetEpisode = episodeParam ? parseInt(episodeParam, 10) - 1 : 0;
// 更新视频源信息
setCurrentSource(urlSource);
setCurrentId(urlId);
setVideoTitle(targetSource.title);
setVideoYear(targetSource.year);
setVideoCover(targetSource.poster);
setVideoDoubanId(targetSource.douban_id || 0);
setDetail(targetSource);
// 更新集数
if (targetEpisode >= 0 && targetEpisode < targetSource.episodes.length) {
setCurrentEpisodeIndex(targetEpisode);
// 如果是同一集,保存播放进度以便恢复
if (targetEpisode === currentEpisodeIndex && currentPlayTime > 1) {
resumeTimeRef.current = currentPlayTime;
} else {
resumeTimeRef.current = null;
}
}
} else {
console.log('[PlayPage] Source not found in available sources, reloading page...');
// 如果新源不在可用列表中,强制刷新页面重新加载
window.location.reload();
}
}
}, [searchParams, currentSource, currentId, availableSources, currentEpisodeIndex]);
// 处理换源
const handleSourceChange = async (
newSource: string,
newId: string,
newTitle: string
) => {
try {
// 显示换源加载状态
setVideoLoadingStage('sourceChanging');
setIsVideoLoading(true);
setVideoError(null);
// 记录当前播放进度(仅在同一集数切换时恢复)
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
console.log('换源前当前播放时间:', currentPlayTime);
// 清除前一个历史记录
if (currentSourceRef.current && currentIdRef.current) {
try {
await deletePlayRecord(
currentSourceRef.current,
currentIdRef.current
);
console.log('已清除前一个播放记录');
} catch (err) {
console.error('清除播放记录失败:', err);
}
}
// 清除并设置下一个跳过片头片尾配置
if (currentSourceRef.current && currentIdRef.current) {
try {
await deleteSkipConfig(
currentSourceRef.current,
currentIdRef.current
);
await saveSkipConfig(newSource, newId, skipConfigRef.current);
} catch (err) {
console.error('清除跳过片头片尾配置失败:', err);
}
}
const newDetail = availableSources.find(
(source) => source.source === newSource && source.id === newId
);
if (!newDetail) {
setError('未找到匹配结果');
return;
}
// 尝试跳转到当前正在播放的集数
let targetIndex = currentEpisodeIndex;
// 如果当前集数超出新源的范围,则跳转到第一集
if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) {
targetIndex = 0;
}
// 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度
if (targetIndex !== currentEpisodeIndex) {
resumeTimeRef.current = 0;
} else if (
(!resumeTimeRef.current || resumeTimeRef.current === 0) &&
currentPlayTime > 1
) {
resumeTimeRef.current = currentPlayTime;
}
// 更新URL参数不刷新页面
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('source', newSource);
newUrl.searchParams.set('id', newId);
newUrl.searchParams.set('year', newDetail.year);
window.history.replaceState({}, '', newUrl.toString());
setVideoTitle(newDetail.title || newTitle);
setVideoYear(newDetail.year);
setVideoCover(newDetail.poster);
setVideoDoubanId(newDetail.douban_id || 0);
setCurrentSource(newSource);
setCurrentId(newId);
setDetail(newDetail);
setCurrentEpisodeIndex(targetIndex);
} catch (err) {
// 隐藏换源加载状态
setIsVideoLoading(false);
setError(err instanceof Error ? err.message : '换源失败');
}
};
useEffect(() => {
document.addEventListener('keydown', handleKeyboardShortcuts);
return () => {
document.removeEventListener('keydown', handleKeyboardShortcuts);
};
}, []);
// ---------------------------------------------------------------------------
// 集数切换
// ---------------------------------------------------------------------------
// 处理集数切换
const handleEpisodeChange = (episodeNumber: number) => {
if (episodeNumber >= 0 && episodeNumber < totalEpisodes) {
// 在更换集数前保存当前播放进度
if (artPlayerRef.current && artPlayerRef.current.paused) {
saveCurrentPlayProgress();
}
setCurrentEpisodeIndex(episodeNumber);
}
};
const handlePreviousEpisode = () => {
const d = detailRef.current;
const idx = currentEpisodeIndexRef.current;
if (d && d.episodes && idx > 0) {
if (artPlayerRef.current && !artPlayerRef.current.paused) {
saveCurrentPlayProgress();
}
setCurrentEpisodeIndex(idx - 1);
}
};
// 检查集数是否被过滤
const isEpisodeFilteredByTitle = (title: string): boolean => {
const filterConfig = episodeFilterConfigRef.current;
if (!filterConfig || filterConfig.rules.length === 0) {
return false;
}
for (const rule of filterConfig.rules) {
if (!rule.enabled) continue;
try {
if (rule.type === 'normal' && title.includes(rule.keyword)) {
return true;
}
if (rule.type === 'regex' && new RegExp(rule.keyword).test(title)) {
return true;
}
} catch (e) {
console.error('集数过滤规则错误:', e);
}
}
return false;
};
const handleNextEpisode = () => {
const d = detailRef.current;
const idx = currentEpisodeIndexRef.current;
if (!d || !d.episodes || idx >= d.episodes.length - 1) {
return;
}
// 保存当前进度
if (artPlayerRef.current && !artPlayerRef.current.paused) {
saveCurrentPlayProgress();
}
// 查找下一个未被过滤的集数
let nextIdx = idx + 1;
while (nextIdx < d.episodes.length) {
const episodeTitle = d.episodes_titles?.[nextIdx];
const isFiltered = episodeTitle && isEpisodeFilteredByTitle(episodeTitle);
if (!isFiltered) {
setCurrentEpisodeIndex(nextIdx);
return;
}
nextIdx++;
}
// 所有后续集数都被屏蔽
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '后续集数均已屏蔽';
artPlayerRef.current.pause();
}
};
// ---------------------------------------------------------------------------
// 弹幕处理函数
// ---------------------------------------------------------------------------
8
// 匹配弹幕集数:优先根据集数标题中的数字匹配,降级到索引匹配
const matchDanmakuEpisode = (
currentEpisodeIndex: number,
danmakuEpisodes: Array<{ episodeId: number; episodeTitle: string }>,
videoEpisodeTitle?: string
) => {
if (!danmakuEpisodes.length) return null;
const extractEpisodeNumber = (title: string): number | null => {
if (!title) return null;
const match = title.match(/^(\d+)$|第?\s*(\d+)\s*[集话話]?/);
return match ? parseInt(match[1] || match[2], 10) : null;
};
if (videoEpisodeTitle) {
const episodeNum = extractEpisodeNumber(videoEpisodeTitle);
if (episodeNum !== null) {
for (const ep of danmakuEpisodes) {
const danmakuNum = extractEpisodeNumber(ep.episodeTitle);
if (danmakuNum === episodeNum) {
console.log(`[弹幕匹配] 根据集数标题匹配: ${videoEpisodeTitle} -> ${ep.episodeTitle}`);
return ep;
}
}
}
}
const index = Math.min(currentEpisodeIndex, danmakuEpisodes.length - 1);
console.log(`[弹幕匹配] 降级到索引匹配: 索引 ${currentEpisodeIndex} -> ${danmakuEpisodes[index].episodeTitle}`);
return danmakuEpisodes[index];
};
// 加载弹幕到播放器
const loadDanmaku = async (episodeId: number) => {
if (!danmakuPluginRef.current) {
console.warn('弹幕插件未初始化');
return;
}
setDanmakuLoading(true);
try {
// 先清空当前弹幕并隐藏
danmakuPluginRef.current.hide();
danmakuPluginRef.current.config({
danmuku: [],
});
danmakuPluginRef.current.load();
// 获取弹幕数据
const comments = await getDanmakuById(episodeId);
if (comments.length === 0) {
console.warn('未获取到弹幕数据');
setDanmakuLoading(false);
return;
}
// 转换弹幕格式
let danmakuData = convertDanmakuFormat(comments);
// 手动应用过滤规则(因为缓存的弹幕不会经过播放器的 filter 函数)
const filterConfig = danmakuFilterConfigRef.current;
if (filterConfig && filterConfig.rules.length > 0) {
const originalCount = danmakuData.length;
danmakuData = danmakuData.filter((danmu) => {
for (const rule of filterConfig.rules) {
// 跳过未启用的规则
if (!rule.enabled) continue;
try {
if (rule.type === 'normal') {
// 普通模式:字符串包含匹配
if (danmu.text.includes(rule.keyword)) {
return false;
}
} else if (rule.type === 'regex') {
// 正则模式:正则表达式匹配
if (new RegExp(rule.keyword).test(danmu.text)) {
return false;
}
}
} catch (e) {
console.error('弹幕过滤规则错误:', e);
}
}
return true;
});
const filteredCount = originalCount - danmakuData.length;
if (filteredCount > 0) {
console.log(`弹幕过滤: 原始 ${originalCount} 条,过滤 ${filteredCount} 条,剩余 ${danmakuData.length}`);
}
}
// 加载弹幕到插件,同时应用当前的弹幕设置
const currentSettings = danmakuSettingsRef.current;
danmakuPluginRef.current.config({
danmuku: danmakuData,
speed: currentSettings.speed,
opacity: currentSettings.opacity,
fontSize: currentSettings.fontSize,
margin: [currentSettings.marginTop, currentSettings.marginBottom],
synchronousPlayback: currentSettings.synchronousPlayback,
});
danmakuPluginRef.current.load();
// 根据设置显示或隐藏弹幕
if (currentSettings.enabled) {
danmakuPluginRef.current.show();
} else {
danmakuPluginRef.current.hide();
}
setDanmakuCount(danmakuData.length);
console.log(`弹幕加载成功,共 ${danmakuData.length}`);
// 延迟一下让用户看到弹幕数量
await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) {
console.error('加载弹幕失败:', error);
setDanmakuCount(0);
} finally {
setDanmakuLoading(false);
}
};
// 处理弹幕选择
const handleDanmakuSelect = async (selection: DanmakuSelection) => {
setCurrentDanmakuSelection(selection);
// 保存选择记忆(包含搜索关键词)
saveDanmakuMemory(
videoTitleRef.current,
selection.animeId,
selection.episodeId,
selection.animeTitle,
selection.episodeTitle,
selection.searchKeyword // 保存用户使用的搜索关键词
);
// 获取该动漫的所有剧集列表
try {
const episodesResult = await getEpisodes(selection.animeId);
if (episodesResult.success && episodesResult.bangumi.episodes.length > 0) {
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
}
} catch (error) {
console.error('获取弹幕剧集列表失败:', error);
}
// 加载弹幕
await loadDanmaku(selection.episodeId);
};
// 处理用户选择弹幕源
const handleDanmakuSourceSelect = async (selectedAnime: DanmakuAnime) => {
setShowDanmakuSourceSelector(false);
setDanmakuLoading(true);
try {
const title = videoTitleRef.current;
console.log('[弹幕] 用户选择弹幕源 - 视频:', title, '弹幕源:', selectedAnime.animeTitle);
// 获取剧集列表
const episodesResult = await getEpisodes(selectedAnime.animeId);
if (
episodesResult.success &&
episodesResult.bangumi.episodes.length > 0
) {
// 保存剧集列表
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
// 根据当前集数选择对应的弹幕
const currentEp = currentEpisodeIndexRef.current;
const videoEpTitle = detailRef.current?.episodes_titles?.[currentEp];
const episode = matchDanmakuEpisode(currentEp, episodesResult.bangumi.episodes, videoEpTitle);
if (episode) {
const selection: DanmakuSelection = {
animeId: selectedAnime.animeId,
episodeId: episode.episodeId,
animeTitle: selectedAnime.animeTitle,
episodeTitle: episode.episodeTitle,
};
setCurrentDanmakuSelection(selection);
// 保存选择记忆(使用当前搜索关键词)
saveDanmakuMemory(
title,
selection.animeId,
selection.episodeId,
selection.animeTitle,
selection.episodeTitle,
currentSearchKeyword || undefined // 使用保存的搜索关键词
);
// 加载弹幕
await loadDanmaku(episode.episodeId);
console.log('用户选择弹幕源:', selection);
}
} else {
console.warn('未找到剧集信息');
}
} catch (error) {
console.error('加载弹幕失败:', error);
} finally {
setDanmakuLoading(false);
}
};
// 手动重新选择弹幕源(忽略记忆)- 保留供将来使用
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _handleReselectDanmakuSource = async () => {
const title = videoTitleRef.current;
if (!title) {
console.warn('视频标题为空,无法搜索弹幕');
return;
}
console.log('[弹幕] 用户手动重新选择弹幕源 - 视频:', title);
setDanmakuLoading(true);
try {
const searchResult = await searchAnime(title);
if (searchResult.success && searchResult.animes.length > 0) {
// 如果有多个匹配结果,让用户选择
if (searchResult.animes.length > 1) {
console.log(`[弹幕] 找到 ${searchResult.animes.length} 个弹幕源`);
setDanmakuMatches(searchResult.animes);
setShowDanmakuSourceSelector(true);
setDanmakuLoading(false);
return;
}
// 只有一个结果,直接使用
const anime = searchResult.animes[0];
await handleDanmakuSourceSelect(anime);
} else {
console.warn('[弹幕] 未找到匹配的弹幕');
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '未找到匹配的弹幕源';
}
setDanmakuLoading(false);
}
} catch (error) {
console.error('[弹幕] 搜索失败:', error);
setDanmakuLoading(false);
}
};
// 自动搜索并加载弹幕
const autoSearchDanmaku = async () => {
const title = videoTitleRef.current;
if (!title) {
console.warn('视频标题为空,无法自动搜索弹幕');
return;
}
console.log('[弹幕] 开始自动搜索 - 视频标题:', title);
// 检查是否有记忆
const memory = loadDanmakuMemory(title);
if (memory) {
console.log('[弹幕] 找到缓存 - 视频:', title, '→ 弹幕源:', memory.animeTitle);
// 获取该动漫的所有剧集列表
try {
const episodesResult = await getEpisodes(memory.animeId);
if (episodesResult.success && episodesResult.bangumi.episodes.length > 0) {
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
// 根据当前集数选择对应的弹幕
const currentEp = currentEpisodeIndexRef.current;
const videoEpTitle = detailRef.current?.episodes_titles?.[currentEp];
const episode = matchDanmakuEpisode(currentEp, episodesResult.bangumi.episodes, videoEpTitle);
if (episode) {
const selection: DanmakuSelection = {
animeId: memory.animeId,
episodeId: episode.episodeId,
animeTitle: memory.animeTitle,
episodeTitle: episode.episodeTitle,
};
setCurrentDanmakuSelection(selection);
// 更新选择记忆(保留原搜索词)
saveDanmakuMemory(
title,
selection.animeId,
selection.episodeId,
selection.animeTitle,
selection.episodeTitle,
memory.searchKeyword // 保留原有的搜索关键词
);
console.log('[弹幕] 使用缓存成功,跳过搜索');
lastLoadedEpisodeIdRef.current = episode.episodeId;
await loadDanmaku(episode.episodeId);
return; // 成功使用缓存,直接返回
}
}
// 如果使用记忆加载失败(没有找到对应的剧集),清除该记忆并继续自动搜索
console.warn('[弹幕] 缓存中没有找到对应剧集,清除缓存并重新搜索');
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '缓存的弹幕源失效,正在重新搜索...';
}
// 清除失效的记忆
if (typeof window !== 'undefined') {
try {
const memoriesJson = localStorage.getItem('danmaku_memories');
if (memoriesJson) {
const memories = JSON.parse(memoriesJson);
delete memories[title];
localStorage.setItem('danmaku_memories', JSON.stringify(memories));
console.log('[弹幕] 已清除失效的缓存');
}
} catch (e) {
console.error('[弹幕] 清除缓存失败:', e);
}
}
} catch (error) {
console.error('[弹幕] 使用缓存加载失败:', error);
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '缓存的弹幕源失效,正在重新搜索...';
}
// 清除失效的记忆
if (typeof window !== 'undefined') {
try {
const memoriesJson = localStorage.getItem('danmaku_memories');
if (memoriesJson) {
const memories = JSON.parse(memoriesJson);
delete memories[title];
localStorage.setItem('danmaku_memories', JSON.stringify(memories));
console.log('[弹幕] 已清除失效的缓存');
}
} catch (e) {
console.error('[弹幕] 清除缓存失败:', e);
}
}
}
// 如果缓存加载失败,继续执行后面的自动搜索逻辑
}
// 自动搜索弹幕
setDanmakuLoading(true);
// 优先使用保存的搜索关键词,否则使用视频标题
const searchKeyword = memory?.searchKeyword || title;
console.log('[弹幕] 搜索关键词:', searchKeyword, memory?.searchKeyword ? '(使用保存的关键词)' : '(使用视频标题)');
try {
const searchResult = await searchAnime(searchKeyword);
if (searchResult.success && searchResult.animes.length > 0) {
// 如果有多个匹配结果,让用户选择
if (searchResult.animes.length > 1) {
console.log(`找到 ${searchResult.animes.length} 个弹幕源,等待用户选择`);
setDanmakuMatches(searchResult.animes);
setCurrentSearchKeyword(searchKeyword); // 保存当前搜索关键词
setShowDanmakuSourceSelector(true);
setDanmakuLoading(false);
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = `找到 ${searchResult.animes.length} 个弹幕源,请选择`;
}
return;
}
// 只有一个结果,直接使用
const anime = searchResult.animes[0];
// 获取剧集列表
const episodesResult = await getEpisodes(anime.animeId);
if (
episodesResult.success &&
episodesResult.bangumi.episodes.length > 0
) {
// 保存剧集列表
setDanmakuEpisodesList(episodesResult.bangumi.episodes);
// 根据当前集数选择对应的弹幕
const currentEp = currentEpisodeIndexRef.current;
const videoEpTitle = detailRef.current?.episodes_titles?.[currentEp];
const episode = matchDanmakuEpisode(currentEp, episodesResult.bangumi.episodes, videoEpTitle);
if (episode) {
const selection: DanmakuSelection = {
animeId: anime.animeId,
episodeId: episode.episodeId,
animeTitle: anime.animeTitle,
episodeTitle: episode.episodeTitle,
};
setCurrentDanmakuSelection(selection);
// 保存选择记忆(保存搜索关键词)
saveDanmakuMemory(
title,
selection.animeId,
selection.episodeId,
selection.animeTitle,
selection.episodeTitle,
searchKeyword // 保存实际使用的搜索关键词
);
// 加载弹幕
lastLoadedEpisodeIdRef.current = episode.episodeId;
await loadDanmaku(episode.episodeId);
console.log('自动搜索弹幕成功:', selection);
}
} else {
console.warn('未找到剧集信息');
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '弹幕加载失败:未找到剧集信息';
}
}
} else {
console.warn('未找到匹配的弹幕');
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '未找到匹配的弹幕,可在弹幕选项卡手动搜索';
}
}
} catch (error) {
console.error('自动搜索弹幕失败:', error);
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '弹幕加载失败,请检查网络或稍后重试';
}
} finally {
setDanmakuLoading(false);
}
};
// ---------------------------------------------------------------------------
// 键盘快捷键
// ---------------------------------------------------------------------------
// 处理全局快捷键
const handleKeyboardShortcuts = (e: KeyboardEvent) => {
// 忽略输入框中的按键事件
if (
(e.target as HTMLElement).tagName === 'INPUT' ||
(e.target as HTMLElement).tagName === 'TEXTAREA'
)
return;
// Alt + 左箭头 = 上一集
if (e.altKey && e.key === 'ArrowLeft') {
if (detailRef.current && currentEpisodeIndexRef.current > 0) {
handlePreviousEpisode();
e.preventDefault();
}
}
// Alt + 右箭头 = 下一集
if (e.altKey && e.key === 'ArrowRight') {
const d = detailRef.current;
const idx = currentEpisodeIndexRef.current;
if (d && idx < d.episodes.length - 1) {
handleNextEpisode();
e.preventDefault();
}
}
// 左箭头 = 快退
if (!e.altKey && e.key === 'ArrowLeft') {
if (artPlayerRef.current && artPlayerRef.current.currentTime > 5) {
artPlayerRef.current.currentTime -= 10;
e.preventDefault();
}
}
// 右箭头 = 快进
if (!e.altKey && e.key === 'ArrowRight') {
if (
artPlayerRef.current &&
artPlayerRef.current.currentTime < artPlayerRef.current.duration - 5
) {
artPlayerRef.current.currentTime += 10;
e.preventDefault();
}
}
// 上箭头 = 音量+
if (e.key === 'ArrowUp') {
if (artPlayerRef.current && artPlayerRef.current.volume < 1) {
artPlayerRef.current.volume =
Math.round((artPlayerRef.current.volume + 0.1) * 10) / 10;
artPlayerRef.current.notice.show = `音量: ${Math.round(
artPlayerRef.current.volume * 100
)}`;
e.preventDefault();
}
}
// 下箭头 = 音量-
if (e.key === 'ArrowDown') {
if (artPlayerRef.current && artPlayerRef.current.volume > 0) {
artPlayerRef.current.volume =
Math.round((artPlayerRef.current.volume - 0.1) * 10) / 10;
artPlayerRef.current.notice.show = `音量: ${Math.round(
artPlayerRef.current.volume * 100
)}`;
e.preventDefault();
}
}
// 空格 = 播放/暂停
if (e.key === ' ') {
if (artPlayerRef.current) {
artPlayerRef.current.toggle();
e.preventDefault();
}
}
// f 键 = 切换全屏
if (e.key === 'f' || e.key === 'F') {
if (artPlayerRef.current) {
artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen;
e.preventDefault();
}
}
};
// ---------------------------------------------------------------------------
// 播放记录相关
// ---------------------------------------------------------------------------
// 保存播放进度
const saveCurrentPlayProgress = async () => {
if (
!artPlayerRef.current ||
!currentSourceRef.current ||
!currentIdRef.current ||
!videoTitleRef.current ||
!detailRef.current?.source_name
) {
return;
}
const player = artPlayerRef.current;
const currentTime = player.currentTime || 0;
const duration = player.duration || 0;
// 如果播放时间太短少于5秒或者视频时长无效不保存
if (currentTime < 1 || !duration) {
return;
}
try {
await savePlayRecord(currentSourceRef.current, currentIdRef.current, {
title: videoTitleRef.current,
source_name: detailRef.current?.source_name || '',
year: detailRef.current?.year,
cover: detailRef.current?.poster || '',
index: currentEpisodeIndexRef.current + 1, // 转换为1基索引
total_episodes: detailRef.current?.episodes.length || 1,
play_time: Math.floor(currentTime),
total_time: Math.floor(duration),
save_time: Date.now(),
search_title: searchTitle,
});
lastSaveTimeRef.current = Date.now();
console.log('播放进度已保存:', {
title: videoTitleRef.current,
episode: currentEpisodeIndexRef.current + 1,
year: detailRef.current?.year,
progress: `${Math.floor(currentTime)}/${Math.floor(duration)}`,
});
} catch (err) {
console.error('保存播放进度失败:', err);
}
};
useEffect(() => {
// 页面即将卸载时保存播放进度和清理资源
const handleBeforeUnload = () => {
saveCurrentPlayProgress();
releaseWakeLock();
cleanupPlayer();
};
// 页面可见性变化时保存播放进度和释放 Wake Lock
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
saveCurrentPlayProgress();
releaseWakeLock();
} else if (document.visibilityState === 'visible') {
// 页面重新可见时,如果正在播放则重新请求 Wake Lock
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
}
}
};
// 添加事件监听器
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
// 清理事件监听器
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [currentEpisodeIndex, detail]);
// 清理定时器
useEffect(() => {
return () => {
if (saveIntervalRef.current) {
clearInterval(saveIntervalRef.current);
}
};
}, []);
// ---------------------------------------------------------------------------
// 收藏相关
// ---------------------------------------------------------------------------
// 每当 source 或 id 变化时检查收藏状态
useEffect(() => {
if (!currentSource || !currentId) return;
(async () => {
try {
const fav = await isFavorited(currentSource, currentId);
setFavorited(fav);
} catch (err) {
console.error('检查收藏状态失败:', err);
}
})();
}, [currentSource, currentId]);
// 监听收藏数据更新事件
useEffect(() => {
if (!currentSource || !currentId) return;
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(favorites: Record<string, any>) => {
const key = generateStorageKey(currentSource, currentId);
const isFav = !!favorites[key];
setFavorited(isFav);
}
);
return unsubscribe;
}, [currentSource, currentId]);
// 切换收藏
const handleToggleFavorite = async () => {
if (
!videoTitleRef.current ||
!detailRef.current ||
!currentSourceRef.current ||
!currentIdRef.current
)
return;
try {
if (favorited) {
// 如果已收藏,删除收藏
await deleteFavorite(currentSourceRef.current, currentIdRef.current);
setFavorited(false);
} else {
// 如果未收藏,添加收藏
await saveFavorite(currentSourceRef.current, currentIdRef.current, {
title: videoTitleRef.current,
source_name: detailRef.current?.source_name || '',
year: detailRef.current?.year || 'unknown',
cover: detailRef.current?.poster || '',
total_episodes: detailRef.current?.episodes.length || 1,
save_time: Date.now(),
search_title: searchTitle,
is_completed: isSeriesCompleted(detailRef.current),
vod_remarks: detailRef.current?.vod_remarks,
});
setFavorited(true);
}
} catch (err) {
console.error('切换收藏失败:', err);
}
};
useEffect(() => {
if (
!videoUrl ||
loading ||
currentEpisodeIndex === null ||
!artRef.current
) {
return;
}
// 确保选集索引有效
if (
!detail ||
!detail.episodes ||
currentEpisodeIndex >= detail.episodes.length ||
currentEpisodeIndex < 0
) {
setError(`选集索引无效,当前共 ${totalEpisodes}`);
return;
}
if (!videoUrl) {
setError('视频地址无效');
return;
}
console.log(videoUrl);
// 检测是否为WebKit浏览器
const isWebkit =
typeof window !== 'undefined' &&
typeof (window as any).webkitConvertPointFromNodeToPage === 'function';
// 检测是否为 iOS 设备iPhone、iPad、iPod
const isIOS = (() => {
if (typeof window === 'undefined') return false;
const ua = navigator.userAgent;
// 排除 Windows Phone它的 UA 中也包含 iPhone
if ((window as any).MSStream) return false;
// 方法1检测 UA 中的 iOS 设备标识
if (/iPad|iPhone|iPod/.test(ua)) {
console.log('[设备检测] iOS 设备(通过 UA:', ua);
return true;
}
// 方法2检测 iPadiOS 13+ 桌面模式)
// 条件UA 包含 Mac + 支持触摸 + 不是 Windows/Linux
const isMacUA = ua.includes('Mac OS X');
const hasTouch = 'ontouchend' in document;
const isNotWindows = !ua.includes('Windows');
const isNotLinux = !ua.includes('Linux');
if (isMacUA && hasTouch && isNotWindows && isNotLinux) {
console.log('[设备检测] iPad 桌面模式:', { ua, hasTouch });
return true;
}
console.log('[设备检测] 非 iOS 设备:', { ua, hasTouch });
return false;
})();
// 非WebKit浏览器且播放器已存在使用switch方法切换
if (!isWebkit && artPlayerRef.current) {
artPlayerRef.current.switch = videoUrl;
artPlayerRef.current.title = `${videoTitle} - 第${
currentEpisodeIndex + 1
}`;
artPlayerRef.current.poster = videoCover;
if (artPlayerRef.current?.video) {
ensureVideoSource(
artPlayerRef.current.video as HTMLVideoElement,
videoUrl
);
}
return;
}
// WebKit浏览器或首次创建销毁之前的播放器实例并创建新的
if (artPlayerRef.current) {
cleanupPlayer();
}
// 异步初始化播放器
const initPlayer = async () => {
try {
// iOS需要等待DOM完全清理
await new Promise(resolve => setTimeout(resolve, 100));
// 双重检查:如果旧播放器仍然存在,再次清理
if (artPlayerRef.current) {
console.warn('旧播放器仍存在,再次清理');
cleanupPlayer();
await new Promise(resolve => setTimeout(resolve, 100));
}
// 再次确保容器为空
if (artRef.current) {
artRef.current.innerHTML = '';
}
// 动态导入播放器库
const [ArtplayerModule, HlsModule, DanmukuPlugin] = await Promise.all([
import('artplayer'),
import('hls.js'),
import('artplayer-plugin-danmuku'),
]);
const Artplayer = ArtplayerModule.default;
const Hls = HlsModule.default;
const artplayerPluginDanmuku = DanmukuPlugin.default as any;
// 创建自定义 HLS loader
const CustomHlsJsLoader = createCustomHlsLoader(Hls);
// 创建新的播放器实例
Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
Artplayer.USE_RAF = true;
artPlayerRef.current = new Artplayer({
container: artRef.current!,
url: videoUrl,
poster: videoCover,
volume: 0.7,
isLive: false,
muted: false,
autoplay: true,
pip: true,
autoSize: false,
autoMini: false,
screenshot: false,
setting: true,
loop: false,
flip: false,
playbackRate: true,
aspectRatio: false,
fullscreen: !isIOS, // iOS 禁用原生全屏按钮,避免触发系统播放器
fullscreenWeb: true, // 保留网页全屏按钮(所有平台)
subtitleOffset: false,
miniProgressBar: false,
mutex: true,
playsInline: true,
autoPlayback: false,
airplay: true,
theme: '#22c55e',
lang: 'zh-cn',
hotkey: false,
fastForward: true,
autoOrientation: true,
lock: true,
moreVideoAttr: {
crossOrigin: 'anonymous',
playsInline: true,
'webkit-playsinline': 'true',
} as any,
// HLS 支持配置
customType: {
m3u8: function (video: HTMLVideoElement, url: string) {
if (!Hls) {
console.error('HLS.js 未加载');
return;
}
if (video.hls) {
video.hls.destroy();
}
// 每次创建HLS实例时都读取最新的blockAdEnabled状态
const shouldUseCustomLoader = blockAdEnabledRef.current;
const hls = new Hls({
debug: false, // 关闭日志
enableWorker: true, // WebWorker 解码,降低主线程压力
lowLatencyMode: true, // 开启低延迟 LL-HLS
/* 缓冲/内存相关 */
maxBufferLength: 30, // 前向缓冲最大 30s过大容易导致高延迟
backBufferLength: 30, // 仅保留 30s 已播放内容,避免内存占用
maxBufferSize: 60 * 1000 * 1000, // 约 60MB超出后触发清理
/* 自定义loader */
loader: (shouldUseCustomLoader
? CustomHlsJsLoader
: Hls.DefaultConfig.loader) 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;
hls.on(Hls.Events.ERROR, function (event: any, data: any) {
console.error('HLS Error:', event, data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
// 检查是否是 manifest 加载错误(通常是 403/404/CORS 错误)
if (data.details === 'manifestLoadError') {
console.log('Manifest 加载失败:可能是 403/404 或 CORS 错误');
hls.destroy();
// 检查是否有响应码
const statusCode = data.response?.code || data.response?.status;
if (statusCode === 403) {
setVideoError('访问被拒绝 (403)');
} else if (statusCode === 404) {
setVideoError('视频不存在 (404)');
} else if (statusCode) {
setVideoError(`HTTP ${statusCode} 错误`);
} else {
// CORS 错误或其他网络错误
setVideoError('无法访问视频源(可能是跨域限制或访问被拒绝)');
}
return;
}
// 检查其他 HTTP 错误状态码
const statusCode = data.response?.code || data.response?.status;
if (statusCode && statusCode >= 400) {
console.log(`HTTP ${statusCode} 错误`);
hls.destroy();
setVideoError(`HTTP ${statusCode} 错误`);
return;
}
console.log('网络错误,尝试恢复...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log('媒体错误,尝试恢复...');
hls.recoverMediaError();
break;
default:
console.log('无法恢复的错误');
hls.destroy();
setVideoError('视频加载错误');
break;
}
}
});
},
},
// 弹幕插件
plugins: [
artplayerPluginDanmuku({
danmuku: [],
speed: danmakuSettingsRef.current.speed,
opacity: danmakuSettingsRef.current.opacity,
fontSize: danmakuSettingsRef.current.fontSize,
color: '#FFFFFF',
mode: 0,
margin: [danmakuSettingsRef.current.marginTop, danmakuSettingsRef.current.marginBottom],
antiOverlap: true,
synchronousPlayback: danmakuSettingsRef.current.synchronousPlayback,
emitter: false,
// 主题
theme: 'dark',
filter: (danmu: any) => {
// 应用过滤规则
const filterConfig = danmakuFilterConfigRef.current;
if (filterConfig && filterConfig.rules.length > 0) {
for (const rule of filterConfig.rules) {
// 跳过未启用的规则
if (!rule.enabled) continue;
try {
if (rule.type === 'normal') {
// 普通模式:字符串包含匹配
if (danmu.text.includes(rule.keyword)) {
return false;
}
} else if (rule.type === 'regex') {
// 正则模式:正则表达式匹配
if (new RegExp(rule.keyword).test(danmu.text)) {
return false;
}
}
} catch (e) {
console.error('弹幕过滤规则错误:', e);
}
}
}
return true;
},
}),
],
icons: {
loading:
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
},
settings: [
{
html: '去广告',
icon: '<text x="50%" y="50%" font-size="20" font-weight="bold" text-anchor="middle" dominant-baseline="middle" fill="#ffffff">AD</text>',
tooltip: blockAdEnabled ? '已开启' : '已关闭',
onClick() {
const newVal = !blockAdEnabled;
try {
localStorage.setItem('enable_blockad', String(newVal));
if (artPlayerRef.current) {
resumeTimeRef.current = artPlayerRef.current.currentTime;
if (
artPlayerRef.current.video &&
artPlayerRef.current.video.hls
) {
artPlayerRef.current.video.hls.destroy();
}
artPlayerRef.current.destroy();
artPlayerRef.current = null;
}
setBlockAdEnabled(newVal);
} catch (_) {
// ignore
}
return newVal ? '当前开启' : '当前关闭';
},
},
{
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 '打开设置';
},
},
...(webGPUSupported ? [
{
name: 'Anime4K超分',
html: 'Anime4K超分',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5zm0 18c-4 0-7-3-7-7V9l7-3.5L19 9v4c0 4-3 7-7 7z" fill="#ffffff"/><path d="M10 12l2 2 4-4" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
switch: anime4kEnabledRef.current,
onSwitch: async function (item: any) {
const newVal = !item.switch;
await toggleAnime4K(newVal);
return newVal;
},
},
{
name: '超分模式',
html: '超分模式',
selector: [
{
html: 'ModeA (快速)',
value: 'ModeA',
default: anime4kModeRef.current === 'ModeA',
},
{
html: 'ModeB (平衡)',
value: 'ModeB',
default: anime4kModeRef.current === 'ModeB',
},
{
html: 'ModeC (质量)',
value: 'ModeC',
default: anime4kModeRef.current === 'ModeC',
},
{
html: 'ModeAA (增强快速)',
value: 'ModeAA',
default: anime4kModeRef.current === 'ModeAA',
},
{
html: 'ModeBB (增强平衡)',
value: 'ModeBB',
default: anime4kModeRef.current === 'ModeBB',
},
{
html: 'ModeCA (最高质量)',
value: 'ModeCA',
default: anime4kModeRef.current === 'ModeCA',
},
],
onSelect: async function (item: any) {
await changeAnime4KMode(item.value);
return item.html;
},
},
{
name: '超分倍数',
html: '超分倍数',
selector: [
{
html: '1.5x',
value: '1.5',
default: anime4kScaleRef.current === 1.5,
},
{
html: '2.0x',
value: '2.0',
default: anime4kScaleRef.current === 2.0,
},
{
html: '3.0x',
value: '3.0',
default: anime4kScaleRef.current === 3.0,
},
{
html: '4.0x',
value: '4.0',
default: anime4kScaleRef.current === 4.0,
},
],
onSelect: async function (item: any) {
await changeAnime4KScale(parseFloat(item.value));
return item.html;
},
}
] : []),
{
name: '跳过片头片尾',
html: '跳过片头片尾',
switch: skipConfigRef.current.enable,
onSwitch: function (item) {
const newConfig = {
...skipConfigRef.current,
enable: !item.switch,
};
handleSkipConfigChange(newConfig);
return !item.switch;
},
},
{
html: '删除跳过配置',
onClick: function () {
handleSkipConfigChange({
enable: false,
intro_time: 0,
outro_time: 0,
});
return '';
},
},
{
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 12L17 12" stroke="#ffffff" stroke-width="2"/><path d="M17 6L17 18" stroke="#ffffff" stroke-width="2"/></svg>',
tooltip:
skipConfigRef.current.intro_time === 0
? '设置片头时间'
: `${formatTime(skipConfigRef.current.intro_time)}`,
onClick: function () {
// 安全地获取当前播放时间,避免循环依赖
const player = artPlayerRef.current;
if (player && player.currentTime) {
const currentTime = player.currentTime || 0;
if (currentTime > 0) {
const newConfig = {
...skipConfigRef.current,
intro_time: currentTime,
};
handleSkipConfigChange(newConfig);
return `${formatTime(currentTime)}`;
}
}
return '';
},
},
{
name: '设置片尾',
html: '设置片尾',
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 6L7 18" stroke="#ffffff" stroke-width="2"/><path d="M7 12L15 12" stroke="#ffffff" stroke-width="2"/><circle cx="19" cy="12" r="2" fill="#ffffff"/></svg>',
tooltip:
skipConfigRef.current.outro_time >= 0
? '设置片尾时间'
: `-${formatTime(-skipConfigRef.current.outro_time)}`,
onClick: function () {
// 安全地获取播放器时长和当前时间,避免循环依赖
const player = artPlayerRef.current;
if (player && player.duration && player.currentTime) {
const outroTime =
-(player.duration - player.currentTime) || 0;
if (outroTime < 0) {
const newConfig = {
...skipConfigRef.current,
outro_time: outroTime,
};
handleSkipConfigChange(newConfig);
return `-${formatTime(-outroTime)}`;
}
}
return '';
},
},
],
// 控制栏配置
controls: [
{
position: 'left',
index: 13,
html: '<i class="art-icon flex"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" fill="currentColor"/></svg></i>',
tooltip: '播放下一集',
click: function () {
// 房员禁用下一集按钮
if (playSync.shouldDisableControls) {
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '房员无法切换集数,请等待房主操作';
}
return;
}
handleNextEpisode();
},
},
// iOS 设备上添加自定义全屏按钮(横屏和竖屏都显示)
...(isIOS ? [{
position: 'right',
index: 100, // 大数字确保在设置按钮右边
html: '<i class="art-icon ios-portrait-fullscreen"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" fill="currentColor"/></svg></i>',
tooltip: '全屏',
style: {
color: '#fff',
},
mounted: function($el: HTMLElement) {
// 添加 CSS 样式:横屏和竖屏都显示
const style = document.createElement('style');
style.textContent = `
/* iOS 自定义全屏按钮在所有方向都显示 */
.ios-portrait-fullscreen {
display: inline-flex !important;
}
/* iOS 全屏选择对话框样式(遵循项目统一风格) */
.ios-fullscreen-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.ios-fullscreen-dialog-content {
background: white;
border-radius: 16px;
max-width: 480px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.dark .ios-fullscreen-dialog-content {
background: rgb(31, 41, 55);
}
/* 标题栏 */
.ios-fullscreen-dialog-header {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
padding: 20px 24px;
}
.ios-fullscreen-dialog-title {
font-size: 20px;
font-weight: 700;
color: white;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.ios-fullscreen-dialog-title svg {
stroke: white;
}
.ios-fullscreen-dialog-subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
}
/* 选项列表 */
.ios-fullscreen-dialog-options {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.ios-fullscreen-option {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: rgb(249, 250, 251);
border: 2px solid transparent;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.dark .ios-fullscreen-option {
background: rgba(55, 65, 81, 0.5);
}
.ios-fullscreen-option:hover {
background: rgb(243, 244, 246);
border-color: #22c55e;
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.15);
}
.dark .ios-fullscreen-option:hover {
background: rgb(55, 65, 81);
}
.ios-fullscreen-option:active {
transform: scale(0.98);
}
/* 推荐选项 */
.ios-fullscreen-option-recommended {
border-color: #22c55e;
}
/* 选项图标 */
.ios-fullscreen-option-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 10px;
color: #22c55e;
}
.dark .ios-fullscreen-option-icon {
background: rgb(31, 41, 55);
}
.ios-fullscreen-option-recommended .ios-fullscreen-option-icon {
background: #22c55e;
color: white;
}
/* 选项内容 */
.ios-fullscreen-option-content {
flex: 1;
}
.ios-fullscreen-option-title {
font-size: 16px;
font-weight: 600;
color: rgb(17, 24, 39);
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.dark .ios-fullscreen-option-title {
color: white;
}
.ios-fullscreen-option-badge {
display: inline-block;
padding: 2px 8px;
background: #22c55e;
color: white;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
}
.ios-fullscreen-option-desc {
font-size: 13px;
color: rgb(107, 114, 128);
line-height: 1.4;
}
.dark .ios-fullscreen-option-desc {
color: rgb(156, 163, 175);
}
/* 箭头图标 */
.ios-fullscreen-option-arrow {
flex-shrink: 0;
color: rgb(209, 213, 219);
transition: transform 0.2s;
}
.dark .ios-fullscreen-option-arrow {
color: rgb(75, 85, 99);
}
.ios-fullscreen-option:hover .ios-fullscreen-option-arrow {
transform: translateX(4px);
color: #22c55e;
}
/* 底部提示 */
.ios-fullscreen-dialog-footer {
padding: 16px 24px;
background: rgb(249, 250, 251);
border-top: 1px solid rgb(229, 231, 235);
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 12px;
color: rgb(107, 114, 128);
line-height: 1.5;
}
.dark .ios-fullscreen-dialog-footer {
background: rgba(17, 24, 39, 0.5);
border-top-color: rgb(55, 65, 81);
color: rgb(156, 163, 175);
}
.ios-fullscreen-dialog-footer svg {
flex-shrink: 0;
margin-top: 2px;
stroke: currentColor;
}
`;
document.head.appendChild(style);
},
click: function () {
if (!artPlayerRef.current) return;
// 检测是否在 PWA 模式下
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
window.matchMedia('(display-mode: fullscreen)').matches ||
(window.navigator as any).standalone === true;
// 检查是否已经在原生全屏状态
const isInNativeFullscreen = !!(document.fullscreenElement || (document as any).webkitFullscreenElement);
// 如果已经在原生全屏状态,退出原生全屏
if (isInNativeFullscreen) {
const exitFullscreen = (document as any).exitFullscreen ||
(document as any).webkitExitFullscreen ||
(document as any).mozCancelFullScreen ||
(document as any).msExitFullscreen;
if (exitFullscreen) {
try {
const result = exitFullscreen.call(document);
if (result && typeof result.catch === 'function') {
result.catch((err: Error) => console.error('退出全屏失败:', err));
}
} catch (err) {
console.error('退出全屏失败:', err);
}
}
return;
}
// 如果已经在网页全屏状态,退出网页全屏
if (artPlayerRef.current.fullscreenWeb) {
artPlayerRef.current.fullscreenWeb = false;
return;
}
// 如果在 PWA 模式下,直接使用容器全屏(可以隐藏状态栏)
if (isPWA) {
const container = artPlayerRef.current.template.$container;
if (container && container.webkitEnterFullscreen) {
container.webkitEnterFullscreen().catch((err: Error) => {
console.error('PWA 全屏失败:', err);
// 如果失败,降级使用网页全屏
artPlayerRef.current.fullscreenWeb = true;
});
} else {
// 不支持原生全屏,使用网页全屏
artPlayerRef.current.fullscreenWeb = true;
}
return;
}
// 非 PWA 模式:创建对话框(使用项目统一风格)
const dialog = document.createElement('div');
dialog.className = 'ios-fullscreen-dialog';
dialog.innerHTML = `
<div class="ios-fullscreen-dialog-content">
<!-- 标题栏 -->
<div class="ios-fullscreen-dialog-header">
<h3 class="ios-fullscreen-dialog-title">
<svg width="24" height="24" 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" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
选择全屏模式
</h3>
<p class="ios-fullscreen-dialog-subtitle">
由于 iOS 系统限制,原生全屏会使用系统播放器,将无法显示弹幕及使用部分播放器功能。网页全屏可能无法完全占满屏幕,但可保留所有功能。
</p>
</div>
<!-- 选项列表 -->
<div class="ios-fullscreen-dialog-options">
<!-- 网页全屏选项 -->
<button class="ios-fullscreen-option ios-fullscreen-option-recommended" data-action="web">
<div class="ios-fullscreen-option-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/>
<path d="M7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z" fill="currentColor"/>
</svg>
</div>
<div class="ios-fullscreen-option-content">
<div class="ios-fullscreen-option-title">
网页全屏
<span class="ios-fullscreen-option-badge">推荐</span>
</div>
<div class="ios-fullscreen-option-desc">
保留弹幕、控制栏等所有功能
</div>
</div>
<svg class="ios-fullscreen-option-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<!-- 原生全屏选项 -->
<button class="ios-fullscreen-option" data-action="native">
<div class="ios-fullscreen-option-icon">
<svg width="24" height="24" 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" stroke="currentColor" stroke-width="2"/>
</svg>
</div>
<div class="ios-fullscreen-option-content">
<div class="ios-fullscreen-option-title">
原生全屏
</div>
<div class="ios-fullscreen-option-desc">
使用系统播放器,部分功能不可用
</div>
</div>
<svg class="ios-fullscreen-option-arrow" width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- 底部提示 -->
<div class="ios-fullscreen-dialog-footer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<path d="M12 16v-4m0-4h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>将网站添加到主屏幕PWA网页全屏可以完全全屏</span>
</div>
</div>
`;
// 添加到页面
document.body.appendChild(dialog);
// 点击背景关闭
dialog.addEventListener('click', (e) => {
if (e.target === dialog) {
document.body.removeChild(dialog);
}
});
// 按钮点击事件
const buttons = dialog.querySelectorAll('.ios-fullscreen-option');
buttons.forEach(button => {
button.addEventListener('click', () => {
const action = button.getAttribute('data-action');
if (action === 'web') {
// 网页全屏
if (artPlayerRef.current) {
artPlayerRef.current.fullscreenWeb = true;
}
} else if (action === 'native') {
// 原生全屏(尝试使用浏览器的全屏 API
if (artPlayerRef.current && artPlayerRef.current.template.$video) {
const videoElement = artPlayerRef.current.template.$video;
if (videoElement.requestFullscreen) {
videoElement.requestFullscreen();
} else if ((videoElement as any).webkitEnterFullscreen) {
(videoElement as any).webkitEnterFullscreen();
}
}
}
// 关闭对话框
document.body.removeChild(dialog);
});
});
},
}] : []),
],
});
// 监听播放器事件
artPlayerRef.current.on('ready', async () => {
setError(null);
// 标记播放器已就绪,触发 usePlaySync 设置事件监听器
setPlayerReady(true);
console.log('[PlayPage] Player ready, triggering sync setup');
// 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);
}
});
// 根据设置显示或隐藏弹幕
if (danmakuSettingsRef.current.enabled) {
danmakuPluginRef.current.show();
} else {
danmakuPluginRef.current.hide();
}
// 自动搜索并加载弹幕
await autoSearchDanmaku();
}
// 播放器就绪后,如果正在播放则请求 Wake Lock
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
}
});
// 监听播放状态变化,控制 Wake Lock
artPlayerRef.current.on('play', () => {
requestWakeLock();
});
artPlayerRef.current.on('pause', () => {
releaseWakeLock();
saveCurrentPlayProgress();
});
artPlayerRef.current.on('video:ended', () => {
releaseWakeLock();
});
// 如果播放器初始化时已经在播放状态,则请求 Wake Lock
if (artPlayerRef.current && !artPlayerRef.current.paused) {
requestWakeLock();
}
artPlayerRef.current.on('video:volumechange', () => {
lastVolumeRef.current = artPlayerRef.current.volume;
});
artPlayerRef.current.on('video:ratechange', () => {
lastPlaybackRateRef.current = artPlayerRef.current.playbackRate;
});
// 监听视频可播放事件,这时恢复播放进度更可靠
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;
}
});
artPlayerRef.current.on('pause', () => {
saveCurrentPlayProgress();
});
if (artPlayerRef.current?.video) {
ensureVideoSource(
artPlayerRef.current.video as HTMLVideoElement,
videoUrl
);
}
} catch (err) {
console.error('创建播放器失败:', err);
setError('播放器初始化失败');
}
};
// 调用异步初始化函数
initPlayer();
}, [videoUrl, loading, blockAdEnabled, currentEpisodeIndex, detail]);
// 当组件卸载时清理定时器、Wake Lock 和播放器资源
useEffect(() => {
return () => {
// 清理定时器
if (saveIntervalRef.current) {
clearInterval(saveIntervalRef.current);
}
// 释放 Wake Lock
releaseWakeLock();
// 清理Anime4K
cleanupAnime4K();
// 销毁播放器实例
cleanupPlayer();
};
}, []);
if (loading) {
return (
<PageLayout activePath='/play'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 动画影院图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>
{loadingStage === 'searching' && '🔍'}
{loadingStage === 'preferring' && '⚡'}
{loadingStage === 'fetching' && '🎬'}
{loadingStage === 'ready' && '✨'}
</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 进度指示器 */}
<div className='mb-6 w-80 mx-auto'>
<div className='flex justify-center space-x-2 mb-4'>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${
loadingStage === 'searching' || loadingStage === 'fetching'
? 'bg-green-500 scale-125'
: loadingStage === 'preferring' ||
loadingStage === 'ready'
? 'bg-green-500'
: 'bg-gray-300'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${
loadingStage === 'preferring'
? 'bg-green-500 scale-125'
: loadingStage === 'ready'
? 'bg-green-500'
: 'bg-gray-300'
}`}
></div>
<div
className={`w-3 h-3 rounded-full transition-all duration-500 ${
loadingStage === 'ready'
? 'bg-green-500 scale-125'
: 'bg-gray-300'
}`}
></div>
</div>
{/* 进度条 */}
<div className='w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden'>
<div
className='h-full bg-gradient-to-r from-green-500 to-emerald-600 rounded-full transition-all duration-1000 ease-out'
style={{
width:
loadingStage === 'searching' ||
loadingStage === 'fetching'
? '33%'
: loadingStage === 'preferring'
? '66%'
: '100%',
}}
></div>
</div>
</div>
{/* 加载消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-gray-800 dark:text-gray-200 animate-pulse'>
{loadingMessage}
</p>
</div>
</div>
</div>
</PageLayout>
);
}
if (error) {
return (
<PageLayout activePath='/play'>
<div className='flex items-center justify-center min-h-screen bg-transparent'>
<div className='text-center max-w-md mx-auto px-6'>
{/* 错误图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>😵</div>
{/* 脉冲效果 */}
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
</div>
{/* 浮动错误粒子 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-red-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-orange-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-yellow-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 错误信息 */}
<div className='space-y-4 mb-8'>
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
</h2>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
<p className='text-red-600 dark:text-red-400 font-medium'>
{error}
</p>
</div>
<p className='text-sm text-gray-500 dark:text-gray-400'>
</p>
</div>
{/* 操作按钮 */}
<div className='space-y-3'>
<button
onClick={() =>
videoTitle
? router.push(`/search?q=${encodeURIComponent(videoTitle)}`)
: router.back()
}
className='w-full px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-xl font-medium hover:from-green-600 hover:to-emerald-700 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
>
{videoTitle ? '🔍 返回搜索' : '← 返回上页'}
</button>
<button
onClick={() => window.location.reload()}
className='w-full px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200'
>
🔄
</button>
</div>
</div>
</div>
</PageLayout>
);
}
return (
<PageLayout activePath='/play'>
{/* 弹幕源选择对话框 */}
{showDanmakuSourceSelector && danmakuMatches.length > 0 && (
<div className='fixed inset-0 z-[1000] flex items-center justify-center bg-black/60 backdrop-blur-sm'>
<div className='relative w-full max-w-2xl max-h-[80vh] mx-4 bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden'>
{/* 标题栏 */}
<div className='sticky top-0 z-10 bg-gradient-to-r from-green-500 to-emerald-600 px-6 py-4'>
<h3 className='text-xl font-bold text-white flex items-center gap-2'>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z' />
</svg>
</h3>
<p className='text-sm text-white/90 mt-1'>
{danmakuMatches.length}
</p>
</div>
{/* 列表区域 */}
<div className='overflow-y-auto max-h-[60vh] p-4'>
<div className='space-y-4'>
{danmakuMatches.map((anime, index) => (
<button
key={anime.animeId}
onClick={() => handleDanmakuSourceSelect(anime)}
className='w-full flex flex-col p-5 bg-gray-50 dark:bg-gray-700/50
hover:bg-gray-100 dark:hover:bg-gray-700 rounded-xl transition-all
duration-200 text-left group border-2 border-transparent
hover:border-green-500 hover:shadow-lg'
>
{/* 顶部:序号和标题 */}
<div className='flex items-start gap-3 mb-3'>
{/* 序号 */}
<div className='flex-shrink-0 w-8 h-8 rounded-full bg-green-500 text-white
flex items-center justify-center font-bold text-sm
group-hover:bg-green-600 transition-colors duration-200'>
{index + 1}
</div>
{/* 标题 */}
<h4 className='flex-1 text-lg font-bold text-gray-900 dark:text-white
group-hover:text-green-600 dark:group-hover:text-green-400
transition-colors duration-200 leading-tight'>
{anime.animeTitle}
</h4>
{/* 选择图标 */}
<div className='flex-shrink-0'>
<svg className='w-6 h-6 text-gray-400 group-hover:text-green-500
transition-colors duration-200'
fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2}
d='M9 5l7 7-7 7' />
</svg>
</div>
</div>
{/* 主体内容 */}
<div className='flex gap-4'>
{/* 封面 */}
{anime.imageUrl && (
<div className='flex-shrink-0 w-20 h-28 rounded-lg overflow-hidden shadow-md
group-hover:shadow-xl transition-shadow duration-200'>
<img
src={anime.imageUrl}
alt={anime.animeTitle}
className='w-full h-full object-cover'
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
</div>
)}
{/* 详细信息 */}
<div className='flex-1 space-y-2'>
{/* 基本信息标签 */}
<div className='flex flex-wrap gap-2'>
{anime.typeDescription && (
<span className='inline-flex items-center px-2.5 py-1 rounded-md
bg-blue-100 dark:bg-blue-900/30 text-blue-700
dark:text-blue-300 text-sm font-medium'>
📺 {anime.typeDescription}
</span>
)}
{anime.episodeCount && (
<span className='inline-flex items-center px-2.5 py-1 rounded-md
bg-purple-100 dark:bg-purple-900/30 text-purple-700
dark:text-purple-300 text-sm font-medium'>
🎬 {anime.episodeCount}
</span>
)}
{anime.startDate && (
<span className='inline-flex items-center px-2.5 py-1 rounded-md
bg-gray-100 dark:bg-gray-600 text-gray-700
dark:text-gray-300 text-sm font-medium'>
📅 {anime.startDate}
</span>
)}
</div>
{/* 动漫ID */}
<div className='text-xs text-gray-500 dark:text-gray-400'>
ID: {anime.animeId}
</div>
{/* 提示信息 */}
<div className='text-sm text-gray-600 dark:text-gray-300 pt-1
opacity-0 group-hover:opacity-100 transition-opacity duration-200'>
</div>
</div>
</div>
</button>
))}
</div>
</div>
{/* 底部操作栏 */}
<div className='sticky bottom-0 z-10 bg-white dark:bg-gray-800 border-t
border-gray-200 dark:border-gray-700 px-6 py-4'>
<button
onClick={() => {
setShowDanmakuSourceSelector(false);
setDanmakuMatches([]);
}}
className='w-full px-4 py-2.5 bg-gray-100 dark:bg-gray-700
hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700
dark:text-gray-300 rounded-lg font-medium transition-colors
duration-200'
>
</button>
</div>
</div>
</div>
)}
<div className='flex flex-col gap-3 py-4 px-5 lg:px-[3rem] 2xl:px-20'>
{/* 第一行:影片标题 */}
<div className='py-1'>
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2 flex-wrap'>
<span>
{videoTitle || '影片标题'}
{totalEpisodes > 1 && (
<span className='text-gray-500 dark:text-gray-400'>
{` > ${
detail?.episodes_titles?.[currentEpisodeIndex] ||
`${currentEpisodeIndex + 1}`
}`}
</span>
)}
</span>
{/* 完结状态标识 */}
{detail && totalEpisodes > 1 && (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
isSeriesCompleted(detail)
? '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'
}`}
>
{isSeriesCompleted(detail) ? '已完结' : '连载中'}
</span>
)}
</h1>
</div>
{/* 第二行:播放器和选集 */}
<div className='space-y-2'>
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
<div className='hidden lg:flex justify-end'>
<button
onClick={() =>
setIsEpisodeSelectorCollapsed(!isEpisodeSelectorCollapsed)
}
className='group relative flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 shadow-sm hover:shadow-md transition-all duration-200'
title={
isEpisodeSelectorCollapsed ? '显示选集面板' : '隐藏选集面板'
}
>
<svg
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${
isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
}`}
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M9 5l7 7-7 7'
/>
</svg>
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
{isEpisodeSelectorCollapsed ? '显示' : '隐藏'}
</span>
{/* 精致的状态指示点 */}
<div
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${
isEpisodeSelectorCollapsed
? 'bg-orange-400 animate-pulse'
: 'bg-green-400'
}`}
></div>
</button>
</div>
<div
className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${
isEpisodeSelectorCollapsed
? 'grid-cols-1'
: 'grid-cols-1 md:grid-cols-4'
}`}
>
{/* 播放器 */}
<div
className={`transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 flex flex-col ${
isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
}`}
>
{/* 播放器容器 */}
<div className='relative w-full h-[300px] lg:flex-1 lg:min-h-0'>
<div
ref={artRef}
className='bg-black w-full h-full rounded-xl overflow-hidden shadow-lg'
></div>
{/* 换源加载蒙层 */}
{(isVideoLoading || videoError) && (
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl flex items-center justify-center z-[500] transition-all duration-300'>
<div className='text-center max-w-md mx-auto px-6'>
{videoError ? (
// 错误显示
<>
{/* 错误图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-red-500 to-rose-600 rounded-2xl shadow-2xl flex items-center justify-center'>
<div className='text-white text-4xl'></div>
</div>
</div>
{/* 错误消息 */}
<div className='space-y-4'>
<p className='text-xl font-semibold text-white'>
</p>
<p className='text-base text-gray-300'>
{videoError}
</p>
<button
onClick={() => {
setVideoError(null);
setIsVideoLoading(true);
// 重新加载视频
if (artPlayerRef.current) {
artPlayerRef.current.url = videoUrl;
}
}}
className='mt-4 px-6 py-2 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-lg hover:from-green-600 hover:to-emerald-700 transition-all duration-200'
>
</button>
</div>
</>
) : (
// 加载显示
<>
{/* 动画影院图标 */}
<div className='relative mb-8'>
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
<div className='text-white text-4xl'>🎬</div>
{/* 旋转光环 */}
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
</div>
{/* 浮动粒子效果 */}
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
<div
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
style={{ animationDelay: '0.5s' }}
></div>
<div
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
style={{ animationDelay: '1s' }}
></div>
</div>
</div>
{/* 换源消息 */}
<div className='space-y-2'>
<p className='text-xl font-semibold text-white animate-pulse'>
{videoLoadingStage === 'sourceChanging'
? '🔄 切换播放源...'
: '🔄 视频加载中...'}
</p>
</div>
</>
)}
</div>
</div>
)}
{/* 弹幕加载蒙层 */}
{danmakuLoading && (
<div className='absolute top-0 right-0 m-4 bg-black/80 backdrop-blur-sm rounded-lg px-4 py-2 z-[600] flex items-center gap-2 border border-green-500/30'>
{danmakuCount > 0 ? (
<>
<svg
className='w-4 h-4 text-green-500'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M5 13l4 4L19 7'
/>
</svg>
<span className='text-sm font-medium text-green-400'>
{danmakuCount}
</span>
</>
) : (
<>
<div className='w-4 h-4 border-2 border-green-500 border-t-transparent rounded-full animate-spin'></div>
<span className='text-sm font-medium text-green-400'>
...
</span>
</>
)}
</div>
)}
</div>
{/* 第三方应用打开按钮 - 观影室同步状态下隐藏 */}
{videoUrl && !playSync.isInRoom && (
<div className='mt-3 px-2 lg:flex-shrink-0 flex justify-end'>
<div className='bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-lg p-2 border border-gray-200/50 dark:border-gray-700/50 w-full lg:w-auto overflow-x-auto'>
<div className='flex gap-1.5 justify-between lg:flex-wrap items-center'>
<div className='flex gap-1.5 lg:flex-wrap'>
{/* 下载按钮 */}
<button
onClick={(e) => {
e.preventDefault();
setShowDownloadSelector(true);
}}
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-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-green-400 flex-shrink-0'
title='下载视频'
>
<svg
className='w-4 h-4 flex-shrink-0 text-white'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4'
/>
</svg>
<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-white'>
</span>
</button>
{/* PotPlayer */}
<button
onClick={(e) => {
e.preventDefault();
// 使用代理 URL
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
const proxyUrl = externalPlayerAdBlock
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(videoUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
: videoUrl;
// URL encode 避免冒号被吃掉
window.open(`potplayer://${proxyUrl}`, '_blank');
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='PotPlayer'
>
<img
src='/players/potplayer.png'
alt='PotPlayer'
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'>
PotPlayer
</span>
</button>
{/* VLC */}
<button
onClick={(e) => {
e.preventDefault();
// 使用代理 URL
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
const proxyUrl = externalPlayerAdBlock
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(videoUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
: videoUrl;
// URL encode 避免冒号被吃掉
window.open(`vlc://${proxyUrl}`, '_blank');
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='VLC'
>
<img
src='/players/vlc.png'
alt='VLC'
className='w-4 h-4 flex-shrink-0'
/>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
VLC
</span>
</button>
{/* MPV */}
<button
onClick={(e) => {
e.preventDefault();
// 使用代理 URL
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
const proxyUrl = externalPlayerAdBlock
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(videoUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
: videoUrl;
// URL encode 避免冒号被吃掉
window.open(`mpv://${proxyUrl}`, '_blank');
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='MPV'
>
<img
src='/players/mpv.png'
alt='MPV'
className='w-4 h-4 flex-shrink-0'
/>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
MPV
</span>
</button>
{/* MX Player */}
<button
onClick={(e) => {
e.preventDefault();
// 使用代理 URL
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
const proxyUrl = externalPlayerAdBlock
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(videoUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
: videoUrl;
window.open(
`intent://${proxyUrl}#Intent;package=com.mxtech.videoplayer.ad;S.title=${encodeURIComponent(
videoTitle
)};end`,
'_blank'
);
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='MX Player'
>
<img
src='/players/mxplayer.png'
alt='MX Player'
className='w-4 h-4 flex-shrink-0'
/>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
MX Player
</span>
</button>
{/* nPlayer */}
<button
onClick={(e) => {
e.preventDefault();
// 使用代理 URL
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
const proxyUrl = externalPlayerAdBlock
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(videoUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
: videoUrl;
window.open(`nplayer-${proxyUrl}`, '_blank');
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='nPlayer'
>
<img
src='/players/nplayer.png'
alt='nPlayer'
className='w-4 h-4 flex-shrink-0'
/>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
nPlayer
</span>
</button>
{/* IINA */}
<button
onClick={(e) => {
e.preventDefault();
// 使用代理 URL
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
const proxyUrl = externalPlayerAdBlock
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(videoUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
: videoUrl;
window.open(
`iina://weblink?url=${encodeURIComponent(
proxyUrl
)}`,
'_blank'
);
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='IINA'
>
<img
src='/players/iina.png'
alt='IINA'
className='w-4 h-4 flex-shrink-0'
/>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
IINA
</span>
</button>
</div>
{/* 去广告开关 */}
<button
onClick={() => setExternalPlayerAdBlock(!externalPlayerAdBlock)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer border flex-shrink-0 ${
externalPlayerAdBlock
? 'bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 text-white border-blue-400'
: 'bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600'
}`}
title={externalPlayerAdBlock ? '去广告已开启' : '去广告已关闭'}
>
<svg
className='w-4 h-4 flex-shrink-0'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
{externalPlayerAdBlock ? (
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'
/>
) : (
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636'
/>
)}
</svg>
<span className='whitespace-nowrap'>
{externalPlayerAdBlock ? '去广告' : '去广告'}
</span>
</button>
</div>
</div>
</div>
)}
</div>
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
<div
className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${
isEpisodeSelectorCollapsed
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
}`}
>
<EpisodeSelector
totalEpisodes={totalEpisodes}
episodes_titles={detail?.episodes_titles || []}
value={currentEpisodeIndex + 1}
onChange={playSync.shouldDisableControls ? () => { /* disabled */ } : handleEpisodeChange}
onSourceChange={playSync.shouldDisableControls ? () => { /* disabled */ } : handleSourceChange}
isRoomMember={playSync.shouldDisableControls}
currentSource={currentSource}
currentId={currentId}
videoTitle={searchTitle || videoTitle}
availableSources={availableSources}
sourceSearchLoading={sourceSearchLoading}
sourceSearchError={sourceSearchError}
precomputedVideoInfo={precomputedVideoInfo}
onDanmakuSelect={handleDanmakuSelect}
currentDanmakuSelection={currentDanmakuSelection}
episodeFilterConfig={episodeFilterConfig}
onFilterConfigUpdate={setEpisodeFilterConfig}
onShowToast={(message, type) => {
setToast({ message, type, onClose: () => setToast(null) });
}}
/>
</div>
</div>
</div>
{/* 详情展示 */}
<div className='grid grid-cols-1 md:grid-cols-4 gap-4'>
{/* 文字区 */}
<div className='md:col-span-3'>
<div className='p-6 flex flex-col min-h-0'>
{/* 标题 */}
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full flex-wrap gap-2'>
<span className={doubanAka.length > 0 ? 'relative group cursor-help' : ''}>
{videoTitle || '影片标题'}
{/* aka 悬浮提示 */}
{doubanAka.length > 0 && (
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-sm rounded-lg shadow-xl opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 ease-out whitespace-nowrap z-[100] pointer-events-none'>
<div className='font-semibold text-xs text-gray-400 mb-1'></div>
{doubanAka.map((name, index) => (
<div key={index} className='text-sm'>
{name}
</div>
))}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800 dark:border-t-gray-900'></div>
</div>
)}
</span>
<button
onClick={(e) => {
e.stopPropagation();
handleToggleFavorite();
}}
className='flex-shrink-0 hover:opacity-80 transition-opacity'
>
<FavoriteIcon filled={favorited} />
</button>
{/* 豆瓣评分显示 */}
{doubanRating && doubanRating.value > 0 && (
<div className='flex items-center gap-2 text-base font-normal'>
{/* 星级显示 */}
<div className='flex items-center gap-1'>
{[1, 2, 3, 4, 5].map((star) => {
const starValue = doubanRating.value / 2; // 转换为5星制
const isFullStar = star <= Math.floor(starValue);
const isHalfStar = !isFullStar && star <= Math.ceil(starValue) && starValue % 1 >= 0.25;
return (
<div key={star} className='relative w-5 h-5'>
{isFullStar ? (
// 全星
<svg
className='w-5 h-5 text-yellow-400 fill-yellow-400'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
</svg>
) : isHalfStar ? (
// 半星
<>
{/* 空星背景 */}
<svg
className='absolute w-5 h-5 text-gray-300 dark:text-gray-600 fill-gray-300 dark:fill-gray-600'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
</svg>
{/* 半星遮罩 */}
<svg
className='absolute w-5 h-5 text-yellow-400 fill-yellow-400'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
style={{ clipPath: 'inset(0 50% 0 0)' }}
>
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
</svg>
</>
) : (
// 空星
<svg
className='w-5 h-5 text-gray-300 dark:text-gray-600 fill-gray-300 dark:fill-gray-600'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
</svg>
)}
</div>
);
})}
</div>
{/* 评分数值 */}
<span className='text-gray-700 dark:text-gray-300 font-semibold'>
{doubanRating.value.toFixed(1)}
</span>
{/* 评分人数 */}
<span className='text-gray-500 dark:text-gray-400 text-sm'>
({doubanRating.count.toLocaleString()})
</span>
</div>
)}
</h1>
{/* 关键信息行 */}
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
{detail?.class && (
<span className='text-green-600 font-semibold'>
{detail.class}
</span>
)}
{/* 优先使用 doubanYear如果没有则使用 detail.year 或 videoYear */}
{(doubanYear || detail?.year || videoYear) && (
<span>{doubanYear || detail?.year || videoYear}</span>
)}
{detail?.source_name && (
<span className='border border-gray-500/60 px-2 py-[1px] rounded'>
{detail.source_name}
</span>
)}
{detail?.type_name && <span>{detail.type_name}</span>}
</div>
{/* 剧情简介 */}
{(doubanCardSubtitle || detail?.desc) && (
<div
className='mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide'
style={{ whiteSpace: 'pre-line' }}
>
{/* card_subtitle 在前desc 在后 */}
{doubanCardSubtitle && (
<div className='mb-3 pb-3 border-b border-gray-300 dark:border-gray-700'>
{doubanCardSubtitle}
</div>
)}
{detail?.desc}
</div>
)}
</div>
</div>
{/* 封面展示 */}
<div className='hidden md:block md:col-span-1 md:order-first'>
<div className='pl-0 py-4 pr-6'>
<div className='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
{videoCover ? (
<>
<img
src={processImageUrl(videoCover)}
alt={videoTitle}
className='w-full h-full object-cover'
/>
{/* 豆瓣链接按钮 */}
{videoDoubanId !== 0 && (
<a
href={`https://movie.douban.com/subject/${videoDoubanId.toString()}`}
target='_blank'
rel='noopener noreferrer'
className='absolute top-3 left-3'
>
<div className='bg-green-500 text-white text-xs font-bold w-8 h-8 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
<svg
width='16'
height='16'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
>
<path d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'></path>
<path d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'></path>
</svg>
</div>
</a>
)}
</>
) : (
<span className='text-gray-600 dark:text-gray-400'>
</span>
)}
</div>
</div>
</div>
</div>
{/* 豆瓣评论区域 */}
{videoDoubanId !== 0 && enableComments && (
<div className='mt-6 px-4'>
<div className='bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50 overflow-hidden'>
{/* 标题 */}
<div className='px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2'>
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 24 24'>
<path d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z'/>
</svg>
</h3>
</div>
{/* 评论内容 */}
<div className='p-6'>
<DoubanComments doubanId={videoDoubanId} />
</div>
</div>
</div>
)}
</div>
{/* Toast通知 */}
{toast && <Toast {...toast} />}
{/* 下载选集面板 */}
<DownloadEpisodeSelector
isOpen={showDownloadSelector}
onClose={() => setShowDownloadSelector(false)}
totalEpisodes={totalEpisodes}
episodesTitles={detail?.episodes_titles || []}
videoTitle={videoTitle}
currentEpisodeIndex={currentEpisodeIndex}
onDownload={handleDownloadEpisode}
enableOfflineDownload={enableOfflineDownload}
hasOfflinePermission={hasOfflinePermission}
/>
{/* 弹幕过滤设置对话框 */}
<DanmakuFilterSettings
isOpen={showDanmakuFilterSettings}
onClose={() => setShowDanmakuFilterSettings(false)}
onConfigUpdate={(config) => {
setDanmakuFilterConfig(config);
danmakuFilterConfigRef.current = config;
// 重新加载弹幕以应用新的过滤规则
if (danmakuPluginRef.current) {
try {
danmakuPluginRef.current.load();
console.log('弹幕过滤规则已更新,重新加载弹幕');
} catch (error) {
console.error('重新加载弹幕失败:', error);
}
}
}}
onShowToast={(message, type) => {
setToast({
message,
type,
onClose: () => setToast(null),
});
}}
/>
</PageLayout>
);
}
// FavoriteIcon 组件
const FavoriteIcon = ({ filled }: { filled: boolean }) => {
if (filled) {
return (
<svg
className='h-7 w-7'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
fill='#ef4444' /* Tailwind red-500 */
stroke='#ef4444'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
);
}
return (
<Heart className='h-7 w-7 stroke-[1] text-gray-600 dark:text-gray-300' />
);
};
export default function PlayPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<PlayPageClient />
</Suspense>
);
}