mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-19 18:53:42 +08:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user