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
+4
View File
@@ -8,6 +8,8 @@ import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
import { PasswordGate } from "@/components/PasswordGate";
import { siteConfig } from "@/lib/config/site-config";
import { AdKeywordsInjector } from "@/components/AdKeywordsInjector";
import { BackToTop } from "@/components/ui/BackToTop";
import { ScrollPositionManager } from "@/components/ScrollPositionManager";
import fs from 'fs';
import path from 'path';
@@ -83,6 +85,8 @@ export default function RootLayout({
<PasswordGate hasEnvPassword={!!process.env.ACCESS_PASSWORD}>
<AdKeywordsWrapper />
{children}
<BackToTop />
<ScrollPositionManager />
</PasswordGate>
<Analytics />
<ServiceWorkerRegister />
+14 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { Suspense } from 'react';
import { Suspense, useMemo } from 'react';
import { SearchForm } from '@/components/search/SearchForm';
import { NoResults } from '@/components/search/NoResults';
import { PopularFeatures } from '@/components/home/PopularFeatures';
@@ -9,6 +9,7 @@ import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
import { Navbar } from '@/components/layout/Navbar';
import { SearchResults } from '@/components/home/SearchResults';
import { useHomePage } from '@/lib/hooks/useHomePage';
import { useLatencyPing } from '@/lib/hooks/useLatencyPing';
function HomePage() {
const {
@@ -23,6 +24,17 @@ function HomePage() {
handleReset,
} = useHomePage();
// Real-time latency pinging
const sourceUrls = useMemo(() =>
availableSources.map(s => ({ id: s.id, baseUrl: s.id })), // Using id as baseUrl if not available elsewhere
[availableSources]
);
const { latencies } = useLatencyPing({
sourceUrls,
enabled: hasSearched && results.length > 0,
});
return (
<div className="min-h-screen">
{/* Glass Navbar */}
@@ -52,6 +64,7 @@ function HomePage() {
results={results}
availableSources={availableSources}
loading={loading}
latencies={latencies}
/>
)}
+13
View File
@@ -28,6 +28,7 @@ export function useSettingsPage() {
const [searchDisplayMode, setSearchDisplayMode] = useState<SearchDisplayMode>('normal');
const [fullscreenType, setFullscreenType] = useState<'native' | 'window'>('native');
const [proxyMode, setProxyMode] = useState<ProxyMode>('retry');
const [rememberScrollPosition, setRememberScrollPosition] = useState(true);
useEffect(() => {
const settings = settingsStore.getSettings();
@@ -40,6 +41,7 @@ export function useSettingsPage() {
setSearchDisplayMode(settings.searchDisplayMode);
setFullscreenType(settings.fullscreenType);
setProxyMode(settings.proxyMode);
setRememberScrollPosition(settings.rememberScrollPosition);
// Fetch env password status
fetch('/api/config')
@@ -301,6 +303,15 @@ export function useSettingsPage() {
});
};
const handleRememberScrollPositionChange = (enabled: boolean) => {
setRememberScrollPosition(enabled);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
rememberScrollPosition: enabled,
});
};
const handleRestoreDefaults = () => {
const defaults = getDefaultSources();
handleSourcesChange(defaults);
@@ -355,5 +366,7 @@ export function useSettingsPage() {
handleFullscreenTypeChange,
proxyMode,
handleProxyModeChange,
rememberScrollPosition,
handleRememberScrollPositionChange,
};
}
+4
View File
@@ -57,6 +57,8 @@ export default function SettingsPage() {
handleFullscreenTypeChange,
proxyMode,
handleProxyModeChange,
rememberScrollPosition,
handleRememberScrollPositionChange,
} = useSettingsPage();
return (
@@ -87,8 +89,10 @@ export default function SettingsPage() {
<DisplaySettings
realtimeLatency={realtimeLatency}
searchDisplayMode={searchDisplayMode}
rememberScrollPosition={rememberScrollPosition}
onRealtimeLatencyChange={handleRealtimeLatencyChange}
onSearchDisplayModeChange={handleSearchDisplayModeChange}
onRememberScrollPositionChange={handleRememberScrollPositionChange}
/>
{/* Source Management */}
+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>
);
}
+13 -1
View File
@@ -12,6 +12,7 @@ export function useHomePage() {
const { loadFromCache, saveToCache } = useSearchCache();
const hasLoadedCache = useRef(false);
const hasSearchedWithSourcesRef = useRef(false);
const isInitialCacheLoad = useRef(false);
const [query, setQuery] = useState('');
const [hasSearched, setHasSearched] = useState(false);
@@ -55,7 +56,9 @@ export function useHomePage() {
// Re-sort results when sort preference changes
useEffect(() => {
if (hasSearched && results.length > 0) {
// Skip re-sorting if this is a load from cache, to preserve the "remembered" position
// Only re-sort if the user explicitly changes the sortBy option later
if (hasSearched && results.length > 0 && !isInitialCacheLoad.current) {
applySorting(currentSortBy);
}
}, [currentSortBy, applySorting, hasSearched, results.length]);
@@ -95,6 +98,14 @@ export function useHomePage() {
const handleSearch = useCallback((searchQuery: string) => {
if (!searchQuery.trim()) return;
// Clear scroll position for this search query to ensure we start at the top on a fresh search
const scrollKey = `scroll-pos:/?q=${encodeURIComponent(searchQuery)}`;
sessionStorage.removeItem(scrollKey);
// Reset cache load flag for new search
isInitialCacheLoad.current = false;
setQuery(searchQuery);
setHasSearched(true);
executeSearch(searchQuery);
@@ -111,6 +122,7 @@ export function useHomePage() {
if (urlQuery) {
setQuery(urlQuery);
if (cached && cached.query === urlQuery && cached.results.length > 0) {
isInitialCacheLoad.current = true;
setHasSearched(true);
loadCachedResults(cached.results, cached.availableSources);
hasSearchedWithSourcesRef.current = true;
+1 -1
View File
@@ -8,7 +8,7 @@ interface SearchCache {
}
const CACHE_KEY = 'kvideo_search_cache';
const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
const MAX_CACHED_RESULTS = 300;
+3
View File
@@ -46,6 +46,7 @@ export interface AppSettings {
episodeReverseOrder: boolean; // Persist episode list reverse state
fullscreenType: 'native' | 'window'; // Fullscreen mode preference
proxyMode: ProxyMode; // Proxy behavior: 'retry' | 'none' | 'always'
rememberScrollPosition: boolean; // Remember scroll position when navigating back or refreshing
}
import { exportSettings, importSettings, SEARCH_HISTORY_KEY, WATCH_HISTORY_KEY } from './settings-helpers';
@@ -119,6 +120,7 @@ function getDefaultAppSettings(): AppSettings {
episodeReverseOrder: false,
fullscreenType: 'native',
proxyMode: 'retry',
rememberScrollPosition: true,
};
}
@@ -196,6 +198,7 @@ export const settingsStore = {
episodeReverseOrder: parsed.episodeReverseOrder !== undefined ? parsed.episodeReverseOrder : false,
fullscreenType: parsed.fullscreenType === 'window' ? 'window' : 'native',
proxyMode: (parsed.proxyMode === 'retry' || parsed.proxyMode === 'none' || parsed.proxyMode === 'always') ? parsed.proxyMode : 'retry',
rememberScrollPosition: parsed.rememberScrollPosition !== undefined ? parsed.rememberScrollPosition : true,
};
} catch {
// Even if localStorage fails, we should return defaults + ENV subscriptions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kvideo",
"version": "4.0.4",
"version": "4.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "4.0.4",
"version": "4.0.5",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.0.4",
"version": "4.0.5",
"private": true,
"scripts": {
"dev": "next dev",