diff --git a/app/layout.tsx b/app/layout.tsx index 4a89cb3..c3da363 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ {children} + + diff --git a/app/page.tsx b/app/page.tsx index a330121..8133927 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 (
{/* Glass Navbar */} @@ -52,6 +64,7 @@ function HomePage() { results={results} availableSources={availableSources} loading={loading} + latencies={latencies} /> )} diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts index dcf15e6..6674a44 100644 --- a/app/settings/hooks/useSettingsPage.ts +++ b/app/settings/hooks/useSettingsPage.ts @@ -28,6 +28,7 @@ export function useSettingsPage() { const [searchDisplayMode, setSearchDisplayMode] = useState('normal'); const [fullscreenType, setFullscreenType] = useState<'native' | 'window'>('native'); const [proxyMode, setProxyMode] = useState('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, }; } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index ca2165b..c85b3f4 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -57,6 +57,8 @@ export default function SettingsPage() { handleFullscreenTypeChange, proxyMode, handleProxyModeChange, + rememberScrollPosition, + handleRememberScrollPositionChange, } = useSettingsPage(); return ( @@ -87,8 +89,10 @@ export default function SettingsPage() { {/* Source Management */} diff --git a/components/ScrollPositionManager.tsx b/components/ScrollPositionManager.tsx new file mode 100644 index 0000000..78471b3 --- /dev/null +++ b/components/ScrollPositionManager.tsx @@ -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; +} diff --git a/components/home/SearchResults.tsx b/components/home/SearchResults.tsx index 7b268d2..9609fca 100644 --- a/components/home/SearchResults.tsx +++ b/components/home/SearchResults.tsx @@ -12,9 +12,16 @@ interface SearchResultsProps { availableSources: SourceBadge[]; loading: boolean; isPremium?: boolean; + latencies?: Record; } -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) */} - +
); } diff --git a/components/search/VideoCard.tsx b/components/search/VideoCard.tsx index 442684c..a178b57 100644 --- a/components/search/VideoCard.tsx +++ b/components/search/VideoCard.tsx @@ -19,6 +19,7 @@ interface VideoCardProps { isActive: boolean; onCardClick: (e: React.MouseEvent, cardId: string, videoUrl: string) => void; isPremium?: boolean; + latencies?: Record; } export const VideoCard = memo(({ @@ -27,8 +28,10 @@ export const VideoCard = memo(({ cardId, isActive, onCardClick, - isPremium = false + isPremium = false, + latencies = {} }) => { + const displayLatency = latencies[video.source] ?? video.latency; return (
(({ )} - {video.latency !== undefined && ( - + {displayLatency !== undefined && ( + )}
diff --git a/components/search/VideoGrid.tsx b/components/search/VideoGrid.tsx index 07e3bb3..edcb2f8 100644 --- a/components/search/VideoGrid.tsx +++ b/components/search/VideoGrid.tsx @@ -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; } -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(null); const [visibleCount, setVisibleCount] = useState(24); const [displayMode, setDisplayMode] = useState<'normal' | 'grouped'>('normal'); const gridRef = useRef(null); const observerRef = useRef(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} /> ); }) diff --git a/components/search/VideoGroupCard.tsx b/components/search/VideoGroupCard.tsx index 2073ebc..0b7a314 100644 --- a/components/search/VideoGroupCard.tsx +++ b/components/search/VideoGroupCard.tsx @@ -31,6 +31,7 @@ interface VideoGroupCardProps { isActive: boolean; onCardClick: (e: React.MouseEvent, cardId: string, videoUrl: string) => void; isPremium?: boolean; + latencies?: Record; } export const VideoGroupCard = memo(({ @@ -38,15 +39,16 @@ export const VideoGroupCard = memo(({ 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(() => { diff --git a/components/settings/DisplaySettings.tsx b/components/settings/DisplaySettings.tsx index 143bcfa..3f81707 100644 --- a/components/settings/DisplaySettings.tsx +++ b/components/settings/DisplaySettings.tsx @@ -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 (

显示设置

+ {/* Remember Scroll Position Toggle */} +
+
+
+

记住滚动位置

+

+ 退出或刷新页面后,自动恢复到之前的滚动位置 +

+
+ +
+
+ {/* Real-time Latency Toggle */}
diff --git a/components/ui/BackToTop.tsx b/components/ui/BackToTop.tsx new file mode 100644 index 0000000..ffbb249 --- /dev/null +++ b/components/ui/BackToTop.tsx @@ -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 ( + + ); +} diff --git a/lib/hooks/useHomePage.ts b/lib/hooks/useHomePage.ts index 7a639a3..dd9c558 100644 --- a/lib/hooks/useHomePage.ts +++ b/lib/hooks/useHomePage.ts @@ -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; diff --git a/lib/hooks/useSearchCache.ts b/lib/hooks/useSearchCache.ts index e6dfedb..fccafda 100644 --- a/lib/hooks/useSearchCache.ts +++ b/lib/hooks/useSearchCache.ts @@ -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; diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index 71227cc..2621945 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -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 diff --git a/package-lock.json b/package-lock.json index 6dfe967..b77d712 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9bc3693..fd6cfa8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.0.4", + "version": "4.0.5", "private": true, "scripts": { "dev": "next dev",