From 2fc1bd56014bef8df65379c24e3efe77b79f4962 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:42:50 +0200 Subject: [PATCH] fix(stalker): keep live playback when switching the ITV/radio category (#1517) Switching the Live TV or radio category in the shell context panel tore down the Stalker player: `onStalkerCategoryClicked` cleared the selected item for every section, and the live layout gates its player on `selectedItem`. Xtream live (#936) and M3U groups already keep the channel playing across a category/group switch. - Context panel: return before `clearSelectedItem()` for `itv`/`radio`; VOD/series clicks still drop the open detail before navigating. - Live layout: the category-change reset effect no longer wipes the playing channel's short-EPG fallback or cancels a fallback load in flight; only a section change (itv <-> radio) does that now. - Regression coverage in the context panel spec, the live layout spec and a new web E2E scenario; docs and a `.changes/` note added. Co-authored-by: Claude Fable 5.1 --- ...r-keep-live-playback-on-category-switch.md | 9 ++ apps/web-e2e/src/stalker.e2e.ts | 38 +++++++ docs/architecture/stalker-epg.md | 6 ++ docs/architecture/stalker-portal.md | 14 +++ docs/architecture/workspace-shell.md | 8 ++ ...alker-live-stream-layout.component.spec.ts | 98 +++++++++++++++++++ .../stalker-live-stream-layout.component.ts | 14 +++ .../workspace-context-panel.component.spec.ts | 66 ++++++++++++- .../workspace-context-panel.component.ts | 8 +- 9 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 .changes/stalker-keep-live-playback-on-category-switch.md diff --git a/.changes/stalker-keep-live-playback-on-category-switch.md b/.changes/stalker-keep-live-playback-on-category-switch.md new file mode 100644 index 000000000..a6ae14ae9 --- /dev/null +++ b/.changes/stalker-keep-live-playback-on-category-switch.md @@ -0,0 +1,9 @@ +--- +type: fix +area: stalker +--- + +In Stalker portals, switching the Live TV or radio category in the sidebar no +longer stops the channel that is playing. The category only changes which +channels are listed, matching how Xtream portals and M3U playlists already +behave. diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 9b186181c..607c08b99 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -462,6 +462,44 @@ test('@stalker PWA hides EPG for ITV channel', async ({ page }) => { expect(shortEpgRequests).toHaveLength(0); }); +test('@stalker ITV playback survives a category switch', async ({ page }) => { + // Regression: the shell context panel used to clear the selected Stalker + // item on every category click, tearing down the player for a channel the + // user never switched away from. Xtream live (#936) and M3U groups keep + // playing across a category/group switch; Stalker must too. + await addStalkerPortal(page); + + await page.getByRole('link', { name: /live|itv/i }).click(); + await page.waitForURL(/stalker.*itv/); + + const categories = page.locator('.category-item'); + await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + + const sidebar = page.locator('app-stalker-live-stream-layout .sidebar'); + const sidebarTitle = sidebar.locator('.category-title'); + const channels = page.locator('[data-test-id="channel-item"]'); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? ''; + expect(firstCategoryTitle).not.toBe(''); + + await channels.first().click(); + await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); + const player = page.locator('app-web-player-view'); + await expect(player).toBeVisible({ timeout: 20_000 }); + + await categories.nth(2).click(); + + // The sidebar re-filters to the new category (proves the click landed and + // change detection ran)… + await expect(sidebarTitle).not.toHaveText(firstCategoryTitle, { + timeout: 20_000, + }); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + // …while the channel picked from the previous category keeps playing. + await expect(player).toBeVisible(); +}); + test('@stalker radio — stations use the inline audio player without EPG', async ({ page, }) => { diff --git a/docs/architecture/stalker-epg.md b/docs/architecture/stalker-epg.md index cec340428..32f321bfc 100644 --- a/docs/architecture/stalker-epg.md +++ b/docs/architecture/stalker-epg.md @@ -310,6 +310,12 @@ therefore falls back to `get_short_epg` when: The fallback is merged with the bulk list rather than replacing it, so the panel shows "now" from the short EPG and the days ahead from the bulk guide. + +The fallback is keyed to the selected channel, not to the category: a category +switch in the sidebar leaves the channel playing and keeps its fallback (and a +fallback load still in flight) intact. It is dropped when the selection moves +to another channel or when the view leaves ITV for radio, where the route +session clears the selection. The stored fallback is tagged with the channel it was fetched for and the merge only applies while that channel is still selected — a channel switch moves the selection synchronously, while the old fallback is replaced only diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 2eee0188c..9e077c038 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -1251,6 +1251,20 @@ The Stalker live route and radio route intentionally share - Some Stalker portals do not expose radio categories. Radio category loading falls back to a synthetic `PORTALS.ALL_RADIO` category with `category_id: '*'` so the station list can still be loaded. +- A category click in the shell context panel only re-filters the channel + sidebar; the selected channel or station keeps playing (Xtream live #936 and + M3U group parity). `onStalkerCategoryClicked` therefore must NOT + `clearSelectedItem()` for `itv`/`radio` — the layout gates its player on + `selectedItem` — while VOD/series clicks still drop the open detail before + navigating to the list route. The layout's category-change reset effect + clears only list state (channels on the legacy paged flow, page, row EPG + previews); the active channel's short-EPG fallback and a fallback load still + in flight belong to the selection and survive the switch. Only a section + change (`itv` ↔ `radio`, where the route session clears the selection) + invalidates that request and drops the fallback. A playing channel outside + the newly selected category simply has no highlighted row, and remote + channel up/down finds no neighbour until a channel from the visible list is + played. ## Full ITV Channel List Cache diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 3078004af..63cc17262 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -146,6 +146,14 @@ default. The panel header exposes a sort menu next to category search with synthetic "all categories" entries stay pinned before sorted provider categories. +A category click in a LIVE section (Xtream `live`, Stalker `itv` and `radio`) +changes only the selected category: the live layouts gate their player on the +store's selected item, so the handler must not clear it — the channel the user +is watching keeps playing while the sidebar re-filters (Xtream: #936; Stalker: +`onStalkerCategoryClicked` returns before `clearSelectedItem()`). VOD and +series category clicks do drop the open detail (`setSelectedItem(null)` / +`clearSelectedItem()`) because they navigate to a list route. + ## Search And Navigation Rules Search is shell-owned and route-aware: diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 8574eb544..cf7f5ab51 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -838,6 +838,104 @@ describe('StalkerLiveStreamLayoutComponent', () => { expect(stalkerStore.setItvChannels).toHaveBeenCalledWith([]); }); + it('keeps the player and the playing channel EPG fallback when the category changes', async () => { + // A category click in the shell sidebar only re-filters the channel + // list — the selected channel keeps playing (Xtream live / M3U group + // parity). The category-change reset effect used to wipe the short-EPG + // fallback of that unchanged channel too, so on portals whose bulk + // guide lacks the current programme the panel lost "now" until the + // next channel switch. + mockFutureOnlyBulkEpg(); + + fixture.detectChanges(); + await component.playChannel(itvChannels()[0]); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.currentProgram()?.title).toBe('Now 10001'); + + selectedCategoryId.set('42'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.streamUrl()).toBe('https://example.com/alpha.m3u8'); + expect( + fixture.nativeElement.querySelector('app-web-player-view') + ).not.toBeNull(); + expect(component.currentProgram()?.title).toBe('Now 10001'); + expect( + component.activeEpgPrograms().map((program) => program.title) + ).toEqual(['Now 10001', 'Future Show']); + expect(component.isLoadingFallbackEpg()).toBe(false); + }); + + it('lets an in-flight EPG fallback load of the playing channel finish across a category switch', async () => { + // The played channel is deliberately absent from the rendered list and + // the listed channel has a current programme, so the row preview queue + // never calls fetchChannelEpg — the single call below is the panel's. + itvChannels.set([defaultItvChannels()[1]]); + mockBulkEpg({ + '10001': [buildFutureProgram('10001', 'Future Show')], + '10002': [buildProgram('10002', 'Beta Now')], + }); + let resolvePanelFallback: ((items: EpgItem[]) => void) | undefined; + fetchChannelEpg.mockImplementation( + () => + new Promise((resolve) => { + resolvePanelFallback = resolve; + }) + ); + + fixture.detectChanges(); + await component.playChannel(defaultItvChannels()[0]); + await fixture.whenStable(); + + expect(fetchChannelEpg).toHaveBeenCalledTimes(1); + expect(fetchChannelEpg).toHaveBeenCalledWith('10001'); + expect(component.isLoadingFallbackEpg()).toBe(true); + + // Category switch while the short-EPG request is still on the wire. + selectedCategoryId.set('42'); + fixture.detectChanges(); + await fixture.whenStable(); + + resolvePanelFallback?.([buildEpgItem('10001', 'Now 10001')]); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.currentProgram()?.title).toBe('Now 10001'); + expect(component.isLoadingFallbackEpg()).toBe(false); + }); + + it('drops the EPG fallback and its loading state when the view leaves ITV', async () => { + // The route session clears the selection on itv → radio; the layout + // must not carry an ITV fallback or a stuck loading flag into radio. + itvChannels.set([defaultItvChannels()[1]]); + mockBulkEpg({ + '10001': [buildFutureProgram('10001', 'Future Show')], + '10002': [buildProgram('10002', 'Beta Now')], + }); + // A short-EPG request that never answers — the view leaves ITV first. + fetchChannelEpg.mockImplementation( + () => new Promise(() => undefined) + ); + + fixture.detectChanges(); + await component.playChannel(defaultItvChannels()[0]); + await fixture.whenStable(); + + expect(component.isLoadingFallbackEpg()).toBe(true); + + stalkerStore.selectedContentType.set('radio'); + selectedCategoryId.set('radio-all'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.fallbackEpgPrograms()).toBeNull(); + expect(component.isLoadingFallbackEpg()).toBe(false); + }); + it('shows an empty state (not a skeleton) for a loaded but empty category', () => { // Full list is loaded, nothing is loading, and the selected category // filters down to zero channels — this must read as "empty", not "stuck". diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 8d6c6ac8c..cf59687c5 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -557,6 +557,20 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } this.stalkerStore.setPage(0); this.clearEpgPreviewMaps(); + }); + }); + + // The panel's short-EPG fallback belongs to the SELECTED channel, not + // to the category: a category switch only re-filters the sidebar while + // the selected channel keeps playing (Xtream parity), so its panel + // programmes — and a fallback load still in flight — must survive it. + // Leaving the section (itv ↔ radio) is different: the route session + // clears the selection there, and an abandoned request must not settle + // its loading flag into the next view, so that transition still + // invalidates the request and drops the fallback. + effect(() => { + this.stalkerStore.selectedContentType(); + untracked(() => { this.epgLoadRequestId += 1; this.fallbackEpgPrograms.set(null); this.isLoadingFallbackEpg.set(false); 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 ae170ef4a..799ebdeb0 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 @@ -534,7 +534,7 @@ describe('WorkspaceContextPanelComponent', () => { ); }); - it('labels Stalker radio categories and keeps category selection in the radio layout', () => { + it('labels Stalker radio categories and keeps the playing station on a category click', () => { fixture.componentRef.setInput('context', { provider: 'stalker', playlistId: 'stalker-1', @@ -558,10 +558,72 @@ describe('WorkspaceContextPanelComponent', () => { expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith('*'); expect(stalkerStore.setPage).toHaveBeenCalledWith(0); - expect(stalkerStore.clearSelectedItem).toHaveBeenCalled(); + // The radio layout renders its audio player only while the store has + // a selected item — clearing it here would silence the station. + expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled(); }); + it('preserves the playing Stalker ITV channel when switching live categories', () => { + // Regression: the click handler used to clearSelectedItem() for every + // Stalker section. The live layout gates its player on selectedItem, + // so a category switch in the sidebar stopped a channel the user never + // switched away from — unlike Xtream live (#936) and M3U groups. + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-1', + }); + fixture.componentRef.setInput('section', 'itv'); + stalkerStore.getCategoryResource.set([ + { category_id: '*', category_name: 'All channels' }, + { category_id: '7', category_name: 'Sports' }, + ]); + fixture.detectChanges(); + + const categoryButtons = Array.from( + fixture.nativeElement.querySelectorAll('.category-item') + ) as HTMLButtonElement[]; + + categoryButtons[1]?.click(); + + expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith('7'); + expect(stalkerStore.setPage).toHaveBeenCalledWith(0); + expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('still closes the open Stalker detail when switching VOD categories', () => { + // VOD/series category clicks navigate to a list route, so the open + // detail must be dropped there — the live exemption is deliberate and + // narrow. + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-1', + }); + fixture.componentRef.setInput('section', 'vod'); + stalkerStore.getCategoryResource.set([ + { category_id: '*', category_name: 'All movies' }, + { category_id: '7', category_name: 'Action' }, + ]); + fixture.detectChanges(); + + const categoryButtons = Array.from( + fixture.nativeElement.querySelectorAll('.category-item') + ) as HTMLButtonElement[]; + + categoryButtons[1]?.click(); + + expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith('7'); + expect(stalkerStore.clearSelectedItem).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'stalker', + 'stalker-1', + 'vod', + '7', + ]); + }); + describe('Stalker category error description', () => { afterEach(() => { stalkerStore.isCategoryResourceFailed.set(false as never); 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 8f7c9acbd..1bd237fee 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 @@ -458,12 +458,18 @@ export class WorkspaceContextPanelComponent { this.contextDrawer?.close(); this.stalkerStore.setSelectedCategory(categoryId); this.stalkerStore.setPage(0); - this.stalkerStore.clearSelectedItem(); if (section === 'itv' || section === 'radio') { + // Live TV / radio: the category only re-filters the channel + // sidebar, the selected channel keeps playing (Xtream parity, + // #936). The live layout gates its player on `selectedItem`, so + // clearing it here would tear the player down for a channel the + // user never switched away from. return; } + this.stalkerStore.clearSelectedItem(); + if (categoryId === '*') { this.router.navigate([ '/workspace',