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 接管时,不能再给