web / frontend /src /pages /TvShowDetailPage.tsx
Chandima Prabhath
Track bun.lockb with Git LFS
cc2caf9
raw
history blame
18.2 kB
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;