refactor(favs-recent): adapt UI and routing behavior

- Introduced StalkerCollectionDetailComponent to handle detailed views for Stalker collections.
- Added XtreamCollectionDetailComponent for managing Xtream collection details.
- Updated routing components to integrate new detail components for both Stalker and Xtream.
- Enhanced global favorites and recently viewed functionalities to support new detail navigation for Stalker and Xtream items.
- Improved styling and structure for better responsiveness and user experience in detail views.

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-18 10:16:09 +02:00
1 parent 562bfd009c
commit 5e41e3d1ce
19 files changed
+1671 -181

No files matched your search

@@ -2,10 +2,7 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from 'm3u-state';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { Playlist, PlaylistMeta } from 'shared-interfaces';
@@ -105,14 +102,12 @@ describe('DashboardDataService', () => {
const playlistsServiceMock = {
getPlaylistById: jest.fn().mockReturnValue(of(playlistMock)),
setFavorites: jest.fn().mockReturnValue(of(undefined)),
removeFromM3uRecentlyViewed: jest
.fn()
.mockReturnValue(
of({
...playlistMock,
recentlyViewed: [],
})
),
removeFromM3uRecentlyViewed: jest.fn().mockReturnValue(
of({
...playlistMock,
recentlyViewed: [],
})
),
};
beforeEach(() => {
@@ -241,10 +236,14 @@ describe('DashboardDataService', () => {
])
);
expect(
service.globalFavoriteItems().filter((item) => item.type === 'movie')
service
.globalFavoriteItems()
.filter((item) => item.type === 'movie')
).toHaveLength(1);
expect(
service.globalFavoriteItems().filter((item) => item.type === 'series')
service
.globalFavoriteItems()
.filter((item) => item.type === 'series')
).toHaveLength(1);
});
@@ -273,6 +272,45 @@ describe('DashboardDataService', () => {
});
});
it('routes Xtream non-live favorites through the global favorites page with inline detail state', async () => {
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
{
id: 51,
category_id: 12,
title: 'Action Movie',
rating: '8.0',
added_at: '2026-02-02T10:00:00.000Z',
poster_url: 'https://example.com/movie.png',
xtream_id: 5001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await service.reloadGlobalFavorites();
const movieItem = service
.globalFavoriteItems()
.find((item) => item.type === 'movie');
expect(movieItem).toBeDefined();
expect(service.getGlobalFavoriteLink(movieItem!)).toEqual([
'/workspace',
'global-favorites',
]);
expect(service.getGlobalFavoriteNavigationState(movieItem!)).toEqual({
openCollectionDetailItem: {
item: expect.objectContaining({
uid: 'xtream::xtream-1::5001',
name: 'Action Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
}),
},
});
});
it('removes M3U favorites via PlaylistsService', async () => {
await service.reloadGlobalFavorites();
const m3uItem = service
@@ -388,6 +426,45 @@ describe('DashboardDataService', () => {
});
});
it('routes Xtream non-live recents through the global recent page with inline detail state', async () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 91,
category_id: 18,
title: 'Recent Movie',
rating: '7.8',
viewed_at: '2026-02-04T10:00:00.000Z',
poster_url: 'https://example.com/recent-movie.png',
xtream_id: 7001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await service.reloadGlobalRecentItems();
const movieItem = service
.globalRecentItems()
.find((item) => item.source === 'xtream');
expect(movieItem).toBeDefined();
expect(service.getRecentItemLink(movieItem!)).toEqual([
'/workspace',
'global-recent',
]);
expect(service.getRecentItemNavigationState(movieItem!)).toEqual({
openCollectionDetailItem: {
item: expect.objectContaining({
uid: 'xtream::xtream-1::7001',
name: 'Recent Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
}),
},
});
});
it('removes M3U recently viewed via PlaylistsService', async () => {
const m3uItem = service
.globalRecentItems()
@@ -399,9 +476,6 @@ describe('DashboardDataService', () => {
expect(
playlistsServiceMock.removeFromM3uRecentlyViewed
).toHaveBeenCalledWith(
'm3u-1',
'https://example.com/stream-1.m3u8'
);
).toHaveBeenCalledWith('m3u-1', 'https://example.com/stream-1.m3u8');
});
});
@@ -42,6 +42,9 @@ import {
toTimestamp,
} from './dashboard-mappers';
import {
buildStalkerDetailNavigationTarget,
buildStalkerStateItem,
buildXtreamNavigationTarget,
getGlobalFavoriteNavigation,
getRecentItemNavigation,
WorkspaceNavigationTarget,
@@ -80,12 +83,15 @@ export class DashboardDataService {
private readonly globalFavoritesLoadingState = signal(true);
private readonly globalFavoritesLoadedState = signal(false);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly playlistsLoaded = this.store.selectSignal(selectPlaylistsLoadingFlag);
readonly playlistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
readonly globalRecentLoading = this.globalRecentLoadingState.asReadonly();
readonly globalRecentLoaded = this.globalRecentLoadedState.asReadonly();
readonly globalFavoritesLoading =
this.globalFavoritesLoadingState.asReadonly();
readonly globalFavoritesLoaded = this.globalFavoritesLoadedState.asReadonly();
readonly globalFavoritesLoaded =
this.globalFavoritesLoadedState.asReadonly();
private readonly playlistFavoritesReloadKey = computed(() => {
if (!this.playlistsLoaded()) {
@@ -96,7 +102,11 @@ export class DashboardDataService {
.map((playlist) =>
[
playlist._id,
playlist.serverUrl ? 'xtream' : playlist.macAddress ? 'stalker' : 'm3u',
playlist.serverUrl
? 'xtream'
: playlist.macAddress
? 'stalker'
: 'm3u',
JSON.stringify(playlist.favorites ?? []),
].join('::')
)
@@ -281,7 +291,8 @@ export class DashboardDataService {
Array.isArray(playlist.favorites) &&
playlist.favorites.some(
(favorite): favorite is string =>
typeof favorite === 'string' && favorite.trim().length > 0
typeof favorite === 'string' &&
favorite.trim().length > 0
)
);
@@ -478,9 +489,7 @@ export class DashboardDataService {
return this.getActivityItemTypeLabel(item);
}
getRecentlyAddedItemTypeLabel(
item: DashboardRecentlyAddedItem
): string {
getRecentlyAddedItemTypeLabel(item: DashboardRecentlyAddedItem): string {
return this.getActivityItemTypeLabel(item);
}
@@ -502,13 +511,57 @@ export class DashboardDataService {
}
getRecentlyAddedLink(item: DashboardRecentlyAddedItem): string[] {
return getGlobalFavoriteNavigation(item).link;
if (item.source === 'stalker' && item.type !== 'live') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
}),
}).link;
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}).link;
}
getRecentlyAddedNavigationState(
item: DashboardRecentlyAddedItem
): WorkspaceNavigationTarget['state'] {
return getGlobalFavoriteNavigation(item).state;
if (item.source === 'stalker' && item.type !== 'live') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
}),
}).state;
}
return buildXtreamNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
itemId: item.xtream_id,
title: item.title,
imageUrl: item.poster_url,
}).state;
}
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
@@ -619,8 +672,7 @@ export class DashboardDataService {
const channelId = String(channel.id ?? '').trim();
const channelUrl = String(channel.url ?? '').trim();
const matchedFavoriteId = favorites.find(
(favorite) =>
favorite === channelId || favorite === channelUrl
(favorite) => favorite === channelId || favorite === channelUrl
);
if (!matchedFavoriteId) {