diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index af46307..62aacda 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/admin/site/route.ts b/src/app/api/admin/site/route.ts index b51a4f5..f1c6fe1 100644 --- a/src/app/api/admin/site/route.ts +++ b/src/app/api/admin/site/route.ts @@ -42,6 +42,7 @@ export async function POST(request: NextRequest) { DanmakuSourceType, DanmakuApiBase, DanmakuApiToken, + DanmakuAutoLoadDefault, TMDBApiKey, TMDBProxy, TMDBReverseProxy, @@ -90,6 +91,7 @@ export async function POST(request: NextRequest) { DanmakuSourceType?: 'builtin' | 'custom'; DanmakuApiBase: string; DanmakuApiToken: string; + DanmakuAutoLoadDefault?: boolean; TMDBApiKey?: string; TMDBProxy?: string; TMDBReverseProxy?: string; @@ -143,6 +145,8 @@ export async function POST(request: NextRequest) { DanmakuSourceType !== 'custom') || typeof DanmakuApiBase !== 'string' || typeof DanmakuApiToken !== 'string' || + (DanmakuAutoLoadDefault !== undefined && + typeof DanmakuAutoLoadDefault !== 'boolean') || (TMDBApiKey !== undefined && typeof TMDBApiKey !== 'string') || (TMDBProxy !== undefined && typeof TMDBProxy !== 'string') || (TMDBReverseProxy !== undefined && typeof TMDBReverseProxy !== 'string') || @@ -203,6 +207,7 @@ export async function POST(request: NextRequest) { DanmakuSourceType, DanmakuApiBase, DanmakuApiToken, + DanmakuAutoLoadDefault, TMDBApiKey, TMDBProxy, TMDBReverseProxy, 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 71a8ea6..f1e165a 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; @@ -119,6 +120,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 || ''; @@ -190,6 +192,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 11b7327..9571692 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -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(null); // 播放记录跳转按钮状态 @@ -1520,7 +1581,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([]); @@ -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(() => { 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 接管时,不能再给