mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -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
@@ -32,10 +32,12 @@ import {
|
||||
FavoritesActions,
|
||||
PlaylistActions,
|
||||
selectActive,
|
||||
selectActivePlaylistId,
|
||||
selectChannels,
|
||||
selectCurrentEpgProgram,
|
||||
} from 'm3u-state';
|
||||
import {
|
||||
firstValueFrom,
|
||||
Observable,
|
||||
Subscription,
|
||||
combineLatest,
|
||||
@@ -64,7 +66,9 @@ import { DataService, PlaylistsService, SettingsStore } from 'services';
|
||||
import {
|
||||
Channel,
|
||||
EpgProgram,
|
||||
M3uRecentlyViewedItem,
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
PlaylistMeta,
|
||||
STORE_KEY,
|
||||
Settings,
|
||||
SidebarView,
|
||||
@@ -106,6 +110,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Active selected channel */
|
||||
readonly activeChannel = this.store.selectSignal(selectActive);
|
||||
readonly activePlaylistId = this.store.selectSignal(selectActivePlaylistId);
|
||||
readonly channels = this.store.selectSignal(selectChannels);
|
||||
|
||||
/** Channels list */
|
||||
readonly channels$: Observable<Channel[]> = this.store.select(
|
||||
@@ -115,7 +121,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
/** Current epg program */
|
||||
readonly epgProgram = this.store.selectSignal(selectCurrentEpgProgram);
|
||||
|
||||
/** Active M3U view (all, groups, favorites) */
|
||||
/** Active M3U view (all, groups, favorites, recent) */
|
||||
readonly activeView = toSignal(
|
||||
this.activatedRoute.params.pipe(
|
||||
map((params) => {
|
||||
@@ -153,6 +159,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
private statusSubscription?: Subscription;
|
||||
private routeSubscription?: Subscription;
|
||||
private lastKnownVolume = 1;
|
||||
private lastRecordedRecentKey = '';
|
||||
|
||||
/** Info overlay component reference for manual triggering */
|
||||
readonly infoOverlay = viewChild(InfoOverlayComponent);
|
||||
@@ -178,6 +185,62 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
showCaptions: this.settingsStore.showCaptions(),
|
||||
};
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const playlistId = this.activePlaylistId();
|
||||
const activeChannel = this.activeChannel();
|
||||
const currentUrl = this.router.url;
|
||||
|
||||
if (
|
||||
!playlistId ||
|
||||
!activeChannel?.url ||
|
||||
currentUrl.includes('/workspace/global-favorites')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKey = `${playlistId}::${activeChannel.url}`;
|
||||
if (this.lastRecordedRecentKey === nextKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastRecordedRecentKey = nextKey;
|
||||
void this.persistRecentlyViewedChannel(playlistId, activeChannel);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const currentView = this.activeView();
|
||||
const channels = this.channels();
|
||||
const activeChannel = this.activeChannel();
|
||||
const state =
|
||||
this.router.currentNavigation()?.extras?.state ??
|
||||
window.history.state;
|
||||
const targetUrl =
|
||||
typeof state?.openRecentChannelUrl === 'string'
|
||||
? state.openRecentChannelUrl.trim()
|
||||
: '';
|
||||
|
||||
if (currentView !== 'recent' || !targetUrl || channels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeChannel?.url === targetUrl) {
|
||||
this.clearConsumedRecentChannelState();
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedChannel = channels.find(
|
||||
(channel) => channel.url === targetUrl
|
||||
);
|
||||
if (!matchedChannel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
ChannelActions.setActiveChannel({ channel: matchedChannel })
|
||||
);
|
||||
this.clearConsumedRecentChannelState();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,6 +473,59 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private async persistRecentlyViewedChannel(
|
||||
playlistId: string,
|
||||
channel: Channel
|
||||
): Promise<void> {
|
||||
const recentlyViewedItem: M3uRecentlyViewedItem = {
|
||||
source: 'm3u',
|
||||
id: channel.url,
|
||||
url: channel.url,
|
||||
title: channel.name?.trim() || channel.tvg?.name || channel.url,
|
||||
channel_id: channel.id,
|
||||
poster_url: channel.tvg?.logo || undefined,
|
||||
tvg_id: channel.tvg?.id || undefined,
|
||||
tvg_name: channel.tvg?.name || undefined,
|
||||
group_title: channel.group?.title || undefined,
|
||||
category_id: 'live',
|
||||
added_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.addM3uRecentlyViewed(
|
||||
playlistId,
|
||||
recentlyViewedItem
|
||||
)
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: playlistId,
|
||||
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
||||
} as PlaylistMeta,
|
||||
}) as any
|
||||
);
|
||||
}
|
||||
|
||||
private clearConsumedRecentChannelState(): void {
|
||||
const historyState = (window.history.state ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (!historyState['openRecentChannelUrl']) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextState = { ...historyState };
|
||||
delete nextState['openRecentChannelUrl'];
|
||||
window.history.replaceState(nextState, document.title);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the overlay with multi EPG view
|
||||
*/
|
||||
|
||||
@@ -32,7 +32,7 @@ import { PortalStatus, PortalStatusService } from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
|
||||
type PlaylistRouteProvider = 'playlists' | 'xtreams' | 'stalker';
|
||||
type PlaylistSectionProvider = Exclude<PlaylistRouteProvider, 'playlists'>;
|
||||
type PlaylistSectionProvider = PlaylistRouteProvider;
|
||||
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';
|
||||
|
||||
interface PlaylistRouteContext {
|
||||
@@ -83,6 +83,7 @@ const STALKER_SECTIONS = [
|
||||
'search',
|
||||
'downloads',
|
||||
] as const;
|
||||
const M3U_SECTIONS = ['all', 'groups', 'favorites', 'recent'] as const;
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-switcher',
|
||||
@@ -292,11 +293,7 @@ export class PlaylistSwitcherComponent {
|
||||
|
||||
private rememberSectionFromRoute(url: string): void {
|
||||
const routeContext = this.getRouteContext(url);
|
||||
if (
|
||||
!routeContext.provider ||
|
||||
routeContext.provider === 'playlists' ||
|
||||
!routeContext.section
|
||||
) {
|
||||
if (!routeContext.provider || !routeContext.section) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -502,7 +499,12 @@ export class PlaylistSwitcherComponent {
|
||||
}
|
||||
|
||||
if (provider === 'playlists') {
|
||||
return [...prefix, 'playlists', playlistId];
|
||||
const section = this.resolveTargetSection(
|
||||
provider,
|
||||
routeContext.section,
|
||||
playlistId
|
||||
);
|
||||
return [...prefix, 'playlists', playlistId, section];
|
||||
}
|
||||
|
||||
if (!this.supportsSectionNavigation(provider)) {
|
||||
@@ -551,6 +553,10 @@ export class PlaylistSwitcherComponent {
|
||||
return normalizedProviderSection;
|
||||
}
|
||||
|
||||
if (provider === 'playlists') {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
return 'vod';
|
||||
}
|
||||
|
||||
@@ -582,6 +588,14 @@ export class PlaylistSwitcherComponent {
|
||||
: null;
|
||||
}
|
||||
|
||||
if (provider === 'playlists') {
|
||||
return M3U_SECTIONS.includes(
|
||||
section as (typeof M3U_SECTIONS)[number]
|
||||
)
|
||||
? section
|
||||
: null;
|
||||
}
|
||||
|
||||
if (section === 'live') {
|
||||
return 'itv';
|
||||
}
|
||||
@@ -620,6 +634,9 @@ export class PlaylistSwitcherComponent {
|
||||
parsedProviders !== null
|
||||
) {
|
||||
const candidate = parsedProviders as Record<string, unknown>;
|
||||
if (typeof candidate['playlists'] === 'string') {
|
||||
providers.playlists = candidate['playlists'];
|
||||
}
|
||||
if (typeof candidate['xtreams'] === 'string') {
|
||||
providers.xtreams = candidate['xtreams'];
|
||||
}
|
||||
@@ -628,8 +645,11 @@ export class PlaylistSwitcherComponent {
|
||||
}
|
||||
} else {
|
||||
// Backward compatibility with previous format:
|
||||
// { xtreams: "...", stalker: "..." }
|
||||
// { playlists: "...", xtreams: "...", stalker: "..." }
|
||||
const legacy = parsed as Record<string, unknown>;
|
||||
if (typeof legacy['playlists'] === 'string') {
|
||||
providers.playlists = legacy['playlists'];
|
||||
}
|
||||
if (typeof legacy['xtreams'] === 'string') {
|
||||
providers.xtreams = legacy['xtreams'];
|
||||
}
|
||||
@@ -657,7 +677,9 @@ export class PlaylistSwitcherComponent {
|
||||
const section = row['section'];
|
||||
const updatedAt = row['updatedAt'];
|
||||
const isProviderValid =
|
||||
provider === 'xtreams' || provider === 'stalker';
|
||||
provider === 'playlists' ||
|
||||
provider === 'xtreams' ||
|
||||
provider === 'stalker';
|
||||
if (!isProviderValid || typeof section !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export interface FavoriteLayoutItem {
|
||||
readonly playlist_id?: string;
|
||||
readonly playlist_name?: string;
|
||||
readonly poster_url?: string;
|
||||
readonly source?: 'xtream' | 'stalker';
|
||||
readonly source?: 'xtream' | 'stalker' | 'm3u';
|
||||
readonly stalker_item?: StalkerPortalItem;
|
||||
readonly stream_icon?: string;
|
||||
readonly title?: string;
|
||||
|
||||
@@ -94,6 +94,13 @@ describe('buildPortalRailLinks', () => {
|
||||
exact: true,
|
||||
section: 'groups',
|
||||
},
|
||||
{
|
||||
icon: 'history',
|
||||
tooltip: 'Recently viewed (this playlist)',
|
||||
path: ['/workspace', 'playlists', 'm3u-1', 'recent'],
|
||||
exact: true,
|
||||
section: 'recent',
|
||||
},
|
||||
{
|
||||
icon: 'star',
|
||||
tooltip: 'Favorites (this playlist)',
|
||||
|
||||
@@ -187,6 +187,13 @@ export function buildPortalRailLinks(
|
||||
exact: true,
|
||||
section: 'groups',
|
||||
},
|
||||
{
|
||||
icon: 'history',
|
||||
tooltip: 'Recently viewed (this playlist)',
|
||||
path: [...root, 'recent'],
|
||||
exact: true,
|
||||
section: 'recent',
|
||||
},
|
||||
{
|
||||
icon: 'star',
|
||||
tooltip: 'Favorites (this playlist)',
|
||||
|
||||
@@ -9,11 +9,7 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
|
||||
import { firstValueFrom, pipe, switchMap, tap } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import {
|
||||
extractStalkerItemId,
|
||||
extractStalkerItemPoster,
|
||||
extractStalkerItemTitle,
|
||||
extractStalkerItemType,
|
||||
normalizeStalkerDate,
|
||||
buildPlaylistRecentItems,
|
||||
Playlist,
|
||||
PortalRecentItem,
|
||||
} from 'shared-interfaces';
|
||||
@@ -148,61 +144,14 @@ export const withRecentItems = function () {
|
||||
const playlists = (await firstValueFrom(
|
||||
playlistsService.getAllPlaylists()
|
||||
)) as Playlist[];
|
||||
const stalkerItems: RecentlyViewedItem[] = playlists
|
||||
.filter((playlist) => Boolean(playlist.macAddress))
|
||||
.reduce(
|
||||
(acc: RecentlyViewedItem[], playlist) => {
|
||||
const recent = Array.isArray(
|
||||
playlist.recentlyViewed
|
||||
)
|
||||
? playlist.recentlyViewed
|
||||
: [];
|
||||
const mapped = recent.map(
|
||||
(rawItem, index: number) => {
|
||||
const item = (rawItem ??
|
||||
{}) as Record<string, unknown>;
|
||||
const id = extractStalkerItemId(
|
||||
item,
|
||||
String(playlist._id),
|
||||
index
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
title: extractStalkerItemTitle(
|
||||
item
|
||||
),
|
||||
type: extractStalkerItemType(
|
||||
item
|
||||
),
|
||||
poster_url:
|
||||
extractStalkerItemPoster(
|
||||
item
|
||||
),
|
||||
content_id: id,
|
||||
playlist_id: String(
|
||||
playlist._id
|
||||
),
|
||||
playlist_name:
|
||||
playlist.title ||
|
||||
'Stalker Portal',
|
||||
viewed_at: normalizeStalkerDate(
|
||||
item['added_at']
|
||||
),
|
||||
xtream_id: id,
|
||||
category_id: String(
|
||||
item['category_id'] ?? ''
|
||||
),
|
||||
source: 'stalker' as const,
|
||||
stalker_item: rawItem,
|
||||
};
|
||||
}
|
||||
);
|
||||
acc.push(...mapped);
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
const playlistBackedItems =
|
||||
buildPlaylistRecentItems(playlists, {
|
||||
stalker: 'Stalker Portal',
|
||||
m3u: 'M3U',
|
||||
}).map((item) => ({
|
||||
...item,
|
||||
content_id: item.id,
|
||||
})) as RecentlyViewedItem[];
|
||||
|
||||
const normalizedXtream: RecentlyViewedItem[] = (
|
||||
xtreamItems || []
|
||||
@@ -222,7 +171,7 @@ export const withRecentItems = function () {
|
||||
|
||||
const items = [
|
||||
...normalizedXtream,
|
||||
...stalkerItems,
|
||||
...playlistBackedItems,
|
||||
].sort(
|
||||
(a, b) =>
|
||||
new Date(b.viewed_at).getTime() -
|
||||
@@ -247,12 +196,14 @@ export const withRecentItems = function () {
|
||||
)) as Playlist[];
|
||||
await Promise.all(
|
||||
playlists
|
||||
.filter((playlist) =>
|
||||
Boolean(playlist.macAddress)
|
||||
.filter(
|
||||
(playlist) =>
|
||||
Boolean(playlist.macAddress) ||
|
||||
!playlist.serverUrl
|
||||
)
|
||||
.map((playlist) =>
|
||||
firstValueFrom(
|
||||
playlistsService.clearPortalRecentlyViewed(
|
||||
playlistsService.clearPlaylistRecentlyViewed(
|
||||
playlist._id
|
||||
)
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<div class="global-recent-subheader-left">
|
||||
<div class="global-recent-note">
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
<span>Includes Xtream and Stalker items</span>
|
||||
<span>Includes Xtream, Stalker, and M3U items</span>
|
||||
</div>
|
||||
<div class="global-recent-filters">
|
||||
<mat-checkbox
|
||||
|
||||
@@ -414,6 +414,21 @@ export class RecentlyViewedComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === 'm3u' && this.isGlobal) {
|
||||
this.dialogRef?.close();
|
||||
this.router.navigate(
|
||||
['/workspace', 'playlists', item.playlist_id, 'recent'],
|
||||
{
|
||||
state: {
|
||||
openRecentChannelUrl: String(
|
||||
item.xtream_id ?? item.id ?? ''
|
||||
),
|
||||
},
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = item.type === 'movie' ? 'vod' : item.type;
|
||||
this.xtreamStore.setSelectedContentType(type);
|
||||
|
||||
@@ -464,6 +479,13 @@ export class RecentlyViewedComponent {
|
||||
item.id
|
||||
)
|
||||
);
|
||||
} else if (item.source === 'm3u') {
|
||||
await firstValueFrom(
|
||||
this.playlistsService.removeFromM3uRecentlyViewed(
|
||||
item.playlist_id,
|
||||
String(item.xtream_id ?? item.id)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
await this.dbService.removeRecentItem(
|
||||
Number(item.id),
|
||||
|
||||
@@ -21,10 +21,15 @@ import {
|
||||
import {
|
||||
Channel,
|
||||
DbStores,
|
||||
extractStalkerItemId,
|
||||
isM3uRecentlyViewedItem,
|
||||
M3uRecentlyViewedItem,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
PlaylistRecentlyViewedItem,
|
||||
PlaylistUpdateState,
|
||||
StalkerPortalItem,
|
||||
normalizeStalkerDate,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
@@ -35,11 +40,6 @@ type PortalFavoriteItem = StalkerPortalItem & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type PortalRecentlyViewedItem = StalkerPortalItem & {
|
||||
id?: string | number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type PlaylistRawItem = {
|
||||
raw?: string;
|
||||
};
|
||||
@@ -654,73 +654,102 @@ export class PlaylistsService {
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
getPortalRecentlyViewed(portalId: string) {
|
||||
if (!portalId) {
|
||||
throw new Error('Portal ID is required');
|
||||
private normalizePortalRecentIdentity(value: unknown): string {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
return raw.includes(':') ? raw.split(':')[0] : raw;
|
||||
}
|
||||
|
||||
private getPlaylistRecentIdentity(item: PlaylistRecentlyViewedItem): string {
|
||||
if (isM3uRecentlyViewedItem(item)) {
|
||||
return String(item.url ?? item.id ?? '').trim();
|
||||
}
|
||||
|
||||
return this.normalizePortalRecentIdentity(
|
||||
extractStalkerItemId(item ?? {})
|
||||
);
|
||||
}
|
||||
|
||||
private matchesPlaylistRecentIdentity(
|
||||
item: PlaylistRecentlyViewedItem,
|
||||
expectedIdentity: string | number
|
||||
): boolean {
|
||||
const expectedRaw = String(expectedIdentity ?? '').trim();
|
||||
if (!expectedRaw) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isM3uRecentlyViewedItem(item)) {
|
||||
return this.getPlaylistRecentIdentity(item) === expectedRaw;
|
||||
}
|
||||
|
||||
return (
|
||||
this.getPlaylistRecentIdentity(item) ===
|
||||
this.normalizePortalRecentIdentity(expectedRaw)
|
||||
);
|
||||
}
|
||||
|
||||
private sortPlaylistRecentItems(
|
||||
items: PlaylistRecentlyViewedItem[]
|
||||
): PlaylistRecentlyViewedItem[] {
|
||||
return [...items].sort(
|
||||
(a, b) =>
|
||||
new Date(normalizeStalkerDate(b.added_at ?? '')).getTime() -
|
||||
new Date(normalizeStalkerDate(a.added_at ?? '')).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
getPlaylistRecentlyViewed(playlistId: string) {
|
||||
if (!playlistId) {
|
||||
throw new Error('Playlist ID is required');
|
||||
}
|
||||
|
||||
return this.getPlaylistById(playlistId).pipe(
|
||||
map((item) => {
|
||||
if (!item || !item.recentlyViewed) return [];
|
||||
return item.recentlyViewed as PortalRecentlyViewedItem[];
|
||||
if (!item || !Array.isArray(item.recentlyViewed)) {
|
||||
return [];
|
||||
}
|
||||
return item.recentlyViewed as PlaylistRecentlyViewedItem[];
|
||||
}),
|
||||
map((items) =>
|
||||
items.sort(
|
||||
(a, b) =>
|
||||
new Date(b.added_at ?? '').getTime() -
|
||||
new Date(a.added_at ?? '').getTime()
|
||||
)
|
||||
)
|
||||
map((items) => this.sortPlaylistRecentItems(items))
|
||||
);
|
||||
}
|
||||
|
||||
addPortalRecentlyViewed(
|
||||
portalId: string,
|
||||
item: PortalRecentlyViewedItem & { id: string | number; title: string }
|
||||
addPlaylistRecentlyViewed(
|
||||
playlistId: string,
|
||||
item: PlaylistRecentlyViewedItem
|
||||
) {
|
||||
if (!portalId) {
|
||||
throw new Error('Portal ID is required');
|
||||
if (!playlistId) {
|
||||
throw new Error('Playlist ID is required');
|
||||
}
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
switchMap((portal) => {
|
||||
|
||||
return this.getPlaylistById(playlistId).pipe(
|
||||
switchMap((playlist) => {
|
||||
const nowIso = new Date().toISOString();
|
||||
const recentItems =
|
||||
(portal.recentlyViewed as PortalRecentlyViewedItem[]) ?? [];
|
||||
const existingIndex = recentItems.findIndex(
|
||||
(recentItem) => String(recentItem?.id) === String(item.id)
|
||||
const recentItems = Array.isArray(playlist.recentlyViewed)
|
||||
? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[])
|
||||
: [];
|
||||
const existingIndex = recentItems.findIndex((recentItem) =>
|
||||
this.matchesPlaylistRecentIdentity(
|
||||
recentItem,
|
||||
this.getPlaylistRecentIdentity(item)
|
||||
)
|
||||
);
|
||||
const existingItem =
|
||||
existingIndex >= 0 ? recentItems[existingIndex] : null;
|
||||
const nextItem: PlaylistRecentlyViewedItem = {
|
||||
...(existingItem ?? {}),
|
||||
...item,
|
||||
added_at: nowIso,
|
||||
};
|
||||
const remainingItems = recentItems.filter(
|
||||
(_, index) => index !== existingIndex
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const updatedRecentItems = [...recentItems];
|
||||
updatedRecentItems[existingIndex] = {
|
||||
...updatedRecentItems[existingIndex],
|
||||
...item,
|
||||
added_at: nowIso,
|
||||
};
|
||||
|
||||
const nextPlaylist: Playlist = {
|
||||
...portal,
|
||||
recentlyViewed: updatedRecentItems,
|
||||
};
|
||||
|
||||
if (this.isElectronStorageAvailable) {
|
||||
return this.upsertSqlitePlaylist(nextPlaylist);
|
||||
}
|
||||
|
||||
return this.dbService.update(
|
||||
DbStores.Playlists,
|
||||
nextPlaylist
|
||||
);
|
||||
}
|
||||
|
||||
const nextPlaylist: Playlist = {
|
||||
...portal,
|
||||
recentlyViewed: [
|
||||
...recentItems,
|
||||
{
|
||||
...item,
|
||||
added_at: item.added_at ?? nowIso,
|
||||
},
|
||||
],
|
||||
...playlist,
|
||||
recentlyViewed: [nextItem, ...remainingItems],
|
||||
};
|
||||
|
||||
if (this.isElectronStorageAvailable) {
|
||||
@@ -732,49 +761,47 @@ export class PlaylistsService {
|
||||
);
|
||||
}
|
||||
|
||||
removeFromPortalRecentlyViewed(portalId: string, id: string | number) {
|
||||
const normalizePortalItemId = (value: unknown): string => {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return '';
|
||||
return raw.includes(':') ? raw.split(':')[0] : raw;
|
||||
};
|
||||
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
switchMap((portal) => {
|
||||
const expectedId = String(id);
|
||||
const expectedNormalized = normalizePortalItemId(id);
|
||||
|
||||
const nextPlaylist: Playlist = {
|
||||
...portal,
|
||||
recentlyViewed: (
|
||||
portal.recentlyViewed as PortalRecentlyViewedItem[]
|
||||
)?.filter((item) => {
|
||||
const itemId = String(item?.id ?? '');
|
||||
const itemNormalized = normalizePortalItemId(itemId);
|
||||
return (
|
||||
itemId !== expectedId &&
|
||||
itemNormalized !== expectedNormalized
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
if (this.isElectronStorageAvailable) {
|
||||
return this.upsertSqlitePlaylist(nextPlaylist);
|
||||
}
|
||||
|
||||
return this.dbService.update(DbStores.Playlists, nextPlaylist);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clearPortalRecentlyViewed(portalId: string) {
|
||||
if (!portalId) {
|
||||
throw new Error('Portal ID is required');
|
||||
removeFromPlaylistRecentlyViewed(
|
||||
playlistId: string,
|
||||
identity: string | number
|
||||
) {
|
||||
if (!playlistId) {
|
||||
throw new Error('Playlist ID is required');
|
||||
}
|
||||
return this.getPlaylistById(portalId).pipe(
|
||||
switchMap((portal) => {
|
||||
|
||||
return this.getPlaylistById(playlistId).pipe(
|
||||
switchMap((playlist) => {
|
||||
const nextPlaylist: Playlist = {
|
||||
...portal,
|
||||
...playlist,
|
||||
recentlyViewed: (
|
||||
playlist.recentlyViewed as PlaylistRecentlyViewedItem[]
|
||||
)?.filter(
|
||||
(item) =>
|
||||
!this.matchesPlaylistRecentIdentity(
|
||||
item,
|
||||
identity
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
if (this.isElectronStorageAvailable) {
|
||||
return this.upsertSqlitePlaylist(nextPlaylist);
|
||||
}
|
||||
|
||||
return this.dbService.update(DbStores.Playlists, nextPlaylist);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clearPlaylistRecentlyViewed(playlistId: string) {
|
||||
if (!playlistId) {
|
||||
throw new Error('Playlist ID is required');
|
||||
}
|
||||
|
||||
return this.getPlaylistById(playlistId).pipe(
|
||||
switchMap((playlist) => {
|
||||
const nextPlaylist: Playlist = {
|
||||
...playlist,
|
||||
recentlyViewed: [],
|
||||
};
|
||||
|
||||
@@ -786,4 +813,42 @@ export class PlaylistsService {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getPortalRecentlyViewed(portalId: string) {
|
||||
return this.getPlaylistRecentlyViewed(portalId).pipe(
|
||||
map((items) =>
|
||||
items.filter(
|
||||
(item): item is StalkerPortalItem =>
|
||||
!isM3uRecentlyViewedItem(item)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
addPortalRecentlyViewed(
|
||||
portalId: string,
|
||||
item: StalkerPortalItem & { id: string | number; title: string }
|
||||
) {
|
||||
return this.addPlaylistRecentlyViewed(portalId, item);
|
||||
}
|
||||
|
||||
addM3uRecentlyViewed(playlistId: string, item: M3uRecentlyViewedItem) {
|
||||
return this.addPlaylistRecentlyViewed(playlistId, item);
|
||||
}
|
||||
|
||||
removeFromPortalRecentlyViewed(portalId: string, id: string | number) {
|
||||
return this.removeFromPlaylistRecentlyViewed(portalId, id);
|
||||
}
|
||||
|
||||
removeFromM3uRecentlyViewed(playlistId: string, channelUrl: string) {
|
||||
return this.removeFromPlaylistRecentlyViewed(playlistId, channelUrl);
|
||||
}
|
||||
|
||||
clearPortalRecentlyViewed(portalId: string) {
|
||||
return this.clearPlaylistRecentlyViewed(portalId);
|
||||
}
|
||||
|
||||
clearM3uRecentlyViewed(playlistId: string) {
|
||||
return this.clearPlaylistRecentlyViewed(playlistId);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export * from './lib/language.enum';
|
||||
export * from './lib/parsed-playlist.interface';
|
||||
export * from './lib/playback-position.interface';
|
||||
export * from './lib/playlist-meta.type';
|
||||
export * from './lib/playlist-recently-viewed.interface';
|
||||
export * from './lib/playlist-recently-viewed.utils';
|
||||
export * from './lib/playlist.interface';
|
||||
export * from './lib/portal-activity-item.interface';
|
||||
export * from './lib/portal-debug.interface';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { StalkerPortalItem } from './stalker-portal-item.interface';
|
||||
|
||||
export interface M3uRecentlyViewedItem {
|
||||
source: 'm3u';
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
channel_id?: string;
|
||||
poster_url?: string;
|
||||
tvg_id?: string;
|
||||
tvg_name?: string;
|
||||
group_title?: string;
|
||||
category_id: 'live';
|
||||
added_at: string | number;
|
||||
}
|
||||
|
||||
export type PlaylistRecentlyViewedItem =
|
||||
| M3uRecentlyViewedItem
|
||||
| StalkerPortalItem;
|
||||
|
||||
export function isM3uRecentlyViewedItem(
|
||||
item: unknown
|
||||
): item is M3uRecentlyViewedItem {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = item as Record<string, unknown>;
|
||||
return (
|
||||
candidate['source'] === 'm3u' &&
|
||||
typeof candidate['url'] === 'string' &&
|
||||
candidate['url'].trim().length > 0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { PlaylistMeta } from './playlist-meta.type';
|
||||
import { PortalRecentItem } from './portal-activity-item.interface';
|
||||
import {
|
||||
extractStalkerItemId,
|
||||
extractStalkerItemPoster,
|
||||
extractStalkerItemTitle,
|
||||
extractStalkerItemType,
|
||||
normalizeStalkerDate,
|
||||
} from './stalker-item.normalizer';
|
||||
import {
|
||||
PlaylistRecentlyViewedItem,
|
||||
isM3uRecentlyViewedItem,
|
||||
} from './playlist-recently-viewed.interface';
|
||||
|
||||
interface PlaylistRecentLabels {
|
||||
stalker: string;
|
||||
m3u: string;
|
||||
}
|
||||
|
||||
function getPlaylistRecentItems(
|
||||
playlist: PlaylistMeta
|
||||
): PlaylistRecentlyViewedItem[] {
|
||||
return Array.isArray(playlist.recentlyViewed)
|
||||
? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[])
|
||||
: [];
|
||||
}
|
||||
|
||||
function mapStalkerPlaylistRecentItems(
|
||||
playlist: PlaylistMeta,
|
||||
defaultPlaylistName: string
|
||||
): PortalRecentItem[] {
|
||||
return getPlaylistRecentItems(playlist).reduce<PortalRecentItem[]>(
|
||||
(acc, rawItem, index) => {
|
||||
if (isM3uRecentlyViewedItem(rawItem)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const item = (rawItem ?? {}) as Record<string, unknown>;
|
||||
const id = extractStalkerItemId(item, playlist._id, index);
|
||||
|
||||
acc.push({
|
||||
id,
|
||||
title: extractStalkerItemTitle(item),
|
||||
type: extractStalkerItemType(item),
|
||||
playlist_id: playlist._id,
|
||||
playlist_name: playlist.title || defaultPlaylistName,
|
||||
viewed_at: normalizeStalkerDate(item['added_at']),
|
||||
category_id: String(item['category_id'] ?? ''),
|
||||
xtream_id: id,
|
||||
poster_url: extractStalkerItemPoster(item),
|
||||
source: 'stalker',
|
||||
stalker_item: rawItem,
|
||||
});
|
||||
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
function mapM3uPlaylistRecentItems(
|
||||
playlist: PlaylistMeta,
|
||||
defaultPlaylistName: string
|
||||
): PortalRecentItem[] {
|
||||
return getPlaylistRecentItems(playlist).reduce<PortalRecentItem[]>(
|
||||
(acc, rawItem) => {
|
||||
if (!isM3uRecentlyViewedItem(rawItem)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const channelUrl = rawItem.url.trim();
|
||||
if (!channelUrl) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push({
|
||||
id: rawItem.id || channelUrl,
|
||||
title:
|
||||
rawItem.title?.trim() ||
|
||||
rawItem.tvg_name?.trim() ||
|
||||
rawItem.channel_id ||
|
||||
channelUrl,
|
||||
type: 'live',
|
||||
playlist_id: playlist._id,
|
||||
playlist_name: playlist.title || defaultPlaylistName,
|
||||
viewed_at: normalizeStalkerDate(rawItem.added_at),
|
||||
category_id: rawItem.category_id || 'live',
|
||||
xtream_id: channelUrl,
|
||||
poster_url: rawItem.poster_url || undefined,
|
||||
source: 'm3u',
|
||||
});
|
||||
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPlaylistRecentItems(
|
||||
playlists: PlaylistMeta[],
|
||||
labels: PlaylistRecentLabels
|
||||
): PortalRecentItem[] {
|
||||
return playlists.reduce<PortalRecentItem[]>((acc, playlist) => {
|
||||
if (playlist.macAddress) {
|
||||
acc.push(
|
||||
...mapStalkerPlaylistRecentItems(playlist, labels.stalker)
|
||||
);
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (!playlist.serverUrl) {
|
||||
acc.push(...mapM3uPlaylistRecentItems(playlist, labels.m3u));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PlaylistRecentlyViewedItem } from './playlist-recently-viewed.interface';
|
||||
import { StalkerPortalItem } from './stalker-portal-item.interface';
|
||||
|
||||
/**
|
||||
@@ -42,7 +43,7 @@ export interface Playlist {
|
||||
password?: string;
|
||||
macAddress?: string;
|
||||
portalUrl?: string;
|
||||
recentlyViewed?: any[];
|
||||
recentlyViewed?: PlaylistRecentlyViewedItem[];
|
||||
/** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */
|
||||
isFullStalkerPortal?: boolean;
|
||||
/** Session token for full stalker portal authentication - persisted for session */
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface PortalActivityItem {
|
||||
category_id: string | number;
|
||||
xtream_id: string | number;
|
||||
poster_url?: string;
|
||||
source?: 'xtream' | 'stalker';
|
||||
source?: 'xtream' | 'stalker' | 'm3u';
|
||||
/** Original stalker item for navigation state; undefined for xtream. */
|
||||
stalker_item?: StalkerPortalItem;
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,5 @@
|
||||
<app-channel-list-container
|
||||
[channelList]="channels()"
|
||||
[activeView]="activeView()"
|
||||
[recentItems]="activePlaylist()?.recentlyViewed ?? []"
|
||||
/>
|
||||
@@ -6,7 +6,7 @@ import { RouterLink } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { selectPlaylistTitle } from 'm3u-state';
|
||||
import { selectActivePlaylist, selectPlaylistTitle } from 'm3u-state';
|
||||
import { Channel } from 'shared-interfaces';
|
||||
import { ChannelListContainerComponent } from 'components';
|
||||
|
||||
@@ -33,6 +33,7 @@ export class SidebarComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly playlistTitle = this.store.selectSignal(selectPlaylistTitle);
|
||||
readonly activePlaylist = this.store.selectSignal(selectActivePlaylist);
|
||||
|
||||
readonly subtitle = computed(() => {
|
||||
const count = this.channels()?.length ?? 0;
|
||||
|
||||
@@ -4,11 +4,11 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { of } from 'rxjs';
|
||||
import { DatabaseService } from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import { Playlist, PlaylistMeta } from 'shared-interfaces';
|
||||
import { DashboardDataService } from './dashboard-data.service';
|
||||
|
||||
describe('DashboardDataService.matchesScope', () => {
|
||||
describe('DashboardDataService', () => {
|
||||
let service: DashboardDataService;
|
||||
|
||||
const playlistsSignal = signal<PlaylistMeta[]>([
|
||||
@@ -18,6 +18,22 @@ describe('DashboardDataService.matchesScope', () => {
|
||||
count: 1,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: ['channel-1', 'https://example.com/stream-2.m3u8'],
|
||||
recentlyViewed: [
|
||||
{
|
||||
source: 'm3u',
|
||||
id: 'https://example.com/stream-1.m3u8',
|
||||
url: 'https://example.com/stream-1.m3u8',
|
||||
title: 'Channel One',
|
||||
channel_id: 'channel-1',
|
||||
poster_url: 'https://example.com/logo-1.png',
|
||||
tvg_id: 'tvg-1',
|
||||
tvg_name: 'Channel One',
|
||||
group_title: 'News',
|
||||
category_id: 'live',
|
||||
added_at: '2026-02-01T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
_id: 'xtream-1',
|
||||
@@ -36,19 +52,90 @@ describe('DashboardDataService.matchesScope', () => {
|
||||
}
|
||||
return signal(null);
|
||||
}),
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
|
||||
const dbServiceMock = {
|
||||
getGlobalRecentlyViewed: jest.fn(),
|
||||
getGlobalFavorites: jest.fn(),
|
||||
getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]),
|
||||
getGlobalFavorites: jest.fn().mockResolvedValue([]),
|
||||
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
|
||||
removeRecentItem: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const playlistMock: Playlist = {
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U Playlist',
|
||||
count: 2,
|
||||
importDate: '2026-01-01T00:00:00.000Z',
|
||||
lastUsage: '2026-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: ['channel-1', 'https://example.com/stream-2.m3u8'],
|
||||
playlist: {
|
||||
items: [
|
||||
{
|
||||
id: 'channel-1',
|
||||
name: 'Channel One',
|
||||
url: 'https://example.com/stream-1.m3u8',
|
||||
tvg: {
|
||||
logo: 'https://example.com/logo-1.png',
|
||||
id: 'tvg-1',
|
||||
name: 'Channel One',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'channel-2',
|
||||
name: 'Channel Two',
|
||||
url: 'https://example.com/stream-2.m3u8',
|
||||
tvg: {
|
||||
logo: 'https://example.com/logo-2.png',
|
||||
id: 'tvg-2',
|
||||
name: 'Channel Two',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Playlist;
|
||||
const playlistsServiceMock = {
|
||||
getPlaylistById: jest.fn().mockReturnValue(of(playlistMock)),
|
||||
setFavorites: jest.fn().mockReturnValue(of(undefined)),
|
||||
removeFromM3uRecentlyViewed: jest
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
of({
|
||||
...playlistMock,
|
||||
recentlyViewed: [],
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
playlistsServiceMock.getPlaylistById.mockClear();
|
||||
playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock));
|
||||
playlistsServiceMock.setFavorites.mockClear();
|
||||
playlistsServiceMock.setFavorites.mockReturnValue(of(undefined));
|
||||
playlistsServiceMock.removeFromM3uRecentlyViewed.mockClear();
|
||||
playlistsServiceMock.removeFromM3uRecentlyViewed.mockReturnValue(
|
||||
of({
|
||||
...playlistMock,
|
||||
recentlyViewed: [],
|
||||
})
|
||||
);
|
||||
dbServiceMock.getGlobalFavorites.mockClear();
|
||||
dbServiceMock.getGlobalFavorites.mockResolvedValue([]);
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockClear();
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
|
||||
dbServiceMock.removeFromFavorites.mockClear();
|
||||
dbServiceMock.removeRecentItem.mockClear();
|
||||
storeMock.dispatch.mockClear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardDataService,
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{ provide: DatabaseService, useValue: dbServiceMock },
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsServiceMock,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
@@ -102,4 +189,118 @@ describe('DashboardDataService.matchesScope', () => {
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('includes M3U favorites in global favorite items', async () => {
|
||||
await service.reloadGlobalFavorites();
|
||||
|
||||
expect(service.globalFavoriteItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'channel-1',
|
||||
title: 'Channel One',
|
||||
type: 'live',
|
||||
playlist_id: 'm3u-1',
|
||||
source: 'm3u',
|
||||
poster_url: 'https://example.com/logo-1.png',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'https://example.com/stream-2.m3u8',
|
||||
title: 'Channel Two',
|
||||
type: 'live',
|
||||
playlist_id: 'm3u-1',
|
||||
source: 'm3u',
|
||||
poster_url: 'https://example.com/logo-2.png',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('builds the M3U favorites route', async () => {
|
||||
await service.reloadGlobalFavorites();
|
||||
const m3uItem = service
|
||||
.globalFavoriteItems()
|
||||
.find((item) => item.source === 'm3u');
|
||||
|
||||
expect(m3uItem).toBeDefined();
|
||||
expect(service.getGlobalFavoriteLink(m3uItem!)).toEqual([
|
||||
'/workspace',
|
||||
'playlists',
|
||||
'm3u-1',
|
||||
'favorites',
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes M3U favorites via PlaylistsService', async () => {
|
||||
await service.reloadGlobalFavorites();
|
||||
const m3uItem = service
|
||||
.globalFavoriteItems()
|
||||
.find((item) => item.id === 'channel-1');
|
||||
|
||||
expect(m3uItem).toBeDefined();
|
||||
|
||||
await service.removeGlobalFavorite(m3uItem!);
|
||||
|
||||
expect(playlistsServiceMock.setFavorites).toHaveBeenCalledWith(
|
||||
'm3u-1',
|
||||
['https://example.com/stream-2.m3u8']
|
||||
);
|
||||
});
|
||||
|
||||
it('matches M3U provider scope', () => {
|
||||
const result = service.matchesScope('m3u-1', 'm3u', {
|
||||
providers: ['m3u'],
|
||||
playlistIds: [],
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('includes M3U recently viewed items in global recent items', () => {
|
||||
expect(service.globalRecentItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'https://example.com/stream-1.m3u8',
|
||||
title: 'Channel One',
|
||||
type: 'live',
|
||||
playlist_id: 'm3u-1',
|
||||
source: 'm3u',
|
||||
xtream_id: 'https://example.com/stream-1.m3u8',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('builds the M3U recent route with navigation state', () => {
|
||||
const m3uItem = service
|
||||
.globalRecentItems()
|
||||
.find((item) => item.source === 'm3u');
|
||||
|
||||
expect(m3uItem).toBeDefined();
|
||||
expect(service.getRecentItemLink(m3uItem!)).toEqual([
|
||||
'/workspace',
|
||||
'playlists',
|
||||
'm3u-1',
|
||||
'recent',
|
||||
]);
|
||||
expect(service.getRecentItemNavigationState(m3uItem!)).toEqual({
|
||||
openRecentChannelUrl: 'https://example.com/stream-1.m3u8',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes M3U recently viewed via PlaylistsService', async () => {
|
||||
const m3uItem = service
|
||||
.globalRecentItems()
|
||||
.find((item) => item.source === 'm3u');
|
||||
|
||||
expect(m3uItem).toBeDefined();
|
||||
|
||||
await service.removeGlobalRecentItem(m3uItem!);
|
||||
|
||||
expect(
|
||||
playlistsServiceMock.removeFromM3uRecentlyViewed
|
||||
).toHaveBeenCalledWith(
|
||||
'm3u-1',
|
||||
'https://example.com/stream-1.m3u8'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
selectActivePlaylist,
|
||||
selectAllPlaylistsMeta,
|
||||
} from 'm3u-state';
|
||||
import { startWith } from 'rxjs';
|
||||
import { DatabaseService } from 'services';
|
||||
import { firstValueFrom, startWith } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import {
|
||||
buildPlaylistRecentItems,
|
||||
Channel,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
PortalActivityType,
|
||||
PortalFavoriteItem,
|
||||
@@ -18,7 +21,6 @@ import {
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
buildStalkerFavoriteItems,
|
||||
buildStalkerRecentItems,
|
||||
getActivityTypeLabelKey,
|
||||
mapDbFavoriteToItem,
|
||||
mapDbRecentToItem,
|
||||
@@ -46,6 +48,7 @@ export type DashboardFavoriteItem = PortalFavoriteItem;
|
||||
export class DashboardDataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = toSignal(
|
||||
@@ -57,19 +60,27 @@ export class DashboardDataService {
|
||||
private readonly xtreamGlobalFavorites = signal<DashboardFavoriteItem[]>(
|
||||
[]
|
||||
);
|
||||
private readonly m3uGlobalFavorites = signal<DashboardFavoriteItem[]>([]);
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
readonly activePlaylist = this.store.selectSignal(selectActivePlaylist);
|
||||
|
||||
readonly stalkerGlobalRecentItems = computed<GlobalRecentItem[]>(() => {
|
||||
readonly playlistBackedGlobalRecentItems = computed<GlobalRecentItem[]>(
|
||||
() => {
|
||||
this.languageTick();
|
||||
return buildStalkerRecentItems(
|
||||
this.playlists(),
|
||||
this.translateText('WORKSPACE.DASHBOARD.STALKER_PORTAL')
|
||||
);
|
||||
});
|
||||
return buildPlaylistRecentItems(this.playlists(), {
|
||||
stalker: this.translateText(
|
||||
'WORKSPACE.DASHBOARD.STALKER_PORTAL'
|
||||
),
|
||||
m3u: this.translateText('WORKSPACE.DASHBOARD.M3U'),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
readonly globalRecentItems = computed<GlobalRecentItem[]>(() =>
|
||||
[...this.xtreamGlobalRecentItems(), ...this.stalkerGlobalRecentItems()]
|
||||
[
|
||||
...this.xtreamGlobalRecentItems(),
|
||||
...this.playlistBackedGlobalRecentItems(),
|
||||
]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
toDateTimestamp(b.viewed_at) - toDateTimestamp(a.viewed_at)
|
||||
@@ -86,15 +97,19 @@ export class DashboardDataService {
|
||||
});
|
||||
|
||||
readonly globalFavoriteItems = computed(() =>
|
||||
[...this.xtreamGlobalFavorites(), ...this.stalkerGlobalFavorites()]
|
||||
[
|
||||
...this.xtreamGlobalFavorites(),
|
||||
...this.m3uGlobalFavorites(),
|
||||
...this.stalkerGlobalFavorites(),
|
||||
]
|
||||
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at))
|
||||
.slice(0, 200)
|
||||
);
|
||||
|
||||
constructor() {
|
||||
void this.reloadGlobalFavorites();
|
||||
if (window.electron) {
|
||||
void this.reloadGlobalRecentItems();
|
||||
void this.reloadGlobalFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +157,13 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
async reloadGlobalFavorites(): Promise<void> {
|
||||
const xtreamReload = this.reloadXtreamGlobalFavorites();
|
||||
const m3uReload = this.reloadM3uGlobalFavorites();
|
||||
|
||||
await Promise.all([xtreamReload, m3uReload]);
|
||||
}
|
||||
|
||||
private async reloadXtreamGlobalFavorites(): Promise<void> {
|
||||
if (!window.electron) {
|
||||
this.xtreamGlobalFavorites.set([]);
|
||||
return;
|
||||
@@ -162,6 +184,38 @@ export class DashboardDataService {
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadM3uGlobalFavorites(): Promise<void> {
|
||||
const m3uPlaylists = this.playlists().filter(
|
||||
(playlist) =>
|
||||
!playlist.serverUrl &&
|
||||
!playlist.macAddress &&
|
||||
Array.isArray(playlist.favorites) &&
|
||||
playlist.favorites.some(
|
||||
(favorite): favorite is string =>
|
||||
typeof favorite === 'string' && favorite.trim().length > 0
|
||||
)
|
||||
);
|
||||
|
||||
if (m3uPlaylists.length === 0) {
|
||||
this.ngZone.run(() => this.m3uGlobalFavorites.set([]));
|
||||
return;
|
||||
}
|
||||
|
||||
const favoriteItems = await Promise.all(
|
||||
m3uPlaylists.map((playlist) =>
|
||||
this.loadM3uPlaylistFavorites(playlist).catch(
|
||||
(): DashboardFavoriteItem[] => []
|
||||
)
|
||||
)
|
||||
);
|
||||
const flattenedFavorites = favoriteItems.reduce(
|
||||
(acc, items) => [...acc, ...items],
|
||||
[] as DashboardFavoriteItem[]
|
||||
);
|
||||
|
||||
this.ngZone.run(() => this.m3uGlobalFavorites.set(flattenedFavorites));
|
||||
}
|
||||
|
||||
isTypeInKind(
|
||||
type: PortalActivityType,
|
||||
kind: DashboardContentKind
|
||||
@@ -256,6 +310,9 @@ export class DashboardDataService {
|
||||
if (item.source === 'xtream') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
||||
}
|
||||
if (item.source === 'm3u') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.M3U');
|
||||
}
|
||||
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
|
||||
}
|
||||
|
||||
@@ -285,36 +342,37 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
if (item.source === 'stalker') {
|
||||
const playlist = this.playlists().find(
|
||||
(p) => p._id === item.playlist_id
|
||||
);
|
||||
if (!playlist) return;
|
||||
|
||||
const currentRecent = Array.isArray(
|
||||
(playlist as { recentlyViewed?: unknown[] }).recentlyViewed
|
||||
)
|
||||
? [
|
||||
...((playlist as { recentlyViewed?: unknown[] })
|
||||
.recentlyViewed ?? []),
|
||||
]
|
||||
: [];
|
||||
|
||||
const itemMatchStr = String(item.id);
|
||||
const filteredRecent = currentRecent.filter(
|
||||
(raw, index) =>
|
||||
!stalkerItemMatchesId(
|
||||
raw,
|
||||
itemMatchStr,
|
||||
playlist._id,
|
||||
index
|
||||
)
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.removeFromPortalRecentlyViewed(
|
||||
item.playlist_id,
|
||||
item.id
|
||||
)
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: item.playlist_id,
|
||||
recentlyViewed: filteredRecent,
|
||||
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
||||
} as unknown as PlaylistMeta,
|
||||
}) as any
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.source === 'm3u') {
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.removeFromM3uRecentlyViewed(
|
||||
item.playlist_id,
|
||||
String(item.xtream_id ?? item.id)
|
||||
)
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: item.playlist_id,
|
||||
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
||||
} as unknown as PlaylistMeta,
|
||||
}) as any
|
||||
);
|
||||
@@ -330,6 +388,9 @@ export class DashboardDataService {
|
||||
if (item.source === 'xtream') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
|
||||
}
|
||||
if (item.source === 'm3u') {
|
||||
return this.translateText('WORKSPACE.DASHBOARD.M3U');
|
||||
}
|
||||
return this.translateText('WORKSPACE.DASHBOARD.PROVIDER');
|
||||
}
|
||||
|
||||
@@ -388,6 +449,29 @@ export class DashboardDataService {
|
||||
}) as any
|
||||
);
|
||||
}
|
||||
|
||||
if (item.source === 'm3u') {
|
||||
const playlist = await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(item.playlist_id)
|
||||
);
|
||||
const currentFavorites = Array.isArray(playlist?.favorites)
|
||||
? playlist.favorites.filter(
|
||||
(favorite): favorite is string =>
|
||||
typeof favorite === 'string'
|
||||
)
|
||||
: [];
|
||||
const filteredFavorites = currentFavorites.filter(
|
||||
(favorite) => favorite !== String(item.id)
|
||||
);
|
||||
|
||||
await firstValueFrom(
|
||||
this.playlistsService.setFavorites(
|
||||
item.playlist_id,
|
||||
filteredFavorites
|
||||
)
|
||||
);
|
||||
await this.reloadGlobalFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
formatTimestamp(value?: string | number): string {
|
||||
@@ -422,6 +506,73 @@ export class DashboardDataService {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private async loadM3uPlaylistFavorites(
|
||||
playlistMeta: PlaylistMeta
|
||||
): Promise<DashboardFavoriteItem[]> {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(playlistMeta._id)
|
||||
)) as Playlist & {
|
||||
playlist?: {
|
||||
items?: Channel[];
|
||||
};
|
||||
};
|
||||
const favorites = Array.isArray(playlist?.favorites)
|
||||
? playlist.favorites.filter(
|
||||
(favorite): favorite is string =>
|
||||
typeof favorite === 'string' && favorite.trim().length > 0
|
||||
)
|
||||
: [];
|
||||
|
||||
if (favorites.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: Channel[] = Array.isArray(playlist?.playlist?.items)
|
||||
? (playlist.playlist.items as Channel[])
|
||||
: [];
|
||||
const fallbackTimestamp =
|
||||
this.getM3uFavoriteTimestamp(playlistMeta) ??
|
||||
new Date(0).toISOString();
|
||||
|
||||
return items.reduce<DashboardFavoriteItem[]>((acc, channel) => {
|
||||
const channelId = String(channel.id ?? '').trim();
|
||||
const channelUrl = String(channel.url ?? '').trim();
|
||||
const matchedFavoriteId = favorites.find(
|
||||
(favorite) =>
|
||||
favorite === channelId || favorite === channelUrl
|
||||
);
|
||||
|
||||
if (!matchedFavoriteId) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push({
|
||||
id: matchedFavoriteId,
|
||||
title: channel.name?.trim() || channel.tvg?.name || channelId,
|
||||
type: 'live',
|
||||
playlist_id: playlistMeta._id,
|
||||
playlist_name:
|
||||
playlistMeta.title || playlistMeta.filename || 'M3U',
|
||||
added_at: fallbackTimestamp,
|
||||
category_id: 'live',
|
||||
xtream_id: matchedFavoriteId,
|
||||
poster_url: channel.tvg?.logo || undefined,
|
||||
source: 'm3u',
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
private getM3uFavoriteTimestamp(playlist: PlaylistMeta): string | null {
|
||||
if (playlist.updateDate) {
|
||||
return new Date(playlist.updateDate).toISOString();
|
||||
}
|
||||
|
||||
return typeof playlist.importDate === 'string'
|
||||
? playlist.importDate
|
||||
: null;
|
||||
}
|
||||
|
||||
private getRecentTimestamp(item: PlaylistMeta): number {
|
||||
return toTimestamp(item.updateDate) || toTimestamp(item.importDate);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,15 @@ export function getRecentItemNavigation(
|
||||
};
|
||||
}
|
||||
|
||||
if (item.source === 'm3u') {
|
||||
return {
|
||||
link: ['/workspace', 'playlists', item.playlist_id, 'recent'],
|
||||
state: {
|
||||
openRecentChannelUrl: String(item.xtream_id ?? item.id ?? ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return buildXtreamNavigationTarget({
|
||||
playlistId: item.playlist_id,
|
||||
type: item.type,
|
||||
@@ -52,6 +61,12 @@ export function getGlobalFavoriteNavigation(
|
||||
};
|
||||
}
|
||||
|
||||
if (item.source === 'm3u') {
|
||||
return {
|
||||
link: ['/workspace', 'playlists', item.playlist_id, 'favorites'],
|
||||
};
|
||||
}
|
||||
|
||||
return buildXtreamNavigationTarget({
|
||||
playlistId: item.playlist_id,
|
||||
type: item.type,
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
PlaylistType,
|
||||
} from '@iptvnator/playlist/shared/ui';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectActivePlaylist,
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistTitle,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
PlaylistsService,
|
||||
SettingsStore,
|
||||
} from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import {
|
||||
SettingsContextService,
|
||||
WorkspaceAccountInfoData,
|
||||
@@ -481,6 +483,21 @@ export class WorkspaceShellComponent {
|
||||
};
|
||||
}
|
||||
|
||||
if (context.provider === 'playlists' && section === 'recent') {
|
||||
return {
|
||||
icon: 'delete_sweep',
|
||||
tooltip: this.translateText(
|
||||
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION'
|
||||
),
|
||||
ariaLabel: this.translateText(
|
||||
'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION_ARIA'
|
||||
),
|
||||
disabled:
|
||||
(this.activePlaylist()?.recentlyViewed?.length ?? 0) ===
|
||||
0,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
);
|
||||
@@ -707,11 +724,37 @@ export class WorkspaceShellComponent {
|
||||
}
|
||||
|
||||
if (context.provider === 'stalker' && section === 'recent') {
|
||||
await firstValueFrom(
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.clearPortalRecentlyViewed(
|
||||
context.playlistId
|
||||
)
|
||||
);
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: context.playlistId,
|
||||
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
||||
} as PlaylistMeta,
|
||||
}) as any
|
||||
);
|
||||
this.bumpRefreshQueryParam();
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.provider === 'playlists' && section === 'recent') {
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.clearM3uRecentlyViewed(
|
||||
context.playlistId
|
||||
)
|
||||
);
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: context.playlistId,
|
||||
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
||||
} as PlaylistMeta,
|
||||
}) as any
|
||||
);
|
||||
this.bumpRefreshQueryParam();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user