mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: Implement background search for alternative video sources and enhance source switching capabilities.
This commit is contained in:
+91
-4
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Generated
+6
-6
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user