/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */ 'use client'; import { Heart } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; import { Suspense } from 'react'; import { useEffect, useRef, useState } from 'react'; import React from 'react'; import { deletePlayRecord, generateStorageKey, getAllPlayRecords, isFavorited, savePlayRecord, toggleFavorite, } from '@/lib/db.client'; import { VideoDetail } from '@/lib/types'; // 扩展 HTMLVideoElement 类型以支持 hls 属性 declare global { interface HTMLVideoElement { hls?: any; } } // 搜索结果类型 interface SearchResult { id: string; title: string; poster: string; episodes?: number; source: string; source_name: string; } function PlayPageClient() { const searchParams = useSearchParams(); const artRef = useRef(null); const artPlayerRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // 使用 useState 保存视频详情 const [detail, setDetail] = useState(null); // 初始标题:如果 URL 中携带 title 参数,则优先使用 const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || ''); const [videoCover, setVideoCover] = useState(''); const [currentSource, setCurrentSource] = useState( searchParams.get('source') || '' ); const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); const [sourceChanging, setSourceChanging] = useState(false); const initialIndex = parseInt(searchParams.get('index') || '1') - 1; // 转换为0基数组索引 const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(initialIndex); const [showEpisodePanel, setShowEpisodePanel] = useState(false); const [showSourcePanel, setShowSourcePanel] = useState(false); const [showTopBar, setShowTopBar] = useState(true); const [showShortcutHint, setShowShortcutHint] = useState(false); const [shortcutText, setShortcutText] = useState(''); const [shortcutDirection, setShortcutDirection] = useState(''); const shortcutHintTimeoutRef = useRef(null); // 换源相关状态 const [searchResults, setSearchResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [searchError, setSearchError] = useState(null); const hasSearchedRef = useRef(false); // 视频播放地址 const [videoUrl, setVideoUrl] = useState(''); // 播放进度保存相关 const saveIntervalRef = useRef(null); const lastSaveTimeRef = useRef(0); const videoEventListenersRef = useRef<{ video: HTMLVideoElement; listeners: Array<{ event: string; handler: EventListener }>; } | null>(null); // 总集数:从 detail 中获取,保证随 detail 更新而变化 const totalEpisodes = detail?.episodes?.length || 0; // 收藏状态 const [favorited, setFavorited] = useState(false); // 是否显示旋转提示(5s 后自动隐藏) const [showOrientationTip, setShowOrientationTip] = useState(false); const orientationTipTimeoutRef = useRef(null); // 当前是否处于竖屏,用于控制"强制横屏"按钮显隐 const [isPortrait, setIsPortrait] = useState( typeof window !== 'undefined' ? window.matchMedia('(orientation: portrait)').matches : true ); // 长按三倍速相关状态 const [isLongPressing, setIsLongPressing] = useState(false); const [showSpeedTip, setShowSpeedTip] = useState(false); const longPressTimeoutRef = useRef(null); const originalPlaybackRateRef = useRef(1); const speedTipTimeoutRef = useRef(null); // 用于记录是否需要在播放器 ready 后跳转到指定进度 const resumeTimeRef = useRef(null); // 动态导入的 Artplayer 与 Hls 实例 const [{ Artplayer, Hls }, setPlayers] = useState<{ Artplayer: any | null; Hls: any | null; }>({ Artplayer: null, Hls: null }); // 解决 iOS Safari 100vh 不准确的问题:将视口高度写入 CSS 变量 --vh useEffect(() => { const setVH = () => { if (typeof window !== 'undefined') { document.documentElement.style.setProperty( '--vh', `${window.innerHeight * 0.01}px` ); } }; setVH(); window.addEventListener('resize', setVH); window.addEventListener('orientationchange', setVH); return () => { window.removeEventListener('resize', setVH); window.removeEventListener('orientationchange', setVH); }; }, []); // 根据 detail 和集数索引更新视频地址(仅当地址真正变化时) const updateVideoUrl = ( detailData: VideoDetail | null, episodeIndex: number ) => { const newUrl = detailData?.episodes[episodeIndex] || ''; if (newUrl != videoUrl) { setVideoUrl(newUrl); } }; // 在指定 video 元素内确保存在 ,供部分浏览器兼容 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'); } }; // 当集数索引变化时自动更新视频地址 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]); // 动态加载 Artplayer 与 Hls 并保存到 state useEffect(() => { (async () => { try { const [ArtplayerModule, HlsModule] = await Promise.all([ import('artplayer'), import('hls.js'), ]); setPlayers({ Artplayer: ArtplayerModule.default, Hls: HlsModule.default, }); } catch (err) { console.error('Failed to load players:', err); setError('播放器加载失败'); setLoading(false); } })(); }, []); useEffect(() => { if (!currentSource || !currentId) { setError('缺少必要参数'); setLoading(false); return; } const fetchDetail = async () => { try { const response = await fetch( `/api/detail?source=${currentSource}&id=${currentId}` ); if (!response.ok) { throw new Error('获取视频详情失败'); } const data = await response.json(); // 更新状态保存详情 setVideoTitle(data.videoInfo.title || videoTitle); setVideoCover(data.videoInfo.cover); setDetail(data); // 确保集数索引在有效范围内 if (currentEpisodeIndex >= data.episodes.length) { 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) { setError(err instanceof Error ? err.message : '获取视频详情失败'); } finally { setLoading(false); } }; fetchDetail(); }, [currentSource]); /* -------------------- 播放记录处理 -------------------- */ 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(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); 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 pauseHandler = () => { saveCurrentPlayProgress(); }; // 阻止移动端长按弹出系统菜单 const contextMenuHandler = (e: Event) => { e.preventDefault(); e.stopPropagation(); }; // timeupdate 节流(5 秒)保存 let lastSave = 0; const timeUpdateHandler = () => { const now = Date.now(); if (now - lastSave > 5000) { saveCurrentPlayProgress(); lastSave = now; } }; video.addEventListener('pause', pauseHandler); video.addEventListener('timeupdate', timeUpdateHandler); video.addEventListener('contextmenu', contextMenuHandler); videoEventListenersRef.current = { video, listeners: [ { event: 'pause', handler: pauseHandler }, { event: 'timeupdate', handler: timeUpdateHandler }, { event: 'contextmenu', handler: contextMenuHandler }, ], }; }; // 播放器创建/切换逻辑,只依赖视频URL和集数索引 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) { 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 { // 创建新的播放器实例 artPlayerRef.current = new Artplayer({ container: artRef.current, url: videoUrl, title: `${videoTitle} - 第${currentEpisodeIndex + 1}集`, poster: videoCover, volume: 0.7, isLive: false, muted: false, autoplay: true, pip: false, autoSize: false, autoMini: false, screenshot: false, setting: false, loop: false, flip: false, playbackRate: false, aspectRatio: false, fullscreen: true, fullscreenWeb: false, subtitleOffset: false, miniProgressBar: false, mutex: true, backdrop: true, playsInline: true, autoPlayback: false, airplay: true, theme: '#23ade5', 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,超出后触发清理 }); 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: '', }, // 控制栏配置 controls: [ { position: 'left', index: 13, html: '', tooltip: '播放下一集', click: function () { handleNextEpisode(); }, }, { position: 'right', html: '选集', tooltip: '选择集数', click: function () { if (artPlayerRef.current && artPlayerRef.current.fullscreen) { artPlayerRef.current.fullscreen = false; } setShowEpisodePanel(true); }, }, ], }); // 监听播放器事件 artPlayerRef.current.on('ready', () => { console.log('播放器准备就绪'); setError(null); // 若存在需要恢复的播放进度,则跳转 if (resumeTimeRef.current && resumeTimeRef.current > 0) { try { artPlayerRef.current.video.currentTime = resumeTimeRef.current; } catch (err) { console.warn('恢复播放进度失败:', err); } resumeTimeRef.current = null; } }); artPlayerRef.current.on('error', (err: any) => { console.error('播放器错误:', err); setError('视频播放失败'); }); // 监听控制栏显隐事件,同步 topbar 的显隐 artPlayerRef.current.on('control', (visible: boolean) => { setShowTopBar(visible); }); // 监听视频播放结束事件,自动播放下一集 artPlayerRef.current.on('video:ended', () => { if ( detail && detail.episodes && currentEpisodeIndex < detail.episodes.length - 1 ) { setTimeout(() => { setCurrentEpisodeIndex(currentEpisodeIndex + 1); }, 1000); } }); if (artPlayerRef.current?.video) { attachVideoEventListeners( artPlayerRef.current.video as HTMLVideoElement ); ensureVideoSource( artPlayerRef.current.video as HTMLVideoElement, videoUrl ); } } catch (err) { console.error('创建播放器失败:', err); setError('播放器初始化失败'); } }, [Artplayer, Hls, videoUrl]); // 页面卸载和隐藏时保存播放进度 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 (shortcutHintTimeoutRef.current) { clearTimeout(shortcutHintTimeoutRef.current); } if (saveIntervalRef.current) { clearInterval(saveIntervalRef.current); } if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.current); } if (speedTipTimeoutRef.current) { clearTimeout(speedTipTimeoutRef.current); } // 清理视频事件监听器 if (videoEventListenersRef.current) { const { video, listeners } = videoEventListenersRef.current; listeners.forEach(({ event, handler }) => { video.removeEventListener(event, handler); }); videoEventListenersRef.current = null; } }; }, []); // 当视频标题变化时重置搜索状态 useEffect(() => { if (videoTitle) { hasSearchedRef.current = false; setSearchResults([]); setSearchError(null); } }, [videoTitle]); // 添加键盘事件监听器 useEffect(() => { document.addEventListener('keydown', handleKeyboardShortcuts); return () => { document.removeEventListener('keydown', handleKeyboardShortcuts); }; }, [currentEpisodeIndex, detail, artPlayerRef.current]); // 处理选集切换 const handleEpisodeChange = (episodeIndex: number) => { if (episodeIndex >= 0 && episodeIndex < totalEpisodes) { // 在更换集数前保存当前播放进度 if ( artPlayerRef.current && artPlayerRef.current.video && !artPlayerRef.current.video.paused ) { saveCurrentPlayProgress(); } setCurrentEpisodeIndex(episodeIndex); setShowEpisodePanel(false); } }; // 处理下一集 const handleNextEpisode = () => { if ( detail && detail.episodes && currentEpisodeIndex < detail.episodes.length - 1 ) { // 在更换集数前保存当前播放进度 if ( artPlayerRef.current && artPlayerRef.current.video && !artPlayerRef.current.video.paused ) { saveCurrentPlayProgress(); } setCurrentEpisodeIndex(currentEpisodeIndex + 1); } }; // 处理返回按钮点击 const handleBack = () => { window.location.href = `/detail?source=${currentSource}&id=${currentId}&title=${encodeURIComponent( videoTitle )}`; }; // 处理上一集 const handlePreviousEpisode = () => { if (detail && currentEpisodeIndex > 0) { if ( artPlayerRef.current && artPlayerRef.current.video && !artPlayerRef.current.video.paused ) { saveCurrentPlayProgress(); } setCurrentEpisodeIndex(currentEpisodeIndex - 1); } }; // 搜索视频源 const handleSearch = async (query: string) => { if (!query.trim()) { setSearchResults([]); return; } setSearchLoading(true); setSearchError(null); try { const response = await fetch( `/api/search?q=${encodeURIComponent(query)}` ); 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 exactMatch = results.find( (result) => result.title.toLowerCase() === videoTitle.toLowerCase() ); // 如果没有完全匹配,选择第一个结果 const selectedResult = exactMatch || results[0]; processedResults.push(selectedResult); }); setSearchResults(processedResults); } catch (err) { setSearchError(err instanceof Error ? err.message : '搜索失败'); setSearchResults([]); } finally { setSearchLoading(false); } }; // 处理换源 const handleSourceChange = async ( newSource: string, newId: string, newTitle: string ) => { try { // 显示换源加载状态 setSourceChanging(true); setError(null); // 清除前一个历史记录 if (currentSource && currentId) { try { await deletePlayRecord(currentSource, currentId); console.log('已清除前一个播放记录'); } catch (err) { console.error('清除播放记录失败:', err); } } // 获取新源的详情 const response = await fetch( `/api/detail?source=${newSource}&id=${newId}` ); if (!response.ok) { throw new Error('获取新源详情失败'); } const newDetail = await response.json(); // 尝试跳转到当前正在播放的集数 let targetIndex = currentEpisodeIndex; // 如果当前集数超出新源的范围,则跳转到第一集 if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) { targetIndex = 0; } // 更新URL参数(不刷新页面) const newUrl = new URL(window.location.href); newUrl.searchParams.set('source', newSource); newUrl.searchParams.set('id', newId); window.history.replaceState({}, '', newUrl.toString()); // 关闭换源面板 setShowSourcePanel(false); setVideoTitle(newDetail.videoInfo.title || newTitle); setVideoCover(newDetail.videoInfo.cover); setCurrentSource(newSource); setCurrentId(newId); setDetail(newDetail); setCurrentEpisodeIndex(targetIndex); } catch (err) { setError(err instanceof Error ? err.message : '换源失败'); } finally { setSourceChanging(false); } }; // 处理播放源面板展开 const handleSourcePanelOpen = () => { setShowSourcePanel(true); // 只在第一次展开时搜索 if (videoTitle && !hasSearchedRef.current) { handleSearch(videoTitle); hasSearchedRef.current = true; } }; // 显示快捷键提示 const displayShortcutHint = (text: string, direction: string) => { setShortcutText(text); setShortcutDirection(direction); setShowShortcutHint(true); // 清除之前的超时 if (shortcutHintTimeoutRef.current) { clearTimeout(shortcutHintTimeoutRef.current); } // 2秒后隐藏 shortcutHintTimeoutRef.current = setTimeout(() => { setShowShortcutHint(false); }, 2000); }; // 处理全局快捷键 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 (detail && currentEpisodeIndex > 0) { handlePreviousEpisode(); displayShortcutHint('上一集', 'left'); e.preventDefault(); } } // Alt + 右箭头 = 下一集 if (e.altKey && e.key === 'ArrowRight') { if (detail && currentEpisodeIndex < detail.episodes.length - 1) { handleNextEpisode(); displayShortcutHint('下一集', 'right'); e.preventDefault(); } } // 左箭头 = 快退 if (!e.altKey && e.key === 'ArrowLeft') { if ( artPlayerRef.current && artPlayerRef.current.video && artPlayerRef.current.video.currentTime > 5 ) { artPlayerRef.current.video.currentTime -= 10; displayShortcutHint('快退', 'left'); e.preventDefault(); } } // 右箭头 = 快进 if (!e.altKey && e.key === 'ArrowRight') { if ( artPlayerRef.current && artPlayerRef.current.video && artPlayerRef.current.video.currentTime < artPlayerRef.current.video.duration - 5 ) { artPlayerRef.current.video.currentTime += 10; displayShortcutHint('快进', 'right'); e.preventDefault(); } } // 上箭头 = 音量+ if (e.key === 'ArrowUp') { if ( artPlayerRef.current && artPlayerRef.current.video && artPlayerRef.current.video.volume < 1 ) { artPlayerRef.current.video.volume += 0.1; displayShortcutHint( `音量 ${Math.round(artPlayerRef.current.video.volume * 100)}`, 'up' ); e.preventDefault(); } } // 下箭头 = 音量- if (e.key === 'ArrowDown') { if ( artPlayerRef.current && artPlayerRef.current.video && artPlayerRef.current.video.volume > 0 ) { artPlayerRef.current.video.volume -= 0.1; displayShortcutHint( `音量 ${Math.round(artPlayerRef.current.video.volume * 100)}`, 'down' ); 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?.video || !currentSource || !currentId || !videoTitle || !detail?.videoInfo?.source_name ) { return; } const video = artPlayerRef.current.video; const currentTime = video.currentTime || 0; const duration = video.duration || 0; // 如果播放时间太短(少于5秒)或者视频时长无效,不保存 if (currentTime < 1 || !duration) { return; } try { await savePlayRecord(currentSource, currentId, { title: videoTitle, source_name: detail.videoInfo.source_name, 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: videoTitle, episode: currentEpisodeIndex + 1, progress: `${Math.floor(currentTime)}/${Math.floor(duration)}`, }); } catch (err) { console.error('保存播放进度失败:', err); } }; // 每当 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?.videoInfo.source_name || '', cover: videoCover || '', total_episodes: totalEpisodes || 1, save_time: Date.now(), }); setFavorited(newState); } catch (err) { console.error('切换收藏失败:', err); } }; // 监听屏幕方向变化:竖屏时显示提示蒙层 useEffect(() => { if (typeof window === 'undefined') return; const mql = window.matchMedia('(orientation: portrait)'); const update = () => { const portrait = mql.matches; // 更新竖屏状态 setIsPortrait(portrait); // 在进入竖屏时显示提示,5 秒后自动隐藏 if (portrait) { setShowOrientationTip(true); if (orientationTipTimeoutRef.current) { clearTimeout(orientationTipTimeoutRef.current); } orientationTipTimeoutRef.current = setTimeout(() => { setShowOrientationTip(false); }, 5000); } else { setShowOrientationTip(false); if (orientationTipTimeoutRef.current) { clearTimeout(orientationTipTimeoutRef.current); orientationTipTimeoutRef.current = null; } } }; // 初始执行一次 update(); if (mql.addEventListener) { mql.addEventListener('change', update); } else { // Safari < 14 // @ts-ignore mql.addListener(update); } return () => { if (mql.removeEventListener) { mql.removeEventListener('change', update); } else { // @ts-ignore mql.removeListener(update); } }; }, []); // 用户点击悬浮按钮 -> 请求全屏并锁定横屏 const handleForceLandscape = async () => { try { const el: any = artRef.current || document.documentElement; if (el.requestFullscreen) { await el.requestFullscreen(); } else if (el.webkitRequestFullscreen) { el.webkitRequestFullscreen(); } if (screen.orientation && (screen.orientation as any).lock) { await (screen.orientation as any).lock('landscape'); } } catch (err) { console.warn('强制横屏失败:', err); } }; // 进入/退出全屏时锁定/解锁横屏(保持原有逻辑) useEffect(() => { if (typeof document === 'undefined') return; const lockLandscape = async () => { try { // 某些浏览器需要在用户手势触发后才能调用 if (screen.orientation && (screen.orientation as any).lock) { await (screen.orientation as any).lock('landscape'); } } catch (err) { console.warn('横屏锁定失败:', err); } }; const unlock = () => { try { if (screen.orientation && (screen.orientation as any).unlock) { (screen.orientation as any).unlock(); } } catch (_) { // 忽略解锁屏幕方向失败的错误 } }; const handleFsChange = () => { const isFs = !!document.fullscreenElement; if (isFs) { lockLandscape(); } else { unlock(); } }; document.addEventListener('fullscreenchange', handleFsChange); return () => { document.removeEventListener('fullscreenchange', handleFsChange); unlock(); }; }, []); useEffect(() => { // 播放页挂载时,锁定页面滚动并消除 body 100vh 带来的额外空白 if (typeof document === 'undefined') return; const { style: bodyStyle } = document.body; const { style: htmlStyle } = document.documentElement; // 记录原始样式,供卸载时恢复 const originalBodyMinH = bodyStyle.minHeight; const originalBodyH = bodyStyle.height; const originalBodyOverflow = bodyStyle.overflow; const originalHtmlOverflow = htmlStyle.overflow; bodyStyle.minHeight = '0'; bodyStyle.height = 'auto'; bodyStyle.overflow = 'hidden'; htmlStyle.overflow = 'hidden'; return () => { bodyStyle.minHeight = originalBodyMinH; bodyStyle.height = originalBodyH; bodyStyle.overflow = originalBodyOverflow; htmlStyle.overflow = originalHtmlOverflow; }; }, []); // 长按三倍速处理函数 const handleTouchStart = (e: TouchEvent) => { // 防止在控制栏区域触发 const target = e.target as HTMLElement; if (target.closest('.art-controls') || target.closest('.art-contextmenu')) { return; } // 清除之前的定时器 if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.current); } // 设置长按检测定时器(500ms) longPressTimeoutRef.current = setTimeout(() => { if (artPlayerRef.current && artPlayerRef.current.video) { // 保存原始播放速度 originalPlaybackRateRef.current = artPlayerRef.current.video.playbackRate; // 设置三倍速 artPlayerRef.current.video.playbackRate = 3; // 更新状态 setIsLongPressing(true); setShowSpeedTip(true); // 触发震动反馈(如果支持) if (navigator.vibrate) { navigator.vibrate(50); } // 3秒后自动隐藏提示 if (speedTipTimeoutRef.current) { clearTimeout(speedTipTimeoutRef.current); } speedTipTimeoutRef.current = setTimeout(() => { setShowSpeedTip(false); }, 3000); } }, 500); }; const handleTouchEnd = () => { // 清除长按检测定时器 if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.current); longPressTimeoutRef.current = null; } // 如果正在长按,恢复原始播放速度 if (isLongPressing && artPlayerRef.current && artPlayerRef.current.video) { artPlayerRef.current.video.playbackRate = originalPlaybackRateRef.current; setIsLongPressing(false); setShowSpeedTip(false); // 清除提示定时器 if (speedTipTimeoutRef.current) { clearTimeout(speedTipTimeoutRef.current); speedTipTimeoutRef.current = null; } } }; // 添加触摸事件监听器 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}
); } if (!detail) { return (
未找到视频
); } return (
{/* 竖屏提示蒙层 */} {showOrientationTip && (
请横屏观看
)} {/* 强制横屏按钮:仅在移动端竖屏时显示 */} {isPortrait && ( )} {/* 换源加载遮罩 */} {sourceChanging && (
换源中...
)} {/* 播放器容器 */}
{/* 顶栏 */}
{/* 返回按钮 */} {/* 中央标题及集数信息 */}
{videoTitle}
{totalEpisodes > 1 && (
第 {currentEpisodeIndex + 1} 集 / 共 {totalEpisodes} 集
)}
{/* 数据源徽章放置在右侧,不影响标题居中 */} {detail?.videoInfo?.source_name && ( {detail.videoInfo.source_name} )}
{/* 快捷键提示 */}
{shortcutDirection === 'left' && ( )} {shortcutDirection === 'right' && ( )} {shortcutDirection === 'up' && ( )} {shortcutDirection === 'down' && ( )} {shortcutText}
{/* 三倍速提示 */}
3x 倍速
{/* 选集侧拉面板 */} {totalEpisodes > 1 && ( <> {/* 遮罩层 */} {showEpisodePanel && (
setShowEpisodePanel(false)} /> )} {/* 侧拉面板 */}

选集列表

当前: 第 {currentEpisodeIndex + 1} 集 / 共 {totalEpisodes} 集
{Array.from({ length: totalEpisodes }, (_, idx) => ( ))}
)} {/* 换源侧拉面板 */} <> {/* 遮罩层 */} {showSourcePanel && (
setShowSourcePanel(false)} /> )} {/* 侧拉面板 */}

播放源

{/* 搜索结果 */}
{searchLoading && (
搜索中...
)} {searchError && (
{searchError}
)} {!searchLoading && !searchError && searchResults.length === 0 && (
未找到相关视频源
)} {!searchLoading && !searchError && searchResults.length > 0 && (
{[ ...searchResults.filter( (r) => r.source === currentSource && String(r.id) === String(currentId) ), ...searchResults.filter( (r) => !( r.source === currentSource && String(r.id) === String(currentId) ) ), ].map((result) => { const isCurrentSource = result.source === currentSource && String(result.id) === String(currentId); return (
!isCurrentSource && handleSourceChange( result.source, result.id, result.title ) } > {/* 视频封面 */}
{result.title} {/* 集数圆形指示器 */} {result.episodes && (
{result.episodes}
)} {isCurrentSource && (
当前播放
)}
{/* 视频信息 */}

{result.title}

{result.source_name}
); })}
)}
); } export default function PlayPage() { return ( ); }