|
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; |
|
} |
|
|
|
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(); |
|
|
|
|
|
const extractEpisodeInfoFromPath = (filePath: string): Episode | null => { |
|
|
|
const fileName = filePath.split('/').pop() || filePath; |
|
|
|
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(); |
|
|
|
|
|
const isHD = fileName.toLowerCase().includes('720p') || |
|
fileName.toLowerCase().includes('1080p') || |
|
fileName.toLowerCase().includes('hdtv'); |
|
|
|
return { |
|
episode_number: episodeNumber, |
|
name: episodeName, |
|
overview: '', |
|
still_path: '/placeholder.svg', |
|
air_date: '', |
|
runtime: isHD ? 24 : 22, |
|
fileName: fileName |
|
}; |
|
} |
|
|
|
return null; |
|
}; |
|
|
|
|
|
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' }; |
|
}; |
|
|
|
|
|
const processTvShowFileStructure = (fileStructure: any): Season[] => { |
|
if (!fileStructure || !fileStructure.contents) { |
|
return []; |
|
} |
|
|
|
const extractedSeasons: Season[] = []; |
|
|
|
|
|
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[] = []; |
|
|
|
|
|
seasonDir.contents.forEach((item: FileStructureItem) => { |
|
if (item.type === 'file') { |
|
const episode = extractEpisodeInfoFromPath(item.path); |
|
if (episode) { |
|
episodesArr.push(episode); |
|
} |
|
} |
|
}); |
|
|
|
|
|
episodesArr.sort((a, b) => a.episode_number - b.episode_number); |
|
|
|
if (episodesArr.length > 0) { |
|
extractedSeasons.push({ |
|
season_number: seasonInfo.number, |
|
name: seasonInfo.name, |
|
overview: '', |
|
poster_path: tvShow?.data?.image || '/placeholder.svg', |
|
air_date: tvShow?.data?.year || '', |
|
episodes: episodesArr |
|
}); |
|
} |
|
}); |
|
|
|
|
|
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); |
|
|
|
|
|
if (processedSeasons.length > 0) { |
|
setSelectedSeason(processedSeasons[0].season_number); |
|
} |
|
} |
|
|
|
|
|
|
|
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) => { |
|
|
|
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); |
|
|
|
if (similarTitle === currentShowName) return null; |
|
return { |
|
type: 'tvshow', |
|
title: similarTitle, |
|
}; |
|
}); |
|
} |
|
return []; |
|
}) |
|
); |
|
|
|
|
|
const flattenedShows = showsByGenre.flat().filter(Boolean); |
|
|
|
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]); |
|
|
|
|
|
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.'; |
|
|
|
|
|
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; |
|
|