mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
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:
1 parent
06700b318a
commit
5bb2dfed71
2 files changed
+195
-14
No files matched your search
+142
-2
@@ -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,
|
||||
|
||||
+53
-12
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user