mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(remote-control): remote control support for xtream and stalker live tv
This commit is contained in:
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: (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -194,7 +194,7 @@ declare global {
|
||||
// Remote control
|
||||
onChannelChange?: (
|
||||
callback: (data: { direction: 'up' | 'down' }) => void
|
||||
) => void;
|
||||
) => () => void;
|
||||
// Player error notifications
|
||||
onPlayerError?: (
|
||||
callback: (data: {
|
||||
|
||||
Reference in new issue
Block a user