feat: refactor xtream-electron related views

- Updated the HTML structure for better organization and readability.
- Enhanced the filtering and searching capabilities for recently viewed items.
- Introduced a new VodDetails component for displaying detailed information about VOD items.
- Implemented workspace layout handling in the main container and navigation components.
- Improved styling for various states in the season container.
- Added support for inline search input in the search results component.
- Refactored the Xtream shell component to conditionally render navigation based on layout.
- Updated styles to use CSS variables for consistency across components.
This commit is contained in:
4gray committed 2026-02-24 19:13:25 +01:00
1 parent a9be475ce1
commit fd2f5972b0
33 files changed
+1393 -659

No files matched your search

@@ -10,7 +10,7 @@
}
.info-section {
border: 1px solid #333;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
@@ -20,7 +20,7 @@
margin-bottom: 16px;
padding-left: 16px;
font-size: 1.1em;
border-bottom: 1px solid #333;
border-bottom: 1px solid var(--mat-sys-outline-variant);
padding-bottom: 8px;
}
@@ -37,17 +37,17 @@ strong {
}
.active {
color: #4caf50;
color: var(--mat-sys-primary);
font-weight: 500;
}
.trial {
color: #ff9800;
color: var(--mat-sys-error);
font-weight: 500;
}
.formats {
color: #2196f3;
color: var(--mat-sys-tertiary);
}
.ports {
@@ -58,13 +58,14 @@ strong {
.ports span {
padding: 4px 8px;
background: #2196f3;
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
border-radius: 4px;
font-size: 0.9em;
}
.welcome-message {
border: 1px solid #333;
border: 1px solid var(--mat-sys-outline-variant);
padding: 16px;
border-radius: 4px;
margin-bottom: 16px;
@@ -25,16 +25,37 @@
<app-stalker-series-view />
}
} @else {
<app-grid-list
[isLoading]="isPaginatedContentLoading()"
[items]="contentWithProgress()"
(itemClicked)="onItemClick($event)"
[pageIndex]="pageIndex()"
[totalPages]="totalPages()"
[limit]="limit()"
[pageSizeOptions]="pageSizeOptions"
(pageChange)="onPageChange($event)"
/>
<div class="category-content-layout">
<div class="category-content-header">
<div class="category-meta">
<h2 class="category-title">{{ selectedCategoryTitle() }}</h2>
<div class="category-subtitle">
{{ categoryItemSubtitle() }}
</div>
</div>
@if (categoryItemCount() > 0) {
<mat-paginator
[pageIndex]="pageIndex()"
[length]="categoryItemCount()"
[pageSize]="limit()"
[pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)"
aria-label="Select page"
/>
}
</div>
<app-grid-list
[isLoading]="isPaginatedContentLoading()"
[items]="contentWithProgress()"
(itemClicked)="onItemClick($event)"
[pageIndex]="pageIndex()"
[totalPages]="totalPages()"
[limit]="limit()"
[pageSizeOptions]="pageSizeOptions"
[showPaginator]="false"
(pageChange)="onPageChange($event)"
/>
</div>
}
} @else {
<app-playlist-error-view
@@ -1,4 +1,105 @@
:host {
overflow-y: auto;
/* padding: 10px; */
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.category-content-layout {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
// ─── Sticky header bar — styled like a widget header ─────────────────────────
.category-content-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 0 20px;
min-height: 52px;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 2;
// distinct widget-header surface so it reads over the content below
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
// ─── Category meta (title + subtitle) ────────────────────────────────────────
.category-meta {
min-width: 0;
display: flex;
align-items: baseline;
gap: 10px;
flex: 1;
}
.category-title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
// Explicit token so it never blends with the header background
color: var(--app-heading-color, var(--mat-sys-on-surface));
letter-spacing: -0.01em;
}
.category-subtitle {
white-space: nowrap;
font-size: 0.78rem;
font-weight: 500;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
// pill chip appearance
background: var(--mat-sys-surface-container);
padding: 2px 8px;
border-radius: 999px;
flex-shrink: 0;
}
// ─── Paginator — flush right, no ghost background ────────────────────────────
mat-paginator {
background: transparent !important;
flex-shrink: 0;
// Shrink paginator text to fit header height comfortably
--mat-paginator-container-size: 40px;
--mat-paginator-enabled-icon-color: var(--app-body-color, var(--mat-sys-on-surface-variant));
--mat-paginator-disabled-icon-color: rgba(255, 255, 255, 0.18);
}
// ─── Grid scroll area ─────────────────────────────────────────────────────────
app-grid-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 20px;
display: block;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
}
@media (max-width: 768px) {
.category-content-header {
flex-direction: column;
align-items: flex-start;
padding: 10px 16px;
min-height: unset;
}
.category-meta {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
app-grid-list {
padding: 12px 16px 16px;
}
}
@@ -7,7 +7,7 @@ import {
OnInit,
signal,
} from '@angular/core';
import { PageEvent } from '@angular/material/paginator';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
@@ -21,6 +21,7 @@ import {
import { DownloadsService } from '../../services/downloads.service';
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
import { createLogger } from '../../shared/utils/logger';
import { StalkerVodSource } from '../../stalker/models';
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
import {
buildStalkerSelectedVodItem,
@@ -32,10 +33,41 @@ import {
toggleStalkerVodFavorite,
} from '../../stalker/stalker-vod.utils';
import { StalkerStore } from '../../stalker/stalker.store';
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
import { XTREAM_DATA_SOURCE } from '../data-sources';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
import { XtreamStore } from '../stores/xtream.store';
import { VodDetailsComponent } from '../vod-details/vod-details.component';
interface CategoryContentItem {
id?: number | string;
is_series?: number | string | boolean;
xtream_id?: number | string;
series_id?: number | string;
stream_id?: number | string;
category_id?: number | string;
[key: string]: unknown;
}
interface CategoryStoreLike {
totalCount?: () => number;
selectItemsFromSelectedCategory?: () => unknown[];
getProgressPercent?: (id: number, type: 'vod') => number;
hasSeriesProgress?: (id: number) => boolean;
isWatched?: (id: number, type: 'vod') => boolean;
currentPlaylist?: () => { id?: string } | null;
loadAllPositions?: (playlistId: string) => void;
selectedCategoryId?: () => number | null;
}
interface DownloadVodData {
id?: string | number;
has_files?: unknown;
info?: {
name?: string;
movie_image?: string;
};
title?: string;
}
@Component({
selector: 'app-category-content-view',
@@ -43,6 +75,7 @@ import { XtreamStore } from '../stores/xtream.store';
styleUrls: ['./category-content-view.component.scss'],
imports: [
GridListComponent,
MatPaginatorModule,
PlaylistErrorViewComponent,
StalkerSeriesViewComponent,
TranslatePipe,
@@ -73,6 +106,44 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
readonly pageSizeOptions = this.isStalker ? [14] : [10, 25, 50, 100];
readonly selectedCategory = this.store.getSelectedCategory;
readonly paginatedContent = this.store.getPaginatedContent;
readonly selectedCategoryTitle = computed(() => {
const category = this.selectedCategory();
if (!category) return '';
// category_name is the StalkerCategoryItem field; name is the Xtream field
const fromCategory =
category.category_name ?? category.name ?? category.title ?? '';
if (fromCategory) return fromCategory;
// Stalker: fall back to getSelectedCategoryName computed (looks up directly from categories array)
if (this.isStalker) {
const stalkerStore = this.store as unknown as {
getSelectedCategoryName?: () => string;
};
const fromStoreComputed =
stalkerStore.getSelectedCategoryName?.() ?? '';
if (fromStoreComputed) return fromStoreComputed;
}
return '';
});
readonly categoryItemCount = computed(() => {
const storeLike = this.store as unknown as CategoryStoreLike;
if (this.isStalker && typeof storeLike.totalCount === 'function') {
return storeLike.totalCount();
}
if (typeof storeLike.selectItemsFromSelectedCategory === 'function') {
return storeLike.selectItemsFromSelectedCategory().length;
}
return 0;
});
readonly categoryItemSubtitle = computed(() => {
const itemCount = this.categoryItemCount();
return `${itemCount} ${itemCount === 1 ? 'item' : 'items'}`;
});
/** Stalker playback positions (key: `${contentType}_${id}`) */
private readonly stalkerPositions = signal<
@@ -149,14 +220,14 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
const seriesPositions = this.stalkerSeriesPositions();
const contentType = this.contentType();
return items.map((item: any) => {
return items.map((item: CategoryContentItem) => {
const id = Number(item.id);
// Check if this item has series/episode progress (watched episodes)
// This works even when is_series flag isn't set on the item
const hasSeriesProgress =
seriesPositions.has(id) &&
seriesPositions.get(id)!.length > 0;
const hasSeriesProgress = Boolean(
seriesPositions.get(id)?.length
);
// For series content type, or items with is_series flag
const isSeries =
@@ -182,10 +253,10 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
});
}
const xtreamStore = this.store as any;
const xtreamStore = this.store as unknown as CategoryStoreLike;
if (!xtreamStore.getProgressPercent) return items;
return items.map((item: any) => {
return items.map((item: CategoryContentItem) => {
const isSeries = this.contentType() === 'series';
const id = Number(
item.xtream_id || item.series_id || item.stream_id
@@ -264,9 +335,10 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
// Ensure playback positions are loaded (Xtream only - Stalker is handled via effect in constructor)
if (!this.isStalker) {
const xtreamStore = this.store as any;
if (xtreamStore.currentPlaylist()?.id) {
xtreamStore.loadAllPositions(xtreamStore.currentPlaylist().id);
const xtreamStore = this.store as unknown as CategoryStoreLike;
const playlistId = xtreamStore.currentPlaylist?.()?.id;
if (playlistId) {
xtreamStore.loadAllPositions?.(playlistId);
}
}
@@ -290,9 +362,10 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
}
if (this.isStalker) {
const storeLike = this.store as unknown as CategoryStoreLike;
this.logger.warn('Stalker category init from route', {
routeCategoryId: categoryId ?? null,
selectedCategoryId: (this.store as any).selectedCategoryId?.(),
selectedCategoryId: storeLike.selectedCategoryId?.(),
selectedContentType: this.contentType(),
});
}
@@ -370,9 +443,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
localStorage.setItem('xtream-page-size', event.pageSize.toString());
}
onItemClick(item: any) {
onItemClick(item: CategoryContentItem) {
const selectedItem = buildStalkerSelectedVodItem(
item,
item as StalkerVodSource,
this.contentType() === 'vod' &&
(item.is_series === '1' || item.is_series === 1)
);
@@ -380,13 +453,17 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
if (this.isStalker) {
this.store.setSelectedItem(selectedItem);
} else {
const xtreamStore = this.store as unknown as CategoryStoreLike;
// When viewing "Recently Added" (no category selected), include category_id in path
const categoryId = this.store.selectedCategoryId();
if (categoryId) {
const categoryId = xtreamStore.selectedCategoryId?.();
if (categoryId && item.xtream_id !== undefined) {
this.router.navigate([item.xtream_id], {
relativeTo: this.activatedRoute,
});
} else {
} else if (
item.category_id !== undefined &&
item.xtream_id !== undefined
) {
this.router.navigate([item.category_id, item.xtream_id], {
relativeTo: this.activatedRoute,
});
@@ -402,7 +479,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
await this.store.createLinkToPlayVod(cmd, title, thumbnail);
}
addToFavorites(item: any, onDone?: () => void) {
addToFavorites(item: Record<string, unknown>, onDone?: () => void) {
this.logger.debug('Add to favorites', item);
if (this.isStalker) {
(this.store as InstanceType<typeof StalkerStore>).addToFavorites(
@@ -487,7 +564,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
if (!playlist || !playlist.portalUrl || !playlist.macAddress) return;
let cmdToUse = item.cmd;
const itemData = item.data as any;
const itemData = item.data as DownloadVodData;
const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
// Align with playback path: resolve has_files references to /media/file_{id}.mpg
@@ -1,19 +1,29 @@
@if (items()?.length > 0) {
<mat-nav-list>
<div class="nav-list">
@for (item of items(); track $index) {
<mat-list-item
class="category-item"
<a
class="nav-item category-item"
[attr.data-category-id]="$any(item).category_id ?? item.id"
[class.selected]="isSelected(item)"
(click)="categoryClicked.emit(item)"
>
{{
item.category_name || $any(item).name || 'No category name'
}}
<span class="nav-item-label">
{{
item.category_name ||
$any(item).name ||
'No category name'
}}
</span>
@if (showCounts()) {
<span class="item-count">({{ getItemCount(item) }})</span>
<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>
}
</mat-list-item>
</a>
} @empty {
<app-playlist-error-view
[title]="'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate"
@@ -24,7 +34,7 @@
[viewType]="'NO_SEARCH_RESULTS'"
/>
}
</mat-nav-list>
</div>
} @else {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE' | translate"
@@ -1,7 +1,6 @@
:host {
height: 100%;
overflow-y: auto;
padding: 0 10px;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
@@ -12,11 +11,6 @@
width: auto;
transition: all 0.2s ease;
&.selected {
background-color: rgba(63, 81, 181, 0.1);
margin-left: 4px;
}
.item-count {
opacity: 0.6;
font-size: 0.85em;
@@ -1,13 +1,18 @@
<div class="downloads">
<header class="downloads__header">
<div class="downloads__header-left">
<h1>{{ 'DOWNLOADS.TITLE' | translate }}</h1>
@if (activeCount() > 0) {
<span class="downloads__badge">{{ activeCount() }}</span>
}
<div class="settings-group__header-icon">
<mat-icon>downloading</mat-icon>
</div>
<div class="settings-group__header-text">
<h1>{{ 'DOWNLOADS.TITLE' | translate }}</h1>
@if (activeCount() > 0) {
<span class="downloads__badge">{{ activeCount() }}</span>
}
</div>
</div>
<div class="downloads__header-actions">
@if (hasDownloads()) {
@if (hasDownloads() && !isWorkspaceLayout) {
<button
mat-stroked-button
(click)="clearCompleted()"
@@ -21,11 +26,11 @@
</header>
<div class="downloads__folder">
<mat-icon>folder</mat-icon>
<mat-icon>folder_open</mat-icon>
<span class="downloads__folder-path">{{
downloadFolder() || ('DOWNLOADS.NO_FOLDER' | translate)
}}</span>
<button mat-stroked-button (click)="changeFolder()">
<button mat-flat-button color="accent" (click)="changeFolder()">
{{ 'DOWNLOADS.CHANGE_FOLDER' | translate }}
</button>
</div>
@@ -42,6 +47,12 @@
<p>{{ 'DOWNLOADS.EMPTY' | translate }}</p>
<span>{{ 'DOWNLOADS.EMPTY_HINT' | translate }}</span>
</div>
} @else if (filteredDownloads().length === 0) {
<div class="downloads__empty">
<mat-icon>search_off</mat-icon>
<p>No downloads match the current filter</p>
<span>Try a different search term</span>
</div>
} @else {
<div class="downloads__list-wrapper">
<div class="downloads__list">
@@ -141,6 +152,7 @@
@case ('completed') {
<button
mat-icon-button
color="primary"
(click)="play(item)"
[matTooltip]="
'DOWNLOADS.PLAY' | translate
@@ -159,6 +171,7 @@
</button>
<button
mat-icon-button
color="warn"
(click)="remove(item)"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
@@ -170,6 +183,7 @@
@case ('failed') {
<button
mat-icon-button
color="accent"
(click)="retry(item)"
[matTooltip]="
'DOWNLOADS.RETRY' | translate
@@ -179,6 +193,7 @@
</button>
<button
mat-icon-button
color="warn"
(click)="remove(item)"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
@@ -190,6 +205,7 @@
@case ('canceled') {
<button
mat-icon-button
color="accent"
(click)="retry(item)"
[matTooltip]="
'DOWNLOADS.RETRY' | translate
@@ -199,6 +215,7 @@
</button>
<button
mat-icon-button
color="warn"
(click)="remove(item)"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
@@ -6,7 +6,6 @@
display: flex;
flex-direction: column;
height: calc(100vh - 20px);
padding: 1rem;
overflow: hidden;
&__header {
@@ -14,18 +13,43 @@
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
padding: 24px 16px 12px;
border-bottom: 1px solid var(--mat-sys-outline-variant);
&-left {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 16px;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 500;
.settings-group__header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: 14px;
background: var(--mat-sys-secondary-container);
color: var(--mat-sys-on-secondary-container);
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
.settings-group__header-text {
display: flex;
align-items: center;
gap: 0.75rem;
h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 800;
letter-spacing: -0.02em;
}
}
.back-btn {
@@ -40,8 +64,8 @@
}
&__badge {
background: var(--primary-color, #3f51b5);
color: white;
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
@@ -52,20 +76,21 @@
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: var(--surface-color, #f5f5f5);
border-radius: 8px;
padding: 12px 14px;
margin: 0 16px 1rem;
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
border: 1px solid var(--mat-sys-primary);
border-radius: 12px;
mat-icon {
color: var(--text-secondary, rgba(0, 0, 0, 0.6));
color: var(--mat-sys-primary);
}
&-path {
flex: 1;
font-family: monospace;
font-family: 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, monospace;
font-size: 0.875rem;
color: var(--text-secondary, rgba(0, 0, 0, 0.6));
color: var(--mat-sys-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -79,20 +104,21 @@
justify-content: center;
flex: 1;
text-align: center;
color: var(--text-secondary, rgba(0, 0, 0, 0.6));
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 4rem;
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
opacity: 0.4;
}
p {
margin: 0 0 0.5rem;
font-size: 1.25rem;
font-weight: 500;
font-weight: 600;
color: var(--mat-sys-on-surface);
}
span {
@@ -103,14 +129,14 @@
&__list-wrapper {
flex: 1;
overflow-y: auto;
margin: 0 -1rem;
padding: 0 1rem;
padding: 0 16px;
}
&__list {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 12px;
padding-bottom: 24px;
}
&__item {
@@ -118,17 +144,27 @@
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--card-background, #fff);
border-radius: 12px;
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
transition: box-shadow 0.2s ease, transform 0.2s ease;
background: var(--mat-sys-surface-container-low);
border-radius: 16px;
border: 1px solid var(--mat-sys-outline-variant);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
background: var(--mat-sys-surface-container);
border-color: var(--mat-sys-outline);
.downloads__item-actions {
opacity: 1;
transform: translateX(0);
}
}
// Status colors using Material semantics where possible, falling back to clean hexes
&.status-downloading {
border-left: 4px solid #2196f3;
background: color-mix(in srgb, #2196f3 4%, var(--mat-sys-surface-container-low));
}
&.status-completed {
@@ -136,23 +172,25 @@
}
&.status-failed {
border-left: 4px solid #f44336;
border-left: 4px solid var(--mat-sys-error);
background: color-mix(in srgb, var(--mat-sys-error) 4%, var(--mat-sys-surface-container-low));
}
&.status-queued {
border-left: 4px solid #9e9e9e;
border-left: 4px solid var(--mat-sys-outline);
}
&-poster {
width: 60px;
height: 85px;
border-radius: 6px;
width: 64px;
height: 90px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background: var(--surface-color, #f5f5f5);
background: var(--mat-sys-surface-variant);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
img {
width: 100%;
@@ -164,7 +202,8 @@
font-size: 2rem;
width: 2rem;
height: 2rem;
color: var(--text-secondary, rgba(0, 0, 0, 0.38));
color: var(--mat-sys-on-surface-variant);
opacity: 0.5;
}
}
@@ -177,8 +216,9 @@
}
&-title {
font-size: 1rem;
font-weight: 500;
font-size: 1.05rem;
font-weight: 600;
color: var(--mat-sys-on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -191,51 +231,67 @@
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.8125rem;
color: var(--text-secondary, rgba(0, 0, 0, 0.6));
font-size: 0.85rem;
color: var(--mat-sys-on-surface-variant);
}
&-status {
display: flex;
align-items: center;
gap: 0.25rem;
gap: 0.35rem;
font-weight: 500;
padding: 2px 8px;
border-radius: 12px;
background: var(--mat-sys-surface-container-high);
mat-icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
font-size: 1.1rem;
width: 1.1rem;
height: 1.1rem;
}
&.status-downloading {
color: #2196f3;
background: color-mix(in srgb, #2196f3 12%, transparent);
}
&.status-completed {
color: #4caf50;
background: color-mix(in srgb, #4caf50 12%, transparent);
}
&.status-failed {
color: #f44336;
color: var(--mat-sys-error);
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
}
&.status-queued {
color: #9e9e9e;
color: var(--mat-sys-on-surface-variant);
}
}
&-size {
font-family: monospace;
font-family: 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, monospace;
background: var(--mat-sys-surface-container-high);
padding: 2px 8px;
border-radius: 12px;
}
&-error {
color: #f44336;
font-size: 0.75rem;
color: var(--mat-sys-error);
font-size: 0.8rem;
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
padding: 2px 8px;
border-radius: 12px;
}
&-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
opacity: 0.7;
transform: translateX(4px);
transition: all 0.2s ease;
}
mat-progress-bar {
@@ -245,58 +301,14 @@
}
&__episode-badge {
background: var(--primary-color, #3f51b5);
color: white;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
padding: 0.125rem 0.5rem;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
// Dark theme support
:host-context(body.dark-theme) {
.downloads {
&__header {
border-bottom-color: rgba(255, 255, 255, 0.12);
}
&__folder {
background: rgba(255, 255, 255, 0.05);
mat-icon {
color: rgba(255, 255, 255, 0.6);
}
&-path {
color: rgba(255, 255, 255, 0.6);
}
}
&__empty {
color: rgba(255, 255, 255, 0.6);
}
&__item {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.08);
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
&-poster {
background: rgba(255, 255, 255, 0.08);
mat-icon {
color: rgba(255, 255, 255, 0.38);
}
}
&-meta {
color: rgba(255, 255, 255, 0.6);
}
}
}
}
@@ -5,12 +5,14 @@ import {
inject,
OnInit,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { map } from 'rxjs';
import {
DownloadItem,
DownloadsService,
@@ -32,22 +34,39 @@ import {
export class DownloadsComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
readonly downloadsService = inject(DownloadsService);
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly downloads = this.downloadsService.downloads;
readonly downloadFolder = this.downloadsService.downloadFolder;
readonly isAvailable = this.downloadsService.isAvailable;
readonly hasDownloads = this.downloadsService.hasDownloads;
readonly activeCount = this.downloadsService.activeCount;
readonly searchTerm = toSignal(
this.route.queryParamMap.pipe(
map((params) => (params.get('q') ?? '').trim().toLowerCase())
),
{ initialValue: '' }
);
/** Filter downloads for current playlist and sort by newest first */
readonly filteredDownloads = computed(() => {
const playlistId = this.route.snapshot.params['id'];
const term = this.searchTerm();
const downloads = playlistId
? this.downloads().filter((d) => d.playlistId === playlistId)
: this.downloads();
const filteredByTerm = term
? downloads.filter((item) =>
`${item.title ?? ''} ${item.errorMessage ?? ''}`
.toLowerCase()
.includes(term)
)
: downloads;
// Sort by createdAt descending (newest first)
return [...downloads].sort((a, b) => {
return [...filteredByTerm].sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA;
@@ -1,6 +1,6 @@
<app-favorites-layout
[categories]="categories()"
[favorites]="favoritesToShow()"
[favorites]="filteredFavoritesToShow()"
[playlistSubtitle]="'Xtream Code'"
[playlistTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
[selectedCategoryId]="selectedCategoryId()"
@@ -1,11 +1,20 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import {
Component,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatCardModule } from '@angular/material/card';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { selectActivePlaylist } from 'm3u-state';
import { BehaviorSubject, switchMap } from 'rxjs';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { XtreamCategory } from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import { FavoriteItem } from '../services/favorite-item.interface';
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../stores/xtream.store';
@@ -16,22 +25,56 @@ import { XtreamStore } from '../stores/xtream.store';
templateUrl: './favorites.component.html',
styleUrls: ['./favorites.component.scss', '../sidebar.scss'],
})
export class FavoritesComponent implements OnInit {
export class FavoritesComponent implements OnInit, OnDestroy {
private favoritesService = inject(FavoritesService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private store = inject(Store);
private xtreamStore = inject(XtreamStore);
private readonly favoritesCtx = inject(FavoritesContextService);
readonly categories = signal<XtreamCategory[]>([]);
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly allItems = signal<FavoriteItem[]>([]);
readonly series = signal<FavoriteItem[]>([]);
readonly movies = signal<FavoriteItem[]>([]);
readonly live = signal<FavoriteItem[]>([]);
readonly searchTerm = toSignal(
this.route.queryParamMap.pipe(
map((params) => (params.get('q') ?? '').trim().toLowerCase())
),
{ initialValue: '' }
);
readonly favoritesToShow = signal<FavoriteItem[]>([]);
readonly selectedCategoryId = signal<string>('movie');
/** Synced with the workspace context service so panel clicks are reactive */
readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId;
/** Items filtered by the active category — fully reactive, no imperative calls */
readonly filteredFavoritesToShow = computed(() => {
const term = this.searchTerm();
const categoryId = this.selectedCategoryId();
let items: FavoriteItem[];
switch (categoryId) {
case 'movie':
items = this.movies();
break;
case 'live':
items = this.live();
break;
case 'series':
items = this.series();
break;
default:
items = this.allItems();
}
if (!term) return items;
return items.filter((item) =>
`${item?.title ?? ''}`.toLowerCase().includes(term)
);
});
private favoritesRefresh$ = new BehaviorSubject<void>(undefined);
@@ -43,59 +86,53 @@ export class FavoritesComponent implements OnInit {
switchMap(() => this.favoritesService.getFavorites(playlistId))
)
.subscribe((items) => {
this.allItems.set(items);
this.movies.set(
items.filter((item) => (item as any).type === 'movie')
);
this.live.set(items.filter((item) => item.type === 'live'));
this.series.set(items.filter((item) => item.type === 'series'));
this.initCategories();
this.setCategoryContent(this.selectedCategoryId());
});
}
initCategories() {
this.categories.set([
const updated: XtreamCategory[] = [
{
id: 1,
category_id: 'movie',
category_name: 'Movies' + ' (' + this.movies().length + ')',
category_id: 'all',
category_name: 'All',
count: this.allItems().length,
parent_id: 0,
},
{
id: 2,
category_id: 'live',
category_name: 'Live TV' + ' (' + this.live().length + ')',
category_id: 'movie',
category_name: 'Movies',
count: this.movies().length,
parent_id: 0,
},
{
id: 3,
category_id: 'series',
category_name: 'Series' + ' (' + this.series().length + ')',
category_id: 'live',
category_name: 'Live TV',
count: this.live().length,
parent_id: 0,
},
]);
{
id: 4,
category_id: 'series',
category_name: 'Series',
count: this.series().length,
parent_id: 0,
},
];
this.categories.set(updated);
this.favoritesCtx.setCategories(updated);
}
setCategoryId(categoryId: string) {
this.selectedCategoryId.set(categoryId);
this.setCategoryContent(categoryId);
}
setCategoryContent(categoryId: string) {
switch (categoryId) {
case 'movie':
this.favoritesToShow.set(this.movies());
break;
case 'live':
this.favoritesToShow.set(this.live());
break;
case 'series':
this.favoritesToShow.set(this.series());
break;
default:
this.favoritesToShow.set(this.movies());
break;
}
this.favoritesCtx.setCategoryId(categoryId);
}
async removeFromFavorites(item: any) {
@@ -103,7 +140,6 @@ export class FavoritesComponent implements OnInit {
item.content_id,
item.playlist_id
);
// Refresh favorites after removal
this.favoritesRefresh$.next();
}
@@ -122,4 +158,8 @@ export class FavoritesComponent implements OnInit {
);
}
}
ngOnDestroy(): void {
this.favoritesCtx.reset();
}
}
@@ -1,80 +1,84 @@
@let isEmbeddedPlayer =
player() === 'videojs' || player() === 'html5' || player() === 'artplayer';
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header">
@if (!isWorkspaceLayout || selectedCategoryId()) {
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header">
@if (selectedCategoryId()) {
<button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button>
<h2 class="category-title">
{{ selectedCategoryInfo()?.name }} ({{ selectedCategoryInfo()?.count }})
</h2>
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
[matTooltip]="'Sort: ' + liveChannelSortLabel()"
aria-label="Sort channels"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<mat-menu #channelSortMenu="matMenu">
<button mat-menu-item (click)="setLiveChannelSortMode('server')">
<span>Server Order</span>
@if (liveChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setLiveChannelSortMode('name-asc')">
<span>Name A-Z</span>
@if (liveChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setLiveChannelSortMode('name-desc')">
<span>Name Z-A</span>
@if (liveChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
} @else {
@if (!isWorkspaceLayout) {
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
[subtitle]="'Xtream Code'"
/>
}
<button
mat-icon-button
class="manage-categories-btn"
(click)="openCategoryManagement()"
[matTooltip]="'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate"
>
<mat-icon>tune</mat-icon>
</button>
}
</div>
@if (selectedCategoryId()) {
<button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button>
<h2 class="category-title">
{{ selectedCategoryInfo()?.name }} ({{ selectedCategoryInfo()?.count }})
</h2>
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
[matTooltip]="'Sort: ' + liveChannelSortLabel()"
aria-label="Sort channels"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<mat-menu #channelSortMenu="matMenu">
<button mat-menu-item (click)="setLiveChannelSortMode('server')">
<span>Server Order</span>
@if (liveChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setLiveChannelSortMode('name-asc')">
<span>Name A-Z</span>
@if (liveChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setLiveChannelSortMode('name-desc')">
<span>Name Z-A</span>
@if (liveChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
} @else {
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
[subtitle]="'Xtream Code'"
<app-portal-channels-list
[sortMode]="liveChannelSortMode()"
(playClicked)="playLive($event)"
/>
} @else {
<app-category-view
[items]="categories()"
[itemCounts]="categoryItemCounts()"
(categoryClicked)="selectCategory($event)"
[showCounts]="true"
/>
<button
mat-icon-button
class="manage-categories-btn"
(click)="openCategoryManagement()"
[matTooltip]="'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate"
>
<mat-icon>tune</mat-icon>
</button>
}
</div>
@if (selectedCategoryId()) {
<app-portal-channels-list
[sortMode]="liveChannelSortMode()"
(playClicked)="playLive($event)"
/>
} @else {
<app-category-view
[items]="categories()"
[itemCounts]="categoryItemCounts()"
(categoryClicked)="selectCategory($event)"
[showCounts]="true"
/>
}
</div>
}
<div class="content-container">
@if (streamUrl) {
@if (isEmbeddedPlayer) {
@@ -13,7 +13,7 @@
width: 400px; // Default, will be overridden by directive
min-width: 250px;
max-width: 600px;
border-right: 1px solid #111;
border-right: 1px solid var(--mat-sys-outline-variant);
display: flex;
flex-direction: column;
flex-shrink: 0;
@@ -81,7 +81,10 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly epgItems = this.xtreamStore.epgItems;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly liveChannelSortMode = signal<LiveChannelSortMode>('server');
private readonly pendingAutoOpenLiveItemId = signal<number | null>(null);
readonly liveChannelSortLabel = computed(() => {
const mode = this.liveChannelSortMode();
if (mode === 'name-asc') return 'Name A-Z';
@@ -118,6 +121,39 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
favorites = new Map<number, boolean>();
constructor() {
const requestedItemId = Number(
(window.history.state as Record<string, unknown> | null)?.[
'openXtreamLiveItemId'
]
);
if (Number.isFinite(requestedItemId) && requestedItemId > 0) {
this.pendingAutoOpenLiveItemId.set(requestedItemId);
}
effect(() => {
const pendingId = this.pendingAutoOpenLiveItemId();
if (!pendingId) {
return;
}
const channels = this.xtreamStore.selectItemsFromSelectedCategory() as any[];
if (!Array.isArray(channels) || channels.length === 0) {
return;
}
const item = channels.find(
(channel) => Number(channel?.xtream_id) === pendingId
);
if (!item) {
this.pendingAutoOpenLiveItemId.set(null);
return;
}
this.playLive(item);
this.pendingAutoOpenLiveItemId.set(null);
this.clearAutoOpenHistoryState();
});
effect(() => {
if (!window.electron?.updateRemoteControlStatus) {
return;
@@ -317,4 +353,21 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
this.playLive(channel);
}
private clearAutoOpenHistoryState(): void {
try {
const state = (window.history.state ?? {}) as Record<string, unknown>;
if (!('openXtreamLiveItemId' in state)) {
return;
}
const nextState = { ...state };
delete nextState['openXtreamLiveItemId'];
delete nextState['openXtreamLiveTitle'];
delete nextState['openXtreamLivePoster'];
window.history.replaceState(nextState, document.title);
} catch {
// no-op
}
}
}
@@ -24,19 +24,12 @@
<mat-divider />
<div class="nav-section">
<!-- <div class="nav-group-label">{{ 'PORTALS.SIDEBAR.MAIN' | translate }}</div> -->
<mat-nav-list>
@for (item of navigationItems(); track item.id) {
<a
mat-list-item
[class.active]="isContentTypeActive(item.id)"
(click)="categoryClick.emit(item.id)"
[matTooltip]="item.labelKey | translate"
matTooltipPosition="right"
>
<mat-icon matListItemIcon>{{ item.icon }}</mat-icon>
</a>
}
</mat-nav-list>
<app-portal-rail-links
variant="list"
activeClass="active"
[selectedSection]="selectedContentType()"
[links]="primaryLinks()"
/>
</div>
<mat-divider />
@@ -44,61 +37,12 @@
<!-- <div class="nav-group-label">
{{ 'PORTALS.SIDEBAR.LIBRARY' | translate }}
</div> -->
<mat-nav-list>
@if (!isStalkerPlaylist()) {
<a
mat-list-item
routerLink="./recently-added"
routerLinkActive="active"
[matTooltip]="'PORTALS.SIDEBAR.RECENTLY_ADDED' | translate"
matTooltipPosition="right"
(click)="pageSwitch('recently-added')"
>
<mat-icon matListItemIcon>new_releases</mat-icon>
</a>
}
<a
mat-list-item
routerLink="./search"
routerLinkActive="active"
[matTooltip]="'PORTALS.SIDEBAR.SEARCH' | translate"
matTooltipPosition="right"
(click)="pageSwitch('search')"
>
<mat-icon matListItemIcon>search</mat-icon>
</a>
<a
mat-list-item
routerLink="./recent"
routerLinkActive="active"
[matTooltip]="'PORTALS.SIDEBAR.RECENT' | translate"
matTooltipPosition="right"
(click)="pageSwitch('recent')"
>
<mat-icon matListItemIcon>history</mat-icon>
</a>
<a
mat-list-item
routerLink="./favorites"
routerLinkActive="active"
[matTooltip]="'PORTALS.SIDEBAR.FAVORITES' | translate"
matTooltipPosition="right"
(click)="pageSwitch('favorites')"
>
<mat-icon matListItemIcon>favorite</mat-icon>
</a>
@if (isElectron) {
<a
mat-list-item
routerLink="./downloads"
routerLinkActive="active"
[matTooltip]="'DOWNLOADS.TITLE' | translate"
matTooltipPosition="right"
>
<mat-icon matListItemIcon>download</mat-icon>
</a>
}
</mat-nav-list>
<app-portal-rail-links
variant="list"
activeClass="active"
[selectedSection]="selectedContentType()"
[links]="secondaryLinks()"
/>
</div>
<div class="navigation-bottom">
@@ -21,6 +21,7 @@
margin: 2px 0;
display: flex;
justify-content: center;
color: var(--mat-sys-on-surface-variant);
.mdc-list-item__start {
margin-inline-end: 0 !important;
@@ -46,12 +47,11 @@
}
.active {
transition: all 0.2s ease;
background: rgba(63, 81, 181, 0.12);
color: #3f51b5;
background: var(--mat-sys-secondary-container);
color: var(--mat-sys-on-secondary-container);
mat-icon {
color: #3f51b5;
color: var(--mat-sys-on-secondary-container);
}
}
@@ -73,94 +73,43 @@ a.mat-mdc-list-item.mdc-list-item {
.portal-status-container {
app-region: drag;
margin: -4px -4px 0;
position: relative;
overflow: visible;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
border-radius: 8px;
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface-variant);
border: 1px solid var(--mat-sys-outline-variant);
}
// Gradient layer that bleeds below the container
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: -16px;
opacity: 0.9;
transition: background 0.4s ease;
pointer-events: none;
}
.portal-status-container.status-active {
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
}
// Status-specific gradients
&.status-active::before {
background: linear-gradient(
to bottom,
rgba(76, 175, 80, 0.35) 0%,
rgba(46, 125, 50, 0.18) 50%,
transparent 100%
);
}
.portal-status-container.status-inactive,
.portal-status-container.status-unavailable {
background: var(--mat-sys-error-container);
color: var(--mat-sys-on-error-container);
}
&.status-active .status-watermark {
color: rgba(76, 175, 80, 0.18);
}
.portal-status-container.status-expired {
background: var(--mat-sys-tertiary-container);
color: var(--mat-sys-on-tertiary-container);
}
&.status-inactive::before,
&.status-unavailable::before {
background: linear-gradient(
to bottom,
rgba(244, 67, 54, 0.35) 0%,
rgba(183, 28, 28, 0.18) 50%,
transparent 100%
);
}
&.status-inactive .status-watermark,
&.status-unavailable .status-watermark {
color: rgba(244, 67, 54, 0.18);
}
&.status-expired::before {
background: linear-gradient(
to bottom,
rgba(255, 152, 0, 0.35) 0%,
rgba(230, 81, 0, 0.18) 50%,
transparent 100%
);
}
&.status-expired .status-watermark {
color: rgba(255, 152, 0, 0.18);
}
// The oversized decorative watermark icon
.status-watermark {
position: absolute;
font-size: 48px !important;
width: 48px !important;
height: 48px !important;
transform: rotate(-12deg);
pointer-events: none;
z-index: 0;
transition: color 0.4s ease, transform 0.4s ease;
margin: 0;
}
&:hover .status-watermark {
transform: rotate(-8deg) scale(1.05);
}
.portal-status-container .status-watermark {
font-size: 24px !important;
width: 24px !important;
height: 24px !important;
margin: 0;
}
.nav-section {
.mat-nav-list {
app-portal-rail-links {
display: block;
padding: 0;
margin: 0;
background: rgba(255, 255, 255, 0.03);
border-radius: 2px;
overflow: hidden;
}
}
@@ -1,4 +1,4 @@
import { Component, computed, inject, input, output } from '@angular/core';
import { Component, computed, inject, input } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
@@ -12,7 +12,8 @@ import { Playlist } from 'shared-interfaces';
import { SettingsComponent } from '../../settings/settings.component';
import { AccountInfoComponent } from '../account-info/account-info.component';
import { XtreamStore } from '../stores/xtream.store';
import { NavigationItem } from './navigation.interface';
import { buildPortalRailLinks } from '../../shared/navigation/portal-rail-links';
import { PortalRailLinksComponent } from '../../shared/navigation/portal-rail-links.component';
@Component({
selector: 'app-navigation',
@@ -23,6 +24,7 @@ import { NavigationItem } from './navigation.interface';
RouterLink,
RouterLinkActive,
TranslatePipe,
PortalRailLinksComponent,
],
templateUrl: './navigation.component.html',
styleUrl: './navigation.component.scss',
@@ -36,24 +38,35 @@ export class NavigationComponent {
readonly portalStatus = input<'active' | 'inactive' | 'expired'>();
readonly selectedContentType = input<string>();
readonly categoryClick = output<string>();
readonly pageClicked = output<
'search' | 'recent' | 'favorites' | 'recently-added'
>();
readonly currentPlaylist = this.store.selectSignal(
selectPlaylistById(this.activatedRoute.snapshot.params.id)
);
readonly isStalkerPlaylist = computed(
() => (this.currentPlaylist() as Playlist)?.macAddress
() => !!(this.currentPlaylist() as Playlist)?.macAddress
);
readonly navigationItems = input<NavigationItem[]>();
/** Check if running in Electron (downloads only available in desktop) */
readonly isElectron = !!window.electron;
readonly railLinks = computed(() => {
const playlistId =
(this.currentPlaylist() as Playlist | null)?._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';
@@ -108,18 +121,9 @@ export class NavigationComponent {
});
}
isContentTypeActive(type: string) {
return this.selectedContentType() === type;
}
openPlaylistInfo() {
this.dialog.open(PlaylistInfoComponent, {
data: this.currentPlaylist(),
});
}
pageSwitch(page: 'search' | 'recent' | 'favorites' | 'recently-added') {
this.pageClicked.emit(page);
this.xtreamStore.setSelectedContentType(undefined);
}
}
@@ -1,5 +0,0 @@
export interface NavigationItem {
id: string;
icon: string;
labelKey: string;
}
@@ -8,7 +8,6 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { DialogService, PlaylistInfoComponent } from 'components';
import { PlaylistActions } from 'm3u-state';
import { selectCurrentPlaylist } from 'm3u-state';
import { DatabaseService } from 'services';
import { Playlist } from 'shared-interfaces';
@Component({
@@ -18,7 +17,6 @@ import { Playlist } from 'shared-interfaces';
imports: [MatButtonModule, MatIconModule, RouterLink, TranslateModule],
})
export class PlaylistErrorViewComponent {
private databaseService = inject(DatabaseService);
private dialog = inject(MatDialog);
private dialogService = inject(DialogService);
private router = inject(Router);
@@ -56,6 +54,5 @@ export class PlaylistErrorViewComponent {
removePlaylist(playlistId: string): void {
this.store.dispatch(PlaylistActions.removePlaylist({ playlistId }));
this.router.navigate(['/']);
this.databaseService.deletePlaylist(playlistId);
}
}
@@ -46,18 +46,18 @@
overflow: hidden;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
background-color: color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent);
.title {
color: #1db954;
color: var(--mat-sys-primary);
}
}
&.active {
background-color: rgba(29, 185, 84, 0.2);
background-color: color-mix(in srgb, var(--mat-sys-primary) 16%, transparent);
.title {
color: #1db954;
color: var(--mat-sys-primary);
}
}
}
@@ -68,7 +68,7 @@
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
background: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent);
}
.channel-info {
@@ -111,14 +111,14 @@
.progress-bar {
flex: 1;
height: 3px;
background: rgba(255, 255, 255, 0.1);
background: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);
border-radius: 2px;
overflow: hidden;
min-width: 20px;
.progress {
height: 100%;
background: #1db954;
background: var(--mat-sys-primary);
transition: width 1s linear;
}
}
@@ -130,7 +130,7 @@
.placeholder-bar {
height: 10px;
width: 60%;
background: rgba(255, 255, 255, 0.1);
background: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);
border-radius: 2px;
margin: 2px 0;
}
@@ -1,86 +1,103 @@
<div class="recent-container">
<div class="header">
<h2>Recently Viewed</h2>
<div class="header-actions">
@if (recentItems()?.length) {
<button mat-button color="warn" (click)="clearHistory()">
<mat-icon>delete</mat-icon>
Clear History
</button>
} @if (isGlobal) {
<button mat-icon-button (click)="dialogRef.close()">
<mat-icon>close</mat-icon>
</button>
}
</div>
</div>
@if (recentItems()?.length) {
<div class="recent-search">
<mat-icon>search</mat-icon>
<input
#recentSearchInput
type="search"
spellcheck="false"
autocomplete="off"
[value]="recentSearchTerm()"
placeholder="Search only in recently watched items"
(input)="updateRecentSearchTerm(recentSearchInput.value)"
/>
</div>
@if (isGlobal) {
<div class="global-recent-subheader">
<div class="global-recent-subheader-left">
<div class="global-recent-note">
<mat-icon>info_outline</mat-icon>
<span>Includes Xtream and Stalker items</span>
</div>
<div class="global-recent-filters">
<mat-checkbox
[ngModel]="typeFilters().live"
(ngModelChange)="toggleTypeFilter('live', $event)"
>
Live
</mat-checkbox>
<mat-checkbox
[ngModel]="typeFilters().movie"
(ngModelChange)="toggleTypeFilter('movie', $event)"
>
Movies
</mat-checkbox>
<mat-checkbox
[ngModel]="typeFilters().series"
(ngModelChange)="toggleTypeFilter('series', $event)"
>
Series
</mat-checkbox>
@if (isGlobal) {
<div class="recent-container">
<div class="header">
<h2>Recently Viewed</h2>
<div class="header-actions">
@if (recentItems()?.length) {
<button mat-button color="warn" (click)="clearHistory()">
<mat-icon>delete</mat-icon>
Clear History
</button>
}
<button mat-icon-button (click)="dialogRef.close()">
<mat-icon>close</mat-icon>
</button>
</div>
</div>
<mat-checkbox
[ngModel]="groupByPlaylist()"
(ngModelChange)="toggleGroupByPlaylist($event)"
>
Group by playlist
</mat-checkbox>
</div>
@if (groupByPlaylist()) {
<div class="playlist-section">
<h2 class="section-title">
Found {{ visibleRecentItems().length }} items across
{{ (getGroupedItems() | keyvalue).length }} playlists
</h2>
@for (group of getGroupedItems() | keyvalue; track group.key) {
<div class="playlist-group">
<h3 class="playlist-title">
<mat-icon>playlist_play</mat-icon>
{{ group.key }}
<span class="item-count"
>({{ $any(group).value.length }} items)</span
>
</h3>
@if (recentItems()?.length) {
<div class="recent-search">
<mat-icon>search</mat-icon>
<input
#recentSearchInput
type="search"
spellcheck="false"
autocomplete="off"
[value]="recentSearchTerm()"
placeholder="Search only in recently watched items"
(input)="updateRecentSearchTerm(recentSearchInput.value)"
/>
</div>
<div class="global-recent-subheader">
<div class="global-recent-subheader-left">
<div class="global-recent-note">
<mat-icon>info_outline</mat-icon>
<span>Includes Xtream and Stalker items</span>
</div>
<div class="global-recent-filters">
<mat-checkbox
[ngModel]="typeFilters().live"
(ngModelChange)="toggleTypeFilter('live', $event)"
>
Live
</mat-checkbox>
<mat-checkbox
[ngModel]="typeFilters().movie"
(ngModelChange)="toggleTypeFilter('movie', $event)"
>
Movies
</mat-checkbox>
<mat-checkbox
[ngModel]="typeFilters().series"
(ngModelChange)="toggleTypeFilter('series', $event)"
>
Series
</mat-checkbox>
</div>
</div>
<mat-checkbox
[ngModel]="groupByPlaylist()"
(ngModelChange)="toggleGroupByPlaylist($event)"
>
Group by playlist
</mat-checkbox>
</div>
@if (groupByPlaylist()) {
<div class="playlist-section">
<h2 class="section-title">
Found {{ visibleRecentItems().length }} items across
{{ (getGroupedItems() | keyvalue).length }} playlists
</h2>
@for (group of getGroupedItems() | keyvalue; track group.key) {
<div class="playlist-group">
<h3 class="playlist-title">
<mat-icon>playlist_play</mat-icon>
{{ group.key }}
<span class="item-count"
>({{ $any(group).value.length }} items)</span
>
</h3>
<div class="items-grid">
@for (item of $any(group).value; track item.id) {
<app-content-card
[posterUrl]="item.poster_url"
[title]="item.title"
[type]="item.type"
[date]="item.viewed_at"
[showRemoveButton]="true"
[removeTooltip]="'Remove from history'"
(cardClick)="openItem(item)"
(remove)="onRemoveItem(item)"
/>
}
</div>
</div>
}
</div>
} @else {
<div class="items-grid">
@for (item of $any(group).value; track item.id) {
@for (item of visibleRecentItems(); track item.id) {
<app-content-card
[posterUrl]="item.poster_url"
[title]="item.title"
@@ -93,44 +110,30 @@
/>
}
</div>
}
} @else {
<div class="no-items">
<mat-icon>history</mat-icon>
<p>No recently viewed items</p>
</div>
}
</div>
} @else {
<div class="items-grid">
@for (item of visibleRecentItems(); track item.id) {
<app-content-card
[posterUrl]="item.poster_url"
[title]="item.title"
[type]="item.type"
[date]="item.viewed_at"
[showRemoveButton]="true"
[removeTooltip]="'Remove from history'"
(cardClick)="openItem(item)"
(remove)="onRemoveItem(item)"
/>
}
</div>
}
} @else {
<div class="items-grid">
@for (item of visibleRecentItems(); track item.id) {
<app-content-card
[posterUrl]="item.poster_url"
[title]="item.title"
[type]="item.type"
[date]="item.viewed_at"
[showRemoveButton]="true"
[removeTooltip]="'Remove from history'"
(cardClick)="openItem(item)"
(remove)="onRemoveItem(item)"
/>
}
</div>
} } @else {
<div class="no-items">
<mat-icon>history</mat-icon>
<p>No recently viewed items</p>
</div>
}
</div>
} @else {
<app-favorites-layout
[categories]="categories()"
[favorites]="nonGlobalItemsToShow()"
[playlistSubtitle]="'Xtream Code'"
[playlistTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
[selectedCategoryId]="selectedCategoryId()"
[titleTranslationString]="'PORTALS.SIDEBAR.RECENT'"
[removeTooltip]="'Remove from history'"
[emptyIcon]="'history'"
[showHeaderAction]="!isWorkspaceLayout"
[headerActionIcon]="'delete_sweep'"
[headerActionTooltip]="'Clear history'"
(categoryClicked)="setCategoryId($event.category_id)"
(removeFavorite)="onRemoveItem($event)"
(headerActionClicked)="clearHistory()"
(openItem)="openItem($event)"
/>
}
@@ -1,5 +1,15 @@
import { KeyValuePipe } from '@angular/common';
import { Component, computed, inject, Optional, signal } from '@angular/core';
import {
Component,
computed,
effect,
inject,
OnDestroy,
Optional,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -7,17 +17,21 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import groupBy from 'lodash/groupBy';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { XtreamCategory } from 'shared-interfaces';
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
import { StalkerStore } from '../../stalker/stalker.store';
import { XtreamStore } from '../stores/xtream.store';
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
import { createLogger } from '../../shared/utils/logger';
import { StalkerStore } from '../../stalker/stalker.store';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import { XtreamStore } from '../stores/xtream.store';
@Component({
selector: 'app-recently-viewed',
imports: [
ContentCardComponent,
FavoritesLayoutComponent,
FormsModule,
KeyValuePipe,
MatButton,
@@ -29,7 +43,7 @@ import { createLogger } from '../../shared/utils/logger';
templateUrl: './recently-viewed.component.html',
styleUrl: './recently-viewed.component.scss',
})
export class RecentlyViewedComponent {
export class RecentlyViewedComponent implements OnDestroy {
private static readonly GROUP_BY_STORAGE_KEY =
'global-recent-group-by-playlist';
private static readonly TYPE_FILTERS_STORAGE_KEY =
@@ -44,8 +58,11 @@ export class RecentlyViewedComponent {
private readonly stalkerStore = inject(StalkerStore);
private dialogData = inject(MAT_DIALOG_DATA, { optional: true });
private readonly logger = createLogger('XtreamRecentlyViewed');
private readonly favoritesCtx = inject(FavoritesContextService);
readonly isGlobal = this.dialogData?.isGlobal ?? false;
readonly isWorkspaceLayout =
this.activatedRoute.snapshot.data['layout'] === 'workspace';
private readonly _groupByPlaylist = (() => {
const saved = localStorage.getItem(
RecentlyViewedComponent.GROUP_BY_STORAGE_KEY
@@ -81,9 +98,61 @@ export class RecentlyViewedComponent {
? this.xtreamStore.globalRecentItems()
: this.xtreamStore.recentItems()
);
readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId;
readonly categories = computed<XtreamCategory[]>(() => {
const items = this.recentItems();
const movies = items.filter(
(item: any) => item?.type === 'movie'
).length;
const live = items.filter((item: any) => item?.type === 'live').length;
const series = items.filter(
(item: any) => item?.type === 'series'
).length;
const cats: XtreamCategory[] = [
{
id: 1,
category_id: 'all',
category_name: 'All',
count: items.length,
parent_id: 0,
},
{
id: 2,
category_id: 'movie',
category_name: 'Movies',
count: movies,
parent_id: 0,
},
{
id: 3,
category_id: 'live',
category_name: 'Live TV',
count: live,
parent_id: 0,
},
{
id: 4,
category_id: 'series',
category_name: 'Series',
count: series,
parent_id: 0,
},
];
return cats;
});
readonly recentSearchTerm = signal('');
readonly workspaceSearchTerm = toSignal(
this.activatedRoute.queryParamMap.pipe(
map((params) => (params.get('q') ?? '').trim().toLowerCase())
),
{ initialValue: '' }
);
readonly filteredRecentItems = computed(() => {
const term = this.recentSearchTerm().trim().toLowerCase();
const term =
this.isWorkspaceLayout && !this.isGlobal
? this.workspaceSearchTerm()
: this.recentSearchTerm().trim().toLowerCase();
const typeFilters = this.typeFilters();
const items = this.recentItems().filter((item: any) => {
if (item?.type === 'live') return typeFilters.live;
@@ -102,10 +171,28 @@ export class RecentlyViewedComponent {
readonly visibleRecentItems = computed(() => {
const items = this.filteredRecentItems();
if (this.isGlobal && !this.groupByPlaylist()) {
return items.slice(0, RecentlyViewedComponent.MAX_FLAT_GLOBAL_ITEMS);
return items.slice(
0,
RecentlyViewedComponent.MAX_FLAT_GLOBAL_ITEMS
);
}
return items;
});
readonly nonGlobalItemsToShow = computed(() => {
const items = this.visibleRecentItems();
switch (this.selectedCategoryId()) {
case 'movie':
return items.filter((item: any) => item?.type === 'movie');
case 'live':
return items.filter((item: any) => item?.type === 'live');
case 'series':
return items.filter((item: any) => item?.type === 'series');
case 'all':
default:
return items;
}
});
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
constructor(
@@ -118,6 +205,13 @@ export class RecentlyViewedComponent {
} else if (this.currentPlaylist()) {
this.xtreamStore.loadRecentItems(this.currentPlaylist);
}
// Keep workspace context panel in sync with current categories
effect(() => {
if (!this.isGlobal) {
this.favoritesCtx.setCategories(this.categories());
}
});
}
private async loadGlobalItems() {
@@ -242,4 +336,12 @@ export class RecentlyViewedComponent {
JSON.stringify(next)
);
}
setCategoryId(categoryId: string): void {
this.favoritesCtx.setCategoryId(categoryId);
}
ngOnDestroy(): void {
this.favoritesCtx.reset();
}
}
@@ -4,6 +4,7 @@
[isLoading]="xtreamStore.isSearching()"
[showCloseButton]="false"
[showResultsCount]="!isGlobalSearch || !groupByPlaylist()"
[showSearchInput]="showInlineSearchInput"
(searchTermChange)="updateSearchTerm($event)"
(closeClick)="onCloseDialog()"
>
@@ -10,6 +10,7 @@ import {
signal,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -22,6 +23,7 @@ import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import groupBy from 'lodash/groupBy';
import { map } from 'rxjs';
import { DatabaseService } from 'services';
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component';
@@ -59,6 +61,14 @@ export class SearchResultsComponent implements AfterViewInit {
readonly activatedRoute = inject(ActivatedRoute);
readonly databaseService = inject(DatabaseService);
private readonly logger = createLogger('XtreamSearchResults');
readonly isWorkspaceLayout =
this.activatedRoute.snapshot.data['layout'] === 'workspace';
readonly routeSearchTerm = toSignal(
this.activatedRoute.queryParamMap.pipe(
map((params) => (params.get('q') ?? '').trim())
),
{ initialValue: '' }
);
/** Search term from store */
readonly searchTerm = this.xtreamStore.searchTerm;
@@ -148,6 +158,19 @@ export class SearchResultsComponent implements AfterViewInit {
this.clearResultsOnly();
}
});
effect(() => {
if (this.isGlobalSearch || !this.isWorkspaceLayout) {
return;
}
const queryTerm = this.routeSearchTerm();
if (!queryTerm || queryTerm === this.searchTerm()) {
return;
}
this.xtreamStore.setSearchTerm(queryTerm);
});
}
ngAfterViewInit() {
@@ -278,4 +301,8 @@ export class SearchResultsComponent implements AfterViewInit {
getGroupedResults() {
return this.groupedResults();
}
get showInlineSearchInput(): boolean {
return !this.isWorkspaceLayout || this.isGlobalSearch;
}
}
@@ -260,8 +260,8 @@
// In-progress state
&--in-progress {
border-color: rgba(229, 9, 20, 0.6);
background: rgba(229, 9, 20, 0.05);
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
&::before {
content: '';
@@ -269,7 +269,7 @@
inset: 0;
background: linear-gradient(
135deg,
rgba(229, 9, 20, 0.1) 0%,
color-mix(in srgb, var(--mat-sys-primary) 10%, transparent) 0%,
transparent 50%
);
pointer-events: none;
@@ -287,7 +287,7 @@
.episode-card__title::after {
content: ' ✓';
color: #46d369;
color: var(--mat-sys-tertiary);
font-weight: bold;
margin-left: 4px;
}
@@ -398,10 +398,10 @@
}
.download-btn--completed {
color: #4caf50 !important;
color: var(--mat-sys-tertiary) !important;
&:hover {
background: rgba(76, 175, 80, 0.8);
background: color-mix(in srgb, var(--mat-sys-tertiary) 80%, transparent);
}
}
@@ -522,12 +522,12 @@
}
&--in-progress {
border-color: rgba(229, 9, 20, 0.4);
background: rgba(229, 9, 20, 0.05);
border-color: color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
&:hover {
border-color: rgba(229, 9, 20, 0.6);
background: rgba(229, 9, 20, 0.08);
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
}
}
@@ -576,7 +576,7 @@
font-size: 16px;
width: 16px;
height: 16px;
color: #46d369;
color: var(--mat-sys-tertiary);
}
}
@@ -598,9 +598,9 @@
&__progress-text {
font-size: 0.75rem;
font-weight: 500;
color: #e50914;
color: var(--mat-sys-primary);
padding: 2px 8px;
background: rgba(229, 9, 20, 0.15);
background: color-mix(in srgb, var(--mat-sys-primary) 15%, transparent);
border-radius: 10px;
}
@@ -625,7 +625,7 @@
&__progress-fill {
height: 100%;
background: #e50914;
background: var(--mat-sys-primary);
border-radius: 2px;
transition: width 0.3s ease;
}
@@ -663,10 +663,10 @@
}
&--success {
color: #46d369;
color: var(--mat-sys-tertiary);
&:hover {
background: rgba(70, 211, 105, 0.15);
background: color-mix(in srgb, var(--mat-sys-tertiary) 15%, transparent);
}
}
@@ -0,0 +1,125 @@
@let meta = normalizedMeta();
<!-- Hero Section with Backdrop -->
<app-content-hero
[title]="meta.title"
[description]="meta.description"
[posterUrl]="meta.posterUrl"
[backdropUrl]="meta.backdropUrl"
(backClicked)="goBack()"
>
<!-- Tags: Year, Genre, Duration, Rating -->
<ng-container hero-tags>
@if (meta.year) {
<span class="details__tag">{{ meta.year }}</span>
}
@if (meta.genre) {
<span class="details__tag">{{ meta.genre }}</span>
}
@if (meta.duration) {
<span class="details__tag">{{ meta.duration }}</span>
}
@if (meta.ratingImdb) {
<span class="details__tag details__tag--rating">
⭐ {{ meta.ratingImdb }}
</span>
}
@if (meta.country) {
<span class="details__tag">{{ meta.country }}</span>
}
</ng-container>
<!-- Metadata Grid -->
<ng-container hero-meta>
@if (meta.actors) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<span class="value">{{ meta.actors }}</span>
</div>
}
@if (meta.director) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="value">{{ meta.director }}</span>
</div>
}
@if (meta.ratingKinopoisk) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.KINOPOISK_RATING' | translate }}</span>
<span class="value">{{ meta.ratingKinopoisk }}</span>
</div>
}
</ng-container>
<!-- Action Buttons -->
<ng-container hero-actions>
<!-- Resume/Play buttons -->
@if (hasPlaybackPosition()) {
<button class="play-btn play-btn--resume" (click)="onResume()">
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.RESUME' | translate }} {{ formattedPosition() }}
</button>
<button class="play-btn play-btn--restart" (click)="onPlay()">
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
</button>
} @else {
<button class="play-btn" (click)="onPlay()">
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}
</button>
}
<!-- Favorites button -->
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button
class="favorite-btn favorite-btn--active"
(click)="toggleFavorite()"
>
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
<!-- Download buttons (Electron only) -->
@if (isElectron()) {
@if (isDownloaded()) {
<button class="download-btn download-btn--completed" (click)="playFromLocal()">
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
} @else if (isDownloading()) {
<button class="download-btn download-btn--downloading" disabled>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
</button>
} @else {
<button class="download-btn" (click)="onDownload()">
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
</button>
}
}
</ng-container>
<!-- YouTube Trailer (Xtream only) -->
@if (meta.youtubeTrailer) {
<div class="youtube-trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<div class="trailer-wrapper">
<iframe
[src]="'https://www.youtube.com/embed/' + meta.youtubeTrailer | safe"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>
</div>
}
</app-content-hero>
@@ -0,0 +1,199 @@
import {
Component,
computed,
inject,
input,
output,
OnDestroy,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { ContentHeroComponent } from 'components';
import {
VodDetailsItem,
normalizeVodDetails,
getVodNumericId,
} from 'shared-interfaces';
import { DownloadsService } from '../../services/downloads.service';
import { SafePipe } from '@iptvnator/pipes';
import { StalkerStore } from '../../stalker/stalker.store';
/**
* Unified VOD details component for both Xtream and Stalker portals.
*
* Uses discriminated union (VodDetailsItem) for type-safe handling.
* All actions are emitted as outputs - parent components handle
* store-specific operations (play, favorites, downloads).
*
* @example
* ```html
* <app-vod-details
* [item]="vodItem"
* [isFavorite]="isFavorite()"
* [playbackPosition]="position()"
* (playClicked)="onPlay($event)"
* (resumeClicked)="onResume($event)"
* (favoriteToggled)="onToggleFavorite($event)"
* />
* ```
*/
@Component({
selector: 'app-vod-details',
templateUrl: './vod-details.component.html',
styleUrls: ['../detail-view.scss'],
imports: [
ContentHeroComponent,
MatIcon,
MatProgressSpinnerModule,
SafePipe,
TranslatePipe,
],
})
export class VodDetailsComponent implements OnDestroy {
// ============ Inputs ============
/** VOD item with discriminated union type */
readonly item = input.required<VodDetailsItem>();
/** Whether this item is in favorites (managed by parent) */
readonly isFavorite = input<boolean>(false);
/** Playback position in seconds for resume feature (managed by parent) */
readonly playbackPosition = input<number | null>(null);
// ============ Outputs ============
/** Emitted when play button is clicked */
readonly playClicked = output<VodDetailsItem>();
/** Emitted when resume button is clicked (includes position) */
readonly resumeClicked = output<{ item: VodDetailsItem; positionSeconds: number }>();
/** Emitted when favorite toggle is clicked */
readonly favoriteToggled = output<{ item: VodDetailsItem; isFavorite: boolean }>();
/** Emitted when back button is clicked */
readonly backClicked = output<void>();
/** Emitted when download is requested (parent handles URL construction) */
readonly downloadRequested = output<VodDetailsItem>();
// ============ Services ============
private readonly downloadsService = inject(DownloadsService);
private readonly stalkerStore = inject(StalkerStore);
// ============ Computed State ============
/** Whether running in Electron (downloads available) */
readonly isElectron = computed(() => this.downloadsService.isAvailable());
/** Normalized metadata for display */
readonly normalizedMeta = computed(() => {
return normalizeVodDetails(this.item());
});
/** Whether item is from Stalker portal */
readonly isStalkerItem = computed(() => this.item().type === 'stalker');
/** Whether there's a playback position to resume from */
readonly hasPlaybackPosition = computed(() => {
const pos = this.playbackPosition();
return pos !== null && pos > 0;
});
/** Formatted playback position (e.g., "12:34" or "1:23:45") */
readonly formattedPosition = computed(() => {
const pos = this.playbackPosition();
if (!pos || pos <= 0) return '';
const hours = Math.floor(pos / 3600);
const minutes = Math.floor((pos % 3600) / 60);
const seconds = Math.floor(pos % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
});
/** Whether VOD is already downloaded */
readonly isDownloaded = computed(() => {
const item = this.item();
// Access signal to create reactive dependency
this.downloadsService.downloads();
const vodId = getVodNumericId(item);
return this.downloadsService.isDownloaded(vodId, item.playlistId, 'vod');
});
/** Whether VOD is currently downloading */
readonly isDownloading = computed(() => {
const item = this.item();
// Access signal to create reactive dependency
this.downloadsService.downloads();
const vodId = getVodNumericId(item);
return this.downloadsService.isDownloading(vodId, item.playlistId, 'vod');
});
// ============ Lifecycle ============
ngOnDestroy(): void {
// Clear selected item when leaving Stalker mode
if (this.isStalkerItem()) {
this.stalkerStore.clearSelectedItem();
}
}
// ============ Actions ============
/** Handle play button click */
onPlay(): void {
this.playClicked.emit(this.item());
}
/** Handle resume button click */
onResume(): void {
const pos = this.playbackPosition();
if (pos && pos > 0) {
this.resumeClicked.emit({
item: this.item(),
positionSeconds: pos,
});
}
}
/** Handle favorite toggle - emits the desired new state */
toggleFavorite(): void {
this.favoriteToggled.emit({
item: this.item(),
isFavorite: !this.isFavorite(),
});
}
/** Handle back navigation - emit event for parent to handle */
goBack(): void {
this.backClicked.emit();
}
/** Handle download request */
onDownload(): void {
this.downloadRequested.emit(this.item());
}
/** Play from local downloaded file */
async playFromLocal(): Promise<void> {
const item = this.item();
const vodId = getVodNumericId(item);
const filePath = this.downloadsService.getDownloadedFilePath(
vodId,
item.playlistId,
'vod'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
}
}
@@ -1,37 +1,48 @@
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header">
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
[subtitle]="'Xtream Code'"
/>
<button
mat-icon-button
class="manage-categories-btn"
(click)="openCategoryManagement()"
[matTooltip]="'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate"
@if (!isWorkspaceLayout) {
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div
class="sidebar-header"
[class.sidebar-header--workspace]="isWorkspaceLayout"
>
<mat-icon>tune</mat-icon>
</button>
@if (!isWorkspaceLayout) {
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
[subtitle]="'Xtream Code'"
/>
}
<button
mat-icon-button
class="manage-categories-btn"
(click)="openCategoryManagement()"
[matTooltip]="'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate"
>
<mat-icon>tune</mat-icon>
</button>
</div>
<app-category-view
[items]="categories()"
[selectedCategoryId]="selectedCategoryId()"
[itemCounts]="categoryItemCounts()"
(categoryClicked)="categoryClicked($event)"
[showCounts]="true"
/>
</div>
<app-category-view
[items]="categories()"
[selectedCategoryId]="selectedCategoryId()"
[itemCounts]="categoryItemCounts()"
(categoryClicked)="categoryClicked($event)"
[showCounts]="true"
/>
</div>
}
<div class="main-content">
@if (!xtreamStore.selectedItem() && !xtreamStore.isLoadingDetails()) {
@if (
!isWorkspaceLayout &&
!xtreamStore.selectedItem() &&
!xtreamStore.isLoadingDetails()
) {
<div class="content-header">
<div class="header-left">
<h2>
@@ -108,7 +119,11 @@
}
<div
class="scrollable-content"
[class.no-header]="xtreamStore.selectedItem() || xtreamStore.isLoadingDetails()"
[class.no-header]="
isWorkspaceLayout ||
xtreamStore.selectedItem() ||
xtreamStore.isLoadingDetails()
"
>
<router-outlet />
</div>
@@ -14,6 +14,10 @@
align-items: center;
app-region: drag;
&.sidebar-header--workspace {
justify-content: flex-end;
}
button {
@include mat.form-field-density(-5);
app-region: no-drag;
@@ -58,6 +58,8 @@ export class XtreamMainContainerComponent implements OnInit {
const type = this.xtreamStore.selectedContentType();
return hasCategorySelected && (type === 'vod' || type === 'series');
});
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
readonly contentSortLabel = computed(() => {
const mode = this.contentSortMode();
if (mode === 'date-asc') return 'Date Added (Oldest First)';
@@ -6,13 +6,12 @@
}
<div class="xtream-container">
<app-navigation
class="navigation-sidebar"
[portalStatus]="$any(portalStatus())"
[navigationItems]="mainNavigationItems"
(categoryClick)="handleCategoryClick($any($event))"
(pageClicked)="handlePageClick($event)"
[selectedContentType]="xtreamStore.selectedContentType()"
/>
@if (!isWorkspaceLayout) {
<app-navigation
class="navigation-sidebar"
[portalStatus]="$any(portalStatus())"
[selectedContentType]="xtreamStore.selectedContentType()"
/>
}
<router-outlet />
</div>
@@ -10,8 +10,13 @@
flex: 1;
overflow: hidden;
min-height: 0; // Important for proper flex behavior
> router-outlet {
min-width: 0;
}
}
.navigation-sidebar {
border-right: 1px solid #333;
border-right: 1px solid var(--mat-sys-outline-variant);
}
@@ -1,13 +1,18 @@
import { Component, effect, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import {
ActivatedRoute,
NavigationEnd,
Router,
RouterOutlet,
} from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { LoadingOverlayComponent } from '../loading-overlay/loading-overlay.component';
import { NavigationComponent } from '../navigation/navigation.component';
import { NavigationItem } from '../navigation/navigation.interface';
import { XtreamStore } from '../stores/xtream.store';
import { filter } from 'rxjs';
@Component({
templateUrl: './xtream-shell.component.html',
@@ -29,26 +34,11 @@ export class XtreamShellComponent {
readonly isImporting = this.xtreamStore.isImporting;
readonly itemsToImport = this.xtreamStore.itemsToImport;
readonly portalStatus = this.xtreamStore.portalStatus;
readonly mainNavigationItems: NavigationItem[] = [
{
id: 'vod',
icon: 'movie',
labelKey: 'PORTALS.SIDEBAR.MOVIES',
},
{
id: 'live',
icon: 'live_tv',
labelKey: 'PORTALS.SIDEBAR.LIVE_TV',
},
{
id: 'series',
icon: 'tv',
labelKey: 'PORTALS.SIDEBAR.SERIES',
},
];
readonly isWorkspaceLayout =
this.route.snapshot.data['layout'] === 'workspace';
private currentPlaylistId: string | null = null;
private currentSection: string | null = null;
constructor() {
// Subscribe to route params to handle switching between playlists
@@ -77,6 +67,17 @@ export class XtreamShellComponent {
await this.xtreamStore.checkPortalStatus();
});
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed()
)
.subscribe(() => {
this.syncSectionFromRoute();
});
this.syncSectionFromRoute();
effect(() => {
const playlist = this.xtreamStore.currentPlaylist();
const playlistId = this.xtreamStore.playlistId();
@@ -89,17 +90,30 @@ export class XtreamShellComponent {
});
}
handleCategoryClick(category: 'vod' | 'live' | 'series') {
this.xtreamStore.setSelectedContentType(category);
this.router.navigate([category], {
relativeTo: this.route,
});
private syncSectionFromRoute(): void {
const sectionFromSnapshot =
this.route.firstChild?.snapshot?.url?.[0]?.path ?? null;
const sectionFromUrl = this.getSectionFromUrl(this.router.url);
const section = sectionFromSnapshot ?? sectionFromUrl;
if (!section || section === this.currentSection) {
return;
}
this.currentSection = section;
if (section === 'vod' || section === 'live' || section === 'series') {
this.xtreamStore.setSelectedContentType(section);
return;
}
this.xtreamStore.setSelectedContentType(undefined);
}
handlePageClick(page: 'search' | 'recent' | 'favorites' | 'recently-added') {
this.xtreamStore.setSelectedContentType(undefined);
this.router.navigate([page], {
relativeTo: this.route,
});
private getSectionFromUrl(url: string): string | null {
const match = url.match(
/^\/(?:workspace\/)?xtreams\/[^\/\?]+\/([^\/\?]+)/
);
return match?.[1] ?? null;
}
}
}