feat: enhance unified collection with workspace commands and backdrop handling

- Added WorkspaceViewCommandService to UnifiedCollectionPageComponent for workspace layout commands.
- Implemented clear current view command for favorites and recent items.
- Updated unified favorites data service to use buildXtreamCollectionUid for UID generation.
- Enhanced unified recent data service to normalize timestamps and handle backdrop URLs.
- Introduced withRecentItems feature to manage recent item additions with backdrop support.
- Updated VodDetailsRouteComponent and SerialDetailsComponent to backfill content backdrops.
- Modified IXtreamDataSource interface to include backdrop URL handling for favorites and recent items.
- Added tests for recent items feature to ensure correct functionality and backdrop handling.
This commit is contained in:
4gray committed 2026-04-22 22:23:50 +02:00
1 parent 6904b81c47
commit 7caa18308d
26 files changed
+1083 -99

No files matched your search

@@ -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(),
});
}
+1
View File
@@ -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';
@@ -0,0 +1,57 @@
<section class="rail" [class.rail--active]="active()">
<header class="rail__header">
<h3 class="rail__title">
<span class="rail__label">{{ label() }}</span>
@if ((totalCount() ?? 0) > 0) {
<span class="rail__count" aria-hidden="true">{{
totalCount()
}}</span>
}
</h3>
@if (seeAllLink(); as link) {
<a class="rail__see-all" [routerLink]="link">
<span class="rail__see-all-label">
@if (seeAllLabel()) {
{{ seeAllLabel() }}
} @else {
{{ 'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate }}
}
</span>
<mat-icon>chevron_right</mat-icon>
</a>
}
</header>
<div class="rail__viewport">
<button
type="button"
class="rail__chev rail__chev--left"
[class.rail__chev--visible]="canScrollLeft()"
(click)="scrollBy(-1)"
aria-label="Scroll left"
tabindex="-1"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div
#track
class="rail__track"
(scroll)="onScroll()"
role="list"
>
<ng-content></ng-content>
</div>
<button
type="button"
class="rail__chev rail__chev--right"
[class.rail__chev--visible]="canScrollRight()"
(click)="scrollBy(1)"
aria-label="Scroll right"
tabindex="-1"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
</section>
@@ -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;
}
}
@@ -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<string>();
/** Subtle pill next to the label. Hidden when null or <= 0. */
readonly totalCount = input<number | null>(null);
readonly seeAllLink = input<string[] | null>(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<string | null>(null);
/** Optional active-accent decoration (e.g. the currently-scoped section). */
readonly active = input<boolean>(false);
private readonly track = viewChild.required<ElementRef<HTMLDivElement>>('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
);
}
}
@@ -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<unknown>(null);
readonly sortMode = input<FavoritesChannelSortMode>('custom');
readonly removeItem = output<UnifiedCollectionItem>();
readonly reorderItems = output<UnifiedCollectionItem[]>();
@@ -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 = [
{
@@ -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') {
+2
View File
@@ -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';
@@ -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,
};
@@ -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<PlaylistMeta>,
])
);
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<PlaylistMeta>,
])
);
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',
})
);
});
});
@@ -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 [];
@@ -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([]);
});
@@ -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 {
@@ -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',
@@ -376,8 +376,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
return this.dbService.getFavorites(playlistId);
}
async addFavorite(contentId: number, playlistId: string): Promise<void> {
await this.dbService.addToFavorites(contentId, playlistId);
async addFavorite(
contentId: number,
playlistId: string,
backdropUrl?: string
): Promise<void> {
await this.dbService.addToFavorites(contentId, playlistId, backdropUrl);
}
async removeFavorite(contentId: number, playlistId: string): Promise<void> {
@@ -396,8 +400,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
return this.dbService.getRecentItems(playlistId);
}
async addRecentItem(contentId: number, playlistId: string): Promise<void> {
await this.dbService.addRecentItem(contentId, playlistId);
async addRecentItem(
contentId: number,
playlistId: string,
backdropUrl?: string
): Promise<void> {
await this.dbService.addRecentItem(contentId, playlistId, backdropUrl);
}
async removeRecentItem(
@@ -318,7 +318,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return results as XtreamContentItem[];
}
async addFavorite(contentId: number, playlistId: string): Promise<void> {
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<void> {
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<void> {
async addRecentItem(
contentId: number,
playlistId: string,
_backdropUrl?: string
): Promise<void> {
const allRecent = this.getRecentItemsFromStorage();
if (!allRecent[playlistId]) {
allRecent[playlistId] = [];
@@ -277,9 +277,16 @@ export interface IXtreamDataSource {
getFavorites(playlistId: string): Promise<XtreamContentItem[]>;
/**
* 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<void>;
addFavorite(
contentId: number,
playlistId: string,
backdropUrl?: string
): Promise<void>;
/**
* Remove content from favorites
@@ -301,9 +308,13 @@ export interface IXtreamDataSource {
getRecentItems(playlistId: string): Promise<XtreamContentItem[]>;
/**
* Add item to recently viewed
* Add item to recently viewed. See `addFavorite` for `backdropUrl`.
*/
addRecentItem(contentId: number, playlistId: string): Promise<void>;
addRecentItem(
contentId: number,
playlistId: string,
backdropUrl?: string
): Promise<void>;
/**
* Remove item from recently viewed
@@ -12,8 +12,13 @@ export class FavoritesService {
async addToFavorites(item: {
content_id: number;
playlist_id: string;
backdrop_url?: string;
}): Promise<void> {
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(
@@ -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);
});
@@ -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();
@@ -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;
@@ -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<typeof TestRecentItemsStore>;
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();
});
});
@@ -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<void> {
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,
@@ -84,6 +84,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
readonly xtreamDownloadContext =
signal<SeasonContainerXtreamDownloadContext | null>(null);
private readonly detailsInitDone = signal(false);
private readonly backdropBackfillKey = signal<string | null>(null);
private lastSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal<number | null>(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],
});
}
@@ -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<string | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly vodPlaybackPosition = signal<PlaybackPositionData | null>(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],
});
}
@@ -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;