feat: Improve hook stability and performance with useCallback and useMemo, and enhance auto-skip and search error handling.

This commit is contained in:
kuekhaoyang
2026-01-04 18:18:47 +08:00
parent e9d060f74a
commit d7cd4c6e36
9 changed files with 83 additions and 45 deletions
+6 -6
View File
@@ -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 (
<div className="min-h-screen bg-[var(--bg-color)]">
+23 -6
View File
@@ -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(() => {
+6 -2
View File
@@ -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
+14 -8
View File
@@ -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();
+3 -1
View File
@@ -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);
}
+22 -17
View File
@@ -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,
+6 -2
View File
@@ -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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "3.8.9",
"version": "3.9.0",
"private": true,
"scripts": {
"dev": "next dev",