diff --git a/server/src/index.js b/server/src/index.js index 7a8d050..d36611d 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -24,9 +24,17 @@ export { broadcastUpdate }; // Read version from package.json to avoid hardcoding const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const packageJsonPath = join(__dirname, '..', 'package.json'); -const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); -const SERVER_VERSION = packageJson.version; + +// Try to read version from package.json, fallback to hardcoded version for Android +let SERVER_VERSION = '2.0.0'; +try { + const packageJsonPath = join(__dirname, '..', 'package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + SERVER_VERSION = packageJson.version; +} catch (err) { + // On Android, package.json may not exist in the bundled assets + console.log('[beStream Server] Could not read package.json, using default version'); +} console.log('[beStream Server] Imports loaded successfully'); @@ -68,6 +76,7 @@ const ALLOWED_ORIGINS = [ // Capacitor 'capacitor://localhost', 'http://localhost', + 'https://localhost', // Android WebView (file:// protocol) null, // file:// origins are null ]; diff --git a/server/src/routes/stream.js b/server/src/routes/stream.js index de93a14..c5490d8 100644 --- a/server/src/routes/stream.js +++ b/server/src/routes/stream.js @@ -461,14 +461,17 @@ streamRouter.get('/:sessionId/info', validate(schemas.sessionId, 'params'), asyn /** * Stop a streaming session * DELETE /api/stream/:sessionId + * Query params: + * - cleanup: If 'true', delete downloaded files */ streamRouter.delete('/:sessionId', validate(schemas.sessionId, 'params'), async (req, res) => { const { sessionId } = req.params; + const cleanupFiles = req.query.cleanup === 'true'; - await torrentManager.stopSession(sessionId); + await torrentManager.stopSession(sessionId, { cleanupFiles }); transcoder.cleanupSession(sessionId); - res.json({ status: 'stopped' }); + res.json({ status: 'stopped', filesDeleted: cleanupFiles }); }); /** diff --git a/server/src/services/transcoder.js b/server/src/services/transcoder.js index 9f96933..9e037ae 100644 --- a/server/src/services/transcoder.js +++ b/server/src/services/transcoder.js @@ -198,19 +198,21 @@ class Transcoder { // Detect if this is 4K - downscale for real-time playback const is4K = quality === '2160p' || quality === '4K' || quality?.includes('2160'); - // Default settings for 1080p and below - let maxrate = '4M'; - let bufsize = '8M'; - let crf = '23'; + // Quality settings optimized for good visual quality with real-time playback + // Using 'veryfast' preset instead of 'ultrafast' - much better quality, still fast + // Higher bitrates prevent pixelation and motion artifacts in action scenes + let maxrate = '8M'; // Good bitrate for 1080p content + let bufsize = '16M'; // 2x maxrate for smooth bitrate allocation + let crf = '20'; // Lower CRF = better quality (18-23 is good range) let scaleFilter = null; if (is4K) { // 4K sources are downscaled to 1080p for real-time playback // Full 4K transcoding is too demanding for most local systems console.log(`🎬 Starting HLS transcode for 4K video → 1080p (session ${sessionId})`); - maxrate = '8M'; // Good bitrate for 1080p from 4K source - bufsize = '16M'; // Larger buffer - crf = '21'; // Slightly better quality since downscaling + maxrate = '12M'; // Higher bitrate for 4K downscaled content + bufsize = '24M'; // Larger buffer for complex scenes + crf = '18'; // Better quality for 4K source material scaleFilter = 'scale=1920:-2'; // Downscale to 1080p, maintain aspect ratio } else { console.log(`🎬 Starting HLS transcode for session ${sessionId}`); @@ -225,18 +227,20 @@ class Transcoder { '-hls_segment_type', 'mpegts', '-hls_segment_filename', path.join(outputDir, 'segment_%03d.ts'), - // Video encoding + // Video encoding - optimized for quality '-c:v', 'libx264', - '-preset', 'ultrafast', // Fast encoding for real-time - '-tune', 'zerolatency', // Low latency + '-preset', 'veryfast', // Good balance of speed and quality + '-tune', 'film', // Optimize for movie content (better grain/detail) '-crf', crf, // Quality (lower = better) - '-maxrate', maxrate, // Max bitrate (adjusted for quality) - '-bufsize', bufsize, // Buffer size (adjusted for quality) + '-maxrate', maxrate, // Max bitrate for complex scenes + '-bufsize', bufsize, // Buffer size for bitrate smoothing '-g', '48', // Keyframe interval + '-bf', '3', // B-frames for better compression/quality + '-refs', '3', // Reference frames for motion prediction // Audio encoding '-c:a', 'aac', - '-b:a', '128k', + '-b:a', '192k', // Higher audio bitrate for better quality '-ac', '2', // Stereo // General @@ -318,18 +322,20 @@ class Transcoder { // Detect if this is 4K - downscale for real-time playback const is4K = quality === '2160p' || quality === '4K' || quality?.includes('2160'); - // Default settings for 1080p and below - let maxrate = '4M'; - let bufsize = '8M'; - let crf = '23'; + // Quality settings optimized for good visual quality with real-time playback + // Using 'veryfast' preset instead of 'ultrafast' - much better quality, still fast + // Higher bitrates prevent pixelation and motion artifacts in action scenes + let maxrate = '8M'; // Good bitrate for 1080p content + let bufsize = '16M'; // 2x maxrate for smooth bitrate allocation + let crf = '20'; // Lower CRF = better quality (18-23 is good range) let scaleFilter = null; if (is4K) { // 4K sources are downscaled to 1080p for real-time playback console.log(`🎬 Starting HLS stream transcode for 4K video → 1080p (session ${sessionId})`); - maxrate = '8M'; // Good bitrate for 1080p from 4K source - bufsize = '16M'; // Larger buffer - crf = '21'; // Slightly better quality since downscaling + maxrate = '12M'; // Higher bitrate for 4K downscaled content + bufsize = '24M'; // Larger buffer for complex scenes + crf = '18'; // Better quality for 4K source material scaleFilter = 'scale=1920:-2'; // Downscale to 1080p, maintain aspect ratio } else { console.log(`🎬 Starting HLS stream transcode for session ${sessionId} (from WebTorrent stream)`); @@ -344,18 +350,20 @@ class Transcoder { '-hls_segment_type', 'mpegts', '-hls_segment_filename', path.join(outputDir, 'segment_%03d.ts'), - // Video encoding + // Video encoding - optimized for quality '-c:v', 'libx264', - '-preset', 'ultrafast', // Fast encoding for real-time - '-tune', 'zerolatency', // Low latency + '-preset', 'veryfast', // Good balance of speed and quality + '-tune', 'film', // Optimize for movie content (better grain/detail) '-crf', crf, // Quality (lower = better) - '-maxrate', maxrate, // Max bitrate (adjusted for quality) - '-bufsize', bufsize, // Buffer size (adjusted for quality) + '-maxrate', maxrate, // Max bitrate for complex scenes + '-bufsize', bufsize, // Buffer size for bitrate smoothing '-g', '48', // Keyframe interval + '-bf', '3', // B-frames for better compression/quality + '-refs', '3', // Reference frames for motion prediction // Audio encoding '-c:a', 'aac', - '-b:a', '128k', + '-b:a', '192k', // Higher audio bitrate for better quality '-ac', '2', // Stereo // General @@ -425,11 +433,13 @@ class Transcoder { .seekInput(startTime) .outputOptions([ '-c:v', videoCodec, - '-preset', 'ultrafast', - '-tune', 'zerolatency', - '-crf', '23', + '-preset', 'veryfast', // Good balance of speed and quality + '-tune', 'film', // Optimize for movie content + '-crf', '20', // Good quality + '-maxrate', '8M', // Prevent bitrate spikes + '-bufsize', '16M', // Smooth bitrate allocation '-c:a', audioCodec, - '-b:a', '128k', + '-b:a', '192k', // Higher audio quality '-movflags', 'frag_keyframe+empty_moov+faststart', '-f', 'mp4', ]) diff --git a/src/App.tsx b/src/App.tsx index f8421a2..f37f583 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,22 @@ -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route, useLocation } from 'react-router-dom'; import { useEffect, Suspense, lazy } from 'react'; import Layout from './components/layout/Layout'; import ErrorBoundary from './components/ErrorBoundary'; +import ProfileGate from './components/profile/ProfileGate'; import { useSettingsStore } from './stores/settingsStore'; +// Scroll to top on route change +function ScrollToTop() { + const { pathname } = useLocation(); + + useEffect(() => { + // Scroll to top on every route change + window.scrollTo(0, 0); + }, [pathname]); + + return null; +} + // Lazy load routes for code splitting const Home = lazy(() => import('./pages/Home')); const Browse = lazy(() => import('./pages/Browse')); @@ -41,8 +54,10 @@ function App() { return ( - }> - + + + }> + }> } /> - - + + + ); } diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index d3a6e45..35f5ba2 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { @@ -15,9 +15,15 @@ import { Minus, Square, Maximize2, + ChevronDown, + User, + LogOut, } from 'lucide-react'; import { clsx } from 'clsx'; import { isElectron } from '../../utils/platform'; +import { useProfileStore } from '../../stores/profileStore'; +import { Avatar } from '../profile/Avatar'; +import type { AvatarId } from '../../types'; const navLinks = [ { path: '/', label: 'Home', icon: Home }, @@ -33,10 +39,16 @@ export default function Navbar() { const [searchQuery, setSearchQuery] = useState(''); const [isSearchOpen, setIsSearchOpen] = useState(false); const [isMaximized, setIsMaximized] = useState(false); + const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); + const profileMenuRef = useRef(null); const location = useLocation(); const navigate = useNavigate(); const isElectronApp = isElectron(); + // Profile store + const { profiles, activeProfileId, getActiveProfile, setActiveProfile } = useProfileStore(); + const activeProfile = getActiveProfile(); + useEffect(() => { const handleScroll = () => { setIsScrolled(window.scrollY > 50); @@ -48,8 +60,20 @@ export default function Navbar() { // Close mobile menu on route change useEffect(() => { setIsMobileMenuOpen(false); + setIsProfileMenuOpen(false); }, [location.pathname]); + // Close profile menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (profileMenuRef.current && !profileMenuRef.current.contains(event.target as Node)) { + setIsProfileMenuOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + // Check if window is maximized (Electron only) useEffect(() => { if (isElectronApp && window.electron) { @@ -200,6 +224,91 @@ export default function Navbar() { + {/* Profile Dropdown */} +
+ setIsProfileMenuOpen(!isProfileMenuOpen)} + className="flex items-center gap-2 p-1 hover:bg-white/10 rounded-lg transition-colors" + > + {activeProfile ? ( + + ) : ( +
+ +
+ )} + +
+ + {/* Profile Dropdown Menu */} + + {isProfileMenuOpen && ( + + {/* Current Profile */} + {activeProfile && ( +
+

Current Profile

+
+ + {activeProfile.name} +
+
+ )} + + {/* Other Profiles */} + {profiles.filter(p => p.id !== activeProfileId).length > 0 && ( +
+

Switch Profile

+ {profiles + .filter((p) => p.id !== activeProfileId) + .map((profile) => ( + + ))} +
+ )} + + {/* Actions */} +
+ +
+
+ )} +
+
+ {/* Mobile Menu Button */} - )} +
+ +

Audio May Not Work

+

+ This video uses an MKV format with audio codecs that may not play in the browser. + Use the Native Player for full audio support. +

+
+ +
- +

+ Tip: MP4 torrents usually have better compatibility +

)} @@ -1201,6 +1210,30 @@ export default function StreamingPlayer({ )} + {/* Cast Button */} + { + // 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 */} + ))} + + ); +}); + +export default Avatar; diff --git a/src/components/profile/ProfileGate.tsx b/src/components/profile/ProfileGate.tsx new file mode 100644 index 0000000..3524562 --- /dev/null +++ b/src/components/profile/ProfileGate.tsx @@ -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('selector'); + const [editingProfile, setEditingProfile] = useState(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 ( + + ); + } + + return ( + + ); + } + + // 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, + }; +} diff --git a/src/hooks/useCast.ts b/src/hooks/useCast.ts new file mode 100644 index 0000000..61348d0 --- /dev/null +++ b/src/hooks/useCast.ts @@ -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; + endSession: () => Promise; + castMedia: (options: CastMediaOptions) => Promise; + play: () => Promise; + pause: () => Promise; + seek: (time: number) => Promise; + stop: () => Promise; +} + +interface CastMediaOptions { + url: string; + title: string; + subtitle?: string; + imageUrl?: string; + contentType?: string; + startTime?: number; +} + +export function useCast(): UseCastResult { + const [state, setState] = useState(castService.currentState); + const [isConnected, setIsConnected] = useState(castService.isConnected); + const [deviceName, setDeviceName] = useState(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 => { + return castService.requestSession(); + }, []); + + const endSession = useCallback(async (): Promise => { + await castService.endSession(); + }, []); + + const castMedia = useCallback(async (options: CastMediaOptions): Promise => { + 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 => { + await castService.play(); + }, []); + + const pause = useCallback(async (): Promise => { + await castService.pause(); + }, []); + + const seek = useCallback(async (time: number): Promise => { + await castService.seek(time); + }, []); + + const stop = useCallback(async (): Promise => { + await castService.stop(); + }, []); + + return { + isAvailable: state !== 'unavailable', + isConnected, + state, + deviceName, + playerState, + currentTime, + duration, + requestSession, + endSession, + castMedia, + play, + pause, + seek, + stop, + }; +} diff --git a/src/hooks/useRecommendations.ts b/src/hooks/useRecommendations.ts new file mode 100644 index 0000000..4b2921f --- /dev/null +++ b/src/hooks/useRecommendations.ts @@ -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([]); + const [basedOnGenres, setBasedOnGenres] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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(null); + const [recommendations, setRecommendations] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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>([]); + 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 }; +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 65f333e..ec83569 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -5,7 +5,9 @@ import MovieRow from '../components/movie/MovieRow'; import TVRow from '../components/tv/TVRow'; import { HeroSkeleton } from '../components/ui/Skeleton'; import { useTrending, useLatest, useTopRated, useByGenre } from '../hooks/useMovies'; +import { usePersonalizedRecommendations, useMultipleBecauseYouWatched } from '../hooks/useRecommendations'; import { tvDiscoveryApi, type DiscoveredShow } from '../services/api/tvDiscovery'; +import { useHistoryStore } from '../stores/historyStore'; import { HERO_MOVIES_COUNT } from '../constants'; export default function Home() { @@ -18,6 +20,35 @@ export default function Home() { const { movies: scifi, isLoading: scifiLoading } = useByGenre('sci-fi'); const { movies: drama, isLoading: dramaLoading } = useByGenre('drama'); + // Watch history for personalized sections + const { getContinueWatching, getProfileItems } = useHistoryStore(); + + // Continue Watching - profile-aware, movies with 5-90% progress + const continueWatchingItems = getContinueWatching(); + const continueWatching = useMemo( + () => continueWatchingItems.map((item) => item.movie), + [continueWatchingItems] + ); + + // Recently Watched - completed movies for the current profile + const profileItems = getProfileItems(); + const recentlyWatched = useMemo( + () => + profileItems + .filter((item) => item.completed) + .slice(0, 10) + .map((item) => item.movie), + [profileItems] + ); + + // Personalized recommendations based on watch history genres + const { movies: recommendations, basedOnGenres, isLoading: recommendationsLoading } = + usePersonalizedRecommendations(20); + + // "Because You Watched" rows + const { rows: becauseYouWatchedRows, isLoading: becauseYouWatchedLoading } = + useMultipleBecauseYouWatched(2); + // Memoize hero movies to prevent unnecessary re-renders const heroMovies = useMemo(() => trending.slice(0, HERO_MOVIES_COUNT), [trending]); @@ -73,6 +104,43 @@ export default function Home() { {/* Movie Rows */}
+ {/* Personalized Sections - only show when user has watch history */} + {continueWatching.length > 0 && ( + + )} + + {recentlyWatched.length > 0 && ( + + )} + + {recommendations && recommendations.length > 0 && ( + 0 + ? `Recommended: ${basedOnGenres.slice(0, 2).join(' & ')}` + : 'Recommended For You'} + movies={recommendations} + isLoading={recommendationsLoading} + /> + )} + + {/* Because You Watched rows */} + {becauseYouWatchedRows.map((row) => ( + + ))} + (null); const [streamSession, setStreamSession] = useState(null); @@ -35,6 +38,9 @@ export default function Player() { const sessionIdRef = useRef(null); // Ref to track polling timeout for cleanup const pollTimeoutRef = useRef(null); + // Ref to track autoDeleteStreams setting for cleanup + const autoDeleteRef = useRef(settings.autoDeleteStreams); + autoDeleteRef.current = settings.autoDeleteStreams; // Get initial playback position const historyItem = movie ? getProgress(movie.id) : undefined; @@ -72,18 +78,32 @@ export default function Player() { checkAndStartServer(); }, []); - // Select the best torrent based on preference + // Select the best torrent based on preference and network conditions useEffect(() => { if (movie?.torrents?.length && status === 'connecting') { const torrents = movie.torrents; - - // Find preferred quality or best available - let torrent = preferredQuality - ? torrents.find((t) => t.quality === preferredQuality) + const availableQualities = torrents.map((t) => t.quality); + + let selectedQuality: string | null = null; + + // If preferred quality is specified in URL, use it + if (preferredQuality) { + selectedQuality = preferredQuality; + } + // If auto-quality is enabled, select based on network conditions + else if (settings.autoQuality) { + const networkInfo = getNetworkInfo(); + selectedQuality = selectOptimalQuality(availableQualities, networkInfo); + logger.info(`[Auto-Quality] Network: ${networkInfo.quality}, Bandwidth: ${networkInfo.downlink || 'unknown'}Mbps, Selected: ${selectedQuality}`); + } + + // Find torrent matching selected quality + let torrent = selectedQuality + ? torrents.find((t) => t.quality === selectedQuality) : null; if (!torrent) { - // Prefer 1080p, then 720p, then highest seeds + // Fallback: Prefer 1080p, then 720p, then highest seeds torrent = torrents.find((t) => t.quality === '1080p') || torrents.find((t) => t.quality === '720p') || @@ -92,7 +112,7 @@ export default function Player() { setSelectedTorrent(torrent); } - }, [movie, preferredQuality, status]); + }, [movie, preferredQuality, status, settings.autoQuality]); // Start streaming when torrent is selected useEffect(() => { @@ -247,7 +267,8 @@ export default function Player() { } // Use ref to get current sessionId (fixes stale closure issue) if (sessionIdRef.current) { - streamingService.stopStream(sessionIdRef.current).catch((err) => { + // Auto-delete files if setting is enabled + streamingService.stopStream(sessionIdRef.current, autoDeleteRef.current).catch((err) => { logger.error('Error stopping stream', err); }); streamingService.disconnect(); diff --git a/src/pages/ProfileManager.tsx b/src/pages/ProfileManager.tsx new file mode 100644 index 0000000..2ef5166 --- /dev/null +++ b/src/pages/ProfileManager.tsx @@ -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(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 ( +
+ {/* Header */} +
+ +
+ + {/* Title */} + + {isEditing ? 'Edit Profile' : 'Add Profile'} + + + {/* Form */} + + {/* Current Avatar Preview */} +
+ +
+ + {/* Name Input */} +
+ + 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 &&

{error}

} +
+ + {/* Avatar Selector */} +
+ + +
+ + {/* Action Buttons */} +
+ + +
+ + {/* Delete Button */} + {canDelete && ( +
+ {showDeleteConfirm ? ( +
+

+ Delete this profile? All watchlist and history data will be lost. +

+
+ + +
+
+ ) : ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/pages/ProfileSelector.tsx b/src/pages/ProfileSelector.tsx new file mode 100644 index 0000000..d1de071 --- /dev/null +++ b/src/pages/ProfileSelector.tsx @@ -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 ( +
+ {/* Logo */} + + beStream + + + {/* Title */} + + Who's watching? + + + {/* Profiles Grid */} + + {profiles.map((profile, index) => ( + handleProfileClick(profile)} + className="group flex flex-col items-center" + > +
+ + {isManaging && ( +
+ +
+ )} +
+ + {profile.name} + +
+ ))} + + {/* Add Profile Button */} + {canCreateProfile() && ( + +
+ +
+ + Add Profile + +
+ )} +
+ + {/* Manage Profiles Button */} + {profiles.length > 0 && ( + 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'} + + )} +
+ ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index f4e76fc..b1afd5d 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -121,6 +121,48 @@ export default function Settings() { />
+ +
+
+

Auto-delete Streams

+

+ Automatically delete downloaded files when streaming ends +

+
+ +
+ +
+
+

Auto-Quality

+

+ Automatically select video quality based on your network speed +

+
+ +
@@ -153,6 +195,27 @@ export default function Settings() { }} /> +
+
+

Smart Downloads

+

+ Automatically start downloading the next episode when watching TV shows +

+
+ +
+

Download location is managed by your operating system's default diff --git a/src/pages/TVPlayer.tsx b/src/pages/TVPlayer.tsx index ec2fcf9..f1b01ff 100644 --- a/src/pages/TVPlayer.tsx +++ b/src/pages/TVPlayer.tsx @@ -5,6 +5,8 @@ import { Loader2, AlertCircle, Server, RefreshCw } from 'lucide-react'; import StreamingPlayer from '../components/player/StreamingPlayer'; import NextEpisodeOverlay from '../components/player/NextEpisodeOverlay'; import streamingService, { type StreamSession } from '../services/streaming/streamingService'; +import { useSettingsStore } from '../stores/settingsStore'; +import { useHistoryStore } from '../stores/historyStore'; import { getApiUrl } from '../utils/platform'; import Button from '../components/ui/Button'; import type { Movie } from '../types'; @@ -68,6 +70,17 @@ export default function TVPlayer() { const [hlsUrl, setHlsUrl] = useState(null); const [status, setStatus] = useState<'checking' | 'connecting' | 'buffering' | 'ready' | 'error'>('checking'); const [error, setError] = useState(null); + + // Settings + const { settings } = useSettingsStore(); + + // History for progress tracking + const { addToHistory, updateProgress } = useHistoryStore(); + + // Refs for cleanup (to avoid stale closures) + const sessionIdRef = useRef(null); + const autoDeleteRef = useRef(settings.autoDeleteStreams); + autoDeleteRef.current = settings.autoDeleteStreams; // Next episode state const [showNextEpisode, setShowNextEpisode] = useState(false); @@ -137,6 +150,7 @@ export default function TVPlayer() { } setSessionId(result.sessionId); + sessionIdRef.current = result.sessionId; // Get initial status immediately const initialStatus = await streamingService.getStatus(result.sessionId); @@ -179,6 +193,39 @@ export default function TVPlayer() { setStreamUrl(streamingService.getVideoUrl(result.sessionId)); setStatus('ready'); + // Add to history for progress tracking (use showId with negative to distinguish from movies) + // Create a unique ID combining show ID, season, and episode + const episodeId = parseInt(showId || '0') * 10000 + season * 100 + episode; + const historyEntry = { + id: episodeId, + url: '', + imdb_code: '', + title: `${showTitle} - S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`, + title_english: episodeTitle || `Season ${season}, Episode ${episode}`, + title_long: `${showTitle} - ${episodeTitle || `Season ${season}, Episode ${episode}`}`, + slug: '', + year: new Date().getFullYear(), + rating: 0, + runtime: 0, + genres: [], + summary: '', + description_full: '', + synopsis: '', + yt_trailer_code: '', + language: 'en', + mpa_rating: '', + background_image: backdrop ? decodeURIComponent(backdrop) : '', + background_image_original: backdrop ? decodeURIComponent(backdrop) : '', + small_cover_image: poster ? decodeURIComponent(poster) : '', + medium_cover_image: poster ? decodeURIComponent(poster) : '', + large_cover_image: poster ? decodeURIComponent(poster) : '', + state: '', + torrents: [], + date_uploaded: new Date().toISOString(), + date_uploaded_unix: Math.floor(Date.now() / 1000), + }; + addToHistory(historyEntry); + // Try to start HLS transcoding for better compatibility // Wait longer for video file to be fully available on disk const attemptHlsStart = async (attempt = 1, maxAttempts = 5) => { @@ -234,12 +281,14 @@ export default function TVPlayer() { // Cleanup on unmount return () => { - if (sessionId) { - streamingService.stopStream(sessionId).catch(console.error); + // Use ref to get current sessionId (fixes stale closure issue) + if (sessionIdRef.current) { + // Auto-delete files if setting is enabled + streamingService.stopStream(sessionIdRef.current, autoDeleteRef.current).catch(console.error); streamingService.disconnect(); } }; - }, [status, torrentHash, showTitle, season, episode, episodeTitle, quality]); + }, [status, torrentHash, showTitle, season, episode, episodeTitle, quality, showId, poster, backdrop, addToHistory]); // Get next episode info const getNextEpisodeInfo = useCallback(async () => { @@ -394,6 +443,12 @@ export default function TVPlayer() { (currentTime: number, duration: number) => { videoCurrentTimeRef.current = currentTime; videoDurationRef.current = duration; + + // Save progress to history store + if (currentTime > 0 && duration > 0) { + const episodeId = parseInt(showId || '0') * 10000 + season * 100 + episode; + updateProgress(episodeId, currentTime, duration); + } // Check if we're in the last 30 seconds const timeRemaining = duration - currentTime; @@ -401,11 +456,14 @@ export default function TVPlayer() { const SHOW_OVERLAY_THRESHOLD = 10; // Show overlay 10 seconds before end if (timeRemaining <= PRELOAD_THRESHOLD && !nextEpisodeInfo && status === 'ready') { - // Start loading next episode info and pre-loading + // Start loading next episode info getNextEpisodeInfo().then((info) => { if (info) { setNextEpisodeInfo(info); - preloadNextEpisode(info); + // Only pre-load if smart downloads is enabled + if (settings.autoDownloadNextEpisode) { + preloadNextEpisode(info); + } } }); } @@ -440,7 +498,7 @@ export default function TVPlayer() { handlePlayNext(); } }, - [nextEpisodeInfo, status, showNextEpisode, countdown, getNextEpisodeInfo, preloadNextEpisode, handlePlayNext] + [nextEpisodeInfo, status, showNextEpisode, countdown, getNextEpisodeInfo, preloadNextEpisode, handlePlayNext, showId, season, episode, updateProgress, settings.autoDownloadNextEpisode] ); // Handle cancel next episode @@ -477,6 +535,7 @@ export default function TVPlayer() { setError(null); setStatus('checking'); setSessionId(null); + sessionIdRef.current = null; setStreamSession(null); setStreamUrl(null); setHlsUrl(null); diff --git a/src/services/cast/capacitorCast.ts b/src/services/cast/capacitorCast.ts new file mode 100644 index 0000000..14e422b --- /dev/null +++ b/src/services/cast/capacitorCast.ts @@ -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; + + // Session management + showCastDialog(): Promise; + endSession(): Promise; + + // Media control + loadMedia(options: LoadMediaOptions): Promise; + play(): Promise; + pause(): Promise; + stop(): Promise; + seek(options: SeekOptions): Promise; + setVolume(options: VolumeOptions): Promise; + + // 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; +} + +// Register the plugin +// This will be available when the native Android plugin is implemented +export const CapacitorGoogleCast = registerPlugin( + 'CapacitorGoogleCast', + { + // Web implementation fallback (no-op for web since we use the Cast SDK directly) + web: () => + import('./capacitorCastWeb').then((m) => new m.CapacitorGoogleCastWeb()), + } +); diff --git a/src/services/cast/capacitorCastWeb.ts b/src/services/cast/capacitorCastWeb.ts new file mode 100644 index 0000000..32bad1b --- /dev/null +++ b/src/services/cast/capacitorCastWeb.ts @@ -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 { + // No-op on web - Cast SDK is initialized in castService + console.log('[CapacitorGoogleCast Web] No-op initialize'); + } + + async showCastDialog(): Promise { + // No-op - handled by castService on web + console.log('[CapacitorGoogleCast Web] No-op showCastDialog'); + } + + async endSession(): Promise { + console.log('[CapacitorGoogleCast Web] No-op endSession'); + } + + async loadMedia(_options: LoadMediaOptions): Promise { + console.log('[CapacitorGoogleCast Web] No-op loadMedia'); + } + + async play(): Promise { + console.log('[CapacitorGoogleCast Web] No-op play'); + } + + async pause(): Promise { + console.log('[CapacitorGoogleCast Web] No-op pause'); + } + + async stop(): Promise { + console.log('[CapacitorGoogleCast Web] No-op stop'); + } + + async seek(_options: SeekOptions): Promise { + console.log('[CapacitorGoogleCast Web] No-op seek'); + } + + async setVolume(_options: VolumeOptions): Promise { + console.log('[CapacitorGoogleCast Web] No-op setVolume'); + } +} diff --git a/src/services/cast/castService.ts b/src/services/cast/castService.ts new file mode 100644 index 0000000..54ebde0 --- /dev/null +++ b/src/services/cast/castService.ts @@ -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 = new Set(); + private mediaListeners: Set = 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 { + // 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 { + 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 { + 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 { + 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 { + if (this.isCapacitorAndroid()) { + const { CapacitorGoogleCast } = await import('./capacitorCast'); + await CapacitorGoogleCast.play(); + return; + } + + if (this.playerController) { + this.playerController.playOrPause(); + } + } + + async pause(): Promise { + if (this.isCapacitorAndroid()) { + const { CapacitorGoogleCast } = await import('./capacitorCast'); + await CapacitorGoogleCast.pause(); + return; + } + + if (this.playerController) { + this.playerController.playOrPause(); + } + } + + async seek(time: number): Promise { + 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 { + 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 { + 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(); diff --git a/src/services/recommendations/recommendationService.ts b/src/services/recommendations/recommendationService.ts new file mode 100644 index 0000000..1783526 --- /dev/null +++ b/src/services/recommendations/recommendationService.ts @@ -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 = 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, + limit = 20 +): Promise { + 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(); + const movieScores = new Map(); + + 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, + limit = 10 +): Promise { + 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 => { + 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, + }; +} diff --git a/src/services/streaming/streamingService.ts b/src/services/streaming/streamingService.ts index ae83e22..abbda4d 100644 --- a/src/services/streaming/streamingService.ts +++ b/src/services/streaming/streamingService.ts @@ -206,10 +206,14 @@ class StreamingService { /** * Stop streaming session + * @param sessionId - Session to stop + * @param cleanupFiles - If true, delete downloaded files */ - async stopStream(sessionId: string): Promise { + async stopStream(sessionId: string, cleanupFiles: boolean = false): Promise { const apiUrl = getApiUrlValue(); - await axios.delete(`${apiUrl}/api/stream/${sessionId}`); + await axios.delete(`${apiUrl}/api/stream/${sessionId}`, { + params: { cleanup: cleanupFiles }, + }); } /** diff --git a/src/stores/historyStore.ts b/src/stores/historyStore.ts index 8465711..ff50db6 100644 --- a/src/stores/historyStore.ts +++ b/src/stores/historyStore.ts @@ -1,27 +1,39 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { Movie, HistoryItem } from '../types'; +import { useProfileStore } from './profileStore'; + +interface HistoryItemWithProfile extends HistoryItem { + profileId: string; +} interface HistoryState { - items: HistoryItem[]; + items: HistoryItemWithProfile[]; addToHistory: (movie: Movie, progress?: number, duration?: number) => void; updateProgress: (movieId: number, progress: number, duration: number) => void; markAsCompleted: (movieId: number) => void; removeFromHistory: (movieId: number) => void; - getProgress: (movieId: number) => HistoryItem | undefined; + getProgress: (movieId: number) => HistoryItemWithProfile | undefined; + getProfileItems: () => HistoryItemWithProfile[]; + getContinueWatching: () => HistoryItemWithProfile[]; clearHistory: () => void; } +const getActiveProfileId = () => useProfileStore.getState().activeProfileId || 'default'; + export const useHistoryStore = create()( persist( (set, get) => ({ items: [], addToHistory: (movie, progress = 0, duration = 0) => { - const existing = get().items.find((item) => item.movieId === movie.id); + const profileId = getActiveProfileId(); + const existing = get().items.find( + (item) => item.movieId === movie.id && item.profileId === profileId + ); if (existing) { set((state) => ({ items: state.items.map((item) => - item.movieId === movie.id + item.movieId === movie.id && item.profileId === profileId ? { ...item, watchedAt: new Date(), progress, duration } : item ), @@ -37,39 +49,77 @@ export const useHistoryStore = create()( progress, duration, completed: false, + profileId, }, ...state.items, ], })); } }, - updateProgress: (movieId, progress, duration) => + updateProgress: (movieId, progress, duration) => { + const profileId = getActiveProfileId(); set((state) => ({ items: state.items.map((item) => - item.movieId === movieId + item.movieId === movieId && item.profileId === profileId ? { ...item, progress, duration, - completed: progress / duration > 0.9, + completed: duration > 0 ? progress / duration > 0.9 : false, watchedAt: new Date(), } : item ), - })), - markAsCompleted: (movieId) => + })); + }, + markAsCompleted: (movieId) => { + const profileId = getActiveProfileId(); set((state) => ({ items: state.items.map((item) => - item.movieId === movieId ? { ...item, completed: true } : item + item.movieId === movieId && item.profileId === profileId + ? { ...item, completed: true } + : item ), - })), - removeFromHistory: (movieId) => + })); + }, + removeFromHistory: (movieId) => { + const profileId = getActiveProfileId(); set((state) => ({ - items: state.items.filter((item) => item.movieId !== movieId), - })), - getProgress: (movieId) => - get().items.find((item) => item.movieId === movieId), - clearHistory: () => set({ items: [] }), + items: state.items.filter( + (item) => !(item.movieId === movieId && item.profileId === profileId) + ), + })); + }, + getProgress: (movieId) => { + const profileId = getActiveProfileId(); + return get().items.find( + (item) => item.movieId === movieId && item.profileId === profileId + ); + }, + getProfileItems: () => { + const profileId = getActiveProfileId(); + return get().items.filter((item) => item.profileId === profileId); + }, + getContinueWatching: () => { + const profileId = getActiveProfileId(); + return get() + .items.filter((item) => { + if (item.profileId !== profileId) return false; + if (item.completed) return false; + if (!item.duration || item.duration === 0) return false; + const progressPercent = item.progress / item.duration; + // Show items between 5% and 90% progress + return progressPercent >= 0.05 && progressPercent < 0.9; + }) + .sort((a, b) => new Date(b.watchedAt).getTime() - new Date(a.watchedAt).getTime()) + .slice(0, 10); // Limit to 10 items + }, + clearHistory: () => { + const profileId = getActiveProfileId(); + set((state) => ({ + items: state.items.filter((item) => item.profileId !== profileId), + })); + }, }), { name: 'bestream-history', diff --git a/src/stores/profileStore.ts b/src/stores/profileStore.ts new file mode 100644 index 0000000..b41db52 --- /dev/null +++ b/src/stores/profileStore.ts @@ -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>) => 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()( + 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}`; +}; diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 695e6dd..7b44dc3 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -12,6 +12,9 @@ const defaultSettings: AppSettings = { preferredQuality: '1080p', preferredLanguage: 'en', autoPlay: true, + autoDeleteStreams: true, + autoQuality: true, + autoDownloadNextEpisode: true, notifications: true, downloadPath: '', maxConcurrentDownloads: 3, diff --git a/src/stores/watchlistStore.ts b/src/stores/watchlistStore.ts index 7a68f2e..c39849b 100644 --- a/src/stores/watchlistStore.ts +++ b/src/stores/watchlistStore.ts @@ -1,21 +1,32 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { Movie, WatchlistItem } from '../types'; +import { useProfileStore } from './profileStore'; + +interface WatchlistItemWithProfile extends WatchlistItem { + profileId: string; +} interface WatchlistState { - items: WatchlistItem[]; + items: WatchlistItemWithProfile[]; addToWatchlist: (movie: Movie) => void; removeFromWatchlist: (movieId: number) => void; isInWatchlist: (movieId: number) => boolean; + getProfileItems: () => WatchlistItemWithProfile[]; clearWatchlist: () => void; } +const getActiveProfileId = () => useProfileStore.getState().activeProfileId || 'default'; + export const useWatchlistStore = create()( persist( (set, get) => ({ items: [], addToWatchlist: (movie) => { - const exists = get().items.some((item) => item.movieId === movie.id); + const profileId = getActiveProfileId(); + const exists = get().items.some( + (item) => item.movieId === movie.id && item.profileId === profileId + ); if (!exists) { set((state) => ({ items: [ @@ -25,18 +36,36 @@ export const useWatchlistStore = create()( movieId: movie.id, movie, addedAt: new Date(), + profileId, }, ], })); } }, - removeFromWatchlist: (movieId) => + removeFromWatchlist: (movieId) => { + const profileId = getActiveProfileId(); set((state) => ({ - items: state.items.filter((item) => item.movieId !== movieId), - })), - isInWatchlist: (movieId) => - get().items.some((item) => item.movieId === movieId), - clearWatchlist: () => set({ items: [] }), + items: state.items.filter( + (item) => !(item.movieId === movieId && item.profileId === profileId) + ), + })); + }, + isInWatchlist: (movieId) => { + const profileId = getActiveProfileId(); + return get().items.some( + (item) => item.movieId === movieId && item.profileId === profileId + ); + }, + getProfileItems: () => { + const profileId = getActiveProfileId(); + return get().items.filter((item) => item.profileId === profileId); + }, + clearWatchlist: () => { + const profileId = getActiveProfileId(); + set((state) => ({ + items: state.items.filter((item) => item.profileId !== profileId), + })); + }, }), { name: 'bestream-watchlist', diff --git a/src/types/index.ts b/src/types/index.ts index 2fd4af0..4b0a788 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -166,12 +166,38 @@ export interface AppSettings { preferredQuality: string; preferredLanguage: string; autoPlay: boolean; + autoDeleteStreams: boolean; + autoQuality: boolean; + autoDownloadNextEpisode: boolean; notifications: boolean; downloadPath: string; maxConcurrentDownloads: number; theme: 'dark' | 'light' | 'system'; } +// Profile Types +export interface Profile { + id: string; + name: string; + avatar: string; // avatar-1 through avatar-8 + createdAt: number; +} + +export const AVATAR_OPTIONS = [ + 'avatar-1', 'avatar-2', 'avatar-3', 'avatar-4', + 'avatar-5', 'avatar-6', 'avatar-7', 'avatar-8', +] as const; + +export type AvatarId = typeof AVATAR_OPTIONS[number]; + +// Recommendation Types +export interface RecommendationCache { + profileId: string; + recommendations: Movie[]; + generatedAt: number; + basedOnGenres: string[]; +} + // Torrent Types export interface TorrentInfo { infoHash: string; diff --git a/src/utils/network.ts b/src/utils/network.ts new file mode 100644 index 0000000..81c6d0d --- /dev/null +++ b/src/utils/network.ts @@ -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 = [ + '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; +}