From 620ecb7518f641e4553c05a3e5ee8fc3cf160504 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 13 Feb 2026 22:17:20 +0100 Subject: [PATCH] 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: {