diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html
index 699034b24..be76f4949 100644
--- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html
+++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html
@@ -1,97 +1,114 @@
-
- @if (searchTerm() && (filteredGroupedChannels() | keyvalue).length === 0) {
-
-
search_off
-
- {{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }}
-
-
- {{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
-
+@if (filteredGroups().length === 0) {
+
+
{{
+ searchTerm() ? 'search_off' : 'folder_off'
+ }}
+
+ {{
+ (searchTerm()
+ ? 'CHANNELS.NO_SEARCH_RESULTS'
+ : 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
+ ) | translate
+ }}
- } @else {
-
- @for (
- groups of filteredGroupedChannels() | keyvalue: groupsComparator;
- track $index
- ) {
- @if (groups.value.length > 0) {
-
-
- {{
- groups.key || ('CHANNELS.UNGROUPED' | translate)
- | titlecase
- }}
- ({{ groups.value.length }})
-
+
+ {{
+ (searchTerm()
+ ? 'CHANNELS.TRY_DIFFERENT_SEARCH'
+ : 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION'
+ ) | translate
+ }}
+
+
+} @else {
+
+
+
+
+
+
+}
diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss
index b5fca9076..beb13bd0b 100644
--- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss
+++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss
@@ -1,58 +1,165 @@
+@use '../../../../../styles/portal-sidebar';
+@use '../../../../../../../apps/web/src/nav-list.scss';
+
:host {
display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
-}
-
-#groups-list {
flex: 1;
min-height: 0;
- overflow-x: hidden;
- overflow-y: auto;
+ overflow: hidden;
}
-.channel-list {
- overflow-x: hidden;
-}
-
-::ng-deep {
- .mat-expansion-panel-body {
- padding: 0 !important;
- }
-
- .mat-expansion-panel {
- border-radius: 0 !important;
- }
-}
-
-.scroll-sentinel {
- padding: 16px;
+.groups-view-layout {
display: flex;
- justify-content: center;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ background: var(--app-content-bg, var(--mat-sys-surface));
}
-.loading-more {
+.groups-nav-panel {
+ width: 208px;
+ min-width: 164px;
+ max-width: 320px;
+ flex-shrink: 0;
+ min-height: 0;
display: flex;
- align-items: center;
+ overflow: hidden;
+ background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
+ padding: 12px 10px 10px 12px;
+ box-sizing: border-box;
+ container-type: inline-size;
+}
+
+.groups-nav-shell {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.groups-nav-header {
+ padding: 4px 6px 12px;
+ flex-shrink: 0;
+}
+
+.groups-nav-title {
+ margin: 0;
+ font-size: 0.94rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--mat-sys-on-surface);
+}
+
+.category-title {
+ margin: 0;
+ font-size: 0.94rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--mat-sys-on-surface);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.category-count {
+ flex-shrink: 0;
+ font-size: 0.72rem;
+ font-weight: 500;
+ color: var(--mat-sys-on-surface-variant);
+ background: var(--mat-sys-surface-container);
+ padding: 2px 8px;
+ border-radius: 999px;
+}
+
+.category-heading {
+ display: flex;
+ align-items: baseline;
gap: 8px;
- color: var(--mdc-theme-primary, #90caf9);
- font-size: 0.875rem;
- opacity: 0.8;
+ flex: 1;
+ min-width: 0;
}
-.loading-more-spinner {
- font-size: 20px;
- width: 20px;
- height: 20px;
- animation: spin 1.5s linear infinite;
+.groups-nav-list {
+ height: 100%;
+ min-height: 0;
+ padding-top: 0;
+ padding-inline: 0 2px;
}
-@keyframes spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
+.group-nav-item {
+ background: transparent;
+}
+
+.groups-content-panel {
+ width: auto;
+ min-width: 0;
+ max-width: none;
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border-right: none;
+ background: var(--app-content-bg, var(--mat-sys-surface));
+ border-top-left-radius: 16px;
+}
+
+.groups-content-panel > .groups-content-header {
+ border-bottom: none;
+ border-top-left-radius: inherit;
+}
+
+.groups-channels-viewport {
+ flex: 1;
+ min-height: 0;
+ width: 100%;
+ padding: 4px 8px 12px;
+ box-sizing: border-box;
+
+ ::ng-deep .cdk-virtual-scroll-content-wrapper {
+ width: 100% !important;
+ max-width: 100% !important;
+ }
+}
+
+.groups-view-empty-state {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 40px 24px;
+ text-align: center;
+}
+
+.empty-state-icon {
+ width: 48px;
+ height: 48px;
+ font-size: 48px;
+ opacity: 0.5;
+ margin-bottom: 16px;
+}
+
+.empty-state-title {
+ font-size: 1.05rem;
+ font-weight: 520;
+ letter-spacing: -0.01em;
+ margin-bottom: 8px;
+}
+
+.empty-state-hint {
+ opacity: 0.65;
+ font-size: 0.85rem;
+ max-width: 240px;
+ line-height: 1.4;
+}
+
+@container (max-width: 220px) {
+ .groups-nav-list {
+ padding-inline-end: 0;
}
}
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
new file mode 100644
index 000000000..e0d3af919
--- /dev/null
+++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts
@@ -0,0 +1,276 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { MatDialog } from '@angular/material/dialog';
+import { TranslateModule } from '@ngx-translate/core';
+import { Channel } from 'shared-interfaces';
+import { GroupsViewComponent } from './groups-view.component';
+
+function createChannel(
+ id: string,
+ name: string,
+ url: string,
+ groupTitle: string
+): Channel {
+ return {
+ epgParams: '',
+ group: {
+ title: groupTitle,
+ },
+ http: {
+ origin: '',
+ referrer: '',
+ 'user-agent': '',
+ },
+ id,
+ name,
+ radio: 'false',
+ tvg: {
+ id: `${id}-tvg`,
+ logo: '',
+ name,
+ rec: '',
+ url: '',
+ },
+ url,
+ } as Channel;
+}
+
+describe('GroupsViewComponent', () => {
+ let fixture: ComponentFixture
;
+ let component: GroupsViewComponent;
+
+ const sportsCenter = createChannel(
+ 'sports-1',
+ 'Sports Center',
+ 'http://example.com/sports-center.m3u8',
+ 'Sports'
+ );
+ const matchNight = createChannel(
+ 'sports-2',
+ 'Match Night',
+ 'http://example.com/match-night.m3u8',
+ 'Sports'
+ );
+ const worldUpdate = createChannel(
+ 'news-1',
+ 'World Update',
+ 'http://example.com/world-update.m3u8',
+ 'News'
+ );
+ const dailyBulletin = createChannel(
+ 'news-2',
+ 'Daily Bulletin',
+ 'http://example.com/daily-bulletin.m3u8',
+ 'News'
+ );
+ const movieClassic = createChannel(
+ 'movies-1',
+ 'Movie Classic',
+ 'http://example.com/movie-classic.m3u8',
+ 'Movies'
+ );
+ const scienceNow = createChannel(
+ 'science-1',
+ 'Science Now',
+ 'http://example.com/science-now.m3u8',
+ 'Series'
+ );
+
+ const groupedChannels: Record = {
+ Movies: [movieClassic],
+ News: [worldUpdate, dailyBulletin],
+ Series: [scienceNow],
+ Sports: [sportsCenter, matchNight],
+ };
+
+ beforeAll(() => {
+ Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
+ configurable: true,
+ value: jest.fn(),
+ writable: true,
+ });
+ });
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ GroupsViewComponent,
+ NoopAnimationsModule,
+ TranslateModule.forRoot(),
+ ],
+ providers: [
+ {
+ provide: MatDialog,
+ useValue: {
+ open: jest.fn(),
+ },
+ },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(GroupsViewComponent);
+ component = fixture.componentInstance;
+
+ setInputs();
+ });
+
+ function setInputs(
+ overrides: Partial<{
+ activeChannelUrl: string | undefined;
+ favoriteIds: Set;
+ groupedChannels: Record;
+ progressTick: number;
+ searchTerm: string;
+ shouldShowEpg: boolean;
+ }> = {}
+ ): void {
+ fixture.componentRef.setInput(
+ 'groupedChannels',
+ overrides.groupedChannels ?? groupedChannels
+ );
+ fixture.componentRef.setInput('searchTerm', overrides.searchTerm ?? '');
+ fixture.componentRef.setInput(
+ 'channelEpgMap',
+ new Map()
+ );
+ fixture.componentRef.setInput(
+ 'progressTick',
+ overrides.progressTick ?? 0
+ );
+ fixture.componentRef.setInput(
+ 'shouldShowEpg',
+ overrides.shouldShowEpg ?? true
+ );
+ fixture.componentRef.setInput(
+ 'activeChannelUrl',
+ overrides.activeChannelUrl
+ );
+ fixture.componentRef.setInput(
+ 'favoriteIds',
+ overrides.favoriteIds ?? new Set()
+ );
+ fixture.detectChanges();
+ }
+
+ it('sorts groups with numeric buckets before alphabetic buckets', () => {
+ setInputs({
+ groupedChannels: {
+ 'Group 10': [sportsCenter],
+ 'Group 2': [movieClassic],
+ Alpha: [scienceNow],
+ },
+ });
+
+ expect(component.filteredGroups().map((group) => group.key)).toEqual([
+ 'Group 2',
+ 'Group 10',
+ 'Alpha',
+ ]);
+ });
+
+ it('prefers the active channel group for initial selection', () => {
+ setInputs({ activeChannelUrl: worldUpdate.url });
+
+ expect(component.selectedGroupKey()).toBe('News');
+ expect(component.selectedGroup()?.key).toBe('News');
+ });
+
+ it('retains a visible manual selection and falls back to the first visible group', () => {
+ component.selectGroup('Movies');
+ fixture.detectChanges();
+
+ setInputs({
+ activeChannelUrl: sportsCenter.url,
+ searchTerm: 'movie',
+ });
+ expect(component.selectedGroupKey()).toBe('Movies');
+
+ setInputs({
+ activeChannelUrl: sportsCenter.url,
+ searchTerm: 'science',
+ });
+ expect(component.selectedGroupKey()).toBe('Series');
+ });
+
+ it('switches selection to the active channel group when playback changes', () => {
+ component.selectGroup('Movies');
+ fixture.detectChanges();
+
+ setInputs({ activeChannelUrl: sportsCenter.url });
+ expect(component.selectedGroupKey()).toBe('Sports');
+
+ setInputs({ activeChannelUrl: scienceNow.url });
+ expect(component.selectedGroupKey()).toBe('Series');
+ });
+
+ it('matches group titles as full-group results and channel names as filtered results', () => {
+ setInputs({ searchTerm: 'news' });
+
+ expect(component.filteredGroups()).toEqual([
+ expect.objectContaining({
+ count: 2,
+ key: 'News',
+ titleMatches: true,
+ }),
+ ]);
+ expect(component.selectedGroupChannels().map((channel) => channel.name)).toEqual([
+ 'World Update',
+ 'Daily Bulletin',
+ ]);
+
+ setInputs({ searchTerm: 'update' });
+
+ expect(component.filteredGroups()).toEqual([
+ expect.objectContaining({
+ count: 1,
+ key: 'News',
+ titleMatches: false,
+ }),
+ ]);
+ expect(component.selectedGroupChannels().map((channel) => channel.name)).toEqual([
+ 'World Update',
+ ]);
+ });
+
+ it('emits channel and favorite events from the selected group pane', () => {
+ const channelSelected = jest.fn();
+ const favoriteToggled = jest.fn();
+ const clickEvent = new MouseEvent('click');
+
+ component.channelSelected.subscribe(channelSelected);
+ component.favoriteToggled.subscribe(favoriteToggled);
+
+ component.onChannelClick(movieClassic);
+ component.onFavoriteToggle(movieClassic, clickEvent);
+
+ expect(channelSelected).toHaveBeenCalledWith(movieClassic);
+ expect(favoriteToggled).toHaveBeenCalledWith({
+ channel: movieClassic,
+ event: clickEvent,
+ });
+ });
+
+ it('renders the no-results state for searches without matches', () => {
+ setInputs({ searchTerm: 'zzz' });
+
+ const emptyState = fixture.nativeElement.querySelector(
+ '.groups-view-empty-state'
+ ) as HTMLElement | null;
+
+ expect(emptyState).not.toBeNull();
+ expect(emptyState?.textContent).toContain('CHANNELS.NO_SEARCH_RESULTS');
+ });
+
+ it('renders the empty-category state when no grouped channels exist', () => {
+ setInputs({ groupedChannels: {} });
+
+ const emptyState = fixture.nativeElement.querySelector(
+ '.groups-view-empty-state'
+ ) as HTMLElement | null;
+
+ expect(emptyState).not.toBeNull();
+ expect(emptyState?.textContent).toContain(
+ 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
+ );
+ });
+});
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 93f44ddcb..2a06319b7 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
@@ -1,23 +1,29 @@
-import { KeyValue, KeyValuePipe, TitleCasePipe } from '@angular/common';
+import { KeyValue, TitleCasePipe } from '@angular/common';
import {
- AfterViewInit,
ChangeDetectionStrategy,
- ChangeDetectorRef,
Component,
+ ElementRef,
computed,
+ effect,
inject,
input,
- NgZone,
- OnDestroy,
output,
signal,
} from '@angular/core';
-import { MatExpansionModule } from '@angular/material/expansion';
+import { ScrollingModule } from '@angular/cdk/scrolling';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { Channel, EpgProgram } from 'shared-interfaces';
import { EnrichedChannel } from '../all-channels-view/all-channels-view.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
+import { ResizableDirective } from '../../resizable/resizable.directive';
+
+interface FilteredGroupView {
+ readonly channels: Channel[];
+ readonly count: number;
+ readonly key: string;
+ readonly titleMatches: boolean;
+}
@Component({
selector: 'app-groups-view',
@@ -26,16 +32,15 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelListItemComponent,
- KeyValuePipe,
- MatExpansionModule,
MatIconModule,
+ ResizableDirective,
+ ScrollingModule,
TitleCasePipe,
TranslatePipe,
],
})
-export class GroupsViewComponent implements AfterViewInit, OnDestroy {
- private readonly cdr = inject(ChangeDetectorRef);
- private readonly ngZone = inject(NgZone);
+export class GroupsViewComponent {
+ private readonly hostEl = inject(ElementRef);
/** Grouped channels object */
readonly groupedChannels = input.required<{ [key: string]: Channel[] }>();
@@ -65,187 +70,160 @@ export class GroupsViewComponent implements AfterViewInit, OnDestroy {
event: MouseEvent;
}>();
- /** IntersectionObserver for infinite scroll in groups */
- private groupScrollObserver?: IntersectionObserver;
+ readonly selectedGroupKey = signal(null);
+ readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
- /** Track observed sentinel elements */
- private observedSentinels = new Set();
+ private previousActiveChannelUrl: string | undefined;
- /** Track expanded groups with their load limits for lazy-loading */
- readonly expandedGroupLimits = signal(new Map());
+ constructor() {
+ effect(() => {
+ const filteredGroups = this.filteredGroups();
+ const visibleGroupKeys = new Set(filteredGroups.map((group) => group.key));
+ const currentSelection = this.selectedGroupKey();
+ const activeGroupKey = this.activeChannelGroupKey();
+ const activeChannelUrl = this.activeChannelUrl();
+ const activeChannelChanged =
+ activeChannelUrl !== this.previousActiveChannelUrl;
- /** Default number of channels to show when group is expanded */
- private readonly DEFAULT_GROUP_LIMIT = 50;
+ this.previousActiveChannelUrl = activeChannelUrl;
- ngAfterViewInit(): void {
- // Set up IntersectionObserver for infinite scroll in groups
- this.groupScrollObserver = new IntersectionObserver(
- (entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting) {
- const element = entry.target as HTMLElement;
- const groupKey = element.dataset['groupKey'];
- const totalInGroup = parseInt(
- element.dataset['totalInGroup'] || '0',
- 10
- );
+ let nextSelection: string | null = null;
- if (groupKey) {
- // Run inside NgZone to trigger change detection
- this.ngZone.run(() => {
- this.loadMoreInGroup(groupKey, totalInGroup);
- });
- }
- }
- });
- },
- {
- root: null, // Use viewport
- rootMargin: '100px', // Load more before reaching the bottom
- threshold: 0.1,
+ if (
+ activeChannelChanged &&
+ activeGroupKey &&
+ visibleGroupKeys.has(activeGroupKey)
+ ) {
+ nextSelection = activeGroupKey;
+ } else if (
+ currentSelection &&
+ visibleGroupKeys.has(currentSelection)
+ ) {
+ nextSelection = currentSelection;
+ } else if (
+ activeGroupKey &&
+ visibleGroupKeys.has(activeGroupKey)
+ ) {
+ nextSelection = activeGroupKey;
+ } else {
+ nextSelection = filteredGroups[0]?.key ?? null;
}
- );
- }
- ngOnDestroy(): void {
- // Clean up IntersectionObserver
- if (this.groupScrollObserver) {
- this.groupScrollObserver.disconnect();
- this.observedSentinels.clear();
- }
- }
-
- /**
- * Gets the current limit for a group, or returns default
- */
- getGroupLimit(groupKey: string): number {
- return (
- this.expandedGroupLimits().get(groupKey) ?? this.DEFAULT_GROUP_LIMIT
- );
- }
-
- /**
- * Loads more channels in a group (used by infinite scroll)
- */
- loadMoreInGroup(groupKey: string, totalInGroup: number): void {
- const limits = new Map(this.expandedGroupLimits());
- const current = limits.get(groupKey) ?? this.DEFAULT_GROUP_LIMIT;
- // Only load more if there are more items to show
- if (current < totalInGroup) {
- limits.set(groupKey, current + this.DEFAULT_GROUP_LIMIT);
- this.expandedGroupLimits.set(limits);
- this.cdr.markForCheck();
- }
- }
-
- /**
- * Sets up IntersectionObserver for a group's sentinel element.
- */
- private observeGroupSentinel(
- element: HTMLElement,
- groupKey: string,
- totalInGroup: number
- ): void {
- if (!element || this.observedSentinels.has(element)) {
- return;
- }
-
- // Store group info on the element for the observer callback
- element.dataset['groupKey'] = groupKey;
- element.dataset['totalInGroup'] = String(totalInGroup);
-
- this.groupScrollObserver?.observe(element);
- this.observedSentinels.add(element);
- }
-
- /**
- * Called when an expansion panel opens - sets up infinite scroll observer
- */
- onGroupPanelOpened(groupKey: string, totalInGroup: number): void {
- // Use setTimeout to ensure the panel content is rendered
- setTimeout(() => {
- const sentinel = document.querySelector(
- `[data-sentinel-group="${groupKey}"]`
- );
- if (sentinel) {
- this.observeGroupSentinel(
- sentinel as HTMLElement,
- groupKey,
- totalInGroup
- );
+ if (nextSelection !== currentSelection) {
+ this.selectedGroupKey.set(nextSelection);
}
- }, 50);
- }
+ });
- /**
- * Computed signal that memoizes enriched channels for all groups
- */
- readonly filteredGroupedChannels = computed(() => {
- const grouped = this.groupedChannels();
- const term = this.searchTerm().trim().toLowerCase();
+ effect(() => {
+ const selectedGroupKey = this.selectedGroupKey();
+ if (selectedGroupKey == null) {
+ return;
+ }
- if (!term) {
- return grouped;
- }
+ queueMicrotask(() => {
+ const container = this.hostEl.nativeElement.querySelector(
+ '.groups-nav-list'
+ ) as HTMLElement | null;
+ const candidates = Array.from(
+ this.hostEl.nativeElement.querySelectorAll(
+ '[data-group-key]'
+ )
+ ) as HTMLElement[];
+ const selected =
+ candidates.find(
+ (candidate) =>
+ candidate.dataset['groupKey'] === selectedGroupKey
+ ) ?? null;
- return Object.entries(grouped).reduce>(
- (acc, [groupKey, channels]) => {
- const groupMatches = groupKey.toLowerCase().includes(term);
- const matchingChannels = groupMatches
- ? channels
- : channels.filter((channel) =>
- channel.name?.toLowerCase().includes(term)
- );
-
- if (matchingChannels.length > 0) {
- acc[groupKey] = matchingChannels;
+ if (!container || !selected) {
+ return;
}
+ const containerRect = container.getBoundingClientRect();
+ const selectedRect = selected.getBoundingClientRect();
+ const targetTop =
+ container.scrollTop +
+ (selectedRect.top - containerRect.top) -
+ container.clientHeight / 2 +
+ selectedRect.height / 2;
+ const maxScrollTop = Math.max(
+ 0,
+ container.scrollHeight - container.clientHeight
+ );
+
+ container.scrollTo({
+ behavior: 'smooth',
+ top: Math.min(maxScrollTop, Math.max(0, targetTop)),
+ });
+ });
+ });
+ }
+
+ readonly sortedGroups = computed(() => {
+ const grouped = this.groupedChannels();
+ const groups = Object.entries(grouped).map(([key, channels]) => ({
+ key,
+ value: channels,
+ }));
+
+ return groups.sort(this.groupsComparator);
+ });
+
+ readonly filteredGroups = computed(() => {
+ const term = this.searchTerm().trim().toLowerCase();
+ const groups = this.sortedGroups();
+
+ if (!term) {
+ return groups
+ .filter((group) => group.value.length > 0)
+ .map((group) => ({
+ channels: group.value,
+ count: group.value.length,
+ key: group.key,
+ titleMatches: false,
+ }));
+ }
+
+ return groups.reduce((acc, group) => {
+ const titleMatches = group.key.toLowerCase().includes(term);
+ const channels = titleMatches
+ ? group.value
+ : group.value.filter((channel) =>
+ `${channel.name ?? ''}`.toLowerCase().includes(term)
+ );
+
+ if (channels.length === 0) {
return acc;
- },
- {}
+ }
+
+ acc.push({
+ channels,
+ count: channels.length,
+ key: group.key,
+ titleMatches,
+ });
+ return acc;
+ }, []);
+ });
+
+ readonly selectedGroup = computed(() => {
+ const selectedGroupKey = this.selectedGroupKey();
+ return (
+ this.filteredGroups().find((group) => group.key === selectedGroupKey) ??
+ null
);
});
- private readonly enrichedGroupChannelsMap = computed(() => {
- const grouped = this.filteredGroupedChannels();
+ readonly selectedGroupChannels = computed(() => {
+ const group = this.selectedGroup();
const epgMap = this.channelEpgMap();
- // Read progressTick to create dependency for progress refresh
this.progressTick();
- const result = new Map();
- for (const [groupKey, channels] of Object.entries(grouped)) {
- result.set(
- groupKey,
- channels.map((channel) => {
- const channelId =
- channel?.tvg?.id?.trim() || channel?.name?.trim();
- const epgProgram = channelId ? epgMap.get(channelId) : null;
- return {
- ...channel,
- epgProgram,
- progressPercentage: this.calculateProgress(epgProgram),
- } as EnrichedChannel;
- })
- );
+ if (!group) {
+ return [];
}
- return result;
- });
- /**
- * Gets enriched channels for a specific group from the memoized map
- */
- getEnrichedGroupChannels(
- channels: Channel[],
- groupKey?: string
- ): EnrichedChannel[] {
- // If groupKey provided, use memoized map
- if (groupKey !== undefined) {
- return this.enrichedGroupChannelsMap().get(groupKey) || [];
- }
- // Fallback for direct channel array (shouldn't happen with proper template usage)
- const epgMap = this.channelEpgMap();
- return channels.map((channel) => {
+ return group.channels.map((channel) => {
const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim();
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
@@ -254,32 +232,36 @@ export class GroupsViewComponent implements AfterViewInit, OnDestroy {
progressPercentage: this.calculateProgress(epgProgram),
} as EnrichedChannel;
});
- }
+ });
- /**
- * Calculates progress percentage for an EPG program
- */
- private calculateProgress(
- epgProgram: EpgProgram | null | undefined
- ): number {
- if (!epgProgram) {
- return 0;
+ readonly activeChannelGroupKey = computed(() => {
+ const activeChannelUrl = this.activeChannelUrl();
+ if (!activeChannelUrl) {
+ return null;
}
- const now = new Date().getTime();
- const start = new Date(epgProgram.start).getTime();
- const stop = new Date(epgProgram.stop).getTime();
+ const grouped = this.groupedChannels();
+ for (const [groupKey, channels] of Object.entries(grouped)) {
+ if (channels.some((channel) => channel.url === activeChannelUrl)) {
+ return groupKey;
+ }
+ }
- const total = stop - start;
- const elapsed = now - start;
+ return null;
+ });
- return Math.min(100, Math.max(0, (elapsed / total) * 100));
+ selectGroup(groupKey: string): void {
+ this.selectedGroupKey.set(groupKey);
}
- trackByFn(_: number, channel: Channel): string {
+ trackByChannel(_: number, channel: Channel): string {
return channel?.id;
}
+ trackByGroupKey(_: number, group: FilteredGroupView): string {
+ return group.key;
+ }
+
onChannelClick(channel: Channel): void {
this.channelSelected.emit(channel);
}
@@ -291,17 +273,49 @@ export class GroupsViewComponent implements AfterViewInit, OnDestroy {
/**
* Comparator for sorting groups - numeric groups first, then alphabetical
*/
- groupsComparator = (
- a: KeyValue,
- b: KeyValue
+ readonly groupsComparator = (
+ a: KeyValue | { key: string; value: Channel[] },
+ b: KeyValue | { key: string; value: Channel[] }
): number => {
- const numA = parseInt(a.key.replace(/\D/g, ''));
- const numB = parseInt(b.key.replace(/\D/g, ''));
+ const numA = parseInt(a.key.replace(/\D/g, ''), 10);
+ const numB = parseInt(b.key.replace(/\D/g, ''), 10);
- if (!isNaN(numA) && !isNaN(numB)) {
+ if (!Number.isNaN(numA) && !Number.isNaN(numB) && numA !== numB) {
return numA - numB;
}
+ if (!Number.isNaN(numA) && Number.isNaN(numB)) {
+ return -1;
+ }
+
+ if (Number.isNaN(numA) && !Number.isNaN(numB)) {
+ return 1;
+ }
+
return a.key.localeCompare(b.key);
};
+
+ private calculateProgress(
+ epgProgram: EpgProgram | null | undefined
+ ): number {
+ if (!epgProgram) {
+ return 0;
+ }
+
+ const now = Date.now();
+ const start = new Date(epgProgram.start).getTime();
+ const stop = new Date(epgProgram.stop).getTime();
+
+ if (!Number.isFinite(start) || !Number.isFinite(stop)) {
+ return 0;
+ }
+
+ const total = stop - start;
+ if (total <= 0) {
+ return 0;
+ }
+
+ const elapsed = Math.min(total, Math.max(0, now - start));
+ return Math.round((elapsed / total) * 100);
+ }
}