Skip to content

Commit 5796dd0

Browse files
authored
Merge pull request #652 from 4gray/feat-remote-control-ui-server-integration
feat-remote-control-ui-server-integration
2 parents ba5662e + 63f8006 commit 5796dd0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+2396
-779
lines changed

apps/electron-backend/project.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"build": {
88
"dependsOn": [
99
{
10-
"projects": ["web"],
10+
"projects": ["web", "remote-control-web"],
1111
"target": "build"
1212
}
1313
],
@@ -54,6 +54,7 @@
5454
"nx run electron-backend:serve-electron"
5555
]
5656
}
57+
5758
},
5859
"serve-electron": {
5960
"executor": "nx-electron:execute",

apps/electron-backend/src/app/api/main.preload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { contextBridge, ipcRenderer } from 'electron';
22

33
contextBridge.exposeInMainWorld('electron', {
4+
// Remote control channel change listener
5+
onChannelChange: (callback: (data: { direction: 'up' | 'down' }) => void) => {
6+
ipcRenderer.on('CHANNEL_CHANGE', (_event, data) => callback(data));
7+
},
48
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
59
platform: process.platform,
610
fetchPlaylistByUrl: (url: string, title?: string) =>
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { BrowserWindow, ipcMain } from 'electron';
2+
import * as http from 'http';
3+
import { httpServer } from '../server/http-server';
4+
import { store } from '../services/store.service';
5+
6+
class RemoteControlEvents {
7+
/**
8+
* Bootstrap remote control events
9+
*/
10+
bootstrapRemoteControlEvents(): void {
11+
// Register HTTP API endpoints
12+
httpServer.registerRemoteControlHandler(
13+
'/api/remote-control/channel/up',
14+
this.handleChannelUp.bind(this)
15+
);
16+
17+
httpServer.registerRemoteControlHandler(
18+
'/api/remote-control/channel/down',
19+
this.handleChannelDown.bind(this)
20+
);
21+
22+
// Start HTTP server if remote control is enabled in settings
23+
const remoteControlEnabled = store.get('remoteControl', false);
24+
const remoteControlPort = store.get('remoteControlPort', 8765);
25+
if (remoteControlEnabled) {
26+
httpServer.start(remoteControlPort);
27+
}
28+
29+
// Register IPC handlers (for when the main app wants to use remote control)
30+
ipcMain.handle('REMOTE_CONTROL_CHANNEL_UP', () => {
31+
return this.changeChannelUp();
32+
});
33+
34+
ipcMain.handle('REMOTE_CONTROL_CHANNEL_DOWN', () => {
35+
return this.changeChannelDown();
36+
});
37+
}
38+
39+
/**
40+
* HTTP handler for channel up
41+
*/
42+
private handleChannelUp(
43+
req: http.IncomingMessage,
44+
res: http.ServerResponse
45+
): void {
46+
if (req.method !== 'POST') {
47+
res.writeHead(405, { 'Content-Type': 'application/json' });
48+
res.end(JSON.stringify({ error: 'Method not allowed' }));
49+
return;
50+
}
51+
52+
this.changeChannelUp();
53+
54+
res.writeHead(200, { 'Content-Type': 'application/json' });
55+
res.end(JSON.stringify({ success: true }));
56+
}
57+
58+
/**
59+
* HTTP handler for channel down
60+
*/
61+
private handleChannelDown(
62+
req: http.IncomingMessage,
63+
res: http.ServerResponse
64+
): void {
65+
if (req.method !== 'POST') {
66+
res.writeHead(405, { 'Content-Type': 'application/json' });
67+
res.end(JSON.stringify({ error: 'Method not allowed' }));
68+
return;
69+
}
70+
71+
this.changeChannelDown();
72+
73+
res.writeHead(200, { 'Content-Type': 'application/json' });
74+
res.end(JSON.stringify({ success: true }));
75+
}
76+
77+
/**
78+
* Change to the next channel
79+
*/
80+
private changeChannelUp(): void {
81+
console.log('Channel UP requested');
82+
this.sendChannelChangeToRenderer('up');
83+
}
84+
85+
/**
86+
* Change to the previous channel
87+
*/
88+
private changeChannelDown(): void {
89+
console.log('Channel DOWN requested');
90+
this.sendChannelChangeToRenderer('down');
91+
}
92+
93+
/**
94+
* Send channel change message to the renderer process
95+
*/
96+
private sendChannelChangeToRenderer(direction: 'up' | 'down'): void {
97+
const windows = BrowserWindow.getAllWindows();
98+
if (windows.length > 0) {
99+
windows[0].webContents.send('CHANNEL_CHANGE', { direction });
100+
console.log(`Sent CHANNEL_CHANGE ${direction} to renderer`);
101+
} else {
102+
console.warn('No browser windows found to send channel change');
103+
}
104+
}
105+
}
106+
107+
export default new RemoteControlEvents();

apps/electron-backend/src/app/events/settings.events.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
MPV_REUSE_INSTANCE,
44
store,
55
} from '../services/store.service';
6+
import { httpServer } from '../server/http-server';
67

78
export default class SettingsEvents {
89
static bootstrapSettingsEvents(): Electron.IpcMain {
@@ -12,9 +13,26 @@ export default class SettingsEvents {
1213

1314
ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
1415
console.log('Received SETTINGS_UPDATE with data:', arg);
15-
16+
1617
// Only set values that are defined
1718
if (arg.mpvReuseInstance !== undefined) {
1819
store.set(MPV_REUSE_INSTANCE, arg.mpvReuseInstance);
1920
}
21+
22+
// Handle remote control settings
23+
if (arg.remoteControl !== undefined || arg.remoteControlPort !== undefined) {
24+
const enabled = arg.remoteControl ?? store.get('remoteControl', false);
25+
const port = arg.remoteControlPort ?? store.get('remoteControlPort', 8765);
26+
27+
// Save to store
28+
if (arg.remoteControl !== undefined) {
29+
store.set('remoteControl', enabled);
30+
}
31+
if (arg.remoteControlPort !== undefined) {
32+
store.set('remoteControlPort', port);
33+
}
34+
35+
// Update HTTP server
36+
httpServer.updateSettings(enabled, port);
37+
}
2038
});
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { app } from 'electron';
2+
import * as fs from 'fs';
3+
import * as http from 'http';
4+
import * as path from 'path';
5+
6+
/**
7+
* HTTP server for serving the remote control web app and providing REST API endpoints
8+
*/
9+
export class HttpServer {
10+
private server: http.Server | null = null;
11+
private port = 8765;
12+
private isEnabled = false;
13+
private distPath: string;
14+
private remoteControlHandlers: Map<
15+
string,
16+
(req: http.IncomingMessage, res: http.ServerResponse) => void
17+
> = new Map();
18+
19+
constructor() {
20+
// Path to the built remote-control-web app
21+
// In development: use workspace root
22+
// In production: use app path
23+
const appPath = app.getAppPath();
24+
const isDev = !app.isPackaged;
25+
26+
if (isDev) {
27+
// Development mode - use workspace root
28+
this.distPath = path.join(
29+
process.cwd(),
30+
'dist',
31+
'apps',
32+
'remote-control-web',
33+
'browser'
34+
);
35+
} else {
36+
// Production mode - files are bundled with the app
37+
this.distPath = path.join(
38+
appPath,
39+
'dist',
40+
'apps',
41+
'remote-control-web',
42+
'browser'
43+
);
44+
}
45+
46+
console.log('[HTTP Server] Serving from:', this.distPath);
47+
}
48+
49+
/**
50+
* Start the HTTP server
51+
*/
52+
start(port?: number): void {
53+
if (port) {
54+
this.port = port;
55+
}
56+
57+
if (this.server) {
58+
console.log('HTTP server is already running');
59+
return;
60+
}
61+
62+
this.server = http.createServer((req, res) => {
63+
this.handleRequest(req, res);
64+
});
65+
66+
this.server.listen(this.port, () => {
67+
console.log(`HTTP server listening on port ${this.port}`);
68+
console.log(
69+
`Remote control available at: http://localhost:${this.port}`
70+
);
71+
});
72+
73+
this.isEnabled = true;
74+
}
75+
76+
/**
77+
* Stop the HTTP server
78+
*/
79+
stop(): void {
80+
if (!this.server) {
81+
return;
82+
}
83+
84+
this.server.close(() => {
85+
console.log('HTTP server stopped');
86+
});
87+
88+
this.server = null;
89+
this.isEnabled = false;
90+
}
91+
92+
/**
93+
* Update server settings
94+
*/
95+
updateSettings(enabled: boolean, port: number): void {
96+
const needsRestart = this.isEnabled && enabled && this.port !== port;
97+
98+
if (!enabled && this.isEnabled) {
99+
this.stop();
100+
} else if (enabled && !this.isEnabled) {
101+
this.start(port);
102+
} else if (needsRestart) {
103+
this.stop();
104+
this.start(port);
105+
}
106+
}
107+
108+
/**
109+
* Register a handler for remote control API endpoints
110+
*/
111+
registerRemoteControlHandler(
112+
path: string,
113+
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void
114+
): void {
115+
this.remoteControlHandlers.set(path, handler);
116+
}
117+
118+
/**
119+
* Handle incoming HTTP requests
120+
*/
121+
private handleRequest(
122+
req: http.IncomingMessage,
123+
res: http.ServerResponse
124+
): void {
125+
const url = req.url || '/';
126+
127+
// Handle API requests
128+
if (url.startsWith('/api/remote-control/')) {
129+
const handler = this.remoteControlHandlers.get(url);
130+
if (handler) {
131+
handler(req, res);
132+
return;
133+
}
134+
135+
res.writeHead(404, { 'Content-Type': 'application/json' });
136+
res.end(JSON.stringify({ error: 'Endpoint not found' }));
137+
return;
138+
}
139+
140+
// Serve static files from the remote-control-web app
141+
this.serveStaticFile(url, res);
142+
}
143+
144+
/**
145+
* Serve static files
146+
*/
147+
private serveStaticFile(url: string, res: http.ServerResponse): void {
148+
// Default to index.html for root path
149+
let filePath = url === '/' ? '/index.html' : url;
150+
151+
// Security: prevent directory traversal
152+
filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, '');
153+
154+
const fullPath = path.join(this.distPath, filePath);
155+
156+
fs.readFile(fullPath, (err, data) => {
157+
if (err) {
158+
// If file not found, try serving index.html (for Angular routing)
159+
if (err.code === 'ENOENT' && filePath !== '/index.html') {
160+
this.serveStaticFile('/', res);
161+
return;
162+
}
163+
164+
res.writeHead(404, { 'Content-Type': 'text/plain' });
165+
res.end('404 Not Found');
166+
return;
167+
}
168+
169+
// Determine content type
170+
const contentType = this.getContentType(fullPath);
171+
res.writeHead(200, { 'Content-Type': contentType });
172+
res.end(data);
173+
});
174+
}
175+
176+
/**
177+
* Get content type based on file extension
178+
*/
179+
private getContentType(filePath: string): string {
180+
const ext = path.extname(filePath).toLowerCase();
181+
const contentTypes: Record<string, string> = {
182+
'.html': 'text/html',
183+
'.js': 'application/javascript',
184+
'.css': 'text/css',
185+
'.json': 'application/json',
186+
'.png': 'image/png',
187+
'.jpg': 'image/jpeg',
188+
'.gif': 'image/gif',
189+
'.svg': 'image/svg+xml',
190+
'.ico': 'image/x-icon',
191+
'.woff': 'font/woff',
192+
'.woff2': 'font/woff2',
193+
'.ttf': 'font/ttf',
194+
};
195+
196+
return contentTypes[ext] || 'application/octet-stream';
197+
}
198+
}
199+
200+
export const httpServer = new HttpServer();

apps/electron-backend/src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ElectronEvents from './app/events/electron.events';
77
import EpgEvents from './app/events/epg.events';
88
import PlayerEvents from './app/events/player.events';
99
import PlaylistEvents from './app/events/playlist.events';
10+
import RemoteControlEvents from './app/events/remote-control.events';
1011
import SettingsEvents from './app/events/settings.events';
1112
import SharedEvents from './app/events/shared.events';
1213
import SquirrelEvents from './app/events/squirrel.events';
@@ -40,6 +41,7 @@ export default class Main {
4041
XtreamEvents.bootstrapXtreamEvents();
4142
DatabaseEvents.bootstrapDatabaseEvents();
4243
EpgEvents.bootstrapEpgEvents();
44+
RemoteControlEvents.bootstrapRemoteControlEvents();
4345

4446
// initialize auto updater service
4547
if (!App.isDevelopmentMode()) {

0 commit comments

Comments
 (0)