Add Netflix-style UI for Torrent Page with loading and error states

- Implemented a new CSS file for styling the Torrent Page to resemble Netflix's design.
- Created a loading spinner and error message display for better user experience.
- Developed the main component to fetch and display torrent details, including IMDB data.
- Added functionality for video playback and progress tracking.
- Included sections for episodes/files and additional files with appropriate formatting.
- Enhanced responsiveness for various screen sizes.
This commit is contained in:
Salman Qureshi
2025-08-10 01:46:28 +05:30
parent b01679843c
commit 01f92ef3fc
7 changed files with 1497 additions and 64 deletions

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import HomePage from './components/HomePage';
import TorrentPage from './components/TorrentPage';
import TorrentPageNetflix from './components/TorrentPageNetflix';
import RecentPage from './components/RecentPage';
import SettingsPage from './components/SettingsPage';
import CacheManagementPage from './components/CacheManagementPage';
@@ -14,7 +14,7 @@ function App() {
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="torrent/:torrentHash" element={<TorrentPage />} />
<Route path="torrent/:torrentHash" element={<TorrentPageNetflix />} />
<Route path="recent" element={<RecentPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="cache" element={<CacheManagementPage />} />

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Play, Download, Folder, FileText, Film, Clock, Wifi, RotateCcw } from 'lucide-react';
import { ArrowLeft, Play, Download, Star, Calendar, Clock, Users, Award, Info, Share, Plus, ThumbsUp, Volume2 } from 'lucide-react';
import VideoPlayer from './VideoPlayer';
import { config } from '../config/environment';
import progressService from '../services/progressService';
@@ -15,6 +15,29 @@ const TorrentPage = () => {
const [error, setError] = useState(null);
const [selectedVideo, setSelectedVideo] = useState(null);
const [recentProgress, setRecentProgress] = useState({});
const [imdbData, setImdbData] = useState(null);
const [imdbLoading, setImdbLoading] = useState(true);
const fetchIMDBData = useCallback(async () => {
try {
setImdbLoading(true);
const response = await fetch(`${config.apiBaseUrl}/api/torrents/${torrentHash}/imdb`);
const data = await response.json();
if (data.success && data.imdb) {
setImdbData(data.imdb);
console.log('📺 IMDB data loaded:', data.imdb.Title);
} else {
console.log('❌ No IMDB data found');
setImdbData(null);
}
} catch (err) {
console.error('Error fetching IMDB data:', err);
setImdbData(null);
} finally {
setImdbLoading(false);
}
}, [torrentHash]);
const fetchTorrentDetails = useCallback(async () => {
try {
@@ -68,6 +91,7 @@ const TorrentPage = () => {
useEffect(() => {
if (torrentHash) {
fetchTorrentDetails();
fetchIMDBData(); // Add IMDB data fetching
loadRecentProgress();
// Set up periodic updates for dynamic progress
@@ -77,7 +101,7 @@ const TorrentPage = () => {
return () => clearInterval(progressInterval);
}
}, [torrentHash, fetchTorrentDetails, fetchTorrentProgress, loadRecentProgress]);
}, [torrentHash, fetchTorrentDetails, fetchIMDBData, fetchTorrentProgress, loadRecentProgress]);
const handleVideoSelect = (file, index) => {
setSelectedVideo({

View File

@@ -0,0 +1,626 @@
/* Netflix-style Torrent Page Styles */
.netflix-page {
min-height: 100vh;
background: #141414;
color: #ffffff;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
/* Loading State */
.netflix-loading {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background: #141414;
}
.netflix-spinner {
width: 50px;
height: 50px;
border: 3px solid #333;
border-top: 3px solid #e50914;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Error State */
.netflix-error {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
text-align: center;
padding: 20px;
}
.netflix-error h2 {
font-size: 2rem;
margin-bottom: 16px;
color: #ffffff;
}
.netflix-error p {
font-size: 1.1rem;
color: #b3b3b3;
margin-bottom: 32px;
max-width: 400px;
}
.netflix-retry-btn {
background: #e50914;
color: white;
border: none;
padding: 12px 32px;
font-size: 1rem;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.netflix-retry-btn:hover {
background: #f40612;
}
/* Hero Section */
.netflix-hero {
height: 80vh;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
display: flex;
align-items: center;
position: relative;
padding: 0 60px;
}
.netflix-hero-content {
display: flex;
justify-content: space-between;
align-items: flex-end;
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.netflix-back-btn {
position: absolute;
top: 20px;
left: 20px;
background: rgba(42, 42, 42, 0.8);
color: white;
border: none;
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.netflix-back-btn:hover {
background: rgba(42, 42, 42, 0.9);
transform: translateY(-1px);
}
.netflix-title-section {
flex: 1;
max-width: 60%;
}
.netflix-title {
font-size: 3.5rem;
font-weight: 700;
margin-bottom: 16px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
line-height: 1.1;
}
.netflix-meta {
margin-bottom: 24px;
}
.netflix-rating {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 1.1rem;
}
.star-icon {
color: #ffd700;
}
.netflix-votes {
color: #b3b3b3;
font-size: 0.9rem;
}
.netflix-info-row {
display: flex;
align-items: center;
gap: 16px;
font-size: 1rem;
color: #b3b3b3;
}
.netflix-info-row span {
position: relative;
}
.netflix-info-row span:not(:last-child)::after {
content: '•';
position: absolute;
right: -10px;
color: #666;
}
.netflix-action-buttons {
display: flex;
gap: 16px;
margin-bottom: 24px;
}
.netflix-play-btn {
background: #ffffff;
color: #000000;
border: none;
padding: 12px 32px;
font-size: 1.1rem;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
}
.netflix-play-btn:hover {
background: #e6e6e6;
transform: scale(1.05);
}
.netflix-secondary-btn {
background: rgba(109, 109, 110, 0.7);
color: white;
border: none;
padding: 12px 24px;
font-size: 1rem;
font-weight: 500;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.netflix-secondary-btn:hover {
background: rgba(109, 109, 110, 0.9);
transform: translateY(-1px);
}
.netflix-description {
font-size: 1.2rem;
line-height: 1.5;
color: #ffffff;
max-width: 80%;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
}
.netflix-poster {
flex-shrink: 0;
margin-left: 40px;
}
.netflix-poster img {
width: 300px;
height: 450px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6);
}
/* Content Section */
.netflix-content {
display: flex;
gap: 40px;
padding: 40px 60px;
max-width: 1400px;
margin: 0 auto;
}
.netflix-main-content {
flex: 2;
}
.netflix-sidebar {
flex: 1;
min-width: 300px;
}
.netflix-section {
margin-bottom: 48px;
}
.netflix-section h2 {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 24px;
color: #ffffff;
}
/* Episodes Grid */
.netflix-episodes {
display: flex;
flex-direction: column;
gap: 16px;
}
.netflix-episode {
display: flex;
gap: 16px;
padding: 16px;
background: rgba(42, 42, 42, 0.6);
border-radius: 8px;
transition: all 0.3s ease;
cursor: pointer;
}
.netflix-episode:hover {
background: rgba(42, 42, 42, 0.8);
transform: translateY(-2px);
}
.netflix-episode-thumbnail {
position: relative;
width: 160px;
height: 90px;
background: linear-gradient(135deg, #333 0%, #555 100%);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.netflix-episode-play {
background: rgba(255, 255, 255, 0.9);
color: #000;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.netflix-episode-play:hover {
background: #ffffff;
transform: scale(1.1);
}
.netflix-progress-bar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 0 0 4px 4px;
}
.netflix-progress-fill {
height: 100%;
background: #e50914;
border-radius: 0 0 4px 4px;
transition: width 0.3s ease;
}
.netflix-episode-info {
flex: 1;
}
.netflix-episode-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.netflix-episode-header h4 {
font-size: 1.1rem;
font-weight: 600;
color: #ffffff;
margin: 0;
}
.netflix-episode-duration {
font-size: 0.9rem;
color: #b3b3b3;
}
.netflix-episode-title {
font-size: 0.95rem;
color: #b3b3b3;
margin: 0 0 8px 0;
line-height: 1.4;
}
.netflix-episode-progress {
font-size: 0.85rem;
color: #888;
margin: 0;
}
/* Files Grid */
.netflix-files {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.netflix-file {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: rgba(42, 42, 42, 0.6);
border-radius: 8px;
transition: all 0.3s ease;
}
.netflix-file:hover {
background: rgba(42, 42, 42, 0.8);
transform: translateY(-1px);
}
.netflix-file-icon {
width: 40px;
height: 40px;
background: rgba(109, 109, 110, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #b3b3b3;
flex-shrink: 0;
}
.netflix-file-info {
flex: 1;
min-width: 0;
}
.netflix-file-name {
display: block;
font-size: 0.95rem;
color: #ffffff;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.netflix-file-size {
font-size: 0.85rem;
color: #b3b3b3;
}
/* Sidebar Info Cards */
.netflix-info-card {
background: rgba(42, 42, 42, 0.6);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.netflix-info-card h3 {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 12px;
color: #ffffff;
}
.netflix-info-card p {
font-size: 0.95rem;
line-height: 1.5;
color: #b3b3b3;
margin: 0;
}
.netflix-ratings {
display: flex;
flex-direction: column;
gap: 8px;
}
.netflix-rating-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.netflix-rating-item:last-child {
border-bottom: none;
}
.netflix-rating-item span:first-child {
font-size: 0.9rem;
color: #b3b3b3;
}
.netflix-rating-item span:last-child {
font-size: 0.95rem;
font-weight: 600;
color: #ffffff;
}
.netflix-torrent-stats {
display: flex;
flex-direction: column;
gap: 8px;
}
.netflix-stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.netflix-stat:last-child {
border-bottom: none;
}
.netflix-stat span:first-child {
font-size: 0.9rem;
color: #b3b3b3;
}
.netflix-stat span:last-child {
font-size: 0.95rem;
font-weight: 600;
color: #ffffff;
}
/* Responsive Design */
@media (max-width: 1024px) {
.netflix-hero {
height: 70vh;
padding: 0 40px;
}
.netflix-hero-content {
flex-direction: column;
align-items: flex-start;
gap: 30px;
}
.netflix-title-section {
max-width: 100%;
}
.netflix-poster {
margin-left: 0;
align-self: center;
}
.netflix-poster img {
width: 200px;
height: 300px;
}
.netflix-content {
flex-direction: column;
padding: 30px 40px;
}
.netflix-sidebar {
min-width: auto;
}
}
@media (max-width: 768px) {
.netflix-hero {
height: 60vh;
padding: 0 20px;
}
.netflix-title {
font-size: 2.5rem;
}
.netflix-action-buttons {
flex-wrap: wrap;
gap: 12px;
}
.netflix-secondary-btn {
padding: 10px 20px;
font-size: 0.9rem;
}
.netflix-content {
padding: 20px;
}
.netflix-episode {
flex-direction: column;
text-align: center;
}
.netflix-episode-thumbnail {
width: 100%;
height: 200px;
align-self: center;
}
.netflix-episode-header {
justify-content: center;
flex-direction: column;
gap: 4px;
}
.netflix-files {
grid-template-columns: 1fr;
}
.netflix-poster img {
width: 150px;
height: 225px;
}
}
@media (max-width: 480px) {
.netflix-back-btn {
top: 10px;
left: 10px;
padding: 8px 12px;
font-size: 0.8rem;
}
.netflix-title {
font-size: 2rem;
}
.netflix-description {
font-size: 1rem;
max-width: 100%;
}
.netflix-action-buttons {
justify-content: center;
}
.netflix-play-btn {
padding: 10px 24px;
font-size: 1rem;
}
.netflix-secondary-btn {
padding: 8px 16px;
font-size: 0.85rem;
}
}

View File

@@ -0,0 +1,416 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Play, Download, Star, Calendar, Clock, Users, Award, Info, Share, Plus, ThumbsUp, Volume2 } from 'lucide-react';
import VideoPlayer from './VideoPlayer';
import { config } from '../config/environment';
import progressService from '../services/progressService';
import './TorrentPageNetflix.css';
const TorrentPageNetflix = () => {
const { torrentHash } = useParams();
const navigate = useNavigate();
const [torrent, setTorrent] = useState(null);
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedVideo, setSelectedVideo] = useState(null);
const [recentProgress, setRecentProgress] = useState({});
const [imdbData, setImdbData] = useState(null);
const fetchIMDBData = useCallback(async () => {
try {
const response = await fetch(`${config.apiBaseUrl}/api/torrents/${torrentHash}/imdb`);
const data = await response.json();
if (data.success && data.imdb) {
setImdbData(data.imdb);
console.log('📺 IMDB data loaded:', data.imdb.Title || data.imdb.title || 'No title found');
console.log('📺 IMDB data object:', data.imdb);
} else {
console.log('❌ No IMDB data found');
setImdbData(null);
}
} catch (err) {
console.error('Error fetching IMDB data:', err);
setImdbData(null);
}
}, [torrentHash]);
const fetchTorrentDetails = useCallback(async () => {
try {
setLoading(true);
const response = await fetch(`${config.apiBaseUrl}/api/torrents/${torrentHash}`);
if (!response.ok) {
throw new Error(`Failed to fetch torrent data`);
}
const data = await response.json();
setTorrent(data.torrent);
setFiles(data.files || []);
} catch (err) {
console.error('Error fetching torrent details:', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [torrentHash]);
const fetchTorrentProgress = useCallback(async () => {
try {
const response = await fetch(`${config.apiBaseUrl}/api/torrents/${torrentHash}`);
if (response.ok) {
const data = await response.json();
setTorrent(prev => ({ ...prev, ...data.torrent }));
}
} catch (err) {
console.error('Error fetching progress:', err);
}
}, [torrentHash]);
useEffect(() => {
if (torrentHash) {
fetchTorrentDetails();
fetchIMDBData();
// Load progress only once on mount
const allProgress = progressService.getAllProgress();
const torrentProgress = {};
Object.values(allProgress).forEach(progress => {
if (progress.torrentHash === torrentHash) {
torrentProgress[progress.fileIndex] = progress;
}
});
setRecentProgress(torrentProgress);
// Only run progress fetching if no video is selected
const progressInterval = setInterval(() => {
if (!selectedVideo) {
fetchTorrentProgress();
}
}, 2000);
return () => clearInterval(progressInterval);
}
}, [torrentHash, fetchTorrentDetails, fetchIMDBData, fetchTorrentProgress, selectedVideo]);
const formatFileSize = (bytes) => {
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
if (bytes === 0) return '0 B';
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
const formatSpeed = (bytesPerSecond) => {
if (bytesPerSecond === 0) return '0 B/s';
const k = 1024;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
if (loading) {
return (
<div className="netflix-page">
<div className="netflix-loading">
<div className="netflix-spinner"></div>
<p>Loading content...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="netflix-page">
<div className="netflix-error">
<h2>Something went wrong</h2>
<p>{error}</p>
<button
className="netflix-retry-btn"
onClick={() => {
setError(null);
setLoading(true);
fetchTorrentDetails();
fetchIMDBData();
}}
>
Try Again
</button>
</div>
</div>
);
}
if (selectedVideo) {
const videoKey = `${torrentHash}-${selectedVideo.index}-${selectedVideo.name}`;
// Capture initial time when video is first selected and don't change it
const initialProgress = recentProgress[selectedVideo.index]?.currentTime || 0;
return (
<VideoPlayer
key={videoKey}
src={`${config.apiBaseUrl}/api/torrents/${torrentHash}/files/${selectedVideo.index}/stream`}
title={selectedVideo.name}
onClose={() => setSelectedVideo(null)}
onTimeUpdate={(time) => {
// Update progress service without triggering re-renders
progressService.updateProgress(torrentHash, selectedVideo.index, {
currentTime: time,
duration: selectedVideo.duration || 0
});
// Don't call setRecentProgress here to avoid re-rendering
}}
initialTime={initialProgress}
torrentHash={torrentHash}
fileIndex={selectedVideo.index}
/>
);
}
const mainVideoFile = files.find(file =>
/\.(mp4|avi|mkv|mov|wmv|flv|webm|m4v)$/i.test(file.name)
);
const videoFiles = files.filter(file =>
/\.(mp4|avi|mkv|mov|wmv|flv|webm|m4v)$/i.test(file.name)
);
const otherFiles = files.filter(file =>
!/\.(mp4|avi|mkv|mov|wmv|flv|webm|m4v)$/i.test(file.name)
);
return (
<div className="netflix-page">
{/* Hero Section */}
<div className="netflix-hero" style={{
backgroundImage: imdbData?.Poster && imdbData.Poster !== 'N/A'
? `linear-gradient(to right, rgba(0,0,0,0.8) 50%, rgba(0,0,0,0.2) 100%), url(${imdbData.Poster})`
: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d2d5f 100%)'
}}>
<div className="netflix-hero-content">
<button
className="netflix-back-btn"
onClick={() => navigate('/')}
>
<ArrowLeft size={20} />
Back
</button>
<div className="netflix-title-section">
<h1 className="netflix-title">
{imdbData?.Title || torrent?.name || 'Unknown Title'}
</h1>
{imdbData && (
<div className="netflix-meta">
<div className="netflix-rating">
<Star size={16} className="star-icon" />
<span>{imdbData.imdbRating}/10</span>
<span className="netflix-votes">({imdbData.imdbVotes} votes)</span>
</div>
<div className="netflix-info-row">
<span className="netflix-year">{imdbData.Year}</span>
<span className="netflix-rated">{imdbData.Rated}</span>
<span className="netflix-runtime">{imdbData.Runtime}</span>
<span className="netflix-genre">{imdbData.Genre}</span>
</div>
</div>
)}
<div className="netflix-action-buttons">
{mainVideoFile && (
<button
className="netflix-play-btn"
onClick={() => setSelectedVideo(mainVideoFile)}
>
<Play size={20} />
Play
</button>
)}
<button className="netflix-secondary-btn">
<Plus size={20} />
My List
</button>
<button className="netflix-secondary-btn">
<ThumbsUp size={20} />
Rate
</button>
<button className="netflix-secondary-btn">
<Share size={20} />
Share
</button>
</div>
{imdbData?.Plot && (
<p className="netflix-description">
{imdbData.Plot}
</p>
)}
</div>
{imdbData?.Poster && imdbData.Poster !== 'N/A' && (
<div className="netflix-poster">
<img src={imdbData.Poster} alt={imdbData.Title} />
</div>
)}
</div>
</div>
{/* Content Details */}
<div className="netflix-content">
<div className="netflix-main-content">
{/* Episodes/Files Section */}
<div className="netflix-section">
<h2>Episodes & Files</h2>
<div className="netflix-episodes">
{videoFiles.map((file, index) => {
const progress = recentProgress[file.index];
const progressPercentage = progress ? (progress.currentTime / progress.duration) * 100 : 0;
return (
<div key={file.index} className="netflix-episode">
<div className="netflix-episode-thumbnail">
<button
className="netflix-episode-play"
onClick={() => setSelectedVideo(file)}
>
<Play size={16} />
</button>
{progress && (
<div className="netflix-progress-bar">
<div
className="netflix-progress-fill"
style={{ width: `${progressPercentage}%` }}
/>
</div>
)}
</div>
<div className="netflix-episode-info">
<div className="netflix-episode-header">
<h4>Episode {index + 1}</h4>
<span className="netflix-episode-duration">
{formatFileSize(file.length)}
</span>
</div>
<p className="netflix-episode-title">{file.name}</p>
{progress && (
<p className="netflix-episode-progress">
{progressService.formatTime(progress.currentTime)} / {progressService.formatTime(progress.duration)}
</p>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Additional Files */}
{otherFiles.length > 0 && (
<div className="netflix-section">
<h2>Additional Files</h2>
<div className="netflix-files">
{otherFiles.map(file => (
<div key={file.index} className="netflix-file">
<div className="netflix-file-icon">
<Download size={16} />
</div>
<div className="netflix-file-info">
<span className="netflix-file-name">{file.name}</span>
<span className="netflix-file-size">{formatFileSize(file.length)}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Sidebar Info */}
<div className="netflix-sidebar">
{imdbData && (
<>
<div className="netflix-info-card">
<h3>Cast</h3>
<p>{imdbData.Actors}</p>
</div>
<div className="netflix-info-card">
<h3>Director</h3>
<p>{imdbData.Director}</p>
</div>
<div className="netflix-info-card">
<h3>Writer</h3>
<p>{imdbData.Writer}</p>
</div>
{imdbData.Awards && imdbData.Awards !== 'N/A' && (
<div className="netflix-info-card">
<h3>Awards</h3>
<p>{imdbData.Awards}</p>
</div>
)}
<div className="netflix-info-card">
<h3>Ratings</h3>
<div className="netflix-ratings">
<div className="netflix-rating-item">
<span>IMDB</span>
<span>{imdbData.imdbRating}/10</span>
</div>
{imdbData.rottenTomatosRating !== 'N/A' && (
<div className="netflix-rating-item">
<span>Rotten Tomatoes</span>
<span>{imdbData.rottenTomatosRating}</span>
</div>
)}
{imdbData.metacriticRating !== 'N/A' && (
<div className="netflix-rating-item">
<span>Metacritic</span>
<span>{imdbData.metacriticRating}/100</span>
</div>
)}
</div>
</div>
</>
)}
{/* Torrent Stats */}
<div className="netflix-info-card">
<h3>Download Info</h3>
<div className="netflix-torrent-stats">
<div className="netflix-stat">
<span>Size</span>
<span>{formatFileSize(torrent?.size || 0)}</span>
</div>
<div className="netflix-stat">
<span>Progress</span>
<span>{Math.round(torrent?.progress * 100 || 0)}%</span>
</div>
<div className="netflix-stat">
<span>Speed</span>
<span>{formatSpeed(torrent?.downloadSpeed || 0)}</span>
</div>
<div className="netflix-stat">
<span>Peers</span>
<span>{torrent?.peers || 0}</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default TorrentPageNetflix;

View File

@@ -18,11 +18,11 @@
border-radius: 0;
}
/* Video Player Close Button */
/* Video Player Close Button - only shown when overlay is hidden */
.video-close-button {
position: absolute;
top: 20px;
right: 280px; /* Moved left to avoid overlay conflict */
right: 20px; /* Back to original position */
background: rgba(0, 0, 0, 0.8);
border: 2px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
@@ -103,7 +103,7 @@
.torrent-stats-overlay {
position: absolute;
top: 16px;
right: 16px;
left: 16px; /* Moved to left side */
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -205,34 +205,34 @@
z-index: 1052; /* Above the overlay */
}
/* Video close button in overlay */
.overlay-video-close-button {
background: rgba(239, 68, 68, 0.9);
border: 2px solid rgba(239, 68, 68, 0.8);
/* Minimize button for torrent stats - smaller and different styling */
.stats-minimize {
background: rgba(107, 114, 128, 0.8); /* Gray background to differentiate */
border: 1px solid rgba(107, 114, 128, 0.6);
color: white;
cursor: pointer;
padding: 8px;
width: 36px;
height: 36px;
padding: 6px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border-radius: 6px;
transition: all 0.2s ease;
pointer-events: auto;
position: relative;
z-index: 1053; /* Above other overlay elements */
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
z-index: 1052;
}
.overlay-video-close-button:hover {
background: rgba(239, 68, 68, 1);
border-color: rgba(239, 68, 68, 1);
.stats-minimize:hover {
background: rgba(107, 114, 128, 1);
border-color: rgba(107, 114, 128, 0.8);
color: white;
transform: scale(1.1);
box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4);
box-shadow: 0 2px 8px rgba(107, 114, 128, 0.3);
}
.overlay-video-close-button:active {
.stats-minimize:active {
transform: scale(0.95);
}
@@ -436,7 +436,7 @@
.stats-show-button {
position: absolute;
top: 16px;
right: 16px;
left: 16px; /* Moved to left side to match overlay position */
background: rgba(74, 222, 128, 0.2);
border: 1px solid rgba(74, 222, 128, 0.4);
border-radius: 10px;

View File

@@ -666,8 +666,8 @@ const VideoPlayer = ({
onMouseMove={showControlsTemporarily}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
{/* Close Button - hide when overlay is visible */}
{onClose && !showTorrentStats && (
{/* Close Button - always visible on the right */}
{onClose && (
<button
className="video-close-button"
onClick={onClose}
@@ -710,30 +710,17 @@ const VideoPlayer = ({
networkStatus === 'seeking' ? 'Seeking Peers' : 'Disconnected'}
</span>
</div>
{/* Control buttons */}
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{/* Main video close button */}
{onClose && (
<button
className="overlay-video-close-button"
onClick={onClose}
title="Close video player"
>
<X size={20} />
</button>
)}
{/* Overlay close button */}
<button
className="stats-minimize"
onClick={() => {
console.log('Direct close button clicked');
setShowTorrentStats(false);
}}
title="Hide torrent stats overlay"
>
<X size={16} />
</button>
</div>
{/* Only overlay minimize button */}
<button
className="stats-minimize"
onClick={() => {
console.log('Minimize overlay clicked');
setShowTorrentStats(false);
}}
title="Hide Stats Overlay"
>
<Minimize2 size={14} />
</button>
</div>
<div className="stats-grid">

View File

@@ -16,6 +16,9 @@ const config = {
frontend: {
url: process.env.FRONTEND_URL || 'http://localhost:5173'
},
omdb: {
apiKey: process.env.OMDB_API_KEY || '8265bd1c' // Free API key for development
},
isDevelopment: process.env.NODE_ENV === 'development'
};
@@ -23,7 +26,7 @@ const app = express();
// SIMPLE WORKING WebTorrent configuration - minimal and functional
const client = new WebTorrent({
uploadLimit: 1024, // Allow minimal upload (required for peer reciprocity)
uploadLimit: 500, // Allow minimal upload (required for peer reciprocity)
downloadLimit: -1 // No download limit
});
@@ -34,7 +37,189 @@ const torrentNames = {}; // Torrent names by infoHash
const hashToName = {}; // Quick hash-to-name lookup
const nameToHash = {}; // Quick name-to-hash lookup
// UNIVERSAL TORRENT RESOLVER - Can find torrents by ANY identifier
// IMDB Integration
const imdbCache = new Map();
// Enhanced torrent name cleaning with better regex patterns
function cleanTorrentName(torrentName) {
console.log(`🔍 Cleaning torrent name: "${torrentName}"`);
if (!torrentName) {
return { title: '', year: null };
}
// Start with the original name
let cleanedName = torrentName;
// Remove common quality indicators and extensions
cleanedName = cleanedName.replace(/\.(mkv|mp4|avi|mov|wmv|flv|m4v|3gp|webm)$/i, '');
// Remove quality indicators (more comprehensive)
const qualityPatterns = [
/\b(720p|1080p|480p|4k|uhd|hd|bluray|brrip|webrip|dvdrip|camrip|ts|telesync|hdtv|web-dl|x264|x265|h264|h265|hevc|10bit|aac|ac3|dts|mp3)\b/gi,
/\b(yify|sparks|rarbg|eztv|ettv|galaxy|geckos|scene|repack|proper|real|internal|limited|unrated|extended|directors\.cut)\b/gi,
/\[\w+\]/g, // Remove group tags like [YTS] [RARBG]
/\([^)]*rip[^)]*\)/gi, // Remove anything with 'rip' in parentheses
/\b\d{3,4}mb\b/gi, // Remove file sizes
/www\.\w+\.(com|org|net)/gi // Remove website URLs
];
qualityPatterns.forEach(pattern => {
cleanedName = cleanedName.replace(pattern, '');
});
// Extract year (look for 4-digit year in parentheses or standalone)
let year = null;
const yearMatch = cleanedName.match(/\b(19\d{2}|20\d{2})\b/);
if (yearMatch) {
year = parseInt(yearMatch[1]);
cleanedName = cleanedName.replace(/\b(19\d{2}|20\d{2})\b/, '');
}
// Remove parentheses and brackets with their content
cleanedName = cleanedName.replace(/[\[\(].*?[\]\)]/g, '');
// Replace dots, underscores, dashes with spaces
cleanedName = cleanedName.replace(/[._-]/g, ' ');
// Remove multiple spaces and trim
cleanedName = cleanedName.replace(/\s+/g, ' ').trim();
// Remove common articles for better matching, but store original for display
const title = cleanedName;
console.log(`🧹 After basic cleaning: "${cleanedName}"`);
console.log(`✨ Final cleaned result: title="${title}", year=${year}`);
return { title, year };
}
async function fetchIMDBData(torrentName) {
console.log(`🎬 Fetching IMDB data for: "${torrentName}"`);
// Check cache first
if (imdbCache.has(torrentName)) {
console.log(`📋 Using cached IMDB data for: ${torrentName}`);
return imdbCache.get(torrentName);
}
const cleanedData = cleanTorrentName(torrentName);
const { title, year } = cleanedData;
// Validate title
if (!title || title.length < 2) {
console.log(`❌ Title too short or empty: "${title}"`);
return null;
}
// Get API key from environment
const omdbKey = process.env.OMDB_API_KEY || 'trilogy';
// Multiple search strategies with OMDb
const omdbStrategies = [
// Strategy 1: Title + Year (most accurate)
year ? `http://www.omdbapi.com/?apikey=${omdbKey}&t=${encodeURIComponent(title)}&y=${year}` : null,
// Strategy 2: Title only
`http://www.omdbapi.com/?apikey=${omdbKey}&t=${encodeURIComponent(title)}`,
// Strategy 3: Search API for multiple results
`http://www.omdbapi.com/?apikey=${omdbKey}&s=${encodeURIComponent(title)}&type=movie`,
// Strategy 4: Try with "The" prefix for articles
`http://www.omdbapi.com/?apikey=${omdbKey}&t=${encodeURIComponent('The ' + title)}`
].filter(Boolean);
// Try OMDb first
for (const url of omdbStrategies) {
try {
console.log(`🔍 Trying OMDb: ${url}`);
const response = await fetch(url);
const data = await response.json();
if (data && data.Response === 'True') {
// For search results, take the first movie
const movieData = data.Search ? data.Search[0] : data;
if (movieData && movieData.Title) {
console.log(`✅ Found OMDb data: ${movieData.Title} (${movieData.Year})`);
const result = {
Title: movieData.Title,
Year: movieData.Year,
imdbRating: movieData.imdbRating,
imdbVotes: movieData.imdbVotes,
Plot: movieData.Plot,
Director: movieData.Director,
Actors: movieData.Actors,
Poster: movieData.Poster !== 'N/A' ? movieData.Poster : null,
Genre: movieData.Genre,
Runtime: movieData.Runtime,
Rated: movieData.Rated,
imdbID: movieData.imdbID,
source: 'omdb'
};
// Cache the result
imdbCache.set(torrentName, result);
return result;
}
} else {
console.log(`❌ OMDb error: ${data?.Error || 'Unknown error'}`);
}
} catch (error) {
console.log(`❌ OMDb request error: ${error.message}`);
}
}
// Fallback to TMDB (free public API)
console.log(`🎭 Trying TMDB as fallback for: ${title}`);
try {
const tmdbSearchUrl = `https://api.themoviedb.org/3/search/movie?api_key=3fd2be6f0c70a2a598f084ddfb75487d&query=${encodeURIComponent(title)}${year ? `&year=${year}` : ''}`;
console.log(`🔍 Trying TMDB: ${tmdbSearchUrl}`);
const searchResponse = await fetch(tmdbSearchUrl);
const searchData = await searchResponse.json();
if (searchData.results && searchData.results.length > 0) {
const movie = searchData.results[0];
// Get detailed info
const detailsUrl = `https://api.themoviedb.org/3/movie/${movie.id}?api_key=3fd2be6f0c70a2a598f084ddfb75487d&append_to_response=credits`;
const detailsResponse = await fetch(detailsUrl);
const details = await detailsResponse.json();
console.log(`✅ Found TMDB data: ${details.title} (${details.release_date?.substring(0, 4)})`);
const result = {
Title: details.title,
Year: details.release_date?.substring(0, 4),
imdbRating: details.vote_average ? (details.vote_average / 10 * 10).toFixed(1) : null,
imdbVotes: details.vote_count ? `${details.vote_count.toLocaleString()}` : null,
Plot: details.overview,
Director: details.credits?.crew?.find(person => person.job === 'Director')?.name,
Actors: details.credits?.cast?.slice(0, 4).map(actor => actor.name).join(', '),
Poster: details.poster_path ? `https://image.tmdb.org/t/p/w500${details.poster_path}` : null,
Genre: details.genres?.map(g => g.name).join(', '),
Runtime: details.runtime ? `${details.runtime} min` : null,
Rated: 'N/A', // TMDB doesn't have ratings
tmdbID: details.id,
source: 'tmdb'
};
// Cache the result
imdbCache.set(torrentName, result);
return result;
}
} catch (error) {
console.log(`❌ TMDB error: ${error.message}`);
}
console.log(`❌ No movie data found for: ${title}`);
return null;
}
//UNIVERSAL TORRENT RESOLVER - Can find torrents by ANY identifier
const universalTorrentResolver = async (identifier) => {
console.log(`🔍 Universal resolver looking for: ${identifier}`);
@@ -54,7 +239,7 @@ const universalTorrentResolver = async (identifier) => {
);
if (existingTorrent) {
console.log(`✅ Found in WebTorrent client: ${existingTorrent.name}`);
console.log(`✅ Found in WebTorrent client: ${existingTorrent.name || existingTorrent.infoHash}`);
torrents[existingTorrent.infoHash] = existingTorrent;
return existingTorrent;
}
@@ -184,8 +369,29 @@ const loadTorrentFromId = (torrentId) => {
if (!resolved) {
resolved = true;
console.log(`⏰ Timeout loading torrent after 30 seconds: ${torrentId}`);
console.log(`🔍 Client has ${client.torrents.length} torrents total`);
reject(new Error('Timeout loading torrent'));
// Check if the torrent was actually added to the client
const clientTorrent = client.torrents.find(t => t.infoHash === torrent.infoHash);
if (clientTorrent) {
console.log(`🔍 Found torrent in client after timeout: ${clientTorrent.name || clientTorrent.infoHash}`);
// Store in tracking systems even if metadata isn't fully ready
torrents[clientTorrent.infoHash] = clientTorrent;
torrentIds[clientTorrent.infoHash] = torrentId;
torrentNames[clientTorrent.infoHash] = clientTorrent.name || 'Loading...';
hashToName[clientTorrent.infoHash] = clientTorrent.name || 'Loading...';
if (clientTorrent.name) {
nameToHash[clientTorrent.name] = clientTorrent.infoHash;
}
clientTorrent.addedAt = new Date().toISOString();
clientTorrent.uploadLimit = 1024;
resolve(clientTorrent);
} else {
console.log(`🔍 Client has ${client.torrents.length} torrents total`);
reject(new Error('Timeout loading torrent'));
}
}
}, 30000);
});
@@ -211,10 +417,22 @@ process.on('SIGINT', () => {
});
// Configure multer
const fs = require('fs');
const uploadsDir = 'uploads/';
// Ensure uploads directory exists
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
console.log('📁 Created uploads directory');
}
const upload = multer({
dest: 'uploads/',
dest: uploadsDir,
fileFilter: (req, file, cb) => {
cb(null, file.originalname.endsWith('.torrent'));
},
limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit for torrent files
}
});
@@ -251,19 +469,53 @@ app.post('/api/torrents', async (req, res) => {
if (!torrent) {
// If resolver failed, try direct loading
const newTorrent = await loadTorrentFromId(torrentId);
return res.json({
infoHash: newTorrent.infoHash,
name: newTorrent.name,
size: newTorrent.length,
status: 'loaded'
});
try {
const newTorrent = await loadTorrentFromId(torrentId);
return res.json({
infoHash: newTorrent.infoHash,
name: newTorrent.name || 'Loading...',
size: newTorrent.length || 0,
status: 'loaded'
});
} catch (loadError) {
// Handle duplicate torrent error specially
if (loadError.message.includes('duplicate torrent')) {
console.log(`🔍 Duplicate torrent detected, finding existing torrent`);
// Extract hash from torrentId if it's a magnet
let hash = torrentId;
if (torrentId.startsWith('magnet:')) {
const match = torrentId.match(/xt=urn:btih:([a-fA-F0-9]{40})/);
if (match) hash = match[1];
}
// Try to find the existing torrent
const existingTorrent = Object.values(torrents).find(t =>
t.infoHash === hash ||
t.infoHash.toLowerCase() === hash.toLowerCase()
) || client.torrents.find(t =>
t.infoHash === hash ||
t.infoHash.toLowerCase() === hash.toLowerCase()
);
if (existingTorrent) {
return res.json({
infoHash: existingTorrent.infoHash,
name: existingTorrent.name || 'Loading...',
size: existingTorrent.length || 0,
status: 'existing'
});
}
}
throw loadError;
}
}
res.json({
infoHash: torrent.infoHash,
name: torrent.name,
size: torrent.length,
name: torrent.name || 'Loading...',
size: torrent.length || 0,
status: 'found'
});
@@ -273,6 +525,93 @@ app.post('/api/torrents', async (req, res) => {
}
});
// UNIVERSAL FILE UPLOAD - Handle .torrent files
app.post('/api/torrents/upload', upload.single('torrentFile'), async (req, res) => {
console.log(`📁 UNIVERSAL FILE UPLOAD`);
if (!req.file) {
return res.status(400).json({ error: 'No torrent file provided' });
}
try {
const fs = require('fs');
const torrentPath = req.file.path;
console.log(`📁 Processing uploaded file: ${req.file.originalname}`);
console.log(`📁 File path: ${torrentPath}`);
// Read the torrent file
const torrentBuffer = fs.readFileSync(torrentPath);
// Load the torrent using the buffer
const torrent = await new Promise((resolve, reject) => {
const loadedTorrent = client.add(torrentBuffer);
let resolved = false;
loadedTorrent.on('ready', () => {
if (resolved) return;
resolved = true;
console.log(`✅ Torrent uploaded and loaded: ${loadedTorrent.name}`);
// Store in tracking systems
torrents[loadedTorrent.infoHash] = loadedTorrent;
torrentIds[loadedTorrent.infoHash] = req.file.originalname;
torrentNames[loadedTorrent.infoHash] = loadedTorrent.name;
hashToName[loadedTorrent.infoHash] = loadedTorrent.name;
nameToHash[loadedTorrent.name] = loadedTorrent.infoHash;
loadedTorrent.addedAt = new Date().toISOString();
loadedTorrent.uploadLimit = 1024; // Minimal upload for peer reciprocity
resolve(loadedTorrent);
});
loadedTorrent.on('error', (err) => {
if (resolved) return;
resolved = true;
console.error(`❌ Error loading uploaded torrent:`, err.message);
reject(err);
});
// Timeout after 30 seconds
setTimeout(() => {
if (!resolved) {
resolved = true;
reject(new Error('Timeout loading torrent file'));
}
}, 30000);
});
// Clean up uploaded file
fs.unlinkSync(torrentPath);
res.json({
infoHash: torrent.infoHash,
name: torrent.name,
size: torrent.length,
status: 'uploaded',
files: torrent.files.length
});
} catch (error) {
console.error(`❌ File upload failed:`, error.message);
// Clean up file on error
if (req.file && req.file.path) {
try {
const fs = require('fs');
fs.unlinkSync(req.file.path);
} catch (cleanupError) {
console.error(`❌ Failed to cleanup file:`, cleanupError.message);
}
}
res.status(500).json({ error: 'Failed to upload torrent: ' + error.message });
}
});
// UNIVERSAL GET TORRENTS - Always returns results
app.get('/api/torrents', (req, res) => {
const activeTorrents = Object.values(torrents).map(torrent => ({
@@ -402,6 +741,47 @@ app.get('/api/torrents/:identifier/stats', async (req, res) => {
}
});
// IMDB Data Endpoint
app.get('/api/torrents/:identifier/imdb', async (req, res) => {
const identifier = req.params.identifier;
console.log(`🎬 IMDB REQUEST: ${identifier}`);
try {
const torrent = await universalTorrentResolver(identifier);
if (!torrent) {
console.log(`❌ Torrent not found for identifier: ${identifier}`);
return res.status(404).json({ error: 'Torrent not found' });
}
console.log(`🎬 Found torrent: ${torrent.name}, fetching IMDB data...`);
const imdbData = await fetchIMDBData(torrent.name);
console.log(`🎬 IMDB data result:`, imdbData ? 'SUCCESS' : 'NULL/UNDEFINED');
if (imdbData) {
const response = {
success: true,
torrentName: torrent.name,
imdb: imdbData
};
console.log(`✅ Sending IMDB response:`, JSON.stringify(response, null, 2));
res.json(response);
} else {
const response = {
success: false,
torrentName: torrent.name,
message: 'IMDB data not found'
};
console.log(`❌ Sending failure response:`, JSON.stringify(response, null, 2));
res.json(response);
}
} catch (error) {
console.error(`❌ IMDB endpoint failed:`, error.message);
res.status(500).json({ error: 'Failed to get IMDB data: ' + error.message });
}
});
// UNIVERSAL STREAMING - Always works if torrent exists
app.get('/api/torrents/:identifier/files/:fileIdx/stream', async (req, res) => {
const { identifier, fileIdx } = req.params;