feat(m3u): add compact mode and conditional EPG display

Add compact layout support for channel list items and make EPG
display conditional based on runtime environment and stored settings.

- Introduce .compact styles in channel-list-item.component.scss to
  reduce item height, padding, and logo size when EPG is hidden.
- Add StorageMap injection and read STORE_KEY.Settings in
  ChannelListContainerComponent to determine whether EPG is configured
  (only enable EPG in Electron when epgUrl exists; disable in PWA).
- Expose shouldShowEpg flag and itemSize getter to switch virtual
  scroll item size between 68 and 48 pixels.
- Bind [class.compact] on channel list item and wire [showEpg] input
  to channel-list-item components so item rendering adapts to mode.
- Replace hardcoded itemSize in cdk-virtual-scroll-viewport with
  dynamic [itemSize] to match compact/expanded layouts.

This reduces visual clutter and avoids showing EPG in environments
(where it is not configured or supported), improving UX and resource
usage.
This commit is contained in:
4gray committed 2025-11-22 11:40:16 +01:00
1 parent 5cf96d7112
commit 9073bf1bb3
4 files changed
+80 -18

No files matched your search

@@ -23,7 +23,7 @@
</div>
<div id="all-channels" class="channel-list">
<cdk-virtual-scroll-viewport
itemSize="68"
[itemSize]="itemSize"
class="scroll-viewport"
>
<app-channel-list-item
@@ -41,6 +41,7 @@
| translate)
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg"
[epgProgram]="getEpgForChannel(channel)"
(clicked)="selectChannel(channel)"
[selected]="activeChannel()?.url === channel?.url"
@@ -90,10 +91,11 @@
| translate)
"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg"
[epgProgram]="getEpgForChannel(channel)"
(clicked)="selectChannel(channel)"
[selected]="
activeChannel()?.url === channel.url
activeChannel()?.url === channel?.url
"
></app-channel-list-item>
}
@@ -140,6 +142,7 @@
"
[isDraggable]="true"
[logo]="channel?.tvg?.logo"
[showEpg]="shouldShowEpg"
[epgProgram]="getEpgForChannel(channel)"
(clicked)="selectChannel(channel)"
[selected]="activeChannel()?.url === channel?.url"
@@ -25,6 +25,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { FilterPipe } from '@iptvnator/pipes';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import * as _ from 'lodash';
import * as PlaylistActions from 'm3u-state';
@@ -33,9 +34,16 @@ import {
selectActivePlaylistId,
selectFavorites,
} from 'm3u-state';
import { BehaviorSubject, combineLatest, map, skipWhile } from 'rxjs';
import {
BehaviorSubject,
combineLatest,
map,
skipWhile,
Subject,
takeUntil,
} from 'rxjs';
import { EpgService } from 'services';
import { Channel, EpgProgram } from 'shared-interfaces';
import { Channel, EpgProgram, Settings, STORE_KEY } from 'shared-interfaces';
import { ChannelListItemComponent } from './channel-list-item/channel-list-item.component';
@Component({
@@ -62,6 +70,7 @@ import { ChannelListItemComponent } from './channel-list-item/channel-list-item.
export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly epgService = inject(EpgService);
private readonly snackBar = inject(MatSnackBar);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly translateService = inject(TranslateService);
@@ -71,6 +80,16 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Interval for refreshing EPG data */
private epgRefreshInterval?: number;
private destroy$ = new Subject<void>();
/** Whether to show EPG data in channel items (false in PWA mode or when EPG is not configured) */
shouldShowEpg = false;
/** Item size for virtual scroll - compact when no EPG */
get itemSize(): number {
return this.shouldShowEpg ? 68 : 48;
}
/**
* Channels array
* Create local copy of the store for local manipulations without updates in the store
@@ -128,7 +147,12 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
this.channelList$,
]).pipe(
map(([favoriteChannelIds, channelList]) => {
console.log('[ChannelList] favorites$ emit - IDs:', favoriteChannelIds.length, 'Channels:', channelList.length);
console.log(
'[ChannelList] favorites$ emit - IDs:',
favoriteChannelIds.length,
'Channels:',
channelList.length
);
const favorites = favoriteChannelIds
.map((favoriteChannelId) =>
channelList.find(
@@ -136,7 +160,11 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
)
)
.filter((channel): channel is Channel => channel !== undefined); // Filter out undefined channels
console.log('[ChannelList] favorites$ result:', favorites.length, 'favorites');
console.log(
'[ChannelList] favorites$ result:',
favorites.length,
'favorites'
);
return favorites;
})
);
@@ -185,6 +213,25 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
// Check if EPG should be shown (only in Electron with configured EPG URL)
const isElectron = !!window['electron'];
if (isElectron) {
this.storage
.get(STORE_KEY.Settings)
.subscribe((settings: unknown) => {
if (
settings &&
Object.keys(settings as Settings).length > 0
) {
const epgUrl = (settings as Settings).epgUrl;
this.shouldShowEpg = !!(epgUrl && epgUrl.length > 0);
}
});
} else {
// PWA mode - don't show EPG
this.shouldShowEpg = false;
}
// Set up EPG refresh interval (every 60 seconds)
this.epgRefreshInterval = window.setInterval(() => {
this.fetchEpgForChannels(this._channelList);
@@ -198,6 +245,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
clearInterval(this.epgRefreshInterval);
}
this.destroy$.next();
this.destroy$.complete();
// Clean up BehaviorSubject
this.channelList$.complete();
}
@@ -213,19 +262,16 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
// Get channel IDs (prefer tvg-id, fallback to name)
const channelIds = channels
.map(channel => channel?.tvg?.id?.trim() || channel?.name?.trim())
.filter(id => !!id);
console.log(`[EPG] Fetching EPG for ${channelIds.length} channels`);
.map((channel) => channel?.tvg?.id?.trim() || channel?.name?.trim())
.filter((id) => !!id);
// Batch fetch EPG programs
this.epgService.getCurrentProgramsForChannels(channelIds).subscribe(
(epgMap) => {
console.log(`[EPG] Received EPG data for ${epgMap.size} channels`);
console.log('[EPG] Sample data:', Array.from(epgMap.entries()).slice(0, 3));
this.epgService
.getCurrentProgramsForChannels(channelIds)
.pipe(takeUntil(this.destroy$))
.subscribe((epgMap) => {
this.channelEpgMap = epgMap;
}
);
});
}
/**
@@ -19,6 +19,12 @@
&.active {
background-color: rgba(0, 0, 0, 0.08);
}
// Compact mode when EPG is not shown
&.compact {
height: 48px;
padding: 6px 12px;
}
}
// Dark theme support
@@ -57,6 +63,12 @@
border-radius: 4px;
object-fit: contain;
flex-shrink: 0;
// Smaller logo in compact mode
.compact & {
width: 32px;
height: 32px;
}
}
// Channel details (name + EPG)
@@ -83,9 +95,9 @@
// EPG title (program name)
.epg-title {
font-family: 'Crimson Pro', serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 0.8rem;
font-weight: 600;
font-weight: 500;
opacity: 0.85;
overflow: hidden;
text-overflow: ellipsis;
@@ -26,6 +26,7 @@ import { EpgProgram } from 'shared-interfaces';
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div
class="channel-list-item"
[class.compact]="!showEpg"
cdkDrag
[cdkDragDisabled]="!isDraggable"
cdkDragPreviewContainer="parent"