feat: Implement background search for alternative video sources and enhance source switching capabilities.

This commit is contained in:
kuekhaoyang
2026-02-17 19:33:02 +08:00
parent 6a2e28c615
commit 798fac455a
6 changed files with 155 additions and 29 deletions
+91 -4
View File
@@ -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<SourceInfo[]>([]);
const groupedSources = useMemo<SourceInfo[]>(() => {
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);
+10
View File
@@ -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');
}
+11 -3
View File
@@ -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)]"
/>
<input
type="text"
type="url"
placeholder="M3U 链接地址"
value={url}
onChange={(e) => 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)]"
/>
<div className="flex justify-end gap-2">
+35 -14
View File
@@ -30,6 +30,27 @@ interface IPTVActions {
interface IPTVStore extends IPTVState, IPTVActions {}
const MAX_CONCURRENT = 3;
async function fetchWithConcurrencyLimit<T>(
tasks: (() => Promise<T>)[],
limit: number
): Promise<T[]> {
const results: T[] = [];
let index = 0;
async function runNext(): Promise<void> {
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<IPTVStore>()(
persist(
(set, get) => ({
@@ -65,20 +86,20 @@ export const useIPTVStore = create<IPTVStore>()(
const allChannels: M3UChannel[] = [];
const allGroups = new Set<string>();
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,
+6 -6
View File
@@ -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"
+2 -2
View File
@@ -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",