Add local profiles, smart features, and Google Cast support
Some checks failed
CI / Lint & Type Check (push) Failing after 43s
CI / Tests (push) Successful in 1m0s
CI / Build Web (push) Has been skipped
CI / Security Scan (push) Successful in 46s
CI / Build Electron (Linux) (push) Has been skipped
CI / Build Tauri (ubuntu-latest) (push) Has been skipped
CI / Build Electron (Windows) (push) Has been cancelled
CI / Build Tauri (windows-latest) (push) Has been cancelled
Some checks failed
CI / Lint & Type Check (push) Failing after 43s
CI / Tests (push) Successful in 1m0s
CI / Build Web (push) Has been skipped
CI / Security Scan (push) Successful in 46s
CI / Build Electron (Linux) (push) Has been skipped
CI / Build Tauri (ubuntu-latest) (push) Has been skipped
CI / Build Electron (Windows) (push) Has been cancelled
CI / Build Tauri (windows-latest) (push) Has been cancelled
- Local Profiles: Profile selector, manager, avatar system, profile-aware stores - Smart Features: Continue Watching, personalized recommendations, auto-quality, smart downloads - Google Cast: Cast service with web SDK and Capacitor Android plugin interface - Settings: New toggles for auto-quality and smart downloads Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,9 +24,17 @@ export { broadcastUpdate };
|
|||||||
// Read version from package.json to avoid hardcoding
|
// 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
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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',
|
||||||
])
|
])
|
||||||
|
|||||||
26
src/App.tsx
26
src/App.tsx
@@ -1,9 +1,22 @@
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||||
import { useEffect, Suspense, lazy } from 'react';
|
import { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
81
src/components/player/CastButton.tsx
Normal file
81
src/components/player/CastButton.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Cast, Loader2 } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { useCast } from '../../hooks/useCast';
|
||||||
|
|
||||||
|
interface CastButtonProps {
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
onCastStart?: () => void;
|
||||||
|
onCastEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CastButton({
|
||||||
|
className,
|
||||||
|
size = 24,
|
||||||
|
onCastStart,
|
||||||
|
onCastEnd,
|
||||||
|
}: CastButtonProps) {
|
||||||
|
const {
|
||||||
|
isAvailable,
|
||||||
|
isConnected,
|
||||||
|
state,
|
||||||
|
deviceName,
|
||||||
|
requestSession,
|
||||||
|
endSession,
|
||||||
|
} = useCast();
|
||||||
|
|
||||||
|
// Don't render if casting is not available
|
||||||
|
if (!isAvailable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (isConnected) {
|
||||||
|
await endSession();
|
||||||
|
onCastEnd?.();
|
||||||
|
} else {
|
||||||
|
const success = await requestSession();
|
||||||
|
if (success) {
|
||||||
|
onCastStart?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isConnecting = state === 'connecting';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isConnecting}
|
||||||
|
className={clsx(
|
||||||
|
'relative p-2 rounded-full transition-colors',
|
||||||
|
isConnected
|
||||||
|
? 'text-netflix-red bg-white/20'
|
||||||
|
: 'text-white hover:bg-white/10',
|
||||||
|
isConnecting && 'opacity-50 cursor-wait',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
isConnected
|
||||||
|
? `Casting to ${deviceName}`
|
||||||
|
: isConnecting
|
||||||
|
? 'Connecting...'
|
||||||
|
: 'Cast to device'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<Loader2 size={size} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Cast size={size} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connected indicator */}
|
||||||
|
{isConnected && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-netflix-red rounded-full border-2 border-black" />
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ import { searchSubtitles, downloadSubtitle, convertSrtToVtt } from '../../servic
|
|||||||
import { useSettingsStore } from '../../stores/settingsStore';
|
import { 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}
|
||||||
|
|||||||
138
src/components/profile/Avatar.tsx
Normal file
138
src/components/profile/Avatar.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import type { AvatarId } from '../../types';
|
||||||
|
|
||||||
|
type AvatarSize = 'sm' | 'md' | 'lg' | number;
|
||||||
|
|
||||||
|
const SIZE_MAP: Record<string, number> = {
|
||||||
|
sm: 32,
|
||||||
|
md: 48,
|
||||||
|
lg: 64,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
avatarId: AvatarId;
|
||||||
|
size?: AvatarSize;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avatar color schemes and icons
|
||||||
|
const AVATAR_CONFIGS: Record<AvatarId, { bg: string; fg: string; icon: string }> = {
|
||||||
|
'avatar-1': { bg: '#E50914', fg: '#fff', icon: 'user' }, // Netflix red
|
||||||
|
'avatar-2': { bg: '#1DB954', fg: '#fff', icon: 'smile' }, // Green
|
||||||
|
'avatar-3': { bg: '#6366F1', fg: '#fff', icon: 'star' }, // Indigo
|
||||||
|
'avatar-4': { bg: '#F59E0B', fg: '#fff', icon: 'zap' }, // Amber
|
||||||
|
'avatar-5': { bg: '#EC4899', fg: '#fff', icon: 'heart' }, // Pink
|
||||||
|
'avatar-6': { bg: '#06B6D4', fg: '#fff', icon: 'film' }, // Cyan
|
||||||
|
'avatar-7': { bg: '#8B5CF6', fg: '#fff', icon: 'ghost' }, // Purple
|
||||||
|
'avatar-8': { bg: '#10B981', fg: '#fff', icon: 'cat' }, // Emerald
|
||||||
|
};
|
||||||
|
|
||||||
|
const IconPaths: Record<string, JSX.Element> = {
|
||||||
|
user: (
|
||||||
|
<path
|
||||||
|
d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v3h20v-3c0-3.3-6.7-5-10-5z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
smile: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" fill="none" />
|
||||||
|
<path d="M8 14s1.5 2 4 2 4-2 4-2" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||||
|
<circle cx="9" cy="9" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="15" cy="9" r="1.5" fill="currentColor" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
star: (
|
||||||
|
<path
|
||||||
|
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
zap: (
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="currentColor" />
|
||||||
|
),
|
||||||
|
heart: (
|
||||||
|
<path
|
||||||
|
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
film: (
|
||||||
|
<>
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="2" stroke="currentColor" strokeWidth="2" fill="none" />
|
||||||
|
<line x1="7" y1="2" x2="7" y2="22" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<line x1="17" y1="2" x2="17" y2="22" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" strokeWidth="2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
ghost: (
|
||||||
|
<path
|
||||||
|
d="M12 2C7.58 2 4 5.58 4 10v12l2-2 2 2 2-2 2 2 2-2 2 2 2-2 2 2V10c0-4.42-3.58-8-8-8zm-2 8a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm5 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cat: (
|
||||||
|
<path
|
||||||
|
d="M12 2c-4 0-8 4-8 8 0 2.5 1 4.5 2.5 6L6 22h2l.5-4h7l.5 4h2l-.5-6c1.5-1.5 2.5-3.5 2.5-6 0-4-4-8-8-8zM8 9l2-4 2 4H8zm4 0l2-4 2 4h-4zm-2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm4 0a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Avatar = memo(function Avatar({ avatarId, size = 'lg', className = '' }: AvatarProps) {
|
||||||
|
const config = AVATAR_CONFIGS[avatarId] || AVATAR_CONFIGS['avatar-1'];
|
||||||
|
const pixelSize = typeof size === 'number' ? size : SIZE_MAP[size] || 64;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-md overflow-hidden flex items-center justify-center ${className}`}
|
||||||
|
style={{
|
||||||
|
width: pixelSize,
|
||||||
|
height: pixelSize,
|
||||||
|
backgroundColor: config.bg,
|
||||||
|
color: config.fg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={pixelSize * 0.6}
|
||||||
|
height={pixelSize * 0.6}
|
||||||
|
style={{ color: config.fg }}
|
||||||
|
>
|
||||||
|
{IconPaths[config.icon]}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AvatarSelector = memo(function AvatarSelector({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
size = 64,
|
||||||
|
}: {
|
||||||
|
selected: AvatarId;
|
||||||
|
onSelect: (id: AvatarId) => void;
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
const avatarIds = Object.keys(AVATAR_CONFIGS) as AvatarId[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{avatarIds.map((id) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => onSelect(id)}
|
||||||
|
className={`rounded-lg p-1 transition-all ${
|
||||||
|
selected === id
|
||||||
|
? 'ring-2 ring-netflix-red ring-offset-2 ring-offset-netflix-black scale-110'
|
||||||
|
: 'hover:scale-105 opacity-70 hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar avatarId={id} size={size} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Avatar;
|
||||||
105
src/components/profile/ProfileGate.tsx
Normal file
105
src/components/profile/ProfileGate.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { useProfileStore } from '../../stores/profileStore';
|
||||||
|
import ProfileSelector from '../../pages/ProfileSelector';
|
||||||
|
import ProfileManager from '../../pages/ProfileManager';
|
||||||
|
import type { Profile } from '../../types';
|
||||||
|
|
||||||
|
type ProfileView = 'selector' | 'manager' | 'edit';
|
||||||
|
|
||||||
|
interface ProfileGateProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileGate({ children }: ProfileGateProps) {
|
||||||
|
const { profiles, activeProfileId, setActiveProfile } = useProfileStore();
|
||||||
|
const [view, setView] = useState<ProfileView>('selector');
|
||||||
|
const [editingProfile, setEditingProfile] = useState<Profile | null>(null);
|
||||||
|
const [showProfileUI, setShowProfileUI] = useState(false);
|
||||||
|
|
||||||
|
// Determine if we should show the profile UI
|
||||||
|
useEffect(() => {
|
||||||
|
// Show profile selector if no active profile or no profiles at all
|
||||||
|
if (!activeProfileId || profiles.length === 0) {
|
||||||
|
setShowProfileUI(true);
|
||||||
|
setView(profiles.length === 0 ? 'manager' : 'selector');
|
||||||
|
} else {
|
||||||
|
setShowProfileUI(false);
|
||||||
|
}
|
||||||
|
}, [activeProfileId, profiles.length]);
|
||||||
|
|
||||||
|
const handleSelectProfile = (profile: Profile) => {
|
||||||
|
setActiveProfile(profile.id);
|
||||||
|
setShowProfileUI(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageProfiles = () => {
|
||||||
|
setEditingProfile(null);
|
||||||
|
setView('manager');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (view === 'edit') {
|
||||||
|
setEditingProfile(null);
|
||||||
|
setView('manager');
|
||||||
|
} else if (profiles.length > 0) {
|
||||||
|
setView('selector');
|
||||||
|
}
|
||||||
|
// If no profiles exist, stay on manager
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (profiles.length === 1 && !activeProfileId) {
|
||||||
|
// Auto-select the first profile after creating
|
||||||
|
const firstProfile = profiles[0] || useProfileStore.getState().profiles[0];
|
||||||
|
if (firstProfile) {
|
||||||
|
setActiveProfile(firstProfile.id);
|
||||||
|
setShowProfileUI(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setEditingProfile(null);
|
||||||
|
setView('selector');
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we need to show profile UI
|
||||||
|
if (showProfileUI) {
|
||||||
|
if (view === 'manager' || view === 'edit') {
|
||||||
|
return (
|
||||||
|
<ProfileManager
|
||||||
|
editingProfile={editingProfile}
|
||||||
|
onBack={handleBack}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProfileSelector
|
||||||
|
onSelectProfile={handleSelectProfile}
|
||||||
|
onManageProfiles={handleManageProfiles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile is selected, show app
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a hook for components to trigger profile switching
|
||||||
|
export function useProfileGate() {
|
||||||
|
const { profiles, setActiveProfile, getActiveProfile } = useProfileStore();
|
||||||
|
const [showSwitch, setShowSwitch] = useState(false);
|
||||||
|
|
||||||
|
const switchProfile = () => {
|
||||||
|
// Clear active profile to trigger ProfileGate to show selector
|
||||||
|
setActiveProfile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeProfile: getActiveProfile(),
|
||||||
|
profiles,
|
||||||
|
showSwitch,
|
||||||
|
setShowSwitch,
|
||||||
|
switchProfile,
|
||||||
|
};
|
||||||
|
}
|
||||||
112
src/hooks/useCast.ts
Normal file
112
src/hooks/useCast.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { castService, type CastState } from '../services/cast/castService';
|
||||||
|
|
||||||
|
interface UseCastResult {
|
||||||
|
isAvailable: boolean;
|
||||||
|
isConnected: boolean;
|
||||||
|
state: CastState;
|
||||||
|
deviceName: string | null;
|
||||||
|
playerState: string;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
requestSession: () => Promise<boolean>;
|
||||||
|
endSession: () => Promise<void>;
|
||||||
|
castMedia: (options: CastMediaOptions) => Promise<boolean>;
|
||||||
|
play: () => Promise<void>;
|
||||||
|
pause: () => Promise<void>;
|
||||||
|
seek: (time: number) => Promise<void>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CastMediaOptions {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
contentType?: string;
|
||||||
|
startTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCast(): UseCastResult {
|
||||||
|
const [state, setState] = useState<CastState>(castService.currentState);
|
||||||
|
const [isConnected, setIsConnected] = useState(castService.isConnected);
|
||||||
|
const [deviceName, setDeviceName] = useState<string | null>(null);
|
||||||
|
const [playerState, setPlayerState] = useState('idle');
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for cast state changes
|
||||||
|
const removeStateListener = castService.addStateListener((newState, session) => {
|
||||||
|
setState(newState);
|
||||||
|
setIsConnected(newState === 'connected');
|
||||||
|
setDeviceName(session?.device.name || null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for media status updates
|
||||||
|
const removeMediaListener = castService.addMediaListener(
|
||||||
|
(newPlayerState, newCurrentTime, newDuration) => {
|
||||||
|
setPlayerState(newPlayerState);
|
||||||
|
setCurrentTime(newCurrentTime);
|
||||||
|
setDuration(newDuration);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeStateListener();
|
||||||
|
removeMediaListener();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestSession = useCallback(async (): Promise<boolean> => {
|
||||||
|
return castService.requestSession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const endSession = useCallback(async (): Promise<void> => {
|
||||||
|
await castService.endSession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const castMedia = useCallback(async (options: CastMediaOptions): Promise<boolean> => {
|
||||||
|
return castService.loadMedia({
|
||||||
|
contentId: options.url,
|
||||||
|
contentType: options.contentType || 'video/mp4',
|
||||||
|
title: options.title,
|
||||||
|
subtitle: options.subtitle,
|
||||||
|
imageUrl: options.imageUrl,
|
||||||
|
currentTime: options.startTime,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const play = useCallback(async (): Promise<void> => {
|
||||||
|
await castService.play();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pause = useCallback(async (): Promise<void> => {
|
||||||
|
await castService.pause();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seek = useCallback(async (time: number): Promise<void> => {
|
||||||
|
await castService.seek(time);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useCallback(async (): Promise<void> => {
|
||||||
|
await castService.stop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAvailable: state !== 'unavailable',
|
||||||
|
isConnected,
|
||||||
|
state,
|
||||||
|
deviceName,
|
||||||
|
playerState,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
requestSession,
|
||||||
|
endSession,
|
||||||
|
castMedia,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
173
src/hooks/useRecommendations.ts
Normal file
173
src/hooks/useRecommendations.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
getPersonalizedRecommendations,
|
||||||
|
getBecauseYouWatched,
|
||||||
|
} from '../services/recommendations/recommendationService';
|
||||||
|
import { useHistoryStore } from '../stores/historyStore';
|
||||||
|
import type { Movie } from '../types';
|
||||||
|
|
||||||
|
interface UseRecommendationsResult {
|
||||||
|
movies: Movie[];
|
||||||
|
basedOnGenres: string[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for personalized recommendations based on watch history
|
||||||
|
export function usePersonalizedRecommendations(limit = 20): UseRecommendationsResult {
|
||||||
|
const [movies, setMovies] = useState<Movie[]>([]);
|
||||||
|
const [basedOnGenres, setBasedOnGenres] = useState<string[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { getProfileItems } = useHistoryStore();
|
||||||
|
const historyItems = getProfileItems();
|
||||||
|
|
||||||
|
// Memoize history data to prevent unnecessary re-fetches
|
||||||
|
const historyHash = useMemo(
|
||||||
|
() => historyItems.map((item) => item.movieId).join(','),
|
||||||
|
[historyItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyItems.length === 0) {
|
||||||
|
setMovies([]);
|
||||||
|
setBasedOnGenres([]);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRecommendations = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const historyMovies = historyItems.map((item) => item.movie);
|
||||||
|
const excludeIds = new Set(historyItems.map((item) => item.movieId));
|
||||||
|
|
||||||
|
const result = await getPersonalizedRecommendations(
|
||||||
|
historyMovies,
|
||||||
|
excludeIds,
|
||||||
|
limit
|
||||||
|
);
|
||||||
|
|
||||||
|
setMovies(result.movies);
|
||||||
|
setBasedOnGenres(result.basedOnGenres);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to get recommendations');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRecommendations();
|
||||||
|
}, [historyHash, limit]);
|
||||||
|
|
||||||
|
return { movies, basedOnGenres, isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BecauseYouWatchedResult {
|
||||||
|
sourceMovie: Movie | null;
|
||||||
|
recommendations: Movie[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for "Because You Watched [Movie]" recommendations
|
||||||
|
export function useBecauseYouWatched(movieId: number | undefined): BecauseYouWatchedResult {
|
||||||
|
const [sourceMovie, setSourceMovie] = useState<Movie | null>(null);
|
||||||
|
const [recommendations, setRecommendations] = useState<Movie[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { getProfileItems } = useHistoryStore();
|
||||||
|
const historyItems = getProfileItems();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!movieId) {
|
||||||
|
setSourceMovie(null);
|
||||||
|
setRecommendations([]);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyItem = historyItems.find((item) => item.movieId === movieId);
|
||||||
|
if (!historyItem) {
|
||||||
|
setSourceMovie(null);
|
||||||
|
setRecommendations([]);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRecommendations = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const excludeIds = new Set(historyItems.map((item) => item.movieId));
|
||||||
|
const recs = await getBecauseYouWatched(historyItem.movie, excludeIds, 10);
|
||||||
|
|
||||||
|
setSourceMovie(historyItem.movie);
|
||||||
|
setRecommendations(recs);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to get recommendations');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRecommendations();
|
||||||
|
}, [movieId, historyItems]);
|
||||||
|
|
||||||
|
return { sourceMovie, recommendations, isLoading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for multiple "Because You Watched" rows
|
||||||
|
export function useMultipleBecauseYouWatched(maxRows = 3): {
|
||||||
|
rows: Array<{ sourceMovie: Movie; recommendations: Movie[] }>;
|
||||||
|
isLoading: boolean;
|
||||||
|
} {
|
||||||
|
const [rows, setRows] = useState<Array<{ sourceMovie: Movie; recommendations: Movie[] }>>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const { getProfileItems } = useHistoryStore();
|
||||||
|
const historyItems = getProfileItems();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (historyItems.length === 0) {
|
||||||
|
setRows([]);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAllRecommendations = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const excludeIds = new Set(historyItems.map((item) => item.movieId));
|
||||||
|
const recentMovies = historyItems.slice(0, maxRows);
|
||||||
|
|
||||||
|
const rowPromises = recentMovies.map(async (item) => {
|
||||||
|
const recs = await getBecauseYouWatched(item.movie, excludeIds, 10);
|
||||||
|
return {
|
||||||
|
sourceMovie: item.movie,
|
||||||
|
recommendations: recs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(rowPromises);
|
||||||
|
// Filter out rows with no recommendations
|
||||||
|
setRows(results.filter((row) => row.recommendations.length > 0));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching recommendations:', err);
|
||||||
|
setRows([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAllRecommendations();
|
||||||
|
}, [historyItems.length, maxRows]);
|
||||||
|
|
||||||
|
return { rows, isLoading };
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import MovieRow from '../components/movie/MovieRow';
|
|||||||
import TVRow from '../components/tv/TVRow';
|
import 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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
175
src/pages/ProfileManager.tsx
Normal file
175
src/pages/ProfileManager.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ArrowLeft, Trash2 } from 'lucide-react';
|
||||||
|
import { useProfileStore } from '../stores/profileStore';
|
||||||
|
import { Avatar, AvatarSelector } from '../components/profile/Avatar';
|
||||||
|
import Button from '../components/ui/Button';
|
||||||
|
import type { Profile, AvatarId } from '../types';
|
||||||
|
|
||||||
|
interface ProfileManagerProps {
|
||||||
|
editingProfile?: Profile | null;
|
||||||
|
onBack: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileManager({ editingProfile, onBack, onSave }: ProfileManagerProps) {
|
||||||
|
const { createProfile, updateProfile, deleteProfile, profiles } = useProfileStore();
|
||||||
|
|
||||||
|
const [name, setName] = useState(editingProfile?.name || '');
|
||||||
|
const [avatar, setAvatar] = useState<AvatarId>(editingProfile?.avatar as AvatarId || 'avatar-1');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
const isEditing = !!editingProfile;
|
||||||
|
const canDelete = isEditing && profiles.length > 1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingProfile) {
|
||||||
|
setName(editingProfile.name);
|
||||||
|
setAvatar(editingProfile.avatar as AvatarId);
|
||||||
|
}
|
||||||
|
}, [editingProfile]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
setError('Please enter a name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedName.length > 20) {
|
||||||
|
setError('Name must be 20 characters or less');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing && editingProfile) {
|
||||||
|
updateProfile(editingProfile.id, { name: trimmedName, avatar });
|
||||||
|
} else {
|
||||||
|
const result = createProfile(trimmedName, avatar);
|
||||||
|
if (!result) {
|
||||||
|
setError('Maximum profiles reached');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (editingProfile && canDelete) {
|
||||||
|
deleteProfile(editingProfile.id);
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-netflix-black flex flex-col items-center p-4 pt-12">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="w-full max-w-md mb-8">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
<span>Back</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="text-2xl md:text-3xl font-medium text-white mb-8"
|
||||||
|
>
|
||||||
|
{isEditing ? 'Edit Profile' : 'Add Profile'}
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<motion.form
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-full max-w-md space-y-8"
|
||||||
|
>
|
||||||
|
{/* Current Avatar Preview */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Avatar avatarId={avatar} size={120} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Enter profile name"
|
||||||
|
maxLength={20}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-md px-4 py-3 text-white placeholder-gray-500 focus:outline-none focus:border-netflix-red transition-colors"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-3">Choose Avatar</label>
|
||||||
|
<AvatarSelector selected={avatar} onSelect={setAvatar} size={56} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-4 pt-4">
|
||||||
|
<Button type="submit" className="flex-1">
|
||||||
|
{isEditing ? 'Save' : 'Create Profile'}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={onBack} className="flex-1">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
{canDelete && (
|
||||||
|
<div className="pt-4 border-t border-gray-800">
|
||||||
|
{showDeleteConfirm ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-center text-gray-400 text-sm">
|
||||||
|
Delete this profile? All watchlist and history data will be lost.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="flex-1 text-red-500 hover:bg-red-500/10"
|
||||||
|
leftIcon={<Trash2 size={18} />}
|
||||||
|
>
|
||||||
|
Yes, Delete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="w-full text-center text-gray-500 hover:text-red-500 transition-colors py-2"
|
||||||
|
>
|
||||||
|
Delete Profile
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/pages/ProfileSelector.tsx
Normal file
117
src/pages/ProfileSelector.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Plus, Pencil } from 'lucide-react';
|
||||||
|
import { useProfileStore } from '../stores/profileStore';
|
||||||
|
import { Avatar } from '../components/profile/Avatar';
|
||||||
|
import type { Profile } from '../types';
|
||||||
|
|
||||||
|
interface ProfileSelectorProps {
|
||||||
|
onSelectProfile: (profile: Profile) => void;
|
||||||
|
onManageProfiles: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileSelector({ onSelectProfile, onManageProfiles }: ProfileSelectorProps) {
|
||||||
|
const { profiles, canCreateProfile } = useProfileStore();
|
||||||
|
const [isManaging, setIsManaging] = useState(false);
|
||||||
|
|
||||||
|
const handleProfileClick = (profile: Profile) => {
|
||||||
|
if (isManaging) {
|
||||||
|
onManageProfiles();
|
||||||
|
} else {
|
||||||
|
onSelectProfile(profile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-netflix-black flex flex-col items-center justify-center p-4">
|
||||||
|
{/* Logo */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
<img src="/icon.svg" alt="beStream" className="h-12 w-12" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="text-3xl md:text-4xl font-medium text-white mb-8"
|
||||||
|
>
|
||||||
|
Who's watching?
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
{/* Profiles Grid */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="flex flex-wrap justify-center gap-4 md:gap-6 max-w-2xl"
|
||||||
|
>
|
||||||
|
{profiles.map((profile, index) => (
|
||||||
|
<motion.button
|
||||||
|
key={profile.id}
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.1 * index }}
|
||||||
|
onClick={() => handleProfileClick(profile)}
|
||||||
|
className="group flex flex-col items-center"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar
|
||||||
|
avatarId={profile.avatar as any}
|
||||||
|
size={100}
|
||||||
|
className="md:w-32 md:h-32 transition-transform group-hover:scale-105 group-hover:ring-2 ring-white"
|
||||||
|
/>
|
||||||
|
{isManaging && (
|
||||||
|
<div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-md">
|
||||||
|
<Pencil size={32} className="text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="mt-3 text-gray-400 group-hover:text-white transition-colors text-sm md:text-base">
|
||||||
|
{profile.name}
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add Profile Button */}
|
||||||
|
{canCreateProfile() && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.1 * profiles.length }}
|
||||||
|
onClick={onManageProfiles}
|
||||||
|
className="group flex flex-col items-center"
|
||||||
|
>
|
||||||
|
<div className="w-[100px] h-[100px] md:w-32 md:h-32 rounded-md bg-gray-800 flex items-center justify-center transition-all group-hover:bg-gray-700 group-hover:scale-105">
|
||||||
|
<Plus size={48} className="text-gray-400 group-hover:text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="mt-3 text-gray-400 group-hover:text-white transition-colors text-sm md:text-base">
|
||||||
|
Add Profile
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Manage Profiles Button */}
|
||||||
|
{profiles.length > 0 && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
onClick={() => setIsManaging(!isManaging)}
|
||||||
|
className={`mt-12 px-6 py-2 border rounded transition-colors ${
|
||||||
|
isManaging
|
||||||
|
? 'border-white bg-white text-black'
|
||||||
|
: 'border-gray-500 text-gray-400 hover:border-white hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isManaging ? 'Done' : 'Manage Profiles'}
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -121,6 +121,48 @@ export default function Settings() {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
101
src/services/cast/capacitorCast.ts
Normal file
101
src/services/cast/capacitorCast.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Capacitor plugin interface for Google Cast on Android
|
||||||
|
// This bridges to a native Android plugin implementation
|
||||||
|
|
||||||
|
import { registerPlugin } from '@capacitor/core';
|
||||||
|
|
||||||
|
export interface LoadMediaOptions {
|
||||||
|
url: string;
|
||||||
|
contentType: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
startTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeekOptions {
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VolumeOptions {
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CastDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CastStateChangeEvent {
|
||||||
|
state: 'unavailable' | 'available' | 'connecting' | 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceAvailableEvent {
|
||||||
|
devices: CastDevice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStartedEvent {
|
||||||
|
device: CastDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaStatusEvent {
|
||||||
|
playerState: 'idle' | 'playing' | 'paused' | 'buffering';
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapacitorGoogleCastPlugin {
|
||||||
|
// Initialization
|
||||||
|
initialize(): Promise<void>;
|
||||||
|
|
||||||
|
// Session management
|
||||||
|
showCastDialog(): Promise<void>;
|
||||||
|
endSession(): Promise<void>;
|
||||||
|
|
||||||
|
// Media control
|
||||||
|
loadMedia(options: LoadMediaOptions): Promise<void>;
|
||||||
|
play(): Promise<void>;
|
||||||
|
pause(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
seek(options: SeekOptions): Promise<void>;
|
||||||
|
setVolume(options: VolumeOptions): Promise<void>;
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
addListener(
|
||||||
|
eventName: 'castStateChanged',
|
||||||
|
listenerFunc: (event: CastStateChangeEvent) => void
|
||||||
|
): Promise<{ remove: () => void }>;
|
||||||
|
|
||||||
|
addListener(
|
||||||
|
eventName: 'deviceAvailable',
|
||||||
|
listenerFunc: (event: DeviceAvailableEvent) => void
|
||||||
|
): Promise<{ remove: () => void }>;
|
||||||
|
|
||||||
|
addListener(
|
||||||
|
eventName: 'sessionStarted',
|
||||||
|
listenerFunc: (event: SessionStartedEvent) => void
|
||||||
|
): Promise<{ remove: () => void }>;
|
||||||
|
|
||||||
|
addListener(
|
||||||
|
eventName: 'sessionEnded',
|
||||||
|
listenerFunc: () => void
|
||||||
|
): Promise<{ remove: () => void }>;
|
||||||
|
|
||||||
|
addListener(
|
||||||
|
eventName: 'mediaStatusUpdated',
|
||||||
|
listenerFunc: (event: MediaStatusEvent) => void
|
||||||
|
): Promise<{ remove: () => void }>;
|
||||||
|
|
||||||
|
removeAllListeners(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the plugin
|
||||||
|
// This will be available when the native Android plugin is implemented
|
||||||
|
export const CapacitorGoogleCast = registerPlugin<CapacitorGoogleCastPlugin>(
|
||||||
|
'CapacitorGoogleCast',
|
||||||
|
{
|
||||||
|
// Web implementation fallback (no-op for web since we use the Cast SDK directly)
|
||||||
|
web: () =>
|
||||||
|
import('./capacitorCastWeb').then((m) => new m.CapacitorGoogleCastWeb()),
|
||||||
|
}
|
||||||
|
);
|
||||||
45
src/services/cast/capacitorCastWeb.ts
Normal file
45
src/services/cast/capacitorCastWeb.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Web implementation fallback for the Capacitor Google Cast plugin
|
||||||
|
// On web, we use the Cast SDK directly through castService, so this is mostly a no-op
|
||||||
|
|
||||||
|
import { WebPlugin } from '@capacitor/core';
|
||||||
|
import type { CapacitorGoogleCastPlugin, LoadMediaOptions, SeekOptions, VolumeOptions } from './capacitorCast';
|
||||||
|
|
||||||
|
export class CapacitorGoogleCastWeb extends WebPlugin implements CapacitorGoogleCastPlugin {
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
// No-op on web - Cast SDK is initialized in castService
|
||||||
|
console.log('[CapacitorGoogleCast Web] No-op initialize');
|
||||||
|
}
|
||||||
|
|
||||||
|
async showCastDialog(): Promise<void> {
|
||||||
|
// No-op - handled by castService on web
|
||||||
|
console.log('[CapacitorGoogleCast Web] No-op showCastDialog');
|
||||||
|
}
|
||||||
|
|
||||||
|
async endSession(): Promise<void> {
|
||||||
|
console.log('[CapacitorGoogleCast Web] No-op endSession');
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMedia(_options: LoadMediaOptions): Promise<void> {
|
||||||
|
console.log('[CapacitorGoogleCast Web] No-op loadMedia');
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(): Promise<void> {
|
||||||
|
console.log('[CapacitorGoogleCast Web] No-op play');
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause(): Promise<void> {
|
||||||
|
console.log('[CapacitorGoogleCast Web] No-op pause');
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
console.log('[CapacitorGoogleCast Web] No-op stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
async seek(_options: SeekOptions): Promise<void> {
|
||||||
|
console.log('[CapacitorGoogleCast Web] No-op seek');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setVolume(_options: VolumeOptions): Promise<void> {
|
||||||
|
console.log('[CapacitorGoogleCast Web] No-op setVolume');
|
||||||
|
}
|
||||||
|
}
|
||||||
458
src/services/cast/castService.ts
Normal file
458
src/services/cast/castService.ts
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
// Google Cast Service for casting to Chromecast devices
|
||||||
|
// Uses Default Media Receiver for standard playback
|
||||||
|
|
||||||
|
export type CastState = 'unavailable' | 'available' | 'connecting' | 'connected';
|
||||||
|
|
||||||
|
interface CastDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaInfo {
|
||||||
|
contentId: string; // Stream URL
|
||||||
|
contentType: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
duration?: number;
|
||||||
|
currentTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CastSession {
|
||||||
|
device: CastDevice;
|
||||||
|
media: MediaInfo | null;
|
||||||
|
playerState: 'idle' | 'playing' | 'paused' | 'buffering';
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
volume: number;
|
||||||
|
isMuted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CastStateListener = (state: CastState, session: CastSession | null) => void;
|
||||||
|
type CastMediaListener = (playerState: string, currentTime: number, duration: number) => void;
|
||||||
|
|
||||||
|
class CastService {
|
||||||
|
private state: CastState = 'unavailable';
|
||||||
|
private session: CastSession | null = null;
|
||||||
|
private stateListeners: Set<CastStateListener> = new Set();
|
||||||
|
private mediaListeners: Set<CastMediaListener> = new Set();
|
||||||
|
private castContext: any = null;
|
||||||
|
private playerController: any = null;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Check if we're on Android with Capacitor
|
||||||
|
if (this.isCapacitorAndroid()) {
|
||||||
|
this.initializeAndroid();
|
||||||
|
} else if (typeof window !== 'undefined' && 'chrome' in window) {
|
||||||
|
// Web Chrome browser - use Google Cast SDK
|
||||||
|
this.initializeWeb();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCapacitorAndroid(): boolean {
|
||||||
|
return typeof (window as any).Capacitor !== 'undefined' &&
|
||||||
|
(window as any).Capacitor?.getPlatform() === 'android';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeAndroid(): Promise<void> {
|
||||||
|
// For Android, we use a Capacitor plugin bridge
|
||||||
|
// This will be implemented via a custom Capacitor plugin
|
||||||
|
try {
|
||||||
|
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||||
|
if (CapacitorGoogleCast) {
|
||||||
|
await CapacitorGoogleCast.initialize();
|
||||||
|
|
||||||
|
// Listen for cast state changes
|
||||||
|
CapacitorGoogleCast.addListener('castStateChanged', (data: { state: CastState }) => {
|
||||||
|
this.state = data.state;
|
||||||
|
this.notifyStateListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for device discovery
|
||||||
|
CapacitorGoogleCast.addListener('deviceAvailable', (data: { devices: CastDevice[] }) => {
|
||||||
|
if (data.devices.length > 0) {
|
||||||
|
this.state = 'available';
|
||||||
|
this.notifyStateListeners();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for connection events
|
||||||
|
CapacitorGoogleCast.addListener('sessionStarted', (data: { device: CastDevice }) => {
|
||||||
|
this.state = 'connected';
|
||||||
|
this.session = {
|
||||||
|
device: data.device,
|
||||||
|
media: null,
|
||||||
|
playerState: 'idle',
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
|
volume: 1,
|
||||||
|
isMuted: false,
|
||||||
|
};
|
||||||
|
this.notifyStateListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
CapacitorGoogleCast.addListener('sessionEnded', () => {
|
||||||
|
this.state = 'available';
|
||||||
|
this.session = null;
|
||||||
|
this.notifyStateListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for media status updates
|
||||||
|
CapacitorGoogleCast.addListener('mediaStatusUpdated', (data: any) => {
|
||||||
|
if (this.session) {
|
||||||
|
this.session.playerState = data.playerState;
|
||||||
|
this.session.currentTime = data.currentTime;
|
||||||
|
this.session.duration = data.duration;
|
||||||
|
}
|
||||||
|
this.notifyMediaListeners(data.playerState, data.currentTime, data.duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
console.log('[Cast] Android Cast service initialized');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[Cast] Android Cast plugin not available:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeWeb(): void {
|
||||||
|
// For web, use the Cast Framework SDK
|
||||||
|
// Check if the Cast SDK is loaded
|
||||||
|
const checkCastApi = () => {
|
||||||
|
if ((window as any).cast && (window as any).chrome?.cast) {
|
||||||
|
this.setupCastFramework();
|
||||||
|
} else {
|
||||||
|
// Load the Cast SDK
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
|
||||||
|
script.onload = () => this.setupCastFramework();
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for DOM ready
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
checkCastApi();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', checkCastApi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupCastFramework(): void {
|
||||||
|
try {
|
||||||
|
const cast = (window as any).cast;
|
||||||
|
const chrome = (window as any).chrome;
|
||||||
|
|
||||||
|
if (!cast?.framework || !chrome?.cast) {
|
||||||
|
console.log('[Cast] Cast framework not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the Cast API
|
||||||
|
cast.framework.CastContext.getInstance().setOptions({
|
||||||
|
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||||
|
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.castContext = cast.framework.CastContext.getInstance();
|
||||||
|
this.playerController = new cast.framework.RemotePlayerController(
|
||||||
|
new cast.framework.RemotePlayer()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen for cast state changes
|
||||||
|
this.castContext.addEventListener(
|
||||||
|
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
|
||||||
|
(event: any) => {
|
||||||
|
switch (event.castState) {
|
||||||
|
case 'NO_DEVICES_AVAILABLE':
|
||||||
|
this.state = 'unavailable';
|
||||||
|
break;
|
||||||
|
case 'NOT_CONNECTED':
|
||||||
|
this.state = 'available';
|
||||||
|
break;
|
||||||
|
case 'CONNECTING':
|
||||||
|
this.state = 'connecting';
|
||||||
|
break;
|
||||||
|
case 'CONNECTED':
|
||||||
|
this.state = 'connected';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.notifyStateListeners();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen for session state changes
|
||||||
|
this.castContext.addEventListener(
|
||||||
|
cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||||
|
(event: any) => {
|
||||||
|
if (event.sessionState === 'SESSION_STARTED' ||
|
||||||
|
event.sessionState === 'SESSION_RESUMED') {
|
||||||
|
const session = this.castContext.getCurrentSession();
|
||||||
|
if (session) {
|
||||||
|
const device = session.getCastDevice();
|
||||||
|
this.session = {
|
||||||
|
device: {
|
||||||
|
id: device.deviceId,
|
||||||
|
name: device.friendlyName,
|
||||||
|
isConnected: true,
|
||||||
|
},
|
||||||
|
media: null,
|
||||||
|
playerState: 'idle',
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
|
volume: 1,
|
||||||
|
isMuted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (event.sessionState === 'SESSION_ENDED') {
|
||||||
|
this.session = null;
|
||||||
|
}
|
||||||
|
this.notifyStateListeners();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Listen for player changes
|
||||||
|
this.playerController.addEventListener(
|
||||||
|
cast.framework.RemotePlayerEventType.ANY_CHANGE,
|
||||||
|
(_event: any) => {
|
||||||
|
if (this.session) {
|
||||||
|
const player = this.playerController.remotePlayer;
|
||||||
|
this.session.currentTime = player.currentTime;
|
||||||
|
this.session.duration = player.duration;
|
||||||
|
this.session.volume = player.volumeLevel;
|
||||||
|
this.session.isMuted = player.isMuted;
|
||||||
|
|
||||||
|
if (player.playerState) {
|
||||||
|
this.session.playerState = player.playerState.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifyMediaListeners(
|
||||||
|
this.session.playerState,
|
||||||
|
this.session.currentTime,
|
||||||
|
this.session.duration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
console.log('[Cast] Web Cast service initialized');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Cast] Failed to initialize Cast framework:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
|
||||||
|
get isAvailable(): boolean {
|
||||||
|
return this.state !== 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected(): boolean {
|
||||||
|
return this.state === 'connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentState(): CastState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentSession(): CastSession | null {
|
||||||
|
return this.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
addStateListener(listener: CastStateListener): () => void {
|
||||||
|
this.stateListeners.add(listener);
|
||||||
|
// Immediately notify with current state
|
||||||
|
listener(this.state, this.session);
|
||||||
|
return () => this.stateListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMediaListener(listener: CastMediaListener): () => void {
|
||||||
|
this.mediaListeners.add(listener);
|
||||||
|
return () => this.mediaListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyStateListeners(): void {
|
||||||
|
this.stateListeners.forEach((listener) => {
|
||||||
|
listener(this.state, this.session);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyMediaListeners(playerState: string, currentTime: number, duration: number): void {
|
||||||
|
this.mediaListeners.forEach((listener) => {
|
||||||
|
listener(playerState, currentTime, duration);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestSession(): Promise<boolean> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
console.log('[Cast] Service not initialized');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isCapacitorAndroid()) {
|
||||||
|
try {
|
||||||
|
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||||
|
await CapacitorGoogleCast.showCastDialog();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Cast] Failed to show cast dialog:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web implementation
|
||||||
|
if (this.castContext) {
|
||||||
|
try {
|
||||||
|
await this.castContext.requestSession();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[Cast] Session request failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async endSession(): Promise<void> {
|
||||||
|
if (this.isCapacitorAndroid()) {
|
||||||
|
try {
|
||||||
|
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||||
|
await CapacitorGoogleCast.endSession();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Cast] Failed to end session:', error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.castContext) {
|
||||||
|
const session = this.castContext.getCurrentSession();
|
||||||
|
if (session) {
|
||||||
|
session.endSession(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMedia(media: MediaInfo): Promise<boolean> {
|
||||||
|
if (!this.isConnected || !this.session) {
|
||||||
|
console.log('[Cast] Not connected');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isCapacitorAndroid()) {
|
||||||
|
try {
|
||||||
|
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||||
|
await CapacitorGoogleCast.loadMedia({
|
||||||
|
url: media.contentId,
|
||||||
|
contentType: media.contentType,
|
||||||
|
title: media.title,
|
||||||
|
subtitle: media.subtitle || '',
|
||||||
|
imageUrl: media.imageUrl || '',
|
||||||
|
startTime: media.currentTime || 0,
|
||||||
|
});
|
||||||
|
this.session.media = media;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Cast] Failed to load media:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web implementation
|
||||||
|
const chrome = (window as any).chrome;
|
||||||
|
const cast = (window as any).cast;
|
||||||
|
if (!chrome?.cast || !cast?.framework) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = this.castContext.getCurrentSession();
|
||||||
|
if (!session) return false;
|
||||||
|
|
||||||
|
const mediaInfo = new chrome.cast.media.MediaInfo(media.contentId, media.contentType);
|
||||||
|
mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata();
|
||||||
|
mediaInfo.metadata.title = media.title;
|
||||||
|
mediaInfo.metadata.subtitle = media.subtitle;
|
||||||
|
if (media.imageUrl) {
|
||||||
|
mediaInfo.metadata.images = [{ url: media.imageUrl }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = new chrome.cast.media.LoadRequest(mediaInfo);
|
||||||
|
request.currentTime = media.currentTime || 0;
|
||||||
|
request.autoplay = true;
|
||||||
|
|
||||||
|
await session.loadMedia(request);
|
||||||
|
this.session.media = media;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Cast] Failed to load media:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(): Promise<void> {
|
||||||
|
if (this.isCapacitorAndroid()) {
|
||||||
|
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||||
|
await CapacitorGoogleCast.play();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.playerController) {
|
||||||
|
this.playerController.playOrPause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause(): Promise<void> {
|
||||||
|
if (this.isCapacitorAndroid()) {
|
||||||
|
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||||
|
await CapacitorGoogleCast.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.playerController) {
|
||||||
|
this.playerController.playOrPause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async seek(time: number): Promise<void> {
|
||||||
|
if (this.isCapacitorAndroid()) {
|
||||||
|
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||||
|
await CapacitorGoogleCast.seek({ time });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.playerController?.remotePlayer) {
|
||||||
|
this.playerController.remotePlayer.currentTime = time;
|
||||||
|
this.playerController.seek();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setVolume(level: number): Promise<void> {
|
||||||
|
if (this.isCapacitorAndroid()) {
|
||||||
|
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||||
|
await CapacitorGoogleCast.setVolume({ level });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.playerController?.remotePlayer) {
|
||||||
|
this.playerController.remotePlayer.volumeLevel = level;
|
||||||
|
this.playerController.setVolumeLevel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.isCapacitorAndroid()) {
|
||||||
|
const { CapacitorGoogleCast } = await import('./capacitorCast');
|
||||||
|
await CapacitorGoogleCast.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.playerController) {
|
||||||
|
this.playerController.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const castService = new CastService();
|
||||||
165
src/services/recommendations/recommendationService.ts
Normal file
165
src/services/recommendations/recommendationService.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { ytsApi } from '../api/yts';
|
||||||
|
import type { Movie } from '../../types';
|
||||||
|
import { useHistoryStore } from '../../stores/historyStore';
|
||||||
|
|
||||||
|
interface GenreScore {
|
||||||
|
genre: string;
|
||||||
|
score: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecommendationResult {
|
||||||
|
movies: Movie[];
|
||||||
|
basedOnGenres: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze watch history to determine preferred genres
|
||||||
|
function analyzeGenrePreferences(historyMovies: Movie[]): GenreScore[] {
|
||||||
|
const genreScores: Map<string, { score: number; count: number }> = new Map();
|
||||||
|
|
||||||
|
historyMovies.forEach((movie, index) => {
|
||||||
|
// More recent movies get higher weight (1.0 for most recent, decreasing)
|
||||||
|
const recencyWeight = 1.0 - (index * 0.1);
|
||||||
|
const weight = Math.max(recencyWeight, 0.3); // Minimum weight of 0.3
|
||||||
|
|
||||||
|
movie.genres?.forEach((genre) => {
|
||||||
|
const current = genreScores.get(genre) || { score: 0, count: 0 };
|
||||||
|
genreScores.set(genre, {
|
||||||
|
score: current.score + weight,
|
||||||
|
count: current.count + 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array and sort by score
|
||||||
|
return Array.from(genreScores.entries())
|
||||||
|
.map(([genre, data]) => ({
|
||||||
|
genre,
|
||||||
|
score: data.score,
|
||||||
|
count: data.count,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get personalized recommendations based on watch history
|
||||||
|
export async function getPersonalizedRecommendations(
|
||||||
|
historyMovies: Movie[],
|
||||||
|
excludeIds: Set<number>,
|
||||||
|
limit = 20
|
||||||
|
): Promise<RecommendationResult> {
|
||||||
|
if (historyMovies.length === 0) {
|
||||||
|
return { movies: [], basedOnGenres: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const genrePreferences = analyzeGenrePreferences(historyMovies);
|
||||||
|
const topGenres = genrePreferences.slice(0, 3).map((g) => g.genre);
|
||||||
|
|
||||||
|
if (topGenres.length === 0) {
|
||||||
|
return { movies: [], basedOnGenres: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch movies for each top genre
|
||||||
|
const genrePromises = topGenres.map((genre) =>
|
||||||
|
ytsApi.getByGenre(genre.toLowerCase(), 15).catch(() => ({ movies: [] }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const genreResults = await Promise.all(genrePromises);
|
||||||
|
|
||||||
|
// Combine and deduplicate movies
|
||||||
|
const movieMap = new Map<number, Movie>();
|
||||||
|
const movieScores = new Map<number, number>();
|
||||||
|
|
||||||
|
genreResults.forEach((result, genreIndex) => {
|
||||||
|
result.movies.forEach((movie) => {
|
||||||
|
if (excludeIds.has(movie.id)) return; // Skip already watched
|
||||||
|
|
||||||
|
if (!movieMap.has(movie.id)) {
|
||||||
|
movieMap.set(movie.id, movie);
|
||||||
|
movieScores.set(movie.id, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score based on genre preference rank
|
||||||
|
const genreWeight = 1.0 - genreIndex * 0.2;
|
||||||
|
const ratingBonus = (movie.rating || 0) / 20; // Up to 0.5 bonus for high-rated
|
||||||
|
movieScores.set(
|
||||||
|
movie.id,
|
||||||
|
(movieScores.get(movie.id) || 0) + genreWeight + ratingBonus
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by score and return top recommendations
|
||||||
|
const sortedMovies = Array.from(movieMap.values())
|
||||||
|
.sort((a, b) => (movieScores.get(b.id) || 0) - (movieScores.get(a.id) || 0))
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
movies: sortedMovies,
|
||||||
|
basedOnGenres: topGenres,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get "Because You Watched" recommendations for a specific movie
|
||||||
|
export async function getBecauseYouWatched(
|
||||||
|
movie: Movie,
|
||||||
|
excludeIds: Set<number>,
|
||||||
|
limit = 10
|
||||||
|
): Promise<Movie[]> {
|
||||||
|
try {
|
||||||
|
// First try the YTS suggestions API
|
||||||
|
const suggestions = await ytsApi.getMovieSuggestions(movie.id);
|
||||||
|
|
||||||
|
if (suggestions.movies.length > 0) {
|
||||||
|
return suggestions.movies
|
||||||
|
.filter((m) => !excludeIds.has(m.id))
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: search by genre
|
||||||
|
if (movie.genres && movie.genres.length > 0) {
|
||||||
|
const primaryGenre = movie.genres[0];
|
||||||
|
const result = await ytsApi.getByGenre(primaryGenre.toLowerCase(), limit * 2);
|
||||||
|
return result.movies
|
||||||
|
.filter((m) => !excludeIds.has(m.id) && m.id !== movie.id)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting recommendations:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook for using recommendations in components
|
||||||
|
export function useRecommendationsService() {
|
||||||
|
const { getProfileItems } = useHistoryStore();
|
||||||
|
|
||||||
|
const getRecommendations = async (limit = 20): Promise<RecommendationResult> => {
|
||||||
|
const historyItems = getProfileItems();
|
||||||
|
const historyMovies = historyItems.map((item) => item.movie);
|
||||||
|
const excludeIds = new Set(historyItems.map((item) => item.movieId));
|
||||||
|
|
||||||
|
return getPersonalizedRecommendations(historyMovies, excludeIds, limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBecauseWatched = async (movieId: number, limit = 10): Promise<{ movie: Movie; recommendations: Movie[] } | null> => {
|
||||||
|
const historyItems = getProfileItems();
|
||||||
|
const historyItem = historyItems.find((item) => item.movieId === movieId);
|
||||||
|
|
||||||
|
if (!historyItem) return null;
|
||||||
|
|
||||||
|
const excludeIds = new Set(historyItems.map((item) => item.movieId));
|
||||||
|
const recommendations = await getBecauseYouWatched(historyItem.movie, excludeIds, limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
movie: historyItem.movie,
|
||||||
|
recommendations,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getRecommendations,
|
||||||
|
getBecauseWatched,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -206,10 +206,14 @@ class StreamingService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop streaming session
|
* 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 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
121
src/stores/profileStore.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { Profile, AvatarId } from '../types';
|
||||||
|
|
||||||
|
const MAX_PROFILES = 5;
|
||||||
|
|
||||||
|
interface ProfileState {
|
||||||
|
profiles: Profile[];
|
||||||
|
activeProfileId: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
createProfile: (name: string, avatar: AvatarId) => Profile | null;
|
||||||
|
updateProfile: (id: string, updates: Partial<Pick<Profile, 'name' | 'avatar'>>) => void;
|
||||||
|
deleteProfile: (id: string) => void;
|
||||||
|
setActiveProfile: (id: string | null) => void;
|
||||||
|
getActiveProfile: () => Profile | null;
|
||||||
|
canCreateProfile: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateId = () => `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
export const useProfileStore = create<ProfileState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
profiles: [],
|
||||||
|
activeProfileId: null,
|
||||||
|
|
||||||
|
createProfile: (name, avatar) => {
|
||||||
|
const { profiles } = get();
|
||||||
|
if (profiles.length >= MAX_PROFILES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newProfile: Profile = {
|
||||||
|
id: generateId(),
|
||||||
|
name: name.trim(),
|
||||||
|
avatar,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
profiles: [...state.profiles, newProfile],
|
||||||
|
// Auto-select if first profile
|
||||||
|
activeProfileId: state.profiles.length === 0 ? newProfile.id : state.activeProfileId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return newProfile;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProfile: (id, updates) => {
|
||||||
|
set((state) => ({
|
||||||
|
profiles: state.profiles.map((profile) =>
|
||||||
|
profile.id === id
|
||||||
|
? { ...profile, ...updates, name: updates.name?.trim() || profile.name }
|
||||||
|
: profile
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProfile: (id) => {
|
||||||
|
const { profiles, activeProfileId } = get();
|
||||||
|
|
||||||
|
// Don't delete if it's the last profile
|
||||||
|
if (profiles.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up profile-specific data from localStorage
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.includes(id)) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const newProfiles = state.profiles.filter((p) => p.id !== id);
|
||||||
|
return {
|
||||||
|
profiles: newProfiles,
|
||||||
|
// If deleting active profile, switch to first available
|
||||||
|
activeProfileId:
|
||||||
|
activeProfileId === id ? newProfiles[0]?.id || null : activeProfileId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveProfile: (id) => {
|
||||||
|
if (id === null) {
|
||||||
|
set({ activeProfileId: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { profiles } = get();
|
||||||
|
if (profiles.some((p) => p.id === id)) {
|
||||||
|
set({ activeProfileId: id });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getActiveProfile: () => {
|
||||||
|
const { profiles, activeProfileId } = get();
|
||||||
|
return profiles.find((p) => p.id === activeProfileId) || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
canCreateProfile: () => {
|
||||||
|
return get().profiles.length < MAX_PROFILES;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'bestream-profiles',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper to get storage key for profile-specific data
|
||||||
|
export const getProfileStorageKey = (baseKey: string, profileId: string | null): string => {
|
||||||
|
if (!profileId) {
|
||||||
|
return baseKey;
|
||||||
|
}
|
||||||
|
return `${baseKey}-${profileId}`;
|
||||||
|
};
|
||||||
@@ -12,6 +12,9 @@ const defaultSettings: AppSettings = {
|
|||||||
preferredQuality: '1080p',
|
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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
199
src/utils/network.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// Network utility for detecting connection quality and selecting appropriate video quality
|
||||||
|
|
||||||
|
export type ConnectionType = 'wifi' | 'cellular' | 'ethernet' | 'unknown';
|
||||||
|
export type ConnectionQuality = 'high' | 'medium' | 'low' | 'unknown';
|
||||||
|
|
||||||
|
interface NetworkInfo {
|
||||||
|
type: ConnectionType;
|
||||||
|
quality: ConnectionQuality;
|
||||||
|
effectiveType?: '4g' | '3g' | '2g' | 'slow-2g';
|
||||||
|
downlink?: number; // Mbps
|
||||||
|
rtt?: number; // Round trip time in ms
|
||||||
|
saveData?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current network information
|
||||||
|
export function getNetworkInfo(): NetworkInfo {
|
||||||
|
const connection =
|
||||||
|
(navigator as any).connection ||
|
||||||
|
(navigator as any).mozConnection ||
|
||||||
|
(navigator as any).webkitConnection;
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return {
|
||||||
|
type: 'unknown',
|
||||||
|
quality: 'unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine connection type
|
||||||
|
let type: ConnectionType = 'unknown';
|
||||||
|
if (connection.type) {
|
||||||
|
switch (connection.type) {
|
||||||
|
case 'wifi':
|
||||||
|
type = 'wifi';
|
||||||
|
break;
|
||||||
|
case 'ethernet':
|
||||||
|
type = 'ethernet';
|
||||||
|
break;
|
||||||
|
case 'cellular':
|
||||||
|
type = 'cellular';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
type = 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine quality based on effective type and downlink
|
||||||
|
let quality: ConnectionQuality = 'unknown';
|
||||||
|
if (connection.effectiveType) {
|
||||||
|
switch (connection.effectiveType) {
|
||||||
|
case '4g':
|
||||||
|
quality = 'high';
|
||||||
|
break;
|
||||||
|
case '3g':
|
||||||
|
quality = 'medium';
|
||||||
|
break;
|
||||||
|
case '2g':
|
||||||
|
case 'slow-2g':
|
||||||
|
quality = 'low';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override based on downlink if available
|
||||||
|
if (connection.downlink !== undefined) {
|
||||||
|
if (connection.downlink >= 10) {
|
||||||
|
quality = 'high';
|
||||||
|
} else if (connection.downlink >= 2) {
|
||||||
|
quality = 'medium';
|
||||||
|
} else {
|
||||||
|
quality = 'low';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
quality,
|
||||||
|
effectiveType: connection.effectiveType,
|
||||||
|
downlink: connection.downlink,
|
||||||
|
rtt: connection.rtt,
|
||||||
|
saveData: connection.saveData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quality thresholds in Mbps
|
||||||
|
const QUALITY_BANDWIDTH_REQUIREMENTS = {
|
||||||
|
'2160p': 25, // 4K needs 25+ Mbps
|
||||||
|
'1080p': 8, // 1080p needs 8+ Mbps
|
||||||
|
'720p': 3, // 720p needs 3+ Mbps
|
||||||
|
'480p': 1.5, // 480p needs 1.5+ Mbps
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select the best quality based on network conditions
|
||||||
|
export function selectOptimalQuality(
|
||||||
|
availableQualities: string[],
|
||||||
|
networkInfo: NetworkInfo
|
||||||
|
): string {
|
||||||
|
// If data saver is enabled, always use lowest quality
|
||||||
|
if (networkInfo.saveData) {
|
||||||
|
return findLowestQuality(availableQualities);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have downlink info, use it
|
||||||
|
if (networkInfo.downlink !== undefined) {
|
||||||
|
return selectQualityByBandwidth(availableQualities, networkInfo.downlink);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use connection quality estimate
|
||||||
|
switch (networkInfo.quality) {
|
||||||
|
case 'high':
|
||||||
|
return findQuality(availableQualities, ['2160p', '1080p', '720p', '480p']);
|
||||||
|
case 'medium':
|
||||||
|
return findQuality(availableQualities, ['720p', '1080p', '480p']);
|
||||||
|
case 'low':
|
||||||
|
return findQuality(availableQualities, ['480p', '720p']);
|
||||||
|
default:
|
||||||
|
// Unknown quality, default to 720p for balance
|
||||||
|
return findQuality(availableQualities, ['720p', '1080p', '480p']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectQualityByBandwidth(availableQualities: string[], bandwidthMbps: number): string {
|
||||||
|
// Check each quality from highest to lowest
|
||||||
|
const qualityOrder: Array<keyof typeof QUALITY_BANDWIDTH_REQUIREMENTS> = [
|
||||||
|
'2160p',
|
||||||
|
'1080p',
|
||||||
|
'720p',
|
||||||
|
'480p',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const quality of qualityOrder) {
|
||||||
|
if (bandwidthMbps >= QUALITY_BANDWIDTH_REQUIREMENTS[quality]) {
|
||||||
|
// Check if this quality is available
|
||||||
|
if (availableQualities.some((q) => q === quality || q === '4K' && quality === '2160p')) {
|
||||||
|
return quality;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to lowest available
|
||||||
|
return findLowestQuality(availableQualities);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findQuality(availableQualities: string[], preferenceOrder: string[]): string {
|
||||||
|
for (const preferred of preferenceOrder) {
|
||||||
|
const found = availableQualities.find(
|
||||||
|
(q) => q === preferred || (q === '4K' && preferred === '2160p')
|
||||||
|
);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return availableQualities[0] || '720p';
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLowestQuality(availableQualities: string[]): string {
|
||||||
|
const priorityOrder = ['480p', '720p', '1080p', '2160p', '4K'];
|
||||||
|
for (const quality of priorityOrder) {
|
||||||
|
if (availableQualities.includes(quality)) {
|
||||||
|
return quality;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return availableQualities[0] || '480p';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to network changes
|
||||||
|
export function onNetworkChange(callback: (info: NetworkInfo) => void): () => void {
|
||||||
|
const connection =
|
||||||
|
(navigator as any).connection ||
|
||||||
|
(navigator as any).mozConnection ||
|
||||||
|
(navigator as any).webkitConnection;
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return () => {}; // No-op cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = () => callback(getNetworkInfo());
|
||||||
|
connection.addEventListener('change', handler);
|
||||||
|
|
||||||
|
return () => connection.removeEventListener('change', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're on a metered connection (cellular)
|
||||||
|
export function isMeteredConnection(): boolean {
|
||||||
|
const info = getNetworkInfo();
|
||||||
|
return info.type === 'cellular' || info.saveData === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate if we can handle a specific quality
|
||||||
|
export function canHandleQuality(quality: string): boolean {
|
||||||
|
const info = getNetworkInfo();
|
||||||
|
|
||||||
|
if (info.downlink === undefined) {
|
||||||
|
// Can't determine, assume yes for non-4K
|
||||||
|
return quality !== '2160p' && quality !== '4K';
|
||||||
|
}
|
||||||
|
|
||||||
|
const required =
|
||||||
|
QUALITY_BANDWIDTH_REQUIREMENTS[quality as keyof typeof QUALITY_BANDWIDTH_REQUIREMENTS] || 3;
|
||||||
|
return info.downlink >= required;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user