mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: Implement IPTV functionality with channel management and player, and add admin account configuration generation.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* IPTVChannelGrid - Displays IPTV channels grouped by category with search
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import type { M3UChannel } from '@/lib/utils/m3u-parser';
|
||||
|
||||
interface IPTVChannelGridProps {
|
||||
channels: M3UChannel[];
|
||||
groups: string[];
|
||||
onSelect: (channel: M3UChannel) => void;
|
||||
activeChannel?: M3UChannel | null;
|
||||
}
|
||||
|
||||
export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: IPTVChannelGridProps) {
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredChannels = useMemo(() => {
|
||||
let result = channels;
|
||||
|
||||
if (selectedGroup) {
|
||||
result = result.filter((c) => c.group === selectedGroup);
|
||||
}
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase().trim();
|
||||
result = result.filter((c) => c.name.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [channels, selectedGroup, search]);
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16 text-[var(--text-color-secondary)]">
|
||||
<Icons.TV size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p className="text-sm">暂无频道</p>
|
||||
<p className="text-xs mt-1">请先添加 M3U 直播源</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search + Group Filter */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Icons.Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索频道..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group Tabs */}
|
||||
{groups.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedGroup(null)}
|
||||
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedGroup === null
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
全部 ({channels.length})
|
||||
</button>
|
||||
{groups.map((group) => {
|
||||
const count = channels.filter((c) => c.group === group).length;
|
||||
return (
|
||||
<button
|
||||
key={group}
|
||||
onClick={() => setSelectedGroup(group === selectedGroup ? null : group)}
|
||||
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedGroup === group
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
{group} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channel Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{filteredChannels.map((channel, index) => (
|
||||
<button
|
||||
key={`${channel.name}-${index}`}
|
||||
onClick={() => onSelect(channel)}
|
||||
className={`group p-3 rounded-[var(--radius-2xl)] border text-left transition-all duration-200 cursor-pointer ${
|
||||
activeChannel?.url === channel.url
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white shadow-[0_4px_12px_rgba(var(--accent-color-rgb),0.3)]'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{channel.logo ? (
|
||||
<img
|
||||
src={channel.logo}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded object-contain bg-black/10 flex-shrink-0"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={`w-8 h-8 rounded flex items-center justify-center flex-shrink-0 ${
|
||||
activeChannel?.url === channel.url ? 'bg-white/20' : 'bg-[var(--glass-bg)]'
|
||||
}`}>
|
||||
<Icons.TV size={14} className={activeChannel?.url === channel.url ? 'text-white/70' : 'text-[var(--text-color-secondary)]'} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-xs font-medium truncate ${
|
||||
activeChannel?.url === channel.url ? 'text-white' : 'text-[var(--text-color)]'
|
||||
}`}>
|
||||
{channel.name}
|
||||
</p>
|
||||
{channel.group && (
|
||||
<p className={`text-[10px] truncate ${
|
||||
activeChannel?.url === channel.url ? 'text-white/70' : 'text-[var(--text-color-secondary)]'
|
||||
}`}>
|
||||
{channel.group}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredChannels.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-[var(--text-color-secondary)]">
|
||||
未找到匹配的频道
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* IPTVPlayer - Lightweight player for IPTV live streams
|
||||
* Uses HLS.js for playback with a channel switching sidebar
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import type { M3UChannel } from '@/lib/utils/m3u-parser';
|
||||
|
||||
interface IPTVPlayerProps {
|
||||
channel: M3UChannel;
|
||||
onClose: () => void;
|
||||
channels: M3UChannel[];
|
||||
onChannelChange: (channel: M3UChannel) => void;
|
||||
}
|
||||
|
||||
export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTVPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSidebar, setShowSidebar] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadChannel = useCallback((ch: M3UChannel) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
// Clean up previous HLS instance
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
|
||||
const url = ch.url;
|
||||
|
||||
if (url.endsWith('.m3u8') || url.includes('.m3u8')) {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
liveDurationInfinity: true,
|
||||
});
|
||||
hlsRef.current = hls;
|
||||
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setIsLoading(false);
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
setIsLoading(false);
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
setError('网络错误,无法加载频道');
|
||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
hls.recoverMediaError();
|
||||
} else {
|
||||
setError('播放错误,请尝试其他频道');
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Native HLS (Safari/iOS)
|
||||
video.src = url;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setIsLoading(false);
|
||||
video.play().catch(() => {});
|
||||
}, { once: true });
|
||||
video.addEventListener('error', () => {
|
||||
setIsLoading(false);
|
||||
setError('播放错误');
|
||||
}, { once: true });
|
||||
} else {
|
||||
setError('您的浏览器不支持 HLS 播放');
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
// Direct video URL (mp4, etc.)
|
||||
video.src = url;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setIsLoading(false);
|
||||
video.play().catch(() => {});
|
||||
}, { once: true });
|
||||
video.addEventListener('error', () => {
|
||||
setIsLoading(false);
|
||||
setError('播放错误');
|
||||
}, { once: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadChannel(channel);
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [channel, loadChannel]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] bg-black flex">
|
||||
{/* Player Area */}
|
||||
<div className="flex-1 relative">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain bg-black"
|
||||
playsInline
|
||||
autoPlay
|
||||
/>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
<p className="text-white/70 text-sm">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Overlay */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
||||
<div className="text-center">
|
||||
<p className="text-red-400 text-sm mb-2">{error}</p>
|
||||
<button
|
||||
onClick={() => loadChannel(channel)}
|
||||
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white text-sm transition-colors cursor-pointer"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LIVE Badge */}
|
||||
<div className="absolute top-4 left-4 flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-red-600 text-white text-xs font-bold rounded flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
|
||||
LIVE
|
||||
</span>
|
||||
<span className="text-white text-sm font-medium drop-shadow-lg">{channel.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="absolute top-4 right-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/70 text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<Icons.List size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-black/50 hover:bg-black/70 text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<Icons.X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Sidebar */}
|
||||
{showSidebar && (
|
||||
<div className="w-72 bg-[#111] border-l border-white/10 overflow-y-auto">
|
||||
<div className="p-3 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-white text-sm font-medium">频道列表</h3>
|
||||
<button
|
||||
onClick={() => setShowSidebar(false)}
|
||||
className="text-white/50 hover:text-white cursor-pointer"
|
||||
>
|
||||
<Icons.X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
{channels.map((ch, i) => (
|
||||
<button
|
||||
key={`${ch.name}-${i}`}
|
||||
onClick={() => {
|
||||
onChannelChange(ch);
|
||||
setShowSidebar(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer ${
|
||||
ch.url === channel.url
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{ch.url === channel.url && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-white flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{ch.name}</span>
|
||||
</div>
|
||||
{ch.group && (
|
||||
<span className={`text-[10px] ${
|
||||
ch.url === channel.url ? 'text-white/60' : 'text-white/30'
|
||||
}`}>
|
||||
{ch.group}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* IPTVSourceManager - Admin UI to manage M3U playlist sources
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIPTVStore, type IPTVSource } from '@/lib/store/iptv-store';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
|
||||
export function IPTVSourceManager() {
|
||||
const { sources, addSource, removeSource, refreshSources, isLoading } = useIPTVStore();
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!name.trim() || !url.trim()) return;
|
||||
addSource(name.trim(), url.trim());
|
||||
setName('');
|
||||
setUrl('');
|
||||
setShowAdd(false);
|
||||
// Auto-refresh after adding
|
||||
setTimeout(() => refreshSources(), 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-[var(--text-color)]">
|
||||
直播源管理
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => refreshSources()}
|
||||
disabled={isLoading || sources.length === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] hover:border-[var(--accent-color)]/30 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Icons.RefreshCw size={12} className={isLoading ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAdd(!showAdd)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] hover:opacity-90 transition-all cursor-pointer"
|
||||
>
|
||||
<Icons.Plus size={12} />
|
||||
添加源
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Source Form */}
|
||||
{showAdd && (
|
||||
<div className="p-4 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="源名称(如:我的IPTV)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="M3U 链接地址"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowAdd(false)}
|
||||
className="px-3 py-1.5 text-xs text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!name.trim() || !url.trim()}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--accent-color)] text-white rounded-[var(--radius-2xl)] hover:opacity-90 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source List */}
|
||||
{sources.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-[var(--text-color-secondary)]">
|
||||
暂无直播源,请添加 M3U 播放列表链接
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sources.map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center justify-between p-3 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
|
||||
<p className="text-xs text-[var(--text-color-secondary)] truncate">{source.url}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeSource(source.id)}
|
||||
className="ml-2 p-1.5 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<Icons.Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user