diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 777495ce6..e9291cd7d 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -137,4 +137,5 @@ contextBridge.exposeInMainWorld('electron', { dbGetContentByXtreamId: (xtreamId: number, playlistId: string) => ipcRenderer.invoke('DB_GET_CONTENT_BY_XTREAM_ID', xtreamId, playlistId), dbDeleteAllPlaylists: () => ipcRenderer.invoke('DB_DELETE_ALL_PLAYLISTS'), + getLocalIpAddresses: () => ipcRenderer.invoke('get-local-ip-addresses'), }); diff --git a/apps/electron-backend/src/app/events/electron.events.ts b/apps/electron-backend/src/app/events/electron.events.ts index 8985e2fea..47795d0f7 100644 --- a/apps/electron-backend/src/app/events/electron.events.ts +++ b/apps/electron-backend/src/app/events/electron.events.ts @@ -4,6 +4,7 @@ */ import { app, ipcMain } from 'electron'; +import * as os from 'os'; import { environment } from '../../environments/environment'; export default class ElectronEvents { @@ -23,3 +24,20 @@ ipcMain.handle('get-app-version', (event) => { ipcMain.on('quit', (event, code) => { app.exit(code); }); + +// Get local IP addresses for remote control URL display +ipcMain.handle('get-local-ip-addresses', () => { + const interfaces = os.networkInterfaces(); + const addresses: string[] = []; + + for (const name of Object.keys(interfaces)) { + for (const iface of interfaces[name] || []) { + // Skip internal (loopback) and non-IPv4 addresses + if (iface.family === 'IPv4' && !iface.internal) { + addresses.push(iface.address); + } + } + } + + return addresses; +}); diff --git a/apps/electron-backend/src/app/server/http-server.ts b/apps/electron-backend/src/app/server/http-server.ts index c56b38d2b..23a235133 100644 --- a/apps/electron-backend/src/app/server/http-server.ts +++ b/apps/electron-backend/src/app/server/http-server.ts @@ -10,13 +10,21 @@ export class HttpServer { private server: http.Server | null = null; private port = 8765; private isEnabled = false; - private distPath: string; + private distPath: string | null = null; private remoteControlHandlers: Map< string, (req: http.IncomingMessage, res: http.ServerResponse) => void > = new Map(); - constructor() { + /** + * Get the path to the remote-control-web static files. + * Lazily computed to avoid calling Electron APIs before app is ready. + */ + private getDistPath(): string { + if (this.distPath) { + return this.distPath; + } + // Path to the built remote-control-web app // In development: use workspace root // In production: use app path @@ -34,16 +42,16 @@ export class HttpServer { ); } else { // Production mode - files are bundled with the app + // electron-builder copies remote-control-web/**/* directly to app root this.distPath = path.join( appPath, - 'dist', - 'apps', 'remote-control-web', 'browser' ); } console.log('[HTTP Server] Serving from:', this.distPath); + return this.distPath; } /** @@ -151,7 +159,7 @@ export class HttpServer { // Security: prevent directory traversal filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, ''); - const fullPath = path.join(this.distPath, filePath); + const fullPath = path.join(this.getDistPath(), filePath); fs.readFile(fullPath, (err, data) => { if (err) { diff --git a/apps/web/project.json b/apps/web/project.json index f3b40227d..17bd28c6b 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -43,7 +43,13 @@ "maximumError": "10kb" } ], - "outputHashing": "all" + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "apps/web/src/environments/environment.ts", + "with": "apps/web/src/environments/environment.prod.ts" + } + ] }, "pwa": { "baseHref": "/", @@ -62,7 +68,13 @@ "outputHashing": "all", "optimization": true, "extractLicenses": true, - "sourceMap": false + "sourceMap": false, + "fileReplacements": [ + { + "replace": "apps/web/src/environments/environment.ts", + "with": "apps/web/src/environments/environment.prod.ts" + } + ] }, "development": { "optimization": false, diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index f882c0261..d184d28f2 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -326,6 +326,49 @@ + @if (localIpAddresses().length > 0) { + +
+
+ {{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }} +

+ {{ 'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION' | translate }} +

+
+
+ @for (ip of localIpAddresses(); track ip) { +
+ + @if (visibleQrCodeIp() === ip) { +
+ +
+ } +
+ } +
+
+ } }
diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 46ac2d60e..0e0b4ac55 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -43,3 +43,34 @@ button { margin: 0 10px; } } + +.remote-control-urls { + text-align: right; +} + +.remote-control-url-item { + margin-bottom: 8px; +} + +.url-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +.remote-control-url { + font-family: monospace; + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.qr-code-container { + display: flex; + justify-content: flex-end; + padding: 8px 0; +} diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 0d3b86897..21d578c35 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -7,6 +7,7 @@ import { Input, OnInit, Optional, + signal, } from '@angular/core'; import { FormArray, @@ -32,6 +33,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { QRCodeComponent } from 'angularx-qrcode'; import { DialogService } from 'components'; import * as PlaylistActions from 'm3u-state'; import { selectIsEpgAvailable } from 'm3u-state'; @@ -65,6 +67,7 @@ import { SettingsService } from './../services/settings.service'; ReactiveFormsModule, TranslateModule, MatDialogModule, + QRCodeComponent, ], }) export class SettingsComponent implements OnInit { @@ -158,6 +161,12 @@ export class SettingsComponent implements OnInit { /** Form array with epg sources */ epgUrl = this.settingsForm.get('epgUrl') as FormArray; + /** Local IP addresses for remote control URL display */ + localIpAddresses = signal([]); + + /** Currently visible QR code IP (null = none visible) */ + visibleQrCodeIp = signal(null); + private settingsStore = inject(SettingsStore); constructor( @@ -175,6 +184,28 @@ export class SettingsComponent implements OnInit { await this.settingsStore.loadSettings(); this.setSettings(); this.checkAppVersion(); + this.fetchLocalIpAddresses(); + } + + /** + * Fetches local IP addresses for remote control URL display + */ + async fetchLocalIpAddresses(): Promise { + if (window.electron?.getLocalIpAddresses) { + const addresses = await window.electron.getLocalIpAddresses(); + this.localIpAddresses.set(addresses); + } + } + + /** + * Toggle QR code visibility for a given IP address + */ + toggleQrCode(ip: string): void { + if (this.visibleQrCodeIp() === ip) { + this.visibleQrCodeIp.set(null); + } else { + this.visibleQrCodeIp.set(ip); + } } /** diff --git a/apps/web/src/app/shared/components/header/header.component.html b/apps/web/src/app/shared/components/header/header.component.html index 2608029e5..dc81325e0 100644 --- a/apps/web/src/app/shared/components/header/header.component.html +++ b/apps/web/src/app/shared/components/header/header.component.html @@ -1,10 +1,7 @@
- iptvnator logo + iptvnator logo
{{ title() }}
@@ -35,7 +32,7 @@ add