mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
1 parent
188ebb6fd9
commit
1e9f5d9d05
27 files changed
+1286
-227
No files matched your search
+19
@@ -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">
|
||||
|
||||
+117
-3
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -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>
|
||||
+9
@@ -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();
|
||||
}
|
||||
|
||||
+37
@@ -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>
|
||||
+33
@@ -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;
|
||||
}
|
||||
+80
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user