From 62e0c12d81b5bbb123b473e0a41b4fe2ffab78b9 Mon Sep 17 00:00:00 2001 From: ShiGuangAlex Date: Tue, 14 Apr 2026 17:40:30 +0800 Subject: [PATCH] Fix Safari playback recovery and episode progress memory --- src/app/admin/page.tsx | 31 ++ src/app/api/server-config/route.ts | 2 + src/app/layout.tsx | 3 + src/app/play/page.tsx | 466 ++++++++++++++++++++++++++--- src/components/EpisodeSelector.tsx | 71 ++++- src/components/UserMenu.tsx | 15 +- src/lib/admin.types.ts | 1 + src/lib/config.ts | 5 + src/lib/db.client.ts | 19 +- 9 files changed, 556 insertions(+), 57 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 263c6ff..d5ce9d3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -344,6 +344,7 @@ interface SiteConfig { DanmakuSourceType?: 'builtin' | 'custom'; DanmakuApiBase: string; DanmakuApiToken: string; + DanmakuAutoLoadDefault?: boolean; TMDBApiKey?: string; TMDBProxy?: string; TMDBReverseProxy?: string; @@ -7799,6 +7800,7 @@ const SiteConfigComponent = ({ DanmakuSourceType: 'builtin', DanmakuApiBase: 'https://mtvpls-danmu.netlify.app/87654321', DanmakuApiToken: '87654321', + DanmakuAutoLoadDefault: true, TMDBApiKey: '', TMDBProxy: '', TMDBReverseProxy: '', @@ -7896,6 +7898,7 @@ const SiteConfigComponent = ({ DanmakuApiBase: config.SiteConfig.DanmakuApiBase || 'http://localhost:9321', DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321', + DanmakuAutoLoadDefault: config.SiteConfig.DanmakuAutoLoadDefault !== false, TMDBApiKey: config.SiteConfig.TMDBApiKey || '', TMDBProxy: config.SiteConfig.TMDBProxy || '', TMDBReverseProxy: config.SiteConfig.TMDBReverseProxy || '', @@ -8538,6 +8541,34 @@ const SiteConfigComponent = ({ )} + +
+
+

+ 默认自动加载弹幕 +

+

+ 新用户或未设置本地偏好时,播放页是否默认自动匹配并加载弹幕。用户仍可在个人设置中自行覆盖。 +

+
+ +
diff --git a/src/app/api/server-config/route.ts b/src/app/api/server-config/route.ts index f9b9ff0..0f1380a 100644 --- a/src/app/api/server-config/route.ts +++ b/src/app/api/server-config/route.ts @@ -38,6 +38,7 @@ export async function GET(request: NextRequest) { Version: CURRENT_VERSION, WatchRoom: watchRoomConfig, 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, EnableOIDCRegistration: config.SiteConfig.EnableOIDCRegistration || false, OIDCButtonText: config.SiteConfig.OIDCButtonText || '', + DanmakuAutoLoadDefault: config.SiteConfig.DanmakuAutoLoadDefault !== false, loginBackgroundImage: config.ThemeConfig?.loginBackgroundImage || '', registerBackgroundImage: config.ThemeConfig?.registerBackgroundImage || '', progressThumbType: config.ThemeConfig?.progressThumbType || 'default', diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 22eb3a6..2a15faf 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -64,6 +64,7 @@ export default async function RootLayout({ process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true'; let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false'; let enableComments = false; + let danmakuAutoLoadDefault = true; let recommendationDataSource = 'Mixed'; let tmdbApiKey = ''; let openListEnabled = false; @@ -118,6 +119,7 @@ export default async function RootLayout({ })); fluidSearch = config.SiteConfig.FluidSearch; enableComments = config.SiteConfig.EnableComments; + danmakuAutoLoadDefault = config.SiteConfig.DanmakuAutoLoadDefault !== false; recommendationDataSource = config.SiteConfig.RecommendationDataSource || 'Mixed'; tmdbApiKey = config.SiteConfig.TMDBApiKey || ''; loginBackgroundImage = config.ThemeConfig?.loginBackgroundImage || ''; @@ -183,6 +185,7 @@ export default async function RootLayout({ CUSTOM_CATEGORIES: customCategories, FLUID_SEARCH: fluidSearch, EnableComments: enableComments, + DANMAKU_AUTO_LOAD_DEFAULT: danmakuAutoLoadDefault, RecommendationDataSource: recommendationDataSource, ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true', ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true', diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 6ceff05..0baaedd 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -672,10 +672,9 @@ function PlayPageClient() { } } - // 如果不是首次检查,标记为已关闭,不再显示跳转按钮 - if (!playRecordJumpInitialCheckRef.current) { - playRecordJumpDismissedRef.current = true; - } + // 切到新的一集后,重新允许检查该集是否存在播放记录。 + playRecordJumpInitialCheckRef.current = true; + playRecordJumpDismissedRef.current = false; }, [currentEpisodeIndex]); // 监听 URL 参数变化,当切换到不同视频时重新加载页面 @@ -1534,6 +1533,94 @@ 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; + }; + + 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 后跳转到指定进度 const resumeTimeRef = useRef(null); // 播放记录跳转按钮状态 @@ -1543,7 +1630,9 @@ function PlayPageClient() { // 上次使用的音量,默认 0.7 const lastVolumeRef = useRef(0.7); // 上次使用的播放速率,默认 1.0 - const lastPlaybackRateRef = useRef(1.0); + const lastPlaybackRateRef = useRef(loadSavedPlaybackRate()); + // Safari 切集时会短暂把 playbackRate 重置为 1,这里保留一段恢复窗口避免污染记忆值 + const playbackRateRestoreWindowUntilRef = useRef(0); // 换源相关状态 const [availableSources, setAvailableSources] = useState([]); @@ -2665,13 +2754,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 isHlsLikeSource = + !!(video as any).hls || + /\.m3u8?($|\?)/i.test(url) || + url.includes('/api/proxy-m3u8') || + url.includes('/api/proxy/vod/m3u8'); + + if (isHlsLikeSource) { + // HLS 由 hls.js 接管时,不能再给