627 lines
24 KiB
TypeScript
627 lines
24 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
Play,
|
|
ArrowLeft,
|
|
Star,
|
|
Calendar,
|
|
Tv,
|
|
Clock,
|
|
Download,
|
|
ChevronDown,
|
|
Loader,
|
|
AlertCircle,
|
|
Search,
|
|
} from 'lucide-react';
|
|
import { tvDiscoveryApi, DiscoveredShow, EztvTorrent } from '../services/api/tvDiscovery';
|
|
import Button from '../components/ui/Button';
|
|
|
|
interface Season {
|
|
id: number;
|
|
name: string;
|
|
season_number: number;
|
|
episode_count: number;
|
|
air_date: string;
|
|
poster_path: string | null;
|
|
overview: string;
|
|
}
|
|
|
|
interface Episode {
|
|
id: number;
|
|
name: string;
|
|
episode_number: number;
|
|
season_number: number;
|
|
air_date: string;
|
|
overview: string;
|
|
still_path: string | null;
|
|
vote_average: number;
|
|
runtime: number;
|
|
}
|
|
|
|
export default function TVShowDetails() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
|
|
const [show, setShow] = useState<(DiscoveredShow & { seasons: Season[]; externalIds: any }) | null>(null);
|
|
const [selectedSeason, setSelectedSeason] = useState<number>(1);
|
|
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
|
const [torrents, setTorrents] = useState<EztvTorrent[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false);
|
|
const [isLoadingTorrents, setIsLoadingTorrents] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showSeasonDropdown, setShowSeasonDropdown] = useState(false);
|
|
const [streamingEpisode, setStreamingEpisode] = useState<{ season: number; episode: number } | null>(null);
|
|
|
|
// Load show details
|
|
useEffect(() => {
|
|
async function loadShow() {
|
|
if (!id) return;
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const showId = parseInt(id);
|
|
const details = await tvDiscoveryApi.getShowDetails(showId);
|
|
setShow(details);
|
|
|
|
// Find first season with episodes
|
|
const firstSeason = details.seasons.find((s: Season) => s.season_number > 0);
|
|
if (firstSeason) {
|
|
setSelectedSeason(firstSeason.season_number);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading show:', err);
|
|
setError('Failed to load show details');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
loadShow();
|
|
}, [id]);
|
|
|
|
// Load episodes for selected season
|
|
useEffect(() => {
|
|
async function loadEpisodes() {
|
|
if (!id || !selectedSeason) return;
|
|
|
|
setIsLoadingEpisodes(true);
|
|
|
|
try {
|
|
const seasonData = await tvDiscoveryApi.getSeasonDetails(parseInt(id), selectedSeason);
|
|
setEpisodes(seasonData.episodes || []);
|
|
} catch (err) {
|
|
console.error('Error loading episodes:', err);
|
|
} finally {
|
|
setIsLoadingEpisodes(false);
|
|
}
|
|
}
|
|
|
|
loadEpisodes();
|
|
}, [id, selectedSeason]);
|
|
|
|
// Load torrents for the show
|
|
useEffect(() => {
|
|
async function loadTorrents() {
|
|
if (!show?.imdbId) {
|
|
console.log('No IMDB ID available for torrent lookup');
|
|
return;
|
|
}
|
|
|
|
setIsLoadingTorrents(true);
|
|
console.log('Loading torrents for IMDB:', show.imdbId);
|
|
|
|
try {
|
|
const torrentData = await tvDiscoveryApi.getTorrents(show.imdbId, show.title);
|
|
console.log('Loaded torrents:', torrentData.length);
|
|
setTorrents(torrentData);
|
|
} catch (err) {
|
|
console.error('Error loading torrents:', err);
|
|
} finally {
|
|
setIsLoadingTorrents(false);
|
|
}
|
|
}
|
|
|
|
loadTorrents();
|
|
}, [show?.imdbId, show?.title]);
|
|
|
|
// Episode-specific torrent cache
|
|
const [episodeTorrentCache, setEpisodeTorrentCache] = useState<Map<string, EztvTorrent[]>>(new Map());
|
|
const [searchingEpisode, setSearchingEpisode] = useState<string | null>(null);
|
|
|
|
// Find torrents for a specific episode (from cache or main torrents)
|
|
const getTorrentsForEpisode = (season: number, episode: number): EztvTorrent[] => {
|
|
const cacheKey = `${season}-${episode}`;
|
|
|
|
// Check cache first
|
|
if (episodeTorrentCache.has(cacheKey)) {
|
|
return episodeTorrentCache.get(cacheKey) || [];
|
|
}
|
|
|
|
// Filter from main torrents
|
|
return torrents.filter((t) => {
|
|
const seasonMatch = t.season === season.toString().padStart(2, '0') ||
|
|
t.season === season.toString() ||
|
|
t.title.toLowerCase().includes(`s${season.toString().padStart(2, '0')}`);
|
|
const episodeMatch = t.episode === episode.toString().padStart(2, '0') ||
|
|
t.episode === episode.toString() ||
|
|
t.title.toLowerCase().includes(`e${episode.toString().padStart(2, '0')}`);
|
|
return seasonMatch && episodeMatch;
|
|
}).sort((a, b) => {
|
|
// Sort by quality (prefer 1080p)
|
|
const qualityOrder: Record<string, number> = { '2160p': 4, '1080p': 3, '720p': 2, '480p': 1, 'Unknown': 0 };
|
|
return (qualityOrder[b.quality] || 0) - (qualityOrder[a.quality] || 0);
|
|
});
|
|
};
|
|
|
|
// Search for episode-specific torrents
|
|
const searchForEpisode = async (season: number, episode: number) => {
|
|
if (!show?.title) return;
|
|
|
|
const cacheKey = `${season}-${episode}`;
|
|
setSearchingEpisode(cacheKey);
|
|
|
|
try {
|
|
// Pass the IMDB ID for better search results
|
|
const results = await tvDiscoveryApi.searchEpisodeTorrents(
|
|
show.title,
|
|
season,
|
|
episode,
|
|
show.imdbId
|
|
);
|
|
setEpisodeTorrentCache(prev => new Map(prev).set(cacheKey, results));
|
|
} catch (error) {
|
|
console.error('Error searching for episode:', error);
|
|
} finally {
|
|
setSearchingEpisode(null);
|
|
}
|
|
};
|
|
|
|
// Modal state for external search
|
|
const [showSearchModal, setShowSearchModal] = useState<{
|
|
season: number;
|
|
episode: number;
|
|
torrent: EztvTorrent;
|
|
} | null>(null);
|
|
const [magnetInput, setMagnetInput] = useState('');
|
|
|
|
// Handle episode play - navigate to TV Player with torrent info
|
|
const handlePlayEpisode = async (season: number, episode: number, torrent: EztvTorrent) => {
|
|
console.log('Playing episode:', { season, episode, torrent });
|
|
|
|
// Check if this is a fallback search URL (not a real magnet)
|
|
if (torrent.magnetUrl.startsWith('https://')) {
|
|
// Show modal to help user find the torrent
|
|
setShowSearchModal({ season, episode, torrent });
|
|
return;
|
|
}
|
|
|
|
// Get episode info for the player
|
|
const episodeInfo = episodes.find(e => e.episode_number === episode);
|
|
|
|
// Build query params for the TV Player
|
|
const params = new URLSearchParams({
|
|
hash: torrent.hash,
|
|
magnet: torrent.magnetUrl,
|
|
quality: torrent.quality,
|
|
show: show?.title || 'TV Show',
|
|
season: season.toString(),
|
|
episode: episode.toString(),
|
|
title: episodeInfo?.name || '',
|
|
poster: encodeURIComponent(show?.poster || ''),
|
|
backdrop: encodeURIComponent(show?.backdrop || episodeInfo?.still_path ? `https://image.tmdb.org/t/p/original${episodeInfo?.still_path}` : ''),
|
|
});
|
|
|
|
// Navigate to the TV Player page
|
|
navigate(`/tv/play/${id}?${params.toString()}`);
|
|
};
|
|
|
|
// Handle magnet link submission - navigate to TV Player
|
|
const handleMagnetSubmit = () => {
|
|
if (!showSearchModal || !magnetInput.trim()) return;
|
|
|
|
// Extract hash from magnet link
|
|
const hashMatch = magnetInput.match(/btih:([a-fA-F0-9]{40}|[a-zA-Z2-7]{32})/i);
|
|
if (!hashMatch) {
|
|
alert('Invalid magnet link. Please paste a valid magnet link.');
|
|
return;
|
|
}
|
|
|
|
const hash = hashMatch[1];
|
|
const { season, episode, torrent } = showSearchModal;
|
|
const episodeInfo = episodes.find(e => e.episode_number === episode);
|
|
|
|
setShowSearchModal(null);
|
|
setMagnetInput('');
|
|
|
|
// Build query params for the TV Player
|
|
const params = new URLSearchParams({
|
|
hash: hash,
|
|
magnet: magnetInput.trim(),
|
|
quality: torrent.quality,
|
|
show: show?.title || 'TV Show',
|
|
season: season.toString(),
|
|
episode: episode.toString(),
|
|
title: episodeInfo?.name || '',
|
|
poster: encodeURIComponent(show?.poster || ''),
|
|
backdrop: encodeURIComponent(show?.backdrop || ''),
|
|
});
|
|
|
|
// Navigate to the TV Player page
|
|
navigate(`/tv/play/${id}?${params.toString()}`);
|
|
};
|
|
|
|
// Filter seasons (exclude specials for now)
|
|
const availableSeasons = useMemo(() => {
|
|
return show?.seasons.filter((s) => s.season_number > 0) || [];
|
|
}, [show]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-netflix-black flex items-center justify-center">
|
|
<Loader size={48} className="animate-spin text-netflix-red" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !show) {
|
|
return (
|
|
<div className="min-h-screen bg-netflix-black flex flex-col items-center justify-center">
|
|
<AlertCircle size={64} className="text-red-500 mb-4" />
|
|
<h1 className="text-2xl font-bold mb-2">Error Loading Show</h1>
|
|
<p className="text-gray-400 mb-4">{error || 'Show not found'}</p>
|
|
<Button onClick={() => navigate(-1)}>Go Back</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="min-h-screen bg-netflix-black"
|
|
>
|
|
{/* Hero Section */}
|
|
<div className="relative h-[60vh] min-h-[400px]">
|
|
<div className="absolute inset-0">
|
|
<img
|
|
src={show.backdrop || show.poster || '/placeholder-backdrop.jpg'}
|
|
alt={show.title}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<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-transparent to-transparent" />
|
|
</div>
|
|
|
|
{/* Back Button */}
|
|
<button
|
|
onClick={() => navigate(-1)}
|
|
className="absolute top-24 left-8 z-10 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors"
|
|
>
|
|
<ArrowLeft size={24} />
|
|
</button>
|
|
|
|
{/* Content */}
|
|
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-16">
|
|
<div className="max-w-4xl">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Tv className="text-blue-400" size={20} />
|
|
<span className="text-blue-400 font-medium text-sm">TV SERIES</span>
|
|
</div>
|
|
|
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">{show.title}</h1>
|
|
|
|
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-300 mb-4">
|
|
<span className="flex items-center gap-1 text-green-400">
|
|
<Star size={16} fill="currentColor" />
|
|
{show.rating.toFixed(1)}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Calendar size={16} />
|
|
{show.year}
|
|
</span>
|
|
{show.seasonCount && (
|
|
<span>{show.seasonCount} Season{show.seasonCount !== 1 ? 's' : ''}</span>
|
|
)}
|
|
{show.episodeCount && (
|
|
<span>{show.episodeCount} Episodes</span>
|
|
)}
|
|
<span className="capitalize px-2 py-0.5 bg-white/10 rounded">{show.status}</span>
|
|
</div>
|
|
|
|
{show.genres.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mb-4">
|
|
{show.genres.map((genre) => (
|
|
<span
|
|
key={genre}
|
|
className="px-3 py-1 bg-white/10 rounded-full text-sm"
|
|
>
|
|
{genre}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-gray-300 max-w-2xl mb-6 line-clamp-3">{show.overview}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Episodes Section */}
|
|
<div className="px-4 md:px-8 lg:px-16 py-8">
|
|
{/* Season Selector */}
|
|
<div className="mb-6">
|
|
<div className="relative inline-block">
|
|
<button
|
|
onClick={() => setShowSeasonDropdown(!showSeasonDropdown)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 transition-colors"
|
|
>
|
|
<span className="font-semibold">
|
|
Season {selectedSeason}
|
|
</span>
|
|
<ChevronDown size={20} className={`transition-transform ${showSeasonDropdown ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{showSeasonDropdown && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
className="absolute top-full left-0 mt-2 bg-gray-900 rounded-lg shadow-xl border border-white/10 overflow-hidden z-20 min-w-[200px]"
|
|
>
|
|
{availableSeasons.map((season) => (
|
|
<button
|
|
key={season.season_number}
|
|
onClick={() => {
|
|
setSelectedSeason(season.season_number);
|
|
setShowSeasonDropdown(false);
|
|
}}
|
|
className={`w-full px-4 py-3 text-left hover:bg-white/10 transition-colors ${
|
|
selectedSeason === season.season_number ? 'bg-white/10' : ''
|
|
}`}
|
|
>
|
|
<div className="font-medium">{season.name}</div>
|
|
<div className="text-sm text-gray-400">
|
|
{season.episode_count} Episodes
|
|
</div>
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Episodes List */}
|
|
{isLoadingEpisodes ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<Loader size={32} className="animate-spin" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{episodes.map((episode) => {
|
|
const episodeTorrents = getTorrentsForEpisode(selectedSeason, episode.episode_number);
|
|
const hasTorrents = episodeTorrents.length > 0;
|
|
const isStreaming = streamingEpisode?.season === selectedSeason &&
|
|
streamingEpisode?.episode === episode.episode_number;
|
|
|
|
return (
|
|
<motion.div
|
|
key={episode.id}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="flex gap-4 p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors group"
|
|
>
|
|
{/* Episode Thumbnail */}
|
|
<div className="relative flex-shrink-0 w-40 md:w-56 aspect-video rounded overflow-hidden bg-gray-800">
|
|
{episode.still_path ? (
|
|
<img
|
|
src={`https://image.tmdb.org/t/p/w300${episode.still_path}`}
|
|
alt={episode.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<Tv size={32} className="text-gray-600" />
|
|
</div>
|
|
)}
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{hasTorrents && (
|
|
<button
|
|
onClick={() => handlePlayEpisode(selectedSeason, episode.episode_number, episodeTorrents[0])}
|
|
disabled={isStreaming}
|
|
className="p-3 rounded-full bg-white text-black hover:scale-110 transition-transform"
|
|
>
|
|
{isStreaming ? (
|
|
<Loader size={24} className="animate-spin" />
|
|
) : (
|
|
<Play size={24} fill="currentColor" />
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="absolute bottom-2 left-2 bg-black/80 px-2 py-0.5 rounded text-xs">
|
|
E{episode.episode_number}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Episode Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-start justify-between gap-4 mb-2">
|
|
<div>
|
|
<h3 className="font-semibold truncate">
|
|
{episode.episode_number}. {episode.name}
|
|
</h3>
|
|
<div className="flex items-center gap-3 text-sm text-gray-400 mt-1">
|
|
{episode.runtime && (
|
|
<span className="flex items-center gap-1">
|
|
<Clock size={14} />
|
|
{episode.runtime}m
|
|
</span>
|
|
)}
|
|
{episode.air_date && (
|
|
<span>{new Date(episode.air_date).toLocaleDateString()}</span>
|
|
)}
|
|
{episode.vote_average > 0 && (
|
|
<span className="flex items-center gap-1">
|
|
<Star size={14} fill="currentColor" className="text-yellow-500" />
|
|
{episode.vote_average.toFixed(1)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Torrent/Play Options */}
|
|
{hasTorrents && (
|
|
<div className="flex-shrink-0">
|
|
<div className="hidden md:flex gap-2">
|
|
{episodeTorrents.slice(0, 3).map((torrent, idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => handlePlayEpisode(selectedSeason, episode.episode_number, torrent)}
|
|
disabled={isStreaming}
|
|
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${
|
|
torrent.quality === '1080p'
|
|
? 'bg-green-600 hover:bg-green-700'
|
|
: 'bg-blue-600 hover:bg-blue-700'
|
|
}`}
|
|
>
|
|
{torrent.quality}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={() => handlePlayEpisode(selectedSeason, episode.episode_number, episodeTorrents[0])}
|
|
disabled={isStreaming}
|
|
className="md:hidden px-4 py-2 bg-netflix-red rounded text-sm font-medium"
|
|
>
|
|
Play
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-sm text-gray-400 line-clamp-2">{episode.overview}</p>
|
|
|
|
{!hasTorrents && !isLoadingTorrents && (
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<p className="text-sm text-yellow-500">
|
|
No streams found
|
|
</p>
|
|
<button
|
|
onClick={() => searchForEpisode(selectedSeason, episode.episode_number)}
|
|
disabled={searchingEpisode === `${selectedSeason}-${episode.episode_number}`}
|
|
className="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 rounded transition-colors flex items-center gap-1"
|
|
>
|
|
{searchingEpisode === `${selectedSeason}-${episode.episode_number}` ? (
|
|
<>
|
|
<Loader size={12} className="animate-spin" />
|
|
Searching...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Search size={12} />
|
|
Find Streams
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{isLoadingTorrents && (
|
|
<div className="text-center py-4 text-gray-400">
|
|
<Loader size={20} className="inline animate-spin mr-2" />
|
|
Loading stream sources...
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search Modal */}
|
|
<AnimatePresence>
|
|
{showSearchModal && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
|
onClick={() => setShowSearchModal(null)}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.9, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.9, opacity: 0 }}
|
|
className="bg-gray-900 rounded-xl p-6 max-w-lg w-full shadow-2xl border border-white/10"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3 className="text-xl font-bold mb-4">Find Stream Source</h3>
|
|
|
|
<p className="text-gray-400 mb-4">
|
|
No direct stream found for <strong>S{showSearchModal.season.toString().padStart(2, '0')}E{showSearchModal.episode.toString().padStart(2, '0')}</strong> ({showSearchModal.torrent.quality}).
|
|
</p>
|
|
|
|
<div className="space-y-4">
|
|
{/* Option 1: Search externally */}
|
|
<div className="p-4 bg-white/5 rounded-lg">
|
|
<h4 className="font-semibold mb-2">Option 1: Search Online</h4>
|
|
<p className="text-sm text-gray-400 mb-3">
|
|
Click below to search for this episode on torrent sites:
|
|
</p>
|
|
<a
|
|
href={showSearchModal.torrent.magnetUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
|
>
|
|
<Search size={16} />
|
|
Search on 1337x
|
|
</a>
|
|
</div>
|
|
|
|
{/* Option 2: Paste magnet */}
|
|
<div className="p-4 bg-white/5 rounded-lg">
|
|
<h4 className="font-semibold mb-2">Option 2: Paste Magnet Link</h4>
|
|
<p className="text-sm text-gray-400 mb-3">
|
|
If you have a magnet link, paste it below:
|
|
</p>
|
|
<input
|
|
type="text"
|
|
value={magnetInput}
|
|
onChange={(e) => setMagnetInput(e.target.value)}
|
|
placeholder="magnet:?xt=urn:btih:..."
|
|
className="w-full bg-black/50 border border-white/20 rounded-lg px-4 py-2 text-sm mb-3 focus:outline-none focus:border-blue-500"
|
|
/>
|
|
<Button
|
|
onClick={handleMagnetSubmit}
|
|
disabled={!magnetInput.trim()}
|
|
leftIcon={<Play size={16} />}
|
|
>
|
|
Start Streaming
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setShowSearchModal(null)}
|
|
className="absolute top-4 right-4 text-gray-400 hover:text-white"
|
|
>
|
|
✕
|
|
</button>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|