feat: use signal for active channel + listen for remote control events

- Replace local selected state with store signal (selectActive) in the
  channel list container so templates bind to activeChannel()?.url
  instead of comparing selected?.id. This unifies selection handling
  with the global store and prevents stale local state.
- Update selectChannel to dispatch setActiveChannel instead of mutating
  local selected property; keep EPG lookup behavior intact.
- Import selectActive alongside other selectors from m3u-state and
  remove unused local InjectionToken in video player.
- Add remote control listener in VideoPlayer (Electron) to handle
  channel up/down events and wire a new handleRemoteChannelChange
  method skeleton. Use combineLatest/take to safely read required
  observables for channel switching.
- Minor rxjs import and formatting adjustments.

These changes centralize active channel state in the store, simplify
template logic, and add support for remote channel changes on desktop.
This commit is contained in:
4gray committed 2025-11-22 11:26:22 +01:00
1 parent a48f7d60be
commit c8961c94d5
3 files changed
+70 -14

No files matched your search

@@ -3,7 +3,6 @@ import { ComponentPortal } from '@angular/cdk/portal';
import { AsyncPipe, CommonModule } from '@angular/common';
import {
Component,
InjectionToken,
Injector,
OnInit,
effect,
@@ -17,6 +16,7 @@ import { StorageMap } from '@ngx-pwa/local-storage';
import {
ArtPlayerComponent,
AudioPlayerComponent,
COMPONENT_OVERLAY_REF,
EpgListComponent,
HtmlVideoPlayerComponent,
InfoOverlayComponent,
@@ -31,7 +31,7 @@ import {
selectChannels,
selectCurrentEpgProgram,
} from 'm3u-state';
import { Observable, combineLatestWith, filter, map, switchMap } from 'rxjs';
import { Observable, combineLatest, combineLatestWith, filter, map, switchMap, take } from 'rxjs';
import { DataService, PlaylistsService } from 'services';
import {
Channel,
@@ -73,10 +73,6 @@ export class VideoPlayerComponent implements OnInit {
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly COMPONENT_OVERLAY_REF = new InjectionToken<OverlayRef>(
'COMPONENT_OVERLAY_REF'
);
/** Active selected channel */
readonly activeChannel$ = this.store
.select(selectActive)
@@ -126,6 +122,13 @@ export class VideoPlayerComponent implements OnInit {
this.applySettings();
this.getPlaylistUrlAsParam();
// 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);
});
}
this.channels$ = this.activatedRoute.params.pipe(
combineLatestWith(this.activatedRoute.queryParams),
switchMap(([params, queryParams]) => {
@@ -162,6 +165,56 @@ export class VideoPlayerComponent implements OnInit {
);
}
/**
* Handle remote control channel change
*/
handleRemoteChannelChange(direction: 'up' | 'down'): void {
console.log(`Remote control: changing channel ${direction}`);
// Use combineLatest to get both values and take only the first emission
combineLatest([this.channels$, this.activeChannel$])
.pipe(
filter(([channels, activeChannel]) => {
return channels.length > 0 && !!activeChannel;
}),
take(1),
map(([channels, activeChannel]) => {
return { channels, activeChannel: activeChannel as Channel };
})
)
.subscribe({
next: ({ channels, activeChannel }) => {
// Find current channel index
const currentIndex = channels.findIndex(
ch => ch.url === activeChannel.url
);
if (currentIndex === -1) {
console.warn('Current channel not found in channel list');
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];
console.log(`Switching to channel: ${nextChannel.name}`);
this.store.dispatch(PlaylistActions.setActiveChannel({ channel: nextChannel }));
},
error: (err) => {
console.error('Error changing channel:', err);
}
});
}
/**
* Opens a playlist provided as a url param
* e.g. iptvnat.or?url=http://...
@@ -214,7 +267,7 @@ export class VideoPlayerComponent implements OnInit {
const injector = Injector.create({
providers: [
{
provide: this.COMPONENT_OVERLAY_REF,
provide: COMPONENT_OVERLAY_REF,
useValue: this.overlayRef,
},
],
@@ -42,7 +42,7 @@
"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel?.id"
[selected]="activeChannel()?.url === channel?.url"
/>
</cdk-virtual-scroll-viewport>
</mat-nav-list>
@@ -90,7 +90,7 @@
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="
selected?.id === channel.id
activeChannel()?.url === channel.url
"
></app-channel-list-item>
}
@@ -136,7 +136,7 @@
[isDraggable]="true"
[logo]="channel?.tvg?.logo"
(clicked)="selectChannel(channel)"
[selected]="selected?.id === channel?.id"
[selected]="activeChannel()?.url === channel?.url"
[showFavoriteButton]="true"
(favoriteToggled)="
toggleFavoriteChannel(channel, $event)
@@ -28,7 +28,11 @@ import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import * as _ from 'lodash';
import * as PlaylistActions from 'm3u-state';
import { selectActivePlaylistId, selectFavorites } from 'm3u-state';
import {
selectActive,
selectActivePlaylistId,
selectFavorites,
} from 'm3u-state';
import { map, skipWhile } from 'rxjs';
import { EpgService } from 'services';
import { Channel } from 'shared-interfaces';
@@ -81,7 +85,7 @@ export class ChannelListContainerComponent implements OnDestroy {
groupedChannels!: { [key: string]: Channel[] };
/** Selected channel */
selected!: Channel;
readonly activeChannel = this.store.selectSignal(selectActive);
/** Search term for channel filter */
searchTerm: { name: string } = {
@@ -123,11 +127,10 @@ export class ChannelListContainerComponent implements OnDestroy {
);
/**
* Sets clicked channel as selected and emits them to the parent component
* Sets clicked channel as active and dispatches to store
* @param channel selected channel
*/
selectChannel(channel: Channel): void {
this.selected = channel;
this.store.dispatch(PlaylistActions.setActiveChannel({ channel }));
// Use tvg-id for EPG matching, fallback to channel name if not available