feat: Implement search stream auto-completion timeout, filter irrelevant video results, and enhance search history dropdown animation with new image domains.

This commit is contained in:
kuekhaoyang
2025-11-21 19:18:13 +08:00
parent 58a8d4910e
commit 968ec2f421
4 changed files with 81 additions and 13 deletions
+13 -1
View File
@@ -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);
}
}
+36 -7
View File
@@ -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);
}
}
+24 -5
View File
@@ -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;
+8
View File
@@ -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'],