mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-13 07:43:43 +08:00
feat: implement IPTV channel search debouncing and pagination, add video source auto-fallback, and improve type normalization.
This commit is contained in:
@@ -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 源的频道独立缓存,避免重复加载
|
||||
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.4.3",
|
||||
"version": "4.4.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user