From c8961c94d5676c8198ec546e83c496709f3952ba Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 16 Nov 2025 17:45:18 +0100 Subject: [PATCH] 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. --- .../video-player/video-player.component.ts | 67 +++++++++++++++++-- .../channel-list-container.component.html | 6 +- .../channel-list-container.component.ts | 11 +-- 3 files changed, 70 insertions(+), 14 deletions(-) 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 19d2aaf7b..e58d41192 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 @@ -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( - '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, }, ], diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 1c0a2e4bc..8414827f7 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -42,7 +42,7 @@ " [logo]="channel?.tvg?.logo" (clicked)="selectChannel(channel)" - [selected]="selected?.id === channel?.id" + [selected]="activeChannel()?.url === channel?.url" /> @@ -90,7 +90,7 @@ [logo]="channel?.tvg?.logo" (clicked)="selectChannel(channel)" [selected]=" - selected?.id === channel.id + activeChannel()?.url === channel.url " > } @@ -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) diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 079d9d8cd..2c74f3747 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -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