Merge branch 'pr-265' into dev

This commit is contained in:
mtvpls
2026-04-15 21:18:32 +08:00
14 changed files with 1477 additions and 213 deletions
+85 -5
View File
@@ -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) {
+13 -2
View File
@@ -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');
+18 -15
View File
@@ -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 && (
+227 -66
View File
@@ -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>