From c0fcfcc4acdf75617f8ac008d78e3ceb644f3790 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Thu, 19 Feb 2026 14:27:17 +0800 Subject: [PATCH] feat: implement IPTV channel search debouncing and pagination, add video source auto-fallback, and improve type normalization. --- README.md | 7 ++--- app/api/iptv/stream/route.ts | 9 ++++--- app/player/page.tsx | 28 ++++++++++++++++++- components/iptv/IPTVPlayer.tsx | 30 ++++++++++++++++++--- components/player/EpisodeList.tsx | 24 ++++++++++++++--- components/search/SourceBadgeList.tsx | 16 ++++++----- components/settings/UserDanmakuSettings.tsx | 5 +++- lib/hooks/useTypeBadges.ts | 15 ++++++++--- lib/hooks/useVideoPlayer.ts | 9 ++++++- package-lock.json | 4 +-- package.json | 2 +- 11 files changed, 120 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 5d691a3..01e013f 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ - **搜索历史**:自动保存搜索历史,支持快速重新搜索 - **搜索结果显示**:支持默认显示和合并同名源两种模式 - **实时延迟监测**:可选实时显示各源的网络延迟 -- **源过滤**:支持按源和类型筛选搜索结果,源标签支持按类型分组显示 +- **源过滤**:支持按源和类型筛选搜索结果,源标签支持按类型分组显示,智能合并同名分类标签 - **多级标签**:搜索结果和播放器中显示源名称和内容类型双重标签 ### 多线路折叠 @@ -65,14 +65,15 @@ - **按类型分组**:当线路包含类型信息时,自动按内容类型分组显示(如"电影"、"电视剧"等) - **延迟排序**:线路按网络延迟自动排序,最快的源排在前面 - **源切换**:在线路列表中快速切换到其他源,支持断点续播 +- **自动切源**:当当前源不可用时,自动切换到延迟最低的可用源 ### IPTV 直播 - **M3U 播放列表**:支持导入和管理 M3U/M3U8 格式的 IPTV 源 -- **频道网格**:按分组展示频道,支持分页浏览 +- **频道网格**:按分组展示频道,支持分页浏览,大列表搜索优化 - **自定义请求头**:自动解析 M3U 中的 `http-user-agent` 和 `http-referrer` 属性,通过代理传递 - **流媒体代理**:内置 HLS 流代理,自动处理 CORS 问题和 M3U8 URL 重写 -- **智能内容检测**:当 content-type 不明确时,检查响应体内容自动识别 M3U8 格式 +- **智能内容检测**:当 content-type 不明确时,检查响应体内容自动识别 M3U8 格式,同时保持二进制流数据完整性 - **重定向跟随**:自动跟随 HTTP 3xx 重定向,提升兼容性 - **超时保护**:15 秒请求超时、30 秒加载超时、20 秒分片加载超时、3 次清单重试 - **逐源频道缓存**:每个 IPTV 源的频道独立缓存,避免重复加载 diff --git a/app/api/iptv/stream/route.ts b/app/api/iptv/stream/route.ts index f9446ec..65b6e84 100644 --- a/app/api/iptv/stream/route.ts +++ b/app/api/iptv/stream/route.ts @@ -91,8 +91,10 @@ export async function GET(request: NextRequest) { contentType.includes('x-mpegURL'); // If content-type is ambiguous, check the response body for M3U header + // Use clone() to avoid consuming the original body for binary streams if (!isM3u8 && (contentType.includes('text/plain') || contentType.includes('application/octet-stream') || !contentType)) { - const text = await response.text(); + const cloned = response.clone(); + const text = await cloned.text(); if (text.trimStart().startsWith('#EXTM3U') || text.trimStart().startsWith('#EXT-X-')) { isM3u8 = true; } @@ -111,8 +113,9 @@ export async function GET(request: NextRequest) { }, }); } - // Not M3U8, return original text as binary-like response - return new NextResponse(text, { + // Not M3U8, stream original binary body directly to preserve data integrity + const body = response.body; + return new NextResponse(body, { status: response.status, headers: { 'Content-Type': contentType || 'video/mp2t', diff --git a/app/player/page.tsx b/app/player/page.tsx index b9d6bea..31dbb34 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -51,6 +51,9 @@ function PlayerContent() { return null; } + // Handle auto-fallback when current source is unavailable (defined later, uses ref) + const sourceUnavailableRef = useRef<(() => void) | undefined>(undefined); + const { videoData, loading, @@ -61,7 +64,9 @@ function PlayerContent() { setPlayUrl, setVideoError, fetchVideoDetails, - } = useVideoPlayer(videoId, source, episodeParam, isReversed); + } = useVideoPlayer(videoId, source, episodeParam, isReversed, useCallback(() => { + sourceUnavailableRef.current?.(); + }, [])); // Parse grouped sources if available const [discoveredSources, setDiscoveredSources] = useState([]); @@ -97,6 +102,27 @@ function PlayerContent() { return sources; }, [groupedSourcesParam, source, videoId, videoData?.vod_pic, discoveredSources]); + // Wire up the source unavailable handler now that groupedSources is defined + sourceUnavailableRef.current = () => { + const alternatives = groupedSources.filter(s => s.source !== source); + if (alternatives.length === 0) return; + + const best = [...alternatives].sort((a, b) => { + const latA = a.latency ?? Infinity; + const latB = b.latency ?? Infinity; + return latA - latB; + })[0]; + + const params = new URLSearchParams(); + params.set('id', String(best.id)); + params.set('source', best.source); + params.set('title', title || ''); + if (episodeParam) params.set('episode', episodeParam); + if (groupedSourcesParam) params.set('groupedSources', groupedSourcesParam); + if (isPremium) params.set('premium', '1'); + router.replace(`/player?${params.toString()}`, { scroll: false }); + }; + // Background fetch alternative sources when none provided or when existing ones lack full info const fetchedSourcesRef = useRef(false); useEffect(() => { diff --git a/components/iptv/IPTVPlayer.tsx b/components/iptv/IPTVPlayer.tsx index 4570803..746e516 100644 --- a/components/iptv/IPTVPlayer.tsx +++ b/components/iptv/IPTVPlayer.tsx @@ -58,6 +58,8 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV const [error, setError] = useState(null); const [showSidebar, setShowSidebar] = useState(false); const [sidebarSearch, setSidebarSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [sidebarVisibleCount, setSidebarVisibleCount] = useState(100); const [isLoading, setIsLoading] = useState(true); const [isPlaying, setIsPlaying] = useState(false); const [showControls, setShowControls] = useState(true); @@ -437,10 +439,19 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV const VolumeIcon = isMuted || volume === 0 ? Icons.VolumeX : volume < 0.5 ? Icons.Volume1 : Icons.Volume2; const filteredSidebarChannels = useMemo(() => { - if (!sidebarSearch.trim()) return channels; - const q = sidebarSearch.toLowerCase().trim(); + if (!debouncedSearch.trim()) return channels; + const q = debouncedSearch.toLowerCase().trim(); return channels.filter(ch => ch.name.toLowerCase().includes(q)); - }, [channels, sidebarSearch]); + }, [channels, debouncedSearch]); + + // Debounce search input + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(sidebarSearch); + setSidebarVisibleCount(100); + }, 200); + return () => clearTimeout(timer); + }, [sidebarSearch]); return (
- {filteredSidebarChannels.map((ch, i) => { + {filteredSidebarChannels.slice(0, sidebarVisibleCount).map((ch, i) => { const isActive = ch.name === channel.name && ch.url === channel.url; return ( + )}
)} diff --git a/components/player/EpisodeList.tsx b/components/player/EpisodeList.tsx index 3e3d93a..3a2a4aa 100644 --- a/components/player/EpisodeList.tsx +++ b/components/player/EpisodeList.tsx @@ -8,6 +8,7 @@ import { Icons } from '@/components/ui/Icon'; import { LatencyBadge } from '@/components/ui/LatencyBadge'; import { Button } from '@/components/ui/Button'; import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation'; +import { settingsStore } from '@/lib/store/settings-store'; interface Episode { name?: string; @@ -72,6 +73,17 @@ export function EpisodeList({ }); }, [sources, latencies]); + // Resolve source ID to its actual baseUrl for pinging + const getSourcePingUrl = useCallback((sourceId: string): string | null => { + const settings = settingsStore.getSettings(); + const allConfigs = [ + ...settings.sources, + ...settings.premiumSources, + ]; + const config = allConfigs.find(s => s.id === sourceId); + return config?.baseUrl || null; + }, []); + // Initialize latencies from sources useEffect(() => { if (!sources) return; @@ -93,10 +105,12 @@ export function EpisodeList({ const results = await Promise.all( missing.map(async (source) => { try { + const pingUrl = getSourcePingUrl(source.source); + if (!pingUrl) return { source: source.source, latency: undefined }; const response = await fetch('/api/ping', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: source.source }), + body: JSON.stringify({ url: pingUrl }), }); if (response.ok) { const data = await response.json(); @@ -116,7 +130,7 @@ export function EpisodeList({ }; autoRefresh(); } - }, [sources]); + }, [sources, getSourcePingUrl]); // Refresh latencies const refreshLatencies = useCallback(async () => { @@ -126,10 +140,12 @@ export function EpisodeList({ const results = await Promise.all( sources.map(async (source) => { try { + const pingUrl = getSourcePingUrl(source.source); + if (!pingUrl) return { source: source.source, latency: undefined }; const response = await fetch('/api/ping', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: source.source }), + body: JSON.stringify({ url: pingUrl }), }); if (response.ok) { const data = await response.json(); @@ -150,7 +166,7 @@ export function EpisodeList({ }); setLatencies(newLatencies); setIsLoadingLatency(false); - }, [sources]); + }, [sources, getSourcePingUrl]); // Memoized display episodes - reversed if toggle is on const displayEpisodes = useMemo(() => { diff --git a/components/search/SourceBadgeList.tsx b/components/search/SourceBadgeList.tsx index c537871..1c6910e 100644 --- a/components/search/SourceBadgeList.tsx +++ b/components/search/SourceBadgeList.tsx @@ -81,7 +81,8 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So }, [sources, onToggleSource]), }); - // Check if content has overflow on mount and when sources change + // Check if content has overflow on mount and when source count changes + const hasCheckedOverflow = useRef(false); useEffect(() => { const checkOverflow = () => { if (badgeContainerRef.current) { @@ -91,10 +92,13 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So }; checkOverflow(); - // Recheck after a short delay to account for animations - const timeout = setTimeout(checkOverflow, 100); - return () => clearTimeout(timeout); - }, [sources]); + // Only do delayed recheck on first measurement + if (!hasCheckedOverflow.current) { + hasCheckedOverflow.current = true; + const timeout = setTimeout(checkOverflow, 100); + return () => clearTimeout(timeout); + } + }, [sources.length]); return ( <> @@ -105,7 +109,7 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So role="group" aria-label="视频源筛选" > -
([]); @@ -100,7 +101,9 @@ export function UserDanmakuSettings() {

使用系统默认

{systemApiUrl && ( -

{systemApiUrl}

+

+ {hasPermission('danmaku_api') ? systemApiUrl : '内置 API'} +

)} {!systemApiUrl && (

未配置系统弹幕 API

diff --git a/lib/hooks/useTypeBadges.ts b/lib/hooks/useTypeBadges.ts index 8172aee..0e82cdf 100644 --- a/lib/hooks/useTypeBadges.ts +++ b/lib/hooks/useTypeBadges.ts @@ -17,12 +17,17 @@ import type { TypeBadge } from '@/lib/types'; // Normalize type names to merge near-duplicates function normalizeTypeName(type: string): string { - let t = type.trim(); - // Remove trailing 片/剧 suffix for grouping (e.g., "动作片" → "动作", "喜剧片" → "喜剧") + // Collapse whitespace and trim + let t = type.replace(/\s+/g, '').trim(); + // Apply NFC unicode normalization + t = t.normalize('NFC'); + // Remove trailing 片/剧/类 suffix for grouping (e.g., "动作片" → "动作", "喜剧片" → "喜剧") // But keep standalone names like "电影", "电视剧" etc. - if (t.length > 2 && t.endsWith('片')) { + if (t.length > 2 && (t.endsWith('片') || t.endsWith('剧') || t.endsWith('类'))) { t = t.slice(0, -1); } + // Lowercase for English name normalization (e.g., "Action" vs "action") + t = t.toLowerCase(); return t; } @@ -40,6 +45,10 @@ export function useTypeBadges(videos: T[]) { const existing = typeMap.get(normalized); if (existing) { existing.count++; + // Prefer shorter display name (e.g., "动作" over "动作片") + if (raw.length < existing.display.length) { + existing.display = raw; + } } else { typeMap.set(normalized, { display: raw, count: 1 }); } diff --git a/lib/hooks/useVideoPlayer.ts b/lib/hooks/useVideoPlayer.ts index 5571ee0..22d0e0d 100644 --- a/lib/hooks/useVideoPlayer.ts +++ b/lib/hooks/useVideoPlayer.ts @@ -32,7 +32,8 @@ export function useVideoPlayer( videoId: string | null, source: string | null, episodeParam: string | null, - isReversed: boolean = false + isReversed: boolean = false, + onSourceUnavailable?: () => void ): UseVideoPlayerReturn { const [videoData, setVideoData] = useState(null); // Initialize loading to true if we have the necessary params to start fetching @@ -45,6 +46,7 @@ export function useVideoPlayer( // This solves the stale closure problem while keeping fetchVideoDetails stable for the player const episodeParamRef = useRef(episodeParam); const isReversedRef = useRef(isReversed); + const onSourceUnavailableRef = useRef(onSourceUnavailable); useEffect(() => { episodeParamRef.current = episodeParam; @@ -54,6 +56,10 @@ export function useVideoPlayer( isReversedRef.current = isReversed; }, [isReversed]); + useEffect(() => { + onSourceUnavailableRef.current = onSourceUnavailable; + }, [onSourceUnavailable]); + const fetchVideoDetails = useCallback(async () => { @@ -93,6 +99,7 @@ export function useVideoPlayer( if (response.status === 404) { setVideoError(data.error || '该视频源不可用。请返回并尝试其他来源。'); setLoading(false); + onSourceUnavailableRef.current?.(); return; } throw new Error(data.error || `HTTP ${response.status}: ${response.statusText}`); diff --git a/package-lock.json b/package-lock.json index c68f852..48d9b3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvideo", - "version": "4.4.3", + "version": "4.4.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvideo", - "version": "4.4.3", + "version": "4.4.4", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 7712d01..3d82207 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.4.3", + "version": "4.4.4", "private": true, "scripts": { "dev": "next dev",