| 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; |
|
|