fix(xtream): open correct channel and category from Ctrl+F live search (#1034)

* fix(xtream): open correct channel and category from Ctrl+F live search

Three bugs prevented a live channel clicked from global search (Ctrl+F)
from playing and highlighting correctly in the sidebar:

1. Component reuse on same-route navigation: when the user was already
   on the /live route, Angular reused LiveStreamLayoutComponent without
   re-running the constructor, so openXtreamLiveItemId was never read
   from history state. Fixed by subscribing to NavigationEnd and calling
   checkPendingAutoOpenFromState() on every navigation.

2. Early effect firing with wrong content type: the auto-open effect
   could fire before syncRouteState set selectedContentType to 'live',
   causing the channel to be searched in VOD streams, not found, and
   the pending ID cleared. Fixed by guarding on selectedContentType()
   === 'live' before processing.

3. Category filter blocking channel lookup: getVisibleChannels() only
   returned channels in the currently selected category, so a channel
   from a different category was never found. Fixed by switching to
   getAllLiveStreams() (raw liveStreams signal) for the auto-open lookup.

Also sets selectedCategoryId to the opened channel's category so the
sidebar scrolls to the right category and highlights the channel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(xtream): address PR review feedback on live search auto-open

- Remove redundant constructor call: rely solely on NavigationEnd
  subscription to read openXtreamLiveItemId from history state, which
  fires after component creation for both initial and re-navigation
- Add explicit setSelectedItem call after auto-open so EPG loading and
  remote-control status reflect the playing channel independently of
  the constructStreamUrl side-effect
- Add 6 regression tests covering: initial NavigationEnd, category
  highlighting, content-type guard, lazy liveStreams loading, missing
  channel, and component-reuse re-navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ilyesbrhandClaude Sonnet 4.6 authored and GitHub committed 2026-06-10 10:52:11 +02:00
1 parent 06700b318a
commit 5bb2dfed71
2 files changed
+195 -14

No files matched your search

@@ -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<unknown>(sampleChannel);
const currentPlaylist = signal(playlist);
const liveStreams = signal<unknown[]>([]);
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<unknown>;
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,
@@ -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<string, unknown> | 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<string, unknown>);
// 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<string, unknown> | 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[];
}