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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-03 21:42:50 +02:00
1 parent 308ed9cb41
commit 2fc1bd5601
9 files changed
+258 -3

No files matched your search

@@ -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.
+38
View File
@@ -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,
}) => {
+6
View File
@@ -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
+14
View File
@@ -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
+8
View File
@@ -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:
@@ -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<EpgItem[]>((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<EpgItem[]>(() => 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".
@@ -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);
@@ -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);
@@ -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',