feat: Implement scroll position management with a new setting, add a back-to-top button, and extend search cache duration.

This commit is contained in:
kuekhaoyang
2026-01-25 15:42:21 +08:00
parent 7368582398
commit 30600e4d95
16 changed files with 289 additions and 18 deletions
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { useEffect, useCallback } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { settingsStore } from '@/lib/store/settings-store';
/**
* ScrollPositionManager - Maintains scroll position across navigation and refreshes
* Uses sessionStorage to persist scroll state per URL
*/
export function ScrollPositionManager() {
const pathname = usePathname();
const searchParams = useSearchParams();
// Create a unique key for the current page including search params
const getPageKey = useCallback(() => {
const params = searchParams.toString();
return `scroll-pos:${pathname}${params ? '?' + params : ''}`;
}, [pathname, searchParams]);
// Restoration logic
useEffect(() => {
const settings = settingsStore.getSettings();
if (!settings.rememberScrollPosition) return;
const key = getPageKey();
const savedPos = sessionStorage.getItem(key);
if (savedPos) {
const position = parseInt(savedPos, 10);
if (!isNaN(position) && position > 0) {
// We use multiple attempts to restore scroll because content might be loading dynamically
// (e.g., search results, movie grids)
// Keep trying until we actually scroll there or a timeout occurs
let attempts = 0;
const maxAttempts = 10;
const tryScroll = () => {
const currentScroll = window.scrollY;
window.scrollTo(0, position);
attempts++;
// Verify if we actually reached the target position (with some wiggle room)
const reached = Math.abs(window.scrollY - position) < 10;
if (!reached && attempts < maxAttempts) {
// If we didn't reach it, it's likely because the page height hasn't caught up yet
setTimeout(tryScroll, 200);
}
};
const timerId = setTimeout(tryScroll, 100);
return () => clearTimeout(timerId);
}
}
}, [getPageKey, pathname, searchParams]); // Run on navigation
// Saving logic
useEffect(() => {
let timeoutId: NodeJS.Timeout;
const handleScroll = () => {
const settings = settingsStore.getSettings();
if (!settings.rememberScrollPosition) return;
// Debounce saving to avoid excessive writes to sessionStorage
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
const key = getPageKey();
// Only save if we have scrolled
if (window.scrollY > 0) {
sessionStorage.setItem(key, window.scrollY.toString());
} else {
sessionStorage.removeItem(key);
}
}, 500);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
clearTimeout(timeoutId);
};
}, [getPageKey]);
return null;
}
+13 -2
View File
@@ -12,9 +12,16 @@ interface SearchResultsProps {
availableSources: SourceBadge[];
loading: boolean;
isPremium?: boolean;
latencies?: Record<string, number>;
}
export function SearchResults({ results, availableSources, loading, isPremium = false }: SearchResultsProps) {
export function SearchResults({
results,
availableSources,
loading,
isPremium = false,
latencies = {}
}: SearchResultsProps) {
// Source badges hook - filters by video source
const {
selectedSources,
@@ -62,7 +69,11 @@ export function SearchResults({ results, availableSources, loading, isPremium =
)}
{/* Display filtered videos (both source and type filters applied) */}
<VideoGrid videos={finalFilteredVideos} isPremium={isPremium} />
<VideoGrid
videos={finalFilteredVideos}
isPremium={isPremium}
latencies={latencies}
/>
</div>
);
}
+6 -3
View File
@@ -19,6 +19,7 @@ interface VideoCardProps {
isActive: boolean;
onCardClick: (e: React.MouseEvent, cardId: string, videoUrl: string) => void;
isPremium?: boolean;
latencies?: Record<string, number>;
}
export const VideoCard = memo<VideoCardProps>(({
@@ -27,8 +28,10 @@ export const VideoCard = memo<VideoCardProps>(({
cardId,
isActive,
onCardClick,
isPremium = false
isPremium = false,
latencies = {}
}) => {
const displayLatency = latencies[video.source] ?? video.latency;
return (
<div
style={{
@@ -91,8 +94,8 @@ export const VideoCard = memo<VideoCardProps>(({
</Badge>
)}
{video.latency !== undefined && (
<LatencyBadge latency={video.latency} className="flex-shrink-0" />
{displayLatency !== undefined && (
<LatencyBadge latency={displayLatency} className="flex-shrink-0" />
)}
</div>
+41 -2
View File
@@ -1,6 +1,7 @@
'use client';
import { useState, useRef, useCallback, useMemo, memo, useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { VideoCard } from './VideoCard';
import { VideoGroupCard, GroupedVideo } from './VideoGroupCard';
import { settingsStore } from '@/lib/store/settings-store';
@@ -10,27 +11,63 @@ interface VideoGridProps {
videos: Video[];
className?: string;
isPremium?: boolean;
latencies?: Record<string, number>;
}
export const VideoGrid = memo(function VideoGrid({ videos, className = '', isPremium = false }: VideoGridProps) {
export const VideoGrid = memo(function VideoGrid({
videos,
className = '',
isPremium = false,
latencies = {}
}: VideoGridProps) {
const [activeCardId, setActiveCardId] = useState<string | null>(null);
const [visibleCount, setVisibleCount] = useState(24);
const [displayMode, setDisplayMode] = useState<'normal' | 'grouped'>('normal');
const gridRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const pathname = usePathname();
const searchParams = useSearchParams();
// Load display mode from settings
useEffect(() => {
const settings = settingsStore.getSettings();
setDisplayMode(settings.searchDisplayMode);
// Initial load: Check for saved scroll position to ensure we render enough items
const params = searchParams.toString();
const scrollKey = `scroll-pos:${pathname}${params ? '?' + params : ''}`;
const savedPos = sessionStorage.getItem(scrollKey);
if (savedPos && settings.rememberScrollPosition) {
const position = parseInt(savedPos, 10);
if (!isNaN(position) && position > 500) {
// Approximate visible count needed:
// 500 is roughly where the second/third row starts.
// Each row is ~300-400px high on most screens.
// 24 items is 4-6 rows.
// If scroll is deep, we force a larger initial visible count.
// 24, 48, 72, 96...
const estimatedRowsNeeded = Math.ceil(position / 300) + 2;
// Match CSS breakpoints: sm: 3, md: 4, lg: 5, xl: 6
const itemsPerRow = window.innerWidth >= 1280 ? 6 :
(window.innerWidth >= 1024 ? 5 :
(window.innerWidth >= 768 ? 4 :
(window.innerWidth >= 640 ? 3 : 2)));
const neededCount = Math.min(videos.length, estimatedRowsNeeded * itemsPerRow);
if (neededCount > 24) {
setVisibleCount(Math.ceil(neededCount / 24) * 24);
}
}
}
const unsubscribe = settingsStore.subscribe(() => {
const newSettings = settingsStore.getSettings();
setDisplayMode(newSettings.searchDisplayMode);
});
return () => unsubscribe();
}, []);
}, [pathname, searchParams, videos.length]);
if (videos.length === 0) {
return null;
@@ -150,6 +187,7 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '', isPre
isActive={isActive}
onCardClick={handleCardClick}
isPremium={isPremium}
latencies={latencies}
/>
);
})
@@ -166,6 +204,7 @@ export const VideoGrid = memo(function VideoGrid({ videos, className = '', isPre
isActive={isActive}
onCardClick={handleCardClick}
isPremium={isPremium}
latencies={latencies}
/>
);
})
+7 -5
View File
@@ -31,6 +31,7 @@ interface VideoGroupCardProps {
isActive: boolean;
onCardClick: (e: React.MouseEvent, cardId: string, videoUrl: string) => void;
isPremium?: boolean;
latencies?: Record<string, number>;
}
export const VideoGroupCard = memo<VideoGroupCardProps>(({
@@ -38,15 +39,16 @@ export const VideoGroupCard = memo<VideoGroupCardProps>(({
cardId,
isActive,
onCardClick,
isPremium = false
isPremium = false,
latencies = {}
}) => {
const { representative, videos, name } = group;
// Best latency from the group
// Best latency from the group, preferring real-time updates
const bestLatency = useMemo(() => {
const latencies = videos.filter(v => v.latency !== undefined).map(v => v.latency!);
return latencies.length > 0 ? Math.min(...latencies) : undefined;
}, [videos]);
const currentLatencies = videos.map(v => latencies[v.source] ?? v.latency).filter(l => l !== undefined) as number[];
return currentLatencies.length > 0 ? Math.min(...currentLatencies) : undefined;
}, [videos, latencies]);
// Generate URL with grouped sources data
const videoUrl = useMemo(() => {
+21
View File
@@ -11,20 +11,41 @@ import { Switch } from '@/components/ui/Switch';
interface DisplaySettingsProps {
realtimeLatency: boolean;
searchDisplayMode: SearchDisplayMode;
rememberScrollPosition: boolean;
onRealtimeLatencyChange: (enabled: boolean) => void;
onSearchDisplayModeChange: (mode: SearchDisplayMode) => void;
onRememberScrollPositionChange: (enabled: boolean) => void;
}
export function DisplaySettings({
realtimeLatency,
searchDisplayMode,
rememberScrollPosition,
onRealtimeLatencyChange,
onSearchDisplayModeChange,
onRememberScrollPositionChange,
}: DisplaySettingsProps) {
return (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
<h2 className="text-xl font-semibold text-[var(--text-color)] mb-4"></h2>
{/* Remember Scroll Position Toggle */}
<div className="mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-[var(--text-color)]"></h3>
<p className="text-sm text-[var(--text-color-secondary)] mt-1">
退
</p>
</div>
<Switch
checked={rememberScrollPosition}
onChange={onRememberScrollPositionChange}
ariaLabel="记住滚动位置开关"
/>
</div>
</div>
{/* Real-time Latency Toggle */}
<div className="mb-6">
<div className="flex items-center justify-between">
+57
View File
@@ -0,0 +1,57 @@
'use client';
import React, { useState, useEffect } from 'react';
import { ChevronUp } from 'lucide-react';
/**
* BackToTop - Floating button to scroll back to top of page
* Follows Liquid Glass design system
*/
export function BackToTop() {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const toggleVisibility = () => {
// Show button after scrolling down 300px
if (window.scrollY > 300) {
setIsVisible(true);
} else {
setIsVisible(false);
}
};
window.addEventListener('scroll', toggleVisibility, { passive: true });
// Initial check in case page is already scrolled (e.g. on refresh)
toggleVisibility();
return () => window.removeEventListener('scroll', toggleVisibility);
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
return (
<button
onClick={scrollToTop}
className={`fixed bottom-8 right-8 z-[9999] p-3 rounded-full
bg-[var(--glass-bg)] border border-[var(--glass-border)]
shadow-[var(--shadow-md)] backdrop-blur-xl
text-[var(--text-color)] transition-all duration-300 ease-out
hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)]
hover:scale-110 active:scale-95
${isVisible
? 'opacity-100 translate-y-0 scale-100'
: 'opacity-0 translate-y-10 scale-50 pointer-events-none'
}`}
aria-label="返回顶部"
title="返回顶部"
>
<ChevronUp size={24} strokeWidth={2.5} />
</button>
);
}