/* 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 { AlertCircle, Cloud, Heart, Keyboard, Loader2, Router, Sparkles, X } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useMemo, useRef, useState } from 'react'; import { isAnimeCategoryText } from '@/lib/anime-keyword-expr'; import { createAnime4KRenderer } from '@/lib/anime4k'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import { clearDanmakuCacheByTitle, convertDanmakuFormat, getDanmakuById, getDanmakuFromCache, getEpisodes, initDanmakuModule, loadDanmakuDisplayState, loadDanmakuSettings, saveDanmakuDisplayState, saveDanmakuSettings, searchAnime, } from '@/lib/danmaku/api'; import { getDanmakuAnimeId, getDanmakuSearchKeyword, getDanmakuSourceIndex, getManualDanmakuSelection, saveDanmakuAnimeId, saveDanmakuSearchKeyword, saveDanmakuSourceIndex, saveManualDanmakuSelection, } from '@/lib/danmaku/selection-memory'; import type { DanmakuAnime, DanmakuComment, DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types'; import { deleteFavorite, deleteSkipConfig, generateStorageKey, getAllPlayRecords, getDanmakuFilterConfig, getEpisodeFilterConfig, getSkipConfig, isFavorited, migratePlayRecord, saveFavorite, savePlayRecord, saveSkipConfig, subscribeToDataUpdates, } from '@/lib/db.client'; import { getDoubanDetail } from '@/lib/douban.client'; import { isEpisodeHiddenByFilter, normalizeEpisodeFilterConfig } from '@/lib/episode-filter'; import { appendSpecialSourceParam, isSpecialSourcesEnabledOnDevice } from '@/lib/special-source.client'; import { buildEpisodeProgressContentKey, loadLocalEpisodeProgress, pruneLocalEpisodeProgressStorage, saveLocalEpisodeProgress, } from '@/lib/episode-progress'; import { isNetdiskSource, normalizeNetdiskSource } from '@/lib/netdisk/source'; import { getRecommendationCache, recommendationCacheKeys, setRecommendationCache, } from '@/lib/recommendations/cache'; import { getIndexedDBVideoPlaybackUrl } from '@/lib/indexeddb-video-cache'; import { convertSubtitleFileToVttObjectUrl, CUSTOM_SUBTITLE_ACCEPT, } from '@/lib/subtitle-converter'; import { getTMDBImageUrl } from '@/lib/tmdb.search'; import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types'; import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import { useEnableAIComments } from '@/hooks/useEnableAIComments'; import { useEnableComments } from '@/hooks/useEnableComments'; import { usePlaySync } from '@/hooks/usePlaySync'; import AIChatPanel from '@/components/AIChatPanel'; import AIComments from '@/components/AIComments'; import CorrectDialog from '@/components/CorrectDialog'; import DanmakuFilterSettings from '@/components/DanmakuFilterSettings'; import DetailPanel from '@/components/DetailPanel'; import DoubanComments from '@/components/DoubanComments'; import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector'; import Drawer from '@/components/Drawer'; import EpisodeSelector from '@/components/EpisodeSelector'; import PageLayout from '@/components/PageLayout'; import PansouSearch from '@/components/PansouSearch'; import ProxyImage from '@/components/ProxyImage'; import { useSite } from '@/components/SiteProvider'; import SmartRecommendations from '@/components/SmartRecommendations'; import Toast, { ToastProps } from '@/components/Toast'; import VideoCard from '@/components/VideoCard'; import { useDownload } from '@/contexts/DownloadContext'; // 扩展 HTMLVideoElement 类型以支持 hls 属性 declare global { interface HTMLVideoElement { hls?: any; } } // Wake Lock API 类型声明 interface WakeLockSentinel { released: boolean; release(): Promise; addEventListener(type: 'release', listener: () => void): void; removeEventListener(type: 'release', listener: () => void): void; } interface PlayFallbackRecommendation { key: string; item: SearchResult; episodes?: number; sourceNames: string[]; doubanId?: number; } interface SearchCachePayload { status: 'complete' | 'partial'; results: SearchResult[]; query: string; updatedAt: number; } type CustomSubtitleEngine = 'native' | 'jassub'; type PlaybackSourceBadge = 'local' | 'offline' | null; interface CustomSubtitleState { name: string; format: string; episodeIndex: number; engine: CustomSubtitleEngine; url?: string; content?: string; } interface SourceSubtitleItem { label: string; url: string; fallbackUrl?: string; fallbackFormat?: string; format?: string; sourceFormat?: string; codec?: string; renderMode?: 'native' | 'jassub'; } interface JassubSubtitleInstance { setTrack?: (content: string) => void | Promise; setTrackByUrl?: (url: string) => void | Promise; freeTrack?: () => void | Promise; destroy?: () => void | Promise; } const PLAYBACK_RATE_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4]; const JASSUB_ASSET_BASE = '/assets/jassub'; const JASSUB_CJK_FONT_FAMILY = 'noto sans cjk sc'; const JASSUB_CJK_FONT_URL = `${JASSUB_ASSET_BASE}/NotoSansCJK-Regular.ttc`; const ADVANCED_SUBTITLE_FORMATS = new Set(['ass', 'ssa']); const PLAY_SHORTCUT_GROUPS = [ { title: '播放控制', items: [ { keys: ['空格'], description: '播放 / 暂停' }, { keys: ['←', '→'], description: '快退 / 快进 10 秒' }, { keys: ['P'], description: '快捷快进' }, { keys: ['↑', '↓'], description: '音量增加 / 减少' }, { keys: ['F'], description: '切换全屏' }, ], }, { title: '剧集切换', items: [ { keys: ['Alt', '←'], description: '上一集' }, { keys: ['Alt', '→'], description: '下一集' }, ], }, { title: '倍速控制', items: [ { keys: ['小键盘 +'], description: '提高一档倍速' }, { keys: ['小键盘 -'], description: '降低一档倍速' }, { keys: ['小键盘 /'], description: '恢复 1x' }, ], }, ]; function PlayPageClient() { const LOCAL_TRANSCODER_BASE_URL = 'http://localhost:19080'; const router = useRouter(); const searchParams = useSearchParams(); const enableComments = useEnableComments(); const enableAIComments = useEnableAIComments(); const { addDownloadTask } = useDownload(); const { siteName } = useSite(); // 获取 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(null); const [detail, setDetail] = useState(null); // TMDB背景图 const [tmdbBackdrop, setTmdbBackdrop] = useState(null); // 收藏状态 const [favorited, setFavorited] = useState(false); // 网盘搜索弹窗状态 const [showPansouDialog, setShowPansouDialog] = useState(false); const [netdiskSearchEnabled, setNetdiskSearchEnabled] = useState(false); // AI问片状态 const [showAIChat, setShowAIChat] = useState(false); const [aiEnabled, setAiEnabled] = useState(false); const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState(''); // 纠错弹窗状态 const [showCorrectDialog, setShowCorrectDialog] = useState(false); // 详情面板状态 const [showDetailPanel, setShowDetailPanel] = useState(false); // 快捷键说明弹窗状态 const [showShortcutDialog, setShowShortcutDialog] = useState(false); useEffect(() => { if (!showShortcutDialog) { return; } const handleShortcutDialogKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setShowShortcutDialog(false); } }; document.addEventListener('keydown', handleShortcutDialogKeyDown); return () => { document.removeEventListener('keydown', handleShortcutDialogKeyDown); }; }, [showShortcutDialog]); // 大屏设备检测(判断选集面板是否在右侧) const [isLargeScreen, setIsLargeScreen] = useState(false); // 检测是否为大屏设备 useEffect(() => { const checkScreenSize = () => { setIsLargeScreen(window.innerWidth >= 768); // md断点 }; checkScreenSize(); window.addEventListener('resize', checkScreenSize); return () => window.removeEventListener('resize', checkScreenSize); }, []); // 抽屉管理:打开指定抽屉时关闭其他抽屉 const openDrawer = (drawerName: 'pansou' | 'aiChat' | 'correct' | 'detail') => { if (!isLargeScreen) { // 小屏设备不需要互斥 switch (drawerName) { case 'pansou': setShowPansouDialog(true); break; case 'aiChat': setShowAIChat(true); break; case 'correct': setShowCorrectDialog(true); break; case 'detail': setShowDetailPanel(true); break; } return; } // 大屏设备:关闭其他抽屉 setShowPansouDialog(drawerName === 'pansou'); setShowAIChat(drawerName === 'aiChat'); setShowCorrectDialog(drawerName === 'correct'); setShowDetailPanel(drawerName === 'detail'); }; // 检查AI功能是否启用 useEffect(() => { if (typeof window !== 'undefined') { const enabled = (window as any).RUNTIME_CONFIG?.AI_ENABLED && (window as any).RUNTIME_CONFIG?.AI_ENABLE_PLAYPAGE_ENTRY; setAiEnabled(enabled); // 加载AI默认消息配置 const defaultMsg = (window as any).RUNTIME_CONFIG?.AI_DEFAULT_MESSAGE_WITH_VIDEO; if (defaultMsg) { setAiDefaultMessageWithVideo(defaultMsg); } } }, []); useEffect(() => { if (typeof window !== 'undefined') { setNetdiskSearchEnabled( !!(window as any).RUNTIME_CONFIG?.NETDISK_SEARCH_ENABLED ); } }, []); // 网页全屏状态 - 控制导航栏的显示隐藏 const [isWebFullscreen, setIsWebFullscreen] = useState(false); // 原生全屏状态 const [isNativeFullscreen, setIsNativeFullscreen] = useState(false); // 监听浏览器原生全屏事件 useEffect(() => { const handleFullscreenChange = () => { const isFullscreen = !!document.fullscreenElement; setIsNativeFullscreen(isFullscreen); }; document.addEventListener('fullscreenchange', handleFullscreenChange); document.addEventListener('webkitfullscreenchange', handleFullscreenChange); document.addEventListener('mozfullscreenchange', handleFullscreenChange); document.addEventListener('MSFullscreenChange', handleFullscreenChange); return () => { document.removeEventListener('fullscreenchange', handleFullscreenChange); document.removeEventListener('webkitfullscreenchange', handleFullscreenChange); document.removeEventListener('mozfullscreenchange', handleFullscreenChange); document.removeEventListener('MSFullscreenChange', handleFullscreenChange); }; }, []); // 组件卸载时清理定时器 useEffect(() => { return () => { clearRefreshTimer(); }; }, []); // 跳过片头片尾配置 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, ]); // 快捷快进设置(默认 1 分 30 秒) const DEFAULT_QUICK_FORWARD_SECONDS = 90; const [quickForwardSeconds, setQuickForwardSeconds] = useState(() => { if (typeof window === 'undefined') return DEFAULT_QUICK_FORWARD_SECONDS; const saved = Number(localStorage.getItem('quickForwardSeconds')); return Number.isFinite(saved) && saved > 0 ? saved : DEFAULT_QUICK_FORWARD_SECONDS; }); const quickForwardSecondsRef = useRef(quickForwardSeconds); useEffect(() => { quickForwardSecondsRef.current = quickForwardSeconds; }, [quickForwardSeconds]); // 跳过检查的时间间隔控制 const lastSkipCheckRef = useRef(0); // 去广告开关(从 localStorage 继承,默认 true) const [blockAdEnabled, setBlockAdEnabled] = useState(() => { 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(() => { 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(''); // 初始化时获取自定义去广告代码 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('使用缓存的去广告代码'); } // 从 window.RUNTIME_CONFIG 获取版本号 const version = (window as any).RUNTIME_CONFIG?.CUSTOM_AD_FILTER_VERSION || 0; // 如果版本号为 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(false); const [anime4kEnabled, setAnime4kEnabled] = useState(false); const [anime4kMode, setAnime4kMode] = useState(() => { if (typeof window !== 'undefined') { const v = localStorage.getItem('anime4k_mode'); if (v !== null) return v; } return 'ModeA'; }); const [anime4kScale, setAnime4kScale] = useState(() => { if (typeof window !== 'undefined') { const v = localStorage.getItem('anime4k_scale'); if (v !== null) return parseFloat(v); } return 2.0; }); const anime4kRef = useRef(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 { // 修复anime4k-webgpu库的buffer size限制问题 // 在全局层面patch requestAdapter,确保所有adapter都有正确的limits const originalRequestAdapter = (navigator as any).gpu.requestAdapter.bind((navigator as any).gpu); (navigator as any).gpu.requestAdapter = async (options?: any) => { const adapter = await originalRequestAdapter(options); if (!adapter) return adapter; // 保存原始的requestDevice方法 const originalRequestDevice = adapter.requestDevice.bind(adapter); // 重写requestDevice方法,添加必要的buffer size限制 adapter.requestDevice = async (descriptor?: any) => { const adapterLimits = adapter.limits; // 合并用户提供的descriptor和我们需要的limits const enhancedDescriptor = { ...descriptor, requiredLimits: { ...descriptor?.requiredLimits, // 使用adapter支持的最大值,但不超过2GB maxBufferSize: Math.min(adapterLimits.maxBufferSize || 2147483648, 2147483648), maxStorageBufferBindingSize: Math.min(adapterLimits.maxStorageBufferBindingSize || 1073741824, 1073741824), } }; console.log('WebGPU设备请求配置:', enhancedDescriptor.requiredLimits); return originalRequestDevice(enhancedDescriptor); }; return adapter; }; const adapter = await (navigator as any).gpu.requestAdapter(); if (!adapter) { setWebGPUSupported(false); console.log('WebGPU不支持:无法获取GPU适配器'); return; } setWebGPUSupported(true); console.log('WebGPU支持检测:✅ 支持'); console.log('Adapter limits:', { maxBufferSize: adapter.limits.maxBufferSize, maxStorageBufferBindingSize: adapter.limits.maxStorageBufferBindingSize }); } catch (err) { setWebGPUSupported(false); console.log('WebGPU不支持:', err); } }; checkWebGPUSupport(); }, []); // 弹幕相关状态 const [danmakuSettings, setDanmakuSettings] = useState( loadDanmakuSettings() ); const [danmakuFilterConfig, setDanmakuFilterConfig] = useState(null); const danmakuFilterConfigRef = useRef(null); const [episodeFilterConfig, setEpisodeFilterConfig] = useState(null); const episodeFilterConfigRef = useRef(null); const [currentDanmakuSelection, setCurrentDanmakuSelection] = useState(null); const [danmakuEpisodesList, setDanmakuEpisodesList] = useState< Array<{ episodeId: number; episodeTitle: string }> >([]); const [danmakuLoading, setDanmakuLoading] = useState(false); const [danmakuCount, setDanmakuCount] = useState(0); const [danmakuOriginalCount, setDanmakuOriginalCount] = useState(0); const danmakuPluginRef = useRef(null); const danmakuSettingsRef = useRef(danmakuSettings); // 弹幕显示状态的 ref,初始化时从 localStorage 读取 const danmakuDisplayStateRef = useRef( (() => { const saved = loadDanmakuDisplayState(); return saved !== false; // null 或 true 都返回 true })() ); // 弹幕热力图完全禁用开关(默认不禁用,即启用热力图功能) const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(() => { if (typeof window !== 'undefined') { const v = localStorage.getItem('danmaku_heatmap_disabled'); if (v !== null) return v === 'true'; } return false; // 默认不禁用 }); const danmakuHeatmapDisabledRef = useRef(danmakuHeatmapDisabled); useEffect(() => { danmakuHeatmapDisabledRef.current = danmakuHeatmapDisabled; }, [danmakuHeatmapDisabled]); // 弹幕热力图开关(默认开启) const [danmakuHeatmapEnabled, setDanmakuHeatmapEnabled] = useState(() => { if (typeof window !== 'undefined') { const v = localStorage.getItem('danmaku_heatmap_enabled'); if (v !== null) return v === 'true'; } return true; // 默认开启 }); const danmakuHeatmapEnabledRef = useRef(danmakuHeatmapEnabled); useEffect(() => { danmakuHeatmapEnabledRef.current = danmakuHeatmapEnabled; }, [danmakuHeatmapEnabled]); // 多条弹幕匹配结果 const [danmakuMatches, setDanmakuMatches] = useState([]); const [showDanmakuSourceSelector, setShowDanmakuSourceSelector] = useState(false); const [showDanmakuFilterSettings, setShowDanmakuFilterSettings] = useState(false); const [currentSearchKeyword, setCurrentSearchKeyword] = useState(''); // 当前搜索使用的关键词 const [toast, setToast] = useState(null); const [isTranscoding, setIsTranscoding] = useState(false); 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) { const normalizedEpisodeConfig = normalizeEpisodeFilterConfig(episodeConfig); setEpisodeFilterConfig(normalizedEpisodeConfig); episodeFilterConfigRef.current = normalizedEpisodeConfig; } else { const defaultEpisodeConfig: EpisodeFilterConfig = normalizeEpisodeFilterConfig(); 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); // 更新浏览器标题 useEffect(() => { if (videoTitle) { document.title = `${siteName} - ${videoTitle}`; } else { document.title = siteName; } }, [videoTitle, siteName]); // 豆瓣评分数据 const [doubanRating, setDoubanRating] = useState<{ value: number; count: number; star_count: number; } | null>(null); // 豆瓣额外信息 const [doubanCardSubtitle, setDoubanCardSubtitle] = useState(''); const [doubanAka, setDoubanAka] = useState([]); const [doubanYear, setDoubanYear] = useState(''); // 从 pubdate 提取的年份 // 纠错后的描述信息(用于显示,不触发 detail 更新) const [correctedDesc, setCorrectedDesc] = useState(''); const [netdiskTMDBMeta, setNetdiskTMDBMeta] = useState<{ desc?: string; poster?: string; year?: string; tmdbId?: number; } | null>(null); const [pendingNetdiskTMDBData, setPendingNetdiskTMDBData] = useState(null); // 当前源和ID - source 直接存储完整格式(如 'emby_wumei' 或 'emby') const [currentSource, setCurrentSource] = useState(normalizeNetdiskSource(searchParams.get('source')) || ''); const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名 const isDirectPlay = currentSource === 'directplay'; useEffect(() => { setNetdiskTMDBMeta(null); setPendingNetdiskTMDBData(null); }, [currentSource, currentId]); // 解析 source 参数以获取 embyKey(仅用于 API 调用) const parseSourceForApi = (source: string): { source: string; embyKey?: string } => { source = normalizeNetdiskSource(source); if (source.startsWith('emby_')) { const key = source.substring(5); return { source: 'emby', embyKey: key }; } return { source }; }; const isLazyDetailSource = (source?: string) => { if (!source) return false; return ( source === 'openlist' || source === 'emby' || source.startsWith('emby_') || source.startsWith('script:') ); }; /** 私人影库/网盘类源:配合 moontvplus-extension 跨域媒体模块,供 Anime4K 等读帧 */ const needsPrivateSourceCrossOrigin = (source?: string | null) => { if (!source) return false; return ( source === 'openlist' || source === 'xiaoya' || source === 'emby' || source.startsWith('emby_') || isNetdiskSource(source) ); }; const applyVideoCrossOrigin = ( video: HTMLVideoElement | null, source?: string | null ) => { if (!video) return; if (needsPrivateSourceCrossOrigin(source ?? currentSourceRef.current)) { if (video.crossOrigin !== 'anonymous') { video.crossOrigin = 'anonymous'; } } else if (video.crossOrigin) { // 切回普通源时去掉,避免无 CORS 的 CDN 在 CORS 模式下播挂 video.crossOrigin = null; } }; const isM3u8LikeUrl = (url?: string) => { if (!url) return false; const normalizedUrl = url.toLowerCase(); return normalizedUrl.includes('.m3u8') || normalizedUrl.includes('/m3u8/'); }; const buildAbsoluteUrl = (url: string) => { if (url.startsWith('http://') || url.startsWith('https://')) { return url; } return `${window.location.origin}${url.startsWith('/') ? '' : '/'}${url}`; }; // 搜索所需信息 const [searchTitle] = useState(searchParams.get('stitle') || ''); const [searchType] = useState(searchParams.get('stype') || ''); const [initialEpisodeProgressTitle] = useState( searchTitle || searchParams.get('title') || '' ); const [initialEpisodeProgressYear] = useState( searchParams.get('year') || '' ); const episodeProgressContentKey = useMemo( () => buildEpisodeProgressContentKey({ doubanId: videoDoubanId || detail?.douban_id, tmdbId: detail?.tmdb_id, title: initialEpisodeProgressTitle, year: initialEpisodeProgressYear, searchType, }), [ detail?.douban_id, detail?.tmdb_id, initialEpisodeProgressTitle, initialEpisodeProgressYear, searchType, videoDoubanId, ] ); // 是否需要优选 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]); // 监听集数变化,移除已显示的跳转按钮 useEffect(() => { // 移除已显示的跳转按钮 if (playRecordJumpLayerRef.current && artPlayerRef.current) { try { artPlayerRef.current.layers.remove('play-record-jump'); playRecordJumpLayerRef.current = null; } catch (err) { console.warn('[PlayRecordJump] 移除跳转按钮失败:', err); } } // 用户主动切集/自动下一集时不再弹“上次播放到 xx”。 // 首次进入页面仍保留检查能力,用于展示继续播放提示。 if (suppressPlayRecordJumpOnNextEpisodeChangeRef.current) { playRecordJumpInitialCheckRef.current = false; playRecordJumpDismissedRef.current = true; suppressPlayRecordJumpOnNextEpisodeChangeRef.current = false; return; } playRecordJumpInitialCheckRef.current = true; playRecordJumpDismissedRef.current = false; }, [currentEpisodeIndex]); // 监听 URL 参数变化,当切换到不同视频时重新加载页面 useEffect(() => { const urlTitle = searchParams.get('title') || ''; const reloadParam = searchParams.get('_reload'); // 只在有 _reload 参数且标题变化时才重新加载页面 // 这样可以避免初始化、API返回、房间同步等场景的误触发 // 只有用户主动点击推荐时才会添加 _reload 参数 if (reloadParam && urlTitle && urlTitle !== videoTitle && !isSourceChangingRef.current) { console.log('[PlayPage] User clicked recommendation, reloading page'); window.location.reload(); } // 重置换源标记 isSourceChangingRef.current = false; }, [searchParams, videoTitle]); const currentSourceRef = useRef(currentSource); const currentIdRef = useRef(currentId); const videoTitleRef = useRef(videoTitle); const videoYearRef = useRef(videoYear); const detailRef = useRef(detail); const currentEpisodeIndexRef = useRef(currentEpisodeIndex); const isSourceChangingRef = useRef(false); // 标记是否正在换源 // 同步最新值到 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, ]); // 当集数改变时,重置下集预缓存标记 useEffect(() => { nextEpisodePreCacheTriggeredRef.current = false; nextEpisodeDanmakuPreloadTriggeredRef.current = false; // 清理之前的预缓存 HLS 实例 if (nextEpisodePreCacheHlsRef.current) { try { nextEpisodePreCacheHlsRef.current.destroy(); } catch (e) { console.error('清理预缓存 HLS 实例失败:', e); } nextEpisodePreCacheHlsRef.current = null; } }, [currentEpisodeIndex]); // 监听剧集切换,自动加载对应的弹幕 const lastLoadedEpisodeIndexForDanmakuRef = useRef(null); const loadingDanmakuEpisodeIdRef = useRef(null); useEffect(() => { // 等待初始化完成(播放记录恢复完成) if (loading) { return; } if (isDirectPlay) { return; } // 检查是否禁用了自动加载弹幕 if (isDanmakuAutoLoadDisabled()) { console.log('[弹幕] 已禁用自动加载弹幕,跳过自动加载'); setShowDanmakuSourceSelector(false); setDanmakuLoading(false); return; } // 检查集数是否有效且是否已改变 if (currentEpisodeIndex < 0 || !videoTitle) { return; } // 如果集数已经加载过,跳过 if (lastLoadedEpisodeIndexForDanmakuRef.current === currentEpisodeIndex) { return; } // 标记当前集数已加载 lastLoadedEpisodeIndexForDanmakuRef.current = currentEpisodeIndex; console.log(`[弹幕] 剧集切换到第 ${currentEpisodeIndex + 1} 集,自动加载弹幕`); // 立即清空当前弹幕(使用 reset 方法,不触发显示/隐藏事件) if (danmakuPluginRef.current) { danmakuPluginRef.current.reset(); setDanmakuCount(0); } // 自动加载弹幕的逻辑 const loadDanmakuForCurrentEpisode = async () => { const title = videoTitleRef.current; if (!title) { console.warn('[弹幕] 视频标题为空,无法加载弹幕'); return; } const episodeIndex = currentEpisodeIndexRef.current; console.log(`[弹幕] 开始加载第 ${episodeIndex + 1} 集弹幕`); // 先尝试从 IndexedDB 缓存加载 try { const cachedData = await getDanmakuFromCache(title, episodeIndex); if (cachedData && cachedData.comments.length > 0) { console.log(`[弹幕] 使用缓存: title="${title}", episodeIndex=${episodeIndex}, 数量=${cachedData.comments.length}`); // 如果弹幕插件还未初始化,等待初始化 if (!danmakuPluginRef.current) { console.log('[弹幕] 弹幕插件未初始化,等待初始化...'); // 缓存命中但插件未初始化,不执行搜索,等待下次触发 return; } setDanmakuLoading(true); // 转换弹幕格式 let danmakuData = convertDanmakuFormat(cachedData.comments); // 手动应用过滤规则 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 maxCount = typeof window !== 'undefined' ? parseInt(localStorage.getItem('danmakuMaxCount') || '0', 10) : 0; let calculatedOriginalCount = 0; if (maxCount > 0 && danmakuData.length > maxCount) { const originalCount = danmakuData.length; const step = danmakuData.length / maxCount; const limitedData = []; for (let i = 0; i < maxCount; i++) { limitedData.push(danmakuData[Math.floor(i * step)]); } danmakuData = limitedData; calculatedOriginalCount = originalCount; setDanmakuOriginalCount(originalCount); console.log(`弹幕数量限制: 原始 ${originalCount} 条,限制到 ${danmakuData.length} 条`); } else { // 没有应用限制,不显示原始数量 setDanmakuOriginalCount(0); } // 加载弹幕到插件 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(); // 根据保存的显示状态来决定显示或隐藏弹幕 const savedDisplayState = loadDanmakuDisplayState(); if (savedDisplayState === false) { danmakuPluginRef.current.hide(); } else { danmakuPluginRef.current.show(); } setDanmakuCount(danmakuData.length); console.log(`[弹幕] 缓存加载成功,共 ${danmakuData.length} 条`); // 更新当前选择状态(使用实时计算的数量) if (cachedData.metadata) { setCurrentDanmakuSelection({ animeId: cachedData.metadata.animeId || 0, episodeId: cachedData.metadata.episodeId || 0, animeTitle: cachedData.metadata.animeTitle || '', episodeTitle: cachedData.metadata.episodeTitle || '', searchKeyword: cachedData.metadata.searchKeyword, danmakuCount: danmakuData.length, danmakuOriginalCount: calculatedOriginalCount > 0 ? calculatedOriginalCount : undefined, }); } await new Promise((resolve) => setTimeout(resolve, 1500)); setDanmakuLoading(false); return; // 使用缓存成功,直接返回 } } catch (error) { console.error('[弹幕] 读取缓存失败:', error); } // 没有缓存,先检查是否有手动选择的剧集 ID console.log(`[弹幕] 第 ${episodeIndex + 1} 集缓存未命中`); // 检查是否有手动选择的剧集 ID const manualEpisodeId = getManualDanmakuSelection(title, episodeIndex); if (manualEpisodeId) { console.log(`[弹幕记忆] 使用手动选择的剧集 ID: ${manualEpisodeId}`); try { // 需要获取完整的 selection 信息来调用 handleDanmakuSelect // 但这里只有 episodeId,所以保持直接调用 loadDanmaku setDanmakuLoading(true); await loadDanmaku(manualEpisodeId); console.log('[弹幕记忆] 使用手动选择的弹幕成功'); return; // 使用手动选择成功,直接返回 } catch (error) { console.error('[弹幕记忆] 使用手动选择的弹幕失败:', error); // 继续执行自动搜索 } } // 尝试使用保存的动漫ID自动匹配剧集 const savedAnimeId = getDanmakuAnimeId(title); if (savedAnimeId) { console.log(`[弹幕记忆] 尝试使用保存的动漫ID: ${savedAnimeId}`); setDanmakuLoading(true); try { const episodesResult = await getEpisodes(savedAnimeId); if (episodesResult.success && episodesResult.bangumi.episodes.length > 0) { // 根据当前集数选择对应的弹幕 const videoEpTitle = detailRef.current?.episodes_titles?.[episodeIndex]; const episode = matchDanmakuEpisode(episodeIndex, episodesResult.bangumi.episodes, videoEpTitle); if (episode) { console.log(`[弹幕记忆] 使用保存的动漫ID匹配成功: ${episode.episodeTitle}`); const selection: DanmakuSelection = { animeId: savedAnimeId, episodeId: episode.episodeId, animeTitle: episodesResult.bangumi.animeTitle, episodeTitle: episode.episodeTitle, }; setDanmakuEpisodesList(episodesResult.bangumi.episodes); // 通过统一的 handleDanmakuSelect 处理弹幕加载 await handleDanmakuSelect(selection); return; // 匹配成功,直接返回 } else { console.log('[弹幕记忆] 使用保存的动漫ID匹配失败,降级到关键词搜索'); } } } catch (error) { console.error('[弹幕记忆] 使用保存的动漫ID失败:', error); } } // 执行自动搜索弹幕(优先使用保存的关键词) console.log(`[弹幕] 开始自动搜索`); setDanmakuLoading(true); // 优先使用保存的搜索关键词,否则使用视频标题 const savedKeyword = getDanmakuSearchKeyword(title); const searchKeyword = savedKeyword || title; console.log(`[弹幕] 搜索关键词: ${searchKeyword}${savedKeyword ? ' (使用保存的关键词)' : ' (使用视频标题)'}`); try { const searchResult = await searchAnime(searchKeyword); if (searchResult.success && searchResult.animes.length > 0) { // 应用智能过滤:优先匹配年份和标题 const videoYear = detailRef.current?.year; const filteredAnimes = filterDanmakuSources( searchResult.animes, title, videoYear ); // 如果有多个匹配结果,先检查是否有记忆的选择 if (filteredAnimes.length > 1) { console.log(`找到 ${filteredAnimes.length} 个弹幕源`); // 检查是否有上次选择的下标 const rememberedIndex = getDanmakuSourceIndex(title); if (rememberedIndex !== null && rememberedIndex < filteredAnimes.length) { console.log(`[弹幕记忆] 使用上次选择的弹幕源,下标: ${rememberedIndex}`); const anime = filteredAnimes[rememberedIndex]; // 获取剧集列表 const episodesResult = await getEpisodes(anime.animeId); if ( episodesResult.success && episodesResult.bangumi.episodes.length > 0 ) { // 根据当前集数选择对应的弹幕 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, }; // 设置剧集列表 setDanmakuEpisodesList(episodesResult.bangumi.episodes); console.log('使用记忆的弹幕源成功:', selection); // 通过统一的 handleDanmakuSelect 处理弹幕加载 await handleDanmakuSelect(selection); setDanmakuLoading(false); return; } } } // 没有记忆或记忆失效,让用户选择 console.log(`等待用户选择弹幕源`); setDanmakuMatches(filteredAnimes); setCurrentSearchKeyword(searchKeyword); // 保存当前搜索关键词 setShowDanmakuSourceSelector(true); setDanmakuLoading(false); if (artPlayerRef.current) { artPlayerRef.current.notice.show = `找到 ${filteredAnimes.length} 个弹幕源,请选择`; } return; } // 只有一个结果,直接使用 const anime = filteredAnimes[0]; // 获取剧集列表 const episodesResult = await getEpisodes(anime.animeId); if ( episodesResult.success && episodesResult.bangumi.episodes.length > 0 ) { // 根据当前集数选择对应的弹幕 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, }; // 设置剧集列表 setDanmakuEpisodesList(episodesResult.bangumi.episodes); console.log('自动搜索弹幕成功:', selection); // 通过统一的 handleDanmakuSelect 处理弹幕加载 await handleDanmakuSelect(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); } }; loadDanmakuForCurrentEpisode(); }, [currentEpisodeIndex, videoTitle, loading, isDirectPlay]); // 获取豆瓣评分数据 useEffect(() => { const fetchDoubanRating = async () => { if (isDirectPlay) { setDoubanRating(null); setDoubanCardSubtitle(''); setDoubanAka([]); setDoubanYear(''); return; } 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, isDirectPlay]); // 获取TMDB背景图 useEffect(() => { const fetchTMDBBackdrop = async () => { if (isDirectPlay) { setTmdbBackdrop(null); return; } // 检查是否禁用背景图 if (typeof window !== 'undefined') { const disabled = localStorage.getItem('tmdb_backdrop_disabled'); if (disabled === 'true') { setTmdbBackdrop(null); return; } } if (!videoTitle) { setTmdbBackdrop(null); return; } try { const mappingCacheKey = recommendationCacheKeys.tmdbTitleMapping(videoTitle); const cachedId = getRecommendationCache(mappingCacheKey); if (cachedId) { console.log('使用缓存的TMDB ID映射'); const detailsCacheKey = recommendationCacheKeys.tmdbDetails(cachedId); const detailsCache = getRecommendationCache(detailsCacheKey); if (detailsCache) { if (detailsCache.backdrop) { setTmdbBackdrop(processImageUrl(detailsCache.backdrop)); } else { setTmdbBackdrop(null); } if (!videoDoubanId || videoDoubanId === 0) { populateDoubanFieldsFromTMDB(detailsCache); } populatePlayMetadataFromTMDB(detailsCache); return; } } // 构建请求URL const url = cachedId ? `/api/tmdb-details?cachedId=${encodeURIComponent(cachedId)}` : `/api/tmdb-details?title=${encodeURIComponent(videoTitle)}`; const response = await fetch(url); if (!response.ok) { setTmdbBackdrop(null); return; } const result = await response.json(); if (result.backdrop) { setTmdbBackdrop(processImageUrl(result.backdrop)); } else { setTmdbBackdrop(null); } // 如果没有豆瓣ID,使用TMDb数据补充 if (!videoDoubanId || videoDoubanId === 0) { populateDoubanFieldsFromTMDB(result); } populatePlayMetadataFromTMDB(result); // 保存title到tmdbId的映射到localStorage(1个月) if (result.tmdbId) { try { setRecommendationCache(mappingCacheKey, String(result.tmdbId)); const detailsCacheKey = recommendationCacheKeys.tmdbDetails(result.tmdbId); setRecommendationCache(detailsCacheKey, result); } catch (e) { console.error('保存缓存失败:', e); } } } catch (error) { console.error('获取TMDB背景图失败:', error); setTmdbBackdrop(null); } }; const populatePlayMetadataFromTMDB = (tmdbData: any) => { const currentDetail = detailRef.current; if (!currentDetail || !isNetdiskSource(currentDetail.source)) { setPendingNetdiskTMDBData(tmdbData); return; } const tmdbYear = tmdbData.releaseDate?.split('-')[0] || ''; const shouldReplaceDesc = !currentDetail.desc || currentDetail.desc.startsWith('临时播放目录:') || currentDetail.desc.startsWith('移动云盘分享:'); const resolvedTmdbId = typeof tmdbData.tmdbId === 'string' ? Number(String(tmdbData.tmdbId).split(':')[1] || 0) : tmdbData.tmdbId; setNetdiskTMDBMeta({ desc: shouldReplaceDesc ? (tmdbData.overview || currentDetail.desc) : currentDetail.desc, poster: currentDetail.poster || tmdbData.poster || '', year: currentDetail.year || tmdbYear, tmdbId: currentDetail.tmdb_id || resolvedTmdbId, }); setDetail((prev) => { if (!prev || !isNetdiskSource(prev.source)) { return prev; } return { ...prev, poster: prev.poster || tmdbData.poster || '', year: prev.year || tmdbYear, desc: shouldReplaceDesc ? (tmdbData.overview || prev.desc) : prev.desc, tmdb_id: prev.tmdb_id || resolvedTmdbId, }; }); if (tmdbData.overview && (!correctedDesc || currentDetail.desc?.startsWith('临时播放目录:'))) { setCorrectedDesc(tmdbData.overview); } if (tmdbData.poster && !currentDetail.poster) { setVideoCover(processImageUrl(tmdbData.poster)); } if (tmdbYear && !currentDetail.year) { setVideoYear(tmdbYear); } }; // 辅助函数:使用TMDb数据填充豆瓣字段 const populateDoubanFieldsFromTMDB = (tmdbData: any) => { // 设置评分 if (tmdbData.rating) { const ratingValue = parseFloat(tmdbData.rating); setDoubanRating({ value: ratingValue, count: 0, // TMDb不提供评分人数 star_count: Math.round(ratingValue / 2), // 转换为5星制 }); } // 设置年份 if (tmdbData.releaseDate) { const year = tmdbData.releaseDate.split('-')[0]; setDoubanYear(year); } // 设置card_subtitle(优先使用genres标签,否则使用年份和类型) if (tmdbData.genres && Array.isArray(tmdbData.genres) && tmdbData.genres.length > 0) { const genreNames = tmdbData.genres.map((g: any) => g.name).join(' / '); setDoubanCardSubtitle(genreNames); } else if (tmdbData.mediaType && tmdbData.releaseDate) { // 兜底:如果没有genres,使用年份和类型 const year = tmdbData.releaseDate.split('-')[0]; const typeText = tmdbData.mediaType === 'movie' ? '电影' : '电视剧'; setDoubanCardSubtitle(`${year} / ${typeText}`); } }; fetchTMDBBackdrop(); }, [videoTitle, videoDoubanId, isDirectPlay]); useEffect(() => { if ( pendingNetdiskTMDBData && isNetdiskSource(detail?.source) ) { const currentDetail = detail; if (!currentDetail) { return; } const pending = pendingNetdiskTMDBData; setPendingNetdiskTMDBData(null); const tmdbYear = pending.releaseDate?.split('-')[0] || ''; const shouldReplaceDesc = !currentDetail.desc || currentDetail.desc.startsWith('临时播放目录:') || currentDetail.desc.startsWith('移动云盘分享:'); const resolvedTmdbId = typeof pending.tmdbId === 'string' ? Number(String(pending.tmdbId).split(':')[1] || 0) : pending.tmdbId; setNetdiskTMDBMeta({ desc: shouldReplaceDesc ? (pending.overview || currentDetail.desc) : currentDetail.desc, poster: currentDetail.poster || pending.poster || '', year: currentDetail.year || tmdbYear, tmdbId: currentDetail.tmdb_id || resolvedTmdbId, }); setDetail((prev) => prev && isNetdiskSource(prev.source) ? { ...prev, poster: prev.poster || pending.poster || '', year: prev.year || tmdbYear, desc: shouldReplaceDesc ? (pending.overview || prev.desc) : prev.desc, tmdb_id: prev.tmdb_id || resolvedTmdbId, } : prev); if (pending.poster && !currentDetail.poster) { setVideoCover(processImageUrl(pending.poster)); } if (tmdbYear && !currentDetail.year) { setVideoYear(tmdbYear); } if (pending.overview) { setCorrectedDesc(pending.overview); } } }, [pendingNetdiskTMDBData, detail]); // 视频播放地址 const [videoUrl, setVideoUrl] = useState(''); const [playbackSourceBadge, setPlaybackSourceBadge] = useState(null); // 视频清晰度列表 const [videoQualities, setVideoQualities] = useState>([]); // Xiaoya链接刷新相关状态 const [isRefreshingUrl, setIsRefreshingUrl] = useState(false); // 是否正在刷新链接 const retryCountRef = useRef(0); // 重试计数 const lastRefreshTimeRef = useRef(0); // 上次刷新时间 const refreshTimerRef = useRef(null); // 14分钟定时器 const currentXiaoyaUrlRef = useRef(''); // 当前xiaoya原始URL(用于刷新) const isInitialLoadRef = useRef(true); // 标记是否为首次加载 // xiaoya 仅 m3u8 可续期;openlist 由 refresh14m 决定。用于 startRefreshTimer 自身兜底校验 const linkRefreshEligibleRef = useRef(false); const suppressPlayRecordJumpOnNextEpisodeChangeRef = useRef(false); // 主动切集时不显示播放记录跳转提示 // 视频源代理模式状态 const [sourceProxyMode, setSourceProxyMode] = useState(false); const resolveCurrentExternalPlaybackUrl = async () => { let urlToUse = videoUrl; if (sourceProxyMode && detail?.episodes && currentEpisodeIndex < detail.episodes.length) { urlToUse = detail.episodes[currentEpisodeIndex]; } if (!urlToUse) { return null; } return buildAbsoluteUrl(urlToUse); }; const handleCreateTranscodeSession = async () => { if (isTranscoding) return; try { setIsTranscoding(true); const currentPlayTime = artPlayerRef.current?.currentTime || 0; const sourceUrl = await resolveCurrentExternalPlaybackUrl(); if (!sourceUrl) { throw new Error('当前没有可转码的播放链接'); } const requestHeaders: Record = {}; if (sourceUrl.startsWith(window.location.origin)) { if (document.cookie) { requestHeaders.Cookie = document.cookie; } requestHeaders.Referer = `${window.location.origin}/`; } let response: Response; try { response = await fetch(`${LOCAL_TRANSCODER_BASE_URL}/v1/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ url: sourceUrl, headers: Object.keys(requestHeaders).length > 0 ? requestHeaders : undefined, subtitle: { mode: 'burn_embedded', stream: 'auto', }, refresh: false, }), }); } catch { throw new Error('转码服务连接失败'); } const data = await response.json().catch(() => null); if (!response.ok) { throw new Error(data?.error || data?.message || `转码请求失败 (${response.status})`); } const playUrl = data?.playlist_url || data?.play_url; if (!playUrl) { throw new Error('转码器未返回播放地址'); } await new Promise((resolve) => setTimeout(resolve, 3000)); currentXiaoyaUrlRef.current = ''; proxyAttemptedRef.current = false; resumeTimeRef.current = currentPlayTime > 0 ? currentPlayTime : null; setVideoQualities([]); setVideoError(null); setCorsFailedUrl(null); setIsVideoLoading(true); setVideoLoadingStage('sourceChanging'); setPlaybackSourceBadge(null); setVideoUrl(playUrl); setToast({ message: '转码任务已创建,等待 3 秒后已切换到转码地址', type: 'success', onClose: () => setToast(null), }); } catch (error) { console.error('创建转码任务失败:', error); setToast({ message: error instanceof Error ? error.message : '创建转码任务失败', type: 'error', onClose: () => setToast(null), }); } finally { setIsTranscoding(false); } }; const showExternalTranscodeButton = Boolean( detail && videoUrl && !videoUrl.startsWith('blob:') && !isM3u8LikeUrl(videoUrl) && ( detail.source === 'openlist' || isNetdiskSource(detail.source) || detail.source === 'xiaoya' || detail.source.startsWith('emby') ) ); // 总集数 const totalEpisodes = detail?.episodes?.length || 0; const directEpisodeLabel = detail?.episodes_titles?.[currentEpisodeIndex] || '直链'; const shouldShowEpisodeLabel = totalEpisodes > 1 || isDirectPlay; const episodeLabel = isDirectPlay ? directEpisodeLabel : detail?.episodes_titles?.[currentEpisodeIndex] || `第 ${currentEpisodeIndex + 1} 集`; const playerEpisodeLabel = isDirectPlay ? directEpisodeLabel : `第${currentEpisodeIndex + 1}集`; const loadSavedPlaybackRate = () => { if (typeof window === 'undefined') { return 1.0; } const raw = localStorage.getItem('preferredPlaybackRate'); const parsed = raw ? Number(raw) : 1; return Number.isFinite(parsed) && parsed > 0 ? parsed : 1.0; }; const persistPlaybackRate = (rate: number) => { if (typeof window === 'undefined' || !Number.isFinite(rate) || rate <= 0) { return; } localStorage.setItem('preferredPlaybackRate', String(rate)); }; const adjustPlaybackRateByStep = (direction: 1 | -1) => { if (!artPlayerRef.current) { return false; } const currentRate = artPlayerRef.current.playbackRate || 1; const currentIndex = PLAYBACK_RATE_OPTIONS.reduce((nearestIndex, rate, index) => { return Math.abs(rate - currentRate) < Math.abs(PLAYBACK_RATE_OPTIONS[nearestIndex] - currentRate) ? index : nearestIndex; }, 0); let nextIndex = -1; if (direction > 0) { nextIndex = PLAYBACK_RATE_OPTIONS.findIndex((rate) => rate > currentRate + 0.01); } else { for (let index = PLAYBACK_RATE_OPTIONS.length - 1; index >= 0; index--) { if (PLAYBACK_RATE_OPTIONS[index] < currentRate - 0.01) { nextIndex = index; break; } } } const boundedNextIndex = nextIndex === -1 ? currentIndex : nextIndex; const effectiveNextIndex = Math.min( Math.max(boundedNextIndex, 0), PLAYBACK_RATE_OPTIONS.length - 1 ); const nextRate = PLAYBACK_RATE_OPTIONS[effectiveNextIndex]; artPlayerRef.current.playbackRate = nextRate; artPlayerRef.current.notice.show = effectiveNextIndex === currentIndex ? direction > 0 ? `已是最高倍速:${nextRate}x` : `已是最低倍速:${nextRate}x` : `倍速:${nextRate}x`; return true; }; const resetPlaybackRate = () => { if (!artPlayerRef.current) { return false; } artPlayerRef.current.playbackRate = 1; artPlayerRef.current.notice.show = '倍速:1x'; return true; }; const formatQuickForwardDuration = (seconds: number) => { if (seconds >= 60) { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return remainingSeconds ? `${minutes}分${remainingSeconds}秒` : `${minutes}分钟`; } return `${seconds}秒`; }; const seekQuickForward = () => { const player = artPlayerRef.current; if (!player) return false; const duration = Number.isFinite(player.duration) ? player.duration : Infinity; const nextTime = Math.min(duration, (player.currentTime || 0) + quickForwardSecondsRef.current); player.currentTime = nextTime; player.notice.show = `快进 ${formatQuickForwardDuration(quickForwardSecondsRef.current)}`; return true; }; const isDanmakuAutoLoadDisabled = () => { if (typeof window === 'undefined') { return false; } const saved = localStorage.getItem('disableAutoLoadDanmaku'); if (saved !== null) { return saved === 'true'; } return (window as any).RUNTIME_CONFIG?.DANMAKU_AUTO_LOAD_DEFAULT === false; }; // 用于记录是否需要在播放器 ready 后跳转到指定进度 const resumeTimeRef = useRef(null); // 播放记录跳转按钮状态 const playRecordJumpDismissedRef = useRef(false); // 记录用户是否已经关闭过跳转按钮 const playRecordJumpLayerRef = useRef(null); // 保存跳转按钮层的引用 const playRecordJumpInitialCheckRef = useRef(true); // 记录是否是首次检查播放记录 // 上次使用的音量,默认 0.7 const lastVolumeRef = useRef(0.7); // 上次使用的播放速率,默认 1.0 const lastPlaybackRateRef = useRef(loadSavedPlaybackRate()); // Safari 切集时会短暂把 playbackRate 重置为 1,这里保留一段恢复窗口避免污染记忆值 const playbackRateRestoreWindowUntilRef = useRef(0); // 换源相关状态 const [availableSources, setAvailableSources] = useState([]); const [sourceSearchLoading, setSourceSearchLoading] = useState(false); const [sourceSearchError, setSourceSearchError] = useState( null ); const [fallbackRecommendations, setFallbackRecommendations] = useState([]); const [hasCompletedSearchRequest, setHasCompletedSearchRequest] = useState(false); const [backgroundSourcesLoading, setBackgroundSourcesLoading] = useState(false); const fallbackRecommendationsRowRef = useRef(null); const fallbackRecommendationsDraggingRef = useRef(false); const fallbackRecommendationsDragStartXRef = useRef(0); const fallbackRecommendationsDragStartScrollLeftRef = useRef(0); useEffect(() => { try { pruneLocalEpisodeProgressStorage(); } catch (error) { console.warn('[Play] Failed to prune local episode progress:', error); } }, []); // 优选和测速开关 const [optimizationEnabled] = useState(() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('enableOptimization'); if (saved !== null) { try { return JSON.parse(saved); } catch { /* ignore */ } } } return true; }); const [preferStrategy] = useState<'fast' | 'full'>(() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('preferStrategy'); if (saved === 'fast' || saved === 'full') { return saved; } } return 'fast'; }); // 保存优选时的测速结果,避免EpisodeSelector重复测速 const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState< Map >(new Map()); // 当前源的视频信息(用于标题旁边显示) const [currentSourceVideoInfo, setCurrentSourceVideoInfo] = useState<{ quality: string; loadSpeed: string; pingTime: number; bitrate: string; } | null>(null); // 折叠状态(仅在 lg 及以上屏幕有效) const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] = useState(false); // 下载选集面板显示状态 const [showDownloadSelector, setShowDownloadSelector] = useState(false); // 换源加载状态 const [isVideoLoading, setIsVideoLoading] = useState(true); const [videoLoadingStage, setVideoLoadingStage] = useState< 'initing' | 'sourceChanging' | 'episodeChanging' >('initing'); const [videoError, setVideoError] = useState(null); // 直链播放时 CORS 失败的原始 URL,用于显示"使用代理播放"按钮 const [corsFailedUrl, setCorsFailedUrl] = useState(null); // 标记当前视频是否已经尝试过代理(防止 415→直连→失败→代理 的无限循环) const proxyAttemptedRef = useRef(false); const videoUrlRequestSeqRef = useRef(0); const lastVideoRequestKeyRef = useRef(null); // 直链代理域名记忆:检查某个域名是否需要代理 const isDirectplayDomainProxied = (url: string): boolean => { try { const domain = new URL(url).hostname; const domains: string[] = JSON.parse(localStorage.getItem('directplay_proxy_domains') || '[]'); return domains.includes(domain); } catch { return false; } }; // 将域名记录到代理列表 const addDirectplayProxyDomain = (url: string) => { try { const domain = new URL(url).hostname; const domains: string[] = JSON.parse(localStorage.getItem('directplay_proxy_domains') || '[]'); if (!domains.includes(domain)) { domains.push(domain); localStorage.setItem('directplay_proxy_domains', JSON.stringify(domains)); } } catch { /* ignore */ } }; // 播放器就绪状态(用于触发 usePlaySync 的事件监听器设置) const [playerReady, setPlayerReady] = useState(false); const handleFallbackRecommendationsWheel = (e: React.WheelEvent) => { const container = fallbackRecommendationsRowRef.current; if (!container) return; if (container.scrollWidth <= container.clientWidth + 1) return; const delta = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX; if (delta === 0) return; const maxScrollLeft = container.scrollWidth - container.clientWidth; const nextScrollLeft = container.scrollLeft + delta; const willScroll = (delta < 0 && container.scrollLeft > 0) || (delta > 0 && container.scrollLeft < maxScrollLeft); if (!willScroll) return; e.preventDefault(); container.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft)); }; const handleFallbackRecommendationsMouseDown = (e: React.MouseEvent) => { const container = fallbackRecommendationsRowRef.current; if (!container || container.scrollWidth <= container.clientWidth) return; if (e.button !== 0) return; fallbackRecommendationsDraggingRef.current = true; fallbackRecommendationsDragStartXRef.current = e.clientX; fallbackRecommendationsDragStartScrollLeftRef.current = container.scrollLeft; }; const handleFallbackRecommendationsMouseMove = (e: React.MouseEvent) => { const container = fallbackRecommendationsRowRef.current; if (!container || !fallbackRecommendationsDraggingRef.current) return; const deltaX = e.clientX - fallbackRecommendationsDragStartXRef.current; container.scrollLeft = fallbackRecommendationsDragStartScrollLeftRef.current - deltaX; }; const stopFallbackRecommendationsDragging = () => { fallbackRecommendationsDraggingRef.current = false; }; // 播放进度保存相关 const saveIntervalRef = useRef(null); const lastSaveTimeRef = useRef(0); const lastSavedPlayTimeRef = useRef(null); // 下集预缓存相关 const nextEpisodePreCacheTriggeredRef = useRef(false); const nextEpisodePreCacheHlsRef = useRef(null); const nextEpisodeDanmakuPreloadTriggeredRef = useRef(false); const artPlayerRef = useRef(null); const artRef = useRef(null); const syncAnime4KCanvasFlip = (flip?: string) => { const canvas = anime4kRef.current?.canvas as HTMLCanvasElement | undefined; if (!canvas) return; const currentFlip = flip || artPlayerRef.current?.flip || 'normal'; canvas.style.transformOrigin = 'center center'; canvas.style.transform = currentFlip === 'horizontal' ? 'scaleX(-1)' : currentFlip === 'vertical' ? 'scaleY(-1)' : 'none'; }; const customSubtitleInputRef = useRef(null); const customSubtitleRef = useRef(null); const currentSubtitleLabelRef = useRef('关闭'); // Wake Lock 相关 const wakeLockRef = useRef(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 getSubtitleStyle = () => ({ color: '#fff', fontSize: typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em', }); const getSubtitleFileExtension = (fileName: string) => { return fileName.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || ''; }; const isAdvancedSubtitleFormat = (format: string) => { return ADVANCED_SUBTITLE_FORMATS.has(format.toLowerCase()); }; const getSourceSubtitleFormat = (subtitle?: SourceSubtitleItem | null) => { return ( subtitle?.format || subtitle?.sourceFormat || subtitle?.codec || '' ).toLowerCase(); }; const isAdvancedSourceSubtitle = (subtitle?: SourceSubtitleItem | null) => { return subtitle?.renderMode === 'jassub' || isAdvancedSubtitleFormat(getSourceSubtitleFormat(subtitle)); }; const getJassubSubtitleInstance = (): JassubSubtitleInstance | null => { return artPlayerRef.current?.plugins?.artplayerPluginJassub?.instance || null; }; const clearJassubSubtitle = () => { try { getJassubSubtitleInstance()?.freeTrack?.(); } catch (error) { console.warn('[Subtitle] 清理高级字幕失败:', error); } }; const revokeCustomSubtitle = () => { const customSubtitle = customSubtitleRef.current; if (customSubtitle?.engine === 'native' && customSubtitle.url) { URL.revokeObjectURL(customSubtitle.url); } if (customSubtitle?.engine === 'jassub') { clearJassubSubtitle(); } customSubtitleRef.current = null; }; const switchSubtitle = (url: string, label: string) => { if (!artPlayerRef.current) return; clearJassubSubtitle(); artPlayerRef.current.subtitle.switch(url, { name: label, type: 'vtt', style: getSubtitleStyle(), encoding: 'utf-8', }); artPlayerRef.current.subtitle.show = true; currentSubtitleLabelRef.current = label; }; const closeSubtitle = () => { if (!artPlayerRef.current) return; artPlayerRef.current.subtitle.show = false; clearJassubSubtitle(); currentSubtitleLabelRef.current = '关闭'; }; const ensureJassubSubtitleInstance = async ( initialTrack: { content?: string; url?: string } ): Promise<{ instance: JassubSubtitleInstance; created: boolean }> => { const existingInstance = getJassubSubtitleInstance(); if (existingInstance) { return { instance: existingInstance, created: false }; } if (!initialTrack.content && !initialTrack.url) { throw new Error('缺少高级字幕内容'); } if (!artPlayerRef.current) { throw new Error('播放器尚未就绪'); } const JassubPluginModule = await import('artplayer-plugin-jassub'); const artplayerPluginJassub = ((JassubPluginModule as any).default || JassubPluginModule) as any; artPlayerRef.current.plugins.add( artplayerPluginJassub({ ...(initialTrack.content ? { subContent: initialTrack.content } : { subUrl: initialTrack.url }), workerUrl: `${JASSUB_ASSET_BASE}/jassub-worker.js`, wasmUrl: `${JASSUB_ASSET_BASE}/jassub-worker.wasm`, modernWasmUrl: `${JASSUB_ASSET_BASE}/jassub-worker-modern.wasm`, availableFonts: { [JASSUB_CJK_FONT_FAMILY]: JASSUB_CJK_FONT_URL, 'liberation sans': `${JASSUB_ASSET_BASE}/default.woff2`, }, fallbackFont: JASSUB_CJK_FONT_FAMILY, }) ); const instance = getJassubSubtitleInstance(); if (!instance) { throw new Error('高级字幕渲染器初始化失败'); } return { instance, created: true }; }; const switchAdvancedSubtitle = async (content: string, label: string) => { if (!artPlayerRef.current) return; artPlayerRef.current.subtitle.show = false; const { instance, created } = await ensureJassubSubtitleInstance({ content }); // 新建实例时 subContent 已作为初始轨道传入;复用实例时需要显式切轨。 if (!created) { await instance.setTrack?.(content); } currentSubtitleLabelRef.current = label; }; const switchAdvancedSubtitleByUrl = async (url: string, label: string) => { if (!artPlayerRef.current) return; artPlayerRef.current.subtitle.show = false; const { instance, created } = await ensureJassubSubtitleInstance({ url }); // 新建实例时 subUrl 已作为初始轨道传入;复用实例时需要显式切轨。 if (!created) { if (instance.setTrackByUrl) { await instance.setTrackByUrl(url); } else { const response = await fetch(url, { credentials: 'include', cache: 'no-store', }); if (!response.ok) { throw new Error(`高级字幕加载失败 (${response.status})`); } await instance.setTrack?.(await response.text()); } } currentSubtitleLabelRef.current = label; }; const switchSourceSubtitle = async (subtitle: SourceSubtitleItem) => { if (!subtitle.url) return; if (isAdvancedSourceSubtitle(subtitle)) { try { await switchAdvancedSubtitleByUrl(subtitle.url, subtitle.label); return; } catch (error) { if (!subtitle.fallbackUrl) { throw error; } console.warn('[Subtitle] 高级字幕加载失败,尝试降级为普通字幕:', error); switchSubtitle(subtitle.fallbackUrl, subtitle.label); const message = `高级字幕渲染失败,已降级为普通字幕:${subtitle.label}`; if (artPlayerRef.current) { artPlayerRef.current.notice.show = message; } setToast({ message, type: 'info', duration: 5000, onClose: () => setToast(null), }); } return; } switchSubtitle(subtitle.url, subtitle.label); }; const removeSubtitleSetting = () => { try { artPlayerRef.current?.setting.remove('subtitle-selector'); } catch (e) { // 忽略错误,可能设置项不存在 } }; const updateSubtitleSetting = () => { if (!artPlayerRef.current) return; const sourceSubtitles = (detailRef.current?.subtitles?.[currentEpisodeIndexRef.current] || []) as SourceSubtitleItem[]; const customSubtitle = customSubtitleRef.current?.episodeIndex === currentEpisodeIndexRef.current ? customSubtitleRef.current : null; removeSubtitleSetting(); const subtitleOptions = [ { html: '关闭', action: 'close' }, { html: '上传本地字幕', action: 'upload' }, ...sourceSubtitles.map((sub: SourceSubtitleItem) => { const isAdvanced = isAdvancedSourceSubtitle(sub); const format = getSourceSubtitleFormat(sub); return { html: sub.label, action: 'switch', engine: isAdvanced ? 'jassub' : 'native', url: sub.url, fallbackUrl: sub.fallbackUrl, fallbackFormat: sub.fallbackFormat, format, }; }), ...(customSubtitle ? [ { html: `本地:${customSubtitle.name}`, action: 'switch', engine: customSubtitle.engine, url: customSubtitle.url, content: customSubtitle.content, }, ] : []), ]; artPlayerRef.current.setting.add({ name: 'subtitle-selector', html: '字幕', selector: subtitleOptions, onSelect: function (item: any) { if (!artPlayerRef.current) { return currentSubtitleLabelRef.current; } if (item.action === 'close') { closeSubtitle(); return item.html; } if (item.action === 'upload') { customSubtitleInputRef.current?.click(); return currentSubtitleLabelRef.current; } if (item.engine === 'jassub') { const switchPromise = item.content ? switchAdvancedSubtitle(item.content, item.html) : item.url ? switchSourceSubtitle({ label: item.html, url: item.url, fallbackUrl: item.fallbackUrl, fallbackFormat: item.fallbackFormat, format: item.format, renderMode: 'jassub', }) : Promise.resolve(); void switchPromise.catch((error) => { console.warn('[Subtitle] 高级字幕切换失败:', error); setToast({ message: error instanceof Error ? error.message : '高级字幕切换失败', type: 'error', onClose: () => setToast(null), }); }); return item.html; } if (item.url) { switchSubtitle(item.url, item.html); return item.html; } return currentSubtitleLabelRef.current; }, default: currentSubtitleLabelRef.current, }); }; const loadNativeCustomSubtitle = async (file: File) => { const convertedSubtitle = await convertSubtitleFileToVttObjectUrl(file); revokeCustomSubtitle(); customSubtitleRef.current = { ...convertedSubtitle, engine: 'native', episodeIndex: currentEpisodeIndexRef.current, }; switchSubtitle( convertedSubtitle.url, `本地:${convertedSubtitle.name}` ); updateSubtitleSetting(); return convertedSubtitle; }; const loadAdvancedCustomSubtitle = async (file: File, format: string) => { const content = await file.text(); revokeCustomSubtitle(); customSubtitleRef.current = { name: file.name, format, engine: 'jassub', content, episodeIndex: currentEpisodeIndexRef.current, }; await switchAdvancedSubtitle(content, `本地:${file.name}`); updateSubtitleSetting(); }; const handleCustomSubtitleFileChange = async ( event: React.ChangeEvent ) => { const file = event.target.files?.[0]; event.target.value = ''; if (!file) return; const extension = getSubtitleFileExtension(file.name); try { if (isAdvancedSubtitleFormat(extension)) { await loadAdvancedCustomSubtitle(file, extension); setToast({ message: `已加载高级字幕:${file.name}`, type: 'success', onClose: () => setToast(null), }); return; } const convertedSubtitle = await loadNativeCustomSubtitle(file); setToast({ message: `已加载本地字幕:${convertedSubtitle.name}`, type: 'success', onClose: () => setToast(null), }); } catch (error) { let displayError = error; if (isAdvancedSubtitleFormat(extension)) { console.warn('[Subtitle] 高级字幕加载失败,尝试降级为普通字幕:', displayError); try { const convertedSubtitle = await loadNativeCustomSubtitle(file); setToast({ message: `高级字幕渲染失败,已降级为普通字幕:${convertedSubtitle.name}`, type: 'info', duration: 5000, onClose: () => setToast(null), }); return; } catch (fallbackError) { console.warn('[Subtitle] 高级字幕降级加载失败:', fallbackError); displayError = fallbackError; } } console.warn('[Subtitle] 自定义字幕加载失败:', displayError); setToast({ message: displayError instanceof Error ? displayError.message : '字幕加载失败', type: 'error', onClose: () => setToast(null), }); } }; // 判断剧集状态 const getSeriesStatus = (detail: SearchResult | null): 'completed' | 'ongoing' | 'unknown' => { if (!detail) return 'unknown'; // 方法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 'ongoing'; } // 如果包含完结关键词,则为已完结 if (completedKeywords.some(keyword => remarks.includes(keyword))) { return 'completed'; } } // 方法2:通过 vod_total 和实际集数对比判断 if (detail.vod_total && detail.vod_total > 0 && detail.episodes && detail.episodes.length > 0) { // 如果实际集数 >= 总集数,则为已完结 if (detail.episodes.length >= detail.vod_total) { return 'completed'; } // 如果实际集数 < 总集数,则为连载中 return 'ongoing'; } // 无法判断,返回 unknown return 'unknown'; }; // 获取当前源的视频信息(分辨率和码率) const fetchCurrentSourceVideoInfo = async () => { if (!detail || !detail.episodes || detail.episodes.length === 0) { return; } // 获取当前集数的播放地址 let episodeUrl = detail.episodes[currentEpisodeIndex]; if (!episodeUrl) { return; } // 简单的正则或者后缀判断,如果明确不是 m3u8 (比如 mp4),则不走 m3u8 代理 const isM3u8 = episodeUrl.toLowerCase().includes('.m3u') || !episodeUrl.toLowerCase().match(/\.(mp4|flv|webm|mkv|avi|mov)(\?.*)?$/); if (currentSource === 'directplay' && isM3u8) { // 仅当 localStorage 记忆了该域名需要代理时才走代理 if (isDirectplayDomainProxied(episodeUrl)) { const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : ''; episodeUrl = `/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=directplay${tokenParam}`; } else { // 直链模式且未走代理:跳过 HLS.js 探测。 // getVideoResolutionFromM3u8 内部使用 HLS.js (XMLHttpRequest) 加载, // 而 XHR 受 CORS 限制,探测必然失败。实际播放器通过