App working
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
.pnp
|
.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
|
|||||||
147
electron/main.ts
147
electron/main.ts
@ -1,8 +1,15 @@
|
|||||||
import { app, BrowserWindow, ipcMain, shell, Menu, Tray, nativeImage } from 'electron';
|
import { app, BrowserWindow, ipcMain, shell, Menu, Tray, nativeImage } from 'electron';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as url from 'url';
|
import * as url from 'url';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { fork, ChildProcess } from 'child_process';
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
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 mainWindow: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
@ -19,7 +26,7 @@ function createWindow() {
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: true,
|
nodeIntegration: true,
|
||||||
contextIsolation: false,
|
contextIsolation: false,
|
||||||
webSecurity: !isDev,
|
webSecurity: false,
|
||||||
},
|
},
|
||||||
icon: path.join(__dirname, '../public/icon.png'),
|
icon: path.join(__dirname, '../public/icon.png'),
|
||||||
});
|
});
|
||||||
@ -29,15 +36,42 @@ function createWindow() {
|
|||||||
mainWindow.loadURL('http://localhost:5173');
|
mainWindow.loadURL('http://localhost:5173');
|
||||||
mainWindow.webContents.openDevTools();
|
mainWindow.webContents.openDevTools();
|
||||||
} else {
|
} else {
|
||||||
mainWindow.loadURL(
|
const indexPath = path.join(app.getAppPath(), 'dist', 'index.html');
|
||||||
url.format({
|
console.log('Loading index.html from:', indexPath);
|
||||||
pathname: path.join(__dirname, '../dist/index.html'),
|
|
||||||
protocol: 'file:',
|
mainWindow.loadFile(indexPath).catch((error) => {
|
||||||
slashes: true,
|
console.error('Failed to load index.html:', error);
|
||||||
})
|
// Show error in window
|
||||||
);
|
mainWindow?.webContents.executeJavaScript(`
|
||||||
|
document.body.innerHTML = '<div style="padding: 20px; font-family: monospace; color: red;">
|
||||||
|
<h1>Error Loading App</h1>
|
||||||
|
<p>Failed to load index.html</p>
|
||||||
|
<p>Path: ${indexPath}</p>
|
||||||
|
<p>Error: ${error.message}</p>
|
||||||
|
<p>App Path: ${app.getAppPath()}</p>
|
||||||
|
</div>';
|
||||||
|
`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Handle external links
|
||||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(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<void>((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 event handlers
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(async () => {
|
||||||
|
// Start server first
|
||||||
|
await startServer();
|
||||||
|
|
||||||
createWindow();
|
createWindow();
|
||||||
createTray();
|
createTray();
|
||||||
|
|
||||||
@ -186,11 +289,16 @@ app.whenReady().then(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
|
stopServer();
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
stopServer();
|
||||||
|
});
|
||||||
|
|
||||||
// IPC handlers for renderer communication
|
// IPC handlers for renderer communication
|
||||||
ipcMain.handle('get-app-path', () => {
|
ipcMain.handle('get-app-path', () => {
|
||||||
return app.getPath('userData');
|
return app.getPath('userData');
|
||||||
@ -208,6 +316,27 @@ ipcMain.handle('open-external', (_event, url: string) => {
|
|||||||
shell.openExternal(url);
|
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
|
// Handle certificate errors for development
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
|
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
|
||||||
|
|||||||
@ -11,6 +11,12 @@ contextBridge.exposeInMainWorld('electron', {
|
|||||||
showItemInFolder: (path: string) => ipcRenderer.invoke('show-item-in-folder', path),
|
showItemInFolder: (path: string) => ipcRenderer.invoke('show-item-in-folder', path),
|
||||||
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
|
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
|
// Navigation
|
||||||
onNavigate: (callback: (path: string) => void) => {
|
onNavigate: (callback: (path: string) => void) => {
|
||||||
ipcRenderer.on('navigate', (_event, path) => callback(path));
|
ipcRenderer.on('navigate', (_event, path) => callback(path));
|
||||||
@ -29,6 +35,10 @@ declare global {
|
|||||||
getDownloadsPath: () => Promise<string>;
|
getDownloadsPath: () => Promise<string>;
|
||||||
showItemInFolder: (path: string) => Promise<void>;
|
showItemInFolder: (path: string) => Promise<void>;
|
||||||
openExternal: (url: string) => Promise<void>;
|
openExternal: (url: string) => Promise<void>;
|
||||||
|
minimizeWindow: () => Promise<void>;
|
||||||
|
maximizeWindow: () => Promise<void>;
|
||||||
|
closeWindow: () => Promise<void>;
|
||||||
|
isMaximized: () => Promise<boolean>;
|
||||||
onNavigate: (callback: (path: string) => void) => void;
|
onNavigate: (callback: (path: string) => void) => void;
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
isElectron: boolean;
|
isElectron: boolean;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"module": "CommonJS",
|
"module": "ES2020",
|
||||||
"lib": ["ES2020"],
|
"lib": ["ES2020"],
|
||||||
"outDir": "../dist-electron",
|
"outDir": "../dist-electron",
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
|
|||||||
25
package.json
25
package.json
@ -2,22 +2,24 @@
|
|||||||
"name": "bestream",
|
"name": "bestream",
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"description": "Cross-platform movie streaming app with Netflix-style UI",
|
"description": "Cross-platform movie streaming app with Netflix-style UI",
|
||||||
|
"author": "beStream",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"dev:all": "concurrently \"npm run dev\" \"npm run server\"",
|
"dev:all": "concurrently \"npm run dev\" \"npm run server\"",
|
||||||
"server": "cd server && npm start",
|
"server": "cd server && npm start",
|
||||||
"server:install": "cd server && npm install",
|
"server:install": "cd server && npm install",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"build:electron": "tsc -p electron/tsconfig.json",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
|
"electron:dev": "npm run build:electron && concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
|
||||||
"electron:build": "vite build && electron-builder",
|
"electron:build": "npm run build:electron && vite build && electron-builder",
|
||||||
"build:win": "vite build && electron-builder --win",
|
"build:win": "npm run build:electron && vite build && electron-builder --win",
|
||||||
"build:mac": "vite build && electron-builder --mac",
|
"build:mac": "npm run build:electron && vite build && electron-builder --mac",
|
||||||
"build:linux": "vite build && electron-builder --linux",
|
"build:linux": "npm run build:electron && vite build && electron-builder --linux",
|
||||||
"cap:sync": "npx cap sync",
|
"cap:sync": "npx cap sync",
|
||||||
"build:android": "vite build && npx cap sync android",
|
"build:android": "vite build && npx cap sync android",
|
||||||
"build:ios": "vite build && npx cap sync ios"
|
"build:ios": "vite build && npx cap sync ios"
|
||||||
@ -69,26 +71,25 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"electron/**/*"
|
"dist-electron/**/*",
|
||||||
|
"server/**/*"
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"nsis"
|
||||||
],
|
],
|
||||||
"icon": "public/icon.ico"
|
"sign": null
|
||||||
},
|
},
|
||||||
"mac": {
|
"mac": {
|
||||||
"target": [
|
"target": [
|
||||||
"dmg"
|
"dmg"
|
||||||
],
|
]
|
||||||
"icon": "public/icon.icns"
|
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"target": [
|
"target": [
|
||||||
"AppImage",
|
"AppImage",
|
||||||
"deb"
|
"deb"
|
||||||
],
|
]
|
||||||
"icon": "public/icon.png"
|
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
|
|||||||
BIN
public/icon.png
BIN
public/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.1 MiB |
@ -10,7 +10,7 @@ const PORT = process.env.PORT || 3001;
|
|||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: ['http://localhost:5173', 'http://localhost:3000'],
|
origin: true, // Allow all origins for Electron app
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|||||||
@ -212,21 +212,47 @@ class TorrentManager {
|
|||||||
// If torrent already has files (was already added), use immediately
|
// If torrent already has files (was already added), use immediately
|
||||||
if (torrent.files && torrent.files.length > 0) {
|
if (torrent.files && torrent.files.length > 0) {
|
||||||
onReady();
|
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 {
|
} else {
|
||||||
// Wait for metadata
|
// Fallback: wait longer for files to appear
|
||||||
torrent.once('ready', onReady);
|
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 {
|
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,
|
path: this.downloadPath,
|
||||||
// Prioritize first and last pieces for faster start
|
// Prioritize first and last pieces for faster start
|
||||||
@ -319,7 +345,19 @@ class TorrentManager {
|
|||||||
async stopSession(sessionId) {
|
async stopSession(sessionId) {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session && session.torrent) {
|
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);
|
this.sessions.delete(sessionId);
|
||||||
}
|
}
|
||||||
@ -356,4 +394,3 @@ class TorrentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const torrentManager = new TorrentManager();
|
export const torrentManager = new TorrentManager();
|
||||||
|
|
||||||
|
|||||||
22
src/App.tsx
22
src/App.tsx
@ -6,26 +6,16 @@ import Browse from './pages/Browse';
|
|||||||
import Search from './pages/Search';
|
import Search from './pages/Search';
|
||||||
import MovieDetails from './pages/MovieDetails';
|
import MovieDetails from './pages/MovieDetails';
|
||||||
import Player from './pages/Player';
|
import Player from './pages/Player';
|
||||||
import TVPlayer from './pages/TVPlayer';
|
|
||||||
import Watchlist from './pages/Watchlist';
|
import Watchlist from './pages/Watchlist';
|
||||||
import Downloads from './pages/Downloads';
|
import Downloads from './pages/Downloads';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
import History from './pages/History';
|
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 Queue from './pages/Queue';
|
||||||
import { useSettingsStore } from './stores/settingsStore';
|
import { useSettingsStore } from './stores/settingsStore';
|
||||||
import { useInitializeIntegrations } from './hooks/useIntegration';
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { settings } = useSettingsStore();
|
const { settings } = useSettingsStore();
|
||||||
|
|
||||||
// Initialize *arr service connections on app startup
|
|
||||||
useInitializeIntegrations();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Apply theme
|
// Apply theme
|
||||||
@ -41,15 +31,7 @@ function App() {
|
|||||||
<Route path="browse/:genre" element={<Browse />} />
|
<Route path="browse/:genre" element={<Browse />} />
|
||||||
<Route path="search" element={<Search />} />
|
<Route path="search" element={<Search />} />
|
||||||
<Route path="movie/:id" element={<MovieDetails />} />
|
<Route path="movie/:id" element={<MovieDetails />} />
|
||||||
{/* TV Shows (Sonarr) */}
|
{/* Queue */}
|
||||||
<Route path="tv" element={<TVShows />} />
|
|
||||||
<Route path="tv/:id" element={<TVShowDetails />} />
|
|
||||||
{/* Music (Lidarr) */}
|
|
||||||
<Route path="music" element={<Music />} />
|
|
||||||
<Route path="music/artist/:id" element={<ArtistDetails />} />
|
|
||||||
<Route path="music/album/:id" element={<AlbumDetails />} />
|
|
||||||
{/* Calendar & Queue */}
|
|
||||||
<Route path="calendar" element={<Calendar />} />
|
|
||||||
<Route path="queue" element={<Queue />} />
|
<Route path="queue" element={<Queue />} />
|
||||||
{/* User */}
|
{/* User */}
|
||||||
<Route path="watchlist" element={<Watchlist />} />
|
<Route path="watchlist" element={<Watchlist />} />
|
||||||
@ -59,8 +41,6 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
{/* Full-screen player */}
|
{/* Full-screen player */}
|
||||||
<Route path="/player/:id" element={<Player />} />
|
<Route path="/player/:id" element={<Player />} />
|
||||||
{/* TV Show Player */}
|
|
||||||
<Route path="/tv/play/:showId" element={<TVPlayer />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,22 +8,19 @@ import {
|
|||||||
X,
|
X,
|
||||||
Home,
|
Home,
|
||||||
Film,
|
Film,
|
||||||
Tv,
|
|
||||||
Music,
|
|
||||||
Calendar,
|
|
||||||
Heart,
|
Heart,
|
||||||
Clock,
|
|
||||||
Download,
|
Download,
|
||||||
Settings,
|
Settings,
|
||||||
|
Minus,
|
||||||
|
Square,
|
||||||
|
Maximize2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
|
import { isElectron } from '../../utils/platform';
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ path: '/', label: 'Home', icon: Home },
|
{ path: '/', label: 'Home', icon: Home },
|
||||||
{ path: '/browse', label: 'Movies', icon: Film },
|
{ 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: '/watchlist', label: 'My List', icon: Heart },
|
||||||
{ path: '/downloads', label: 'Downloads', icon: Download },
|
{ path: '/downloads', label: 'Downloads', icon: Download },
|
||||||
];
|
];
|
||||||
@ -33,8 +30,10 @@ export default function Navbar() {
|
|||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
const [isMaximized, setIsMaximized] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isElectronApp = isElectron();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
@ -49,6 +48,21 @@ export default function Navbar() {
|
|||||||
setIsMobileMenuOpen(false);
|
setIsMobileMenuOpen(false);
|
||||||
}, [location.pathname]);
|
}, [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) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (searchQuery.trim()) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Window Controls (Electron only, Windows/Linux) */}
|
||||||
|
{isElectronApp && (window.electron?.platform === 'win32' || window.electron?.platform === 'linux') && (
|
||||||
|
<div className="absolute top-0 right-0 flex items-center h-8 z-50">
|
||||||
|
<button
|
||||||
|
onClick={handleMinimize}
|
||||||
|
className="w-12 h-8 flex items-center justify-center hover:bg-white/10 transition-colors text-gray-300 hover:text-white"
|
||||||
|
title="Minimize"
|
||||||
|
>
|
||||||
|
<Minus size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleMaximize}
|
||||||
|
className="w-12 h-8 flex items-center justify-center hover:bg-white/10 transition-colors text-gray-300 hover:text-white"
|
||||||
|
title={isMaximized ? 'Restore' : 'Maximize'}
|
||||||
|
>
|
||||||
|
{isMaximized ? <Square size={14} /> : <Maximize2 size={14} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="w-12 h-8 flex items-center justify-center hover:bg-red-600 transition-colors text-gray-300 hover:text-white"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<nav
|
<nav
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'fixed top-0 left-0 right-0 z-50 transition-all duration-300 safe-top',
|
'fixed top-0 left-0 right-0 z-50 transition-all duration-300 safe-top',
|
||||||
|
|||||||
70
src/main.tsx
70
src/main.tsx
@ -1,14 +1,68 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { HashRouter } from 'react-router-dom';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
// Error boundary for better debugging
|
||||||
<React.StrictMode>
|
window.addEventListener('error', (event) => {
|
||||||
<BrowserRouter>
|
console.error('Global error:', event.error);
|
||||||
<App />
|
});
|
||||||
</BrowserRouter>
|
|
||||||
</React.StrictMode>
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
);
|
console.error('Unhandled promise rejection:', event.reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for DOM to be ready
|
||||||
|
function mountApp() {
|
||||||
|
try {
|
||||||
|
// Ensure body exists
|
||||||
|
if (!document.body) {
|
||||||
|
console.error('Document body not found!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create root element if it doesn't exist
|
||||||
|
let rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) {
|
||||||
|
console.warn('Root element not found, creating it...');
|
||||||
|
rootElement = document.createElement('div');
|
||||||
|
rootElement.id = 'root';
|
||||||
|
document.body.appendChild(rootElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Root element found, mounting React...');
|
||||||
|
|
||||||
|
ReactDOM.createRoot(rootElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<HashRouter>
|
||||||
|
<App />
|
||||||
|
</HashRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('React app mounted successfully');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mount React app:', error);
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.style.cssText = 'padding: 20px; font-family: monospace; color: red; background: white; position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 99999; overflow: auto;';
|
||||||
|
errorDiv.innerHTML = `
|
||||||
|
<h1>Failed to Load App</h1>
|
||||||
|
<p>Error: ${error instanceof Error ? error.message : String(error)}</p>
|
||||||
|
<pre style="background: #f0f0f0; padding: 10px; overflow: auto;">${error instanceof Error ? error.stack : ''}</pre>
|
||||||
|
`;
|
||||||
|
if (document.body) {
|
||||||
|
document.body.appendChild(errorDiv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DOM to be ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', mountApp);
|
||||||
|
} else {
|
||||||
|
// DOM is already ready
|
||||||
|
mountApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useQueue } from '../hooks/useCalendar';
|
import { useQueue } from '../hooks/useCalendar';
|
||||||
import { useHasConnectedServices } from '../hooks/useIntegration';
|
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
import Badge from '../components/ui/Badge';
|
import Badge from '../components/ui/Badge';
|
||||||
import ProgressBar from '../components/ui/ProgressBar';
|
import ProgressBar from '../components/ui/ProgressBar';
|
||||||
|
|||||||
@ -8,16 +8,13 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Info,
|
Info,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Link2,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Select from '../components/ui/Select';
|
import Select from '../components/ui/Select';
|
||||||
import Button from '../components/ui/Button';
|
import Button from '../components/ui/Button';
|
||||||
import { ConnectionSetup, ServiceStatus } from '../components/integration';
|
|
||||||
import { useSettingsStore } from '../stores/settingsStore';
|
import { useSettingsStore } from '../stores/settingsStore';
|
||||||
import { useWatchlistStore } from '../stores/watchlistStore';
|
import { useWatchlistStore } from '../stores/watchlistStore';
|
||||||
import { useHistoryStore } from '../stores/historyStore';
|
import { useHistoryStore } from '../stores/historyStore';
|
||||||
import { useDownloadStore } from '../stores/downloadStore';
|
import { useDownloadStore } from '../stores/downloadStore';
|
||||||
import { useIntegrationStore } from '../stores/integrationStore';
|
|
||||||
import { ytsApi } from '../services/api/yts';
|
import { ytsApi } from '../services/api/yts';
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
@ -25,7 +22,6 @@ export default function Settings() {
|
|||||||
const { items: watchlistItems, clearWatchlist } = useWatchlistStore();
|
const { items: watchlistItems, clearWatchlist } = useWatchlistStore();
|
||||||
const { items: historyItems, clearHistory } = useHistoryStore();
|
const { items: historyItems, clearHistory } = useHistoryStore();
|
||||||
const { items: downloadItems, clearCompleted } = useDownloadStore();
|
const { items: downloadItems, clearCompleted } = useDownloadStore();
|
||||||
const { settings: integrationSettings, updateSettings: updateIntegrationSettings } = useIntegrationStore();
|
|
||||||
|
|
||||||
const handleClearCache = () => {
|
const handleClearCache = () => {
|
||||||
if (confirm('Clear all cached data? This will not affect your watchlist or history.')) {
|
if (confirm('Clear all cached data? This will not affect your watchlist or history.')) {
|
||||||
@ -64,60 +60,6 @@ export default function Settings() {
|
|||||||
|
|
||||||
{/* Settings Sections */}
|
{/* Settings Sections */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Integrations */}
|
|
||||||
<motion.section
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.05 }}
|
|
||||||
className="p-6 glass rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
|
||||||
<Link2 size={20} />
|
|
||||||
Integrations
|
|
||||||
</h2>
|
|
||||||
<ServiceStatus showLabels size="md" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-400 mb-6">
|
|
||||||
Connect to Radarr, Sonarr, and Lidarr to access your media libraries and enable
|
|
||||||
automated downloads, calendar view, and more.
|
|
||||||
</p>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ConnectionSetup type="radarr" />
|
|
||||||
<ConnectionSetup type="sonarr" />
|
|
||||||
<ConnectionSetup type="lidarr" />
|
|
||||||
</div>
|
|
||||||
</motion.section>
|
|
||||||
|
|
||||||
{/* Content Sources */}
|
|
||||||
<motion.section
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.08 }}
|
|
||||||
className="p-6 glass rounded-lg"
|
|
||||||
>
|
|
||||||
<h2 className="text-xl font-semibold flex items-center gap-2 mb-4">
|
|
||||||
<Database size={20} />
|
|
||||||
Content Sources
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Select
|
|
||||||
label="Movie Source"
|
|
||||||
options={[
|
|
||||||
{ value: 'yts', label: 'YTS Only (Streaming)' },
|
|
||||||
{ value: 'radarr', label: 'Radarr Only (Library)' },
|
|
||||||
{ value: 'all', label: 'Both YTS & Radarr' },
|
|
||||||
]}
|
|
||||||
value={integrationSettings.movieSource}
|
|
||||||
onChange={(e) => updateIntegrationSettings({
|
|
||||||
movieSource: e.target.value as 'yts' | 'radarr' | 'all'
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Choose whether to browse movies from YTS (torrent streaming) or your Radarr library.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</motion.section>
|
|
||||||
|
|
||||||
{/* Playback Settings */}
|
{/* Playback Settings */}
|
||||||
<motion.section
|
<motion.section
|
||||||
|
|||||||
@ -237,9 +237,4 @@ export const TRACKERS = [
|
|||||||
'udp://tracker.leechers-paradise.org:6969',
|
'udp://tracker.leechers-paradise.org:6969',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Re-export unified and service types
|
|
||||||
export * from './unified';
|
|
||||||
export * from './radarr';
|
|
||||||
export * from './sonarr';
|
|
||||||
export * from './lidarr';
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user