Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
displayName: 'portal-catalog-feature',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/portal/catalog/feature',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "portal-catalog-feature",
|
||||
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/portal/catalog/feature/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:portal", "domain:portal-shared", "type:feature"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/portal/catalog/feature/jest.config.ts",
|
||||
"tsConfig": "libs/portal/catalog/feature/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/category-content-view/category-content-view.component';
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
@if (selectedCategory()) {
|
||||
@if (selectedDetailComponent(); as detailComponentRef) {
|
||||
<ng-container *ngComponentOutlet="detailComponentRef" />
|
||||
} @else {
|
||||
<div class="category-content-layout">
|
||||
<div class="category-content-header">
|
||||
<div class="category-meta">
|
||||
<h2 class="category-title">
|
||||
{{ selectedCategoryTitle() }}
|
||||
</h2>
|
||||
<div class="category-subtitle">
|
||||
{{ categoryItemSubtitle() }}
|
||||
</div>
|
||||
</div>
|
||||
@if (canSortContent()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="sort-action"
|
||||
[matMenuTriggerFor]="sortContentMenu"
|
||||
matTooltip="Sort content"
|
||||
aria-label="Sort content"
|
||||
>
|
||||
<mat-icon>sort</mat-icon>
|
||||
</button>
|
||||
<mat-menu #sortContentMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('date-desc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_DATE_DESC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'date-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('date-asc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_DATE_ASC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'date-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('name-asc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_NAME_ASC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('name-desc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_NAME_DESC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</mat-menu>
|
||||
}
|
||||
@if (categoryItemCount() > 0) {
|
||||
<mat-paginator
|
||||
[pageIndex]="pageIndex()"
|
||||
[length]="categoryItemCount()"
|
||||
[pageSize]="limit()"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
(page)="onPageChange($event)"
|
||||
aria-label="Select page"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<app-grid-list
|
||||
[isLoading]="isPaginatedContentLoading()"
|
||||
[items]="contentWithProgress()"
|
||||
(itemClicked)="onItemClick($event)"
|
||||
[pageIndex]="pageIndex()"
|
||||
[totalPages]="totalPages()"
|
||||
[limit]="limit()"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
[showPaginator]="false"
|
||||
(pageChange)="onPageChange($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<app-playlist-error-view
|
||||
[title]="'PORTALS.ERROR_VIEW.NO_CATEGORY_SELECTED.TITLE' | translate"
|
||||
[description]="
|
||||
'PORTALS.ERROR_VIEW.NO_CATEGORY_SELECTED.DESCRIPTION' | translate
|
||||
"
|
||||
[showActionButtons]="false"
|
||||
[viewType]="'EMPTY_CATEGORY'"
|
||||
/>
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
@use '../../../../../../ui/styles/panel-header' as panel;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.category-content-layout {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ─── Sticky header bar — styled like a widget header ─────────────────────────
|
||||
.category-content-header {
|
||||
@include panel.standard-panel-header($sticky: true);
|
||||
}
|
||||
|
||||
// ─── Category meta (title + subtitle) ────────────────────────────────────────
|
||||
.category-meta {
|
||||
@include panel.standard-panel-meta();
|
||||
}
|
||||
|
||||
.category-title {
|
||||
@include panel.standard-panel-title();
|
||||
}
|
||||
|
||||
.category-subtitle {
|
||||
@include panel.standard-panel-subtitle();
|
||||
}
|
||||
|
||||
// ─── Paginator — flush right, no ghost background ────────────────────────────
|
||||
mat-paginator {
|
||||
background: transparent !important;
|
||||
flex-shrink: 0;
|
||||
// Shrink paginator text to fit header height comfortably
|
||||
--mat-paginator-container-size: 40px;
|
||||
--mat-paginator-enabled-icon-color: var(--app-body-color, var(--mat-sys-on-surface-variant));
|
||||
--mat-paginator-disabled-icon-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
// ─── Grid scroll area ─────────────────────────────────────────────────────────
|
||||
app-grid-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 20px;
|
||||
display: block;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.category-content-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 16px;
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.category-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
app-grid-list {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
}
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { NgComponentOutlet } from '@angular/common';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { ReplaySubject, of } from 'rxjs';
|
||||
import {
|
||||
PORTAL_CATALOG_DETAIL_COMPONENT,
|
||||
PORTAL_CATALOG_FACADE,
|
||||
PortalCatalogSortMode,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { CategoryContentViewComponent } from './category-content-view.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-grid-list',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class MockGridListComponent {
|
||||
readonly isLoading = input<boolean>();
|
||||
readonly items = input<unknown[]>();
|
||||
readonly pageIndex = input<number>();
|
||||
readonly totalPages = input<number>();
|
||||
readonly limit = input<number>();
|
||||
readonly pageSizeOptions = input<number[]>();
|
||||
readonly showPaginator = input(true);
|
||||
readonly itemClicked = output<unknown>();
|
||||
readonly pageChange = output<unknown>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-error-view',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class MockPlaylistErrorViewComponent {
|
||||
readonly title = input('');
|
||||
readonly description = input('');
|
||||
readonly showActionButtons = input(true);
|
||||
readonly viewType = input('');
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class MockDetailComponent {}
|
||||
|
||||
describe('CategoryContentViewComponent', () => {
|
||||
let fixture: ComponentFixture<CategoryContentViewComponent>;
|
||||
let router: { navigate: jest.Mock };
|
||||
const paramMap$ = new ReplaySubject(1);
|
||||
const queryParamMap$ = new ReplaySubject(1);
|
||||
const isPaginatedContentLoading = signal(true);
|
||||
const categoryItemCount = signal(0);
|
||||
const contentSortMode = signal<PortalCatalogSortMode | null>(null);
|
||||
const catalog = {
|
||||
provider: 'xtream' as const,
|
||||
pageSizeOptions: [10, 25, 50],
|
||||
contentType: signal('vod'),
|
||||
limit: signal(25),
|
||||
pageIndex: signal(0),
|
||||
selectedCategory: signal({ id: 1 }),
|
||||
paginatedContent: signal<unknown[]>([]),
|
||||
selectedCategoryTitle: signal('Movies'),
|
||||
categoryItemCount,
|
||||
selectedItem: signal(null),
|
||||
totalPages: signal(0),
|
||||
contentSortMode,
|
||||
playlist: signal(null),
|
||||
isPaginatedContentLoading,
|
||||
initialize: jest.fn(),
|
||||
setSearchQuery: jest.fn(),
|
||||
clearSelectedItem: jest.fn(),
|
||||
setPage: jest.fn(),
|
||||
setLimit: jest.fn(),
|
||||
setContentSortMode: jest.fn(),
|
||||
selectItem: jest.fn().mockReturnValue(null),
|
||||
getItemProgress: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
isPaginatedContentLoading.set(true);
|
||||
categoryItemCount.set(0);
|
||||
contentSortMode.set(null);
|
||||
catalog.initialize.mockClear();
|
||||
catalog.setSearchQuery.mockClear();
|
||||
catalog.setPage.mockClear();
|
||||
catalog.setLimit.mockClear();
|
||||
catalog.selectItem.mockClear();
|
||||
catalog.selectItem.mockReturnValue(null);
|
||||
router = {
|
||||
navigate: jest.fn(),
|
||||
};
|
||||
paramMap$.next(convertToParamMap({}));
|
||||
queryParamMap$.next(convertToParamMap({}));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CategoryContentViewComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) =>
|
||||
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
|
||||
? 'Fetching playlist data from source...'
|
||||
: key,
|
||||
get: (key: string) =>
|
||||
of(
|
||||
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
|
||||
? 'Fetching playlist data from source...'
|
||||
: key
|
||||
),
|
||||
stream: (key: string) =>
|
||||
of(
|
||||
key === 'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
|
||||
? 'Fetching playlist data from source...'
|
||||
: key
|
||||
),
|
||||
onLangChange: of(null),
|
||||
onTranslationChange: of(null),
|
||||
onDefaultLangChange: of(null),
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_CATALOG_FACADE,
|
||||
useValue: catalog,
|
||||
},
|
||||
{
|
||||
provide: PORTAL_CATALOG_DETAIL_COMPONENT,
|
||||
useValue: MockDetailComponent,
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: paramMap$.asObservable(),
|
||||
queryParamMap: queryParamMap$.asObservable(),
|
||||
snapshot: {
|
||||
params: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(CategoryContentViewComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
NgComponentOutlet,
|
||||
MockGridListComponent,
|
||||
MockPlaylistErrorViewComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CategoryContentViewComponent);
|
||||
});
|
||||
|
||||
it('shows loading copy in the subtitle instead of 0 items while xtream content is still warming up', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const subtitle = fixture.nativeElement.querySelector(
|
||||
'.category-subtitle'
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(catalog.initialize).toHaveBeenCalledWith(null);
|
||||
expect(subtitle?.textContent?.trim()).toBe(
|
||||
'Fetching playlist data from source...'
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards query-param search updates to the catalog facade when supported', () => {
|
||||
fixture.detectChanges();
|
||||
catalog.setSearchQuery.mockClear();
|
||||
|
||||
queryParamMap$.next(
|
||||
convertToParamMap({
|
||||
q: 'matrix',
|
||||
})
|
||||
);
|
||||
|
||||
expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix');
|
||||
});
|
||||
|
||||
it('restores the zero-based catalog page from the one-based page query param', () => {
|
||||
fixture.detectChanges();
|
||||
catalog.setPage.mockClear();
|
||||
|
||||
queryParamMap$.next(
|
||||
convertToParamMap({
|
||||
page: '3',
|
||||
})
|
||||
);
|
||||
|
||||
expect(catalog.setPage).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('falls back to the first catalog page when the page query param is absent or invalid', () => {
|
||||
fixture.detectChanges();
|
||||
catalog.setPage.mockClear();
|
||||
|
||||
queryParamMap$.next(convertToParamMap({}));
|
||||
queryParamMap$.next(
|
||||
convertToParamMap({
|
||||
page: 'not-a-page',
|
||||
})
|
||||
);
|
||||
|
||||
expect(catalog.setPage).toHaveBeenNthCalledWith(1, 0);
|
||||
expect(catalog.setPage).toHaveBeenNthCalledWith(2, 0);
|
||||
});
|
||||
|
||||
it('writes one-based page query params when the paginator changes', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.onPageChange({
|
||||
length: 100,
|
||||
pageIndex: 1,
|
||||
pageSize: 25,
|
||||
previousPageIndex: 0,
|
||||
});
|
||||
|
||||
expect(catalog.setPage).toHaveBeenCalledWith(1);
|
||||
expect(catalog.setLimit).toHaveBeenCalledWith(25);
|
||||
expect(router.navigate).toHaveBeenCalledWith([], {
|
||||
relativeTo: expect.any(Object),
|
||||
queryParams: {
|
||||
page: 2,
|
||||
},
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('removes the page query param when returning to the first page', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.onPageChange({
|
||||
length: 100,
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
previousPageIndex: 1,
|
||||
});
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith([], {
|
||||
relativeTo: expect.any(Object),
|
||||
queryParams: {
|
||||
page: null,
|
||||
},
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('scrolls the grid list host to the top when the paginator changes', () => {
|
||||
fixture.detectChanges();
|
||||
const gridList = fixture.nativeElement.querySelector(
|
||||
'app-grid-list'
|
||||
) as HTMLElement;
|
||||
const scrollTo = jest.fn();
|
||||
Object.defineProperty(gridList, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: scrollTo,
|
||||
});
|
||||
|
||||
fixture.componentInstance.onPageChange({
|
||||
length: 100,
|
||||
pageIndex: 1,
|
||||
pageSize: 25,
|
||||
previousPageIndex: 0,
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 0 });
|
||||
});
|
||||
|
||||
it('preserves query params when navigating from an item to Xtream details', () => {
|
||||
catalog.selectItem.mockReturnValue(['42']);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.onItemClick({
|
||||
xtream_id: 42,
|
||||
});
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(['42'], {
|
||||
relativeTo: expect.any(Object),
|
||||
queryParamsHandling: 'preserve',
|
||||
});
|
||||
});
|
||||
});
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import { NgComponentOutlet } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
GridListComponent,
|
||||
PlaylistErrorViewComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
clearNavigationStateKeys,
|
||||
getOpenStalkerItemState,
|
||||
PortalCatalogFacade,
|
||||
OPEN_STALKER_ITEM_STATE_KEY,
|
||||
PORTAL_CATALOG_DETAIL_COMPONENT,
|
||||
PORTAL_CATALOG_FACADE,
|
||||
PortalCatalogSortMode,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
interface CategoryContentItem {
|
||||
id?: number | string;
|
||||
is_series?: number | string | boolean;
|
||||
xtream_id?: number | string;
|
||||
series_id?: number | string;
|
||||
stream_id?: number | string;
|
||||
category_id?: number | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-content-view',
|
||||
templateUrl: './category-content-view.component.html',
|
||||
styleUrls: ['./category-content-view.component.scss'],
|
||||
imports: [
|
||||
GridListComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatTooltip,
|
||||
NgComponentOutlet,
|
||||
PlaylistErrorViewComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class CategoryContentViewComponent implements OnInit {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
private readonly router = inject(Router);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly catalog = inject(
|
||||
PORTAL_CATALOG_FACADE
|
||||
) as PortalCatalogFacade<
|
||||
CategoryContentItem,
|
||||
CategoryContentItem,
|
||||
CategoryContentItem
|
||||
>;
|
||||
|
||||
readonly detailComponent = inject(PORTAL_CATALOG_DETAIL_COMPONENT);
|
||||
readonly contentType = this.catalog.contentType;
|
||||
readonly limit = this.catalog.limit;
|
||||
readonly pageIndex = this.catalog.pageIndex;
|
||||
readonly pageSizeOptions = Array.from(this.catalog.pageSizeOptions);
|
||||
readonly selectedCategory = this.catalog.selectedCategory;
|
||||
readonly paginatedContent = this.catalog.paginatedContent;
|
||||
readonly selectedCategoryTitle = this.catalog.selectedCategoryTitle;
|
||||
readonly categoryItemCount = this.catalog.categoryItemCount;
|
||||
readonly selectedItem = this.catalog.selectedItem;
|
||||
readonly totalPages = this.catalog.totalPages;
|
||||
readonly contentSortMode = this.catalog.contentSortMode;
|
||||
readonly isPaginatedContentLoading = this.catalog.isPaginatedContentLoading;
|
||||
readonly isXtreamLoadingSubtitle = computed(
|
||||
() =>
|
||||
this.catalog.provider === 'xtream' &&
|
||||
this.isPaginatedContentLoading()
|
||||
);
|
||||
readonly categoryItemSubtitle = computed(() => {
|
||||
if (this.isXtreamLoadingSubtitle()) {
|
||||
return this.translate.instant(
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_LOADING'
|
||||
);
|
||||
}
|
||||
|
||||
const itemCount = this.categoryItemCount();
|
||||
return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
|
||||
});
|
||||
readonly canSortContent = computed(() => this.contentSortMode() !== null);
|
||||
readonly selectedDetailComponent = computed(() =>
|
||||
this.selectedItem() ? this.detailComponent : null
|
||||
);
|
||||
readonly contentWithProgress = computed(() =>
|
||||
(this.paginatedContent() ?? []).map((item: CategoryContentItem) => ({
|
||||
...item,
|
||||
...this.catalog.getItemProgress(item),
|
||||
}))
|
||||
);
|
||||
|
||||
setContentSortMode(mode: PortalCatalogSortMode): void {
|
||||
this.catalog.setContentSortMode(mode);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.activatedRoute.paramMap
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((params) => {
|
||||
this.catalog.initialize(params.get('categoryId'));
|
||||
this.openStalkerItemFromNavigationState();
|
||||
});
|
||||
|
||||
this.activatedRoute.queryParamMap
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((params) => {
|
||||
this.catalog.setSearchQuery?.(params.get('q') ?? '');
|
||||
this.catalog.setPage(this.toPageIndex(params.get('page')));
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.catalog.setPage(event.pageIndex);
|
||||
this.catalog.setLimit(event.pageSize);
|
||||
this.scrollGridToTop();
|
||||
|
||||
void this.router.navigate([], {
|
||||
relativeTo: this.activatedRoute,
|
||||
queryParams: {
|
||||
page: event.pageIndex > 0 ? event.pageIndex + 1 : null,
|
||||
},
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
onItemClick(item: CategoryContentItem): void {
|
||||
const navigation = this.catalog.selectItem(item);
|
||||
if (navigation?.length) {
|
||||
this.router.navigate(navigation, {
|
||||
relativeTo: this.activatedRoute,
|
||||
queryParamsHandling: 'preserve',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private toPageIndex(value: string | null): number {
|
||||
const page = Number(value);
|
||||
return Number.isInteger(page) && page > 0 ? page - 1 : 0;
|
||||
}
|
||||
|
||||
private scrollGridToTop(): void {
|
||||
const gridList = this.hostElement.nativeElement.querySelector(
|
||||
'app-grid-list'
|
||||
) as HTMLElement | null;
|
||||
gridList?.scrollTo?.({ top: 0 });
|
||||
}
|
||||
|
||||
private openStalkerItemFromNavigationState(): void {
|
||||
if (this.catalog.provider !== 'stalker') {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = getOpenStalkerItemState(window.history.state);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.catalog.selectItem(item as CategoryContentItem);
|
||||
clearNavigationStateKeys([
|
||||
OPEN_STALKER_ITEM_STATE_KEY,
|
||||
'openFavoriteItem',
|
||||
'openRecentItem',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "portal-downloads-feature",
|
||||
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/portal/downloads/feature/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:portal", "domain:portal-shared", "type:feature"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/downloads.component';
|
||||
@@ -0,0 +1,331 @@
|
||||
<div class="downloads-layout">
|
||||
<div class="downloads">
|
||||
<header class="downloads__header">
|
||||
<div class="downloads__header-left">
|
||||
<div class="settings-group__header-text">
|
||||
<h1>{{ 'DOWNLOADS.TITLE' | translate }}</h1>
|
||||
@if (activeCount() > 0) {
|
||||
<span class="downloads__badge">{{
|
||||
activeCount()
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="downloads__header-actions">
|
||||
@if (isAvailable()) {
|
||||
<div
|
||||
class="downloads__folder-inline"
|
||||
[attr.title]="
|
||||
downloadFolder() ||
|
||||
('DOWNLOADS.NO_FOLDER' | translate)
|
||||
"
|
||||
>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
<span class="downloads__folder-inline-path">
|
||||
{{
|
||||
downloadFolder() ||
|
||||
('DOWNLOADS.NO_FOLDER' | translate)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<button mat-stroked-button (click)="changeFolder()">
|
||||
{{ 'DOWNLOADS.CHANGE_FOLDER' | translate }}
|
||||
</button>
|
||||
@if (hasClearableDownloads()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="clearCompleted()"
|
||||
[matTooltip]="'DOWNLOADS.CLEAR_COMPLETED' | translate"
|
||||
[attr.aria-label]="
|
||||
'DOWNLOADS.CLEAR_COMPLETED' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
{{ 'DOWNLOADS.CLEAR_COMPLETED' | translate }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (!isAvailable()) {
|
||||
<div class="downloads__empty">
|
||||
<mat-icon>desktop_access_disabled</mat-icon>
|
||||
<p>{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}</p>
|
||||
<span>{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}</span>
|
||||
</div>
|
||||
} @else if (hasNoPlaylists()) {
|
||||
<app-empty-state
|
||||
type="no-data"
|
||||
icon="download_for_offline"
|
||||
titleKey="DOWNLOADS.NO_PLAYLISTS_TITLE"
|
||||
descriptionKey="DOWNLOADS.NO_PLAYLISTS_BODY"
|
||||
primaryActionLabelKey="HOME.PLAYLISTS.ADD_YOUR_FIRST"
|
||||
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_SOURCES"
|
||||
(primaryActionClicked)="addPlaylist()"
|
||||
(secondaryActionClicked)="goToSources()"
|
||||
/>
|
||||
} @else if (!hasDownloads()) {
|
||||
<app-empty-state
|
||||
type="no-data"
|
||||
icon="download_for_offline"
|
||||
titleKey="DOWNLOADS.EMPTY"
|
||||
descriptionKey="DOWNLOADS.NO_ITEMS_BODY"
|
||||
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_DASHBOARD"
|
||||
(secondaryActionClicked)="goToDashboard()"
|
||||
/>
|
||||
} @else if (filteredDownloads().length === 0) {
|
||||
<div class="downloads__empty">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<p>{{ 'DOWNLOADS.NO_FILTER_MATCH' | translate }}</p>
|
||||
<span>{{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="downloads__list-wrapper">
|
||||
<div class="downloads__list">
|
||||
@for (item of filteredDownloads(); track item.id) {
|
||||
<div
|
||||
class="downloads__item"
|
||||
[class]="getStatusColor(item.status)"
|
||||
[class.downloads__item--clickable]="
|
||||
isItemNavigable(item)
|
||||
"
|
||||
[class.downloads__item--unavailable]="
|
||||
!hasSourcePlaylist(item)
|
||||
"
|
||||
[attr.role]="
|
||||
isItemNavigable(item) ? 'button' : null
|
||||
"
|
||||
[attr.tabindex]="isItemNavigable(item) ? 0 : null"
|
||||
[matTooltip]="
|
||||
!hasSourcePlaylist(item)
|
||||
? ('DOWNLOADS.SOURCE_PLAYLIST_MISSING'
|
||||
| translate)
|
||||
: null
|
||||
"
|
||||
(click)="openInLibrary(item)"
|
||||
(keydown)="onItemCardKeydown($event, item)"
|
||||
>
|
||||
<div class="downloads__item-poster">
|
||||
@if (hasPoster(item)) {
|
||||
<img
|
||||
[src]="item.posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="lazy"
|
||||
(error)="markPosterFailed(item)"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
getPosterPlaceholderIcon(item)
|
||||
}}</mat-icon>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="downloads__item-info">
|
||||
<div class="downloads__item-title">
|
||||
<span class="downloads__item-title-text">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
@if (item.contentType === 'episode') {
|
||||
<span class="downloads__episode-badge">
|
||||
{{ formatEpisodeLabel(item) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="downloads__item-meta">
|
||||
<span
|
||||
class="downloads__item-status"
|
||||
[class]="getStatusColor(item.status)"
|
||||
>
|
||||
<mat-icon>{{
|
||||
getStatusIcon(item.status)
|
||||
}}</mat-icon>
|
||||
{{
|
||||
'DOWNLOADS.STATUS.' +
|
||||
item.status.toUpperCase()
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
|
||||
@if (
|
||||
item.status === 'downloading' ||
|
||||
item.status === 'completed'
|
||||
) {
|
||||
<span class="downloads__item-size">
|
||||
{{
|
||||
formatBytes(
|
||||
item.bytesDownloaded
|
||||
)
|
||||
}}
|
||||
@if (item.totalBytes) {
|
||||
/
|
||||
{{
|
||||
formatBytes(item.totalBytes)
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (item.errorMessage) {
|
||||
<span class="downloads__item-error">
|
||||
{{ item.errorMessage }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (item.status === 'downloading') {
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="getProgress(item)"
|
||||
></mat-progress-bar>
|
||||
}
|
||||
|
||||
<div class="downloads__item-actions">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
copyUrl(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.COPY_URL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
@switch (item.status) {
|
||||
@case ('queued') {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
cancel(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.CANCEL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('downloading') {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
cancel(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.CANCEL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('completed') {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
play(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.PLAY' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
reveal(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REVEAL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
remove(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REMOVE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('failed') {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="accent"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
retry(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.RETRY' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
remove(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REMOVE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('canceled') {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="accent"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
retry(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.RETRY' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
remove(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REMOVE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,418 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.downloads-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.downloads {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding: 24px 16px 12px;
|
||||
border-bottom: 1px solid var(--mat-sys-outline-variant);
|
||||
|
||||
&-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.settings-group__header-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
background: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-on-primary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__folder-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: min(42vw, 460px);
|
||||
padding: 7px 10px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-high) 86%,
|
||||
transparent
|
||||
);
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 1px 0
|
||||
color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent);
|
||||
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
color: var(--mat-sys-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__folder-inline-path {
|
||||
font-family:
|
||||
'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas,
|
||||
monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--mat-sys-on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 4rem;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 520;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__list-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 16px;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr));
|
||||
gap: 12px;
|
||||
padding-bottom: 24px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
min-width: 0;
|
||||
background: var(
|
||||
--app-widget-bg,
|
||||
var(--mat-sys-surface-container-high)
|
||||
);
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
|
||||
box-shadow: var(--app-widget-shadow, none);
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--app-selection-surface);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-border) 72%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&--unavailable {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&-poster {
|
||||
width: 64px;
|
||||
height: 90px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(
|
||||
--app-widget-header-bg,
|
||||
var(--mat-sys-surface-container-high)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 2rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 90px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--mat-sys-on-surface);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-title-text {
|
||||
min-width: 0;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
&-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: var(
|
||||
--app-widget-header-bg,
|
||||
var(--mat-sys-surface-container-high)
|
||||
);
|
||||
|
||||
mat-icon {
|
||||
font-size: 1.1rem;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
&.status-downloading {
|
||||
color: #2196f3;
|
||||
background: color-mix(in srgb, #2196f3 12%, transparent);
|
||||
}
|
||||
|
||||
&.status-completed {
|
||||
color: #4caf50;
|
||||
background: color-mix(in srgb, #4caf50 12%, transparent);
|
||||
}
|
||||
|
||||
&.status-failed {
|
||||
color: var(--mat-sys-error);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error) 12%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&.status-queued {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
&-size {
|
||||
font-family:
|
||||
'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas,
|
||||
monospace;
|
||||
background: transparent;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
&-error {
|
||||
color: var(--mat-sys-error);
|
||||
font-size: 0.8rem;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error) 12%,
|
||||
transparent
|
||||
);
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.2rem;
|
||||
margin-top: auto;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-separator, var(--mat-sys-outline-variant)) 72%,
|
||||
transparent
|
||||
);
|
||||
|
||||
button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 9px;
|
||||
--mdc-icon-button-state-layer-size: 32px;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
opacity: 0.86;
|
||||
|
||||
&:hover {
|
||||
background: var(--app-selection-surface);
|
||||
transform: translateY(-1px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
mat-progress-bar {
|
||||
margin-top: 0.15rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__episode-badge {
|
||||
background: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-on-primary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.downloads {
|
||||
&__header {
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__header-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&__folder-inline {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&__list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 760px) {
|
||||
.downloads__item {
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { DialogService } from 'components';
|
||||
import { firstValueFrom, map } from 'rxjs';
|
||||
import {
|
||||
DatabaseService,
|
||||
type DownloadItem,
|
||||
DownloadsService,
|
||||
PlaylistsService,
|
||||
} from 'services';
|
||||
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { queryParamSignal } from '@iptvnator/portal/shared/util';
|
||||
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
buildStandardCollectionCategories,
|
||||
filterCollectionBucket,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PortalCollectionContextService } from '@iptvnator/portal/shared/util';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
|
||||
type PortalSource = 'xtream' | 'stalker';
|
||||
const DOWNLOAD_COLLECTION_LABELS = {
|
||||
all: 'All',
|
||||
movie: 'Movies',
|
||||
live: 'Live TV',
|
||||
series: 'Series',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-downloads',
|
||||
templateUrl: './downloads.component.html',
|
||||
styleUrls: [
|
||||
'./downloads.component.scss',
|
||||
'../../../../shared/ui/src/lib/styles/portal-sidebar.scss',
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
EmptyStateComponent,
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
MatProgressBarModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class DownloadsComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly collectionCtx = inject(PortalCollectionContextService);
|
||||
private readonly dialogService = inject(DialogService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
readonly downloadsService = inject(DownloadsService);
|
||||
|
||||
readonly downloads = this.downloadsService.downloads;
|
||||
readonly downloadFolder = this.downloadsService.downloadFolder;
|
||||
readonly isAvailable = this.downloadsService.isAvailable;
|
||||
readonly hasDownloads = this.downloadsService.hasDownloads;
|
||||
readonly activeCount = this.downloadsService.activeCount;
|
||||
readonly playlistId = toSignal(
|
||||
this.route.paramMap.pipe(map((params) => params.get('id') ?? '')),
|
||||
{ initialValue: this.route.snapshot.params['id'] ?? '' }
|
||||
);
|
||||
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
|
||||
(value ?? '').trim().toLowerCase()
|
||||
);
|
||||
readonly playlists = toSignal(this.playlistsService.getAllPlaylists(), {
|
||||
initialValue: [] as Playlist[],
|
||||
});
|
||||
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
|
||||
|
||||
addPlaylist(): void {
|
||||
this.shellActions.openAddPlaylistDialog();
|
||||
}
|
||||
|
||||
goToSources(): void {
|
||||
void this.router.navigate(['/workspace', 'sources']);
|
||||
}
|
||||
|
||||
goToDashboard(): void {
|
||||
void this.router.navigate(['/workspace', 'dashboard']);
|
||||
}
|
||||
|
||||
readonly scopedDownloads = computed(() => {
|
||||
const playlistId = this.playlistId();
|
||||
const downloads = this.downloads();
|
||||
return playlistId
|
||||
? downloads.filter((item) => item.playlistId === playlistId)
|
||||
: downloads;
|
||||
});
|
||||
|
||||
readonly categories = computed(() => {
|
||||
const downloads = this.scopedDownloads();
|
||||
const moviesCount = downloads.filter(
|
||||
(item) => item.contentType === 'vod'
|
||||
).length;
|
||||
const seriesCount = downloads.filter(
|
||||
(item) => item.contentType === 'episode'
|
||||
).length;
|
||||
|
||||
return buildStandardCollectionCategories({
|
||||
labels: DOWNLOAD_COLLECTION_LABELS,
|
||||
counts: {
|
||||
all: downloads.length,
|
||||
movie: moviesCount,
|
||||
series: seriesCount,
|
||||
},
|
||||
});
|
||||
});
|
||||
readonly collectionContext = createPortalCollectionContext({
|
||||
ctx: this.collectionCtx,
|
||||
categories: this.categories,
|
||||
});
|
||||
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
|
||||
readonly failedPosterKeys = signal<Record<string, true>>({});
|
||||
|
||||
/** Filter downloads for current playlist and sort by newest first */
|
||||
readonly filteredDownloads = computed(() => {
|
||||
const term = this.searchTerm();
|
||||
const downloads = this.scopedDownloads();
|
||||
const filteredByTerm = filterCollectionBucket({
|
||||
selectedCategoryId: this.selectedCategoryId(),
|
||||
allItems: downloads,
|
||||
buckets: {
|
||||
movie: downloads.filter((item) => item.contentType === 'vod'),
|
||||
series: downloads.filter(
|
||||
(item) => item.contentType === 'episode'
|
||||
),
|
||||
},
|
||||
searchTerm: term,
|
||||
textOf: (item) => `${item.title ?? ''} ${item.errorMessage ?? ''}`,
|
||||
});
|
||||
|
||||
// Sort by createdAt descending (newest first)
|
||||
return [...filteredByTerm].sort((a, b) => {
|
||||
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
});
|
||||
readonly hasClearableDownloads = computed(() =>
|
||||
this.scopedDownloads().some(
|
||||
(item) =>
|
||||
item.status === 'completed' ||
|
||||
item.status === 'failed' ||
|
||||
item.status === 'canceled'
|
||||
)
|
||||
);
|
||||
readonly availablePlaylistIds = computed(
|
||||
() => new Set(this.playlists().map((playlist) => playlist._id))
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const playlistId = this.playlistId();
|
||||
this.collectionContext.setCategoryId('all');
|
||||
void this.downloadsService.loadDownloads(playlistId || undefined);
|
||||
});
|
||||
}
|
||||
|
||||
getProgress(item: DownloadItem): number {
|
||||
return this.downloadsService.getProgressPercent(item);
|
||||
}
|
||||
|
||||
formatBytes(bytes: number | undefined): string {
|
||||
if (!bytes) return '0 B';
|
||||
return this.downloadsService.formatBytes(bytes);
|
||||
}
|
||||
|
||||
getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'schedule';
|
||||
case 'downloading':
|
||||
return 'downloading';
|
||||
case 'completed':
|
||||
return 'check_circle';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
case 'canceled':
|
||||
return 'cancel';
|
||||
default:
|
||||
return 'help';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'status-queued';
|
||||
case 'downloading':
|
||||
return 'status-downloading';
|
||||
case 'completed':
|
||||
return 'status-completed';
|
||||
case 'failed':
|
||||
case 'canceled':
|
||||
return 'status-failed';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async copyUrl(item: DownloadItem): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(item.url);
|
||||
this.snackBar.open(
|
||||
this.translate.instant('DOWNLOADS.URL_COPIED'),
|
||||
undefined,
|
||||
{ duration: 2000, horizontalPosition: 'start' }
|
||||
);
|
||||
} catch {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('DOWNLOADS.URL_COPY_FAILED'),
|
||||
undefined,
|
||||
{ duration: 3000, horizontalPosition: 'start' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(item: DownloadItem) {
|
||||
await this.downloadsService.cancelDownload(item.id);
|
||||
}
|
||||
|
||||
async retry(item: DownloadItem) {
|
||||
await this.downloadsService.retryDownload(item.id);
|
||||
}
|
||||
|
||||
async remove(item: DownloadItem) {
|
||||
await this.downloadsService.removeDownload(item.id);
|
||||
}
|
||||
|
||||
async play(item: DownloadItem) {
|
||||
if (item.filePath) {
|
||||
const result = await this.downloadsService.playDownload(
|
||||
item.filePath
|
||||
);
|
||||
if (!result.success) {
|
||||
this.handleFileActionError(result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reveal(item: DownloadItem) {
|
||||
if (item.filePath) {
|
||||
const result = await this.downloadsService.revealFile(item.filePath);
|
||||
if (!result.success) {
|
||||
this.handleFileActionError(result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async changeFolder() {
|
||||
await this.downloadsService.selectFolder();
|
||||
}
|
||||
|
||||
async clearCompleted() {
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant(
|
||||
'DOWNLOADS.CLEAR_COMPLETED_DIALOG.TITLE'
|
||||
),
|
||||
message: this.translate.instant(
|
||||
'DOWNLOADS.CLEAR_COMPLETED_DIALOG.MESSAGE'
|
||||
),
|
||||
confirmLabel: this.translate.instant('DOWNLOADS.CLEAR_COMPLETED'),
|
||||
onConfirm: async (): Promise<void> => {
|
||||
await this.downloadsService.clearCompleted(this.playlistId());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
formatEpisodeLabel(item: DownloadItem): string {
|
||||
if (item.contentType !== 'episode') return '';
|
||||
const s = item.seasonNumber?.toString().padStart(2, '0') || '00';
|
||||
const e = item.episodeNumber?.toString().padStart(2, '0') || '00';
|
||||
return `S${s}E${e}`;
|
||||
}
|
||||
|
||||
hasPoster(item: DownloadItem): boolean {
|
||||
return !!item.posterUrl && !this.failedPosterKeys()[this.getPosterKey(item)];
|
||||
}
|
||||
|
||||
markPosterFailed(item: DownloadItem): void {
|
||||
const key = this.getPosterKey(item);
|
||||
this.failedPosterKeys.update((state) => {
|
||||
if (state[key]) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
[key]: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getPosterPlaceholderIcon(item: DownloadItem): string {
|
||||
return item.contentType === 'episode' ? 'video_library' : 'movie';
|
||||
}
|
||||
|
||||
hasSourcePlaylist(item: DownloadItem): boolean {
|
||||
return this.availablePlaylistIds().has(item.playlistId);
|
||||
}
|
||||
|
||||
isItemNavigable(item: DownloadItem): boolean {
|
||||
return (
|
||||
!!item.playlistId &&
|
||||
this.hasSourcePlaylist(item) &&
|
||||
this.getTargetContentId(item) !== null
|
||||
);
|
||||
}
|
||||
|
||||
onItemCardKeydown(event: KeyboardEvent, item: DownloadItem): void {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void this.openInLibrary(item);
|
||||
}
|
||||
|
||||
async openInLibrary(item: DownloadItem): Promise<void> {
|
||||
if (!this.hasSourcePlaylist(item)) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('DOWNLOADS.SOURCE_PLAYLIST_MISSING'),
|
||||
undefined,
|
||||
{ duration: 3000, horizontalPosition: 'start' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isItemNavigable(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = await this.resolveSourceType(item.playlistId);
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === 'xtream') {
|
||||
await this.openXtreamItem(item);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.openStalkerItem(item);
|
||||
}
|
||||
|
||||
private getTargetContentId(item: DownloadItem): number | null {
|
||||
const id =
|
||||
item.contentType === 'episode'
|
||||
? (item.seriesXtreamId ?? item.xtreamId)
|
||||
: item.xtreamId;
|
||||
const numeric = Number(id);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
private buildPlaylistRoute(
|
||||
source: PortalSource,
|
||||
playlistId: string,
|
||||
segments: Array<string | number>
|
||||
): Array<string | number> {
|
||||
const sourceSegment = source === 'stalker' ? 'stalker' : 'xtreams';
|
||||
return ['/workspace', sourceSegment, playlistId, ...segments];
|
||||
}
|
||||
|
||||
private async resolveSourceType(
|
||||
playlistId: string
|
||||
): Promise<PortalSource | null> {
|
||||
try {
|
||||
const playlist = await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(playlistId)
|
||||
);
|
||||
|
||||
if (!playlist) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (playlist.portalUrl && playlist.macAddress) {
|
||||
return 'stalker';
|
||||
}
|
||||
|
||||
return 'xtream';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async openXtreamItem(item: DownloadItem): Promise<void> {
|
||||
const targetId = this.getTargetContentId(item);
|
||||
if (targetId === null) return;
|
||||
|
||||
const contentType = item.contentType === 'episode' ? 'series' : 'vod';
|
||||
const content = await this.dbService.getContentByXtreamId(
|
||||
targetId,
|
||||
item.playlistId
|
||||
);
|
||||
const categoryId = content?.category_id;
|
||||
|
||||
if (categoryId === null || categoryId === undefined) {
|
||||
await this.router.navigate(
|
||||
this.buildPlaylistRoute('xtream', item.playlistId, [
|
||||
contentType,
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.router.navigate(
|
||||
this.buildPlaylistRoute('xtream', item.playlistId, [
|
||||
contentType,
|
||||
String(categoryId),
|
||||
String(targetId),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
private normalizePortalItemId(value: unknown): string {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return '';
|
||||
return raw.includes(':') ? raw.split(':')[0] : raw;
|
||||
}
|
||||
|
||||
private normalizeStalkerCategoryId(
|
||||
value: unknown,
|
||||
fallback: 'vod' | 'series'
|
||||
): 'vod' | 'series' | 'itv' {
|
||||
const normalized = String(value ?? '').toLowerCase();
|
||||
if (normalized === 'movie') return 'vod';
|
||||
if (
|
||||
normalized === 'vod' ||
|
||||
normalized === 'series' ||
|
||||
normalized === 'itv'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private findMatchingStalkerRecentItem(
|
||||
items: Array<Record<string, unknown>>,
|
||||
targetId: number
|
||||
): Record<string, unknown> | undefined {
|
||||
const expectedId = String(targetId);
|
||||
return items.find((recentItem) => {
|
||||
const candidates = [
|
||||
recentItem['id'],
|
||||
recentItem['movie_id'],
|
||||
recentItem['series_id'],
|
||||
recentItem['stream_id'],
|
||||
];
|
||||
|
||||
return candidates.some(
|
||||
(candidate) =>
|
||||
this.normalizePortalItemId(candidate) === expectedId
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async buildStalkerOpenState(
|
||||
item: DownloadItem,
|
||||
targetId: number,
|
||||
fallbackCategory: 'vod' | 'series'
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const items = (await firstValueFrom(
|
||||
this.playlistsService.getPortalRecentlyViewed(item.playlistId)
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
const matched = this.findMatchingStalkerRecentItem(items, targetId);
|
||||
|
||||
if (matched) {
|
||||
return {
|
||||
...matched,
|
||||
id:
|
||||
matched['id'] ??
|
||||
matched['series_id'] ??
|
||||
matched['movie_id'] ??
|
||||
String(targetId),
|
||||
category_id: this.normalizeStalkerCategoryId(
|
||||
matched['category_id'],
|
||||
fallbackCategory
|
||||
),
|
||||
title: matched['title'] ?? item.title,
|
||||
name: matched['name'] ?? matched['o_name'] ?? item.title,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore and use fallback state below.
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(targetId),
|
||||
category_id: fallbackCategory,
|
||||
title: item.title,
|
||||
name: item.title,
|
||||
o_name: item.title,
|
||||
cover: item.posterUrl,
|
||||
logo: item.posterUrl,
|
||||
};
|
||||
}
|
||||
|
||||
private async openStalkerItem(item: DownloadItem): Promise<void> {
|
||||
const targetId = this.getTargetContentId(item);
|
||||
if (targetId === null) return;
|
||||
|
||||
const fallbackCategory =
|
||||
item.contentType === 'episode' ? 'series' : 'vod';
|
||||
const openRecentItem = await this.buildStalkerOpenState(
|
||||
item,
|
||||
targetId,
|
||||
fallbackCategory
|
||||
);
|
||||
|
||||
await this.router.navigate(
|
||||
this.buildPlaylistRoute('stalker', item.playlistId, ['recent']),
|
||||
{
|
||||
state: { openRecentItem },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private getPosterKey(item: DownloadItem): string {
|
||||
return `${item.id}:${item.posterUrl ?? ''}`;
|
||||
}
|
||||
|
||||
private handleFileActionError(error?: string): void {
|
||||
const message =
|
||||
error === 'File not found'
|
||||
? this.translate.instant('DOWNLOADS.FILE_NOT_FOUND')
|
||||
: this.translate.instant('DOWNLOADS.FILE_ACTION_ERROR');
|
||||
|
||||
this.snackBar.open(message, undefined, {
|
||||
duration: 3000,
|
||||
horizontalPosition: 'start',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
displayName: 'portal-shared-ui',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/portal/shared/ui',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "portal-shared-ui",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/portal/shared/ui/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:portal", "domain:portal-shared", "type:ui"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/portal/shared/ui/jest.config.ts",
|
||||
"tsConfig": "libs/portal/shared/ui/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export * from './lib/components/category-view/category-view.component';
|
||||
export * from './lib/components/content-card/content-card.component';
|
||||
export * from './lib/components/content-rail-shell/content-rail-shell.component';
|
||||
export * from './lib/components/favorites-layout/favorites-layout.component';
|
||||
export * from './lib/components/global-favorites-list/global-favorites-list.component';
|
||||
export * from './lib/components/grid-list/grid-list.component';
|
||||
export * from './lib/components/playlist-error-view/playlist-error-view.component';
|
||||
export { ProgressCapsuleComponent } from 'components';
|
||||
export * from './lib/components/portal-collection-live-shell/portal-collection-live-shell.component';
|
||||
export * from './lib/components/portal-collection-shell/portal-collection-shell.component';
|
||||
export * from './lib/components/portal-empty-state/portal-empty-state.component';
|
||||
export * from './lib/components/search-form/search-form.component';
|
||||
export * from './lib/components/search-layout/search-layout.component';
|
||||
export { WatchedBadgeComponent } from 'components';
|
||||
export * from './lib/components/unified-collection/unified-collection-page.component';
|
||||
export * from './lib/components/unified-collection/unified-collection-detail.directive';
|
||||
export * from './lib/components/unified-collection/unified-live-tab.component';
|
||||
export * from './lib/navigation/navigation.component';
|
||||
export * from './lib/navigation/portal-rail-links.component';
|
||||
@@ -0,0 +1,49 @@
|
||||
@if (items()?.length > 0) {
|
||||
<div class="nav-list">
|
||||
@for (item of items(); track $index) {
|
||||
<a
|
||||
class="nav-item category-item"
|
||||
[attr.data-category-id]="$any(item).category_id ?? item.id"
|
||||
[class.selected]="isSelected(item)"
|
||||
[class.category-item--selected]="isSelected(item)"
|
||||
[attr.aria-current]="isSelected(item) ? 'true' : null"
|
||||
(click)="categoryClicked.emit(item)"
|
||||
>
|
||||
<span class="nav-item-label">
|
||||
{{
|
||||
item.category_name ||
|
||||
$any(item).name ||
|
||||
'No category name'
|
||||
}}
|
||||
</span>
|
||||
@if (showCounts()) {
|
||||
<div class="nav-item-meta item-count">
|
||||
{{ getItemCount(item) }}
|
||||
</div>
|
||||
} @else if ($any(item).count !== undefined) {
|
||||
<div class="nav-item-meta item-count">
|
||||
{{ $any(item).count }}
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
} @empty {
|
||||
<app-playlist-error-view
|
||||
[title]="'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate"
|
||||
[description]="
|
||||
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate
|
||||
"
|
||||
[showActionButtons]="false"
|
||||
[viewType]="'NO_SEARCH_RESULTS'"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="category-empty-state">
|
||||
<app-playlist-error-view
|
||||
[title]="'No categories available'"
|
||||
[showIllustration]="false"
|
||||
[showActionButtons]="false"
|
||||
[viewType]="'EMPTY_CATEGORY'"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@use '../../../../../../../../apps/web/src/nav-list.scss';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
|
||||
.item-count {
|
||||
opacity: 0.6;
|
||||
font-size: 0.85em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-item--selected .item-count {
|
||||
color: var(--app-selection-color) !important;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
overflow-wrap: break-word;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.category-empty-state {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(14px, 3vh, 28px) 12px clamp(20px, 4vh, 34px);
|
||||
box-sizing: border-box;
|
||||
|
||||
app-playlist-error-view {
|
||||
--error-empty-illustration-size: clamp(132px, 15vw, 180px);
|
||||
--error-empty-max-width: min(270px, 100%);
|
||||
--error-empty-title-size: clamp(1.15rem, 1.35vw, 1.4rem);
|
||||
--error-empty-title-weight: 610;
|
||||
--error-empty-title-spacing: -0.006em;
|
||||
--error-empty-copy-gap: 4px;
|
||||
--error-empty-description-opacity: 0.82;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.category-item {
|
||||
width: 100%;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.category-empty-state {
|
||||
app-playlist-error-view {
|
||||
--error-empty-illustration-size: 142px;
|
||||
--error-empty-max-width: 230px;
|
||||
--error-empty-title-size: 1.05rem;
|
||||
--error-empty-title-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
|
||||
|
||||
interface CategoryViewItem {
|
||||
readonly category_id?: string | number;
|
||||
readonly category_name?: string;
|
||||
readonly count?: number;
|
||||
readonly id?: string | number;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-view',
|
||||
imports: [MatListModule, PlaylistErrorViewComponent, TranslatePipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './category-view.component.html',
|
||||
styleUrls: ['./category-view.component.scss'],
|
||||
})
|
||||
export class CategoryViewComponent {
|
||||
readonly items = input<CategoryViewItem[]>([]);
|
||||
readonly selectedCategoryId = input<string | number | null | undefined>();
|
||||
readonly itemCounts = input<Map<number, number>>(new Map());
|
||||
readonly showCounts = input<boolean>(false);
|
||||
private readonly hostEl = inject(ElementRef<HTMLElement>);
|
||||
|
||||
readonly categoryClicked = output<CategoryViewItem>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const selectedCategory = this.selectedCategoryId();
|
||||
if (selectedCategory == null) return;
|
||||
|
||||
queueMicrotask(() => {
|
||||
const container = this.hostEl.nativeElement as HTMLElement;
|
||||
const candidates = Array.from(
|
||||
container.querySelectorAll('[data-category-id]')
|
||||
) as HTMLElement[];
|
||||
const selected = candidates.find(
|
||||
(el) => el.dataset.categoryId === String(selectedCategory)
|
||||
);
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const selectedRect = selected.getBoundingClientRect();
|
||||
const targetTop =
|
||||
container.scrollTop +
|
||||
(selectedRect.top - containerRect.top) -
|
||||
container.clientHeight / 2 +
|
||||
selectedRect.height / 2;
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
container.scrollHeight - container.clientHeight
|
||||
);
|
||||
|
||||
container.scrollTo({
|
||||
behavior: 'smooth',
|
||||
top: Math.min(maxScrollTop, Math.max(0, targetTop)),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isSelected(item: CategoryViewItem): boolean {
|
||||
const selectedCategory = this.selectedCategoryId();
|
||||
const itemId = item.category_id ?? item.id;
|
||||
|
||||
// Compare as strings to handle both numeric and string category IDs.
|
||||
return (
|
||||
selectedCategory != null &&
|
||||
String(selectedCategory) === String(itemId)
|
||||
);
|
||||
}
|
||||
|
||||
getItemCount(item: CategoryViewItem): number {
|
||||
// content.category_id references categories.id (internal DB id)
|
||||
// For DB format categories, use id; for API format, use category_id
|
||||
const itemId = Number(item.id ?? item.category_id);
|
||||
return this.itemCounts().get(itemId) ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<div class="content-card" (click)="onCardClick()">
|
||||
<div class="thumbnail-wrapper">
|
||||
@if (showRemoveButton()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="remove-button"
|
||||
[matTooltip]="removeTooltip()"
|
||||
(click)="onRemoveClick($event)"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (posterUrl()) {
|
||||
<img
|
||||
[src]="posterUrl()"
|
||||
[alt]="title()"
|
||||
(error)="onImageError($event)"
|
||||
class="poster"
|
||||
/>
|
||||
} @else if (showPlaceholder()) {
|
||||
<div class="poster-placeholder">
|
||||
<mat-icon>{{ getPlaceholderIcon() }}</mat-icon>
|
||||
</div>
|
||||
} @else {
|
||||
<img
|
||||
src="./assets/images/default-poster.png"
|
||||
[alt]="title()"
|
||||
class="poster"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (type()) {
|
||||
<span class="type-badge" [class]="type()">
|
||||
{{ type() }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card-info">
|
||||
<h3>{{ title() }}</h3>
|
||||
@if (date()) {
|
||||
<p class="date-info">
|
||||
{{ date() | date: 'd MMM, HH:mm' : undefined : currentLocale() }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
@use '../../styles/content-grid' as grid;
|
||||
|
||||
.content-card {
|
||||
@include grid.content-card;
|
||||
|
||||
.thumbnail-wrapper {
|
||||
@include grid.thumbnail-wrapper;
|
||||
|
||||
.remove-button {
|
||||
@include grid.remove-button;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
@include grid.type-badge;
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
@include grid.card-info;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-card',
|
||||
standalone: true,
|
||||
imports: [DatePipe, MatIcon, MatIconButton, MatTooltip],
|
||||
templateUrl: './content-card.component.html',
|
||||
styleUrl: './content-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ContentCardComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
/** URL of the poster image */
|
||||
readonly posterUrl = input<string>();
|
||||
|
||||
/** Title to display */
|
||||
readonly title = input.required<string>();
|
||||
|
||||
/** Content type (live, movie, series) */
|
||||
readonly type = input<string>();
|
||||
|
||||
/** Optional date to display (Date, string, or timestamp number) */
|
||||
readonly date = input<Date | string | number>();
|
||||
|
||||
/** Whether to show the remove button */
|
||||
readonly showRemoveButton = input<boolean>(false);
|
||||
|
||||
/** Tooltip text for the remove button */
|
||||
readonly removeTooltip = input<string>('Remove');
|
||||
|
||||
/** Whether to show placeholder when no poster */
|
||||
readonly showPlaceholder = input<boolean>(true);
|
||||
|
||||
/** Emitted when the card is clicked */
|
||||
readonly cardClick = output<void>();
|
||||
|
||||
/** Emitted when the remove button is clicked */
|
||||
readonly remove = output<void>();
|
||||
readonly currentLocale = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
});
|
||||
|
||||
/** Get the icon for the placeholder based on type */
|
||||
getPlaceholderIcon(): string {
|
||||
switch (this.type()) {
|
||||
case 'live':
|
||||
return 'live_tv';
|
||||
case 'series':
|
||||
return 'tv';
|
||||
default:
|
||||
return 'movie';
|
||||
}
|
||||
}
|
||||
|
||||
onCardClick(): void {
|
||||
this.cardClick.emit();
|
||||
}
|
||||
|
||||
onRemoveClick(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.remove.emit();
|
||||
}
|
||||
|
||||
onImageError(event: Event): void {
|
||||
(event.target as HTMLImageElement).src =
|
||||
'./assets/images/default-poster.png';
|
||||
}
|
||||
}
|
||||
+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
|
||||
);
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
@if (!isWorkspaceLayout) {
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="playlistTitle()"
|
||||
[subtitle]="playlistSubtitle()"
|
||||
/>
|
||||
</div>
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
[selectedCategoryId]="$any(selectedCategoryId())"
|
||||
(categoryClicked)="setCategoryId($event.category_id)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="main-content">
|
||||
<div class="content-header">
|
||||
<div class="category-meta">
|
||||
<h2 class="category-title">
|
||||
{{ titleTranslationString() | translate }}
|
||||
</h2>
|
||||
@if (selectedCategoryId()) {
|
||||
<div class="category-subtitle">{{ selectedCategoryId() }}</div>
|
||||
}
|
||||
</div>
|
||||
@if (showHeaderAction() && !isWorkspaceLayout) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
[matTooltip]="headerActionTooltip()"
|
||||
(click)="onHeaderActionClick()"
|
||||
>
|
||||
<mat-icon>{{ headerActionIcon() }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="scrollable-content">
|
||||
@if (favorites().length) {
|
||||
<div class="items-grid">
|
||||
@for (item of favorites(); track $index) {
|
||||
<app-content-card
|
||||
[posterUrl]="item.poster_url || item.cover"
|
||||
[title]="
|
||||
item?.title ||
|
||||
item?.details?.info?.name ||
|
||||
'Unknown'
|
||||
"
|
||||
[type]="item.type"
|
||||
[date]="item.added_at"
|
||||
[showRemoveButton]="true"
|
||||
[removeTooltip]="
|
||||
removeTooltip() ||
|
||||
('CHANNELS.REMOVE_FAVORITE' | translate)
|
||||
"
|
||||
(cardClick)="openFavorite(item)"
|
||||
(remove)="removeFromFavorites(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-items">
|
||||
<mat-icon>{{ emptyIcon() }}</mat-icon>
|
||||
<p>No items in the selected category</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
@use '@angular/material' as mat;
|
||||
@use '../../styles/content-grid' as grid;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; // Important for proper flex behavior
|
||||
}
|
||||
|
||||
.content-header {
|
||||
padding: 8px 12px;
|
||||
min-height: 48px;
|
||||
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-title {
|
||||
margin: 0;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mat-sys-on-surface);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// Informational badge — primary-tinted pill with definition ring
|
||||
.category-subtitle {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.70rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: capitalize;
|
||||
line-height: 1;
|
||||
color: var(--mat-sys-primary);
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 10%, var(--mat-sys-surface-container-high));
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--mat-sys-primary) 30%, transparent),
|
||||
0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.scrollable-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.favorites-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
@include grid.content-grid(140px, 20px);
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
@include mat.form-field-density(-5);
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { Component, inject, input, output } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { StalkerPortalItem, XtreamCategory } from 'shared-interfaces';
|
||||
import { CategoryViewComponent } from '../category-view/category-view.component';
|
||||
import { ContentCardComponent } from '../content-card/content-card.component';
|
||||
|
||||
interface FavoriteLayoutSelection {
|
||||
readonly category_id?: string | number;
|
||||
}
|
||||
|
||||
export interface FavoriteLayoutItem {
|
||||
readonly added_at?: string | number;
|
||||
readonly content_id?: number;
|
||||
readonly category_id?: string | number;
|
||||
readonly cover?: string;
|
||||
readonly details?: {
|
||||
readonly info?: {
|
||||
readonly name?: string;
|
||||
};
|
||||
};
|
||||
readonly id?: string | number;
|
||||
readonly name?: string;
|
||||
readonly o_name?: string;
|
||||
readonly playlist_id?: string;
|
||||
readonly playlist_name?: string;
|
||||
readonly poster_url?: string;
|
||||
readonly source?: 'xtream' | 'stalker' | 'm3u';
|
||||
readonly stalker_item?: StalkerPortalItem;
|
||||
readonly stream_icon?: string;
|
||||
readonly title?: string;
|
||||
readonly type?: string;
|
||||
readonly viewed_at?: string;
|
||||
readonly xtream_id?: string | number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-favorites-layout',
|
||||
templateUrl: './favorites-layout.component.html',
|
||||
styleUrls: [
|
||||
'./favorites-layout.component.scss',
|
||||
'../../styles/portal-sidebar.scss',
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
ContentCardComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
PlaylistSwitcherComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
MatTooltip,
|
||||
],
|
||||
})
|
||||
export class FavoritesLayoutComponent {
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(inject(ActivatedRoute));
|
||||
|
||||
readonly categories = input<XtreamCategory[]>([]);
|
||||
readonly favorites = input<FavoriteLayoutItem[]>([]);
|
||||
readonly playlistSubtitle = input<string>('');
|
||||
readonly playlistTitle = input<string>('Playlist');
|
||||
readonly selectedCategoryId = input<string>('movie');
|
||||
readonly titleTranslationString = input<string>('CHANNELS.FAVORITES');
|
||||
readonly removeTooltip = input<string>('');
|
||||
readonly emptyIcon = input<string>('favorite_border');
|
||||
readonly showHeaderAction = input<boolean>(false);
|
||||
readonly headerActionIcon = input<string>('delete_sweep');
|
||||
readonly headerActionTooltip = input<string>('');
|
||||
|
||||
readonly categoryClicked = output<FavoriteLayoutSelection>();
|
||||
readonly removeFavorite = output<FavoriteLayoutItem>();
|
||||
readonly openItem = output<FavoriteLayoutItem>();
|
||||
readonly headerActionClicked = output<void>();
|
||||
|
||||
setCategoryId(categoryId: string | number) {
|
||||
this.categoryClicked.emit({ category_id: categoryId });
|
||||
}
|
||||
|
||||
removeFromFavorites(item: FavoriteLayoutItem) {
|
||||
this.removeFavorite.emit(item);
|
||||
}
|
||||
|
||||
openFavorite(item: FavoriteLayoutItem) {
|
||||
this.openItem.emit(item);
|
||||
}
|
||||
|
||||
onHeaderActionClick() {
|
||||
this.headerActionClicked.emit();
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<div
|
||||
class="nav-list"
|
||||
cdkDropList
|
||||
[cdkDropListDisabled]="!canDragDrop()"
|
||||
(cdkDropListDropped)="onDrop($event)"
|
||||
>
|
||||
@if (enrichedChannels().length === 0) {
|
||||
<div class="empty-state">
|
||||
<mat-icon class="empty-icon">star_border</mat-icon>
|
||||
@if (hasSearchTerm()) {
|
||||
<p class="empty-title">
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS_HINT'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
} @else {
|
||||
<p class="empty-title">
|
||||
{{
|
||||
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_TITLE' | translate
|
||||
}}
|
||||
</p>
|
||||
<p class="empty-hint">
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_HINT' | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (ch of enrichedChannels(); track trackByUid($index, ch)) {
|
||||
<app-channel-list-item
|
||||
[isDraggable]="canDragDrop()"
|
||||
[logo]="ch.logo"
|
||||
[name]="ch.name"
|
||||
[selected]="activeUid() === ch.uid"
|
||||
[showEpg]="true"
|
||||
[epgProgram]="ch.currentEpgProgram"
|
||||
[progressPercentage]="ch.progressPercentage"
|
||||
[showFavoriteButton]="true"
|
||||
[isFavorite]="true"
|
||||
[showProgramInfoButton]="true"
|
||||
(clicked)="onChannelClick(ch)"
|
||||
(favoriteToggled)="onFavoriteToggled(ch)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: -10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 90%;
|
||||
@include mat.form-field-density(-5);
|
||||
}
|
||||
|
||||
:host ::ng-deep .search-bar div {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .search-bar div[matformfieldlineripple],
|
||||
:host ::ng-deep .search-bar .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
max-width: 260px;
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
CdkDragDrop,
|
||||
DragDropModule,
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ChannelListItemComponent } from 'components';
|
||||
import { EpgProgram } from 'shared-interfaces';
|
||||
import {
|
||||
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
|
||||
FavoritesChannelSortMode,
|
||||
sortFavoriteChannelItems,
|
||||
UnifiedFavoriteChannel,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
export interface EnrichedUnifiedFavorite extends UnifiedFavoriteChannel {
|
||||
currentEpgProgram: EpgProgram | null;
|
||||
progressPercentage: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-global-favorites-list',
|
||||
templateUrl: './global-favorites-list.component.html',
|
||||
styleUrl: './global-favorites-list.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ChannelListItemComponent,
|
||||
DragDropModule,
|
||||
MatIconModule,
|
||||
TranslateModule,
|
||||
],
|
||||
})
|
||||
export class GlobalFavoritesListComponent {
|
||||
readonly channels = input.required<UnifiedFavoriteChannel[]>();
|
||||
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
|
||||
readonly progressTick = input<number>(0);
|
||||
readonly activeUid = input<string | null>(null);
|
||||
readonly searchTermInput = input('');
|
||||
readonly draggable = input(true);
|
||||
readonly sortMode = input<FavoritesChannelSortMode>(
|
||||
DEFAULT_FAVORITES_CHANNEL_SORT_MODE
|
||||
);
|
||||
|
||||
readonly channelSelected = output<UnifiedFavoriteChannel>();
|
||||
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
|
||||
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
|
||||
|
||||
readonly isCustomSort = computed(() => this.sortMode() === 'custom');
|
||||
readonly canDragDrop = computed(
|
||||
() => this.draggable() && this.isCustomSort() && !this.hasSearchTerm()
|
||||
);
|
||||
|
||||
readonly hasSearchTerm = computed(
|
||||
() => this.searchTermInput().trim().length > 0
|
||||
);
|
||||
|
||||
readonly enrichedChannels = computed((): EnrichedUnifiedFavorite[] => {
|
||||
const channels = this.channels();
|
||||
const epgMap = this.epgMap();
|
||||
const term = this.searchTermInput().trim().toLowerCase();
|
||||
this.progressTick();
|
||||
|
||||
const filtered = term
|
||||
? channels.filter((ch) => ch.name.toLowerCase().includes(term))
|
||||
: channels;
|
||||
|
||||
const sorted = sortFavoriteChannelItems(filtered, this.sortMode(), {
|
||||
getName: (ch) => ch.name,
|
||||
getAddedAt: (ch) => ch.addedAt,
|
||||
});
|
||||
|
||||
return sorted.map((ch) => {
|
||||
const epgKey = ch.tvgId?.trim() || ch.name?.trim();
|
||||
const currentEpgProgram = epgKey
|
||||
? (epgMap.get(epgKey) ?? null)
|
||||
: null;
|
||||
return {
|
||||
...ch,
|
||||
currentEpgProgram,
|
||||
progressPercentage: this.calcProgress(currentEpgProgram),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
onChannelClick(channel: UnifiedFavoriteChannel): void {
|
||||
this.channelSelected.emit(channel);
|
||||
}
|
||||
|
||||
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
|
||||
this.favoriteToggled.emit(channel);
|
||||
}
|
||||
|
||||
onDrop(event: CdkDragDrop<EnrichedUnifiedFavorite[]>): void {
|
||||
if (!this.canDragDrop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = [...this.channels()];
|
||||
moveItemInArray(list, event.previousIndex, event.currentIndex);
|
||||
this.channelsReordered.emit(list);
|
||||
}
|
||||
|
||||
trackByUid(_: number, ch: EnrichedUnifiedFavorite): string {
|
||||
return ch.uid;
|
||||
}
|
||||
|
||||
private calcProgress(program: EpgProgram | null | undefined): number {
|
||||
if (!program) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const start = new Date(program.start).getTime();
|
||||
const stop = new Date(program.stop).getTime();
|
||||
const total = stop - start;
|
||||
const elapsed = now - start;
|
||||
return Math.min(100, Math.max(0, (elapsed / total) * 100));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
@use '../../styles/skeleton';
|
||||
|
||||
// ─── Card base ───────────────────────────────────────────────────────────────
|
||||
mat-card {
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
mat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
// ─── Grid layout ─────────────────────────────────────────────────────────────
|
||||
.grid {
|
||||
mat-card {
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
|
||||
mat-card-content {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-thumbnail-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 2 / 3; // maintain poster ratio
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
}
|
||||
|
||||
.stream-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
display: block;
|
||||
// No hardcoded light bg — uses parent container bg
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stream-icon-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
// Use graphite surface token, not hardcoded grey
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
border-radius: 10px;
|
||||
|
||||
&::after {
|
||||
content: 'No cover';
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 8px;
|
||||
padding: 0 2px;
|
||||
word-break: break-word;
|
||||
text-align: center;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rating badge ─────────────────────────────────────────────────────────────
|
||||
.rating {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
// Use a solid or highly opaque dark pill to avoid expensive backdrop-filter
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: #4ade80; // green-400, readable across themes
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2px 5px;
|
||||
gap: 2px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 13px;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-empty-state {
|
||||
grid-column: 1 / -1;
|
||||
min-height: clamp(320px, 56vh, 620px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: clamp(12px, 2.8vw, 30px);
|
||||
box-sizing: border-box;
|
||||
|
||||
app-playlist-error-view {
|
||||
--error-empty-illustration-size: clamp(170px, 18vw, 250px);
|
||||
--error-empty-max-width: min(480px, 100%);
|
||||
--error-empty-title-size: clamp(1.45rem, 2vw, 1.9rem);
|
||||
--error-empty-description-size: clamp(0.95rem, 1.15vw, 1.05rem);
|
||||
--error-empty-title-weight: 660;
|
||||
--error-empty-title-spacing: -0.014em;
|
||||
--error-empty-copy-gap: 10px;
|
||||
--error-empty-description-line-height: 1.44;
|
||||
--error-empty-description-opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-empty-state {
|
||||
min-height: clamp(260px, 48vh, 460px);
|
||||
padding: 14px;
|
||||
|
||||
app-playlist-error-view {
|
||||
--error-empty-illustration-size: 165px;
|
||||
--error-empty-max-width: 310px;
|
||||
--error-empty-title-size: 1.3rem;
|
||||
--error-empty-description-size: 0.9rem;
|
||||
--error-empty-title-weight: 640;
|
||||
--error-empty-copy-gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
formatGridRating,
|
||||
resolveGridRating,
|
||||
} from './grid-list.component';
|
||||
|
||||
describe('grid list rating helpers', () => {
|
||||
it('rounds numeric ratings to a single decimal place', () => {
|
||||
expect(formatGridRating(7.243)).toBe('7.2');
|
||||
expect(formatGridRating('6.529')).toBe('6.5');
|
||||
expect(formatGridRating('6')).toBe('6.0');
|
||||
});
|
||||
|
||||
it('prefers imdb ratings before generic ratings when both are present', () => {
|
||||
expect(
|
||||
resolveGridRating({
|
||||
rating: '6.529',
|
||||
rating_imdb: '7.243',
|
||||
})
|
||||
).toBe('7.2');
|
||||
});
|
||||
|
||||
it('falls back to the generic rating when imdb rating is blank', () => {
|
||||
expect(
|
||||
resolveGridRating({
|
||||
rating: '5.67',
|
||||
rating_imdb: ' ',
|
||||
})
|
||||
).toBe('5.7');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
ProgressCapsuleComponent,
|
||||
WatchedBadgeComponent,
|
||||
} from 'components';
|
||||
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
|
||||
|
||||
interface GridListItem {
|
||||
id?: number | string;
|
||||
is_series?: number | string | boolean;
|
||||
xtream_id?: number | string;
|
||||
series_id?: number | string;
|
||||
stream_id?: number | string;
|
||||
category_id?: number | string;
|
||||
poster_url?: string;
|
||||
cover?: string;
|
||||
title?: string;
|
||||
o_name?: string;
|
||||
name?: string;
|
||||
rating?: string | number;
|
||||
rating_imdb?: string | number;
|
||||
progress?: number;
|
||||
isWatched?: boolean;
|
||||
hasSeriesProgress?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function formatGridRating(value: unknown): string | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const numericRating = Number.parseFloat(trimmed);
|
||||
return Number.isFinite(numericRating) ? numericRating.toFixed(1) : trimmed;
|
||||
}
|
||||
|
||||
export function resolveGridRating(
|
||||
item: Pick<GridListItem, 'rating' | 'rating_imdb'>
|
||||
): string | undefined {
|
||||
return formatGridRating(item.rating_imdb) ?? formatGridRating(item.rating);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-grid-list',
|
||||
template: `<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-4"
|
||||
>
|
||||
@if (isLoading()) {
|
||||
@for (row of skeletonRows(); track row) {
|
||||
<div class="grid-skeleton-card" aria-hidden="true">
|
||||
<div class="grid-skeleton-thumb">
|
||||
<span class="grid-skeleton-badge"></span>
|
||||
</div>
|
||||
<div class="grid-skeleton-title">
|
||||
<span
|
||||
class="grid-skeleton-line grid-skeleton-line--primary"
|
||||
></span>
|
||||
<span
|
||||
class="grid-skeleton-line grid-skeleton-line--secondary"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
@for (item of items(); track $index) {
|
||||
@let i = $any(item);
|
||||
<mat-card (click)="itemClicked.emit(item)">
|
||||
@let poster = i.poster_url ?? i.cover;
|
||||
<div class="card-thumbnail-container">
|
||||
<img
|
||||
class="stream-icon"
|
||||
[src]="
|
||||
poster ||
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
loading="lazy"
|
||||
alt="logo"
|
||||
/>
|
||||
@if (i.progress && i.progress > 0) {
|
||||
<app-progress-capsule [progress]="i.progress" />
|
||||
}
|
||||
@if (i.isWatched) {
|
||||
<app-watched-badge
|
||||
[isWatched]="true"
|
||||
icon="check_circle"
|
||||
/>
|
||||
} @else if (i.hasSeriesProgress) {
|
||||
<app-watched-badge
|
||||
[isWatched]="true"
|
||||
icon="remove_red_eye"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@let rating = resolveRating(i);
|
||||
@if (rating) {
|
||||
<div
|
||||
class="rating"
|
||||
[matTooltip]="'XTREAM.IMDB_RATING' | translate"
|
||||
>
|
||||
<mat-icon>star</mat-icon>{{ rating }}
|
||||
</div>
|
||||
}
|
||||
@let title = i.title ?? i.o_name ?? i.name;
|
||||
<mat-card-actions>
|
||||
<div class="title">
|
||||
{{ title || 'No name' }}
|
||||
</div>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
} @empty {
|
||||
<div class="grid-empty-state">
|
||||
<app-playlist-error-view
|
||||
[title]="
|
||||
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
|
||||
| translate
|
||||
"
|
||||
[description]="
|
||||
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION'
|
||||
| translate
|
||||
"
|
||||
[showActionButtons]="false"
|
||||
[viewType]="'EMPTY_CATEGORY'"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (showPaginator() && items()?.length > 0) {
|
||||
<mat-paginator
|
||||
[pageIndex]="pageIndex()"
|
||||
[length]="totalPages() * limit()"
|
||||
[pageSize]="limit()"
|
||||
[pageSizeOptions]="pageSizeOptions()"
|
||||
(page)="pageChange.emit($event)"
|
||||
aria-label="Select page"
|
||||
/>
|
||||
} `,
|
||||
styleUrl: './grid-list.component.scss',
|
||||
imports: [
|
||||
TranslatePipe,
|
||||
PlaylistErrorViewComponent,
|
||||
MatCardModule,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
MatPaginatorModule,
|
||||
ProgressCapsuleComponent,
|
||||
WatchedBadgeComponent,
|
||||
],
|
||||
})
|
||||
export class GridListComponent {
|
||||
readonly items = input<GridListItem[]>();
|
||||
readonly isLoading = input<boolean>();
|
||||
readonly showPaginator = input(true);
|
||||
readonly itemClicked = output<GridListItem>();
|
||||
readonly pageChange = output<PageEvent>();
|
||||
|
||||
readonly pageIndex = input<number>();
|
||||
readonly totalPages = input<number>();
|
||||
readonly limit = input<number>();
|
||||
readonly pageSizeOptions = input<number[]>();
|
||||
protected readonly resolveRating = resolveGridRating;
|
||||
|
||||
readonly skeletonRows = computed(() => {
|
||||
const preferredCount = this.limit() ?? 12;
|
||||
const count = Math.max(8, Math.min(18, preferredCount));
|
||||
return Array.from({ length: count }, (_, index) => index);
|
||||
});
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<div
|
||||
class="error-view"
|
||||
[class.error-view--empty-category]="viewType() === 'EMPTY_CATEGORY'"
|
||||
[class.error-view--text-only]="!showIllustration()"
|
||||
>
|
||||
@if (showIllustration()) {
|
||||
@if (viewType() === 'ERROR') {
|
||||
<img
|
||||
alt="Something went wrong"
|
||||
src="./assets/images/broken-tv.svg"
|
||||
/>
|
||||
} @else if (viewType() === 'EMPTY_CATEGORY') {
|
||||
<img
|
||||
alt="empty category"
|
||||
class="error-view__illustration"
|
||||
src="./assets/images/empty-category.svg"
|
||||
/>
|
||||
} @else if (viewType() === 'NO_SEARCH_RESULTS') {
|
||||
<mat-icon class="icon">search</mat-icon>
|
||||
}
|
||||
}
|
||||
<div class="error-view__copy">
|
||||
<h3 class="error-view__title">{{ title() }}</h3>
|
||||
@if (description()) {
|
||||
<p class="error-view__description">{{ description() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showActionButtons()) {
|
||||
<div class="error-view__actions">
|
||||
<button mat-stroked-button routerLink="/">
|
||||
<mat-icon>home</mat-icon>
|
||||
{{ 'PORTALS.ERROR_VIEW.HOME' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button (click)="openPlaylistDetails()">
|
||||
<mat-icon>settings</mat-icon>
|
||||
{{ 'PORTALS.ERROR_VIEW.PLAYLIST_SETTINGS' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button color="warn" (click)="removeClicked()">
|
||||
<mat-icon>delete</mat-icon>
|
||||
{{ 'PORTALS.ERROR_VIEW.DELETE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.error-view {
|
||||
--error-view-illustration-size: var(
|
||||
--error-empty-illustration-size,
|
||||
clamp(170px, 28vw, 260px)
|
||||
);
|
||||
--error-view-copy-width: var(--error-empty-max-width, 420px);
|
||||
--error-view-copy-gap: var(--error-empty-copy-gap, 8px);
|
||||
--error-view-title-size: var(--error-empty-title-size, 1.55rem);
|
||||
--error-view-title-weight: var(--error-empty-title-weight, 640);
|
||||
--error-view-title-spacing: var(--error-empty-title-spacing, -0.012em);
|
||||
--error-view-description-size: var(--error-empty-description-size, 0.98rem);
|
||||
--error-view-description-line-height: var(
|
||||
--error-empty-description-line-height,
|
||||
1.4
|
||||
);
|
||||
--error-view-description-opacity: var(
|
||||
--error-empty-description-opacity,
|
||||
0.88
|
||||
);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: clamp(12px, 2.2vw, 26px);
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
|
||||
|
||||
img {
|
||||
width: min(100%, var(--error-view-illustration-size));
|
||||
height: auto;
|
||||
filter: invert(80%);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 64px;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.error-view--empty-category .error-view__illustration {
|
||||
margin-top: clamp(0px, 1vh, 8px);
|
||||
}
|
||||
|
||||
.error-view--text-only {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.error-view__copy {
|
||||
max-width: var(--error-view-copy-width);
|
||||
display: grid;
|
||||
gap: var(--error-view-copy-gap);
|
||||
}
|
||||
|
||||
.error-view__title {
|
||||
margin: 0;
|
||||
font-size: var(--error-view-title-size);
|
||||
font-weight: var(--error-view-title-weight);
|
||||
line-height: 1.12;
|
||||
color: var(--app-heading-color, var(--mat-sys-on-surface));
|
||||
letter-spacing: var(--error-view-title-spacing);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.error-view__description {
|
||||
margin: 0;
|
||||
font-size: var(--error-view-description-size);
|
||||
line-height: var(--error-view-description-line-height);
|
||||
color: inherit;
|
||||
opacity: var(--error-view-description-opacity);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.error-view__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.error-view {
|
||||
--error-view-title-size: var(--error-empty-title-size, 1.4rem);
|
||||
--error-view-description-size: var(
|
||||
--error-empty-description-size,
|
||||
0.92rem
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.dark-theme {
|
||||
.error-view {
|
||||
img {
|
||||
filter: invert(10%) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistInfoComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { DialogService } from 'components';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-error-view',
|
||||
templateUrl: './playlist-error-view.component.html',
|
||||
styleUrls: ['./playlist-error-view.component.scss'],
|
||||
imports: [MatButtonModule, MatIconModule, RouterLink, TranslateModule],
|
||||
})
|
||||
export class PlaylistErrorViewComponent {
|
||||
private dialog = inject(MatDialog);
|
||||
private dialogService = inject(DialogService);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private router = inject(Router);
|
||||
private store = inject(Store);
|
||||
private translate = inject(TranslateService);
|
||||
|
||||
private readonly currentPlaylist = this.playlistContext.activePlaylist;
|
||||
|
||||
readonly description = input<string>(undefined);
|
||||
readonly showIllustration = input(true);
|
||||
readonly showActionButtons = input(true);
|
||||
readonly title = input<string>(undefined);
|
||||
readonly viewType = input<'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS'>(
|
||||
'ERROR'
|
||||
);
|
||||
|
||||
openPlaylistDetails() {
|
||||
this.dialog.open(PlaylistInfoComponent, {
|
||||
data: this.currentPlaylist(),
|
||||
});
|
||||
}
|
||||
|
||||
removeClicked(): void {
|
||||
const currentPlaylist = this.currentPlaylist();
|
||||
if (!currentPlaylist?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
|
||||
message: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
|
||||
),
|
||||
onConfirm: (): void => this.removePlaylist(currentPlaylist._id),
|
||||
});
|
||||
}
|
||||
|
||||
removePlaylist(playlistId: string): void {
|
||||
this.store.dispatch(PlaylistActions.removePlaylist({ playlistId }));
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header category-content-header">
|
||||
<div class="category-meta">
|
||||
<h2 class="category-title">{{ title() }}</h2>
|
||||
<div class="category-subtitle">{{ subtitle() }}</div>
|
||||
</div>
|
||||
<ng-content select="[liveHeaderActions]" />
|
||||
</div>
|
||||
|
||||
<ng-content select="[liveChannels]" />
|
||||
</div>
|
||||
|
||||
<div class="content-container">
|
||||
@if (hasSelection()) {
|
||||
@if (streamUrl() && isEmbeddedPlayer()) {
|
||||
<div class="video-player">
|
||||
<app-web-player-view [streamUrl]="streamUrl()" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="epg">
|
||||
<div class="epg-content">
|
||||
@if (epgItems().length > 0) {
|
||||
<app-epg-view [epgItems]="epgItems()" />
|
||||
}
|
||||
|
||||
@if (isLoadingEpg()) {
|
||||
<div class="loading-indicator">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (hasMoreEpg() && !isLoadingEpg()) {
|
||||
<div class="load-more-epg">
|
||||
<button mat-button (click)="onLoadMoreEpg()">
|
||||
{{ 'CHANNELS.LOAD_MORE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
@use '@angular/material' as mat;
|
||||
@use '../../styles/portal-layout' as portal;
|
||||
|
||||
@include portal.live-layout;
|
||||
|
||||
// ─── Sidebar panel header ─────────────────────────────────────────────────────
|
||||
.category-content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
min-height: 48px;
|
||||
flex-shrink: 0;
|
||||
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
app-region: drag;
|
||||
|
||||
// Own buttons (direct children scoped to this component)
|
||||
button {
|
||||
@include mat.form-field-density(-5);
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
// Projected buttons (liveHeaderActions slot) — view encapsulation
|
||||
// prevents the rule above from reaching them, so ng-deep is required.
|
||||
::ng-deep button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.category-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// ─── Title + count chip — now aligned with category-content-view ─────────────
|
||||
.category-title {
|
||||
margin: 0;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mat-sys-on-surface);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// Informational badge — primary-tinted pill with definition ring
|
||||
.category-subtitle {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.70rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: capitalize;
|
||||
line-height: 1;
|
||||
color: var(--mat-sys-primary);
|
||||
background: color-mix(in srgb, var(--mat-sys-primary) 10%, var(--mat-sys-surface-container-high));
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--mat-sys-primary) 30%, transparent),
|
||||
0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
// ─── EPG controls ─────────────────────────────────────────────────────────────
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.load-more-epg {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { EpgItem } from 'shared-interfaces';
|
||||
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
|
||||
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-collection-live-shell',
|
||||
imports: [
|
||||
EpgViewComponent,
|
||||
MatButtonModule,
|
||||
MatProgressSpinnerModule,
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
templateUrl: './portal-collection-live-shell.component.html',
|
||||
styleUrl: './portal-collection-live-shell.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PortalCollectionLiveShellComponent {
|
||||
readonly title = input<string>('Favorites');
|
||||
readonly subtitle = input<string>('Live TV');
|
||||
readonly count = input<number>(0);
|
||||
readonly streamUrl = input<string>('');
|
||||
readonly isEmbeddedPlayer = input<boolean>(true);
|
||||
readonly hasSelection = input<boolean>(false);
|
||||
readonly epgItems = input<EpgItem[]>([]);
|
||||
readonly isLoadingEpg = input<boolean>(false);
|
||||
readonly hasMoreEpg = input<boolean>(false);
|
||||
|
||||
readonly loadMoreEpgClicked = output<void>();
|
||||
|
||||
onLoadMoreEpg(): void {
|
||||
this.loadMoreEpgClicked.emit();
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
@switch (mode()) {
|
||||
@case ('detail') {
|
||||
<ng-content select="[details]" />
|
||||
}
|
||||
@case ('live') {
|
||||
<ng-content select="[live]" />
|
||||
}
|
||||
@default {
|
||||
<app-favorites-layout
|
||||
[categories]="categories()"
|
||||
[favorites]="items()"
|
||||
[playlistSubtitle]="playlistSubtitle()"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[titleTranslationString]="
|
||||
layout().titleTranslationKey || 'CHANNELS.FAVORITES'
|
||||
"
|
||||
[removeTooltip]="layout().removeTooltip || ''"
|
||||
[emptyIcon]="layout().emptyIcon || 'favorite_border'"
|
||||
[showHeaderAction]="layout().showHeaderAction || false"
|
||||
[headerActionIcon]="layout().headerActionIcon || 'delete_sweep'"
|
||||
[headerActionTooltip]="layout().headerActionTooltip || ''"
|
||||
(categoryClicked)="onCategoryClicked($event)"
|
||||
(removeFavorite)="removeItem.emit($event)"
|
||||
(headerActionClicked)="headerActionClicked.emit()"
|
||||
(openItem)="openItem.emit($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import {
|
||||
FavoriteLayoutItem,
|
||||
FavoritesLayoutComponent,
|
||||
} from '../favorites-layout/favorites-layout.component';
|
||||
|
||||
interface PortalCategorySelection {
|
||||
readonly category_id?: string | number;
|
||||
readonly id?: string | number;
|
||||
}
|
||||
|
||||
export interface PortalCollectionShellLayout {
|
||||
titleTranslationKey?: string;
|
||||
removeTooltip?: string;
|
||||
emptyIcon?: string;
|
||||
showHeaderAction?: boolean;
|
||||
headerActionIcon?: string;
|
||||
headerActionTooltip?: string;
|
||||
}
|
||||
|
||||
export type PortalCollectionMode = 'grid' | 'detail' | 'live';
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-collection-shell',
|
||||
imports: [FavoritesLayoutComponent],
|
||||
templateUrl: './portal-collection-shell.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PortalCollectionShellComponent {
|
||||
readonly categories = input<XtreamCategory[]>([]);
|
||||
readonly items = input<FavoriteLayoutItem[]>([]);
|
||||
readonly playlistSubtitle = input<string>('');
|
||||
readonly playlistTitle = input<string>('Playlist');
|
||||
readonly selectedCategoryId = input<string>('all');
|
||||
readonly layout = input<PortalCollectionShellLayout>({});
|
||||
readonly mode = input<PortalCollectionMode>('grid');
|
||||
|
||||
readonly categoryClicked = output<string>();
|
||||
readonly removeItem = output<FavoriteLayoutItem>();
|
||||
readonly openItem = output<FavoriteLayoutItem>();
|
||||
readonly headerActionClicked = output<void>();
|
||||
|
||||
onCategoryClicked(event: PortalCategorySelection): void {
|
||||
const categoryId = event.category_id ?? event.id;
|
||||
if (categoryId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.categoryClicked.emit(String(categoryId));
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
<div class="empty-state-container">
|
||||
<mat-icon class="empty-state-icon">{{ icon() }}</mat-icon>
|
||||
<h2 class="empty-state-title">{{ message() }}</h2>
|
||||
</div>
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-state-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
animation: portal-empty-state-fade-in 220ms ease-out;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 72px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 24px;
|
||||
opacity: 0.2;
|
||||
animation: portal-empty-state-float 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 520;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes portal-empty-state-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes portal-empty-state-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-empty-state',
|
||||
templateUrl: './portal-empty-state.component.html',
|
||||
styleUrl: './portal-empty-state.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatIcon],
|
||||
})
|
||||
export class PortalEmptyStateComponent {
|
||||
readonly icon = input<string>('live_tv');
|
||||
readonly message = input.required<string>();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="search-container">
|
||||
<div class="recent-search">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input
|
||||
#searchInput
|
||||
type="search"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
[placeholder]="placeholder"
|
||||
[ngModel]="searchTerm()"
|
||||
(ngModelChange)="onSearchTermChange()"
|
||||
/>
|
||||
@if (searchTerm()) {
|
||||
<button mat-icon-button class="clear-btn" (click)="clearSearch()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (filterConfig.length > 0) {
|
||||
<div class="type-filters">
|
||||
@for (filter of filterConfig; track filter.key) {
|
||||
<mat-checkbox
|
||||
[(ngModel)]="filters[filter.key]"
|
||||
(ngModelChange)="onFilterChange(filter.key)"
|
||||
>
|
||||
@if (filter.translationKey) {
|
||||
{{ filter.translationKey | translate }}
|
||||
} @else {
|
||||
{{ filter.label }}
|
||||
}
|
||||
</mat-checkbox>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
model,
|
||||
Output,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
export interface SearchFilters {
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
export interface SearchFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
translationKey?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-form',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatIconModule,
|
||||
MatCheckboxModule,
|
||||
MatButtonModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './search-form.component.html',
|
||||
styles: [
|
||||
`
|
||||
.search-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.recent-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.recent-search mat-icon {
|
||||
opacity: 0.6;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.recent-search input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.recent-search input::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
min-width: 30px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class SearchFormComponent {
|
||||
@ViewChild('searchInput') searchInput!: ElementRef;
|
||||
@Input() placeholder = 'Search';
|
||||
@Input() filters: SearchFilters = {};
|
||||
@Input() filterConfig: SearchFilter[] = [];
|
||||
@Input() singleSelection = false; // Add this new input
|
||||
searchTerm = model<string>('');
|
||||
|
||||
@Output() filtersChange = new EventEmitter<SearchFilters>();
|
||||
@Output() search = new EventEmitter<void>();
|
||||
|
||||
onFilterChange(changedKey: string) {
|
||||
if (this.singleSelection) {
|
||||
// Reset all other filters when in single selection mode
|
||||
Object.keys(this.filters).forEach((key) => {
|
||||
if (key !== changedKey) {
|
||||
this.filters[key] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
this.filtersChange.emit(this.filters);
|
||||
this.search.emit();
|
||||
}
|
||||
|
||||
onSearchTermChange() {
|
||||
setTimeout(() => {
|
||||
this.searchTerm.set(this.searchInput.nativeElement.value);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
this.searchInput.nativeElement.value = '';
|
||||
this.searchTerm.set('');
|
||||
}
|
||||
|
||||
focusSearchInput() {
|
||||
this.searchInput.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<div class="search-page">
|
||||
<!-- Inline details slot (for stalker) - shown above everything when active -->
|
||||
<ng-content select="[details]" />
|
||||
|
||||
@if (!showDetails()) {
|
||||
<div class="search-content">
|
||||
<div class="header sticky-header">
|
||||
<div class="header-top">
|
||||
<div class="header-left">
|
||||
<h2>{{ title() | translate }}</h2>
|
||||
|
||||
<!-- Filters slot -->
|
||||
<div class="type-filters">
|
||||
<ng-content select="[filters]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
@if (showResultsCount() && resultsCount() > 0) {
|
||||
<div class="results-count">
|
||||
{{
|
||||
'PORTALS.SEARCH_VIEW.RESULTS_FOUND'
|
||||
| translate: { count: resultsCount() }
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
@if (showCloseButton()) {
|
||||
<button mat-icon-button (click)="onCloseClick()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<!-- Extra header actions slot -->
|
||||
<ng-content select="[header-actions]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showSearchInput()) {
|
||||
<div class="search-input-container">
|
||||
<app-search-form
|
||||
[searchTerm]="searchTerm()"
|
||||
(searchTermChange)="onSearchTermChange($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Subheader slot (info notes, secondary controls) -->
|
||||
<ng-content select="[subheader]" />
|
||||
</div>
|
||||
|
||||
<div class="results-container">
|
||||
@if (isLoading()) {
|
||||
<div class="loading-state">
|
||||
<mat-spinner diameter="48" />
|
||||
</div>
|
||||
} @else if (showResults) {
|
||||
<!-- Results slot -->
|
||||
<ng-content select="[results]" />
|
||||
} @else if (showNoResults) {
|
||||
<div class="no-items">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
|
||||
<p class="search-term-note">
|
||||
{{
|
||||
'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR'
|
||||
| translate: { term: searchTerm() }
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (showInitialState) {
|
||||
<div class="no-items initial-state">
|
||||
<mat-icon>search</mat-icon>
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Custom empty state slot (optional override) -->
|
||||
<ng-content select="[empty]" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,171 @@
|
||||
@use '../../styles/content-grid' as grid;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 0 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 20px 0 10px 0;
|
||||
margin-bottom: 2px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.type-filters {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
|
||||
::ng-deep mat-checkbox {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.results-count {
|
||||
font-size: 0.85rem;
|
||||
padding: 6px 12px;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.results-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.no-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.no-items-content {
|
||||
h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 520;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.search-term-note {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&.initial-state {
|
||||
opacity: 0.8;
|
||||
|
||||
mat-icon {
|
||||
color: var(--primary-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Results grid mixin for consumers
|
||||
::ng-deep .results-grid {
|
||||
@include grid.content-grid(140px, 20px);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
// Global search overlay context
|
||||
:host-context(.global-search-overlay) {
|
||||
// Prevent Electron's app-region: drag from bleeding through the dialog overlay
|
||||
app-region: no-drag;
|
||||
|
||||
.search-page {
|
||||
background: var(--background);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: var(--background);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { SearchFormComponent } from '../search-form/search-form.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-layout',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatProgressSpinner,
|
||||
SearchFormComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './search-layout.component.html',
|
||||
styleUrl: './search-layout.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SearchLayoutComponent {
|
||||
private readonly searchFormComponent = viewChild(SearchFormComponent);
|
||||
|
||||
/** Page title translation key */
|
||||
readonly title = input<string>('PORTALS.SIDEBAR.SEARCH');
|
||||
|
||||
/** Current search term */
|
||||
readonly searchTerm = input<string>('');
|
||||
|
||||
/** Number of results found */
|
||||
readonly resultsCount = input<number>(0);
|
||||
|
||||
/** Whether search is in progress */
|
||||
readonly isLoading = input<boolean>(false);
|
||||
|
||||
/** Whether to show the close button (for dialog mode) */
|
||||
readonly showCloseButton = input<boolean>(false);
|
||||
|
||||
/** Whether to show results count */
|
||||
readonly showResultsCount = input<boolean>(true);
|
||||
|
||||
/** Whether to show the inline search input */
|
||||
readonly showSearchInput = input<boolean>(true);
|
||||
|
||||
/** Whether inline details are active and should replace the search body */
|
||||
readonly showDetails = input<boolean>(false);
|
||||
|
||||
/** Minimum characters required for search */
|
||||
readonly minSearchLength = input<number>(3);
|
||||
|
||||
/** Emitted when search term changes */
|
||||
readonly searchTermChange = output<string>();
|
||||
|
||||
/** Emitted when close button is clicked */
|
||||
readonly closeClick = output<void>();
|
||||
|
||||
/** Focus the search input */
|
||||
focusSearchInput(): void {
|
||||
this.searchFormComponent()?.focusSearchInput();
|
||||
}
|
||||
|
||||
onSearchTermChange(term: string): void {
|
||||
this.searchTermChange.emit(term);
|
||||
}
|
||||
|
||||
onCloseClick(): void {
|
||||
this.closeClick.emit();
|
||||
}
|
||||
|
||||
/** Check if we should show the "no results" state */
|
||||
get showNoResults(): boolean {
|
||||
return (
|
||||
this.searchTerm().length >= this.minSearchLength() &&
|
||||
this.resultsCount() === 0 &&
|
||||
!this.isLoading()
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if we should show the initial state */
|
||||
get showInitialState(): boolean {
|
||||
return (
|
||||
this.searchTerm().length < this.minSearchLength() &&
|
||||
this.resultsCount() === 0 &&
|
||||
!this.isLoading()
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if we should show results */
|
||||
get showResults(): boolean {
|
||||
return this.resultsCount() > 0 && !this.isLoading();
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { Directive, inject, TemplateRef } from '@angular/core';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
|
||||
export interface UnifiedCollectionDetailContext {
|
||||
$implicit: UnifiedCollectionItem;
|
||||
item: UnifiedCollectionItem;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: 'ng-template[unifiedCollectionDetail]',
|
||||
})
|
||||
export class UnifiedCollectionDetailDirective {
|
||||
readonly templateRef = inject(TemplateRef<UnifiedCollectionDetailContext>);
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
<ng-content select="ng-template[unifiedCollectionDetail]"></ng-content>
|
||||
|
||||
@if (detailContext(); as detailContext) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="detailTemplate()?.templateRef ?? null"
|
||||
[ngTemplateOutletContext]="detailContext"
|
||||
/>
|
||||
} @else if (isLoading()) {
|
||||
<div class="collection-header">
|
||||
<span class="collection-title">{{ title() | translate }}</span>
|
||||
<div class="collection-header-toggles">
|
||||
<span class="toggle-placeholder"></span>
|
||||
@if (showScopeToggle()) {
|
||||
<span
|
||||
class="toggle-placeholder toggle-placeholder--scope"
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (selectedContentType() === 'live') {
|
||||
<div class="skeleton-list">
|
||||
@for (row of skeletonRows; track row) {
|
||||
<div class="list-skeleton-row" aria-hidden="true">
|
||||
<span class="list-skeleton-handle"></span>
|
||||
<span class="list-skeleton-avatar"></span>
|
||||
<div class="list-skeleton-text">
|
||||
<span
|
||||
class="grid-skeleton-line grid-skeleton-line--primary"
|
||||
></span>
|
||||
<span
|
||||
class="grid-skeleton-line grid-skeleton-line--secondary"
|
||||
></span>
|
||||
</div>
|
||||
<span class="list-skeleton-star"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="skeleton-grid">
|
||||
@for (card of skeletonCards; track card) {
|
||||
<div class="grid-skeleton-card" aria-hidden="true">
|
||||
<div class="grid-skeleton-thumb">
|
||||
<span class="grid-skeleton-badge"></span>
|
||||
</div>
|
||||
<div class="grid-skeleton-title">
|
||||
<span
|
||||
class="grid-skeleton-line grid-skeleton-line--primary"
|
||||
></span>
|
||||
<span
|
||||
class="grid-skeleton-line grid-skeleton-line--secondary"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="collection-header">
|
||||
<span class="collection-title">{{ title() | translate }}</span>
|
||||
|
||||
@if (showFavSortButton()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="favorites-sort-button"
|
||||
[matMenuTriggerFor]="favoritesSortMenu"
|
||||
[matTooltip]="
|
||||
('WORKSPACE.SORT_LABEL' | translate) +
|
||||
(favSortLabelKey() | translate)
|
||||
"
|
||||
[attr.aria-label]="'WORKSPACE.SORT_LABEL' | translate"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
</button>
|
||||
<mat-menu #favoritesSortMenu="matMenu">
|
||||
@for (option of favSortOptions; track option.mode) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setFavSortMode(option.mode)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.translationKey | translate }}</span>
|
||||
@if (favSortMode() === option.mode) {
|
||||
<mat-icon class="sort-check">check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
}
|
||||
|
||||
<div class="collection-header-toggles">
|
||||
@if (showContentToggle()) {
|
||||
<mat-button-toggle-group
|
||||
class="content-toggle"
|
||||
[value]="selectedContentType()"
|
||||
(change)="onContentTypeChange($event.value)"
|
||||
hideSingleSelectionIndicator
|
||||
>
|
||||
@if (hasLive()) {
|
||||
<mat-button-toggle value="live">
|
||||
{{ 'PORTALS.LIVE_TV' | translate }}
|
||||
</mat-button-toggle>
|
||||
}
|
||||
@if (hasMovies()) {
|
||||
<mat-button-toggle value="movie">
|
||||
{{ 'PORTALS.MOVIES' | translate }}
|
||||
</mat-button-toggle>
|
||||
}
|
||||
@if (hasSeries()) {
|
||||
<mat-button-toggle value="series">
|
||||
{{ 'PORTALS.SERIES' | translate }}
|
||||
</mat-button-toggle>
|
||||
}
|
||||
</mat-button-toggle-group>
|
||||
}
|
||||
|
||||
@if (showScopeToggle()) {
|
||||
<mat-button-toggle-group
|
||||
class="scope-toggle"
|
||||
[value]="scope()"
|
||||
(change)="onScopeChange($event.value)"
|
||||
hideSingleSelectionIndicator
|
||||
>
|
||||
<mat-button-toggle value="playlist">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
<span class="toggle-label">{{
|
||||
'PORTALS.THIS_PLAYLIST' | translate
|
||||
}}</span>
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle value="all">
|
||||
<mat-icon>public</mat-icon>
|
||||
<span class="toggle-label">{{
|
||||
'PORTALS.ALL_PLAYLISTS' | translate
|
||||
}}</span>
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
}
|
||||
|
||||
@if (currentTypeItems().length > 0) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="clearAllCurrent()"
|
||||
[matTooltip]="
|
||||
clearButtonTooltipKey()
|
||||
| translate
|
||||
: {
|
||||
type:
|
||||
currentTypeLabelKey() | translate,
|
||||
}
|
||||
"
|
||||
[attr.aria-label]="
|
||||
clearButtonTooltipKey()
|
||||
| translate
|
||||
: {
|
||||
type:
|
||||
currentTypeLabelKey() | translate,
|
||||
}
|
||||
"
|
||||
>
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@switch (selectedContentType()) {
|
||||
@case ('live') {
|
||||
@if (hasLive()) {
|
||||
<app-unified-live-tab
|
||||
[items]="liveItems()"
|
||||
[mode]="mode()"
|
||||
[searchTerm]="workspaceSearchTerm()"
|
||||
[autoOpenItem]="pendingAutoOpenLiveItem()"
|
||||
[sortMode]="favSortMode()"
|
||||
(removeItem)="onRemoveItem($event)"
|
||||
(reorderItems)="onReorder($event)"
|
||||
(itemPlayed)="onItemPlayed($event)"
|
||||
(autoOpenHandled)="onLiveAutoOpenHandled()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('movie') {
|
||||
@if (hasMovies()) {
|
||||
<app-unified-grid-tab
|
||||
[items]="movieItems()"
|
||||
[mode]="mode()"
|
||||
[contentType]="'movie'"
|
||||
[searchTerm]="workspaceSearchTerm()"
|
||||
(itemSelected)="onGridItemSelected($event)"
|
||||
(removeItem)="onRemoveItem($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('series') {
|
||||
@if (hasSeries()) {
|
||||
<app-unified-grid-tab
|
||||
[items]="seriesItems()"
|
||||
[mode]="mode()"
|
||||
[contentType]="'series'"
|
||||
[searchTerm]="workspaceSearchTerm()"
|
||||
(itemSelected)="onGridItemSelected($event)"
|
||||
(removeItem)="onRemoveItem($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if (!hasLive() && !hasMovies() && !hasSeries()) {
|
||||
@if (workspaceSearchTerm()) {
|
||||
<div class="empty-state">
|
||||
<mat-icon class="empty-icon">search_off</mat-icon>
|
||||
<p class="empty-title">
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<app-empty-state
|
||||
type="no-data"
|
||||
[icon]="emptyStateIcon()"
|
||||
[titleKey]="emptyStateTitleKey()"
|
||||
[descriptionKey]="emptyStateBodyKey()"
|
||||
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_DASHBOARD"
|
||||
(secondaryActionClicked)="goToDashboard()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
@use '../../styles/skeleton';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton-list {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton-grid {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.collection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
.collection-title {
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
.favorites-sort-button {
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.collection-header-toggles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toggle-placeholder {
|
||||
height: 43px;
|
||||
width: 120px;
|
||||
border-radius: 18px;
|
||||
background: color-mix(in srgb, var(--mat-sys-on-surface) 6%, transparent);
|
||||
|
||||
&--scope {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-toggle,
|
||||
.scope-toggle {
|
||||
.toggle-label {
|
||||
margin-left: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
app-unified-live-tab,
|
||||
app-unified-grid-tab {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
app-unified-grid-tab {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
app-unified-live-tab {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 24px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 56px;
|
||||
height: 56px;
|
||||
width: 56px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 520;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
+734
@@ -0,0 +1,734 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
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';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { UnifiedCollectionPageComponent } from './unified-collection-page.component';
|
||||
import { UnifiedCollectionDetailDirective } from './unified-collection-detail.directive';
|
||||
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
|
||||
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-unified-live-tab',
|
||||
template: '',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubUnifiedLiveTabComponent {
|
||||
readonly items = input.required<UnifiedCollectionItem[]>();
|
||||
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[]>();
|
||||
readonly itemPlayed = output<UnifiedCollectionItem>();
|
||||
readonly autoOpenHandled = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-unified-grid-tab',
|
||||
template: '',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubUnifiedGridTabComponent {
|
||||
readonly items = input.required<UnifiedCollectionItem[]>();
|
||||
readonly mode = input<'favorites' | 'recent'>('favorites');
|
||||
readonly contentType = input<'movie' | 'series'>('movie');
|
||||
readonly searchTerm = input('');
|
||||
|
||||
readonly itemSelected = output<UnifiedCollectionItem>();
|
||||
readonly removeItem = output<UnifiedCollectionItem>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<app-unified-collection-page
|
||||
[mode]="mode"
|
||||
[portalType]="portalType"
|
||||
[defaultScope]="defaultScope"
|
||||
>
|
||||
<ng-template unifiedCollectionDetail let-item>
|
||||
<div class="detail-probe">{{ item.name }}</div>
|
||||
</ng-template>
|
||||
</app-unified-collection-page>
|
||||
`,
|
||||
imports: [UnifiedCollectionDetailDirective, UnifiedCollectionPageComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class HostUnifiedCollectionPageComponent {
|
||||
mode: 'favorites' | 'recent' = 'favorites';
|
||||
portalType?: string;
|
||||
defaultScope: CollectionScope | undefined = 'all';
|
||||
|
||||
@ViewChild(UnifiedCollectionPageComponent)
|
||||
pageComponent?: UnifiedCollectionPageComponent;
|
||||
}
|
||||
|
||||
describe('UnifiedCollectionPageComponent', () => {
|
||||
let fixture: ComponentFixture<UnifiedCollectionPageComponent>;
|
||||
let route: ActivatedRoute & {
|
||||
snapshot: {
|
||||
paramMap: ReturnType<typeof convertToParamMap>;
|
||||
queryParamMap: ReturnType<typeof convertToParamMap>;
|
||||
queryParams: Record<string, unknown>;
|
||||
params: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
parent: null;
|
||||
};
|
||||
paramMap: ReturnType<
|
||||
BehaviorSubject<
|
||||
ReturnType<typeof convertToParamMap>
|
||||
>['asObservable']
|
||||
>;
|
||||
queryParamMap: ReturnType<
|
||||
BehaviorSubject<
|
||||
ReturnType<typeof convertToParamMap>
|
||||
>['asObservable']
|
||||
>;
|
||||
pathFromRoot: ActivatedRoute[];
|
||||
};
|
||||
let routeParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let routeQueryParamMap$: BehaviorSubject<
|
||||
ReturnType<typeof convertToParamMap>
|
||||
>;
|
||||
let workspaceParamMap$: BehaviorSubject<
|
||||
ReturnType<typeof convertToParamMap>
|
||||
>;
|
||||
const playlistsLoaded = signal(false);
|
||||
const playlists = signal<PlaylistMeta[]>([]);
|
||||
const favoritesData = {
|
||||
getFavorites: jest.fn().mockResolvedValue([]),
|
||||
clearFavorites: jest.fn().mockResolvedValue(undefined),
|
||||
removeFavorite: jest.fn(),
|
||||
reorder: jest.fn(),
|
||||
};
|
||||
const recentData = {
|
||||
getRecentItems: jest.fn().mockResolvedValue([]),
|
||||
removeRecentItem: jest.fn(),
|
||||
clearRecentItems: jest.fn(),
|
||||
};
|
||||
const dialogService = {
|
||||
openConfirmDialog: jest.fn(),
|
||||
};
|
||||
const workspaceViewCommands = {
|
||||
registerCommand: jest.fn(),
|
||||
};
|
||||
const router = {
|
||||
navigate: jest.fn().mockResolvedValue(true),
|
||||
url: '/workspace/global-favorites',
|
||||
};
|
||||
|
||||
function toParamMapRecord(
|
||||
values: Record<string, unknown>
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(values)
|
||||
.filter(([, value]) => value != null)
|
||||
.map(([key, value]) => [key, String(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function setRouteParams(params: Record<string, unknown>): void {
|
||||
const nextParamMap = convertToParamMap(toParamMapRecord(params));
|
||||
route.snapshot.params = params;
|
||||
route.snapshot.paramMap = nextParamMap;
|
||||
routeParamMap$.next(nextParamMap);
|
||||
}
|
||||
|
||||
function setRouteQueryParams(queryParams: Record<string, unknown>): void {
|
||||
const nextQueryParamMap = convertToParamMap(
|
||||
toParamMapRecord(queryParams)
|
||||
);
|
||||
route.snapshot.queryParams = queryParams;
|
||||
route.snapshot.queryParamMap = nextQueryParamMap;
|
||||
routeQueryParamMap$.next(nextQueryParamMap);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
playlistsLoaded.set(false);
|
||||
playlists.set([]);
|
||||
jest.clearAllMocks();
|
||||
workspaceViewCommands.registerCommand.mockReturnValue(jest.fn());
|
||||
routeParamMap$ = new BehaviorSubject(convertToParamMap({}));
|
||||
routeQueryParamMap$ = new BehaviorSubject(convertToParamMap({}));
|
||||
workspaceParamMap$ = new BehaviorSubject(convertToParamMap({}));
|
||||
|
||||
const workspaceRoute = {
|
||||
snapshot: {
|
||||
data: { layout: 'workspace' },
|
||||
paramMap: convertToParamMap({}),
|
||||
params: {},
|
||||
},
|
||||
paramMap: workspaceParamMap$.asObservable(),
|
||||
} as ActivatedRoute;
|
||||
|
||||
route = {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({}),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
queryParams: {},
|
||||
params: {},
|
||||
data: {},
|
||||
parent: null,
|
||||
},
|
||||
paramMap: routeParamMap$.asObservable(),
|
||||
queryParamMap: routeQueryParamMap$.asObservable(),
|
||||
pathFromRoot: [],
|
||||
} as ActivatedRoute & {
|
||||
snapshot: {
|
||||
paramMap: ReturnType<typeof convertToParamMap>;
|
||||
queryParamMap: ReturnType<typeof convertToParamMap>;
|
||||
queryParams: Record<string, unknown>;
|
||||
params: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
parent: null;
|
||||
};
|
||||
paramMap: ReturnType<
|
||||
BehaviorSubject<
|
||||
ReturnType<typeof convertToParamMap>
|
||||
>['asObservable']
|
||||
>;
|
||||
queryParamMap: ReturnType<
|
||||
BehaviorSubject<
|
||||
ReturnType<typeof convertToParamMap>
|
||||
>['asObservable']
|
||||
>;
|
||||
pathFromRoot: ActivatedRoute[];
|
||||
};
|
||||
route.pathFromRoot = [workspaceRoute, route];
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
HostUnifiedCollectionPageComponent,
|
||||
UnifiedCollectionPageComponent,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: route,
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: {
|
||||
selectSignal: jest.fn((selector: unknown) => {
|
||||
if (selector === selectAllPlaylistsMeta) {
|
||||
return playlists;
|
||||
}
|
||||
if (selector === selectPlaylistsLoadingFlag) {
|
||||
return playlistsLoaded;
|
||||
}
|
||||
return signal(null);
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ScopeToggleService,
|
||||
useValue: {
|
||||
getScope: jest.fn(() => signal<CollectionScope>('all')),
|
||||
setScope: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UnifiedFavoritesDataService,
|
||||
useValue: favoritesData,
|
||||
},
|
||||
{
|
||||
provide: UnifiedRecentDataService,
|
||||
useValue: recentData,
|
||||
},
|
||||
{
|
||||
provide: DialogService,
|
||||
useValue: dialogService,
|
||||
},
|
||||
{
|
||||
provide: WorkspaceViewCommandService,
|
||||
useValue: workspaceViewCommands,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedCollectionPageComponent, {
|
||||
remove: {
|
||||
imports: [UnifiedGridTabComponent, UnifiedLiveTabComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubUnifiedGridTabComponent,
|
||||
StubUnifiedLiveTabComponent,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UnifiedCollectionPageComponent);
|
||||
fixture.componentRef.setInput('mode', 'favorites');
|
||||
fixture.componentRef.setInput('defaultScope', 'all');
|
||||
window.history.replaceState({}, document.title);
|
||||
});
|
||||
|
||||
it('reloads favorites after playlist hydration completes', async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1);
|
||||
|
||||
playlists.set([
|
||||
{
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream One',
|
||||
count: 1,
|
||||
importDate: '2026-04-03T10:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://example.com',
|
||||
favorites: ['fav-1'],
|
||||
} as PlaylistMeta,
|
||||
]);
|
||||
playlistsLoaded.set(true);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(2);
|
||||
expect(favoritesData.getFavorites).toHaveBeenLastCalledWith(
|
||||
'all',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the route default scope over the persisted collection scope', async () => {
|
||||
setRouteParams({ id: 'playlist-1' });
|
||||
playlistsLoaded.set(true);
|
||||
fixture.componentRef.setInput('portalType', 'm3u');
|
||||
fixture.componentRef.setInput('defaultScope', 'playlist');
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1);
|
||||
expect(favoritesData.getFavorites).toHaveBeenLastCalledWith(
|
||||
'playlist',
|
||||
'playlist-1',
|
||||
'm3u'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reload when local item state changes on empty playlist favorites', async () => {
|
||||
setRouteParams({ id: 'playlist-1' });
|
||||
setRouteQueryParams({ scope: 'playlist' });
|
||||
playlistsLoaded.set(true);
|
||||
playlists.set([
|
||||
{
|
||||
_id: 'playlist-1',
|
||||
title: 'Playlist One',
|
||||
count: 0,
|
||||
importDate: '2026-04-05T20:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: [],
|
||||
} as PlaylistMeta,
|
||||
]);
|
||||
|
||||
fixture.componentRef.setInput('portalType', 'm3u');
|
||||
fixture.componentRef.setInput('defaultScope', undefined);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1);
|
||||
expect(favoritesData.getFavorites).toHaveBeenLastCalledWith(
|
||||
'playlist',
|
||||
'playlist-1',
|
||||
'm3u'
|
||||
);
|
||||
|
||||
fixture.componentInstance.allItems.set([]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reloads favorites when the playlist id changes in place', async () => {
|
||||
setRouteParams({ id: 'playlist-1' });
|
||||
setRouteQueryParams({ scope: 'playlist' });
|
||||
playlistsLoaded.set(true);
|
||||
fixture.componentRef.setInput('portalType', 'm3u');
|
||||
fixture.componentRef.setInput('defaultScope', undefined);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(1);
|
||||
expect(favoritesData.getFavorites).toHaveBeenLastCalledWith(
|
||||
'playlist',
|
||||
'playlist-1',
|
||||
'm3u'
|
||||
);
|
||||
|
||||
setRouteParams({ id: 'playlist-2' });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(2);
|
||||
expect(favoritesData.getFavorites).toHaveBeenLastCalledWith(
|
||||
'playlist',
|
||||
'playlist-2',
|
||||
'm3u'
|
||||
);
|
||||
});
|
||||
|
||||
it('reloads recent items when the playlist id changes in place', async () => {
|
||||
setRouteParams({ id: 'playlist-1' });
|
||||
setRouteQueryParams({ scope: 'playlist' });
|
||||
playlistsLoaded.set(true);
|
||||
fixture.componentRef.setInput('mode', 'recent');
|
||||
fixture.componentRef.setInput('portalType', 'm3u');
|
||||
fixture.componentRef.setInput('defaultScope', undefined);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(recentData.getRecentItems).toHaveBeenCalledTimes(1);
|
||||
expect(recentData.getRecentItems).toHaveBeenLastCalledWith(
|
||||
'playlist',
|
||||
'playlist-1',
|
||||
'm3u'
|
||||
);
|
||||
|
||||
setRouteParams({ id: 'playlist-2' });
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(recentData.getRecentItems).toHaveBeenCalledTimes(2);
|
||||
expect(recentData.getRecentItems).toHaveBeenLastCalledWith(
|
||||
'playlist',
|
||||
'playlist-2',
|
||||
'm3u'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears current favorites through the bulk clear service path', async () => {
|
||||
const liveItems = [
|
||||
{
|
||||
uid: 'm3u::playlist-1::one',
|
||||
name: 'Favorite One',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'playlist-1',
|
||||
playlistName: 'Playlist One',
|
||||
streamUrl: 'https://example.com/one.m3u8',
|
||||
},
|
||||
{
|
||||
uid: 'm3u::playlist-1::two',
|
||||
name: 'Favorite Two',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'playlist-1',
|
||||
playlistName: 'Playlist One',
|
||||
streamUrl: 'https://example.com/two.m3u8',
|
||||
},
|
||||
] satisfies UnifiedCollectionItem[];
|
||||
favoritesData.getFavorites.mockResolvedValueOnce(liveItems);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
fixture.componentInstance.clearAllCurrent();
|
||||
const [dialogConfig] = dialogService.openConfirmDialog.mock.calls.at(
|
||||
-1
|
||||
) ?? [null];
|
||||
|
||||
expect(dialogConfig).toBeTruthy();
|
||||
|
||||
await dialogConfig.onConfirm();
|
||||
|
||||
expect(favoritesData.clearFavorites).toHaveBeenCalledTimes(1);
|
||||
expect(favoritesData.clearFavorites).toHaveBeenCalledWith(liveItems);
|
||||
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 = [
|
||||
{
|
||||
uid: 'm3u::playlist-1::one',
|
||||
name: 'Favorite One',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'playlist-1',
|
||||
playlistName: 'Playlist One',
|
||||
streamUrl: 'https://example.com/one.m3u8',
|
||||
},
|
||||
] satisfies UnifiedCollectionItem[];
|
||||
favoritesData.getFavorites
|
||||
.mockResolvedValueOnce(liveItems)
|
||||
.mockResolvedValueOnce(liveItems);
|
||||
favoritesData.clearFavorites.mockRejectedValueOnce(
|
||||
new Error('clear failed')
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
fixture.componentInstance.clearAllCurrent();
|
||||
const [dialogConfig] = dialogService.openConfirmDialog.mock.calls.at(
|
||||
-1
|
||||
) ?? [null];
|
||||
|
||||
await dialogConfig.onConfirm();
|
||||
|
||||
expect(favoritesData.getFavorites).toHaveBeenCalledTimes(2);
|
||||
expect(favoritesData.getFavorites).toHaveBeenLastCalledWith(
|
||||
'all',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(fixture.componentInstance.allItems()).toEqual(liveItems);
|
||||
});
|
||||
|
||||
it('opens inline detail from history state when a detail template is projected', async () => {
|
||||
const item: UnifiedCollectionItem = {
|
||||
uid: 'xtream::xtream-1::77',
|
||||
name: 'Inline Movie',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream One',
|
||||
xtreamId: 77,
|
||||
categoryId: 12,
|
||||
};
|
||||
window.history.replaceState(
|
||||
{
|
||||
[OPEN_COLLECTION_DETAIL_STATE_KEY]: {
|
||||
item,
|
||||
},
|
||||
},
|
||||
document.title
|
||||
);
|
||||
|
||||
const hostFixture = TestBed.createComponent(
|
||||
HostUnifiedCollectionPageComponent
|
||||
);
|
||||
|
||||
hostFixture.detectChanges();
|
||||
await hostFixture.whenStable();
|
||||
hostFixture.detectChanges();
|
||||
|
||||
expect(
|
||||
hostFixture.nativeElement.querySelector('.detail-probe')
|
||||
?.textContent
|
||||
).toContain('Inline Movie');
|
||||
expect(
|
||||
hostFixture.componentInstance.pageComponent?.selectedContentType()
|
||||
).toBe('movie');
|
||||
});
|
||||
|
||||
it('restores collection scope and selected content type from history state', async () => {
|
||||
setRouteParams({ id: 'playlist-1' });
|
||||
playlistsLoaded.set(true);
|
||||
window.history.replaceState(
|
||||
{
|
||||
[COLLECTION_VIEW_STATE_KEY]: {
|
||||
selectedContentType: 'movie',
|
||||
scope: 'all',
|
||||
},
|
||||
},
|
||||
document.title
|
||||
);
|
||||
|
||||
const restoredFixture = TestBed.createComponent(
|
||||
UnifiedCollectionPageComponent
|
||||
);
|
||||
restoredFixture.componentRef.setInput('mode', 'recent');
|
||||
restoredFixture.componentRef.setInput('portalType', 'stalker');
|
||||
restoredFixture.componentRef.setInput('defaultScope', 'playlist');
|
||||
|
||||
restoredFixture.detectChanges();
|
||||
await restoredFixture.whenStable();
|
||||
|
||||
expect(restoredFixture.componentInstance.scope()).toBe('all');
|
||||
expect(restoredFixture.componentInstance.selectedContentType()).toBe(
|
||||
'movie'
|
||||
);
|
||||
expect(recentData.getRecentItems).toHaveBeenLastCalledWith(
|
||||
'all',
|
||||
'playlist-1',
|
||||
'stalker'
|
||||
);
|
||||
});
|
||||
|
||||
it('closes inline detail when popstate removes the detail state', async () => {
|
||||
const item: UnifiedCollectionItem = {
|
||||
uid: 'stalker::stalker-1::series-9',
|
||||
name: 'Inline Series',
|
||||
contentType: 'series',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker One',
|
||||
stalkerId: 'series-9',
|
||||
categoryId: 'series',
|
||||
};
|
||||
const hostFixture = TestBed.createComponent(
|
||||
HostUnifiedCollectionPageComponent
|
||||
);
|
||||
|
||||
hostFixture.detectChanges();
|
||||
await hostFixture.whenStable();
|
||||
hostFixture.componentInstance.pageComponent?.onGridItemSelected(item);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
expect(
|
||||
hostFixture.nativeElement.querySelector('.detail-probe')
|
||||
?.textContent
|
||||
).toContain('Inline Series');
|
||||
|
||||
window.history.replaceState({}, document.title);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
await hostFixture.whenStable();
|
||||
hostFixture.detectChanges();
|
||||
|
||||
expect(
|
||||
hostFixture.nativeElement.querySelector('.detail-probe')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('routes cross-provider collection detail opens through the global collection route', async () => {
|
||||
const item: UnifiedCollectionItem = {
|
||||
uid: 'xtream::xtream-1::77',
|
||||
name: 'Cross Provider Movie',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream One',
|
||||
xtreamId: 77,
|
||||
categoryId: 12,
|
||||
};
|
||||
const hostFixture = TestBed.createComponent(
|
||||
HostUnifiedCollectionPageComponent
|
||||
);
|
||||
setRouteParams({ id: 'host-playlist' });
|
||||
router.url = '/workspace/stalker/host/recent';
|
||||
hostFixture.componentInstance.mode = 'recent';
|
||||
hostFixture.componentInstance.portalType = 'stalker';
|
||||
|
||||
hostFixture.detectChanges();
|
||||
await hostFixture.whenStable();
|
||||
hostFixture.componentInstance.pageComponent?.onScopeChange('all');
|
||||
hostFixture.componentInstance.pageComponent?.onContentTypeChange(
|
||||
'movie'
|
||||
);
|
||||
|
||||
hostFixture.componentInstance.pageComponent?.onGridItemSelected(item);
|
||||
|
||||
expect(window.history.state).toEqual(
|
||||
expect.objectContaining({
|
||||
[COLLECTION_VIEW_STATE_KEY]: {
|
||||
selectedContentType: 'movie',
|
||||
scope: 'all',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith(
|
||||
['/workspace', 'global-recent'],
|
||||
{
|
||||
state: {
|
||||
[OPEN_COLLECTION_DETAIL_STATE_KEY]: {
|
||||
item: expect.objectContaining({
|
||||
uid: item.uid,
|
||||
name: item.name,
|
||||
contentType: item.contentType,
|
||||
sourceType: item.sourceType,
|
||||
playlistId: item.playlistId,
|
||||
playlistName: item.playlistName,
|
||||
xtreamId: item.xtreamId,
|
||||
categoryId: String(item.categoryId),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
+817
@@ -0,0 +1,817 @@
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import {
|
||||
AfterContentInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
contentChild,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
linkedSignal,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { DialogService } from 'components';
|
||||
import {
|
||||
buildGlobalCollectionDetailNavigationTarget,
|
||||
buildCollectionViewState,
|
||||
buildOpenCollectionDetailItemState,
|
||||
clearNavigationStateKeys,
|
||||
COLLECTION_VIEW_STATE_KEY,
|
||||
CollectionContentType,
|
||||
CollectionScope,
|
||||
CollectionViewState,
|
||||
FavoritesChannelSortMode,
|
||||
getFavoritesChannelSortModeTranslationKey,
|
||||
getOpenCollectionDetailItemState,
|
||||
getCollectionViewState,
|
||||
getOpenLiveCollectionItemState,
|
||||
getUnifiedCollectionNavigation,
|
||||
isWorkspaceLayoutRoute,
|
||||
OPEN_COLLECTION_DETAIL_STATE_KEY,
|
||||
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
|
||||
persistFavoritesChannelSortMode,
|
||||
queryParamSignal,
|
||||
restoreFavoritesChannelSortMode,
|
||||
routeParamSignal,
|
||||
ScopeToggleService,
|
||||
STALKER_RETURN_TO_STATE_KEY,
|
||||
UnifiedCollectionItem,
|
||||
UnifiedFavoritesDataService,
|
||||
UnifiedRecentDataService,
|
||||
WorkspaceViewCommandService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from 'm3u-state';
|
||||
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
|
||||
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
|
||||
import {
|
||||
UnifiedCollectionDetailContext,
|
||||
UnifiedCollectionDetailDirective,
|
||||
} from './unified-collection-detail.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-unified-collection-page',
|
||||
templateUrl: './unified-collection-page.component.html',
|
||||
styleUrl: './unified-collection-page.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
EmptyStateComponent,
|
||||
NgTemplateOutlet,
|
||||
MatButtonToggleModule,
|
||||
MatIconButton,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
UnifiedGridTabComponent,
|
||||
UnifiedLiveTabComponent,
|
||||
],
|
||||
})
|
||||
export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
readonly mode = input<'favorites' | 'recent'>('favorites');
|
||||
readonly portalType = input<string>();
|
||||
readonly playlistIdInput = input<string | undefined>(undefined, {
|
||||
alias: 'playlistId',
|
||||
});
|
||||
readonly defaultScope = input<CollectionScope>();
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly scopeService = inject(ScopeToggleService);
|
||||
private readonly favoritesData = inject(UnifiedFavoritesDataService);
|
||||
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
|
||||
);
|
||||
private readonly playlistsLoaded = this.store.selectSignal(
|
||||
selectPlaylistsLoadingFlag
|
||||
);
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
private readonly queryScope = queryParamSignal<CollectionScope | null>(
|
||||
this.route,
|
||||
'scope',
|
||||
(value) => (value === 'all' || value === 'playlist' ? value : null)
|
||||
);
|
||||
private readonly routeSearchTerm = queryParamSignal(
|
||||
this.route,
|
||||
'q',
|
||||
(value) => (value ?? '').trim()
|
||||
);
|
||||
private readonly routePlaylistId = routeParamSignal<string | undefined>(
|
||||
this.route,
|
||||
'id',
|
||||
(value) => value ?? undefined
|
||||
);
|
||||
readonly playlistId = computed(
|
||||
() => this.playlistIdInput() ?? this.routePlaylistId()
|
||||
);
|
||||
readonly workspaceSearchTerm = computed(() =>
|
||||
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
|
||||
);
|
||||
private readonly historyCollectionViewState =
|
||||
signal<CollectionViewState | null>(
|
||||
getCollectionViewState(window.history.state)
|
||||
);
|
||||
|
||||
readonly isLoading = signal(true);
|
||||
readonly allItems = signal<UnifiedCollectionItem[]>([]);
|
||||
readonly selectedContentType = signal<CollectionContentType>(
|
||||
this.historyCollectionViewState()?.selectedContentType ?? 'live'
|
||||
);
|
||||
readonly selectedDetailItem = signal<UnifiedCollectionItem | null>(null);
|
||||
readonly pendingAutoOpenLiveItem = signal(
|
||||
getOpenLiveCollectionItemState(window.history.state)
|
||||
);
|
||||
readonly detailContext = computed<UnifiedCollectionDetailContext | null>(
|
||||
() => {
|
||||
const item = this.selectedDetailItem();
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
$implicit: item,
|
||||
item,
|
||||
close: this.requestCloseDetail,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
readonly skeletonRows = Array.from({ length: 12 }, (_, i) => i);
|
||||
readonly skeletonCards = Array.from({ length: 8 }, (_, i) => i);
|
||||
|
||||
readonly scopeKey = computed(() => this.mode());
|
||||
private readonly persistedScope = computed(() =>
|
||||
this.scopeService.getScope(this.scopeKey())()
|
||||
);
|
||||
readonly scope = linkedSignal<CollectionScope>(() => {
|
||||
if (!this.showScopeToggle()) {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
const queryScope = this.queryScope();
|
||||
if (queryScope) {
|
||||
return queryScope;
|
||||
}
|
||||
|
||||
const historyScope = this.historyCollectionViewState()?.scope;
|
||||
if (historyScope) {
|
||||
return historyScope;
|
||||
}
|
||||
|
||||
const defaultScope = this.defaultScope();
|
||||
if (defaultScope) {
|
||||
return defaultScope;
|
||||
}
|
||||
|
||||
return this.persistedScope();
|
||||
});
|
||||
readonly showScopeToggle = computed(() => Boolean(this.playlistId()));
|
||||
readonly effectiveScope = computed<CollectionScope>(() =>
|
||||
this.showScopeToggle() ? this.scope() : 'all'
|
||||
);
|
||||
|
||||
readonly liveItems = computed(() =>
|
||||
this.allItems().filter((i) => i.contentType === 'live')
|
||||
);
|
||||
readonly movieItems = computed(() =>
|
||||
this.allItems().filter((i) => i.contentType === 'movie')
|
||||
);
|
||||
readonly seriesItems = computed(() =>
|
||||
this.allItems().filter((i) => i.contentType === 'series')
|
||||
);
|
||||
|
||||
readonly hasLive = computed(() => this.liveItems().length > 0);
|
||||
readonly hasMovies = computed(() => this.movieItems().length > 0);
|
||||
readonly hasSeries = computed(() => this.seriesItems().length > 0);
|
||||
|
||||
readonly availableTypes = computed(() => {
|
||||
const types: CollectionContentType[] = [];
|
||||
if (this.hasLive()) types.push('live');
|
||||
if (this.hasMovies()) types.push('movie');
|
||||
if (this.hasSeries()) types.push('series');
|
||||
return types;
|
||||
});
|
||||
|
||||
readonly showContentToggle = computed(
|
||||
() => this.availableTypes().length > 1
|
||||
);
|
||||
|
||||
readonly currentTypeItems = computed(() => {
|
||||
switch (this.selectedContentType()) {
|
||||
case 'live':
|
||||
return this.liveItems();
|
||||
case 'movie':
|
||||
return this.movieItems();
|
||||
case 'series':
|
||||
return this.seriesItems();
|
||||
}
|
||||
});
|
||||
|
||||
readonly currentTypeLabelKey = computed(() => {
|
||||
switch (this.selectedContentType()) {
|
||||
case 'live':
|
||||
return 'PORTALS.LIVE_TV';
|
||||
case 'movie':
|
||||
return 'PORTALS.MOVIES';
|
||||
case 'series':
|
||||
return 'PORTALS.SERIES';
|
||||
}
|
||||
});
|
||||
|
||||
readonly clearButtonTooltipKey = computed(() =>
|
||||
this.mode() === 'favorites'
|
||||
? 'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE'
|
||||
: 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_TYPE'
|
||||
);
|
||||
|
||||
readonly title = computed(() => {
|
||||
return this.mode() === 'favorites'
|
||||
? 'PORTALS.FAVORITES'
|
||||
: 'PORTALS.RECENTLY_VIEWED';
|
||||
});
|
||||
|
||||
readonly favSortMode = signal<FavoritesChannelSortMode>(
|
||||
restoreFavoritesChannelSortMode()
|
||||
);
|
||||
readonly favSortLabelKey = computed(() =>
|
||||
getFavoritesChannelSortModeTranslationKey(this.favSortMode())
|
||||
);
|
||||
readonly showFavSortButton = computed(
|
||||
() =>
|
||||
this.mode() === 'favorites' &&
|
||||
this.selectedContentType() === 'live' &&
|
||||
this.hasLive()
|
||||
);
|
||||
readonly favSortOptions: ReadonlyArray<{
|
||||
mode: FavoritesChannelSortMode;
|
||||
translationKey: string;
|
||||
icon: string;
|
||||
}> = [
|
||||
{
|
||||
mode: 'custom',
|
||||
translationKey: 'WORKSPACE.SORT_CUSTOM',
|
||||
icon: 'drag_indicator',
|
||||
},
|
||||
{
|
||||
mode: 'name-asc',
|
||||
translationKey: 'WORKSPACE.SORT_NAME_ASC',
|
||||
icon: 'sort_by_alpha',
|
||||
},
|
||||
{
|
||||
mode: 'name-desc',
|
||||
translationKey: 'WORKSPACE.SORT_NAME_DESC',
|
||||
icon: 'sort_by_alpha',
|
||||
},
|
||||
{
|
||||
mode: 'date-desc',
|
||||
translationKey: 'WORKSPACE.SORT_DATE_DESC',
|
||||
icon: 'schedule',
|
||||
},
|
||||
];
|
||||
|
||||
private readonly favoritesReloadKey = computed(() => {
|
||||
if (this.mode() !== 'favorites') {
|
||||
return 'recent';
|
||||
}
|
||||
|
||||
if (!this.playlistsLoaded()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.playlists()
|
||||
.map((playlist) =>
|
||||
[
|
||||
playlist._id,
|
||||
playlist.serverUrl
|
||||
? 'xtream'
|
||||
: playlist.macAddress
|
||||
? 'stalker'
|
||||
: 'm3u',
|
||||
JSON.stringify(playlist.favorites ?? []),
|
||||
].join('::')
|
||||
)
|
||||
.join('|');
|
||||
});
|
||||
private readonly loadRequest = computed(() => ({
|
||||
mode: this.mode(),
|
||||
portalType: this.portalType(),
|
||||
playlistId: this.playlistId(),
|
||||
scope: this.effectiveScope(),
|
||||
reloadKey: this.favoritesReloadKey(),
|
||||
}));
|
||||
|
||||
private loadRequestId = 0;
|
||||
|
||||
private readonly loadEffect = effect(() => {
|
||||
const { mode, portalType, playlistId, scope } = this.loadRequest();
|
||||
untracked(() => {
|
||||
void this.loadData({
|
||||
mode,
|
||||
portalType,
|
||||
playlistId,
|
||||
scope,
|
||||
});
|
||||
});
|
||||
});
|
||||
private readonly persistCollectionViewState = effect(() => {
|
||||
const selectedContentType = this.selectedContentType();
|
||||
const scope = this.showScopeToggle() ? this.scope() : undefined;
|
||||
|
||||
untracked(() => {
|
||||
this.syncCollectionViewStateToHistory({
|
||||
selectedContentType,
|
||||
scope,
|
||||
});
|
||||
});
|
||||
});
|
||||
private readonly crossProviderDetailRedirect = effect(() => {
|
||||
const item = this.selectedDetailItem();
|
||||
if (!item || this.canRenderInlineDetailOnCurrentRoute(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const navigation = this.getGlobalCollectionDetailNavigation(item);
|
||||
if (!navigation) {
|
||||
return;
|
||||
}
|
||||
|
||||
untracked(() => {
|
||||
this.selectedDetailItem.set(null);
|
||||
void this.router.navigate(navigation.link, {
|
||||
state: navigation.state,
|
||||
});
|
||||
});
|
||||
});
|
||||
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') {
|
||||
const onPopState = () => {
|
||||
this.syncCollectionViewStateFromHistory();
|
||||
this.syncDetailFromHistoryState();
|
||||
};
|
||||
window.addEventListener('popstate', onPopState);
|
||||
this.destroyRef.onDestroy(() => {
|
||||
window.removeEventListener('popstate', onPopState);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterContentInit(): void {
|
||||
this.syncCollectionViewStateFromHistory();
|
||||
this.syncDetailFromHistoryState();
|
||||
}
|
||||
|
||||
onScopeChange(value: CollectionScope): void {
|
||||
if (!this.showScopeToggle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scope.set(value);
|
||||
this.scopeService.setScope(this.scopeKey(), value);
|
||||
}
|
||||
|
||||
onContentTypeChange(value: CollectionContentType): void {
|
||||
this.selectedContentType.set(value);
|
||||
}
|
||||
|
||||
readonly emptyStateIcon = computed(() =>
|
||||
this.mode() === 'favorites' ? 'favorite_border' : 'history_toggle_off'
|
||||
);
|
||||
|
||||
readonly emptyStateTitleKey = computed(() =>
|
||||
this.mode() === 'favorites'
|
||||
? 'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_TITLE'
|
||||
: 'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_TITLE'
|
||||
);
|
||||
|
||||
readonly emptyStateBodyKey = computed(() =>
|
||||
this.mode() === 'favorites'
|
||||
? 'WORKSPACE.GLOBAL_FAVORITES.NO_ITEMS_BODY'
|
||||
: 'WORKSPACE.GLOBAL_RECENT.NO_ITEMS_BODY'
|
||||
);
|
||||
|
||||
goToDashboard(): void {
|
||||
void this.router.navigate(['/workspace', 'dashboard']);
|
||||
}
|
||||
|
||||
setFavSortMode(mode: FavoritesChannelSortMode): void {
|
||||
this.favSortMode.set(mode);
|
||||
persistFavoritesChannelSortMode(mode);
|
||||
}
|
||||
|
||||
onGridItemSelected(item: UnifiedCollectionItem): void {
|
||||
this.syncCurrentCollectionViewState();
|
||||
|
||||
if (this.canOpenInlineDetail(item)) {
|
||||
this.pushInlineDetailState(item);
|
||||
this.openInlineDetail(item);
|
||||
return;
|
||||
}
|
||||
|
||||
const globalDetailNavigation =
|
||||
this.getGlobalCollectionDetailNavigation(item);
|
||||
if (globalDetailNavigation) {
|
||||
void this.router.navigate(globalDetailNavigation.link, {
|
||||
state: globalDetailNavigation.state,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const navigation = getUnifiedCollectionNavigation(item);
|
||||
if (!navigation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state =
|
||||
item.sourceType === 'stalker' && item.contentType !== 'live'
|
||||
? {
|
||||
...(navigation.state ?? {}),
|
||||
[STALKER_RETURN_TO_STATE_KEY]: this.router.url,
|
||||
}
|
||||
: navigation.state;
|
||||
|
||||
void this.router.navigate(navigation.link, { state });
|
||||
}
|
||||
|
||||
async onRemoveItem(item: UnifiedCollectionItem): Promise<void> {
|
||||
if (this.mode() === 'favorites') {
|
||||
await this.favoritesData.removeFavorite(item);
|
||||
} else {
|
||||
await this.recentData.removeRecentItem(item);
|
||||
}
|
||||
this.allItems.update((items) =>
|
||||
items.filter((i) => i.uid !== item.uid)
|
||||
);
|
||||
}
|
||||
|
||||
clearAllCurrent(): void {
|
||||
const itemsToRemove = this.currentTypeItems();
|
||||
if (itemsToRemove.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFavorites = this.mode() === 'favorites';
|
||||
const type = this.translate.instant(this.currentTypeLabelKey());
|
||||
const isPlaylistScope = this.effectiveScope() === 'playlist';
|
||||
const titleKey = isFavorites
|
||||
? 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_TITLE'
|
||||
: 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_TITLE';
|
||||
const messageKey = isFavorites
|
||||
? isPlaylistScope
|
||||
? 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST'
|
||||
: 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_MESSAGE_ALL'
|
||||
: isPlaylistScope
|
||||
? 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_PLAYLIST'
|
||||
: 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_MESSAGE_ALL';
|
||||
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant(titleKey, { type }),
|
||||
message: this.translate.instant(messageKey, { type }),
|
||||
onConfirm: async () => {
|
||||
if (isFavorites) {
|
||||
await this.clearCurrentFavorites(itemsToRemove);
|
||||
return;
|
||||
}
|
||||
|
||||
const removedType = this.selectedContentType();
|
||||
this.allItems.update((items) =>
|
||||
items.filter((item) => item.contentType !== removedType)
|
||||
);
|
||||
const remaining = this.availableTypes();
|
||||
if (remaining.length > 0) {
|
||||
this.selectedContentType.set(remaining[0]);
|
||||
}
|
||||
void Promise.all(
|
||||
itemsToRemove.map((item) =>
|
||||
this.recentData.removeRecentItem(item)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async onReorder(items: UnifiedCollectionItem[]): Promise<void> {
|
||||
const nonLive = this.allItems().filter((i) => i.contentType !== 'live');
|
||||
this.allItems.set([...items, ...nonLive]);
|
||||
await this.favoritesData.reorder(items, {
|
||||
scope: this.effectiveScope(),
|
||||
playlistId: this.playlistId(),
|
||||
portalType: this.portalType(),
|
||||
});
|
||||
}
|
||||
|
||||
onItemPlayed(item: UnifiedCollectionItem): void {
|
||||
if (this.mode() !== 'recent') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.allItems.update((items) => {
|
||||
const nextItems = [
|
||||
item,
|
||||
...items.filter((candidate) => candidate.uid !== item.uid),
|
||||
];
|
||||
return nextItems.sort(
|
||||
(a, b) =>
|
||||
new Date(b.viewedAt ?? 0).getTime() -
|
||||
new Date(a.viewedAt ?? 0).getTime()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async clearCurrentFavorites(
|
||||
itemsToRemove: UnifiedCollectionItem[]
|
||||
): Promise<void> {
|
||||
const removedType = this.selectedContentType();
|
||||
this.allItems.update((items) =>
|
||||
items.filter((item) => item.contentType !== removedType)
|
||||
);
|
||||
const remaining = this.availableTypes();
|
||||
if (remaining.length > 0) {
|
||||
this.selectedContentType.set(remaining[0]);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.favoritesData.clearFavorites(itemsToRemove);
|
||||
} catch {
|
||||
await this.reloadCurrentCollection();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadData(params: {
|
||||
mode: 'favorites' | 'recent';
|
||||
portalType?: string;
|
||||
playlistId?: string;
|
||||
scope: CollectionScope;
|
||||
}): Promise<void> {
|
||||
const requestId = ++this.loadRequestId;
|
||||
if (this.allItems().length === 0) {
|
||||
this.isLoading.set(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const items =
|
||||
params.mode === 'favorites'
|
||||
? await this.favoritesData.getFavorites(
|
||||
params.scope,
|
||||
params.playlistId,
|
||||
params.portalType
|
||||
)
|
||||
: await this.recentData.getRecentItems(
|
||||
params.scope,
|
||||
params.playlistId,
|
||||
params.portalType
|
||||
);
|
||||
if (requestId !== this.loadRequestId) {
|
||||
return;
|
||||
}
|
||||
this.allItems.set(items);
|
||||
this.autoSelectContentType();
|
||||
if (
|
||||
this.pendingAutoOpenLiveItem() &&
|
||||
items.some((item) => item.contentType === 'live')
|
||||
) {
|
||||
this.selectedContentType.set('live');
|
||||
}
|
||||
} catch {
|
||||
if (requestId !== this.loadRequestId) {
|
||||
return;
|
||||
}
|
||||
this.allItems.set([]);
|
||||
} finally {
|
||||
if (requestId === this.loadRequestId) {
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadCurrentCollection(): Promise<void> {
|
||||
await this.loadData({
|
||||
mode: this.mode(),
|
||||
portalType: this.portalType(),
|
||||
playlistId: this.playlistId(),
|
||||
scope: this.effectiveScope(),
|
||||
});
|
||||
}
|
||||
|
||||
private autoSelectContentType(): void {
|
||||
if (this.selectedDetailItem()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const types = this.availableTypes();
|
||||
if (types.length > 0 && !types.includes(this.selectedContentType())) {
|
||||
this.selectedContentType.set(types[0]);
|
||||
}
|
||||
}
|
||||
|
||||
onLiveAutoOpenHandled(): void {
|
||||
this.pendingAutoOpenLiveItem.set(null);
|
||||
clearNavigationStateKeys([OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]);
|
||||
}
|
||||
|
||||
private readonly requestCloseDetail = (): void => {
|
||||
if (getOpenCollectionDetailItemState(window.history.state)) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearInlineDetail();
|
||||
};
|
||||
|
||||
private canOpenInlineDetail(item: UnifiedCollectionItem): boolean {
|
||||
return (
|
||||
Boolean(this.detailTemplate()) &&
|
||||
item.contentType !== 'live' &&
|
||||
(item.sourceType === 'xtream' || item.sourceType === 'stalker') &&
|
||||
this.canRenderInlineDetailOnCurrentRoute(item)
|
||||
);
|
||||
}
|
||||
|
||||
private canRenderInlineDetailOnCurrentRoute(
|
||||
item: UnifiedCollectionItem
|
||||
): boolean {
|
||||
const url = this.router.url;
|
||||
|
||||
if (url.includes('/workspace/xtreams/')) {
|
||||
return item.sourceType === 'xtream';
|
||||
}
|
||||
|
||||
if (url.includes('/workspace/stalker/')) {
|
||||
return item.sourceType === 'stalker';
|
||||
}
|
||||
|
||||
if (
|
||||
url.includes('/workspace/global-favorites') ||
|
||||
url.includes('/workspace/global-recent')
|
||||
) {
|
||||
return (
|
||||
item.sourceType === 'xtream' || item.sourceType === 'stalker'
|
||||
);
|
||||
}
|
||||
|
||||
const portalType = this.portalType();
|
||||
return !portalType || portalType === item.sourceType;
|
||||
}
|
||||
|
||||
private getGlobalCollectionDetailNavigation(item: UnifiedCollectionItem) {
|
||||
if (
|
||||
item.contentType === 'live' ||
|
||||
(item.sourceType !== 'xtream' && item.sourceType !== 'stalker')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildGlobalCollectionDetailNavigationTarget(this.mode(), item);
|
||||
}
|
||||
|
||||
private openInlineDetail(item: UnifiedCollectionItem): void {
|
||||
this.selectedContentType.set(item.contentType);
|
||||
this.selectedDetailItem.set(item);
|
||||
}
|
||||
|
||||
private clearInlineDetail(): void {
|
||||
this.selectedDetailItem.set(null);
|
||||
this.autoSelectContentType();
|
||||
clearNavigationStateKeys([OPEN_COLLECTION_DETAIL_STATE_KEY]);
|
||||
}
|
||||
|
||||
private pushInlineDetailState(item: UnifiedCollectionItem): void {
|
||||
const currentState = window.history.state ?? {};
|
||||
window.history.pushState(
|
||||
{
|
||||
...currentState,
|
||||
[OPEN_COLLECTION_DETAIL_STATE_KEY]:
|
||||
buildOpenCollectionDetailItemState(item),
|
||||
},
|
||||
document.title
|
||||
);
|
||||
}
|
||||
|
||||
private syncCollectionViewStateFromHistory(): void {
|
||||
const collectionViewState = getCollectionViewState(
|
||||
window.history.state
|
||||
);
|
||||
this.historyCollectionViewState.set(collectionViewState);
|
||||
|
||||
if (collectionViewState?.selectedContentType) {
|
||||
this.selectedContentType.set(
|
||||
collectionViewState.selectedContentType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private syncCurrentCollectionViewState(): void {
|
||||
this.syncCollectionViewStateToHistory({
|
||||
selectedContentType: this.selectedContentType(),
|
||||
scope: this.showScopeToggle() ? this.scope() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private syncCollectionViewStateToHistory(state: CollectionViewState): void {
|
||||
const nextCollectionViewState = buildCollectionViewState(state);
|
||||
const currentCollectionViewState = this.historyCollectionViewState();
|
||||
|
||||
if (
|
||||
this.isSameCollectionViewState(
|
||||
currentCollectionViewState,
|
||||
nextCollectionViewState
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = this.toHistoryStateRecord(window.history.state);
|
||||
const nextState = { ...currentState };
|
||||
|
||||
if (nextCollectionViewState) {
|
||||
nextState[COLLECTION_VIEW_STATE_KEY] = nextCollectionViewState;
|
||||
} else {
|
||||
delete nextState[COLLECTION_VIEW_STATE_KEY];
|
||||
}
|
||||
|
||||
window.history.replaceState(nextState, document.title);
|
||||
this.historyCollectionViewState.set(nextCollectionViewState);
|
||||
}
|
||||
|
||||
private syncDetailFromHistoryState(): void {
|
||||
const detailItem = getOpenCollectionDetailItemState(
|
||||
window.history.state
|
||||
)?.item;
|
||||
|
||||
if (detailItem && this.canOpenInlineDetail(detailItem)) {
|
||||
this.openInlineDetail(detailItem);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedDetailItem()) {
|
||||
this.clearInlineDetail();
|
||||
}
|
||||
}
|
||||
|
||||
private isSameCollectionViewState(
|
||||
left: CollectionViewState | null,
|
||||
right: CollectionViewState | null
|
||||
): boolean {
|
||||
return (
|
||||
left?.selectedContentType === right?.selectedContentType &&
|
||||
left?.scope === right?.scope
|
||||
);
|
||||
}
|
||||
|
||||
private toHistoryStateRecord(state: unknown): Record<string, unknown> {
|
||||
return state && typeof state === 'object'
|
||||
? { ...(state as Record<string, unknown>) }
|
||||
: {};
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
@if (filteredItems().length === 0) {
|
||||
<div class="empty-state">
|
||||
<mat-icon class="empty-icon">{{
|
||||
contentType() === 'movie' ? 'movie' : 'tv'
|
||||
}}</mat-icon>
|
||||
<p class="empty-title">
|
||||
@if (searchTerm()) {
|
||||
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
|
||||
} @else {
|
||||
{{
|
||||
(mode() === 'favorites'
|
||||
? 'PORTALS.NO_FAVORITES'
|
||||
: 'PORTALS.NO_RECENT_ITEMS'
|
||||
) | translate
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid-container">
|
||||
@for (item of filteredItems(); track trackByUid($index, item)) {
|
||||
<app-content-card
|
||||
[posterUrl]="item.posterUrl ?? ''"
|
||||
[title]="item.name"
|
||||
[type]="item.contentType"
|
||||
[date]="item.addedAt ?? item.viewedAt"
|
||||
[showRemoveButton]="true"
|
||||
[removeTooltip]="
|
||||
(mode() === 'favorites'
|
||||
? 'PORTALS.REMOVE_FROM_FAVORITES'
|
||||
: 'PORTALS.REMOVE_FROM_RECENT'
|
||||
) | translate
|
||||
"
|
||||
(cardClick)="onCardClick(item)"
|
||||
(remove)="onRemove(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: -10px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
@include mat.form-field-density(-5);
|
||||
}
|
||||
|
||||
:host ::ng-deep .search-bar div {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
:host ::ng-deep .search-bar div[matformfieldlineripple],
|
||||
:host ::ng-deep .search-bar .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 24px;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
CollectionContentType,
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { ContentCardComponent } from '../content-card/content-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-unified-grid-tab',
|
||||
templateUrl: './unified-grid-tab.component.html',
|
||||
styleUrl: './unified-grid-tab.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ContentCardComponent, MatIconModule, TranslatePipe],
|
||||
})
|
||||
export class UnifiedGridTabComponent {
|
||||
readonly items = input.required<UnifiedCollectionItem[]>();
|
||||
readonly mode = input<'favorites' | 'recent'>('favorites');
|
||||
readonly contentType = input<CollectionContentType>('movie');
|
||||
readonly searchTerm = input('');
|
||||
|
||||
readonly removeItem = output<UnifiedCollectionItem>();
|
||||
readonly itemSelected = output<UnifiedCollectionItem>();
|
||||
|
||||
readonly filteredItems = computed(() => {
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
const all = this.items();
|
||||
return term
|
||||
? all.filter((i) => i.name.toLowerCase().includes(term))
|
||||
: all;
|
||||
});
|
||||
|
||||
onCardClick(item: UnifiedCollectionItem): void {
|
||||
this.itemSelected.emit(item);
|
||||
}
|
||||
|
||||
onRemove(item: UnifiedCollectionItem): void {
|
||||
this.removeItem.emit(item);
|
||||
}
|
||||
|
||||
trackByUid(_: number, item: UnifiedCollectionItem): string {
|
||||
return item.uid;
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="360"
|
||||
storageKey="unified-live-tab-sidebar-width"
|
||||
>
|
||||
<app-global-favorites-list
|
||||
[channels]="channelsForList()"
|
||||
[epgMap]="epgMap()"
|
||||
[progressTick]="progressTick()"
|
||||
[activeUid]="activeUid()"
|
||||
[searchTermInput]="searchTerm()"
|
||||
[draggable]="mode() === 'favorites'"
|
||||
[sortMode]="sortMode()"
|
||||
(channelSelected)="onChannelSelected($event)"
|
||||
(channelsReordered)="onReorder($event)"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="content-container">
|
||||
@if (activeDetail()) {
|
||||
@if (shouldUseInlinePlayer()) {
|
||||
<div class="video-player" [class.video-player--radio]="isRadioSelection()">
|
||||
@if (isRadioSelection()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="onClose()"
|
||||
class="radio-close-btn"
|
||||
aria-label="Close player"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
|
||||
<app-audio-player
|
||||
[url]="currentStreamUrl()"
|
||||
[icon]="
|
||||
activeRadioChannel()?.tvg?.logo ??
|
||||
activeDetail()?.playback?.thumbnail ??
|
||||
''
|
||||
"
|
||||
[channelName]="
|
||||
activeRadioChannel()?.name ||
|
||||
activeDetail()?.playback?.title ||
|
||||
''
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<div class="player-toolbar">
|
||||
<span class="player-title">{{
|
||||
activeDetail()?.playback?.title
|
||||
}}</span>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="onClose()"
|
||||
class="close-btn"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (player() === 'videojs') {
|
||||
<app-vjs-player
|
||||
[options]="{
|
||||
sources: [
|
||||
{
|
||||
src: currentStreamUrl(),
|
||||
type: 'application/x-mpegURL',
|
||||
},
|
||||
],
|
||||
}"
|
||||
[volume]="1"
|
||||
/>
|
||||
}
|
||||
@if (player() === 'html5') {
|
||||
<app-html-video-player
|
||||
[channel]="activeChannelForOverlay()!"
|
||||
[volume]="1"
|
||||
[showCaptions]="false"
|
||||
/>
|
||||
}
|
||||
@if (player() === 'artplayer') {
|
||||
<app-art-player
|
||||
[channel]="activeChannelForOverlay()!"
|
||||
[volume]="1"
|
||||
[showCaptions]="false"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isRadioSelection()) {
|
||||
<div class="epg">
|
||||
@if (!shouldUseInlinePlayer()) {
|
||||
<div class="player-toolbar">
|
||||
<span class="player-title">{{
|
||||
activeDetail()?.playback?.title
|
||||
}}</span>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="onClose()"
|
||||
class="close-btn"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="epg-content">
|
||||
@if (isM3uSelection()) {
|
||||
<app-epg-list
|
||||
[controlledChannel]="currentM3uChannel()"
|
||||
[controlledPrograms]="currentM3uPrograms()"
|
||||
[archivePlaybackAvailable]="
|
||||
currentM3uArchivePlaybackAvailable()
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-view [epgItems]="currentPortalEpgItems()" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
@use '../../styles/portal-layout' as portal;
|
||||
|
||||
@include portal.live-layout;
|
||||
|
||||
.sidebar {
|
||||
> app-global-favorites-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.content-container {
|
||||
background: var(--mat-sys-surface);
|
||||
|
||||
&:has(> app-portal-empty-state) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.video-player {
|
||||
background: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&--radio {
|
||||
position: relative;
|
||||
background: var(--mat-sys-surface);
|
||||
}
|
||||
|
||||
> app-vjs-player,
|
||||
> app-html-video-player,
|
||||
> app-art-player,
|
||||
> app-audio-player {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.player-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px 4px 16px;
|
||||
flex-shrink: 0;
|
||||
background: var(--mat-sys-surface-container-low);
|
||||
border-bottom: 1px solid var(--mat-sys-outline-variant);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.radio-close-btn {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 4;
|
||||
color: var(--mat-sys-on-surface);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-high) 72%,
|
||||
transparent
|
||||
);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.epg {
|
||||
border-top: 1px solid var(--mat-sys-outline-variant);
|
||||
background: var(--mat-sys-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.epg-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> app-epg-view,
|
||||
> app-epg-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { isM3uCatchupPlaybackSupported } from 'm3u-utils';
|
||||
import {
|
||||
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
|
||||
FavoritesChannelSortMode,
|
||||
matchesOpenLiveCollectionItem,
|
||||
OpenLiveCollectionItemState,
|
||||
PORTAL_PLAYER,
|
||||
ResolvedLiveCollectionDetail,
|
||||
StreamResolverService,
|
||||
UnifiedCollectionItem,
|
||||
UnifiedFavoriteChannel,
|
||||
UnifiedRecentDataService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { EpgListComponent } from '@iptvnator/ui/epg';
|
||||
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
|
||||
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
ArtPlayerComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
VjsPlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { SettingsStore } from 'services';
|
||||
import { Channel, EpgProgram } from 'shared-interfaces';
|
||||
import { EpgViewComponent } from 'shared-portals';
|
||||
|
||||
@Component({
|
||||
selector: 'app-unified-live-tab',
|
||||
templateUrl: './unified-live-tab.component.html',
|
||||
styleUrl: './unified-live-tab.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AudioPlayerComponent,
|
||||
ArtPlayerComponent,
|
||||
EpgListComponent,
|
||||
EpgViewComponent,
|
||||
GlobalFavoritesListComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
VjsPlayerComponent,
|
||||
],
|
||||
})
|
||||
export class UnifiedLiveTabComponent {
|
||||
readonly items = input.required<UnifiedCollectionItem[]>();
|
||||
readonly mode = input<'favorites' | 'recent'>('favorites');
|
||||
readonly searchTerm = input('');
|
||||
readonly autoOpenItem = input<OpenLiveCollectionItemState | null>(null);
|
||||
readonly sortMode = input<FavoritesChannelSortMode>(
|
||||
DEFAULT_FAVORITES_CHANNEL_SORT_MODE
|
||||
);
|
||||
|
||||
readonly removeItem = output<UnifiedCollectionItem>();
|
||||
readonly reorderItems = output<UnifiedCollectionItem[]>();
|
||||
readonly itemPlayed = output<UnifiedCollectionItem>();
|
||||
readonly autoOpenHandled = output<void>();
|
||||
|
||||
private readonly streamResolver = inject(StreamResolverService);
|
||||
private readonly recentData = inject(UnifiedRecentDataService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly player = this.settingsStore.player;
|
||||
readonly isEmbeddedPlayer = computed(() =>
|
||||
this.portalPlayer.isEmbeddedPlayer()
|
||||
);
|
||||
|
||||
readonly activeDetail = signal<ResolvedLiveCollectionDetail | null>(null);
|
||||
readonly activeUid = signal<string | null>(null);
|
||||
readonly isSelecting = signal(false);
|
||||
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
|
||||
readonly progressTick = signal(0);
|
||||
readonly currentStreamUrl = computed(
|
||||
() => this.activeDetail()?.playback.streamUrl ?? ''
|
||||
);
|
||||
readonly isM3uSelection = computed(
|
||||
() => this.activeDetail()?.epgMode === 'm3u'
|
||||
);
|
||||
readonly currentPortalEpgItems = computed(
|
||||
() => this.activeDetail()?.epgItems ?? []
|
||||
);
|
||||
readonly currentM3uPrograms = computed(() => {
|
||||
const detail = this.activeDetail();
|
||||
if (detail?.epgMode !== 'm3u') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return detail.epgPrograms ?? [];
|
||||
});
|
||||
readonly currentM3uChannel = computed(() => {
|
||||
const detail = this.activeDetail();
|
||||
if (detail?.epgMode !== 'm3u') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return detail.channel ?? null;
|
||||
});
|
||||
readonly currentM3uArchivePlaybackAvailable = computed(() =>
|
||||
isM3uCatchupPlaybackSupported(this.currentM3uChannel())
|
||||
);
|
||||
readonly activeRadioChannel = computed(() => {
|
||||
const channel = this.currentM3uChannel();
|
||||
return channel?.radio === 'true' ? channel : null;
|
||||
});
|
||||
readonly isRadioSelection = computed(() => this.activeRadioChannel() !== null);
|
||||
readonly shouldUseInlinePlayer = computed(() => {
|
||||
return this.isRadioSelection() || this.isEmbeddedPlayer();
|
||||
});
|
||||
|
||||
readonly activeChannelForOverlay = computed((): Channel | undefined => {
|
||||
const detail = this.activeDetail();
|
||||
if (!detail) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (detail.channel) {
|
||||
return detail.channel;
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.activeUid() ?? '',
|
||||
name: detail.playback.title ?? '',
|
||||
url: detail.playback.streamUrl,
|
||||
tvg: {
|
||||
logo: detail.playback.thumbnail ?? '',
|
||||
id: '',
|
||||
name: '',
|
||||
rec: '',
|
||||
url: '',
|
||||
},
|
||||
group: { title: '' },
|
||||
http: {
|
||||
referrer: detail.playback.referer ?? '',
|
||||
'user-agent': detail.playback.userAgent ?? '',
|
||||
origin: detail.playback.origin ?? '',
|
||||
},
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
} satisfies Channel;
|
||||
});
|
||||
|
||||
readonly channelsForList = computed((): UnifiedFavoriteChannel[] =>
|
||||
this.items().map((item) => ({
|
||||
uid: item.uid,
|
||||
name: item.name,
|
||||
logo: item.logo ?? null,
|
||||
sourceType: item.sourceType,
|
||||
playlistId: item.playlistId,
|
||||
playlistName: item.playlistName,
|
||||
streamUrl: item.streamUrl,
|
||||
xtreamId: item.xtreamId,
|
||||
tvgId: item.tvgId,
|
||||
stalkerCmd: item.stalkerCmd,
|
||||
stalkerPortalUrl: item.stalkerPortalUrl,
|
||||
stalkerMacAddress: item.stalkerMacAddress,
|
||||
addedAt: item.addedAt ?? new Date(0).toISOString(),
|
||||
position: item.position ?? 0,
|
||||
contentId: item.contentId,
|
||||
}))
|
||||
);
|
||||
|
||||
private selectionRequestId = 0;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const items = this.items();
|
||||
void this.loadEpgMap(items);
|
||||
|
||||
const activeUid = this.activeUid();
|
||||
if (activeUid && !items.some((item) => item.uid === activeUid)) {
|
||||
this.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const target = this.autoOpenItem();
|
||||
const items = this.items();
|
||||
if (!target || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedItem = items.find((item) =>
|
||||
matchesOpenLiveCollectionItem(item, target)
|
||||
);
|
||||
if (!matchedItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeUid() === matchedItem.uid) {
|
||||
if (this.activeDetail()) {
|
||||
this.autoOpenHandled.emit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isSelecting()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void this.activateItem(matchedItem, true);
|
||||
});
|
||||
|
||||
const tickInterval = setInterval(
|
||||
() => this.progressTick.update((tick) => tick + 1),
|
||||
30_000
|
||||
);
|
||||
this.destroyRef.onDestroy(() => clearInterval(tickInterval));
|
||||
}
|
||||
|
||||
async onChannelSelected(channel: UnifiedFavoriteChannel): Promise<void> {
|
||||
const item = this.items().find(
|
||||
(candidate) => candidate.uid === channel.uid
|
||||
);
|
||||
if (item) {
|
||||
await this.activateItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
|
||||
const item = this.items().find(
|
||||
(candidate) => candidate.uid === channel.uid
|
||||
);
|
||||
if (item) {
|
||||
this.removeItem.emit(item);
|
||||
}
|
||||
}
|
||||
|
||||
onReorder(channels: UnifiedFavoriteChannel[]): void {
|
||||
const reordered = channels
|
||||
.map((channel) =>
|
||||
this.items().find((candidate) => candidate.uid === channel.uid)
|
||||
)
|
||||
.filter(Boolean) as UnifiedCollectionItem[];
|
||||
this.reorderItems.emit(reordered);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.selectionRequestId += 1;
|
||||
this.isSelecting.set(false);
|
||||
this.activeDetail.set(null);
|
||||
this.activeUid.set(null);
|
||||
}
|
||||
|
||||
private async loadEpgMap(items: UnifiedCollectionItem[]): Promise<void> {
|
||||
const epgMap = await this.streamResolver.loadEpgForItems(items);
|
||||
this.epgMap.set(epgMap);
|
||||
}
|
||||
|
||||
private async activateItem(
|
||||
item: UnifiedCollectionItem,
|
||||
isAutoOpen = false
|
||||
): Promise<void> {
|
||||
if (this.activeUid() === item.uid && this.activeDetail()) {
|
||||
if (isAutoOpen) {
|
||||
this.autoOpenHandled.emit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++this.selectionRequestId;
|
||||
this.activeUid.set(item.uid);
|
||||
this.activeDetail.set(null);
|
||||
this.isSelecting.set(true);
|
||||
|
||||
try {
|
||||
const detail =
|
||||
item.sourceType === 'm3u'
|
||||
? await this.streamResolver.resolveM3uPlaybackDetail(item)
|
||||
: await this.streamResolver.resolveLiveDetail(item);
|
||||
if (requestId !== this.selectionRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeDetail.set(detail);
|
||||
|
||||
if (detail.epgMode === 'm3u') {
|
||||
void this.hydrateSelectedM3uPrograms(item, detail, requestId);
|
||||
}
|
||||
|
||||
if (this.shouldOpenExternalPlayback(detail)) {
|
||||
void this.portalPlayer.openResolvedPlayback(detail.playback);
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedItem =
|
||||
await this.recentData.recordLivePlayback(item);
|
||||
if (requestId === this.selectionRequestId) {
|
||||
this.itemPlayed.emit(updatedItem);
|
||||
}
|
||||
} catch {
|
||||
// Keep playback/EPG visible even if history persistence fails.
|
||||
}
|
||||
|
||||
if (requestId === this.selectionRequestId && isAutoOpen) {
|
||||
this.autoOpenHandled.emit();
|
||||
}
|
||||
} catch {
|
||||
if (requestId === this.selectionRequestId) {
|
||||
this.activeDetail.set(null);
|
||||
this.activeUid.set(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === this.selectionRequestId) {
|
||||
this.isSelecting.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private shouldOpenExternalPlayback(
|
||||
detail: ResolvedLiveCollectionDetail
|
||||
): boolean {
|
||||
return !this.isRadioDetail(detail) && !this.portalPlayer.isEmbeddedPlayer();
|
||||
}
|
||||
|
||||
private isRadioDetail(
|
||||
detail: ResolvedLiveCollectionDetail | null | undefined
|
||||
): boolean {
|
||||
return detail?.epgMode === 'm3u' && detail.channel?.radio === 'true';
|
||||
}
|
||||
|
||||
private async hydrateSelectedM3uPrograms(
|
||||
item: UnifiedCollectionItem,
|
||||
detail: ResolvedLiveCollectionDetail,
|
||||
requestId: number
|
||||
): Promise<void> {
|
||||
if (detail.epgMode !== 'm3u') {
|
||||
return;
|
||||
}
|
||||
|
||||
const epgPrograms = await this.streamResolver.loadM3uProgramsForItem(
|
||||
item,
|
||||
detail.channel
|
||||
);
|
||||
if (requestId !== this.selectionRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeDetail.update((currentDetail) => {
|
||||
if (!currentDetail || currentDetail.epgMode !== 'm3u') {
|
||||
return currentDetail;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentDetail,
|
||||
epgPrograms,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<div
|
||||
class="portal-status-container"
|
||||
[class]="getStatusColor()"
|
||||
[matTooltip]="portalStatus() ?? ''"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon class="status-watermark">{{ getStatusIcon() }}</mat-icon>
|
||||
</div>
|
||||
<div class="nav-section">
|
||||
<mat-nav-list>
|
||||
<a
|
||||
class="portal-nav-link nav-item"
|
||||
mat-list-item
|
||||
routerLink="/"
|
||||
routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
[matTooltip]="'PORTALS.SIDEBAR.HOME' | translate"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon matListItemIcon>home</mat-icon>
|
||||
</a>
|
||||
</mat-nav-list>
|
||||
</div>
|
||||
<mat-divider />
|
||||
<div class="nav-section">
|
||||
<app-portal-rail-links
|
||||
variant="list"
|
||||
activeClass="active"
|
||||
[selectedSection]="selectedContentType()"
|
||||
[links]="primaryLinks()"
|
||||
/>
|
||||
</div>
|
||||
<mat-divider />
|
||||
|
||||
<div class="nav-section">
|
||||
<app-portal-rail-links
|
||||
variant="list"
|
||||
activeClass="active"
|
||||
[selectedSection]="selectedContentType()"
|
||||
[links]="secondaryLinks()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="navigation-bottom">
|
||||
<mat-divider />
|
||||
<mat-nav-list>
|
||||
@if (!isStalkerPlaylist()) {
|
||||
<a
|
||||
mat-list-item
|
||||
class="portal-nav-link nav-item action-button"
|
||||
(click)="openAccountInfo()"
|
||||
[matTooltip]="'PORTALS.SIDEBAR.ACCOUNT_INFO' | translate"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon>person</mat-icon>
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
class="portal-nav-link nav-item"
|
||||
mat-list-item
|
||||
(click)="openPlaylistInfo()"
|
||||
[matTooltip]="'PORTALS.SIDEBAR.PLAYLIST_INFO' | translate"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon matListItemIcon>playlist_play</mat-icon>
|
||||
</a>
|
||||
<mat-divider />
|
||||
<a
|
||||
class="portal-nav-link nav-item"
|
||||
mat-list-item
|
||||
(click)="openSettings()"
|
||||
[matTooltip]="'MENU.SETTINGS' | translate"
|
||||
matTooltipPosition="right"
|
||||
>
|
||||
<mat-icon matListItemIcon>settings</mat-icon>
|
||||
</a>
|
||||
</mat-nav-list>
|
||||
</div>
|
||||
@@ -0,0 +1,116 @@
|
||||
@use '../../../../../../../apps/web/src/nav-list.scss';
|
||||
|
||||
:host {
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mdc-list {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.mat-nav-list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
.portal-nav-link.mdc-list-item {
|
||||
min-height: 28px;
|
||||
height: 28px;
|
||||
padding: 0 !important;
|
||||
border-radius: 10px;
|
||||
margin: 2px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
.mdc-list-item__start {
|
||||
margin-inline-end: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
.mdc-list-item__content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon.mat-mdc-list-item-icon,
|
||||
mat-icon {
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-bottom {
|
||||
margin-top: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--app-selection-color);
|
||||
|
||||
mat-icon {
|
||||
color: var(--app-selection-color);
|
||||
}
|
||||
}
|
||||
|
||||
mat-divider {
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
margin-right: 0;
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
|
||||
a.mat-mdc-list-item.mdc-list-item {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.portal-status-container {
|
||||
app-region: drag;
|
||||
margin: -4px -4px 0;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
.portal-status-container.status-active {
|
||||
background: var(--mat-sys-primary-container);
|
||||
color: var(--mat-sys-on-primary-container);
|
||||
}
|
||||
|
||||
.portal-status-container.status-inactive,
|
||||
.portal-status-container.status-unavailable {
|
||||
background: var(--mat-sys-error-container);
|
||||
color: var(--mat-sys-on-error-container);
|
||||
}
|
||||
|
||||
.portal-status-container.status-expired {
|
||||
background: var(--mat-sys-tertiary-container);
|
||||
color: var(--mat-sys-on-tertiary-container);
|
||||
}
|
||||
|
||||
.portal-status-container .status-watermark {
|
||||
font-size: 24px !important;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
app-portal-rail-links {
|
||||
display: block;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
buildPortalRailLinks,
|
||||
PORTAL_NAVIGATION_ACTIONS,
|
||||
PortalRailSection,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { selectPlaylistById } from 'm3u-state';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
import { PortalRailLinksComponent } from './portal-rail-links.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-navigation',
|
||||
imports: [
|
||||
MatDividerModule,
|
||||
MatIcon,
|
||||
MatListModule,
|
||||
MatTooltip,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
TranslatePipe,
|
||||
PortalRailLinksComponent,
|
||||
],
|
||||
templateUrl: './navigation.component.html',
|
||||
styleUrl: './navigation.component.scss',
|
||||
})
|
||||
export class NavigationComponent {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly navigationActions = inject(PORTAL_NAVIGATION_ACTIONS);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
readonly portalStatus = input<
|
||||
'active' | 'inactive' | 'expired' | 'unavailable'
|
||||
>();
|
||||
readonly selectedContentType = input<PortalRailSection | undefined>();
|
||||
|
||||
readonly currentPlaylist = this.store.selectSignal(
|
||||
selectPlaylistById(this.activatedRoute.snapshot.params.id)
|
||||
);
|
||||
|
||||
readonly isStalkerPlaylist = computed(
|
||||
() => !!(this.currentPlaylist() as Playlist | undefined)?.macAddress
|
||||
);
|
||||
|
||||
readonly isElectron = !!window.electron;
|
||||
|
||||
readonly railLinks = computed(() => {
|
||||
const playlistId =
|
||||
(this.currentPlaylist() as Playlist | undefined)?._id ??
|
||||
this.activatedRoute.snapshot.params['id'];
|
||||
if (!playlistId) {
|
||||
return { primary: [], secondary: [] };
|
||||
}
|
||||
|
||||
return buildPortalRailLinks({
|
||||
provider: this.isStalkerPlaylist() ? 'stalker' : 'xtreams',
|
||||
playlistId,
|
||||
isElectron: this.isElectron,
|
||||
workspace: false,
|
||||
});
|
||||
});
|
||||
readonly primaryLinks = computed(() => this.railLinks().primary);
|
||||
readonly secondaryLinks = computed(() => this.railLinks().secondary);
|
||||
|
||||
getStatusColor(): string {
|
||||
if (this.isStalkerPlaylist()) return 'status-active';
|
||||
|
||||
switch (this.portalStatus()) {
|
||||
case 'active':
|
||||
return 'status-active';
|
||||
case 'inactive':
|
||||
return 'status-inactive';
|
||||
case 'expired':
|
||||
return 'status-expired';
|
||||
default:
|
||||
return 'status-unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusIcon(): string {
|
||||
if (this.isStalkerPlaylist()) return 'play_circle';
|
||||
|
||||
switch (this.portalStatus()) {
|
||||
case 'active':
|
||||
return 'check_circle';
|
||||
case 'inactive':
|
||||
return 'cancel';
|
||||
case 'expired':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
openAccountInfo(): void {
|
||||
this.navigationActions.openAccountInfo();
|
||||
}
|
||||
|
||||
openSettings(): void {
|
||||
this.navigationActions.openSettings();
|
||||
}
|
||||
|
||||
openPlaylistInfo(): void {
|
||||
this.navigationActions.openPlaylistInfo(this.currentPlaylist());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@if (variant() === 'list') {
|
||||
<mat-nav-list class="p-0 m-0">
|
||||
@for (link of links(); track link.path.join('/')) {
|
||||
<a
|
||||
class="portal-rail-link nav-item"
|
||||
mat-list-item
|
||||
[routerLink]="link.path"
|
||||
[routerLinkActive]="activeClass()"
|
||||
[routerLinkActiveOptions]="{ exact: link.exact ?? false }"
|
||||
[matTooltip]="link.tooltip"
|
||||
matTooltipPosition="right"
|
||||
[attr.aria-label]="link.tooltip"
|
||||
[attr.aria-current]="isActive(link) ? 'page' : null"
|
||||
[class.active]="activeClass() === 'active' && isActive(link)"
|
||||
[class.is-active]="
|
||||
activeClass() === 'is-active' && isActive(link)
|
||||
"
|
||||
>
|
||||
<mat-icon matListItemIcon>{{ resolveIcon(link) }}</mat-icon>
|
||||
</a>
|
||||
}
|
||||
</mat-nav-list>
|
||||
} @else {
|
||||
<nav class="flex flex-col items-center gap-2 w-full">
|
||||
@for (link of links(); track link.path.join('/')) {
|
||||
<a
|
||||
class="portal-rail-link nav-item"
|
||||
[routerLink]="link.path"
|
||||
[routerLinkActive]="activeClass()"
|
||||
[routerLinkActiveOptions]="{ exact: link.exact ?? false }"
|
||||
[matTooltip]="link.tooltip"
|
||||
[matTooltipPosition]="'right'"
|
||||
[attr.aria-label]="link.tooltip"
|
||||
[attr.aria-current]="isActive(link) ? 'page' : null"
|
||||
[class.active]="activeClass() === 'active' && isActive(link)"
|
||||
[class.is-active]="
|
||||
activeClass() === 'is-active' && isActive(link)
|
||||
"
|
||||
>
|
||||
<mat-icon>{{ resolveIcon(link) }}</mat-icon>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
@use '../../../../../../../apps/web/src/nav-list.scss';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
--rail-link-color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.p-0.m-0 {
|
||||
.portal-rail-link.mdc-list-item {
|
||||
min-height: 28px;
|
||||
height: 28px;
|
||||
padding: 0 !important;
|
||||
border-radius: 10px;
|
||||
margin: 2px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--rail-link-color);
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
.mdc-list-item__start {
|
||||
margin-inline-end: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
.mdc-list-item__content {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon.mat-mdc-list-item-icon,
|
||||
mat-icon {
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
nav.flex .portal-rail-link {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
border-radius: 14px;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--rail-link-color);
|
||||
app-region: no-drag;
|
||||
animation: rail-link-enter 0.2s ease-out both;
|
||||
}
|
||||
|
||||
@for $i from 1 through 8 {
|
||||
nav.flex .portal-rail-link:nth-child(#{$i}) {
|
||||
animation-delay: #{($i - 1) * 30}ms;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rail-link-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
nav.flex .portal-rail-link.is-active,
|
||||
nav.flex .portal-rail-link.active {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--app-selection-border),
|
||||
0 10px 20px -16px var(--app-selection-glow);
|
||||
}
|
||||
|
||||
nav.flex .portal-rail-link.is-active::after,
|
||||
nav.flex .portal-rail-link.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-selection-color);
|
||||
box-shadow: 0 0 10px var(--app-selection-glow);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import {
|
||||
IsActiveMatchOptions,
|
||||
Router,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
} from '@angular/router';
|
||||
import { PortalRailLink, PortalRailSection } from '@iptvnator/portal/shared/util';
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-rail-links',
|
||||
imports: [MatIcon, MatListModule, MatTooltip, RouterLink, RouterLinkActive],
|
||||
templateUrl: './portal-rail-links.component.html',
|
||||
styleUrl: './portal-rail-links.component.scss',
|
||||
})
|
||||
export class PortalRailLinksComponent {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly links = input<PortalRailLink[]>([]);
|
||||
readonly selectedSection = input<
|
||||
PortalRailSection | string | null | undefined
|
||||
>(null);
|
||||
readonly activeClass = input<'active' | 'is-active'>('active');
|
||||
readonly variant = input<'list' | 'rail'>('list');
|
||||
|
||||
resolveIcon(link: PortalRailLink): string {
|
||||
const normalizedPath = link.path.map((segment) => String(segment)).join('/');
|
||||
if (normalizedPath.includes('/workspace/sources')) {
|
||||
return 'playlist_play';
|
||||
}
|
||||
|
||||
return link.icon;
|
||||
}
|
||||
|
||||
isActive(link: PortalRailLink): boolean {
|
||||
const selectedSection = this.selectedSection();
|
||||
if (selectedSection && link.section) {
|
||||
return selectedSection === link.section;
|
||||
}
|
||||
|
||||
const commands = link.path.map((segment) => String(segment));
|
||||
const tree = this.router.createUrlTree(commands);
|
||||
const matchOptions: IsActiveMatchOptions = {
|
||||
paths: link.exact ? 'exact' : 'subset',
|
||||
queryParams: 'ignored',
|
||||
fragment: 'ignored',
|
||||
matrixParams: 'ignored',
|
||||
};
|
||||
return this.router.isActive(tree, matchOptions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Canonical source: libs/ui/styles/_content-grid.scss
|
||||
// This stub forwards the mixins for components that @use this file directly.
|
||||
@forward '../../../../../../../libs/ui/styles/content-grid';
|
||||
@@ -0,0 +1,3 @@
|
||||
// Canonical source: libs/ui/styles/_portal-layout.scss
|
||||
// This stub forwards the mixin for components that @use this file directly.
|
||||
@forward '../../../../../../../libs/ui/styles/portal-layout';
|
||||
@@ -0,0 +1,162 @@
|
||||
// ─── Shared skeleton / shimmer primitives ────────────────────────────────────
|
||||
// Used by grid-list (poster cards) and unified-collection (channel list rows).
|
||||
|
||||
// ─── Grid skeleton (poster cards) ────────────────────────────────────────────
|
||||
|
||||
.grid-skeleton-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grid-skeleton-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 84%,
|
||||
var(--app-widget-bg, var(--mat-sys-surface-container)) 16%
|
||||
);
|
||||
border: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 94%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.grid-skeleton-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 34px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 56%,
|
||||
var(--app-body-color, var(--mat-sys-on-surface-variant)) 44%
|
||||
);
|
||||
}
|
||||
|
||||
.grid-skeleton-title {
|
||||
margin-top: 10px;
|
||||
padding: 0 3px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.grid-skeleton-line {
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
|
||||
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
|
||||
);
|
||||
|
||||
&--primary {
|
||||
width: 92%;
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
width: 68%;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── List skeleton (channel rows) ────────────────────────────────────────────
|
||||
|
||||
.list-skeleton-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 40%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.list-skeleton-handle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 60%,
|
||||
var(--app-body-color, var(--mat-sys-on-surface-variant)) 40%
|
||||
);
|
||||
}
|
||||
|
||||
.list-skeleton-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 84%,
|
||||
var(--app-widget-bg, var(--mat-sys-surface-container)) 16%
|
||||
);
|
||||
}
|
||||
|
||||
.list-skeleton-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.list-skeleton-star {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 60%,
|
||||
var(--app-body-color, var(--mat-sys-on-surface-variant)) 40%
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shimmer animation (shared) ──────────────────────────────────────────────
|
||||
|
||||
.grid-skeleton-thumb,
|
||||
.grid-skeleton-badge,
|
||||
.grid-skeleton-line,
|
||||
.list-skeleton-handle,
|
||||
.list-skeleton-avatar,
|
||||
.list-skeleton-star,
|
||||
.list-skeleton-text .grid-skeleton-line {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 10%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-heading-color, var(--mat-sys-on-surface)) 20%,
|
||||
transparent
|
||||
)
|
||||
46%,
|
||||
transparent 76%
|
||||
);
|
||||
transform: translateX(-120%);
|
||||
animation: skeleton-shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
100% {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
app-region: drag;
|
||||
|
||||
&.sidebar-header--workspace {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
button {
|
||||
@include mat.form-field-density(-5);
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
app-region: drag;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.header-left {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.category-search {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.scrollable-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
&.no-header {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Canonical source: libs/ui/styles/_portal-sidebar.scss
|
||||
// This stub forwards the rules for components that @use this file directly.
|
||||
@forward '../../../../../../../libs/ui/styles/portal-sidebar';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
displayName: 'portal-shared-util',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/portal/shared/util',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "portal-shared-util",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/portal/shared/util/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:portal", "domain:portal-shared", "type:util"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/portal/shared/util/jest.config.ts",
|
||||
"tsConfig": "libs/portal/shared/util/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export * from './lib/logger';
|
||||
export * from './lib/channel-sort';
|
||||
export * from './lib/favorites-channel-sort';
|
||||
export * from './lib/portal-catalog-detail';
|
||||
export * from './lib/portal-catalog-facade';
|
||||
export * from './lib/portal-external-playback';
|
||||
export * from './lib/portal-player';
|
||||
export * from './lib/portal-playback-positions';
|
||||
export * from './lib/portal-navigation-actions';
|
||||
export * from './lib/portal-collection-context';
|
||||
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';
|
||||
export * from './lib/collection';
|
||||
@@ -0,0 +1,61 @@
|
||||
const CHANNEL_SORT_COLLATOR = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
export type PortalChannelSortMode = 'server' | 'name-asc' | 'name-desc';
|
||||
|
||||
export const DEFAULT_PORTAL_CHANNEL_SORT_MODE: PortalChannelSortMode = 'server';
|
||||
|
||||
export function isPortalChannelSortMode(
|
||||
value: unknown
|
||||
): value is PortalChannelSortMode {
|
||||
return value === 'server' || value === 'name-asc' || value === 'name-desc';
|
||||
}
|
||||
|
||||
export function restorePortalChannelSortMode(
|
||||
storageKey: string,
|
||||
fallback: PortalChannelSortMode = DEFAULT_PORTAL_CHANNEL_SORT_MODE
|
||||
): PortalChannelSortMode {
|
||||
const storedValue = localStorage.getItem(storageKey);
|
||||
return isPortalChannelSortMode(storedValue) ? storedValue : fallback;
|
||||
}
|
||||
|
||||
export function persistPortalChannelSortMode(
|
||||
storageKey: string,
|
||||
mode: PortalChannelSortMode
|
||||
): void {
|
||||
localStorage.setItem(storageKey, mode);
|
||||
}
|
||||
|
||||
export function getPortalChannelSortModeLabel(
|
||||
mode: PortalChannelSortMode
|
||||
): string {
|
||||
if (mode === 'name-asc') {
|
||||
return 'Name A-Z';
|
||||
}
|
||||
|
||||
if (mode === 'name-desc') {
|
||||
return 'Name Z-A';
|
||||
}
|
||||
|
||||
return 'Server Order';
|
||||
}
|
||||
|
||||
export function sortPortalChannelItems<T>(
|
||||
items: readonly T[],
|
||||
mode: PortalChannelSortMode,
|
||||
getDisplayName: (item: T) => string | null | undefined
|
||||
): readonly T[] {
|
||||
if (mode === 'server') {
|
||||
return items;
|
||||
}
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const result = CHANNEL_SORT_COLLATOR.compare(
|
||||
getDisplayName(a) ?? '',
|
||||
getDisplayName(b) ?? ''
|
||||
);
|
||||
return mode === 'name-asc' ? result : -result;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { StalkerPortalItem } from 'shared-interfaces';
|
||||
import { CollectionContentType } from './unified-collection-item.interface';
|
||||
|
||||
export interface XtreamFavoriteRow {
|
||||
readonly id: number;
|
||||
readonly category_id: number;
|
||||
readonly playlist_id: string;
|
||||
readonly playlist_name: string;
|
||||
readonly xtream_id: number;
|
||||
readonly title: string;
|
||||
readonly type: string;
|
||||
readonly poster_url?: string | null;
|
||||
readonly rating?: string | null;
|
||||
readonly added_at?: string | null;
|
||||
readonly position?: number | null;
|
||||
}
|
||||
|
||||
export function isStalkerItem(
|
||||
v: string | StalkerPortalItem
|
||||
): v is StalkerPortalItem {
|
||||
return typeof v !== 'string';
|
||||
}
|
||||
|
||||
export function stalkerContentType(
|
||||
fav: StalkerPortalItem
|
||||
): CollectionContentType {
|
||||
if (fav.movie_id) return 'movie';
|
||||
if (fav.series_id) return 'series';
|
||||
return 'live';
|
||||
}
|
||||
|
||||
export function xtreamContentType(type: string): CollectionContentType {
|
||||
if (type === 'movie' || type === 'movies') return 'movie';
|
||||
if (type === 'series') return 'series';
|
||||
return 'live';
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './unified-collection-item.interface';
|
||||
export * from './unified-favorite-channel.interface';
|
||||
export * from './scope-toggle.service';
|
||||
export * from './unified-favorites-data.service';
|
||||
export * from './unified-recent-data.service';
|
||||
export * from './stream-resolver.service';
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type CollectionScope = 'playlist' | 'all';
|
||||
|
||||
const STORAGE_PREFIX = 'collection-scope-';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ScopeToggleService {
|
||||
private readonly scopes = new Map<string, ReturnType<typeof signal<CollectionScope>>>();
|
||||
|
||||
getScope(viewKey: string): ReturnType<typeof signal<CollectionScope>> {
|
||||
if (!this.scopes.has(viewKey)) {
|
||||
const persisted = this.readFromStorage(viewKey);
|
||||
this.scopes.set(viewKey, signal<CollectionScope>(persisted));
|
||||
}
|
||||
return this.scopes.get(viewKey)!;
|
||||
}
|
||||
|
||||
setScope(viewKey: string, value: CollectionScope): void {
|
||||
const s = this.getScope(viewKey);
|
||||
s.set(value);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + viewKey, value);
|
||||
} catch {
|
||||
// storage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
private readFromStorage(viewKey: string): CollectionScope {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_PREFIX + viewKey);
|
||||
if (stored === 'all' || stored === 'playlist') return stored;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 'playlist';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
XtreamApiService,
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import { DataService, PlaylistsService } from 'services';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
import { UnifiedCollectionItem } from './unified-collection-item.interface';
|
||||
import {
|
||||
ResolvedLiveCollectionDetail,
|
||||
StreamResolverService,
|
||||
} from './stream-resolver.service';
|
||||
|
||||
describe('StreamResolverService', () => {
|
||||
let service: StreamResolverService;
|
||||
let playlistsService: { getPlaylistById: jest.Mock };
|
||||
let xtreamApi: { getShortEpg: jest.Mock };
|
||||
let xtreamUrl: { constructLiveUrl: jest.Mock };
|
||||
let dataService: { sendIpcEvent: jest.Mock };
|
||||
let stalkerSession: { makeAuthenticatedRequest: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
playlistsService = {
|
||||
getPlaylistById: jest.fn(),
|
||||
};
|
||||
xtreamApi = {
|
||||
getShortEpg: jest.fn(),
|
||||
};
|
||||
xtreamUrl = {
|
||||
constructLiveUrl: jest.fn(),
|
||||
};
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
stalkerSession = {
|
||||
makeAuthenticatedRequest: jest.fn(),
|
||||
};
|
||||
|
||||
window.electron = {
|
||||
...window.electron,
|
||||
getChannelPrograms: jest.fn(),
|
||||
} as typeof window.electron;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
StreamResolverService,
|
||||
{ provide: PlaylistsService, useValue: playlistsService },
|
||||
{ provide: XtreamApiService, useValue: xtreamApi },
|
||||
{ provide: XtreamUrlService, useValue: xtreamUrl },
|
||||
{ provide: DataService, useValue: dataService },
|
||||
{ provide: StalkerSessionService, useValue: stalkerSession },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(StreamResolverService);
|
||||
});
|
||||
|
||||
it('returns M3U live detail with the full channel and raw EPG programs', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'm3u-1',
|
||||
playlist: {
|
||||
items: [
|
||||
{
|
||||
id: 'channel-1',
|
||||
name: 'News',
|
||||
url: 'https://example.com/live.m3u8',
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: 'news-id',
|
||||
name: 'News',
|
||||
url: '',
|
||||
logo: 'news.png',
|
||||
rec: '',
|
||||
},
|
||||
http: {
|
||||
referrer: 'https://ref.example.com',
|
||||
'user-agent': 'IPTVnator',
|
||||
origin: 'https://origin.example.com',
|
||||
},
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
(window.electron.getChannelPrograms as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
start: '2026-03-26T11:00:00.000Z',
|
||||
stop: '2026-03-26T12:00:00.000Z',
|
||||
channel: 'news-id',
|
||||
title: 'Morning News',
|
||||
desc: 'Latest headlines',
|
||||
category: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const detail = await service.resolveLiveDetail({
|
||||
uid: 'm3u::m3u-1::https://example.com/live.m3u8',
|
||||
name: 'News',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'm3u-1',
|
||||
playlistName: 'M3U List',
|
||||
streamUrl: 'https://example.com/live.m3u8',
|
||||
channelId: 'channel-1',
|
||||
tvgId: 'news-id',
|
||||
logo: 'news.png',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(detail).toMatchObject<Partial<ResolvedLiveCollectionDetail>>({
|
||||
epgMode: 'm3u',
|
||||
channel: expect.objectContaining({
|
||||
id: 'channel-1',
|
||||
url: 'https://example.com/live.m3u8',
|
||||
}),
|
||||
playback: expect.objectContaining({
|
||||
streamUrl: 'https://example.com/live.m3u8',
|
||||
userAgent: 'IPTVnator',
|
||||
referer: 'https://ref.example.com',
|
||||
origin: 'https://origin.example.com',
|
||||
}),
|
||||
});
|
||||
expect(detail.epgPrograms).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('resolves M3U playback detail without blocking on channel-program lookup', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'm3u-1',
|
||||
playlist: {
|
||||
items: [
|
||||
{
|
||||
id: 'channel-1',
|
||||
name: 'News',
|
||||
url: 'https://example.com/live.m3u8',
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: 'news-id',
|
||||
name: 'News',
|
||||
url: '',
|
||||
logo: 'news.png',
|
||||
rec: '',
|
||||
},
|
||||
http: {
|
||||
referrer: 'https://ref.example.com',
|
||||
'user-agent': 'IPTVnator',
|
||||
origin: 'https://origin.example.com',
|
||||
},
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
|
||||
const detail = await service.resolveM3uPlaybackDetail({
|
||||
uid: 'm3u::m3u-1::https://example.com/live.m3u8',
|
||||
name: 'News',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'm3u-1',
|
||||
playlistName: 'M3U List',
|
||||
streamUrl: 'https://example.com/live.m3u8',
|
||||
channelId: 'channel-1',
|
||||
tvgId: 'news-id',
|
||||
logo: 'news.png',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(detail.playback.streamUrl).toBe('https://example.com/live.m3u8');
|
||||
expect(detail.epgPrograms).toEqual([]);
|
||||
expect(window.electron.getChannelPrograms).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the radio flag when M3U playback falls back to item metadata', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'm3u-1',
|
||||
playlist: {
|
||||
items: [],
|
||||
},
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
|
||||
const detail = await service.resolveM3uPlaybackDetail({
|
||||
uid: 'm3u::m3u-1::radio-channel',
|
||||
name: 'Radio Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'm3u-1',
|
||||
playlistName: 'M3U List',
|
||||
streamUrl: 'https://example.com/radio.m3u8',
|
||||
channelId: 'radio-channel',
|
||||
tvgId: 'radio-id',
|
||||
logo: 'radio.png',
|
||||
radio: 'true',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(detail.channel).toMatchObject({
|
||||
id: 'radio-channel',
|
||||
url: 'https://example.com/radio.m3u8',
|
||||
radio: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns Xtream live detail with shared EPG items', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'xtream-1',
|
||||
serverUrl: 'https://xtream.example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
xtreamUrl.constructLiveUrl.mockReturnValue(
|
||||
'https://xtream.example.com/live/1'
|
||||
);
|
||||
xtreamApi.getShortEpg.mockResolvedValue([
|
||||
{
|
||||
id: '1',
|
||||
epg_id: '',
|
||||
title: 'Xtream Show',
|
||||
description: 'Current Xtream program',
|
||||
lang: '',
|
||||
start: '2026-03-26T11:00:00.000Z',
|
||||
end: '2026-03-26T12:00:00.000Z',
|
||||
stop: '2026-03-26T12:00:00.000Z',
|
||||
channel_id: '1',
|
||||
start_timestamp: '1774522800',
|
||||
stop_timestamp: '1774526400',
|
||||
},
|
||||
]);
|
||||
|
||||
const detail = await service.resolveLiveDetail({
|
||||
uid: 'xtream::xtream-1::1',
|
||||
name: 'Xtream Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream',
|
||||
xtreamId: 1,
|
||||
logo: 'xtream.png',
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(detail.epgMode).toBe('portal');
|
||||
expect(detail.playback.streamUrl).toBe(
|
||||
'https://xtream.example.com/live/1'
|
||||
);
|
||||
expect(detail.epgItems).toHaveLength(1);
|
||||
expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(
|
||||
{
|
||||
serverUrl: 'https://xtream.example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
},
|
||||
1,
|
||||
10,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses cached empty Xtream preview EPG results instead of refetching immediately', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'xtream-1',
|
||||
serverUrl: 'https://xtream.example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
xtreamApi.getShortEpg.mockResolvedValue([]);
|
||||
|
||||
const items = [
|
||||
{
|
||||
uid: 'xtream::xtream-1::1',
|
||||
name: 'Xtream Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream',
|
||||
xtreamId: 1,
|
||||
} satisfies UnifiedCollectionItem,
|
||||
];
|
||||
|
||||
await service.loadEpgForItems(items);
|
||||
await service.loadEpgForItems(items);
|
||||
|
||||
expect(xtreamApi.getShortEpg).toHaveBeenCalledTimes(1);
|
||||
expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(
|
||||
{
|
||||
serverUrl: 'https://xtream.example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
},
|
||||
1,
|
||||
2,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('backs off repeated Xtream detail EPG failures during the cooldown window', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'xtream-1',
|
||||
serverUrl: 'https://xtream.example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
xtreamUrl.constructLiveUrl.mockReturnValue(
|
||||
'https://xtream.example.com/live/1'
|
||||
);
|
||||
xtreamApi.getShortEpg.mockRejectedValue(new Error('EPG failed'));
|
||||
|
||||
const item = {
|
||||
uid: 'xtream::xtream-1::1',
|
||||
name: 'Xtream Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream',
|
||||
xtreamId: 1,
|
||||
} satisfies UnifiedCollectionItem;
|
||||
|
||||
const firstDetail = await service.resolveLiveDetail(item);
|
||||
const secondDetail = await service.resolveLiveDetail(item);
|
||||
|
||||
expect(firstDetail.epgItems).toEqual([]);
|
||||
expect(secondDetail.epgItems).toEqual([]);
|
||||
expect(xtreamApi.getShortEpg).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to empty Xtream EPG when the provider response is too slow', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
try {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'xtream-1',
|
||||
serverUrl: 'https://xtream.example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
xtreamUrl.constructLiveUrl.mockReturnValue(
|
||||
'https://xtream.example.com/live/1'
|
||||
);
|
||||
xtreamApi.getShortEpg.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve([
|
||||
{
|
||||
id: '1',
|
||||
epg_id: '',
|
||||
title: 'Late Xtream Show',
|
||||
description: 'Delayed Xtream program',
|
||||
lang: '',
|
||||
start: '2026-03-26T11:00:00.000Z',
|
||||
end: '2026-03-26T12:00:00.000Z',
|
||||
stop: '2026-03-26T12:00:00.000Z',
|
||||
channel_id: '1',
|
||||
start_timestamp: '1774522800',
|
||||
stop_timestamp: '1774526400',
|
||||
},
|
||||
]),
|
||||
10_000
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const detailPromise = service.resolveLiveDetail({
|
||||
uid: 'xtream::xtream-1::1',
|
||||
name: 'Xtream Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream',
|
||||
xtreamId: 1,
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(3000);
|
||||
|
||||
await expect(detailPromise).resolves.toMatchObject({
|
||||
epgMode: 'portal',
|
||||
playback: expect.objectContaining({
|
||||
streamUrl: 'https://xtream.example.com/live/1',
|
||||
}),
|
||||
epgItems: [],
|
||||
});
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('loads current Stalker EPG previews for live collection rows', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
portalUrl: 'https://stalker.example.com',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
isFullStalkerPortal: false,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
js: [
|
||||
{
|
||||
id: '10',
|
||||
name: 'Stalker Live',
|
||||
descr: 'Current Stalker program',
|
||||
time: '2026-03-26T11:00:00.000Z',
|
||||
time_to: '2026-03-26T12:00:00.000Z',
|
||||
ch_id: '77',
|
||||
start_timestamp: String(Math.floor(Date.now() / 1000) - 60),
|
||||
stop_timestamp: String(Math.floor(Date.now() / 1000) + 60),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const epgMap = await service.loadEpgForItems([
|
||||
{
|
||||
uid: 'stalker::stalker-1::77',
|
||||
name: 'Stalker Channel',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker',
|
||||
stalkerId: '77',
|
||||
tvgId: '77',
|
||||
stalkerCmd: 'ffmpeg http://stalker/77',
|
||||
} satisfies UnifiedCollectionItem,
|
||||
]);
|
||||
|
||||
expect(epgMap.get('77')).toMatchObject({
|
||||
title: 'Stalker Live',
|
||||
channel: '77',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,827 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { DataService, PlaylistsService } from 'services';
|
||||
import {
|
||||
Channel,
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
Playlist,
|
||||
ResolvedPortalPlayback,
|
||||
STALKER_REQUEST,
|
||||
StalkerPortalActions,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
XtreamApiService,
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import { UnifiedCollectionItem } from './unified-collection-item.interface';
|
||||
|
||||
type PlaylistWithChannels = Playlist & {
|
||||
readonly playlist?: { readonly items?: Channel[] };
|
||||
};
|
||||
|
||||
interface StalkerCreateLinkResponse {
|
||||
readonly js?: { readonly cmd?: string };
|
||||
}
|
||||
|
||||
interface StalkerEpgEntry {
|
||||
readonly id?: string | number;
|
||||
readonly name?: string;
|
||||
readonly descr?: string;
|
||||
readonly time?: string;
|
||||
readonly time_to?: string;
|
||||
readonly ch_id?: string | number;
|
||||
readonly start_timestamp?: string | number;
|
||||
readonly stop_timestamp?: string | number;
|
||||
}
|
||||
|
||||
interface StalkerEpgResponse {
|
||||
readonly js?: StalkerEpgEntry[] | { readonly data?: StalkerEpgEntry[] };
|
||||
}
|
||||
|
||||
interface XtreamEpgCacheEntry {
|
||||
readonly data: EpgItem[];
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
export interface ResolvedLiveCollectionDetail {
|
||||
readonly playback: ResolvedPortalPlayback;
|
||||
readonly epgMode: 'm3u' | 'portal';
|
||||
readonly channel?: Channel;
|
||||
readonly epgPrograms?: EpgProgram[];
|
||||
readonly epgItems?: EpgItem[];
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StreamResolverService {
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly xtreamApi = inject(XtreamApiService);
|
||||
private readonly xtreamUrl = inject(XtreamUrlService);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly stalkerSession = inject(StalkerSessionService);
|
||||
private readonly m3uEpgTimeoutMs = 3000;
|
||||
private readonly portalEpgTimeoutMs = 3000;
|
||||
private readonly xtreamEpgCache = new Map<string, XtreamEpgCacheEntry>();
|
||||
private readonly xtreamEpgFailureTimestamps = new Map<string, number>();
|
||||
private readonly xtreamEpgCacheTtlMs = 60 * 1000;
|
||||
private readonly xtreamEpgFailureCooldownMs = 60 * 1000;
|
||||
|
||||
private async getElectronPlaylist(
|
||||
playlistId: string
|
||||
): Promise<Playlist | undefined> {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return (
|
||||
(await window.electron?.dbGetAppPlaylist?.(playlistId)) ??
|
||||
undefined
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async resolvePlayback(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<ResolvedPortalPlayback> {
|
||||
if (item.sourceType === 'm3u') {
|
||||
return (await this.resolveM3uPlaybackDetail(item)).playback;
|
||||
}
|
||||
|
||||
return (await this.resolveLiveDetail(item)).playback;
|
||||
}
|
||||
|
||||
async resolveLiveDetail(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<ResolvedLiveCollectionDetail> {
|
||||
switch (item.sourceType) {
|
||||
case 'm3u':
|
||||
return this.resolveM3uDetail(item);
|
||||
case 'xtream':
|
||||
return this.resolveXtreamDetail(item);
|
||||
case 'stalker':
|
||||
return this.resolveStalkerDetail(item);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveM3uPlaybackDetail(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<ResolvedLiveCollectionDetail> {
|
||||
return this.buildM3uDetail(item, false);
|
||||
}
|
||||
|
||||
async loadM3uProgramsForItem(
|
||||
item: UnifiedCollectionItem,
|
||||
channel?: Channel
|
||||
): Promise<EpgProgram[]> {
|
||||
const epgLookupKey = channel
|
||||
? this.getM3uEpgLookupKey(channel, item)
|
||||
: await this.getM3uEpgLookupKeyForItem(item);
|
||||
|
||||
return this.fetchM3uPrograms(epgLookupKey);
|
||||
}
|
||||
|
||||
async loadEpgItems(item: UnifiedCollectionItem): Promise<EpgItem[]> {
|
||||
const detail = await this.resolveLiveDetail(item);
|
||||
if (detail.epgItems) {
|
||||
return detail.epgItems;
|
||||
}
|
||||
|
||||
return this.mapProgramsToEpgItems(detail.epgPrograms ?? []);
|
||||
}
|
||||
|
||||
async loadEpgForItems(
|
||||
items: UnifiedCollectionItem[]
|
||||
): Promise<Map<string, EpgProgram | null>> {
|
||||
const epgMap = new Map<string, EpgProgram | null>();
|
||||
const now = Date.now();
|
||||
const xtreamByPlaylist = new Map<string, UnifiedCollectionItem[]>();
|
||||
const stalkerByPlaylist = new Map<string, UnifiedCollectionItem[]>();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.contentType !== 'live') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.sourceType === 'xtream') {
|
||||
const list = xtreamByPlaylist.get(item.playlistId) ?? [];
|
||||
list.push(item);
|
||||
xtreamByPlaylist.set(item.playlistId, list);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.sourceType === 'stalker') {
|
||||
const list = stalkerByPlaylist.get(item.playlistId) ?? [];
|
||||
list.push(item);
|
||||
stalkerByPlaylist.set(item.playlistId, list);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.sourceType === 'm3u' && item.contentType === 'live') {
|
||||
tasks.push(this.loadM3uEpg(item, epgMap, now));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [playlistId, playlistItems] of xtreamByPlaylist.entries()) {
|
||||
tasks.push(
|
||||
this.loadXtreamEpgBatch(playlistId, playlistItems, epgMap, now)
|
||||
);
|
||||
}
|
||||
|
||||
for (const [playlistId, playlistItems] of stalkerByPlaylist.entries()) {
|
||||
tasks.push(
|
||||
this.loadStalkerEpgBatch(playlistId, playlistItems, epgMap, now)
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(tasks.map((task) => task.catch(() => null)));
|
||||
return epgMap;
|
||||
}
|
||||
|
||||
private async resolveM3uDetail(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<ResolvedLiveCollectionDetail> {
|
||||
return this.buildM3uDetail(item, true);
|
||||
}
|
||||
|
||||
private async resolveXtreamDetail(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<ResolvedLiveCollectionDetail> {
|
||||
const playback = await this.resolveXtream(item);
|
||||
const epgItems = await this.withFallbackTimeout(
|
||||
this.loadXtreamEpgItems(item),
|
||||
this.portalEpgTimeoutMs,
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
playback,
|
||||
epgMode: 'portal',
|
||||
epgItems,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveStalkerDetail(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<ResolvedLiveCollectionDetail> {
|
||||
const playback = await this.resolveStalker(item);
|
||||
const epgItems = await this.withFallbackTimeout(
|
||||
this.loadStalkerEpgItems(item, 10),
|
||||
this.portalEpgTimeoutMs,
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
playback,
|
||||
epgMode: 'portal',
|
||||
epgItems,
|
||||
};
|
||||
}
|
||||
|
||||
private buildM3uPlayback(
|
||||
channel: Channel,
|
||||
playlist?: Playlist
|
||||
): ResolvedPortalPlayback {
|
||||
const userAgent =
|
||||
channel.http?.['user-agent']?.trim() || playlist?.userAgent;
|
||||
const referer = channel.http?.referrer?.trim() || playlist?.referrer;
|
||||
const origin = channel.http?.origin?.trim() || playlist?.origin;
|
||||
const headers: Record<string, string> = {};
|
||||
if (userAgent) {
|
||||
headers['User-Agent'] = userAgent;
|
||||
}
|
||||
if (referer) {
|
||||
headers['Referer'] = referer;
|
||||
}
|
||||
if (origin) {
|
||||
headers['Origin'] = origin;
|
||||
}
|
||||
|
||||
return {
|
||||
streamUrl: channel.url ?? '',
|
||||
title: channel.name,
|
||||
thumbnail: channel.tvg?.logo ?? null,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
userAgent,
|
||||
referer,
|
||||
origin,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveXtream(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<ResolvedPortalPlayback> {
|
||||
const creds = await this.getXtreamCredentials(item.playlistId);
|
||||
if (!creds || item.xtreamId == null) {
|
||||
throw new Error('Missing Xtream credentials');
|
||||
}
|
||||
|
||||
const streamUrl = this.xtreamUrl.constructLiveUrl(creds, item.xtreamId);
|
||||
|
||||
return {
|
||||
streamUrl,
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveStalker(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<ResolvedPortalPlayback> {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(item.playlistId)
|
||||
)) as Playlist | undefined;
|
||||
const portalUrl =
|
||||
item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
|
||||
const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? '';
|
||||
const params = {
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
cmd: item.stalkerCmd ?? '',
|
||||
type: 'itv' as const,
|
||||
disable_ad: '0',
|
||||
download: '0',
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
let response: StalkerCreateLinkResponse | undefined;
|
||||
if (playlist?.isFullStalkerPortal && playlist) {
|
||||
response = await this.stalkerSession.makeAuthenticatedRequest(
|
||||
playlist,
|
||||
params
|
||||
);
|
||||
} else {
|
||||
response = await this.dataService.sendIpcEvent(STALKER_REQUEST, {
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
const rawCmd = response?.js?.cmd ?? '';
|
||||
|
||||
return {
|
||||
streamUrl: this.normalizeStalkerCmd(rawCmd),
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadXtreamEpgItems(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<EpgItem[]> {
|
||||
try {
|
||||
if (item.xtreamId == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const creds = await this.getXtreamCredentials(item.playlistId);
|
||||
if (!creds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await this.fetchXtreamEpgItems(
|
||||
item.playlistId,
|
||||
creds,
|
||||
item.xtreamId,
|
||||
10
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async loadStalkerEpgItems(
|
||||
item: UnifiedCollectionItem,
|
||||
size: number
|
||||
): Promise<EpgItem[]> {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(item.playlistId)
|
||||
)) as Playlist | undefined;
|
||||
const channelId = String(
|
||||
item.stalkerId ??
|
||||
(item.stalkerItem as Record<string, unknown> | undefined)?.[
|
||||
'id'
|
||||
] ??
|
||||
''
|
||||
).trim();
|
||||
|
||||
if (!playlist || !channelId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.fetchStalkerShortEpg(playlist, channelId, size);
|
||||
}
|
||||
|
||||
private async getXtreamCredentials(playlistId: string) {
|
||||
const playlist =
|
||||
(await this.getElectronPlaylist(playlistId)) ??
|
||||
((await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(playlistId)
|
||||
)) as Playlist | undefined);
|
||||
|
||||
if (!playlist?.serverUrl || !playlist.username || !playlist.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadM3uEpg(
|
||||
item: UnifiedCollectionItem,
|
||||
epgMap: Map<string, EpgProgram | null>,
|
||||
now: number
|
||||
): Promise<void> {
|
||||
const epgLookupKey = item.tvgId?.trim() || item.name?.trim();
|
||||
if (!epgLookupKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const programs = await this.fetchM3uPrograms(epgLookupKey);
|
||||
epgMap.set(epgLookupKey, this.findCurrentProgram(programs, now));
|
||||
}
|
||||
|
||||
private async buildM3uDetail(
|
||||
item: UnifiedCollectionItem,
|
||||
includePrograms: boolean
|
||||
): Promise<ResolvedLiveCollectionDetail> {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(item.playlistId)
|
||||
)) as PlaylistWithChannels | undefined;
|
||||
const channel =
|
||||
this.findM3uChannel(playlist?.playlist?.items ?? [], item) ??
|
||||
this.buildFallbackM3uChannel(item);
|
||||
const epgPrograms = includePrograms
|
||||
? await this.fetchM3uPrograms(
|
||||
this.getM3uEpgLookupKey(channel, item)
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
playback: this.buildM3uPlayback(channel, playlist),
|
||||
epgMode: 'm3u',
|
||||
channel,
|
||||
epgPrograms,
|
||||
};
|
||||
}
|
||||
|
||||
private async getM3uEpgLookupKeyForItem(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<string | null> {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(item.playlistId)
|
||||
)) as PlaylistWithChannels | undefined;
|
||||
const channel =
|
||||
this.findM3uChannel(playlist?.playlist?.items ?? [], item) ??
|
||||
this.buildFallbackM3uChannel(item);
|
||||
|
||||
return this.getM3uEpgLookupKey(channel, item);
|
||||
}
|
||||
|
||||
private async fetchM3uPrograms(
|
||||
epgLookupKey?: string | null
|
||||
): Promise<EpgProgram[]> {
|
||||
if (!window.electron?.getChannelPrograms || !epgLookupKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.withFallbackTimeout(
|
||||
window.electron.getChannelPrograms(epgLookupKey),
|
||||
this.m3uEpgTimeoutMs,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
private async withFallbackTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
fallback: T
|
||||
): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
const timeoutId = setTimeout(() => resolve(fallback), timeoutMs);
|
||||
|
||||
promise
|
||||
.then((value) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(value);
|
||||
})
|
||||
.catch(() => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(fallback);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async loadXtreamEpgBatch(
|
||||
playlistId: string,
|
||||
channels: UnifiedCollectionItem[],
|
||||
epgMap: Map<string, EpgProgram | null>,
|
||||
now: number
|
||||
): Promise<void> {
|
||||
const creds = await this.getXtreamCredentials(playlistId);
|
||||
if (!creds) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
channels.map(async (channel) => {
|
||||
if (!channel.xtreamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await this.fetchXtreamEpgItems(
|
||||
playlistId,
|
||||
creds,
|
||||
channel.xtreamId,
|
||||
2
|
||||
);
|
||||
const nowSeconds = Math.floor(now / 1000);
|
||||
const currentItem =
|
||||
items.find(
|
||||
(item) =>
|
||||
Number(item.start_timestamp) <= nowSeconds &&
|
||||
nowSeconds < Number(item.stop_timestamp)
|
||||
) ?? null;
|
||||
const epgKey =
|
||||
channel.tvgId?.trim() || channel.name?.trim();
|
||||
|
||||
if (!epgKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
epgMap.set(
|
||||
epgKey,
|
||||
currentItem
|
||||
? {
|
||||
start: new Date(
|
||||
Number(currentItem.start_timestamp) * 1000
|
||||
).toISOString(),
|
||||
stop: new Date(
|
||||
Number(currentItem.stop_timestamp) * 1000
|
||||
).toISOString(),
|
||||
channel: String(channel.xtreamId),
|
||||
title: currentItem.title,
|
||||
desc: currentItem.description ?? null,
|
||||
category: null,
|
||||
}
|
||||
: null
|
||||
);
|
||||
} catch {
|
||||
const epgKey =
|
||||
channel.tvgId?.trim() || channel.name?.trim();
|
||||
if (epgKey) {
|
||||
epgMap.set(epgKey, null);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private getXtreamEpgCacheKey(
|
||||
playlistId: string,
|
||||
streamId: number,
|
||||
limit: number
|
||||
): string {
|
||||
return `${playlistId}:${streamId}:${limit}`;
|
||||
}
|
||||
|
||||
private getCachedXtreamEpgItems(cacheKey: string): EpgItem[] | null {
|
||||
const entry = this.xtreamEpgCache.get(cacheKey);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() - entry.timestamp > this.xtreamEpgCacheTtlMs) {
|
||||
this.xtreamEpgCache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
private isXtreamEpgFailureCoolingDown(cacheKey: string): boolean {
|
||||
const timestamp = this.xtreamEpgFailureTimestamps.get(cacheKey);
|
||||
if (timestamp == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Date.now() - timestamp > this.xtreamEpgFailureCooldownMs) {
|
||||
this.xtreamEpgFailureTimestamps.delete(cacheKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async fetchXtreamEpgItems(
|
||||
playlistId: string,
|
||||
credentials: {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
},
|
||||
streamId: number,
|
||||
limit: number
|
||||
): Promise<EpgItem[]> {
|
||||
const cacheKey = this.getXtreamEpgCacheKey(playlistId, streamId, limit);
|
||||
const cached = this.getCachedXtreamEpgItems(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (this.isXtreamEpgFailureCoolingDown(cacheKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await this.xtreamApi.getShortEpg(
|
||||
credentials,
|
||||
streamId,
|
||||
limit,
|
||||
{
|
||||
suppressErrorLog: true,
|
||||
}
|
||||
);
|
||||
|
||||
this.xtreamEpgCache.set(cacheKey, {
|
||||
data: items,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.xtreamEpgFailureTimestamps.delete(cacheKey);
|
||||
return items;
|
||||
} catch {
|
||||
this.xtreamEpgFailureTimestamps.set(cacheKey, Date.now());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async loadStalkerEpgBatch(
|
||||
playlistId: string,
|
||||
channels: UnifiedCollectionItem[],
|
||||
epgMap: Map<string, EpgProgram | null>,
|
||||
now: number
|
||||
): Promise<void> {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(playlistId)
|
||||
)) as Playlist | undefined;
|
||||
|
||||
if (!playlist) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
channels.map(async (channel) => {
|
||||
const channelId = String(channel.stalkerId ?? '').trim();
|
||||
const epgKey =
|
||||
channel.tvgId?.trim() || channelId || channel.name?.trim();
|
||||
|
||||
if (!channelId || !epgKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await this.fetchStalkerShortEpg(
|
||||
playlist,
|
||||
channelId,
|
||||
1
|
||||
);
|
||||
epgMap.set(
|
||||
epgKey,
|
||||
items.length > 0
|
||||
? this.toPreviewProgram(items[0], channelId, now)
|
||||
: null
|
||||
);
|
||||
} catch {
|
||||
epgMap.set(epgKey, null);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchStalkerShortEpg(
|
||||
playlist: Playlist,
|
||||
channelId: string,
|
||||
size: number
|
||||
): Promise<EpgItem[]> {
|
||||
const params = {
|
||||
action: StalkerPortalActions.GetShortEpg,
|
||||
type: 'itv',
|
||||
ch_id: channelId,
|
||||
size: String(size),
|
||||
};
|
||||
|
||||
let response: StalkerEpgResponse;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
response = await this.stalkerSession.makeAuthenticatedRequest(
|
||||
playlist,
|
||||
params
|
||||
);
|
||||
} else {
|
||||
response = await this.dataService.sendIpcEvent(STALKER_REQUEST, {
|
||||
url: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
const epgData = Array.isArray(response?.js)
|
||||
? response.js
|
||||
: (response?.js?.data ?? []);
|
||||
|
||||
return epgData.map((item) => ({
|
||||
id: String(item.id ?? ''),
|
||||
epg_id: '',
|
||||
title: item.name ?? '',
|
||||
description: item.descr ?? '',
|
||||
lang: '',
|
||||
start: item.time ?? '',
|
||||
end: item.time_to ?? '',
|
||||
stop: item.time_to ?? '',
|
||||
channel_id: String(item.ch_id ?? channelId),
|
||||
start_timestamp: String(item.start_timestamp ?? ''),
|
||||
stop_timestamp: String(item.stop_timestamp ?? ''),
|
||||
}));
|
||||
}
|
||||
|
||||
private findM3uChannel(
|
||||
channels: Channel[],
|
||||
item: UnifiedCollectionItem
|
||||
): Channel | undefined {
|
||||
return channels.find(
|
||||
(channel) =>
|
||||
(item.streamUrl && channel.url === item.streamUrl) ||
|
||||
(item.channelId && channel.id === item.channelId)
|
||||
);
|
||||
}
|
||||
|
||||
private buildFallbackM3uChannel(item: UnifiedCollectionItem): Channel {
|
||||
return {
|
||||
id: item.channelId ?? item.uid.split('::')[2] ?? '',
|
||||
name: item.name,
|
||||
url: item.streamUrl ?? '',
|
||||
tvg: {
|
||||
id: item.tvgId ?? '',
|
||||
name: item.name,
|
||||
url: '',
|
||||
logo: item.logo ?? '',
|
||||
rec: '',
|
||||
},
|
||||
group: { title: '' },
|
||||
http: {
|
||||
referrer: '',
|
||||
'user-agent': '',
|
||||
origin: '',
|
||||
},
|
||||
radio: item.radio ?? 'false',
|
||||
epgParams: '',
|
||||
};
|
||||
}
|
||||
|
||||
private getM3uEpgLookupKey(
|
||||
channel: Channel | undefined,
|
||||
item: UnifiedCollectionItem
|
||||
): string {
|
||||
return (
|
||||
channel?.tvg?.id?.trim() ||
|
||||
item.tvgId?.trim() ||
|
||||
channel?.tvg?.name?.trim() ||
|
||||
channel?.name?.trim() ||
|
||||
item.name?.trim() ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
private findCurrentProgram(
|
||||
programs: EpgProgram[],
|
||||
now: number
|
||||
): EpgProgram | null {
|
||||
return (
|
||||
programs.find((program) => {
|
||||
const start = new Date(program.start).getTime();
|
||||
const stop = new Date(program.stop).getTime();
|
||||
return start <= now && now < stop;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private toPreviewProgram(
|
||||
item: EpgItem,
|
||||
channelId: string | number,
|
||||
now: number
|
||||
): EpgProgram | null {
|
||||
const startTimestamp = Number(item.start_timestamp);
|
||||
const stopTimestamp = Number(item.stop_timestamp);
|
||||
|
||||
if (
|
||||
Number.isFinite(startTimestamp) &&
|
||||
Number.isFinite(stopTimestamp) &&
|
||||
startTimestamp > 0 &&
|
||||
stopTimestamp > 0
|
||||
) {
|
||||
const nowSeconds = Math.floor(now / 1000);
|
||||
if (nowSeconds < startTimestamp || nowSeconds >= stopTimestamp) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: item.start,
|
||||
stop: item.stop || item.end,
|
||||
channel: String(channelId),
|
||||
title: item.title,
|
||||
desc: item.description || null,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapProgramsToEpgItems(programs: EpgProgram[]): EpgItem[] {
|
||||
return programs.map((program, index) => ({
|
||||
id: String(index),
|
||||
epg_id: program.channel,
|
||||
title: program.title,
|
||||
lang: 'en',
|
||||
start: program.start,
|
||||
end: program.stop,
|
||||
stop: program.stop,
|
||||
description: program.desc ?? '',
|
||||
channel_id: program.channel,
|
||||
start_timestamp: String(
|
||||
Math.floor(new Date(program.start).getTime() / 1000)
|
||||
),
|
||||
stop_timestamp: String(
|
||||
Math.floor(new Date(program.stop).getTime() / 1000)
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
private normalizeStalkerCmd(value: string): string {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const splitAt = trimmed.indexOf(' ');
|
||||
if (splitAt > 0) {
|
||||
const candidate = trimmed.slice(splitAt + 1).trim();
|
||||
if (
|
||||
candidate.startsWith('http://') ||
|
||||
candidate.startsWith('https://')
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Unified interface for displaying favorites or recently-viewed items
|
||||
* from any source (M3U, Xtream, Stalker) in one list.
|
||||
*
|
||||
* Extends the previous live-only UnifiedFavoriteChannel to cover
|
||||
* all content types: live TV, movies, and series.
|
||||
*/
|
||||
export type CollectionSourceType = 'm3u' | 'xtream' | 'stalker';
|
||||
export type CollectionContentType = 'live' | 'movie' | 'series';
|
||||
|
||||
export interface UnifiedCollectionItem {
|
||||
/** Unique identifier: `{sourceType}::{playlistId}::{sourceItemId}` */
|
||||
uid: string;
|
||||
|
||||
/** Display name */
|
||||
name: string;
|
||||
|
||||
/** Content type — determines which tab / layout to use */
|
||||
contentType: CollectionContentType;
|
||||
|
||||
/** Source type — determines how to resolve playback */
|
||||
sourceType: CollectionSourceType;
|
||||
|
||||
/** Parent playlist ID */
|
||||
playlistId: string;
|
||||
|
||||
/** Human-readable playlist name */
|
||||
playlistName: string;
|
||||
|
||||
/** Logo / thumbnail URL (live channels) */
|
||||
logo?: string | null;
|
||||
|
||||
/** Poster image URL (movies / series) */
|
||||
posterUrl?: string | null;
|
||||
|
||||
/** Direct stream URL (M3U channels only) */
|
||||
streamUrl?: string;
|
||||
|
||||
/** Stable live-source EPG lookup key */
|
||||
tvgId?: string;
|
||||
|
||||
/** M3U playlist channel id used to rehydrate the full channel object */
|
||||
channelId?: string;
|
||||
|
||||
/** M3U radio flag used to preserve the dedicated radio player layout */
|
||||
radio?: string;
|
||||
|
||||
/** Xtream numeric stream ID */
|
||||
xtreamId?: number;
|
||||
|
||||
/** Xtream DB content id — used for reorder / remove IPC */
|
||||
contentId?: number;
|
||||
|
||||
/** Stalker cmd for stream resolution */
|
||||
stalkerId?: string | number;
|
||||
stalkerCmd?: string;
|
||||
stalkerPortalUrl?: string;
|
||||
stalkerMacAddress?: string;
|
||||
|
||||
/** Category ID for VOD/Series navigation */
|
||||
categoryId?: string | number;
|
||||
|
||||
/** Content rating (VOD) */
|
||||
rating?: string;
|
||||
|
||||
/** ISO timestamp when added to favorites */
|
||||
addedAt?: string;
|
||||
|
||||
/** Display order (favorites) */
|
||||
position?: number;
|
||||
|
||||
/** ISO timestamp when viewed (recently-viewed) */
|
||||
viewedAt?: string;
|
||||
|
||||
/** Original stalker item for detail navigation */
|
||||
stalkerItem?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable UID string for a unified collection item.
|
||||
*/
|
||||
export function buildCollectionUid(
|
||||
sourceType: CollectionSourceType,
|
||||
playlistId: string,
|
||||
sourceItemId: string | number
|
||||
): string {
|
||||
return `${sourceType}::${playlistId}::${sourceItemId}`;
|
||||
}
|
||||
|
||||
export function buildXtreamCollectionUid(
|
||||
playlistId: string,
|
||||
contentType: CollectionContentType,
|
||||
xtreamId: string | number
|
||||
): string {
|
||||
return buildCollectionUid('xtream', playlistId, `${contentType}:${xtreamId}`);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Unified interface for displaying live TV favorites from any source
|
||||
* (M3U playlists, Xtream Codes portals, Stalker portals) in one list.
|
||||
*/
|
||||
export type FavoriteSourceType = 'm3u' | 'xtream' | 'stalker';
|
||||
|
||||
export interface UnifiedFavoriteChannel {
|
||||
/**
|
||||
* Unique identifier within the global-favorites list.
|
||||
* Format: `{sourceType}::{playlistId}::{sourceItemId}`
|
||||
*/
|
||||
uid: string;
|
||||
|
||||
/** Display name of the channel */
|
||||
name: string;
|
||||
|
||||
/** Logo / thumbnail URL */
|
||||
logo: string | null;
|
||||
|
||||
/** Source type – determines how to resolve the stream URL */
|
||||
sourceType: FavoriteSourceType;
|
||||
|
||||
/** ID of the parent playlist */
|
||||
playlistId: string;
|
||||
|
||||
/** Human-readable playlist name */
|
||||
playlistName: string;
|
||||
|
||||
/** Direct stream URL (M3U channels only) */
|
||||
streamUrl?: string;
|
||||
|
||||
/** Xtream numeric stream ID (Xtream channels only) */
|
||||
xtreamId?: number;
|
||||
|
||||
/**
|
||||
* TVG id for M3U EPG lookup.
|
||||
* For Xtream channels, also used for short-EPG lookup.
|
||||
*/
|
||||
tvgId?: string;
|
||||
|
||||
/**
|
||||
* The stalker cmd value needed to resolve the stream URL.
|
||||
* Stored for Stalker favorites that are live channels.
|
||||
*/
|
||||
stalkerCmd?: string;
|
||||
|
||||
/** ISO timestamp when added to favorites */
|
||||
addedAt: string;
|
||||
|
||||
/**
|
||||
* Display order within the unified list.
|
||||
* Lower values appear first. 0 = unset (sort by addedAt desc).
|
||||
*/
|
||||
position: number;
|
||||
|
||||
/** Xtream DB content id — used for REORDER IPC call */
|
||||
contentId?: number;
|
||||
|
||||
/** Stalker portal credentials needed to resolve the live stream */
|
||||
stalkerPortalUrl?: string;
|
||||
stalkerMacAddress?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable UID string for a unified favorite channel.
|
||||
*/
|
||||
export function buildFavoriteUid(
|
||||
sourceType: FavoriteSourceType,
|
||||
playlistId: string,
|
||||
sourceItemId: string | number
|
||||
): string {
|
||||
return `${sourceType}::${playlistId}::${sourceItemId}`;
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import {
|
||||
Channel,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
StalkerPortalItem,
|
||||
} from 'shared-interfaces';
|
||||
import { UnifiedCollectionItem } from './unified-collection-item.interface';
|
||||
import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
|
||||
|
||||
describe('UnifiedFavoritesDataService', () => {
|
||||
let service: UnifiedFavoritesDataService;
|
||||
let electronApi: {
|
||||
dbGetAllGlobalFavorites: jest.Mock;
|
||||
dbRemoveFavorite: jest.Mock;
|
||||
};
|
||||
let databaseService: {
|
||||
getAllGlobalFavorites: jest.Mock;
|
||||
getFavorites: jest.Mock;
|
||||
};
|
||||
let playlistsService: {
|
||||
getPlaylistById: jest.Mock;
|
||||
setFavorites: jest.Mock;
|
||||
setPortalFavorites: jest.Mock;
|
||||
};
|
||||
|
||||
const m3uChannels: Channel[] = [
|
||||
{
|
||||
id: 'channel-1',
|
||||
name: 'Channel One',
|
||||
url: 'https://example.com/1.m3u8',
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: 'one',
|
||||
name: 'Channel One',
|
||||
url: '',
|
||||
logo: 'one.png',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
},
|
||||
{
|
||||
id: 'channel-2',
|
||||
name: 'Channel Two',
|
||||
url: 'https://example.com/2.m3u8',
|
||||
group: { title: 'Sports' },
|
||||
tvg: {
|
||||
id: 'two',
|
||||
name: 'Channel Two',
|
||||
url: '',
|
||||
logo: 'two.png',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'true',
|
||||
epgParams: '',
|
||||
},
|
||||
];
|
||||
|
||||
const stalkerFavorites: StalkerPortalItem[] = [
|
||||
{
|
||||
id: '101',
|
||||
title: 'Stalker One',
|
||||
category_id: 'itv',
|
||||
cmd: 'ffmpeg http://stalker/101',
|
||||
logo: 'one.png',
|
||||
added_at: '2026-03-26T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '202',
|
||||
title: 'Stalker Two',
|
||||
category_id: 'itv',
|
||||
cmd: 'ffmpeg http://stalker/202',
|
||||
logo: 'two.png',
|
||||
added_at: '2026-03-26T11:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
electronApi = {
|
||||
dbGetAllGlobalFavorites: jest.fn(),
|
||||
dbRemoveFavorite: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: electronApi as Window['electron'],
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
playlistsService = {
|
||||
getPlaylistById: jest.fn(),
|
||||
setFavorites: jest.fn().mockReturnValue(of({})),
|
||||
setPortalFavorites: jest.fn().mockReturnValue(of({})),
|
||||
};
|
||||
databaseService = {
|
||||
getAllGlobalFavorites: jest.fn().mockResolvedValue([]),
|
||||
getFavorites: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
UnifiedFavoritesDataService,
|
||||
{
|
||||
provide: Store,
|
||||
useValue: {
|
||||
select: jest.fn(() =>
|
||||
of([
|
||||
{
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U List',
|
||||
favorites: [
|
||||
'https://example.com/2.m3u8',
|
||||
'channel-1',
|
||||
],
|
||||
},
|
||||
{
|
||||
_id: 'stalker-1',
|
||||
title: 'Stalker List',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
favorites: stalkerFavorites,
|
||||
},
|
||||
] satisfies PlaylistMeta[])
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: databaseService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsService,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(UnifiedFavoritesDataService);
|
||||
});
|
||||
|
||||
it('maps global Xtream favorites across live, movie, and series content types', async () => {
|
||||
databaseService.getAllGlobalFavorites.mockResolvedValue([
|
||||
{
|
||||
id: 10,
|
||||
category_id: 1,
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream One',
|
||||
xtream_id: 101,
|
||||
title: 'Live One',
|
||||
type: 'live',
|
||||
poster_url: 'live.png',
|
||||
added_at: '2026-03-26T09:00:00.000Z',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
category_id: 2,
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream One',
|
||||
xtream_id: 102,
|
||||
title: 'Movie One',
|
||||
type: 'movie',
|
||||
poster_url: 'movie.png',
|
||||
added_at: '2026-03-26T08:00:00.000Z',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
category_id: 3,
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream One',
|
||||
xtream_id: 103,
|
||||
title: 'Series One',
|
||||
type: 'series',
|
||||
poster_url: 'series.png',
|
||||
added_at: '2026-03-26T07:00:00.000Z',
|
||||
position: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
const items = await service.getFavorites('all');
|
||||
|
||||
expect(items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'Live One',
|
||||
contentType: 'live',
|
||||
logo: 'live.png',
|
||||
posterUrl: null,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'Movie One',
|
||||
contentType: 'movie',
|
||||
logo: null,
|
||||
posterUrl: 'movie.png',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'Series One',
|
||||
contentType: 'series',
|
||||
logo: null,
|
||||
posterUrl: 'series.png',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves persisted M3U favorites order when extracting playlist favorites', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'm3u-1',
|
||||
favorites: [
|
||||
'https://example.com/2.m3u8',
|
||||
'channel-1',
|
||||
],
|
||||
playlist: {
|
||||
items: m3uChannels,
|
||||
},
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
|
||||
const items = await service.getFavorites('playlist', 'm3u-1', 'm3u');
|
||||
|
||||
expect(items.map((item) => item.streamUrl)).toEqual([
|
||||
'https://example.com/2.m3u8',
|
||||
'https://example.com/1.m3u8',
|
||||
]);
|
||||
expect(items[1].channelId).toBe('channel-1');
|
||||
expect(items[0].radio).toBe('true');
|
||||
});
|
||||
|
||||
it('persists M3U playlist reorders through setFavorites', async () => {
|
||||
const reorderedItems = [
|
||||
{
|
||||
uid: 'm3u::m3u-1::https://example.com/2.m3u8',
|
||||
name: 'Channel Two',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'm3u-1',
|
||||
playlistName: 'M3U List',
|
||||
streamUrl: 'https://example.com/2.m3u8',
|
||||
channelId: 'channel-2',
|
||||
},
|
||||
{
|
||||
uid: 'm3u::m3u-1::https://example.com/1.m3u8',
|
||||
name: 'Channel One',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'm3u-1',
|
||||
playlistName: 'M3U List',
|
||||
streamUrl: 'https://example.com/1.m3u8',
|
||||
channelId: 'channel-1',
|
||||
},
|
||||
] satisfies UnifiedCollectionItem[];
|
||||
|
||||
await service.reorder(reorderedItems, {
|
||||
scope: 'playlist',
|
||||
playlistId: 'm3u-1',
|
||||
portalType: 'm3u',
|
||||
});
|
||||
|
||||
expect(playlistsService.setFavorites).toHaveBeenCalledWith('m3u-1', [
|
||||
'https://example.com/2.m3u8',
|
||||
'https://example.com/1.m3u8',
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists Stalker playlist reorders through setPortalFavorites', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
favorites: stalkerFavorites,
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
|
||||
await service.reorder(
|
||||
[
|
||||
{
|
||||
uid: 'stalker::stalker-1::202',
|
||||
name: 'Stalker Two',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker List',
|
||||
stalkerId: '202',
|
||||
},
|
||||
{
|
||||
uid: 'stalker::stalker-1::101',
|
||||
name: 'Stalker One',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker List',
|
||||
stalkerId: '101',
|
||||
},
|
||||
] satisfies UnifiedCollectionItem[],
|
||||
{
|
||||
scope: 'playlist',
|
||||
playlistId: 'stalker-1',
|
||||
portalType: 'stalker',
|
||||
}
|
||||
);
|
||||
|
||||
expect(playlistsService.setPortalFavorites).toHaveBeenCalledWith(
|
||||
'stalker-1',
|
||||
[stalkerFavorites[1], stalkerFavorites[0]]
|
||||
);
|
||||
});
|
||||
|
||||
it('clears M3U favorites once per playlist with the remaining favorites preserved', async () => {
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'm3u-1',
|
||||
favorites: [
|
||||
'https://example.com/2.m3u8',
|
||||
'channel-1',
|
||||
'https://example.com/3.m3u8',
|
||||
],
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
|
||||
await service.clearFavorites([
|
||||
{
|
||||
uid: 'm3u::m3u-1::https://example.com/2.m3u8',
|
||||
name: 'Channel Two',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'm3u-1',
|
||||
playlistName: 'M3U List',
|
||||
streamUrl: 'https://example.com/2.m3u8',
|
||||
channelId: 'channel-2',
|
||||
},
|
||||
{
|
||||
uid: 'm3u::m3u-1::channel-1',
|
||||
name: 'Channel One',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'm3u-1',
|
||||
playlistName: 'M3U List',
|
||||
streamUrl: 'https://example.com/1.m3u8',
|
||||
channelId: 'channel-1',
|
||||
},
|
||||
] satisfies UnifiedCollectionItem[]);
|
||||
|
||||
expect(playlistsService.setFavorites).toHaveBeenCalledTimes(1);
|
||||
expect(playlistsService.setFavorites).toHaveBeenCalledWith('m3u-1', [
|
||||
'https://example.com/3.m3u8',
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears Stalker favorites once per playlist with the remaining favorites preserved', async () => {
|
||||
const remainingFavorite: StalkerPortalItem = {
|
||||
id: '303',
|
||||
title: 'Stalker Three',
|
||||
category_id: 'itv',
|
||||
cmd: 'ffmpeg http://stalker/303',
|
||||
logo: 'three.png',
|
||||
added_at: '2026-03-26T12:00:00.000Z',
|
||||
};
|
||||
playlistsService.getPlaylistById.mockReturnValue(
|
||||
of({
|
||||
_id: 'stalker-1',
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
favorites: [...stalkerFavorites, remainingFavorite],
|
||||
} satisfies Partial<Playlist>)
|
||||
);
|
||||
|
||||
await service.clearFavorites([
|
||||
{
|
||||
uid: 'stalker::stalker-1::101',
|
||||
name: 'Stalker One',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker List',
|
||||
stalkerId: '101',
|
||||
},
|
||||
{
|
||||
uid: 'stalker::stalker-1::202',
|
||||
name: 'Stalker Two',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'stalker-1',
|
||||
playlistName: 'Stalker List',
|
||||
stalkerId: '202',
|
||||
},
|
||||
] satisfies UnifiedCollectionItem[]);
|
||||
|
||||
expect(playlistsService.setPortalFavorites).toHaveBeenCalledTimes(1);
|
||||
expect(playlistsService.setPortalFavorites).toHaveBeenCalledWith(
|
||||
'stalker-1',
|
||||
[remainingFavorite]
|
||||
);
|
||||
});
|
||||
|
||||
it('clears Xtream favorites through the bulk removal path', async () => {
|
||||
await service.clearFavorites([
|
||||
{
|
||||
uid: 'xtream::xtream-1::101',
|
||||
name: 'Xtream One',
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream One',
|
||||
xtreamId: 101,
|
||||
contentId: 10,
|
||||
},
|
||||
{
|
||||
uid: 'xtream::xtream-1::102',
|
||||
name: 'Xtream Two',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream One',
|
||||
xtreamId: 102,
|
||||
contentId: 11,
|
||||
},
|
||||
] satisfies UnifiedCollectionItem[]);
|
||||
|
||||
expect(electronApi.dbRemoveFavorite).toHaveBeenCalledTimes(2);
|
||||
expect(electronApi.dbRemoveFavorite).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
10,
|
||||
'xtream-1'
|
||||
);
|
||||
expect(electronApi.dbRemoveFavorite).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
11,
|
||||
'xtream-1'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,552 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { firstValueFrom, map } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import {
|
||||
Channel,
|
||||
extractStalkerItemId,
|
||||
extractStalkerItemPoster,
|
||||
extractStalkerItemTitle,
|
||||
extractStalkerItemType,
|
||||
normalizeStalkerDate,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
StalkerPortalItem,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
buildCollectionUid,
|
||||
buildXtreamCollectionUid,
|
||||
UnifiedCollectionItem,
|
||||
} from './unified-collection-item.interface';
|
||||
import { CollectionScope } from './scope-toggle.service';
|
||||
import {
|
||||
isStalkerItem,
|
||||
xtreamContentType,
|
||||
XtreamFavoriteRow,
|
||||
} from './collection-helpers';
|
||||
|
||||
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
|
||||
|
||||
type PlaylistWithChannels = Playlist & {
|
||||
readonly playlist?: { readonly items?: Channel[] };
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UnifiedFavoritesDataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
async getFavorites(
|
||||
scope: CollectionScope,
|
||||
playlistId?: string,
|
||||
portalType?: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
if (scope === 'playlist' && playlistId) {
|
||||
return this.getPlaylistFavorites(playlistId, portalType);
|
||||
}
|
||||
return this.getAllFavorites();
|
||||
}
|
||||
|
||||
async removeFavorite(item: UnifiedCollectionItem): Promise<void> {
|
||||
switch (item.sourceType) {
|
||||
case 'm3u': {
|
||||
const playlist = await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(item.playlistId)
|
||||
);
|
||||
const filtered = ((playlist.favorites as string[]) ?? [])
|
||||
.filter(
|
||||
(favoriteId) =>
|
||||
favoriteId !== item.streamUrl &&
|
||||
favoriteId !== item.channelId
|
||||
);
|
||||
await firstValueFrom(
|
||||
this.playlistsService.setFavorites(item.playlistId, filtered)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'xtream':
|
||||
if (window.electron && item.contentId != null) {
|
||||
await window.electron.dbRemoveFavorite(item.contentId, item.playlistId);
|
||||
}
|
||||
break;
|
||||
case 'stalker': {
|
||||
const sourceItemId = item.uid.split('::')[2];
|
||||
await firstValueFrom(
|
||||
this.playlistsService.removeFromPortalFavorites(item.playlistId, sourceItemId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clearFavorites(items: UnifiedCollectionItem[]): Promise<void> {
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.clearM3uFavorites(
|
||||
items.filter((item) => item.sourceType === 'm3u')
|
||||
),
|
||||
this.clearXtreamFavorites(
|
||||
items.filter((item) => item.sourceType === 'xtream')
|
||||
),
|
||||
this.clearStalkerFavorites(
|
||||
items.filter((item) => item.sourceType === 'stalker')
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async reorder(
|
||||
items: UnifiedCollectionItem[],
|
||||
options?: {
|
||||
scope: CollectionScope;
|
||||
playlistId?: string;
|
||||
portalType?: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
if (
|
||||
options?.scope === 'playlist' &&
|
||||
options.playlistId &&
|
||||
options.portalType === 'm3u'
|
||||
) {
|
||||
await firstValueFrom(
|
||||
this.playlistsService.setFavorites(
|
||||
options.playlistId,
|
||||
items
|
||||
.map((item) => item.streamUrl ?? item.channelId ?? '')
|
||||
.filter((value) => value.length > 0)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
options?.scope === 'playlist' &&
|
||||
options.playlistId &&
|
||||
options.portalType === 'stalker'
|
||||
) {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(options.playlistId)
|
||||
)) as Playlist | undefined;
|
||||
const currentFavorites = Array.isArray(playlist?.favorites)
|
||||
? playlist.favorites.filter(isStalkerItem)
|
||||
: [];
|
||||
const favoritesById = new Map(
|
||||
currentFavorites.map((favorite) => [
|
||||
this.getStalkerFavoriteId(favorite),
|
||||
favorite,
|
||||
])
|
||||
);
|
||||
const reorderedFavorites = items
|
||||
.map(
|
||||
(item) =>
|
||||
favoritesById.get(this.getStalkerFavoriteId(item)) ?? null
|
||||
)
|
||||
.filter(
|
||||
(favorite): favorite is StalkerPortalItem =>
|
||||
favorite !== null
|
||||
);
|
||||
await firstValueFrom(
|
||||
this.playlistsService.setPortalFavorites(
|
||||
options.playlistId,
|
||||
reorderedFavorites
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const xtreamUpdates = this.buildXtreamPositionUpdates(items);
|
||||
|
||||
if (
|
||||
options?.scope === 'playlist' &&
|
||||
options.playlistId &&
|
||||
options.portalType === 'xtream'
|
||||
) {
|
||||
if (xtreamUpdates.length > 0 && window.electron) {
|
||||
await window.electron.dbReorderGlobalFavorites(xtreamUpdates);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
xtreamUpdates.length > 0 && window.electron
|
||||
? window.electron.dbReorderGlobalFavorites(xtreamUpdates)
|
||||
: Promise.resolve(),
|
||||
this.saveOrder(items.map((item) => item.uid)),
|
||||
]);
|
||||
}
|
||||
|
||||
private async getAllFavorites(): Promise<UnifiedCollectionItem[]> {
|
||||
const [m3u, xtream, stalker, order] = await Promise.all([
|
||||
this.getM3uFavorites(),
|
||||
this.getXtreamAllFavorites(),
|
||||
this.getStalkerAllFavorites(),
|
||||
this.getSavedOrder(),
|
||||
]);
|
||||
return this.applyOrder([...m3u, ...xtream, ...stalker], order);
|
||||
}
|
||||
|
||||
private async clearM3uFavorites(
|
||||
items: UnifiedCollectionItem[]
|
||||
): Promise<void> {
|
||||
const groupedItems = this.groupItemsByPlaylist(items);
|
||||
|
||||
await Promise.all(
|
||||
Array.from(groupedItems.entries()).map(
|
||||
async ([playlistId, playlistItems]) => {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(playlistId)
|
||||
)) as Playlist | undefined;
|
||||
const targetIds = new Set<string>();
|
||||
playlistItems.forEach((item) => {
|
||||
[item.streamUrl, item.channelId].forEach((value) => {
|
||||
const normalized = value?.trim();
|
||||
if (normalized) {
|
||||
targetIds.add(normalized);
|
||||
}
|
||||
});
|
||||
});
|
||||
const currentFavorites = Array.isArray(playlist?.favorites)
|
||||
? playlist.favorites.filter(
|
||||
(favorite): favorite is string =>
|
||||
typeof favorite === 'string'
|
||||
)
|
||||
: [];
|
||||
const nextFavorites = currentFavorites.filter(
|
||||
(favorite) => !targetIds.has(favorite.trim())
|
||||
);
|
||||
|
||||
await firstValueFrom(
|
||||
this.playlistsService.setFavorites(
|
||||
playlistId,
|
||||
nextFavorites
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async clearXtreamFavorites(
|
||||
items: UnifiedCollectionItem[]
|
||||
): Promise<void> {
|
||||
if (!window.electron) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
items
|
||||
.filter((item) => item.contentId != null)
|
||||
.map((item) =>
|
||||
window.electron!.dbRemoveFavorite(
|
||||
item.contentId!,
|
||||
item.playlistId
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async clearStalkerFavorites(
|
||||
items: UnifiedCollectionItem[]
|
||||
): Promise<void> {
|
||||
const groupedItems = this.groupItemsByPlaylist(items);
|
||||
|
||||
await Promise.all(
|
||||
Array.from(groupedItems.entries()).map(
|
||||
async ([playlistId, playlistItems]) => {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(playlistId)
|
||||
)) as Playlist | undefined;
|
||||
const targetIds = new Set(
|
||||
playlistItems.map((item) =>
|
||||
this.getStalkerFavoriteId(item)
|
||||
)
|
||||
);
|
||||
const currentFavorites = Array.isArray(playlist?.favorites)
|
||||
? playlist.favorites.filter(isStalkerItem)
|
||||
: [];
|
||||
const nextFavorites = currentFavorites.filter(
|
||||
(favorite) =>
|
||||
!targetIds.has(this.getStalkerFavoriteId(favorite))
|
||||
);
|
||||
|
||||
await firstValueFrom(
|
||||
this.playlistsService.setPortalFavorites(
|
||||
playlistId,
|
||||
nextFavorites
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getPlaylistFavorites(
|
||||
playlistId: string,
|
||||
portalType?: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
if (portalType === 'xtream') return this.getXtreamPlaylistFavorites(playlistId);
|
||||
if (portalType === 'stalker') return this.getStalkerPlaylistFavorites(playlistId);
|
||||
return this.getM3uPlaylistFavorites(playlistId);
|
||||
}
|
||||
|
||||
private async getM3uFavorites(): Promise<UnifiedCollectionItem[]> {
|
||||
const allMeta = await this.getAllMeta();
|
||||
const results: UnifiedCollectionItem[] = [];
|
||||
for (const meta of allMeta.filter((p) => p._id && !p.serverUrl && !p.macAddress)) {
|
||||
results.push(...(await this.extractM3uFavorites(meta)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getM3uPlaylistFavorites(id: string): Promise<UnifiedCollectionItem[]> {
|
||||
const meta = await this.getPlaylistMeta(id);
|
||||
return meta ? this.extractM3uFavorites(meta) : [];
|
||||
}
|
||||
|
||||
private async extractM3uFavorites(meta: PlaylistMeta): Promise<UnifiedCollectionItem[]> {
|
||||
if (!meta.favorites?.length) return [];
|
||||
const favoriteIds = (meta.favorites as string[]).map(String);
|
||||
let playlist: PlaylistWithChannels | undefined;
|
||||
try {
|
||||
playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(meta._id)
|
||||
)) as PlaylistWithChannels | undefined;
|
||||
} catch { return []; }
|
||||
const channels = playlist?.playlist?.items ?? [];
|
||||
const channelsByFavoriteId = new Map<string, Channel>();
|
||||
channels.forEach((channel) => {
|
||||
if (channel.url?.trim()) {
|
||||
channelsByFavoriteId.set(channel.url.trim(), channel);
|
||||
}
|
||||
if (channel.id?.trim()) {
|
||||
channelsByFavoriteId.set(channel.id.trim(), channel);
|
||||
}
|
||||
});
|
||||
|
||||
const seenUrls = new Set<string>();
|
||||
return favoriteIds
|
||||
.map((favoriteId, index) => {
|
||||
const channel = channelsByFavoriteId.get(favoriteId.trim());
|
||||
if (!channel?.url || seenUrls.has(channel.url)) {
|
||||
return null;
|
||||
}
|
||||
seenUrls.add(channel.url);
|
||||
const sourceItemId = channel.url?.trim() || channel.id?.trim();
|
||||
if (!sourceItemId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
uid: buildCollectionUid('m3u', meta._id, sourceItemId),
|
||||
name: channel.name,
|
||||
contentType: 'live' as const,
|
||||
sourceType: 'm3u' as const,
|
||||
playlistId: meta._id,
|
||||
playlistName: meta.title || meta.filename || 'M3U',
|
||||
logo: channel.tvg?.logo ?? null,
|
||||
streamUrl: channel.url,
|
||||
channelId: channel.id,
|
||||
radio: channel.radio,
|
||||
tvgId: channel.tvg?.id || channel.tvg?.name || channel.name,
|
||||
addedAt: new Date(0).toISOString(),
|
||||
position: index,
|
||||
} satisfies UnifiedCollectionItem;
|
||||
})
|
||||
.filter((channel) => channel !== null) as UnifiedCollectionItem[];
|
||||
}
|
||||
|
||||
private async getXtreamAllFavorites(): Promise<UnifiedCollectionItem[]> {
|
||||
if (!window.electron?.dbGetAllGlobalFavorites) return [];
|
||||
try {
|
||||
const rows = (await this.dbService.getAllGlobalFavorites()) as XtreamFavoriteRow[];
|
||||
return rows.map((r) => this.mapXtreamRow(r));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
private async getXtreamPlaylistFavorites(playlistId: string): Promise<UnifiedCollectionItem[]> {
|
||||
if (!window.electron) return [];
|
||||
try {
|
||||
const rows = await this.dbService.getFavorites(playlistId);
|
||||
const meta = await this.getPlaylistMeta(playlistId);
|
||||
return (rows as unknown as XtreamFavoriteRow[]).map((r) => ({
|
||||
...this.mapXtreamRow(r),
|
||||
playlistId,
|
||||
playlistName: meta?.title || 'Xtream',
|
||||
}));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem {
|
||||
const ct = xtreamContentType(row.type);
|
||||
return {
|
||||
uid: buildXtreamCollectionUid(row.playlist_id, ct, row.xtream_id),
|
||||
name: row.title,
|
||||
contentType: ct,
|
||||
sourceType: 'xtream',
|
||||
playlistId: row.playlist_id,
|
||||
playlistName: row.playlist_name ?? 'Xtream',
|
||||
logo: ct === 'live' ? (row.poster_url ?? null) : null,
|
||||
posterUrl: ct !== 'live' ? (row.poster_url ?? null) : null,
|
||||
xtreamId: row.xtream_id,
|
||||
categoryId: row.category_id,
|
||||
tvgId: ct === 'live' ? String(row.xtream_id) : undefined,
|
||||
rating: row.rating ?? undefined,
|
||||
addedAt:
|
||||
normalizeStalkerDate(row.added_at) ||
|
||||
new Date(0).toISOString(),
|
||||
position: row.position ?? 0,
|
||||
contentId: row.id,
|
||||
};
|
||||
}
|
||||
|
||||
private async getStalkerAllFavorites(): Promise<UnifiedCollectionItem[]> {
|
||||
const allMeta = await this.getAllMeta();
|
||||
const results: UnifiedCollectionItem[] = [];
|
||||
for (const meta of allMeta.filter((p) => p._id && p.macAddress)) {
|
||||
results.push(...(await this.extractStalkerFavorites(meta)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getStalkerPlaylistFavorites(id: string): Promise<UnifiedCollectionItem[]> {
|
||||
const meta = await this.getPlaylistMeta(id);
|
||||
return meta ? this.extractStalkerFavorites(meta) : [];
|
||||
}
|
||||
|
||||
private async extractStalkerFavorites(meta: PlaylistMeta): Promise<UnifiedCollectionItem[]> {
|
||||
if (!meta.favorites?.length) return [];
|
||||
let playlist: Playlist | undefined;
|
||||
try {
|
||||
playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(meta._id)
|
||||
)) as Playlist | undefined;
|
||||
} catch { return []; }
|
||||
const favs = Array.isArray(playlist?.favorites)
|
||||
? playlist.favorites.filter(isStalkerItem)
|
||||
: [];
|
||||
|
||||
return favs.map((fav, index) => {
|
||||
const ct = extractStalkerItemType(fav);
|
||||
const stalkerId = extractStalkerItemId(fav, meta._id, index);
|
||||
const poster = extractStalkerItemPoster(fav) || null;
|
||||
return {
|
||||
uid: buildCollectionUid('stalker', meta._id, stalkerId),
|
||||
name:
|
||||
extractStalkerItemTitle(fav) ||
|
||||
this.translate.instant(
|
||||
'WORKSPACE.GLOBAL_FAVORITES.UNKNOWN_CHANNEL'
|
||||
),
|
||||
contentType: ct,
|
||||
sourceType: 'stalker' as const,
|
||||
playlistId: meta._id,
|
||||
playlistName:
|
||||
meta.title ||
|
||||
meta.filename ||
|
||||
this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.STALKER_PORTAL'
|
||||
),
|
||||
logo: ct === 'live' ? poster : null,
|
||||
posterUrl: ct !== 'live' ? poster : null,
|
||||
tvgId: ct === 'live' ? stalkerId : undefined,
|
||||
stalkerId,
|
||||
stalkerCmd: fav.cmd,
|
||||
stalkerPortalUrl: playlist?.portalUrl ?? playlist?.url,
|
||||
stalkerMacAddress: playlist?.macAddress,
|
||||
categoryId: fav.category_id,
|
||||
stalkerItem: fav,
|
||||
addedAt:
|
||||
normalizeStalkerDate(fav.added_at) ||
|
||||
new Date(0).toISOString(),
|
||||
position: index,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async getAllMeta(): Promise<PlaylistMeta[]> {
|
||||
return firstValueFrom(
|
||||
this.store.select(selectAllPlaylistsMeta).pipe(map((m) => m as PlaylistMeta[]))
|
||||
);
|
||||
}
|
||||
|
||||
private async getPlaylistMeta(id: string): Promise<PlaylistMeta | undefined> {
|
||||
return (await this.getAllMeta()).find((p) => p._id === id);
|
||||
}
|
||||
|
||||
private async getSavedOrder(): Promise<string[]> {
|
||||
if (!window.electron?.dbGetAppState) return [];
|
||||
try {
|
||||
const raw = await window.electron.dbGetAppState(GLOBAL_FAVORITES_ORDER_KEY);
|
||||
return raw ? (JSON.parse(raw) as string[]) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
private async saveOrder(uidOrder: string[]): Promise<void> {
|
||||
if (!window.electron?.dbSetAppState) return;
|
||||
try {
|
||||
await window.electron.dbSetAppState(GLOBAL_FAVORITES_ORDER_KEY, JSON.stringify(uidOrder));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private applyOrder(items: UnifiedCollectionItem[], savedOrder: string[]): UnifiedCollectionItem[] {
|
||||
if (!savedOrder.length) {
|
||||
return items.slice().sort((a, b) => {
|
||||
const pa = a.position ?? 0;
|
||||
const pb = b.position ?? 0;
|
||||
if (pa !== pb) return pa - pb;
|
||||
return new Date(b.addedAt ?? 0).getTime() - new Date(a.addedAt ?? 0).getTime();
|
||||
});
|
||||
}
|
||||
const orderMap = new Map(savedOrder.map((uid, i) => [uid, i]));
|
||||
const ordered: UnifiedCollectionItem[] = [];
|
||||
const unordered: UnifiedCollectionItem[] = [];
|
||||
for (const item of items) {
|
||||
const pos = orderMap.get(item.uid);
|
||||
pos != null ? ordered.push({ ...item, position: pos }) : unordered.push(item);
|
||||
}
|
||||
ordered.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
unordered.sort((a, b) => new Date(b.addedAt ?? 0).getTime() - new Date(a.addedAt ?? 0).getTime());
|
||||
return [...ordered, ...unordered];
|
||||
}
|
||||
|
||||
private buildXtreamPositionUpdates(items: UnifiedCollectionItem[]) {
|
||||
return items
|
||||
.filter((item) => item.sourceType === 'xtream' && item.contentId != null)
|
||||
.map((item, index) => ({
|
||||
content_id: item.contentId!,
|
||||
position: index,
|
||||
}));
|
||||
}
|
||||
|
||||
private groupItemsByPlaylist(
|
||||
items: UnifiedCollectionItem[]
|
||||
): Map<string, UnifiedCollectionItem[]> {
|
||||
return items.reduce((groups, item) => {
|
||||
const group = groups.get(item.playlistId);
|
||||
if (group) {
|
||||
group.push(item);
|
||||
} else {
|
||||
groups.set(item.playlistId, [item]);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, new Map<string, UnifiedCollectionItem[]>());
|
||||
}
|
||||
|
||||
private getStalkerFavoriteId(
|
||||
favorite: Pick<UnifiedCollectionItem, 'stalkerId' | 'uid'> | StalkerPortalItem
|
||||
): string {
|
||||
if ('uid' in favorite) {
|
||||
return String(
|
||||
favorite.stalkerId ?? favorite.uid.split('::')[2] ?? ''
|
||||
).trim();
|
||||
}
|
||||
|
||||
return extractStalkerItemId(favorite);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Directive,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
GlobalFavoritesListComponent,
|
||||
UnifiedLiveTabComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
ArtPlayerComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
VjsPlayerComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import { EpgListComponent } from '@iptvnator/ui/epg';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { SettingsStore } from 'services';
|
||||
import { Channel, EpgItem, EpgProgram } from 'shared-interfaces';
|
||||
import { EpgViewComponent } from 'shared-portals';
|
||||
import {
|
||||
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
|
||||
PORTAL_PLAYER,
|
||||
FavoritesChannelSortMode,
|
||||
StreamResolverService,
|
||||
UnifiedCollectionItem,
|
||||
UnifiedFavoriteChannel,
|
||||
UnifiedRecentDataService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
@Directive({
|
||||
selector: '[appResizable]',
|
||||
})
|
||||
class StubResizableDirective {
|
||||
readonly minWidth = input<number>();
|
||||
readonly maxWidth = input<number>();
|
||||
readonly defaultWidth = input<number>();
|
||||
readonly storageKey = input<string>('');
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-global-favorites-list',
|
||||
template: '',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubGlobalFavoritesListComponent {
|
||||
readonly channels = input.required<UnifiedFavoriteChannel[]>();
|
||||
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
|
||||
readonly progressTick = input(0);
|
||||
readonly activeUid = input<string | null>(null);
|
||||
readonly searchTermInput = input('');
|
||||
readonly draggable = input(true);
|
||||
readonly sortMode = input<FavoritesChannelSortMode>(
|
||||
DEFAULT_FAVORITES_CHANNEL_SORT_MODE
|
||||
);
|
||||
|
||||
readonly channelSelected = output<UnifiedFavoriteChannel>();
|
||||
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
|
||||
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-list',
|
||||
template: '<div class="stub-epg-list"></div>',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubEpgListComponent {
|
||||
readonly controlledChannel = input<Channel | null>(null);
|
||||
readonly controlledPrograms = input<EpgProgram[] | null>(null);
|
||||
readonly archivePlaybackAvailable = input<boolean | null>(null);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-view',
|
||||
template: '<div class="stub-epg-view"></div>',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubEpgViewComponent {
|
||||
readonly epgItems = input<EpgItem[]>([]);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-vjs-player',
|
||||
template: '',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubVjsPlayerComponent {
|
||||
readonly options = input<unknown>();
|
||||
readonly volume = input(1);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-html-video-player',
|
||||
template: '',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubHtmlVideoPlayerComponent {
|
||||
readonly channel = input<Channel | null>(null);
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-art-player',
|
||||
template: '',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubArtPlayerComponent {
|
||||
readonly channel = input<Channel | null>(null);
|
||||
readonly volume = input(1);
|
||||
readonly showCaptions = input(false);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-audio-player',
|
||||
template: '<div class="stub-audio-player"></div>',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class StubAudioPlayerComponent {
|
||||
readonly icon = input('');
|
||||
readonly url = input.required<string>();
|
||||
readonly channelName = input('');
|
||||
}
|
||||
|
||||
describe('UnifiedLiveTabComponent', () => {
|
||||
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
|
||||
let component: UnifiedLiveTabComponent;
|
||||
let streamResolver: {
|
||||
resolveLiveDetail: jest.Mock;
|
||||
resolveM3uPlaybackDetail: jest.Mock;
|
||||
loadM3uProgramsForItem: jest.Mock;
|
||||
loadEpgForItems: jest.Mock;
|
||||
};
|
||||
let recentData: {
|
||||
recordLivePlayback: jest.Mock;
|
||||
};
|
||||
let portalPlayer: {
|
||||
isEmbeddedPlayer: jest.Mock;
|
||||
openResolvedPlayback: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
streamResolver = {
|
||||
resolveLiveDetail: jest.fn(),
|
||||
resolveM3uPlaybackDetail: jest.fn(),
|
||||
loadM3uProgramsForItem: jest.fn().mockResolvedValue([]),
|
||||
loadEpgForItems: jest.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
recentData = {
|
||||
recordLivePlayback: jest.fn(),
|
||||
};
|
||||
portalPlayer = {
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), UnifiedLiveTabComponent],
|
||||
providers: [
|
||||
{ provide: StreamResolverService, useValue: streamResolver },
|
||||
{ provide: UnifiedRecentDataService, useValue: recentData },
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
player: signal('videojs'),
|
||||
},
|
||||
},
|
||||
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedLiveTabComponent, {
|
||||
remove: {
|
||||
imports: [
|
||||
AudioPlayerComponent,
|
||||
ArtPlayerComponent,
|
||||
EpgListComponent,
|
||||
EpgViewComponent,
|
||||
GlobalFavoritesListComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
ResizableDirective,
|
||||
VjsPlayerComponent,
|
||||
],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
StubAudioPlayerComponent,
|
||||
StubArtPlayerComponent,
|
||||
StubEpgListComponent,
|
||||
StubEpgViewComponent,
|
||||
StubGlobalFavoritesListComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubResizableDirective,
|
||||
StubVjsPlayerComponent,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UnifiedLiveTabComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('renders controlled M3U EPG and records recent history on selection', async () => {
|
||||
const item = buildLiveItem('m3u');
|
||||
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
|
||||
epgMode: 'm3u',
|
||||
playback: {
|
||||
streamUrl: 'https://example.com/m3u.m3u8',
|
||||
title: 'M3U Live',
|
||||
},
|
||||
channel: {
|
||||
id: 'm3u-channel',
|
||||
name: 'M3U Live',
|
||||
url: 'https://example.com/m3u.m3u8',
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: 'm3u-channel',
|
||||
name: 'M3U Live',
|
||||
url: '',
|
||||
logo: 'm3u.png',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
},
|
||||
epgPrograms: [],
|
||||
});
|
||||
streamResolver.loadM3uProgramsForItem.mockResolvedValue([
|
||||
buildProgram('M3U Show'),
|
||||
]);
|
||||
recentData.recordLivePlayback.mockResolvedValue({
|
||||
...item,
|
||||
viewedAt: '2026-03-26T12:00:00.000Z',
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.componentRef.setInput('mode', 'recent');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
|
||||
expect(streamResolver.loadM3uProgramsForItem).toHaveBeenCalledWith(
|
||||
item,
|
||||
expect.objectContaining({
|
||||
id: 'm3u-channel',
|
||||
})
|
||||
);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-list')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not wait for M3U program lookup before opening playback', async () => {
|
||||
const item = buildLiveItem('m3u');
|
||||
const pendingPrograms = createDeferred<EpgProgram[]>();
|
||||
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
|
||||
epgMode: 'm3u',
|
||||
playback: {
|
||||
streamUrl: 'https://example.com/m3u.m3u8',
|
||||
title: 'M3U Live',
|
||||
},
|
||||
channel: {
|
||||
id: 'm3u-channel',
|
||||
name: 'M3U Live',
|
||||
url: 'https://example.com/m3u.m3u8',
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: 'm3u-channel',
|
||||
name: 'M3U Live',
|
||||
url: '',
|
||||
logo: 'm3u.png',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
},
|
||||
epgPrograms: [buildProgram('M3U Show')],
|
||||
});
|
||||
streamResolver.loadM3uProgramsForItem.mockReturnValue(
|
||||
pendingPrograms.promise
|
||||
);
|
||||
recentData.recordLivePlayback.mockResolvedValue({
|
||||
...item,
|
||||
viewedAt: '2026-03-26T12:00:00.000Z',
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.componentRef.setInput('mode', 'recent');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
streamUrl: 'https://example.com/m3u.m3u8',
|
||||
})
|
||||
);
|
||||
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
|
||||
expect(streamResolver.loadM3uProgramsForItem).toHaveBeenCalled();
|
||||
|
||||
pendingPrograms.resolve([buildProgram('M3U Show')]);
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('renders inline audio for M3U radio items and skips external playback', async () => {
|
||||
const item = {
|
||||
...buildLiveItem('m3u'),
|
||||
radio: 'true',
|
||||
} satisfies UnifiedCollectionItem;
|
||||
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
|
||||
epgMode: 'm3u',
|
||||
playback: {
|
||||
streamUrl: 'https://example.com/radio.m3u8',
|
||||
title: 'M3U Radio',
|
||||
thumbnail: 'radio.png',
|
||||
},
|
||||
channel: {
|
||||
id: 'm3u-channel',
|
||||
name: 'M3U Radio',
|
||||
url: 'https://example.com/radio.m3u8',
|
||||
group: { title: 'Radio' },
|
||||
tvg: {
|
||||
id: 'm3u-channel',
|
||||
name: 'M3U Radio',
|
||||
url: '',
|
||||
logo: 'radio.png',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'true',
|
||||
epgParams: '',
|
||||
},
|
||||
epgPrograms: [],
|
||||
});
|
||||
recentData.recordLivePlayback.mockResolvedValue({
|
||||
...item,
|
||||
viewedAt: '2026-03-26T12:00:00.000Z',
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.componentRef.setInput('mode', 'recent');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
|
||||
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-audio-player')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders shared EPG view for Xtream items and records recent history', async () => {
|
||||
const item = buildLiveItem('xtream');
|
||||
streamResolver.resolveLiveDetail.mockResolvedValue({
|
||||
epgMode: 'portal',
|
||||
playback: {
|
||||
streamUrl: 'https://example.com/xtream.m3u8',
|
||||
title: 'Xtream Live',
|
||||
},
|
||||
epgItems: [buildEpgItem('Xtream Show')],
|
||||
});
|
||||
recentData.recordLivePlayback.mockResolvedValue({
|
||||
...item,
|
||||
viewedAt: '2026-03-26T12:00:00.000Z',
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-view')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders shared EPG view for Stalker items and records recent history', async () => {
|
||||
const item = buildLiveItem('stalker');
|
||||
streamResolver.resolveLiveDetail.mockResolvedValue({
|
||||
epgMode: 'portal',
|
||||
playback: {
|
||||
streamUrl: 'https://example.com/stalker.m3u8',
|
||||
title: 'Stalker Live',
|
||||
},
|
||||
epgItems: [buildEpgItem('Stalker Show')],
|
||||
});
|
||||
recentData.recordLivePlayback.mockResolvedValue({
|
||||
...item,
|
||||
viewedAt: '2026-03-26T12:00:00.000Z',
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-view')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not restart auto-open while the same live item is still resolving', async () => {
|
||||
const item = buildLiveItem('xtream');
|
||||
const pendingDetail = createDeferred<{
|
||||
epgMode: 'portal';
|
||||
playback: {
|
||||
streamUrl: string;
|
||||
title: string;
|
||||
};
|
||||
epgItems: EpgItem[];
|
||||
}>();
|
||||
const autoOpenHandledSpy = jest.spyOn(
|
||||
component.autoOpenHandled,
|
||||
'emit'
|
||||
);
|
||||
|
||||
streamResolver.resolveLiveDetail.mockReturnValue(pendingDetail.promise);
|
||||
recentData.recordLivePlayback.mockResolvedValue({
|
||||
...item,
|
||||
viewedAt: '2026-03-26T12:00:00.000Z',
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.componentRef.setInput('autoOpenItem', {
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: item.playlistId,
|
||||
itemId: String(item.xtreamId),
|
||||
title: item.name,
|
||||
imageUrl: item.logo,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(streamResolver.resolveLiveDetail).toHaveBeenCalledTimes(1);
|
||||
expect(component.isSelecting()).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput('items', [{ ...item }]);
|
||||
fixture.detectChanges();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(streamResolver.resolveLiveDetail).toHaveBeenCalledTimes(1);
|
||||
|
||||
pendingDetail.resolve({
|
||||
epgMode: 'portal',
|
||||
playback: {
|
||||
streamUrl: 'https://example.com/xtream.m3u8',
|
||||
title: 'Xtream Live',
|
||||
},
|
||||
epgItems: [buildEpgItem('Xtream Show')],
|
||||
});
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.activeDetail()).toEqual(
|
||||
expect.objectContaining({
|
||||
playback: expect.objectContaining({
|
||||
streamUrl: 'https://example.com/xtream.m3u8',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(autoOpenHandledSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function buildLiveItem(
|
||||
sourceType: 'm3u' | 'xtream' | 'stalker'
|
||||
): UnifiedCollectionItem {
|
||||
if (sourceType === 'm3u') {
|
||||
return {
|
||||
uid: 'm3u::pl-1::m3u-channel',
|
||||
name: 'M3U Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'pl-1',
|
||||
playlistName: 'Playlist One',
|
||||
streamUrl: 'https://example.com/m3u.m3u8',
|
||||
channelId: 'm3u-channel',
|
||||
tvgId: 'm3u-channel',
|
||||
logo: 'm3u.png',
|
||||
radio: 'false',
|
||||
};
|
||||
}
|
||||
|
||||
if (sourceType === 'xtream') {
|
||||
return {
|
||||
uid: 'xtream::pl-2::20',
|
||||
name: 'Xtream Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'pl-2',
|
||||
playlistName: 'Playlist Two',
|
||||
xtreamId: 20,
|
||||
tvgId: '20',
|
||||
logo: 'xtream.png',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
uid: 'stalker::pl-3::30',
|
||||
name: 'Stalker Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'pl-3',
|
||||
playlistName: 'Playlist Three',
|
||||
stalkerId: '30',
|
||||
stalkerCmd: 'ffmpeg http://stalker/30',
|
||||
tvgId: '30',
|
||||
logo: 'stalker.png',
|
||||
};
|
||||
}
|
||||
|
||||
function buildProgram(title: string): EpgProgram {
|
||||
return {
|
||||
start: '2026-03-26T11:00:00.000Z',
|
||||
stop: '2026-03-26T12:00:00.000Z',
|
||||
channel: 'test-channel',
|
||||
title,
|
||||
desc: `${title} description`,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEpgItem(title: string): EpgItem {
|
||||
return {
|
||||
id: '1',
|
||||
epg_id: '',
|
||||
title,
|
||||
description: `${title} description`,
|
||||
lang: '',
|
||||
start: '2026-03-26T11:00:00.000Z',
|
||||
end: '2026-03-26T12:00:00.000Z',
|
||||
stop: '2026-03-26T12:00:00.000Z',
|
||||
channel_id: '1',
|
||||
start_timestamp: '1774522800',
|
||||
stop_timestamp: '1774526400',
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import {
|
||||
Channel,
|
||||
M3uRecentlyViewedItem,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
} from 'shared-interfaces';
|
||||
import { UnifiedCollectionItem } from './unified-collection-item.interface';
|
||||
import { UnifiedRecentDataService } from './unified-recent-data.service';
|
||||
|
||||
describe('UnifiedRecentDataService', () => {
|
||||
let service: UnifiedRecentDataService;
|
||||
let store: { select: jest.Mock; dispatch: jest.Mock };
|
||||
let playlistsService: {
|
||||
getPlaylistById: jest.Mock;
|
||||
addM3uRecentlyViewed: jest.Mock;
|
||||
removeFromM3uRecentlyViewed: jest.Mock;
|
||||
removeFromPortalRecentlyViewed: jest.Mock;
|
||||
clearPlaylistRecentlyViewed: jest.Mock;
|
||||
getAllPlaylists: jest.Mock;
|
||||
};
|
||||
let dbService: {
|
||||
getGlobalRecentlyViewed: jest.Mock;
|
||||
getRecentItems: jest.Mock;
|
||||
removeRecentItem: jest.Mock;
|
||||
clearPlaylistRecentItems: jest.Mock;
|
||||
clearGlobalRecentlyViewed: jest.Mock;
|
||||
addRecentItem: jest.Mock;
|
||||
getContentByXtreamId: jest.Mock;
|
||||
};
|
||||
|
||||
const playlistMeta = {
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U List',
|
||||
recentlyViewed: [
|
||||
{
|
||||
source: 'm3u',
|
||||
id: 'https://example.com/2.m3u8',
|
||||
url: 'https://example.com/2.m3u8',
|
||||
title: 'Recent Channel',
|
||||
channel_id: 'channel-2',
|
||||
tvg_id: 'recent-two',
|
||||
category_id: 'live',
|
||||
added_at: '2026-03-26T11:00:00.000Z',
|
||||
},
|
||||
],
|
||||
} satisfies PlaylistMeta;
|
||||
|
||||
const channels: Channel[] = [
|
||||
{
|
||||
id: 'channel-1',
|
||||
name: 'Channel One',
|
||||
url: 'https://example.com/1.m3u8',
|
||||
group: { title: 'News' },
|
||||
tvg: {
|
||||
id: 'one',
|
||||
name: 'Channel One',
|
||||
url: '',
|
||||
logo: 'one.png',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
},
|
||||
{
|
||||
id: 'channel-2',
|
||||
name: 'Channel Two',
|
||||
url: 'https://example.com/2.m3u8',
|
||||
group: { title: 'Sports' },
|
||||
tvg: {
|
||||
id: '',
|
||||
name: 'Channel Two',
|
||||
url: '',
|
||||
logo: 'two.png',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'true',
|
||||
epgParams: '',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
store = {
|
||||
select: jest.fn(() => of([playlistMeta])),
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
playlistsService = {
|
||||
getPlaylistById: jest.fn().mockReturnValue(
|
||||
of({
|
||||
_id: 'm3u-1',
|
||||
playlist: { items: channels },
|
||||
} satisfies Partial<Playlist>)
|
||||
),
|
||||
addM3uRecentlyViewed: jest.fn().mockReturnValue(
|
||||
of({
|
||||
recentlyViewed: [
|
||||
{
|
||||
source: 'm3u',
|
||||
id: 'https://example.com/1.m3u8',
|
||||
url: 'https://example.com/1.m3u8',
|
||||
title: 'Channel One',
|
||||
category_id: 'live',
|
||||
added_at: '2026-03-26T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
removeFromM3uRecentlyViewed: jest.fn().mockReturnValue(of({ recentlyViewed: [] })),
|
||||
removeFromPortalRecentlyViewed: jest.fn().mockReturnValue(of({ recentlyViewed: [] })),
|
||||
clearPlaylistRecentlyViewed: jest.fn().mockReturnValue(of({ recentlyViewed: [] })),
|
||||
getAllPlaylists: jest.fn().mockReturnValue(of([])),
|
||||
};
|
||||
dbService = {
|
||||
getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]),
|
||||
getRecentItems: jest.fn().mockResolvedValue([]),
|
||||
removeRecentItem: jest.fn().mockResolvedValue(true),
|
||||
clearPlaylistRecentItems: jest.fn().mockResolvedValue(true),
|
||||
clearGlobalRecentlyViewed: jest.fn().mockResolvedValue(undefined),
|
||||
addRecentItem: jest.fn().mockResolvedValue(true),
|
||||
getContentByXtreamId: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
UnifiedRecentDataService,
|
||||
{ provide: Store, useValue: store },
|
||||
{ provide: PlaylistsService, useValue: playlistsService },
|
||||
{ provide: DatabaseService, useValue: dbService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(UnifiedRecentDataService);
|
||||
});
|
||||
|
||||
it('rehydrates M3U recent items with stream and channel metadata', async () => {
|
||||
const items = await service.getRecentItems('playlist', 'm3u-1', 'm3u');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
sourceType: 'm3u',
|
||||
streamUrl: 'https://example.com/2.m3u8',
|
||||
channelId: 'channel-2',
|
||||
name: 'Recent Channel',
|
||||
tvgId: 'recent-two',
|
||||
logo: 'two.png',
|
||||
radio: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('records M3U live playback through playlist recently viewed storage', async () => {
|
||||
const item = {
|
||||
uid: 'm3u::m3u-1::https://example.com/1.m3u8',
|
||||
name: 'Channel One',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'm3u-1',
|
||||
playlistName: 'M3U List',
|
||||
streamUrl: 'https://example.com/1.m3u8',
|
||||
channelId: 'channel-1',
|
||||
tvgId: 'one',
|
||||
logo: 'one.png',
|
||||
} satisfies UnifiedCollectionItem;
|
||||
|
||||
const recorded = await service.recordLivePlayback(item);
|
||||
|
||||
expect(playlistsService.addM3uRecentlyViewed).toHaveBeenCalledWith(
|
||||
'm3u-1',
|
||||
expect.objectContaining<M3uRecentlyViewedItem>({
|
||||
source: 'm3u',
|
||||
url: 'https://example.com/1.m3u8',
|
||||
channel_id: 'channel-1',
|
||||
tvg_id: 'one',
|
||||
})
|
||||
);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: expect.stringContaining('Update Playlist Meta'),
|
||||
})
|
||||
);
|
||||
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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectAllPlaylistsMeta,
|
||||
} from 'm3u-state';
|
||||
import { firstValueFrom, map } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import {
|
||||
Channel,
|
||||
extractStalkerItemId,
|
||||
extractStalkerItemPoster,
|
||||
extractStalkerItemTitle,
|
||||
extractStalkerItemType,
|
||||
isM3uRecentlyViewedItem,
|
||||
M3uRecentlyViewedItem,
|
||||
normalizeStalkerDate,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
PlaylistRecentlyViewedItem,
|
||||
PlaylistUpdateState,
|
||||
StalkerPortalItem,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
buildCollectionUid,
|
||||
buildXtreamCollectionUid,
|
||||
UnifiedCollectionItem,
|
||||
} from './unified-collection-item.interface';
|
||||
import { CollectionScope } from './scope-toggle.service';
|
||||
import { xtreamContentType } from './collection-helpers';
|
||||
|
||||
type PlaylistWithChannels = Playlist & {
|
||||
readonly playlist?: { readonly items?: Channel[] };
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UnifiedRecentDataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
|
||||
async getRecentItems(
|
||||
scope: CollectionScope,
|
||||
playlistId?: string,
|
||||
portalType?: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
if (scope === 'playlist' && playlistId) {
|
||||
return this.getPlaylistRecentItems(playlistId, portalType);
|
||||
}
|
||||
|
||||
return this.getAllRecentItems();
|
||||
}
|
||||
|
||||
async removeRecentItem(item: UnifiedCollectionItem): Promise<void> {
|
||||
if (item.sourceType === 'xtream') {
|
||||
if (item.contentId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dbService.removeRecentItem(item.contentId, item.playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.sourceType === 'm3u') {
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.removeFromM3uRecentlyViewed(
|
||||
item.playlistId,
|
||||
item.streamUrl ?? item.uid.split('::')[2]
|
||||
)
|
||||
);
|
||||
this.dispatchPlaylistRecentUpdate(item.playlistId, updatedPlaylist);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.removeFromPortalRecentlyViewed(
|
||||
item.playlistId,
|
||||
item.stalkerId ?? item.uid.split('::')[2]
|
||||
)
|
||||
);
|
||||
this.dispatchPlaylistRecentUpdate(item.playlistId, updatedPlaylist);
|
||||
}
|
||||
|
||||
async clearRecentItems(
|
||||
scope: CollectionScope,
|
||||
playlistId?: string
|
||||
): Promise<void> {
|
||||
if (scope === 'playlist' && playlistId) {
|
||||
await this.dbService.clearPlaylistRecentItems(playlistId);
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.clearPlaylistRecentlyViewed(playlistId)
|
||||
);
|
||||
this.dispatchPlaylistRecentUpdate(playlistId, updatedPlaylist);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dbService.clearGlobalRecentlyViewed();
|
||||
const playlists = (await firstValueFrom(
|
||||
this.playlistsService.getAllPlaylists()
|
||||
)) as Playlist[];
|
||||
|
||||
await Promise.all(
|
||||
playlists
|
||||
.filter((playlist) => Boolean(playlist.macAddress) || !playlist.serverUrl)
|
||||
.map(async (playlist) => {
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.clearPlaylistRecentlyViewed(
|
||||
playlist._id
|
||||
)
|
||||
);
|
||||
this.dispatchPlaylistRecentUpdate(
|
||||
playlist._id,
|
||||
updatedPlaylist
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async recordLivePlayback(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<UnifiedCollectionItem> {
|
||||
const viewedAt = new Date().toISOString();
|
||||
|
||||
if (item.sourceType === 'm3u') {
|
||||
if (!item.streamUrl) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.addM3uRecentlyViewed(item.playlistId, {
|
||||
source: 'm3u',
|
||||
id: item.streamUrl,
|
||||
url: item.streamUrl,
|
||||
title: item.name?.trim() || item.streamUrl,
|
||||
channel_id: item.channelId,
|
||||
poster_url: item.logo ?? undefined,
|
||||
tvg_id: item.tvgId,
|
||||
tvg_name: item.name,
|
||||
category_id: 'live',
|
||||
added_at: viewedAt,
|
||||
} satisfies M3uRecentlyViewedItem)
|
||||
);
|
||||
|
||||
this.dispatchPlaylistRecentUpdate(item.playlistId, updatedPlaylist);
|
||||
|
||||
return {
|
||||
...item,
|
||||
viewedAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.sourceType === 'xtream') {
|
||||
const contentId =
|
||||
item.contentId ??
|
||||
(item.xtreamId != null
|
||||
? (
|
||||
await this.dbService.getContentByXtreamId(
|
||||
item.xtreamId,
|
||||
item.playlistId,
|
||||
item.contentType
|
||||
)
|
||||
)?.id
|
||||
: null);
|
||||
|
||||
if (contentId != null) {
|
||||
await this.dbService.addRecentItem(contentId, item.playlistId);
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
contentId: contentId ?? item.contentId,
|
||||
viewedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const stalkerItem =
|
||||
(item.stalkerItem as StalkerPortalItem | undefined) ?? {};
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.addPortalRecentlyViewed(item.playlistId, {
|
||||
...stalkerItem,
|
||||
id:
|
||||
item.stalkerId ??
|
||||
extractStalkerItemId(stalkerItem) ??
|
||||
item.uid.split('::')[2],
|
||||
cmd: item.stalkerCmd ?? stalkerItem.cmd,
|
||||
cover:
|
||||
stalkerItem.cover ??
|
||||
item.logo ??
|
||||
item.posterUrl ??
|
||||
undefined,
|
||||
logo:
|
||||
stalkerItem.logo ??
|
||||
item.logo ??
|
||||
item.posterUrl ??
|
||||
undefined,
|
||||
title: item.name,
|
||||
name: stalkerItem.name ?? item.name,
|
||||
o_name: stalkerItem.o_name ?? item.name,
|
||||
category_id: 'itv',
|
||||
added_at: Date.now(),
|
||||
})
|
||||
);
|
||||
|
||||
this.dispatchPlaylistRecentUpdate(item.playlistId, updatedPlaylist);
|
||||
|
||||
return {
|
||||
...item,
|
||||
viewedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async getAllRecentItems(): Promise<UnifiedCollectionItem[]> {
|
||||
const [xtream, m3u, stalker] = await Promise.all([
|
||||
this.getXtreamGlobalRecent(),
|
||||
this.getM3uGlobalRecent(),
|
||||
this.getStalkerGlobalRecent(),
|
||||
]);
|
||||
|
||||
return [...xtream, ...m3u, ...stalker].sort(
|
||||
(a, b) =>
|
||||
new Date(b.viewedAt ?? 0).getTime() -
|
||||
new Date(a.viewedAt ?? 0).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
private async getPlaylistRecentItems(
|
||||
playlistId: string,
|
||||
portalType?: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
if (portalType === 'xtream') {
|
||||
return this.getXtreamPlaylistRecent(playlistId);
|
||||
}
|
||||
|
||||
if (portalType === 'stalker') {
|
||||
return this.getStalkerPlaylistRecent(playlistId);
|
||||
}
|
||||
|
||||
return this.getM3uPlaylistRecent(playlistId);
|
||||
}
|
||||
|
||||
private async getXtreamGlobalRecent(): Promise<UnifiedCollectionItem[]> {
|
||||
try {
|
||||
const rows = await this.dbService.getGlobalRecentlyViewed();
|
||||
return (rows || []).map((row) => ({
|
||||
uid: buildXtreamCollectionUid(
|
||||
row.playlist_id,
|
||||
xtreamContentType(row.type),
|
||||
row.xtream_id
|
||||
),
|
||||
name: row.title,
|
||||
contentType: xtreamContentType(row.type),
|
||||
sourceType: 'xtream' as const,
|
||||
playlistId: row.playlist_id,
|
||||
playlistName: row.playlist_name ?? 'Xtream',
|
||||
logo: row.type === 'live' ? (row.poster_url ?? null) : null,
|
||||
posterUrl: row.type !== 'live' ? (row.poster_url ?? null) : null,
|
||||
xtreamId: row.xtream_id,
|
||||
categoryId: row.category_id,
|
||||
tvgId: row.type === 'live' ? String(row.xtream_id) : undefined,
|
||||
contentId: row.id,
|
||||
viewedAt: normalizeStalkerDate(row.viewed_at),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getXtreamPlaylistRecent(
|
||||
playlistId: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
try {
|
||||
const rows = await this.dbService.getRecentItems(playlistId);
|
||||
const meta = await this.getPlaylistMeta(playlistId);
|
||||
|
||||
return (rows || []).map((row) => ({
|
||||
uid: buildXtreamCollectionUid(
|
||||
playlistId,
|
||||
xtreamContentType(row.type),
|
||||
row.xtream_id
|
||||
),
|
||||
name: row.title,
|
||||
contentType: xtreamContentType(row.type),
|
||||
sourceType: 'xtream' as const,
|
||||
playlistId,
|
||||
playlistName: meta?.title || 'Xtream',
|
||||
logo: row.type === 'live' ? (row.poster_url ?? null) : null,
|
||||
posterUrl: row.type !== 'live' ? (row.poster_url ?? null) : null,
|
||||
xtreamId: row.xtream_id,
|
||||
categoryId: row.category_id,
|
||||
tvgId: row.type === 'live' ? String(row.xtream_id) : undefined,
|
||||
contentId: row.id,
|
||||
viewedAt: normalizeStalkerDate(row.viewed_at),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getM3uGlobalRecent(): Promise<UnifiedCollectionItem[]> {
|
||||
const allMeta = await this.getAllMeta();
|
||||
const results: UnifiedCollectionItem[] = [];
|
||||
|
||||
for (const meta of allMeta.filter((playlist) => this.isM3uPlaylist(playlist))) {
|
||||
results.push(...(await this.extractM3uRecent(meta)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getM3uPlaylistRecent(
|
||||
playlistId: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
const meta = await this.getPlaylistMeta(playlistId);
|
||||
return meta ? this.extractM3uRecent(meta) : [];
|
||||
}
|
||||
|
||||
private async getStalkerGlobalRecent(): Promise<UnifiedCollectionItem[]> {
|
||||
const allMeta = await this.getAllMeta();
|
||||
const results: UnifiedCollectionItem[] = [];
|
||||
|
||||
for (const meta of allMeta.filter((playlist) => Boolean(playlist.macAddress))) {
|
||||
results.push(...(await this.extractStalkerRecent(meta)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getStalkerPlaylistRecent(
|
||||
playlistId: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
const meta = await this.getPlaylistMeta(playlistId);
|
||||
return meta ? this.extractStalkerRecent(meta) : [];
|
||||
}
|
||||
|
||||
private async extractM3uRecent(
|
||||
meta: PlaylistMeta
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
const recentItems = this.sortRecentItems(meta.recentlyViewed).filter(
|
||||
isM3uRecentlyViewedItem
|
||||
);
|
||||
|
||||
if (recentItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let playlist: PlaylistWithChannels | undefined;
|
||||
try {
|
||||
playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(meta._id)
|
||||
)) as PlaylistWithChannels | undefined;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const channels = playlist?.playlist?.items ?? [];
|
||||
const channelsByUrl = new Map<string, Channel>(
|
||||
channels
|
||||
.filter((channel) => channel.url?.trim())
|
||||
.map((channel) => [channel.url.trim(), channel] as const)
|
||||
);
|
||||
const channelsById = new Map<string, Channel>(
|
||||
channels
|
||||
.filter((channel) => channel.id?.trim())
|
||||
.map((channel) => [channel.id.trim(), channel] as const)
|
||||
);
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
return recentItems
|
||||
.map((recentItem) => {
|
||||
const channel =
|
||||
channelsByUrl.get(recentItem.url.trim()) ||
|
||||
(recentItem.channel_id
|
||||
? channelsById.get(String(recentItem.channel_id).trim())
|
||||
: undefined);
|
||||
const fallbackSourceId =
|
||||
recentItem.url?.trim() ||
|
||||
String(recentItem.channel_id ?? '').trim();
|
||||
|
||||
if (!channel?.url || seenUrls.has(channel.url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seenUrls.add(channel.url);
|
||||
|
||||
return {
|
||||
uid: buildCollectionUid(
|
||||
'm3u',
|
||||
meta._id,
|
||||
fallbackSourceId || channel.url || channel.id
|
||||
),
|
||||
name:
|
||||
recentItem.title?.trim() ||
|
||||
channel.name ||
|
||||
recentItem.tvg_name?.trim() ||
|
||||
recentItem.url,
|
||||
contentType: 'live' as const,
|
||||
sourceType: 'm3u' as const,
|
||||
playlistId: meta._id,
|
||||
playlistName: meta.title || meta.filename || 'M3U',
|
||||
logo: channel.tvg?.logo ?? recentItem.poster_url ?? null,
|
||||
streamUrl: channel.url,
|
||||
channelId: channel.id,
|
||||
radio: channel.radio,
|
||||
tvgId:
|
||||
channel.tvg?.id ||
|
||||
recentItem.tvg_id ||
|
||||
recentItem.tvg_name ||
|
||||
channel.tvg?.name ||
|
||||
channel.name,
|
||||
categoryId: recentItem.category_id,
|
||||
viewedAt: normalizeStalkerDate(recentItem.added_at),
|
||||
} satisfies UnifiedCollectionItem;
|
||||
})
|
||||
.filter((item) => item !== null) as UnifiedCollectionItem[];
|
||||
}
|
||||
|
||||
private async extractStalkerRecent(
|
||||
meta: PlaylistMeta
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
const recentItems = this.sortRecentItems(meta.recentlyViewed).filter(
|
||||
(item): item is StalkerPortalItem => !isM3uRecentlyViewedItem(item)
|
||||
);
|
||||
|
||||
if (recentItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let playlist: Playlist | undefined;
|
||||
try {
|
||||
playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(meta._id)
|
||||
)) as Playlist | undefined;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
return recentItems.map((item, index) => {
|
||||
const stalkerId = extractStalkerItemId(item, meta._id, index);
|
||||
const contentType = extractStalkerItemType(item);
|
||||
const imageUrl = extractStalkerItemPoster(item) || null;
|
||||
|
||||
return {
|
||||
uid: buildCollectionUid('stalker', meta._id, stalkerId),
|
||||
name: extractStalkerItemTitle(item),
|
||||
contentType,
|
||||
sourceType: 'stalker' as const,
|
||||
playlistId: meta._id,
|
||||
playlistName: meta.title || meta.filename || 'Stalker Portal',
|
||||
logo: contentType === 'live' ? imageUrl : null,
|
||||
posterUrl: contentType !== 'live' ? imageUrl : null,
|
||||
tvgId: contentType === 'live' ? stalkerId : undefined,
|
||||
stalkerId,
|
||||
stalkerCmd: item.cmd,
|
||||
stalkerPortalUrl: playlist?.portalUrl ?? playlist?.url,
|
||||
stalkerMacAddress: playlist?.macAddress,
|
||||
categoryId: item.category_id,
|
||||
stalkerItem: item,
|
||||
viewedAt: normalizeStalkerDate(item.added_at),
|
||||
} satisfies UnifiedCollectionItem;
|
||||
});
|
||||
}
|
||||
|
||||
private async getPlaylistMeta(
|
||||
id: string
|
||||
): Promise<PlaylistMeta | undefined> {
|
||||
return (await this.getAllMeta()).find((playlist) => playlist._id === id);
|
||||
}
|
||||
|
||||
private async getAllMeta(): Promise<PlaylistMeta[]> {
|
||||
return firstValueFrom(
|
||||
this.store
|
||||
.select(selectAllPlaylistsMeta)
|
||||
.pipe(map((playlists) => playlists as PlaylistMeta[]))
|
||||
);
|
||||
}
|
||||
|
||||
private sortRecentItems(
|
||||
items: PlaylistMeta['recentlyViewed']
|
||||
): PlaylistRecentlyViewedItem[] {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...items].sort(
|
||||
(a, b) =>
|
||||
new Date(normalizeStalkerDate(b.added_at ?? '')).getTime() -
|
||||
new Date(normalizeStalkerDate(a.added_at ?? '')).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
private dispatchPlaylistRecentUpdate(
|
||||
playlistId: string,
|
||||
updatedPlaylist: Partial<Pick<Playlist, 'recentlyViewed'>> | undefined
|
||||
): void {
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: {
|
||||
_id: playlistId,
|
||||
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
|
||||
updateState: PlaylistUpdateState.UPDATED,
|
||||
} as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private isM3uPlaylist(
|
||||
playlist: Pick<PlaylistMeta, 'serverUrl' | 'macAddress'>
|
||||
): boolean {
|
||||
return !playlist.serverUrl && !playlist.macAddress;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
const FAVORITES_SORT_COLLATOR = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
export type FavoritesChannelSortMode =
|
||||
| 'custom'
|
||||
| 'name-asc'
|
||||
| 'name-desc'
|
||||
| 'date-desc';
|
||||
|
||||
export const DEFAULT_FAVORITES_CHANNEL_SORT_MODE: FavoritesChannelSortMode =
|
||||
'custom';
|
||||
|
||||
export const FAVORITES_CHANNEL_SORT_STORAGE_KEY =
|
||||
'favorites-channel-sort-mode';
|
||||
|
||||
export function isFavoritesChannelSortMode(
|
||||
value: unknown
|
||||
): value is FavoritesChannelSortMode {
|
||||
return (
|
||||
value === 'custom' ||
|
||||
value === 'name-asc' ||
|
||||
value === 'name-desc' ||
|
||||
value === 'date-desc'
|
||||
);
|
||||
}
|
||||
|
||||
export function restoreFavoritesChannelSortMode(
|
||||
storageKey: string = FAVORITES_CHANNEL_SORT_STORAGE_KEY,
|
||||
fallback: FavoritesChannelSortMode = DEFAULT_FAVORITES_CHANNEL_SORT_MODE
|
||||
): FavoritesChannelSortMode {
|
||||
const storedValue = localStorage.getItem(storageKey);
|
||||
return isFavoritesChannelSortMode(storedValue) ? storedValue : fallback;
|
||||
}
|
||||
|
||||
export function persistFavoritesChannelSortMode(
|
||||
mode: FavoritesChannelSortMode,
|
||||
storageKey: string = FAVORITES_CHANNEL_SORT_STORAGE_KEY
|
||||
): void {
|
||||
localStorage.setItem(storageKey, mode);
|
||||
}
|
||||
|
||||
export function getFavoritesChannelSortModeTranslationKey(
|
||||
mode: FavoritesChannelSortMode
|
||||
): string {
|
||||
switch (mode) {
|
||||
case 'name-asc':
|
||||
return 'WORKSPACE.SORT_NAME_ASC';
|
||||
case 'name-desc':
|
||||
return 'WORKSPACE.SORT_NAME_DESC';
|
||||
case 'date-desc':
|
||||
return 'WORKSPACE.SORT_DATE_DESC';
|
||||
default:
|
||||
return 'WORKSPACE.SORT_CUSTOM';
|
||||
}
|
||||
}
|
||||
|
||||
export function sortFavoriteChannelItems<T>(
|
||||
items: readonly T[],
|
||||
mode: FavoritesChannelSortMode,
|
||||
accessors: {
|
||||
getName: (item: T) => string | null | undefined;
|
||||
getAddedAt?: (item: T) => string | null | undefined;
|
||||
}
|
||||
): readonly T[] {
|
||||
if (mode === 'custom') {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (mode === 'date-desc') {
|
||||
const getAddedAt = accessors.getAddedAt;
|
||||
if (!getAddedAt) {
|
||||
return items;
|
||||
}
|
||||
return [...items].sort((a, b) => {
|
||||
const timeA = Date.parse(getAddedAt(a) ?? '') || 0;
|
||||
const timeB = Date.parse(getAddedAt(b) ?? '') || 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
}
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const result = FAVORITES_SORT_COLLATOR.compare(
|
||||
accessors.getName(a) ?? '',
|
||||
accessors.getName(b) ?? ''
|
||||
);
|
||||
return mode === 'name-asc' ? result : -result;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
createPortalDebugErrorEvent,
|
||||
createPortalDebugRequestContext,
|
||||
createPortalDebugSuccessEvent,
|
||||
logPortalDebugEvent,
|
||||
logPortalDebugRequest,
|
||||
} from './logger';
|
||||
|
||||
describe('portal debug logger', () => {
|
||||
const globalWithNgDevMode = globalThis as typeof globalThis & {
|
||||
ngDevMode?: boolean;
|
||||
};
|
||||
const originalNgDevMode = globalWithNgDevMode.ngDevMode;
|
||||
|
||||
beforeEach(() => {
|
||||
globalWithNgDevMode.ngDevMode = true;
|
||||
jest.spyOn(console, 'groupCollapsed').mockImplementation(() => undefined);
|
||||
jest.spyOn(console, 'groupEnd').mockImplementation(() => undefined);
|
||||
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalWithNgDevMode.ngDevMode = originalNgDevMode;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not log when production is enabled', () => {
|
||||
globalWithNgDevMode.ngDevMode = false;
|
||||
|
||||
const context = createPortalDebugRequestContext({
|
||||
provider: 'xtream',
|
||||
operation: 'get_account_info',
|
||||
transport: 'pwa-http',
|
||||
request: { url: '/xtream' },
|
||||
});
|
||||
|
||||
logPortalDebugRequest(context);
|
||||
logPortalDebugEvent(createPortalDebugSuccessEvent(context, { ok: true }));
|
||||
|
||||
expect(console.groupCollapsed).not.toHaveBeenCalled();
|
||||
expect(console.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes request id and duration in success events', () => {
|
||||
const nowSpy = jest
|
||||
.spyOn(performance, 'now')
|
||||
.mockReturnValueOnce(10)
|
||||
.mockReturnValueOnce(18);
|
||||
const context = createPortalDebugRequestContext({
|
||||
provider: 'stalker',
|
||||
operation: 'get_ordered_list',
|
||||
transport: 'electron-renderer',
|
||||
request: { url: '/stalker' },
|
||||
});
|
||||
|
||||
const event = createPortalDebugSuccessEvent(context, { js: [] });
|
||||
|
||||
expect(event.requestId).toBe(context.requestId);
|
||||
expect(event.durationMs).toBe(8);
|
||||
expect(event.response).toEqual({ js: [] });
|
||||
expect(event.status).toBe('success');
|
||||
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('includes request id and duration in error events', () => {
|
||||
const nowSpy = jest
|
||||
.spyOn(performance, 'now')
|
||||
.mockReturnValueOnce(100)
|
||||
.mockReturnValueOnce(112.5);
|
||||
const context = createPortalDebugRequestContext({
|
||||
provider: 'xtream',
|
||||
operation: 'get_vod_info',
|
||||
transport: 'pwa-http',
|
||||
request: { url: '/xtream' },
|
||||
});
|
||||
|
||||
const event = createPortalDebugErrorEvent(context, new Error('boom'));
|
||||
|
||||
expect(event.requestId).toBe(context.requestId);
|
||||
expect(event.durationMs).toBe(12.5);
|
||||
expect(event.status).toBe('error');
|
||||
expect((event.error as Error).message).toBe('boom');
|
||||
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
PortalDebugEvent,
|
||||
PortalDebugProvider,
|
||||
PortalDebugTransport,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
export interface Logger {
|
||||
debug: (...args: unknown[]) => void;
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
function isProductionBuild(): boolean {
|
||||
const globalWithNgDevMode = globalThis as typeof globalThis & {
|
||||
ngDevMode?: boolean;
|
||||
};
|
||||
|
||||
return globalWithNgDevMode.ngDevMode === false;
|
||||
}
|
||||
|
||||
export function createLogger(scope: string): Logger {
|
||||
const prefix = `[${scope}]`;
|
||||
const debugEnabled = !isProductionBuild();
|
||||
|
||||
return {
|
||||
debug: (...args: unknown[]) => {
|
||||
if (debugEnabled) {
|
||||
console.debug(prefix, ...args);
|
||||
}
|
||||
},
|
||||
info: (...args: unknown[]) => {
|
||||
if (debugEnabled) {
|
||||
console.info(prefix, ...args);
|
||||
}
|
||||
},
|
||||
warn: (...args: unknown[]) => {
|
||||
console.warn(prefix, ...args);
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
console.error(prefix, ...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PortalDebugRequestContext {
|
||||
requestId: string;
|
||||
provider: PortalDebugProvider;
|
||||
operation: string;
|
||||
transport: PortalDebugTransport;
|
||||
request: unknown;
|
||||
startedAt: number;
|
||||
startedAtIso: string;
|
||||
}
|
||||
|
||||
let portalRequestSequence = 0;
|
||||
|
||||
function isPortalDebugEnabled(): boolean {
|
||||
return !isProductionBuild();
|
||||
}
|
||||
|
||||
function getNow(): number {
|
||||
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function createPortalRequestId(provider: PortalDebugProvider): string {
|
||||
portalRequestSequence += 1;
|
||||
return `${provider}-${Date.now().toString(36)}-${portalRequestSequence}`;
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number): string {
|
||||
return `${durationMs.toFixed(1)}ms`;
|
||||
}
|
||||
|
||||
function logPortalDebugSection(
|
||||
label: string,
|
||||
value: unknown,
|
||||
method: 'log' | 'error' = 'log'
|
||||
): void {
|
||||
if (typeof console[method] === 'function') {
|
||||
console[method](label, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function createPortalDebugRequestContext(input: {
|
||||
provider: PortalDebugProvider;
|
||||
operation: string;
|
||||
transport: PortalDebugTransport;
|
||||
request: unknown;
|
||||
}): PortalDebugRequestContext {
|
||||
return {
|
||||
requestId: createPortalRequestId(input.provider),
|
||||
provider: input.provider,
|
||||
operation: input.operation,
|
||||
transport: input.transport,
|
||||
request: input.request,
|
||||
startedAt: getNow(),
|
||||
startedAtIso: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPortalDebugSuccessEvent(
|
||||
context: PortalDebugRequestContext,
|
||||
response: unknown
|
||||
): PortalDebugEvent {
|
||||
return {
|
||||
requestId: context.requestId,
|
||||
provider: context.provider,
|
||||
operation: context.operation,
|
||||
transport: context.transport,
|
||||
request: context.request,
|
||||
response,
|
||||
startedAt: context.startedAtIso,
|
||||
durationMs: getNow() - context.startedAt,
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
export function createPortalDebugErrorEvent(
|
||||
context: PortalDebugRequestContext,
|
||||
error: unknown
|
||||
): PortalDebugEvent {
|
||||
return {
|
||||
requestId: context.requestId,
|
||||
provider: context.provider,
|
||||
operation: context.operation,
|
||||
transport: context.transport,
|
||||
request: context.request,
|
||||
error,
|
||||
startedAt: context.startedAtIso,
|
||||
durationMs: getNow() - context.startedAt,
|
||||
status: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
export function logPortalDebugRequest(
|
||||
context: PortalDebugRequestContext
|
||||
): void {
|
||||
if (!isPortalDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.groupCollapsed(
|
||||
`[PortalDebug:${context.provider}] ${context.operation} request ${context.requestId}`
|
||||
);
|
||||
logPortalDebugSection('meta', {
|
||||
requestId: context.requestId,
|
||||
provider: context.provider,
|
||||
operation: context.operation,
|
||||
transport: context.transport,
|
||||
startedAt: context.startedAtIso,
|
||||
});
|
||||
logPortalDebugSection('request', context.request);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
export function logPortalDebugEvent(event: PortalDebugEvent): void {
|
||||
if (!isPortalDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusLabel = event.status === 'success' ? 'response' : 'error';
|
||||
console.groupCollapsed(
|
||||
`[PortalDebug:${event.provider}] ${event.operation} ${statusLabel} ${event.requestId} ${formatDuration(event.durationMs)}`
|
||||
);
|
||||
logPortalDebugSection('meta', {
|
||||
requestId: event.requestId,
|
||||
provider: event.provider,
|
||||
operation: event.operation,
|
||||
transport: event.transport,
|
||||
startedAt: event.startedAt,
|
||||
durationMs: event.durationMs,
|
||||
status: event.status,
|
||||
});
|
||||
logPortalDebugSection('request', event.request);
|
||||
|
||||
if (event.status === 'success') {
|
||||
logPortalDebugSection('response', event.response);
|
||||
} else {
|
||||
logPortalDebugSection('error', event.error, 'error');
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user