mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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({
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+27
@@ -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');
|
||||
|
||||
+6
-1
@@ -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);
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
<div class="skeleton-list">
|
||||
<app-channel-list-skeleton
|
||||
[count]="12"
|
||||
[showEpg]="true"
|
||||
[showEpg]="supportsEpg"
|
||||
[actionCount]="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+22
@@ -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();
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+1
@@ -10,6 +10,7 @@
|
||||
<app-global-favorites-list
|
||||
[channels]="channelsForList()"
|
||||
[mode]="mode()"
|
||||
[showEpg]="supportsEpg"
|
||||
[favoriteUids]="favoriteUids()"
|
||||
[epgMap]="epgMap()"
|
||||
[progressTick]="progressTick()"
|
||||
|
||||
+7
@@ -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();
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
+57
-1
@@ -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 () => {
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+41
-1
@@ -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', () => {
|
||||
|
||||
+1
@@ -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(() => {
|
||||
|
||||
+3
-3
@@ -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();
|
||||
|
||||
|
||||
+4
-1
@@ -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">
|
||||
|
||||
+2
@@ -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', () => {
|
||||
|
||||
+1
-1
@@ -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');
|
||||
|
||||
+21
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
-17
@@ -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;
|
||||
}
|
||||
}
|
||||
+18
@@ -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', () => {
|
||||
|
||||
+22
@@ -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');
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+16
@@ -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();
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user