🎉 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:
Cursor Agent
2025-08-29 04:39:36 +00:00
parent 303263d513
commit d811fb5aa6
13 changed files with 1212 additions and 491 deletions
+213
View File
@@ -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;
+215
View File
@@ -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;
+1 -5
View File
@@ -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: '综艺',
+17
View File
@@ -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>
{/* 菜单项 */}