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

- 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:
2026-01-31 20:44:13 +01:00
parent bf6dcdabd0
commit 766cbbce89
28 changed files with 2601 additions and 108 deletions

View File

@@ -24,9 +24,17 @@ export { broadcastUpdate };
// Read version from package.json to avoid hardcoding // Read version from package.json to avoid hardcoding
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const packageJsonPath = join(__dirname, '..', 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); // Try to read version from package.json, fallback to hardcoded version for Android
const SERVER_VERSION = packageJson.version; 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'); console.log('[beStream Server] Imports loaded successfully');
@@ -68,6 +76,7 @@ const ALLOWED_ORIGINS = [
// Capacitor // Capacitor
'capacitor://localhost', 'capacitor://localhost',
'http://localhost', 'http://localhost',
'https://localhost',
// Android WebView (file:// protocol) // Android WebView (file:// protocol)
null, // file:// origins are null null, // file:// origins are null
]; ];

View File

@@ -461,14 +461,17 @@ streamRouter.get('/:sessionId/info', validate(schemas.sessionId, 'params'), asyn
/** /**
* Stop a streaming session * Stop a streaming session
* DELETE /api/stream/:sessionId * DELETE /api/stream/:sessionId
* Query params:
* - cleanup: If 'true', delete downloaded files
*/ */
streamRouter.delete('/:sessionId', validate(schemas.sessionId, 'params'), async (req, res) => { streamRouter.delete('/:sessionId', validate(schemas.sessionId, 'params'), async (req, res) => {
const { sessionId } = req.params; const { sessionId } = req.params;
const cleanupFiles = req.query.cleanup === 'true';
await torrentManager.stopSession(sessionId); await torrentManager.stopSession(sessionId, { cleanupFiles });
transcoder.cleanupSession(sessionId); transcoder.cleanupSession(sessionId);
res.json({ status: 'stopped' }); res.json({ status: 'stopped', filesDeleted: cleanupFiles });
}); });
/** /**

View File

@@ -198,19 +198,21 @@ class Transcoder {
// Detect if this is 4K - downscale for real-time playback // Detect if this is 4K - downscale for real-time playback
const is4K = quality === '2160p' || quality === '4K' || quality?.includes('2160'); const is4K = quality === '2160p' || quality === '4K' || quality?.includes('2160');
// Default settings for 1080p and below // Quality settings optimized for good visual quality with real-time playback
let maxrate = '4M'; // Using 'veryfast' preset instead of 'ultrafast' - much better quality, still fast
let bufsize = '8M'; // Higher bitrates prevent pixelation and motion artifacts in action scenes
let crf = '23'; 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; let scaleFilter = null;
if (is4K) { if (is4K) {
// 4K sources are downscaled to 1080p for real-time playback // 4K sources are downscaled to 1080p for real-time playback
// Full 4K transcoding is too demanding for most local systems // Full 4K transcoding is too demanding for most local systems
console.log(`🎬 Starting HLS transcode for 4K video → 1080p (session ${sessionId})`); console.log(`🎬 Starting HLS transcode for 4K video → 1080p (session ${sessionId})`);
maxrate = '8M'; // Good bitrate for 1080p from 4K source maxrate = '12M'; // Higher bitrate for 4K downscaled content
bufsize = '16M'; // Larger buffer bufsize = '24M'; // Larger buffer for complex scenes
crf = '21'; // Slightly better quality since downscaling crf = '18'; // Better quality for 4K source material
scaleFilter = 'scale=1920:-2'; // Downscale to 1080p, maintain aspect ratio scaleFilter = 'scale=1920:-2'; // Downscale to 1080p, maintain aspect ratio
} else { } else {
console.log(`🎬 Starting HLS transcode for session ${sessionId}`); console.log(`🎬 Starting HLS transcode for session ${sessionId}`);
@@ -225,18 +227,20 @@ class Transcoder {
'-hls_segment_type', 'mpegts', '-hls_segment_type', 'mpegts',
'-hls_segment_filename', path.join(outputDir, 'segment_%03d.ts'), '-hls_segment_filename', path.join(outputDir, 'segment_%03d.ts'),
// Video encoding // Video encoding - optimized for quality
'-c:v', 'libx264', '-c:v', 'libx264',
'-preset', 'ultrafast', // Fast encoding for real-time '-preset', 'veryfast', // Good balance of speed and quality
'-tune', 'zerolatency', // Low latency '-tune', 'film', // Optimize for movie content (better grain/detail)
'-crf', crf, // Quality (lower = better) '-crf', crf, // Quality (lower = better)
'-maxrate', maxrate, // Max bitrate (adjusted for quality) '-maxrate', maxrate, // Max bitrate for complex scenes
'-bufsize', bufsize, // Buffer size (adjusted for quality) '-bufsize', bufsize, // Buffer size for bitrate smoothing
'-g', '48', // Keyframe interval '-g', '48', // Keyframe interval
'-bf', '3', // B-frames for better compression/quality
'-refs', '3', // Reference frames for motion prediction
// Audio encoding // Audio encoding
'-c:a', 'aac', '-c:a', 'aac',
'-b:a', '128k', '-b:a', '192k', // Higher audio bitrate for better quality
'-ac', '2', // Stereo '-ac', '2', // Stereo
// General // General
@@ -318,18 +322,20 @@ class Transcoder {
// Detect if this is 4K - downscale for real-time playback // Detect if this is 4K - downscale for real-time playback
const is4K = quality === '2160p' || quality === '4K' || quality?.includes('2160'); const is4K = quality === '2160p' || quality === '4K' || quality?.includes('2160');
// Default settings for 1080p and below // Quality settings optimized for good visual quality with real-time playback
let maxrate = '4M'; // Using 'veryfast' preset instead of 'ultrafast' - much better quality, still fast
let bufsize = '8M'; // Higher bitrates prevent pixelation and motion artifacts in action scenes
let crf = '23'; 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; let scaleFilter = null;
if (is4K) { if (is4K) {
// 4K sources are downscaled to 1080p for real-time playback // 4K sources are downscaled to 1080p for real-time playback
console.log(`🎬 Starting HLS stream transcode for 4K video → 1080p (session ${sessionId})`); console.log(`🎬 Starting HLS stream transcode for 4K video → 1080p (session ${sessionId})`);
maxrate = '8M'; // Good bitrate for 1080p from 4K source maxrate = '12M'; // Higher bitrate for 4K downscaled content
bufsize = '16M'; // Larger buffer bufsize = '24M'; // Larger buffer for complex scenes
crf = '21'; // Slightly better quality since downscaling crf = '18'; // Better quality for 4K source material
scaleFilter = 'scale=1920:-2'; // Downscale to 1080p, maintain aspect ratio scaleFilter = 'scale=1920:-2'; // Downscale to 1080p, maintain aspect ratio
} else { } else {
console.log(`🎬 Starting HLS stream transcode for session ${sessionId} (from WebTorrent stream)`); console.log(`🎬 Starting HLS stream transcode for session ${sessionId} (from WebTorrent stream)`);
@@ -344,18 +350,20 @@ class Transcoder {
'-hls_segment_type', 'mpegts', '-hls_segment_type', 'mpegts',
'-hls_segment_filename', path.join(outputDir, 'segment_%03d.ts'), '-hls_segment_filename', path.join(outputDir, 'segment_%03d.ts'),
// Video encoding // Video encoding - optimized for quality
'-c:v', 'libx264', '-c:v', 'libx264',
'-preset', 'ultrafast', // Fast encoding for real-time '-preset', 'veryfast', // Good balance of speed and quality
'-tune', 'zerolatency', // Low latency '-tune', 'film', // Optimize for movie content (better grain/detail)
'-crf', crf, // Quality (lower = better) '-crf', crf, // Quality (lower = better)
'-maxrate', maxrate, // Max bitrate (adjusted for quality) '-maxrate', maxrate, // Max bitrate for complex scenes
'-bufsize', bufsize, // Buffer size (adjusted for quality) '-bufsize', bufsize, // Buffer size for bitrate smoothing
'-g', '48', // Keyframe interval '-g', '48', // Keyframe interval
'-bf', '3', // B-frames for better compression/quality
'-refs', '3', // Reference frames for motion prediction
// Audio encoding // Audio encoding
'-c:a', 'aac', '-c:a', 'aac',
'-b:a', '128k', '-b:a', '192k', // Higher audio bitrate for better quality
'-ac', '2', // Stereo '-ac', '2', // Stereo
// General // General
@@ -425,11 +433,13 @@ class Transcoder {
.seekInput(startTime) .seekInput(startTime)
.outputOptions([ .outputOptions([
'-c:v', videoCodec, '-c:v', videoCodec,
'-preset', 'ultrafast', '-preset', 'veryfast', // Good balance of speed and quality
'-tune', 'zerolatency', '-tune', 'film', // Optimize for movie content
'-crf', '23', '-crf', '20', // Good quality
'-maxrate', '8M', // Prevent bitrate spikes
'-bufsize', '16M', // Smooth bitrate allocation
'-c:a', audioCodec, '-c:a', audioCodec,
'-b:a', '128k', '-b:a', '192k', // Higher audio quality
'-movflags', 'frag_keyframe+empty_moov+faststart', '-movflags', 'frag_keyframe+empty_moov+faststart',
'-f', 'mp4', '-f', 'mp4',
]) ])

View File

@@ -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 { useEffect, Suspense, lazy } from 'react';
import Layout from './components/layout/Layout'; import Layout from './components/layout/Layout';
import ErrorBoundary from './components/ErrorBoundary'; import ErrorBoundary from './components/ErrorBoundary';
import ProfileGate from './components/profile/ProfileGate';
import { useSettingsStore } from './stores/settingsStore'; 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 // Lazy load routes for code splitting
const Home = lazy(() => import('./pages/Home')); const Home = lazy(() => import('./pages/Home'));
const Browse = lazy(() => import('./pages/Browse')); const Browse = lazy(() => import('./pages/Browse'));
@@ -41,8 +54,10 @@ function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<LoadingFallback />}> <ProfileGate>
<Routes> <ScrollToTop />
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={<Layout />}> <Route path="/" element={<Layout />}>
<Route <Route
index index
@@ -171,8 +186,9 @@ function App() {
</ErrorBoundary> </ErrorBoundary>
} }
/> />
</Routes> </Routes>
</Suspense> </Suspense>
</ProfileGate>
</ErrorBoundary> </ErrorBoundary>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { import {
@@ -15,9 +15,15 @@ import {
Minus, Minus,
Square, Square,
Maximize2, Maximize2,
ChevronDown,
User,
LogOut,
} from 'lucide-react'; } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { isElectron } from '../../utils/platform'; import { isElectron } from '../../utils/platform';
import { useProfileStore } from '../../stores/profileStore';
import { Avatar } from '../profile/Avatar';
import type { AvatarId } from '../../types';
const navLinks = [ const navLinks = [
{ path: '/', label: 'Home', icon: Home }, { path: '/', label: 'Home', icon: Home },
@@ -33,10 +39,16 @@ export default function Navbar() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const profileMenuRef = useRef<HTMLDivElement>(null);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const isElectronApp = isElectron(); const isElectronApp = isElectron();
// Profile store
const { profiles, activeProfileId, getActiveProfile, setActiveProfile } = useProfileStore();
const activeProfile = getActiveProfile();
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
setIsScrolled(window.scrollY > 50); setIsScrolled(window.scrollY > 50);
@@ -48,8 +60,20 @@ export default function Navbar() {
// Close mobile menu on route change // Close mobile menu on route change
useEffect(() => { useEffect(() => {
setIsMobileMenuOpen(false); setIsMobileMenuOpen(false);
setIsProfileMenuOpen(false);
}, [location.pathname]); }, [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) // Check if window is maximized (Electron only)
useEffect(() => { useEffect(() => {
if (isElectronApp && window.electron) { if (isElectronApp && window.electron) {
@@ -200,6 +224,91 @@ export default function Navbar() {
</motion.div> </motion.div>
</Link> </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 */} {/* Mobile Menu Button */}
<button <button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}

View 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>
);
}

View File

@@ -28,6 +28,8 @@ import { searchSubtitles, downloadSubtitle, convertSrtToVtt } from '../../servic
import { useSettingsStore } from '../../stores/settingsStore'; import { useSettingsStore } from '../../stores/settingsStore';
import { isCapacitor } from '../../utils/platform'; import { isCapacitor } from '../../utils/platform';
import { playWithNativePlayer } from '../../plugins/ExoPlayer'; import { playWithNativePlayer } from '../../plugins/ExoPlayer';
import CastButton from './CastButton';
import { useCast } from '../../hooks/useCast';
interface StreamingPlayerProps { interface StreamingPlayerProps {
movie: Movie; movie: Movie;
@@ -80,6 +82,9 @@ export default function StreamingPlayer({
const [isNativePlayerPlaying, setIsNativePlayerPlaying] = useState(false); const [isNativePlayerPlaying, setIsNativePlayerPlaying] = useState(false);
const { settings } = useSettingsStore(); const { settings } = useSettingsStore();
// Cast integration
const { castMedia } = useCast();
// Detect potential audio issues on Android with MKV files and enable native player // Detect potential audio issues on Android with MKV files and enable native player
useEffect(() => { useEffect(() => {
// Only check on Capacitor (Android) since desktop has FFmpeg transcoding // 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 */} {/* Audio Warning Banner for Android MKV files with Native Player option */}
<AnimatePresence> <AnimatePresence>
{audioWarning && !audioWarningDismissed && ( {audioWarning && !audioWarningDismissed && useNativePlayer && (
<motion.div <motion.div
initial={{ opacity: 0, y: -50 }} initial={{ opacity: 0 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0, y: -50 }} exit={{ opacity: 0 }}
className="absolute top-0 left-0 right-0 z-50 bg-yellow-600/95 text-white p-3" 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"> <div className="max-w-md mx-4 text-center">
<AlertTriangle size={24} className="flex-shrink-0 mt-0.5" /> <AlertTriangle size={48} className="text-yellow-500 mx-auto mb-4" />
<div className="flex-1 text-sm"> <h2 className="text-xl font-bold text-white mb-2">Audio May Not Work</h2>
<p className="font-medium mb-1">Audio Format Issue</p> <p className="text-gray-300 mb-6">
<p className="text-yellow-100">{audioWarning}</p> This video uses an MKV format with audio codecs that may not play in the browser.
{useNativePlayer && ( Use the Native Player for full audio support.
<button </p>
onClick={handleNativePlayerLaunch} <div className="flex flex-col gap-3">
disabled={isNativePlayerPlaying} <button
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" onClick={handleNativePlayerLaunch}
> disabled={isNativePlayerPlaying}
<MonitorPlay size={18} /> 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"
{isNativePlayerPlaying ? 'Opening Native Player...' : 'Play with Native Player (Full Audio Support)'} >
</button> <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> </div>
<button <p className="text-gray-500 text-sm mt-4">
onClick={() => setAudioWarningDismissed(true)} Tip: MP4 torrents usually have better compatibility
className="text-yellow-200 hover:text-white p-1" </p>
>
</button>
</div> </div>
</motion.div> </motion.div>
)} )}
@@ -1201,6 +1210,30 @@ export default function StreamingPlayer({
)} )}
</div> </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 */} {/* Fullscreen */}
<button <button
onClick={toggleFullscreen} onClick={toggleFullscreen}

View 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;

View 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
View 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,
};
}

View 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 };
}

View File

@@ -5,7 +5,9 @@ import MovieRow from '../components/movie/MovieRow';
import TVRow from '../components/tv/TVRow'; import TVRow from '../components/tv/TVRow';
import { HeroSkeleton } from '../components/ui/Skeleton'; import { HeroSkeleton } from '../components/ui/Skeleton';
import { useTrending, useLatest, useTopRated, useByGenre } from '../hooks/useMovies'; import { useTrending, useLatest, useTopRated, useByGenre } from '../hooks/useMovies';
import { usePersonalizedRecommendations, useMultipleBecauseYouWatched } from '../hooks/useRecommendations';
import { tvDiscoveryApi, type DiscoveredShow } from '../services/api/tvDiscovery'; import { tvDiscoveryApi, type DiscoveredShow } from '../services/api/tvDiscovery';
import { useHistoryStore } from '../stores/historyStore';
import { HERO_MOVIES_COUNT } from '../constants'; import { HERO_MOVIES_COUNT } from '../constants';
export default function Home() { export default function Home() {
@@ -18,6 +20,35 @@ export default function Home() {
const { movies: scifi, isLoading: scifiLoading } = useByGenre('sci-fi'); const { movies: scifi, isLoading: scifiLoading } = useByGenre('sci-fi');
const { movies: drama, isLoading: dramaLoading } = useByGenre('drama'); 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 // Memoize hero movies to prevent unnecessary re-renders
const heroMovies = useMemo(() => trending.slice(0, HERO_MOVIES_COUNT), [trending]); const heroMovies = useMemo(() => trending.slice(0, HERO_MOVIES_COUNT), [trending]);
@@ -73,6 +104,43 @@ export default function Home() {
{/* Movie Rows */} {/* Movie Rows */}
<div className="-mt-32 relative z-10"> <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 <MovieRow
title="Trending Now" title="Trending Now"
movies={trending} movies={trending}

View File

@@ -5,8 +5,10 @@ import { Loader2, AlertCircle, Server, RefreshCw } from 'lucide-react';
import StreamingPlayer from '../components/player/StreamingPlayer'; import StreamingPlayer from '../components/player/StreamingPlayer';
import { useMovieDetails } from '../hooks/useMovies'; import { useMovieDetails } from '../hooks/useMovies';
import { useHistoryStore } from '../stores/historyStore'; import { useHistoryStore } from '../stores/historyStore';
import { useSettingsStore } from '../stores/settingsStore';
import streamingService, { type StreamSession } from '../services/streaming/streamingService'; import streamingService, { type StreamSession } from '../services/streaming/streamingService';
import { getApiUrl } from '../utils/platform'; import { getApiUrl } from '../utils/platform';
import { getNetworkInfo, selectOptimalQuality } from '../utils/network';
import type { Torrent } from '../types'; import type { Torrent } from '../types';
import Button from '../components/ui/Button'; import Button from '../components/ui/Button';
import serverManager from '../services/server/serverManager'; import serverManager from '../services/server/serverManager';
@@ -22,6 +24,7 @@ export default function Player() {
const { movie, isLoading: movieLoading, error: movieError } = useMovieDetails(movieId); const { movie, isLoading: movieLoading, error: movieError } = useMovieDetails(movieId);
const { addToHistory, updateProgress, getProgress } = useHistoryStore(); const { addToHistory, updateProgress, getProgress } = useHistoryStore();
const { settings } = useSettingsStore();
const [_sessionId, setSessionId] = useState<string | null>(null); const [_sessionId, setSessionId] = useState<string | null>(null);
const [streamSession, setStreamSession] = useState<StreamSession | null>(null); const [streamSession, setStreamSession] = useState<StreamSession | null>(null);
@@ -35,6 +38,9 @@ export default function Player() {
const sessionIdRef = useRef<string | null>(null); const sessionIdRef = useRef<string | null>(null);
// Ref to track polling timeout for cleanup // Ref to track polling timeout for cleanup
const pollTimeoutRef = useRef<NodeJS.Timeout | null>(null); 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 // Get initial playback position
const historyItem = movie ? getProgress(movie.id) : undefined; const historyItem = movie ? getProgress(movie.id) : undefined;
@@ -72,18 +78,32 @@ export default function Player() {
checkAndStartServer(); checkAndStartServer();
}, []); }, []);
// Select the best torrent based on preference // Select the best torrent based on preference and network conditions
useEffect(() => { useEffect(() => {
if (movie?.torrents?.length && status === 'connecting') { if (movie?.torrents?.length && status === 'connecting') {
const torrents = movie.torrents; const torrents = movie.torrents;
const availableQualities = torrents.map((t) => t.quality);
// Find preferred quality or best available
let torrent = preferredQuality let selectedQuality: string | null = null;
? torrents.find((t) => t.quality === preferredQuality)
// 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; : null;
if (!torrent) { if (!torrent) {
// Prefer 1080p, then 720p, then highest seeds // Fallback: Prefer 1080p, then 720p, then highest seeds
torrent = torrent =
torrents.find((t) => t.quality === '1080p') || torrents.find((t) => t.quality === '1080p') ||
torrents.find((t) => t.quality === '720p') || torrents.find((t) => t.quality === '720p') ||
@@ -92,7 +112,7 @@ export default function Player() {
setSelectedTorrent(torrent); setSelectedTorrent(torrent);
} }
}, [movie, preferredQuality, status]); }, [movie, preferredQuality, status, settings.autoQuality]);
// Start streaming when torrent is selected // Start streaming when torrent is selected
useEffect(() => { useEffect(() => {
@@ -247,7 +267,8 @@ export default function Player() {
} }
// Use ref to get current sessionId (fixes stale closure issue) // Use ref to get current sessionId (fixes stale closure issue)
if (sessionIdRef.current) { 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); logger.error('Error stopping stream', err);
}); });
streamingService.disconnect(); streamingService.disconnect();

View 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>
);
}

View 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>
);
}

View File

@@ -121,6 +121,48 @@ export default function Settings() {
/> />
</button> </button>
</div> </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> </div>
</motion.section> </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"> <div className="p-4 bg-white/5 rounded-lg">
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
Download location is managed by your operating system's default Download location is managed by your operating system's default

View File

@@ -5,6 +5,8 @@ import { Loader2, AlertCircle, Server, RefreshCw } from 'lucide-react';
import StreamingPlayer from '../components/player/StreamingPlayer'; import StreamingPlayer from '../components/player/StreamingPlayer';
import NextEpisodeOverlay from '../components/player/NextEpisodeOverlay'; import NextEpisodeOverlay from '../components/player/NextEpisodeOverlay';
import streamingService, { type StreamSession } from '../services/streaming/streamingService'; import streamingService, { type StreamSession } from '../services/streaming/streamingService';
import { useSettingsStore } from '../stores/settingsStore';
import { useHistoryStore } from '../stores/historyStore';
import { getApiUrl } from '../utils/platform'; import { getApiUrl } from '../utils/platform';
import Button from '../components/ui/Button'; import Button from '../components/ui/Button';
import type { Movie } from '../types'; import type { Movie } from '../types';
@@ -68,6 +70,17 @@ export default function TVPlayer() {
const [hlsUrl, setHlsUrl] = useState<string | null>(null); const [hlsUrl, setHlsUrl] = useState<string | null>(null);
const [status, setStatus] = useState<'checking' | 'connecting' | 'buffering' | 'ready' | 'error'>('checking'); const [status, setStatus] = useState<'checking' | 'connecting' | 'buffering' | 'ready' | 'error'>('checking');
const [error, setError] = useState<string | null>(null); 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 // Next episode state
const [showNextEpisode, setShowNextEpisode] = useState(false); const [showNextEpisode, setShowNextEpisode] = useState(false);
@@ -137,6 +150,7 @@ export default function TVPlayer() {
} }
setSessionId(result.sessionId); setSessionId(result.sessionId);
sessionIdRef.current = result.sessionId;
// Get initial status immediately // Get initial status immediately
const initialStatus = await streamingService.getStatus(result.sessionId); const initialStatus = await streamingService.getStatus(result.sessionId);
@@ -179,6 +193,39 @@ export default function TVPlayer() {
setStreamUrl(streamingService.getVideoUrl(result.sessionId)); setStreamUrl(streamingService.getVideoUrl(result.sessionId));
setStatus('ready'); 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 // Try to start HLS transcoding for better compatibility
// Wait longer for video file to be fully available on disk // Wait longer for video file to be fully available on disk
const attemptHlsStart = async (attempt = 1, maxAttempts = 5) => { const attemptHlsStart = async (attempt = 1, maxAttempts = 5) => {
@@ -234,12 +281,14 @@ export default function TVPlayer() {
// Cleanup on unmount // Cleanup on unmount
return () => { return () => {
if (sessionId) { // Use ref to get current sessionId (fixes stale closure issue)
streamingService.stopStream(sessionId).catch(console.error); if (sessionIdRef.current) {
// Auto-delete files if setting is enabled
streamingService.stopStream(sessionIdRef.current, autoDeleteRef.current).catch(console.error);
streamingService.disconnect(); streamingService.disconnect();
} }
}; };
}, [status, torrentHash, showTitle, season, episode, episodeTitle, quality]); }, [status, torrentHash, showTitle, season, episode, episodeTitle, quality, showId, poster, backdrop, addToHistory]);
// Get next episode info // Get next episode info
const getNextEpisodeInfo = useCallback(async () => { const getNextEpisodeInfo = useCallback(async () => {
@@ -394,6 +443,12 @@ export default function TVPlayer() {
(currentTime: number, duration: number) => { (currentTime: number, duration: number) => {
videoCurrentTimeRef.current = currentTime; videoCurrentTimeRef.current = currentTime;
videoDurationRef.current = duration; 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 // Check if we're in the last 30 seconds
const timeRemaining = duration - currentTime; const timeRemaining = duration - currentTime;
@@ -401,11 +456,14 @@ export default function TVPlayer() {
const SHOW_OVERLAY_THRESHOLD = 10; // Show overlay 10 seconds before end const SHOW_OVERLAY_THRESHOLD = 10; // Show overlay 10 seconds before end
if (timeRemaining <= PRELOAD_THRESHOLD && !nextEpisodeInfo && status === 'ready') { if (timeRemaining <= PRELOAD_THRESHOLD && !nextEpisodeInfo && status === 'ready') {
// Start loading next episode info and pre-loading // Start loading next episode info
getNextEpisodeInfo().then((info) => { getNextEpisodeInfo().then((info) => {
if (info) { if (info) {
setNextEpisodeInfo(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(); 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 // Handle cancel next episode
@@ -477,6 +535,7 @@ export default function TVPlayer() {
setError(null); setError(null);
setStatus('checking'); setStatus('checking');
setSessionId(null); setSessionId(null);
sessionIdRef.current = null;
setStreamSession(null); setStreamSession(null);
setStreamUrl(null); setStreamUrl(null);
setHlsUrl(null); setHlsUrl(null);

View 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()),
}
);

View 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');
}
}

View 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();

View 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,
};
}

View File

@@ -206,10 +206,14 @@ class StreamingService {
/** /**
* Stop streaming session * 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(); const apiUrl = getApiUrlValue();
await axios.delete(`${apiUrl}/api/stream/${sessionId}`); await axios.delete(`${apiUrl}/api/stream/${sessionId}`, {
params: { cleanup: cleanupFiles },
});
} }
/** /**

View File

@@ -1,27 +1,39 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import type { Movie, HistoryItem } from '../types'; import type { Movie, HistoryItem } from '../types';
import { useProfileStore } from './profileStore';
interface HistoryItemWithProfile extends HistoryItem {
profileId: string;
}
interface HistoryState { interface HistoryState {
items: HistoryItem[]; items: HistoryItemWithProfile[];
addToHistory: (movie: Movie, progress?: number, duration?: number) => void; addToHistory: (movie: Movie, progress?: number, duration?: number) => void;
updateProgress: (movieId: number, progress: number, duration: number) => void; updateProgress: (movieId: number, progress: number, duration: number) => void;
markAsCompleted: (movieId: number) => void; markAsCompleted: (movieId: number) => void;
removeFromHistory: (movieId: number) => void; removeFromHistory: (movieId: number) => void;
getProgress: (movieId: number) => HistoryItem | undefined; getProgress: (movieId: number) => HistoryItemWithProfile | undefined;
getProfileItems: () => HistoryItemWithProfile[];
getContinueWatching: () => HistoryItemWithProfile[];
clearHistory: () => void; clearHistory: () => void;
} }
const getActiveProfileId = () => useProfileStore.getState().activeProfileId || 'default';
export const useHistoryStore = create<HistoryState>()( export const useHistoryStore = create<HistoryState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
items: [], items: [],
addToHistory: (movie, progress = 0, duration = 0) => { 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) { if (existing) {
set((state) => ({ set((state) => ({
items: state.items.map((item) => items: state.items.map((item) =>
item.movieId === movie.id item.movieId === movie.id && item.profileId === profileId
? { ...item, watchedAt: new Date(), progress, duration } ? { ...item, watchedAt: new Date(), progress, duration }
: item : item
), ),
@@ -37,39 +49,77 @@ export const useHistoryStore = create<HistoryState>()(
progress, progress,
duration, duration,
completed: false, completed: false,
profileId,
}, },
...state.items, ...state.items,
], ],
})); }));
} }
}, },
updateProgress: (movieId, progress, duration) => updateProgress: (movieId, progress, duration) => {
const profileId = getActiveProfileId();
set((state) => ({ set((state) => ({
items: state.items.map((item) => items: state.items.map((item) =>
item.movieId === movieId item.movieId === movieId && item.profileId === profileId
? { ? {
...item, ...item,
progress, progress,
duration, duration,
completed: progress / duration > 0.9, completed: duration > 0 ? progress / duration > 0.9 : false,
watchedAt: new Date(), watchedAt: new Date(),
} }
: item : item
), ),
})), }));
markAsCompleted: (movieId) => },
markAsCompleted: (movieId) => {
const profileId = getActiveProfileId();
set((state) => ({ set((state) => ({
items: state.items.map((item) => 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) => ({ set((state) => ({
items: state.items.filter((item) => item.movieId !== movieId), items: state.items.filter(
})), (item) => !(item.movieId === movieId && item.profileId === profileId)
getProgress: (movieId) => ),
get().items.find((item) => item.movieId === movieId), }));
clearHistory: () => set({ items: [] }), },
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', name: 'bestream-history',

121
src/stores/profileStore.ts Normal file
View 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}`;
};

View File

@@ -12,6 +12,9 @@ const defaultSettings: AppSettings = {
preferredQuality: '1080p', preferredQuality: '1080p',
preferredLanguage: 'en', preferredLanguage: 'en',
autoPlay: true, autoPlay: true,
autoDeleteStreams: true,
autoQuality: true,
autoDownloadNextEpisode: true,
notifications: true, notifications: true,
downloadPath: '', downloadPath: '',
maxConcurrentDownloads: 3, maxConcurrentDownloads: 3,

View File

@@ -1,21 +1,32 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import type { Movie, WatchlistItem } from '../types'; import type { Movie, WatchlistItem } from '../types';
import { useProfileStore } from './profileStore';
interface WatchlistItemWithProfile extends WatchlistItem {
profileId: string;
}
interface WatchlistState { interface WatchlistState {
items: WatchlistItem[]; items: WatchlistItemWithProfile[];
addToWatchlist: (movie: Movie) => void; addToWatchlist: (movie: Movie) => void;
removeFromWatchlist: (movieId: number) => void; removeFromWatchlist: (movieId: number) => void;
isInWatchlist: (movieId: number) => boolean; isInWatchlist: (movieId: number) => boolean;
getProfileItems: () => WatchlistItemWithProfile[];
clearWatchlist: () => void; clearWatchlist: () => void;
} }
const getActiveProfileId = () => useProfileStore.getState().activeProfileId || 'default';
export const useWatchlistStore = create<WatchlistState>()( export const useWatchlistStore = create<WatchlistState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
items: [], items: [],
addToWatchlist: (movie) => { 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) { if (!exists) {
set((state) => ({ set((state) => ({
items: [ items: [
@@ -25,18 +36,36 @@ export const useWatchlistStore = create<WatchlistState>()(
movieId: movie.id, movieId: movie.id,
movie, movie,
addedAt: new Date(), addedAt: new Date(),
profileId,
}, },
], ],
})); }));
} }
}, },
removeFromWatchlist: (movieId) => removeFromWatchlist: (movieId) => {
const profileId = getActiveProfileId();
set((state) => ({ set((state) => ({
items: state.items.filter((item) => item.movieId !== movieId), items: state.items.filter(
})), (item) => !(item.movieId === movieId && item.profileId === profileId)
isInWatchlist: (movieId) => ),
get().items.some((item) => item.movieId === movieId), }));
clearWatchlist: () => set({ items: [] }), },
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', name: 'bestream-watchlist',

View File

@@ -166,12 +166,38 @@ export interface AppSettings {
preferredQuality: string; preferredQuality: string;
preferredLanguage: string; preferredLanguage: string;
autoPlay: boolean; autoPlay: boolean;
autoDeleteStreams: boolean;
autoQuality: boolean;
autoDownloadNextEpisode: boolean;
notifications: boolean; notifications: boolean;
downloadPath: string; downloadPath: string;
maxConcurrentDownloads: number; maxConcurrentDownloads: number;
theme: 'dark' | 'light' | 'system'; 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 // Torrent Types
export interface TorrentInfo { export interface TorrentInfo {
infoHash: string; infoHash: string;

199
src/utils/network.ts Normal file
View 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;
}