增加预缓冲和缓冲策略
This commit is contained in:
@@ -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<number | null>(null);
|
||||
|
||||
@@ -742,6 +756,10 @@ function PlayPageClient() {
|
||||
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSaveTimeRef = useRef<number>(0);
|
||||
|
||||
// 下集预缓存相关
|
||||
const nextEpisodePreCacheTriggeredRef = useRef<boolean>(false);
|
||||
const nextEpisodePreCacheHlsRef = useRef<any>(null);
|
||||
|
||||
const artPlayerRef = useRef<any>(null);
|
||||
const artRef = useRef<HTMLDivElement | null>(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', () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
{/* 分割线 */}
|
||||
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||
|
||||
{/* 缓冲设置 */}
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<h4 className='text-base font-semibold text-gray-800 dark:text-gray-200'>
|
||||
缓冲设置
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
调整播放器缓冲策略(仅在播放页面生效)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 缓冲策略 */}
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
缓冲策略
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
设置视频缓冲块大小,影响播放流畅度和流量消耗
|
||||
</p>
|
||||
</div>
|
||||
<div className='relative' data-dropdown='buffer-strategy'>
|
||||
{/* 自定义下拉选择框 */}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setIsBufferStrategyDropdownOpen(!isBufferStrategyDropdownOpen)}
|
||||
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
|
||||
>
|
||||
{
|
||||
bufferStrategyOptions.find(
|
||||
(option) => option.value === bufferStrategy
|
||||
)?.label
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* 下拉箭头 */}
|
||||
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isBufferStrategyDropdownOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 下拉选项列表 */}
|
||||
{isBufferStrategyDropdownOpen && (
|
||||
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
|
||||
{bufferStrategyOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => {
|
||||
handleBufferStrategyChange(option.value);
|
||||
setIsBufferStrategyDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${bufferStrategy === option.value
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className='truncate'>{option.label}</span>
|
||||
{bufferStrategy === option.value && (
|
||||
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下集预缓冲 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
下集预缓冲
|
||||
</h4>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
播放进度达到90%时,自动预缓冲下一集内容
|
||||
</p>
|
||||
</div>
|
||||
<label className='flex items-center cursor-pointer'>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={nextEpisodePreCache}
|
||||
onChange={(e) => handleNextEpisodePreCacheToggle(e.target.checked)}
|
||||
/>
|
||||
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||
|
||||
{/* 清除弹幕缓存 */}
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user