diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html index bd1762abe..2a5e384bb 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html @@ -33,6 +33,16 @@ ')' }} + @if (showHeaderAction()) { + + }
@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 @@
} @else {
- favorite_border + {{ emptyIcon() }}

No items in the selected category

} diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss index c9fc7c64a..6fccda148 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss @@ -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; diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts index 7e164f09f..c3446f34b 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts @@ -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('Playlist'); readonly selectedCategoryId = input('movie'); readonly titleTranslationString = input('CHANNELS.FAVORITES'); + readonly removeTooltip = input(''); + readonly emptyIcon = input('favorite_border'); + readonly showHeaderAction = input(false); + readonly headerActionIcon = input('delete_sweep'); + readonly headerActionTooltip = input(''); readonly categoryClicked = output(); readonly removeFavorite = output(); readonly openItem = output(); + readonly headerActionClicked = output(); 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(); + } } diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html index 59f146302..8e7012bfd 100644 --- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html +++ b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html @@ -16,6 +16,7 @@ } diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts index 51965e356..1221a1a6e 100644 --- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts +++ b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts @@ -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(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) { - 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() ?? [] + ) + ); + } } diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts index 03f283742..75012a701 100644 --- a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts +++ b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts @@ -21,6 +21,7 @@ import { NormalizedStalkerFavoriteItem, normalizeStalkerFavoriteItem, isSelectedStalkerVodFavorite, + toggleStalkerVodFavorite, } from '../stalker-vod.utils'; @Component({ @@ -143,6 +144,14 @@ export class StalkerFavoritesComponent { ); } + addToFavorites(item: Record, 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 */ diff --git a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts index faf23478a..dda9fccff 100644 --- a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts +++ b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts @@ -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; } } diff --git a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html index 0aa387daa..0661f10f9 100644 --- a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html +++ b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html @@ -16,6 +16,7 @@ (playClicked)="onVodPlay($event)" (resumeClicked)="onVodResume($event)" (favoriteToggled)="onVodFavoriteToggled($event)" + (downloadRequested)="onVodDownload($event)" (backClicked)="onVodBack()" /> } diff --git a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts index 1545ac21f..e6a5c5ef1 100644 --- a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts +++ b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts @@ -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 { + if (item.type !== 'stalker') return; + + const stalkerStore = this.store as InstanceType; + 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?.(); } diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 4ed395832..6343f4579 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -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: [], }) ) );