From 798fac455a555aed2e5d9aa743304a0c5ce69e76 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 17 Feb 2026 19:33:02 +0800 Subject: [PATCH] feat: Implement background search for alternative video sources and enhance source switching capabilities. --- app/player/page.tsx | 95 +++++++++++++++++++++++++-- components/history/HistoryItem.tsx | 10 +++ components/iptv/IPTVSourceManager.tsx | 14 +++- lib/store/iptv-store.ts | 49 ++++++++++---- package-lock.json | 12 ++-- package.json | 4 +- 6 files changed, 155 insertions(+), 29 deletions(-) diff --git a/app/player/page.tsx b/app/player/page.tsx index aadd173..5bc0c9b 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Suspense, useEffect, useMemo, useState, useCallback } from 'react'; +import { Suspense, useEffect, useMemo, useState, useCallback, useRef } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { Button } from '@/components/ui/Button'; import { VideoPlayer } from '@/components/player/VideoPlayer'; @@ -8,6 +8,7 @@ import { VideoMetadata } from '@/components/player/VideoMetadata'; import { EpisodeList } from '@/components/player/EpisodeList'; import { PlayerError } from '@/components/player/PlayerError'; import { SourceInfo } from '@/components/player/EpisodeList'; +import type { VideoSource } from '@/lib/types'; import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer'; import { useHistory } from '@/lib/store/history-store'; import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar'; @@ -16,6 +17,7 @@ import { PlayerNavbar } from '@/components/player/PlayerNavbar'; import { settingsStore } from '@/lib/store/settings-store'; import { premiumModeSettingsStore } from '@/lib/store/premium-mode-settings'; import { SegmentedControl } from '@/components/ui/SegmentedControl'; +import { getSourceName } from '@/lib/utils/source-names'; function PlayerContent() { const searchParams = useSearchParams(); @@ -62,6 +64,8 @@ function PlayerContent() { } = useVideoPlayer(videoId, source, episodeParam, isReversed); // Parse grouped sources if available + const [discoveredSources, setDiscoveredSources] = useState([]); + const groupedSources = useMemo(() => { let sources: SourceInfo[] = []; if (groupedSourcesParam) { @@ -72,17 +76,96 @@ function PlayerContent() { } } + // Merge in discovered sources (from background search) + if (discoveredSources.length > 0) { + for (const ds of discoveredSources) { + if (!sources.find(s => s.source === ds.source)) { + sources.push(ds); + } + } + } + // Always ensure the current source is in the list if (source && !sources.find(s => s.source === source)) { sources.unshift({ id: videoId || '', source: source, - sourceName: source, + sourceName: getSourceName(source), pic: videoData?.vod_pic }); } return sources; - }, [groupedSourcesParam, source, videoId, videoData?.vod_pic]); + }, [groupedSourcesParam, source, videoId, videoData?.vod_pic, discoveredSources]); + + // Background fetch alternative sources when none provided + const fetchedSourcesRef = useRef(false); + useEffect(() => { + if (groupedSourcesParam || fetchedSourcesRef.current || !title) return; + fetchedSourcesRef.current = true; + + const settings = settingsStore.getSettings(); + const sourcesForMode = isPremium ? settings.premiumSources : settings.sources; + const allSources = sourcesForMode?.filter((s: VideoSource) => s.enabled !== false) || []; + // Only search other sources (not the current one) + const otherSources = allSources.filter((s: VideoSource) => s.id !== source); + if (otherSources.length === 0) return; + + const controller = new AbortController(); + + (async () => { + try { + const response = await fetch('/api/search-parallel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: title, sources: otherSources, page: 1 }), + signal: controller.signal, + }); + if (!response.ok || !response.body) return; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + const found: SourceInfo[] = []; + const normalizedTitle = title.toLowerCase().trim(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + try { + const data = JSON.parse(line.slice(6)); + if (data.type === 'videos' && data.videos) { + // Find exact or close title match + const match = data.videos.find((v: any) => + v.vod_name?.toLowerCase().trim() === normalizedTitle + ); + if (match) { + found.push({ + id: match.vod_id, + source: match.source, + sourceName: match.sourceDisplayName || getSourceName(match.source), + latency: match.latency, + pic: match.vod_pic, + }); + // Update state incrementally + setDiscoveredSources([...found]); + } + } + } catch { /* ignore parse errors */ } + } + } + } catch { + // Silently ignore - this is a background enhancement + } + })(); + + return () => controller.abort(); + }, [title, source, groupedSourcesParam, isPremium]); // Track current source for switching const [currentSourceId, setCurrentSourceId] = useState(source); @@ -251,7 +334,11 @@ function PlayerContent() { params.set('id', String(newSource.id)); params.set('source', newSource.source); params.set('title', title || ''); - if (groupedSourcesParam) { + // Pass all known sources so switching persists + const allSources = groupedSources.length > 0 ? groupedSources : []; + if (allSources.length > 1) { + params.set('groupedSources', JSON.stringify(allSources)); + } else if (groupedSourcesParam) { params.set('groupedSources', groupedSourcesParam); } setCurrentSourceId(newSource.source); diff --git a/components/history/HistoryItem.tsx b/components/history/HistoryItem.tsx index 7040642..0acc154 100644 --- a/components/history/HistoryItem.tsx +++ b/components/history/HistoryItem.tsx @@ -8,6 +8,7 @@ import { Icons } from '@/components/ui/Icon'; import { formatTime, formatDate } from '@/lib/utils/format-utils'; import { PosterImage } from './PosterImage'; import { FavoriteButton } from '@/components/favorites/FavoriteButton'; +import { getSourceName } from '@/lib/utils/source-names'; import type { VideoHistoryItem } from '@/lib/types'; interface HistoryItemProps { @@ -24,6 +25,15 @@ export function HistoryItem({ item, onRemove, isPremium = false }: HistoryItemPr title: item.title, episode: item.episodeIndex.toString(), }); + // Pass sourceMap as groupedSources for source switching + if (item.sourceMap && Object.keys(item.sourceMap).length > 1) { + const groupData = Object.entries(item.sourceMap).map(([sourceName, videoId]) => ({ + id: videoId, + source: sourceName, + sourceName: getSourceName(sourceName), + })); + params.set('groupedSources', JSON.stringify(groupData)); + } if (isPremium) { params.set('premium', '1'); } diff --git a/components/iptv/IPTVSourceManager.tsx b/components/iptv/IPTVSourceManager.tsx index 339e397..64d4e8c 100644 --- a/components/iptv/IPTVSourceManager.tsx +++ b/components/iptv/IPTVSourceManager.tsx @@ -20,8 +20,10 @@ export function IPTVSourceManager() { setName(''); setUrl(''); setShowAdd(false); - // Auto-refresh after adding - setTimeout(() => refreshSources(), 100); + // Auto-refresh after adding (only if not already loading) + if (!isLoading) { + setTimeout(() => refreshSources(), 100); + } }; return ( @@ -57,13 +59,19 @@ export function IPTVSourceManager() { placeholder="源名称(如:我的IPTV)" value={name} onChange={(e) => setName(e.target.value)} + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]" /> setUrl(e.target.value)} + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]" />
diff --git a/lib/store/iptv-store.ts b/lib/store/iptv-store.ts index 9501a9b..5f5b4b7 100644 --- a/lib/store/iptv-store.ts +++ b/lib/store/iptv-store.ts @@ -30,6 +30,27 @@ interface IPTVActions { interface IPTVStore extends IPTVState, IPTVActions {} +const MAX_CONCURRENT = 3; + +async function fetchWithConcurrencyLimit( + tasks: (() => Promise)[], + limit: number +): Promise { + const results: T[] = []; + let index = 0; + + async function runNext(): Promise { + while (index < tasks.length) { + const currentIndex = index++; + results[currentIndex] = await tasks[currentIndex](); + } + } + + const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext()); + await Promise.all(workers); + return results; +} + export const useIPTVStore = create()( persist( (set, get) => ({ @@ -65,20 +86,20 @@ export const useIPTVStore = create()( const allChannels: M3UChannel[] = []; const allGroups = new Set(); - await Promise.all( - sources.map(async (source) => { - try { - const res = await fetch('/api/iptv?' + new URLSearchParams({ url: source.url })); - if (!res.ok) return; - const text = await res.text(); - const playlist = parseM3U(text); - allChannels.push(...playlist.channels); - playlist.groups.forEach((g) => allGroups.add(g)); - } catch (e) { - console.error(`Failed to fetch IPTV source: ${source.name}`, e); - } - }) - ); + const tasks = sources.map((source) => async () => { + try { + const res = await fetch('/api/iptv?' + new URLSearchParams({ url: source.url })); + if (!res.ok) return; + const text = await res.text(); + const playlist = parseM3U(text); + allChannels.push(...playlist.channels); + playlist.groups.forEach((g) => allGroups.add(g)); + } catch (e) { + console.error(`Failed to fetch IPTV source: ${source.name}`, e); + } + }); + + await fetchWithConcurrencyLimit(tasks, MAX_CONCURRENT); set({ cachedChannels: allChannels, diff --git a/package-lock.json b/package-lock.json index c3590d8..56252c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "kvideo", - "version": "4.3.7", + "version": "4.3.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.3.7", + "version": "4.3.8", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@vercel/analytics": "^1.6.1", "hls.js": "^1.6.15", - "lucide-react": "^0.570.0", + "lucide-react": "^0.571.0", "next": "16.1.6", "react": "19.2.4", "react-dom": "19.2.4", @@ -7230,9 +7230,9 @@ } }, "node_modules/lucide-react": { - "version": "0.570.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.570.0.tgz", - "integrity": "sha512-qGnQ8bEPJLMseKo7kI6jK6GW6Y2Yl4PpqoWbroNsobZ8+tZR4SUuO4EXK3oWCdZr48SZ7PnaulTkvzkKvG/Iqg==", + "version": "0.571.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.571.0.tgz", + "integrity": "sha512-WyKTOQ5MFyedq1C5kYEiWR1bpwa0lKQaxBgudVziReCiu5itJh6c0WTeg83sPv97IOyAcEA3mKOt4NCAq5cIOw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index 8012469..a9eba96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.3.7", + "version": "4.3.8", "private": true, "scripts": { "dev": "next dev", @@ -15,7 +15,7 @@ "@dnd-kit/utilities": "^3.2.2", "@vercel/analytics": "^1.6.1", "hls.js": "^1.6.15", - "lucide-react": "^0.570.0", + "lucide-react": "^0.571.0", "next": "16.1.6", "react": "19.2.4", "react-dom": "19.2.4",