feat(m3u): refactor GroupsViewComponent with improved state management and UI updates

This commit is contained in:
4gray committed 2026-03-28 15:38:19 +01:00
1 parent 7cf00510d1
commit 1677d709ff
4 files changed
+740 -326

No files matched your search

@@ -1,97 +1,114 @@
<div id="groups-list" class="channel-list">
@if (searchTerm() && (filteredGroupedChannels() | keyvalue).length === 0) {
<div class="no-results">
<mat-icon class="no-results-icon">search_off</mat-icon>
<div class="no-results-title">
{{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }}
</div>
<div class="no-results-hint">
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
</div>
@if (filteredGroups().length === 0) {
<div class="groups-view-empty-state">
<mat-icon class="empty-state-icon">{{
searchTerm() ? 'search_off' : 'folder_off'
}}</mat-icon>
<div class="empty-state-title">
{{
(searchTerm()
? 'CHANNELS.NO_SEARCH_RESULTS'
: 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
) | translate
}}
</div>
} @else {
<mat-accordion multi>
@for (
groups of filteredGroupedChannels() | keyvalue: groupsComparator;
track $index
) {
@if (groups.value.length > 0) {
<mat-expansion-panel
(afterExpand)="
onGroupPanelOpened(groups.key, groups.value.length)
"
>
<mat-expansion-panel-header>
{{
groups.key || ('CHANNELS.UNGROUPED' | translate)
| titlecase
}}
({{ groups.value.length }})
</mat-expansion-panel-header>
<div class="empty-state-hint">
{{
(searchTerm()
? 'CHANNELS.TRY_DIFFERENT_SEARCH'
: 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION'
) | translate
}}
</div>
</div>
} @else {
<div class="groups-view-layout">
<aside
class="groups-nav-panel"
appResizable
[minWidth]="164"
[maxWidth]="320"
[defaultWidth]="208"
storageKey="m3u-groups-nav-width"
>
<div class="groups-nav-shell">
<header class="groups-nav-header">
<h2 class="groups-nav-title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
</header>
<ng-template matExpansionPanelContent>
@let enrichedGroupChannels =
getEnrichedGroupChannels(groups.value, groups.key);
@let limit = getGroupLimit(groups.key);
<div class="group-channels">
@for (
channel of enrichedGroupChannels.slice(
0,
limit
);
track trackByFn($index, channel);
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name ||
'CHANNELS.UNNAMED_CHANNEL'
| translate)
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="channel.epgProgram"
[progressPercentage]="
channel.progressPercentage
"
(clicked)="onChannelClick(channel)"
[selected]="
activeChannelUrl() === channel?.url
"
[showFavoriteButton]="true"
[isFavorite]="
favoriteIds().has(channel?.url)
"
(favoriteToggled)="
onFavoriteToggle(channel, $event)
"
></app-channel-list-item>
<div class="groups-nav-list nav-list">
@for (group of filteredGroups(); track trackByGroupKey($index, group)) {
<button
type="button"
class="nav-item group-nav-item"
[attr.data-group-key]="group.key"
[class.selected]="selectedGroupKey() === group.key"
[attr.aria-current]="
selectedGroupKey() === group.key ? 'true' : null
"
(click)="selectGroup(group.key)"
>
<span class="nav-item-label">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
@if (groups.value.length > limit) {
<div
class="scroll-sentinel"
[attr.data-sentinel-group]="groups.key"
>
<div class="loading-more">
<mat-icon
class="loading-more-spinner"
>sync</mat-icon
>
<span>{{
'CHANNELS.LOADING_MORE'
| translate
}}</span>
</div>
</div>
}
</div>
</ng-template>
</mat-expansion-panel>
}
</span>
<span class="nav-item-meta">
{{ group.count }}
</span>
</button>
}
</div>
</div>
</aside>
<section class="sidebar groups-content-panel">
@if (selectedGroup(); as group) {
<header class="sidebar-header groups-content-header">
<div class="category-heading">
<h2 class="category-title">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</h2>
<span class="category-count">{{ group.count }}</span>
</div>
</header>
<cdk-virtual-scroll-viewport
[itemSize]="itemSize()"
class="groups-channels-viewport"
>
<app-channel-list-item
*cdkVirtualFor="
let channel of selectedGroupChannels();
index as i;
trackBy: trackByChannel
"
[name]="
i +
1 +
'. ' +
(channel?.name ||
('CHANNELS.UNNAMED_CHANNEL' | translate))
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="channel.epgProgram"
[progressPercentage]="channel.progressPercentage"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
[isFavorite]="favoriteIds().has(channel?.url)"
(clicked)="onChannelClick(channel)"
(favoriteToggled)="onFavoriteToggle(channel, $event)"
/>
</cdk-virtual-scroll-viewport>
}
</mat-accordion>
}
</div>
</section>
</div>
}
@@ -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;
}
}
@@ -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<GroupsViewComponent>;
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<string, Channel[]> = {
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<string>;
groupedChannels: Record<string, Channel[]>;
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<string, null>()
);
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<string>()
);
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'
);
});
});
@@ -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<HTMLElement>);
/** 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<string | null>(null);
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
/** Track observed sentinel elements */
private observedSentinels = new Set<Element>();
private previousActiveChannelUrl: string | undefined;
/** Track expanded groups with their load limits for lazy-loading */
readonly expandedGroupLimits = signal(new Map<string, number>());
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<Record<string, Channel[]>>(
(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<FilteredGroupView[]>(() => {
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<FilteredGroupView[]>((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<EnrichedChannel[]>(() => {
const group = this.selectedGroup();
const epgMap = this.channelEpgMap();
// Read progressTick to create dependency for progress refresh
this.progressTick();
const result = new Map<string, EnrichedChannel[]>();
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<string, Channel[]>,
b: KeyValue<string, Channel[]>
readonly groupsComparator = (
a: KeyValue<string, Channel[]> | { key: string; value: Channel[] },
b: KeyValue<string, Channel[]> | { 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);
}
}