Spaces:
Sleeping
Sleeping
| import React, { useEffect, useState } from 'react'; | |
| import { useParams, Link } from 'react-router-dom'; | |
| import { Play, Plus, ThumbsUp, Share2, ChevronDown } from 'lucide-react'; | |
| import { getTvShowMetadata, getGenresItems } from '../lib/api'; | |
| import ContentRow from '../components/ContentRow'; | |
| import { useToast } from '@/hooks/use-toast'; | |
| interface Episode { | |
| episode_number: number; | |
| name: string; | |
| overview: string; | |
| still_path: string; | |
| air_date: string; | |
| runtime: number; | |
| fileName?: string; // The actual file name with extension | |
| } | |
| interface Season { | |
| season_number: number; | |
| name: string; | |
| overview: string; | |
| poster_path: string; | |
| air_date: string; | |
| episodes: Episode[]; | |
| } | |
| interface FileStructureItem { | |
| type: string; | |
| path: string; | |
| contents?: FileStructureItem[]; | |
| size?: number; | |
| } | |
| const TvShowDetailPage = () => { | |
| const { title } = useParams<{ title: string }>(); | |
| const [tvShow, setTvShow] = useState<any>(null); | |
| const [seasons, setSeasons] = useState<Season[]>([]); | |
| const [selectedSeason, setSelectedSeason] = useState<number>(1); | |
| const [episodes, setEpisodes] = useState<Episode[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [seasonsLoading, setSeasonsLoading] = useState(false); | |
| const [similarShows, setSimilarShows] = useState<any[]>([]); | |
| const [expandedSeasons, setExpandedSeasons] = useState(false); | |
| const { toast } = useToast(); | |
| // Helper function to extract episode info from file path | |
| const extractEpisodeInfoFromPath = (filePath: string): Episode | null => { | |
| // Get the actual file name (with extension) from the full file path | |
| const fileName = filePath.split('/').pop() || filePath; | |
| // For file names like "Nanbaka - S01E02 - The Inmates Are Stupid! The Guards Are Kind of Stupid, Too! SDTV.mp4" | |
| const episodeRegex = /S(\d+)E(\d+)\s*-\s*(.+?)(?=\.\w+$)/i; | |
| const match = fileName.match(episodeRegex); | |
| if (match) { | |
| const episodeNumber = parseInt(match[2], 10); | |
| const episodeName = match[3].trim(); | |
| // Determine quality from the file name | |
| const isHD = fileName.toLowerCase().includes('720p') || | |
| fileName.toLowerCase().includes('1080p') || | |
| fileName.toLowerCase().includes('hdtv'); | |
| return { | |
| episode_number: episodeNumber, | |
| name: episodeName, | |
| overview: '', // No overview available from file path | |
| still_path: '/placeholder.svg', // Use placeholder image | |
| air_date: '', // No air date available | |
| runtime: isHD ? 24 : 22, // Approximate runtime based on quality | |
| fileName: fileName // Store only the file name with extension | |
| }; | |
| } | |
| return null; | |
| }; | |
| // Helper function to extract season number and name from directory path | |
| const getSeasonInfoFromPath = (path: string): { number: number, name: string } => { | |
| const seasonRegex = /Season\s*(\d+)/i; | |
| const specialsRegex = /Specials/i; | |
| if (specialsRegex.test(path)) { | |
| return { number: 0, name: 'Specials' }; | |
| } | |
| const match = path.match(seasonRegex); | |
| if (match) { | |
| return { | |
| number: parseInt(match[1], 10), | |
| name: `Season ${match[1]}` | |
| }; | |
| } | |
| return { number: 1, name: 'Season 1' }; // Default if no match | |
| }; | |
| // Process the file structure to extract seasons and episodes | |
| const processTvShowFileStructure = (fileStructure: any): Season[] => { | |
| if (!fileStructure || !fileStructure.contents) { | |
| return []; | |
| } | |
| const extractedSeasons: Season[] = []; | |
| // Find season directories | |
| const seasonDirectories = fileStructure.contents.filter( | |
| (item: FileStructureItem) => item.type === 'directory' | |
| ); | |
| seasonDirectories.forEach((seasonDir: FileStructureItem) => { | |
| if (!seasonDir.contents) return; | |
| const seasonInfo = getSeasonInfoFromPath(seasonDir.path); | |
| const episodesArr: Episode[] = []; | |
| // Process files in this season directory | |
| seasonDir.contents.forEach((item: FileStructureItem) => { | |
| if (item.type === 'file') { | |
| const episode = extractEpisodeInfoFromPath(item.path); | |
| if (episode) { | |
| episodesArr.push(episode); | |
| } | |
| } | |
| }); | |
| // Sort episodes by episode number | |
| episodesArr.sort((a, b) => a.episode_number - b.episode_number); | |
| if (episodesArr.length > 0) { | |
| extractedSeasons.push({ | |
| season_number: seasonInfo.number, | |
| name: seasonInfo.name, | |
| overview: '', // No overview available | |
| poster_path: tvShow?.data?.image || '/placeholder.svg', | |
| air_date: tvShow?.data?.year || '', | |
| episodes: episodesArr | |
| }); | |
| } | |
| }); | |
| // Sort seasons by season number | |
| extractedSeasons.sort((a, b) => a.season_number - b.season_number); | |
| return extractedSeasons; | |
| }; | |
| useEffect(() => { | |
| const fetchTvShowData = async () => { | |
| if (!title) return; | |
| try { | |
| setLoading(true); | |
| const data = await getTvShowMetadata(title); | |
| setTvShow(data); | |
| if (data && data.file_structure) { | |
| const processedSeasons = processTvShowFileStructure(data.file_structure); | |
| setSeasons(processedSeasons); | |
| // Select the first season by default (Specials = 0, Season 1 = 1) | |
| if (processedSeasons.length > 0) { | |
| setSelectedSeason(processedSeasons[0].season_number); | |
| } | |
| } | |
| // Fetch similar shows based on individual genres | |
| if (data.data && data.data.genres && data.data.genres.length > 0) { | |
| const currentShowName = data.data.name; | |
| const showsByGenre = await Promise.all( | |
| data.data.genres.map(async (genre: any) => { | |
| // Pass a single genre name for each call | |
| const genreResult = await getGenresItems([genre.name], 'series', 10, 1); | |
| console.log('Genre result:', genreResult); | |
| if (genreResult.series && Array.isArray(genreResult.series)) { | |
| return genreResult.series.map((showItem: any) => { | |
| const { title: similarTitle } = showItem; | |
| console.log('Similar show:', showItem); | |
| // Skip current show | |
| if (similarTitle === currentShowName) return null; | |
| return { | |
| type: 'tvshow', | |
| title: similarTitle, | |
| }; | |
| }); | |
| } | |
| return []; | |
| }) | |
| ); | |
| // Flatten the array of arrays and remove null results | |
| const flattenedShows = showsByGenre.flat().filter(Boolean); | |
| // Remove duplicates based on the title | |
| const uniqueShows = Array.from( | |
| new Map(flattenedShows.map(show => [show.title, show])).values() | |
| ); | |
| setSimilarShows(uniqueShows); | |
| } | |
| } catch (error) { | |
| console.error(`Error fetching TV show details for ${title}:`, error); | |
| toast({ | |
| title: "Error loading TV show details", | |
| description: "Please try again later", | |
| variant: "destructive" | |
| }); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| fetchTvShowData(); | |
| }, [title, toast]); | |
| // Update episodes when selectedSeason or seasons change | |
| useEffect(() => { | |
| if (seasons.length > 0) { | |
| const season = seasons.find(s => s.season_number === selectedSeason); | |
| if (season) { | |
| setEpisodes(season.episodes); | |
| } else { | |
| setEpisodes([]); | |
| } | |
| } | |
| }, [selectedSeason, seasons]); | |
| const toggleExpandSeasons = () => { | |
| setExpandedSeasons(!expandedSeasons); | |
| }; | |
| if (loading) { | |
| return ( | |
| <div className="flex items-center justify-center min-h-screen"> | |
| <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-netflix-red"></div> | |
| </div> | |
| ); | |
| } | |
| if (!tvShow) { | |
| return ( | |
| <div className="pt-24 px-4 md:px-8 text-center min-h-screen"> | |
| <h1 className="text-3xl font-bold mb-4">TV Show Not Found</h1> | |
| <p className="text-netflix-gray mb-6">We couldn't find the TV show you're looking for.</p> | |
| <Link to="/tv-shows" className="bg-netflix-red px-6 py-2 rounded font-medium"> | |
| Back to TV Shows | |
| </Link> | |
| </div> | |
| ); | |
| } | |
| const tvShowData = tvShow.data; | |
| const airYears = tvShowData.year; | |
| const language = tvShowData.originalLanguage; | |
| const showName = (tvShowData.translations?.nameTranslations?.find((t: any) => t.language === 'eng')?.name || tvShowData.name || ''); | |
| const overview = | |
| tvShowData.translations?.overviewTranslations?.find((t: any) => t.language === 'eng')?.overview || | |
| tvShowData.translations?.overviewTranslations?.[0]?.overview || | |
| tvShowData.overview || | |
| 'No overview available.'; | |
| // Get the current season details | |
| const currentSeason = seasons.find(s => s.season_number === selectedSeason); | |
| const currentSeasonName = currentSeason?.name || `Season ${selectedSeason}`; | |
| return ( | |
| <div className="pb-12 animate-fade-in"> | |
| {/* Hero backdrop */} | |
| <div className="relative w-full h-[500px] md:h-[600px]"> | |
| <div className="absolute inset-0"> | |
| <img | |
| src={tvShowData.image} | |
| alt={showName} | |
| className="w-full h-full object-cover" | |
| onError={(e) => { | |
| const target = e.target as HTMLImageElement; | |
| target.src = '/placeholder.svg'; | |
| }} | |
| /> | |
| <div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/60 to-transparent" /> | |
| <div className="absolute inset-0 bg-gradient-to-r from-netflix-black/80 via-netflix-black/40 to-transparent" /> | |
| </div> | |
| </div> | |
| {/* TV Show details */} | |
| <div className="px-4 md:px-8 -mt-60 relative z-10 max-w-7xl mx-auto"> | |
| <div className="flex flex-col md:flex-row gap-8"> | |
| {/* Poster */} | |
| <div className="flex-shrink-0 hidden md:block"> | |
| <img | |
| src={tvShowData.image} | |
| alt={showName} | |
| className="w-64 h-96 object-cover rounded-md shadow-lg" | |
| onError={(e) => { | |
| const target = e.target as HTMLImageElement; | |
| target.src = '/placeholder.svg'; | |
| }} | |
| /> | |
| </div> | |
| {/* Details */} | |
| <div className="flex-grow"> | |
| <h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-3">{showName}</h1> | |
| <div className="flex flex-wrap items-center text-sm text-gray-300 mb-6"> | |
| {airYears && <span className="mr-3">{airYears}</span>} | |
| {tvShowData.vote_average && ( | |
| <span className="mr-3"> | |
| <span className="text-netflix-red">★</span> {tvShowData.vote_average.toFixed(1)} | |
| </span> | |
| )} | |
| {seasons.length > 0 && ( | |
| <span className="mr-3">{seasons.length} Season{seasons.length !== 1 ? 's' : ''}</span> | |
| )} | |
| </div> | |
| <div className="flex flex-wrap items-center gap-2 my-4"> | |
| {tvShowData.genres && tvShowData.genres.map((genre: any, index: number) => ( | |
| <Link | |
| key={index} | |
| to={`/tv-shows?genre=${genre.name || genre}`} | |
| className="px-3 py-1 bg-netflix-gray/20 rounded-full text-sm hover:bg-netflix-gray/40 transition" | |
| > | |
| {genre.name || genre} | |
| </Link> | |
| ))} | |
| </div> | |
| <p className="text-gray-300 mb-8 max-w-3xl">{overview}</p> | |
| <div className="flex flex-wrap gap-3 mb-8"> | |
| <Link | |
| to={`/tv-show/${encodeURIComponent(title!)}/watch?season=${encodeURIComponent(currentSeasonName)}&episode=${encodeURIComponent(episodes[0]?.fileName || '')}`} | |
| className="flex items-center px-6 py-2 rounded bg-netflix-red text-white font-semibold hover:bg-red-700 transition" | |
| > | |
| <Play className="w-5 h-5 mr-2" /> Play | |
| </Link> | |
| <button className="flex items-center px-4 py-2 rounded bg-gray-700 text-white hover:bg-gray-600 transition"> | |
| <Plus className="w-5 h-5 mr-2" /> My List | |
| </button> | |
| <button className="flex items-center justify-center w-10 h-10 rounded-full bg-gray-700 text-white hover:bg-gray-600 transition"> | |
| <ThumbsUp className="w-5 h-5" /> | |
| </button> | |
| <button className="flex items-center justify-center w-10 h-10 rounded-full bg-gray-700 text-white hover:bg-gray-600 transition"> | |
| <Share2 className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| {/* Additional details */} | |
| <div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| {language && ( | |
| <div> | |
| <h3 className="text-gray-400 font-semibold mb-1">Language</h3> | |
| <p className="text-white">{language}</p> | |
| </div> | |
| )} | |
| {tvShowData.translations?.nameTranslations?.find((t: any) => t.isPrimary) && ( | |
| <div> | |
| <h3 className="text-gray-400 font-semibold mb-1">Tagline</h3> | |
| <p className="text-white"> | |
| "{tvShowData.translations.nameTranslations.find((t: any) => t.isPrimary).tagline || ''}" | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Episodes */} | |
| <div className="mt-12 bg-netflix-dark rounded-md overflow-hidden"> | |
| <div className="p-4 border-b border-netflix-gray/30"> | |
| <div className="flex justify-between items-center"> | |
| <h2 className="text-xl font-semibold">Episodes</h2> | |
| <div className="relative"> | |
| <button | |
| onClick={toggleExpandSeasons} | |
| className="flex items-center gap-2 px-4 py-1.5 rounded border border-netflix-gray hover:bg-netflix-gray/20 transition" | |
| > | |
| <span>{currentSeasonName}</span> | |
| <ChevronDown className={`w-4 h-4 transition-transform ${expandedSeasons ? 'rotate-180' : ''}`} /> | |
| </button> | |
| {expandedSeasons && ( | |
| <div className="absolute right-0 mt-1 w-48 bg-netflix-dark rounded border border-netflix-gray/50 shadow-lg z-10 max-h-56 overflow-y-auto py-1"> | |
| {seasons.map((season) => ( | |
| <button | |
| key={season.season_number} | |
| className={`block w-full text-left px-4 py-2 hover:bg-netflix-gray/20 transition ${selectedSeason === season.season_number ? 'bg-netflix-gray/30' : ''}`} | |
| onClick={() => { | |
| setSelectedSeason(season.season_number); | |
| setExpandedSeasons(false); | |
| }} | |
| > | |
| {season.name} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="divide-y divide-netflix-gray/30"> | |
| {seasonsLoading ? ( | |
| <div className="p-8 flex justify-center"> | |
| <div className="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-netflix-red"></div> | |
| </div> | |
| ) : episodes.length === 0 ? ( | |
| <div className="p-8 text-center text-netflix-gray"> | |
| No episodes available for this season. | |
| </div> | |
| ) : ( | |
| episodes.map((episode) => ( | |
| <div key={episode.episode_number} className="p-4 hover:bg-netflix-gray/10 transition"> | |
| <Link | |
| to={`/tv-show/${encodeURIComponent(title!)}/watch?season=${encodeURIComponent(currentSeasonName)}&episode=${encodeURIComponent(episode.fileName || '')}`} | |
| className="flex flex-col md:flex-row md:items-center gap-4" | |
| > | |
| <div className="flex-shrink-0 relative group"> | |
| <img | |
| src={episode.still_path} | |
| alt={episode.name} | |
| className="w-full md:w-40 h-24 object-cover rounded" | |
| onError={(e) => { | |
| const target = e.target as HTMLImageElement; | |
| target.src = '/placeholder.svg'; | |
| }} | |
| /> | |
| <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"> | |
| <Play className="w-10 h-10 text-white" /> | |
| </div> | |
| <div className="absolute bottom-2 left-2 bg-black/70 px-2 py-1 rounded text-xs"> | |
| {episode.runtime ? `${episode.runtime} min` : '--'} | |
| </div> | |
| </div> | |
| <div className="flex-grow"> | |
| <div className="flex justify-between"> | |
| <h3 className="font-medium"> | |
| {episode.episode_number}. {episode.name} | |
| </h3> | |
| <span className="text-netflix-gray text-sm"> | |
| {episode.air_date ? new Date(episode.air_date).toLocaleDateString() : ''} | |
| </span> | |
| </div> | |
| <p className="text-netflix-gray text-sm mt-1 line-clamp-2"> | |
| {episode.overview || 'No description available.'} | |
| </p> | |
| </div> | |
| </Link> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| {/* Similar Shows */} | |
| {similarShows.length > 0 && ( | |
| <div className="mt-16"> | |
| <ContentRow title="More Like This" items={similarShows} /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default TvShowDetailPage; | |