From e243a44c6bff6a44541a460b705d07e1dbf723fa Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 02:20:22 +0200 Subject: [PATCH] fix(ui): align compact channel row consumers --- .../src/portal-mock-fixtures.ts | 2 +- .../src/xtream-epg.e2e.ts | 124 +++++++++++++++++- docs/architecture/iptvnator-ui-guidelines.md | 15 ++- ...26-07-30-responsive-channel-epg-preview.md | 4 +- ...9-responsive-channel-epg-preview-design.md | 6 +- .../video-player/video-player.component.html | 10 +- .../video-player.component.spec.ts | 1 + .../global-favorites-list.component.html | 3 +- .../global-favorites-list.component.spec.ts | 27 ++++ .../global-favorites-list.component.ts | 7 +- .../unified-collection-page.component.html | 2 +- .../unified-collection-page.component.spec.ts | 22 ++++ .../unified-collection-page.component.ts | 3 + .../unified-live-tab.component.html | 1 + .../unified-live-tab.component.spec.ts | 7 + .../unified-live-tab.component.ts | 1 + .../unified-favorite-channel.interface.ts | 3 + .../stalker-live-stream-layout.component.html | 8 +- ...alker-live-stream-layout.component.spec.ts | 58 +++++++- .../portal-channels-list.component.html | 3 +- .../portal-channels-list.component.spec.ts | 42 +++++- .../portal-channels-list.component.ts | 1 + .../all-channels-view.component.spec.ts | 6 +- .../channel-list-container.component.html | 5 +- .../channel-list-container.component.spec.ts | 2 + .../channel-list-container.component.ts | 2 +- .../channel-list-item-skeleton.component.scss | 3 +- .../channel-list-item.component.scss | 3 +- .../channel-list-item.component.spec.ts | 18 +++ .../groups-view/groups-view.component.spec.ts | 22 ++++ .../groups-view/groups-view.component.ts | 2 +- .../channel-list-loading-state.component.html | 4 +- ...annel-list-loading-state.component.spec.ts | 16 +++ .../channel-list-loading-state.component.ts | 1 + 34 files changed, 404 insertions(+), 30 deletions(-) diff --git a/apps/electron-backend-e2e/src/portal-mock-fixtures.ts b/apps/electron-backend-e2e/src/portal-mock-fixtures.ts index 043976e1b..55e220c3b 100644 --- a/apps/electron-backend-e2e/src/portal-mock-fixtures.ts +++ b/apps/electron-backend-e2e/src/portal-mock-fixtures.ts @@ -203,7 +203,7 @@ export async function fetchXtreamEpgFixture( export async function fetchStalkerCategoryFixture( request: APIRequestContext, - type: 'itv' | 'series' | 'vod' + type: 'itv' | 'radio' | 'series' | 'vod' ): Promise { const categoriesResponse = await fetchJson< StalkerProxyPayload diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index 0be6a5639..f65926039 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -1,5 +1,6 @@ import type { Locator } from '@playwright/test'; import { + addStalkerPortal, addXtreamPortal, channelItemByTitle, clickCategoryByNameExact, @@ -12,9 +13,13 @@ import { resetMockServers, saveSettings, test, + waitForStalkerCatalog, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; -import { fetchXtreamEpgFixture } from './portal-mock-fixtures'; +import { + fetchStalkerCategoryFixture, + fetchXtreamEpgFixture, +} from './portal-mock-fixtures'; const epgPortalName = 'Xtream EPG Fixture'; const epgCredentials = { @@ -222,6 +227,63 @@ test('@epg @xtream @electron keeps EPG context and actions at narrow channel-row } }); +test('@radio @stalker @electron keeps radio rows compact at narrow widths', async ({ + dataDir, + request, +}) => { + await resetMockServers(request, ['stalker']); + const fixture = await fetchStalkerCategoryFixture(request, 'radio'); + const [firstItem, secondItem] = fixture.items; + const firstTitle = firstItem?.o_name || firstItem?.name; + const secondTitle = secondItem?.o_name || secondItem?.name; + if (!firstTitle || !secondTitle) { + throw new Error( + 'Expected the Stalker radio fixture to include two named stations.' + ); + } + const app = await launchElectronApp(dataDir); + + try { + await addStalkerPortal(app.mainWindow, { + name: 'Stalker Radio Row Fixture', + }); + await waitForStalkerCatalog(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Radio'); + const categoryButton = app.mainWindow.getByRole('button', { + name: fixture.categoryName, + exact: true, + }); + await expect(categoryButton).toBeVisible(); + await categoryButton.click(); + + const radioRow = channelItemByTitle( + app.mainWindow, + firstTitle + ).first(); + await expect(radioRow).toBeVisible({ timeout: 20000 }); + await expect(radioRow).toHaveClass(/compact/); + await expect(radioRow.locator('.epg-placeholder')).toHaveCount(0); + await expect(radioRow.locator('.epg-title')).toHaveCount(0); + await expect(radioRow.locator('.epg-timeline')).toHaveCount(0); + await expectCompactRadioRowHeightAndStride(radioRow, secondTitle); + + await setStalkerChannelItemWidth(app.mainWindow, 232); + + await expect(radioRow.locator('.channel-logo-shell')).toBeVisible(); + await expect(radioRow.locator('.favorite-button')).toBeVisible(); + await expectCompactRadioRowHeightAndStride(radioRow, secondTitle); + + await setStalkerChannelItemWidth(app.mainWindow, 200); + + await expect(radioRow.locator('.channel-name')).toBeVisible(); + await expect(radioRow.locator('.channel-logo-shell')).toBeHidden(); + await expect(radioRow.locator('.favorite-button')).toBeHidden(); + await expectCompactRadioRowHeightAndStride(radioRow, secondTitle); + } finally { + await closeElectronApp(app); + } +}); + test('@epg @xtream @electron renders the vertical list view when the setting is "list"', async ({ dataDir, request, @@ -310,7 +372,32 @@ async function setPortalChannelItemWidth( .toBe(width); } +async function setStalkerChannelItemWidth( + page: Parameters[0], + width: number +) { + const channelItems = page.locator( + 'app-stalker-live-stream-layout app-channel-list-item' + ); + await channelItems.evaluateAll((elements, itemWidth) => { + for (const element of elements) { + (element as HTMLElement).style.width = `${itemWidth}px`; + } + }, width); + await expect + .poll(() => + channelItems + .first() + .evaluate((element) => + Math.round(element.getBoundingClientRect().width) + ) + ) + .toBe(width); +} + const portalChannelItemSize = 68; +const compactRadioItemSize = 52; +const stalkerChannelItemGap = 2; const rowGeometryTolerance = 0.25; const contentGeometryTolerance = 1; @@ -351,6 +438,41 @@ async function expectPortalRowHeightAndStride( ).toBeLessThanOrEqual(rowGeometryTolerance); } +async function expectCompactRadioRowHeightAndStride( + row: ReturnType, + expectedFollowingTitle: string +) { + const followingRow = row + .locator('xpath=ancestor::app-channel-list-item') + .locator('xpath=following-sibling::app-channel-list-item[1]') + .getByTestId('channel-item'); + await expect(followingRow.locator('.channel-name')).toHaveText( + expectedFollowingTitle + ); + + const [rowBox, followingRowBox] = await Promise.all([ + row.boundingBox(), + followingRow.boundingBox(), + ]); + if (!rowBox || !followingRowBox) { + throw new Error( + `Expected compact radio-row geometry before "${expectedFollowingTitle}".` + ); + } + + expect(Math.abs(rowBox.height - compactRadioItemSize)).toBeLessThanOrEqual( + rowGeometryTolerance + ); + expect( + Math.abs( + followingRowBox.y - + rowBox.y - + compactRadioItemSize - + stalkerChannelItemGap + ) + ).toBeLessThanOrEqual(rowGeometryTolerance); +} + async function expectTimelineStartAndProgressAligned( row: ReturnType ) { diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index a4d24adaf..b4631e696 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -138,17 +138,22 @@ The shared row should be reused instead of rebuilding channel markup per view. ### Responsive Information Priority - EPG-enabled, noncompact rows keep a fixed `68px` height that matches the - virtual-scroll stride. EPG-disabled, compact rows retain their existing - `52px` minimum height. + virtual-scroll stride. EPG-disabled, compact rows use a matching fixed `52px` + row and virtual-scroll size. - At `310px` and below, hide the end time while keeping the start time and progress bar. - At `270px` and below, hide the decorative logo while retaining program context and actions, and tighten horizontal padding to preserve the remaining content. - At `220px` and below, hide the start time while keeping the progress bar. -- Narrow width alone must not remove the channel name, program title or - no-program placeholder, progress bar, drag affordance when applicable, or - enabled actions. +- In EPG-preview rows, narrow width alone must not remove the channel name, + program title or no-program placeholder, progress bar, drag affordance when + applicable, or enabled actions. +- Radio consumers without EPG render the row as compact instead of showing a + false no-program placeholder. Compact rows keep the logo at `270px`, then + hide the logo and actions at `220px`. +- `isRadio` alone must not change row height inside a fixed-size mixed virtual + list; the consumer's `showEpg` state and virtual-scroll item size own density. - Loading skeletons mirror the same responsive hierarchy and row geometry. ### Logo Rules diff --git a/docs/superpowers/plans/2026-07-30-responsive-channel-epg-preview.md b/docs/superpowers/plans/2026-07-30-responsive-channel-epg-preview.md index 858dc30c3..273600f1d 100644 --- a/docs/superpowers/plans/2026-07-30-responsive-channel-epg-preview.md +++ b/docs/superpowers/plans/2026-07-30-responsive-channel-epg-preview.md @@ -245,7 +245,9 @@ Replace the current `max-width: 220px` action-hiding rule with: The end time is already hidden by the wider breakpoint. Channel name, current programme or placeholder, progress, drag affordance, and every enabled action remain available in EPG rows. The `.compact` branch deliberately preserves the -existing radio/non-EPG degradation. +existing non-EPG degradation; radio-only consumers without EPG opt into that +branch through `showEpg=false`, rather than changing mixed-list height through +`isRadio`. - [ ] **Step 4: Match skeleton degradation to the live row** diff --git a/docs/superpowers/specs/2026-07-29-responsive-channel-epg-preview-design.md b/docs/superpowers/specs/2026-07-29-responsive-channel-epg-preview-design.md index 9287973d5..3549d402c 100644 --- a/docs/superpowers/specs/2026-07-29-responsive-channel-epg-preview-design.md +++ b/docs/superpowers/specs/2026-07-29-responsive-channel-epg-preview-design.md @@ -54,8 +54,10 @@ loading content does not jump between incompatible layouts. and global channel lists. The contract therefore applies consistently wherever the host becomes narrow. This PR does not add provider-specific overrides. -Radio rows and compact rows without EPG keep their existing behavior. The -change does not add EPG data where a consumer currently disables it. +Compact rows without EPG keep their existing behavior. Radio-only consumers +that do not expose EPG mark their rows compact; `isRadio` alone never changes +height inside a fixed-size mixed virtual list. The change does not add EPG data +where a consumer currently disables it. ## Alternatives Considered diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index bd30b8c03..f4e33afdb 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -37,9 +37,15 @@ (sidebarToggleRequested)="toggleSidebar()" /> } @loading { - + } @placeholder { - + } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 5279ae96a..f0c7bba3b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -64,6 +64,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); }) class StubChannelListLoadingStateComponent { readonly view = input(null); + readonly showEpg = input(true); } @Component({ diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html index b686dc97d..a79a63c55 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html @@ -36,7 +36,8 @@ [logo]="ch.logo" [name]="ch.name" [selected]="activeUid() === ch.uid" - [showEpg]="true" + [showEpg]="showEpg() && ch.radio !== 'true'" + [isRadio]="ch.radio === 'true'" [epgProgram]="ch.currentEpgProgram" [progressPercentage]="ch.progressPercentage" [showFavoriteButton]="true" diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts index 3da53d630..a01b7075a 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts @@ -75,6 +75,33 @@ describe('GlobalFavoritesListComponent', () => { expect(icons).toEqual(['star_outline', 'star']); }); + it('renders radio rows as compact without a false EPG placeholder', () => { + fixture.componentRef.setInput('channels', [ + buildChannel('radio', 'Radio One', { radio: 'true' }), + ]); + fixture.detectChanges(); + + const row = fixture.nativeElement.querySelector('.channel-list-item'); + expect(row.classList.contains('compact')).toBe(true); + expect( + fixture.nativeElement.querySelector('.epg-placeholder') + ).toBeNull(); + }); + + it('renders non-radio rows as compact when the host disables EPG', () => { + fixture.componentRef.setInput('showEpg', false); + fixture.componentRef.setInput('channels', [ + buildChannel('pwa', 'PWA Channel'), + ]); + fixture.detectChanges(); + + const row = fixture.nativeElement.querySelector('.channel-list-item'); + expect(row.classList.contains('compact')).toBe(true); + expect( + fixture.nativeElement.querySelector('.epg-placeholder') + ).toBeNull(); + }); + it('preserves incoming recent order when a favorites sort mode is set', () => { fixture.componentRef.setInput('mode', 'recent'); fixture.componentRef.setInput('sortMode', 'name-asc'); diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts index e3e0c25d9..9fe42c7c0 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts @@ -20,7 +20,10 @@ import { ChannelDetailsDialogComponent, ChannelListItemComponent, } from '@iptvnator/ui/components'; -import { SettingsStore } from '@iptvnator/services'; +import { + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; import { buildStalkerEpgMappingKey, buildXtreamEpgMappingKey, @@ -61,6 +64,7 @@ export class GlobalFavoritesListComponent { private readonly dialog = inject(MatDialog); private readonly epgBridge = inject(EpgRuntimeBridgeService); readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping; + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); readonly contextMenuTrigger = @@ -71,6 +75,7 @@ export class GlobalFavoritesListComponent { readonly channels = input.required(); readonly mode = input('favorites'); + readonly showEpg = input(this.runtime.supportsEpg); readonly favoriteUids = input>(new Set()); readonly epgMap = input>(new Map()); readonly progressTick = input(0); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html index 8808dc868..8b2024bca 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html @@ -21,7 +21,7 @@
diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index 13b303ec1..0bfeb27dd 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -26,6 +26,7 @@ import { UnifiedRecentDataService, } from '@iptvnator/portal/shared/data-access'; import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { BehaviorSubject } from 'rxjs'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; import { UnifiedCollectionPageComponent } from './unified-collection-page.component'; @@ -287,6 +288,12 @@ describe('UnifiedCollectionPageComponent', () => { provide: WorkspaceViewCommandService, useValue: workspaceViewCommands, }, + { + provide: RuntimeCapabilitiesService, + useValue: { + supportsEpg: false, + }, + }, ], }) .overrideComponent(UnifiedCollectionPageComponent, { @@ -308,6 +315,21 @@ describe('UnifiedCollectionPageComponent', () => { fixture.componentRef.setInput('defaultScope', 'all'); }); + it('uses compact loading rows when the runtime has no EPG support', () => { + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '.channel-list-item-skeleton.compact' + ) + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector( + '.channel-list-item-skeleton:not(.compact)' + ) + ).toBeNull(); + }); + it('reloads favorites after playlist hydration completes', async () => { fixture.detectChanges(); await fixture.whenStable(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index 81df4588c..a03b82f7b 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -55,6 +55,7 @@ import { UnifiedFavoritesDataService, UnifiedRecentDataService, } from '@iptvnator/portal/shared/data-access'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state'; import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui'; import { UnifiedLiveTabComponent } from './unified-live-tab.component'; @@ -99,6 +100,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { private readonly favoritesData = inject(UnifiedFavoritesDataService); private readonly recentData = inject(UnifiedRecentDataService); private readonly dialogService = inject(DialogService); + private readonly runtime = inject(RuntimeCapabilitiesService); private readonly translate = inject(TranslateService); private readonly workspaceViewCommands = inject(WorkspaceViewCommandService); private readonly liveSidebarStateService = inject( @@ -144,6 +146,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { readonly selectedContentType = signal( this.historyCollectionViewState()?.selectedContentType ?? 'live' ); + readonly supportsEpg = this.runtime.supportsEpg; readonly selectedDetailItem = signal(null); readonly selectedDetailSeriesResume = signal( null diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 08714535e..df9ce8167 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -10,6 +10,7 @@ (); readonly mode = input<'favorites' | 'recent'>('favorites'); + readonly showEpg = input(true); readonly favoriteUids = input>(new Set()); readonly epgMap = input>(new Map()); readonly progressTick = input(0); @@ -347,6 +348,10 @@ describe('UnifiedLiveTabComponent', () => { expect(streamResolver.loadEpgForItems).not.toHaveBeenCalled(); expect(streamResolver.loadM3uProgramsForItem).not.toHaveBeenCalled(); + const list = fixture.debugElement.query( + By.directive(StubGlobalFavoritesListComponent) + ).componentInstance as StubGlobalFavoritesListComponent; + expect(list.showEpg()).toBe(false); expect( fixture.nativeElement.querySelector('app-web-player-view') ).not.toBeNull(); @@ -721,6 +726,8 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); + expect(component.channelsForList()[0].radio).toBe('true'); + await component.onChannelSelected(component.channelsForList()[0]); fixture.detectChanges(); await fixture.whenStable(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index ba0d5702f..4318c157a 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -277,6 +277,7 @@ export class UnifiedLiveTabComponent { playlistName: item.playlistName, streamUrl: item.streamUrl, m3uChannel: item.m3uChannel, + radio: item.radio, xtreamId: item.xtreamId, tvgId: item.tvgId, stalkerCmd: item.stalkerCmd, diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts index 438854125..12db6982e 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts @@ -34,6 +34,9 @@ export interface UnifiedFavoriteChannel { /** Full M3U channel metadata used by row context menu actions */ m3uChannel?: Channel; + /** Radio flag used to keep non-EPG audio rows compact */ + radio?: string; + /** Xtream numeric stream ID (Xtream channels only) */ xtreamId?: number; diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index f9780a282..7e53dea5b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -69,7 +69,7 @@ @if (isInitialChannelsLoading()) { } @else if (searchTerm() && visibleChannels().length === 0) { @@ -106,9 +106,13 @@ ) ?? 0) : 0 " + [showEpg]="supportsEpg && !isRadioMode()" + [isRadio]="isRadioMode()" [showFavoriteButton]="true" [showProgramInfoButton]="false" - [showDetailsContextMenu]="supportsEpgMapping" + [showDetailsContextMenu]=" + supportsEpgMapping && !isRadioMode() + " [isFavorite]=" favorites.get(normalizeStalkerEntityId(item.id)) ?? false 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 79a12e7a9..4515593ca 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 @@ -46,6 +46,8 @@ class StubChannelListItemComponent { readonly name = input(''); readonly logo = input(null); readonly selected = input(false); + readonly showEpg = input(true); + readonly isRadio = input(false); readonly epgProgram = input(null); readonly progressPercentage = input(0); readonly showFavoriteButton = input(false); @@ -367,7 +369,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { return Boolean(window.electron); }, get supportsEpgMapping() { - return false; + return Boolean(window.electron); }, }, }, @@ -501,6 +503,60 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).not.toBeNull(); expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull(); + const channelRows = fixture.debugElement.queryAll( + By.directive(StubChannelListItemComponent) + ); + expect(channelRows).not.toHaveLength(0); + expect( + channelRows.every( + (row) => + !( + row.componentInstance as StubChannelListItemComponent + ).showEpg() + ) + ).toBe(true); + expect( + channelRows.every( + (row) => + !( + row.componentInstance as StubChannelListItemComponent + ).showDetailsContextMenu() + ) + ).toBe(true); + }); + + it('renders radio channel rows with compact no-EPG density', () => { + stalkerStore.selectedContentType.set('radio'); + + fixture.detectChanges(); + + const channelRows = fixture.debugElement.queryAll( + By.directive(StubChannelListItemComponent) + ); + expect(channelRows).not.toHaveLength(0); + expect( + channelRows.every((row) => + ( + row.componentInstance as StubChannelListItemComponent + ).isRadio() + ) + ).toBe(true); + expect( + channelRows.every( + (row) => + !( + row.componentInstance as StubChannelListItemComponent + ).showEpg() + ) + ).toBe(true); + expect( + channelRows.every( + (row) => + !( + row.componentInstance as StubChannelListItemComponent + ).showDetailsContextMenu() + ) + ).toBe(true); }); it('restores the collapsed live EPG panel state after embedded playback starts', async () => { diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index 6271ec3e0..3f72bab30 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -17,7 +17,7 @@ } @else if (filteredChannels().length > 0) { { expect(component.currentProgramsProgress.get(50)).toBeCloseTo(50, 1); }); - it('does not derive or subscribe to row EPG previews in browser/PWA mode', () => { + it('does not derive or subscribe to row EPG previews in browser/PWA mode', async () => { Object.defineProperty(window, 'electron', { configurable: true, writable: true, @@ -285,6 +288,8 @@ describe('PortalChannelsListComponent', () => { fixture.destroy(); fixture = TestBed.createComponent(PortalChannelsListComponent); fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); epgResults$.next({ streamId: 50, @@ -302,8 +307,43 @@ describe('PortalChannelsListComponent', () => { const pwaComponent = fixture.componentInstance; expect(pwaComponent.supportsEpg).toBe(false); + expect(pwaComponent.channelItemSize).toBe(52); expect(pwaComponent.epgPrograms.size).toBe(0); expect(pwaComponent.currentProgramsProgress.size).toBe(0); + + const virtualScrollElement = fixture.debugElement.query( + By.css('cdk-virtual-scroll-viewport') + ); + if (!virtualScrollElement) { + throw new Error( + `Expected PWA channel viewport, received: ${fixture.nativeElement.innerHTML.slice(0, 1000)}` + ); + } + const virtualScroll = virtualScrollElement.injector.get( + CdkFixedSizeVirtualScroll + ); + Object.defineProperty( + virtualScrollElement.nativeElement, + 'clientHeight', + { + configurable: true, + value: 520, + } + ); + pwaComponent.viewport()?.checkViewportSize(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const channelRow = fixture.debugElement + .query(By.directive(ChannelListItemComponent)) + .injector.get(ChannelListItemComponent); + expect(virtualScroll.itemSize).toBe(52); + expect(channelRow.showEpg()).toBe(false); + expect( + fixture.nativeElement + .querySelector('.channel-list-item') + .classList.contains('compact') + ).toBe(true); }); it('does not mark a live item as favorite when only a colliding movie ID is favorited', () => { diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 8a2076a32..a85ab35af 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -99,6 +99,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { readonly contextMenuPosition = signal({ x: '0px', y: '0px' }); readonly supportsEpg = this.runtime.supportsEpg; readonly supportsEpgMapping = this.runtime.supportsEpgMapping; + readonly channelItemSize = this.supportsEpg ? 68 : 52; readonly isSelectedTypeContentLoading = this.xtreamStore.selectedTypeContentLoading; readonly channels = computed(() => { diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts index f7ecacaf4..4ce8801ee 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.spec.ts @@ -81,7 +81,7 @@ describe('AllChannelsViewComponent', () => { ); fixture.componentRef.setInput('progressTick', 0); fixture.componentRef.setInput('shouldShowEpg', false); - fixture.componentRef.setInput('itemSize', 48); + fixture.componentRef.setInput('itemSize', 52); fixture.componentRef.setInput('favoriteIds', new Set()); fixture.detectChanges(); }); @@ -111,7 +111,7 @@ describe('AllChannelsViewComponent', () => { ); fixture.componentRef.setInput('progressTick', 0); fixture.componentRef.setInput('shouldShowEpg', false); - fixture.componentRef.setInput('itemSize', 48); + fixture.componentRef.setInput('itemSize', 52); fixture.componentRef.setInput('favoriteIds', new Set()); fixture.detectChanges(); @@ -132,7 +132,7 @@ describe('AllChannelsViewComponent', () => { ); fixture.componentRef.setInput('progressTick', 0); fixture.componentRef.setInput('shouldShowEpg', false); - fixture.componentRef.setInput('itemSize', 48); + fixture.componentRef.setInput('itemSize', 52); fixture.componentRef.setInput('favoriteIds', new Set()); fixture.detectChanges(); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 437dd388d..d6be05b29 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -1,5 +1,8 @@ @if (channelsLoading()) { - + } @else if (displayedChannels().length) { @if (viewTitle() && activeView() !== 'groups' && activeView() !== 'all') {
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts index 8c423a128..913695bcb 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts @@ -209,6 +209,7 @@ describe('ChannelListContainerComponent', () => { fixture.detectChanges(); expect(fixture.componentInstance.shouldShowEpg()).toBe(false); + expect(fixture.componentInstance.itemSize()).toBe(52); expect(storageGet).not.toHaveBeenCalled(); }); @@ -222,6 +223,7 @@ describe('ChannelListContainerComponent', () => { expect(storageGet).toHaveBeenCalled(); expect(fixture.componentInstance.shouldShowEpg()).toBe(true); + expect(fixture.componentInstance.itemSize()).toBe(68); }); it('enables EPG rows and scopes lookups when the active M3U playlist has detected EPG URLs', () => { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index d36f2aa81..dbaec930d 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -178,7 +178,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { ); /** Item size for virtual scroll - compact when no EPG */ - readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48)); + readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 52)); /** Active view (all, groups, favorites, recent) */ readonly activeView = input('all'); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss index 7f3d5226f..5b9dc714c 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss @@ -20,8 +20,9 @@ overflow: hidden; &.compact { + height: 52px; min-height: 52px; - padding-block: 7px; + padding-block: 6px; } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss index 104613872..75c1faed8 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss @@ -44,8 +44,9 @@ } &.compact { + height: 52px; min-height: 52px; - padding-block: 7px; + padding-block: 6px; } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts index 733e0e2ab..9f1c3cfbc 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts @@ -78,6 +78,24 @@ describe('ChannelListItemComponent', () => { expect( fixture.nativeElement.querySelector('.epg-placeholder') ).toBeNull(); + expect( + fixture.nativeElement + .querySelector('.channel-list-item') + .classList.contains('compact') + ).toBe(false); + }); + + it('uses compact density when a radio consumer disables EPG', () => { + fixture.componentRef.setInput('name', 'Radio One'); + fixture.componentRef.setInput('showEpg', false); + fixture.componentRef.setInput('isRadio', true); + fixture.detectChanges(); + + expect( + fixture.nativeElement + .querySelector('.channel-list-item') + .classList.contains('compact') + ).toBe(true); }); it('shows the generic fallback icon when no logo is available', () => { diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index da7f066c9..69365f082 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -1,4 +1,6 @@ +import { CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatDialog } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; @@ -213,6 +215,26 @@ describe('GroupsViewComponent', () => { expect(component.groupChannelSortLabel()).toBe('Playlist Order'); }); + it('keeps the virtual-scroll item size aligned with row EPG density', () => { + setInputs({ shouldShowEpg: false }); + + expect(component.itemSize()).toBe(52); + expect( + fixture.debugElement + .query(By.css('cdk-virtual-scroll-viewport')) + .injector.get(CdkFixedSizeVirtualScroll).itemSize + ).toBe(52); + + setInputs({ shouldShowEpg: true }); + + expect(component.itemSize()).toBe(68); + expect( + fixture.debugElement + .query(By.css('cdk-virtual-scroll-viewport')) + .injector.get(CdkFixedSizeVirtualScroll).itemSize + ).toBe(68); + }); + it('restores a saved valid sort mode and ignores invalid stored values', () => { fixture.destroy(); localStorage.setItem(GROUP_CHANNEL_SORT_STORAGE_KEY, 'name-asc'); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index eea1018d8..596a0f361 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -142,7 +142,7 @@ export class GroupsViewComponent { this.searchTerm().trim().length > 0 || this.localGroupSearchTerm().trim().length > 0 ); - readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48)); + readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 52)); readonly contextMenuChannel = signal(null); readonly contextMenuPosition = signal({ x: '0px', diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html index 1ce0fe8c3..676655433 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html @@ -42,7 +42,7 @@
@@ -59,7 +59,7 @@
diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts index b223d283b..bb41aa6fd 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts @@ -31,6 +31,22 @@ describe('ChannelListLoadingStateComponent', () => { ).toBeNull(); }); + it('renders compact skeleton rows when the host disables EPG', () => { + fixture.componentRef.setInput('showEpg', false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '.channel-list-item-skeleton.compact' + ) + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelector( + '.channel-list-item-skeleton:not(.compact)' + ) + ).toBeNull(); + }); + it('renders a two-column group loading layout for the groups view', () => { fixture.componentRef.setInput('view', 'groups'); fixture.detectChanges(); diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts index a65429ae4..9d3f83600 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts @@ -16,6 +16,7 @@ import { ChannelListSkeletonComponent } from '../channel-list-container/channel- }) export class ChannelListLoadingStateComponent { readonly view = input('all'); + readonly showEpg = input(true); readonly isGroupsView = computed(() => this.view() === 'groups'); readonly groupRows = Array.from({ length: 10 }, (_, index) => index);