🎉 Release KatelyaTV v2.0.0 - Major Update with IPTV Support
✨ New Features: - 📺 IPTV Live TV support with M3U playlist import - 🎮 Advanced channel management and favorites - �� Mobile-optimized IPTV player - 🔄 Multiple import methods (URL/File upload) 🛠️ Technical Improvements: - 🚀 Cloudflare Pages optimization (removed Docker) - 📱 iOS Safari compatibility fixes - 🎨 Modern UI/UX enhancements - ⚡ Performance optimizations 🔧 Development: - 📦 Updated to v2.0.0 - 📚 Comprehensive documentation update - 🛡️ Enhanced security and error handling - 🌐 Better responsive design Breaking Changes: - Removed Docker deployment support - Focus on Cloudflare Pages deployment - Updated environment variables This release transforms KatelyaTV into a comprehensive streaming platform with both VOD and live TV capabilities.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Search, Play, Star, StarOff, Tv, Globe, Heart } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface IPTVChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
logo?: string;
|
||||
group?: string;
|
||||
country?: string;
|
||||
language?: string;
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
interface IPTVChannelListProps {
|
||||
channels: IPTVChannel[];
|
||||
currentChannel?: IPTVChannel;
|
||||
onChannelSelect: (channel: IPTVChannel) => void;
|
||||
onToggleFavorite?: (channelId: string) => void;
|
||||
}
|
||||
|
||||
export function IPTVChannelList({
|
||||
channels,
|
||||
currentChannel,
|
||||
onChannelSelect,
|
||||
onToggleFavorite
|
||||
}: IPTVChannelListProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedGroup, setSelectedGroup] = useState<string>('all');
|
||||
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
||||
|
||||
// 按组分类频道
|
||||
const groupedChannels = channels.reduce((acc, channel) => {
|
||||
const group = channel.group || '其他';
|
||||
if (!acc[group]) acc[group] = [];
|
||||
acc[group].push(channel);
|
||||
return acc;
|
||||
}, {} as Record<string, IPTVChannel[]>);
|
||||
|
||||
// 获取所有组名
|
||||
const groups = Object.keys(groupedChannels).sort();
|
||||
|
||||
// 过滤频道
|
||||
const filteredChannels = channels.filter(channel => {
|
||||
const matchesSearch = channel.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesGroup = selectedGroup === 'all' || channel.group === selectedGroup;
|
||||
const matchesFavorite = !showFavoritesOnly || channel.isFavorite;
|
||||
|
||||
return matchesSearch && matchesGroup && matchesFavorite;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-4 h-full flex flex-col">
|
||||
{/* 头部 */}
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<Tv className="mr-2" size={24} />
|
||||
IPTV 频道
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
({filteredChannels.length})
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索频道..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 过滤器 */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<select
|
||||
value={selectedGroup}
|
||||
onChange={(e) => setSelectedGroup(e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="all">所有分组</option>
|
||||
{groups.map(group => (
|
||||
<option key={group} value={group}>{group}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
||||
className={`px-3 py-1 rounded-md text-sm flex items-center transition-colors ${
|
||||
showFavoritesOnly
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Heart size={14} className="mr-1" />
|
||||
收藏
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 频道列表 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{filteredChannels.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
className={`p-3 rounded-lg cursor-pointer transition-all duration-200 group ${
|
||||
currentChannel?.id === channel.id
|
||||
? 'bg-purple-100 dark:bg-purple-900 border-2 border-purple-500'
|
||||
: 'bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border-2 border-transparent'
|
||||
}`}
|
||||
onClick={() => onChannelSelect(channel)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 频道Logo */}
|
||||
<div className="w-12 h-12 rounded-lg overflow-hidden bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
|
||||
{channel.logo ? (
|
||||
<Image
|
||||
src={channel.logo}
|
||||
alt={channel.name}
|
||||
width={48}
|
||||
height={48}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Tv className="text-gray-400" size={24} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 频道信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{channel.name}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* 收藏按钮 */}
|
||||
{onToggleFavorite && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite(channel.id);
|
||||
}}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
channel.isFavorite
|
||||
? 'text-red-500 hover:text-red-600'
|
||||
: 'text-gray-400 hover:text-red-500'
|
||||
}`}
|
||||
>
|
||||
{channel.isFavorite ? <Star size={16} fill="currentColor" /> : <StarOff size={16} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 播放按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChannelSelect(channel);
|
||||
}}
|
||||
className="p-1 rounded text-gray-400 hover:text-purple-500 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Play size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 频道详情 */}
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{channel.group && (
|
||||
<span className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded">
|
||||
{channel.group}
|
||||
</span>
|
||||
)}
|
||||
{channel.country && (
|
||||
<span className="flex items-center">
|
||||
<Globe size={12} className="mr-1" />
|
||||
{channel.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredChannels.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<Tv size={48} className="mx-auto mb-2 opacity-50" />
|
||||
<p>没有找到匹配的频道</p>
|
||||
{searchQuery && (
|
||||
<p className="text-sm mt-1">
|
||||
尝试修改搜索关键词或切换分组
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IPTVChannelList;
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Play, Pause, Volume2, VolumeX, Maximize, Settings } from 'lucide-react';
|
||||
|
||||
interface IPTVChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
logo?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface IPTVPlayerProps {
|
||||
channels: IPTVChannel[];
|
||||
currentChannel?: IPTVChannel;
|
||||
onChannelChange?: (channel: IPTVChannel) => void;
|
||||
}
|
||||
|
||||
export function IPTVPlayer({ channels, currentChannel, onChannelChange }: IPTVPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(100);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !currentChannel) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const handleLoadStart = () => setIsLoading(true);
|
||||
const handleCanPlay = () => {
|
||||
setIsLoading(false);
|
||||
if (isPlaying) {
|
||||
video.play().catch(console.error);
|
||||
}
|
||||
};
|
||||
const handleError = () => {
|
||||
setIsLoading(false);
|
||||
setError('无法加载频道,请检查网络连接或尝试其他频道');
|
||||
};
|
||||
|
||||
video.addEventListener('loadstart', handleLoadStart);
|
||||
video.addEventListener('canplay', handleCanPlay);
|
||||
video.addEventListener('error', handleError);
|
||||
|
||||
// 加载新频道
|
||||
video.src = currentChannel.url;
|
||||
video.load();
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadstart', handleLoadStart);
|
||||
video.removeEventListener('canplay', handleCanPlay);
|
||||
video.removeEventListener('error', handleError);
|
||||
};
|
||||
}, [currentChannel, isPlaying]);
|
||||
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.play().catch(console.error);
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.muted = !isMuted;
|
||||
setIsMuted(!isMuted);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (newVolume: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.volume = newVolume / 100;
|
||||
setVolume(newVolume);
|
||||
setIsMuted(newVolume === 0);
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
video.requestFullscreen().catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetControlsTimeout = () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
setShowControls(true);
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const groupedChannels = channels.reduce((acc, channel) => {
|
||||
const group = channel.group || '其他';
|
||||
if (!acc[group]) acc[group] = [];
|
||||
acc[group].push(channel);
|
||||
return acc;
|
||||
}, {} as Record<string, IPTVChannel[]>);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-black rounded-lg overflow-hidden">
|
||||
{/* 视频播放器 */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-contain"
|
||||
playsInline
|
||||
onMouseMove={resetControlsTimeout}
|
||||
onTouchStart={resetControlsTimeout}
|
||||
/>
|
||||
|
||||
{/* 加载指示器 */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div className="flex items-center space-x-3 text-white">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div className="text-center text-white p-6">
|
||||
<div className="mb-4">⚠️</div>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制栏 */}
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
|
||||
showControls ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 播放/暂停 */}
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="text-white hover:text-purple-400 transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||
</button>
|
||||
|
||||
{/* 音量控制 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="text-white hover:text-purple-400 transition-colors"
|
||||
>
|
||||
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume}
|
||||
onChange={(e) => handleVolumeChange(Number(e.target.value))}
|
||||
className="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 频道信息 */}
|
||||
<div className="flex-1 text-white">
|
||||
<div className="text-sm opacity-80">正在播放</div>
|
||||
<div className="font-medium">{currentChannel?.name || '未选择频道'}</div>
|
||||
</div>
|
||||
|
||||
{/* 全屏 */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="text-white hover:text-purple-400 transition-colors"
|
||||
>
|
||||
<Maximize size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 频道列表 */}
|
||||
<div className="absolute top-4 right-4">
|
||||
<div className="relative">
|
||||
<button className="bg-black/50 text-white p-2 rounded-lg hover:bg-black/70 transition-colors">
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
|
||||
{/* 这里可以添加频道选择下拉菜单 */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IPTVPlayer;
|
||||
@@ -20,16 +20,12 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||
const navItems = [
|
||||
{ icon: Home, label: '首页', href: '/' },
|
||||
{ icon: Search, label: '搜索', href: '/search' },
|
||||
{ icon: Tv, label: 'IPTV', href: '/iptv' },
|
||||
{
|
||||
icon: Film,
|
||||
label: '电影',
|
||||
href: '/douban?type=movie',
|
||||
},
|
||||
{
|
||||
icon: Tv,
|
||||
label: '剧集',
|
||||
href: '/douban?type=tv',
|
||||
},
|
||||
{
|
||||
icon: Clover,
|
||||
label: '综艺',
|
||||
|
||||
@@ -216,6 +216,23 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
href='/iptv'
|
||||
onClick={() => setActive('/iptv')}
|
||||
data-active={active === '/iptv'}
|
||||
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-purple-100/30 hover:text-purple-600 data-[active=true]:bg-purple-500/20 data-[active=true]:text-purple-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-purple-400 dark:data-[active=true]:bg-purple-500/10 dark:data-[active=true]:text-purple-400 ${
|
||||
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||
} gap-3 justify-start`}
|
||||
>
|
||||
<div className='w-4 h-4 flex items-center justify-center'>
|
||||
<Tv className='h-4 w-4 text-gray-500 group-hover:text-purple-600 data-[active=true]:text-purple-700 dark:text-gray-400 dark:group-hover:text-purple-400 dark:data-[active=true]:text-purple-400' />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||
IPTV直播
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* 菜单项 */}
|
||||
|
||||
Reference in New Issue
Block a user