/* 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 { type MediaProviderAdapter, AirPlayButton, isHLSProvider, MediaPlayer, MediaProvider, } from '@vidstack/react'; import { AirPlayIcon } from '@vidstack/react/icons'; import { defaultLayoutIcons, DefaultVideoLayout, } from '@vidstack/react/player/layouts/default'; import Hls from 'hls.js'; import { Heart } from 'lucide-react'; import { useSearchParams } from 'next/navigation'; import { Suspense } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import React from 'react'; import 'vidstack/styles/defaults.css'; import '@vidstack/react/player/styles/default/theme.css'; import '@vidstack/react/player/styles/default/layouts/video.css'; 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(); // @ts-ignore const playerRef = useRef(null); const playerContainerRef = 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 [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); // 总集数:从 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 [isFullscreen, setIsFullscreen] = useState(false); // 长按三倍速相关状态 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); const currentEpisodeIndexRef = useRef(currentEpisodeIndex); const detailRef = useRef(detail); const currentSourceRef = useRef(currentSource); const currentIdRef = useRef(currentId); const videoTitleRef = useRef(videoTitle); // 同步最新值到 refs useEffect(() => { currentSourceRef.current = currentSource; currentIdRef.current = currentId; detailRef.current = detail; currentEpisodeIndexRef.current = currentEpisodeIndex; videoTitleRef.current = videoTitle; }, [currentSource, currentId, detail, currentEpisodeIndex, videoTitle]); // 解决 iOS Safari 100vh 不准确的问题:将视口高度写入 CSS 变量 --vh const setVH = useCallback(() => { if (typeof window !== 'undefined') { document.documentElement.style.setProperty( '--vh', `${window.innerHeight * 0.01}px` ); } }, []); // 解决 iOS Safari 100vh 不准确的问题:将视口高度写入 CSS 变量 --vh useEffect(() => { setVH(); window.addEventListener('resize', setVH); window.addEventListener('orientationchange', setVH); return () => { window.removeEventListener('resize', setVH); window.removeEventListener('orientationchange', setVH); }; }, [setVH]); // 根据 detail 和集数索引更新视频地址(仅当地址真正变化时) 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); playerContainerRef.current?.focus(); } }; // 当集数索引变化时自动更新视频地址 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(() => { 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) { 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) { 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(); }, []); // 播放器事件处理 const onCanPlay = () => { console.log('播放器准备就绪'); setError(null); // 若存在需要恢复的播放进度,则跳转 if ( playerRef.current && resumeTimeRef.current && resumeTimeRef.current > 0 ) { try { playerRef.current.currentTime = resumeTimeRef.current; } catch (err) { console.warn('恢复播放进度失败:', err); } resumeTimeRef.current = null; } }; const onEnded = () => { const d = detailRef.current; const idx = currentEpisodeIndexRef.current; if (d && d.episodes && idx < d.episodes.length - 1) { setTimeout(() => { setCurrentEpisodeIndex(idx + 1); }, 1000); } }; const onTimeUpdate = () => { const now = Date.now(); if (now - lastSaveTimeRef.current > 5000) { saveCurrentPlayProgress(); lastSaveTimeRef.current = now; } }; const handlePlayerError = (e: any) => { console.error('播放器错误:', e); setError('视频播放失败'); }; // 页面卸载和隐藏时保存播放进度 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, playerRef.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); } }; }, []); // 当视频标题变化时重置搜索状态 useEffect(() => { if (videoTitle) { hasSearchedRef.current = false; setSearchResults([]); setSearchError(null); } }, [videoTitle]); // 添加键盘事件监听器 (使用 refs 避免重复绑定) useEffect(() => { document.addEventListener('keydown', handleKeyboardShortcuts); return () => { document.removeEventListener('keydown', handleKeyboardShortcuts); }; }, []); // 处理选集切换 const handleEpisodeChange = (episodeIndex: number) => { if (episodeIndex >= 0 && episodeIndex < totalEpisodes) { // 在更换集数前保存当前播放进度 if (playerRef.current && !playerRef.current.paused) { saveCurrentPlayProgress(); } setCurrentEpisodeIndex(episodeIndex); setShowEpisodePanel(false); } }; // 处理下一集 const handleNextEpisode = () => { const d = detailRef.current; const idx = currentEpisodeIndexRef.current; if (d && d.episodes && idx < d.episodes.length - 1) { if (playerRef.current && !playerRef.current.paused) { saveCurrentPlayProgress(); } setCurrentEpisodeIndex(idx + 1); } }; // 处理上一集 const handlePreviousEpisode = () => { const idx = currentEpisodeIndexRef.current; if (detailRef.current && idx > 0) { if (playerRef.current && !playerRef.current.paused) { saveCurrentPlayProgress(); } setCurrentEpisodeIndex(idx - 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); playerContainerRef.current?.focus(); // 只在第一次展开时搜索 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 (detailRef.current && currentEpisodeIndexRef.current > 0) { handlePreviousEpisode(); displayShortcutHint('上一集', 'left'); } else { displayShortcutHint('已经是第一集了', 'error'); } 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(); displayShortcutHint('下一集', 'right'); } else { displayShortcutHint('已经是最后一集了', 'error'); } e.preventDefault(); } if (!playerRef.current) return; const player = playerRef.current; // 左箭头 = 快退 if (!e.altKey && e.key === 'ArrowLeft') { if (player.currentTime > 5) { player.currentTime -= 10; displayShortcutHint('快退', 'left'); } e.preventDefault(); } // 右箭头 = 快进 if (!e.altKey && e.key === 'ArrowRight') { if (player.currentTime < player.duration - 5) { player.currentTime += 10; displayShortcutHint('快进', 'right'); } e.preventDefault(); } // 上箭头 = 音量+ if (e.key === 'ArrowUp') { if (player.volume < 1) { player.volume += 0.1; displayShortcutHint(`音量 ${Math.round(player.volume * 100)}`, 'up'); } else { displayShortcutHint('音量 100', 'up'); } e.preventDefault(); } // 下箭头 = 音量- if (e.key === 'ArrowDown') { if (player.volume > 0) { player.volume -= 0.1; displayShortcutHint(`音量 ${Math.round(player.volume * 100)}`, 'down'); } else { displayShortcutHint('音量 0', 'down'); } e.preventDefault(); } // 空格 = 播放/暂停 if (e.key === ' ') { if (playerRef.current.paused) { playerRef.current.play(); displayShortcutHint('播放', 'play'); } else { playerRef.current.pause(); displayShortcutHint('暂停', 'pause'); } e.preventDefault(); } // f 键 = 切换全屏 if (e.key === 'f' || e.key === 'F') { if (player.state.fullscreen) { player.exitFullscreen(); } else { player.enterFullscreen(); } e.preventDefault(); } }; // 保存播放进度的函数 const saveCurrentPlayProgress = async () => { if ( !playerRef.current || !currentSourceRef.current || !currentIdRef.current || !videoTitleRef.current || !detailRef.current?.videoInfo?.source_name ) { return; } const player = playerRef.current; const currentTime = player.currentTime || 0; const duration = player.duration || 0; // 如果播放时间太短(少于5秒)或者视频时长无效,不保存 if (currentTime < 1 || !duration) { return; } try { await savePlayRecord(currentSourceRef.current, currentIdRef.current, { title: videoTitleRef.current, source_name: detailRef.current?.videoInfo.source_name, cover: videoCover, index: currentEpisodeIndexRef.current + 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); } }; // 每当 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 = 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 player = playerRef.current; if (!player) return; return player.subscribe(({ fullscreen }: any) => { setIsFullscreen(fullscreen); if (fullscreen) { lockLandscape(); } else { unlock(); // 强制重绘逻辑,解决退出全屏的黑屏/白边问题 const playerEl = playerRef.current?.el as HTMLElement | null; if (playerEl) { playerEl.style.display = 'none'; setTimeout(() => { playerEl.style.display = ''; setVH(); }, 0); } } }); }, [playerRef.current, setVH]); 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('.vds-controls') || target.closest('.vds-context-menu') || target.closest('.vds-lazy-gesture') ) { return; } // 仅在播放时触发 if (!playerRef.current?.playing) { return; } // 清除之前的定时器 if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.current); } // 设置长按检测定时器(500ms) longPressTimeoutRef.current = setTimeout(() => { if (playerRef.current) { // 保存原始播放速度 originalPlaybackRateRef.current = playerRef.current.playbackRate; // 设置三倍速 playerRef.current.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 && playerRef.current) { playerRef.current.playbackRate = originalPlaybackRateRef.current; setIsLongPressing(false); setShowSpeedTip(false); // 清除提示定时器 if (speedTipTimeoutRef.current) { clearTimeout(speedTipTimeoutRef.current); speedTipTimeoutRef.current = null; } } }; // 添加触摸事件监听器 useEffect(() => { const playerEl = playerRef.current?.el; if (!playerEl) return; const disableContextMenu = (e: Event) => { e.preventDefault(); e.stopPropagation(); }; playerEl.addEventListener('touchstart', handleTouchStart, { passive: true, }); playerEl.addEventListener('touchend', handleTouchEnd, { passive: true }); playerEl.addEventListener('touchcancel', handleTouchEnd, { passive: true }); playerEl.addEventListener('contextmenu', disableContextMenu); return () => { playerEl.removeEventListener('touchstart', handleTouchStart); playerEl.removeEventListener('touchend', handleTouchEnd); playerEl.removeEventListener('touchcancel', handleTouchEnd); playerEl.removeEventListener('contextmenu', disableContextMenu); }; }, [playerRef.current, isLongPressing]); /* -------------------- 设置 meta theme-color 为纯黑 -------------------- */ useEffect(() => { if (typeof document === 'undefined') return; // 查找或创建 meta[name="theme-color"] let metaTag = document.querySelector( 'meta[name="theme-color"]' ) as HTMLMetaElement | null; if (!metaTag) { metaTag = document.createElement('meta'); metaTag.setAttribute('name', 'theme-color'); document.head.appendChild(metaTag); } // 记录原始颜色,并设置为纯黑 metaTag.setAttribute('content', '#000000'); // 卸载时恢复 return () => { metaTag?.setAttribute('content', '#e6f3fb'); }; }, []); if (loading) { return (
加载中...
); } if (error) { return (
播放失败
{error}
); } if (!detail) { return (
未找到视频
); } const PlayerUITopbar = ({ videoTitle, favorited, totalEpisodes, currentEpisodeIndex, sourceName, onToggleFavorite, onOpenSourcePanel, isFullscreen, }: { videoTitle: string; favorited: boolean; totalEpisodes: number; currentEpisodeIndex: number; sourceName: string; onToggleFavorite: () => void; onOpenSourcePanel: () => void; isFullscreen: boolean; }) => { return (
{/* 返回按钮 */} {!isFullscreen && ( )} {/* 中央标题及集数信息 */}
{videoTitle}
{totalEpisodes > 1 && (
第 {currentEpisodeIndex + 1} 集 / 共 {totalEpisodes} 集
)}
{/* 数据源徽章放置在右侧,不影响标题居中 */} {sourceName && ( {sourceName} )}
); }; const onProviderChange = (provider: MediaProviderAdapter | null) => { class extendedHls extends Hls { attachMedia(media: HTMLMediaElement): void { super.attachMedia(media); media.disableRemotePlayback = false; media.autoplay = true; } } if (isHLSProvider(provider)) { provider.library = extendedHls; } }; return (
{/* 竖屏提示蒙层 */} {showOrientationTip && (
请横屏观看
)} {/* 强制横屏按钮:仅在移动端竖屏时显示 */} {isPortrait && ( )} {/* 换源加载遮罩 */} {sourceChanging && (
换源中...
)} {/* 播放器容器 */} 1 ? ( // 下一集按钮放在时间显示前 ) : null, beforeFullscreenButton: ( <> {totalEpisodes > 1 && ( )} {/* 自定义 AirPlay 按钮 */} ), }} /> {/* 选集侧拉面板 */} {totalEpisodes > 1 && (
{/* 遮罩层 */} {showEpisodePanel && (
{ setShowEpisodePanel(false); playerContainerRef.current?.focus(); }} /> )} {/* 侧拉面板 */}

选集列表

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

播放源

{/* 搜索结果 */}
{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}
); })}
)}
{/* 快捷键提示 */}
{shortcutDirection === 'left' && ( )} {shortcutDirection === 'right' && ( )} {shortcutDirection === 'up' && ( )} {shortcutDirection === 'down' && ( )} {shortcutDirection === 'play' && ( )} {shortcutDirection === 'pause' && ( )} {shortcutDirection === 'error' && ( )} {shortcutText}
{/* 三倍速提示 */}
3x 倍速
); } const PlaybackRateButton = ({ playerRef, }: { playerRef: React.RefObject; }) => { const [rate, setRate] = useState(1); const rates = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0]; useEffect(() => { const player = playerRef.current; if (!player) return; // @ts-ignore const unsubscribe = player.subscribe(({ playbackRate }) => { setRate(playbackRate); }); return unsubscribe; }, [playerRef]); const cycleRate = () => { const player = playerRef.current; if (!player) return; const currentIndex = rates.indexOf(rate); const nextIndex = (currentIndex + 1) % rates.length; player.playbackRate = rates[nextIndex]; }; return ( ); }; const FavoriteIcon = ({ filled }: { filled: boolean }) => { if (filled) { return ( ); } return ; }; export default function PlayPage() { return ( ); }