fix(ui): align compact channel row consumers

This commit is contained in:
4gray committed 2026-07-30 02:20:22 +02:00
1 parent 13f76fceb1
commit e243a44c6b
34 files changed
+404 -30

No files matched your search

@@ -203,7 +203,7 @@ export async function fetchXtreamEpgFixture(
export async function fetchStalkerCategoryFixture(
request: APIRequestContext,
type: 'itv' | 'series' | 'vod'
type: 'itv' | 'radio' | 'series' | 'vod'
): Promise<StalkerCategoryFixture> {
const categoriesResponse = await fetchJson<
StalkerProxyPayload<StalkerCategory[]>
+123 -1
View File
@@ -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<typeof channelItemByTitle>[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<typeof channelItemByTitle>,
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<typeof channelItemByTitle>
) {
+10 -5
View File
@@ -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
@@ -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**
@@ -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
@@ -37,9 +37,15 @@
(sidebarToggleRequested)="toggleSidebar()"
/>
} @loading {
<app-channel-list-loading-state [view]="activeView()" />
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
/>
} @placeholder {
<app-channel-list-loading-state [view]="activeView()" />
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
/>
}
</div>
@@ -64,6 +64,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
})
class StubChannelListLoadingStateComponent {
readonly view = input<string | null>(null);
readonly showEpg = input(true);
}
@Component({
@@ -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"
@@ -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');
@@ -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<UnifiedFavoriteChannel[]>();
readonly mode = input<GlobalFavoritesListMode>('favorites');
readonly showEpg = input(this.runtime.supportsEpg);
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = input<number>(0);
@@ -21,7 +21,7 @@
<div class="skeleton-list">
<app-channel-list-skeleton
[count]="12"
[showEpg]="true"
[showEpg]="supportsEpg"
[actionCount]="1"
/>
</div>
@@ -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();
@@ -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<CollectionContentType>(
this.historyCollectionViewState()?.selectedContentType ?? 'live'
);
readonly supportsEpg = this.runtime.supportsEpg;
readonly selectedDetailItem = signal<UnifiedCollectionItem | null>(null);
readonly selectedDetailSeriesResume = signal<SeriesResumeTarget | null>(
null
@@ -10,6 +10,7 @@
<app-global-favorites-list
[channels]="channelsForList()"
[mode]="mode()"
[showEpg]="supportsEpg"
[favoriteUids]="favoriteUids()"
[epgMap]="epgMap()"
[progressTick]="progressTick()"
@@ -65,6 +65,7 @@ class StubResizableDirective {
class StubGlobalFavoritesListComponent {
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly showEpg = input(true);
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
readonly epgMap = input<Map<string, EpgProgram | null>>(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();
@@ -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,
@@ -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;
@@ -69,7 +69,7 @@
@if (isInitialChannelsLoading()) {
<app-channel-list-skeleton
[count]="9"
[showEpg]="false"
[showEpg]="supportsEpg && !isRadioMode()"
[actionCount]="1"
/>
} @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
@@ -46,6 +46,8 @@ class StubChannelListItemComponent {
readonly name = input('');
readonly logo = input<string | null | undefined>(null);
readonly selected = input(false);
readonly showEpg = input(true);
readonly isRadio = input(false);
readonly epgProgram = input<unknown>(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 () => {
@@ -17,7 +17,7 @@
</div>
} @else if (filteredChannels().length > 0) {
<cdk-virtual-scroll-viewport
[itemSize]="68"
[itemSize]="channelItemSize"
class="scroll-viewport-portals"
>
<app-channel-list-item
@@ -41,6 +41,7 @@
? (currentProgramsProgress.get(item.xtream_id) ?? 0)
: 0
"
[showEpg]="supportsEpg"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[showDetailsContextMenu]="supportsEpgMapping"
@@ -1,5 +1,7 @@
import { CdkFixedSizeVirtualScroll } from '@angular/cdk/scrolling';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
@@ -11,6 +13,7 @@ import {
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { ChannelListItemComponent } from '@iptvnator/ui/components';
import { PortalChannelsListComponent } from './portal-channels-list.component';
function buildEpgItem(params: {
@@ -257,7 +260,7 @@ describe('PortalChannelsListComponent', () => {
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', () => {
@@ -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(() => {
@@ -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<string>());
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<string>());
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<string>());
fixture.detectChanges();
@@ -1,5 +1,8 @@
@if (channelsLoading()) {
<app-channel-list-loading-state [view]="activeView()" />
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="shouldShowEpg()"
/>
} @else if (displayedChannels().length) {
@if (viewTitle() && activeView() !== 'groups' && activeView() !== 'all') {
<header class="context-header">
@@ -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', () => {
@@ -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<string>('all');
@@ -20,8 +20,9 @@
overflow: hidden;
&.compact {
height: 52px;
min-height: 52px;
padding-block: 7px;
padding-block: 6px;
}
}
@@ -44,8 +44,9 @@
}
&.compact {
height: 52px;
min-height: 52px;
padding-block: 7px;
padding-block: 6px;
}
}
@@ -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', () => {
@@ -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');
@@ -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<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
@@ -42,7 +42,7 @@
<div class="groups-loading-content__list">
<app-channel-list-skeleton
[count]="9"
[showEpg]="true"
[showEpg]="showEpg()"
[actionCount]="2"
/>
</div>
@@ -59,7 +59,7 @@
<div class="channels-loading-state__list">
<app-channel-list-skeleton
[count]="9"
[showEpg]="true"
[showEpg]="showEpg()"
[actionCount]="2"
/>
</div>
@@ -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();
@@ -16,6 +16,7 @@ import { ChannelListSkeletonComponent } from '../channel-list-container/channel-
})
export class ChannelListLoadingStateComponent {
readonly view = input<string>('all');
readonly showEpg = input(true);
readonly isGroupsView = computed(() => this.view() === 'groups');
readonly groupRows = Array.from({ length: 10 }, (_, index) => index);