feat(remote-control): remote control support for xtream and stalker live tv

This commit is contained in:
4gray committed 2026-02-13 22:17:20 +01:00
1 parent 12bbdf562a
commit 620ecb7518
8 files changed
+143 -35

No files matched your search

@@ -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: (
+6 -3
View File
@@ -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
@@ -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://...
@@ -282,7 +282,7 @@
<mat-divider />
<div class="row">
<div class="column">
{{ 'SETTINGS.REMOTE_CONTROL' | translate }} (m3u)
{{ 'SETTINGS.REMOTE_CONTROL' | translate }}
<p>
{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}
</p>
@@ -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<T>(
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;
}
@@ -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<ElementRef>('scrollContainer');
private scrollListener: (() => void) | null = null;
private searchDebounceTimer: ReturnType<typeof setTimeout> | 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);
}
}
@@ -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<number, boolean>();
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);
}
}
+1 -1
View File
@@ -194,7 +194,7 @@ declare global {
// Remote control
onChannelChange?: (
callback: (data: { direction: 'up' | 'down' }) => void
) => void;
) => () => void;
// Player error notifications
onPlayerError?: (
callback: (data: {