refactor(stalker): adapt store calls, enhance recently viewed, favorites, download manger layout

This commit is contained in:
4gray committed 2026-02-14 15:44:16 +01:00
1 parent 91aa6c02a0
commit a9eee9e64f
10 files changed
+213 -19

No files matched your search

@@ -33,6 +33,16 @@
')'
}}
</h2>
@if (showHeaderAction()) {
<button
mat-icon-button
[matTooltip]="headerActionTooltip()"
[disabled]="!favorites().length"
(click)="onHeaderActionClick()"
>
<mat-icon>{{ headerActionIcon() }}</mat-icon>
</button>
}
</div>
<div class="scrollable-content">
@if (favorites().length) {
@@ -44,7 +54,10 @@
[type]="item.type"
[date]="item.added_at"
[showRemoveButton]="true"
[removeTooltip]="'CHANNELS.REMOVE_FAVORITE' | translate"
[removeTooltip]="
removeTooltip() ||
('CHANNELS.REMOVE_FAVORITE' | translate)
"
(cardClick)="openFavorite(item)"
(remove)="removeFromFavorites(item)"
/>
@@ -52,7 +65,7 @@
</div>
} @else {
<div class="no-items">
<mat-icon>favorite_border</mat-icon>
<mat-icon>{{ emptyIcon() }}</mat-icon>
<p>No items in the selected category</p>
</div>
}
@@ -20,6 +20,10 @@
text-transform: capitalize;
padding: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
h2 {
margin: 0;
@@ -1,6 +1,7 @@
import { Component, input, output } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { CategoryViewComponent } from '../../../xtream-tauri/category-view/category-view.component';
@@ -21,6 +22,7 @@ import { ContentCardComponent } from '../content-card/content-card.component';
PlaylistSwitcherComponent,
ResizableDirective,
TranslatePipe,
MatTooltip,
],
})
export class FavoritesLayoutComponent {
@@ -30,10 +32,16 @@ export class FavoritesLayoutComponent {
readonly playlistTitle = input<string>('Playlist');
readonly selectedCategoryId = input<string>('movie');
readonly titleTranslationString = input<string>('CHANNELS.FAVORITES');
readonly removeTooltip = input<string>('');
readonly emptyIcon = input<string>('favorite_border');
readonly showHeaderAction = input<boolean>(false);
readonly headerActionIcon = input<string>('delete_sweep');
readonly headerActionTooltip = input<string>('');
readonly categoryClicked = output<any>();
readonly removeFavorite = output<any>();
readonly openItem = output<any>();
readonly headerActionClicked = output<void>();
setCategoryId(categoryId: any) {
this.categoryClicked.emit({ category_id: categoryId });
@@ -46,4 +54,8 @@ export class FavoritesLayoutComponent {
openFavorite(item: any) {
this.openItem.emit(item);
}
onHeaderActionClick() {
this.headerActionClicked.emit();
}
}
@@ -16,6 +16,7 @@
<!-- Regular VOD item -->
<app-vod-details
[item]="vodDetailsItem"
[isFavorite]="isSelectedVodFavorite()"
(playClicked)="onVodPlay($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
@@ -34,8 +35,14 @@
[playlistTitle]="currentPlaylist()?.title || 'Portal'"
[selectedCategoryId]="selectedCategoryId()"
[titleTranslationString]="'PORTALS.SIDEBAR.RECENT'"
[removeTooltip]="'Remove from recent'"
[emptyIcon]="'history'"
[showHeaderAction]="true"
[headerActionIcon]="'delete_sweep'"
[headerActionTooltip]="'Clear all recently viewed'"
(categoryClicked)="setCategoryId($event.category_id)"
(removeFavorite)="removeFromRecentlyViewed($event)"
(openItem)="openItem($event)"
(headerActionClicked)="clearAllRecentlyViewed()"
/>
}
@@ -1,4 +1,4 @@
import { Component, computed, inject, signal } from '@angular/core';
import { Component, computed, effect, inject, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import {
@@ -15,12 +15,13 @@ import {
} from '../models';
import {
createPortalCollectionResource,
normalizeStalkerEntityIdAsNumber,
createPortalFavoritesResource,
toggleStalkerVodFavorite,
clearStalkerDetailViewState,
createStalkerDetailViewState,
createRefreshTrigger,
normalizeStalkerVodDetailsItem,
isSelectedStalkerVodFavorite,
} from '../stalker-vod.utils';
@Component({
@@ -54,12 +55,14 @@ export class RecentlyViewedComponent {
}
private readonly playlistService = inject(PlaylistsService);
private readonly collectionRefresh = createRefreshTrigger();
private readonly favoritesRefresh = createRefreshTrigger();
private readonly stalkerStore = inject(StalkerStore);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerRecentlyViewed');
itemDetails: (StalkerVodSource & { category_id?: string }) | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly isSelectedVodFavorite = signal<boolean>(false);
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
@@ -70,6 +73,11 @@ export class RecentlyViewedComponent {
(playlistService, portalId) =>
playlistService.getPortalRecentlyViewed(portalId)
);
readonly portalFavorites = createPortalFavoritesResource(
this.playlistService,
() => this.stalkerStore.currentPlaylist()?._id,
() => this.favoritesRefresh.refreshVersion()
);
readonly categories = computed(() => [
{
@@ -131,14 +139,34 @@ export class RecentlyViewedComponent {
this.allFavorites.value()?.filter((item) => item.category_id === 'itv')
);
constructor() {
effect(() => {
this.portalFavorites.value();
this.syncSelectedVodFavorite();
});
}
removeFromRecentlyViewed(item: Pick<StalkerVodSource, 'id'>) {
const id = normalizeStalkerEntityIdAsNumber(item.id);
if (id === null) return;
const id = String(item.id ?? '').trim();
if (!id) return;
this.stalkerStore.removeFromRecentlyViewed(id, () => {
this.collectionRefresh.refresh();
});
}
clearAllRecentlyViewed() {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!playlistId) return;
this.playlistService.clearPortalRecentlyViewed(playlistId).subscribe({
next: () => this.collectionRefresh.refresh(),
error: (error) =>
this.logger.error(
'Failed to clear recently viewed items',
error
),
});
}
setCategoryId(categoryId: string) {
this.selectedCategoryId.set(categoryId);
}
@@ -166,6 +194,7 @@ export class RecentlyViewedComponent {
};
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.stalkerStore.setSelectedItem(detailViewState.itemDetails);
this.syncSelectedVodFavorite();
break;
case 'series':
this.itemDetails = item;
@@ -193,6 +222,10 @@ export class RecentlyViewedComponent {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites(favoriteId, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
}
@@ -201,6 +234,7 @@ export class RecentlyViewedComponent {
const cleared = clearStalkerDetailViewState();
this.itemDetails = cleared.itemDetails;
this.vodDetailsItem = cleared.vodDetailsItem;
this.isSelectedVodFavorite.set(false);
}
async createLinkToPlayVodItv(
@@ -231,4 +265,13 @@ export class RecentlyViewedComponent {
private normalizeRecentItem(item: StalkerVodSource): StalkerSelectedVodItem {
return normalizeStalkerVodDetailsItem(item);
}
private syncSelectedVodFavorite(): void {
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
this.vodDetailsItem,
this.portalFavorites.value() ?? []
)
);
}
}
@@ -21,6 +21,7 @@ import {
NormalizedStalkerFavoriteItem,
normalizeStalkerFavoriteItem,
isSelectedStalkerVodFavorite,
toggleStalkerVodFavorite,
} from '../stalker-vod.utils';
@Component({
@@ -143,6 +144,14 @@ export class StalkerFavoritesComponent {
);
}
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.stalkerStore.addToFavorites(item, () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
onDone?.();
});
}
setCategoryId(categoryId: string) {
this.selectedCategoryId.set(categoryId);
}
@@ -195,10 +204,15 @@ export class StalkerFavoritesComponent {
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
if (!event.isFavorite && event.item.type === 'stalker') {
this.removeFromFavorites({ id: event.item.data.id });
this.isSelectedVodFavorite.set(false);
}
toggleStalkerVodFavorite(event, {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites({ id: favoriteId }, onDone),
onComplete: () => {
this.favoritesRefresh.refresh();
this.syncSelectedVodFavorite();
},
});
}
/** Handle back from vod-details component */
@@ -71,7 +71,10 @@ export class StalkerSeriesViewComponent {
* Note: is_series can be true, 1, or "1" depending on the source
*/
readonly isVodSeries = computed(() => {
return isVodSeriesItem(this.displayItem());
return (
this.stalkerStore.selectedContentType() === 'vod' &&
isVodSeriesItem(this.displayItem())
);
});
/**
@@ -103,17 +106,21 @@ export class StalkerSeriesViewComponent {
const playlist = this.stalkerStore.currentPlaylist();
if (item && playlist?._id) {
const seriesId = Number(item.id);
const normalizedSeriesId = this.toSeriesId(item.id);
this.logger.debug(
'Loading positions for series',
{
id: item.id,
seriesId,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
}
);
if (!isNaN(seriesId)) {
if (!isNaN(normalizedSeriesId)) {
// Load playback positions from the XtreamStore (works for any playlist type)
this.xtreamStore.loadSeriesPositions(playlist._id, seriesId);
this.xtreamStore.loadSeriesPositions(
playlist._id,
normalizedSeriesId
);
}
}
});
@@ -331,8 +338,11 @@ export class StalkerSeriesViewComponent {
this.stalkerStore.clearSelectedItem();
}
toSeriesId(id: string): number {
const parsed = Number(id);
toSeriesId(id: string | number): number {
const raw = String(id ?? '').trim();
if (!raw) return 0;
const primary = raw.includes(':') ? raw.split(':')[0] : raw;
const parsed = Number(primary);
return Number.isFinite(parsed) ? parsed : 0;
}
}
@@ -16,6 +16,7 @@
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(downloadRequested)="onVodDownload($event)"
(backClicked)="onVodBack()"
/>
}
@@ -25,6 +25,11 @@ import {
toggleStalkerVodFavorite,
} from '../../stalker/stalker-vod.utils';
import { createLogger } from '../../shared/utils/logger';
import { DownloadsService } from '../../services/downloads.service';
import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
} from '../../stalker/stalker-vod.utils';
@Component({
selector: 'app-category-content-view',
@@ -54,6 +59,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
readonly contentType = this.store.selectedContentType;
private readonly playlistService = inject(PlaylistsService);
private readonly downloadsService = inject(DownloadsService);
private readonly favoritesRefresh = createRefreshTrigger();
readonly limit = this.store.limit;
@@ -411,6 +417,62 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
this.store.setSelectedItem(null);
}
/** Handle download from vod-details component */
async onVodDownload(item: VodDetailsItem): Promise<void> {
if (item.type !== 'stalker') return;
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
const playlist = stalkerStore.currentPlaylist();
if (!playlist || !playlist.portalUrl || !playlist.macAddress) return;
let cmdToUse = item.cmd;
const itemData = item.data as any;
const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
// Align with playback path: resolve has_files references to /media/file_{id}.mpg
if (
itemData?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId = await stalkerStore.fetchMovieFileId(normalizedItemId);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const url = await stalkerStore.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse
);
if (!url) return;
const numericId =
normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
await this.downloadsService.startDownload({
playlistId: playlist._id,
xtreamId: numericId,
contentType: 'vod',
title: itemData?.info?.name || itemData?.title || 'Unknown',
url,
posterUrl: itemData?.info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
// Playlist info for auto-creation (Stalker playlists)
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
ngOnDestroy(): void {
this.unsubscribePositionUpdates?.();
}
+31 -3
View File
@@ -411,13 +411,41 @@ 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);
return this.dbService.update(DbStores.Playlists, {
...portal,
recentlyViewed: portal.recentlyViewed?.filter((i: any) => {
const itemId = String(i?.id ?? '');
const itemNormalized = normalizePortalItemId(itemId);
return (
itemId !== expectedId &&
itemNormalized !== expectedNormalized
);
}),
});
})
);
}
clearPortalRecentlyViewed(portalId: string) {
if (!portalId) {
throw new Error('Portal ID is required');
}
return this.getPlaylistById(portalId).pipe(
switchMap((portal) =>
this.dbService.update(DbStores.Playlists, {
...portal,
recentlyViewed: portal.recentlyViewed?.filter(
(i) => i.id !== id
),
recentlyViewed: [],
})
)
);