Spaces:
Running
Running
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; | |