From 620ecb7518f641e4553c05a3e5ee8fc3cf160504 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 13 Feb 2026 22:17:20 +0100 Subject: [PATCH 1/3] feat(remote-control): remote control support for xtream and stalker live tv --- .../src/app/api/main.preload.ts | 5 +- apps/remote-control-web/README.md | 9 ++-- .../video-player/video-player.component.ts | 50 ++++++++----------- .../src/app/settings/settings.component.html | 2 +- .../remote-channel-navigation.util.ts | 34 +++++++++++++ .../stalker-live-stream-layout.component.ts | 35 +++++++++++++ .../live-stream-layout.component.ts | 41 ++++++++++++++- apps/web/src/typings.d.ts | 2 +- 8 files changed, 143 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/app/shared/services/remote-channel-navigation.util.ts diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index c2cc0759e..1f5607745 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -5,7 +5,10 @@ contextBridge.exposeInMainWorld('electron', { onChannelChange: ( callback: (data: { direction: 'up' | 'down' }) => void ) => { - ipcRenderer.on('CHANNEL_CHANGE', (_event, data) => callback(data)); + const handler = (_event: Electron.IpcRendererEvent, data: any) => + callback(data); + ipcRenderer.on('CHANNEL_CHANGE', handler); + return () => ipcRenderer.off('CHANNEL_CHANGE', handler); }, // Player error listener onPlayerError: ( diff --git a/apps/remote-control-web/README.md b/apps/remote-control-web/README.md index 9137bddae..2e7c1ff8f 100644 --- a/apps/remote-control-web/README.md +++ b/apps/remote-control-web/README.md @@ -1,6 +1,6 @@ # Remote Control Web App -A lightweight, mobile-optimized web application that provides remote control functionality for IPTVnator. Control channel switching from your phone or tablet on the same network. +A lightweight, mobile-optimized web application that provides remote control functionality for IPTVnator. Control live channel switching from your phone or tablet on the same network. ## Overview @@ -11,6 +11,7 @@ This is a standalone Angular application designed to be served by IPTVnator's El - **Apple TV-Inspired Design** - Premium dark theme with glass morphism effects - **Mobile-Optimized** - Touch-friendly interface designed for phones and tablets - **Real-time Control** - Instant channel switching via REST API +- **Multi-Portal Support** - Works with M3U playlists, Xtream Live TV, and Stalker ITV sections - **Minimal & Fast** - Lightweight app (~55 KB compressed) with no external dependencies - **Responsive** - Adapts to different screen sizes (320px - 480px+) @@ -35,7 +36,7 @@ This is a standalone Angular application designed to be served by IPTVnator's El 2. **Static Files**: Serves this Angular app's built files 3. **REST API**: Exposes `/api/remote-control/channel/up` and `/api/remote-control/channel/down` endpoints 4. **IPC Communication**: HTTP server sends events to the main Electron window -5. **Channel Switching**: Main app receives events and switches to next/previous channel +5. **Channel Switching**: Main app receives events and switches to next/previous live channel in the active live view ### API Endpoints @@ -166,7 +167,9 @@ Potential improvements: - **HTTP Server**: `apps/electron-backend/src/app/server/http-server.ts` - **Remote Control Events**: `apps/electron-backend/src/app/events/remote-control.events.ts` -- **Main App Handler**: `apps/web/src/app/home/video-player/video-player.component.ts` (handleRemoteChannelChange method) +- **M3U Handler**: `apps/web/src/app/home/video-player/video-player.component.ts` (handleRemoteChannelChange method) +- **Xtream Live Handler**: `apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts` +- **Stalker ITV Handler**: `apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` - **Settings UI**: `apps/web/src/app/settings/settings.component.html` ## License diff --git a/apps/web/src/app/home/video-player/video-player.component.ts b/apps/web/src/app/home/video-player/video-player.component.ts index cc8f40a3c..378bbb116 100644 --- a/apps/web/src/app/home/video-player/video-player.component.ts +++ b/apps/web/src/app/home/video-player/video-player.component.ts @@ -5,6 +5,7 @@ import { Component, HostListener, Injector, + OnDestroy, OnInit, effect, inject, @@ -53,6 +54,7 @@ import { VideoPlayer, } from 'shared-interfaces'; import { SettingsStore } from '../../services/settings-store.service'; +import { getAdjacentChannelItem } from '../../shared/services/remote-channel-navigation.util'; import { SettingsComponent } from '../../settings/settings.component'; @Component({ @@ -74,7 +76,7 @@ import { SettingsComponent } from '../../settings/settings.component'; templateUrl: './video-player.component.html', styleUrl: './video-player.component.scss', }) -export class VideoPlayerComponent implements OnInit { +export class VideoPlayerComponent implements OnInit, OnDestroy { private readonly activatedRoute = inject(ActivatedRoute); private readonly dataService = inject(DataService); private readonly dialog = inject(MatDialog); @@ -108,6 +110,7 @@ export class VideoPlayerComponent implements OnInit { /** EPG overlay reference */ private overlayRef!: OverlayRef; + private unsubscribeRemoteChannelChange?: () => void; /** Info overlay component reference for manual triggering */ readonly infoOverlay = viewChild(InfoOverlayComponent); @@ -144,11 +147,14 @@ export class VideoPlayerComponent implements OnInit { // Setup remote control channel change listener (Electron only) if (this.isDesktop && window.electron?.onChannelChange) { - window.electron.onChannelChange( - (data: { direction: 'up' | 'down' }) => { - this.handleRemoteChannelChange(data.direction); - } - ); + const unsubscribe = window.electron.onChannelChange((data: { + direction: 'up' | 'down'; + }) => { + this.handleRemoteChannelChange(data.direction); + }); + if (typeof unsubscribe === 'function') { + this.unsubscribeRemoteChannelChange = unsubscribe; + } } this.channels$ = this.activatedRoute.params.pipe( @@ -229,33 +235,17 @@ export class VideoPlayerComponent implements OnInit { ) .subscribe({ next: ({ channels, activeChannel }) => { - // Find current channel index - const currentIndex = channels.findIndex( - (ch) => ch.url === activeChannel.url + const nextChannel = getAdjacentChannelItem( + channels, + activeChannel.url, + direction, + (channel) => channel.url ); - if (currentIndex === -1) { + if (!nextChannel) { return; } - // Calculate next/previous index with wraparound - let nextIndex: number; - if (direction === 'up') { - // Up = previous channel (decrease index) - nextIndex = - currentIndex - 1 < 0 - ? channels.length - 1 - : currentIndex - 1; - } else { - // Down = next channel (increase index) - nextIndex = - currentIndex + 1 >= channels.length - ? 0 - : currentIndex + 1; - } - - // Dispatch action to change channel - const nextChannel = channels[nextIndex]; this.store.dispatch( ChannelActions.setActiveChannel({ channel: nextChannel, @@ -268,6 +258,10 @@ export class VideoPlayerComponent implements OnInit { }); } + ngOnDestroy(): void { + this.unsubscribeRemoteChannelChange?.(); + } + /** * Opens a playlist provided as a url param * e.g. iptvnat.or?url=http://... diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index e5d0cb27c..892d88783 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -282,7 +282,7 @@
- {{ 'SETTINGS.REMOTE_CONTROL' | translate }} (m3u) + {{ 'SETTINGS.REMOTE_CONTROL' | translate }}

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

diff --git a/apps/web/src/app/shared/services/remote-channel-navigation.util.ts b/apps/web/src/app/shared/services/remote-channel-navigation.util.ts new file mode 100644 index 000000000..d5f57eaed --- /dev/null +++ b/apps/web/src/app/shared/services/remote-channel-navigation.util.ts @@ -0,0 +1,34 @@ +export type RemoteChannelDirection = 'up' | 'down'; + +/** + * Returns the adjacent item in the list for remote channel navigation. + * Uses wraparound when reaching the start or end of the list. + */ +export function getAdjacentChannelItem( + items: T[], + activeId: string | number | null | undefined, + direction: RemoteChannelDirection, + getId: (item: T) => string | number | null | undefined +): T | null { + if (!Array.isArray(items) || items.length === 0 || activeId == null) { + return null; + } + + const activeIdAsString = String(activeId); + const currentIndex = items.findIndex((item) => { + const itemId = getId(item); + return itemId != null && String(itemId) === activeIdAsString; + }); + + if (currentIndex === -1) { + return null; + } + + const length = items.length; + const nextIndex = + direction === 'up' + ? (currentIndex - 1 + length) % length + : (currentIndex + 1) % length; + + return items[nextIndex] ?? null; +} diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index c0fc84f76..7fbe2c75f 100644 --- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -26,6 +26,7 @@ import { EpgItem } from 'shared-interfaces'; import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; import { SettingsStore } from '../../services/settings-store.service'; import { PlayerService } from '../../services/player.service'; +import { getAdjacentChannelItem } from '../../shared/services/remote-channel-navigation.util'; import { CategoryViewComponent } from '../../xtream-tauri/category-view/category-view.component'; import { PlaylistErrorViewComponent } from '../../xtream/playlist-error-view/playlist-error-view.component'; import { StalkerStore } from '../stalker.store'; @@ -107,6 +108,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { readonly scrollContainer = viewChild('scrollContainer'); private scrollListener: (() => void) | null = null; private searchDebounceTimer: ReturnType | null = null; + private unsubscribeRemoteChannelChange?: () => void; constructor() { // Load favorites for current playlist @@ -154,9 +156,21 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.stalkerStore.setSearchPhrase(search); }, 500); }); + + if (window.electron?.onChannelChange) { + const unsubscribe = window.electron.onChannelChange((data: { + direction: 'up' | 'down'; + }) => { + this.handleRemoteChannelChange(data.direction); + }); + if (typeof unsubscribe === 'function') { + this.unsubscribeRemoteChannelChange = unsubscribe; + } + } } ngOnDestroy() { + this.unsubscribeRemoteChannelChange?.(); this.removeScrollListener(); if (this.searchDebounceTimer) { clearTimeout(this.searchDebounceTimer); @@ -389,4 +403,25 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.scrollListener = null; } } + + private handleRemoteChannelChange(direction: 'up' | 'down'): void { + const activeItem = this.stalkerStore.selectedItem(); + if (!activeItem?.id) { + return; + } + + const channels = this.itvChannels(); + const nextItem = getAdjacentChannelItem( + channels, + activeItem.id, + direction, + (item) => item.id + ); + + if (!nextItem) { + return; + } + + void this.playChannel(nextItem); + } } diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts index e6888a423..142f07e6d 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts @@ -6,6 +6,7 @@ import { ElementRef, inject, Injector, + OnDestroy, OnInit, signal, viewChild, @@ -25,6 +26,7 @@ import { PlaylistSwitcherComponent, ResizableDirective } from 'components'; import { XtreamCategory } from 'shared-interfaces'; import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; import { SettingsStore } from '../../services/settings-store.service'; +import { getAdjacentChannelItem } from '../../shared/services/remote-channel-navigation.util'; import { CategoryManagementDialogComponent, CategoryManagementDialogData, @@ -63,7 +65,7 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class LiveStreamLayoutComponent implements OnInit { +export class LiveStreamLayoutComponent implements OnInit, OnDestroy { private readonly favoritesService = inject(FavoritesService); private readonly xtreamStore = inject(XtreamStore); private readonly settingsStore = inject(SettingsStore); @@ -104,12 +106,24 @@ export class LiveStreamLayoutComponent implements OnInit { read: ElementRef, }); private categoryScrollTop = 0; + private unsubscribeRemoteChannelChange?: () => void; readonly player = this.settingsStore.player; streamUrl: string; favorites = new Map(); ngOnInit() { + if (window.electron?.onChannelChange) { + const unsubscribe = window.electron.onChannelChange((data: { + direction: 'up' | 'down'; + }) => { + this.handleRemoteChannelChange(data.direction); + }); + if (typeof unsubscribe === 'function') { + this.unsubscribeRemoteChannelChange = unsubscribe; + } + } + const savedSortMode = localStorage.getItem( LIVE_CHANNEL_SORT_STORAGE_KEY ); @@ -202,4 +216,29 @@ export class LiveStreamLayoutComponent implements OnInit { this.liveChannelSortMode.set(mode); localStorage.setItem(LIVE_CHANNEL_SORT_STORAGE_KEY, mode); } + + ngOnDestroy(): void { + this.unsubscribeRemoteChannelChange?.(); + } + + private handleRemoteChannelChange(direction: 'up' | 'down'): void { + const activeItem = this.xtreamStore.selectedItem(); + if (!activeItem?.xtream_id) { + return; + } + + const channels = this.xtreamStore.selectItemsFromSelectedCategory() as any[]; + const nextItem = getAdjacentChannelItem( + channels, + activeItem.xtream_id, + direction, + (item) => item.xtream_id + ); + + if (!nextItem) { + return; + } + + this.playLive(nextItem); + } } diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index f157308b4..31e2fe1a4 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -194,7 +194,7 @@ declare global { // Remote control onChannelChange?: ( callback: (data: { direction: 'up' | 'down' }) => void - ) => void; + ) => () => void; // Player error notifications onPlayerError?: ( callback: (data: { From 7c9db4bc7d0dd2a7b8d76b5136f1e7e4bfaff215 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 00:03:42 +0100 Subject: [PATCH 2/3] feat(remote-control): add channel selection by number and volume control features - Implemented `getChannelItemByNumber` utility function to retrieve channels by their number. - Enhanced `StalkerLiveStreamLayoutComponent` and `LiveStreamLayoutComponent` to handle remote control commands for channel selection and volume adjustments. - Updated typings to include new remote control commands and status updates. - Refactored `RemoteControlComponent` to manage numeric input for channel selection and volume control actions. - Improved UI for remote control with new volume and channel navigation features. - Added service methods for volume up, down, and mute toggle in `RemoteControlService`. --- .../src/app/api/main.preload.ts | 18 + .../src/app/events/remote-control.events.ts | 187 ++++++ apps/remote-control-web/README.md | 10 +- .../video-player/video-player.component.ts | 106 +++- .../remote-channel-navigation.util.ts | 23 + .../stalker-live-stream-layout.component.ts | 73 ++- .../live-stream-layout.component.ts | 78 ++- apps/web/src/typings.d.ts | 22 + global.d.ts | 24 +- libs/ui/remote-control/README.md | 13 +- .../remote-control.component.html | 160 +++-- .../remote-control.component.scss | 595 ++++++++---------- .../remote-control.component.ts | 145 ++++- .../remote-control/remote-control.service.ts | 46 ++ 14 files changed, 1100 insertions(+), 400 deletions(-) diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 1f5607745..2421da93c 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -10,6 +10,24 @@ contextBridge.exposeInMainWorld('electron', { ipcRenderer.on('CHANNEL_CHANGE', handler); return () => ipcRenderer.off('CHANNEL_CHANGE', handler); }, + onRemoteControlCommand: ( + callback: (data: { + type: + | 'channel-select-number' + | 'volume-up' + | 'volume-down' + | 'volume-toggle-mute'; + number?: number; + }) => void + ) => { + const handler = (_event: Electron.IpcRendererEvent, data: any) => + callback(data); + ipcRenderer.on('REMOTE_CONTROL_COMMAND', handler); + return () => ipcRenderer.off('REMOTE_CONTROL_COMMAND', handler); + }, + updateRemoteControlStatus: (status: any) => { + ipcRenderer.send('REMOTE_CONTROL_STATUS_UPDATE', status); + }, // Player error listener onPlayerError: ( callback: (data: { diff --git a/apps/electron-backend/src/app/events/remote-control.events.ts b/apps/electron-backend/src/app/events/remote-control.events.ts index 90c6e7a10..e21858224 100644 --- a/apps/electron-backend/src/app/events/remote-control.events.ts +++ b/apps/electron-backend/src/app/events/remote-control.events.ts @@ -3,7 +3,37 @@ import * as http from 'http'; import { httpServer } from '../server/http-server'; import { store } from '../services/store.service'; +interface RemoteControlCommand { + type: + | 'channel-select-number' + | 'volume-up' + | 'volume-down' + | 'volume-toggle-mute'; + number?: number; +} + +interface RemoteControlStatus { + portal: 'm3u' | 'xtream' | 'stalker' | 'unknown'; + isLiveView: boolean; + channelName?: string; + channelNumber?: number; + epgTitle?: string; + epgStart?: string; + epgEnd?: string; + supportsVolume?: boolean; + volume?: number; + muted?: boolean; + updatedAt?: string; +} + class RemoteControlEvents { + private remoteControlStatus: RemoteControlStatus = { + portal: 'unknown', + isLiveView: false, + supportsVolume: false, + updatedAt: new Date().toISOString(), + }; + /** * Bootstrap remote control events */ @@ -18,6 +48,26 @@ class RemoteControlEvents { '/api/remote-control/channel/down', this.handleChannelDown.bind(this) ); + httpServer.registerRemoteControlHandler( + '/api/remote-control/channel/select-number', + this.handleChannelSelectNumber.bind(this) + ); + httpServer.registerRemoteControlHandler( + '/api/remote-control/volume/up', + this.handleVolumeUp.bind(this) + ); + httpServer.registerRemoteControlHandler( + '/api/remote-control/volume/down', + this.handleVolumeDown.bind(this) + ); + httpServer.registerRemoteControlHandler( + '/api/remote-control/volume/toggle-mute', + this.handleToggleMute.bind(this) + ); + httpServer.registerRemoteControlHandler( + '/api/remote-control/status', + this.handleGetStatus.bind(this) + ); // Start HTTP server if remote control is enabled in settings const remoteControlEnabled = store.get('remoteControl', false); @@ -34,6 +84,17 @@ class RemoteControlEvents { ipcMain.handle('REMOTE_CONTROL_CHANNEL_DOWN', () => { return this.changeChannelDown(); }); + + ipcMain.on( + 'REMOTE_CONTROL_STATUS_UPDATE', + (_event, status: Partial) => { + this.remoteControlStatus = { + ...this.remoteControlStatus, + ...status, + updatedAt: new Date().toISOString(), + }; + } + ); } /** @@ -74,6 +135,96 @@ class RemoteControlEvents { res.end(JSON.stringify({ success: true })); } + /** + * HTTP handler for selecting channel by number + */ + private handleChannelSelectNumber( + 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.readJsonBody(req, res, (body) => { + const value = Number(body?.number); + if (!Number.isFinite(value) || value < 1) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid channel number' })); + return; + } + + this.sendRemoteCommandToRenderer({ + type: 'channel-select-number', + number: Math.floor(value), + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + }); + } + + private handleVolumeUp( + 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.sendRemoteCommandToRenderer({ type: 'volume-up' }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + } + + private handleVolumeDown( + 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.sendRemoteCommandToRenderer({ type: 'volume-down' }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + } + + private handleToggleMute( + 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.sendRemoteCommandToRenderer({ type: 'volume-toggle-mute' }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + } + + private handleGetStatus( + req: http.IncomingMessage, + res: http.ServerResponse + ): void { + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(this.remoteControlStatus)); + } + /** * Change to the next channel */ @@ -102,6 +253,42 @@ class RemoteControlEvents { console.warn('No browser windows found to send channel change'); } } + + private sendRemoteCommandToRenderer(command: RemoteControlCommand): void { + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + windows[0].webContents.send('REMOTE_CONTROL_COMMAND', command); + console.log( + `Sent REMOTE_CONTROL_COMMAND ${command.type} to renderer` + ); + } else { + console.warn('No browser windows found to send remote command'); + } + } + + private readJsonBody( + req: http.IncomingMessage, + res: http.ServerResponse, + callback: (body: any) => void + ): void { + let data = ''; + req.on('data', (chunk: Buffer) => { + data += chunk.toString(); + }); + req.on('end', () => { + if (!data) { + callback({}); + return; + } + + try { + callback(JSON.parse(data)); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON payload' })); + } + }); + } } export default new RemoteControlEvents(); diff --git a/apps/remote-control-web/README.md b/apps/remote-control-web/README.md index 2e7c1ff8f..42f2b4691 100644 --- a/apps/remote-control-web/README.md +++ b/apps/remote-control-web/README.md @@ -12,6 +12,9 @@ This is a standalone Angular application designed to be served by IPTVnator's El - **Mobile-Optimized** - Touch-friendly interface designed for phones and tablets - **Real-time Control** - Instant channel switching via REST API - **Multi-Portal Support** - Works with M3U playlists, Xtream Live TV, and Stalker ITV sections +- **Numeric Channel Input** - Enter channel numbers directly from the web remote +- **Live Status Panel** - Displays active channel and current EPG program when available +- **Volume Controls** - Volume up/down/mute when the active player supports remote volume - **Minimal & Fast** - Lightweight app (~55 KB compressed) with no external dependencies - **Responsive** - Adapts to different screen sizes (320px - 480px+) @@ -34,7 +37,7 @@ This is a standalone Angular application designed to be served by IPTVnator's El 1. **HTTP Server**: Electron backend starts an HTTP server (default port: 8765) 2. **Static Files**: Serves this Angular app's built files -3. **REST API**: Exposes `/api/remote-control/channel/up` and `/api/remote-control/channel/down` endpoints +3. **REST API**: Exposes channel/volume/status endpoints for the web remote 4. **IPC Communication**: HTTP server sends events to the main Electron window 5. **Channel Switching**: Main app receives events and switches to next/previous live channel in the active live view @@ -43,6 +46,11 @@ This is a standalone Angular application designed to be served by IPTVnator's El - `GET /` - Serves the remote control web interface - `POST /api/remote-control/channel/up` - Switch to previous channel - `POST /api/remote-control/channel/down` - Switch to next channel +- `POST /api/remote-control/channel/select-number` - Switch by channel number +- `POST /api/remote-control/volume/up` - Increase volume +- `POST /api/remote-control/volume/down` - Decrease volume +- `POST /api/remote-control/volume/toggle-mute` - Toggle mute +- `GET /api/remote-control/status` - Get current playback status for remote UI ## Usage diff --git a/apps/web/src/app/home/video-player/video-player.component.ts b/apps/web/src/app/home/video-player/video-player.component.ts index 378bbb116..27af6ba26 100644 --- a/apps/web/src/app/home/video-player/video-player.component.ts +++ b/apps/web/src/app/home/video-player/video-player.component.ts @@ -37,10 +37,12 @@ import { } from 'm3u-state'; import { Observable, + Subscription, combineLatest, combineLatestWith, filter, map, + startWith, switchMap, take, } from 'rxjs'; @@ -54,7 +56,10 @@ import { VideoPlayer, } from 'shared-interfaces'; import { SettingsStore } from '../../services/settings-store.service'; -import { getAdjacentChannelItem } from '../../shared/services/remote-channel-navigation.util'; +import { + getAdjacentChannelItem, + getChannelItemByNumber, +} from '../../shared/services/remote-channel-navigation.util'; import { SettingsComponent } from '../../settings/settings.component'; @Component({ @@ -111,6 +116,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { /** EPG overlay reference */ private overlayRef!: OverlayRef; private unsubscribeRemoteChannelChange?: () => void; + private unsubscribeRemoteCommand?: () => void; + private statusSubscription?: Subscription; + private lastKnownVolume = 1; /** Info overlay component reference for manual triggering */ readonly infoOverlay = viewChild(InfoOverlayComponent); @@ -156,6 +164,16 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.unsubscribeRemoteChannelChange = unsubscribe; } } + if (this.isDesktop && window.electron?.onRemoteControlCommand) { + const unsubscribe = window.electron.onRemoteControlCommand( + (command) => { + this.handleRemoteControlCommand(command); + } + ); + if (typeof unsubscribe === 'function') { + this.unsubscribeRemoteCommand = unsubscribe; + } + } this.channels$ = this.activatedRoute.params.pipe( combineLatestWith(this.activatedRoute.queryParams), @@ -211,6 +229,35 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } }) ); + + this.statusSubscription = combineLatest([ + this.channels$, + this.activeChannel$, + this.epgProgram$.pipe(startWith(null)), + ]).subscribe(([channels, activeChannel, epgProgram]) => { + if (!window.electron?.updateRemoteControlStatus || !activeChannel) { + return; + } + + const currentIndex = channels.findIndex( + (channel) => channel.url === activeChannel.url + ); + + window.electron.updateRemoteControlStatus({ + portal: 'm3u', + isLiveView: true, + channelName: + activeChannel.name ?? + activeChannel.tvg?.name, + channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined, + epgTitle: (epgProgram as any)?.title, + epgStart: (epgProgram as any)?.start, + epgEnd: (epgProgram as any)?.end, + supportsVolume: true, + volume: this.volume, + muted: this.volume === 0, + }); + }); } /** @@ -260,6 +307,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.unsubscribeRemoteChannelChange?.(); + this.unsubscribeRemoteCommand?.(); + this.statusSubscription?.unsubscribe(); } /** @@ -428,14 +477,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.channels$ .pipe( take(1), - map((channels) => { - // Channel numbers are 1-based, array is 0-based - const channelIndex = channelNumber - 1; - if (channelIndex >= 0 && channelIndex < channels.length) { - return channels[channelIndex]; - } - return null; - }) + map((channels) => getChannelItemByNumber(channels, channelNumber)) ) .subscribe((channel) => { if (channel) { @@ -470,4 +512,50 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { target instanceof HTMLTextAreaElement ); } + + private handleRemoteControlCommand(command: { + type: + | 'channel-select-number' + | 'volume-up' + | 'volume-down' + | 'volume-toggle-mute'; + number?: number; + }): void { + if (command.type === 'channel-select-number' && command.number) { + this.switchToChannelByNumber(command.number); + return; + } + + if (command.type === 'volume-up') { + this.setVolume(this.volume + 0.1); + } else if (command.type === 'volume-down') { + this.setVolume(this.volume - 0.1); + } else if (command.type === 'volume-toggle-mute') { + if (this.volume === 0) { + this.setVolume(this.lastKnownVolume || 1); + } else { + this.lastKnownVolume = this.volume; + this.setVolume(0); + } + } + } + + private setVolume(next: number): void { + const clamped = Math.max(0, Math.min(1, Number(next.toFixed(2)))); + this.volume = clamped; + if (clamped > 0) { + this.lastKnownVolume = clamped; + } + localStorage.setItem('volume', String(clamped)); + + if (window.electron?.updateRemoteControlStatus) { + window.electron.updateRemoteControlStatus({ + portal: 'm3u', + isLiveView: true, + supportsVolume: true, + volume: this.volume, + muted: this.volume === 0, + }); + } + } } diff --git a/apps/web/src/app/shared/services/remote-channel-navigation.util.ts b/apps/web/src/app/shared/services/remote-channel-navigation.util.ts index d5f57eaed..04d75048d 100644 --- a/apps/web/src/app/shared/services/remote-channel-navigation.util.ts +++ b/apps/web/src/app/shared/services/remote-channel-navigation.util.ts @@ -32,3 +32,26 @@ export function getAdjacentChannelItem( return items[nextIndex] ?? null; } + +/** + * Returns the Nth item in a list (1-based index), or null if out of range. + */ +export function getChannelItemByNumber( + items: T[], + channelNumber: number +): T | null { + if ( + !Array.isArray(items) || + items.length === 0 || + !Number.isFinite(channelNumber) + ) { + return null; + } + + const index = Math.floor(channelNumber) - 1; + if (index < 0 || index >= items.length) { + return null; + } + + return items[index] ?? null; +} diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 7fbe2c75f..1c86f6df9 100644 --- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -26,7 +26,10 @@ import { EpgItem } from 'shared-interfaces'; import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; import { SettingsStore } from '../../services/settings-store.service'; import { PlayerService } from '../../services/player.service'; -import { getAdjacentChannelItem } from '../../shared/services/remote-channel-navigation.util'; +import { + getAdjacentChannelItem, + getChannelItemByNumber, +} from '../../shared/services/remote-channel-navigation.util'; import { CategoryViewComponent } from '../../xtream-tauri/category-view/category-view.component'; import { PlaylistErrorViewComponent } from '../../xtream/playlist-error-view/playlist-error-view.component'; import { StalkerStore } from '../stalker.store'; @@ -109,6 +112,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { private scrollListener: (() => void) | null = null; private searchDebounceTimer: ReturnType | null = null; private unsubscribeRemoteChannelChange?: () => void; + private unsubscribeRemoteCommand?: () => void; constructor() { // Load favorites for current playlist @@ -157,6 +161,42 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { }, 500); }); + effect(() => { + if (!window.electron?.updateRemoteControlStatus) { + return; + } + + const selectedItem = this.stalkerStore.selectedItem(); + const selectedType = this.stalkerStore.selectedContentType(); + const channels = this.itvChannels(); + const epgItems = this.epgItems(); + + if (selectedType !== 'itv' || !selectedItem?.id) { + window.electron.updateRemoteControlStatus({ + portal: 'stalker', + isLiveView: false, + supportsVolume: false, + }); + return; + } + + const currentIndex = channels.findIndex( + (item) => Number(item.id) === Number(selectedItem.id) + ); + const currentProgram = epgItems?.[0]; + + window.electron.updateRemoteControlStatus({ + portal: 'stalker', + isLiveView: true, + channelName: selectedItem.o_name || selectedItem.name, + channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined, + epgTitle: currentProgram?.title, + epgStart: (currentProgram as any)?.start, + epgEnd: (currentProgram as any)?.end, + supportsVolume: false, + }); + }); + if (window.electron?.onChannelChange) { const unsubscribe = window.electron.onChannelChange((data: { direction: 'up' | 'down'; @@ -167,10 +207,21 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.unsubscribeRemoteChannelChange = unsubscribe; } } + if (window.electron?.onRemoteControlCommand) { + const unsubscribe = window.electron.onRemoteControlCommand( + (command) => { + this.handleRemoteControlCommand(command); + } + ); + if (typeof unsubscribe === 'function') { + this.unsubscribeRemoteCommand = unsubscribe; + } + } } ngOnDestroy() { this.unsubscribeRemoteChannelChange?.(); + this.unsubscribeRemoteCommand?.(); this.removeScrollListener(); if (this.searchDebounceTimer) { clearTimeout(this.searchDebounceTimer); @@ -424,4 +475,24 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { void this.playChannel(nextItem); } + + private handleRemoteControlCommand(command: { + type: + | 'channel-select-number' + | 'volume-up' + | 'volume-down' + | 'volume-toggle-mute'; + number?: number; + }): void { + if (command.type !== 'channel-select-number' || !command.number) { + return; + } + + const channel = getChannelItemByNumber(this.itvChannels(), command.number); + if (!channel) { + return; + } + + void this.playChannel(channel); + } } diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts index 142f07e6d..e909a8d6c 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.ts @@ -4,6 +4,7 @@ import { Component, computed, ElementRef, + effect, inject, Injector, OnDestroy, @@ -26,7 +27,10 @@ import { PlaylistSwitcherComponent, ResizableDirective } from 'components'; import { XtreamCategory } from 'shared-interfaces'; import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals'; import { SettingsStore } from '../../services/settings-store.service'; -import { getAdjacentChannelItem } from '../../shared/services/remote-channel-navigation.util'; +import { + getAdjacentChannelItem, + getChannelItemByNumber, +} from '../../shared/services/remote-channel-navigation.util'; import { CategoryManagementDialogComponent, CategoryManagementDialogData, @@ -107,11 +111,51 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { }); private categoryScrollTop = 0; private unsubscribeRemoteChannelChange?: () => void; + private unsubscribeRemoteCommand?: () => void; readonly player = this.settingsStore.player; streamUrl: string; favorites = new Map(); + constructor() { + effect(() => { + if (!window.electron?.updateRemoteControlStatus) { + return; + } + + const selectedContentType = this.xtreamStore.selectedContentType(); + const selectedItem = this.xtreamStore.selectedItem(); + const channels = this.xtreamStore.selectItemsFromSelectedCategory() as any[]; + const epgItems = this.xtreamStore.epgItems(); + + if (selectedContentType !== 'live' || !selectedItem?.xtream_id) { + window.electron.updateRemoteControlStatus({ + portal: 'xtream', + isLiveView: false, + supportsVolume: false, + }); + return; + } + + const currentIndex = channels.findIndex( + (item) => + Number(item.xtream_id) === Number(selectedItem.xtream_id) + ); + const currentProgram = epgItems?.[0]; + + window.electron.updateRemoteControlStatus({ + portal: 'xtream', + isLiveView: true, + channelName: selectedItem.title ?? selectedItem.name, + channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined, + epgTitle: currentProgram?.title, + epgStart: (currentProgram as any)?.start, + epgEnd: (currentProgram as any)?.end, + supportsVolume: false, + }); + }); + } + ngOnInit() { if (window.electron?.onChannelChange) { const unsubscribe = window.electron.onChannelChange((data: { @@ -123,6 +167,16 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.unsubscribeRemoteChannelChange = unsubscribe; } } + if (window.electron?.onRemoteControlCommand) { + const unsubscribe = window.electron.onRemoteControlCommand( + (command) => { + this.handleRemoteControlCommand(command); + } + ); + if (typeof unsubscribe === 'function') { + this.unsubscribeRemoteCommand = unsubscribe; + } + } const savedSortMode = localStorage.getItem( LIVE_CHANNEL_SORT_STORAGE_KEY @@ -219,6 +273,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { ngOnDestroy(): void { this.unsubscribeRemoteChannelChange?.(); + this.unsubscribeRemoteCommand?.(); } private handleRemoteChannelChange(direction: 'up' | 'down'): void { @@ -241,4 +296,25 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { this.playLive(nextItem); } + + private handleRemoteControlCommand(command: { + type: + | 'channel-select-number' + | 'volume-up' + | 'volume-down' + | 'volume-toggle-mute'; + number?: number; + }): void { + if (command.type !== 'channel-select-number' || !command.number) { + return; + } + + const channels = this.xtreamStore.selectItemsFromSelectedCategory() as any[]; + const channel = getChannelItemByNumber(channels, command.number); + if (!channel) { + return; + } + + this.playLive(channel); + } } diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 31e2fe1a4..4eec305a4 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -195,6 +195,28 @@ declare global { onChannelChange?: ( callback: (data: { direction: 'up' | 'down' }) => void ) => () => void; + onRemoteControlCommand?: ( + callback: (data: { + type: + | 'channel-select-number' + | 'volume-up' + | 'volume-down' + | 'volume-toggle-mute'; + number?: number; + }) => void + ) => () => void; + updateRemoteControlStatus?: (status: { + portal: 'm3u' | 'xtream' | 'stalker' | 'unknown'; + isLiveView: boolean; + channelName?: string; + channelNumber?: number; + epgTitle?: string; + epgStart?: string; + epgEnd?: string; + supportsVolume?: boolean; + volume?: number; + muted?: boolean; + }) => void; // Player error notifications onPlayerError?: ( callback: (data: { diff --git a/global.d.ts b/global.d.ts index e0e0ba955..42034d34a 100644 --- a/global.d.ts +++ b/global.d.ts @@ -197,7 +197,29 @@ declare global { // Remote control onChannelChange?: ( callback: (data: { direction: 'up' | 'down' }) => void - ) => void; + ) => () => void; + onRemoteControlCommand?: ( + callback: (data: { + type: + | 'channel-select-number' + | 'volume-up' + | 'volume-down' + | 'volume-toggle-mute'; + number?: number; + }) => void + ) => () => void; + updateRemoteControlStatus?: (status: { + portal: 'm3u' | 'xtream' | 'stalker' | 'unknown'; + isLiveView: boolean; + channelName?: string; + channelNumber?: number; + epgTitle?: string; + epgStart?: string; + epgEnd?: string; + supportsVolume?: boolean; + volume?: number; + muted?: boolean; + }) => void; // Player error notifications onPlayerError?: ( callback: (data: { diff --git a/libs/ui/remote-control/README.md b/libs/ui/remote-control/README.md index 0630db4e9..549a507a1 100644 --- a/libs/ui/remote-control/README.md +++ b/libs/ui/remote-control/README.md @@ -1,6 +1,17 @@ # remote-control -This library was generated with [Nx](https://nx.dev). +UI library for the remote control web app. + +## Scope + +- Sends channel up/down commands to `/api/remote-control/channel/up|down` +- Sends direct number-based channel selection (`/channel/select-number`) +- Reads live playback status from `/api/remote-control/status` +- Sends volume commands (`/volume/up|down|toggle-mute`) +- Used for live channel navigation in: +- M3U playlists +- Xtream Live TV +- Stalker ITV ## Running unit tests diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.html b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.html index bcfc57fb2..a2a1f55c9 100644 --- a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.html +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.html @@ -1,21 +1,40 @@
- -
- +
+
+
Now Playing
+
+ + {{ isLoading ? 'Executing' : portalLabel }} +
+
+
+ {{ status?.channelName || 'No live stream selected' }} +
+
+ + @if (status?.channelNumber) { + CH {{ status?.channelNumber }} + } @else { + CH - + } + + {{ status?.portal || 'unknown' }} +
+
+ {{ status?.epgTitle || 'No current EPG program' }} +
- @if (error) {
{{ error }}
} - -
+
+
Channel Navigation
- - - - -
-
+ {{ status?.channelNumber || '-' }}
- - -
- -
-
- {{ - isLoading ? 'Switching' : 'Ready' - }} +
+
Channel Number
+
{{ numericInput || '----' }}
+
+ @for (digit of digits; track digit) { + + } + + + +
+
+ +
+
Volume
+
+ + + +
+
+
+
+
+ @if (status?.supportsVolume) { + {{ volumePercent ?? 0 }}% + } @else { + Volume control unavailable for this player mode + } +
+
+ +
diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss index 4f73c25aa..da772cebb 100644 --- a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss @@ -1,398 +1,335 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; - -webkit-tap-highlight-color: transparent; - user-select: none; +:host { + --bg-0: #0a1016; + --bg-1: #111c26; + --bg-2: #1c2d3b; + --line: rgba(177, 220, 255, 0.2); + --ink: #e5f5ff; + --ink-dim: rgba(229, 245, 255, 0.72); + --accent: #2ec4ff; + --accent-soft: rgba(46, 196, 255, 0.2); + --danger: #ff596d; + display: block; + min-height: 100vh; +} + +* { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; } -// Main container .remote { min-height: 100vh; - width: 100%; + padding: 14px; display: flex; - align-items: center; justify-content: center; - background-color: #1a1a1a; - font-family: - -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', - sans-serif; - padding: 1rem; - background-image: - radial-gradient( - circle at 50% 20%, - rgba(10, 132, 255, 0.15) 0%, - transparent 50% - ), - radial-gradient( - circle at 80% 80%, - rgba(120, 120, 255, 0.08) 0%, - transparent 50% - ); + background: + radial-gradient(circle at 12% 8%, rgba(255, 200, 92, 0.18), transparent 35%), + radial-gradient(circle at 85% 0%, rgba(46, 196, 255, 0.28), transparent 45%), + linear-gradient(145deg, var(--bg-0), var(--bg-1) 58%, var(--bg-2)); + color: var(--ink); + font-family: 'Avenir Next', 'Trebuchet MS', 'Segoe UI', sans-serif; } .remote__body { - width: 100%; - max-width: 420px; + width: min(100%, 520px); + display: grid; + gap: 10px; + animation: reveal 0.6s ease-out; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--danger); + box-shadow: 0 0 10px rgba(255, 89, 109, 0.7); +} + +.status-dot.is-active { + background: #44e38c; + box-shadow: 0 0 12px rgba(68, 227, 140, 0.7); +} + +.now-card { + padding: 12px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient( + 155deg, + rgba(17, 39, 56, 0.92), + rgba(15, 29, 42, 0.86) + ); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.now-card__top { display: flex; - flex-direction: column; - gap: 2.5rem; - animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) backwards; + align-items: center; + justify-content: space-between; + gap: 10px; } -// Header -.remote__header { - text-align: center; +.now-card__statusline { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + color: var(--ink-dim); + white-space: nowrap; } -.remote__logo { - font-size: 1.5rem; - font-weight: 600; - letter-spacing: -0.02em; - color: #ffffff; - opacity: 0; - animation: fadeIn 1s ease-out 0.3s forwards; +.now-card__label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.13em; + color: var(--ink-dim); } -// Error message -.remote__error { - background: rgba(255, 69, 58, 0.15); - border: 1px solid rgba(255, 69, 58, 0.4); - border-radius: 1rem; - padding: 1rem 1.25rem; - color: #ffb3ab; - font-size: 0.875rem; - font-weight: 500; - text-align: center; - animation: slideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1); +.now-card__channel { + margin-top: 5px; + font-size: 18px; + font-weight: 700; + line-height: 1.2; } -// Touchpad - Main control -.touchpad { - position: relative; - width: 100%; - aspect-ratio: 1; - max-width: 360px; - margin: 0 auto; - animation: scaleIn 0.9s cubic-bezier(0.16, 1, 0.3, 1) 0.2s backwards; +.now-card__meta { + margin-top: 6px; + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--ink-dim); +} + +.now-card__program { + margin-top: 8px; + font-size: 13px; + color: #b6d5ea; +} + +.cluster { + border: 1px solid var(--line); + border-radius: 18px; + padding: 10px; + background: rgba(9, 21, 31, 0.72); +} + +.cluster__title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.11em; + color: var(--ink-dim); + margin-bottom: 10px; } .touchpad__glass { + width: min(100%, 320px); + aspect-ratio: 1; + margin: 0 auto; position: relative; - width: 100%; - height: 100%; border-radius: 50%; - background: rgba(255, 255, 255, 0.2); - backdrop-filter: blur(20px); - box-shadow: - 0 16px 48px rgba(0, 0, 0, 0.6), - 0 0 0 1px rgba(255, 255, 255, 0.15), - inset 0 1px 1px rgba(255, 255, 255, 0.1), - inset 0 -1px 1px rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.3); overflow: hidden; - - // Top reflection - &::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 45%; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.25) 0%, - rgba(255, 255, 255, 0.1) 50%, - transparent 100% - ); - border-radius: 50% 50% 0 0; - pointer-events: none; - } - - // Bottom shadow - &::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 35%; - background: linear-gradient( - 0deg, - rgba(0, 0, 0, 0.4) 0%, - rgba(0, 0, 0, 0.1) 50%, - transparent 100% - ); - border-radius: 0 0 50% 50%; - pointer-events: none; - } + border: 1px solid rgba(97, 166, 214, 0.38); + background: + radial-gradient(circle at 50% 32%, rgba(46, 196, 255, 0.24), transparent 56%), + linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.01)); } -.touchpad__edge { - position: absolute; - inset: -2px; - border-radius: 50%; - background: radial-gradient( - circle at 50% 0%, - rgba(255, 255, 255, 0.15), - transparent 50% - ); - opacity: 0.6; - pointer-events: none; - filter: blur(1px); -} - -// Touch buttons .touchpad__button { position: absolute; left: 0; width: 100%; height: 50%; + border: 0; background: transparent; - border: none; + color: var(--ink); + font-size: 20px; + font-weight: 700; + letter-spacing: 0.06em; cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + transition: 180ms ease; +} - svg { - width: 56px; - height: 56px; - color: #ffffff; - stroke: #ffffff; - opacity: 0.5; - transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); - filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3)); - } +.touchpad__button:active:not(:disabled) { + background: var(--accent-soft); +} - &:hover:not(:disabled) svg { - opacity: 0.9; - transform: scale(1.05); - } - - &:active:not(:disabled) { - svg { - opacity: 1; - transform: scale(0.92); - } - - &::after { - opacity: 1; - } - } - - // Ripple effect - &::after { - content: ''; - position: absolute; - inset: 0; - background: radial-gradient( - circle at center, - rgba(10, 132, 255, 0.5) 0%, - transparent 70% - ); - opacity: 0; - transition: opacity 0.2s ease; - } - - &:disabled { - cursor: not-allowed; - opacity: 0.4; - } - - &.is-loading svg { - opacity: 0.5; - animation: pulse 1.5s ease-in-out infinite; - } +.touchpad__button:disabled { + opacity: 0.45; } .touchpad__button--up { top: 0; - border-radius: 50% 50% 0 0; - - svg { - margin-bottom: 20%; - } } .touchpad__button--down { bottom: 0; - border-radius: 0 0 50% 50%; - - svg { - margin-top: 20%; - } } -// Center indicator .touchpad__center { position: absolute; - top: 50%; left: 50%; + top: 50%; transform: translate(-50%, -50%); - width: 48px; - height: 48px; + width: 72px; + height: 72px; border-radius: 50%; - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.08); - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; - box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(167, 218, 255, 0.35); + background: rgba(9, 23, 34, 0.9); + display: grid; + place-items: center; + font-size: 24px; + font-weight: 700; } -.touchpad__pulse { - width: 16px; - height: 16px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.2); - transition: all 0.4s ease; - - &.is-active { - background: #0a84ff; - box-shadow: - 0 0 12px rgba(10, 132, 255, 0.5), - 0 0 24px rgba(10, 132, 255, 0.5); - animation: breathe 3s ease-in-out infinite; - } +.digit-display { + margin-bottom: 8px; + border-radius: 12px; + border: 1px dashed rgba(167, 218, 255, 0.5); + padding: 10px; + text-align: center; + font-family: 'Courier New', monospace; + font-size: 20px; + letter-spacing: 0.16em; + color: #d5eeff; } -// Status bar -.remote__status { - display: flex; - align-items: center; - justify-content: center; - gap: 0.625rem; - padding: 0.875rem 1.25rem; +.keypad { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.keypad__key { + height: 46px; + border-radius: 12px; + border: 1px solid rgba(167, 218, 255, 0.34); + background: rgba(15, 38, 55, 0.84); + color: var(--ink); + font-size: 16px; + font-weight: 700; +} + +.keypad__key:active:not(:disabled) { + transform: translateY(1px); + background: rgba(29, 67, 94, 0.94); +} + +.keypad__key:disabled { + opacity: 0.45; +} + +.keypad__key--alt { + font-size: 12px; + letter-spacing: 0.06em; +} + +.keypad__key--go { + background: linear-gradient(140deg, #16a8df, #2ec4ff); + color: #042130; +} + +.volume-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; +} + +.volume-row__button { + min-height: 40px; + border-radius: 10px; + border: 1px solid rgba(167, 218, 255, 0.34); + background: rgba(15, 38, 55, 0.84); + color: var(--ink); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; +} + +.volume-row__button--mute { + border-color: rgba(255, 129, 143, 0.45); +} + +.volume-row__button:disabled { + opacity: 0.45; +} + +.volume-meter { + margin-top: 10px; + height: 10px; + border-radius: 999px; background: rgba(255, 255, 255, 0.1); - border-radius: 1.25rem; - border: 1px solid rgba(255, 255, 255, 0.06); - animation: fadeIn 1s ease-out 0.5s backwards; + overflow: hidden; } -.status-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.25); - transition: all 0.4s ease; +.volume-meter__fill { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #1fa8de, #42e8ff); + transition: width 180ms ease; +} - &.is-active { - background: #30d158; - box-shadow: - 0 0 8px rgba(48, 209, 88, 0.5), - 0 0 16px rgba(48, 209, 88, 0.3); - animation: breathe 2.5s ease-in-out infinite; +.volume-caption { + margin-top: 6px; + color: var(--ink-dim); + font-size: 12px; +} + +.remote__error { + border-radius: 12px; + border: 1px solid rgba(255, 117, 132, 0.5); + background: rgba(78, 12, 24, 0.65); + color: #ffd9de; + font-size: 13px; + padding: 10px 12px; +} + +.remote__footer { + text-align: center; + color: rgba(213, 238, 255, 0.72); + font-size: 12px; + padding-bottom: 10px; +} + +@media (max-width: 640px) { + .remote { + padding: 8px; + } + + .remote__body { + width: 100%; + max-width: none; + gap: 8px; + } + + .now-card { + border-radius: 14px; + } + + .cluster { + border-radius: 14px; + padding: 9px; + } + + .touchpad__glass { + width: min(100%, 360px); + } + + .keypad__key { + height: 44px; + } + + .volume-row__button { + min-height: 38px; } } -.status-text { - font-size: 0.8125rem; - font-weight: 500; - color: rgba(255, 255, 255, 0.7); - letter-spacing: 0.01em; -} - -// Animations -@keyframes fadeIn { - to { - opacity: 1; - } -} - -@keyframes fadeInUp { +@keyframes reveal { from { opacity: 0; - transform: translateY(20px); + transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } - -@keyframes slideDown { - from { - opacity: 0; - transform: translateY(-8px); - } -} - -@keyframes scaleIn { - from { - opacity: 0; - transform: scale(0.9); - } -} - -@keyframes breathe { - 0%, - 100% { - opacity: 1; - transform: scale(1); - } - 50% { - opacity: 0.7; - transform: scale(0.9); - } -} - -@keyframes pulse { - 0%, - 100% { - opacity: 0.5; - } - 50% { - opacity: 0.8; - } -} - -// Responsive -@media (max-width: 480px) { - .remote__logo { - font-size: 1.25rem; - } - - .touchpad { - max-width: 320px; - } - - .touchpad__button svg { - width: 48px; - height: 48px; - } -} - -@media (max-width: 360px) { - .touchpad { - max-width: 280px; - } - - .touchpad__button svg { - width: 42px; - height: 42px; - } - - .touchpad__center { - width: 40px; - height: 40px; - } - - .touchpad__pulse { - width: 14px; - height: 14px; - } -} - -// Accessibility -@media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts index eb390caf7..60120bfae 100644 --- a/libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts @@ -1,6 +1,9 @@ import { CommonModule } from '@angular/common'; -import { Component, inject } from '@angular/core'; -import { RemoteControlService } from './remote-control.service'; +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { + RemoteControlService, + RemoteControlStatus, +} from './remote-control.service'; @Component({ selector: 'lib-remote-control', @@ -8,32 +11,144 @@ import { RemoteControlService } from './remote-control.service'; templateUrl: './remote-control.component.html', styleUrls: ['./remote-control.component.scss'], }) -export class RemoteControlComponent { +export class RemoteControlComponent implements OnInit, OnDestroy { private remoteControlService = inject(RemoteControlService); + private statusRefreshTimer?: number; isLoading = false; + isStatusLoading = false; error: string | null = null; + status: RemoteControlStatus | null = null; + numericInput = ''; + readonly digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']; - async changeChannelUp(): Promise { - this.isLoading = true; - this.error = null; - try { - await this.remoteControlService.channelUp(); - } catch (err) { - this.error = 'Failed to change channel up'; - console.error(err); - } finally { - this.isLoading = false; + ngOnInit(): void { + void this.refreshStatus(); + this.statusRefreshTimer = window.setInterval(() => { + void this.refreshStatus(true); + }, 2000); + } + + ngOnDestroy(): void { + if (this.statusRefreshTimer) { + clearInterval(this.statusRefreshTimer); + this.statusRefreshTimer = undefined; } } + async changeChannelUp(): Promise { + await this.executeAction( + () => this.remoteControlService.channelUp(), + 'Failed to change channel up' + ); + } + async changeChannelDown(): Promise { + await this.executeAction( + () => this.remoteControlService.channelDown(), + 'Failed to change channel down' + ); + } + + appendDigit(digit: string): void { + if (this.numericInput.length >= 4) { + return; + } + this.numericInput += digit; + } + + backspaceDigit(): void { + this.numericInput = this.numericInput.slice(0, -1); + } + + clearDigits(): void { + this.numericInput = ''; + } + + async submitChannelNumber(): Promise { + const channelNumber = Number(this.numericInput); + if (!Number.isFinite(channelNumber) || channelNumber < 1) { + return; + } + + await this.executeAction( + () => + this.remoteControlService.selectChannelByNumber(channelNumber), + 'Failed to switch by channel number' + ); + this.clearDigits(); + } + + async volumeUp(): Promise { + await this.executeAction( + () => this.remoteControlService.volumeUp(), + 'Failed to increase volume' + ); + } + + async volumeDown(): Promise { + await this.executeAction( + () => this.remoteControlService.volumeDown(), + 'Failed to decrease volume' + ); + } + + async toggleMute(): Promise { + await this.executeAction( + () => this.remoteControlService.toggleMute(), + 'Failed to toggle mute' + ); + } + + get portalLabel(): string { + const portal = this.status?.portal ?? 'unknown'; + if (portal === 'm3u') return 'M3U Live'; + if (portal === 'xtream') return 'Xtream Live'; + if (portal === 'stalker') return 'Stalker ITV'; + return 'Waiting For Playback'; + } + + get isReady(): boolean { + return !!this.status?.isLiveView && !this.isLoading; + } + + get volumePercent(): number | null { + if (!this.status?.supportsVolume || this.status.volume == null) { + return null; + } + + return Math.round((this.status.volume || 0) * 100); + } + + private async refreshStatus(silent = false): Promise { + if (!silent) { + this.isStatusLoading = true; + } + try { + this.status = await this.remoteControlService.getStatus(); + } catch (err) { + if (!silent) { + this.error = 'Failed to fetch remote status'; + console.error(err); + } + } finally { + if (!silent) { + this.isStatusLoading = false; + } + } + } + + private async executeAction( + action: () => Promise, + errorMessage: string + ): Promise { this.isLoading = true; this.error = null; try { - await this.remoteControlService.channelDown(); + await action(); + await this.refreshStatus(true); } catch (err) { - this.error = 'Failed to change channel down'; + this.error = errorMessage; console.error(err); } finally { this.isLoading = false; diff --git a/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts b/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts index 8151ae401..79c057901 100644 --- a/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts +++ b/libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts @@ -2,6 +2,20 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; +export interface RemoteControlStatus { + portal: 'm3u' | 'xtream' | 'stalker' | 'unknown'; + isLiveView: boolean; + channelName?: string; + channelNumber?: number; + epgTitle?: string; + epgStart?: string; + epgEnd?: string; + supportsVolume?: boolean; + volume?: number; + muted?: boolean; + updatedAt?: string; +} + /** * Service for remote control operations. * This service communicates with the Electron backend via HTTP API. @@ -31,4 +45,36 @@ export class RemoteControlService { this.http.post(`${this.apiBaseUrl}/channel/down`, {}) ); } + + async selectChannelByNumber(number: number): Promise { + await firstValueFrom( + this.http.post(`${this.apiBaseUrl}/channel/select-number`, { + number, + }) + ); + } + + async volumeUp(): Promise { + await firstValueFrom( + this.http.post(`${this.apiBaseUrl}/volume/up`, {}) + ); + } + + async volumeDown(): Promise { + await firstValueFrom( + this.http.post(`${this.apiBaseUrl}/volume/down`, {}) + ); + } + + async toggleMute(): Promise { + await firstValueFrom( + this.http.post(`${this.apiBaseUrl}/volume/toggle-mute`, {}) + ); + } + + async getStatus(): Promise { + return await firstValueFrom( + this.http.get(`${this.apiBaseUrl}/status`) + ); + } } From 3ee1d71b79c47a80e7d43e4cd7e7c1012589490e Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 14 Feb 2026 16:11:35 +0100 Subject: [PATCH 3/3] fix(remote-control): add payload size limit handling for remote control requests --- .../src/app/events/remote-control.events.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/electron-backend/src/app/events/remote-control.events.ts b/apps/electron-backend/src/app/events/remote-control.events.ts index e21858224..366284ade 100644 --- a/apps/electron-backend/src/app/events/remote-control.events.ts +++ b/apps/electron-backend/src/app/events/remote-control.events.ts @@ -272,10 +272,27 @@ class RemoteControlEvents { callback: (body: any) => void ): void { let data = ''; + let totalSize = 0; + let payloadTooLarge = false; + const MAX_BODY_SIZE = 10 * 1024; // 10KB + req.on('data', (chunk: Buffer) => { + if (payloadTooLarge) return; + + totalSize += chunk.length; + if (totalSize > MAX_BODY_SIZE) { + payloadTooLarge = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + data += chunk.toString(); }); req.on('end', () => { + if (payloadTooLarge) return; + if (!data) { callback({}); return;