diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 0d91a1c75..b8d4bc9bd 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -2,10 +2,15 @@ import { Directive, Component, input, output, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { + ActivatedRoute, + NavigationEnd, + Router, + convertToParamMap, +} from '@angular/router'; import { MockPipe } from 'ng-mocks'; import { TranslatePipe } from '@ngx-translate/core'; -import { BehaviorSubject, of } from 'rxjs'; +import { BehaviorSubject, Subject, of } from 'rxjs'; import { LIVE_EPG_PANEL_STATE_STORAGE_KEY, LIVE_SIDEBAR_STATE_STORAGE_KEY, @@ -139,6 +144,7 @@ describe('LiveStreamLayoutComponent', () => { const selectedContentType = signal<'live' | 'vod' | 'series'>('live'); const selectedItem = signal(sampleChannel); const currentPlaylist = signal(playlist); + const liveStreams = signal([]); const xtreamStore = { getCategoriesBySelectedType: categories, @@ -151,10 +157,15 @@ describe('LiveStreamLayoutComponent', () => { selectedContentType, selectedItem, currentPlaylist, + liveStreams, selectItemsFromSelectedCategory: jest.fn(() => [sampleChannel]), constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'), openPlayer: jest.fn(), + setSelectedItem: jest.fn(), + setSelectedCategory: jest.fn(), }; + + let routerEvents: Subject; const favoritesService = { getFavorites: jest.fn().mockReturnValue(of([])), }; @@ -186,11 +197,15 @@ describe('LiveStreamLayoutComponent', () => { onRemoteControlCommand: jest.fn(() => jest.fn()), } as typeof window.electron; + routerEvents = new Subject(); xtreamStore.constructStreamUrl.mockClear(); xtreamStore.openPlayer.mockClear(); + xtreamStore.setSelectedItem.mockClear(); + xtreamStore.setSelectedCategory.mockClear(); xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([ sampleChannel, ]); + liveStreams.set([]); favoritesService.getFavorites.mockClear(); xtreamUrlService.resolveCatchupUrl.mockClear(); portalPlayer.isEmbeddedPlayer.mockReset(); @@ -226,6 +241,10 @@ describe('LiveStreamLayoutComponent', () => { ], }, }, + { + provide: Router, + useValue: { events: routerEvents.asObservable() }, + }, { provide: XtreamStore, useValue: xtreamStore }, { provide: FavoritesService, useValue: favoritesService }, { provide: XtreamUrlService, useValue: xtreamUrlService }, @@ -648,6 +667,127 @@ describe('LiveStreamLayoutComponent', () => { ).toBeNull(); }); + describe('auto-open from Ctrl+F search navigation state', () => { + const searchChannel = { + xtream_id: 202, + name: 'Search Channel', + category_id: '7', + stream_icon: 'search-channel.png', + tv_archive: 0, + tv_archive_duration: 0, + }; + + function triggerNavigationEnd() { + routerEvents.next( + new NavigationEnd( + 1, + '/workspace/xtreams/playlist-1/live', + '/workspace/xtreams/playlist-1/live' + ) + ); + } + + beforeEach(() => { + window.history.replaceState( + { openXtreamLiveItemId: searchChannel.xtream_id }, + '' + ); + }); + + afterEach(() => { + window.history.replaceState({}, ''); + }); + + it('plays and selects a channel found in liveStreams on NavigationEnd', () => { + liveStreams.set([searchChannel]); + fixture.detectChanges(); + + triggerNavigationEnd(); + fixture.detectChanges(); + + expect(xtreamStore.constructStreamUrl).toHaveBeenCalledWith( + searchChannel + ); + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith( + searchChannel + ); + }); + + it('sets the channel category so the sidebar highlights the correct entry', () => { + liveStreams.set([searchChannel]); + fixture.detectChanges(); + + triggerNavigationEnd(); + fixture.detectChanges(); + + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(7); + }); + + it('does not auto-open while selectedContentType is not live', () => { + selectedContentType.set('vod'); + liveStreams.set([searchChannel]); + fixture.detectChanges(); + + triggerNavigationEnd(); + fixture.detectChanges(); + + expect(xtreamStore.constructStreamUrl).not.toHaveBeenCalledWith( + searchChannel + ); + }); + + it('waits for liveStreams to populate before playing', () => { + liveStreams.set([]); + fixture.detectChanges(); + + triggerNavigationEnd(); + fixture.detectChanges(); + + expect(xtreamStore.constructStreamUrl).not.toHaveBeenCalled(); + + liveStreams.set([searchChannel]); + fixture.detectChanges(); + + expect(xtreamStore.constructStreamUrl).toHaveBeenCalledWith( + searchChannel + ); + }); + + it('clears the pending ID when the channel is not found in liveStreams', () => { + liveStreams.set([{ ...searchChannel, xtream_id: 999 }]); + fixture.detectChanges(); + + triggerNavigationEnd(); + fixture.detectChanges(); + + expect(xtreamStore.constructStreamUrl).not.toHaveBeenCalledWith( + searchChannel + ); + }); + + it('re-triggers auto-open on re-navigation when component is reused', () => { + liveStreams.set([searchChannel]); + fixture.detectChanges(); + + // First navigation — clears the pending state + triggerNavigationEnd(); + fixture.detectChanges(); + xtreamStore.constructStreamUrl.mockClear(); + + // Simulate navigating away and back with the same state + window.history.replaceState( + { openXtreamLiveItemId: searchChannel.xtream_id }, + '' + ); + triggerNavigationEnd(); + fixture.detectChanges(); + + expect(xtreamStore.constructStreamUrl).toHaveBeenCalledWith( + searchChannel + ); + }); + }); + it('hides the archive-unavailable notice when there are no past programs yet', () => { const nonArchiveChannel = { ...sampleChannel, diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index f4e0b6a6a..0d3417eab 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -2,6 +2,7 @@ import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, + DestroyRef, HostListener, computed, effect, @@ -10,6 +11,8 @@ import { OnInit, signal, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { filter } from 'rxjs'; import { MatButtonModule } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; @@ -62,12 +65,13 @@ import { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; interface XtreamLiveChannelItem { + readonly category_id?: string | number; readonly name?: string; readonly poster_url?: string; readonly stream_icon?: string; @@ -100,7 +104,9 @@ interface XtreamLiveChannelItem { changeDetection: ChangeDetectionStrategy.OnPush, }) export class LiveStreamLayoutComponent implements OnInit, OnDestroy { + private readonly destroyRef = inject(DestroyRef); private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); private readonly favoritesService = inject(FavoritesService); private readonly xtreamStore = inject(XtreamStore); private readonly xtreamUrlService = inject(XtreamUrlService); @@ -233,14 +239,15 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { onCleanup(() => clearInterval(intervalId)); }); - const requestedItemId = Number( - (window.history.state as Record | null)?.[ - 'openXtreamLiveItemId' - ] - ); - if (Number.isFinite(requestedItemId) && requestedItemId > 0) { - this.pendingAutoOpenLiveItemId.set(requestedItemId); - } + // Read pending auto-open state on every NavigationEnd — covers both the + // initial navigation (Angular fires NavigationEnd after component creation) + // and re-navigation to the same /live route when the component is reused. + this.router.events + .pipe( + filter((e) => e instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.checkPendingAutoOpenFromState()); effect(() => { const pendingId = this.pendingAutoOpenLiveItemId(); @@ -248,12 +255,21 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { return; } - const channels = this.getVisibleChannels(); - if (!Array.isArray(channels) || channels.length === 0) { + // Guard: only process once the live content view is active; otherwise + // the effect may fire while selectedContentType is still 'vod' and + // incorrectly conclude the channel isn't found. + if (this.xtreamStore.selectedContentType() !== 'live') { return; } - const item = channels.find( + // Search across all live streams, not just the category-filtered view, + // so a channel from a different category can still be auto-opened. + const allChannels = this.getAllLiveStreams(); + if (!Array.isArray(allChannels) || allChannels.length === 0) { + return; + } + + const item = allChannels.find( (channel) => Number(channel?.xtream_id) === pendingId ); if (!item) { @@ -262,6 +278,16 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { } this.playLive(item); + // Ensure selectedItem is set so EPG loading and remote-control + // status reflect the channel (constructStreamUrl also does this + // internally, but an explicit call makes the intent clear and + // keeps the auto-open path testable in isolation). + this.xtreamStore.setSelectedItem(item as unknown as Record); + // Navigate to the channel's category so the sidebar shows it highlighted + const categoryId = Number(item.category_id); + if (Number.isFinite(categoryId) && categoryId > 0) { + this.xtreamStore.setSelectedCategory(categoryId); + } this.pendingAutoOpenLiveItemId.set(null); this.clearAutoOpenHistoryState(); }); @@ -470,6 +496,21 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { ); } + private checkPendingAutoOpenFromState(): void { + const requestedItemId = Number( + (window.history.state as Record | null)?.[ + 'openXtreamLiveItemId' + ] + ); + if (Number.isFinite(requestedItemId) && requestedItemId > 0) { + this.pendingAutoOpenLiveItemId.set(requestedItemId); + } + } + + private getAllLiveStreams(): XtreamLiveChannelItem[] { + return this.xtreamStore.liveStreams() as unknown as XtreamLiveChannelItem[]; + } + private getVisibleChannels(): XtreamLiveChannelItem[] { return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[]; }