/* 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 Artplayer from 'artplayer'; import Hls from 'hls.js'; import { Heart } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useRef, useState } from 'react'; import { deletePlayRecord, generateStorageKey, getAllPlayRecords, isFavorited, savePlayRecord, toggleFavorite, } from '@/lib/db.client'; import { type VideoDetail, fetchVideoDetail, } from '@/lib/fetchVideoDetail.client'; import { SearchResult } from '@/lib/types'; import EpisodeSelector from '@/components/EpisodeSelector'; import PageLayout from '@/components/PageLayout'; // 扩展 HTMLVideoElement 类型以支持 hls 属性 declare global { interface HTMLVideoElement { hls?: any; } } function PlayPageClient() { const searchParams = useSearchParams(); // ----------------------------------------------------------------------------- // 状态变量(State) // ----------------------------------------------------------------------------- const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [detail, setDetail] = useState(null); // 收藏状态 const [favorited, setFavorited] = useState(false); // 去广告开关(从 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 [videoType, setVideoType] = useState(searchParams.get('type') || ''); const [videoDoubanId, setVideoDoubanId] = useState( searchParams.get('douban_id') || '' ); const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || ''); const [videoYear, setVideoYear] = useState(searchParams.get('year') || ''); const [videoCover, setVideoCover] = useState(''); // 当前源和ID const [currentSource, setCurrentSource] = useState( searchParams.get('source') || '' ); const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); // 集数相关 const initialIndex = parseInt(searchParams.get('index') || '1') - 1; // 转换为0基数组索引 const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(initialIndex); const currentSourceRef = useRef(currentSource); const currentIdRef = useRef(currentId); const videoTitleRef = useRef(videoTitle); const detailRef = useRef(detail); const currentEpisodeIndexRef = useRef(currentEpisodeIndex); // 同步最新值到 refs useEffect(() => { currentSourceRef.current = currentSource; currentIdRef.current = currentId; detailRef.current = detail; currentEpisodeIndexRef.current = currentEpisodeIndex; videoTitleRef.current = videoTitle; }, [currentSource, currentId, detail, currentEpisodeIndex, videoTitle]); // 视频播放地址 const [videoUrl, setVideoUrl] = useState(''); // 总集数 const totalEpisodes = detail?.episodes?.length || 0; // 长按三倍速相关状态 const [isLongPressing, setIsLongPressing] = useState(false); const longPressTimeoutRef = useRef(null); const originalPlaybackRateRef = useRef(1); // 用于记录是否需要在播放器 ready 后跳转到指定进度 const resumeTimeRef = useRef(null); // 上次使用的音量,默认 0.7 const lastVolumeRef = useRef(0.7); // 换源相关状态 const [availableSources, setAvailableSources] = useState([]); const [sourceSearchLoading, setSourceSearchLoading] = useState(false); const [sourceSearchError, setSourceSearchError] = useState( null ); // 折叠状态(仅在 lg 及以上屏幕有效) const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] = useState(false); // 播放进度保存相关 const saveIntervalRef = useRef(null); const lastSaveTimeRef = useRef(0); const videoEventListenersRef = useRef<{ video: HTMLVideoElement; listeners: Array<{ event: string; handler: EventListener }>; } | null>(null); const artPlayerRef = useRef(null); const artRef = useRef(null); // ----------------------------------------------------------------------------- // 工具函数(Utils) // ----------------------------------------------------------------------------- // 更新视频地址 const updateVideoUrl = ( detailData: VideoDetail | null, episodeIndex: number ) => { if ( !detailData || !detailData.episodes || episodeIndex >= detailData.episodes.length ) { setVideoUrl(''); return; } const newUrl = detailData?.episodes[episodeIndex] || ''; if (newUrl !== videoUrl) { setVideoUrl(newUrl); } }; 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'); } }; // 去广告相关函数 function filterAdsFromM3U8(m3u8Content: string): string { if (!m3u8Content) return ''; // 按行分割M3U8内容 const lines = m3u8Content.split('\n'); const filteredLines = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // 只过滤#EXT-X-DISCONTINUITY标识 if (!line.includes('#EXT-X-DISCONTINUITY')) { filteredLines.push(line); } } return filteredLines.join('\n'); } class CustomHlsJsLoader extends Hls.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(response.data); } return onSuccess(response, stats, context, null); }; } // 执行原始load方法 load(context, config, callbacks); }; } } // 当集数索引变化时自动更新视频地址 useEffect(() => { updateVideoUrl(detail, currentEpisodeIndex); }, [detail, currentEpisodeIndex]); // 确保初始状态与URL参数同步 useEffect(() => { const urlSource = searchParams.get('source'); const urlId = searchParams.get('id'); if (urlSource && urlSource !== currentSource) { setCurrentSource(urlSource); } if (urlId && urlId !== currentId) { setCurrentId(urlId); } }, [searchParams, currentSource, currentId]); // 获取视频详情 useEffect(() => { const fetchDetailAsync = async () => { if (!currentSource && !currentId && !videoTitle) { setError('缺少必要参数'); setLoading(false); return; } if (!currentSource && !currentId) { // 只包含视频标题,搜索视频 setLoading(true); const searchResults = await handleSearchSources(videoTitle); console.log('searchResults', searchResults); if (searchResults.length == 0) { setError('未找到匹配结果'); setLoading(false); return; } setCurrentSource(searchResults[0].source); setCurrentId(searchResults[0].id); setVideoYear(searchResults[0].year); setVideoType(''); setVideoDoubanId(''); // 清空豆瓣ID // 替换URL参数 const newUrl = new URL(window.location.href); newUrl.searchParams.set('source', searchResults[0].source); newUrl.searchParams.set('id', searchResults[0].id); newUrl.searchParams.set('year', searchResults[0].year); newUrl.searchParams.delete('douban_id'); window.history.replaceState({}, '', newUrl.toString()); return; } const fetchDetail = async () => { try { const detailData = await fetchVideoDetail({ source: currentSource, id: currentId, fallbackTitle: videoTitle.trim(), fallbackYear: videoYear, }); // 更新状态保存详情 setVideoTitle(detailData.title || videoTitle); setVideoCover(detailData.poster); setDetail(detailData); // 确保集数索引在有效范围内 if (currentEpisodeIndex >= detailData.episodes.length) { console.log('currentEpisodeIndex', currentEpisodeIndex); setCurrentEpisodeIndex(0); } // 清理URL参数(移除index参数) if (searchParams.has('index')) { const newUrl = new URL(window.location.href); newUrl.searchParams.delete('index'); newUrl.searchParams.delete('position'); window.history.replaceState({}, '', newUrl.toString()); } } catch (err) { console.error('获取视频详情失败:', err); } finally { setLoading(false); } }; fetchDetail(); }; fetchDetailAsync(); }, [currentSource, currentId]); // 播放记录处理 useEffect(() => { // 仅在初次挂载时检查播放记录 const initFromHistory = async () => { if (!currentSource || !currentId) return; try { const allRecords = await getAllPlayRecords(); const key = generateStorageKey(currentSource, currentId); const record = allRecords[key]; // URL 参数 const urlIndexParam = searchParams.get('index'); const urlPositionParam = searchParams.get('position'); // 当index参数存在时的处理逻辑 if (urlIndexParam) { const urlIndex = parseInt(urlIndexParam, 10) - 1; let targetTime = 0; // 默认从0开始 // 只有index参数和position参数都存在时才生效position if (urlPositionParam) { targetTime = parseInt(urlPositionParam, 10); } else if (record && urlIndex === record.index - 1) { // 如果有同集播放记录则跳转到播放记录处 targetTime = record.play_time; } // 否则从0开始(targetTime已经是0) // 更新当前选集索引 if (urlIndex !== currentEpisodeIndex) { setCurrentEpisodeIndex(urlIndex); } // 保存待恢复的播放进度,待播放器就绪后跳转 resumeTimeRef.current = targetTime; } else if (record) { // 没有index参数但有播放记录时,使用原有逻辑 const targetIndex = record.index - 1; const targetTime = record.play_time; // 更新当前选集索引 if (targetIndex !== currentEpisodeIndex) { setCurrentEpisodeIndex(targetIndex); } // 保存待恢复的播放进度,待播放器就绪后跳转 resumeTimeRef.current = targetTime; } } catch (err) { console.error('读取播放记录失败:', err); } }; initFromHistory(); }, []); // --------------------------------------------------------------------------- // 换源搜索与切换 // --------------------------------------------------------------------------- // 处理换源搜索 const handleSearchSources = async ( query: string ): Promise => { if (!query.trim()) { setAvailableSources([]); return []; } setSourceSearchLoading(true); setSourceSearchError(null); try { const response = await fetch( `/api/search?q=${encodeURIComponent(query.trim())}` ); if (!response.ok) { throw new Error('搜索失败'); } const data = await response.json(); // 处理搜索结果:每个数据源只展示一个,优先展示与title同名的结果 const processedResults: SearchResult[] = []; const sourceMap = new Map(); // 按数据源分组 data.results?.forEach((result: SearchResult) => { if (!sourceMap.has(result.source)) { sourceMap.set(result.source, []); } const list = sourceMap.get(result.source); if (list) { list.push(result); } }); // 为每个数据源选择最佳结果 sourceMap.forEach((results) => { if (results.length === 0) return; // 只选择和当前视频标题完全匹配的结果,如果有年份,还需要年份完全匹配 const exactMatchs = results.filter( (result) => result.title.toLowerCase() === videoTitle.toLowerCase() && (videoYear ? result.year.toLowerCase() === videoYear.toLowerCase() : true) && (detail ? (detail.episodes.length === 1 && result.episodes.length === 1) || (detail.episodes.length > 1 && result.episodes.length > 1) : true) && (videoDoubanId && result.douban_id ? result.douban_id.toString() === videoDoubanId : true) && (videoType ? (videoType === 'movie' && result.episodes.length === 1) || (videoType === 'tv' && result.episodes.length > 1) : true) ); if (exactMatchs.length > 0) { processedResults.push(...exactMatchs); } }); console.log('processedResults', processedResults); setAvailableSources(processedResults); return processedResults; } catch (err) { setSourceSearchError(err instanceof Error ? err.message : '搜索失败'); setAvailableSources([]); return []; } finally { setSourceSearchLoading(false); } }; // 处理换源 const handleSourceChange = async ( newSource: string, newId: string, newTitle: string ) => { try { // 记录当前播放进度(仅在同一集数切换时恢复) const currentPlayTime = artPlayerRef.current?.currentTime || 0; console.log('换源前当前播放时间:', currentPlayTime); // 显示加载状态 setError(null); // 清除前一个历史记录 if (currentSource && currentId) { try { await deletePlayRecord(currentSource, currentId); console.log('已清除前一个播放记录'); } catch (err) { console.error('清除播放记录失败:', err); } } // 获取新源的详情 const newDetail = await fetchVideoDetail({ source: newSource, id: newId, fallbackTitle: newTitle.trim(), fallbackYear: videoYear, }); // 尝试跳转到当前正在播放的集数 let targetIndex = currentEpisodeIndex; // 如果当前集数超出新源的范围,则跳转到第一集 if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) { targetIndex = 0; } // 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度 if (targetIndex === currentEpisodeIndex && currentPlayTime > 1) { resumeTimeRef.current = currentPlayTime; } else { // 否则从头开始播放,防止影响后续选集逻辑 resumeTimeRef.current = 0; } // 更新URL参数(不刷新页面) const newUrl = new URL(window.location.href); newUrl.searchParams.set('source', newSource); newUrl.searchParams.set('id', newId); window.history.replaceState({}, '', newUrl.toString()); setVideoTitle(newDetail.title || newTitle); setVideoCover(newDetail.poster); setCurrentSource(newSource); setCurrentId(newId); setDetail(newDetail); setCurrentEpisodeIndex(targetIndex); } catch (err) { 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 handleNextEpisode = () => { const d = detailRef.current; const idx = currentEpisodeIndexRef.current; if (d && d.episodes && idx < d.episodes.length - 1) { if (artPlayerRef.current && !artPlayerRef.current.paused) { saveCurrentPlayProgress(); } setCurrentEpisodeIndex(idx + 1); } }; // --------------------------------------------------------------------------- // 键盘快捷键 // --------------------------------------------------------------------------- // 处理全局快捷键 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(currentSource, currentId, { title: videoTitleRef.current, source_name: detailRef.current?.source_name || '', year: videoYear || detailRef.current?.year || '', cover: videoCover, index: currentEpisodeIndex + 1, // 转换为1基索引 total_episodes: totalEpisodes, play_time: Math.floor(currentTime), total_time: Math.floor(duration), save_time: Date.now(), }); lastSaveTimeRef.current = Date.now(); console.log('播放进度已保存:', { title: videoTitleRef.current, episode: currentEpisodeIndexRef.current + 1, progress: `${Math.floor(currentTime)}/${Math.floor(duration)}`, }); } catch (err) { console.error('保存播放进度失败:', err); } }; useEffect(() => { // 页面即将卸载时保存播放进度 const handleBeforeUnload = () => { saveCurrentPlayProgress(); }; // 页面可见性变化时保存播放进度 const handleVisibilityChange = () => { if (document.visibilityState === 'hidden') { saveCurrentPlayProgress(); } }; // 添加事件监听器 window.addEventListener('beforeunload', handleBeforeUnload); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { // 清理事件监听器 window.removeEventListener('beforeunload', handleBeforeUnload); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [currentEpisodeIndex, detail, artPlayerRef.current]); // 清理定时器 useEffect(() => { return () => { if (saveIntervalRef.current) { clearInterval(saveIntervalRef.current); } if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.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]); // 切换收藏 const handleToggleFavorite = async () => { if (!currentSource || !currentId) return; try { const newState = await toggleFavorite(currentSource, currentId, { title: videoTitle, source_name: detail?.source_name || '', year: detail?.year || videoYear || '', cover: videoCover || '', total_episodes: totalEpisodes || 1, save_time: Date.now(), }); setFavorited(newState); } catch (err) { console.error('切换收藏失败:', err); } }; useEffect(() => { if ( !Artplayer || !Hls || !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'; // 非WebKit浏览器且播放器已存在,使用switch方法切换 if (!isWebkit && artPlayerRef.current) { artPlayerRef.current.switch = videoUrl; artPlayerRef.current.title = `${videoTitle} - 第${ currentEpisodeIndex + 1 }集`; artPlayerRef.current.poster = videoCover; if (artPlayerRef.current?.video) { console.log('attachVideoEventListeners'); attachVideoEventListeners( artPlayerRef.current.video as HTMLVideoElement ); ensureVideoSource( artPlayerRef.current.video as HTMLVideoElement, videoUrl ); } return; } // WebKit浏览器或首次创建:销毁之前的播放器实例并创建新的 if (artPlayerRef.current) { if (artPlayerRef.current.video && artPlayerRef.current.video.hls) { artPlayerRef.current.video.hls.destroy(); } // 销毁播放器实例 artPlayerRef.current.destroy(); artPlayerRef.current = null; } try { // 创建新的播放器实例 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: true, fullscreenWeb: false, subtitleOffset: false, miniProgressBar: true, mutex: true, playsInline: true, autoPlayback: false, airplay: true, theme: '#22c55e', lang: 'zh-cn', hotkey: false, moreVideoAttr: { crossOrigin: 'anonymous', }, // HLS 支持配置 customType: { m3u8: function (video: HTMLVideoElement, url: string) { if (!Hls) { console.error('HLS.js 未加载'); return; } if (video.hls) { video.hls.destroy(); } 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: blockAdEnabled ? CustomHlsJsLoader : Hls.DefaultConfig.loader, }); hls.loadSource(url); hls.attachMedia(video); video.hls = hls; ensureVideoSource(video, url); 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: console.log('网络错误,尝试恢复...'); hls.startLoad(); break; case Hls.ErrorTypes.MEDIA_ERROR: console.log('媒体错误,尝试恢复...'); hls.recoverMediaError(); break; default: console.log('无法恢复的错误'); hls.destroy(); break; } } }); }, }, icons: { loading: '', }, settings: [ { html: blockAdEnabled ? '关闭去广告' : '开启去广告', icon: 'AD', tooltip: blockAdEnabled ? '当前开启' : '当前关闭', onClick() { const newVal = !blockAdEnabled; try { saveCurrentPlayProgress(); localStorage.setItem('enable_blockad', String(newVal)); } catch (_) { // ignore } window.location.reload(); return newVal ? '当前开启' : '当前关闭'; }, }, ], // 控制栏配置 controls: [ { position: 'left', index: 13, html: '', tooltip: '播放下一集', click: function () { handleNextEpisode(); }, }, ], }); // 监听播放器事件 artPlayerRef.current.on('ready', () => { setError(null); }); artPlayerRef.current.on('video:volumechange', () => { lastVolumeRef.current = artPlayerRef.current.volume; }); // 监听视频可播放事件,这时恢复播放进度更可靠 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; } artPlayerRef.current.notice.show = ''; }, 0); }); artPlayerRef.current.on('error', (err: any) => { console.error('播放器错误:', err); setError('视频播放失败'); }); // 监听视频播放结束事件,自动播放下一集 artPlayerRef.current.on('video:ended', () => { const d = detailRef.current; const idx = currentEpisodeIndexRef.current; if (d && d.episodes && idx < d.episodes.length - 1) { setTimeout(() => { setCurrentEpisodeIndex(idx + 1); }, 1000); } }); artPlayerRef.current.on('fullscreen', async (state: boolean) => { if (state) { if (screen.orientation && (screen.orientation as any).lock) { await (screen.orientation as any).lock('landscape'); } } else { if (screen.orientation && (screen.orientation as any).unlock) { (screen.orientation as any).unlock(); } } }); artPlayerRef.current.on('video:timeupdate', () => { const now = Date.now(); if (now - lastSaveTimeRef.current > 5000) { saveCurrentPlayProgress(); lastSaveTimeRef.current = now; } }); artPlayerRef.current.on('pause', () => { saveCurrentPlayProgress(); }); if (artPlayerRef.current?.video) { console.log('attachVideoEventListeners'); attachVideoEventListeners( artPlayerRef.current.video as HTMLVideoElement ); ensureVideoSource( artPlayerRef.current.video as HTMLVideoElement, videoUrl ); } } catch (err) { console.error('创建播放器失败:', err); setError('播放器初始化失败'); } }, [Artplayer, Hls, videoUrl]); // --------------------------------------------------------------------------- // 视频元素事件监听 // --------------------------------------------------------------------------- const attachVideoEventListeners = (video: HTMLVideoElement) => { if (!video) return; // 移除旧监听器(如果存在) if (videoEventListenersRef.current) { const { video: oldVideo, listeners } = videoEventListenersRef.current; listeners.forEach(({ event, handler }) => { oldVideo.removeEventListener(event, handler); }); videoEventListenersRef.current = null; } // 阻止移动端长按弹出系统菜单 const contextMenuHandler = (e: Event) => { e.preventDefault(); e.stopPropagation(); }; video.addEventListener('contextmenu', contextMenuHandler); videoEventListenersRef.current = { video, listeners: [{ event: 'contextmenu', handler: contextMenuHandler }], }; }; // 当组件卸载时清理定时器 useEffect(() => { return () => { if (saveIntervalRef.current) { clearInterval(saveIntervalRef.current); } }; }, []); // --------------------------------------------------------------------------- // 移动端触摸(长按三倍速) // --------------------------------------------------------------------------- // 长按三倍速处理函数 const handleTouchStart = (e: TouchEvent) => { // 防止在控制栏区域触发 const target = e.target as HTMLElement; if ( target.closest('.art-controls') || target.closest('.art-contextmenu') || target.closest('.art-layer') ) { return; } // 仅在播放时触发 if (!artPlayerRef.current?.playing) { return; } // 清除之前的定时器 if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.current); } // 设置长按检测定时器(500ms) longPressTimeoutRef.current = setTimeout(() => { if (artPlayerRef.current) { // 保存原始播放速度 originalPlaybackRateRef.current = artPlayerRef.current.playbackRate; // 设置三倍速 artPlayerRef.current.playbackRate = 3; // 更新状态 setIsLongPressing(true); artPlayerRef.current.notice.show = '3x'; // 触发震动反馈(如果支持) if (navigator.vibrate) { navigator.vibrate(50); } } }, 500); }; const handleTouchEnd = () => { // 清除长按检测定时器 if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.current); longPressTimeoutRef.current = null; } // 如果正在长按,恢复原始播放速度 if (isLongPressing && artPlayerRef.current) { artPlayerRef.current.playbackRate = originalPlaybackRateRef.current; setIsLongPressing(false); artPlayerRef.current.notice.show = ''; } }; // 添加触摸事件监听器 useEffect(() => { if (!artRef.current) return; const element = artRef.current; const disableContextMenu = (e: Event) => { e.preventDefault(); e.stopPropagation(); }; element.addEventListener('touchstart', handleTouchStart, { passive: true }); element.addEventListener('touchend', handleTouchEnd, { passive: true }); element.addEventListener('touchcancel', handleTouchEnd, { passive: true }); element.addEventListener('contextmenu', disableContextMenu); return () => { element.removeEventListener('touchstart', handleTouchStart); element.removeEventListener('touchend', handleTouchEnd); element.removeEventListener('touchcancel', handleTouchEnd); element.removeEventListener('contextmenu', disableContextMenu); }; }, [artRef.current, isLongPressing]); if (loading) { return (

加载中...

); } if (error) { return (
⚠️

{error}

); } return (
{/* 第一行:影片标题 */}

{videoTitle || '影片标题'} {totalEpisodes > 1 && ( {` > 第 ${currentEpisodeIndex + 1} 集`} )}

{/* 第二行:播放器和选集 */}
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
{/* 播放器 */}
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
{/* 详情展示 */}
{/* 文字区 */}
{/* 标题 */}

{videoTitle || '影片标题'}

{/* 关键信息行 */}
{detail?.class && ( {detail.class} )} {(detail?.year || videoYear) && ( {detail?.year || videoYear} )} {detail?.source_name && ( {detail.source_name} )} {detail?.type_name && {detail.type_name}}
{/* 剧情简介 */} {detail?.desc && (
{detail.desc}
)}
{/* 封面展示 */}
{videoCover ? ( {videoTitle} ) : ( 封面图片 )}
); } // FavoriteIcon 组件 const FavoriteIcon = ({ filled }: { filled: boolean }) => { if (filled) { return ( ); } return ( ); }; export default function PlayPage() { return ( Loading...}> ); }