mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
refactor(stalker): adapt store calls, enhance recently viewed, favorites, download manger layout
This commit is contained in:
1 parent
91aa6c02a0
commit
a9eee9e64f
10 files changed
+213
-19
No files matched your search
+15
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
@@ -16,6 +16,7 @@
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(resumeClicked)="onVodResume($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(downloadRequested)="onVodDownload($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
|
||||
+62
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user