diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index e308398..aa370d0 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -476,6 +476,20 @@ function PlayPageClient() { videoYear, ]); + // 当集数改变时,重置下集预缓存标记 + useEffect(() => { + nextEpisodePreCacheTriggeredRef.current = false; + // 清理之前的预缓存 HLS 实例 + if (nextEpisodePreCacheHlsRef.current) { + try { + nextEpisodePreCacheHlsRef.current.destroy(); + } catch (e) { + console.error('清理预缓存 HLS 实例失败:', e); + } + nextEpisodePreCacheHlsRef.current = null; + } + }, [currentEpisodeIndex]); + // 监听剧集切换,自动加载对应的弹幕 const lastLoadedEpisodeIdRef = useRef(null); @@ -742,6 +756,10 @@ function PlayPageClient() { const saveIntervalRef = useRef(null); const lastSaveTimeRef = useRef(0); + // 下集预缓存相关 + const nextEpisodePreCacheTriggeredRef = useRef(false); + const nextEpisodePreCacheHlsRef = useRef(null); + const artPlayerRef = useRef(null); const artRef = useRef(null); @@ -3260,15 +3278,58 @@ function PlayPageClient() { // 每次创建HLS实例时,都读取最新的blockAdEnabled状态 const shouldUseCustomLoader = blockAdEnabledRef.current; + // 从localStorage读取缓冲策略 + const bufferStrategy = typeof window !== 'undefined' + ? localStorage.getItem('bufferStrategy') || 'medium' + : 'medium'; + + // 根据缓冲策略配置不同的缓冲参数 + const getBufferConfig = (strategy: string) => { + switch (strategy) { + case 'low': + return { + maxBufferLength: 15, + backBufferLength: 15, + maxBufferSize: 30 * 1000 * 1000, // ~30MB + }; + case 'medium': + return { + maxBufferLength: 30, + backBufferLength: 30, + maxBufferSize: 60 * 1000 * 1000, // ~60MB + }; + case 'high': + return { + maxBufferLength: 60, + backBufferLength: 40, + maxBufferSize: 120 * 1000 * 1000, // ~120MB + }; + case 'ultra': + return { + maxBufferLength: 120, + backBufferLength: 60, + maxBufferSize: 240 * 1000 * 1000, // ~240MB + }; + default: + return { + maxBufferLength: 30, + backBufferLength: 30, + maxBufferSize: 60 * 1000 * 1000, + }; + } + }; + + const bufferConfig = getBufferConfig(bufferStrategy); + const hls = new Hls({ debug: false, // 关闭日志 enableWorker: true, // WebWorker 解码,降低主线程压力 lowLatencyMode: true, // 开启低延迟 LL-HLS - /* 缓冲/内存相关 */ - maxBufferLength: 30, // 前向缓冲最大 30s,过大容易导致高延迟 - backBufferLength: 30, // 仅保留 30s 已播放内容,避免内存占用 - maxBufferSize: 60 * 1000 * 1000, // 约 60MB,超出后触发清理 + /* 缓冲/内存相关 - 根据用户设置的缓冲策略动态调整 */ + maxBufferLength: bufferConfig.maxBufferLength, // 前向缓冲长度 + backBufferLength: bufferConfig.backBufferLength, // 已播放内容保留长度 + maxBufferSize: bufferConfig.maxBufferSize, // 最大缓冲大小 /* 自定义loader */ loader: (shouldUseCustomLoader @@ -4327,6 +4388,84 @@ function PlayPageClient() { saveCurrentPlayProgress(); lastSaveTimeRef.current = now; } + + // 下集预缓冲逻辑 + const nextEpisodePreCacheEnabled = typeof window !== 'undefined' + ? localStorage.getItem('nextEpisodePreCache') === 'true' + : false; + + if (nextEpisodePreCacheEnabled) { + const currentTime = artPlayerRef.current?.currentTime || 0; + const duration = artPlayerRef.current?.duration || 0; + const progress = duration > 0 ? currentTime / duration : 0; + + // 检查是否已经到达90%播放进度 + if (duration > 0 && progress >= 0.9 && !nextEpisodePreCacheTriggeredRef.current) { + // 标记已触发,防止重复执行 + nextEpisodePreCacheTriggeredRef.current = true; + + // 获取下一集信息 + const currentIdx = currentEpisodeIndexRef.current; + const episodes = detailRef.current?.episodes; + + if (!episodes || currentIdx >= episodes.length - 1) { + return; + } + + const nextEpisodeIndex = currentIdx + 1; + const nextEpisodeUrl = episodes[nextEpisodeIndex]; + + if (!nextEpisodeUrl) { + return; + } + + // 使用 fetch 预加载资源,利用浏览器缓存 + const preloadNextEpisode = async () => { + try { + // 判断是否是m3u8流 + if (nextEpisodeUrl.includes('.m3u8') || nextEpisodeUrl.includes('m3u8')) { + // 1. 先fetch m3u8文件 + const m3u8Response = await fetch(nextEpisodeUrl); + const m3u8Text = await m3u8Response.text(); + + // 2. 解析m3u8,提取ts分片URL + const lines = m3u8Text.split('\n'); + const tsUrls: string[] = []; + const baseUrl = nextEpisodeUrl.substring(0, nextEpisodeUrl.lastIndexOf('/') + 1); + + for (const line of lines) { + const trimmedLine = line.trim(); + // 跳过注释和空行 + if (!trimmedLine || trimmedLine.startsWith('#')) { + continue; + } + // 构建完整的ts URL + const tsUrl = trimmedLine.startsWith('http') + ? trimmedLine + : baseUrl + trimmedLine; + tsUrls.push(tsUrl); + } + + // 3. 预加载前20个ts分片 + const maxFragmentsToPreload = Math.min(20, tsUrls.length); + + for (let i = 0; i < maxFragmentsToPreload; i++) { + try { + await fetch(tsUrls[i]); + } catch (err) { + // 静默处理分片加载失败 + } + } + } + } catch (error) { + // 静默处理预缓冲失败 + } + }; + + // 异步执行预缓冲 + preloadNextEpisode(); + } + } }); artPlayerRef.current.on('pause', () => { diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index aad359c..3b861b1 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -97,6 +97,9 @@ export const UserMenu: React.FC = () => { const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false); const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] = useState(false); + const [bufferStrategy, setBufferStrategy] = useState('medium'); + const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true); + const [isBufferStrategyDropdownOpen, setIsBufferStrategyDropdownOpen] = useState(false); // 豆瓣数据源选项 const doubanDataSourceOptions = [ @@ -123,6 +126,14 @@ export const UserMenu: React.FC = () => { { value: 'custom', label: '自定义代理' }, ]; + // 缓冲策略选项 + const bufferStrategyOptions = [ + { value: 'low', label: '低缓冲(省流量)' }, + { value: 'medium', label: '中缓冲(推荐)' }, + { value: 'high', label: '高缓冲(流畅播放)' }, + { value: 'ultra', label: '超高缓冲(极速体验)' }, + ]; + // 修改密码相关状态 const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); @@ -315,6 +326,16 @@ export const UserMenu: React.FC = () => { if (savedTmdbBackdropDisabled !== null) { setTmdbBackdropDisabled(savedTmdbBackdropDisabled === 'true'); } + + const savedBufferStrategy = localStorage.getItem('bufferStrategy'); + if (savedBufferStrategy !== null) { + setBufferStrategy(savedBufferStrategy); + } + + const savedNextEpisodePreCache = localStorage.getItem('nextEpisodePreCache'); + if (savedNextEpisodePreCache !== null) { + setNextEpisodePreCache(savedNextEpisodePreCache === 'true'); + } } }, []); @@ -353,6 +374,23 @@ export const UserMenu: React.FC = () => { } }, [isDoubanImageProxyDropdownOpen]); + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (isBufferStrategyDropdownOpen) { + const target = event.target as Element; + if (!target.closest('[data-dropdown="buffer-strategy"]')) { + setIsBufferStrategyDropdownOpen(false); + } + } + }; + + if (isBufferStrategyDropdownOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => + document.removeEventListener('mousedown', handleClickOutside); + } + }, [isBufferStrategyDropdownOpen]); + const handleMenuClick = () => { setIsOpen(!isOpen); }; @@ -547,6 +585,20 @@ export const UserMenu: React.FC = () => { } }; + const handleBufferStrategyChange = (value: string) => { + setBufferStrategy(value); + if (typeof window !== 'undefined') { + localStorage.setItem('bufferStrategy', value); + } + }; + + const handleNextEpisodePreCacheToggle = (value: boolean) => { + setNextEpisodePreCache(value); + if (typeof window !== 'undefined') { + localStorage.setItem('nextEpisodePreCache', String(value)); + } + }; + // 获取感谢信息 const getThanksInfo = (dataSource: string) => { switch (dataSource) { @@ -587,6 +639,8 @@ export const UserMenu: React.FC = () => { setDoubanDataSource(defaultDoubanProxyType); setDoubanImageProxyType(defaultDoubanImageProxyType); setDoubanImageProxyUrl(defaultDoubanImageProxyUrl); + setBufferStrategy('medium'); + setNextEpisodePreCache(true); if (typeof window !== 'undefined') { localStorage.setItem('defaultAggregateSearch', JSON.stringify(true)); @@ -598,6 +652,8 @@ export const UserMenu: React.FC = () => { localStorage.setItem('doubanDataSource', defaultDoubanProxyType); localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType); localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl); + localStorage.setItem('bufferStrategy', 'medium'); + localStorage.setItem('nextEpisodePreCache', 'true'); } }; @@ -1244,6 +1300,104 @@ export const UserMenu: React.FC = () => { {/* 分割线 */}
+ {/* 缓冲设置 */} +
+
+

+ 缓冲设置 +

+

+ 调整播放器缓冲策略(仅在播放页面生效) +

+
+ + {/* 缓冲策略 */} +
+
+

+ 缓冲策略 +

+

+ 设置视频缓冲块大小,影响播放流畅度和流量消耗 +

+
+
+ {/* 自定义下拉选择框 */} + + + {/* 下拉箭头 */} +
+ +
+ + {/* 下拉选项列表 */} + {isBufferStrategyDropdownOpen && ( +
+ {bufferStrategyOptions.map((option) => ( + + ))} +
+ )} +
+
+ + {/* 下集预缓冲 */} +
+
+

+ 下集预缓冲 +

+

+ 播放进度达到90%时,自动预缓冲下一集内容 +

+
+ +
+
+ + {/* 分割线 */} +
+ {/* 清除弹幕缓存 */}