Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+21
View File
@@ -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',
],
};
+21
View File
@@ -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"
}
}
}
+19
View File
@@ -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';
}
}
@@ -0,0 +1,57 @@
<section class="rail" [class.rail--active]="active()">
<header class="rail__header">
<h3 class="rail__title">
<span class="rail__label">{{ label() }}</span>
@if ((totalCount() ?? 0) > 0) {
<span class="rail__count" aria-hidden="true">{{
totalCount()
}}</span>
}
</h3>
@if (seeAllLink(); as link) {
<a class="rail__see-all" [routerLink]="link">
<span class="rail__see-all-label">
@if (seeAllLabel()) {
{{ seeAllLabel() }}
} @else {
{{ 'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate }}
}
</span>
<mat-icon>chevron_right</mat-icon>
</a>
}
</header>
<div class="rail__viewport">
<button
type="button"
class="rail__chev rail__chev--left"
[class.rail__chev--visible]="canScrollLeft()"
(click)="scrollBy(-1)"
aria-label="Scroll left"
tabindex="-1"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div
#track
class="rail__track"
(scroll)="onScroll()"
role="list"
>
<ng-content></ng-content>
</div>
<button
type="button"
class="rail__chev rail__chev--right"
[class.rail__chev--visible]="canScrollRight()"
(click)="scrollBy(1)"
aria-label="Scroll right"
tabindex="-1"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
</section>
@@ -0,0 +1,267 @@
:host {
display: block;
--rail-gap: 16px;
}
.rail {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0 0;
border: 1px solid transparent;
border-radius: 16px;
transition:
background 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
// Optional active accent — used by recently-added when a section matches the
// currently-scoped content type. Mirrors the prior `.section--active` look.
.rail--active {
padding: 12px 14px 0;
border-color: var(--app-selection-border);
background: linear-gradient(
145deg,
var(--app-selection-surface),
color-mix(in srgb, var(--app-selection-color) 6%, transparent)
);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 14px 24px -22px var(--app-selection-glow);
.rail__label {
color: var(--app-selection-color);
}
}
// ── Header ─────────────────────────────────────────────────────────────────
.rail__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 2px;
min-height: 24px;
}
.rail__title {
margin: 0;
display: inline-flex;
align-items: baseline;
gap: 8px;
font-size: 1.125rem;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.012em;
color: var(--app-heading-color, #1a1d23);
}
.rail__count {
font-size: 0.78rem;
font-weight: 500;
line-height: 1;
color: var(--app-muted-color, #8891a4);
font-variant-numeric: tabular-nums;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-muted-color, #8891a4) 18%,
transparent
);
letter-spacing: 0;
}
.rail__see-all {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 4px 8px 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 500;
line-height: 1;
letter-spacing: 0.005em;
color: var(--app-muted-color, #8891a4);
text-decoration: none;
transition:
color 0.15s ease,
background-color 0.15s ease;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
transition: transform 0.15s ease;
}
&:hover {
color: var(--app-selection-color, #2f7bff);
background: color-mix(
in srgb,
var(--app-selection-color, #2f7bff) 12%,
transparent
);
mat-icon {
transform: translateX(2px);
}
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 2px;
}
}
// ── Viewport ───────────────────────────────────────────────────────────────
.rail__viewport {
position: relative;
min-width: 0;
// Edge gradients hint at horizontal scroll without interaction.
&::before,
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 32px;
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 0.2s ease;
}
&::before {
left: 0;
background: linear-gradient(
to right,
var(--app-content-bg, #161a22) 0%,
transparent 100%
);
}
&::after {
right: 0;
background: linear-gradient(
to left,
var(--app-content-bg, #161a22) 0%,
transparent 100%
);
}
&:has(.rail__chev--left.rail__chev--visible)::before {
opacity: 0.9;
}
&:has(.rail__chev--right.rail__chev--visible)::after {
opacity: 0.9;
}
&:hover .rail__chev--visible {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
.rail__track {
display: flex;
align-items: flex-start;
gap: var(--rail-gap);
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-padding-inline-start: 4px;
scrollbar-width: none;
padding: 4px 0 16px;
margin: -4px 0 0;
&::-webkit-scrollbar {
display: none;
}
// Callers style their own projected cards (width, scroll-snap-align,
// flex: 0 0 auto) — projected content stays in the parent's view
// encapsulation and can't be reached from here without `::ng-deep`.
}
// ── Chevron — streaming-app circle (matches dashboard rail) ────────────────
.rail__chev {
position: absolute;
top: 50%;
transform: translateY(-50%) scale(0.96);
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 50%;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #fff;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition:
opacity 0.18s ease,
background-color 0.18s ease,
transform 0.18s ease,
box-shadow 0.18s ease;
z-index: 2;
box-shadow:
0 4px 14px -4px rgba(0, 0, 0, 0.55),
0 0 0 0 rgba(255, 255, 255, 0);
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
line-height: 24px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}
&--visible {
opacity: 0.5;
pointer-events: auto;
}
&:hover {
background: rgba(0, 0, 0, 0.88);
transform: translateY(-50%) scale(1.08);
box-shadow:
0 8px 22px -6px rgba(0, 0, 0, 0.65),
0 0 0 4px rgba(255, 255, 255, 0.06);
}
&:active {
transform: translateY(-50%) scale(0.96);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 3px;
}
&--left {
left: 6px;
}
&--right {
right: 6px;
}
}
@media (hover: none) {
.rail__chev {
display: none;
}
.rail__viewport::before,
.rail__viewport::after {
display: none;
}
}
@@ -0,0 +1,95 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
effect,
input,
signal,
viewChild,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
/**
* Reusable horizontal-rail chrome: label + count badge + "see all" link,
* scroll-snap track with projected cards, fade-in chevrons and edge gradients
* that appear when scroll is possible. Matches the dashboard rail pattern so
* any rail-style surface in the app reads as part of the same design system.
*
* Card markup is projected — callers supply whatever card component fits the
* surface (simple poster tile, `app-content-card`, etc.). The shell only
* owns the container, header, and navigation affordances.
*/
@Component({
selector: 'app-content-rail-shell',
standalone: true,
imports: [MatIcon, RouterLink, TranslatePipe],
templateUrl: './content-rail-shell.component.html',
styleUrl: './content-rail-shell.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContentRailShellComponent implements AfterViewInit, OnDestroy {
readonly label = input.required<string>();
/** Subtle pill next to the label. Hidden when null or <= 0. */
readonly totalCount = input<number | null>(null);
readonly seeAllLink = input<string[] | null>(null);
/**
* Already-translated label for the see-all link. Falls back to the
* dashboard's "Manage all" translation when null, so dashboard callers
* don't need to pass anything.
*/
readonly seeAllLabel = input<string | null>(null);
/** Optional active-accent decoration (e.g. the currently-scoped section). */
readonly active = input<boolean>(false);
private readonly track = viewChild.required<ElementRef<HTMLDivElement>>('track');
readonly canScrollLeft = signal(false);
readonly canScrollRight = signal(false);
private readonly viewReady = signal(false);
private resizeObserver?: ResizeObserver;
constructor() {
// Re-measure whenever the track contents change (projected cards).
effect(() => {
if (!this.viewReady()) return;
// Wait one frame so projected content lays out first.
requestAnimationFrame(() => this.updateScrollState());
});
}
ngAfterViewInit(): void {
this.viewReady.set(true);
this.updateScrollState();
this.resizeObserver = new ResizeObserver(() => this.updateScrollState());
this.resizeObserver.observe(this.track().nativeElement);
}
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
}
onScroll(): void {
this.updateScrollState();
}
scrollBy(direction: 1 | -1): void {
const el = this.track().nativeElement;
el.scrollBy({
left: direction * el.clientWidth * 0.85,
behavior: 'smooth',
});
}
private updateScrollState(): void {
const el = this.track().nativeElement;
this.canScrollLeft.set(el.scrollLeft > 4);
this.canScrollRight.set(
el.scrollLeft + el.clientWidth < el.scrollWidth - 4
);
}
}
@@ -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>
@@ -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);
}
}
@@ -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();
}
}
@@ -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>
@@ -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;
}
}
@@ -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);
});
}
@@ -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>
@@ -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;
}
}
}
}
@@ -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(['/']);
}
}
@@ -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>
@@ -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;
}
@@ -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();
}
}
@@ -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)"
/>
}
}
@@ -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));
}
}
@@ -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>
@@ -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);
}
}
@@ -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();
}
}
@@ -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>);
}
@@ -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()"
/>
}
}
}
@@ -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;
}
}
@@ -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),
}),
},
},
}
);
});
});
@@ -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>) }
: {};
}
}
@@ -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>
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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>
@@ -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;
}
}
@@ -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';
+6
View File
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+28
View File
@@ -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"
}
]
}
+12
View File
@@ -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"]
}
+17
View File
@@ -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"
]
}