Spaces:
Running
Running
import React, { useEffect, useState } from 'react'; | |
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; | |
import { getTvShowMetadata } from '../lib/api'; | |
import { useToast } from '@/hooks/use-toast'; | |
import TVShowPlayer from '../components/TVShowPlayer'; | |
import EpisodesPanel from '../components/EpisodesPanel'; | |
interface FileStructureItem { | |
type: string; | |
path: string; | |
contents?: FileStructureItem[]; | |
size?: number; | |
} | |
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; | |
episodes: Episode[]; | |
} | |
interface PlaybackProgress { | |
[key: string]: { | |
currentTime: number; | |
duration: number; | |
lastPlayed: string; | |
completed: boolean; | |
}; | |
} | |
const TvShowPlayerPage = () => { | |
const [loading, setLoading] = useState(true); | |
const [error, setError] = useState<string | null>(null); | |
const [showInfo, setShowInfo] = useState<any>(null); | |
const [showName, setShowName] = useState<string>(''); | |
const [seasons, setSeasons] = useState<Season[]>([]); | |
const [selectedSeason, setSelectedSeason] = useState<string>(''); | |
const [selectedEpisode, setSelectedEpisode] = useState<string>(''); | |
const [activeEpisodeIndex, setActiveEpisodeIndex] = useState<number>(0); | |
const [activeSeasonIndex, setActiveSeasonIndex] = useState<number>(0); | |
const [showEpisodeSelector, setShowEpisodeSelector] = useState(false); | |
const [playbackProgress, setPlaybackProgress] = useState<PlaybackProgress>({}); | |
const [needsReload, setNeedsReload] = useState(false); // Flag to trigger video reload | |
const { title } = useParams<{ title: string }>(); | |
const [searchParams] = useSearchParams(); | |
const seasonParam = searchParams.get('season'); | |
const episodeParam = searchParams.get('episode'); | |
const navigate = useNavigate(); | |
const { toast } = useToast(); | |
// Helper function to extract episode info from file path | |
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; | |
}; | |
// Helper function to extract season info 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' }; | |
}; | |
// 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, | |
episodes: episodesArr | |
}); | |
} | |
}); | |
// Sort seasons by season number | |
extractedSeasons.sort((a, b) => a.season_number - b.season_number); | |
return extractedSeasons; | |
}; | |
// Select first available episode when none is specified | |
const selectFirstAvailableEpisode = (seasons: Season[]) => { | |
if (seasons.length === 0) return; | |
// First try to find Season 1 | |
const regularSeason = seasons.find(s => s.season_number === 1); | |
// If not available, use the first available season (could be Specials/Season 0) | |
const firstSeason = regularSeason || seasons[0]; | |
if (firstSeason && firstSeason.episodes.length > 0) { | |
setSelectedSeason(firstSeason.name); | |
setSelectedEpisode(firstSeason.episodes[0].fileName || ''); | |
setActiveSeasonIndex(seasons.indexOf(firstSeason)); | |
setActiveEpisodeIndex(0); | |
} | |
}; | |
// Load playback progress from localStorage | |
const loadPlaybackProgress = () => { | |
try { | |
const storedProgress = localStorage.getItem(`playback-${title}`); | |
if (storedProgress) { | |
setPlaybackProgress(JSON.parse(storedProgress)); | |
} | |
} catch (error) { | |
console.error("Failed to load playback progress:", error); | |
} | |
}; | |
// Save playback progress to localStorage | |
const savePlaybackProgress = (episodeId: string, currentTime: number, duration: number, completed: boolean = false) => { | |
try { | |
const newProgress = { | |
...playbackProgress, | |
[episodeId]: { | |
currentTime, | |
duration, | |
lastPlayed: new Date().toISOString(), | |
completed | |
} | |
}; | |
localStorage.setItem(`playback-${title}`, JSON.stringify(newProgress)); | |
setPlaybackProgress(newProgress); | |
} catch (error) { | |
console.error("Failed to save playback progress:", error); | |
} | |
}; | |
// Function to load the next episode and reset the video | |
const loadNextEpisode = () => { | |
if (!seasons.length) return; | |
const currentSeason = seasons[activeSeasonIndex]; | |
if (!currentSeason) return; | |
// If there's another episode in the current season | |
if (activeEpisodeIndex < currentSeason.episodes.length - 1) { | |
const nextEpisode = currentSeason.episodes[activeEpisodeIndex + 1]; | |
setSelectedEpisode(nextEpisode.fileName || ''); | |
setActiveEpisodeIndex(activeEpisodeIndex + 1); | |
setNeedsReload(true); // Flag to reload the video | |
// Update URL without page reload | |
navigate(`/tv-show/${encodeURIComponent(title || '')}/watch?season=${encodeURIComponent(currentSeason.name)}&episode=${encodeURIComponent(nextEpisode.fileName || '')}`, { replace: true }); | |
toast({ | |
title: "Playing Next Episode", | |
description: `${nextEpisode.name}`, | |
}); | |
} | |
// If there's another season available | |
else if (activeSeasonIndex < seasons.length - 1) { | |
const nextSeason = seasons[activeSeasonIndex + 1]; | |
if (nextSeason.episodes.length > 0) { | |
const firstEpisode = nextSeason.episodes[0]; | |
setSelectedSeason(nextSeason.name); | |
setSelectedEpisode(firstEpisode.fileName || ''); | |
setActiveSeasonIndex(activeSeasonIndex + 1); | |
setActiveEpisodeIndex(0); | |
setNeedsReload(true); // Flag to reload the video | |
// Update URL without page reload | |
navigate(`/tv-show/${encodeURIComponent(title || '')}/watch?season=${encodeURIComponent(nextSeason.name)}&episode=${encodeURIComponent(firstEpisode.fileName || '')}`, { replace: true }); | |
toast({ | |
title: "Starting Next Season", | |
description: `${nextSeason.name}: ${firstEpisode.name}`, | |
}); | |
} | |
} else { | |
toast({ | |
title: "End of Series", | |
description: "You've watched all available episodes.", | |
}); | |
} | |
}; | |
// Watch for changes in seasons or episodes selection | |
useEffect(() => { | |
if (seasons.length && selectedSeason && selectedEpisode) { | |
// Find active season and episode indexes | |
const seasonIndex = seasons.findIndex(s => s.name === selectedSeason); | |
if (seasonIndex >= 0) { | |
setActiveSeasonIndex(seasonIndex); | |
const episodeIndex = seasons[seasonIndex].episodes.findIndex( | |
e => e.fileName === selectedEpisode | |
); | |
if (episodeIndex >= 0) { | |
setActiveEpisodeIndex(episodeIndex); | |
} | |
} | |
} | |
}, [seasons, selectedSeason, selectedEpisode]); | |
useEffect(() => { | |
const fetchData = async () => { | |
if (!title) return; | |
try { | |
setLoading(true); | |
setError(null); | |
// Load saved playback progress | |
loadPlaybackProgress(); | |
// Get TV show metadata first | |
const showData = await getTvShowMetadata(title); | |
setShowInfo(showData); | |
console.log('TV Show Metadata:', showData); | |
setShowName(showInfo?.data?.translations?.nameTranslations?.find((t: any) => t.language === 'eng')?.name || showInfo?.name || ''); | |
// Process seasons and episodes from file structure | |
if (showData && showData.file_structure) { | |
const processedSeasons = processTvShowFileStructure(showData.file_structure); | |
setSeasons(processedSeasons); | |
// Set selected season and episode from URL params or select first available | |
if (seasonParam && episodeParam) { | |
setSelectedSeason(seasonParam); | |
setSelectedEpisode(episodeParam); | |
} else { | |
selectFirstAvailableEpisode(processedSeasons); | |
} | |
} | |
} catch (error) { | |
console.error(`Error fetching metadata for ${title}:`, error); | |
setError('Failed to load episode data'); | |
toast({ | |
title: "Error Loading Data", | |
description: "Please try again later", | |
variant: "destructive" | |
}); | |
} finally { | |
setLoading(false); | |
} | |
}; | |
fetchData(); | |
}, [title, seasonParam, episodeParam, toast]); | |
const handleBack = () => { | |
navigate(`/tv-show/${encodeURIComponent(title || '')}`); | |
}; | |
const getEpisodeNumber = () => { | |
if (!selectedEpisode) return "1"; | |
const episodeMatch = selectedEpisode.match(/E(\d+)/i); | |
return episodeMatch ? episodeMatch[1] : "1"; | |
}; | |
const handleSelectEpisode = (seasonName: string, episode: Episode) => { | |
// Only reload if we're changing episodes | |
if (selectedEpisode !== episode.fileName) { | |
setSelectedSeason(seasonName); | |
setSelectedEpisode(episode.fileName || ''); | |
setNeedsReload(true); | |
// Update URL | |
navigate(`/tv-show/${encodeURIComponent(title || '')}/watch?season=${encodeURIComponent(seasonName)}&episode=${encodeURIComponent(episode.fileName || '')}`, { replace: true }); | |
} | |
setShowEpisodeSelector(false); | |
}; | |
const handleProgressUpdate = (currentTime: number, duration: number) => { | |
if (!selectedEpisode || !title) return; | |
const episodeId = `${selectedSeason}-${selectedEpisode}`; | |
const isCompleted = (currentTime / duration) > 0.9; // Mark as completed if watched 90% | |
savePlaybackProgress(episodeId, currentTime, duration, isCompleted); | |
}; | |
const getStartTime = () => { | |
if (!selectedSeason || !selectedEpisode) return 0; | |
const episodeId = `${selectedSeason}-${selectedEpisode}`; | |
const progress = playbackProgress[episodeId]; | |
if (progress && !progress.completed) { | |
return progress.currentTime; | |
} | |
return 0; | |
}; | |
const episodeTitle = showInfo | |
? `${showInfo.data?.name} ${selectedSeason}E${getEpisodeNumber()}` | |
: `Episode`; | |
// Reset needs reload flag when video has been updated | |
useEffect(() => { | |
if (needsReload) { | |
setNeedsReload(false); | |
} | |
}, [selectedEpisode, selectedSeason]); | |
if (loading) { | |
return ( | |
<div className="flex items-center justify-center min-h-screen bg-black"> | |
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-red-600"></div> | |
</div> | |
); | |
} | |
// To force reload of video component when changing episodes | |
const tvShowPlayerKey = `${selectedSeason}-${selectedEpisode}-${needsReload ? 'reload' : 'loaded'}`; | |
return ( | |
<div className="min-h-screen bg-black relative overflow-hidden"> | |
{/* Episodes panel */} | |
{showEpisodeSelector && ( | |
<div className="fixed inset-0 z-40 bg-black/80" onClick={() => setShowEpisodeSelector(false)}> | |
<div | |
className="fixed right-0 top-0 bottom-0 w-full md:w-1/3 lg:w-1/4 bg-gray-900 z-50 overflow-y-auto" | |
onClick={e => e.stopPropagation()} | |
> | |
<EpisodesPanel | |
seasons={seasons} | |
selectedSeason={selectedSeason} | |
selectedEpisode={selectedEpisode} | |
playbackProgress={playbackProgress} | |
onSelectEpisode={handleSelectEpisode} | |
onClose={() => setShowEpisodeSelector(false)} | |
showTitle={showName || 'Episodes'} | |
/> | |
</div> | |
</div> | |
)} | |
{/* TV Show Player component with key to force reload */} | |
<TVShowPlayer | |
key={tvShowPlayerKey} | |
videoTitle={title || ''} | |
season={selectedSeason} | |
episode={selectedEpisode} | |
movieTitle={title || ''} | |
contentRatings={showInfo?.data?.contentRatings || []} | |
startTime={getStartTime()} | |
onClosePlayer={handleBack} | |
onProgressUpdate={handleProgressUpdate} | |
onVideoEnded={loadNextEpisode} | |
onShowEpisodes={() => setShowEpisodeSelector(true)} | |
/> | |
</div> | |
); | |
}; | |
export default TvShowPlayerPage; | |