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",