Spaces:
Sleeping
Sleeping
| import { Play, CheckCircle2 } from 'lucide-react'; | |
| import { useState, useEffect } from 'react'; | |
| export default function EpisodeList({ episodes, currentTap, movieSlug }) { | |
| const [serverIndex, setServerIndex] = useState(0); | |
| const [selectedChunkIndex, setSelectedChunkIndex] = useState(0); | |
| const [watched, setWatched] = useState([]); | |
| // Tự động đồng bộ server và tab (chunk) chứa tập đang phát (currentTap) | |
| useEffect(() => { | |
| if (currentTap && episodes?.length) { | |
| const foundServerIdx = episodes.findIndex(server => | |
| server.server_data?.some(ep => ep.name === currentTap) | |
| ); | |
| if (foundServerIdx !== -1) { | |
| setServerIndex(foundServerIdx); | |
| const targetServer = episodes[foundServerIdx]; | |
| const targetEpisodes = targetServer?.server_data || []; | |
| const epIdx = targetEpisodes.findIndex(ep => ep.name === currentTap); | |
| if (epIdx !== -1) { | |
| const targetChunkIdx = Math.floor(epIdx / 25); | |
| setSelectedChunkIndex(targetChunkIdx); | |
| } | |
| } | |
| } | |
| }, [currentTap, episodes]); | |
| // Tải lịch sử xem phim | |
| useEffect(() => { | |
| try { | |
| const history = JSON.parse(localStorage.getItem('txa_watch_history') || '{}'); | |
| if (history[movieSlug]?.watched) { | |
| setWatched(history[movieSlug].watched); | |
| } | |
| } catch (e) { | |
| console.error('Failed to load watch history'); | |
| } | |
| }, [movieSlug]); | |
| if (!episodes?.length) return null; | |
| const currentServer = episodes[serverIndex] || episodes[0]; | |
| const allEpisodes = currentServer?.server_data || []; | |
| // Chia danh sách tập phim thành các nhóm tối đa 25 tập | |
| const chunkSize = 25; | |
| const chunks = []; | |
| for (let i = 0; i < allEpisodes.length; i += chunkSize) { | |
| chunks.push(allEpisodes.slice(i, i + chunkSize)); | |
| } | |
| // Đảm bảo selectedChunkIndex không vượt quá phạm vi mới sau khi đổi server | |
| const activeChunkIndex = selectedChunkIndex < chunks.length ? selectedChunkIndex : 0; | |
| const visibleEpisodes = chunks[activeChunkIndex] || []; | |
| return ( | |
| <div suppressHydrationWarning className="glass rounded-[32px] p-6 shadow-2xl backdrop-blur-2xl border border-white/5 space-y-6"> | |
| {/* Tiêu đề & Chọn Server / Chọn Tab 25 tập */} | |
| <div suppressHydrationWarning className="flex flex-col gap-4 border-b border-white/5 pb-6"> | |
| <div suppressHydrationWarning className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center"> | |
| <div suppressHydrationWarning className="flex items-center gap-3"> | |
| <span className="h-8 w-2 rounded-full bg-primary shadow-[0_0_15px_rgba(229,9,20,0.5)]" /> | |
| <h3 className="text-xl font-black uppercase tracking-tighter text-white"> | |
| Danh sách tập | |
| </h3> | |
| </div> | |
| {/* Các Server phát */} | |
| {episodes.length > 1 && ( | |
| <div suppressHydrationWarning className="flex flex-wrap gap-2"> | |
| {episodes.map((server, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => { | |
| setServerIndex(idx); | |
| setSelectedChunkIndex(0); // Reset về tab đầu tiên khi đổi server | |
| }} | |
| className={`rounded-2xl px-5 py-2.5 text-xs font-black uppercase tracking-widest transition-all cursor-pointer ${ | |
| serverIndex === idx | |
| ? 'bg-primary text-white shadow-[0_0_20px_rgba(229,9,20,0.4)] scale-105' | |
| : 'glass text-gray-400 hover:text-white hover:bg-white/5' | |
| }`} | |
| > | |
| {server.server_name} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* Các Tab phân trang 25 tập (chỉ hiện khi có trên 25 tập) */} | |
| {chunks.length > 1 && ( | |
| <div suppressHydrationWarning className="flex flex-wrap gap-2 mt-2 pt-4 border-t border-white/5"> | |
| {chunks.map((chunk, idx) => { | |
| const startIdx = idx * chunkSize + 1; | |
| const endIdx = Math.min((idx + 1) * chunkSize, allEpisodes.length); | |
| // Tạo nhãn tab trực quan theo tên tập hoặc index số | |
| const getLabel = () => { | |
| const sName = chunk[0]?.name; | |
| const eName = chunk[chunk.length - 1]?.name; | |
| const sNum = parseInt(sName); | |
| const eNum = parseInt(eName); | |
| if (!isNaN(sNum) && !isNaN(eNum)) { | |
| return `Tập ${sNum} - ${eNum}`; | |
| } | |
| return `${sName} - ${eName}`; | |
| }; | |
| const isActive = activeChunkIndex === idx; | |
| return ( | |
| <button | |
| key={idx} | |
| onClick={() => setSelectedChunkIndex(idx)} | |
| className={`rounded-xl px-4 py-2 text-xs font-black transition-all cursor-pointer ${ | |
| isActive | |
| ? 'bg-yellow-500 text-dark shadow-[0_0_15px_rgba(234,179,8,0.3)] scale-105' | |
| : 'bg-white/5 text-gray-400 hover:text-white hover:bg-white/10' | |
| }`} | |
| > | |
| {getLabel()} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| {/* Grid danh sách tập */} | |
| <div suppressHydrationWarning className="grid grid-cols-3 gap-3 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10"> | |
| {visibleEpisodes.map((ep) => { | |
| const isWatched = watched.includes(ep.name); | |
| const isActive = ep.name === currentTap; | |
| return ( | |
| <a | |
| key={ep.name} | |
| href={`/xem/${movieSlug}?tap=${ep.name}`} | |
| className={`group relative flex flex-col items-center justify-center rounded-2xl py-4 transition-all duration-300 ${ | |
| isActive | |
| ? 'bg-primary shadow-[0_0_25px_rgba(229,9,20,0.3)] ring-2 ring-primary scale-105 z-10' | |
| : 'glass hover:bg-white/10 hover:translate-y-[-2px]' | |
| }`} | |
| > | |
| <span className={`text-sm font-black tracking-tight ${isActive ? 'text-white' : 'text-gray-400 group-hover:text-white'}`}> | |
| {ep.name.toLowerCase().includes('tập') ? ep.name : `Tập ${ep.name}`} | |
| </span> | |
| {isWatched && !isActive && ( | |
| <div className="absolute -right-1 -top-1 rounded-full bg-green-500 p-0.5 text-white shadow-lg ring-2 ring-[#0f0f0f]"> | |
| <CheckCircle2 size={10} fill="currentColor" className="text-white" /> | |
| </div> | |
| )} | |
| {isActive && ( | |
| <div className="mt-1"> | |
| <Play size={10} fill="white" className="text-white animate-pulse" /> | |
| </div> | |
| )} | |
| </a> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| } | |