From 98599bb9722ed453595829cb3ed10757142fb44a Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Fri, 20 Feb 2026 11:41:53 +0800 Subject: [PATCH] feat: implement multi-level IPTV sidebar with source and group expansion, multi-route collapse, and optimized search performance. --- README.md | 7 +- app/iptv/page.tsx | 2 + app/player/page.tsx | 25 ++- components/iptv/IPTVPlayer.tsx | 311 ++++++++++++++++++++------ components/iptv/IPTVSourceManager.tsx | 4 +- components/search/TypeBadgeList.tsx | 18 +- lib/utils/m3u-parser.ts | 82 ++++++- package-lock.json | 4 +- package.json | 2 +- 9 files changed, 374 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 01e013f..855bfa5 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ - **搜索历史**:自动保存搜索历史,支持快速重新搜索 - **搜索结果显示**:支持默认显示和合并同名源两种模式 - **实时延迟监测**:可选实时显示各源的网络延迟 -- **源过滤**:支持按源和类型筛选搜索结果,源标签支持按类型分组显示,智能合并同名分类标签 +- **源过滤**:支持按源和类型筛选搜索结果,源标签支持按类型分组显示,智能合并同名分类标签,展开/折叠状态持久化记忆 - **多级标签**:搜索结果和播放器中显示源名称和内容类型双重标签 ### 多线路折叠 @@ -70,7 +70,11 @@ ### IPTV 直播 - **M3U 播放列表**:支持导入和管理 M3U/M3U8 格式的 IPTV 源 +- **JSON 频道列表**:支持导入 JSON 格式的频道列表(数组或对象格式,自动识别) - **频道网格**:按分组展示频道,支持分页浏览,大列表搜索优化 +- **多级频道列表**:播放器内按源分组 → 按分类分组 → 频道的三级列表导航 +- **多线路折叠**:频道多线路默认显示前 3 条,可点击展开查看全部 +- **自动切源**:当视频源不可用时,自动选择延迟最低的可用源 - **自定义请求头**:自动解析 M3U 中的 `http-user-agent` 和 `http-referrer` 属性,通过代理传递 - **流媒体代理**:内置 HLS 流代理,自动处理 CORS 问题和 M3U8 URL 重写 - **智能内容检测**:当 content-type 不明确时,检查响应体内容自动识别 M3U8 格式,同时保持二进制流数据完整性 @@ -80,6 +84,7 @@ - **并发控制**:最多同时拉取 3 个源,防止网络拥堵 - **权限控制**:通过 `iptv_access` 权限控制谁可以访问 IPTV 功能 - **键盘快捷键**:播放器内支持空格暂停/继续、F 全屏、M 静音、方向键调节音量等 +- **搜索优化**:播放器内搜索使用 `useTransition` 非阻塞渲染,避免大列表卡顿 ### 豆瓣集成 diff --git a/app/iptv/page.tsx b/app/iptv/page.tsx index baceff5..ba9b10f 100644 --- a/app/iptv/page.tsx +++ b/app/iptv/page.tsx @@ -119,6 +119,8 @@ export default function IPTVPage() { onClose={() => setActiveChannel(null)} channels={cachedChannels} onChannelChange={setActiveChannel} + channelsBySource={cachedChannelsBySource} + sources={sources} /> )} diff --git a/app/player/page.tsx b/app/player/page.tsx index 31dbb34..2d98dfa 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -53,6 +53,7 @@ function PlayerContent() { // Handle auto-fallback when current source is unavailable (defined later, uses ref) const sourceUnavailableRef = useRef<(() => void) | undefined>(undefined); + const pendingFallbackRef = useRef(false); const { videoData, @@ -99,14 +100,26 @@ function PlayerContent() { pic: videoData?.vod_pic }); } + + // Use current video's poster as fallback pic for sources that don't have one + const fallbackPic = videoData?.vod_pic; + if (fallbackPic) { + sources = sources.map(s => s.pic ? s : { ...s, pic: fallbackPic }); + } + 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; + if (alternatives.length === 0) { + // No alternatives yet — mark pending so we retry when discovered sources arrive + pendingFallbackRef.current = true; + return; + } + pendingFallbackRef.current = false; const best = [...alternatives].sort((a, b) => { const latA = a.latency ?? Infinity; const latB = b.latency ?? Infinity; @@ -123,6 +136,13 @@ function PlayerContent() { router.replace(`/player?${params.toString()}`, { scroll: false }); }; + // Retry pending fallback when discovered sources arrive + useEffect(() => { + if (pendingFallbackRef.current && discoveredSources.length > 0) { + sourceUnavailableRef.current?.(); + } + }, [discoveredSources]); + // Background fetch alternative sources when none provided or when existing ones lack full info const fetchedSourcesRef = useRef(false); useEffect(() => { @@ -133,7 +153,8 @@ function PlayerContent() { if (groupedSourcesParam) { try { existingSources = JSON.parse(groupedSourcesParam); } catch {} } - const hasFullInfo = existingSources.length > 1 && + // Always fetch alternatives if there's a pending fallback (source unavailable) + const hasFullInfo = !pendingFallbackRef.current && existingSources.length > 1 && existingSources.every(s => s.pic || s.latency !== undefined); if (hasFullInfo) return; diff --git a/components/iptv/IPTVPlayer.tsx b/components/iptv/IPTVPlayer.tsx index 746e516..a75d8f3 100644 --- a/components/iptv/IPTVPlayer.tsx +++ b/components/iptv/IPTVPlayer.tsx @@ -3,13 +3,15 @@ /** * IPTVPlayer - Player for IPTV streams with controls, volume, progress, and sidebar. * Supports HLS (via HLS.js), native HLS (Safari), and direct video playback. - * Routes streams through proxy to avoid CORS when direct access fails. + * Features multi-level sidebar (source -> group -> channels), multi-route collapse, + * and optimized search performance. */ -import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import { useRef, useEffect, useState, useCallback, useMemo, useTransition } from 'react'; import Hls from 'hls.js'; import { Icons } from '@/components/ui/Icon'; import type { M3UChannel } from '@/lib/utils/m3u-parser'; +import type { IPTVSource } from '@/lib/store/iptv-store'; const HLS_LIVE_CONFIG: Partial = { enableWorker: true, @@ -22,12 +24,15 @@ const HLS_LIVE_CONFIG: Partial = { }; const LOADING_TIMEOUT_MS = 30000; +const MAX_VISIBLE_ROUTES = 3; interface IPTVPlayerProps { channel: M3UChannel; onClose: () => void; channels: M3UChannel[]; onChannelChange: (channel: M3UChannel) => void; + channelsBySource?: Record; + sources?: IPTVSource[]; } function getProxiedUrl(url: string, ua?: string, referer?: string): string { @@ -47,7 +52,7 @@ function formatTime(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } -export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTVPlayerProps) { +export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channelsBySource, sources }: IPTVPlayerProps) { const videoRef = useRef(null); const hlsRef = useRef(null); const containerRef = useRef(null); @@ -58,8 +63,9 @@ 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 [filteredResults, setFilteredResults] = useState([]); + const [isSearching, startSearchTransition] = useTransition(); + const [sidebarVisibleCount, setSidebarVisibleCount] = useState(50); const [isLoading, setIsLoading] = useState(true); const [isPlaying, setIsPlaying] = useState(false); const [showControls, setShowControls] = useState(true); @@ -71,11 +77,25 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV const [showVolumeSlider, setShowVolumeSlider] = useState(false); const [currentRouteIndex, setCurrentRouteIndex] = useState(0); const [isFullscreen, setIsFullscreen] = useState(false); + const [showAllRoutes, setShowAllRoutes] = useState(false); + + // Multi-level sidebar state + const [activeSourceId, setActiveSourceId] = useState(null); + const [activeGroup, setActiveGroup] = useState(null); + const [expandedSources, setExpandedSources] = useState>(new Set()); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + + // Whether we have multi-source data + const hasMultiSource = channelsBySource && sources && sources.length > 0; // Get current route URL const routes = channel.routes || [channel.url]; const currentUrl = routes[currentRouteIndex] || channel.url; + // Route display - collapse if > MAX_VISIBLE_ROUTES + const visibleRoutes = showAllRoutes ? routes : routes.slice(0, MAX_VISIBLE_ROUTES); + const hasMoreRoutes = routes.length > MAX_VISIBLE_ROUTES; + // Auto-scroll to active channel in sidebar useEffect(() => { if (showSidebar && activeChannelRef.current) { @@ -83,6 +103,16 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV } }, [showSidebar, channel.url]); + // Auto-expand the source/group containing the active channel + useEffect(() => { + if (channel.sourceId) { + setExpandedSources(prev => new Set(prev).add(channel.sourceId!)); + if (channel.group) { + setExpandedGroups(prev => new Set(prev).add(`${channel.sourceId}::${channel.group}`)); + } + } + }, [channel.sourceId, channel.group]); + // Track fullscreen changes useEffect(() => { const handleFullscreenChange = () => { @@ -335,6 +365,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV // Reset route index when channel changes useEffect(() => { setCurrentRouteIndex(0); + setShowAllRoutes(false); }, [channel.name, channel.url]); // Playback controls @@ -438,20 +469,189 @@ 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 (!debouncedSearch.trim()) return channels; - const q = debouncedSearch.toLowerCase().trim(); - return channels.filter(ch => ch.name.toLowerCase().includes(q)); - }, [channels, debouncedSearch]); - - // Debounce search input + // Debounce search with useTransition for non-blocking rendering useEffect(() => { const timer = setTimeout(() => { - setDebouncedSearch(sidebarSearch); - setSidebarVisibleCount(100); + const q = sidebarSearch.toLowerCase().trim(); + if (!q) { + setFilteredResults([]); + setSidebarVisibleCount(50); + return; + } + startSearchTransition(() => { + const results = channels.filter(ch => ch.name.toLowerCase().includes(q)); + setFilteredResults(results); + setSidebarVisibleCount(50); + }); }, 200); return () => clearTimeout(timer); - }, [sidebarSearch]); + }, [sidebarSearch, channels]); + + const isSearchMode = sidebarSearch.trim().length > 0; + + // Toggle source expansion + const toggleSource = (sourceId: string) => { + setExpandedSources(prev => { + const next = new Set(prev); + if (next.has(sourceId)) next.delete(sourceId); + else next.add(sourceId); + return next; + }); + }; + + // Toggle group expansion + const toggleGroup = (key: string) => { + setExpandedGroups(prev => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + // Render a channel button + const renderChannelButton = (ch: M3UChannel, i: number) => { + const isActive = ch.name === channel.name && ch.url === channel.url; + return ( + + ); + }; + + // Render multi-level sidebar content + const renderMultiLevelSidebar = () => { + if (!channelsBySource || !sources) return null; + + return ( +
+ {sources.map(source => { + const sourceData = channelsBySource[source.id]; + if (!sourceData || sourceData.channels.length === 0) return null; + + const isExpanded = expandedSources.has(source.id); + + return ( +
+ {/* Source Header */} + + + {/* Source Content */} + {isExpanded && ( +
+ {sourceData.groups.length > 0 ? ( + // Has groups — show group-level + sourceData.groups.map(group => { + const groupKey = `${source.id}::${group}`; + const groupExpanded = expandedGroups.has(groupKey); + const groupChannels = sourceData.channels.filter(ch => ch.group === group); + + return ( +
+ + {groupExpanded && ( +
+ {groupChannels.map((ch, i) => renderChannelButton(ch, i))} +
+ )} +
+ ); + }) + ) : ( + // No groups — show channels directly + sourceData.channels.map((ch, i) => renderChannelButton(ch, i)) + )} + + {/* Ungrouped channels */} + {sourceData.groups.length > 0 && (() => { + const ungrouped = sourceData.channels.filter(ch => !ch.group); + if (ungrouped.length === 0) return null; + return ( +
+
未分组
+ {ungrouped.map((ch, i) => renderChannelButton(ch, i))} +
+ ); + })()} +
+ )} +
+ ); + })} +
+ ); + }; + + // Render flat channel list (search results or single-source fallback) + const renderFlatChannelList = (channelList: M3UChannel[]) => { + const visible = channelList.slice(0, sidebarVisibleCount); + return ( +
+ {visible.map((ch, i) => renderChannelButton(ch, i))} + {channelList.length > sidebarVisibleCount && ( + + )} +
+ ); + }; return (
- {/* Route Selector */} + {/* Route Selector - collapsed */} {routes.length > 1 && ( -
- {routes.map((_, i) => ( +
+ {visibleRoutes.map((_, i) => ( ))} + {hasMoreRoutes && ( + + )}
)} @@ -666,59 +874,26 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV onClick={(e) => e.stopPropagation()} className="w-full pl-7 pr-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-xs text-white placeholder:text-white/30 focus:outline-none focus:border-white/20" /> + {isSearching && ( +
+
+
+ )}
-
- {filteredSidebarChannels.slice(0, sidebarVisibleCount).map((ch, i) => { - const isActive = ch.name === channel.name && ch.url === channel.url; - return ( - - ); - })} - {filteredSidebarChannels.length > sidebarVisibleCount && ( - - )} -
+ + {/* Sidebar Content */} + {isSearchMode ? ( + // Search mode — flat list of filtered results + renderFlatChannelList(filteredResults) + ) : hasMultiSource ? ( + // Multi-source mode — hierarchical list + renderMultiLevelSidebar() + ) : ( + // Single source or fallback — flat list + renderFlatChannelList(channels) + )} )} diff --git a/components/iptv/IPTVSourceManager.tsx b/components/iptv/IPTVSourceManager.tsx index a7e17f6..534189c 100644 --- a/components/iptv/IPTVSourceManager.tsx +++ b/components/iptv/IPTVSourceManager.tsx @@ -102,7 +102,7 @@ export function IPTVSourceManager() { /> setUrl(e.target.value)} {...inputProps} @@ -129,7 +129,7 @@ export function IPTVSourceManager() { {/* Source List */} {sources.length === 0 ? (
- 暂无直播源,请添加 M3U 播放列表链接 + 暂无直播源,请添加 M3U 或 JSON 播放列表链接
) : (
diff --git a/components/search/TypeBadgeList.tsx b/components/search/TypeBadgeList.tsx index 927ccdf..2651297 100644 --- a/components/search/TypeBadgeList.tsx +++ b/components/search/TypeBadgeList.tsx @@ -11,6 +11,8 @@ import { Icons } from '@/components/ui/Icon'; import { TypeBadgeItem } from './TypeBadgeItem'; import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation'; +const TYPE_EXPAND_KEY = 'kvideo_type_badges_expanded'; + interface TypeBadge { type: string; count: number; @@ -23,7 +25,11 @@ interface TypeBadgeListProps { } export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadgeListProps) { - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(() => { + if (typeof window === 'undefined') return true; + const saved = localStorage.getItem(TYPE_EXPAND_KEY); + return saved !== 'false'; // default to expanded + }); const [focusedIndex, setFocusedIndex] = useState(-1); const [hasOverflow, setHasOverflow] = useState(false); const containerRef = useRef(null); @@ -76,7 +82,7 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge role="group" aria-label="类型筛选" > -
{hasOverflow && (