Merge branch 'pr-265' into dev
This commit is contained in:
+561
-81
@@ -4,7 +4,7 @@
|
||||
|
||||
import { AlertCircle, Cloud, Heart, Loader2, Router, Sparkles, X } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
import {
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
import type { DanmakuAnime, DanmakuComment, DanmakuSelection, DanmakuSettings } from '@/lib/danmaku/types';
|
||||
import {
|
||||
deleteFavorite,
|
||||
deletePlayRecord,
|
||||
deleteSkipConfig,
|
||||
generateStorageKey,
|
||||
getAllPlayRecords,
|
||||
@@ -41,11 +40,18 @@ import {
|
||||
getEpisodeFilterConfig,
|
||||
getSkipConfig,
|
||||
isFavorited,
|
||||
migratePlayRecord,
|
||||
saveFavorite,
|
||||
savePlayRecord,
|
||||
saveSkipConfig,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
import {
|
||||
buildEpisodeProgressContentKey,
|
||||
loadLocalEpisodeProgress,
|
||||
pruneLocalEpisodeProgressStorage,
|
||||
saveLocalEpisodeProgress,
|
||||
} from '@/lib/episode-progress';
|
||||
import { getDoubanDetail } from '@/lib/douban.client';
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||
import {
|
||||
@@ -642,6 +648,30 @@ function PlayPageClient() {
|
||||
// 搜索所需信息
|
||||
const [searchTitle] = useState(searchParams.get('stitle') || '');
|
||||
const [searchType] = useState(searchParams.get('stype') || '');
|
||||
const [initialEpisodeProgressTitle] = useState(
|
||||
searchTitle || searchParams.get('title') || ''
|
||||
);
|
||||
const [initialEpisodeProgressYear] = useState(
|
||||
searchParams.get('year') || ''
|
||||
);
|
||||
const episodeProgressContentKey = useMemo(
|
||||
() =>
|
||||
buildEpisodeProgressContentKey({
|
||||
doubanId: videoDoubanId || detail?.douban_id,
|
||||
tmdbId: detail?.tmdb_id,
|
||||
title: initialEpisodeProgressTitle,
|
||||
year: initialEpisodeProgressYear,
|
||||
searchType,
|
||||
}),
|
||||
[
|
||||
detail?.douban_id,
|
||||
detail?.tmdb_id,
|
||||
initialEpisodeProgressTitle,
|
||||
initialEpisodeProgressYear,
|
||||
searchType,
|
||||
videoDoubanId,
|
||||
]
|
||||
);
|
||||
|
||||
// 是否需要优选
|
||||
const [needPrefer, setNeedPrefer] = useState(
|
||||
@@ -687,10 +717,9 @@ function PlayPageClient() {
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是首次检查,标记为已关闭,不再显示跳转按钮
|
||||
if (!playRecordJumpInitialCheckRef.current) {
|
||||
playRecordJumpDismissedRef.current = true;
|
||||
}
|
||||
// 切到新的一集后,重新允许检查该集是否存在播放记录。
|
||||
playRecordJumpInitialCheckRef.current = true;
|
||||
playRecordJumpDismissedRef.current = false;
|
||||
}, [currentEpisodeIndex]);
|
||||
|
||||
// 监听 URL 参数变化,当切换到不同视频时重新加载页面
|
||||
@@ -764,10 +793,11 @@ function PlayPageClient() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否禁用了自动装填弹幕
|
||||
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
||||
if (disableAutoLoad) {
|
||||
console.log('[弹幕] 已禁用自动装填弹幕,跳过自动加载');
|
||||
// 检查是否禁用了自动加载弹幕
|
||||
if (isDanmakuAutoLoadDisabled()) {
|
||||
console.log('[弹幕] 已禁用自动加载弹幕,跳过自动加载');
|
||||
setShowDanmakuSourceSelector(false);
|
||||
setDanmakuLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1511,6 +1541,37 @@ function PlayPageClient() {
|
||||
? directEpisodeLabel
|
||||
: `第${currentEpisodeIndex + 1}集`;
|
||||
|
||||
const loadSavedPlaybackRate = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem('preferredPlaybackRate');
|
||||
const parsed = raw ? Number(raw) : 1;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1.0;
|
||||
};
|
||||
|
||||
const persistPlaybackRate = (rate: number) => {
|
||||
if (typeof window === 'undefined' || !Number.isFinite(rate) || rate <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('preferredPlaybackRate', String(rate));
|
||||
};
|
||||
|
||||
const isDanmakuAutoLoadDisabled = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const saved = localStorage.getItem('disableAutoLoadDanmaku');
|
||||
if (saved !== null) {
|
||||
return saved === 'true';
|
||||
}
|
||||
|
||||
return (window as any).RUNTIME_CONFIG?.DANMAKU_AUTO_LOAD_DEFAULT === false;
|
||||
};
|
||||
|
||||
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
||||
const resumeTimeRef = useRef<number | null>(null);
|
||||
// 播放记录跳转按钮状态
|
||||
@@ -1520,7 +1581,9 @@ function PlayPageClient() {
|
||||
// 上次使用的音量,默认 0.7
|
||||
const lastVolumeRef = useRef<number>(0.7);
|
||||
// 上次使用的播放速率,默认 1.0
|
||||
const lastPlaybackRateRef = useRef<number>(1.0);
|
||||
const lastPlaybackRateRef = useRef<number>(loadSavedPlaybackRate());
|
||||
// Safari 切集时会短暂把 playbackRate 重置为 1,这里保留一段恢复窗口避免污染记忆值
|
||||
const playbackRateRestoreWindowUntilRef = useRef<number>(0);
|
||||
|
||||
// 换源相关状态
|
||||
const [availableSources, setAvailableSources] = useState<SearchResult[]>([]);
|
||||
@@ -1536,6 +1599,14 @@ function PlayPageClient() {
|
||||
const fallbackRecommendationsDragStartXRef = useRef(0);
|
||||
const fallbackRecommendationsDragStartScrollLeftRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
pruneLocalEpisodeProgressStorage();
|
||||
} catch (error) {
|
||||
console.warn('[Play] Failed to prune local episode progress:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 优选和测速开关
|
||||
const [optimizationEnabled] = useState<boolean>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -2691,13 +2762,25 @@ function PlayPageClient() {
|
||||
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,保持唯一
|
||||
const isHlsJsActive = !!(video as any).hls;
|
||||
const isHlsLikeSource =
|
||||
/\.m3u8?($|\?)/i.test(url) ||
|
||||
url.includes('/api/proxy-m3u8') ||
|
||||
url.includes('/api/proxy/vod/m3u8');
|
||||
|
||||
if (isHlsJsActive && isHlsLikeSource) {
|
||||
// HLS 由 hls.js 接管时,不能再给 <video> 塞原始 m3u8 source,
|
||||
// 否则 Safari 可能切回原生 HLS,和 MSE/hls.js 抢同一个播放器。
|
||||
sources.forEach((s) => s.remove());
|
||||
const sourceEl = document.createElement('source');
|
||||
sourceEl.src = url;
|
||||
video.appendChild(sourceEl);
|
||||
} else {
|
||||
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)
|
||||
@@ -3966,6 +4049,13 @@ function PlayPageClient() {
|
||||
|
||||
// 加载播放记录
|
||||
try {
|
||||
const detailEpisodeProgressContentKey = buildEpisodeProgressContentKey({
|
||||
doubanId: detailData.douban_id,
|
||||
tmdbId: detailData.tmdb_id,
|
||||
title: initialEpisodeProgressTitle,
|
||||
year: initialEpisodeProgressYear,
|
||||
searchType,
|
||||
});
|
||||
const allRecords = await getAllPlayRecords();
|
||||
const key = generateStorageKey(detailData.source, detailData.id);
|
||||
const record = allRecords[key];
|
||||
@@ -3990,6 +4080,11 @@ function PlayPageClient() {
|
||||
} else {
|
||||
// 否则使用点击的文件集数,从头开始播放
|
||||
initialIndex = detailData.initialEpisodeIndex;
|
||||
const localEpisodeTime = loadLocalEpisodeProgress(
|
||||
detailEpisodeProgressContentKey,
|
||||
initialIndex
|
||||
);
|
||||
resumeTimeRef.current = localEpisodeTime;
|
||||
console.log('[Play] 使用点击的文件集数:', initialIndex);
|
||||
}
|
||||
} else {
|
||||
@@ -4004,10 +4099,18 @@ function PlayPageClient() {
|
||||
if (detailData.initialEpisodeIndex !== undefined) {
|
||||
// 使用点击的文件集数
|
||||
initialIndex = detailData.initialEpisodeIndex;
|
||||
resumeTimeRef.current = loadLocalEpisodeProgress(
|
||||
detailEpisodeProgressContentKey,
|
||||
initialIndex
|
||||
);
|
||||
console.log('[Play] 没有播放记录,使用点击的文件集数:', initialIndex);
|
||||
} else {
|
||||
// 默认从第0集开始
|
||||
initialIndex = 0;
|
||||
resumeTimeRef.current = loadLocalEpisodeProgress(
|
||||
detailEpisodeProgressContentKey,
|
||||
initialIndex
|
||||
);
|
||||
console.log('[Play] 没有播放记录,从第0集开始');
|
||||
}
|
||||
}
|
||||
@@ -4171,6 +4274,41 @@ function PlayPageClient() {
|
||||
}
|
||||
}, [detail, currentEpisodeIndex]);
|
||||
|
||||
const getSourceSwitchResumeTime = async (
|
||||
episodeIndex: number,
|
||||
currentPlayTime: number
|
||||
): Promise<number | null> => {
|
||||
if (currentPlayTime > 1) {
|
||||
return currentPlayTime;
|
||||
}
|
||||
|
||||
if (!currentSourceRef.current || !currentIdRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const allRecords = await getAllPlayRecords();
|
||||
const currentRecord = allRecords[
|
||||
generateStorageKey(currentSourceRef.current, currentIdRef.current)
|
||||
];
|
||||
|
||||
if (
|
||||
currentRecord &&
|
||||
currentRecord.index - 1 === episodeIndex &&
|
||||
currentRecord.play_time > 1
|
||||
) {
|
||||
return currentRecord.play_time;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Play] Failed to read source-switch play record:', error);
|
||||
}
|
||||
|
||||
return loadLocalEpisodeProgress(
|
||||
episodeProgressContentKey,
|
||||
episodeIndex
|
||||
);
|
||||
};
|
||||
|
||||
// 处理换源
|
||||
const handleSourceChange = async (
|
||||
newSource: string,
|
||||
@@ -4192,19 +4330,6 @@ function PlayPageClient() {
|
||||
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
|
||||
console.log('换源前当前播放时间:', currentPlayTime);
|
||||
|
||||
// 清除前一个历史记录
|
||||
if (currentSourceRef.current && currentIdRef.current) {
|
||||
try {
|
||||
await deletePlayRecord(
|
||||
currentSourceRef.current,
|
||||
currentIdRef.current
|
||||
);
|
||||
console.log('已清除前一个播放记录');
|
||||
} catch (err) {
|
||||
console.error('清除播放记录失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除并设置下一个跳过片头片尾配置
|
||||
if (currentSourceRef.current && currentIdRef.current) {
|
||||
try {
|
||||
@@ -4253,8 +4378,19 @@ function PlayPageClient() {
|
||||
return;
|
||||
}
|
||||
|
||||
const newEpisodeProgressContentKey = buildEpisodeProgressContentKey({
|
||||
doubanId: newDetail.douban_id,
|
||||
tmdbId: newDetail.tmdb_id,
|
||||
title: initialEpisodeProgressTitle,
|
||||
year: initialEpisodeProgressYear,
|
||||
searchType,
|
||||
});
|
||||
|
||||
// 尝试跳转到当前正在播放的集数
|
||||
let targetIndex = currentEpisodeIndex;
|
||||
const previousEpisodeIndex = currentEpisodeIndexRef.current;
|
||||
const previousSource = currentSourceRef.current;
|
||||
const previousId = currentIdRef.current;
|
||||
let targetIndex = previousEpisodeIndex;
|
||||
|
||||
// 如果新源的集数跟旧源的集数不一致,清除当前剧集的所有弹幕缓存
|
||||
const oldEpisodeCount = detail?.episodes?.length || 0;
|
||||
@@ -4272,15 +4408,14 @@ function PlayPageClient() {
|
||||
targetIndex = 0;
|
||||
}
|
||||
|
||||
// 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度
|
||||
if (targetIndex !== currentEpisodeIndex) {
|
||||
resumeTimeRef.current = 0;
|
||||
} else if (
|
||||
(!resumeTimeRef.current || resumeTimeRef.current === 0) &&
|
||||
currentPlayTime > 1
|
||||
) {
|
||||
resumeTimeRef.current = currentPlayTime;
|
||||
}
|
||||
const isSameEpisodeSwitch = targetIndex === previousEpisodeIndex;
|
||||
const resumeTime = isSameEpisodeSwitch
|
||||
? await getSourceSwitchResumeTime(previousEpisodeIndex, currentPlayTime)
|
||||
: loadLocalEpisodeProgress(
|
||||
newEpisodeProgressContentKey,
|
||||
targetIndex
|
||||
);
|
||||
resumeTimeRef.current = resumeTime;
|
||||
|
||||
// 更新URL参数(不刷新页面)
|
||||
const newUrl = new URL(window.location.href);
|
||||
@@ -4318,6 +4453,46 @@ function PlayPageClient() {
|
||||
setVideoCover(finalCover);
|
||||
setCorrectedDesc(finalDesc);
|
||||
setVideoDoubanId(newDetail.douban_id || 0);
|
||||
|
||||
if (isSameEpisodeSwitch && resumeTime && resumeTime > 1) {
|
||||
const currentDuration = artPlayerRef.current?.duration || 0;
|
||||
saveLocalEpisodeProgress(
|
||||
newEpisodeProgressContentKey,
|
||||
targetIndex,
|
||||
resumeTime,
|
||||
currentDuration
|
||||
);
|
||||
|
||||
try {
|
||||
const migratedRecord = {
|
||||
title: finalTitle,
|
||||
source_name: newDetail.source_name || '',
|
||||
year: newDetail.year || '',
|
||||
cover: finalCover || '',
|
||||
index: targetIndex + 1,
|
||||
total_episodes: newDetail.episodes?.length || 1,
|
||||
play_time: Math.floor(resumeTime),
|
||||
total_time: Math.floor(currentDuration),
|
||||
save_time: Date.now(),
|
||||
search_title: searchTitle,
|
||||
};
|
||||
|
||||
if (previousSource && previousId) {
|
||||
await migratePlayRecord(
|
||||
previousSource,
|
||||
previousId,
|
||||
newSource,
|
||||
newId,
|
||||
migratedRecord
|
||||
);
|
||||
} else {
|
||||
await savePlayRecord(newSource, newId, migratedRecord);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Play] Failed to migrate source-switch play record:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// newSource 已经是完整格式
|
||||
setCurrentSource(newSource);
|
||||
setCurrentId(newId);
|
||||
@@ -4341,31 +4516,77 @@ function PlayPageClient() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 集数切换
|
||||
// ---------------------------------------------------------------------------
|
||||
// 处理集数切换
|
||||
const handleEpisodeChange = (episodeNumber: number) => {
|
||||
if (episodeNumber >= 0 && episodeNumber < totalEpisodes) {
|
||||
// 在更换集数前保存当前播放进度
|
||||
if (artPlayerRef.current && artPlayerRef.current.paused) {
|
||||
saveCurrentPlayProgress();
|
||||
const primeEpisodeResumeState = async (targetEpisodeIndex: number) => {
|
||||
if (!currentSourceRef.current || !currentIdRef.current) {
|
||||
resumeTimeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allRecords = await getAllPlayRecords();
|
||||
const key = generateStorageKey(currentSourceRef.current, currentIdRef.current);
|
||||
const record = allRecords[key];
|
||||
|
||||
if (record && record.index - 1 === targetEpisodeIndex && record.play_time > 1) {
|
||||
resumeTimeRef.current = record.play_time;
|
||||
} else {
|
||||
resumeTimeRef.current = loadLocalEpisodeProgress(
|
||||
episodeProgressContentKey,
|
||||
targetEpisodeIndex
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Play] Failed to prime episode resume state:', error);
|
||||
if (currentSourceRef.current && currentIdRef.current) {
|
||||
resumeTimeRef.current = loadLocalEpisodeProgress(
|
||||
episodeProgressContentKey,
|
||||
targetEpisodeIndex
|
||||
);
|
||||
} else {
|
||||
resumeTimeRef.current = null;
|
||||
}
|
||||
setVideoLoadingStage('episodeChanging');
|
||||
setIsVideoLoading(true);
|
||||
setVideoError(null);
|
||||
setCurrentEpisodeIndex(episodeNumber);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousEpisode = () => {
|
||||
const prepareEpisodeSwitch = async () => {
|
||||
if (artPlayerRef.current) {
|
||||
lastPlaybackRateRef.current =
|
||||
artPlayerRef.current.playbackRate || lastPlaybackRateRef.current;
|
||||
lastVolumeRef.current =
|
||||
artPlayerRef.current.volume || lastVolumeRef.current;
|
||||
playbackRateRestoreWindowUntilRef.current = Date.now() + 8000;
|
||||
|
||||
await saveCurrentPlayProgress();
|
||||
}
|
||||
|
||||
setVideoLoadingStage('episodeChanging');
|
||||
setIsVideoLoading(true);
|
||||
setVideoError(null);
|
||||
};
|
||||
|
||||
// 处理集数切换
|
||||
const handleEpisodeChange = async (episodeNumber: number) => {
|
||||
if (episodeNumber < 0 || episodeNumber >= totalEpisodes) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (episodeNumber === currentEpisodeIndexRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prepareEpisodeSwitch();
|
||||
await primeEpisodeResumeState(episodeNumber);
|
||||
setCurrentEpisodeIndex(episodeNumber);
|
||||
};
|
||||
|
||||
const handlePreviousEpisode = async () => {
|
||||
const d = detailRef.current;
|
||||
const idx = currentEpisodeIndexRef.current;
|
||||
if (d && d.episodes && idx > 0) {
|
||||
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
||||
saveCurrentPlayProgress();
|
||||
}
|
||||
setVideoLoadingStage('episodeChanging');
|
||||
setIsVideoLoading(true);
|
||||
setVideoError(null);
|
||||
setCurrentEpisodeIndex(idx - 1);
|
||||
await prepareEpisodeSwitch();
|
||||
const targetIndex = idx - 1;
|
||||
await primeEpisodeResumeState(targetIndex);
|
||||
setCurrentEpisodeIndex(targetIndex);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4393,7 +4614,7 @@ function PlayPageClient() {
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleNextEpisode = () => {
|
||||
const handleNextEpisode = async () => {
|
||||
const d = detailRef.current;
|
||||
const idx = currentEpisodeIndexRef.current;
|
||||
|
||||
@@ -4401,11 +4622,6 @@ function PlayPageClient() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存当前进度
|
||||
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
||||
saveCurrentPlayProgress();
|
||||
}
|
||||
|
||||
// 查找下一个未被过滤的集数
|
||||
let nextIdx = idx + 1;
|
||||
while (nextIdx < d.episodes.length) {
|
||||
@@ -4413,9 +4629,8 @@ function PlayPageClient() {
|
||||
const isFiltered = episodeTitle && isEpisodeFilteredByTitle(episodeTitle);
|
||||
|
||||
if (!isFiltered) {
|
||||
setVideoLoadingStage('episodeChanging');
|
||||
setIsVideoLoading(true);
|
||||
setVideoError(null);
|
||||
await prepareEpisodeSwitch();
|
||||
await primeEpisodeResumeState(nextIdx);
|
||||
setCurrentEpisodeIndex(nextIdx);
|
||||
return;
|
||||
}
|
||||
@@ -4704,8 +4919,7 @@ function PlayPageClient() {
|
||||
const preloadNextEpisodeDanmaku = async () => {
|
||||
try {
|
||||
if (isDirectPlay) return;
|
||||
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
||||
if (disableAutoLoad) return;
|
||||
if (isDanmakuAutoLoadDisabled()) return;
|
||||
|
||||
const title = videoTitleRef.current;
|
||||
if (!title) {
|
||||
@@ -5053,7 +5267,7 @@ function PlayPageClient() {
|
||||
// 自动搜索并加载弹幕
|
||||
const autoSearchDanmaku = async () => {
|
||||
if (isDirectPlay) return;
|
||||
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
||||
const disableAutoLoad = isDanmakuAutoLoadDisabled();
|
||||
if (disableAutoLoad) return;
|
||||
|
||||
const title = videoTitleRef.current;
|
||||
@@ -5383,6 +5597,13 @@ function PlayPageClient() {
|
||||
}
|
||||
|
||||
try {
|
||||
saveLocalEpisodeProgress(
|
||||
episodeProgressContentKey,
|
||||
currentEpisodeIndexRef.current,
|
||||
currentTime,
|
||||
duration
|
||||
);
|
||||
|
||||
await savePlayRecord(currentSourceRef.current, currentIdRef.current, {
|
||||
title: videoTitleRef.current,
|
||||
source_name: detailRef.current?.source_name || '',
|
||||
@@ -5692,12 +5913,135 @@ function PlayPageClient() {
|
||||
const Artplayer = ArtplayerModule.default;
|
||||
const Hls = HlsModule.default;
|
||||
const artplayerPluginDanmuku = DanmukuPlugin.default as any;
|
||||
const playerTimeouts = new Set<number>();
|
||||
const clearTrackedTimeout = (timeoutId: number | null) => {
|
||||
if (timeoutId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(timeoutId);
|
||||
playerTimeouts.delete(timeoutId);
|
||||
};
|
||||
const schedulePlayerTimeout = (callback: () => void, delay: number) => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
playerTimeouts.delete(timeoutId);
|
||||
callback();
|
||||
}, delay);
|
||||
playerTimeouts.add(timeoutId);
|
||||
return timeoutId;
|
||||
};
|
||||
const clearPlayerTimeouts = () => {
|
||||
playerTimeouts.forEach((timeoutId) => {
|
||||
window.clearTimeout(timeoutId);
|
||||
});
|
||||
playerTimeouts.clear();
|
||||
};
|
||||
|
||||
const syncPlaybackPitch = () => {
|
||||
if (!isWebkit || !artPlayerRef.current?.video) {
|
||||
return;
|
||||
}
|
||||
|
||||
const video = artPlayerRef.current.video as HTMLVideoElement & {
|
||||
webkitPreservesPitch?: boolean;
|
||||
};
|
||||
const shouldPreservePitch = true;
|
||||
|
||||
if ('preservesPitch' in video) {
|
||||
video.preservesPitch = shouldPreservePitch;
|
||||
}
|
||||
if ('webkitPreservesPitch' in video) {
|
||||
video.webkitPreservesPitch = shouldPreservePitch;
|
||||
}
|
||||
};
|
||||
|
||||
const shouldRescueWebkitHls = (
|
||||
video: HTMLVideoElement & {
|
||||
hls?: {
|
||||
detachMedia?: () => void;
|
||||
attachMedia?: (video: HTMLVideoElement) => void;
|
||||
startLoad?: (startPosition?: number) => void;
|
||||
bufferController?: {
|
||||
mediaSource?: {
|
||||
readyState?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
) => {
|
||||
const hls = video.hls;
|
||||
if (!hls) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hasBufferedData = false;
|
||||
try {
|
||||
hasBufferedData = video.buffered.length > 0;
|
||||
} catch {
|
||||
hasBufferedData = false;
|
||||
}
|
||||
|
||||
if (video.readyState > 0 || hasBufferedData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentSrc = video.currentSrc || video.src || '';
|
||||
const mediaSourceState = hls.bufferController?.mediaSource?.readyState || '';
|
||||
const usingBlobMsePath = currentSrc.startsWith('blob:') && mediaSourceState !== 'closed';
|
||||
|
||||
return !usingBlobMsePath;
|
||||
};
|
||||
|
||||
const rescueWebkitHlsBootstrap = (
|
||||
reason: string,
|
||||
retryDelays: number[] = [1500, 3500, 6000]
|
||||
) => {
|
||||
if (!isWebkit || !artPlayerRef.current?.video) {
|
||||
return;
|
||||
}
|
||||
|
||||
const video = artPlayerRef.current.video as HTMLVideoElement & {
|
||||
hls?: {
|
||||
detachMedia?: () => void;
|
||||
attachMedia?: (video: HTMLVideoElement) => void;
|
||||
startLoad?: (startPosition?: number) => void;
|
||||
};
|
||||
};
|
||||
|
||||
retryDelays.forEach((delay) => {
|
||||
schedulePlayerTimeout(() => {
|
||||
if (!artPlayerRef.current || artPlayerRef.current.video !== video) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hls = video.hls;
|
||||
if (!shouldRescueWebkitHls(video)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[HLS] Safari bootstrap rescue triggered (${reason}, ${delay}ms)`
|
||||
);
|
||||
|
||||
try {
|
||||
hls.detachMedia?.();
|
||||
hls.attachMedia?.(video);
|
||||
hls.startLoad?.(-1);
|
||||
video.play().catch((error) => {
|
||||
console.warn('[HLS] Safari rescue play failed:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[HLS] Safari bootstrap rescue failed:', error);
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
};
|
||||
|
||||
// 创建自定义 HLS loader
|
||||
const CustomHlsJsLoader = createCustomHlsLoader(Hls);
|
||||
|
||||
// 创建新的播放器实例
|
||||
Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||||
Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];
|
||||
Artplayer.USE_RAF = true;
|
||||
|
||||
// 获取当前集的字幕
|
||||
@@ -5830,7 +6174,9 @@ function PlayPageClient() {
|
||||
const hls = new Hls({
|
||||
debug: false, // 关闭日志
|
||||
enableWorker: true, // WebWorker 解码,降低主线程压力
|
||||
lowLatencyMode: true, // 开启低延迟 LL-HLS
|
||||
// 点播播放不需要 LL-HLS,小缓冲在 Safari 高倍速下更容易抖动。
|
||||
lowLatencyMode: false,
|
||||
autoStartLoad: true,
|
||||
|
||||
/* 缓冲/内存相关 - 根据用户设置的缓冲策略动态调整 */
|
||||
maxBufferLength: bufferConfig.maxBufferLength, // 前向缓冲长度
|
||||
@@ -5841,10 +6187,45 @@ function PlayPageClient() {
|
||||
loader: loaderClass as any,
|
||||
});
|
||||
|
||||
const kickStartHlsPlayback = () => {
|
||||
try {
|
||||
hls.startLoad(-1);
|
||||
} catch (error) {
|
||||
console.warn('[HLS] startLoad failed:', error);
|
||||
}
|
||||
|
||||
if (!video.paused) {
|
||||
video.play().catch((error) => {
|
||||
console.warn('[HLS] play after attach failed:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
kickStartHlsPlayback();
|
||||
});
|
||||
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
video.hls = hls;
|
||||
|
||||
if (isWebkit) {
|
||||
schedulePlayerTimeout(() => {
|
||||
if (!shouldRescueWebkitHls(video)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('[HLS] Safari attach watchdog triggered, forcing reattach');
|
||||
try {
|
||||
hls.detachMedia();
|
||||
hls.attachMedia(video);
|
||||
kickStartHlsPlayback();
|
||||
} catch (error) {
|
||||
console.warn('[HLS] Safari attach reattach failed:', error);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
ensureVideoSource(video, url);
|
||||
|
||||
// 额外确保 iOS 内联播放属性(防止全屏时使用系统播放器)
|
||||
@@ -5857,6 +6238,17 @@ function PlayPageClient() {
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[HLS] Manifest解析完成');
|
||||
|
||||
const player = artPlayerRef.current;
|
||||
if (video.paused && (player?.option.autoplay || player?.loading)) {
|
||||
try {
|
||||
Promise.resolve(player?.play?.()).catch((error) => {
|
||||
console.warn('[HLS] play after manifest parsed failed:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[HLS] play after manifest parsed failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 只在首次加载时启动定时器(后续刷新会在refreshXiaoyaUrl中启动)
|
||||
if (isInitialLoadRef.current && currentXiaoyaUrlRef.current && url.includes('.m3u8')) {
|
||||
isInitialLoadRef.current = false; // 标记已完成首次加载
|
||||
@@ -6693,10 +7085,16 @@ function PlayPageClient() {
|
||||
],
|
||||
});
|
||||
|
||||
artPlayerRef.current.on('destroy', () => {
|
||||
clearPlayerTimeouts();
|
||||
});
|
||||
|
||||
// 监听播放器事件
|
||||
artPlayerRef.current.on('ready', async () => {
|
||||
setError(null);
|
||||
|
||||
rescueWebkitHlsBootstrap('player-ready');
|
||||
|
||||
// 标记播放器已就绪,触发 usePlaySync 设置事件监听器
|
||||
setPlayerReady(true);
|
||||
console.log('[PlayPage] Player ready, triggering sync setup');
|
||||
@@ -7031,7 +7429,43 @@ function PlayPageClient() {
|
||||
lastVolumeRef.current = artPlayerRef.current.volume;
|
||||
});
|
||||
artPlayerRef.current.on('video:ratechange', () => {
|
||||
lastPlaybackRateRef.current = artPlayerRef.current.playbackRate;
|
||||
const currentRate = artPlayerRef.current.playbackRate;
|
||||
const shouldIgnoreSafariReset =
|
||||
isWebkit &&
|
||||
Date.now() < playbackRateRestoreWindowUntilRef.current &&
|
||||
Math.abs(currentRate - 1) < 0.01 &&
|
||||
lastPlaybackRateRef.current > 1;
|
||||
|
||||
if (shouldIgnoreSafariReset) {
|
||||
// Safari 切集后可能偷偷回到 1x,这不是用户真实选择,不要覆盖记忆值。
|
||||
schedulePlayerTimeout(() => {
|
||||
if (
|
||||
artPlayerRef.current &&
|
||||
Math.abs(
|
||||
artPlayerRef.current.playbackRate - lastPlaybackRateRef.current
|
||||
) > 0.01
|
||||
) {
|
||||
artPlayerRef.current.playbackRate = lastPlaybackRateRef.current;
|
||||
}
|
||||
}, 0);
|
||||
syncPlaybackPitch();
|
||||
return;
|
||||
}
|
||||
|
||||
lastPlaybackRateRef.current = currentRate;
|
||||
persistPlaybackRate(currentRate);
|
||||
syncPlaybackPitch();
|
||||
});
|
||||
artPlayerRef.current.on('video:playing', () => {
|
||||
if (
|
||||
isWebkit &&
|
||||
Date.now() < playbackRateRestoreWindowUntilRef.current &&
|
||||
Math.abs(
|
||||
artPlayerRef.current.playbackRate - lastPlaybackRateRef.current
|
||||
) > 0.01
|
||||
) {
|
||||
artPlayerRef.current.playbackRate = lastPlaybackRateRef.current;
|
||||
}
|
||||
});
|
||||
|
||||
// 监听网页全屏事件,控制导航栏显示隐藏
|
||||
@@ -7525,6 +7959,8 @@ function PlayPageClient() {
|
||||
|
||||
// 监听视频可播放事件,这时恢复播放进度更可靠
|
||||
artPlayerRef.current.on('video:canplay', () => {
|
||||
let restoredResumeTime = false;
|
||||
|
||||
// 若存在需要恢复的播放进度,则跳转
|
||||
if (resumeTimeRef.current && resumeTimeRef.current > 0) {
|
||||
try {
|
||||
@@ -7534,6 +7970,7 @@ function PlayPageClient() {
|
||||
target = Math.max(0, duration - 5);
|
||||
}
|
||||
artPlayerRef.current.currentTime = target;
|
||||
restoredResumeTime = true;
|
||||
console.log('成功恢复播放进度到:', resumeTimeRef.current);
|
||||
} catch (err) {
|
||||
console.warn('恢复播放进度失败:', err);
|
||||
@@ -7541,20 +7978,62 @@ function PlayPageClient() {
|
||||
}
|
||||
resumeTimeRef.current = null;
|
||||
|
||||
setTimeout(() => {
|
||||
schedulePlayerTimeout(() => {
|
||||
if (!artPlayerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const restorePlaybackRate = () => {
|
||||
if (!artPlayerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(
|
||||
artPlayerRef.current.playbackRate - lastPlaybackRateRef.current
|
||||
) > 0.01 &&
|
||||
isWebkit
|
||||
) {
|
||||
artPlayerRef.current.playbackRate = lastPlaybackRateRef.current;
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
Math.abs(artPlayerRef.current.volume - lastVolumeRef.current) > 0.01
|
||||
) {
|
||||
artPlayerRef.current.volume = lastVolumeRef.current;
|
||||
}
|
||||
if (
|
||||
Math.abs(
|
||||
artPlayerRef.current.playbackRate - lastPlaybackRateRef.current
|
||||
) > 0.01 &&
|
||||
isWebkit
|
||||
) {
|
||||
artPlayerRef.current.playbackRate = lastPlaybackRateRef.current;
|
||||
|
||||
// Safari 在 seek 刚发生时立刻恢复 3x,容易卡进持续 seeking 状态。
|
||||
// 这里等 seek 稳定后再恢复倍速,避免恢复进度和变速互相打架。
|
||||
if (restoredResumeTime && isWebkit && artPlayerRef.current?.video) {
|
||||
const video = artPlayerRef.current.video as HTMLVideoElement;
|
||||
const applyRateAfterSeek = () => {
|
||||
restorePlaybackRate();
|
||||
};
|
||||
|
||||
if (video.seeking) {
|
||||
const handleSeeked = () => {
|
||||
clearTrackedTimeout(seekedTimeout);
|
||||
applyRateAfterSeek();
|
||||
};
|
||||
const seekedTimeout = schedulePlayerTimeout(() => {
|
||||
video.removeEventListener('seeked', handleSeeked);
|
||||
applyRateAfterSeek();
|
||||
}, 300);
|
||||
|
||||
video.addEventListener(
|
||||
'seeked',
|
||||
handleSeeked,
|
||||
{ once: true }
|
||||
);
|
||||
} else {
|
||||
restorePlaybackRate();
|
||||
}
|
||||
} else {
|
||||
restorePlaybackRate();
|
||||
}
|
||||
syncPlaybackPitch();
|
||||
artPlayerRef.current.notice.show = '';
|
||||
}, 0);
|
||||
|
||||
@@ -8987,6 +9466,7 @@ function PlayPageClient() {
|
||||
isRoomMember={playSync.shouldDisableControls}
|
||||
currentSource={currentSource}
|
||||
currentId={currentId}
|
||||
episodeProgressContentKey={episodeProgressContentKey || undefined}
|
||||
videoTitle={searchTitle || videoTitle}
|
||||
availableSources={availableSources}
|
||||
sourceSearchLoading={sourceSearchLoading}
|
||||
|
||||
Reference in New Issue
Block a user