diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index de1012256..b91fafabb 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -1,6 +1,10 @@ import { contextBridge, ipcRenderer } from 'electron'; contextBridge.exposeInMainWorld('electron', { + // Remote control channel change listener + onChannelChange: (callback: (data: { direction: 'up' | 'down' }) => void) => { + ipcRenderer.on('CHANNEL_CHANGE', (_event, data) => callback(data)); + }, getAppVersion: () => ipcRenderer.invoke('get-app-version'), platform: process.platform, fetchPlaylistByUrl: (url: string, title?: string) => diff --git a/apps/electron-backend/src/app/events/remote-control.events.ts b/apps/electron-backend/src/app/events/remote-control.events.ts new file mode 100644 index 000000000..90c6e7a10 --- /dev/null +++ b/apps/electron-backend/src/app/events/remote-control.events.ts @@ -0,0 +1,107 @@ +import { BrowserWindow, ipcMain } from 'electron'; +import * as http from 'http'; +import { httpServer } from '../server/http-server'; +import { store } from '../services/store.service'; + +class RemoteControlEvents { + /** + * Bootstrap remote control events + */ + bootstrapRemoteControlEvents(): void { + // Register HTTP API endpoints + httpServer.registerRemoteControlHandler( + '/api/remote-control/channel/up', + this.handleChannelUp.bind(this) + ); + + httpServer.registerRemoteControlHandler( + '/api/remote-control/channel/down', + this.handleChannelDown.bind(this) + ); + + // Start HTTP server if remote control is enabled in settings + const remoteControlEnabled = store.get('remoteControl', false); + const remoteControlPort = store.get('remoteControlPort', 8765); + if (remoteControlEnabled) { + httpServer.start(remoteControlPort); + } + + // Register IPC handlers (for when the main app wants to use remote control) + ipcMain.handle('REMOTE_CONTROL_CHANNEL_UP', () => { + return this.changeChannelUp(); + }); + + ipcMain.handle('REMOTE_CONTROL_CHANNEL_DOWN', () => { + return this.changeChannelDown(); + }); + } + + /** + * HTTP handler for channel up + */ + private handleChannelUp( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + this.changeChannelUp(); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + } + + /** + * HTTP handler for channel down + */ + private handleChannelDown( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + this.changeChannelDown(); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + } + + /** + * Change to the next channel + */ + private changeChannelUp(): void { + console.log('Channel UP requested'); + this.sendChannelChangeToRenderer('up'); + } + + /** + * Change to the previous channel + */ + private changeChannelDown(): void { + console.log('Channel DOWN requested'); + this.sendChannelChangeToRenderer('down'); + } + + /** + * Send channel change message to the renderer process + */ + private sendChannelChangeToRenderer(direction: 'up' | 'down'): void { + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + windows[0].webContents.send('CHANNEL_CHANGE', { direction }); + console.log(`Sent CHANNEL_CHANGE ${direction} to renderer`); + } else { + console.warn('No browser windows found to send channel change'); + } + } +} + +export default new RemoteControlEvents(); diff --git a/apps/electron-backend/src/app/events/settings.events.ts b/apps/electron-backend/src/app/events/settings.events.ts index 692b266d0..4d50233dc 100644 --- a/apps/electron-backend/src/app/events/settings.events.ts +++ b/apps/electron-backend/src/app/events/settings.events.ts @@ -3,6 +3,7 @@ import { MPV_REUSE_INSTANCE, store, } from '../services/store.service'; +import { httpServer } from '../server/http-server'; export default class SettingsEvents { static bootstrapSettingsEvents(): Electron.IpcMain { @@ -12,9 +13,26 @@ export default class SettingsEvents { ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => { console.log('Received SETTINGS_UPDATE with data:', arg); - + // Only set values that are defined if (arg.mpvReuseInstance !== undefined) { store.set(MPV_REUSE_INSTANCE, arg.mpvReuseInstance); } + + // Handle remote control settings + if (arg.remoteControl !== undefined || arg.remoteControlPort !== undefined) { + const enabled = arg.remoteControl ?? store.get('remoteControl', false); + const port = arg.remoteControlPort ?? store.get('remoteControlPort', 8765); + + // Save to store + if (arg.remoteControl !== undefined) { + store.set('remoteControl', enabled); + } + if (arg.remoteControlPort !== undefined) { + store.set('remoteControlPort', port); + } + + // Update HTTP server + httpServer.updateSettings(enabled, port); + } }); diff --git a/apps/electron-backend/src/app/server/http-server.ts b/apps/electron-backend/src/app/server/http-server.ts new file mode 100644 index 000000000..c56b38d2b --- /dev/null +++ b/apps/electron-backend/src/app/server/http-server.ts @@ -0,0 +1,200 @@ +import { app } from 'electron'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as path from 'path'; + +/** + * HTTP server for serving the remote control web app and providing REST API endpoints + */ +export class HttpServer { + private server: http.Server | null = null; + private port = 8765; + private isEnabled = false; + private distPath: string; + private remoteControlHandlers: Map< + string, + (req: http.IncomingMessage, res: http.ServerResponse) => void + > = new Map(); + + constructor() { + // Path to the built remote-control-web app + // In development: use workspace root + // In production: use app path + const appPath = app.getAppPath(); + const isDev = !app.isPackaged; + + if (isDev) { + // Development mode - use workspace root + this.distPath = path.join( + process.cwd(), + 'dist', + 'apps', + 'remote-control-web', + 'browser' + ); + } else { + // Production mode - files are bundled with the app + this.distPath = path.join( + appPath, + 'dist', + 'apps', + 'remote-control-web', + 'browser' + ); + } + + console.log('[HTTP Server] Serving from:', this.distPath); + } + + /** + * Start the HTTP server + */ + start(port?: number): void { + if (port) { + this.port = port; + } + + if (this.server) { + console.log('HTTP server is already running'); + return; + } + + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + + this.server.listen(this.port, () => { + console.log(`HTTP server listening on port ${this.port}`); + console.log( + `Remote control available at: http://localhost:${this.port}` + ); + }); + + this.isEnabled = true; + } + + /** + * Stop the HTTP server + */ + stop(): void { + if (!this.server) { + return; + } + + this.server.close(() => { + console.log('HTTP server stopped'); + }); + + this.server = null; + this.isEnabled = false; + } + + /** + * Update server settings + */ + updateSettings(enabled: boolean, port: number): void { + const needsRestart = this.isEnabled && enabled && this.port !== port; + + if (!enabled && this.isEnabled) { + this.stop(); + } else if (enabled && !this.isEnabled) { + this.start(port); + } else if (needsRestart) { + this.stop(); + this.start(port); + } + } + + /** + * Register a handler for remote control API endpoints + */ + registerRemoteControlHandler( + path: string, + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void + ): void { + this.remoteControlHandlers.set(path, handler); + } + + /** + * Handle incoming HTTP requests + */ + private handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + const url = req.url || '/'; + + // Handle API requests + if (url.startsWith('/api/remote-control/')) { + const handler = this.remoteControlHandlers.get(url); + if (handler) { + handler(req, res); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Endpoint not found' })); + return; + } + + // Serve static files from the remote-control-web app + this.serveStaticFile(url, res); + } + + /** + * Serve static files + */ + private serveStaticFile(url: string, res: http.ServerResponse): void { + // Default to index.html for root path + let filePath = url === '/' ? '/index.html' : url; + + // Security: prevent directory traversal + filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, ''); + + const fullPath = path.join(this.distPath, filePath); + + fs.readFile(fullPath, (err, data) => { + if (err) { + // If file not found, try serving index.html (for Angular routing) + if (err.code === 'ENOENT' && filePath !== '/index.html') { + this.serveStaticFile('/', res); + return; + } + + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('404 Not Found'); + return; + } + + // Determine content type + const contentType = this.getContentType(fullPath); + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); + } + + /** + * Get content type based on file extension + */ + private getContentType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const contentTypes: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + }; + + return contentTypes[ext] || 'application/octet-stream'; + } +} + +export const httpServer = new HttpServer(); diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 4e1d08ed6..69d6f8e9b 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -7,6 +7,7 @@ import ElectronEvents from './app/events/electron.events'; import EpgEvents from './app/events/epg.events'; import PlayerEvents from './app/events/player.events'; import PlaylistEvents from './app/events/playlist.events'; +import RemoteControlEvents from './app/events/remote-control.events'; import SettingsEvents from './app/events/settings.events'; import SharedEvents from './app/events/shared.events'; import SquirrelEvents from './app/events/squirrel.events'; @@ -40,6 +41,7 @@ export default class Main { XtreamEvents.bootstrapXtreamEvents(); DatabaseEvents.bootstrapDatabaseEvents(); EpgEvents.bootstrapEpgEvents(); + RemoteControlEvents.bootstrapRemoteControlEvents(); // initialize auto updater service if (!App.isDevelopmentMode()) { diff --git a/global.d.ts b/global.d.ts index bacf1bde3..199d2292e 100644 --- a/global.d.ts +++ b/global.d.ts @@ -156,6 +156,10 @@ declare global { xtreamId: number, playlistId: string ) => Promise; + // Remote control + onChannelChange?: ( + callback: (data: { direction: 'up' | 'down' }) => void + ) => void; }; process: NodeJS.Process; require: NodeRequire;