mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(m3u): refactor GroupsViewComponent with improved state management and UI updates
This commit is contained in:
1 parent
7cf00510d1
commit
1677d709ff
4 files changed
+740
-326
No files matched your search
+110
-93
@@ -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>
|
||||
}
|
||||
+148
-41
@@ -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;
|
||||
}
|
||||
}
|
||||
+276
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
+206
-192
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user