From d7cd4c6e368b64ddb1d9282b7a1ffb42ab74342f Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sun, 4 Jan 2026 18:18:47 +0800 Subject: [PATCH] feat: Improve hook stability and performance with `useCallback` and `useMemo`, and enhance auto-skip and search error handling. --- app/player/page.tsx | 12 ++++---- components/player/hooks/useAutoSkip.ts | 29 +++++++++++++++---- lib/hooks/useHomePage.ts | 8 ++++-- lib/hooks/useLatencyPing.ts | 22 +++++++++------ lib/hooks/useSearchAction.ts | 4 ++- lib/hooks/useSearchCache.ts | 39 +++++++++++++++----------- lib/hooks/useSecretHomePage.ts | 8 ++++-- package-lock.json | 4 +-- package.json | 2 +- 9 files changed, 83 insertions(+), 45 deletions(-) diff --git a/app/player/page.tsx b/app/player/page.tsx index 728e0f0..99fb0fa 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Suspense, useEffect, useMemo, useState } from 'react'; +import { Suspense, useEffect, useMemo, useState, useCallback } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { Button } from '@/components/ui/Button'; import { VideoPlayer } from '@/components/player/VideoPlayer'; @@ -93,7 +93,7 @@ function PlayerContent() { } }, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]); - const handleEpisodeClick = (episode: any, index: number) => { + const handleEpisodeClick = useCallback((episode: any, index: number) => { setCurrentEpisode(index); setPlayUrl(episode.url); setVideoError(''); @@ -102,7 +102,7 @@ function PlayerContent() { const params = new URLSearchParams(searchParams.toString()); params.set('episode', index.toString()); router.replace(`/player?${params.toString()}`, { scroll: false }); - }; + }, [searchParams, router, setCurrentEpisode, setPlayUrl, setVideoError]); const handleToggleReverse = (reversed: boolean) => { setIsReversed(reversed); @@ -114,7 +114,7 @@ function PlayerContent() { }; // Handle auto-next episode - const handleNextEpisode = () => { + const handleNextEpisode = useCallback(() => { const episodes = videoData?.episodes; if (!episodes) return; @@ -129,9 +129,9 @@ function PlayerContent() { const nextEpisode = episodes[nextIndex]; if (nextEpisode) { - handleEpisodeClick(nextEpisode, nextIndex); + handleEpisodeClick(nextEpisode, nextIndex); // handleEpisodeClick relies on state setters, which are stable } - }; + }, [videoData, currentEpisode, isReversed, router, searchParams]); // handleEpisodeClick is not memoized, but uses stable hooks setters. wait, handleEpisodeClick is inline too! return (
diff --git a/components/player/hooks/useAutoSkip.ts b/components/player/hooks/useAutoSkip.ts index 56538a3..4579a3c 100644 --- a/components/player/hooks/useAutoSkip.ts +++ b/components/player/hooks/useAutoSkip.ts @@ -83,18 +83,27 @@ export function useAutoSkip({ const canAdvanceToNext = useCallback(() => { if (totalEpisodes <= 1) return false; + const nextEpisodeFn = onNextEpisodeRef.current; + if (!isReversed) { // Normal order: next is index + 1 - return currentEpisodeIndex < totalEpisodes - 1 && onNextEpisode; + return currentEpisodeIndex < totalEpisodes - 1 && !!nextEpisodeFn; } else { // Reversed order: next is index - 1 (since we're going backwards) - return currentEpisodeIndex > 0 && onNextEpisode; + return currentEpisodeIndex > 0 && !!nextEpisodeFn; } - }, [totalEpisodes, currentEpisodeIndex, onNextEpisode, isReversed]); + }, [totalEpisodes, currentEpisodeIndex, isReversed]); + + + // Keep a stable ref to onNextEpisode to avoid effect re-runs + const onNextEpisodeRef = useRef(onNextEpisode); + useEffect(() => { + onNextEpisodeRef.current = onNextEpisode; + }, [onNextEpisode]); // Helper to trigger next episode exactly once per source const triggerNextEpisode = useCallback((reason: string) => { - if (!onNextEpisode) return; + if (!onNextEpisodeRef.current) return; // Prevent double trigger for the same source URL if (lastHandledSrcRef.current === src) { @@ -102,12 +111,20 @@ export function useAutoSkip({ return; } + // Safety check: if we are already transitioning, do not trigger again + // This is a critical guard against infinite loops where the trigger might be called repeatedly + // before the parent component has a chance to unmount or change the source. + if (isTransitioningToNextEpisode) { + console.log(`[AutoSkip] Ignoring ${reason} trigger: already transitioning`); + return; + } + console.log(`[AutoSkip] Triggering next episode via ${reason}`); lastHandledSrcRef.current = src; // Set transitioning state for custom loading indicator setIsTransitioningToNextEpisode(true); - onNextEpisode(); - }, [src, onNextEpisode]); + onNextEpisodeRef.current(); + }, [src, isTransitioningToNextEpisode]); // Validate that duration is ready (not 0, NaN, or Infinity) const isDurationValid = useCallback(() => { diff --git a/lib/hooks/useHomePage.ts b/lib/hooks/useHomePage.ts index 4002c2a..05050dd 100644 --- a/lib/hooks/useHomePage.ts +++ b/lib/hooks/useHomePage.ts @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useSearchCache } from '@/lib/hooks/useSearchCache'; import { useParallelSearch } from '@/lib/hooks/useParallelSearch'; @@ -16,6 +16,10 @@ export function useHomePage() { const [hasSearched, setHasSearched] = useState(false); const [currentSortBy, setCurrentSortBy] = useState('default'); + const onUrlUpdate = useCallback((q: string) => { + router.replace(`/?q=${encodeURIComponent(q)}`, { scroll: false }); + }, [router]); + // Search stream hook const { loading, @@ -29,7 +33,7 @@ export function useHomePage() { applySorting, } = useParallelSearch( saveToCache, - (q: string) => router.replace(`/?q=${encodeURIComponent(q)}`, { scroll: false }) + onUrlUpdate ); // Re-sort results when sort preference changes diff --git a/lib/hooks/useLatencyPing.ts b/lib/hooks/useLatencyPing.ts index 7ca4462..42ec530 100644 --- a/lib/hooks/useLatencyPing.ts +++ b/lib/hooks/useLatencyPing.ts @@ -3,7 +3,7 @@ * Periodically pings video sources when enabled */ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { settingsStore } from '@/lib/store/settings-store'; interface LatencyState { @@ -29,6 +29,12 @@ export function useLatencyPing({ // Check if real-time latency is enabled in settings const [realtimeEnabled, setRealtimeEnabled] = useState(false); + // Stabilize sourceUrls to prevent unnecessary effect re-runs if parent passes new array + const stableSourceUrls = useMemo(() => sourceUrls, [ + // Create a unique key for the sources array + sourceUrls.map(s => `${s.id}|${s.baseUrl}`).join(',') + ]); + useEffect(() => { const settings = settingsStore.getSettings(); setRealtimeEnabled(settings.realtimeLatency); @@ -63,12 +69,12 @@ export function useLatencyPing({ }, []); const pingAllSources = useCallback(async () => { - if (!mountedRef.current || sourceUrls.length === 0) return; + if (!mountedRef.current || stableSourceUrls.length === 0) return; setIsLoading(true); const results = await Promise.all( - sourceUrls.map(async ({ id, baseUrl }) => { + stableSourceUrls.map(async ({ id, baseUrl }) => { const latency = await pingSource(id, baseUrl); return { id, latency }; }) @@ -86,13 +92,13 @@ export function useLatencyPing({ }); setIsLoading(false); } - }, [sourceUrls, pingSource]); + }, [stableSourceUrls, pingSource]); // Start/stop polling based on enabled state useEffect(() => { mountedRef.current = true; - const shouldPoll = enabled && realtimeEnabled && sourceUrls.length > 0; + const shouldPoll = enabled && realtimeEnabled && stableSourceUrls.length > 0; if (shouldPoll) { // Initial ping @@ -109,10 +115,10 @@ export function useLatencyPing({ intervalRef.current = null; } }; - }, [enabled, realtimeEnabled, sourceUrls, intervalMs, pingAllSources]); + }, [enabled, realtimeEnabled, stableSourceUrls, intervalMs, pingAllSources]); const refreshLatency = useCallback((sourceId: string) => { - const source = sourceUrls.find(s => s.id === sourceId); + const source = stableSourceUrls.find(s => s.id === sourceId); if (source) { pingSource(sourceId, source.baseUrl).then(latency => { if (latency !== null && mountedRef.current) { @@ -120,7 +126,7 @@ export function useLatencyPing({ } }); } - }, [sourceUrls, pingSource]); + }, [stableSourceUrls, pingSource]); const refreshAll = useCallback(() => { pingAllSources(); diff --git a/lib/hooks/useSearchAction.ts b/lib/hooks/useSearchAction.ts index 35a3694..1a0c6a8 100644 --- a/lib/hooks/useSearchAction.ts +++ b/lib/hooks/useSearchAction.ts @@ -123,7 +123,9 @@ export function useSearchAction({ state, onCacheUpdate, onUrlUpdate }: UseSearch } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - // Ignore abort errors + // Ignore abort errors and DO NOT set loading to false + // because a new search might have already started + return; } else { console.error('Search error:', error); } diff --git a/lib/hooks/useSearchCache.ts b/lib/hooks/useSearchCache.ts index fe2686a..e6dfedb 100644 --- a/lib/hooks/useSearchCache.ts +++ b/lib/hooks/useSearchCache.ts @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useRef, useCallback } from 'react'; interface SearchCache { query: string; @@ -12,24 +12,29 @@ const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes const MAX_CACHED_RESULTS = 300; +/** + * Strip unnecessary large fields before caching to save LocalStorage space + */ +const stripVideoData = (results: any[]) => { + return results.slice(0, MAX_CACHED_RESULTS).map(video => { + // Remove large text fields that are only needed for the detail page + const { + vod_content, + vod_actor, + vod_director, + ...rest + } = video; + return rest; + }); +}; + export function useSearchCache() { /** * Strip unnecessary large fields before caching to save LocalStorage space */ - const stripVideoData = (results: any[]) => { - return results.slice(0, MAX_CACHED_RESULTS).map(video => { - // Remove large text fields that are only needed for the detail page - const { - vod_content, - vod_actor, - vod_director, - ...rest - } = video; - return rest; - }); - }; - const saveToCache = ( + + const saveToCache = useCallback(( query: string, results: any[], sources: any[] @@ -68,9 +73,9 @@ export function useSearchCache() { console.error('[Cache] Failed to save search results to LocalStorage:', error); } } - }; + }, []); - const loadFromCache = (): SearchCache | null => { + const loadFromCache = useCallback((): SearchCache | null => { try { const cached = localStorage.getItem(CACHE_KEY); if (!cached) return null; @@ -88,7 +93,7 @@ export function useSearchCache() { console.error('[Cache] Failed to load search results from LocalStorage:', error); return null; } - }; + }, []); return { saveToCache, diff --git a/lib/hooks/useSecretHomePage.ts b/lib/hooks/useSecretHomePage.ts index 1759466..0f835b7 100644 --- a/lib/hooks/useSecretHomePage.ts +++ b/lib/hooks/useSecretHomePage.ts @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useMemo } from 'react'; +import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useSearchCache } from '@/lib/hooks/useSearchCache'; import { useParallelSearch } from '@/lib/hooks/useParallelSearch'; @@ -20,6 +20,10 @@ export function useSecretHomePage() { return settings.adultSources.filter(s => s.enabled); }, []); + const onUrlUpdate = useCallback((q: string) => { + router.replace(`/secret?q=${encodeURIComponent(q)}`, { scroll: false }); + }, [router]); + // Search stream hook const { loading, @@ -33,7 +37,7 @@ export function useSecretHomePage() { applySorting, } = useParallelSearch( saveToCache, - (q: string) => router.replace(`/secret?q=${encodeURIComponent(q)}`, { scroll: false }) + onUrlUpdate ); // Re-sort results when sort preference changes diff --git a/package-lock.json b/package-lock.json index 642b1a9..ab1e91a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "3.8.9", + "version": "3.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "3.8.9", + "version": "3.9.0", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 5dde87f..0826697 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "3.8.9", + "version": "3.9.0", "private": true, "scripts": { "dev": "next dev",