fix(ui): preserve EPG in narrow channel rows (#1312)

Preserve current-program context and enabled actions in narrow channel rows while aligning loaded rows, skeletons, and virtual-scroll geometry across M3U, Xtream, Stalker, Favorites, and Recent views.
This commit is contained in:
4gray authored and GitHub committed 2026-07-31 08:27:36 +02:00
1 parent 2ac0de752f
commit 46c7713841
37 files changed
+1414 -53

No files matched your search

@@ -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,11 +20,16 @@
overflow: hidden;
&.compact {
height: 52px;
min-height: 52px;
padding-block: 7px;
padding-block: 6px;
}
}
.channel-list-item-skeleton:not(.compact) {
height: 68px;
}
.channel-content-skeleton {
min-width: 0;
flex: 1;
@@ -104,29 +109,35 @@
@container (max-width: 270px) {
.channel-list-item-skeleton {
min-height: 52px;
gap: 8px;
padding-inline: 8px 6px;
}
.channel-logo-skeleton {
width: 34px;
height: 34px;
.channel-list-item-skeleton:not(.compact) {
padding-block: 7px;
}
.epg-title-skeleton,
.epg-progress-skeleton {
.channel-list-item-skeleton:not(.compact) .channel-logo-skeleton {
display: none;
}
.channel-list-item-skeleton:not(.compact) .channel-content-skeleton {
gap: 0;
}
.channel-list-item-skeleton.compact .channel-logo-skeleton {
width: 34px;
height: 34px;
}
}
@container (max-width: 220px) {
.channel-logo-skeleton,
.action-buttons-skeleton {
.channel-list-item-skeleton.compact .channel-logo-skeleton,
.channel-list-item-skeleton.compact .action-buttons-skeleton {
display: none;
}
.channel-content-skeleton {
.channel-list-item-skeleton.compact .channel-content-skeleton {
gap: 0;
}
}
@@ -44,11 +44,16 @@
}
&.compact {
height: 52px;
min-height: 52px;
padding-block: 7px;
padding-block: 6px;
}
}
.channel-list-item:not(.compact) {
height: 68px;
}
.drag-icon {
flex-shrink: 0;
cursor: move;
@@ -73,7 +78,11 @@
place-items: center;
overflow: hidden;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0)),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.12),
rgba(255, 255, 255, 0)
),
rgba(127, 127, 127, 0.12);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
@@ -180,7 +189,8 @@
rgba(255, 255, 255, 0)
);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--app-selection-border) 82%, transparent),
0 0 0 1px
color-mix(in srgb, var(--app-selection-border) 82%, transparent),
0 0 10px -2px var(--app-selection-glow);
transition: width 0.35s ease;
}
@@ -260,7 +270,7 @@
@container (max-width: 310px) {
.epg-timeline {
grid-template-columns: 1fr;
grid-template-columns: auto minmax(24px, 1fr);
gap: 5px;
}
@@ -271,35 +281,48 @@
@container (max-width: 270px) {
.channel-list-item {
min-height: 52px;
gap: 8px;
padding-inline: 8px 6px;
}
.channel-logo-shell {
width: 34px;
height: 34px;
.channel-list-item:not(.compact) {
padding-block: 7px;
}
.epg-title,
.epg-timeline,
.epg-placeholder,
.program-info-button {
.channel-list-item:not(.compact) .channel-logo-shell {
display: none;
}
.channel-list-item:not(.compact) .channel-content {
gap: 0;
}
.channel-list-item.compact .channel-logo-shell {
width: 34px;
height: 34px;
}
}
@container (max-width: 220px) {
.channel-logo-shell,
.action-buttons {
.channel-list-item:not(.compact) .epg-timeline {
grid-template-columns: minmax(24px, 1fr);
}
.channel-list-item:not(.compact) .epg-time:first-child {
display: none;
}
.channel-content {
.channel-list-item.compact .channel-logo-shell,
.channel-list-item.compact .action-buttons {
display: none;
}
.channel-list-item.compact .channel-content {
gap: 0;
}
}
.favorite-button, .program-info-button {
.favorite-button,
.program-info-button {
padding: 0;
}
}
@@ -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);