feat: implement favorites channel sorting functionality and UI updates

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-19 22:31:45 +02:00
1 parent 21a9cdb0c6
commit 4c6e65be9b
10 files changed
+355 -145

No files matched your search

@@ -1,7 +1,7 @@
<div
class="nav-list"
cdkDropList
[cdkDropListDisabled]="!draggable() || hasSearchTerm()"
[cdkDropListDisabled]="!canDragDrop()"
(cdkDropListDropped)="onDrop($event)"
>
@if (enrichedChannels().length === 0) {
@@ -32,7 +32,7 @@
@for (ch of enrichedChannels(); track trackByUid($index, ch)) {
<app-channel-list-item
[isDraggable]="draggable() && !hasSearchTerm()"
[isDraggable]="canDragDrop()"
[logo]="ch.logo"
[name]="ch.name"
[selected]="activeUid() === ch.uid"
@@ -11,10 +11,15 @@ import {
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListItemComponent } from 'components';
import { EpgProgram } from 'shared-interfaces';
import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
FavoritesChannelSortMode,
sortFavoriteChannelItems,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
import { TranslateModule } from '@ngx-translate/core';
export interface EnrichedUnifiedFavorite extends UnifiedFavoriteChannel {
currentEpgProgram: EpgProgram | null;
@@ -30,7 +35,7 @@ export interface EnrichedUnifiedFavorite extends UnifiedFavoriteChannel {
ChannelListItemComponent,
DragDropModule,
MatIconModule,
TranslatePipe,
TranslateModule,
],
})
export class GlobalFavoritesListComponent {
@@ -40,11 +45,19 @@ export class GlobalFavoritesListComponent {
readonly activeUid = input<string | null>(null);
readonly searchTermInput = input('');
readonly draggable = input(true);
readonly sortMode = input<FavoritesChannelSortMode>(
DEFAULT_FAVORITES_CHANNEL_SORT_MODE
);
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly isCustomSort = computed(() => this.sortMode() === 'custom');
readonly canDragDrop = computed(
() => this.draggable() && this.isCustomSort() && !this.hasSearchTerm()
);
readonly hasSearchTerm = computed(
() => this.searchTermInput().trim().length > 0
);
@@ -59,7 +72,12 @@ export class GlobalFavoritesListComponent {
? channels.filter((ch) => ch.name.toLowerCase().includes(term))
: channels;
return filtered.map((ch) => {
const sorted = sortFavoriteChannelItems(filtered, this.sortMode(), {
getName: (ch) => ch.name,
getAddedAt: (ch) => ch.addedAt,
});
return sorted.map((ch) => {
const epgKey = ch.tvgId?.trim() || ch.name?.trim();
const currentEpgProgram = epgKey
? (epgMap.get(epgKey) ?? null)
@@ -81,7 +99,7 @@ export class GlobalFavoritesListComponent {
}
onDrop(event: CdkDragDrop<EnrichedUnifiedFavorite[]>): void {
if (this.hasSearchTerm()) {
if (!this.canDragDrop()) {
return;
}
@@ -58,6 +58,36 @@
<div class="collection-header">
<span class="collection-title">{{ title() | translate }}</span>
@if (showFavSortButton()) {
<button
type="button"
mat-icon-button
class="favorites-sort-button"
[matMenuTriggerFor]="favoritesSortMenu"
[matTooltip]="
('WORKSPACE.SORT_LABEL' | translate) +
(favSortLabelKey() | translate)
"
[attr.aria-label]="'WORKSPACE.SORT_LABEL' | translate"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<mat-menu #favoritesSortMenu="matMenu">
@for (option of favSortOptions; track option.mode) {
<button
mat-menu-item
(click)="setFavSortMode(option.mode)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.translationKey | translate }}</span>
@if (favSortMode() === option.mode) {
<mat-icon class="sort-check">check</mat-icon>
}
</button>
}
</mat-menu>
}
<div class="collection-header-toggles">
@if (showContentToggle()) {
<mat-button-toggle-group
@@ -106,18 +136,26 @@
</mat-button-toggle-group>
}
@if (mode() === 'recent' && allItems().length > 0) {
@if (currentTypeItems().length > 0) {
<button
type="button"
mat-icon-button
(click)="clearAllRecent()"
(click)="clearAllCurrent()"
[matTooltip]="
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION'
clearButtonTooltipKey()
| translate
: {
type:
currentTypeLabelKey() | translate,
}
"
[attr.aria-label]="
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION_ARIA'
clearButtonTooltipKey()
| translate
: {
type:
currentTypeLabelKey() | translate,
}
"
>
<mat-icon>delete_sweep</mat-icon>
@@ -134,6 +172,7 @@
[mode]="mode()"
[searchTerm]="workspaceSearchTerm()"
[autoOpenItem]="pendingAutoOpenLiveItem()"
[sortMode]="favSortMode()"
(removeItem)="onRemoveItem($event)"
(reorderItems)="onReorder($event)"
(itemPlayed)="onItemPlayed($event)"
@@ -36,13 +36,17 @@
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
margin-right: auto;
}
.favorites-sort-button {
margin-left: -4px;
}
.collection-header-toggles {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
}
.toggle-placeholder {
@@ -75,9 +79,16 @@ app-unified-grid-tab {
flex: 1;
min-height: 0;
display: flex;
}
app-unified-grid-tab {
flex-direction: column;
}
app-unified-live-tab {
flex-direction: row;
}
.empty-state {
display: flex;
flex-direction: column;
@@ -16,6 +16,7 @@ import {
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIconButton } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -30,6 +31,8 @@ import {
CollectionContentType,
CollectionScope,
CollectionViewState,
FavoritesChannelSortMode,
getFavoritesChannelSortModeTranslationKey,
getOpenCollectionDetailItemState,
getCollectionViewState,
getOpenLiveCollectionItemState,
@@ -37,7 +40,9 @@ import {
isWorkspaceLayoutRoute,
OPEN_COLLECTION_DETAIL_STATE_KEY,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
persistFavoritesChannelSortMode,
queryParamSignal,
restoreFavoritesChannelSortMode,
routeParamSignal,
ScopeToggleService,
STALKER_RETURN_TO_STATE_KEY,
@@ -63,6 +68,7 @@ import {
MatButtonToggleModule,
MatIconButton,
MatIconModule,
MatMenuModule,
MatTooltip,
TranslatePipe,
UnifiedGridTabComponent,
@@ -204,12 +210,79 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
() => this.availableTypes().length > 1
);
readonly currentTypeItems = computed(() => {
switch (this.selectedContentType()) {
case 'live':
return this.liveItems();
case 'movie':
return this.movieItems();
case 'series':
return this.seriesItems();
}
});
readonly currentTypeLabelKey = computed(() => {
switch (this.selectedContentType()) {
case 'live':
return 'PORTALS.LIVE_TV';
case 'movie':
return 'PORTALS.MOVIES';
case 'series':
return 'PORTALS.SERIES';
}
});
readonly clearButtonTooltipKey = computed(() =>
this.mode() === 'favorites'
? 'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE'
: 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_TYPE'
);
readonly title = computed(() => {
return this.mode() === 'favorites'
? 'PORTALS.FAVORITES'
: 'PORTALS.RECENTLY_VIEWED';
});
readonly favSortMode = signal<FavoritesChannelSortMode>(
restoreFavoritesChannelSortMode()
);
readonly favSortLabelKey = computed(() =>
getFavoritesChannelSortModeTranslationKey(this.favSortMode())
);
readonly showFavSortButton = computed(
() =>
this.mode() === 'favorites' &&
this.selectedContentType() === 'live' &&
this.hasLive()
);
readonly favSortOptions: ReadonlyArray<{
mode: FavoritesChannelSortMode;
translationKey: string;
icon: string;
}> = [
{
mode: 'custom',
translationKey: 'WORKSPACE.SORT_CUSTOM',
icon: 'drag_indicator',
},
{
mode: 'name-asc',
translationKey: 'WORKSPACE.SORT_NAME_ASC',
icon: 'sort_by_alpha',
},
{
mode: 'name-desc',
translationKey: 'WORKSPACE.SORT_NAME_DESC',
icon: 'sort_by_alpha',
},
{
mode: 'date-desc',
translationKey: 'WORKSPACE.SORT_DATE_DESC',
icon: 'schedule',
},
];
private readonly favoritesReloadKey = computed(() => {
if (this.mode() !== 'favorites') {
return 'recent';
@@ -315,6 +388,11 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
this.selectedContentType.set(value);
}
setFavSortMode(mode: FavoritesChannelSortMode): void {
this.favSortMode.set(mode);
persistFavoritesChannelSortMode(mode);
}
onGridItemSelected(item: UnifiedCollectionItem): void {
this.syncCurrentCollectionViewState();
@@ -360,24 +438,45 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
);
}
clearAllRecent(): void {
if (this.allItems().length === 0) {
clearAllCurrent(): void {
const itemsToRemove = this.currentTypeItems();
if (itemsToRemove.length === 0) {
return;
}
const scope = this.effectiveScope();
const playlistId = this.playlistId();
const isFavorites = this.mode() === 'favorites';
const type = this.translate.instant(this.currentTypeLabelKey());
const isPlaylistScope = this.effectiveScope() === 'playlist';
const titleKey = isFavorites
? 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_TITLE'
: 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_TITLE';
const messageKey = isFavorites
? isPlaylistScope
? 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST'
: 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_ALL'
: isPlaylistScope
? 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST'
: 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL';
this.dialogService.openConfirmDialog({
title: this.translate.instant(
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_TITLE'
),
message: this.translate.instant(
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE'
),
title: this.translate.instant(titleKey, { type }),
message: this.translate.instant(messageKey, { type }),
onConfirm: () => {
this.allItems.set([]);
void this.recentData.clearRecentItems(scope, playlistId);
const removedType = this.selectedContentType();
this.allItems.update((items) =>
items.filter((item) => item.contentType !== removedType)
);
const remaining = this.availableTypes();
if (remaining.length > 0) {
this.selectedContentType.set(remaining[0]);
}
void Promise.all(
itemsToRemove.map((item) =>
isFavorites
? this.favoritesData.removeFavorite(item)
: this.recentData.removeRecentItem(item)
)
);
},
});
}
@@ -1,26 +1,27 @@
@if (activeDetail()) {
<div [class]="layoutClass()">
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="360"
storageKey="unified-live-tab-sidebar-width"
>
<app-global-favorites-list
[channels]="channelsForList()"
[epgMap]="epgMap()"
[progressTick]="progressTick()"
[activeUid]="activeUid()"
[searchTermInput]="searchTerm()"
[draggable]="mode() === 'favorites'"
(channelSelected)="onChannelSelected($event)"
(channelsReordered)="onReorder($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
</div>
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="360"
storageKey="unified-live-tab-sidebar-width"
>
<app-global-favorites-list
[channels]="channelsForList()"
[epgMap]="epgMap()"
[progressTick]="progressTick()"
[activeUid]="activeUid()"
[searchTermInput]="searchTerm()"
[draggable]="mode() === 'favorites'"
[sortMode]="sortMode()"
(channelSelected)="onChannelSelected($event)"
(channelsReordered)="onReorder($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
</div>
<div class="content-container">
@if (activeDetail()) {
@if (shouldUseInlinePlayer()) {
<div class="video-player" [class.video-player--radio]="isRadioSelection()">
@if (isRadioSelection()) {
@@ -122,19 +123,10 @@
</div>
</div>
}
</div>
} @else {
<div class="full-list">
<app-global-favorites-list
[channels]="channelsForList()"
[epgMap]="epgMap()"
[progressTick]="progressTick()"
[activeUid]="activeUid()"
[searchTermInput]="searchTerm()"
[draggable]="mode() === 'favorites'"
(channelSelected)="onChannelSelected($event)"
(channelsReordered)="onReorder($event)"
(favoriteToggled)="onFavoriteToggled($event)"
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
/>
</div>
}
}
</div>
@@ -1,71 +1,42 @@
:host {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
@use '../../styles/portal-layout' as portal;
.itv-container {
display: grid;
grid-template-columns: auto 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
'sidebar video-player video-player'
'sidebar epg epg';
height: 100%;
}
@include portal.live-layout;
.itv-container--radio {
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
grid-template-areas: 'sidebar video-player';
}
.itv-container--external {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
grid-template-areas: 'sidebar epg';
height: 100%;
.epg {
border-top: none;
.sidebar {
> app-global-favorites-list {
flex: 1;
min-height: 0;
overflow: hidden;
}
}
.full-list {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.content-container {
background: var(--mat-sys-surface);
.sidebar {
grid-area: sidebar;
width: 360px;
min-width: 250px;
max-width: 600px;
flex-shrink: 0;
position: relative;
border-right: 1px solid var(--mat-sys-outline-variant);
display: flex;
flex-direction: column;
overflow: hidden;
&:has(> app-portal-empty-state) {
align-items: center;
justify-content: center;
}
}
.video-player {
grid-area: video-player;
min-height: 0;
overflow: hidden;
background: #000;
display: flex;
flex-direction: column;
}
.video-player--radio {
position: relative;
background: var(--mat-sys-surface);
&--radio {
position: relative;
background: var(--mat-sys-surface);
}
> app-vjs-player,
> app-html-video-player,
> app-art-player,
> app-audio-player {
flex: 1;
min-height: 0;
display: block;
}
}
.player-toolbar {
@@ -108,20 +79,9 @@
backdrop-filter: blur(12px);
}
.video-player > app-vjs-player,
.video-player > app-html-video-player,
.video-player > app-art-player,
.video-player > app-audio-player {
flex: 1;
min-height: 0;
display: block;
}
.epg {
grid-area: epg;
min-height: 0;
overflow: hidden;
border-top: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface);
display: flex;
flex-direction: column;
}
@@ -132,9 +92,10 @@
overflow: hidden;
display: flex;
flex-direction: column;
}
.epg-content > app-epg-view {
flex: 1;
min-height: 0;
> app-epg-view,
> app-epg-list {
flex: 1;
min-height: 0;
}
}
@@ -12,8 +12,11 @@ import {
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { isM3uCatchupPlaybackSupported } from 'm3u-utils';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
FavoritesChannelSortMode,
matchesOpenLiveCollectionItem,
OpenLiveCollectionItemState,
PORTAL_PLAYER,
@@ -25,6 +28,7 @@ import {
} from '@iptvnator/portal/shared/util';
import { EpgListComponent } from '@iptvnator/ui/epg';
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
import {
AudioPlayerComponent,
ArtPlayerComponent,
@@ -51,7 +55,9 @@ import { EpgViewComponent } from 'shared-portals';
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
VjsPlayerComponent,
],
})
@@ -60,6 +66,9 @@ export class UnifiedLiveTabComponent {
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly searchTerm = input('');
readonly autoOpenItem = input<OpenLiveCollectionItemState | null>(null);
readonly sortMode = input<FavoritesChannelSortMode>(
DEFAULT_FAVORITES_CHANNEL_SORT_MODE
);
readonly removeItem = output<UnifiedCollectionItem>();
readonly reorderItems = output<UnifiedCollectionItem[]>();
@@ -118,15 +127,6 @@ export class UnifiedLiveTabComponent {
readonly shouldUseInlinePlayer = computed(() => {
return this.isRadioSelection() || this.isEmbeddedPlayer();
});
readonly layoutClass = computed(() => {
if (this.isRadioSelection()) {
return 'itv-container itv-container--radio';
}
return this.shouldUseInlinePlayer()
? 'itv-container'
: 'itv-container--external';
});
readonly activeChannelForOverlay = computed((): Channel | undefined => {
const detail = this.activeDetail();
@@ -232,10 +232,9 @@ export class UnifiedLiveTabComponent {
const item = this.items().find(
(candidate) => candidate.uid === channel.uid
);
if (!item) {
return;
if (item) {
await this.activateItem(item);
}
await this.activateItem(item);
}
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
+1
View File
@@ -1,5 +1,6 @@
export * from './lib/logger';
export * from './lib/channel-sort';
export * from './lib/favorites-channel-sort';
export * from './lib/portal-catalog-detail';
export * from './lib/portal-catalog-facade';
export * from './lib/portal-external-playback';
@@ -0,0 +1,90 @@
const FAVORITES_SORT_COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export type FavoritesChannelSortMode =
| 'custom'
| 'name-asc'
| 'name-desc'
| 'date-desc';
export const DEFAULT_FAVORITES_CHANNEL_SORT_MODE: FavoritesChannelSortMode =
'custom';
export const FAVORITES_CHANNEL_SORT_STORAGE_KEY =
'favorites-channel-sort-mode';
export function isFavoritesChannelSortMode(
value: unknown
): value is FavoritesChannelSortMode {
return (
value === 'custom' ||
value === 'name-asc' ||
value === 'name-desc' ||
value === 'date-desc'
);
}
export function restoreFavoritesChannelSortMode(
storageKey: string = FAVORITES_CHANNEL_SORT_STORAGE_KEY,
fallback: FavoritesChannelSortMode = DEFAULT_FAVORITES_CHANNEL_SORT_MODE
): FavoritesChannelSortMode {
const storedValue = localStorage.getItem(storageKey);
return isFavoritesChannelSortMode(storedValue) ? storedValue : fallback;
}
export function persistFavoritesChannelSortMode(
mode: FavoritesChannelSortMode,
storageKey: string = FAVORITES_CHANNEL_SORT_STORAGE_KEY
): void {
localStorage.setItem(storageKey, mode);
}
export function getFavoritesChannelSortModeTranslationKey(
mode: FavoritesChannelSortMode
): string {
switch (mode) {
case 'name-asc':
return 'WORKSPACE.SORT_NAME_ASC';
case 'name-desc':
return 'WORKSPACE.SORT_NAME_DESC';
case 'date-desc':
return 'WORKSPACE.SORT_DATE_DESC';
default:
return 'WORKSPACE.SORT_CUSTOM';
}
}
export function sortFavoriteChannelItems<T>(
items: readonly T[],
mode: FavoritesChannelSortMode,
accessors: {
getName: (item: T) => string | null | undefined;
getAddedAt?: (item: T) => string | null | undefined;
}
): readonly T[] {
if (mode === 'custom') {
return items;
}
if (mode === 'date-desc') {
const getAddedAt = accessors.getAddedAt;
if (!getAddedAt) {
return items;
}
return [...items].sort((a, b) => {
const timeA = Date.parse(getAddedAt(a) ?? '') || 0;
const timeB = Date.parse(getAddedAt(b) ?? '') || 0;
return timeB - timeA;
});
}
return [...items].sort((a, b) => {
const result = FAVORITES_SORT_COLLATOR.compare(
accessors.getName(a) ?? '',
accessors.getName(b) ?? ''
);
return mode === 'name-asc' ? result : -result;
});
}