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`.
This commit is contained in:
4gray committed 2026-02-14 00:03:42 +01:00
1 parent 620ecb7518
commit 7c9db4bc7d
14 files changed
+1100 -400

No files matched your search

@@ -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,
});
}
}
}
@@ -32,3 +32,26 @@ export function getAdjacentChannelItem<T>(
return items[nextIndex] ?? null;
}
/**
* Returns the Nth item in a list (1-based index), or null if out of range.
*/
export function getChannelItemByNumber<T>(
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;
}
@@ -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<typeof setTimeout> | 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);
}
}
@@ -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<number, boolean>();
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);
}
}
+22
View File
@@ -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: {