diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index 08a6b2ee4..9be5dd110 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -525,6 +525,14 @@ test.describe('Electron Workspace Search', () => { app.mainWindow, `Live TV / ${sample.categoryName}` ); + await expect( + app.mainWindow.locator( + 'app-live-stream-layout .content-container .video-player' + ) + ).toBeVisible({ timeout: 20000 }); + await expect( + channelItemByTitle(app.mainWindow, sample.targetTitle).first() + ).toHaveClass(/(^|\s)active(\s|$)/); await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); await expectPathname( diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel-route.utils.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel-route.utils.ts new file mode 100644 index 000000000..bbe0eff52 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel-route.utils.ts @@ -0,0 +1,14 @@ +import type { ActivatedRouteSnapshot } from '@angular/router'; + +export function hasActiveLiveCategoryRoute( + route: ActivatedRouteSnapshot +): boolean { + if ( + route.routeConfig?.path === 'live/:categoryId' && + route.paramMap.has('categoryId') + ) { + return true; + } + + return route.children.some((child) => hasActiveLiveCategoryRoute(child)); +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 0eb5c0292..db895249f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -21,6 +21,27 @@ const translations: Record = { 'Fetching playlist data from source...', }; +interface RouteSnapshotStub { + routeConfig: { path: string } | null; + paramMap: { has: (param: string) => boolean }; + children: RouteSnapshotStub[]; +} + +function createRouteSnapshot( + path: string | null, + hasCategoryId = false, + children: RouteSnapshotStub[] = [] +): RouteSnapshotStub { + return { + routeConfig: path ? { path } : null, + paramMap: { + has: (param: string) => + hasCategoryId && param === 'categoryId', + }, + children, + }; +} + describe('WorkspaceContextPanelComponent', () => { let fixture: ComponentFixture; const xtreamCategories = signal([ @@ -65,6 +86,11 @@ describe('WorkspaceContextPanelComponent', () => { clearSelectedItem: jest.fn(), }; const router = { + routerState: { + snapshot: { + root: createRouteSnapshot(null), + }, + }, navigate: jest.fn(), }; const dialog = { @@ -90,6 +116,9 @@ describe('WorkspaceContextPanelComponent', () => { stalkerStore.setSelectedCategory.mockClear(); stalkerStore.setPage.mockClear(); stalkerStore.clearSelectedItem.mockClear(); + router.routerState.snapshot.root = createRouteSnapshot(null, false, [ + createRouteSnapshot('live'), + ]); router.navigate.mockClear(); dialog.open.mockClear(); @@ -226,6 +255,47 @@ describe('WorkspaceContextPanelComponent', () => { ]); }); + it('preserves the active xtream live item when switching live categories', () => { + fixture.componentRef.setInput('section', 'live'); + xtreamSelectedTypeContentState.set('ready'); + fixture.detectChanges(); + + const categoryButtons = Array.from( + fixture.nativeElement.querySelectorAll('.category-item') + ) as HTMLButtonElement[]; + + categoryButtons[1]?.click(); + + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(2); + expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('updates the live category URL without clearing playback when already deep linked', () => { + fixture.componentRef.setInput('section', 'live'); + router.routerState.snapshot.root = createRouteSnapshot(null, false, [ + createRouteSnapshot('live/:categoryId', true), + ]); + xtreamSelectedTypeContentState.set('ready'); + fixture.detectChanges(); + + const categoryButtons = Array.from( + fixture.nativeElement.querySelectorAll('.category-item') + ) as HTMLButtonElement[]; + + categoryButtons[1]?.click(); + + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(2); + expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith( + ['/workspace', 'xtreams', 'playlist-1', 'live', 2], + { + queryParamsHandling: 'preserve', + replaceUrl: true, + } + ); + }); + it('labels Stalker radio categories and keeps category selection in the radio layout', () => { fixture.componentRef.setInput('context', { provider: 'stalker', diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index c510f81e3..43db140e1 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -20,6 +20,7 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component'; import { WorkspaceContextErrorViewComponent } from './components/workspace-context-error-view.component'; +import { hasActiveLiveCategoryRoute } from './workspace-context-panel-route.utils'; type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists'; @@ -297,13 +298,31 @@ export class WorkspaceContextPanelComponent { } const categoryId = numericCategoryId; - this.xtreamStore.setSelectedItem(null); - this.xtreamStore.setSelectedCategory(categoryId); - if (section === 'live') { + this.xtreamStore.setSelectedCategory(categoryId); + const liveRouteHasCategory = hasActiveLiveCategoryRoute( + this.router.routerState.snapshot.root + ); + if (liveRouteHasCategory) { + void this.router.navigate( + [ + '/workspace', + 'xtreams', + context.playlistId, + 'live', + categoryId, + ], + { + queryParamsHandling: 'preserve', + replaceUrl: true, + } + ); + } return; } + this.xtreamStore.setSelectedItem(null); + this.xtreamStore.setSelectedCategory(categoryId); this.router.navigate([ '/workspace', 'xtreams',