Merge branch 'pr-265' into dev

This commit is contained in:
mtvpls
2026-04-15 21:18:32 +08:00
14 changed files with 1477 additions and 213 deletions
+31
View File
@@ -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 = ({
</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={(e) =>
setSiteSettings((prev) => ({
...prev,
DanmakuAutoLoadDefault: e.target.checked,
}))
}
/>
<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>
</details>
+5
View File
@@ -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,
+2
View File
@@ -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',
+3
View File
@@ -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',
+561 -81
View File
@@ -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}
+75 -39
View File
@@ -70,6 +70,7 @@ function SearchPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const submittedSearchQuery = searchParams.get('q')?.trim() || '';
const currentQueryRef = useRef<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
@@ -355,19 +356,21 @@ function SearchPageClient() {
return normalizedTitle.includes(normalizedQuery);
};
const allExactSearchResults = useMemo(() => {
if (!exactSearch) return searchResults;
return searchResults.filter((item) =>
titleContainsQuery(item.title, submittedSearchQuery)
);
}, [searchResults, submittedSearchQuery, exactSearch]);
// 聚合后的结果(按标题和年份分组)
const aggregatedResults = useMemo(() => {
// 首先应用精确搜索过滤
const filteredResults = exactSearch
? searchResults.filter((item) =>
titleContainsQuery(item.title, currentQueryRef.current)
)
: searchResults;
//===== 阶段1:按 normalizedTitle-type 初步分组 =====
const preliminaryMap = new Map<string, SearchResult[]>();
filteredResults.forEach((item) => {
allExactSearchResults.forEach((item) => {
const normalizedTitle = normalizeTitle(item.title);
const type = getType(item);
const preliminaryKey = `${normalizedTitle}-${type}`;
@@ -428,7 +431,7 @@ function SearchPageClient() {
return keyOrder.map(
(key) => [key, finalMap.get(key)!] as [string, SearchResult[]]
);
}, [searchResults, exactSearch]);
}, [allExactSearchResults]);
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新
useEffect(() => {
@@ -641,14 +644,7 @@ function SearchPageClient() {
const filteredAllResults = useMemo(() => {
const { source, title, year, yearOrder } = filterAll;
// 首先应用精确搜索过滤
const exactSearchFiltered = exactSearch
? searchResults.filter((item) =>
titleContainsQuery(item.title, currentQueryRef.current)
)
: searchResults;
const filtered = exactSearchFiltered.filter((item) => {
const filtered = allExactSearchResults.filter((item) => {
if (source !== 'all' && item.source !== source) return false;
if (title !== 'all' && item.title !== title) return false;
if (year !== 'all' && item.year !== year) return false;
@@ -677,7 +673,7 @@ function SearchPageClient() {
? a.title.localeCompare(b.title)
: b.title.localeCompare(a.title);
});
}, [searchResults, filterAll, searchQuery, exactSearch]);
}, [allExactSearchResults, filterAll, searchQuery]);
// 聚合:应用筛选与排序
const filteredAggResults = useMemo(() => {
@@ -734,6 +730,30 @@ function SearchPageClient() {
filteredAllResults.length,
]);
const resultCountMeta = useMemo(() => {
const isAggregateView = viewMode === 'agg';
const visibleCount = isAggregateView
? filteredAggResults.length
: filteredAllResults.length;
const totalCount = isAggregateView
? aggregatedResults.length
: allExactSearchResults.length;
return {
visibleCount,
totalCount,
isFiltered: visibleCount !== totalCount,
modeLabel: isAggregateView ? '聚合结果' : '搜索结果',
unit: isAggregateView ? '组' : '条',
};
}, [
viewMode,
filteredAggResults.length,
filteredAllResults.length,
aggregatedResults.length,
allExactSearchResults.length,
]);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('searchResultDisplayMode', resultDisplayMode);
@@ -1570,28 +1590,44 @@ function SearchPageClient() {
<>
{/* 影视搜索结果 */}
{/* 标题 */}
<div className='mb-4 flex items-center justify-between'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
{isFromCache ? (
<span className='ml-2 rounded-md bg-green-50 px-2 py-0.5 text-xs font-medium text-green-600 dark:bg-green-900/30 dark:text-green-400'>
<div className='mb-4 flex items-start justify-between gap-4'>
<div className='min-w-0'>
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
{isFromCache ? (
<span className='ml-2 rounded-md bg-green-50 px-2 py-0.5 text-xs font-medium text-green-600 dark:bg-green-900/30 dark:text-green-400'>
</span>
) : (
<>
{totalSources > 0 && useFluidSearch && (
<span className='ml-2 text-sm font-normal text-gray-500 dark:text-gray-400'>
{completedSources}/{totalSources}
</span>
)}
{isLoading && useFluidSearch && (
<span className='ml-2 inline-block align-middle'>
<span className='inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-300 border-t-green-500'></span>
</span>
)}
</>
)}
</h2>
<div className='mt-2 flex flex-wrap items-center gap-2 text-xs'>
<span className='inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200'>
{resultCountMeta.modeLabel}{' '}
{resultCountMeta.visibleCount.toLocaleString()}{' '}
{resultCountMeta.unit}
</span>
) : (
<>
{totalSources > 0 && useFluidSearch && (
<span className='ml-2 text-sm font-normal text-gray-500 dark:text-gray-400'>
{completedSources}/{totalSources}
</span>
)}
{isLoading && useFluidSearch && (
<span className='ml-2 inline-block align-middle'>
<span className='inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-300 border-t-green-500'></span>
</span>
)}
</>
)}
</h2>
{resultCountMeta.isFiltered && (
<span className='inline-flex items-center rounded-full bg-white/80 px-2.5 py-1 font-medium text-gray-500 ring-1 ring-gray-200 dark:bg-gray-900/70 dark:text-gray-400 dark:ring-gray-700'>
{' '}
{resultCountMeta.totalCount.toLocaleString()}{' '}
{resultCountMeta.unit}
</span>
)}
</div>
</div>
{searchQuery && (
<button
onClick={() => {