diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index 99169e6b8..d858f2cf2 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -151,11 +151,13 @@ ipcMain.handle(AUTO_UPDATE_PLAYLISTS, async (event, playlists) => { continue; } - // Preserve the original _id and autoRefresh setting + // Preserve user data when updating playlist updatedPlaylists.push({ ...playlistObject, _id: playlist._id, autoRefresh: playlist.autoRefresh, + favorites: playlist.favorites || [], // Preserve favorites + userAgent: playlist.userAgent, // Preserve custom user agent }); console.log(`Successfully updated playlist "${playlist.title}"`); 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 e58d41192..a03a0d086 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,10 +3,12 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { AsyncPipe, CommonModule } from '@angular/common'; import { Component, + HostListener, Injector, OnInit, effect, inject, + viewChild, } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatSidenavModule } from '@angular/material/sidenav'; @@ -31,7 +33,15 @@ import { selectChannels, selectCurrentEpgProgram, } from 'm3u-state'; -import { Observable, combineLatest, combineLatestWith, filter, map, switchMap, take } from 'rxjs'; +import { + Observable, + combineLatest, + combineLatestWith, + filter, + map, + switchMap, + take, +} from 'rxjs'; import { DataService, PlaylistsService } from 'services'; import { Channel, @@ -97,6 +107,14 @@ export class VideoPlayerComponent implements OnInit { /** EPG overlay reference */ private overlayRef!: OverlayRef; + /** Info overlay component reference for manual triggering */ + readonly infoOverlay = viewChild(InfoOverlayComponent); + + /** Channel number input state */ + channelNumberInput = ''; + showChannelNumberOverlay = false; + private channelNumberTimeout?: number; + volume = 1; constructor() { @@ -124,9 +142,11 @@ 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); - }); + window.electron.onChannelChange( + (data: { direction: 'up' | 'down' }) => { + this.handleRemoteChannelChange(data.direction); + } + ); } this.channels$ = this.activatedRoute.params.pipe( @@ -153,6 +173,26 @@ export class VideoPlayerComponent implements OnInit { channels: playlist.playlist.items, }) ); + + // Load favorites from the playlist + if ( + playlist.favorites && + playlist.favorites.length > 0 + ) { + this.store.dispatch( + PlaylistActions.setFavorites({ + channelIds: playlist.favorites, + }) + ); + } else { + // Clear favorites if playlist has none + this.store.dispatch( + PlaylistActions.setFavorites({ + channelIds: [], + }) + ); + } + return playlist.playlist.items as Channel[]; }) ); @@ -179,18 +219,20 @@ export class VideoPlayerComponent implements OnInit { }), take(1), map(([channels, activeChannel]) => { - return { channels, activeChannel: activeChannel as Channel }; + return { + channels, + activeChannel: activeChannel as Channel, + }; }) ) .subscribe({ next: ({ channels, activeChannel }) => { // Find current channel index const currentIndex = channels.findIndex( - ch => ch.url === activeChannel.url + (ch) => ch.url === activeChannel.url ); if (currentIndex === -1) { - console.warn('Current channel not found in channel list'); return; } @@ -198,20 +240,29 @@ export class VideoPlayerComponent implements OnInit { let nextIndex: number; if (direction === 'up') { // Up = previous channel (decrease index) - nextIndex = currentIndex - 1 < 0 ? channels.length - 1 : currentIndex - 1; + nextIndex = + currentIndex - 1 < 0 + ? channels.length - 1 + : currentIndex - 1; } else { // Down = next channel (increase index) - nextIndex = currentIndex + 1 >= channels.length ? 0 : currentIndex + 1; + 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 })); + this.store.dispatch( + PlaylistActions.setActiveChannel({ + channel: nextChannel, + }) + ); }, error: (err) => { console.error('Error changing channel:', err); - } + }, }); } @@ -304,4 +355,95 @@ export class VideoPlayerComponent implements OnInit { data: { isDialog: true }, }); } + + /** + * Keyboard shortcut: Press 'I' to toggle EPG info overlay + */ + @HostListener('document:keydown.i', ['$event']) + handleInfoKeyPress(event: Event): void { + // Prevent default behavior and show info overlay + event.preventDefault(); + this.toggleInfoOverlay(); + } + + /** + * Handle digit key presses for channel number input + */ + @HostListener('document:keydown', ['$event']) + handleKeyPress(event: KeyboardEvent): void { + // Only handle digit keys (0-9) + if (event.key >= '0' && event.key <= '9') { + event.preventDefault(); + this.handleChannelNumberInput(event.key); + } + } + + /** + * Toggles the EPG info overlay visibility + * Called by 'I' keyboard shortcut or info button + */ + toggleInfoOverlay(): void { + const overlay = this.infoOverlay(); + if (overlay) { + overlay.showOverlay(); + } + } + + /** + * Handle channel number input from keyboard + * Debounces input to allow multi-digit channel numbers + */ + handleChannelNumberInput(digit: string): void { + // Clear existing timeout + if (this.channelNumberTimeout) { + clearTimeout(this.channelNumberTimeout); + } + + // Add digit to current input + this.channelNumberInput += digit; + this.showChannelNumberOverlay = true; + + // Set timeout to switch channel after 2 seconds of no input + this.channelNumberTimeout = window.setTimeout(() => { + this.switchToChannelByNumber(parseInt(this.channelNumberInput, 10)); + this.clearChannelNumberInput(); + }, 2000); + } + + /** + * Switch to channel by number (1-based index) + */ + switchToChannelByNumber(channelNumber: number): void { + 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; + }) + ) + .subscribe((channel) => { + if (channel) { + this.store.dispatch( + PlaylistActions.setActiveChannel({ channel }) + ); + } + }); + } + + /** + * Clear channel number input and hide overlay + */ + clearChannelNumberInput(): void { + this.channelNumberInput = ''; + this.showChannelNumberOverlay = false; + if (this.channelNumberTimeout) { + clearTimeout(this.channelNumberTimeout); + this.channelNumberTimeout = undefined; + } + } } diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 2a4345708..bc8d44346 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -8,6 +8,7 @@ import { TranslateService } from '@ngx-translate/core'; import { combineLatestWith, EMPTY, + filter, firstValueFrom, map, switchMap, @@ -130,6 +131,8 @@ export class PlaylistEffects { setActiveChannel$ = createEffect(() => { return this.actions$.pipe( ofType(PlaylistActions.setActiveChannel), + // Skip the effect entirely when channel is falsy + filter((action) => !!action.channel), map((action) => { const { channel } = action; 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 df6ae709b..77e70e8b5 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 @@ -74,33 +74,36 @@ - @for ( - channel of groups.value; - track trackByFn($index, channel); - let i = $index - ) { - - } +
+ @for ( + channel of groups.value; + track trackByFn($index, channel); + let i = $index + ) { + + } +
} } - + @let showFavorites = (playlistId$ | async) !== 'GLOBAL_FAVORITES'; 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 328165544..83af308a9 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 @@ -33,7 +33,7 @@ import { selectActivePlaylistId, selectFavorites, } from 'm3u-state'; -import { map, skipWhile } from 'rxjs'; +import { BehaviorSubject, combineLatest, map, skipWhile } from 'rxjs'; import { EpgService } from 'services'; import { Channel, EpgProgram } from 'shared-interfaces'; import { ChannelListItemComponent } from './channel-list-item/channel-list-item.component'; @@ -76,6 +76,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { * Create local copy of the store for local manipulations without updates in the store */ _channelList: Channel[] = []; + private channelList$ = new BehaviorSubject([]); + get channelList(): Channel[] { return this._channelList; } @@ -83,6 +85,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { @Input() set channelList(value: Channel[]) { this._channelList = value; + this.channelList$.next(value); // Emit to observable this.groupedChannels = _.default.groupBy(value, 'group.title'); // Fetch EPG for new channel list this.fetchEpgForChannels(value); @@ -119,18 +122,23 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { ) ); - /** List with favorites */ - favorites$ = this.store.select(selectFavorites).pipe( - map( - ( - favoriteChannelIds // TODO: move to selector - ) => - favoriteChannelIds.map((favoriteChannelId) => - this.channelList.find( + /** List with favorites - combines favorites from store with current channel list */ + favorites$ = combineLatest([ + this.store.select(selectFavorites), + this.channelList$, + ]).pipe( + map(([favoriteChannelIds, channelList]) => { + console.log('[ChannelList] favorites$ emit - IDs:', favoriteChannelIds.length, 'Channels:', channelList.length); + const favorites = favoriteChannelIds + .map((favoriteChannelId) => + channelList.find( (channel) => channel.url === favoriteChannelId ) ) - ) + .filter((channel): channel is Channel => channel !== undefined); // Filter out undefined channels + console.log('[ChannelList] favorites$ result:', favorites.length, 'favorites'); + return favorites; + }) ); /** @@ -189,6 +197,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { if (this.epgRefreshInterval) { clearInterval(this.epgRefreshInterval); } + + // Clean up BehaviorSubject + this.channelList$.complete(); } /**