diff --git a/.gitignore b/.gitignore index b628b8f..ca0f4d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Dependencies node_modules/ +**/node_modules/ .pnp .pnp.js diff --git a/electron/main.ts b/electron/main.ts index feda98c..4b26a0d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,8 +1,15 @@ import { app, BrowserWindow, ipcMain, shell, Menu, Tray, nativeImage } from 'electron'; import * as path from 'path'; import * as url from 'url'; +import { fileURLToPath } from 'url'; +import { fork, ChildProcess } from 'child_process'; const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; +let serverProcess: ChildProcess | null = null; + +// ES module compatible __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); let mainWindow: BrowserWindow | null = null; let tray: Tray | null = null; @@ -19,7 +26,7 @@ function createWindow() { webPreferences: { nodeIntegration: true, contextIsolation: false, - webSecurity: !isDev, + webSecurity: false, }, icon: path.join(__dirname, '../public/icon.png'), }); @@ -29,14 +36,41 @@ function createWindow() { mainWindow.loadURL('http://localhost:5173'); mainWindow.webContents.openDevTools(); } else { - mainWindow.loadURL( - url.format({ - pathname: path.join(__dirname, '../dist/index.html'), - protocol: 'file:', - slashes: true, - }) - ); + const indexPath = path.join(app.getAppPath(), 'dist', 'index.html'); + console.log('Loading index.html from:', indexPath); + + mainWindow.loadFile(indexPath).catch((error) => { + console.error('Failed to load index.html:', error); + // Show error in window + mainWindow?.webContents.executeJavaScript(` + document.body.innerHTML = '
+

Error Loading App

+

Failed to load index.html

+

Path: ${indexPath}

+

Error: ${error.message}

+

App Path: ${app.getAppPath()}

+
'; + `); + }); } + + // Log all console messages from renderer + mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => { + console.log(`[Renderer ${level}] ${message} (${sourceId}:${line})`); + }); + + // Log page load events + mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => { + console.error('Page failed to load:', { + errorCode, + errorDescription, + url: validatedURL, + }); + }); + + mainWindow.webContents.on('did-finish-load', () => { + console.log('Page finished loading'); + }); // Handle external links mainWindow.webContents.setWindowOpenHandler(({ url }) => { @@ -173,8 +207,77 @@ function createTray() { }); } +/** + * Start the backend server using Electron's bundled Node.js + */ +function startServer() { + const serverDir = path.join(app.getAppPath(), 'server'); + const serverPath = path.join(serverDir, 'src', 'index.js'); + + console.log('Starting server from:', serverPath); + console.log('Server directory:', serverDir); + + // Use fork() which automatically uses Electron's bundled Node.js runtime + // This works in both development and packaged apps + serverProcess = fork(serverPath, [], { + cwd: serverDir, + env: { + ...process.env, + PORT: '3001', + NODE_ENV: isDev ? 'development' : 'production', + }, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], // 'ipc' channel for fork communication + silent: false, // Allow server to output to console + }); + + serverProcess.stdout?.on('data', (data) => { + console.log(`[Server] ${data.toString()}`); + }); + + serverProcess.stderr?.on('data', (data) => { + console.error(`[Server Error] ${data.toString()}`); + }); + + serverProcess.on('exit', (code, signal) => { + console.log(`Server process exited with code ${code}${signal ? ` (signal: ${signal})` : ''}`); + serverProcess = null; + }); + + serverProcess.on('error', (error) => { + console.error('Failed to start server:', error); + serverProcess = null; + }); + + // Wait a bit for server to start before creating window + return new Promise((resolve) => { + // Give server time to start + setTimeout(() => { + // Check if server is still running + if (serverProcess && !serverProcess.killed) { + console.log('Server started successfully'); + } else { + console.warn('Server may not have started properly'); + } + resolve(); + }, 2000); + }); +} + +/** + * Stop the backend server + */ +function stopServer() { + if (serverProcess) { + serverProcess.kill(); + serverProcess = null; + } +} + // App event handlers -app.whenReady().then(() => { +app.whenReady().then(async () => { + // Start server first + await startServer(); + createWindow(); createTray(); @@ -186,11 +289,16 @@ app.whenReady().then(() => { }); app.on('window-all-closed', () => { + stopServer(); if (process.platform !== 'darwin') { app.quit(); } }); +app.on('before-quit', () => { + stopServer(); +}); + // IPC handlers for renderer communication ipcMain.handle('get-app-path', () => { return app.getPath('userData'); @@ -208,6 +316,27 @@ ipcMain.handle('open-external', (_event, url: string) => { shell.openExternal(url); }); +// Window control handlers +ipcMain.handle('window-minimize', () => { + mainWindow?.minimize(); +}); + +ipcMain.handle('window-maximize', () => { + if (mainWindow?.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow?.maximize(); + } +}); + +ipcMain.handle('window-close', () => { + mainWindow?.close(); +}); + +ipcMain.handle('window-is-maximized', () => { + return mainWindow?.isMaximized() ?? false; +}); + // Handle certificate errors for development if (isDev) { app.on('certificate-error', (event, webContents, url, error, certificate, callback) => { diff --git a/electron/preload.ts b/electron/preload.ts index 8de4867..504774e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -11,6 +11,12 @@ contextBridge.exposeInMainWorld('electron', { showItemInFolder: (path: string) => ipcRenderer.invoke('show-item-in-folder', path), openExternal: (url: string) => ipcRenderer.invoke('open-external', url), + // Window controls + minimizeWindow: () => ipcRenderer.invoke('window-minimize'), + maximizeWindow: () => ipcRenderer.invoke('window-maximize'), + closeWindow: () => ipcRenderer.invoke('window-close'), + isMaximized: () => ipcRenderer.invoke('window-is-maximized'), + // Navigation onNavigate: (callback: (path: string) => void) => { ipcRenderer.on('navigate', (_event, path) => callback(path)); @@ -29,6 +35,10 @@ declare global { getDownloadsPath: () => Promise; showItemInFolder: (path: string) => Promise; openExternal: (url: string) => Promise; + minimizeWindow: () => Promise; + maximizeWindow: () => Promise; + closeWindow: () => Promise; + isMaximized: () => Promise; onNavigate: (callback: (path: string) => void) => void; platform: NodeJS.Platform; isElectron: boolean; diff --git a/electron/tsconfig.json b/electron/tsconfig.json index 721df31..db16e2d 100644 --- a/electron/tsconfig.json +++ b/electron/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2020", - "module": "CommonJS", + "module": "ES2020", "lib": ["ES2020"], "outDir": "../dist-electron", "rootDir": ".", diff --git a/package.json b/package.json index 399e9fd..5426387 100644 --- a/package.json +++ b/package.json @@ -2,22 +2,24 @@ "name": "bestream", "version": "2.4.0", "description": "Cross-platform movie streaming app with Netflix-style UI", + "author": "beStream", "private": true, "type": "module", - "main": "electron/main.js", + "main": "dist-electron/main.js", "scripts": { "dev": "vite", "dev:all": "concurrently \"npm run dev\" \"npm run server\"", "server": "cd server && npm start", "server:install": "cd server && npm install", "build": "tsc -b && vite build", + "build:electron": "tsc -p electron/tsconfig.json", "preview": "vite preview", "lint": "eslint .", - "electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"", - "electron:build": "vite build && electron-builder", - "build:win": "vite build && electron-builder --win", - "build:mac": "vite build && electron-builder --mac", - "build:linux": "vite build && electron-builder --linux", + "electron:dev": "npm run build:electron && concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"", + "electron:build": "npm run build:electron && vite build && electron-builder", + "build:win": "npm run build:electron && vite build && electron-builder --win", + "build:mac": "npm run build:electron && vite build && electron-builder --mac", + "build:linux": "npm run build:electron && vite build && electron-builder --linux", "cap:sync": "npx cap sync", "build:android": "vite build && npx cap sync android", "build:ios": "vite build && npx cap sync ios" @@ -69,26 +71,25 @@ }, "files": [ "dist/**/*", - "electron/**/*" + "dist-electron/**/*", + "server/**/*" ], "win": { "target": [ "nsis" ], - "icon": "public/icon.ico" + "sign": null }, "mac": { "target": [ "dmg" - ], - "icon": "public/icon.icns" + ] }, "linux": { "target": [ "AppImage", "deb" - ], - "icon": "public/icon.png" + ] }, "nsis": { "oneClick": false, diff --git a/public/icon.png b/public/icon.png index 22165cd..c1ab738 100644 Binary files a/public/icon.png and b/public/icon.png differ diff --git a/server/src/index.js b/server/src/index.js index 200702f..1eb0825 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -10,7 +10,7 @@ const PORT = process.env.PORT || 3001; // Middleware app.use(cors({ - origin: ['http://localhost:5173', 'http://localhost:3000'], + origin: true, // Allow all origins for Electron app credentials: true, })); app.use(express.json()); diff --git a/server/src/services/torrentManager.js b/server/src/services/torrentManager.js index 30edd4d..1ae730e 100644 --- a/server/src/services/torrentManager.js +++ b/server/src/services/torrentManager.js @@ -22,7 +22,7 @@ class TorrentManager { }); this.sessions = new Map(); this.downloadPath = path.join(os.tmpdir(), 'bestream-cache'); - + // Ensure download directory exists if (!fs.existsSync(this.downloadPath)) { fs.mkdirSync(this.downloadPath, { recursive: true }); @@ -58,7 +58,7 @@ class TorrentManager { const existingSession = Array.from(this.sessions.values()).find( (s) => s.hash === torrentHash && (s.status === 'ready' || s.status === 'downloading') ); - + if (existingSession) { console.log(`♻️ Reusing existing session for ${movieName}`); // Create a new session entry that shares the same torrent @@ -129,10 +129,10 @@ class TorrentManager { // Helper function to setup torrent and resolve const setupTorrent = (torrent) => { clearTimeout(timeout); - + session.torrent = torrent; session.total = torrent.length || 0; - + // Wait for torrent to be ready with metadata const onReady = () => { // Find the video file @@ -159,7 +159,7 @@ class TorrentManager { session.videoFile = videoFile; session.status = 'downloading'; session.total = torrent.length; - + console.log(`📹 Found video: ${videoFile.name} (${this.formatBytes(videoFile.length)})`); // Select only the video file @@ -171,7 +171,7 @@ class TorrentManager { // Prioritize first pieces for faster playback start videoFile.select(); - + // Progress updates const onDownload = () => { session.progress = torrent.progress; @@ -179,12 +179,12 @@ class TorrentManager { session.uploadSpeed = torrent.uploadSpeed; session.peers = torrent.numPeers; session.downloaded = torrent.downloaded; - + if (onProgress) { onProgress(session); } }; - + // Remove any existing listeners to prevent duplicates torrent.removeAllListeners('download'); torrent.on('download', onDownload); @@ -212,22 +212,48 @@ class TorrentManager { // If torrent already has files (was already added), use immediately if (torrent.files && torrent.files.length > 0) { onReady(); + } else if (typeof torrent.once === 'function') { + // Wait for metadata if torrent supports events + // Add timeout for metadata retrieval + const metadataTimeout = setTimeout(() => { + if (!torrent.files || torrent.files.length === 0) { + session.status = 'error'; + session.error = 'Torrent metadata timeout - no peers or invalid torrent'; + console.error(`❌ Torrent metadata timeout for: ${movieName}`); + reject(new Error('Torrent metadata timeout - no peers or invalid torrent')); + } + }, 30000); // 30 second timeout for metadata + + torrent.once('ready', () => { + clearTimeout(metadataTimeout); + onReady(); + }); + + // Also handle error events + torrent.once('error', (err) => { + clearTimeout(metadataTimeout); + session.status = 'error'; + session.error = err.message || 'Torrent error'; + console.error(`❌ Torrent error for ${movieName}:`, err.message); + reject(err); + }); } else { - // Wait for metadata - torrent.once('ready', onReady); + // Fallback: wait longer for files to appear + setTimeout(() => { + if (torrent.files && torrent.files.length > 0) { + onReady(); + } else { + session.status = 'error'; + session.error = 'Torrent failed to initialize - no metadata received'; + console.error(`❌ Torrent failed to initialize: ${movieName}`); + reject(new Error('Torrent failed to initialize - no metadata received')); + } + }, 10000); // Increased to 10 seconds } }; try { - // Check if torrent already exists in client - const existingTorrent = this.client.get(torrentHash); - if (existingTorrent) { - console.log(`♻️ Reusing existing torrent: ${movieName}`); - setupTorrent(existingTorrent); - return; - } - - this.client.add(magnetUri, { + this.client.add(magnetUri, { path: this.downloadPath, // Prioritize first and last pieces for faster start strategy: 'sequential', @@ -246,7 +272,7 @@ class TorrentManager { */ findVideoFile(torrent) { const videoExtensions = ['.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v']; - + // Sort by size and find largest video file const videoFiles = torrent.files .filter((file) => { @@ -319,7 +345,19 @@ class TorrentManager { async stopSession(sessionId) { const session = this.sessions.get(sessionId); if (session && session.torrent) { - session.torrent.destroy(); + // Remove torrent from client if it has a destroy method + if (typeof session.torrent.destroy === 'function') { + session.torrent.destroy(); + } else { + // Fallback: remove from client using the hash + try { + this.client.remove(session.torrent, (err) => { + if (err) console.error('Error removing torrent:', err); + }); + } catch (err) { + console.error('Error removing torrent:', err); + } + } } this.sessions.delete(sessionId); } @@ -355,5 +393,4 @@ class TorrentManager { } } -export const torrentManager = new TorrentManager(); - +export const torrentManager = new TorrentManager(); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index ce4c73a..e080593 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,26 +6,16 @@ import Browse from './pages/Browse'; import Search from './pages/Search'; import MovieDetails from './pages/MovieDetails'; import Player from './pages/Player'; -import TVPlayer from './pages/TVPlayer'; import Watchlist from './pages/Watchlist'; import Downloads from './pages/Downloads'; import Settings from './pages/Settings'; import History from './pages/History'; -import TVShows from './pages/TVShows'; -import TVShowDetails from './pages/TVShowDetails'; -import Music from './pages/Music'; -import ArtistDetails from './pages/ArtistDetails'; -import AlbumDetails from './pages/AlbumDetails'; -import Calendar from './pages/Calendar'; import Queue from './pages/Queue'; import { useSettingsStore } from './stores/settingsStore'; -import { useInitializeIntegrations } from './hooks/useIntegration'; function App() { const { settings } = useSettingsStore(); - // Initialize *arr service connections on app startup - useInitializeIntegrations(); useEffect(() => { // Apply theme @@ -41,15 +31,7 @@ function App() { } /> } /> } /> - {/* TV Shows (Sonarr) */} - } /> - } /> - {/* Music (Lidarr) */} - } /> - } /> - } /> - {/* Calendar & Queue */} - } /> + {/* Queue */} } /> {/* User */} } /> @@ -59,8 +41,6 @@ function App() { {/* Full-screen player */} } /> - {/* TV Show Player */} - } /> ); } diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 1f6b124..1d42ee8 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -8,22 +8,19 @@ import { X, Home, Film, - Tv, - Music, - Calendar, Heart, - Clock, Download, Settings, + Minus, + Square, + Maximize2, } from 'lucide-react'; import { clsx } from 'clsx'; +import { isElectron } from '../../utils/platform'; const navLinks = [ { path: '/', label: 'Home', icon: Home }, { path: '/browse', label: 'Movies', icon: Film }, - { path: '/tv', label: 'TV Shows', icon: Tv }, - { path: '/music', label: 'Music', icon: Music }, - { path: '/calendar', label: 'Calendar', icon: Calendar }, { path: '/watchlist', label: 'My List', icon: Heart }, { path: '/downloads', label: 'Downloads', icon: Download }, ]; @@ -33,8 +30,10 @@ export default function Navbar() { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [isSearchOpen, setIsSearchOpen] = useState(false); + const [isMaximized, setIsMaximized] = useState(false); const location = useLocation(); const navigate = useNavigate(); + const isElectronApp = isElectron(); useEffect(() => { const handleScroll = () => { @@ -49,6 +48,21 @@ export default function Navbar() { setIsMobileMenuOpen(false); }, [location.pathname]); + // Check if window is maximized (Electron only) + useEffect(() => { + if (isElectronApp && window.electron) { + const checkMaximized = async () => { + const maximized = await window.electron.isMaximized(); + setIsMaximized(maximized); + }; + checkMaximized(); + + // Check periodically (Electron doesn't have a direct event for this) + const interval = setInterval(checkMaximized, 500); + return () => clearInterval(interval); + } + }, [isElectronApp]); + const handleSearch = (e: React.FormEvent) => { e.preventDefault(); if (searchQuery.trim()) { @@ -58,8 +72,53 @@ export default function Navbar() { } }; + const handleMinimize = () => { + if (isElectronApp && window.electron) { + window.electron.minimizeWindow(); + } + }; + + const handleMaximize = () => { + if (isElectronApp && window.electron) { + window.electron.maximizeWindow(); + setIsMaximized(!isMaximized); + } + }; + + const handleClose = () => { + if (isElectronApp && window.electron) { + window.electron.closeWindow(); + } + }; + return ( <> + {/* Window Controls (Electron only, Windows/Linux) */} + {isElectronApp && (window.electron?.platform === 'win32' || window.electron?.platform === 'linux') && ( +
+ + + +
+ )}