mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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.
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+98
@@ -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".
|
||||
|
||||
+14
@@ -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);
|
||||
|
||||
+64
-2
@@ -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);
|
||||
|
||||
+7
-1
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user