feat: implement IPTV channel search debouncing and pagination, add video source auto-fallback, and improve type normalization.

This commit is contained in:
kuekhaoyang
2026-02-19 14:27:17 +08:00
parent 9ea9f85507
commit c0fcfcc4ac
11 changed files with 120 additions and 29 deletions
+4 -3
View File
@@ -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 源的频道独立缓存,避免重复加载
+6 -3
View File
@@ -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',
+27 -1
View File
@@ -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<SourceInfo[]>([]);
@@ -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(() => {
+26 -4
View File
@@ -58,6 +58,8 @@ 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 [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 (
<div
@@ -659,7 +670,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
</div>
</div>
<div className="p-1">
{filteredSidebarChannels.map((ch, i) => {
{filteredSidebarChannels.slice(0, sidebarVisibleCount).map((ch, i) => {
const isActive = ch.name === channel.name && ch.url === channel.url;
return (
<button
@@ -696,6 +707,17 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
</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>
)}
+20 -4
View File
@@ -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(() => {
+10 -6
View File
@@ -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="视频源筛选"
>
<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}
+4 -1
View File
@@ -5,6 +5,7 @@ import { SettingsSection } from './SettingsSection';
import { Icons } from '@/components/ui/Icon';
import { userSourcesStore, type DanmakuApiEntry } from '@/lib/store/user-sources-store';
import { settingsStore } from '@/lib/store/settings-store';
import { hasPermission } from '@/lib/store/auth-store';
export function UserDanmakuSettings() {
const [apis, setApis] = useState<DanmakuApiEntry[]>([]);
@@ -100,7 +101,9 @@ export function UserDanmakuSettings() {
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--text-color)]">使</p>
{systemApiUrl && (
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">{systemApiUrl}</p>
<p className="text-[10px] text-[var(--text-color-secondary)] truncate">
{hasPermission('danmaku_api') ? systemApiUrl : '内置 API'}
</p>
)}
{!systemApiUrl && (
<p className="text-[10px] text-[var(--text-color-secondary)]"> API</p>
+12 -3
View File
@@ -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<T extends { type_name?: string }>(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 });
}
+8 -1
View File
@@ -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<VideoData | null>(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}`);
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.4.3",
"version": "4.4.4",
"private": true,
"scripts": {
"dev": "next dev",