From 968ec2f421c61b2d022713aa3bf94922bf6c71b6 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Fri, 21 Nov 2025 19:18:13 +0800 Subject: [PATCH] feat: Implement search stream auto-completion timeout, filter irrelevant video results, and enhance search history dropdown animation with new image domains. --- app/styles/search-history.css | 14 +++++++++++- lib/utils/search-stream.ts | 43 +++++++++++++++++++++++++++++------ lib/utils/search.ts | 29 +++++++++++++++++++---- next.config.ts | 8 +++++++ 4 files changed, 81 insertions(+), 13 deletions(-) diff --git a/app/styles/search-history.css b/app/styles/search-history.css index f785b47..c6d70e1 100644 --- a/app/styles/search-history.css +++ b/app/styles/search-history.css @@ -3,7 +3,6 @@ max-height: 400px; overflow-y: auto; background: var(--bg-color); - opacity: 0.98; border-radius: var(--radius-2xl); box-shadow: var(--shadow-md); border: 1px solid var(--glass-border); @@ -83,4 +82,17 @@ .search-history-remove:active { transform: scale(0.95); +} + +/* Animations */ +@keyframes search-dropdown-appear { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } } \ No newline at end of file diff --git a/lib/utils/search-stream.ts b/lib/utils/search-stream.ts index 329dc18..ce16594 100644 --- a/lib/utils/search-stream.ts +++ b/lib/utils/search-stream.ts @@ -1,6 +1,6 @@ import { Video } from '@/lib/types'; import { getSourceName } from '@/lib/utils/source-names'; -import { calculateRelevanceScore } from '@/lib/utils/search'; +import { calculateRelevanceScore, hasMinimumMatch } from '@/lib/utils/search'; import { binaryInsertVideos } from '@/lib/utils/sorted-insert'; interface StreamHandlerParams { @@ -24,8 +24,27 @@ export async function processSearchStream({ }: StreamHandlerParams) { const decoder = new TextDecoder(); let buffer = ''; + let lastProgressTime = Date.now(); + let timeoutId: NodeJS.Timeout | null = null; + let isCompleted = false; + + // Auto-complete if no progress for 3 seconds + const resetTimeout = () => { + if (timeoutId) clearTimeout(timeoutId); + lastProgressTime = Date.now(); + + timeoutId = setTimeout(() => { + if (!isCompleted) { + console.log('Search timeout: No progress for 3 seconds, auto-completing'); + isCompleted = true; + onComplete(); + } + }, 3000); + }; try { + resetTimeout(); // Start initial timeout + while (true) { const { done, value } = await reader.read(); if (done) break; @@ -42,17 +61,24 @@ export async function processSearchStream({ if (data.type === 'start') { onStart(data.totalSources); + resetTimeout(); } else if (data.type === 'videos') { - const newVideos: Video[] = data.videos.map((video: any) => ({ - ...video, - sourceName: video.sourceDisplayName || getSourceName(video.source), - isNew: true, - relevanceScore: calculateRelevanceScore(video, currentQuery), - })); + const newVideos: Video[] = data.videos + .filter((video: any) => hasMinimumMatch(video.vod_name, currentQuery)) + .map((video: any) => ({ + ...video, + sourceName: video.sourceDisplayName || getSourceName(video.source), + isNew: true, + relevanceScore: calculateRelevanceScore(video, currentQuery), + })); onVideos(newVideos, data.source); + resetTimeout(); } else if (data.type === 'progress') { onProgress(data.completedSources, data.totalVideosFound); + resetTimeout(); } else if (data.type === 'complete') { + if (timeoutId) clearTimeout(timeoutId); + isCompleted = true; onComplete(); } else if (data.type === 'error') { onError(data.message); @@ -63,6 +89,9 @@ export async function processSearchStream({ } } } catch (error) { + if (timeoutId) clearTimeout(timeoutId); throw error; + } finally { + if (timeoutId) clearTimeout(timeoutId); } } diff --git a/lib/utils/search.ts b/lib/utils/search.ts index 7c97d20..ce16460 100644 --- a/lib/utils/search.ts +++ b/lib/utils/search.ts @@ -5,6 +5,25 @@ import type { VideoItem } from '@/lib/types'; +/** + * Check if title contains at least 2 consecutive characters from search query + * This filters out irrelevant results + */ +export function hasMinimumMatch(title: string, query: string): boolean { + const normalizedTitle = title.toLowerCase(); + const normalizedQuery = query.toLowerCase().trim(); + + // Extract all 2+ character substrings from query + for (let i = 0; i <= normalizedQuery.length - 2; i++) { + const substring = normalizedQuery.slice(i, i + 2); + if (normalizedTitle.includes(substring)) { + return true; + } + } + + return false; +} + /** * Calculate search relevance score * Higher score = more relevant to the search query @@ -16,7 +35,7 @@ export function calculateRelevanceScore(item: VideoItem, query: string): number // Split query into words for partial matching const queryWords = normalizedQuery.split(/\s+/); - + // 1. Exact title match (highest priority) if (normalizedTitle === normalizedQuery) { score += 1000; @@ -31,14 +50,14 @@ export function calculateRelevanceScore(item: VideoItem, query: string): number // 3. Title contains full query as substring if (normalizedTitle.includes(normalizedQuery)) { score += 200; - + // Bonus for query appearing earlier in title const position = normalizedTitle.indexOf(normalizedQuery); score += Math.max(0, 50 - position * 2); } // 4. All query words present in title - const allWordsPresent = queryWords.every(word => + const allWordsPresent = queryWords.every(word => normalizedTitle.includes(word) ); if (allWordsPresent && queryWords.length > 1) { @@ -48,10 +67,10 @@ export function calculateRelevanceScore(item: VideoItem, query: string): number // 5. Individual word matches queryWords.forEach(word => { if (word.length < 2) return; // Skip very short words - + if (normalizedTitle.includes(word)) { score += 30; - + // Bonus if word is at the start if (normalizedTitle.startsWith(word)) { score += 20; diff --git a/next.config.ts b/next.config.ts index 791aad5..7b36d92 100644 --- a/next.config.ts +++ b/next.config.ts @@ -94,6 +94,14 @@ const nextConfig: NextConfig = { protocol: 'https', hostname: '**.online', }, + { + protocol: 'http', + hostname: '**.top', + }, + { + protocol: 'https', + hostname: '**.top', + }, ], // Add image optimization for better performance formats: ['image/webp'],