Use content ids for episode progress keys

This commit is contained in:
ShiGuangAlex
2026-04-15 11:13:18 +08:00
parent 6372ed307d
commit d3eb78d9ce
3 changed files with 207 additions and 52 deletions
+127 -29
View File
@@ -4,7 +4,7 @@
import { AlertCircle, Cloud, Heart, Loader2, Router, Sparkles, X } from 'lucide-react'; import { AlertCircle, Cloud, Heart, Loader2, Router, Sparkles, X } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation'; 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 { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import { import {
@@ -633,12 +633,29 @@ function PlayPageClient() {
// 搜索所需信息 // 搜索所需信息
const [searchTitle] = useState(searchParams.get('stitle') || ''); const [searchTitle] = useState(searchParams.get('stitle') || '');
const [searchType] = useState(searchParams.get('stype') || ''); const [searchType] = useState(searchParams.get('stype') || '');
const [episodeProgressContentKey] = useState(() => const [initialEpisodeProgressTitle] = useState(
buildEpisodeProgressContentKey({ searchTitle || searchParams.get('title') || ''
title: searchTitle || searchParams.get('title') || '', );
year: searchParams.get('year') || '', 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, searchType,
}) videoDoubanId,
]
); );
// 是否需要优选 // 是否需要优选
@@ -2719,13 +2736,13 @@ function PlayPageClient() {
const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => { const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => {
if (!video || !url) return; if (!video || !url) return;
const sources = Array.from(video.getElementsByTagName('source')); const sources = Array.from(video.getElementsByTagName('source'));
const isHlsJsActive = !!(video as any).hls;
const isHlsLikeSource = const isHlsLikeSource =
!!(video as any).hls ||
/\.m3u8?($|\?)/i.test(url) || /\.m3u8?($|\?)/i.test(url) ||
url.includes('/api/proxy-m3u8') || url.includes('/api/proxy-m3u8') ||
url.includes('/api/proxy/vod/m3u8'); url.includes('/api/proxy/vod/m3u8');
if (isHlsLikeSource) { if (isHlsJsActive && isHlsLikeSource) {
// HLS 由 hls.js 接管时,不能再给 <video> 塞原始 m3u8 source // HLS 由 hls.js 接管时,不能再给 <video> 塞原始 m3u8 source
// 否则 Safari 可能切回原生 HLS,和 MSE/hls.js 抢同一个播放器。 // 否则 Safari 可能切回原生 HLS,和 MSE/hls.js 抢同一个播放器。
sources.forEach((s) => s.remove()); sources.forEach((s) => s.remove());
@@ -3898,6 +3915,13 @@ function PlayPageClient() {
// 加载播放记录 // 加载播放记录
try { try {
const detailEpisodeProgressContentKey = buildEpisodeProgressContentKey({
doubanId: detailData.douban_id,
tmdbId: detailData.tmdb_id,
title: initialEpisodeProgressTitle,
year: initialEpisodeProgressYear,
searchType,
});
const allRecords = await getAllPlayRecords(); const allRecords = await getAllPlayRecords();
const key = generateStorageKey(detailData.source, detailData.id); const key = generateStorageKey(detailData.source, detailData.id);
const record = allRecords[key]; const record = allRecords[key];
@@ -3923,7 +3947,7 @@ function PlayPageClient() {
// 否则使用点击的文件集数,从头开始播放 // 否则使用点击的文件集数,从头开始播放
initialIndex = detailData.initialEpisodeIndex; initialIndex = detailData.initialEpisodeIndex;
const localEpisodeTime = loadLocalEpisodeProgress( const localEpisodeTime = loadLocalEpisodeProgress(
episodeProgressContentKey, detailEpisodeProgressContentKey,
initialIndex initialIndex
); );
resumeTimeRef.current = localEpisodeTime; resumeTimeRef.current = localEpisodeTime;
@@ -3942,7 +3966,7 @@ function PlayPageClient() {
// 使用点击的文件集数 // 使用点击的文件集数
initialIndex = detailData.initialEpisodeIndex; initialIndex = detailData.initialEpisodeIndex;
resumeTimeRef.current = loadLocalEpisodeProgress( resumeTimeRef.current = loadLocalEpisodeProgress(
episodeProgressContentKey, detailEpisodeProgressContentKey,
initialIndex initialIndex
); );
console.log('[Play] 没有播放记录,使用点击的文件集数:', initialIndex); console.log('[Play] 没有播放记录,使用点击的文件集数:', initialIndex);
@@ -3950,7 +3974,7 @@ function PlayPageClient() {
// 默认从第0集开始 // 默认从第0集开始
initialIndex = 0; initialIndex = 0;
resumeTimeRef.current = loadLocalEpisodeProgress( resumeTimeRef.current = loadLocalEpisodeProgress(
episodeProgressContentKey, detailEpisodeProgressContentKey,
initialIndex initialIndex
); );
console.log('[Play] 没有播放记录,从第0集开始'); console.log('[Play] 没有播放记录,从第0集开始');
@@ -4220,6 +4244,14 @@ function PlayPageClient() {
return; return;
} }
const newEpisodeProgressContentKey = buildEpisodeProgressContentKey({
doubanId: newDetail.douban_id,
tmdbId: newDetail.tmdb_id,
title: initialEpisodeProgressTitle,
year: initialEpisodeProgressYear,
searchType,
});
// 尝试跳转到当前正在播放的集数 // 尝试跳转到当前正在播放的集数
const previousEpisodeIndex = currentEpisodeIndexRef.current; const previousEpisodeIndex = currentEpisodeIndexRef.current;
const previousSource = currentSourceRef.current; const previousSource = currentSourceRef.current;
@@ -4235,7 +4267,7 @@ function PlayPageClient() {
const resumeTime = isSameEpisodeSwitch const resumeTime = isSameEpisodeSwitch
? await getSourceSwitchResumeTime(previousEpisodeIndex, currentPlayTime) ? await getSourceSwitchResumeTime(previousEpisodeIndex, currentPlayTime)
: loadLocalEpisodeProgress( : loadLocalEpisodeProgress(
episodeProgressContentKey, newEpisodeProgressContentKey,
targetIndex targetIndex
); );
resumeTimeRef.current = resumeTime; resumeTimeRef.current = resumeTime;
@@ -4280,7 +4312,7 @@ function PlayPageClient() {
if (isSameEpisodeSwitch && resumeTime && resumeTime > 1) { if (isSameEpisodeSwitch && resumeTime && resumeTime > 1) {
const currentDuration = artPlayerRef.current?.duration || 0; const currentDuration = artPlayerRef.current?.duration || 0;
saveLocalEpisodeProgress( saveLocalEpisodeProgress(
episodeProgressContentKey, newEpisodeProgressContentKey,
targetIndex, targetIndex,
resumeTime, resumeTime,
currentDuration currentDuration
@@ -5730,6 +5762,29 @@ function PlayPageClient() {
const Artplayer = ArtplayerModule.default; const Artplayer = ArtplayerModule.default;
const Hls = HlsModule.default; const Hls = HlsModule.default;
const artplayerPluginDanmuku = DanmukuPlugin.default as any; 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 = () => { const syncPlaybackPitch = () => {
if (!isWebkit || !artPlayerRef.current?.video) { if (!isWebkit || !artPlayerRef.current?.video) {
@@ -5749,6 +5804,43 @@ function PlayPageClient() {
} }
}; };
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 = ( const rescueWebkitHlsBootstrap = (
reason: string, reason: string,
retryDelays: number[] = [1500, 3500, 6000] retryDelays: number[] = [1500, 3500, 6000]
@@ -5766,15 +5858,13 @@ function PlayPageClient() {
}; };
retryDelays.forEach((delay) => { retryDelays.forEach((delay) => {
window.setTimeout(() => { schedulePlayerTimeout(() => {
if (!artPlayerRef.current || artPlayerRef.current.video !== video) { if (!artPlayerRef.current || artPlayerRef.current.video !== video) {
return; return;
} }
const hls = video.hls; const hls = video.hls;
const currentSrc = video.currentSrc || video.src || ''; if (!shouldRescueWebkitHls(video)) {
if (!hls || currentSrc || video.readyState > 0) {
return; return;
} }
@@ -5962,18 +6052,15 @@ function PlayPageClient() {
hls.on(Hls.Events.MEDIA_ATTACHED, () => { hls.on(Hls.Events.MEDIA_ATTACHED, () => {
kickStartHlsPlayback(); kickStartHlsPlayback();
});
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
if (!isWebkit) { if (!isWebkit) {
return; return;
} }
// Safari 偶发出现 media 已 attach 但 video.src 仍为空的状态。 // Safari 偶发出现 media 已 attach 但 video.src 仍为空的状态。
// 这里延迟自检一次,必要时重新 attach,强制进入 blob: MSE 路径。 // 这里延迟自检一次,必要时重新 attach,强制回到 blob: MSE 路径。
window.setTimeout(() => { schedulePlayerTimeout(() => {
const currentSrc = video.currentSrc || video.src || ''; if (!shouldRescueWebkitHls(video)) {
if (currentSrc) {
return; return;
} }
@@ -5993,9 +6080,8 @@ function PlayPageClient() {
video.hls = hls; video.hls = hls;
if (isWebkit) { if (isWebkit) {
window.setTimeout(() => { schedulePlayerTimeout(() => {
const currentSrc = video.currentSrc || video.src || ''; if (!shouldRescueWebkitHls(video)) {
if (currentSrc) {
return; return;
} }
@@ -6864,6 +6950,10 @@ function PlayPageClient() {
], ],
}); });
artPlayerRef.current.on('destroy', () => {
clearPlayerTimeouts();
});
// 监听播放器事件 // 监听播放器事件
artPlayerRef.current.on('ready', async () => { artPlayerRef.current.on('ready', async () => {
setError(null); setError(null);
@@ -7753,8 +7843,16 @@ function PlayPageClient() {
} }
resumeTimeRef.current = null; resumeTimeRef.current = null;
setTimeout(() => { schedulePlayerTimeout(() => {
if (!artPlayerRef.current) {
return;
}
const restorePlaybackRate = () => { const restorePlaybackRate = () => {
if (!artPlayerRef.current) {
return;
}
if ( if (
Math.abs( Math.abs(
artPlayerRef.current.playbackRate - lastPlaybackRateRef.current artPlayerRef.current.playbackRate - lastPlaybackRateRef.current
@@ -7781,10 +7879,10 @@ function PlayPageClient() {
if (video.seeking) { if (video.seeking) {
const handleSeeked = () => { const handleSeeked = () => {
window.clearTimeout(seekedTimeout); clearTrackedTimeout(seekedTimeout);
applyRateAfterSeek(); applyRateAfterSeek();
}; };
const seekedTimeout = window.setTimeout(() => { const seekedTimeout = schedulePlayerTimeout(() => {
video.removeEventListener('seeked', handleSeeked); video.removeEventListener('seeked', handleSeeked);
applyRateAfterSeek(); applyRateAfterSeek();
}, 300); }, 300);
+38 -19
View File
@@ -55,14 +55,18 @@ export default function VirtualScrollableGrid({
const measureGridRef = useRef<HTMLDivElement>(null); const measureGridRef = useRef<HTMLDivElement>(null);
const childrenRef = useRef(children); const childrenRef = useRef(children);
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
const needsMeasureRef = useRef(true);
childrenRef.current = children; childrenRef.current = children;
const [layout, setLayout] = useState<LayoutMetrics>(() => ({ const initialLayout: LayoutMetrics = {
columns: Math.max(1, mobileColumns), columns: Math.max(1, mobileColumns),
rowHeight: DEFAULT_ROW_HEIGHT, rowHeight: DEFAULT_ROW_HEIGHT,
totalRows: Math.ceil(children.length / Math.max(1, mobileColumns)), 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 [range, setRange] = useState({ startRow: 0, endRow: 0 });
const computeFallbackColumns = () => { const computeFallbackColumns = () => {
@@ -169,9 +173,23 @@ export default function VirtualScrollableGrid({
return { startRow: clampedStart, endRow: clampedEnd }; return { startRow: clampedStart, endRow: clampedEnd };
}; };
const syncLayoutAndRange = () => { const syncRange = (nextLayout: LayoutMetrics) => {
const nextLayout = readLayout();
const nextRange = computeRange(nextLayout); const nextRange = computeRange(nextLayout);
setRange((prev) => {
if (
prev.startRow === nextRange.startRow &&
prev.endRow === nextRange.endRow
) {
return prev;
}
return nextRange;
});
};
const syncMeasuredLayout = () => {
const nextLayout = readLayout();
layoutRef.current = nextLayout;
setLayout((prev) => { setLayout((prev) => {
if ( if (
@@ -185,37 +203,38 @@ export default function VirtualScrollableGrid({
return nextLayout; return nextLayout;
}); });
setRange((prev) => { syncRange(nextLayout);
if (
prev.startRow === nextRange.startRow &&
prev.endRow === nextRange.endRow
) {
return prev;
}
return nextRange;
});
}; };
const scheduleUpdate = () => { const scheduleUpdate = (measure = false) => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
if (measure) {
needsMeasureRef.current = true;
}
if (rafRef.current != null) return; if (rafRef.current != null) return;
rafRef.current = window.requestAnimationFrame(() => { rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null; rafRef.current = null;
syncLayoutAndRange();
if (needsMeasureRef.current) {
needsMeasureRef.current = false;
syncMeasuredLayout();
return;
}
syncRange(layoutRef.current);
}); });
}; };
useEffect(() => { useEffect(() => {
scheduleUpdate(); scheduleUpdate(true);
const handleScroll = () => { const handleScroll = () => {
scheduleUpdate(); scheduleUpdate();
}; };
const handleResize = () => { const handleResize = () => {
scheduleUpdate(); scheduleUpdate(true);
}; };
const bodyEl = document.body; const bodyEl = document.body;
@@ -230,7 +249,7 @@ export default function VirtualScrollableGrid({
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') { if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => { resizeObserver = new ResizeObserver(() => {
scheduleUpdate(); scheduleUpdate(true);
}); });
if (containerRef.current) { if (containerRef.current) {
+42 -4
View File
@@ -1,5 +1,5 @@
const EPISODE_PROGRESS_PREFIX = 'moontv_episode_progress:'; const EPISODE_PROGRESS_PREFIX = 'moontv_episode_progress:';
const EPISODE_PROGRESS_MAX_SHOWS = 100; const EPISODE_PROGRESS_MAX_SHOWS = 20;
const EPISODE_PROGRESS_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 120; const EPISODE_PROGRESS_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 120;
export interface LocalEpisodeProgressRecord { export interface LocalEpisodeProgressRecord {
@@ -9,6 +9,8 @@ export interface LocalEpisodeProgressRecord {
} }
interface EpisodeProgressContentIdentity { interface EpisodeProgressContentIdentity {
doubanId?: number | string;
tmdbId?: number | string;
title?: string; title?: string;
year?: string; year?: string;
searchType?: string; searchType?: string;
@@ -201,7 +203,21 @@ function normalizeContentTitle(title: string) {
.toLowerCase(); .toLowerCase();
} }
export function buildEpisodeProgressContentKey( 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 identity: EpisodeProgressContentIdentity
) { ) {
const title = normalizeContentTitle(identity.title || ''); const title = normalizeContentTitle(identity.title || '');
@@ -215,6 +231,23 @@ export function buildEpisodeProgressContentKey(
return `${title}|${year}|${searchType}`; 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) { export function getEpisodeProgressStorageKey(contentKey: string) {
return `${EPISODE_PROGRESS_PREFIX}${contentKey}`; return `${EPISODE_PROGRESS_PREFIX}${contentKey}`;
} }
@@ -287,6 +320,7 @@ export function saveLocalEpisodeProgress(
const key = getEpisodeProgressStorageKey(contentKey); const key = getEpisodeProgressStorageKey(contentKey);
const now = Date.now(); const now = Date.now();
const currentStore = readEpisodeProgressStore(contentKey); const currentStore = readEpisodeProgressStore(contentKey);
const shouldPruneAfterSave = !currentStore;
const nextStore: LocalEpisodeProgressStore = { const nextStore: LocalEpisodeProgressStore = {
updatedAt: now, updatedAt: now,
episodes: { episodes: {
@@ -303,13 +337,17 @@ export function saveLocalEpisodeProgress(
try { try {
localStorage.setItem(key, payload); localStorage.setItem(key, payload);
pruneLocalEpisodeProgressStorage(); if (shouldPruneAfterSave) {
pruneLocalEpisodeProgressStorage();
}
} catch (error) { } catch (error) {
if (!isQuotaExceededError(error)) { if (!isQuotaExceededError(error)) {
throw error; throw error;
} }
pruneLocalEpisodeProgressStorage(Math.max(50, Math.floor(EPISODE_PROGRESS_MAX_SHOWS / 2))); pruneLocalEpisodeProgressStorage(
Math.max(10, Math.floor(EPISODE_PROGRESS_MAX_SHOWS / 2))
);
localStorage.setItem(key, payload); localStorage.setItem(key, payload);
pruneLocalEpisodeProgressStorage(); pruneLocalEpisodeProgressStorage();
} }