Fix Safari playback recovery and episode progress memory
This commit is contained in:
@@ -344,6 +344,7 @@ interface SiteConfig {
|
|||||||
DanmakuSourceType?: 'builtin' | 'custom';
|
DanmakuSourceType?: 'builtin' | 'custom';
|
||||||
DanmakuApiBase: string;
|
DanmakuApiBase: string;
|
||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
|
DanmakuAutoLoadDefault?: boolean;
|
||||||
TMDBApiKey?: string;
|
TMDBApiKey?: string;
|
||||||
TMDBProxy?: string;
|
TMDBProxy?: string;
|
||||||
TMDBReverseProxy?: string;
|
TMDBReverseProxy?: string;
|
||||||
@@ -7799,6 +7800,7 @@ const SiteConfigComponent = ({
|
|||||||
DanmakuSourceType: 'builtin',
|
DanmakuSourceType: 'builtin',
|
||||||
DanmakuApiBase: 'https://mtvpls-danmu.netlify.app/87654321',
|
DanmakuApiBase: 'https://mtvpls-danmu.netlify.app/87654321',
|
||||||
DanmakuApiToken: '87654321',
|
DanmakuApiToken: '87654321',
|
||||||
|
DanmakuAutoLoadDefault: true,
|
||||||
TMDBApiKey: '',
|
TMDBApiKey: '',
|
||||||
TMDBProxy: '',
|
TMDBProxy: '',
|
||||||
TMDBReverseProxy: '',
|
TMDBReverseProxy: '',
|
||||||
@@ -7896,6 +7898,7 @@ const SiteConfigComponent = ({
|
|||||||
DanmakuApiBase:
|
DanmakuApiBase:
|
||||||
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
|
config.SiteConfig.DanmakuApiBase || 'http://localhost:9321',
|
||||||
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
|
DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321',
|
||||||
|
DanmakuAutoLoadDefault: config.SiteConfig.DanmakuAutoLoadDefault !== false,
|
||||||
TMDBApiKey: config.SiteConfig.TMDBApiKey || '',
|
TMDBApiKey: config.SiteConfig.TMDBApiKey || '',
|
||||||
TMDBProxy: config.SiteConfig.TMDBProxy || '',
|
TMDBProxy: config.SiteConfig.TMDBProxy || '',
|
||||||
TMDBReverseProxy: config.SiteConfig.TMDBReverseProxy || '',
|
TMDBReverseProxy: config.SiteConfig.TMDBReverseProxy || '',
|
||||||
@@ -8538,6 +8541,34 @@ const SiteConfigComponent = ({
|
|||||||
</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'>
|
||||||
|
新用户或未设置本地偏好时,播放页是否默认自动匹配并加载弹幕。用户仍可在个人设置中自行覆盖。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='flex items-center cursor-pointer'>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='sr-only peer'
|
||||||
|
checked={siteSettings.DanmakuAutoLoadDefault !== false}
|
||||||
|
onChange={() =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
DanmakuAutoLoadDefault: prev.DanmakuAutoLoadDefault === false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export async function GET(request: NextRequest) {
|
|||||||
Version: CURRENT_VERSION,
|
Version: CURRENT_VERSION,
|
||||||
WatchRoom: watchRoomConfig,
|
WatchRoom: watchRoomConfig,
|
||||||
EnableOfflineDownload: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
EnableOfflineDownload: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
||||||
|
DanmakuAutoLoadDefault: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ export async function GET(request: NextRequest) {
|
|||||||
EnableOIDCLogin: config.SiteConfig.EnableOIDCLogin || false,
|
EnableOIDCLogin: config.SiteConfig.EnableOIDCLogin || false,
|
||||||
EnableOIDCRegistration: config.SiteConfig.EnableOIDCRegistration || false,
|
EnableOIDCRegistration: config.SiteConfig.EnableOIDCRegistration || false,
|
||||||
OIDCButtonText: config.SiteConfig.OIDCButtonText || '',
|
OIDCButtonText: config.SiteConfig.OIDCButtonText || '',
|
||||||
|
DanmakuAutoLoadDefault: config.SiteConfig.DanmakuAutoLoadDefault !== false,
|
||||||
loginBackgroundImage: config.ThemeConfig?.loginBackgroundImage || '',
|
loginBackgroundImage: config.ThemeConfig?.loginBackgroundImage || '',
|
||||||
registerBackgroundImage: config.ThemeConfig?.registerBackgroundImage || '',
|
registerBackgroundImage: config.ThemeConfig?.registerBackgroundImage || '',
|
||||||
progressThumbType: config.ThemeConfig?.progressThumbType || 'default',
|
progressThumbType: config.ThemeConfig?.progressThumbType || 'default',
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export default async function RootLayout({
|
|||||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
||||||
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
||||||
let enableComments = false;
|
let enableComments = false;
|
||||||
|
let danmakuAutoLoadDefault = true;
|
||||||
let recommendationDataSource = 'Mixed';
|
let recommendationDataSource = 'Mixed';
|
||||||
let tmdbApiKey = '';
|
let tmdbApiKey = '';
|
||||||
let openListEnabled = false;
|
let openListEnabled = false;
|
||||||
@@ -118,6 +119,7 @@ export default async function RootLayout({
|
|||||||
}));
|
}));
|
||||||
fluidSearch = config.SiteConfig.FluidSearch;
|
fluidSearch = config.SiteConfig.FluidSearch;
|
||||||
enableComments = config.SiteConfig.EnableComments;
|
enableComments = config.SiteConfig.EnableComments;
|
||||||
|
danmakuAutoLoadDefault = config.SiteConfig.DanmakuAutoLoadDefault !== false;
|
||||||
recommendationDataSource = config.SiteConfig.RecommendationDataSource || 'Mixed';
|
recommendationDataSource = config.SiteConfig.RecommendationDataSource || 'Mixed';
|
||||||
tmdbApiKey = config.SiteConfig.TMDBApiKey || '';
|
tmdbApiKey = config.SiteConfig.TMDBApiKey || '';
|
||||||
loginBackgroundImage = config.ThemeConfig?.loginBackgroundImage || '';
|
loginBackgroundImage = config.ThemeConfig?.loginBackgroundImage || '';
|
||||||
@@ -183,6 +185,7 @@ export default async function RootLayout({
|
|||||||
CUSTOM_CATEGORIES: customCategories,
|
CUSTOM_CATEGORIES: customCategories,
|
||||||
FLUID_SEARCH: fluidSearch,
|
FLUID_SEARCH: fluidSearch,
|
||||||
EnableComments: enableComments,
|
EnableComments: enableComments,
|
||||||
|
DANMAKU_AUTO_LOAD_DEFAULT: danmakuAutoLoadDefault,
|
||||||
RecommendationDataSource: recommendationDataSource,
|
RecommendationDataSource: recommendationDataSource,
|
||||||
ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true',
|
ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true',
|
||||||
ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
||||||
|
|||||||
+418
-48
@@ -672,10 +672,9 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果不是首次检查,标记为已关闭,不再显示跳转按钮
|
// 切到新的一集后,重新允许检查该集是否存在播放记录。
|
||||||
if (!playRecordJumpInitialCheckRef.current) {
|
playRecordJumpInitialCheckRef.current = true;
|
||||||
playRecordJumpDismissedRef.current = true;
|
playRecordJumpDismissedRef.current = false;
|
||||||
}
|
|
||||||
}, [currentEpisodeIndex]);
|
}, [currentEpisodeIndex]);
|
||||||
|
|
||||||
// 监听 URL 参数变化,当切换到不同视频时重新加载页面
|
// 监听 URL 参数变化,当切换到不同视频时重新加载页面
|
||||||
@@ -1534,6 +1533,94 @@ function PlayPageClient() {
|
|||||||
? directEpisodeLabel
|
? directEpisodeLabel
|
||||||
: `第${currentEpisodeIndex + 1}集`;
|
: `第${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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEpisodeProgressStorageKey = (
|
||||||
|
source: string,
|
||||||
|
id: string,
|
||||||
|
episodeIndex: number
|
||||||
|
) => `moontv_episode_progress:${source}+${id}:${episodeIndex}`;
|
||||||
|
|
||||||
|
const loadLocalEpisodeProgress = (
|
||||||
|
source: string,
|
||||||
|
id: string,
|
||||||
|
episodeIndex: number
|
||||||
|
): number | null => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(
|
||||||
|
getEpisodeProgressStorageKey(source, id, episodeIndex)
|
||||||
|
);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as { playTime?: number };
|
||||||
|
const playTime = Number(parsed.playTime);
|
||||||
|
return Number.isFinite(playTime) && playTime > 1 ? playTime : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Play] Failed to load local episode progress:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveLocalEpisodeProgress = (
|
||||||
|
source: string,
|
||||||
|
id: string,
|
||||||
|
episodeIndex: number,
|
||||||
|
playTime: number,
|
||||||
|
totalTime: number
|
||||||
|
) => {
|
||||||
|
if (typeof window === 'undefined' || !Number.isFinite(playTime) || playTime <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
getEpisodeProgressStorageKey(source, id, episodeIndex),
|
||||||
|
JSON.stringify({
|
||||||
|
playTime: Math.floor(playTime),
|
||||||
|
totalTime: Math.floor(totalTime),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Play] Failed to save local episode progress:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
||||||
const resumeTimeRef = useRef<number | null>(null);
|
const resumeTimeRef = useRef<number | null>(null);
|
||||||
// 播放记录跳转按钮状态
|
// 播放记录跳转按钮状态
|
||||||
@@ -1543,7 +1630,9 @@ function PlayPageClient() {
|
|||||||
// 上次使用的音量,默认 0.7
|
// 上次使用的音量,默认 0.7
|
||||||
const lastVolumeRef = useRef<number>(0.7);
|
const lastVolumeRef = useRef<number>(0.7);
|
||||||
// 上次使用的播放速率,默认 1.0
|
// 上次使用的播放速率,默认 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[]>([]);
|
const [availableSources, setAvailableSources] = useState<SearchResult[]>([]);
|
||||||
@@ -2665,13 +2754,25 @@ function PlayPageClient() {
|
|||||||
const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => {
|
const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => {
|
||||||
if (!video || !url) return;
|
if (!video || !url) return;
|
||||||
const sources = Array.from(video.getElementsByTagName('source'));
|
const sources = Array.from(video.getElementsByTagName('source'));
|
||||||
const existed = sources.some((s) => s.src === url);
|
const isHlsLikeSource =
|
||||||
if (!existed) {
|
!!(video as any).hls ||
|
||||||
// 移除旧的 source,保持唯一
|
/\.m3u8?($|\?)/i.test(url) ||
|
||||||
|
url.includes('/api/proxy-m3u8') ||
|
||||||
|
url.includes('/api/proxy/vod/m3u8');
|
||||||
|
|
||||||
|
if (isHlsLikeSource) {
|
||||||
|
// HLS 由 hls.js 接管时,不能再给 <video> 塞原始 m3u8 source,
|
||||||
|
// 否则 Safari 可能切回原生 HLS,和 MSE/hls.js 抢同一个播放器。
|
||||||
sources.forEach((s) => s.remove());
|
sources.forEach((s) => s.remove());
|
||||||
const sourceEl = document.createElement('source');
|
} else {
|
||||||
sourceEl.src = url;
|
const existed = sources.some((s) => s.src === url);
|
||||||
video.appendChild(sourceEl);
|
if (!existed) {
|
||||||
|
// 移除旧的 source,保持唯一
|
||||||
|
sources.forEach((s) => s.remove());
|
||||||
|
const sourceEl = document.createElement('source');
|
||||||
|
sourceEl.src = url;
|
||||||
|
video.appendChild(sourceEl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 始终允许远程播放(AirPlay / Cast)
|
// 始终允许远程播放(AirPlay / Cast)
|
||||||
@@ -3856,6 +3957,12 @@ function PlayPageClient() {
|
|||||||
} else {
|
} else {
|
||||||
// 否则使用点击的文件集数,从头开始播放
|
// 否则使用点击的文件集数,从头开始播放
|
||||||
initialIndex = detailData.initialEpisodeIndex;
|
initialIndex = detailData.initialEpisodeIndex;
|
||||||
|
const localEpisodeTime = loadLocalEpisodeProgress(
|
||||||
|
detailData.source,
|
||||||
|
detailData.id,
|
||||||
|
initialIndex
|
||||||
|
);
|
||||||
|
resumeTimeRef.current = localEpisodeTime;
|
||||||
console.log('[Play] 使用点击的文件集数:', initialIndex);
|
console.log('[Play] 使用点击的文件集数:', initialIndex);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -3870,10 +3977,20 @@ function PlayPageClient() {
|
|||||||
if (detailData.initialEpisodeIndex !== undefined) {
|
if (detailData.initialEpisodeIndex !== undefined) {
|
||||||
// 使用点击的文件集数
|
// 使用点击的文件集数
|
||||||
initialIndex = detailData.initialEpisodeIndex;
|
initialIndex = detailData.initialEpisodeIndex;
|
||||||
|
resumeTimeRef.current = loadLocalEpisodeProgress(
|
||||||
|
detailData.source,
|
||||||
|
detailData.id,
|
||||||
|
initialIndex
|
||||||
|
);
|
||||||
console.log('[Play] 没有播放记录,使用点击的文件集数:', initialIndex);
|
console.log('[Play] 没有播放记录,使用点击的文件集数:', initialIndex);
|
||||||
} else {
|
} else {
|
||||||
// 默认从第0集开始
|
// 默认从第0集开始
|
||||||
initialIndex = 0;
|
initialIndex = 0;
|
||||||
|
resumeTimeRef.current = loadLocalEpisodeProgress(
|
||||||
|
detailData.source,
|
||||||
|
detailData.id,
|
||||||
|
initialIndex
|
||||||
|
);
|
||||||
console.log('[Play] 没有播放记录,从第0集开始');
|
console.log('[Play] 没有播放记录,从第0集开始');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4196,31 +4313,73 @@ function PlayPageClient() {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 集数切换
|
// 集数切换
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 处理集数切换
|
const primeEpisodeResumeState = async (targetEpisodeIndex: number) => {
|
||||||
const handleEpisodeChange = (episodeNumber: number) => {
|
if (!currentSourceRef.current || !currentIdRef.current) {
|
||||||
if (episodeNumber >= 0 && episodeNumber < totalEpisodes) {
|
resumeTimeRef.current = null;
|
||||||
// 在更换集数前保存当前播放进度
|
return;
|
||||||
if (artPlayerRef.current && artPlayerRef.current.paused) {
|
}
|
||||||
saveCurrentPlayProgress();
|
|
||||||
|
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(
|
||||||
|
currentSourceRef.current,
|
||||||
|
currentIdRef.current,
|
||||||
|
targetEpisodeIndex
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setVideoLoadingStage('episodeChanging');
|
} catch (error) {
|
||||||
setIsVideoLoading(true);
|
console.warn('[Play] Failed to prime episode resume state:', error);
|
||||||
setVideoError(null);
|
if (currentSourceRef.current && currentIdRef.current) {
|
||||||
|
resumeTimeRef.current = loadLocalEpisodeProgress(
|
||||||
|
currentSourceRef.current,
|
||||||
|
currentIdRef.current,
|
||||||
|
targetEpisodeIndex
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
resumeTimeRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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) {
|
||||||
|
await prepareEpisodeSwitch();
|
||||||
|
await primeEpisodeResumeState(episodeNumber);
|
||||||
setCurrentEpisodeIndex(episodeNumber);
|
setCurrentEpisodeIndex(episodeNumber);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviousEpisode = () => {
|
const handlePreviousEpisode = async () => {
|
||||||
const d = detailRef.current;
|
const d = detailRef.current;
|
||||||
const idx = currentEpisodeIndexRef.current;
|
const idx = currentEpisodeIndexRef.current;
|
||||||
if (d && d.episodes && idx > 0) {
|
if (d && d.episodes && idx > 0) {
|
||||||
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
await prepareEpisodeSwitch();
|
||||||
saveCurrentPlayProgress();
|
const targetIndex = idx - 1;
|
||||||
}
|
await primeEpisodeResumeState(targetIndex);
|
||||||
setVideoLoadingStage('episodeChanging');
|
setCurrentEpisodeIndex(targetIndex);
|
||||||
setIsVideoLoading(true);
|
|
||||||
setVideoError(null);
|
|
||||||
setCurrentEpisodeIndex(idx - 1);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4248,7 +4407,7 @@ function PlayPageClient() {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextEpisode = () => {
|
const handleNextEpisode = async () => {
|
||||||
const d = detailRef.current;
|
const d = detailRef.current;
|
||||||
const idx = currentEpisodeIndexRef.current;
|
const idx = currentEpisodeIndexRef.current;
|
||||||
|
|
||||||
@@ -4256,11 +4415,6 @@ function PlayPageClient() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存当前进度
|
|
||||||
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
|
||||||
saveCurrentPlayProgress();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找下一个未被过滤的集数
|
// 查找下一个未被过滤的集数
|
||||||
let nextIdx = idx + 1;
|
let nextIdx = idx + 1;
|
||||||
while (nextIdx < d.episodes.length) {
|
while (nextIdx < d.episodes.length) {
|
||||||
@@ -4268,9 +4422,8 @@ function PlayPageClient() {
|
|||||||
const isFiltered = episodeTitle && isEpisodeFilteredByTitle(episodeTitle);
|
const isFiltered = episodeTitle && isEpisodeFilteredByTitle(episodeTitle);
|
||||||
|
|
||||||
if (!isFiltered) {
|
if (!isFiltered) {
|
||||||
setVideoLoadingStage('episodeChanging');
|
await prepareEpisodeSwitch();
|
||||||
setIsVideoLoading(true);
|
await primeEpisodeResumeState(nextIdx);
|
||||||
setVideoError(null);
|
|
||||||
setCurrentEpisodeIndex(nextIdx);
|
setCurrentEpisodeIndex(nextIdx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4908,7 +5061,7 @@ function PlayPageClient() {
|
|||||||
// 自动搜索并加载弹幕
|
// 自动搜索并加载弹幕
|
||||||
const autoSearchDanmaku = async () => {
|
const autoSearchDanmaku = async () => {
|
||||||
if (isDirectPlay) return;
|
if (isDirectPlay) return;
|
||||||
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
|
const disableAutoLoad = isDanmakuAutoLoadDisabled();
|
||||||
if (disableAutoLoad) return;
|
if (disableAutoLoad) return;
|
||||||
|
|
||||||
const title = videoTitleRef.current;
|
const title = videoTitleRef.current;
|
||||||
@@ -5238,6 +5391,14 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
saveLocalEpisodeProgress(
|
||||||
|
currentSourceRef.current,
|
||||||
|
currentIdRef.current,
|
||||||
|
currentEpisodeIndexRef.current,
|
||||||
|
currentTime,
|
||||||
|
duration
|
||||||
|
);
|
||||||
|
|
||||||
await savePlayRecord(currentSourceRef.current, currentIdRef.current, {
|
await savePlayRecord(currentSourceRef.current, currentIdRef.current, {
|
||||||
title: videoTitleRef.current,
|
title: videoTitleRef.current,
|
||||||
source_name: detailRef.current?.source_name || '',
|
source_name: detailRef.current?.source_name || '',
|
||||||
@@ -5548,11 +5709,77 @@ function PlayPageClient() {
|
|||||||
const Hls = HlsModule.default;
|
const Hls = HlsModule.default;
|
||||||
const artplayerPluginDanmuku = DanmukuPlugin.default as any;
|
const artplayerPluginDanmuku = DanmukuPlugin.default as any;
|
||||||
|
|
||||||
|
const syncPlaybackPitch = () => {
|
||||||
|
if (!isWebkit || !artPlayerRef.current?.video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = artPlayerRef.current.video as HTMLVideoElement & {
|
||||||
|
webkitPreservesPitch?: boolean;
|
||||||
|
};
|
||||||
|
const playbackRate = artPlayerRef.current.playbackRate || 1;
|
||||||
|
const shouldPreservePitch = playbackRate <= 2;
|
||||||
|
|
||||||
|
if ('preservesPitch' in video) {
|
||||||
|
video.preservesPitch = shouldPreservePitch;
|
||||||
|
}
|
||||||
|
if ('webkitPreservesPitch' in video) {
|
||||||
|
video.webkitPreservesPitch = shouldPreservePitch;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!artPlayerRef.current || artPlayerRef.current.video !== video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hls = video.hls;
|
||||||
|
const currentSrc = video.currentSrc || video.src || '';
|
||||||
|
|
||||||
|
if (!hls || currentSrc || video.readyState > 0) {
|
||||||
|
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
|
// 创建自定义 HLS loader
|
||||||
const CustomHlsJsLoader = createCustomHlsLoader(Hls);
|
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;
|
Artplayer.USE_RAF = true;
|
||||||
|
|
||||||
// 获取当前集的字幕
|
// 获取当前集的字幕
|
||||||
@@ -5685,7 +5912,9 @@ function PlayPageClient() {
|
|||||||
const hls = new Hls({
|
const hls = new Hls({
|
||||||
debug: false, // 关闭日志
|
debug: false, // 关闭日志
|
||||||
enableWorker: true, // WebWorker 解码,降低主线程压力
|
enableWorker: true, // WebWorker 解码,降低主线程压力
|
||||||
lowLatencyMode: true, // 开启低延迟 LL-HLS
|
// 点播播放不需要 LL-HLS,小缓冲在 Safari 高倍速下更容易抖动。
|
||||||
|
lowLatencyMode: false,
|
||||||
|
autoStartLoad: true,
|
||||||
|
|
||||||
/* 缓冲/内存相关 - 根据用户设置的缓冲策略动态调整 */
|
/* 缓冲/内存相关 - 根据用户设置的缓冲策略动态调整 */
|
||||||
maxBufferLength: bufferConfig.maxBufferLength, // 前向缓冲长度
|
maxBufferLength: bufferConfig.maxBufferLength, // 前向缓冲长度
|
||||||
@@ -5696,10 +5925,70 @@ function PlayPageClient() {
|
|||||||
loader: loaderClass as any,
|
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.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||||
|
if (!isWebkit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safari 偶发出现 media 已 attach 但 video.src 仍为空的状态。
|
||||||
|
// 这里延迟自检一次,必要时重新 attach,强制进入 blob: MSE 路径。
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const currentSrc = video.currentSrc || video.src || '';
|
||||||
|
if (currentSrc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[HLS] Safari attach watchdog triggered, reattaching media');
|
||||||
|
try {
|
||||||
|
hls.detachMedia();
|
||||||
|
hls.attachMedia(video);
|
||||||
|
kickStartHlsPlayback();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[HLS] Safari reattach failed:', error);
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
|
});
|
||||||
|
|
||||||
hls.loadSource(url);
|
hls.loadSource(url);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
video.hls = hls;
|
video.hls = hls;
|
||||||
|
|
||||||
|
if (isWebkit) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const currentSrc = video.currentSrc || video.src || '';
|
||||||
|
if (currentSrc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('[HLS] Safari post-attach watchdog triggered, forcing reattach');
|
||||||
|
try {
|
||||||
|
hls.detachMedia();
|
||||||
|
hls.attachMedia(video);
|
||||||
|
kickStartHlsPlayback();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[HLS] Safari post-attach reattach failed:', error);
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
ensureVideoSource(video, url);
|
ensureVideoSource(video, url);
|
||||||
|
|
||||||
// 额外确保 iOS 内联播放属性(防止全屏时使用系统播放器)
|
// 额外确保 iOS 内联播放属性(防止全屏时使用系统播放器)
|
||||||
@@ -5712,6 +6001,12 @@ function PlayPageClient() {
|
|||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
console.log('[HLS] Manifest解析完成');
|
console.log('[HLS] Manifest解析完成');
|
||||||
|
|
||||||
|
if (video.paused && (artPlayerRef.current?.option.autoplay || artPlayerRef.current?.loading)) {
|
||||||
|
video.play().catch((error) => {
|
||||||
|
console.warn('[HLS] play after manifest parsed failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 只在首次加载时启动定时器(后续刷新会在refreshXiaoyaUrl中启动)
|
// 只在首次加载时启动定时器(后续刷新会在refreshXiaoyaUrl中启动)
|
||||||
if (isInitialLoadRef.current && currentXiaoyaUrlRef.current && url.includes('.m3u8')) {
|
if (isInitialLoadRef.current && currentXiaoyaUrlRef.current && url.includes('.m3u8')) {
|
||||||
isInitialLoadRef.current = false; // 标记已完成首次加载
|
isInitialLoadRef.current = false; // 标记已完成首次加载
|
||||||
@@ -6552,6 +6847,8 @@ function PlayPageClient() {
|
|||||||
artPlayerRef.current.on('ready', async () => {
|
artPlayerRef.current.on('ready', async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
rescueWebkitHlsBootstrap('player-ready');
|
||||||
|
|
||||||
// 标记播放器已就绪,触发 usePlaySync 设置事件监听器
|
// 标记播放器已就绪,触发 usePlaySync 设置事件监听器
|
||||||
setPlayerReady(true);
|
setPlayerReady(true);
|
||||||
console.log('[PlayPage] Player ready, triggering sync setup');
|
console.log('[PlayPage] Player ready, triggering sync setup');
|
||||||
@@ -6886,7 +7183,43 @@ function PlayPageClient() {
|
|||||||
lastVolumeRef.current = artPlayerRef.current.volume;
|
lastVolumeRef.current = artPlayerRef.current.volume;
|
||||||
});
|
});
|
||||||
artPlayerRef.current.on('video:ratechange', () => {
|
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,这不是用户真实选择,不要覆盖记忆值。
|
||||||
|
setTimeout(() => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听网页全屏事件,控制导航栏显示隐藏
|
// 监听网页全屏事件,控制导航栏显示隐藏
|
||||||
@@ -7380,6 +7713,8 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
// 监听视频可播放事件,这时恢复播放进度更可靠
|
// 监听视频可播放事件,这时恢复播放进度更可靠
|
||||||
artPlayerRef.current.on('video:canplay', () => {
|
artPlayerRef.current.on('video:canplay', () => {
|
||||||
|
let restoredResumeTime = false;
|
||||||
|
|
||||||
// 若存在需要恢复的播放进度,则跳转
|
// 若存在需要恢复的播放进度,则跳转
|
||||||
if (resumeTimeRef.current && resumeTimeRef.current > 0) {
|
if (resumeTimeRef.current && resumeTimeRef.current > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -7389,6 +7724,7 @@ function PlayPageClient() {
|
|||||||
target = Math.max(0, duration - 5);
|
target = Math.max(0, duration - 5);
|
||||||
}
|
}
|
||||||
artPlayerRef.current.currentTime = target;
|
artPlayerRef.current.currentTime = target;
|
||||||
|
restoredResumeTime = true;
|
||||||
console.log('成功恢复播放进度到:', resumeTimeRef.current);
|
console.log('成功恢复播放进度到:', resumeTimeRef.current);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('恢复播放进度失败:', err);
|
console.warn('恢复播放进度失败:', err);
|
||||||
@@ -7397,19 +7733,53 @@ function PlayPageClient() {
|
|||||||
resumeTimeRef.current = null;
|
resumeTimeRef.current = null;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
const restorePlaybackRate = () => {
|
||||||
|
if (
|
||||||
|
Math.abs(
|
||||||
|
artPlayerRef.current.playbackRate - lastPlaybackRateRef.current
|
||||||
|
) > 0.01 &&
|
||||||
|
isWebkit
|
||||||
|
) {
|
||||||
|
artPlayerRef.current.playbackRate = lastPlaybackRateRef.current;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Math.abs(artPlayerRef.current.volume - lastVolumeRef.current) > 0.01
|
Math.abs(artPlayerRef.current.volume - lastVolumeRef.current) > 0.01
|
||||||
) {
|
) {
|
||||||
artPlayerRef.current.volume = lastVolumeRef.current;
|
artPlayerRef.current.volume = lastVolumeRef.current;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
Math.abs(
|
// Safari 在 seek 刚发生时立刻恢复 3x,容易卡进持续 seeking 状态。
|
||||||
artPlayerRef.current.playbackRate - lastPlaybackRateRef.current
|
// 这里等 seek 稳定后再恢复倍速,避免恢复进度和变速互相打架。
|
||||||
) > 0.01 &&
|
if (restoredResumeTime && isWebkit && artPlayerRef.current?.video) {
|
||||||
isWebkit
|
const video = artPlayerRef.current.video as HTMLVideoElement;
|
||||||
) {
|
const applyRateAfterSeek = () => {
|
||||||
artPlayerRef.current.playbackRate = lastPlaybackRateRef.current;
|
restorePlaybackRate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (video.seeking) {
|
||||||
|
const handleSeeked = () => {
|
||||||
|
window.clearTimeout(seekedTimeout);
|
||||||
|
applyRateAfterSeek();
|
||||||
|
};
|
||||||
|
const seekedTimeout = window.setTimeout(() => {
|
||||||
|
video.removeEventListener('seeked', handleSeeked);
|
||||||
|
applyRateAfterSeek();
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
video.addEventListener(
|
||||||
|
'seeked',
|
||||||
|
handleSeeked,
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
restorePlaybackRate();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
restorePlaybackRate();
|
||||||
}
|
}
|
||||||
|
syncPlaybackPitch();
|
||||||
artPlayerRef.current.notice.show = '';
|
artPlayerRef.current.notice.show = '';
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
|
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
|
||||||
|
import { generateStorageKey, getCachedPlayRecordsSnapshot } from '@/lib/db.client';
|
||||||
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
||||||
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
|
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -109,6 +110,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
const [isRetestingAll, setIsRetestingAll] = useState(false);
|
const [isRetestingAll, setIsRetestingAll] = useState(false);
|
||||||
// 标记是否正在进行初始测速
|
// 标记是否正在进行初始测速
|
||||||
const [isInitialTesting, setIsInitialTesting] = useState(false);
|
const [isInitialTesting, setIsInitialTesting] = useState(false);
|
||||||
|
const [watchedEpisodes, setWatchedEpisodes] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// 使用 ref 来避免闭包问题
|
// 使用 ref 来避免闭包问题
|
||||||
const attemptedSourcesRef = useRef<Set<string>>(new Set());
|
const attemptedSourcesRef = useRef<Set<string>>(new Set());
|
||||||
@@ -123,6 +125,62 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
videoInfoMapRef.current = videoInfoMap;
|
videoInfoMapRef.current = videoInfoMap;
|
||||||
}, [videoInfoMap]);
|
}, [videoInfoMap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || !currentSource || !currentId) {
|
||||||
|
setWatchedEpisodes(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readWatchedEpisodes = () => {
|
||||||
|
const watched = new Set<number>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const records = getCachedPlayRecordsSnapshot();
|
||||||
|
const record = records[generateStorageKey(currentSource, currentId)];
|
||||||
|
if (record && record.index > 0 && record.play_time > 1) {
|
||||||
|
watched.add(record.index);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[EpisodeSelector] Failed to read cached play records:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let episodeNumber = 1; episodeNumber <= totalEpisodes; episodeNumber++) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(
|
||||||
|
`moontv_episode_progress:${currentSource}+${currentId}:${episodeNumber - 1}`
|
||||||
|
);
|
||||||
|
if (!raw) continue;
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as { playTime?: number };
|
||||||
|
if (Number(parsed.playTime) > 1) {
|
||||||
|
watched.add(episodeNumber);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[EpisodeSelector] Failed to read local episode progress:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setWatchedEpisodes(watched);
|
||||||
|
};
|
||||||
|
|
||||||
|
readWatchedEpisodes();
|
||||||
|
|
||||||
|
const handlePlayRecordsUpdated = () => {
|
||||||
|
readWatchedEpisodes();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('playRecordsUpdated', handlePlayRecordsUpdated as EventListener);
|
||||||
|
window.addEventListener('storage', handlePlayRecordsUpdated);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
'playRecordsUpdated',
|
||||||
|
handlePlayRecordsUpdated as EventListener
|
||||||
|
);
|
||||||
|
window.removeEventListener('storage', handlePlayRecordsUpdated);
|
||||||
|
};
|
||||||
|
}, [currentSource, currentId, totalEpisodes, value]);
|
||||||
|
|
||||||
// 主要的 tab 状态:'danmaku' | 'episodes' | 'sources'
|
// 主要的 tab 状态:'danmaku' | 'episodes' | 'sources'
|
||||||
// 默认显示选集选项卡,但如果是房员则显示弹幕
|
// 默认显示选集选项卡,但如果是房员则显示弹幕
|
||||||
const [activeTab, setActiveTab] = useState<'danmaku' | 'episodes' | 'sources'>(
|
const [activeTab, setActiveTab] = useState<'danmaku' | 'episodes' | 'sources'>(
|
||||||
@@ -723,16 +781,23 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
.filter(episodeNumber => !isEpisodeFiltered(episodeNumber))
|
.filter(episodeNumber => !isEpisodeFiltered(episodeNumber))
|
||||||
.map((episodeNumber) => {
|
.map((episodeNumber) => {
|
||||||
const isActive = episodeNumber === value;
|
const isActive = episodeNumber === value;
|
||||||
|
const isWatched = watchedEpisodes.has(episodeNumber);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={episodeNumber}
|
key={episodeNumber}
|
||||||
onClick={() => handleEpisodeClick(episodeNumber - 1)}
|
onClick={() => handleEpisodeClick(episodeNumber - 1)}
|
||||||
className={`h-10 min-w-10 px-3 py-2 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200 whitespace-nowrap font-mono
|
className={`relative h-10 min-w-10 px-3 py-2 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200 whitespace-nowrap font-mono border
|
||||||
${isActive
|
${isActive
|
||||||
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
|
? 'bg-green-500 text-white border-green-400 shadow-lg shadow-green-500/25 dark:bg-green-600'
|
||||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 hover:scale-105 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
: isWatched
|
||||||
|
? 'bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100 hover:scale-105 dark:bg-emerald-900/20 dark:text-emerald-300 dark:border-emerald-700/60 dark:hover:bg-emerald-900/30'
|
||||||
|
: 'bg-gray-200 text-gray-700 border-transparent hover:bg-gray-300 hover:scale-105 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||||
}`.trim()}
|
}`.trim()}
|
||||||
|
title={isWatched && !isActive ? '已观看过' : undefined}
|
||||||
>
|
>
|
||||||
|
{isWatched && !isActive && (
|
||||||
|
<span className='absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400' />
|
||||||
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const title = episodes_titles?.[episodeNumber - 1];
|
const title = episodes_titles?.[episodeNumber - 1];
|
||||||
if (!title) {
|
if (!title) {
|
||||||
|
|||||||
@@ -542,6 +542,10 @@ export const UserMenu: React.FC = () => {
|
|||||||
const savedDisableAutoLoadDanmaku = localStorage.getItem('disableAutoLoadDanmaku');
|
const savedDisableAutoLoadDanmaku = localStorage.getItem('disableAutoLoadDanmaku');
|
||||||
if (savedDisableAutoLoadDanmaku !== null) {
|
if (savedDisableAutoLoadDanmaku !== null) {
|
||||||
setDisableAutoLoadDanmaku(savedDisableAutoLoadDanmaku === 'true');
|
setDisableAutoLoadDanmaku(savedDisableAutoLoadDanmaku === 'true');
|
||||||
|
} else {
|
||||||
|
const runtimeDefault =
|
||||||
|
(window as any).RUNTIME_CONFIG?.DANMAKU_AUTO_LOAD_DEFAULT !== false;
|
||||||
|
setDisableAutoLoadDanmaku(!runtimeDefault);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedDanmakuMaxCount = localStorage.getItem('danmakuMaxCount');
|
const savedDanmakuMaxCount = localStorage.getItem('danmakuMaxCount');
|
||||||
@@ -1339,7 +1343,11 @@ export const UserMenu: React.FC = () => {
|
|||||||
setBufferStrategy('medium');
|
setBufferStrategy('medium');
|
||||||
setNextEpisodePreCache(true);
|
setNextEpisodePreCache(true);
|
||||||
setNextEpisodeDanmakuPreload(true);
|
setNextEpisodeDanmakuPreload(true);
|
||||||
setDisableAutoLoadDanmaku(false);
|
const defaultDanmakuAutoLoad =
|
||||||
|
(typeof window !== 'undefined' &&
|
||||||
|
(window as any).RUNTIME_CONFIG?.DANMAKU_AUTO_LOAD_DEFAULT !== false) ||
|
||||||
|
false;
|
||||||
|
setDisableAutoLoadDanmaku(!defaultDanmakuAutoLoad);
|
||||||
setHomeBannerEnabled(true);
|
setHomeBannerEnabled(true);
|
||||||
setHomeContinueWatchingEnabled(true);
|
setHomeContinueWatchingEnabled(true);
|
||||||
setHomeModules(defaultHomeModules);
|
setHomeModules(defaultHomeModules);
|
||||||
@@ -1364,7 +1372,10 @@ export const UserMenu: React.FC = () => {
|
|||||||
localStorage.setItem('bufferStrategy', 'medium');
|
localStorage.setItem('bufferStrategy', 'medium');
|
||||||
localStorage.setItem('nextEpisodePreCache', 'true');
|
localStorage.setItem('nextEpisodePreCache', 'true');
|
||||||
localStorage.setItem('nextEpisodeDanmakuPreload', 'true');
|
localStorage.setItem('nextEpisodeDanmakuPreload', 'true');
|
||||||
localStorage.setItem('disableAutoLoadDanmaku', 'false');
|
localStorage.setItem(
|
||||||
|
'disableAutoLoadDanmaku',
|
||||||
|
String(!defaultDanmakuAutoLoad)
|
||||||
|
);
|
||||||
localStorage.setItem('danmakuMaxCount', '0');
|
localStorage.setItem('danmakuMaxCount', '0');
|
||||||
localStorage.setItem('danmaku_heatmap_disabled', 'false');
|
localStorage.setItem('danmaku_heatmap_disabled', 'false');
|
||||||
localStorage.setItem('homeBannerEnabled', 'true');
|
localStorage.setItem('homeBannerEnabled', 'true');
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface AdminConfig {
|
|||||||
DanmakuSourceType?: 'builtin' | 'custom';
|
DanmakuSourceType?: 'builtin' | 'custom';
|
||||||
DanmakuApiBase: string;
|
DanmakuApiBase: string;
|
||||||
DanmakuApiToken: string;
|
DanmakuApiToken: string;
|
||||||
|
DanmakuAutoLoadDefault?: boolean; // 是否默认自动加载弹幕(用户可在本地覆盖)
|
||||||
// TMDB配置
|
// TMDB配置
|
||||||
TMDBApiKey?: string;
|
TMDBApiKey?: string;
|
||||||
TMDBProxy?: string;
|
TMDBProxy?: string;
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ async function getInitConfig(configFile: string, subConfig: {
|
|||||||
process.env.DANMAKU_API_BASE ||
|
process.env.DANMAKU_API_BASE ||
|
||||||
(hasCustomDanmakuEnv ? 'http://localhost:9321' : BUILTIN_DANMAKU_API_BASE),
|
(hasCustomDanmakuEnv ? 'http://localhost:9321' : BUILTIN_DANMAKU_API_BASE),
|
||||||
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
|
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
|
||||||
|
DanmakuAutoLoadDefault: true,
|
||||||
// TMDB配置
|
// TMDB配置
|
||||||
TMDBApiKey: process.env.TMDB_API_KEY || '',
|
TMDBApiKey: process.env.TMDB_API_KEY || '',
|
||||||
TMDBProxy: process.env.TMDB_PROXY || '',
|
TMDBProxy: process.env.TMDB_PROXY || '',
|
||||||
@@ -450,6 +451,7 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
DanmakuSourceType: 'builtin',
|
DanmakuSourceType: 'builtin',
|
||||||
DanmakuApiBase: BUILTIN_DANMAKU_API_BASE,
|
DanmakuApiBase: BUILTIN_DANMAKU_API_BASE,
|
||||||
DanmakuApiToken: '87654321',
|
DanmakuApiToken: '87654321',
|
||||||
|
DanmakuAutoLoadDefault: true,
|
||||||
PansouApiUrl: '',
|
PansouApiUrl: '',
|
||||||
PansouUsername: '',
|
PansouUsername: '',
|
||||||
PansouPassword: '',
|
PansouPassword: '',
|
||||||
@@ -482,6 +484,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
|||||||
if (!adminConfig.SiteConfig.DanmakuApiToken) {
|
if (!adminConfig.SiteConfig.DanmakuApiToken) {
|
||||||
adminConfig.SiteConfig.DanmakuApiToken = '87654321';
|
adminConfig.SiteConfig.DanmakuApiToken = '87654321';
|
||||||
}
|
}
|
||||||
|
if (adminConfig.SiteConfig.DanmakuAutoLoadDefault === undefined) {
|
||||||
|
adminConfig.SiteConfig.DanmakuAutoLoadDefault = true;
|
||||||
|
}
|
||||||
// 确保评论开关存在
|
// 确保评论开关存在
|
||||||
if (adminConfig.SiteConfig.EnableComments === undefined) {
|
if (adminConfig.SiteConfig.EnableComments === undefined) {
|
||||||
adminConfig.SiteConfig.EnableComments = false;
|
adminConfig.SiteConfig.EnableComments = false;
|
||||||
|
|||||||
+15
-4
@@ -787,9 +787,21 @@ export async function savePlayRecord(
|
|||||||
body: JSON.stringify({ key, record }),
|
body: JSON.stringify({ key, record }),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await handleDatabaseOperationFailure('playRecords', err);
|
// 播放记录以用户体验为优先:保留已经写入的本地缓存,避免切集后记忆进度被回滚。
|
||||||
triggerGlobalError('保存播放记录失败');
|
console.warn('同步播放记录到数据库失败,保留本地缓存:', err);
|
||||||
throw err;
|
|
||||||
|
// 后台再尝试补一次,不打断当前播放流程。
|
||||||
|
window.setTimeout(() => {
|
||||||
|
fetchWithAuth('/api/playrecords', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key, record }),
|
||||||
|
}).catch((retryErr) => {
|
||||||
|
console.warn('播放记录后台重试失败:', retryErr);
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2245,4 +2257,3 @@ export async function saveEpisodeFilterConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user