Add local profiles, smart features, and Google Cast support
Some checks failed
CI / Lint & Type Check (push) Failing after 43s
CI / Tests (push) Successful in 1m0s
CI / Build Web (push) Has been skipped
CI / Security Scan (push) Successful in 46s
CI / Build Electron (Linux) (push) Has been skipped
CI / Build Tauri (ubuntu-latest) (push) Has been skipped
CI / Build Electron (Windows) (push) Has been cancelled
CI / Build Tauri (windows-latest) (push) Has been cancelled
Some checks failed
CI / Lint & Type Check (push) Failing after 43s
CI / Tests (push) Successful in 1m0s
CI / Build Web (push) Has been skipped
CI / Security Scan (push) Successful in 46s
CI / Build Electron (Linux) (push) Has been skipped
CI / Build Tauri (ubuntu-latest) (push) Has been skipped
CI / Build Electron (Windows) (push) Has been cancelled
CI / Build Tauri (windows-latest) (push) Has been cancelled
- Local Profiles: Profile selector, manager, avatar system, profile-aware stores - Smart Features: Continue Watching, personalized recommendations, auto-quality, smart downloads - Google Cast: Cast service with web SDK and Capacitor Android plugin interface - Settings: New toggles for auto-quality and smart downloads Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,9 +24,17 @@ export { broadcastUpdate };
|
||||
// Read version from package.json to avoid hardcoding
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const packageJsonPath = join(__dirname, '..', 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
const SERVER_VERSION = packageJson.version;
|
||||
|
||||
// Try to read version from package.json, fallback to hardcoded version for Android
|
||||
let SERVER_VERSION = '2.0.0';
|
||||
try {
|
||||
const packageJsonPath = join(__dirname, '..', 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
||||
SERVER_VERSION = packageJson.version;
|
||||
} catch (err) {
|
||||
// On Android, package.json may not exist in the bundled assets
|
||||
console.log('[beStream Server] Could not read package.json, using default version');
|
||||
}
|
||||
|
||||
console.log('[beStream Server] Imports loaded successfully');
|
||||
|
||||
@@ -68,6 +76,7 @@ const ALLOWED_ORIGINS = [
|
||||
// Capacitor
|
||||
'capacitor://localhost',
|
||||
'http://localhost',
|
||||
'https://localhost',
|
||||
// Android WebView (file:// protocol)
|
||||
null, // file:// origins are null
|
||||
];
|
||||
|
||||
@@ -461,14 +461,17 @@ streamRouter.get('/:sessionId/info', validate(schemas.sessionId, 'params'), asyn
|
||||
/**
|
||||
* Stop a streaming session
|
||||
* DELETE /api/stream/:sessionId
|
||||
* Query params:
|
||||
* - cleanup: If 'true', delete downloaded files
|
||||
*/
|
||||
streamRouter.delete('/:sessionId', validate(schemas.sessionId, 'params'), async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const cleanupFiles = req.query.cleanup === 'true';
|
||||
|
||||
await torrentManager.stopSession(sessionId);
|
||||
await torrentManager.stopSession(sessionId, { cleanupFiles });
|
||||
transcoder.cleanupSession(sessionId);
|
||||
|
||||
res.json({ status: 'stopped' });
|
||||
res.json({ status: 'stopped', filesDeleted: cleanupFiles });
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -198,19 +198,21 @@ class Transcoder {
|
||||
// Detect if this is 4K - downscale for real-time playback
|
||||
const is4K = quality === '2160p' || quality === '4K' || quality?.includes('2160');
|
||||
|
||||
// Default settings for 1080p and below
|
||||
let maxrate = '4M';
|
||||
let bufsize = '8M';
|
||||
let crf = '23';
|
||||
// Quality settings optimized for good visual quality with real-time playback
|
||||
// Using 'veryfast' preset instead of 'ultrafast' - much better quality, still fast
|
||||
// Higher bitrates prevent pixelation and motion artifacts in action scenes
|
||||
let maxrate = '8M'; // Good bitrate for 1080p content
|
||||
let bufsize = '16M'; // 2x maxrate for smooth bitrate allocation
|
||||
let crf = '20'; // Lower CRF = better quality (18-23 is good range)
|
||||
let scaleFilter = null;
|
||||
|
||||
if (is4K) {
|
||||
// 4K sources are downscaled to 1080p for real-time playback
|
||||
// Full 4K transcoding is too demanding for most local systems
|
||||
console.log(`🎬 Starting HLS transcode for 4K video → 1080p (session ${sessionId})`);
|
||||
maxrate = '8M'; // Good bitrate for 1080p from 4K source
|
||||
bufsize = '16M'; // Larger buffer
|
||||
crf = '21'; // Slightly better quality since downscaling
|
||||
maxrate = '12M'; // Higher bitrate for 4K downscaled content
|
||||
bufsize = '24M'; // Larger buffer for complex scenes
|
||||
crf = '18'; // Better quality for 4K source material
|
||||
scaleFilter = 'scale=1920:-2'; // Downscale to 1080p, maintain aspect ratio
|
||||
} else {
|
||||
console.log(`🎬 Starting HLS transcode for session ${sessionId}`);
|
||||
@@ -225,18 +227,20 @@ class Transcoder {
|
||||
'-hls_segment_type', 'mpegts',
|
||||
'-hls_segment_filename', path.join(outputDir, 'segment_%03d.ts'),
|
||||
|
||||
// Video encoding
|
||||
// Video encoding - optimized for quality
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'ultrafast', // Fast encoding for real-time
|
||||
'-tune', 'zerolatency', // Low latency
|
||||
'-preset', 'veryfast', // Good balance of speed and quality
|
||||
'-tune', 'film', // Optimize for movie content (better grain/detail)
|
||||
'-crf', crf, // Quality (lower = better)
|
||||
'-maxrate', maxrate, // Max bitrate (adjusted for quality)
|
||||
'-bufsize', bufsize, // Buffer size (adjusted for quality)
|
||||
'-maxrate', maxrate, // Max bitrate for complex scenes
|
||||
'-bufsize', bufsize, // Buffer size for bitrate smoothing
|
||||
'-g', '48', // Keyframe interval
|
||||
'-bf', '3', // B-frames for better compression/quality
|
||||
'-refs', '3', // Reference frames for motion prediction
|
||||
|
||||
// Audio encoding
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-b:a', '192k', // Higher audio bitrate for better quality
|
||||
'-ac', '2', // Stereo
|
||||
|
||||
// General
|
||||
@@ -318,18 +322,20 @@ class Transcoder {
|
||||
// Detect if this is 4K - downscale for real-time playback
|
||||
const is4K = quality === '2160p' || quality === '4K' || quality?.includes('2160');
|
||||
|
||||
// Default settings for 1080p and below
|
||||
let maxrate = '4M';
|
||||
let bufsize = '8M';
|
||||
let crf = '23';
|
||||
// Quality settings optimized for good visual quality with real-time playback
|
||||
// Using 'veryfast' preset instead of 'ultrafast' - much better quality, still fast
|
||||
// Higher bitrates prevent pixelation and motion artifacts in action scenes
|
||||
let maxrate = '8M'; // Good bitrate for 1080p content
|
||||
let bufsize = '16M'; // 2x maxrate for smooth bitrate allocation
|
||||
let crf = '20'; // Lower CRF = better quality (18-23 is good range)
|
||||
let scaleFilter = null;
|
||||
|
||||
if (is4K) {
|
||||
// 4K sources are downscaled to 1080p for real-time playback
|
||||
console.log(`🎬 Starting HLS stream transcode for 4K video → 1080p (session ${sessionId})`);
|
||||
maxrate = '8M'; // Good bitrate for 1080p from 4K source
|
||||
bufsize = '16M'; // Larger buffer
|
||||
crf = '21'; // Slightly better quality since downscaling
|
||||
maxrate = '12M'; // Higher bitrate for 4K downscaled content
|
||||
bufsize = '24M'; // Larger buffer for complex scenes
|
||||
crf = '18'; // Better quality for 4K source material
|
||||
scaleFilter = 'scale=1920:-2'; // Downscale to 1080p, maintain aspect ratio
|
||||
} else {
|
||||
console.log(`🎬 Starting HLS stream transcode for session ${sessionId} (from WebTorrent stream)`);
|
||||
@@ -344,18 +350,20 @@ class Transcoder {
|
||||
'-hls_segment_type', 'mpegts',
|
||||
'-hls_segment_filename', path.join(outputDir, 'segment_%03d.ts'),
|
||||
|
||||
// Video encoding
|
||||
// Video encoding - optimized for quality
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'ultrafast', // Fast encoding for real-time
|
||||
'-tune', 'zerolatency', // Low latency
|
||||
'-preset', 'veryfast', // Good balance of speed and quality
|
||||
'-tune', 'film', // Optimize for movie content (better grain/detail)
|
||||
'-crf', crf, // Quality (lower = better)
|
||||
'-maxrate', maxrate, // Max bitrate (adjusted for quality)
|
||||
'-bufsize', bufsize, // Buffer size (adjusted for quality)
|
||||
'-maxrate', maxrate, // Max bitrate for complex scenes
|
||||
'-bufsize', bufsize, // Buffer size for bitrate smoothing
|
||||
'-g', '48', // Keyframe interval
|
||||
'-bf', '3', // B-frames for better compression/quality
|
||||
'-refs', '3', // Reference frames for motion prediction
|
||||
|
||||
// Audio encoding
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '128k',
|
||||
'-b:a', '192k', // Higher audio bitrate for better quality
|
||||
'-ac', '2', // Stereo
|
||||
|
||||
// General
|
||||
@@ -425,11 +433,13 @@ class Transcoder {
|
||||
.seekInput(startTime)
|
||||
.outputOptions([
|
||||
'-c:v', videoCodec,
|
||||
'-preset', 'ultrafast',
|
||||
'-tune', 'zerolatency',
|
||||
'-crf', '23',
|
||||
'-preset', 'veryfast', // Good balance of speed and quality
|
||||
'-tune', 'film', // Optimize for movie content
|
||||
'-crf', '20', // Good quality
|
||||
'-maxrate', '8M', // Prevent bitrate spikes
|
||||
'-bufsize', '16M', // Smooth bitrate allocation
|
||||
'-c:a', audioCodec,
|
||||
'-b:a', '128k',
|
||||
'-b:a', '192k', // Higher audio quality
|
||||
'-movflags', 'frag_keyframe+empty_moov+faststart',
|
||||
'-f', 'mp4',
|
||||
])
|
||||
|
||||
26
src/App.tsx
26
src/App.tsx
@@ -1,9 +1,22 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { useEffect, Suspense, lazy } from 'react';
|
||||
import Layout from './components/layout/Layout';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import ProfileGate from './components/profile/ProfileGate';
|
||||
import { useSettingsStore } from './stores/settingsStore';
|
||||
|
||||
// Scroll to top on route change
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to top on every route change
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lazy load routes for code splitting
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Browse = lazy(() => import('./pages/Browse'));
|
||||
@@ -41,8 +54,10 @@ function App() {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Routes>
|
||||
<ProfileGate>
|
||||
<ScrollToTop />
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route
|
||||
index
|
||||
@@ -171,8 +186,9 @@ function App() {
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ProfileGate>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
@@ -15,9 +15,15 @@ import {
|
||||
Minus,
|
||||
Square,
|
||||
Maximize2,
|
||||
ChevronDown,
|
||||
User,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { isElectron } from '../../utils/platform';
|
||||
import { useProfileStore } from '../../stores/profileStore';
|
||||
import { Avatar } from '../profile/Avatar';
|
||||
import type { AvatarId } from '../../types';
|
||||
|
||||
const navLinks = [
|
||||
{ path: '/', label: 'Home', icon: Home },
|
||||
@@ -33,10 +39,16 @@ export default function Navbar() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const profileMenuRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isElectronApp = isElectron();
|
||||
|
||||
// Profile store
|
||||
const { profiles, activeProfileId, getActiveProfile, setActiveProfile } = useProfileStore();
|
||||
const activeProfile = getActiveProfile();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 50);
|
||||
@@ -48,8 +60,20 @@ export default function Navbar() {
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
setIsProfileMenuOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Close profile menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) {
|
||||
setIsProfileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Check if window is maximized (Electron only)
|
||||
useEffect(() => {
|
||||
if (isElectronApp && window.electron) {
|
||||
@@ -200,6 +224,91 @@ export default function Navbar() {
|
||||
</motion.div>
|
||||
</Link>
|
||||
|
||||
{/* Profile Dropdown */}
|
||||
<div className="relative" ref={profileMenuRef}>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setIsProfileMenuOpen(!isProfileMenuOpen)}
|
||||
className="flex items-center gap-2 p-1 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
{activeProfile ? (
|
||||
<Avatar avatarId={activeProfile.avatar as AvatarId} size="sm" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-md bg-netflix-medium-gray flex items-center justify-center">
|
||||
<User size={18} />
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={clsx(
|
||||
'hidden md:block transition-transform',
|
||||
isProfileMenuOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</motion.button>
|
||||
|
||||
{/* Profile Dropdown Menu */}
|
||||
<AnimatePresence>
|
||||
{isProfileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 top-full mt-2 w-56 bg-netflix-dark-gray/95 backdrop-blur-md rounded-lg shadow-xl border border-white/10 overflow-hidden z-50"
|
||||
>
|
||||
{/* Current Profile */}
|
||||
{activeProfile && (
|
||||
<div className="px-4 py-3 border-b border-white/10">
|
||||
<p className="text-xs text-gray-400 mb-1">Current Profile</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar avatarId={activeProfile.avatar as AvatarId} size="sm" />
|
||||
<span className="font-medium text-white">{activeProfile.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Profiles */}
|
||||
{profiles.filter(p => p.id !== activeProfileId).length > 0 && (
|
||||
<div className="py-2">
|
||||
<p className="px-4 text-xs text-gray-400 mb-1">Switch Profile</p>
|
||||
{profiles
|
||||
.filter((p) => p.id !== activeProfileId)
|
||||
.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
onClick={() => {
|
||||
setActiveProfile(profile.id);
|
||||
setIsProfileMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Avatar avatarId={profile.avatar as AvatarId} size="sm" />
|
||||
<span className="text-gray-300">{profile.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="border-t border-white/10 py-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveProfile(null);
|
||||
setIsProfileMenuOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-white/10 transition-colors text-gray-300"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>Switch Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
|
||||
81
src/components/player/CastButton.tsx
Normal file
81
src/components/player/CastButton.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Cast, Loader2 } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useCast } from '../../hooks/useCast';
|
||||
|
||||
interface CastButtonProps {
|
||||
className?: string;
|
||||
size?: number;
|
||||
onCastStart?: () => void;
|
||||
onCastEnd?: () => void;
|
||||
}
|
||||
|
||||
export default function CastButton({
|
||||
className,
|
||||
size = 24,
|
||||
onCastStart,
|
||||
onCastEnd,
|
||||
}: CastButtonProps) {
|
||||
const {
|
||||
isAvailable,
|
||||
isConnected,
|
||||
state,
|
||||
deviceName,
|
||||
requestSession,
|
||||
endSession,
|
||||
} = useCast();
|
||||
|
||||
// Don't render if casting is not available
|
||||
if (!isAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = async () => {
|
||||
if (isConnected) {
|
||||
await endSession();
|
||||
onCastEnd?.();
|
||||
} else {
|
||||
const success = await requestSession();
|
||||
if (success) {
|
||||
onCastStart?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isConnecting = state === 'connecting';
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={handleClick}
|
||||
disabled={isConnecting}
|
||||
className={clsx(
|
||||
'relative p-2 rounded-full transition-colors',
|
||||
isConnected
|
||||
? 'text-netflix-red bg-white/20'
|
||||
: 'text-white hover:bg-white/10',
|
||||
isConnecting && 'opacity-50 cursor-wait',
|
||||
className
|
||||
)}
|
||||
title={
|
||||
isConnected
|
||||
? `Casting to ${deviceName}`
|
||||
: isConnecting
|
||||
? 'Connecting...'
|
||||
: 'Cast to device'
|
||||
}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader2 size={size} className="animate-spin" />
|
||||
) : (
|
||||
<Cast size={size} />
|
||||
)}
|
||||
|
||||
{/* Connected indicator */}
|
||||
{isConnected && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-netflix-red rounded-full border-2 border-black" />
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import { searchSubtitles, downloadSubtitle, convertSrtToVtt } from '../../servic
|
||||
import { useSettingsStore } from '../../stores/settingsStore';
|
||||
import { isCapacitor } from '../../utils/platform';
|
||||
import { playWithNativePlayer } from '../../plugins/ExoPlayer';
|
||||
import CastButton from './CastButton';
|
||||
import { useCast } from '../../hooks/useCast';
|
||||
|
||||
interface StreamingPlayerProps {
|
||||
movie: Movie;
|
||||
@@ -80,6 +82,9 @@ export default function StreamingPlayer({
|
||||
const [isNativePlayerPlaying, setIsNativePlayerPlaying] = useState(false);
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
// Cast integration
|
||||
const { castMedia } = useCast();
|
||||
|
||||
// Detect potential audio issues on Android with MKV files and enable native player
|
||||
useEffect(() => {
|
||||
// Only check on Capacitor (Android) since desktop has FFmpeg transcoding
|
||||
@@ -812,35 +817,39 @@ export default function StreamingPlayer({
|
||||
|
||||
{/* Audio Warning Banner for Android MKV files with Native Player option */}
|
||||
<AnimatePresence>
|
||||
{audioWarning && !audioWarningDismissed && (
|
||||
{audioWarning && !audioWarningDismissed && useNativePlayer && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -50 }}
|
||||
className="absolute top-0 left-0 right-0 z-50 bg-yellow-600/95 text-white p-3"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-black/90"
|
||||
>
|
||||
<div className="flex items-start gap-3 max-w-4xl mx-auto">
|
||||
<AlertTriangle size={24} className="flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium mb-1">Audio Format Issue</p>
|
||||
<p className="text-yellow-100">{audioWarning}</p>
|
||||
{useNativePlayer && (
|
||||
<button
|
||||
onClick={handleNativePlayerLaunch}
|
||||
disabled={isNativePlayerPlaying}
|
||||
className="mt-2 flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
<MonitorPlay size={18} />
|
||||
{isNativePlayerPlaying ? 'Opening Native Player...' : 'Play with Native Player (Full Audio Support)'}
|
||||
</button>
|
||||
)}
|
||||
<div className="max-w-md mx-4 text-center">
|
||||
<AlertTriangle size={48} className="text-yellow-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-white mb-2">Audio May Not Work</h2>
|
||||
<p className="text-gray-300 mb-6">
|
||||
This video uses an MKV format with audio codecs that may not play in the browser.
|
||||
Use the Native Player for full audio support.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={handleNativePlayerLaunch}
|
||||
disabled={isNativePlayerPlaying}
|
||||
className="flex items-center justify-center gap-2 px-6 py-3 bg-netflix-red hover:bg-netflix-red-hover rounded-lg font-semibold text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
<MonitorPlay size={20} />
|
||||
{isNativePlayerPlaying ? 'Opening...' : 'Use Native Player (Recommended)'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAudioWarningDismissed(true)}
|
||||
className="px-6 py-3 bg-white/10 hover:bg-white/20 rounded-lg font-medium text-gray-300 transition-colors"
|
||||
>
|
||||
Try Browser Player Anyway
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAudioWarningDismissed(true)}
|
||||
className="text-yellow-200 hover:text-white p-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<p className="text-gray-500 text-sm mt-4">
|
||||
Tip: MP4 torrents usually have better compatibility
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -1201,6 +1210,30 @@ export default function StreamingPlayer({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cast Button */}
|
||||
<CastButton
|
||||
size={20}
|
||||
onCastStart={() => {
|
||||
// Cast the current stream
|
||||
const streamTocast = hlsUrl || streamUrl;
|
||||
if (streamTocast) {
|
||||
castMedia({
|
||||
url: streamTocast,
|
||||
title: movie.title,
|
||||
subtitle: `${movie.year} • ${movie.runtime} min`,
|
||||
imageUrl: movie.large_cover_image || movie.medium_cover_image,
|
||||
startTime: currentTime,
|
||||
});
|
||||
// Pause local playback when casting starts
|
||||
videoRef.current?.pause();
|
||||
}
|
||||
}}
|
||||
onCastEnd={() => {
|
||||
// Resume local playback when casting ends
|
||||
videoRef.current?.play();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
|
||||
138
src/components/profile/Avatar.tsx
Normal file
138
src/components/profile/Avatar.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { memo } from 'react';
|
||||
import type { AvatarId } from '../../types';
|
||||
|
||||
type AvatarSize = 'sm' | 'md' | 'lg' | number;
|
||||
|
||||
const SIZE_MAP: Record<string, number> = {
|
||||
sm: 32,
|
||||
md: 48,
|
||||
lg: 64,
|
||||
};
|
||||
|
||||
interface AvatarProps {
|
||||
avatarId: AvatarId;
|
||||
size?: AvatarSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Avatar color schemes and icons
|
||||
const AVATAR_CONFIGS: Record<AvatarId, { bg: string; fg: string; icon: string }> = {
|
||||
'avatar-1': { bg: '#E50914', fg: '#fff', icon: 'user' }, // Netflix red
|
||||
'avatar-2': { bg: '#1DB954', fg: '#fff', icon: 'smile' }, // Green
|
||||
'avatar-3': { bg: '#6366F1', fg: '#fff', icon: 'star' }, // Indigo
|
||||
'avatar-4': { bg: '#F59E0B', fg: '#fff', icon: 'zap' }, // Amber
|
||||
'avatar-5': { bg: '#EC4899', fg: '#fff', icon: 'heart' }, // Pink
|
||||
'avatar-6': { bg: '#06B6D4', fg: '#fff', icon: 'film' }, // Cyan
|
||||
'avatar-7': { bg: '#8B5CF6', fg: '#fff', icon: 'ghost' }, // Purple
|
||||
'avatar-8': { bg: '#10B981', fg: '#fff', icon: 'cat' }, // Emerald
|
||||
};
|
||||
|
||||
const IconPaths: Record<string, JSX.Element> = {
|
||||
user: (
|
||||
<path
|
||||
d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v3h20v-3c0-3.3-6.7-5-10-5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
),
|
||||
smile: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none" />
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||
<circle cx="9" cy="9" r="1.5" fill="currentColor" />
|
||||
<circle cx="15" cy="9" r="1.5" fill="currentColor" />
|
||||
</>
|
||||
),
|
||||
star: (
|
||||
<path
|
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
),
|
||||
zap: (
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="currentColor" />
|
||||
),
|
||||
heart: (
|
||||
<path
|
||||
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
),
|
||||
film: (
|
||||
<>
|
||||
<rect x="2" y="2" width="20" height="20" rx="2" stroke="currentColor" strokeWidth="2" fill="none" />
|
||||
<line x1="7" y1="2" x2="7" y2="22" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="17" y1="2" x2="17" y2="22" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" strokeWidth="2" />
|
||||
</>
|
||||
),
|
||||
ghost: (
|
||||
<path
|
||||
d="M12 2C7.58 2 4 5.58 4 10v12l2-2 2 2 2-2 2 2 2-2 2 2 2-2 2 2V10c0-4.42-3.58-8-8-8zm-2 8a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm5 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
),
|
||||
cat: (
|
||||
<path
|
||||
d="M12 2c-4 0-8 4-8 8 0 2.5 1 4.5 2.5 6L6 22h2l.5-4h7l.5 4h2l-.5-6c1.5-1.5 2.5-3.5 2.5-6 0-4-4-8-8-8zM8 9l2-4 2 4H8zm4 0l2-4 2 4h-4zm-2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const Avatar = memo(function Avatar({ avatarId, size = 'lg', className = '' }: AvatarProps) {
|
||||
const config = AVATAR_CONFIGS[avatarId] || AVATAR_CONFIGS['avatar-1'];
|
||||
const pixelSize = typeof size === 'number' ? size : SIZE_MAP[size] || 64;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md overflow-hidden flex items-center justify-center ${className}`}
|
||||
style={{
|
||||
width: pixelSize,
|
||||
height: pixelSize,
|
||||
backgroundColor: config.bg,
|
||||
color: config.fg,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={pixelSize * 0.6}
|
||||
height={pixelSize * 0.6}
|
||||
style={{ color: config.fg }}
|
||||
>
|
||||
{IconPaths[config.icon]}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const AvatarSelector = memo(function AvatarSelector({
|
||||
selected,
|
||||
onSelect,
|
||||
size = 64,
|
||||
}: {
|
||||
selected: AvatarId;
|
||||
onSelect: (id: AvatarId) => void;
|
||||
size?: number;
|
||||
}) {
|
||||
const avatarIds = Object.keys(AVATAR_CONFIGS) as AvatarId[];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{avatarIds.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => onSelect(id)}
|
||||
className={`rounded-lg p-1 transition-all ${
|
||||
selected === id
|
||||
? 'ring-2 ring-netflix-red ring-offset-2 ring-offset-netflix-black scale-110'
|
||||
: 'hover:scale-105 opacity-70 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<Avatar avatarId={id} size={size} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Avatar;
|
||||
105
src/components/profile/ProfileGate.tsx
Normal file
105
src/components/profile/ProfileGate.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState, useEffect, ReactNode } from 'react';
|
||||
import { useProfileStore } from '../../stores/profileStore';
|
||||
import ProfileSelector from '../../pages/ProfileSelector';
|
||||
import ProfileManager from '../../pages/ProfileManager';
|
||||
import type { Profile } from '../../types';
|
||||
|
||||
type ProfileView = 'selector' | 'manager' | 'edit';
|
||||
|
||||
interface ProfileGateProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function ProfileGate({ children }: ProfileGateProps) {
|
||||
const { profiles, activeProfileId, setActiveProfile } = useProfileStore();
|
||||
const [view, setView] = useState<ProfileView>('selector');
|
||||
const [editingProfile, setEditingProfile] = useState<Profile | null>(null);
|
||||
const [showProfileUI, setShowProfileUI] = useState(false);
|
||||
|
||||
// Determine if we should show the profile UI
|
||||
useEffect(() => {
|
||||
// Show profile selector if no active profile or no profiles at all
|
||||
if (!activeProfileId || profiles.length === 0) {
|
||||
setShowProfileUI(true);
|
||||
setView(profiles.length === 0 ? 'manager' : 'selector');
|
||||
} else {
|
||||
setShowProfileUI(false);
|
||||
}
|
||||
}, [activeProfileId, profiles.length]);
|
||||
|
||||
const handleSelectProfile = (profile: Profile) => {
|
||||
setActiveProfile(profile.id);
|
||||
setShowProfileUI(false);
|
||||
};
|
||||
|
||||
const handleManageProfiles = () => {
|
||||
setEditingProfile(null);
|
||||
setView('manager');
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (view === 'edit') {
|
||||
setEditingProfile(null);
|
||||
setView('manager');
|
||||
} else if (profiles.length > 0) {
|
||||
setView('selector');
|
||||
}
|
||||
// If no profiles exist, stay on manager
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (profiles.length === 1 && !activeProfileId) {
|
||||
// Auto-select the first profile after creating
|
||||
const firstProfile = profiles[0] || useProfileStore.getState().profiles[0];
|
||||
if (firstProfile) {
|
||||
setActiveProfile(firstProfile.id);
|
||||
setShowProfileUI(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setEditingProfile(null);
|
||||
setView('selector');
|
||||
};
|
||||
|
||||
// If we need to show profile UI
|
||||
if (showProfileUI) {
|
||||
if (view === 'manager' || view === 'edit') {
|
||||
return (
|
||||
<ProfileManager
|
||||
editingProfile={editingProfile}
|
||||
onBack={handleBack}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProfileSelector
|
||||
onSelectProfile={handleSelectProfile}
|
||||
onManageProfiles={handleManageProfiles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Profile is selected, show app
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Export a hook for components to trigger profile switching
|
||||
export function useProfileGate() {
|
||||
const { profiles, setActiveProfile, getActiveProfile } = useProfileStore();
|
||||
const [showSwitch, setShowSwitch] = useState(false);
|
||||
|
||||
const switchProfile = () => {
|
||||
// Clear active profile to trigger ProfileGate to show selector
|
||||
setActiveProfile(null);
|
||||
};
|
||||
|
||||
return {
|
||||
activeProfile: getActiveProfile(),
|
||||
profiles,
|
||||
showSwitch,
|
||||
setShowSwitch,
|
||||
switchProfile,
|
||||
};
|
||||
}
|
||||
112
src/hooks/useCast.ts
Normal file
112
src/hooks/useCast.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { castService, type CastState } from '../services/cast/castService';
|
||||
|
||||
interface UseCastResult {
|
||||
isAvailable: boolean;
|
||||
isConnected: boolean;
|
||||
state: CastState;
|
||||
deviceName: string | null;
|
||||
playerState: string;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
requestSession: () => Promise<boolean>;
|
||||
endSession: () => Promise<void>;
|
||||
castMedia: (options: CastMediaOptions) => Promise<boolean>;
|
||||
play: () => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
seek: (time: number) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface CastMediaOptions {
|
||||
url: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
contentType?: string;
|
||||
startTime?: number;
|
||||
}
|
||||
|
||||
export function useCast(): UseCastResult {
|
||||
const [state, setState] = useState<CastState>(castService.currentState);
|
||||
const [isConnected, setIsConnected] = useState(castService.isConnected);
|
||||
const [deviceName, setDeviceName] = useState<string | null>(null);
|
||||
const [playerState, setPlayerState] = useState('idle');
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for cast state changes
|
||||
const removeStateListener = castService.addStateListener((newState, session) => {
|
||||
setState(newState);
|
||||
setIsConnected(newState === 'connected');
|
||||
setDeviceName(session?.device.name || null);
|
||||
});
|
||||
|
||||
// Listen for media status updates
|
||||
const removeMediaListener = castService.addMediaListener(
|
||||
(newPlayerState, newCurrentTime, newDuration) => {
|
||||
setPlayerState(newPlayerState);
|
||||
setCurrentTime(newCurrentTime);
|
||||
setDuration(newDuration);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
removeStateListener();
|
||||
removeMediaListener();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const requestSession = useCallback(async (): Promise<boolean> => {
|
||||
return castService.requestSession();
|
||||
}, []);
|
||||
|
||||
const endSession = useCallback(async (): Promise<void> => {
|
||||
await castService.endSession();
|
||||
}, []);
|
||||
|
||||
const castMedia = useCallback(async (options: CastMediaOptions): Promise<boolean> => {
|
||||
return castService.loadMedia({
|
||||
contentId: options.url,
|
||||
contentType: options.contentType || 'video/mp4',
|
||||
title: options.title,
|
||||
subtitle: options.subtitle,
|
||||
imageUrl: options.imageUrl,
|
||||
currentTime: options.startTime,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const play = useCallback(async (): Promise<void> => {
|
||||
await castService.play();
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(async (): Promise<void> => {
|
||||
await castService.pause();
|
||||
}, []);
|
||||
|
||||
const seek = useCallback(async (time: number): Promise<void> => {
|
||||
await castService.seek(time);
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async (): Promise<void> => {
|
||||
await castService.stop();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isAvailable: state !== 'unavailable',
|
||||
isConnected,
|
||||
state,
|
||||
deviceName,
|
||||
playerState,
|
||||
currentTime,
|
||||
duration,
|
||||
requestSession,
|
||||
endSession,
|
||||
castMedia,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
173
src/hooks/useRecommendations.ts
Normal file
173
src/hooks/useRecommendations.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
getPersonalizedRecommendations,
|
||||
getBecauseYouWatched,
|
||||
} from '../services/recommendations/recommendationService';
|
||||
import { useHistoryStore } from '../stores/historyStore';
|
||||
import type { Movie } from '../types';
|
||||
|
||||
interface UseRecommendationsResult {
|
||||
movies: Movie[];
|
||||
basedOnGenres: string[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Hook for personalized recommendations based on watch history
|
||||
export function usePersonalizedRecommendations(limit = 20): UseRecommendationsResult {
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const [basedOnGenres, setBasedOnGenres] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { getProfileItems } = useHistoryStore();
|
||||
const historyItems = getProfileItems();
|
||||
|
||||
// Memoize history data to prevent unnecessary re-fetches
|
||||
const historyHash = useMemo(
|
||||
() => historyItems.map((item) => item.movieId).join(','),
|
||||
[historyItems]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (historyItems.length === 0) {
|
||||
setMovies([]);
|
||||
setBasedOnGenres([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const historyMovies = historyItems.map((item) => item.movie);
|
||||
const excludeIds = new Set(historyItems.map((item) => item.movieId));
|
||||
|
||||
const result = await getPersonalizedRecommendations(
|
||||
historyMovies,
|
||||
excludeIds,
|
||||
limit
|
||||
);
|
||||
|
||||
setMovies(result.movies);
|
||||
setBasedOnGenres(result.basedOnGenres);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to get recommendations');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecommendations();
|
||||
}, [historyHash, limit]);
|
||||
|
||||
return { movies, basedOnGenres, isLoading, error };
|
||||
}
|
||||
|
||||
interface BecauseYouWatchedResult {
|
||||
sourceMovie: Movie | null;
|
||||
recommendations: Movie[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Hook for "Because You Watched [Movie]" recommendations
|
||||
export function useBecauseYouWatched(movieId: number | undefined): BecauseYouWatchedResult {
|
||||
const [sourceMovie, setSourceMovie] = useState<Movie | null>(null);
|
||||
const [recommendations, setRecommendations] = useState<Movie[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { getProfileItems } = useHistoryStore();
|
||||
const historyItems = getProfileItems();
|
||||
|
||||
useEffect(() => {
|
||||
if (!movieId) {
|
||||
setSourceMovie(null);
|
||||
setRecommendations([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const historyItem = historyItems.find((item) => item.movieId === movieId);
|
||||
if (!historyItem) {
|
||||
setSourceMovie(null);
|
||||
setRecommendations([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const excludeIds = new Set(historyItems.map((item) => item.movieId));
|
||||
const recs = await getBecauseYouWatched(historyItem.movie, excludeIds, 10);
|
||||
|
||||
setSourceMovie(historyItem.movie);
|
||||
setRecommendations(recs);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to get recommendations');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecommendations();
|
||||
}, [movieId, historyItems]);
|
||||
|
||||
return { sourceMovie, recommendations, isLoading, error };
|
||||
}
|
||||
|
||||
// Hook for multiple "Because You Watched" rows
|
||||
export function useMultipleBecauseYouWatched(maxRows = 3): {
|
||||
rows: Array<{ sourceMovie: Movie; recommendations: Movie[] }>;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const [rows, setRows] = useState<Array<{ sourceMovie: Movie; recommendations: Movie[] }>>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { getProfileItems } = useHistoryStore();
|
||||
const historyItems = getProfileItems();
|
||||
|
||||
useEffect(() => {
|
||||
if (historyItems.length === 0) {
|
||||
setRows([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchAllRecommendations = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const excludeIds = new Set(historyItems.map((item) => item.movieId));
|
||||
const recentMovies = historyItems.slice(0, maxRows);
|
||||
|
||||
const rowPromises = recentMovies.map(async (item) => {
|
||||
const recs = await getBecauseYouWatched(item.movie, excludeIds, 10);
|
||||
return {
|
||||
sourceMovie: item.movie,
|
||||
recommendations: recs,
|
||||
};
|
||||
});
|
||||
|
||||
const results = await Promise.all(rowPromises);
|
||||
// Filter out rows with no recommendations
|
||||
setRows(results.filter((row) => row.recommendations.length > 0));
|
||||
} catch (err) {
|
||||
console.error('Error fetching recommendations:', err);
|
||||
setRows([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAllRecommendations();
|
||||
}, [historyItems.length, maxRows]);
|
||||
|
||||
return { rows, isLoading };
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import MovieRow from '../components/movie/MovieRow';
|
||||
import TVRow from '../components/tv/TVRow';
|
||||
import { HeroSkeleton } from '../components/ui/Skeleton';
|
||||
import { useTrending, useLatest, useTopRated, useByGenre } from '../hooks/useMovies';
|
||||
import { usePersonalizedRecommendations, useMultipleBecauseYouWatched } from '../hooks/useRecommendations';
|
||||
import { tvDiscoveryApi, type DiscoveredShow } from '../services/api/tvDiscovery';
|
||||
import { useHistoryStore } from '../stores/historyStore';
|
||||
import { HERO_MOVIES_COUNT } from '../constants';
|
||||
|
||||
export default function Home() {
|
||||
@@ -18,6 +20,35 @@ export default function Home() {
|
||||
const { movies: scifi, isLoading: scifiLoading } = useByGenre('sci-fi');
|
||||
const { movies: drama, isLoading: dramaLoading } = useByGenre('drama');
|
||||
|
||||
// Watch history for personalized sections
|
||||
const { getContinueWatching, getProfileItems } = useHistoryStore();
|
||||
|
||||
// Continue Watching - profile-aware, movies with 5-90% progress
|
||||
const continueWatchingItems = getContinueWatching();
|
||||
const continueWatching = useMemo(
|
||||
() => continueWatchingItems.map((item) => item.movie),
|
||||
[continueWatchingItems]
|
||||
);
|
||||
|
||||
// Recently Watched - completed movies for the current profile
|
||||
const profileItems = getProfileItems();
|
||||
const recentlyWatched = useMemo(
|
||||
() =>
|
||||
profileItems
|
||||
.filter((item) => item.completed)
|
||||
.slice(0, 10)
|
||||
.map((item) => item.movie),
|
||||
[profileItems]
|
||||
);
|
||||
|
||||
// Personalized recommendations based on watch history genres
|
||||
const { movies: recommendations, basedOnGenres, isLoading: recommendationsLoading } =
|
||||
usePersonalizedRecommendations(20);
|
||||
|
||||
// "Because You Watched" rows
|
||||
const { rows: becauseYouWatchedRows, isLoading: becauseYouWatchedLoading } =
|
||||
useMultipleBecauseYouWatched(2);
|
||||
|
||||
// Memoize hero movies to prevent unnecessary re-renders
|
||||
const heroMovies = useMemo(() => trending.slice(0, HERO_MOVIES_COUNT), [trending]);
|
||||
|
||||
@@ -73,6 +104,43 @@ export default function Home() {
|
||||
|
||||
{/* Movie Rows */}
|
||||
<div className="-mt-32 relative z-10">
|
||||
{/* Personalized Sections - only show when user has watch history */}
|
||||
{continueWatching.length > 0 && (
|
||||
<MovieRow
|
||||
title="Continue Watching"
|
||||
movies={continueWatching}
|
||||
linkTo="/history"
|
||||
/>
|
||||
)}
|
||||
|
||||
{recentlyWatched.length > 0 && (
|
||||
<MovieRow
|
||||
title="Recently Watched"
|
||||
movies={recentlyWatched}
|
||||
linkTo="/history"
|
||||
/>
|
||||
)}
|
||||
|
||||
{recommendations && recommendations.length > 0 && (
|
||||
<MovieRow
|
||||
title={basedOnGenres.length > 0
|
||||
? `Recommended: ${basedOnGenres.slice(0, 2).join(' & ')}`
|
||||
: 'Recommended For You'}
|
||||
movies={recommendations}
|
||||
isLoading={recommendationsLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Because You Watched rows */}
|
||||
{becauseYouWatchedRows.map((row) => (
|
||||
<MovieRow
|
||||
key={row.sourceMovie.id}
|
||||
title={`Because You Watched "${row.sourceMovie.title}"`}
|
||||
movies={row.recommendations}
|
||||
isLoading={becauseYouWatchedLoading}
|
||||
/>
|
||||
))}
|
||||
|
||||
<MovieRow
|
||||
title="Trending Now"
|
||||
movies={trending}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { Loader2, AlertCircle, Server, RefreshCw } from 'lucide-react';
|
||||
import StreamingPlayer from '../components/player/StreamingPlayer';
|
||||
import { useMovieDetails } from '../hooks/useMovies';
|
||||
import { useHistoryStore } from '../stores/historyStore';
|
||||
import { useSettingsStore } from '../stores/settingsStore';
|
||||
import streamingService, { type StreamSession } from '../services/streaming/streamingService';
|
||||
import { getApiUrl } from '../utils/platform';
|
||||
import { getNetworkInfo, selectOptimalQuality } from '../utils/network';
|
||||
import type { Torrent } from '../types';
|
||||
import Button from '../components/ui/Button';
|
||||
import serverManager from '../services/server/serverManager';
|
||||
@@ -22,6 +24,7 @@ export default function Player() {
|
||||
|
||||
const { movie, isLoading: movieLoading, error: movieError } = useMovieDetails(movieId);
|
||||
const { addToHistory, updateProgress, getProgress } = useHistoryStore();
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
const [_sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [streamSession, setStreamSession] = useState<StreamSession | null>(null);
|
||||
@@ -35,6 +38,9 @@ export default function Player() {
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
// Ref to track polling timeout for cleanup
|
||||
const pollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Ref to track autoDeleteStreams setting for cleanup
|
||||
const autoDeleteRef = useRef(settings.autoDeleteStreams);
|
||||
autoDeleteRef.current = settings.autoDeleteStreams;
|
||||
|
||||
// Get initial playback position
|
||||
const historyItem = movie ? getProgress(movie.id) : undefined;
|
||||
@@ -72,18 +78,32 @@ export default function Player() {
|
||||
checkAndStartServer();
|
||||
}, []);
|
||||
|
||||
// Select the best torrent based on preference
|
||||
// Select the best torrent based on preference and network conditions
|
||||
useEffect(() => {
|
||||
if (movie?.torrents?.length && status === 'connecting') {
|
||||
const torrents = movie.torrents;
|
||||
|
||||
// Find preferred quality or best available
|
||||
let torrent = preferredQuality
|
||||
? torrents.find((t) => t.quality === preferredQuality)
|
||||
const availableQualities = torrents.map((t) => t.quality);
|
||||
|
||||
let selectedQuality: string | null = null;
|
||||
|
||||
// If preferred quality is specified in URL, use it
|
||||
if (preferredQuality) {
|
||||
selectedQuality = preferredQuality;
|
||||
}
|
||||
// If auto-quality is enabled, select based on network conditions
|
||||
else if (settings.autoQuality) {
|
||||
const networkInfo = getNetworkInfo();
|
||||
selectedQuality = selectOptimalQuality(availableQualities, networkInfo);
|
||||
logger.info(`[Auto-Quality] Network: ${networkInfo.quality}, Bandwidth: ${networkInfo.downlink || 'unknown'}Mbps, Selected: ${selectedQuality}`);
|
||||
}
|
||||
|
||||
// Find torrent matching selected quality
|
||||
let torrent = selectedQuality
|
||||
? torrents.find((t) => t.quality === selectedQuality)
|
||||
: null;
|
||||
|
||||
if (!torrent) {
|
||||
// Prefer 1080p, then 720p, then highest seeds
|
||||
// Fallback: Prefer 1080p, then 720p, then highest seeds
|
||||
torrent =
|
||||
torrents.find((t) => t.quality === '1080p') ||
|
||||
torrents.find((t) => t.quality === '720p') ||
|
||||
@@ -92,7 +112,7 @@ export default function Player() {
|
||||
|
||||
setSelectedTorrent(torrent);
|
||||
}
|
||||
}, [movie, preferredQuality, status]);
|
||||
}, [movie, preferredQuality, status, settings.autoQuality]);
|
||||
|
||||
// Start streaming when torrent is selected
|
||||
useEffect(() => {
|
||||
@@ -247,7 +267,8 @@ export default function Player() {
|
||||
}
|
||||
// Use ref to get current sessionId (fixes stale closure issue)
|
||||
if (sessionIdRef.current) {
|
||||
streamingService.stopStream(sessionIdRef.current).catch((err) => {
|
||||
// Auto-delete files if setting is enabled
|
||||
streamingService.stopStream(sessionIdRef.current, autoDeleteRef.current).catch((err) => {
|
||||
logger.error('Error stopping stream', err);
|
||||
});
|
||||
streamingService.disconnect();
|
||||
|
||||
175
src/pages/ProfileManager.tsx
Normal file
175
src/pages/ProfileManager.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, Trash2 } from 'lucide-react';
|
||||
import { useProfileStore } from '../stores/profileStore';
|
||||
import { Avatar, AvatarSelector } from '../components/profile/Avatar';
|
||||
import Button from '../components/ui/Button';
|
||||
import type { Profile, AvatarId } from '../types';
|
||||
|
||||
interface ProfileManagerProps {
|
||||
editingProfile?: Profile | null;
|
||||
onBack: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export default function ProfileManager({ editingProfile, onBack, onSave }: ProfileManagerProps) {
|
||||
const { createProfile, updateProfile, deleteProfile, profiles } = useProfileStore();
|
||||
|
||||
const [name, setName] = useState(editingProfile?.name || '');
|
||||
const [avatar, setAvatar] = useState<AvatarId>(editingProfile?.avatar as AvatarId || 'avatar-1');
|
||||
const [error, setError] = useState('');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const isEditing = !!editingProfile;
|
||||
const canDelete = isEditing && profiles.length > 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (editingProfile) {
|
||||
setName(editingProfile.name);
|
||||
setAvatar(editingProfile.avatar as AvatarId);
|
||||
}
|
||||
}, [editingProfile]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setError('Please enter a name');
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedName.length > 20) {
|
||||
setError('Name must be 20 characters or less');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEditing && editingProfile) {
|
||||
updateProfile(editingProfile.id, { name: trimmedName, avatar });
|
||||
} else {
|
||||
const result = createProfile(trimmedName, avatar);
|
||||
if (!result) {
|
||||
setError('Maximum profiles reached');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onSave();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (editingProfile && canDelete) {
|
||||
deleteProfile(editingProfile.id);
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-netflix-black flex flex-col items-center p-4 pt-12">
|
||||
{/* Header */}
|
||||
<div className="w-full max-w-md mb-8">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-2xl md:text-3xl font-medium text-white mb-8"
|
||||
>
|
||||
{isEditing ? 'Edit Profile' : 'Add Profile'}
|
||||
</motion.h1>
|
||||
|
||||
{/* Form */}
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-md space-y-8"
|
||||
>
|
||||
{/* Current Avatar Preview */}
|
||||
<div className="flex justify-center">
|
||||
<Avatar avatarId={avatar} size={120} />
|
||||
</div>
|
||||
|
||||
{/* Name Input */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter profile name"
|
||||
maxLength={20}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-md px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-netflix-red transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Avatar Selector */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-3">Choose Avatar</label>
|
||||
<AvatarSelector selected={avatar} onSelect={setAvatar} size={56} />
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-4 pt-4">
|
||||
<Button type="submit" className="flex-1">
|
||||
{isEditing ? 'Save' : 'Create Profile'}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={onBack} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
{canDelete && (
|
||||
<div className="pt-4 border-t border-gray-800">
|
||||
{showDeleteConfirm ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-center text-gray-400 text-sm">
|
||||
Delete this profile? All watchlist and history data will be lost.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
className="flex-1 text-red-500 hover:bg-red-500/10"
|
||||
leftIcon={<Trash2 size={18} />}
|
||||
>
|
||||
Yes, Delete
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="w-full text-center text-gray-500 hover:text-red-500 transition-colors py-2"
|
||||
>
|
||||
Delete Profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
src/pages/ProfileSelector.tsx
Normal file
117
src/pages/ProfileSelector.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Plus, Pencil } from 'lucide-react';
|
||||
import { useProfileStore } from '../stores/profileStore';
|
||||
import { Avatar } from '../components/profile/Avatar';
|
||||
import type { Profile } from '../types';
|
||||
|
||||
interface ProfileSelectorProps {
|
||||
onSelectProfile: (profile: Profile) => void;
|
||||
onManageProfiles: () => void;
|
||||
}
|
||||
|
||||
export default function ProfileSelector({ onSelectProfile, onManageProfiles }: ProfileSelectorProps) {
|
||||
const { profiles, canCreateProfile } = useProfileStore();
|
||||
const [isManaging, setIsManaging] = useState(false);
|
||||
|
||||
const handleProfileClick = (profile: Profile) => {
|
||||
if (isManaging) {
|
||||
onManageProfiles();
|
||||
} else {
|
||||
onSelectProfile(profile);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-netflix-black flex flex-col items-center justify-center p-4">
|
||||
{/* Logo */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<img src="/icon.svg" alt="beStream" className="h-12 w-12" />
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="text-3xl md:text-4xl font-medium text-white mb-8"
|
||||
>
|
||||
Who's watching?
|
||||
</motion.h1>
|
||||
|
||||
{/* Profiles Grid */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="flex flex-wrap justify-center gap-4 md:gap-6 max-w-2xl"
|
||||
>
|
||||
{profiles.map((profile, index) => (
|
||||
<motion.button
|
||||
key={profile.id}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.1 * index }}
|
||||
onClick={() => handleProfileClick(profile)}
|
||||
className="group flex flex-col items-center"
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
avatarId={profile.avatar as any}
|
||||
size={100}
|
||||
className="md:w-32 md:h-32 transition-transform group-hover:scale-105 group-hover:ring-2 ring-white"
|
||||
/>
|
||||
{isManaging && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-md">
|
||||
<Pencil size={32} className="text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="mt-3 text-gray-400 group-hover:text-white transition-colors text-sm md:text-base">
|
||||
{profile.name}
|
||||
</span>
|
||||
</motion.button>
|
||||
))}
|
||||
|
||||
{/* Add Profile Button */}
|
||||
{canCreateProfile() && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.1 * profiles.length }}
|
||||
onClick={onManageProfiles}
|
||||
className="group flex flex-col items-center"
|
||||
>
|
||||
<div className="w-[100px] h-[100px] md:w-32 md:h-32 rounded-md bg-gray-800 flex items-center justify-center transition-all group-hover:bg-gray-700 group-hover:scale-105">
|
||||
<Plus size={48} className="text-gray-400 group-hover:text-white" />
|
||||
</div>
|
||||
<span className="mt-3 text-gray-400 group-hover:text-white transition-colors text-sm md:text-base">
|
||||
Add Profile
|
||||
</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Manage Profiles Button */}
|
||||
{profiles.length > 0 && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
onClick={() => setIsManaging(!isManaging)}
|
||||
className={`mt-12 px-6 py-2 border rounded transition-colors ${
|
||||
isManaging
|
||||
? 'border-white bg-white text-black'
|
||||
: 'border-gray-500 text-gray-400 hover:border-white hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{isManaging ? 'Done' : 'Manage Profiles'}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -121,6 +121,48 @@ export default function Settings() {
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Auto-delete Streams</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Automatically delete downloaded files when streaming ends
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateSettings({ autoDeleteStreams: !settings.autoDeleteStreams })}
|
||||
className={`w-12 h-6 rounded-full transition-colors ${
|
||||
settings.autoDeleteStreams ? 'bg-netflix-red' : 'bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full transition-transform ${
|
||||
settings.autoDeleteStreams ? 'translate-x-6' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Auto-Quality</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Automatically select video quality based on your network speed
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateSettings({ autoQuality: !settings.autoQuality })}
|
||||
className={`w-12 h-6 rounded-full transition-colors ${
|
||||
settings.autoQuality ? 'bg-netflix-red' : 'bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full transition-transform ${
|
||||
settings.autoQuality ? 'translate-x-6' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
@@ -153,6 +195,27 @@ export default function Settings() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Smart Downloads</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Automatically start downloading the next episode when watching TV shows
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateSettings({ autoDownloadNextEpisode: !settings.autoDownloadNextEpisode })}
|
||||
className={`w-12 h-6 rounded-full transition-colors ${
|
||||
settings.autoDownloadNextEpisode ? 'bg-netflix-red' : 'bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full transition-transform ${
|
||||
settings.autoDownloadNextEpisode ? 'translate-x-6' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-sm text-gray-400">
|
||||
Download location is managed by your operating system's default
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Loader2, AlertCircle, Server, RefreshCw } from 'lucide-react';
|
||||
import StreamingPlayer from '../components/player/StreamingPlayer';
|
||||
import NextEpisodeOverlay from '../components/player/NextEpisodeOverlay';
|
||||
import streamingService, { type StreamSession } from '../services/streaming/streamingService';
|
||||
import { useSettingsStore } from '../stores/settingsStore';
|
||||
import { useHistoryStore } from '../stores/historyStore';
|
||||
import { getApiUrl } from '../utils/platform';
|
||||
import Button from '../components/ui/Button';
|
||||
import type { Movie } from '../types';
|
||||
@@ -68,6 +70,17 @@ export default function TVPlayer() {
|
||||
const [hlsUrl, setHlsUrl] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<'checking' | 'connecting' | 'buffering' | 'ready' | 'error'>('checking');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Settings
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
// History for progress tracking
|
||||
const { addToHistory, updateProgress } = useHistoryStore();
|
||||
|
||||
// Refs for cleanup (to avoid stale closures)
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const autoDeleteRef = useRef(settings.autoDeleteStreams);
|
||||
autoDeleteRef.current = settings.autoDeleteStreams;
|
||||
|
||||
// Next episode state
|
||||
const [showNextEpisode, setShowNextEpisode] = useState(false);
|
||||
@@ -137,6 +150,7 @@ export default function TVPlayer() {
|
||||
}
|
||||
|
||||
setSessionId(result.sessionId);
|
||||
sessionIdRef.current = result.sessionId;
|
||||
|
||||
// Get initial status immediately
|
||||
const initialStatus = await streamingService.getStatus(result.sessionId);
|
||||
@@ -179,6 +193,39 @@ export default function TVPlayer() {
|
||||
setStreamUrl(streamingService.getVideoUrl(result.sessionId));
|
||||
setStatus('ready');
|
||||
|
||||
// Add to history for progress tracking (use showId with negative to distinguish from movies)
|
||||
// Create a unique ID combining show ID, season, and episode
|
||||
const episodeId = parseInt(showId || '0') * 10000 + season * 100 + episode;
|
||||
const historyEntry = {
|
||||
id: episodeId,
|
||||
url: '',
|
||||
imdb_code: '',
|
||||
title: `${showTitle} - S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`,
|
||||
title_english: episodeTitle || `Season ${season}, Episode ${episode}`,
|
||||
title_long: `${showTitle} - ${episodeTitle || `Season ${season}, Episode ${episode}`}`,
|
||||
slug: '',
|
||||
year: new Date().getFullYear(),
|
||||
rating: 0,
|
||||
runtime: 0,
|
||||
genres: [],
|
||||
summary: '',
|
||||
description_full: '',
|
||||
synopsis: '',
|
||||
yt_trailer_code: '',
|
||||
language: 'en',
|
||||
mpa_rating: '',
|
||||
background_image: backdrop ? decodeURIComponent(backdrop) : '',
|
||||
background_image_original: backdrop ? decodeURIComponent(backdrop) : '',
|
||||
small_cover_image: poster ? decodeURIComponent(poster) : '',
|
||||
medium_cover_image: poster ? decodeURIComponent(poster) : '',
|
||||
large_cover_image: poster ? decodeURIComponent(poster) : '',
|
||||
state: '',
|
||||
torrents: [],
|
||||
date_uploaded: new Date().toISOString(),
|
||||
date_uploaded_unix: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
addToHistory(historyEntry);
|
||||
|
||||
// Try to start HLS transcoding for better compatibility
|
||||
// Wait longer for video file to be fully available on disk
|
||||
const attemptHlsStart = async (attempt = 1, maxAttempts = 5) => {
|
||||
@@ -234,12 +281,14 @@ export default function TVPlayer() {
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (sessionId) {
|
||||
streamingService.stopStream(sessionId).catch(console.error);
|
||||
// Use ref to get current sessionId (fixes stale closure issue)
|
||||
if (sessionIdRef.current) {
|
||||
// Auto-delete files if setting is enabled
|
||||
streamingService.stopStream(sessionIdRef.current, autoDeleteRef.current).catch(console.error);
|
||||
streamingService.disconnect();
|
||||
}
|
||||
};
|
||||
}, [status, torrentHash, showTitle, season, episode, episodeTitle, quality]);
|
||||
}, [status, torrentHash, showTitle, season, episode, episodeTitle, quality, showId, poster, backdrop, addToHistory]);
|
||||
|
||||
// Get next episode info
|
||||
const getNextEpisodeInfo = useCallback(async () => {
|
||||
@@ -394,6 +443,12 @@ export default function TVPlayer() {
|
||||
(currentTime: number, duration: number) => {
|
||||
videoCurrentTimeRef.current = currentTime;
|
||||
videoDurationRef.current = duration;
|
||||
|
||||
// Save progress to history store
|
||||
if (currentTime > 0 && duration > 0) {
|
||||
const episodeId = parseInt(showId || '0') * 10000 + season * 100 + episode;
|
||||
updateProgress(episodeId, currentTime, duration);
|
||||
}
|
||||
|
||||
// Check if we're in the last 30 seconds
|
||||
const timeRemaining = duration - currentTime;
|
||||
@@ -401,11 +456,14 @@ export default function TVPlayer() {
|
||||
const SHOW_OVERLAY_THRESHOLD = 10; // Show overlay 10 seconds before end
|
||||
|
||||
if (timeRemaining <= PRELOAD_THRESHOLD && !nextEpisodeInfo && status === 'ready') {
|
||||
// Start loading next episode info and pre-loading
|
||||
// Start loading next episode info
|
||||
getNextEpisodeInfo().then((info) => {
|
||||
if (info) {
|
||||
setNextEpisodeInfo(info);
|
||||
preloadNextEpisode(info);
|
||||
// Only pre-load if smart downloads is enabled
|
||||
if (settings.autoDownloadNextEpisode) {
|
||||
preloadNextEpisode(info);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -440,7 +498,7 @@ export default function TVPlayer() {
|
||||
handlePlayNext();
|
||||
}
|
||||
},
|
||||
[nextEpisodeInfo, status, showNextEpisode, countdown, getNextEpisodeInfo, preloadNextEpisode, handlePlayNext]
|
||||
[nextEpisodeInfo, status, showNextEpisode, countdown, getNextEpisodeInfo, preloadNextEpisode, handlePlayNext, showId, season, episode, updateProgress, settings.autoDownloadNextEpisode]
|
||||
);
|
||||
|
||||
// Handle cancel next episode
|
||||
@@ -477,6 +535,7 @@ export default function TVPlayer() {
|
||||
setError(null);
|
||||
setStatus('checking');
|
||||
setSessionId(null);
|
||||
sessionIdRef.current = null;
|
||||
setStreamSession(null);
|
||||
setStreamUrl(null);
|
||||
setHlsUrl(null);
|
||||
|
||||
101
src/services/cast/capacitorCast.ts
Normal file
101
src/services/cast/capacitorCast.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Capacitor plugin interface for Google Cast on Android
|
||||
// This bridges to a native Android plugin implementation
|
||||
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
export interface LoadMediaOptions {
|
||||
url: string;
|
||||
contentType: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
startTime?: number;
|
||||
}
|
||||
|
||||
export interface SeekOptions {
|
||||
time: number;
|
||||
}
|
||||
|
||||
export interface VolumeOptions {
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface CastDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export interface CastStateChangeEvent {
|
||||
state: 'unavailable' | 'available' | 'connecting' | 'connected';
|
||||
}
|
||||
|
||||
export interface DeviceAvailableEvent {
|
||||
devices: CastDevice[];
|
||||
}
|
||||
|
||||
export interface SessionStartedEvent {
|
||||
device: CastDevice;
|
||||
}
|
||||
|
||||
export interface MediaStatusEvent {
|
||||
playerState: 'idle' | 'playing' | 'paused' | 'buffering';
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface CapacitorGoogleCastPlugin {
|
||||
// Initialization
|
||||
initialize(): Promise<void>;
|
||||
|
||||
// Session management
|
||||
showCastDialog(): Promise<void>;
|
||||
endSession(): Promise<void>;
|
||||
|
||||
// Media control
|
||||
loadMedia(options: LoadMediaOptions): Promise<void>;
|
||||
play(): Promise<void>;
|
||||
pause(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
seek(options: SeekOptions): Promise<void>;
|
||||
setVolume(options: VolumeOptions): Promise<void>;
|
||||
|
||||
// Event listeners
|
||||
addListener(
|
||||
eventName: 'castStateChanged',
|
||||
listenerFunc: (event: CastStateChangeEvent) => void
|
||||
): Promise<{ remove: () => void }>;
|
||||
|
||||
addListener(
|
||||
eventName: 'deviceAvailable',
|
||||
listenerFunc: (event: DeviceAvailableEvent) => void
|
||||
): Promise<{ remove: () => void }>;
|
||||
|
||||
addListener(
|
||||
eventName: 'sessionStarted',
|
||||
listenerFunc: (event: SessionStartedEvent) => void
|
||||
): Promise<{ remove: () => void }>;
|
||||
|
||||
addListener(
|
||||
eventName: 'sessionEnded',
|
||||
listenerFunc: () => void
|
||||
): Promise<{ remove: () => void }>;
|
||||
|
||||
addListener(
|
||||
eventName: 'mediaStatusUpdated',
|
||||
listenerFunc: (event: MediaStatusEvent) => void
|
||||
): Promise<{ remove: () => void }>;
|
||||
|
||||
removeAllListeners(): Promise<void>;
|
||||
}
|
||||
|
||||
// Register the plugin
|
||||
// This will be available when the native Android plugin is implemented
|
||||
export const CapacitorGoogleCast = registerPlugin<CapacitorGoogleCastPlugin>(
|
||||
'CapacitorGoogleCast',
|
||||
{
|
||||
// Web implementation fallback (no-op for web since we use the Cast SDK directly)
|
||||
web: () =>
|
||||
import('./capacitorCastWeb').then((m) => new m.CapacitorGoogleCastWeb()),
|
||||
}
|
||||
);
|
||||
45
src/services/cast/capacitorCastWeb.ts
Normal file
45
src/services/cast/capacitorCastWeb.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Web implementation fallback for the Capacitor Google Cast plugin
|
||||
// On web, we use the Cast SDK directly through castService, so this is mostly a no-op
|
||||
|
||||
import { WebPlugin } from '@capacitor/core';
|
||||
import type { CapacitorGoogleCastPlugin, LoadMediaOptions, SeekOptions, VolumeOptions } from './capacitorCast';
|
||||
|
||||
export class CapacitorGoogleCastWeb extends WebPlugin implements CapacitorGoogleCastPlugin {
|
||||
async initialize(): Promise<void> {
|
||||
// No-op on web - Cast SDK is initialized in castService
|
||||
console.log('[CapacitorGoogleCast Web] No-op initialize');
|
||||
}
|
||||
|
||||
async showCastDialog(): Promise<void> {
|
||||
// No-op - handled by castService on web
|
||||
console.log('[CapacitorGoogleCast Web] No-op showCastDialog');
|
||||
}
|
||||
|
||||
async endSession(): Promise<void> {
|
||||
console.log('[CapacitorGoogleCast Web] No-op endSession');
|
||||
}
|
||||
|
||||
async loadMedia(_options: LoadMediaOptions): Promise<void> {
|
||||
console.log('[CapacitorGoogleCast Web] No-op loadMedia');
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
console.log('[CapacitorGoogleCast Web] No-op play');
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
console.log('[CapacitorGoogleCast Web] No-op pause');
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
console.log('[CapacitorGoogleCast Web] No-op stop');
|
||||
}
|
||||
|
||||
async seek(_options: SeekOptions): Promise<void> {
|
||||
console.log('[CapacitorGoogleCast Web] No-op seek');
|
||||
}
|
||||
|
||||
async setVolume(_options: VolumeOptions): Promise<void> {
|
||||
console.log('[CapacitorGoogleCast Web] No-op setVolume');
|
||||
}
|
||||
}
|
||||
458
src/services/cast/castService.ts
Normal file
458
src/services/cast/castService.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
// Google Cast Service for casting to Chromecast devices
|
||||
// Uses Default Media Receiver for standard playback
|
||||
|
||||
export type CastState = 'unavailable' | 'available' | 'connecting' | 'connected';
|
||||
|
||||
interface CastDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
interface MediaInfo {
|
||||
contentId: string; // Stream URL
|
||||
contentType: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
duration?: number;
|
||||
currentTime?: number;
|
||||
}
|
||||
|
||||
interface CastSession {
|
||||
device: CastDevice;
|
||||
media: MediaInfo | null;
|
||||
playerState: 'idle' | 'playing' | 'paused' | 'buffering';
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
}
|
||||
|
||||
type CastStateListener = (state: CastState, session: CastSession | null) => void;
|
||||
type CastMediaListener = (playerState: string, currentTime: number, duration: number) => void;
|
||||
|
||||
class CastService {
|
||||
private state: CastState = 'unavailable';
|
||||
private session: CastSession | null = null;
|
||||
private stateListeners: Set<CastStateListener> = new Set();
|
||||
private mediaListeners: Set<CastMediaListener> = new Set();
|
||||
private castContext: any = null;
|
||||
private playerController: any = null;
|
||||
private initialized = false;
|
||||
|
||||
constructor() {
|
||||
// Check if we're on Android with Capacitor
|
||||
if (this.isCapacitorAndroid()) {
|
||||
this.initializeAndroid();
|
||||
} else if (typeof window !== 'undefined' && 'chrome' in window) {
|
||||
// Web Chrome browser - use Google Cast SDK
|
||||
this.initializeWeb();
|
||||
}
|
||||
}
|
||||
|
||||
private isCapacitorAndroid(): boolean {
|
||||
return typeof (window as any).Capacitor !== 'undefined' &&
|
||||
(window as any).Capacitor?.getPlatform() === 'android';
|
||||
}
|
||||
|
||||
private async initializeAndroid(): Promise<void> {
|
||||
// For Android, we use a Capacitor plugin bridge
|
||||
// This will be implemented via a custom Capacitor plugin
|
||||
try {
|
||||
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||
if (CapacitorGoogleCast) {
|
||||
await CapacitorGoogleCast.initialize();
|
||||
|
||||
// Listen for cast state changes
|
||||
CapacitorGoogleCast.addListener('castStateChanged', (data: { state: CastState }) => {
|
||||
this.state = data.state;
|
||||
this.notifyStateListeners();
|
||||
});
|
||||
|
||||
// Listen for device discovery
|
||||
CapacitorGoogleCast.addListener('deviceAvailable', (data: { devices: CastDevice[] }) => {
|
||||
if (data.devices.length > 0) {
|
||||
this.state = 'available';
|
||||
this.notifyStateListeners();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for connection events
|
||||
CapacitorGoogleCast.addListener('sessionStarted', (data: { device: CastDevice }) => {
|
||||
this.state = 'connected';
|
||||
this.session = {
|
||||
device: data.device,
|
||||
media: null,
|
||||
playerState: 'idle',
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 1,
|
||||
isMuted: false,
|
||||
};
|
||||
this.notifyStateListeners();
|
||||
});
|
||||
|
||||
CapacitorGoogleCast.addListener('sessionEnded', () => {
|
||||
this.state = 'available';
|
||||
this.session = null;
|
||||
this.notifyStateListeners();
|
||||
});
|
||||
|
||||
// Listen for media status updates
|
||||
CapacitorGoogleCast.addListener('mediaStatusUpdated', (data: any) => {
|
||||
if (this.session) {
|
||||
this.session.playerState = data.playerState;
|
||||
this.session.currentTime = data.currentTime;
|
||||
this.session.duration = data.duration;
|
||||
}
|
||||
this.notifyMediaListeners(data.playerState, data.currentTime, data.duration);
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[Cast] Android Cast service initialized');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[Cast] Android Cast plugin not available:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeWeb(): void {
|
||||
// For web, use the Cast Framework SDK
|
||||
// Check if the Cast SDK is loaded
|
||||
const checkCastApi = () => {
|
||||
if ((window as any).cast && (window as any).chrome?.cast) {
|
||||
this.setupCastFramework();
|
||||
} else {
|
||||
// Load the Cast SDK
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
|
||||
script.onload = () => this.setupCastFramework();
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for DOM ready
|
||||
if (document.readyState === 'complete') {
|
||||
checkCastApi();
|
||||
} else {
|
||||
window.addEventListener('load', checkCastApi);
|
||||
}
|
||||
}
|
||||
|
||||
private setupCastFramework(): void {
|
||||
try {
|
||||
const cast = (window as any).cast;
|
||||
const chrome = (window as any).chrome;
|
||||
|
||||
if (!cast?.framework || !chrome?.cast) {
|
||||
console.log('[Cast] Cast framework not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize the Cast API
|
||||
cast.framework.CastContext.getInstance().setOptions({
|
||||
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||
});
|
||||
|
||||
this.castContext = cast.framework.CastContext.getInstance();
|
||||
this.playerController = new cast.framework.RemotePlayerController(
|
||||
new cast.framework.RemotePlayer()
|
||||
);
|
||||
|
||||
// Listen for cast state changes
|
||||
this.castContext.addEventListener(
|
||||
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
|
||||
(event: any) => {
|
||||
switch (event.castState) {
|
||||
case 'NO_DEVICES_AVAILABLE':
|
||||
this.state = 'unavailable';
|
||||
break;
|
||||
case 'NOT_CONNECTED':
|
||||
this.state = 'available';
|
||||
break;
|
||||
case 'CONNECTING':
|
||||
this.state = 'connecting';
|
||||
break;
|
||||
case 'CONNECTED':
|
||||
this.state = 'connected';
|
||||
break;
|
||||
}
|
||||
this.notifyStateListeners();
|
||||
}
|
||||
);
|
||||
|
||||
// Listen for session state changes
|
||||
this.castContext.addEventListener(
|
||||
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
(event: any) => {
|
||||
if (event.sessionState === 'SESSION_STARTED' ||
|
||||
event.sessionState === 'SESSION_RESUMED') {
|
||||
const session = this.castContext.getCurrentSession();
|
||||
if (session) {
|
||||
const device = session.getCastDevice();
|
||||
this.session = {
|
||||
device: {
|
||||
id: device.deviceId,
|
||||
name: device.friendlyName,
|
||||
isConnected: true,
|
||||
},
|
||||
media: null,
|
||||
playerState: 'idle',
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 1,
|
||||
isMuted: false,
|
||||
};
|
||||
}
|
||||
} else if (event.sessionState === 'SESSION_ENDED') {
|
||||
this.session = null;
|
||||
}
|
||||
this.notifyStateListeners();
|
||||
}
|
||||
);
|
||||
|
||||
// Listen for player changes
|
||||
this.playerController.addEventListener(
|
||||
cast.framework.RemotePlayerEventType.ANY_CHANGE,
|
||||
(_event: any) => {
|
||||
if (this.session) {
|
||||
const player = this.playerController.remotePlayer;
|
||||
this.session.currentTime = player.currentTime;
|
||||
this.session.duration = player.duration;
|
||||
this.session.volume = player.volumeLevel;
|
||||
this.session.isMuted = player.isMuted;
|
||||
|
||||
if (player.playerState) {
|
||||
this.session.playerState = player.playerState.toLowerCase();
|
||||
}
|
||||
|
||||
this.notifyMediaListeners(
|
||||
this.session.playerState,
|
||||
this.session.currentTime,
|
||||
this.session.duration
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[Cast] Web Cast service initialized');
|
||||
} catch (error) {
|
||||
console.error('[Cast] Failed to initialize Cast framework:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
get isAvailable(): boolean {
|
||||
return this.state !== 'unavailable';
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.state === 'connected';
|
||||
}
|
||||
|
||||
get currentState(): CastState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
get currentSession(): CastSession | null {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
addStateListener(listener: CastStateListener): () => void {
|
||||
this.stateListeners.add(listener);
|
||||
// Immediately notify with current state
|
||||
listener(this.state, this.session);
|
||||
return () => this.stateListeners.delete(listener);
|
||||
}
|
||||
|
||||
addMediaListener(listener: CastMediaListener): () => void {
|
||||
this.mediaListeners.add(listener);
|
||||
return () => this.mediaListeners.delete(listener);
|
||||
}
|
||||
|
||||
private notifyStateListeners(): void {
|
||||
this.stateListeners.forEach((listener) => {
|
||||
listener(this.state, this.session);
|
||||
});
|
||||
}
|
||||
|
||||
private notifyMediaListeners(playerState: string, currentTime: number, duration: number): void {
|
||||
this.mediaListeners.forEach((listener) => {
|
||||
listener(playerState, currentTime, duration);
|
||||
});
|
||||
}
|
||||
|
||||
async requestSession(): Promise<boolean> {
|
||||
if (!this.initialized) {
|
||||
console.log('[Cast] Service not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isCapacitorAndroid()) {
|
||||
try {
|
||||
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||
await CapacitorGoogleCast.showCastDialog();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Cast] Failed to show cast dialog:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Web implementation
|
||||
if (this.castContext) {
|
||||
try {
|
||||
await this.castContext.requestSession();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('[Cast] Session request failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async endSession(): Promise<void> {
|
||||
if (this.isCapacitorAndroid()) {
|
||||
try {
|
||||
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||
await CapacitorGoogleCast.endSession();
|
||||
} catch (error) {
|
||||
console.error('[Cast] Failed to end session:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.castContext) {
|
||||
const session = this.castContext.getCurrentSession();
|
||||
if (session) {
|
||||
session.endSession(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadMedia(media: MediaInfo): Promise<boolean> {
|
||||
if (!this.isConnected || !this.session) {
|
||||
console.log('[Cast] Not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isCapacitorAndroid()) {
|
||||
try {
|
||||
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||
await CapacitorGoogleCast.loadMedia({
|
||||
url: media.contentId,
|
||||
contentType: media.contentType,
|
||||
title: media.title,
|
||||
subtitle: media.subtitle || '',
|
||||
imageUrl: media.imageUrl || '',
|
||||
startTime: media.currentTime || 0,
|
||||
});
|
||||
this.session.media = media;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Cast] Failed to load media:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Web implementation
|
||||
const chrome = (window as any).chrome;
|
||||
const cast = (window as any).cast;
|
||||
if (!chrome?.cast || !cast?.framework) return false;
|
||||
|
||||
try {
|
||||
const session = this.castContext.getCurrentSession();
|
||||
if (!session) return false;
|
||||
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo(media.contentId, media.contentType);
|
||||
mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata();
|
||||
mediaInfo.metadata.title = media.title;
|
||||
mediaInfo.metadata.subtitle = media.subtitle;
|
||||
if (media.imageUrl) {
|
||||
mediaInfo.metadata.images = [{ url: media.imageUrl }];
|
||||
}
|
||||
|
||||
const request = new chrome.cast.media.LoadRequest(mediaInfo);
|
||||
request.currentTime = media.currentTime || 0;
|
||||
request.autoplay = true;
|
||||
|
||||
await session.loadMedia(request);
|
||||
this.session.media = media;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Cast] Failed to load media:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
if (this.isCapacitorAndroid()) {
|
||||
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||
await CapacitorGoogleCast.play();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playerController) {
|
||||
this.playerController.playOrPause();
|
||||
}
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
if (this.isCapacitorAndroid()) {
|
||||
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||
await CapacitorGoogleCast.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playerController) {
|
||||
this.playerController.playOrPause();
|
||||
}
|
||||
}
|
||||
|
||||
async seek(time: number): Promise<void> {
|
||||
if (this.isCapacitorAndroid()) {
|
||||
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||
await CapacitorGoogleCast.seek({ time });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playerController?.remotePlayer) {
|
||||
this.playerController.remotePlayer.currentTime = time;
|
||||
this.playerController.seek();
|
||||
}
|
||||
}
|
||||
|
||||
async setVolume(level: number): Promise<void> {
|
||||
if (this.isCapacitorAndroid()) {
|
||||
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||
await CapacitorGoogleCast.setVolume({ level });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playerController?.remotePlayer) {
|
||||
this.playerController.remotePlayer.volumeLevel = level;
|
||||
this.playerController.setVolumeLevel();
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.isCapacitorAndroid()) {
|
||||
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||
await CapacitorGoogleCast.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.playerController) {
|
||||
this.playerController.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const castService = new CastService();
|
||||
165
src/services/recommendations/recommendationService.ts
Normal file
165
src/services/recommendations/recommendationService.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { ytsApi } from '../api/yts';
|
||||
import type { Movie } from '../../types';
|
||||
import { useHistoryStore } from '../../stores/historyStore';
|
||||
|
||||
interface GenreScore {
|
||||
genre: string;
|
||||
score: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface RecommendationResult {
|
||||
movies: Movie[];
|
||||
basedOnGenres: string[];
|
||||
}
|
||||
|
||||
// Analyze watch history to determine preferred genres
|
||||
function analyzeGenrePreferences(historyMovies: Movie[]): GenreScore[] {
|
||||
const genreScores: Map<string, { score: number; count: number }> = new Map();
|
||||
|
||||
historyMovies.forEach((movie, index) => {
|
||||
// More recent movies get higher weight (1.0 for most recent, decreasing)
|
||||
const recencyWeight = 1.0 - (index * 0.1);
|
||||
const weight = Math.max(recencyWeight, 0.3); // Minimum weight of 0.3
|
||||
|
||||
movie.genres?.forEach((genre) => {
|
||||
const current = genreScores.get(genre) || { score: 0, count: 0 };
|
||||
genreScores.set(genre, {
|
||||
score: current.score + weight,
|
||||
count: current.count + 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to array and sort by score
|
||||
return Array.from(genreScores.entries())
|
||||
.map(([genre, data]) => ({
|
||||
genre,
|
||||
score: data.score,
|
||||
count: data.count,
|
||||
}))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
// Get personalized recommendations based on watch history
|
||||
export async function getPersonalizedRecommendations(
|
||||
historyMovies: Movie[],
|
||||
excludeIds: Set<number>,
|
||||
limit = 20
|
||||
): Promise<RecommendationResult> {
|
||||
if (historyMovies.length === 0) {
|
||||
return { movies: [], basedOnGenres: [] };
|
||||
}
|
||||
|
||||
const genrePreferences = analyzeGenrePreferences(historyMovies);
|
||||
const topGenres = genrePreferences.slice(0, 3).map((g) => g.genre);
|
||||
|
||||
if (topGenres.length === 0) {
|
||||
return { movies: [], basedOnGenres: [] };
|
||||
}
|
||||
|
||||
// Fetch movies for each top genre
|
||||
const genrePromises = topGenres.map((genre) =>
|
||||
ytsApi.getByGenre(genre.toLowerCase(), 15).catch(() => ({ movies: [] }))
|
||||
);
|
||||
|
||||
const genreResults = await Promise.all(genrePromises);
|
||||
|
||||
// Combine and deduplicate movies
|
||||
const movieMap = new Map<number, Movie>();
|
||||
const movieScores = new Map<number, number>();
|
||||
|
||||
genreResults.forEach((result, genreIndex) => {
|
||||
result.movies.forEach((movie) => {
|
||||
if (excludeIds.has(movie.id)) return; // Skip already watched
|
||||
|
||||
if (!movieMap.has(movie.id)) {
|
||||
movieMap.set(movie.id, movie);
|
||||
movieScores.set(movie.id, 0);
|
||||
}
|
||||
|
||||
// Score based on genre preference rank
|
||||
const genreWeight = 1.0 - genreIndex * 0.2;
|
||||
const ratingBonus = (movie.rating || 0) / 20; // Up to 0.5 bonus for high-rated
|
||||
movieScores.set(
|
||||
movie.id,
|
||||
(movieScores.get(movie.id) || 0) + genreWeight + ratingBonus
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by score and return top recommendations
|
||||
const sortedMovies = Array.from(movieMap.values())
|
||||
.sort((a, b) => (movieScores.get(b.id) || 0) - (movieScores.get(a.id) || 0))
|
||||
.slice(0, limit);
|
||||
|
||||
return {
|
||||
movies: sortedMovies,
|
||||
basedOnGenres: topGenres,
|
||||
};
|
||||
}
|
||||
|
||||
// Get "Because You Watched" recommendations for a specific movie
|
||||
export async function getBecauseYouWatched(
|
||||
movie: Movie,
|
||||
excludeIds: Set<number>,
|
||||
limit = 10
|
||||
): Promise<Movie[]> {
|
||||
try {
|
||||
// First try the YTS suggestions API
|
||||
const suggestions = await ytsApi.getMovieSuggestions(movie.id);
|
||||
|
||||
if (suggestions.movies.length > 0) {
|
||||
return suggestions.movies
|
||||
.filter((m) => !excludeIds.has(m.id))
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
// Fallback: search by genre
|
||||
if (movie.genres && movie.genres.length > 0) {
|
||||
const primaryGenre = movie.genres[0];
|
||||
const result = await ytsApi.getByGenre(primaryGenre.toLowerCase(), limit * 2);
|
||||
return result.movies
|
||||
.filter((m) => !excludeIds.has(m.id) && m.id !== movie.id)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error getting recommendations:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for using recommendations in components
|
||||
export function useRecommendationsService() {
|
||||
const { getProfileItems } = useHistoryStore();
|
||||
|
||||
const getRecommendations = async (limit = 20): Promise<RecommendationResult> => {
|
||||
const historyItems = getProfileItems();
|
||||
const historyMovies = historyItems.map((item) => item.movie);
|
||||
const excludeIds = new Set(historyItems.map((item) => item.movieId));
|
||||
|
||||
return getPersonalizedRecommendations(historyMovies, excludeIds, limit);
|
||||
};
|
||||
|
||||
const getBecauseWatched = async (movieId: number, limit = 10): Promise<{ movie: Movie; recommendations: Movie[] } | null> => {
|
||||
const historyItems = getProfileItems();
|
||||
const historyItem = historyItems.find((item) => item.movieId === movieId);
|
||||
|
||||
if (!historyItem) return null;
|
||||
|
||||
const excludeIds = new Set(historyItems.map((item) => item.movieId));
|
||||
const recommendations = await getBecauseYouWatched(historyItem.movie, excludeIds, limit);
|
||||
|
||||
return {
|
||||
movie: historyItem.movie,
|
||||
recommendations,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getRecommendations,
|
||||
getBecauseWatched,
|
||||
};
|
||||
}
|
||||
@@ -206,10 +206,14 @@ class StreamingService {
|
||||
|
||||
/**
|
||||
* Stop streaming session
|
||||
* @param sessionId - Session to stop
|
||||
* @param cleanupFiles - If true, delete downloaded files
|
||||
*/
|
||||
async stopStream(sessionId: string): Promise<void> {
|
||||
async stopStream(sessionId: string, cleanupFiles: boolean = false): Promise<void> {
|
||||
const apiUrl = getApiUrlValue();
|
||||
await axios.delete(`${apiUrl}/api/stream/${sessionId}`);
|
||||
await axios.delete(`${apiUrl}/api/stream/${sessionId}`, {
|
||||
params: { cleanup: cleanupFiles },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Movie, HistoryItem } from '../types';
|
||||
import { useProfileStore } from './profileStore';
|
||||
|
||||
interface HistoryItemWithProfile extends HistoryItem {
|
||||
profileId: string;
|
||||
}
|
||||
|
||||
interface HistoryState {
|
||||
items: HistoryItem[];
|
||||
items: HistoryItemWithProfile[];
|
||||
addToHistory: (movie: Movie, progress?: number, duration?: number) => void;
|
||||
updateProgress: (movieId: number, progress: number, duration: number) => void;
|
||||
markAsCompleted: (movieId: number) => void;
|
||||
removeFromHistory: (movieId: number) => void;
|
||||
getProgress: (movieId: number) => HistoryItem | undefined;
|
||||
getProgress: (movieId: number) => HistoryItemWithProfile | undefined;
|
||||
getProfileItems: () => HistoryItemWithProfile[];
|
||||
getContinueWatching: () => HistoryItemWithProfile[];
|
||||
clearHistory: () => void;
|
||||
}
|
||||
|
||||
const getActiveProfileId = () => useProfileStore.getState().activeProfileId || 'default';
|
||||
|
||||
export const useHistoryStore = create<HistoryState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
items: [],
|
||||
addToHistory: (movie, progress = 0, duration = 0) => {
|
||||
const existing = get().items.find((item) => item.movieId === movie.id);
|
||||
const profileId = getActiveProfileId();
|
||||
const existing = get().items.find(
|
||||
(item) => item.movieId === movie.id && item.profileId === profileId
|
||||
);
|
||||
if (existing) {
|
||||
set((state) => ({
|
||||
items: state.items.map((item) =>
|
||||
item.movieId === movie.id
|
||||
item.movieId === movie.id && item.profileId === profileId
|
||||
? { ...item, watchedAt: new Date(), progress, duration }
|
||||
: item
|
||||
),
|
||||
@@ -37,39 +49,77 @@ export const useHistoryStore = create<HistoryState>()(
|
||||
progress,
|
||||
duration,
|
||||
completed: false,
|
||||
profileId,
|
||||
},
|
||||
...state.items,
|
||||
],
|
||||
}));
|
||||
}
|
||||
},
|
||||
updateProgress: (movieId, progress, duration) =>
|
||||
updateProgress: (movieId, progress, duration) => {
|
||||
const profileId = getActiveProfileId();
|
||||
set((state) => ({
|
||||
items: state.items.map((item) =>
|
||||
item.movieId === movieId
|
||||
item.movieId === movieId && item.profileId === profileId
|
||||
? {
|
||||
...item,
|
||||
progress,
|
||||
duration,
|
||||
completed: progress / duration > 0.9,
|
||||
completed: duration > 0 ? progress / duration > 0.9 : false,
|
||||
watchedAt: new Date(),
|
||||
}
|
||||
: item
|
||||
),
|
||||
})),
|
||||
markAsCompleted: (movieId) =>
|
||||
}));
|
||||
},
|
||||
markAsCompleted: (movieId) => {
|
||||
const profileId = getActiveProfileId();
|
||||
set((state) => ({
|
||||
items: state.items.map((item) =>
|
||||
item.movieId === movieId ? { ...item, completed: true } : item
|
||||
item.movieId === movieId && item.profileId === profileId
|
||||
? { ...item, completed: true }
|
||||
: item
|
||||
),
|
||||
})),
|
||||
removeFromHistory: (movieId) =>
|
||||
}));
|
||||
},
|
||||
removeFromHistory: (movieId) => {
|
||||
const profileId = getActiveProfileId();
|
||||
set((state) => ({
|
||||
items: state.items.filter((item) => item.movieId !== movieId),
|
||||
})),
|
||||
getProgress: (movieId) =>
|
||||
get().items.find((item) => item.movieId === movieId),
|
||||
clearHistory: () => set({ items: [] }),
|
||||
items: state.items.filter(
|
||||
(item) => !(item.movieId === movieId && item.profileId === profileId)
|
||||
),
|
||||
}));
|
||||
},
|
||||
getProgress: (movieId) => {
|
||||
const profileId = getActiveProfileId();
|
||||
return get().items.find(
|
||||
(item) => item.movieId === movieId && item.profileId === profileId
|
||||
);
|
||||
},
|
||||
getProfileItems: () => {
|
||||
const profileId = getActiveProfileId();
|
||||
return get().items.filter((item) => item.profileId === profileId);
|
||||
},
|
||||
getContinueWatching: () => {
|
||||
const profileId = getActiveProfileId();
|
||||
return get()
|
||||
.items.filter((item) => {
|
||||
if (item.profileId !== profileId) return false;
|
||||
if (item.completed) return false;
|
||||
if (!item.duration || item.duration === 0) return false;
|
||||
const progressPercent = item.progress / item.duration;
|
||||
// Show items between 5% and 90% progress
|
||||
return progressPercent >= 0.05 && progressPercent < 0.9;
|
||||
})
|
||||
.sort((a, b) => new Date(b.watchedAt).getTime() - new Date(a.watchedAt).getTime())
|
||||
.slice(0, 10); // Limit to 10 items
|
||||
},
|
||||
clearHistory: () => {
|
||||
const profileId = getActiveProfileId();
|
||||
set((state) => ({
|
||||
items: state.items.filter((item) => item.profileId !== profileId),
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'bestream-history',
|
||||
|
||||
121
src/stores/profileStore.ts
Normal file
121
src/stores/profileStore.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Profile, AvatarId } from '../types';
|
||||
|
||||
const MAX_PROFILES = 5;
|
||||
|
||||
interface ProfileState {
|
||||
profiles: Profile[];
|
||||
activeProfileId: string | null;
|
||||
|
||||
// Actions
|
||||
createProfile: (name: string, avatar: AvatarId) => Profile | null;
|
||||
updateProfile: (id: string, updates: Partial<Pick<Profile, 'name' | 'avatar'>>) => void;
|
||||
deleteProfile: (id: string) => void;
|
||||
setActiveProfile: (id: string | null) => void;
|
||||
getActiveProfile: () => Profile | null;
|
||||
canCreateProfile: () => boolean;
|
||||
}
|
||||
|
||||
const generateId = () => `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
export const useProfileStore = create<ProfileState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
profiles: [],
|
||||
activeProfileId: null,
|
||||
|
||||
createProfile: (name, avatar) => {
|
||||
const { profiles } = get();
|
||||
if (profiles.length >= MAX_PROFILES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newProfile: Profile = {
|
||||
id: generateId(),
|
||||
name: name.trim(),
|
||||
avatar,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
profiles: [...state.profiles, newProfile],
|
||||
// Auto-select if first profile
|
||||
activeProfileId: state.profiles.length === 0 ? newProfile.id : state.activeProfileId,
|
||||
}));
|
||||
|
||||
return newProfile;
|
||||
},
|
||||
|
||||
updateProfile: (id, updates) => {
|
||||
set((state) => ({
|
||||
profiles: state.profiles.map((profile) =>
|
||||
profile.id === id
|
||||
? { ...profile, ...updates, name: updates.name?.trim() || profile.name }
|
||||
: profile
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
deleteProfile: (id) => {
|
||||
const { profiles, activeProfileId } = get();
|
||||
|
||||
// Don't delete if it's the last profile
|
||||
if (profiles.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up profile-specific data from localStorage
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.includes(id)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
|
||||
set((state) => {
|
||||
const newProfiles = state.profiles.filter((p) => p.id !== id);
|
||||
return {
|
||||
profiles: newProfiles,
|
||||
// If deleting active profile, switch to first available
|
||||
activeProfileId:
|
||||
activeProfileId === id ? newProfiles[0]?.id || null : activeProfileId,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setActiveProfile: (id) => {
|
||||
if (id === null) {
|
||||
set({ activeProfileId: null });
|
||||
return;
|
||||
}
|
||||
const { profiles } = get();
|
||||
if (profiles.some((p) => p.id === id)) {
|
||||
set({ activeProfileId: id });
|
||||
}
|
||||
},
|
||||
|
||||
getActiveProfile: () => {
|
||||
const { profiles, activeProfileId } = get();
|
||||
return profiles.find((p) => p.id === activeProfileId) || null;
|
||||
},
|
||||
|
||||
canCreateProfile: () => {
|
||||
return get().profiles.length < MAX_PROFILES;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'bestream-profiles',
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Helper to get storage key for profile-specific data
|
||||
export const getProfileStorageKey = (baseKey: string, profileId: string | null): string => {
|
||||
if (!profileId) {
|
||||
return baseKey;
|
||||
}
|
||||
return `${baseKey}-${profileId}`;
|
||||
};
|
||||
@@ -12,6 +12,9 @@ const defaultSettings: AppSettings = {
|
||||
preferredQuality: '1080p',
|
||||
preferredLanguage: 'en',
|
||||
autoPlay: true,
|
||||
autoDeleteStreams: true,
|
||||
autoQuality: true,
|
||||
autoDownloadNextEpisode: true,
|
||||
notifications: true,
|
||||
downloadPath: '',
|
||||
maxConcurrentDownloads: 3,
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Movie, WatchlistItem } from '../types';
|
||||
import { useProfileStore } from './profileStore';
|
||||
|
||||
interface WatchlistItemWithProfile extends WatchlistItem {
|
||||
profileId: string;
|
||||
}
|
||||
|
||||
interface WatchlistState {
|
||||
items: WatchlistItem[];
|
||||
items: WatchlistItemWithProfile[];
|
||||
addToWatchlist: (movie: Movie) => void;
|
||||
removeFromWatchlist: (movieId: number) => void;
|
||||
isInWatchlist: (movieId: number) => boolean;
|
||||
getProfileItems: () => WatchlistItemWithProfile[];
|
||||
clearWatchlist: () => void;
|
||||
}
|
||||
|
||||
const getActiveProfileId = () => useProfileStore.getState().activeProfileId || 'default';
|
||||
|
||||
export const useWatchlistStore = create<WatchlistState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
items: [],
|
||||
addToWatchlist: (movie) => {
|
||||
const exists = get().items.some((item) => item.movieId === movie.id);
|
||||
const profileId = getActiveProfileId();
|
||||
const exists = get().items.some(
|
||||
(item) => item.movieId === movie.id && item.profileId === profileId
|
||||
);
|
||||
if (!exists) {
|
||||
set((state) => ({
|
||||
items: [
|
||||
@@ -25,18 +36,36 @@ export const useWatchlistStore = create<WatchlistState>()(
|
||||
movieId: movie.id,
|
||||
movie,
|
||||
addedAt: new Date(),
|
||||
profileId,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
},
|
||||
removeFromWatchlist: (movieId) =>
|
||||
removeFromWatchlist: (movieId) => {
|
||||
const profileId = getActiveProfileId();
|
||||
set((state) => ({
|
||||
items: state.items.filter((item) => item.movieId !== movieId),
|
||||
})),
|
||||
isInWatchlist: (movieId) =>
|
||||
get().items.some((item) => item.movieId === movieId),
|
||||
clearWatchlist: () => set({ items: [] }),
|
||||
items: state.items.filter(
|
||||
(item) => !(item.movieId === movieId && item.profileId === profileId)
|
||||
),
|
||||
}));
|
||||
},
|
||||
isInWatchlist: (movieId) => {
|
||||
const profileId = getActiveProfileId();
|
||||
return get().items.some(
|
||||
(item) => item.movieId === movieId && item.profileId === profileId
|
||||
);
|
||||
},
|
||||
getProfileItems: () => {
|
||||
const profileId = getActiveProfileId();
|
||||
return get().items.filter((item) => item.profileId === profileId);
|
||||
},
|
||||
clearWatchlist: () => {
|
||||
const profileId = getActiveProfileId();
|
||||
set((state) => ({
|
||||
items: state.items.filter((item) => item.profileId !== profileId),
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'bestream-watchlist',
|
||||
|
||||
@@ -166,12 +166,38 @@ export interface AppSettings {
|
||||
preferredQuality: string;
|
||||
preferredLanguage: string;
|
||||
autoPlay: boolean;
|
||||
autoDeleteStreams: boolean;
|
||||
autoQuality: boolean;
|
||||
autoDownloadNextEpisode: boolean;
|
||||
notifications: boolean;
|
||||
downloadPath: string;
|
||||
maxConcurrentDownloads: number;
|
||||
theme: 'dark' | 'light' | 'system';
|
||||
}
|
||||
|
||||
// Profile Types
|
||||
export interface Profile {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string; // avatar-1 through avatar-8
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export const AVATAR_OPTIONS = [
|
||||
'avatar-1', 'avatar-2', 'avatar-3', 'avatar-4',
|
||||
'avatar-5', 'avatar-6', 'avatar-7', 'avatar-8',
|
||||
] as const;
|
||||
|
||||
export type AvatarId = typeof AVATAR_OPTIONS[number];
|
||||
|
||||
// Recommendation Types
|
||||
export interface RecommendationCache {
|
||||
profileId: string;
|
||||
recommendations: Movie[];
|
||||
generatedAt: number;
|
||||
basedOnGenres: string[];
|
||||
}
|
||||
|
||||
// Torrent Types
|
||||
export interface TorrentInfo {
|
||||
infoHash: string;
|
||||
|
||||
199
src/utils/network.ts
Normal file
199
src/utils/network.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// Network utility for detecting connection quality and selecting appropriate video quality
|
||||
|
||||
export type ConnectionType = 'wifi' | 'cellular' | 'ethernet' | 'unknown';
|
||||
export type ConnectionQuality = 'high' | 'medium' | 'low' | 'unknown';
|
||||
|
||||
interface NetworkInfo {
|
||||
type: ConnectionType;
|
||||
quality: ConnectionQuality;
|
||||
effectiveType?: '4g' | '3g' | '2g' | 'slow-2g';
|
||||
downlink?: number; // Mbps
|
||||
rtt?: number; // Round trip time in ms
|
||||
saveData?: boolean;
|
||||
}
|
||||
|
||||
// Get the current network information
|
||||
export function getNetworkInfo(): NetworkInfo {
|
||||
const connection =
|
||||
(navigator as any).connection ||
|
||||
(navigator as any).mozConnection ||
|
||||
(navigator as any).webkitConnection;
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
type: 'unknown',
|
||||
quality: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
// Determine connection type
|
||||
let type: ConnectionType = 'unknown';
|
||||
if (connection.type) {
|
||||
switch (connection.type) {
|
||||
case 'wifi':
|
||||
type = 'wifi';
|
||||
break;
|
||||
case 'ethernet':
|
||||
type = 'ethernet';
|
||||
break;
|
||||
case 'cellular':
|
||||
type = 'cellular';
|
||||
break;
|
||||
default:
|
||||
type = 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// Determine quality based on effective type and downlink
|
||||
let quality: ConnectionQuality = 'unknown';
|
||||
if (connection.effectiveType) {
|
||||
switch (connection.effectiveType) {
|
||||
case '4g':
|
||||
quality = 'high';
|
||||
break;
|
||||
case '3g':
|
||||
quality = 'medium';
|
||||
break;
|
||||
case '2g':
|
||||
case 'slow-2g':
|
||||
quality = 'low';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Override based on downlink if available
|
||||
if (connection.downlink !== undefined) {
|
||||
if (connection.downlink >= 10) {
|
||||
quality = 'high';
|
||||
} else if (connection.downlink >= 2) {
|
||||
quality = 'medium';
|
||||
} else {
|
||||
quality = 'low';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
quality,
|
||||
effectiveType: connection.effectiveType,
|
||||
downlink: connection.downlink,
|
||||
rtt: connection.rtt,
|
||||
saveData: connection.saveData,
|
||||
};
|
||||
}
|
||||
|
||||
// Quality thresholds in Mbps
|
||||
const QUALITY_BANDWIDTH_REQUIREMENTS = {
|
||||
'2160p': 25, // 4K needs 25+ Mbps
|
||||
'1080p': 8, // 1080p needs 8+ Mbps
|
||||
'720p': 3, // 720p needs 3+ Mbps
|
||||
'480p': 1.5, // 480p needs 1.5+ Mbps
|
||||
};
|
||||
|
||||
// Select the best quality based on network conditions
|
||||
export function selectOptimalQuality(
|
||||
availableQualities: string[],
|
||||
networkInfo: NetworkInfo
|
||||
): string {
|
||||
// If data saver is enabled, always use lowest quality
|
||||
if (networkInfo.saveData) {
|
||||
return findLowestQuality(availableQualities);
|
||||
}
|
||||
|
||||
// If we have downlink info, use it
|
||||
if (networkInfo.downlink !== undefined) {
|
||||
return selectQualityByBandwidth(availableQualities, networkInfo.downlink);
|
||||
}
|
||||
|
||||
// Otherwise use connection quality estimate
|
||||
switch (networkInfo.quality) {
|
||||
case 'high':
|
||||
return findQuality(availableQualities, ['2160p', '1080p', '720p', '480p']);
|
||||
case 'medium':
|
||||
return findQuality(availableQualities, ['720p', '1080p', '480p']);
|
||||
case 'low':
|
||||
return findQuality(availableQualities, ['480p', '720p']);
|
||||
default:
|
||||
// Unknown quality, default to 720p for balance
|
||||
return findQuality(availableQualities, ['720p', '1080p', '480p']);
|
||||
}
|
||||
}
|
||||
|
||||
function selectQualityByBandwidth(availableQualities: string[], bandwidthMbps: number): string {
|
||||
// Check each quality from highest to lowest
|
||||
const qualityOrder: Array<keyof typeof QUALITY_BANDWIDTH_REQUIREMENTS> = [
|
||||
'2160p',
|
||||
'1080p',
|
||||
'720p',
|
||||
'480p',
|
||||
];
|
||||
|
||||
for (const quality of qualityOrder) {
|
||||
if (bandwidthMbps >= QUALITY_BANDWIDTH_REQUIREMENTS[quality]) {
|
||||
// Check if this quality is available
|
||||
if (availableQualities.some((q) => q === quality || q === '4K' && quality === '2160p')) {
|
||||
return quality;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to lowest available
|
||||
return findLowestQuality(availableQualities);
|
||||
}
|
||||
|
||||
function findQuality(availableQualities: string[], preferenceOrder: string[]): string {
|
||||
for (const preferred of preferenceOrder) {
|
||||
const found = availableQualities.find(
|
||||
(q) => q === preferred || (q === '4K' && preferred === '2160p')
|
||||
);
|
||||
if (found) return found;
|
||||
}
|
||||
return availableQualities[0] || '720p';
|
||||
}
|
||||
|
||||
function findLowestQuality(availableQualities: string[]): string {
|
||||
const priorityOrder = ['480p', '720p', '1080p', '2160p', '4K'];
|
||||
for (const quality of priorityOrder) {
|
||||
if (availableQualities.includes(quality)) {
|
||||
return quality;
|
||||
}
|
||||
}
|
||||
return availableQualities[0] || '480p';
|
||||
}
|
||||
|
||||
// Subscribe to network changes
|
||||
export function onNetworkChange(callback: (info: NetworkInfo) => void): () => void {
|
||||
const connection =
|
||||
(navigator as any).connection ||
|
||||
(navigator as any).mozConnection ||
|
||||
(navigator as any).webkitConnection;
|
||||
|
||||
if (!connection) {
|
||||
return () => {}; // No-op cleanup
|
||||
}
|
||||
|
||||
const handler = () => callback(getNetworkInfo());
|
||||
connection.addEventListener('change', handler);
|
||||
|
||||
return () => connection.removeEventListener('change', handler);
|
||||
}
|
||||
|
||||
// Check if we're on a metered connection (cellular)
|
||||
export function isMeteredConnection(): boolean {
|
||||
const info = getNetworkInfo();
|
||||
return info.type === 'cellular' || info.saveData === true;
|
||||
}
|
||||
|
||||
// Estimate if we can handle a specific quality
|
||||
export function canHandleQuality(quality: string): boolean {
|
||||
const info = getNetworkInfo();
|
||||
|
||||
if (info.downlink === undefined) {
|
||||
// Can't determine, assume yes for non-4K
|
||||
return quality !== '2160p' && quality !== '4K';
|
||||
}
|
||||
|
||||
const required =
|
||||
QUALITY_BANDWIDTH_REQUIREMENTS[quality as keyof typeof QUALITY_BANDWIDTH_REQUIREMENTS] || 3;
|
||||
return info.downlink >= required;
|
||||
}
|
||||
Reference in New Issue
Block a user