feat(global-widgets): add M3U recently-viewed and favs support

- Introduced M3uRecentlyViewedItem interface to define structure for recently viewed items.
- Implemented utility functions to handle mapping and building of recently viewed items from playlists.
- Updated Playlist interface to use PlaylistRecentlyViewedItem type for recently viewed items.
- Enhanced PortalActivityItem interface to include 'm3u' as a possible source.
- Added UI components for displaying recently viewed channels, including clear functionality.
- Integrated recent view functionality into the channel list container and sidebar components.
- Updated dashboard data service to manage M3U favorites and recently viewed items.
- Added tests to ensure correct behavior of recently viewed items in the dashboard data service.
This commit is contained in:
4gray committed 2026-03-11 23:57:13 +01:00
1 parent 188ebb6fd9
commit 1e9f5d9d05
27 files changed
+1286 -227

No files matched your search

@@ -3,6 +3,14 @@
<header class="context-header">
<div class="context-header__top">
<h2>{{ viewTitle() | translate }}</h2>
@if (
activeView() === 'recent' &&
recentChannelItems().length > 0
) {
<button mat-button (click)="clearRecentChannels()">
{{ 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION' | translate }}
</button>
}
</div>
</header>
<div class="context-divider"></div>
@@ -49,6 +57,17 @@
/>
}
}
@case ('recent') {
<app-recent-view
[recentItems]="recentChannelItems()"
[channelEpgMap]="channelEpgMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[activeChannelUrl]="activeChannelUrl()"
(channelSelected)="onChannelSelected($event)"
(removeRecent)="removeRecentChannel($event)"
/>
}
}
} @else {
<div class="loading-container">
@@ -11,6 +11,7 @@ import {
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -20,15 +21,37 @@ import { EpgService } from '@iptvnator/epg/data-access';
import {
ChannelActions,
FavoritesActions,
PlaylistActions,
selectActive,
selectActivePlaylistId,
selectFavorites,
} from 'm3u-state';
import { BehaviorSubject, combineLatest, filter, map, skipWhile } from 'rxjs';
import { Channel, EpgProgram, Settings, STORE_KEY } from 'shared-interfaces';
import {
BehaviorSubject,
combineLatest,
filter,
firstValueFrom,
map,
skipWhile,
} from 'rxjs';
import { PlaylistsService } from 'services';
import {
Channel,
EpgProgram,
isM3uRecentlyViewedItem,
normalizeStalkerDate,
PlaylistMeta,
PlaylistRecentlyViewedItem,
Settings,
STORE_KEY,
} from 'shared-interfaces';
import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component';
import { FavoritesViewComponent } from './favorites-view/favorites-view.component';
import { GroupsViewComponent } from './groups-view/groups-view.component';
import {
RecentViewComponent,
RecentViewItem,
} from './recent-view/recent-view.component';
function groupChannelsByTitle(channels: Channel[]): Record<string, Channel[]> {
return channels.reduce<Record<string, Channel[]>>((groups, channel) => {
@@ -51,12 +74,15 @@ function groupChannelsByTitle(channels: Channel[]): Record<string, Channel[]> {
CommonModule,
FavoritesViewComponent,
GroupsViewComponent,
MatButtonModule,
MatIconModule,
RecentViewComponent,
TranslatePipe,
],
})
export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly epgService = inject(EpgService);
private readonly playlistsService = inject(PlaylistsService);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly router = inject(Router);
@@ -79,8 +105,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Item size for virtual scroll - compact when no EPG */
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
/** Active view (all, groups, favorites) */
/** Active view (all, groups, favorites, recent) */
readonly activeView = input<string>('all');
readonly recentItems = input<PlaylistRecentlyViewedItem[]>([]);
readonly currentUrl = toSignal(
this.router.events.pipe(
@@ -95,6 +122,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
const url = this.currentUrl();
if (view === 'all') return 'CHANNELS.ALL_CHANNELS';
if (view === 'groups') return 'CHANNELS.GROUPS';
if (view === 'recent') return 'PORTALS.SIDEBAR.RECENT';
if (view === 'favorites') {
return url.includes('/workspace/global-favorites')
? 'HOME.PLAYLISTS.GLOBAL_FAVORITES'
@@ -136,6 +164,49 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
groupChannelsByTitle(this.displayedChannels())
);
readonly recentChannelItems = computed<RecentViewItem[]>(() => {
const channels = this.channelListSignal();
const recentItems = this.recentItems();
const channelsByUrl = new Map(
channels.map((channel) => [channel.url, channel] as const)
);
const channelsById = new Map(
channels.map((channel) => [channel.id, channel] as const)
);
const seenUrls = new Set<string>();
return [...recentItems]
.filter(isM3uRecentlyViewedItem)
.sort(
(a, b) =>
new Date(normalizeStalkerDate(b.added_at)).getTime() -
new Date(normalizeStalkerDate(a.added_at)).getTime()
)
.reduce<RecentViewItem[]>((acc, item) => {
const channelUrl = item.url?.trim();
if (!channelUrl || seenUrls.has(channelUrl)) {
return acc;
}
const channel =
channelsByUrl.get(channelUrl) ||
(item.channel_id
? channelsById.get(item.channel_id)
: undefined);
if (!channel) {
return acc;
}
seenUrls.add(channel.url);
acc.push({
channel,
viewedAt: normalizeStalkerDate(item.added_at),
});
return acc;
}, []);
});
/** Selected channel */
readonly activeChannel = this.store.selectSignal(selectActive);
@@ -263,4 +334,47 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
onFavoritesReordered(channelIds: string[]): void {
this.store.dispatch(FavoritesActions.setFavorites({ channelIds }));
}
async removeRecentChannel(channelUrl: string): Promise<void> {
const playlistId = this.activePlaylistIdSignal();
if (!playlistId) {
return;
}
const updatedPlaylist = await firstValueFrom(
this.playlistsService.removeFromM3uRecentlyViewed(
playlistId,
channelUrl
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
}) as any
);
}
async clearRecentChannels(): Promise<void> {
const playlistId = this.activePlaylistIdSignal();
if (!playlistId) {
return;
}
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearM3uRecentlyViewed(playlistId)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
}) as any
);
}
}
@@ -58,7 +58,8 @@
</div>
@if (
(showProgramInfoButton() && showEpg() && epgProgram()) ||
showFavoriteButton()
showFavoriteButton() ||
showAuxActionButton()
) {
<div class="action-buttons">
@if (showProgramInfoButton() && showEpg() && epgProgram(); as program) {
@@ -90,6 +91,16 @@
}}</mat-icon>
</button>
}
@if (showAuxActionButton()) {
<button
mat-icon-button
class="favorite-button"
[matTooltip]="auxActionTooltip()"
(click)="onAuxActionClick($event)"
>
<mat-icon>{{ auxActionIcon() }}</mat-icon>
</button>
}
</div>
}
</div>
@@ -39,6 +39,7 @@ export class ChannelListItemComponent {
readonly logo = input<string | null | undefined>('');
readonly name = input('');
readonly showFavoriteButton = input(false);
readonly showAuxActionButton = input(false);
readonly showProgramInfoButton = input(true);
readonly isFavorite = input(false);
readonly selected = input(false);
@@ -46,9 +47,12 @@ export class ChannelListItemComponent {
readonly epgProgram = input<EpgProgram | null | undefined>();
/** Progress percentage pre-computed by parent for performance */
readonly progressPercentage = input(0);
readonly auxActionIcon = input('delete');
readonly auxActionTooltip = input('');
readonly clicked = output<void>();
readonly favoriteToggled = output<MouseEvent>();
readonly auxActionClicked = output<MouseEvent>();
constructor() {
effect(() => {
@@ -86,6 +90,11 @@ export class ChannelListItemComponent {
this.favoriteToggled.emit(event);
}
onAuxActionClick(event: MouseEvent): void {
event.stopPropagation();
this.auxActionClicked.emit(event);
}
showLogoFallback(): boolean {
return !this.logo() || this.logoFailed();
}
@@ -0,0 +1,37 @@
<div class="channel-list" id="recent-list">
@if (enrichedRecentItems().length > 0) {
@for (
item of enrichedRecentItems();
track item.channel.url;
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(item.channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[logo]="item.channel?.tvg?.logo"
[showEpg]="shouldShowEpg()"
[epgProgram]="item.epgProgram"
[progressPercentage]="item.progressPercentage"
[selected]="activeChannelUrl() === item.channel?.url"
[showAuxActionButton]="true"
[auxActionIcon]="'delete'"
[auxActionTooltip]="
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
"
(clicked)="onChannelClick(item.channel)"
(auxActionClicked)="onRemoveRecent(item.channel, $event)"
/>
}
} @else {
<div class="empty-recent">
<strong>{{ 'WORKSPACE.DASHBOARD.NO_HISTORY' | translate }}</strong>
</div>
<div class="empty-recent-hint">
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate }}
</div>
}
</div>
@@ -0,0 +1,33 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
#recent-list {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
}
.channel-list {
overflow-x: hidden;
}
.empty-recent,
.empty-recent-hint {
padding: 16px 24px;
text-align: center;
}
.empty-recent {
margin-top: 40px;
font-size: 1rem;
}
.empty-recent-hint {
opacity: 0.7;
font-size: 0.9rem;
}
@@ -0,0 +1,80 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { Channel, EpgProgram } from 'shared-interfaces';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
export interface RecentViewItem {
channel: Channel;
viewedAt: string;
}
@Component({
selector: 'app-recent-view',
templateUrl: './recent-view.component.html',
styleUrls: ['./recent-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ChannelListItemComponent, TranslatePipe],
})
export class RecentViewComponent {
readonly recentItems = input.required<RecentViewItem[]>();
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
readonly progressTick = input.required<number>();
readonly shouldShowEpg = input.required<boolean>();
readonly activeChannelUrl = input<string | undefined>();
readonly channelSelected = output<Channel>();
readonly removeRecent = output<string>();
readonly enrichedRecentItems = computed(() => {
const recentItems = this.recentItems();
const epgMap = this.channelEpgMap();
this.progressTick();
return recentItems.map(({ channel, viewedAt }) => {
const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim();
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
channel,
viewedAt,
epgProgram,
progressPercentage: this.calculateProgress(epgProgram),
};
});
});
private calculateProgress(
epgProgram: EpgProgram | null | undefined
): number {
if (!epgProgram) {
return 0;
}
const now = new Date().getTime();
const start = new Date(epgProgram.start).getTime();
const stop = new Date(epgProgram.stop).getTime();
const total = stop - start;
const elapsed = now - start;
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
trackByFn(_: number, item: RecentViewItem): string {
return item.channel?.url;
}
onChannelClick(channel: Channel): void {
this.channelSelected.emit(channel);
}
onRemoveRecent(channel: Channel, event: MouseEvent): void {
event.stopPropagation();
this.removeRecent.emit(channel.url);
}
}