mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: implement multi-level IPTV sidebar with source and group expansion, multi-route collapse, and optimized search performance.
This commit is contained in:
@@ -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` 非阻塞渲染,避免大列表卡顿
|
||||
|
||||
### 豆瓣集成
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ export default function IPTVPage() {
|
||||
onClose={() => setActiveChannel(null)}
|
||||
channels={cachedChannels}
|
||||
onChannelChange={setActiveChannel}
|
||||
channelsBySource={cachedChannelsBySource}
|
||||
sources={sources}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+23
-2
@@ -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;
|
||||
|
||||
|
||||
+243
-68
@@ -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<Hls['config']> = {
|
||||
enableWorker: true,
|
||||
@@ -22,12 +24,15 @@ const HLS_LIVE_CONFIG: Partial<Hls['config']> = {
|
||||
};
|
||||
|
||||
const LOADING_TIMEOUT_MS = 30000;
|
||||
const MAX_VISIBLE_ROUTES = 3;
|
||||
|
||||
interface IPTVPlayerProps {
|
||||
channel: M3UChannel;
|
||||
onClose: () => void;
|
||||
channels: M3UChannel[];
|
||||
onChannelChange: (channel: M3UChannel) => void;
|
||||
channelsBySource?: Record<string, { channels: M3UChannel[]; groups: string[] }>;
|
||||
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<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -58,8 +63,9 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSidebar, setShowSidebar] = useState(false);
|
||||
const [sidebarSearch, setSidebarSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [sidebarVisibleCount, setSidebarVisibleCount] = useState(100);
|
||||
const [filteredResults, setFilteredResults] = useState<M3UChannel[]>([]);
|
||||
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<string | null>(null);
|
||||
const [activeGroup, setActiveGroup] = useState<string | null>(null);
|
||||
const [expandedSources, setExpandedSources] = useState<Set<string>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(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 (
|
||||
<button
|
||||
key={`${ch.sourceId || ''}-${ch.name}-${i}`}
|
||||
ref={isActive ? activeChannelRef : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChannelChange(ch);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isActive && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white flex-shrink-0 animate-pulse" />
|
||||
)}
|
||||
<span className="truncate flex-1">{ch.name}</span>
|
||||
{ch.routes && ch.routes.length > 1 && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded flex-shrink-0 ${
|
||||
isActive ? 'bg-white/20' : 'bg-white/5 text-white/40'
|
||||
}`}>
|
||||
{ch.routes.length}线路
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Render multi-level sidebar content
|
||||
const renderMultiLevelSidebar = () => {
|
||||
if (!channelsBySource || !sources) return null;
|
||||
|
||||
return (
|
||||
<div className="p-1">
|
||||
{sources.map(source => {
|
||||
const sourceData = channelsBySource[source.id];
|
||||
if (!sourceData || sourceData.channels.length === 0) return null;
|
||||
|
||||
const isExpanded = expandedSources.has(source.id);
|
||||
|
||||
return (
|
||||
<div key={source.id} className="mb-1">
|
||||
{/* Source Header */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleSource(source.id); }}
|
||||
className="w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm font-medium text-white/90 hover:bg-white/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icons.TV size={14} className="flex-shrink-0 text-[var(--accent-color)]" />
|
||||
<span className="truncate">{source.name}</span>
|
||||
<span className="text-[10px] text-white/40 flex-shrink-0">{sourceData.channels.length}</span>
|
||||
</div>
|
||||
<Icons.ChevronDown
|
||||
size={14}
|
||||
className={`flex-shrink-0 text-white/40 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Source Content */}
|
||||
{isExpanded && (
|
||||
<div className="ml-2 border-l border-white/10 pl-1">
|
||||
{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 (
|
||||
<div key={groupKey} className="mb-0.5">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleGroup(groupKey); }}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 rounded text-xs text-white/60 hover:bg-white/5 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Icons.Tag size={12} className="flex-shrink-0" />
|
||||
<span className="truncate">{group}</span>
|
||||
<span className="text-[10px] text-white/30 flex-shrink-0">{groupChannels.length}</span>
|
||||
</div>
|
||||
<Icons.ChevronDown
|
||||
size={12}
|
||||
className={`flex-shrink-0 text-white/30 transition-transform duration-200 ${groupExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{groupExpanded && (
|
||||
<div className="ml-2">
|
||||
{groupChannels.map((ch, i) => renderChannelButton(ch, i))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// 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 (
|
||||
<div className="mb-0.5">
|
||||
<div className="px-2 py-1 text-[10px] text-white/30">未分组</div>
|
||||
{ungrouped.map((ch, i) => renderChannelButton(ch, i))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render flat channel list (search results or single-source fallback)
|
||||
const renderFlatChannelList = (channelList: M3UChannel[]) => {
|
||||
const visible = channelList.slice(0, sidebarVisibleCount);
|
||||
return (
|
||||
<div className="p-1">
|
||||
{visible.map((ch, i) => renderChannelButton(ch, i))}
|
||||
{channelList.length > sidebarVisibleCount && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSidebarVisibleCount(prev => prev + 50);
|
||||
}}
|
||||
className="w-full py-2 text-xs text-white/50 hover:text-white/80 transition-colors cursor-pointer"
|
||||
>
|
||||
显示更多 ({channelList.length - sidebarVisibleCount} 个频道)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -576,10 +776,10 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Route Selector */}
|
||||
{/* Route Selector - collapsed */}
|
||||
{routes.length > 1 && (
|
||||
<div className="flex gap-1" data-controls>
|
||||
{routes.map((_, i) => (
|
||||
<div className="flex gap-1 items-center" data-controls>
|
||||
{visibleRoutes.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={(e) => { e.stopPropagation(); setCurrentRouteIndex(i); }}
|
||||
@@ -592,6 +792,14 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
线路{i + 1}
|
||||
</button>
|
||||
))}
|
||||
{hasMoreRoutes && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowAllRoutes(!showAllRoutes); }}
|
||||
className="px-2 py-0.5 text-[10px] rounded bg-white/5 text-white/40 hover:bg-white/10 hover:text-white/60 transition-colors cursor-pointer"
|
||||
>
|
||||
{showAllRoutes ? '收起' : `+${routes.length - MAX_VISIBLE_ROUTES}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
<div className="w-3 h-3 border border-white/30 border-t-white rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
{filteredSidebarChannels.slice(0, sidebarVisibleCount).map((ch, i) => {
|
||||
const isActive = ch.name === channel.name && ch.url === channel.url;
|
||||
return (
|
||||
<button
|
||||
key={`${ch.name}-${i}`}
|
||||
ref={isActive ? activeChannelRef : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChannelChange(ch);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isActive && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white flex-shrink-0 animate-pulse" />
|
||||
)}
|
||||
<span className="truncate flex-1">{ch.name}</span>
|
||||
{ch.routes && ch.routes.length > 1 && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded flex-shrink-0 ${
|
||||
isActive ? 'bg-white/20' : 'bg-white/5 text-white/40'
|
||||
}`}>
|
||||
{ch.routes.length}线路
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ch.group && (
|
||||
<span className={`text-[10px] ${isActive ? 'text-white/60' : 'text-white/30'}`}>
|
||||
{ch.group}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredSidebarChannels.length > sidebarVisibleCount && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSidebarVisibleCount(prev => prev + 100);
|
||||
}}
|
||||
className="w-full py-2 text-xs text-white/50 hover:text-white/80 transition-colors cursor-pointer"
|
||||
>
|
||||
显示更多 ({filteredSidebarChannels.length - sidebarVisibleCount} 个频道)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -102,7 +102,7 @@ export function IPTVSourceManager() {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="M3U 链接地址"
|
||||
placeholder="M3U / JSON 链接地址"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
{...inputProps}
|
||||
@@ -129,7 +129,7 @@ export function IPTVSourceManager() {
|
||||
{/* Source List */}
|
||||
{sources.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-[var(--text-color-secondary)]">
|
||||
暂无直播源,请添加 M3U 播放列表链接
|
||||
暂无直播源,请添加 M3U 或 JSON 播放列表链接
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
@@ -76,7 +82,7 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
|
||||
role="group"
|
||||
aria-label="类型筛选"
|
||||
>
|
||||
<div className={`relative transition-all duration-300 z-10 ${!isExpanded ? 'max-h-[50px] overflow-hidden' : 'overflow-visible'
|
||||
<div className={`relative transition-[max-height] duration-300 z-10 ${!isExpanded ? 'max-h-[50px] overflow-hidden' : 'overflow-visible'
|
||||
}`}>
|
||||
<div
|
||||
ref={badgeContainerRef}
|
||||
@@ -98,7 +104,13 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
|
||||
</div>
|
||||
{hasOverflow && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onClick={() => {
|
||||
setIsExpanded(prev => {
|
||||
const next = !prev;
|
||||
localStorage.setItem(TYPE_EXPAND_KEY, String(next));
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
|
||||
flex items-center gap-1 transition-colors self-start cursor-pointer"
|
||||
>
|
||||
|
||||
+80
-2
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* M3U Playlist Parser
|
||||
* Parses M3U/M3U8 IPTV playlist format
|
||||
* Parses M3U/M3U8 IPTV playlist format and JSON channel lists
|
||||
*/
|
||||
|
||||
export interface M3UChannel {
|
||||
@@ -23,9 +23,81 @@ export interface M3UPlaylist {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse M3U playlist content into structured data
|
||||
* Try to parse content as JSON channel list.
|
||||
* Supports formats:
|
||||
* - Array of channel objects: [{ name, url, group?, logo?, ... }]
|
||||
* - Object with channels/list field: { channels: [...] } or { list: [...] }
|
||||
*/
|
||||
function tryParseJSON(content: string): M3UPlaylist | null {
|
||||
try {
|
||||
const data = JSON.parse(content);
|
||||
let channels: any[] = [];
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
channels = data;
|
||||
} else if (data && typeof data === 'object') {
|
||||
channels = data.channels || data.list || data.items || [];
|
||||
if (!Array.isArray(channels)) return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (channels.length === 0) return null;
|
||||
|
||||
// Validate that items look like channel data
|
||||
const first = channels[0];
|
||||
if (!first || typeof first !== 'object') return null;
|
||||
// Must have at least a name and url
|
||||
if (!first.name && !first.title && !first.channel_name) return null;
|
||||
if (!first.url && !first.stream_url && !first.src) return null;
|
||||
|
||||
const groupSet = new Set<string>();
|
||||
const parsed: M3UChannel[] = [];
|
||||
|
||||
for (const ch of channels) {
|
||||
const name = ch.name || ch.title || ch.channel_name || '';
|
||||
const url = ch.url || ch.stream_url || ch.src || '';
|
||||
if (!name || !url) continue;
|
||||
|
||||
const group = ch.group || ch.group_title || ch.category || '';
|
||||
if (group) groupSet.add(group);
|
||||
|
||||
parsed.push({
|
||||
name,
|
||||
url,
|
||||
logo: ch.logo || ch.icon || ch.tvg_logo || undefined,
|
||||
group: group || undefined,
|
||||
tvgId: ch.tvg_id || ch.tvgId || undefined,
|
||||
tvgName: ch.tvg_name || ch.tvgName || undefined,
|
||||
httpUserAgent: ch.http_user_agent || ch.httpUserAgent || ch.user_agent || undefined,
|
||||
httpReferrer: ch.http_referrer || ch.httpReferrer || ch.referer || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.length === 0) return null;
|
||||
|
||||
return {
|
||||
channels: parsed,
|
||||
groups: Array.from(groupSet).sort(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse M3U playlist content into structured data.
|
||||
* Also supports JSON format channel lists.
|
||||
*/
|
||||
export function parseM3U(content: string): M3UPlaylist {
|
||||
const trimmed = content.trim();
|
||||
|
||||
// Try JSON first if it looks like JSON
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
const jsonResult = tryParseJSON(trimmed);
|
||||
if (jsonResult) return jsonResult;
|
||||
}
|
||||
|
||||
const lines = content.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
||||
const channels: M3UChannel[] = [];
|
||||
const groupSet = new Set<string>();
|
||||
@@ -80,6 +152,12 @@ export function parseM3U(content: string): M3UPlaylist {
|
||||
}
|
||||
}
|
||||
|
||||
// If no EXTINF entries were found, also try JSON as a fallback
|
||||
if (channels.length === 0) {
|
||||
const jsonResult = tryParseJSON(content);
|
||||
if (jsonResult) return jsonResult;
|
||||
}
|
||||
|
||||
return {
|
||||
channels,
|
||||
groups: Array.from(groupSet).sort(),
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.4.7",
|
||||
"version": "4.4.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kvideo",
|
||||
"version": "4.4.7",
|
||||
"version": "4.4.8",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.4.7",
|
||||
"version": "4.4.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user