feat: implement multi-level IPTV sidebar with source and group expansion, multi-route collapse, and optimized search performance.

This commit is contained in:
kuekhaoyang
2026-02-20 11:41:53 +08:00
parent d92fc9d986
commit 98599bb972
9 changed files with 374 additions and 81 deletions
+6 -1
View File
@@ -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` 非阻塞渲染,避免大列表卡顿
### 豆瓣集成
+2
View File
@@ -119,6 +119,8 @@ export default function IPTVPage() {
onClose={() => setActiveChannel(null)}
channels={cachedChannels}
onChannelChange={setActiveChannel}
channelsBySource={cachedChannelsBySource}
sources={sources}
/>
)}
</div>
+23 -2
View File
@@ -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;
+242 -67
View File
@@ -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,60 +874,27 @@ 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 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>
</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>
);
+2 -2
View File
@@ -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">
+15 -3
View File
@@ -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
View File
@@ -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(),
+2 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.4.7",
"version": "4.4.8",
"private": true,
"scripts": {
"dev": "next dev",