mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 16:23:43 +08:00
feat: Improve hook stability and performance with useCallback and useMemo, and enhance auto-skip and search error handling.
This commit is contained in:
+6
-6
@@ -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)]">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "3.8.9",
|
||||
"version": "3.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user