mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
+57
@@ -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>
|
||||
+267
@@ -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;
|
||||
}
|
||||
}
|
||||
+95
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+68
@@ -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 = [
|
||||
{
|
||||
|
||||
+35
@@ -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') {
|
||||
|
||||
@@ -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] = [];
|
||||
|
||||
+15
-4
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user