mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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:
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
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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
@@ -160,6 +160,7 @@ declare global {
|
||||
onChannelChange?: (
|
||||
callback: (data: { direction: 'up' | 'down' }) => void
|
||||
) => void;
|
||||
getLocalIpAddresses: () => Promise<string[]>;
|
||||
};
|
||||
process: NodeJS.Process;
|
||||
require: NodeRequire;
|
||||
|
||||
Reference in new issue
Block a user