mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
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;
|
||||
|
||||
+31
-10
@@ -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
|
||||
|
||||
+103
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
+96
-19
@@ -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();
|
||||
}
|
||||
}
|
||||
+74
-70
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user