diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index ed152714e..96501d4da 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -758,6 +758,13 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { icon: 'view_list', tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG', ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG', + palette: { + labelKey: 'TOP_MENU.OPEN_MULTI_EPG', + descriptionKey: + 'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION', + keywords: ['epg', 'guide', 'schedule'], + priority: 10, + }, run: () => this.openMultiEpgView(), }); } diff --git a/libs/playlist/shared/ui/src/index.ts b/libs/playlist/shared/ui/src/index.ts index 0e0c7cc73..e550324e3 100644 --- a/libs/playlist/shared/ui/src/index.ts +++ b/libs/playlist/shared/ui/src/index.ts @@ -3,3 +3,4 @@ export * from './lib/filter-sort-menu/filter-sort-menu.component'; export * from './lib/playlist-switcher/playlist-switcher.component'; export * from './lib/recent-playlists/playlist-info/playlist-info.component'; export * from './lib/recent-playlists/recent-playlists.component'; +export * from './lib/recent-playlists/empty-state/empty-state.component'; diff --git a/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.html b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.html new file mode 100644 index 000000000..3e353d250 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.html @@ -0,0 +1,57 @@ +
+
+

+ {{ label() }} + @if ((totalCount() ?? 0) > 0) { + + } +

+ @if (seeAllLink(); as link) { + + + @if (seeAllLabel()) { + {{ seeAllLabel() }} + } @else { + {{ 'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate }} + } + + chevron_right + + } +
+ +
+ + +
+ +
+ + +
+
diff --git a/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss new file mode 100644 index 000000000..76123f01a --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss @@ -0,0 +1,267 @@ +:host { + display: block; + --rail-gap: 16px; +} + +.rail { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 0 0; + border: 1px solid transparent; + border-radius: 16px; + transition: + background 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +// Optional active accent — used by recently-added when a section matches the +// currently-scoped content type. Mirrors the prior `.section--active` look. +.rail--active { + padding: 12px 14px 0; + border-color: var(--app-selection-border); + background: linear-gradient( + 145deg, + var(--app-selection-surface), + color-mix(in srgb, var(--app-selection-color) 6%, transparent) + ); + box-shadow: + inset 0 0 0 1px var(--app-selection-border), + 0 14px 24px -22px var(--app-selection-glow); + + .rail__label { + color: var(--app-selection-color); + } +} + +// ── Header ───────────────────────────────────────────────────────────────── +.rail__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 2px; + min-height: 24px; +} + +.rail__title { + margin: 0; + display: inline-flex; + align-items: baseline; + gap: 8px; + font-size: 1.125rem; + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.012em; + color: var(--app-heading-color, #1a1d23); +} + +.rail__count { + font-size: 0.78rem; + font-weight: 500; + line-height: 1; + color: var(--app-muted-color, #8891a4); + font-variant-numeric: tabular-nums; + padding: 2px 8px; + border-radius: 999px; + background: color-mix( + in srgb, + var(--app-muted-color, #8891a4) 18%, + transparent + ); + letter-spacing: 0; +} + +.rail__see-all { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 4px 8px 4px 10px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 500; + line-height: 1; + letter-spacing: 0.005em; + color: var(--app-muted-color, #8891a4); + text-decoration: none; + transition: + color 0.15s ease, + background-color 0.15s ease; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + transition: transform 0.15s ease; + } + + &:hover { + color: var(--app-selection-color, #2f7bff); + background: color-mix( + in srgb, + var(--app-selection-color, #2f7bff) 12%, + transparent + ); + + mat-icon { + transform: translateX(2px); + } + } + + &:focus-visible { + outline: 2px solid var(--app-selection-color, #2f7bff); + outline-offset: 2px; + } +} + +// ── Viewport ─────────────────────────────────────────────────────────────── +.rail__viewport { + position: relative; + min-width: 0; + + // Edge gradients hint at horizontal scroll without interaction. + &::before, + &::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 32px; + pointer-events: none; + z-index: 1; + opacity: 0; + transition: opacity 0.2s ease; + } + + &::before { + left: 0; + background: linear-gradient( + to right, + var(--app-content-bg, #161a22) 0%, + transparent 100% + ); + } + + &::after { + right: 0; + background: linear-gradient( + to left, + var(--app-content-bg, #161a22) 0%, + transparent 100% + ); + } + + &:has(.rail__chev--left.rail__chev--visible)::before { + opacity: 0.9; + } + + &:has(.rail__chev--right.rail__chev--visible)::after { + opacity: 0.9; + } + + &:hover .rail__chev--visible { + opacity: 1; + transform: translateY(-50%) scale(1); + } +} + +.rail__track { + display: flex; + align-items: flex-start; + gap: var(--rail-gap); + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + scroll-padding-inline-start: 4px; + scrollbar-width: none; + padding: 4px 0 16px; + margin: -4px 0 0; + + &::-webkit-scrollbar { + display: none; + } + // Callers style their own projected cards (width, scroll-snap-align, + // flex: 0 0 auto) — projected content stays in the parent's view + // encapsulation and can't be reached from here without `::ng-deep`. +} + +// ── Chevron — streaming-app circle (matches dashboard rail) ──────────────── +.rail__chev { + position: absolute; + top: 50%; + transform: translateY(-50%) scale(0.96); + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 50%; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + color: #fff; + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: + opacity 0.18s ease, + background-color 0.18s ease, + transform 0.18s ease, + box-shadow 0.18s ease; + z-index: 2; + box-shadow: + 0 4px 14px -4px rgba(0, 0, 0, 0.55), + 0 0 0 0 rgba(255, 255, 255, 0); + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + line-height: 24px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); + } + + &--visible { + opacity: 0.5; + pointer-events: auto; + } + + &:hover { + background: rgba(0, 0, 0, 0.88); + transform: translateY(-50%) scale(1.08); + box-shadow: + 0 8px 22px -6px rgba(0, 0, 0, 0.65), + 0 0 0 4px rgba(255, 255, 255, 0.06); + } + + &:active { + transform: translateY(-50%) scale(0.96); + } + + &:focus-visible { + outline: 2px solid var(--app-selection-color, #78adff); + outline-offset: 3px; + } + + &--left { + left: 6px; + } + + &--right { + right: 6px; + } +} + +@media (hover: none) { + .rail__chev { + display: none; + } + + .rail__viewport::before, + .rail__viewport::after { + display: none; + } +} diff --git a/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.ts b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.ts new file mode 100644 index 000000000..1f7593edc --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.ts @@ -0,0 +1,95 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + OnDestroy, + effect, + input, + signal, + viewChild, +} from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { RouterLink } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; + +/** + * Reusable horizontal-rail chrome: label + count badge + "see all" link, + * scroll-snap track with projected cards, fade-in chevrons and edge gradients + * that appear when scroll is possible. Matches the dashboard rail pattern so + * any rail-style surface in the app reads as part of the same design system. + * + * Card markup is projected — callers supply whatever card component fits the + * surface (simple poster tile, `app-content-card`, etc.). The shell only + * owns the container, header, and navigation affordances. + */ +@Component({ + selector: 'app-content-rail-shell', + standalone: true, + imports: [MatIcon, RouterLink, TranslatePipe], + templateUrl: './content-rail-shell.component.html', + styleUrl: './content-rail-shell.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContentRailShellComponent implements AfterViewInit, OnDestroy { + readonly label = input.required(); + /** Subtle pill next to the label. Hidden when null or <= 0. */ + readonly totalCount = input(null); + readonly seeAllLink = input(null); + /** + * Already-translated label for the see-all link. Falls back to the + * dashboard's "Manage all" translation when null, so dashboard callers + * don't need to pass anything. + */ + readonly seeAllLabel = input(null); + /** Optional active-accent decoration (e.g. the currently-scoped section). */ + readonly active = input(false); + + private readonly track = viewChild.required>('track'); + + readonly canScrollLeft = signal(false); + readonly canScrollRight = signal(false); + private readonly viewReady = signal(false); + + private resizeObserver?: ResizeObserver; + + constructor() { + // Re-measure whenever the track contents change (projected cards). + effect(() => { + if (!this.viewReady()) return; + // Wait one frame so projected content lays out first. + requestAnimationFrame(() => this.updateScrollState()); + }); + } + + ngAfterViewInit(): void { + this.viewReady.set(true); + this.updateScrollState(); + this.resizeObserver = new ResizeObserver(() => this.updateScrollState()); + this.resizeObserver.observe(this.track().nativeElement); + } + + ngOnDestroy(): void { + this.resizeObserver?.disconnect(); + } + + onScroll(): void { + this.updateScrollState(); + } + + scrollBy(direction: 1 | -1): void { + const el = this.track().nativeElement; + el.scrollBy({ + left: direction * el.clientWidth * 0.85, + behavior: 'smooth', + }); + } + + private updateScrollState(): void { + const el = this.track().nativeElement; + this.canScrollLeft.set(el.scrollLeft > 4); + this.canScrollRight.set( + el.scrollLeft + el.clientWidth < el.scrollWidth - 4 + ); + } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index e2fd39d16..3b6fe8028 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -14,11 +14,13 @@ import { DialogService } from 'components'; import { COLLECTION_VIEW_STATE_KEY, CollectionScope, + FavoritesChannelSortMode, OPEN_COLLECTION_DETAIL_STATE_KEY, ScopeToggleService, UnifiedCollectionItem, UnifiedFavoritesDataService, UnifiedRecentDataService, + WorkspaceViewCommandService, } from '@iptvnator/portal/shared/util'; import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from 'm3u-state'; import { BehaviorSubject } from 'rxjs'; @@ -38,6 +40,7 @@ class StubUnifiedLiveTabComponent { readonly mode = input<'favorites' | 'recent'>('favorites'); readonly searchTerm = input(''); readonly autoOpenItem = input(null); + readonly sortMode = input('custom'); readonly removeItem = output(); readonly reorderItems = output(); @@ -130,6 +133,9 @@ describe('UnifiedCollectionPageComponent', () => { const dialogService = { openConfirmDialog: jest.fn(), }; + const workspaceViewCommands = { + registerCommand: jest.fn(), + }; const router = { navigate: jest.fn().mockResolvedValue(true), url: '/workspace/global-favorites', @@ -165,6 +171,7 @@ describe('UnifiedCollectionPageComponent', () => { playlistsLoaded.set(false); playlists.set([]); jest.clearAllMocks(); + workspaceViewCommands.registerCommand.mockReturnValue(jest.fn()); routeParamMap$ = new BehaviorSubject(convertToParamMap({})); routeQueryParamMap$ = new BehaviorSubject(convertToParamMap({})); workspaceParamMap$ = new BehaviorSubject(convertToParamMap({})); @@ -261,6 +268,10 @@ describe('UnifiedCollectionPageComponent', () => { provide: DialogService, useValue: dialogService, }, + { + provide: WorkspaceViewCommandService, + useValue: workspaceViewCommands, + }, ], }) .overrideComponent(UnifiedCollectionPageComponent, { @@ -463,6 +474,63 @@ describe('UnifiedCollectionPageComponent', () => { expect(favoritesData.removeFavorite).not.toHaveBeenCalled(); }); + it('registers a workspace command when the current view has actionable items', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.componentInstance.isWorkspaceLayout).toBe(true); + + fixture.componentInstance.allItems.set([ + { + uid: 'm3u::playlist-1::one', + name: 'Favorite One', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'playlist-1', + playlistName: 'Playlist One', + streamUrl: 'https://example.com/one.m3u8', + }, + ]); + TestBed.flushEffects(); + + expect(workspaceViewCommands.registerCommand).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'unified-collection-clear-current-favorites', + group: 'view', + icon: 'delete_sweep', + labelKey: 'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE', + descriptionKey: + 'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION', + }) + ); + }); + + it('unregisters the workspace command when the current view becomes empty', async () => { + const unregister = jest.fn(); + workspaceViewCommands.registerCommand.mockReturnValue(unregister); + + fixture.detectChanges(); + await fixture.whenStable(); + + fixture.componentInstance.allItems.set([ + { + uid: 'm3u::playlist-1::one', + name: 'Favorite One', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'playlist-1', + playlistName: 'Playlist One', + streamUrl: 'https://example.com/one.m3u8', + }, + ]); + TestBed.flushEffects(); + + fixture.componentInstance.allItems.set([]); + TestBed.flushEffects(); + fixture.detectChanges(); + + expect(unregister).toHaveBeenCalled(); + }); + it('reloads favorites when bulk clear persistence fails', async () => { const liveItems = [ { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index 23d9b658e..0a38fe431 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -49,6 +49,7 @@ import { UnifiedCollectionItem, UnifiedFavoritesDataService, UnifiedRecentDataService, + WorkspaceViewCommandService, } from '@iptvnator/portal/shared/util'; import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from 'm3u-state'; import { UnifiedLiveTabComponent } from './unified-live-tab.component'; @@ -92,6 +93,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { private readonly recentData = inject(UnifiedRecentDataService); private readonly dialogService = inject(DialogService); private readonly translate = inject(TranslateService); + private readonly workspaceViewCommands = inject(WorkspaceViewCommandService); readonly detailTemplate = contentChild(UnifiedCollectionDetailDirective); private readonly playlists = this.store.selectSignal( selectAllPlaylistsMeta @@ -356,6 +358,39 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { }); }); }); + private readonly workspaceCommandEffect = effect((onCleanup) => { + if (!this.isWorkspaceLayout) { + return; + } + + const items = this.currentTypeItems(); + if (items.length === 0) { + return; + } + + const unregister = this.workspaceViewCommands.registerCommand({ + id: `unified-collection-clear-current-${this.mode()}`, + group: 'view', + icon: 'delete_sweep', + labelKey: this.clearButtonTooltipKey(), + labelParams: () => ({ + type: this.translate.instant(this.currentTypeLabelKey()), + }), + descriptionKey: + 'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION', + descriptionParams: () => ({ + type: this.translate.instant(this.currentTypeLabelKey()), + }), + keywords: () => + this.mode() === 'favorites' + ? ['clear', 'favorites', 'remove', this.selectedContentType()] + : ['clear', 'recent', 'history', this.selectedContentType()], + priority: 10, + run: () => this.clearAllCurrent(), + }); + + onCleanup(unregister); + }); constructor() { if (typeof window !== 'undefined') { diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 2f17b0643..00a61df5a 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -12,6 +12,8 @@ export * from './lib/portal-collection-context.service'; export * from './lib/portal-collection-items'; export * from './lib/remote-channel-navigation'; export * from './lib/workspace-header-context.service'; +export * from './lib/workspace-view-command.types'; +export * from './lib/workspace-view-command.service'; export * from './lib/navigation/portal-rail-links'; export * from './lib/navigation/portal-route.utils'; export * from './lib/navigation/workspace-portal-navigation'; diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts index 74e439dba..b720efc5e 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts @@ -17,6 +17,7 @@ import { } from 'shared-interfaces'; import { buildCollectionUid, + buildXtreamCollectionUid, UnifiedCollectionItem, } from './unified-collection-item.interface'; import { CollectionScope } from './scope-toggle.service'; @@ -384,7 +385,7 @@ export class UnifiedFavoritesDataService { private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem { const ct = xtreamContentType(row.type); return { - uid: buildCollectionUid('xtream', row.playlist_id, row.xtream_id), + uid: buildXtreamCollectionUid(row.playlist_id, ct, row.xtream_id), name: row.title, contentType: ct, sourceType: 'xtream', @@ -396,7 +397,9 @@ export class UnifiedFavoritesDataService { categoryId: row.category_id, tvgId: ct === 'live' ? String(row.xtream_id) : undefined, rating: row.rating ?? undefined, - addedAt: row.added_at ?? new Date(0).toISOString(), + addedAt: + normalizeStalkerDate(row.added_at) || + new Date(0).toISOString(), position: row.position ?? 0, contentId: row.id, }; diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts index 52e6319b5..a0160b878 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts @@ -184,4 +184,126 @@ describe('UnifiedRecentDataService', () => { ); expect(recorded.viewedAt).toBeTruthy(); }); + + it('records Xtream playback with a type-aware fallback lookup', async () => { + dbService.getContentByXtreamId.mockResolvedValue({ + id: 3867578, + xtream_id: 290, + type: 'live', + title: 'SE: V Film Premiere FHD', + }); + + const item = { + uid: 'xtream::xtream-1::live:290', + name: 'SE: V Film Premiere FHD', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + xtreamId: 290, + } satisfies UnifiedCollectionItem; + + const recorded = await service.recordLivePlayback(item); + + expect(dbService.getContentByXtreamId).toHaveBeenCalledWith( + 290, + 'xtream-1', + 'live' + ); + expect(dbService.addRecentItem).toHaveBeenCalledWith( + 3867578, + 'xtream-1' + ); + expect(recorded).toEqual( + expect.objectContaining({ + contentId: 3867578, + viewedAt: expect.any(String), + }) + ); + }); + + it('builds distinct Xtream recent UIDs when live and series share an xtream id', async () => { + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream One', + serverUrl: 'https://example.com', + } satisfies Partial, + ]) + ); + dbService.getRecentItems.mockResolvedValue([ + { + id: 3867578, + category_id: 11, + title: 'SE: V Film Premiere FHD', + poster_url: 'https://example.com/live.png', + xtream_id: 290, + type: 'live', + viewed_at: '2026-04-21T20:42:27.000Z', + }, + { + id: 3941697, + category_id: 17, + title: 'Krypton', + poster_url: 'https://example.com/krypton.png', + xtream_id: 290, + type: 'series', + viewed_at: '2026-04-21T20:43:27.000Z', + }, + ]); + + const items = await service.getRecentItems('playlist', 'xtream-1', 'xtream'); + + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + uid: 'xtream::xtream-1::live:290', + contentType: 'live', + contentId: 3867578, + }), + expect.objectContaining({ + uid: 'xtream::xtream-1::series:290', + contentType: 'series', + contentId: 3941697, + }), + ]) + ); + }); + + it('normalizes SQLite-style Xtream recent timestamps to ISO before exposing them', async () => { + store.select.mockReturnValue( + of([ + playlistMeta, + { + _id: 'xtream-1', + title: 'Xtream One', + serverUrl: 'https://example.com', + } satisfies Partial, + ]) + ); + dbService.getGlobalRecentlyViewed.mockResolvedValue([ + { + id: 3940227, + category_id: 18, + title: 'Unter Nachbarn - 2011', + poster_url: 'https://example.com/unter.png', + xtream_id: 815302, + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream One', + viewed_at: '2026-04-21 22:49:02', + }, + ]); + + const items = await service.getRecentItems('all'); + const xtreamItem = items.find((item) => item.sourceType === 'xtream'); + + expect(xtreamItem).toEqual( + expect.objectContaining({ + name: 'Unter Nachbarn - 2011', + viewedAt: '2026-04-21T22:49:02.000Z', + }) + ); + }); }); diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts index 47d190158..b8bac3b17 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts @@ -14,15 +14,16 @@ import { extractStalkerItemType, isM3uRecentlyViewedItem, M3uRecentlyViewedItem, + normalizeStalkerDate, Playlist, PlaylistMeta, PlaylistRecentlyViewedItem, PlaylistUpdateState, StalkerPortalItem, - normalizeStalkerDate, } from 'shared-interfaces'; import { buildCollectionUid, + buildXtreamCollectionUid, UnifiedCollectionItem, } from './unified-collection-item.interface'; import { CollectionScope } from './scope-toggle.service'; @@ -155,7 +156,8 @@ export class UnifiedRecentDataService { ? ( await this.dbService.getContentByXtreamId( item.xtreamId, - item.playlistId + item.playlistId, + item.contentType ) )?.id : null); @@ -240,7 +242,11 @@ export class UnifiedRecentDataService { try { const rows = await this.dbService.getGlobalRecentlyViewed(); return (rows || []).map((row) => ({ - uid: buildCollectionUid('xtream', row.playlist_id, row.xtream_id), + uid: buildXtreamCollectionUid( + row.playlist_id, + xtreamContentType(row.type), + row.xtream_id + ), name: row.title, contentType: xtreamContentType(row.type), sourceType: 'xtream' as const, @@ -252,7 +258,7 @@ export class UnifiedRecentDataService { categoryId: row.category_id, tvgId: row.type === 'live' ? String(row.xtream_id) : undefined, contentId: row.id, - viewedAt: row.viewed_at, + viewedAt: normalizeStalkerDate(row.viewed_at), })); } catch { return []; @@ -267,7 +273,11 @@ export class UnifiedRecentDataService { const meta = await this.getPlaylistMeta(playlistId); return (rows || []).map((row) => ({ - uid: buildCollectionUid('xtream', playlistId, row.xtream_id), + uid: buildXtreamCollectionUid( + playlistId, + xtreamContentType(row.type), + row.xtream_id + ), name: row.title, contentType: xtreamContentType(row.type), sourceType: 'xtream' as const, @@ -279,7 +289,7 @@ export class UnifiedRecentDataService { categoryId: row.category_id, tvgId: row.type === 'live' ? String(row.xtream_id) : undefined, contentId: row.id, - viewedAt: row.viewed_at ?? '', + viewedAt: normalizeStalkerDate(row.viewed_at), })); } catch { return []; diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts index 041363e91..87e1d1f4d 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts @@ -17,14 +17,11 @@ describe('buildPortalRailLinks', () => { expect(links.secondary.map((link) => link.section)).toEqual([ 'recently-added', 'search', - 'recent', - 'favorites', 'downloads', ]); expect(links.primary[0]?.tooltip).toBe('Movies (this playlist)'); - expect(links.secondary[3]?.tooltip).toBe('Favorites (this playlist)'); - expect(links.secondary[4]?.tooltip).toBe('Downloads (this playlist)'); + expect(links.secondary[2]?.tooltip).toBe('Downloads (this playlist)'); }); it('builds workspace Xtream links without downloads on web', () => { @@ -60,15 +57,10 @@ describe('buildPortalRailLinks', () => { 'itv', 'series', ]); - expect(links.secondary.map((link) => link.section)).toEqual([ - 'search', - 'recent', - 'favorites', - ]); + expect(links.secondary.map((link) => link.section)).toEqual(['search']); expect(links.primary[1]?.tooltip).toBe('Live TV (this playlist)'); expect(links.secondary[0]?.tooltip).toBe('Search (this playlist)'); - expect(links.secondary[2]?.tooltip).toBe('Favorites (this playlist)'); }); it('builds M3U playlist links with scoped tooltip labels', () => { @@ -94,20 +86,6 @@ describe('buildPortalRailLinks', () => { exact: true, section: 'groups', }, - { - icon: 'history', - tooltip: 'Recently viewed (this playlist)', - path: ['/workspace', 'playlists', 'm3u-1', 'recent'], - exact: true, - section: 'recent', - }, - { - icon: 'favorite', - tooltip: 'Favorites (this playlist)', - path: ['/workspace', 'playlists', 'm3u-1', 'favorites'], - exact: true, - section: 'favorites', - }, ]); expect(links.secondary).toEqual([]); }); diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts index 038cc79bc..16085a48e 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts @@ -89,18 +89,6 @@ export function buildPortalRailLinks( tooltip: 'Search (this playlist)', path: [...root, 'search'], section: 'search', - }, - { - icon: 'history', - tooltip: 'Recently viewed (this playlist)', - path: [...root, 'recent'], - section: 'recent', - }, - { - icon: 'favorite', - tooltip: 'Favorites (this playlist)', - path: [...root, 'favorites'], - section: 'favorites', } ); @@ -145,18 +133,6 @@ export function buildPortalRailLinks( path: [...root, 'search'], section: 'search', }, - { - icon: 'history', - tooltip: 'Recently viewed (this playlist)', - path: [...root, 'recent'], - section: 'recent', - }, - { - icon: 'favorite', - tooltip: 'Favorites (this playlist)', - path: [...root, 'favorites'], - section: 'favorites', - }, ]; if (isElectron) { @@ -187,20 +163,6 @@ export function buildPortalRailLinks( exact: true, section: 'groups', }, - { - icon: 'history', - tooltip: 'Recently viewed (this playlist)', - path: [...root, 'recent'], - exact: true, - section: 'recent', - }, - { - icon: 'favorite', - tooltip: 'Favorites (this playlist)', - path: [...root, 'favorites'], - exact: true, - section: 'favorites', - }, ]; return { diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index 5b7174a91..5d11e0c2e 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -7,6 +7,7 @@ import { } from 'shared-interfaces'; import { buildCollectionUid, + buildXtreamCollectionUid, CollectionContentType, CollectionSourceType, UnifiedCollectionItem, @@ -550,7 +551,11 @@ function buildDashboardCollectionDetailItem( if (item.source === 'xtream') { return normalizeCollectionDetailItem({ - uid: buildCollectionUid('xtream', item.playlist_id, item.xtream_id), + uid: buildXtreamCollectionUid( + item.playlist_id, + item.type, + item.xtream_id + ), name: item.title, contentType: item.type, sourceType: 'xtream', diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index 83aae53f4..a236c5e28 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -376,8 +376,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { return this.dbService.getFavorites(playlistId); } - async addFavorite(contentId: number, playlistId: string): Promise { - await this.dbService.addToFavorites(contentId, playlistId); + async addFavorite( + contentId: number, + playlistId: string, + backdropUrl?: string + ): Promise { + await this.dbService.addToFavorites(contentId, playlistId, backdropUrl); } async removeFavorite(contentId: number, playlistId: string): Promise { @@ -396,8 +400,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { return this.dbService.getRecentItems(playlistId); } - async addRecentItem(contentId: number, playlistId: string): Promise { - await this.dbService.addRecentItem(contentId, playlistId); + async addRecentItem( + contentId: number, + playlistId: string, + backdropUrl?: string + ): Promise { + await this.dbService.addRecentItem(contentId, playlistId, backdropUrl); } async removeRecentItem( diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 619e90f28..e1628cab5 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -318,7 +318,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return results as XtreamContentItem[]; } - async addFavorite(contentId: number, playlistId: string): Promise { + async addFavorite( + contentId: number, + playlistId: string, + // PWA uses localStorage with no content table, so backdrop persistence + // is electron-only. Accept the param for interface parity. + _backdropUrl?: string + ): Promise { const allFavorites = this.getFavoritesFromStorage(); if (!allFavorites[playlistId]) { allFavorites[playlistId] = []; @@ -530,7 +536,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return results as XtreamContentItem[]; } - async addRecentItem(contentId: number, playlistId: string): Promise { + async addRecentItem( + contentId: number, + playlistId: string, + _backdropUrl?: string + ): Promise { const allRecent = this.getRecentItemsFromStorage(); if (!allRecent[playlistId]) { allRecent[playlistId] = []; diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 276d1efc2..de9b5e181 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -277,9 +277,16 @@ export interface IXtreamDataSource { getFavorites(playlistId: string): Promise; /** - * Add content to favorites + * Add content to favorites. + * @param backdropUrl optionally persisted to `content.backdrop_url` when + * the row doesn't already have one. Enables the dashboard hero to surface + * a cinematic backdrop without a separate round-trip. */ - addFavorite(contentId: number, playlistId: string): Promise; + addFavorite( + contentId: number, + playlistId: string, + backdropUrl?: string + ): Promise; /** * Remove content from favorites @@ -301,9 +308,13 @@ export interface IXtreamDataSource { getRecentItems(playlistId: string): Promise; /** - * Add item to recently viewed + * Add item to recently viewed. See `addFavorite` for `backdropUrl`. */ - addRecentItem(contentId: number, playlistId: string): Promise; + addRecentItem( + contentId: number, + playlistId: string, + backdropUrl?: string + ): Promise; /** * Remove item from recently viewed diff --git a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts index f4ec45697..b3ad6b290 100644 --- a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts @@ -12,8 +12,13 @@ export class FavoritesService { async addToFavorites(item: { content_id: number; playlist_id: string; + backdrop_url?: string; }): Promise { - await this.dbService.addToFavorites(item.content_id, item.playlist_id); + await this.dbService.addToFavorites( + item.content_id, + item.playlist_id, + item.backdrop_url + ); } async removeFromFavorites( diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts index 17240c5ec..67a395879 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts @@ -228,6 +228,9 @@ describe('withContent import state', () => { const liveOperationId = optionsByType.get('live')?.operationId ?? 'live-op'; expect(store.isImporting()).toBe(true); + expect(store.activeImportContentType()).toBe('live'); + expect(store.activeImportCurrentCount()).toBe(1); + expect(store.activeImportTotalCount()).toBe(2); expect(store.importCount()).toBe(1); expect(store.itemsToImport()).toBe(2); expect(store.activeImportOperationIds()).toEqual([liveOperationId]); @@ -246,6 +249,9 @@ describe('withContent import state', () => { await waitForCondition(() => store.importCount() === 2); const movieOperationId = optionsByType.get('movie')?.operationId ?? 'movie-op'; + expect(store.activeImportContentType()).toBe('vod'); + expect(store.activeImportCurrentCount()).toBe(1); + expect(store.activeImportTotalCount()).toBe(3); expect(store.itemsToImport()).toBe(5); expect(store.activeImportOperationIds()).toEqual([movieOperationId]); @@ -262,6 +268,9 @@ describe('withContent import state', () => { await waitForCondition(() => store.importCount() === 3); const seriesOperationId = optionsByType.get('series')?.operationId ?? 'series-op'; + expect(store.activeImportContentType()).toBe('series'); + expect(store.activeImportCurrentCount()).toBe(1); + expect(store.activeImportTotalCount()).toBe(4); expect(store.itemsToImport()).toBe(9); expect(store.activeImportOperationIds()).toEqual([seriesOperationId]); @@ -287,6 +296,9 @@ describe('withContent import state', () => { }); expect(store.activeImportOperationIds()).toEqual([]); expect(store.importPhase()).toBeNull(); + expect(store.activeImportContentType()).toBeNull(); + expect(store.activeImportCurrentCount()).toBe(0); + expect(store.activeImportTotalCount()).toBe(0); expect(store.importCount()).toBe(0); expect(store.itemsToImport()).toBe(0); }); @@ -715,6 +727,9 @@ describe('withContent import state', () => { ); const liveOperationId = optionsByType.get('live')?.operationId ?? 'live-op'; + expect(store.activeImportContentType()).toBe('live'); + expect(store.activeImportCurrentCount()).toBe(2); + expect(store.activeImportTotalCount()).toBe(5); await store.cancelImport(); @@ -748,6 +763,9 @@ describe('withContent import state', () => { }); expect(store.activeImportOperationIds()).toEqual([]); expect(store.importPhase()).toBeNull(); + expect(store.activeImportContentType()).toBeNull(); + expect(store.activeImportCurrentCount()).toBe(0); + expect(store.activeImportTotalCount()).toBe(0); expect(store.importCount()).toBe(0); expect(store.itemsToImport()).toBe(0); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index 087c90da4..dceaca409 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -93,6 +93,9 @@ export interface ContentState { importCount: number; importPhase: string | null; itemsToImport: number; + activeImportContentType: ContentType | null; + activeImportCurrentCount: number; + activeImportTotalCount: number; activeImportSessionId: string | null; activeImportOperationIds: string[]; isContentInitialized: boolean; @@ -123,6 +126,9 @@ const initialContentState: ContentState = { importCount: 0, importPhase: null, itemsToImport: 0, + activeImportContentType: null, + activeImportCurrentCount: 0, + activeImportTotalCount: 0, activeImportSessionId: null, activeImportOperationIds: [], isContentInitialized: false, @@ -221,6 +227,18 @@ export function withContent() { })); }; + const setActiveImportProgress = ( + type: ContentType | null, + current = 0, + total = 0 + ): void => { + patchState(store, { + activeImportContentType: type, + activeImportCurrentCount: current, + activeImportTotalCount: total, + }); + }; + const resolveInitBlockReason = ( portalStatus: PortalStatusType | null | undefined ): XtreamContentInitBlockReason | null => { @@ -354,13 +372,25 @@ export function withContent() { : state.activeImportOperationIds.includes( operationId ) - ? state.activeImportOperationIds + ? state.activeImportOperationIds : [ ...state.activeImportOperationIds, operationId, ], isCancellingImport: state.isCancellingImport, })); + + if ( + event.operation === 'save-content' && + store.activeImportContentType() + ) { + patchState(store, (state) => ({ + activeImportCurrentCount: + event.current ?? state.activeImportCurrentCount, + activeImportTotalCount: + event.total ?? state.activeImportTotalCount, + })); + } }; const registerImportOperation = (operationId: string): void => { @@ -473,6 +503,9 @@ export function withContent() { importCount: 0, importPhase: null, itemsToImport: 0, + activeImportContentType: null, + activeImportCurrentCount: 0, + activeImportTotalCount: 0, activeImportSessionId: importSessionId, activeImportOperationIds: [], contentLoadStateByType: { @@ -574,6 +607,9 @@ export function withContent() { importCount: 0, importPhase: null, itemsToImport: 0, + activeImportContentType: null, + activeImportCurrentCount: 0, + activeImportTotalCount: 0, activeImportSessionId: null, activeImportOperationIds: [], }); @@ -700,16 +736,24 @@ export function withContent() { const onTotal = (count: number) => { totalItems += count; - patchState(store, { itemsToImport: totalItems }); + patchState(store, { + itemsToImport: totalItems, + activeImportTotalCount: count, + }); }; const onProgress = (count: number) => { importedItems += count; - patchState(store, { importCount: importedItems }); + patchState(store, (state) => ({ + importCount: importedItems, + activeImportCurrentCount: + state.activeImportCurrentCount + count, + })); }; try { throwIfImportCancelled(options?.importSessionId); + setActiveImportProgress('live'); const liveOperationId = databaseService.createOperationId( 'db-save-content' @@ -746,6 +790,7 @@ export function withContent() { updateContentTypeLoadState('live', 'ready'); throwIfImportCancelled(options?.importSessionId); + setActiveImportProgress('vod'); const vodOperationId = databaseService.createOperationId( 'db-save-content' @@ -781,6 +826,7 @@ export function withContent() { updateContentTypeLoadState('vod', 'ready'); throwIfImportCancelled(options?.importSessionId); + setActiveImportProgress('series'); const seriesOperationId = databaseService.createOperationId( 'db-save-content' @@ -880,6 +926,9 @@ export function withContent() { patchState(store, { isCancellingImport: true, contentInitBlockReason: 'cancelled', + activeImportContentType: null, + activeImportCurrentCount: 0, + activeImportTotalCount: 0, activeImportSessionId: null, }); const ctx = getCredentialsFromStore(); diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts index 6c24e0bb0..324126864 100644 --- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts @@ -24,7 +24,8 @@ export const withFavorites = function () { async toggleFavorite( xtreamId: number, playlistId: string, - contentType: 'live' | 'movie' | 'series' + contentType: 'live' | 'movie' | 'series', + backdropUrl?: string ) { if (!xtreamId || !playlistId) { return false; @@ -58,6 +59,7 @@ export const withFavorites = function () { await favoritesService.addToFavorites({ content_id: content.id, playlist_id: playlistId, + backdrop_url: backdropUrl, }); patchState(store, { isFavorite: true }); return true; diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts new file mode 100644 index 000000000..950f41e2a --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts @@ -0,0 +1,161 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { signalStore } from '@ngrx/signals'; +import { of } from 'rxjs'; +import { DatabaseService, PlaylistsService } from 'services'; +import { withRecentItems } from './with-recent-items'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + createLogger: () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); + +const TestRecentItemsStore = signalStore(withRecentItems()); + +describe('withRecentItems', () => { + let store: InstanceType; + let databaseService: { + addRecentItem: jest.Mock; + getContentByXtreamId: jest.Mock; + getRecentItems: jest.Mock; + setContentBackdropIfMissing: jest.Mock; + }; + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + value: {} as Window['electron'], + configurable: true, + }); + + databaseService = { + addRecentItem: jest.fn().mockResolvedValue(undefined), + getContentByXtreamId: jest.fn(), + getRecentItems: jest.fn().mockResolvedValue([ + { + id: 3941697, + title: 'Krypton', + type: 'series', + poster_url: 'https://example.com/krypton.png', + backdrop_url: 'https://example.com/krypton-backdrop.png', + viewed_at: '2026-04-21T20:42:27.000Z', + xtream_id: 290, + category_id: 17, + }, + ]), + setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined), + }; + + TestBed.configureTestingModule({ + providers: [ + TestRecentItemsStore, + { + provide: DatabaseService, + useValue: databaseService, + }, + { + provide: PlaylistsService, + useValue: { + clearPlaylistRecentlyViewed: jest.fn().mockReturnValue( + of(undefined) + ), + getAllPlaylists: jest.fn().mockReturnValue(of([])), + }, + }, + ], + }); + + store = TestBed.inject(TestRecentItemsStore); + }); + + it('looks recent items up with the requested content type before saving one', async () => { + databaseService.getContentByXtreamId.mockResolvedValue({ + id: 3941697, + title: 'Krypton', + type: 'series', + xtream_id: 290, + }); + + store.addRecentItem({ + xtreamId: 290, + contentType: 'series', + playlist: signal({ id: 'playlist-1' }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + 290, + 'playlist-1', + 'series' + ); + expect(databaseService.addRecentItem).toHaveBeenCalledWith( + 3941697, + 'playlist-1', + undefined + ); + expect(store.recentItems()).toEqual([ + expect.objectContaining({ + id: 3941697, + title: 'Krypton', + type: 'series', + xtream_id: 290, + backdrop_url: 'https://example.com/krypton-backdrop.png', + }), + ]); + }); + + it('forwards backdrop urls on recent-item saves', async () => { + databaseService.getContentByXtreamId.mockResolvedValue({ + id: 3941697, + title: 'Krypton', + type: 'series', + xtream_id: 290, + }); + + store.addRecentItem({ + xtreamId: 290, + contentType: 'series', + playlist: signal({ id: 'playlist-1' }), + backdropUrl: 'https://example.com/krypton-backdrop.png', + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(databaseService.addRecentItem).toHaveBeenCalledWith( + 3941697, + 'playlist-1', + 'https://example.com/krypton-backdrop.png' + ); + }); + + it('backfills a backdrop without rewriting recent ordering', async () => { + databaseService.getContentByXtreamId.mockResolvedValue({ + id: 3941697, + title: 'Krypton', + type: 'series', + xtream_id: 290, + }); + + await store.backfillContentBackdrop({ + xtreamId: 290, + contentType: 'series', + playlist: signal({ id: 'playlist-1' }), + backdropUrl: ' https://example.com/krypton-backdrop.png ', + }); + + expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + 290, + 'playlist-1', + 'series' + ); + expect( + databaseService.setContentBackdropIfMissing + ).toHaveBeenCalledWith( + 3941697, + 'https://example.com/krypton-backdrop.png' + ); + expect(databaseService.addRecentItem).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts index 1065afc69..32e1f6046 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts @@ -26,6 +26,7 @@ function mapDbRecentItem( title: string; type: string; poster_url: string; + backdrop_url?: string | null; viewed_at?: string; xtream_id: number; category_id: number; @@ -37,6 +38,7 @@ function mapDbRecentItem( title: item.title, type: item.type as 'live' | 'movie' | 'series', poster_url: item.poster_url, + backdrop_url: item.backdrop_url ?? undefined, content_id: item.id, playlist_id: playlistId, viewed_at: item.viewed_at || '', @@ -75,22 +77,30 @@ export const withRecentItems = function () { playlistsService = inject(PlaylistsService) ) => ({ addRecentItem: rxMethod<{ - contentId: number; + xtreamId: number; + contentType: 'live' | 'movie' | 'series'; playlist: Signal<{ id: string }>; + backdropUrl?: string; }>( pipe( - switchMap(async ({ contentId, playlist }) => { - // contentId is actually xtream_id, need to look up the database content.id + switchMap( + async ({ + xtreamId, + contentType, + playlist, + backdropUrl, + }) => { const playlistId = playlist().id; - const content = - await dbService.getContentByXtreamId( - contentId, - playlistId - ); + const content = await dbService.getContentByXtreamId( + xtreamId, + playlistId, + contentType + ); if (content) { await dbService.addRecentItem( content.id, - playlistId + playlistId, + backdropUrl ); // Reload after add/update so re-watched items @@ -106,6 +116,41 @@ export const withRecentItems = function () { }) ) ), + async backfillContentBackdrop({ + xtreamId, + contentType, + playlist, + backdropUrl, + }: { + xtreamId: number; + contentType: 'live' | 'movie' | 'series'; + playlist: Signal<{ id: string }>; + backdropUrl?: string; + }): Promise { + if (!window.electron) { + return; + } + + const playlistId = playlist().id; + const normalizedBackdropUrl = backdropUrl?.trim(); + if (!playlistId || !normalizedBackdropUrl) { + return; + } + + const content = await dbService.getContentByXtreamId( + xtreamId, + playlistId, + contentType + ); + if (!content) { + return; + } + + await dbService.setContentBackdropIfMissing( + content.id, + normalizedBackdropUrl + ); + }, clearRecentItems: rxMethod<{ id: string }>( pipe( switchMap(async (playlist) => { @@ -160,6 +205,7 @@ export const withRecentItems = function () { title: item.title, type: item.type as 'live' | 'movie' | 'series', poster_url: item.poster_url, + backdrop_url: item.backdrop_url ?? undefined, content_id: item.id, playlist_id: item.playlist_id, playlist_name: item.playlist_name, diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 8622cf107..89ca17a05 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -84,6 +84,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { readonly xtreamDownloadContext = signal(null); private readonly detailsInitDone = signal(false); + private readonly backdropBackfillKey = signal(null); private lastSaveTime = 0; private unsubscribePositionUpdates: (() => void) | null = null; readonly openingEpisodeId = signal(null); @@ -131,6 +132,30 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { this.detailsInitDone.set(true); }); + effect(() => { + const playlistId = this.currentPlaylistId(); + const selectedItem = this.selectedItem(); + const xtreamId = Number(selectedItem?.series_id ?? 0); + const backdropUrl = selectedItem?.info?.backdrop_path?.[0]?.trim(); + + if (!playlistId || !Number.isFinite(xtreamId) || xtreamId <= 0 || !backdropUrl) { + return; + } + + const backfillKey = `${playlistId}:${xtreamId}:${backdropUrl}`; + if (this.backdropBackfillKey() === backfillKey) { + return; + } + + this.backdropBackfillKey.set(backfillKey); + void this.xtreamStore.backfillContentBackdrop({ + xtreamId, + contentType: 'series', + playlist: this.xtreamStore.currentPlaylist, + backdropUrl, + }); + }); + effect(() => { const session = this.externalPlayback.activeSession(); const selectedItem = this.selectedItem(); @@ -236,7 +261,8 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { this.xtreamStore.toggleFavorite( this.route.snapshot.params.serialId, this.xtreamStore.currentPlaylist().id, - 'series' + 'series', + this.selectedItem()?.info?.backdrop_path?.[0] ); } @@ -285,8 +311,10 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { private addToRecentlyViewed(xtreamId: number): void { this.xtreamStore.addRecentItem({ - contentId: xtreamId, + xtreamId, + contentType: 'series', playlist: this.xtreamStore.currentPlaylist, + backdropUrl: this.selectedItem()?.info?.backdrop_path?.[0], }); } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 1bac200fd..3b2e97f16 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -66,6 +66,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { private readonly translateService = inject(TranslateService); private readonly logger = createLogger('VodDetailsRoute'); private readonly detailsInitDone = signal(false); + private readonly backdropBackfillKey = signal(null); readonly inlinePlayback = signal(null); readonly vodPlaybackPosition = signal(null); @@ -269,6 +270,29 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.detailsInitDone.set(true); }); + effect(() => { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + const vodId = Number(this.route.snapshot.params.vodId); + const backdropUrl = this.selectedVodInfo()?.backdrop_path?.[0]?.trim(); + + if (!playlistId || !Number.isFinite(vodId) || vodId <= 0 || !backdropUrl) { + return; + } + + const backfillKey = `${playlistId}:${vodId}:${backdropUrl}`; + if (this.backdropBackfillKey() === backfillKey) { + return; + } + + this.backdropBackfillKey.set(backfillKey); + void this.xtreamStore.backfillContentBackdrop({ + xtreamId: vodId, + contentType: 'movie', + playlist: this.xtreamStore.currentPlaylist, + backdropUrl, + }); + }); + if (window.electron?.onPlaybackPositionUpdate) { this.unsubscribePositionUpdates = window.electron.onPlaybackPositionUpdate( @@ -392,7 +416,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.xtreamStore.toggleFavorite( this.route.snapshot.params.vodId, this.xtreamStore.currentPlaylist().id, - 'movie' + 'movie', + this.selectedVodInfo()?.backdrop_path?.[0] ); } @@ -485,8 +510,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { private addToRecentlyViewed(): void { this.xtreamStore.addRecentItem({ - contentId: this.route.snapshot.params.vodId, + xtreamId: Number(this.route.snapshot.params.vodId), + contentType: 'movie', playlist: this.xtreamStore.currentPlaylist, + backdropUrl: this.selectedVodInfo()?.backdrop_path?.[0], }); } diff --git a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts index a0f27b67a..548fdad18 100644 --- a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts @@ -16,6 +16,13 @@ export interface PortalActivityItem { category_id: string | number; xtream_id: string | number; poster_url?: string; + /** + * Wide backdrop URL (typically 16:9), persisted when the user first views + * the detail page. Absent for stalker and for items that have never had + * their detail page opened. Dashboards should fall back to a blurred + * `poster_url` when undefined. + */ + backdrop_url?: string; source?: 'xtream' | 'stalker' | 'm3u'; /** Original stalker item for navigation state; undefined for xtream. */ stalker_item?: StalkerPortalItem;