Merge branch 'pr-265' into dev
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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={() => {
|
||||
|
||||
@@ -11,6 +11,8 @@ import React, {
|
||||
} from 'react';
|
||||
|
||||
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
|
||||
import { generateStorageKey, getCachedPlayRecordsSnapshot } from '@/lib/db.client';
|
||||
import { loadAllLocalEpisodeProgressRecords } from '@/lib/episode-progress';
|
||||
import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
|
||||
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
|
||||
|
||||
@@ -42,6 +44,7 @@ interface EpisodeSelectorProps {
|
||||
onSourceChange?: (source: string, id: string, title: string) => void;
|
||||
currentSource?: string;
|
||||
currentId?: string;
|
||||
episodeProgressContentKey?: string;
|
||||
videoTitle?: string;
|
||||
videoYear?: string;
|
||||
availableSources?: SearchResult[];
|
||||
@@ -75,6 +78,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
onSourceChange,
|
||||
currentSource,
|
||||
currentId,
|
||||
episodeProgressContentKey,
|
||||
videoTitle,
|
||||
availableSources = [],
|
||||
sourceSearchLoading = false,
|
||||
@@ -109,6 +113,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
const [isRetestingAll, setIsRetestingAll] = useState(false);
|
||||
// 标记是否正在进行初始测速
|
||||
const [isInitialTesting, setIsInitialTesting] = useState(false);
|
||||
const [watchedEpisodes, setWatchedEpisodes] = useState<Set<number>>(new Set());
|
||||
|
||||
// 使用 ref 来避免闭包问题
|
||||
const attemptedSourcesRef = useRef<Set<string>>(new Set());
|
||||
@@ -123,6 +128,68 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
videoInfoMapRef.current = videoInfoMap;
|
||||
}, [videoInfoMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!currentSource ||
|
||||
!currentId ||
|
||||
!episodeProgressContentKey
|
||||
) {
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
const episodeRecords = loadAllLocalEpisodeProgressRecords(
|
||||
episodeProgressContentKey
|
||||
);
|
||||
|
||||
for (const [episodeIndex, record] of Object.entries(episodeRecords)) {
|
||||
if (Number(record?.playTime) > 1) {
|
||||
const episodeNumber = Number(episodeIndex) + 1;
|
||||
if (episodeNumber >= 1 && episodeNumber <= totalEpisodes) {
|
||||
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, episodeProgressContentKey, totalEpisodes]);
|
||||
|
||||
// 主要的 tab 状态:'danmaku' | 'episodes' | 'sources'
|
||||
// 默认显示选集选项卡,但如果是房员则显示弹幕
|
||||
const [activeTab, setActiveTab] = useState<'danmaku' | 'episodes' | 'sources'>(
|
||||
@@ -509,9 +576,13 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
|
||||
const handleEpisodeClick = useCallback(
|
||||
(episodeNumber: number) => {
|
||||
if (episodeNumber + 1 === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange?.(episodeNumber);
|
||||
},
|
||||
[onChange]
|
||||
[onChange, value]
|
||||
);
|
||||
|
||||
const handleSourceClick = useCallback(
|
||||
@@ -723,16 +794,25 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
.filter(episodeNumber => !isEpisodeFiltered(episodeNumber))
|
||||
.map((episodeNumber) => {
|
||||
const isActive = episodeNumber === value;
|
||||
const isWatched = watchedEpisodes.has(episodeNumber);
|
||||
return (
|
||||
<button
|
||||
key={episodeNumber}
|
||||
disabled={isActive}
|
||||
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
|
||||
? 'bg-green-500 text-white 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'
|
||||
}`.trim()}
|
||||
? 'bg-green-500 text-white border-green-400 shadow-lg shadow-green-500/25 dark:bg-green-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'
|
||||
} ${isActive ? 'cursor-default' : ''}`.trim()}
|
||||
title={isWatched && !isActive ? '已观看过' : undefined}
|
||||
aria-current={isActive ? 'true' : 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];
|
||||
if (!title) {
|
||||
|
||||
@@ -552,6 +552,10 @@ export const UserMenu: React.FC = () => {
|
||||
const savedDisableAutoLoadDanmaku = localStorage.getItem('disableAutoLoadDanmaku');
|
||||
if (savedDisableAutoLoadDanmaku !== null) {
|
||||
setDisableAutoLoadDanmaku(savedDisableAutoLoadDanmaku === 'true');
|
||||
} else {
|
||||
const runtimeDefault =
|
||||
(window as any).RUNTIME_CONFIG?.DANMAKU_AUTO_LOAD_DEFAULT !== false;
|
||||
setDisableAutoLoadDanmaku(!runtimeDefault);
|
||||
}
|
||||
|
||||
const savedDanmakuMaxCount = localStorage.getItem('danmakuMaxCount');
|
||||
@@ -1343,7 +1347,11 @@ export const UserMenu: React.FC = () => {
|
||||
setBufferStrategy('medium');
|
||||
setNextEpisodePreCache(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);
|
||||
setHomeContinueWatchingEnabled(true);
|
||||
setHomeModules(defaultHomeModules);
|
||||
@@ -1368,7 +1376,10 @@ export const UserMenu: React.FC = () => {
|
||||
localStorage.setItem('bufferStrategy', 'medium');
|
||||
localStorage.setItem('nextEpisodePreCache', 'true');
|
||||
localStorage.setItem('nextEpisodeDanmakuPreload', 'true');
|
||||
localStorage.setItem('disableAutoLoadDanmaku', 'false');
|
||||
localStorage.setItem(
|
||||
'disableAutoLoadDanmaku',
|
||||
String(!defaultDanmakuAutoLoad)
|
||||
);
|
||||
localStorage.setItem('danmakuMaxCount', '0');
|
||||
localStorage.setItem('danmaku_heatmap_disabled', 'false');
|
||||
localStorage.setItem('homeBannerEnabled', 'true');
|
||||
|
||||
@@ -982,7 +982,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actualEpisodes && actualEpisodes > 1 && orientation === 'vertical' && (
|
||||
{orientation === 'vertical' &&
|
||||
((actualEpisodes && actualEpisodes > 1) || displayYear) && (
|
||||
<div
|
||||
className='absolute top-1 right-1 sm:top-2 sm:right-2 flex flex-col gap-0.5 sm:gap-1.5'
|
||||
style={{
|
||||
@@ -996,20 +997,22 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
}}
|
||||
>
|
||||
{/* 集数显示 */}
|
||||
<div
|
||||
className='bg-black/60 text-white text-[9px] sm:text-xs font-medium px-2 sm:px-3 py-0.5 sm:py-1 rounded-full shadow-md transition-all duration-300 ease-out group-hover:scale-110 backdrop-blur-sm flex items-center justify-center'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
共{actualEpisodes}集
|
||||
</div>
|
||||
{actualEpisodes && actualEpisodes > 1 && (
|
||||
<div
|
||||
className='bg-black/60 text-white text-[9px] sm:text-xs font-medium px-2 sm:px-3 py-0.5 sm:py-1 rounded-full shadow-md transition-all duration-300 ease-out group-hover:scale-110 backdrop-blur-sm flex items-center justify-center'
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
共{actualEpisodes}集
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 年份显示 */}
|
||||
{displayYear && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface VirtualScrollableGridProps {
|
||||
children: React.ReactNode[];
|
||||
@@ -17,6 +17,32 @@ interface VirtualScrollableGridProps {
|
||||
|
||||
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
|
||||
|
||||
const DEFAULT_ROW_HEIGHT = 320;
|
||||
const MAX_MEASURE_ITEMS = 24;
|
||||
const SAME_ROW_TOLERANCE = 1;
|
||||
|
||||
interface LayoutMetrics {
|
||||
columns: number;
|
||||
rowHeight: number;
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
const getViewportScrollTop = () => {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
return (
|
||||
window.scrollY ||
|
||||
window.pageYOffset ||
|
||||
document.documentElement.scrollTop ||
|
||||
document.body.scrollTop ||
|
||||
0
|
||||
);
|
||||
};
|
||||
|
||||
const parsePixelValue = (value?: string) => {
|
||||
const parsed = Number.parseFloat(value ?? '');
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
export default function VirtualScrollableGrid({
|
||||
children,
|
||||
gridClassName,
|
||||
@@ -26,101 +52,235 @@ export default function VirtualScrollableGrid({
|
||||
maxContentWidth = 1400,
|
||||
}: VirtualScrollableGridProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const columnsRef = useRef<number>(mobileColumns);
|
||||
const rowHeightRef = useRef<number>(320);
|
||||
const totalRowsRef = useRef<number>(0);
|
||||
const measureGridRef = useRef<HTMLDivElement>(null);
|
||||
const childrenRef = useRef(children);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const needsMeasureRef = useRef(true);
|
||||
|
||||
childrenRef.current = children;
|
||||
|
||||
const initialLayout: LayoutMetrics = {
|
||||
columns: Math.max(1, mobileColumns),
|
||||
rowHeight: DEFAULT_ROW_HEIGHT,
|
||||
totalRows: Math.ceil(children.length / Math.max(1, mobileColumns)),
|
||||
};
|
||||
const layoutRef = useRef<LayoutMetrics>(initialLayout);
|
||||
|
||||
const [layout, setLayout] = useState<LayoutMetrics>(() => initialLayout);
|
||||
const [range, setRange] = useState({ startRow: 0, endRow: 0 });
|
||||
|
||||
const computeColumns = () => {
|
||||
const computeFallbackColumns = () => {
|
||||
if (typeof window === 'undefined') return mobileColumns;
|
||||
const width = window.innerWidth;
|
||||
if (width < 640) return mobileColumns;
|
||||
const containerWidth = Math.min(width - 32, maxContentWidth);
|
||||
if (window.innerWidth < 640) return mobileColumns;
|
||||
|
||||
const containerWidth = Math.min(
|
||||
containerRef.current?.clientWidth ?? window.innerWidth - 32,
|
||||
maxContentWidth
|
||||
);
|
||||
|
||||
return Math.max(mobileColumns, Math.floor(containerWidth / minItemWidth));
|
||||
};
|
||||
|
||||
const updateLayout = () => {
|
||||
const gapY = window.innerWidth >= 640 ? 80 : 56; // gap-y-14 / sm:gap-y-20
|
||||
const columns = computeColumns();
|
||||
columnsRef.current = columns;
|
||||
totalRowsRef.current = Math.ceil(children.length / Math.max(1, columns));
|
||||
const readLayout = (): LayoutMetrics => {
|
||||
const currentChildren = childrenRef.current;
|
||||
|
||||
// Measure a single item height (wrapper div around VideoCard) and add vertical gap.
|
||||
const measureEl = measureRef.current;
|
||||
const firstItem = measureEl?.querySelector<HTMLElement>('[data-virtual-measure-item]');
|
||||
const itemH = firstItem?.getBoundingClientRect().height;
|
||||
if (itemH && Number.isFinite(itemH) && itemH > 0) {
|
||||
rowHeightRef.current = Math.max(120, Math.round(itemH + gapY));
|
||||
if (currentChildren.length === 0) {
|
||||
return {
|
||||
columns: Math.max(1, mobileColumns),
|
||||
rowHeight: DEFAULT_ROW_HEIGHT,
|
||||
totalRows: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const measureGrid = measureGridRef.current;
|
||||
const measureItems = measureGrid
|
||||
? Array.from(
|
||||
measureGrid.querySelectorAll<HTMLElement>('[data-virtual-measure-item]')
|
||||
)
|
||||
: [];
|
||||
|
||||
let columns = computeFallbackColumns();
|
||||
let rowHeight = DEFAULT_ROW_HEIGHT;
|
||||
|
||||
if (measureItems.length > 0) {
|
||||
const firstTop = measureItems[0].offsetTop;
|
||||
let detectedColumns = 0;
|
||||
let nextRowTop: number | null = null;
|
||||
|
||||
for (const item of measureItems) {
|
||||
if (Math.abs(item.offsetTop - firstTop) <= SAME_ROW_TOLERANCE) {
|
||||
detectedColumns += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextRowTop = item.offsetTop;
|
||||
break;
|
||||
}
|
||||
|
||||
if (detectedColumns > 0) {
|
||||
columns = Math.max(1, detectedColumns);
|
||||
}
|
||||
|
||||
const firstItemHeight = measureItems[0].getBoundingClientRect().height;
|
||||
const rowGap = measureGrid
|
||||
? parsePixelValue(window.getComputedStyle(measureGrid).rowGap)
|
||||
: 0;
|
||||
|
||||
if (nextRowTop != null && nextRowTop > firstTop) {
|
||||
rowHeight = Math.max(120, Math.round(nextRowTop - firstTop));
|
||||
} else if (firstItemHeight > 0) {
|
||||
rowHeight = Math.max(120, Math.round(firstItemHeight + rowGap));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
columns,
|
||||
rowHeight,
|
||||
totalRows: Math.ceil(currentChildren.length / Math.max(1, columns)),
|
||||
};
|
||||
};
|
||||
|
||||
const updateRange = () => {
|
||||
const computeRange = (nextLayout: LayoutMetrics) => {
|
||||
if (nextLayout.totalRows <= 0 || typeof window === 'undefined') {
|
||||
return { startRow: 0, endRow: 0 };
|
||||
}
|
||||
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (!el || nextLayout.rowHeight <= 0) {
|
||||
return {
|
||||
startRow: 0,
|
||||
endRow: Math.min(nextLayout.totalRows - 1, overscanRows * 2),
|
||||
};
|
||||
}
|
||||
|
||||
const totalRows = totalRowsRef.current;
|
||||
if (totalRows <= 0) return;
|
||||
|
||||
const rowHeight = rowHeightRef.current;
|
||||
if (!rowHeight || rowHeight <= 0) return;
|
||||
|
||||
// This app uses `document.body` as the actual scroll container (see search page back-to-top logic).
|
||||
const scrollTop = document.body.scrollTop || 0;
|
||||
const scrollTop = getViewportScrollTop();
|
||||
const viewportBottom = scrollTop + window.innerHeight;
|
||||
const containerTop = el.getBoundingClientRect().top + scrollTop;
|
||||
|
||||
const startRow = Math.floor((scrollTop - containerTop) / rowHeight) - overscanRows;
|
||||
const endRow = Math.ceil((viewportBottom - containerTop) / rowHeight) + overscanRows;
|
||||
const startRow =
|
||||
Math.floor((scrollTop - containerTop) / nextLayout.rowHeight) - overscanRows;
|
||||
const endRow =
|
||||
Math.ceil((viewportBottom - containerTop) / nextLayout.rowHeight) +
|
||||
overscanRows;
|
||||
|
||||
const clampedStart = clamp(startRow, 0, Math.max(0, totalRows - 1));
|
||||
const clampedEnd = clamp(endRow, clampedStart, Math.max(0, totalRows - 1));
|
||||
const clampedStart = clamp(startRow, 0, Math.max(0, nextLayout.totalRows - 1));
|
||||
const clampedEnd = clamp(
|
||||
endRow,
|
||||
clampedStart,
|
||||
Math.max(0, nextLayout.totalRows - 1)
|
||||
);
|
||||
|
||||
return { startRow: clampedStart, endRow: clampedEnd };
|
||||
};
|
||||
|
||||
const syncRange = (nextLayout: LayoutMetrics) => {
|
||||
const nextRange = computeRange(nextLayout);
|
||||
setRange((prev) => {
|
||||
if (prev.startRow === clampedStart && prev.endRow === clampedEnd) return prev;
|
||||
return { startRow: clampedStart, endRow: clampedEnd };
|
||||
if (
|
||||
prev.startRow === nextRange.startRow &&
|
||||
prev.endRow === nextRange.endRow
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return nextRange;
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
const syncMeasuredLayout = () => {
|
||||
const nextLayout = readLayout();
|
||||
layoutRef.current = nextLayout;
|
||||
|
||||
setLayout((prev) => {
|
||||
if (
|
||||
prev.columns === nextLayout.columns &&
|
||||
prev.rowHeight === nextLayout.rowHeight &&
|
||||
prev.totalRows === nextLayout.totalRows
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return nextLayout;
|
||||
});
|
||||
|
||||
syncRange(nextLayout);
|
||||
};
|
||||
|
||||
const scheduleUpdate = (measure = false) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (measure) {
|
||||
needsMeasureRef.current = true;
|
||||
}
|
||||
if (rafRef.current != null) return;
|
||||
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
updateLayout();
|
||||
updateRange();
|
||||
|
||||
if (needsMeasureRef.current) {
|
||||
needsMeasureRef.current = false;
|
||||
syncMeasuredLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
syncRange(layoutRef.current);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateLayout();
|
||||
updateRange();
|
||||
scheduleUpdate(true);
|
||||
|
||||
let isRunning = true;
|
||||
const rafLoop = () => {
|
||||
if (!isRunning) return;
|
||||
const handleScroll = () => {
|
||||
scheduleUpdate();
|
||||
window.requestAnimationFrame(rafLoop);
|
||||
};
|
||||
rafLoop();
|
||||
|
||||
document.body.addEventListener('scroll', scheduleUpdate, { passive: true });
|
||||
window.addEventListener('resize', scheduleUpdate);
|
||||
const handleResize = () => {
|
||||
scheduleUpdate(true);
|
||||
};
|
||||
|
||||
const bodyEl = document.body;
|
||||
const documentEl = document.documentElement;
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
bodyEl.addEventListener('scroll', handleScroll, { passive: true });
|
||||
documentEl.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('orientationchange', handleResize);
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
scheduleUpdate(true);
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
if (measureGridRef.current) {
|
||||
resizeObserver.observe(measureGridRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
isRunning = false;
|
||||
document.body.removeEventListener('scroll', scheduleUpdate);
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
bodyEl.removeEventListener('scroll', handleScroll);
|
||||
documentEl.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('orientationchange', handleResize);
|
||||
resizeObserver?.disconnect();
|
||||
if (rafRef.current != null) window.cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [children.length, overscanRows, mobileColumns, minItemWidth, maxContentWidth]);
|
||||
}, [
|
||||
children.length,
|
||||
gridClassName,
|
||||
overscanRows,
|
||||
mobileColumns,
|
||||
minItemWidth,
|
||||
maxContentWidth,
|
||||
]);
|
||||
|
||||
const columns = columnsRef.current;
|
||||
const totalRows = totalRowsRef.current;
|
||||
const rowHeight = rowHeightRef.current;
|
||||
const columns = layout.columns;
|
||||
const totalRows = layout.totalRows;
|
||||
const rowHeight = layout.rowHeight;
|
||||
|
||||
const startIndex = range.startRow * columns;
|
||||
const endIndexExclusive = Math.min(children.length, (range.endRow + 1) * columns);
|
||||
@@ -130,19 +290,20 @@ export default function VirtualScrollableGrid({
|
||||
const bottomSpacerHeight = Math.max(0, (totalRows - range.endRow - 1) * rowHeight);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='w-full'>
|
||||
<div ref={containerRef} className='relative w-full'>
|
||||
{/* hidden measuring row (first visible row) */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
className='pointer-events-none absolute left-0 top-0 -z-10 opacity-0'
|
||||
className='pointer-events-none absolute left-0 top-0 -z-10 w-full opacity-0'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<div className={gridClassName}>
|
||||
{children.slice(0, Math.max(1, columns)).map((child, idx) => (
|
||||
<div key={`measure-${idx}`} data-virtual-measure-item>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
<div ref={measureGridRef} className={gridClassName}>
|
||||
{children
|
||||
.slice(0, Math.min(children.length, MAX_MEASURE_ITEMS))
|
||||
.map((child, idx) => (
|
||||
<div key={`measure-${idx}`} data-virtual-measure-item>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface AdminConfig {
|
||||
DanmakuSourceType?: 'builtin' | 'custom';
|
||||
DanmakuApiBase: string;
|
||||
DanmakuApiToken: string;
|
||||
DanmakuAutoLoadDefault?: boolean; // 是否默认自动加载弹幕(用户可在本地覆盖)
|
||||
// TMDB配置
|
||||
TMDBApiKey?: string;
|
||||
TMDBProxy?: string;
|
||||
|
||||
@@ -255,6 +255,7 @@ async function getInitConfig(configFile: string, subConfig: {
|
||||
process.env.DANMAKU_API_BASE ||
|
||||
(hasCustomDanmakuEnv ? 'http://localhost:9321' : BUILTIN_DANMAKU_API_BASE),
|
||||
DanmakuApiToken: process.env.DANMAKU_API_TOKEN || '87654321',
|
||||
DanmakuAutoLoadDefault: true,
|
||||
// TMDB配置
|
||||
TMDBApiKey: process.env.TMDB_API_KEY || '',
|
||||
TMDBProxy: process.env.TMDB_PROXY || '',
|
||||
@@ -450,6 +451,7 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
DanmakuSourceType: 'builtin',
|
||||
DanmakuApiBase: BUILTIN_DANMAKU_API_BASE,
|
||||
DanmakuApiToken: '87654321',
|
||||
DanmakuAutoLoadDefault: true,
|
||||
PansouApiUrl: '',
|
||||
PansouUsername: '',
|
||||
PansouPassword: '',
|
||||
@@ -482,6 +484,9 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
if (!adminConfig.SiteConfig.DanmakuApiToken) {
|
||||
adminConfig.SiteConfig.DanmakuApiToken = '87654321';
|
||||
}
|
||||
if (adminConfig.SiteConfig.DanmakuAutoLoadDefault === undefined) {
|
||||
adminConfig.SiteConfig.DanmakuAutoLoadDefault = true;
|
||||
}
|
||||
// 确保评论开关存在
|
||||
if (adminConfig.SiteConfig.EnableComments === undefined) {
|
||||
adminConfig.SiteConfig.EnableComments = false;
|
||||
|
||||
+97
-5
@@ -787,9 +787,21 @@ export async function savePlayRecord(
|
||||
body: JSON.stringify({ key, record }),
|
||||
});
|
||||
} catch (err) {
|
||||
await handleDatabaseOperationFailure('playRecords', err);
|
||||
triggerGlobalError('保存播放记录失败');
|
||||
throw err;
|
||||
// 播放记录以用户体验为优先:保留已经写入的本地缓存,避免切集后记忆进度被回滚。
|
||||
console.warn('同步播放记录到数据库失败,保留本地缓存:', 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;
|
||||
}
|
||||
@@ -875,6 +887,88 @@ export async function deletePlayRecord(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移播放记录到新的 source/id。
|
||||
* 用于换源时保留单一记忆点语义:当前进度迁移到新源后,再清理旧源记录。
|
||||
*/
|
||||
export async function migratePlayRecord(
|
||||
fromSource: string,
|
||||
fromId: string,
|
||||
toSource: string,
|
||||
toId: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
const fromKey = generateStorageKey(fromSource, fromId);
|
||||
const toKey = generateStorageKey(toSource, toId);
|
||||
|
||||
if (fromKey === toKey) {
|
||||
await savePlayRecord(toSource, toId, record);
|
||||
return;
|
||||
}
|
||||
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
const cachedRecords = {
|
||||
...(cacheManager.getCachedPlayRecords() || {}),
|
||||
};
|
||||
|
||||
delete cachedRecords[fromKey];
|
||||
cachedRecords[toKey] = record;
|
||||
cacheManager.cachePlayRecords(cachedRecords);
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: cachedRecords,
|
||||
})
|
||||
);
|
||||
|
||||
const persistMove = async () => {
|
||||
await fetchWithAuth('/api/playrecords', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key: toKey, record }),
|
||||
});
|
||||
|
||||
await fetchWithAuth(`/api/playrecords?key=${encodeURIComponent(fromKey)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
persistMove().catch((err) => {
|
||||
console.warn('迁移播放记录到数据库失败,稍后重试:', err);
|
||||
|
||||
window.setTimeout(() => {
|
||||
persistMove().catch((retryErr) => {
|
||||
console.warn('迁移播放记录后台重试失败:', retryErr);
|
||||
});
|
||||
}, 3000);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端迁移播放记录到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allRecords = await getAllPlayRecords();
|
||||
delete allRecords[fromKey];
|
||||
allRecords[toKey] = record;
|
||||
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('playRecordsUpdated', {
|
||||
detail: allRecords,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('迁移播放记录失败:', err);
|
||||
triggerGlobalError('迁移播放记录失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 搜索历史相关 API ---------------- */
|
||||
|
||||
/**
|
||||
@@ -2244,5 +2338,3 @@ export async function saveEpisodeFilterConfig(
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
const EPISODE_PROGRESS_PREFIX = 'moontv_episode_progress:';
|
||||
const EPISODE_PROGRESS_MAX_SHOWS = 20;
|
||||
const EPISODE_PROGRESS_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 120;
|
||||
|
||||
export interface LocalEpisodeProgressRecord {
|
||||
playTime: number;
|
||||
totalTime: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface EpisodeProgressContentIdentity {
|
||||
doubanId?: number | string;
|
||||
tmdbId?: number | string;
|
||||
title?: string;
|
||||
year?: string;
|
||||
searchType?: string;
|
||||
}
|
||||
|
||||
interface LocalEpisodeProgressStore {
|
||||
updatedAt: number;
|
||||
episodes: Record<string, LocalEpisodeProgressRecord>;
|
||||
}
|
||||
|
||||
function isBrowser() {
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
|
||||
function isQuotaExceededError(error: unknown) {
|
||||
return (
|
||||
error instanceof DOMException &&
|
||||
(error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED')
|
||||
);
|
||||
}
|
||||
|
||||
function parseEpisodeProgressRecord(
|
||||
value: unknown
|
||||
): LocalEpisodeProgressRecord | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = value as Partial<LocalEpisodeProgressRecord>;
|
||||
const playTime = Number(parsed.playTime);
|
||||
const totalTime = Number(parsed.totalTime);
|
||||
const updatedAt = Number(parsed.updatedAt);
|
||||
|
||||
if (!Number.isFinite(playTime) || playTime <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
playTime: Math.floor(playTime),
|
||||
totalTime: Number.isFinite(totalTime) && totalTime >= 0 ? Math.floor(totalTime) : 0,
|
||||
updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEpisodeProgressStore(
|
||||
value: unknown
|
||||
): { store: LocalEpisodeProgressStore | null; changed: boolean } {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return { store: null, changed: false };
|
||||
}
|
||||
|
||||
const parsed = value as Partial<LocalEpisodeProgressStore>;
|
||||
const rawEpisodes = parsed.episodes;
|
||||
if (!rawEpisodes || typeof rawEpisodes !== 'object') {
|
||||
return { store: null, changed: false };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const episodes: Record<string, LocalEpisodeProgressRecord> = {};
|
||||
let latestUpdatedAt = 0;
|
||||
let changed = false;
|
||||
|
||||
for (const [episodeIndex, entry] of Object.entries(rawEpisodes)) {
|
||||
const normalized = parseEpisodeProgressRecord(entry);
|
||||
if (!normalized) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.updatedAt > 0 &&
|
||||
now - normalized.updatedAt > EPISODE_PROGRESS_MAX_AGE_MS
|
||||
) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
episodes[episodeIndex] = normalized;
|
||||
latestUpdatedAt = Math.max(latestUpdatedAt, normalized.updatedAt);
|
||||
}
|
||||
|
||||
if (Object.keys(episodes).length === 0) {
|
||||
return { store: null, changed: true };
|
||||
}
|
||||
|
||||
const rootUpdatedAt = Number(parsed.updatedAt);
|
||||
const normalizedUpdatedAt =
|
||||
Number.isFinite(rootUpdatedAt) && rootUpdatedAt > 0
|
||||
? Math.max(rootUpdatedAt, latestUpdatedAt)
|
||||
: latestUpdatedAt;
|
||||
|
||||
if (normalizedUpdatedAt !== rootUpdatedAt) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return {
|
||||
store: {
|
||||
updatedAt: normalizedUpdatedAt,
|
||||
episodes,
|
||||
},
|
||||
changed,
|
||||
};
|
||||
}
|
||||
|
||||
function readEpisodeProgressStore(contentKey: string): LocalEpisodeProgressStore | null {
|
||||
if (!isBrowser()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = getEpisodeProgressStorageKey(contentKey);
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const { store, changed } = normalizeEpisodeProgressStore(parsed);
|
||||
|
||||
if (!store) {
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
localStorage.setItem(key, JSON.stringify(store));
|
||||
}
|
||||
|
||||
return store;
|
||||
} catch {
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectEpisodeProgressEntries() {
|
||||
if (!isBrowser()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keys = Array.from({ length: localStorage.length }, (_, index) =>
|
||||
localStorage.key(index)
|
||||
).filter((key): key is string => Boolean(key));
|
||||
|
||||
const entries: Array<{ key: string; updatedAt: number }> = [];
|
||||
|
||||
for (const key of keys) {
|
||||
if (!key.startsWith(EPISODE_PROGRESS_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
localStorage.removeItem(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const { store, changed } = normalizeEpisodeProgressStore(parsed);
|
||||
if (!store) {
|
||||
localStorage.removeItem(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
localStorage.setItem(key, JSON.stringify(store));
|
||||
}
|
||||
|
||||
entries.push({
|
||||
key,
|
||||
updatedAt: store.updatedAt,
|
||||
});
|
||||
} catch {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function normalizeContentTitle(title: string) {
|
||||
return title
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[\uff01-\uff5e]/g, (char) =>
|
||||
String.fromCharCode(char.charCodeAt(0) - 0xfee0)
|
||||
)
|
||||
.replace(/[()()[\]【】{}「」『』<>《》]/g, '')
|
||||
.replace(/[^\w\u4e00-\u9fa5]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeContentIdentityId(value: unknown) {
|
||||
const numericValue = Number(value);
|
||||
if (Number.isFinite(numericValue) && numericValue > 0) {
|
||||
return String(Math.floor(numericValue));
|
||||
}
|
||||
|
||||
const text = String(value || '').trim();
|
||||
if (/^[1-9]\d*$/.test(text)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildLegacyEpisodeProgressContentKey(
|
||||
identity: EpisodeProgressContentIdentity
|
||||
) {
|
||||
const title = normalizeContentTitle(identity.title || '');
|
||||
const year = String(identity.year || '').trim();
|
||||
const searchType = String(identity.searchType || '').trim().toLowerCase();
|
||||
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${title}|${year}|${searchType}`;
|
||||
}
|
||||
|
||||
export function buildEpisodeProgressContentKey(
|
||||
identity: EpisodeProgressContentIdentity
|
||||
) {
|
||||
const doubanId = normalizeContentIdentityId(identity.doubanId);
|
||||
if (doubanId) {
|
||||
return `douban:${doubanId}`;
|
||||
}
|
||||
|
||||
const tmdbId = normalizeContentIdentityId(identity.tmdbId);
|
||||
if (tmdbId) {
|
||||
const searchType = String(identity.searchType || '').trim().toLowerCase();
|
||||
return searchType ? `tmdb:${searchType}:${tmdbId}` : `tmdb:${tmdbId}`;
|
||||
}
|
||||
|
||||
return buildLegacyEpisodeProgressContentKey(identity);
|
||||
}
|
||||
|
||||
export function getEpisodeProgressStorageKey(contentKey: string) {
|
||||
return `${EPISODE_PROGRESS_PREFIX}${contentKey}`;
|
||||
}
|
||||
|
||||
export function loadAllLocalEpisodeProgressRecords(contentKey: string | null) {
|
||||
if (!contentKey) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return readEpisodeProgressStore(contentKey)?.episodes || {};
|
||||
}
|
||||
|
||||
export function loadLocalEpisodeProgressRecord(
|
||||
contentKey: string | null,
|
||||
episodeIndex: number
|
||||
) {
|
||||
const episodes = loadAllLocalEpisodeProgressRecords(contentKey);
|
||||
return episodes[String(episodeIndex)] || null;
|
||||
}
|
||||
|
||||
export function loadLocalEpisodeProgress(
|
||||
contentKey: string | null,
|
||||
episodeIndex: number
|
||||
) {
|
||||
const record = loadLocalEpisodeProgressRecord(contentKey, episodeIndex);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number.isFinite(record.playTime) && record.playTime > 1
|
||||
? Math.floor(record.playTime)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function pruneLocalEpisodeProgressStorage(
|
||||
maxShows = EPISODE_PROGRESS_MAX_SHOWS
|
||||
) {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = collectEpisodeProgressEntries().sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt
|
||||
);
|
||||
|
||||
if (entries.length <= maxShows) {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.slice(maxShows).forEach(({ key }) => {
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
}
|
||||
|
||||
export function saveLocalEpisodeProgress(
|
||||
contentKey: string | null,
|
||||
episodeIndex: number,
|
||||
playTime: number,
|
||||
totalTime: number
|
||||
) {
|
||||
if (
|
||||
!isBrowser() ||
|
||||
!contentKey ||
|
||||
!Number.isFinite(playTime) ||
|
||||
playTime <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = getEpisodeProgressStorageKey(contentKey);
|
||||
const now = Date.now();
|
||||
const currentStore = readEpisodeProgressStore(contentKey);
|
||||
const shouldPruneAfterSave = !currentStore;
|
||||
const nextStore: LocalEpisodeProgressStore = {
|
||||
updatedAt: now,
|
||||
episodes: {
|
||||
...(currentStore?.episodes || {}),
|
||||
[String(episodeIndex)]: {
|
||||
playTime: Math.floor(playTime),
|
||||
totalTime: Number.isFinite(totalTime) && totalTime >= 0 ? Math.floor(totalTime) : 0,
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const payload = JSON.stringify(nextStore);
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, payload);
|
||||
if (shouldPruneAfterSave) {
|
||||
pruneLocalEpisodeProgressStorage();
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isQuotaExceededError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
pruneLocalEpisodeProgressStorage(
|
||||
Math.max(10, Math.floor(EPISODE_PROGRESS_MAX_SHOWS / 2))
|
||||
);
|
||||
localStorage.setItem(key, payload);
|
||||
pruneLocalEpisodeProgressStorage();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user