feat(ui): add remote-control URLs + fix header typos and env

Add support for displaying local remote-control URLs and QR code in the
Settings UI, including styles for URL list, row layout, monospaced links,
and a QR container. Expose a new global API signature getLocalIpAddresses
for retrieving local IPs.

Fix several UI issues:
- Correct typo in header method name (opedAddPlaylistDialog -> openAddPlaylistDialog).
- Fix malformed GitHub URL and collapse img tag formatting.
- Reformat imports and viewChild usage for readability.

Change local development backend URL to localhost:3000 in the local
environment to point to the running local API during development.

These changes improve developer ergonomics (local backend), add a remote
control discovery UX for users on the same network, and tidy up header
bugs and formatting.
This commit is contained in:
4gray committed 2025-12-06 21:46:35 +01:00
1 parent 7e4732a1bf
commit 3e2afe11e3
11 files changed
+171 -18

No files matched your search

@@ -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'),
});
@@ -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;
});
@@ -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) {
+14 -2
View File
@@ -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,
@@ -326,6 +326,49 @@
</mat-form-field>
</div>
</div>
@if (localIpAddresses().length > 0) {
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}
<p>
{{ 'SETTINGS.REMOTE_CONTROL_URL_DESCRIPTION' | translate }}
</p>
</div>
<div class="column margin-right remote-control-urls">
@for (ip of localIpAddresses(); track ip) {
<div class="remote-control-url-item">
<div class="url-row">
<a
[href]="'http://' + ip + ':' + settingsForm.value.remoteControlPort"
target="_blank"
class="remote-control-url"
>
http://{{ ip }}:{{ settingsForm.value.remoteControlPort }}
</a>
<button
mat-icon-button
type="button"
(click)="toggleQrCode(ip)"
[matTooltip]="'SETTINGS.SHOW_QR_CODE' | translate"
>
<mat-icon>qr_code_2</mat-icon>
</button>
</div>
@if (visibleQrCodeIp() === ip) {
<div class="qr-code-container">
<qrcode
[qrdata]="'http://' + ip + ':' + settingsForm.value.remoteControlPort"
[width]="150"
errorCorrectionLevel="M"
></qrcode>
</div>
}
</div>
}
</div>
</div>
}
}
<mat-divider />
<div class="row">
@@ -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;
}
@@ -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<string[]>([]);
/** Currently visible QR code IP (null = none visible) */
visibleQrCodeIp = signal<string | null>(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<void> {
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);
}
}
/**
@@ -1,10 +1,7 @@
<div class="modern-header">
<div class="brand-block">
<div class="logo-pulse">
<img
src="./assets/icons/icon-tv-256.png"
alt="iptvnator logo"
/>
<img src="./assets/icons/icon-tv-256.png" alt="iptvnator logo" />
</div>
<div class="brand-text">
<div class="title">{{ title() }}</div>
@@ -35,7 +32,7 @@
<mat-icon>add</mat-icon>
</button>
<app-add-playlist-menu
(playlistTypeSelected)="opedAddPlaylistDialog($event)"
(playlistTypeSelected)="openAddPlaylistDialog($event)"
/>
<!-- Sort button -->
<button
@@ -88,7 +85,7 @@
<button
mat-menu-item
[attr.aria-label]="'MENU.SUPPORT_ARIA' | translate"
(click)="openUrl('https:///github.com/4gray/iptvnator')"
(click)="openUrl('https://github.com/4gray/iptvnator')"
>
<mat-icon>recommend</mat-icon>
<span>GitHub</span>
@@ -1,4 +1,13 @@
import { Component, HostBinding, OnInit, effect, inject, input, output, viewChild } from '@angular/core';
import {
Component,
HostBinding,
OnInit,
effect,
inject,
input,
output,
viewChild,
} from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -12,8 +21,8 @@ import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { DataService, SortBy, SortOrder, SortService } from 'services';
//import { shell } from 'electron';
import { selectActiveTypeFilters, setSelectedFilters } from 'm3u-state';
import { AddPlaylistMenuComponent, PlaylistType } from 'components';
import { selectActiveTypeFilters, setSelectedFilters } from 'm3u-state';
import { HomeComponent } from '../../../home/home.component';
import { AboutDialogComponent } from '../about-dialog/about-dialog.component';
import { AddPlaylistDialogComponent } from '../add-playlist/add-playlist-dialog.component';
@@ -47,7 +56,9 @@ export class HeaderComponent implements OnInit {
private store = inject(Store);
private sortService = inject(SortService);
readonly addPlaylistMenuComponent = viewChild.required(AddPlaylistMenuComponent);
readonly addPlaylistMenuComponent = viewChild.required(
AddPlaylistMenuComponent
);
readonly isDesktop = !!window.electron;
readonly title = input.required<string>();
readonly subtitle = input.required<string>();
@@ -133,7 +144,7 @@ export class HeaderComponent implements OnInit {
});
}
opedAddPlaylistDialog(type: PlaylistType) {
openAddPlaylistDialog(type: PlaylistType) {
this.dialog.open<AddPlaylistDialogComponent, { type: PlaylistType }>(
AddPlaylistDialogComponent,
{
+1 -1
View File
@@ -4,5 +4,5 @@ export const AppConfig = {
production: false,
environment: 'LOCAL',
version: packageJson.version,
BACKEND_URL: 'https://iptvnator-playlist-parser-api.vercel.app',
BACKEND_URL: 'http://localhost:3000',
};
Vendored
+1
View File
@@ -160,6 +160,7 @@ declare global {
onChannelChange?: (
callback: (data: { direction: 'up' | 'down' }) => void
) => void;
getLocalIpAddresses: () => Promise<string[]>;
};
process: NodeJS.Process;
require: NodeRequire;