mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
refactor: streamline navigation and layout handling across components
- Updated StalkerShellComponent to utilize utility functions for workspace layout detection and portal section resolution. - Refactored StalkerVod utilities to include inline detail state normalization and added tests for new functionality. - Modified workspace context panels to import shared components for better organization. - Enhanced DownloadsComponent to utilize a collection context for managing categories and downloads. - Updated FavoritesComponent to leverage a portal collection shell layout and improved category management. - Refactored RecentlyViewedComponent to use a shared collection shell layout and optimized item filtering. - Improved SearchResultsComponent to utilize query parameter signals for search term handling. - Updated XtreamMainContainer and XtreamShell components to streamline layout detection and section management.
This commit is contained in:
1 parent
ea55c485a7
commit
7bb61ab64c
47 files changed
+1487
-718
No files matched your search
File renamed without changes.
+1
-1
@@ -1,4 +1,4 @@
|
||||
@use '../../../nav-list.scss';
|
||||
@use '../../../../nav-list.scss';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
File renamed without changes.
@@ -5,7 +5,8 @@ import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { CategoryViewComponent } from '../../../xtream-electron/category-view/category-view.component';
|
||||
import { isWorkspaceLayoutRoute } from '../../navigation/portal-route.utils';
|
||||
import { CategoryViewComponent } from '../category-view/category-view.component';
|
||||
import { ContentCardComponent } from '../content-card/content-card.component';
|
||||
|
||||
@Component({
|
||||
@@ -13,7 +14,7 @@ import { ContentCardComponent } from '../content-card/content-card.component';
|
||||
templateUrl: './favorites-layout.component.html',
|
||||
styleUrls: [
|
||||
'./favorites-layout.component.scss',
|
||||
'../../../xtream-electron/sidebar.scss',
|
||||
'../../styles/portal-sidebar.scss',
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
@@ -27,9 +28,9 @@ import { ContentCardComponent } from '../content-card/content-card.component';
|
||||
],
|
||||
})
|
||||
export class FavoritesLayoutComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
readonly isWorkspaceLayout =
|
||||
this.route.snapshot.data['layout'] === 'workspace';
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(
|
||||
inject(ActivatedRoute)
|
||||
);
|
||||
|
||||
readonly categories = input<any[]>([]);
|
||||
readonly favorites = input<any[]>([]);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistErrorViewComponent } from '../../../xtream-electron/playlist-error-view/playlist-error-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
|
||||
import { ProgressCapsuleComponent } from '../../../xtream-electron/shared/progress-capsule/progress-capsule.component';
|
||||
import { WatchedBadgeComponent } from '../../../xtream-electron/shared/watched-badge/watched-badge.component';
|
||||
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './content-card/content-card.component';
|
||||
export * from './category-view/category-view.component';
|
||||
export * from './header/header.component';
|
||||
export * from './playlist-error-view/playlist-error-view.component';
|
||||
export * from './portal-collection-shell/portal-collection-shell.component';
|
||||
export * from './stalker-inline-detail/stalker-inline-detail.component';
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+23
@@ -0,0 +1,23 @@
|
||||
@if (showDetails()) {
|
||||
<ng-content select="[details]" />
|
||||
} @else {
|
||||
<app-favorites-layout
|
||||
[categories]="categories()"
|
||||
[favorites]="items()"
|
||||
[playlistSubtitle]="playlistSubtitle()"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[titleTranslationString]="
|
||||
layout().titleTranslationKey || 'CHANNELS.FAVORITES'
|
||||
"
|
||||
[removeTooltip]="layout().removeTooltip || ''"
|
||||
[emptyIcon]="layout().emptyIcon || 'favorite_border'"
|
||||
[showHeaderAction]="layout().showHeaderAction || false"
|
||||
[headerActionIcon]="layout().headerActionIcon || 'delete_sweep'"
|
||||
[headerActionTooltip]="layout().headerActionTooltip || ''"
|
||||
(categoryClicked)="onCategoryClicked($event)"
|
||||
(removeFavorite)="removeItem.emit($event)"
|
||||
(headerActionClicked)="headerActionClicked.emit()"
|
||||
(openItem)="openItem.emit($event)"
|
||||
/>
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import { FavoritesLayoutComponent } from '../favorites-layout/favorites-layout.component';
|
||||
|
||||
export interface PortalCollectionShellLayout {
|
||||
titleTranslationKey?: string;
|
||||
removeTooltip?: string;
|
||||
emptyIcon?: string;
|
||||
showHeaderAction?: boolean;
|
||||
headerActionIcon?: string;
|
||||
headerActionTooltip?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-collection-shell',
|
||||
imports: [FavoritesLayoutComponent],
|
||||
templateUrl: './portal-collection-shell.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PortalCollectionShellComponent {
|
||||
readonly categories = input<XtreamCategory[]>([]);
|
||||
readonly items = input<any[]>([]);
|
||||
readonly playlistSubtitle = input<string>('');
|
||||
readonly playlistTitle = input<string>('Playlist');
|
||||
readonly selectedCategoryId = input<string>('all');
|
||||
readonly layout = input<PortalCollectionShellLayout>({});
|
||||
readonly showDetails = input<boolean>(false);
|
||||
|
||||
readonly categoryClicked = output<string>();
|
||||
readonly removeItem = output<any>();
|
||||
readonly openItem = output<any>();
|
||||
readonly headerActionClicked = output<void>();
|
||||
|
||||
onCategoryClicked(event: { category_id?: string }) {
|
||||
if (!event?.category_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.categoryClicked.emit(event.category_id);
|
||||
}
|
||||
}
|
||||
@@ -2,83 +2,85 @@
|
||||
<!-- Inline details slot (for stalker) - shown above everything when active -->
|
||||
<ng-content select="[details]" />
|
||||
|
||||
<div class="search-content">
|
||||
<div class="header sticky-header">
|
||||
<div class="header-top">
|
||||
<div class="header-left">
|
||||
<h2>{{ title() | translate }}</h2>
|
||||
@if (!showDetails()) {
|
||||
<div class="search-content">
|
||||
<div class="header sticky-header">
|
||||
<div class="header-top">
|
||||
<div class="header-left">
|
||||
<h2>{{ title() | translate }}</h2>
|
||||
|
||||
<!-- Filters slot -->
|
||||
<div class="type-filters">
|
||||
<ng-content select="[filters]" />
|
||||
<!-- Filters slot -->
|
||||
<div class="type-filters">
|
||||
<ng-content select="[filters]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
@if (showResultsCount() && resultsCount() > 0) {
|
||||
<div class="results-count">
|
||||
{{
|
||||
'PORTALS.SEARCH_VIEW.RESULTS_FOUND'
|
||||
| translate: { count: resultsCount() }
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
@if (showCloseButton()) {
|
||||
<button mat-icon-button (click)="onCloseClick()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<!-- Extra header actions slot -->
|
||||
<ng-content select="[header-actions]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
@if (showResultsCount() && resultsCount() > 0) {
|
||||
<div class="results-count">
|
||||
{{
|
||||
'PORTALS.SEARCH_VIEW.RESULTS_FOUND'
|
||||
| translate: { count: resultsCount() }
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
@if (showCloseButton()) {
|
||||
<button mat-icon-button (click)="onCloseClick()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<!-- Extra header actions slot -->
|
||||
<ng-content select="[header-actions]" />
|
||||
</div>
|
||||
@if (showSearchInput()) {
|
||||
<div class="search-input-container">
|
||||
<app-search-form
|
||||
[searchTerm]="searchTerm()"
|
||||
(searchTermChange)="onSearchTermChange($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Subheader slot (info notes, secondary controls) -->
|
||||
<ng-content select="[subheader]" />
|
||||
</div>
|
||||
|
||||
@if (showSearchInput()) {
|
||||
<div class="search-input-container">
|
||||
<app-search-form
|
||||
[searchTerm]="searchTerm()"
|
||||
(searchTermChange)="onSearchTermChange($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Subheader slot (info notes, secondary controls) -->
|
||||
<ng-content select="[subheader]" />
|
||||
</div>
|
||||
|
||||
<div class="results-container">
|
||||
@if (isLoading()) {
|
||||
<div class="loading-state">
|
||||
<mat-spinner diameter="48" />
|
||||
</div>
|
||||
} @else if (showResults) {
|
||||
<!-- Results slot -->
|
||||
<ng-content select="[results]" />
|
||||
} @else if (showNoResults) {
|
||||
<div class="no-items">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
|
||||
<p class="search-term-note">
|
||||
{{
|
||||
'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR'
|
||||
| translate: { term: searchTerm() }
|
||||
}}
|
||||
</p>
|
||||
<div class="results-container">
|
||||
@if (isLoading()) {
|
||||
<div class="loading-state">
|
||||
<mat-spinner diameter="48" />
|
||||
</div>
|
||||
</div>
|
||||
} @else if (showInitialState) {
|
||||
<div class="no-items initial-state">
|
||||
<mat-icon>search</mat-icon>
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}</p>
|
||||
} @else if (showResults) {
|
||||
<!-- Results slot -->
|
||||
<ng-content select="[results]" />
|
||||
} @else if (showNoResults) {
|
||||
<div class="no-items">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
|
||||
<p class="search-term-note">
|
||||
{{
|
||||
'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR'
|
||||
| translate: { term: searchTerm() }
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Custom empty state slot (optional override) -->
|
||||
<ng-content select="[empty]" />
|
||||
} @else if (showInitialState) {
|
||||
<div class="no-items initial-state">
|
||||
<mat-icon>search</mat-icon>
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Custom empty state slot (optional override) -->
|
||||
<ng-content select="[empty]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -50,6 +50,9 @@ export class SearchLayoutComponent {
|
||||
/** Whether to show the inline search input */
|
||||
readonly showSearchInput = input<boolean>(true);
|
||||
|
||||
/** Whether inline details are active and should replace the search body */
|
||||
readonly showDetails = input<boolean>(false);
|
||||
|
||||
/** Minimum characters required for search */
|
||||
readonly minSearchLength = input<number>(3);
|
||||
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
@if (categoryId() === 'vod') {
|
||||
@if ((seriesItem()?.series?.length ?? 0) > 0) {
|
||||
<!-- VOD item with embedded series (vclub) -->
|
||||
<app-stalker-series-view
|
||||
[vodWithSeries]="seriesItem()"
|
||||
(backClicked)="onBackClicked()"
|
||||
/>
|
||||
} @else if (isSeries()) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<app-stalker-series-view (backClicked)="onBackClicked()" />
|
||||
} @else if (vodDetailsItem()) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem()"
|
||||
[isFavorite]="isFavorite()"
|
||||
(playClicked)="onPlayClicked($event)"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(backClicked)="onBackClicked()"
|
||||
/>
|
||||
}
|
||||
} @else if (categoryId() === 'series') {
|
||||
<app-stalker-series-view (backClicked)="onBackClicked()" />
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { VodDetailsItem } from 'shared-interfaces';
|
||||
import { VodDetailsComponent } from '../../../xtream-electron/vod-details/vod-details.component';
|
||||
import { StalkerSelectedVodItem } from '../../../stalker/models/stalker-favorite-item.interface';
|
||||
import { StalkerSeriesViewComponent } from '../../../stalker/stalker-series-view/stalker-series-view.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-inline-detail',
|
||||
imports: [StalkerSeriesViewComponent, VodDetailsComponent],
|
||||
templateUrl: './stalker-inline-detail.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class StalkerInlineDetailComponent {
|
||||
readonly categoryId = input<'vod' | 'series' | null>(null);
|
||||
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
|
||||
readonly isSeries = input<boolean>(false);
|
||||
readonly vodDetailsItem = input<VodDetailsItem | null>(null);
|
||||
readonly isFavorite = input<boolean>(false);
|
||||
|
||||
readonly backClicked = output<void>();
|
||||
readonly playClicked = output<VodDetailsItem>();
|
||||
readonly favoriteToggled = output<{
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
}>();
|
||||
|
||||
onBackClicked() {
|
||||
this.backClicked.emit();
|
||||
}
|
||||
|
||||
onPlayClicked(item: VodDetailsItem) {
|
||||
this.playClicked.emit(item);
|
||||
}
|
||||
|
||||
onFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }) {
|
||||
this.favoriteToggled.emit(event);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
} from '@angular/router';
|
||||
import { PortalRailLink } from './portal-rail-links';
|
||||
import { PortalRailLink, PortalRailSection } from './portal-rail-links';
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-rail-links',
|
||||
@@ -20,7 +20,9 @@ export class PortalRailLinksComponent {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly links = input<PortalRailLink[]>([]);
|
||||
readonly selectedSection = input<string | null | undefined>(null);
|
||||
readonly selectedSection = input<
|
||||
PortalRailSection | string | null | undefined
|
||||
>(null);
|
||||
readonly activeClass = input<'active' | 'is-active'>('active');
|
||||
readonly variant = input<'list' | 'rail'>('list');
|
||||
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
export type PortalProvider = 'xtreams' | 'stalker' | 'playlists';
|
||||
export type PortalRailSection =
|
||||
| 'all'
|
||||
| 'downloads'
|
||||
| 'favorites'
|
||||
| 'groups'
|
||||
| 'itv'
|
||||
| 'library'
|
||||
| 'live'
|
||||
| 'recent'
|
||||
| 'recently-added'
|
||||
| 'search'
|
||||
| 'series'
|
||||
| 'vod';
|
||||
|
||||
export interface PortalRailLink {
|
||||
icon: string;
|
||||
tooltip: string;
|
||||
path: (string | number)[];
|
||||
exact?: boolean;
|
||||
section?: string;
|
||||
section?: PortalRailSection;
|
||||
}
|
||||
|
||||
interface BuildPortalRailLinksOptions {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
createEnvironmentInjector,
|
||||
EnvironmentInjector,
|
||||
runInInjectionContext,
|
||||
} from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import {
|
||||
extractPortalSection,
|
||||
isWorkspaceLayoutRoute,
|
||||
queryParamSignal,
|
||||
resolveCurrentPortalSection,
|
||||
} from './portal-route.utils';
|
||||
|
||||
describe('portal-route.utils', () => {
|
||||
it('detects workspace layout from route data', () => {
|
||||
const route = {
|
||||
snapshot: {
|
||||
data: {
|
||||
layout: 'workspace',
|
||||
},
|
||||
},
|
||||
} as unknown as ActivatedRoute;
|
||||
|
||||
expect(isWorkspaceLayoutRoute(route)).toBe(true);
|
||||
});
|
||||
|
||||
it('extracts Xtream sections from workspace and local URLs', () => {
|
||||
expect(extractPortalSection('/xtreams/123/search', 'xtreams')).toBe(
|
||||
'search'
|
||||
);
|
||||
expect(
|
||||
extractPortalSection('/workspace/xtreams/123/recent', 'xtreams')
|
||||
).toBe('recent');
|
||||
});
|
||||
|
||||
it('extracts Stalker sections from workspace and local URLs', () => {
|
||||
expect(extractPortalSection('/stalker/321/series', 'stalker')).toBe(
|
||||
'series'
|
||||
);
|
||||
expect(
|
||||
extractPortalSection('/workspace/stalker/321/favorites', 'stalker')
|
||||
).toBe('favorites');
|
||||
});
|
||||
|
||||
it('prefers the child snapshot section and falls back to URL parsing', () => {
|
||||
const routeWithChild = {
|
||||
firstChild: {
|
||||
snapshot: {
|
||||
url: [{ path: 'vod' }],
|
||||
},
|
||||
},
|
||||
} as unknown as ActivatedRoute;
|
||||
const routeWithoutChild = {
|
||||
firstChild: null,
|
||||
} as unknown as ActivatedRoute;
|
||||
|
||||
expect(
|
||||
resolveCurrentPortalSection(
|
||||
routeWithChild,
|
||||
'/workspace/stalker/123/search',
|
||||
'stalker'
|
||||
)
|
||||
).toBe('vod');
|
||||
expect(
|
||||
resolveCurrentPortalSection(
|
||||
routeWithoutChild,
|
||||
'/workspace/stalker/123/search',
|
||||
'stalker'
|
||||
)
|
||||
).toBe('search');
|
||||
});
|
||||
|
||||
it('creates normalized query-param signals', () => {
|
||||
TestBed.configureTestingModule({});
|
||||
|
||||
const parentInjector = TestBed.inject(EnvironmentInjector);
|
||||
const childInjector = createEnvironmentInjector([], parentInjector);
|
||||
const queryParams$ = new BehaviorSubject(
|
||||
convertToParamMap({
|
||||
q: ' Matrix ',
|
||||
refresh: '42',
|
||||
})
|
||||
);
|
||||
const route = {
|
||||
snapshot: {
|
||||
data: {},
|
||||
queryParamMap: convertToParamMap({
|
||||
q: ' Matrix ',
|
||||
refresh: '42',
|
||||
}),
|
||||
},
|
||||
queryParamMap: queryParams$.asObservable(),
|
||||
} as unknown as ActivatedRoute;
|
||||
|
||||
const searchTerm = runInInjectionContext(childInjector, () =>
|
||||
queryParamSignal(route, 'q', (value) =>
|
||||
(value ?? '').trim().toLowerCase()
|
||||
)
|
||||
);
|
||||
const refreshToken = runInInjectionContext(childInjector, () =>
|
||||
queryParamSignal(route, 'refresh', (value) => value ?? '')
|
||||
);
|
||||
|
||||
expect(searchTerm()).toBe('matrix');
|
||||
expect(refreshToken()).toBe('42');
|
||||
|
||||
queryParams$.next(
|
||||
convertToParamMap({
|
||||
q: 'Alien',
|
||||
refresh: '84',
|
||||
})
|
||||
);
|
||||
|
||||
expect(searchTerm()).toBe('alien');
|
||||
expect(refreshToken()).toBe('84');
|
||||
|
||||
childInjector.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { map } from 'rxjs';
|
||||
import {
|
||||
PortalProvider,
|
||||
PortalRailSection,
|
||||
} from './portal-rail-links';
|
||||
|
||||
type QueryParamNormalizer<T> = (value: string | null) => T;
|
||||
|
||||
export function isWorkspaceLayoutRoute(route: ActivatedRoute): boolean {
|
||||
return route.snapshot.data['layout'] === 'workspace';
|
||||
}
|
||||
|
||||
export function queryParamSignal<T = string>(
|
||||
route: ActivatedRoute,
|
||||
key: string,
|
||||
normalizer?: QueryParamNormalizer<T>
|
||||
): Signal<T> {
|
||||
const normalize =
|
||||
normalizer ??
|
||||
(((
|
||||
value: string | null
|
||||
) => (value ?? '') as T) as QueryParamNormalizer<T>);
|
||||
|
||||
return toSignal(
|
||||
route.queryParamMap.pipe(map((params) => normalize(params.get(key)))),
|
||||
{
|
||||
initialValue: normalize(route.snapshot.queryParamMap.get(key)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPortalSection(
|
||||
url: string,
|
||||
provider: PortalProvider
|
||||
): PortalRailSection | null {
|
||||
const match = url.match(
|
||||
new RegExp(`^/(?:workspace/)?${provider}/[^/?]+/([^/?]+)`)
|
||||
);
|
||||
return (match?.[1] as PortalRailSection | undefined) ?? null;
|
||||
}
|
||||
|
||||
export function resolveCurrentPortalSection(
|
||||
route: ActivatedRoute,
|
||||
routerUrl: string,
|
||||
provider: PortalProvider
|
||||
): PortalRailSection | null {
|
||||
const sectionFromSnapshot =
|
||||
route.firstChild?.snapshot?.url?.[0]?.path ?? null;
|
||||
|
||||
return (
|
||||
(sectionFromSnapshot as PortalRailSection | null) ??
|
||||
extractPortalSection(routerUrl, provider)
|
||||
);
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
createEnvironmentInjector,
|
||||
EnvironmentInjector,
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { FavoritesContextService } from '../../workspace/favorites-context.service';
|
||||
import { createPortalCollectionContext } from './portal-collection-context';
|
||||
|
||||
describe('portal-collection-context', () => {
|
||||
it('syncs categories into the favorites context', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [FavoritesContextService],
|
||||
});
|
||||
|
||||
const parentInjector = TestBed.inject(EnvironmentInjector);
|
||||
const childInjector = createEnvironmentInjector([], parentInjector);
|
||||
const ctx = TestBed.inject(FavoritesContextService);
|
||||
const categories = signal([
|
||||
{
|
||||
id: 1,
|
||||
category_id: 'all',
|
||||
category_name: 'All',
|
||||
count: 2,
|
||||
parent_id: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const bridge = runInInjectionContext(childInjector, () =>
|
||||
createPortalCollectionContext({
|
||||
ctx,
|
||||
categories,
|
||||
})
|
||||
);
|
||||
|
||||
bridge.setCategoryId('all');
|
||||
|
||||
expect(ctx.categories()).toEqual(categories());
|
||||
expect(ctx.selectedCategoryId()).toBe('all');
|
||||
|
||||
childInjector.destroy();
|
||||
});
|
||||
|
||||
it('does not mutate the shared context when disabled', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [FavoritesContextService],
|
||||
});
|
||||
|
||||
const parentInjector = TestBed.inject(EnvironmentInjector);
|
||||
const childInjector = createEnvironmentInjector([], parentInjector);
|
||||
const ctx = TestBed.inject(FavoritesContextService);
|
||||
const categories = signal([
|
||||
{
|
||||
id: 1,
|
||||
category_id: 'movie',
|
||||
category_name: 'Movies',
|
||||
count: 1,
|
||||
parent_id: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
ctx.setCategories([
|
||||
{
|
||||
id: 99,
|
||||
category_id: 'all',
|
||||
category_name: 'Existing',
|
||||
count: 5,
|
||||
parent_id: 0,
|
||||
},
|
||||
]);
|
||||
ctx.setCategoryId('series');
|
||||
|
||||
const bridge = runInInjectionContext(childInjector, () =>
|
||||
createPortalCollectionContext({
|
||||
ctx,
|
||||
categories,
|
||||
enabled: () => false,
|
||||
})
|
||||
);
|
||||
|
||||
bridge.setCategoryId('movie');
|
||||
|
||||
expect(ctx.categories()[0]?.category_name).toBe('Existing');
|
||||
expect(ctx.selectedCategoryId()).toBe('series');
|
||||
|
||||
childInjector.destroy();
|
||||
|
||||
expect(ctx.categories()[0]?.category_name).toBe('Existing');
|
||||
expect(ctx.selectedCategoryId()).toBe('series');
|
||||
});
|
||||
|
||||
it('resets managed context state on destroy', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [FavoritesContextService],
|
||||
});
|
||||
|
||||
const parentInjector = TestBed.inject(EnvironmentInjector);
|
||||
const childInjector = createEnvironmentInjector([], parentInjector);
|
||||
const ctx = TestBed.inject(FavoritesContextService);
|
||||
const categories = signal([
|
||||
{
|
||||
id: 1,
|
||||
category_id: 'all',
|
||||
category_name: 'All',
|
||||
count: 3,
|
||||
parent_id: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
runInInjectionContext(childInjector, () =>
|
||||
createPortalCollectionContext({
|
||||
ctx,
|
||||
categories,
|
||||
})
|
||||
);
|
||||
|
||||
ctx.setCategoryId('movie');
|
||||
|
||||
childInjector.destroy();
|
||||
|
||||
expect(ctx.categories()).toEqual([]);
|
||||
expect(ctx.selectedCategoryId()).toBe('all');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { DestroyRef, Signal, effect, inject } from '@angular/core';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import { FavoritesContextService } from '../../workspace/favorites-context.service';
|
||||
|
||||
export interface PortalCollectionContextBridge {
|
||||
readonly selectedCategoryId: Signal<string>;
|
||||
setCategoryId(id: string): void;
|
||||
}
|
||||
|
||||
interface CreatePortalCollectionContextOptions {
|
||||
ctx: FavoritesContextService;
|
||||
categories: () => XtreamCategory[];
|
||||
enabled?: () => boolean;
|
||||
}
|
||||
|
||||
export function createPortalCollectionContext(
|
||||
options: CreatePortalCollectionContextOptions
|
||||
): PortalCollectionContextBridge {
|
||||
const destroyRef = inject(DestroyRef);
|
||||
const isEnabled = options.enabled ?? (() => true);
|
||||
let managesContext = false;
|
||||
|
||||
effect(() => {
|
||||
if (!isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
managesContext = true;
|
||||
options.ctx.setCategories(options.categories());
|
||||
});
|
||||
|
||||
destroyRef.onDestroy(() => {
|
||||
if (managesContext && isEnabled()) {
|
||||
options.ctx.reset();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
selectedCategoryId: options.ctx.selectedCategoryId,
|
||||
setCategoryId(id: string): void {
|
||||
if (isEnabled()) {
|
||||
options.ctx.setCategoryId(id);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
buildStandardCollectionCategories,
|
||||
filterCollectionBucket,
|
||||
} from './portal-collection-items';
|
||||
|
||||
describe('portal-collection-items', () => {
|
||||
it('builds 4-category collections when live content is included', () => {
|
||||
const categories = buildStandardCollectionCategories({
|
||||
labels: {
|
||||
all: 'All',
|
||||
movie: 'Movies',
|
||||
live: 'Live TV',
|
||||
series: 'Series',
|
||||
},
|
||||
counts: {
|
||||
all: 10,
|
||||
movie: 4,
|
||||
live: 3,
|
||||
series: 3,
|
||||
},
|
||||
includeLive: true,
|
||||
});
|
||||
|
||||
expect(categories.map((category) => category.category_id)).toEqual([
|
||||
'all',
|
||||
'movie',
|
||||
'live',
|
||||
'series',
|
||||
]);
|
||||
expect(categories[2]?.count).toBe(3);
|
||||
});
|
||||
|
||||
it('supports alternate live category ids for stalker collections', () => {
|
||||
const categories = buildStandardCollectionCategories({
|
||||
labels: {
|
||||
all: 'All',
|
||||
movie: 'Movies',
|
||||
live: 'Live TV',
|
||||
series: 'Series',
|
||||
},
|
||||
counts: {
|
||||
all: 3,
|
||||
movie: 1,
|
||||
live: 1,
|
||||
series: 1,
|
||||
},
|
||||
includeLive: true,
|
||||
liveCategoryId: 'itv',
|
||||
});
|
||||
|
||||
expect(categories[2]?.category_id).toBe('itv');
|
||||
});
|
||||
|
||||
it('builds 3-category collections when live content is excluded', () => {
|
||||
const categories = buildStandardCollectionCategories({
|
||||
labels: {
|
||||
all: 'All',
|
||||
movie: 'Movies',
|
||||
live: 'Live TV',
|
||||
series: 'Series',
|
||||
},
|
||||
counts: {
|
||||
all: 5,
|
||||
movie: 2,
|
||||
series: 3,
|
||||
},
|
||||
includeLive: false,
|
||||
});
|
||||
|
||||
expect(categories.map((category) => category.category_id)).toEqual([
|
||||
'all',
|
||||
'movie',
|
||||
'series',
|
||||
]);
|
||||
expect(categories[2]?.id).toBe(3);
|
||||
});
|
||||
|
||||
it('filters items by selected category', () => {
|
||||
const items = [
|
||||
{ title: 'Movie 1' },
|
||||
{ title: 'Series 1' },
|
||||
{ title: 'Live 1' },
|
||||
];
|
||||
|
||||
const filtered = filterCollectionBucket({
|
||||
selectedCategoryId: 'series',
|
||||
allItems: items,
|
||||
buckets: {
|
||||
movie: [items[0]],
|
||||
live: [items[2]],
|
||||
series: [items[1]],
|
||||
},
|
||||
searchTerm: '',
|
||||
textOf: (item) => item.title,
|
||||
});
|
||||
|
||||
expect(filtered).toEqual([items[1]]);
|
||||
});
|
||||
|
||||
it('filters alternate live buckets without changing stalker category ids', () => {
|
||||
const items = [
|
||||
{ name: 'Movie' },
|
||||
{ name: 'Live' },
|
||||
];
|
||||
|
||||
const filtered = filterCollectionBucket({
|
||||
selectedCategoryId: 'itv',
|
||||
allItems: items,
|
||||
buckets: {
|
||||
movie: [items[0]],
|
||||
live: [items[1]],
|
||||
},
|
||||
liveCategoryId: 'itv',
|
||||
textOf: (item) => item.name,
|
||||
});
|
||||
|
||||
expect(filtered).toEqual([items[1]]);
|
||||
});
|
||||
|
||||
it('filters items by search term using the provided text selector', () => {
|
||||
const items = [
|
||||
{ name: 'Alien', o_name: 'Extended Cut' },
|
||||
{ name: 'Matrix', o_name: 'Neo' },
|
||||
];
|
||||
|
||||
const filtered = filterCollectionBucket({
|
||||
selectedCategoryId: 'all',
|
||||
allItems: items,
|
||||
buckets: {
|
||||
movie: items,
|
||||
},
|
||||
searchTerm: 'neo',
|
||||
textOf: (item) => `${item.name} ${item.o_name}`,
|
||||
});
|
||||
|
||||
expect(filtered).toEqual([items[1]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
|
||||
type StandardCollectionBucket = 'all' | 'movie' | 'live' | 'series';
|
||||
|
||||
interface BuildStandardCollectionCategoriesOptions {
|
||||
labels: Record<StandardCollectionBucket, string>;
|
||||
counts: Partial<Record<StandardCollectionBucket, number>>;
|
||||
includeLive?: boolean;
|
||||
liveCategoryId?: 'itv' | 'live';
|
||||
}
|
||||
|
||||
interface FilterCollectionBucketOptions<T> {
|
||||
selectedCategoryId: string | null | undefined;
|
||||
allItems: readonly T[] | null | undefined;
|
||||
buckets: Partial<Record<'movie' | 'live' | 'series', readonly T[] | null | undefined>>;
|
||||
searchTerm?: string | null | undefined;
|
||||
liveCategoryId?: string;
|
||||
textOf: (item: T) => string;
|
||||
}
|
||||
|
||||
export function buildStandardCollectionCategories(
|
||||
options: BuildStandardCollectionCategoriesOptions
|
||||
): XtreamCategory[] {
|
||||
const {
|
||||
labels,
|
||||
counts,
|
||||
includeLive = false,
|
||||
liveCategoryId = 'live',
|
||||
} = options;
|
||||
const categories: XtreamCategory[] = [
|
||||
{
|
||||
id: 1,
|
||||
category_id: 'all',
|
||||
category_name: labels.all,
|
||||
count: counts.all ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category_id: 'movie',
|
||||
category_name: labels.movie,
|
||||
count: counts.movie ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
];
|
||||
|
||||
if (includeLive) {
|
||||
categories.push({
|
||||
id: 3,
|
||||
category_id: liveCategoryId,
|
||||
category_name: labels.live,
|
||||
count: counts.live ?? 0,
|
||||
parent_id: 0,
|
||||
});
|
||||
}
|
||||
|
||||
categories.push({
|
||||
id: includeLive ? 4 : 3,
|
||||
category_id: 'series',
|
||||
category_name: labels.series,
|
||||
count: counts.series ?? 0,
|
||||
parent_id: 0,
|
||||
});
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
export function filterCollectionBucket<T>(
|
||||
options: FilterCollectionBucketOptions<T>
|
||||
): T[] {
|
||||
const {
|
||||
selectedCategoryId,
|
||||
allItems,
|
||||
buckets,
|
||||
searchTerm,
|
||||
liveCategoryId = 'live',
|
||||
textOf,
|
||||
} = options;
|
||||
const baseItems =
|
||||
selectedCategoryId === 'movie'
|
||||
? buckets.movie ?? []
|
||||
: selectedCategoryId === liveCategoryId
|
||||
? buckets.live ?? []
|
||||
: selectedCategoryId === 'series'
|
||||
? buckets.series ?? []
|
||||
: allItems ?? [];
|
||||
const normalizedTerm = (searchTerm ?? '').trim().toLowerCase();
|
||||
|
||||
if (!normalizedTerm) {
|
||||
return [...baseItems];
|
||||
}
|
||||
|
||||
return baseItems.filter((item) =>
|
||||
String(textOf(item)).toLowerCase().includes(normalizedTerm)
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,25 @@
|
||||
@if (itemDetails) {
|
||||
@switch (itemDetails.category_id) {
|
||||
@case ('vod') {
|
||||
@if (itemDetails.series?.length > 0) {
|
||||
<!-- VOD item with embedded series (vclub) -->
|
||||
<app-stalker-series-view
|
||||
[vodWithSeries]="itemDetails"
|
||||
(backClicked)="itemDetails = null; vodDetailsItem = null"
|
||||
/>
|
||||
} @else if (itemDetails.is_series === true) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<app-stalker-series-view
|
||||
(backClicked)="itemDetails = null; vodDetailsItem = null"
|
||||
/>
|
||||
} @else if (vodDetailsItem) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('series') {
|
||||
<app-stalker-series-view (backClicked)="itemDetails = null" />
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<app-favorites-layout
|
||||
[categories]="categories() ?? []"
|
||||
[favorites]="itemsToShow() ?? []"
|
||||
[playlistSubtitle]="'Stalker Portal'"
|
||||
[playlistTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[titleTranslationString]="'PORTALS.SIDEBAR.RECENT'"
|
||||
[removeTooltip]="'Remove from recent'"
|
||||
[emptyIcon]="'history'"
|
||||
[showHeaderAction]="!isWorkspaceLayout"
|
||||
[headerActionIcon]="'delete_sweep'"
|
||||
[headerActionTooltip]="'Clear recently viewed'"
|
||||
(categoryClicked)="setCategoryId($event.category_id)"
|
||||
(removeFavorite)="removeFromRecentlyViewed($event)"
|
||||
(headerActionClicked)="clearAllRecentlyViewed()"
|
||||
(openItem)="openItem($event)"
|
||||
<app-portal-collection-shell
|
||||
[categories]="categories() ?? []"
|
||||
[items]="itemsToShow() ?? []"
|
||||
[playlistSubtitle]="playlistSubtitle"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[layout]="layout"
|
||||
[showDetails]="showDetails()"
|
||||
(categoryClicked)="setCategoryId($event)"
|
||||
(removeItem)="removeFromRecentlyViewed($event)"
|
||||
(headerActionClicked)="clearAllRecentlyViewed()"
|
||||
(openItem)="openItem($event)"
|
||||
>
|
||||
<app-stalker-inline-detail
|
||||
details
|
||||
[categoryId]="inlineDetail().categoryId"
|
||||
[seriesItem]="inlineDetail().seriesItem"
|
||||
[isSeries]="inlineDetail().isSeries"
|
||||
[vodDetailsItem]="inlineDetail().vodDetailsItem"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
</app-portal-collection-shell>
|
||||
@@ -3,23 +3,32 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
OnDestroy,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { map } from 'rxjs';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { VodDetailsItem } from 'shared-interfaces';
|
||||
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
|
||||
import {
|
||||
PortalCollectionShellComponent,
|
||||
PortalCollectionShellLayout,
|
||||
} from '../../shared/components/portal-collection-shell/portal-collection-shell.component';
|
||||
import { StalkerInlineDetailComponent } from '../../shared/components/stalker-inline-detail/stalker-inline-detail.component';
|
||||
import {
|
||||
isWorkspaceLayoutRoute,
|
||||
queryParamSignal,
|
||||
} from '../../shared/navigation/portal-route.utils';
|
||||
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
|
||||
import {
|
||||
buildStandardCollectionCategories,
|
||||
filterCollectionBucket,
|
||||
} from '../../shared/utils/portal-collection-items';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
import { FavoritesContextService } from '../../workspace/favorites-context.service';
|
||||
import { VodDetailsComponent } from '../../xtream-electron/vod-details/vod-details.component';
|
||||
import { StalkerSelectedVodItem, StalkerVodSource } from '../models';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
import {
|
||||
clearStalkerDetailViewState,
|
||||
createStalkerInlineDetailState,
|
||||
createPortalCollectionResource,
|
||||
createPortalFavoritesResource,
|
||||
createRefreshTrigger,
|
||||
@@ -30,14 +39,21 @@ import {
|
||||
} from '../stalker-vod.utils';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
|
||||
const STALKER_RECENT_LAYOUT: Omit<
|
||||
PortalCollectionShellLayout,
|
||||
'showHeaderAction'
|
||||
> = {
|
||||
titleTranslationKey: 'PORTALS.SIDEBAR.RECENT',
|
||||
removeTooltip: 'Remove from recent',
|
||||
emptyIcon: 'history',
|
||||
headerActionIcon: 'delete_sweep',
|
||||
headerActionTooltip: 'Clear recently viewed',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-recently-viewed',
|
||||
templateUrl: './recently-viewed.component.html',
|
||||
imports: [
|
||||
FavoritesLayoutComponent,
|
||||
StalkerSeriesViewComponent,
|
||||
VodDetailsComponent,
|
||||
],
|
||||
imports: [PortalCollectionShellComponent, StalkerInlineDetailComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
@@ -53,7 +69,7 @@ import { StalkerStore } from '../stalker.store';
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class RecentlyViewedComponent implements OnDestroy {
|
||||
export class RecentlyViewedComponent {
|
||||
private static isCategoryType(
|
||||
value: string
|
||||
): value is 'vod' | 'series' | 'itv' {
|
||||
@@ -69,11 +85,15 @@ export class RecentlyViewedComponent implements OnDestroy {
|
||||
private readonly logger = createLogger('StalkerRecentlyViewed');
|
||||
private readonly favoritesCtx = inject(FavoritesContextService);
|
||||
|
||||
itemDetails: (StalkerVodSource & { category_id?: string }) | null = null;
|
||||
itemDetails: StalkerSelectedVodItem | null = null;
|
||||
vodDetailsItem: VodDetailsItem | null = null;
|
||||
readonly isSelectedVodFavorite = signal<boolean>(false);
|
||||
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
readonly playlistSubtitle = 'Stalker Portal';
|
||||
readonly playlistTitle = computed(
|
||||
() => this.currentPlaylist()?.title || 'Portal'
|
||||
);
|
||||
|
||||
readonly allFavorites = createPortalCollectionResource(
|
||||
this.playlistService,
|
||||
@@ -88,81 +108,58 @@ export class RecentlyViewedComponent implements OnDestroy {
|
||||
() => this.favoritesRefresh.refreshVersion()
|
||||
);
|
||||
|
||||
readonly categories = computed(() => [
|
||||
{
|
||||
id: 1,
|
||||
category_id: 'all',
|
||||
category_name: this.translate.instant('PORTALS.ALL_CATEGORIES'),
|
||||
count: this.allFavorites.value()?.length ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category_id: 'movie',
|
||||
category_name: this.translate.instant('PORTALS.SIDEBAR.MOVIES'),
|
||||
count: this.movies()?.length ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category_id: 'itv',
|
||||
category_name: this.translate.instant('PORTALS.SIDEBAR.LIVE_TV'),
|
||||
count: this.live()?.length ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category_id: 'series',
|
||||
category_name: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
|
||||
count: this.series()?.length ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
]);
|
||||
readonly categories = computed(() =>
|
||||
buildStandardCollectionCategories({
|
||||
labels: {
|
||||
all: this.translate.instant('PORTALS.ALL_CATEGORIES'),
|
||||
movie: this.translate.instant('PORTALS.SIDEBAR.MOVIES'),
|
||||
live: this.translate.instant('PORTALS.SIDEBAR.LIVE_TV'),
|
||||
series: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
|
||||
},
|
||||
counts: {
|
||||
all: this.allFavorites.value()?.length ?? 0,
|
||||
movie: this.movies()?.length ?? 0,
|
||||
live: this.live()?.length ?? 0,
|
||||
series: this.series()?.length ?? 0,
|
||||
},
|
||||
includeLive: true,
|
||||
liveCategoryId: 'itv',
|
||||
})
|
||||
);
|
||||
readonly collectionContext = createPortalCollectionContext({
|
||||
ctx: this.favoritesCtx,
|
||||
categories: this.categories,
|
||||
});
|
||||
|
||||
readonly itemsToShow = computed(() => {
|
||||
const term = this.searchTerm();
|
||||
const filterByTerm = (
|
||||
items: (StalkerVodSource & { category_id?: string })[] | undefined
|
||||
) => {
|
||||
if (!items) return [];
|
||||
if (!term) return items;
|
||||
|
||||
return items.filter((item) =>
|
||||
`${item?.name ?? ''} ${item?.o_name ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(term)
|
||||
);
|
||||
};
|
||||
|
||||
switch (this.selectedCategoryId()) {
|
||||
case 'all':
|
||||
return filterByTerm(this.allFavorites.value());
|
||||
case 'movie':
|
||||
return filterByTerm(this.movies());
|
||||
case 'itv':
|
||||
return filterByTerm(this.live());
|
||||
case 'series':
|
||||
return filterByTerm(this.series());
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
return filterCollectionBucket({
|
||||
selectedCategoryId: this.selectedCategoryId(),
|
||||
allItems: this.allFavorites.value(),
|
||||
buckets: {
|
||||
movie: this.movies(),
|
||||
live: this.live(),
|
||||
series: this.series(),
|
||||
},
|
||||
searchTerm: this.searchTerm(),
|
||||
liveCategoryId: 'itv',
|
||||
textOf: (item: any) => `${item?.name ?? ''} ${item?.o_name ?? ''}`,
|
||||
});
|
||||
});
|
||||
|
||||
/** Synced with workspace context service so panel clicks are reactive */
|
||||
readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId;
|
||||
readonly isWorkspaceLayout =
|
||||
this.route.snapshot.data['layout'] === 'workspace';
|
||||
readonly searchTerm = toSignal(
|
||||
this.route.queryParamMap.pipe(
|
||||
map((params) => (params.get('q') ?? '').trim().toLowerCase())
|
||||
),
|
||||
{ initialValue: '' }
|
||||
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
readonly layout: PortalCollectionShellLayout = {
|
||||
...STALKER_RECENT_LAYOUT,
|
||||
showHeaderAction: !this.isWorkspaceLayout,
|
||||
};
|
||||
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
|
||||
(value ?? '').trim().toLowerCase()
|
||||
);
|
||||
readonly refreshToken = toSignal(
|
||||
this.route.queryParamMap.pipe(
|
||||
map((params) => params.get('refresh') ?? '')
|
||||
),
|
||||
{ initialValue: '' }
|
||||
readonly refreshToken = queryParamSignal(
|
||||
this.route,
|
||||
'refresh',
|
||||
(value) => value ?? ''
|
||||
);
|
||||
|
||||
readonly series = computed(() =>
|
||||
@@ -183,11 +180,6 @@ export class RecentlyViewedComponent implements OnDestroy {
|
||||
this.syncSelectedVodFavorite();
|
||||
});
|
||||
|
||||
// Keep workspace context panel in sync with categories
|
||||
effect(() => {
|
||||
this.favoritesCtx.setCategories(this.categories());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const refresh = this.refreshToken();
|
||||
if (!refresh) {
|
||||
@@ -245,7 +237,7 @@ export class RecentlyViewedComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
setCategoryId(categoryId: string) {
|
||||
this.favoritesCtx.setCategoryId(categoryId);
|
||||
this.collectionContext.setCategoryId(categoryId);
|
||||
}
|
||||
|
||||
openItem(item: StalkerVodSource & { category_id: string }) {
|
||||
@@ -279,8 +271,8 @@ export class RecentlyViewedComponent implements OnDestroy {
|
||||
break;
|
||||
}
|
||||
case 'series':
|
||||
this.itemDetails = item;
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
this.itemDetails = this.normalizeRecentItem(item);
|
||||
this.stalkerStore.setSelectedItem(this.itemDetails);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -362,7 +354,14 @@ export class RecentlyViewedComponent implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.favoritesCtx.reset();
|
||||
inlineDetail() {
|
||||
return createStalkerInlineDetailState(
|
||||
this.itemDetails,
|
||||
this.vodDetailsItem
|
||||
);
|
||||
}
|
||||
|
||||
showDetails() {
|
||||
return this.inlineDetail().categoryId !== null;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,24 @@
|
||||
@if (itemDetails) {
|
||||
@switch (itemDetails.category_id) {
|
||||
@case ('vod') {
|
||||
@if (itemDetails.details?.series?.length > 0) {
|
||||
<!-- VOD item with embedded series (vclub) -->
|
||||
<app-stalker-series-view
|
||||
[vodWithSeries]="itemDetails.details"
|
||||
(backClicked)="onSeriesBack()"
|
||||
/>
|
||||
} @else if (itemDetails.details?.is_series === true) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<app-stalker-series-view
|
||||
(backClicked)="onSeriesBack()"
|
||||
/>
|
||||
} @else if (vodDetailsItem) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('series') {
|
||||
<app-stalker-series-view (backClicked)="onSeriesBack()" />
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<app-favorites-layout
|
||||
[categories]="categories() ?? []"
|
||||
[favorites]="favoritesToShow() ?? []"
|
||||
[playlistSubtitle]="'Stalker Portal'"
|
||||
[playlistTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
(categoryClicked)="setCategoryId($event.category_id)"
|
||||
(removeFavorite)="removeFromFavorites($event)"
|
||||
(openItem)="openItem($event)"
|
||||
<app-portal-collection-shell
|
||||
[categories]="categories() ?? []"
|
||||
[items]="favoritesToShow() ?? []"
|
||||
[playlistSubtitle]="playlistSubtitle"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[layout]="layout"
|
||||
[showDetails]="showDetails()"
|
||||
(categoryClicked)="setCategoryId($event)"
|
||||
(removeItem)="removeFromFavorites($event)"
|
||||
(openItem)="openItem($event)"
|
||||
>
|
||||
<app-stalker-inline-detail
|
||||
details
|
||||
[categoryId]="inlineDetail().categoryId"
|
||||
[seriesItem]="inlineDetail().seriesItem"
|
||||
[isSeries]="inlineDetail().isSeries"
|
||||
[vodDetailsItem]="inlineDetail().vodDetailsItem"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onSeriesBack()"
|
||||
/>
|
||||
}
|
||||
</app-portal-collection-shell>
|
||||
@@ -3,23 +3,29 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
OnDestroy,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { map } from 'rxjs';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { VodDetailsItem } from 'shared-interfaces';
|
||||
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
|
||||
import {
|
||||
PortalCollectionShellComponent,
|
||||
PortalCollectionShellLayout,
|
||||
} from '../../shared/components/portal-collection-shell/portal-collection-shell.component';
|
||||
import { StalkerInlineDetailComponent } from '../../shared/components/stalker-inline-detail/stalker-inline-detail.component';
|
||||
import { queryParamSignal } from '../../shared/navigation/portal-route.utils';
|
||||
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
|
||||
import {
|
||||
buildStandardCollectionCategories,
|
||||
filterCollectionBucket,
|
||||
} from '../../shared/utils/portal-collection-items';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
import { FavoritesContextService } from '../../workspace/favorites-context.service';
|
||||
import { VodDetailsComponent } from '../../xtream-electron/vod-details/vod-details.component';
|
||||
import { StalkerFavoriteItem } from '../models';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
import {
|
||||
clearStalkerDetailViewState,
|
||||
createStalkerInlineDetailState,
|
||||
createPortalFavoritesResource,
|
||||
createRefreshTrigger,
|
||||
createStalkerDetailViewState,
|
||||
@@ -31,14 +37,12 @@ import {
|
||||
} from '../stalker-vod.utils';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
|
||||
const STALKER_FAVORITES_LAYOUT: PortalCollectionShellLayout = {};
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-favorites',
|
||||
templateUrl: './stalker-favorites.component.html',
|
||||
imports: [
|
||||
FavoritesLayoutComponent,
|
||||
StalkerSeriesViewComponent,
|
||||
VodDetailsComponent,
|
||||
],
|
||||
imports: [PortalCollectionShellComponent, StalkerInlineDetailComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
@@ -54,7 +58,7 @@ import { StalkerStore } from '../stalker.store';
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class StalkerFavoritesComponent implements OnDestroy {
|
||||
export class StalkerFavoritesComponent {
|
||||
private static isCategoryType(
|
||||
value: unknown
|
||||
): value is 'vod' | 'series' | 'itv' {
|
||||
@@ -80,6 +84,11 @@ export class StalkerFavoritesComponent implements OnDestroy {
|
||||
readonly isSelectedVodFavorite = signal<boolean>(false);
|
||||
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
readonly layout = STALKER_FAVORITES_LAYOUT;
|
||||
readonly playlistSubtitle = 'Stalker Portal';
|
||||
readonly playlistTitle = computed(
|
||||
() => this.currentPlaylist()?.title || 'Portal'
|
||||
);
|
||||
|
||||
readonly allFavorites = createPortalFavoritesResource(
|
||||
this.playlistService,
|
||||
@@ -87,71 +96,48 @@ export class StalkerFavoritesComponent implements OnDestroy {
|
||||
() => this.favoritesRefresh.refreshVersion()
|
||||
);
|
||||
|
||||
readonly categories = computed(() => [
|
||||
{
|
||||
id: 1,
|
||||
category_id: 'all',
|
||||
category_name: this.translate.instant('PORTALS.ALL_CATEGORIES'),
|
||||
count: this.allFavorites.value()?.length ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category_id: 'movie',
|
||||
category_name: this.translate.instant('PORTALS.SIDEBAR.MOVIES'),
|
||||
count: this.movies()?.length ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category_id: 'itv',
|
||||
category_name: this.translate.instant('PORTALS.SIDEBAR.LIVE_TV'),
|
||||
count: this.live()?.length ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category_id: 'series',
|
||||
category_name: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
|
||||
count: this.series()?.length ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
]);
|
||||
readonly categories = computed(() =>
|
||||
buildStandardCollectionCategories({
|
||||
labels: {
|
||||
all: this.translate.instant('PORTALS.ALL_CATEGORIES'),
|
||||
movie: this.translate.instant('PORTALS.SIDEBAR.MOVIES'),
|
||||
live: this.translate.instant('PORTALS.SIDEBAR.LIVE_TV'),
|
||||
series: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
|
||||
},
|
||||
counts: {
|
||||
all: this.allFavorites.value()?.length ?? 0,
|
||||
movie: this.movies()?.length ?? 0,
|
||||
live: this.live()?.length ?? 0,
|
||||
series: this.series()?.length ?? 0,
|
||||
},
|
||||
includeLive: true,
|
||||
liveCategoryId: 'itv',
|
||||
})
|
||||
);
|
||||
readonly collectionContext = createPortalCollectionContext({
|
||||
ctx: this.favoritesCtx,
|
||||
categories: this.categories,
|
||||
});
|
||||
|
||||
readonly favoritesToShow = computed(() => {
|
||||
const term = this.searchTerm();
|
||||
const filterByTerm = (items: StalkerFavoriteItem[] | undefined) => {
|
||||
if (!items) return [];
|
||||
if (!term) return items;
|
||||
|
||||
return items.filter((item) =>
|
||||
`${item?.name ?? ''} ${item?.o_name ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(term)
|
||||
);
|
||||
};
|
||||
|
||||
switch (this.selectedCategoryId()) {
|
||||
case 'all':
|
||||
return filterByTerm(this.allFavorites.value());
|
||||
case 'movie':
|
||||
return filterByTerm(this.movies());
|
||||
case 'itv':
|
||||
return filterByTerm(this.live());
|
||||
case 'series':
|
||||
return filterByTerm(this.series());
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
return filterCollectionBucket({
|
||||
selectedCategoryId: this.selectedCategoryId(),
|
||||
allItems: this.allFavorites.value(),
|
||||
buckets: {
|
||||
movie: this.movies(),
|
||||
live: this.live(),
|
||||
series: this.series(),
|
||||
},
|
||||
searchTerm: this.searchTerm(),
|
||||
liveCategoryId: 'itv',
|
||||
textOf: (item: any) => `${item?.name ?? ''} ${item?.o_name ?? ''}`,
|
||||
});
|
||||
});
|
||||
|
||||
/** Synced with workspace context service so panel clicks are reactive */
|
||||
readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId;
|
||||
readonly searchTerm = toSignal(
|
||||
this.route.queryParamMap.pipe(
|
||||
map((params) => (params.get('q') ?? '').trim().toLowerCase())
|
||||
),
|
||||
{ initialValue: '' }
|
||||
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
|
||||
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
|
||||
(value ?? '').trim().toLowerCase()
|
||||
);
|
||||
|
||||
readonly series = computed(() =>
|
||||
@@ -172,11 +158,6 @@ export class StalkerFavoritesComponent implements OnDestroy {
|
||||
this.syncSelectedVodFavorite();
|
||||
});
|
||||
|
||||
// Keep workspace context panel in sync with categories
|
||||
effect(() => {
|
||||
this.favoritesCtx.setCategories(this.categories());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const playlist = this.currentPlaylist();
|
||||
if (!playlist?._id) {
|
||||
@@ -227,7 +208,7 @@ export class StalkerFavoritesComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
setCategoryId(categoryId: string) {
|
||||
this.favoritesCtx.setCategoryId(categoryId);
|
||||
this.collectionContext.setCategoryId(categoryId);
|
||||
}
|
||||
|
||||
openItem(item: StalkerFavoriteItem) {
|
||||
@@ -331,6 +312,17 @@ export class StalkerFavoritesComponent implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
inlineDetail() {
|
||||
return createStalkerInlineDetailState(
|
||||
this.itemDetails,
|
||||
this.vodDetailsItem
|
||||
);
|
||||
}
|
||||
|
||||
showDetails() {
|
||||
return this.inlineDetail().categoryId !== null;
|
||||
}
|
||||
|
||||
async createLinkToPlayVodItv(
|
||||
cmd?: string,
|
||||
title?: string,
|
||||
@@ -343,8 +335,4 @@ export class StalkerFavoritesComponent implements OnDestroy {
|
||||
});
|
||||
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.favoritesCtx.reset();
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -30,12 +30,13 @@ import { EpgItem, EpgProgram, PlaylistMeta } from 'shared-interfaces';
|
||||
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
|
||||
import { SettingsStore } from '../../services/settings-store.service';
|
||||
import { PlayerService } from '../../services/player.service';
|
||||
import { isWorkspaceLayoutRoute } from '../../shared/navigation/portal-route.utils';
|
||||
import {
|
||||
getAdjacentChannelItem,
|
||||
getChannelItemByNumber,
|
||||
} from '../../shared/services/remote-channel-navigation.util';
|
||||
import { CategoryViewComponent } from '../../xtream-electron/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../../xtream-electron/playlist-error-view/playlist-error-view.component';
|
||||
import { CategoryViewComponent } from '../../shared/components/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../../shared/components/playlist-error-view/playlist-error-view.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
@@ -53,7 +54,7 @@ import {
|
||||
templateUrl: './stalker-live-stream-layout.component.html',
|
||||
styleUrls: [
|
||||
'./stalker-live-stream-layout.component.scss',
|
||||
'../../xtream-electron/sidebar.scss',
|
||||
'../../shared/styles/portal-sidebar.scss',
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
@@ -95,8 +96,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
readonly isCategoryFailed = this.stalkerStore.isCategoryResourceFailed;
|
||||
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
readonly isWorkspaceLayout =
|
||||
this.route.snapshot.data['layout'] === 'workspace';
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
|
||||
/** Channels */
|
||||
readonly itvChannels = this.stalkerStore.itvChannels;
|
||||
|
||||
@@ -5,8 +5,9 @@ import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../xtream-electron/playlist-error-view/playlist-error-view.component';
|
||||
import { CategoryViewComponent } from '../shared/components/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../shared/components/playlist-error-view/playlist-error-view.component';
|
||||
import { isWorkspaceLayoutRoute } from '../shared/navigation/portal-route.utils';
|
||||
import { StalkerStore } from './stalker.store';
|
||||
|
||||
@Component({
|
||||
@@ -15,7 +16,7 @@ import { StalkerStore } from './stalker.store';
|
||||
styleUrls: [
|
||||
'./stalker-main-container.component.scss',
|
||||
'../xtream-electron/xtream-main-container.component.scss',
|
||||
'../xtream-electron/sidebar.scss',
|
||||
'../shared/styles/portal-sidebar.scss',
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
@@ -34,8 +35,7 @@ export class StalkerMainContainerComponent {
|
||||
private readonly router = inject(Router);
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
readonly isWorkspaceLayout =
|
||||
this.route.snapshot.data['layout'] === 'workspace';
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
|
||||
currentLayout:
|
||||
| 'category_content'
|
||||
|
||||
@@ -1,67 +1,50 @@
|
||||
@if (showingDetails) {
|
||||
<!-- Inline details view -->
|
||||
<div class="details-view">
|
||||
@switch (selectedFilterType()) {
|
||||
@case ('vod') {
|
||||
@if (itemDetails.series?.length > 0) {
|
||||
<!-- VOD item with embedded series (vclub or Ministra) -->
|
||||
<app-stalker-series-view
|
||||
[vodWithSeries]="itemDetails"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
} @else if (itemDetails.is_series === true) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<app-stalker-series-view (backClicked)="onVodBack()" />
|
||||
} @else if (vodDetailsItem) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('series') {
|
||||
<app-stalker-series-view (backClicked)="itemDetails = null" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<app-search-layout
|
||||
[searchTerm]="searchTerm()"
|
||||
[resultsCount]="resultsCount"
|
||||
[isLoading]="searchResultsResource.isLoading()"
|
||||
[showResultsCount]="true"
|
||||
[showSearchInput]="!isWorkspaceLayout"
|
||||
(searchTermChange)="updateSearchTerm($event)"
|
||||
>
|
||||
<!-- Filters (single selection mode for stalker) -->
|
||||
<ng-container filters>
|
||||
@for (filter of filterConfig; track filter.key) {
|
||||
<mat-checkbox
|
||||
[ngModel]="filters()[filter.key]"
|
||||
(ngModelChange)="updateFilter(filter.key, $event)"
|
||||
>
|
||||
{{ filter.translationKey | translate }}
|
||||
</mat-checkbox>
|
||||
}
|
||||
</ng-container>
|
||||
<app-search-layout
|
||||
[searchTerm]="searchTerm()"
|
||||
[resultsCount]="resultsCount"
|
||||
[isLoading]="searchResultsResource.isLoading()"
|
||||
[showResultsCount]="true"
|
||||
[showSearchInput]="!isWorkspaceLayout"
|
||||
[showDetails]="showingDetails"
|
||||
(searchTermChange)="updateSearchTerm($event)"
|
||||
>
|
||||
@if (showingDetails) {
|
||||
<app-stalker-inline-detail
|
||||
details
|
||||
[categoryId]="inlineDetail().categoryId"
|
||||
[seriesItem]="inlineDetail().seriesItem"
|
||||
[isSeries]="inlineDetail().isSeries"
|
||||
[vodDetailsItem]="inlineDetail().vodDetailsItem"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Results -->
|
||||
<ng-container results>
|
||||
<div class="results-grid">
|
||||
@for (item of searchResultsResource.value(); track item.id) {
|
||||
<app-content-card
|
||||
[title]="item.o_name || item.name"
|
||||
[posterUrl]="item.screenshot_uri"
|
||||
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
</app-search-layout>
|
||||
}
|
||||
<!-- Filters (single selection mode for stalker) -->
|
||||
<ng-container filters>
|
||||
@for (filter of filterConfig; track filter.key) {
|
||||
<mat-checkbox
|
||||
[ngModel]="filters()[filter.key]"
|
||||
(ngModelChange)="updateFilter(filter.key, $event)"
|
||||
>
|
||||
{{ filter.translationKey | translate }}
|
||||
</mat-checkbox>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Results -->
|
||||
<ng-container results>
|
||||
<div class="results-grid">
|
||||
@for (item of searchResultsResource.value(); track item.id) {
|
||||
<app-content-card
|
||||
[title]="item.o_name || item.name"
|
||||
[posterUrl]="item.screenshot_uri"
|
||||
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
</app-search-layout>
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, effect, inject, resource, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
@@ -15,10 +14,13 @@ import {
|
||||
} from 'shared-interfaces';
|
||||
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
|
||||
import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component';
|
||||
import { VodDetailsComponent } from '../../xtream-electron/vod-details/vod-details.component';
|
||||
import { StalkerInlineDetailComponent } from '../../shared/components/stalker-inline-detail/stalker-inline-detail.component';
|
||||
import { StalkerContentTypes } from '../stalker-content-types';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
import {
|
||||
isWorkspaceLayoutRoute,
|
||||
queryParamSignal,
|
||||
} from '../../shared/navigation/portal-route.utils';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
import {
|
||||
StalkerSelectedVodItem,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
import {
|
||||
buildStalkerSelectedVodItem,
|
||||
clearStalkerDetailViewState,
|
||||
createStalkerInlineDetailState,
|
||||
createPortalFavoritesResource,
|
||||
createRefreshTrigger,
|
||||
createStalkerDetailViewState,
|
||||
@@ -48,9 +51,8 @@ interface StalkerFilter {
|
||||
FormsModule,
|
||||
MatCheckboxModule,
|
||||
SearchLayoutComponent,
|
||||
StalkerSeriesViewComponent,
|
||||
StalkerInlineDetailComponent,
|
||||
TranslatePipe,
|
||||
VodDetailsComponent,
|
||||
],
|
||||
templateUrl: './stalker-search.component.html',
|
||||
styleUrl: './stalker-search.component.scss',
|
||||
@@ -68,8 +70,7 @@ export class StalkerSearchComponent {
|
||||
series: false,
|
||||
vod: true,
|
||||
});
|
||||
readonly isWorkspaceLayout =
|
||||
this.activatedRoute.snapshot.data['layout'] === 'workspace';
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
|
||||
|
||||
readonly filterConfig: StalkerFilter[] = [
|
||||
{
|
||||
@@ -85,6 +86,11 @@ export class StalkerSearchComponent {
|
||||
];
|
||||
|
||||
readonly searchTerm = signal('');
|
||||
readonly routeSearchTerm = queryParamSignal(
|
||||
this.activatedRoute,
|
||||
'q',
|
||||
(value) => (value ?? '').trim()
|
||||
);
|
||||
|
||||
private readonly currentPlaylist = this.store.selectSignal(
|
||||
selectPlaylistById(this.activatedRoute?.parent?.snapshot.params.id)
|
||||
@@ -162,14 +168,12 @@ export class StalkerSearchComponent {
|
||||
readonly isSelectedVodFavorite = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
this.activatedRoute.queryParamMap
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((queryParams) => {
|
||||
const routeTerm = queryParams.get('q')?.trim() ?? '';
|
||||
if (routeTerm !== this.searchTerm()) {
|
||||
this.searchTerm.set(routeTerm);
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
const routeTerm = this.routeSearchTerm();
|
||||
if (routeTerm !== this.searchTerm()) {
|
||||
this.searchTerm.set(routeTerm);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
// Re-evaluate favorite state whenever favorites resource changes.
|
||||
@@ -180,7 +184,7 @@ export class StalkerSearchComponent {
|
||||
|
||||
/** Check if showing item details */
|
||||
get showingDetails(): boolean {
|
||||
return this.itemDetails !== null;
|
||||
return this.inlineDetail().categoryId !== null;
|
||||
}
|
||||
|
||||
/** Get results count for layout */
|
||||
@@ -296,6 +300,14 @@ export class StalkerSearchComponent {
|
||||
);
|
||||
}
|
||||
|
||||
inlineDetail() {
|
||||
return createStalkerInlineDetailState(
|
||||
this.itemDetails,
|
||||
this.vodDetailsItem,
|
||||
this.selectedFilterType() === 'series' ? 'series' : 'vod'
|
||||
);
|
||||
}
|
||||
|
||||
private processItemUrls(
|
||||
item: StalkerVodSource,
|
||||
portalUrl: string
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlaylistActions, selectPlaylistById } from 'm3u-state';
|
||||
import { map, switchMap } from 'rxjs';
|
||||
import { PortalRailSection } from '../../shared/navigation/portal-rail-links';
|
||||
import {
|
||||
isWorkspaceLayoutRoute,
|
||||
resolveCurrentPortalSection,
|
||||
} from '../../shared/navigation/portal-route.utils';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
import { NavigationComponent } from '../../xtream-electron/navigation/navigation.component';
|
||||
import { XtreamStore } from '../../xtream-electron/stores/xtream.store';
|
||||
@@ -27,8 +32,7 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
private readonly router = inject(Router);
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly store = inject(Store);
|
||||
readonly isWorkspaceLayout =
|
||||
this.route.snapshot.data['layout'] === 'workspace';
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
|
||||
/** Current playlist derived from route params */
|
||||
readonly currentPlaylist = toSignal(
|
||||
@@ -37,7 +41,7 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
switchMap((id) => this.store.select(selectPlaylistById(id)))
|
||||
)
|
||||
);
|
||||
private currentSection: string | null = null;
|
||||
private currentSection: PortalRailSection | null = null;
|
||||
|
||||
constructor() {
|
||||
// Subscribe to route params to handle switching between playlists
|
||||
@@ -84,10 +88,11 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private syncSectionFromRoute(): void {
|
||||
const sectionFromSnapshot =
|
||||
this.route.firstChild?.snapshot?.url?.[0]?.path ?? null;
|
||||
const sectionFromUrl = this.getSectionFromUrl(this.router.url);
|
||||
const section = sectionFromSnapshot ?? sectionFromUrl;
|
||||
const section = resolveCurrentPortalSection(
|
||||
this.route,
|
||||
this.router.url,
|
||||
'stalker'
|
||||
);
|
||||
|
||||
if (!section || section === this.currentSection) {
|
||||
return;
|
||||
@@ -102,11 +107,4 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
|
||||
this.stalkerStore.setSelectedContentType(null as any);
|
||||
}
|
||||
|
||||
private getSectionFromUrl(url: string): string | null {
|
||||
const match = url.match(
|
||||
/^\/(?:workspace\/)?stalker\/[^\/\?]+\/([^\/\?]+)/
|
||||
);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { VodDetailsItem } from 'shared-interfaces';
|
||||
import {
|
||||
createStalkerInlineDetailState,
|
||||
createStalkerDetailViewState,
|
||||
normalizeStalkerFavoriteItem,
|
||||
toggleStalkerVodFavorite,
|
||||
} from './stalker-vod.utils';
|
||||
|
||||
@@ -106,4 +108,61 @@ describe('stalker-vod.utils regressions', () => {
|
||||
expect(removeFromFavorites).toHaveBeenCalledTimes(1);
|
||||
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('normalizes nested favorite details for the shared inline detail shell', () => {
|
||||
const detailState = createStalkerInlineDetailState(
|
||||
normalizeStalkerFavoriteItem({
|
||||
id: '7',
|
||||
category_id: 'vod',
|
||||
name: 'Nested Movie',
|
||||
details: {
|
||||
id: '7',
|
||||
cmd: '/media/file_7.mpg',
|
||||
series: [11, 12],
|
||||
info: {
|
||||
name: 'Nested Movie',
|
||||
movie_image: '',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
},
|
||||
} as any),
|
||||
null
|
||||
);
|
||||
|
||||
expect(detailState.categoryId).toBe('vod');
|
||||
expect(detailState.seriesItem?.series).toEqual([11, 12]);
|
||||
expect(detailState.isSeries).toBe(false);
|
||||
expect(detailState.vodDetailsItem).toBeNull();
|
||||
});
|
||||
|
||||
it('allows search to force series mode when results omit category metadata', () => {
|
||||
const detailState = createStalkerInlineDetailState(
|
||||
{
|
||||
id: '9',
|
||||
cmd: '/media/file_9.mpg',
|
||||
info: {
|
||||
name: 'Series Result',
|
||||
movie_image: '',
|
||||
description: '',
|
||||
actors: '',
|
||||
director: '',
|
||||
releasedate: '',
|
||||
genre: '',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '',
|
||||
},
|
||||
},
|
||||
null,
|
||||
'series'
|
||||
);
|
||||
|
||||
expect(detailState.categoryId).toBe('series');
|
||||
expect(detailState.seriesItem?.id).toBe('9');
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,13 @@ export interface StalkerDetailViewState {
|
||||
vodDetailsItem: VodDetailsItem | null;
|
||||
}
|
||||
|
||||
export interface StalkerInlineDetailState {
|
||||
categoryId: 'vod' | 'series' | null;
|
||||
seriesItem: StalkerSelectedVodItem | null;
|
||||
isSeries: boolean;
|
||||
vodDetailsItem: VodDetailsItem | null;
|
||||
}
|
||||
|
||||
export interface StalkerFavoriteToggleEvent {
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
@@ -222,6 +229,44 @@ export function normalizeStalkerVodDetailsItem(
|
||||
};
|
||||
}
|
||||
|
||||
export function createStalkerInlineDetailState(
|
||||
itemDetails: StalkerSelectedVodItem | NormalizedStalkerFavoriteItem | null,
|
||||
vodDetailsItem: VodDetailsItem | null,
|
||||
categoryOverride?: 'vod' | 'series' | null
|
||||
): StalkerInlineDetailState {
|
||||
if (!itemDetails) {
|
||||
return {
|
||||
categoryId: null,
|
||||
seriesItem: null,
|
||||
isSeries: false,
|
||||
vodDetailsItem: null,
|
||||
};
|
||||
}
|
||||
|
||||
const seriesItem =
|
||||
'details' in itemDetails ? itemDetails.details : itemDetails;
|
||||
const rawCategoryId =
|
||||
categoryOverride ??
|
||||
(typeof itemDetails.category_id === 'string'
|
||||
? itemDetails.category_id
|
||||
: typeof seriesItem.category_id === 'string'
|
||||
? seriesItem.category_id
|
||||
: undefined);
|
||||
const categoryId =
|
||||
rawCategoryId === 'vod' || rawCategoryId === 'movie'
|
||||
? 'vod'
|
||||
: rawCategoryId === 'series'
|
||||
? 'series'
|
||||
: null;
|
||||
|
||||
return {
|
||||
categoryId,
|
||||
seriesItem,
|
||||
isSeries: isStalkerSeriesFlag(seriesItem?.is_series),
|
||||
vodDetailsItem,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStalkerDetailViewState(
|
||||
normalizedItem: StalkerSelectedVodItem,
|
||||
playlistId: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PlaylistErrorViewComponent } from '../xtream-electron/playlist-error-view/playlist-error-view.component';
|
||||
import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component';
|
||||
import { CategoryViewComponent } from '../shared/components/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../shared/components/playlist-error-view/playlist-error-view.component';
|
||||
import { XtreamStore } from '../xtream-electron/stores/xtream.store';
|
||||
import { StalkerStore } from '../stalker/stalker.store';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component';
|
||||
import { CategoryViewComponent } from '../shared/components/category-view/category-view.component';
|
||||
import { FavoritesContextService } from './favorites-context.service';
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -20,6 +20,7 @@ import {
|
||||
} from 'shared-interfaces';
|
||||
import { DownloadsService } from '../../services/downloads.service';
|
||||
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
|
||||
import { PlaylistErrorViewComponent } from '../../shared/components/playlist-error-view/playlist-error-view.component';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
import { StalkerVodSource } from '../../stalker/models';
|
||||
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
} from '../../stalker/stalker-vod.utils';
|
||||
import { StalkerStore } from '../../stalker/stalker.store';
|
||||
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';
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -16,20 +15,37 @@ import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ResizableDirective } from 'components';
|
||||
import { firstValueFrom, map } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import {
|
||||
DownloadItem,
|
||||
DownloadsService,
|
||||
} from '../../services/downloads.service';
|
||||
import { CategoryViewComponent } from '../../shared/components/category-view/category-view.component';
|
||||
import {
|
||||
isWorkspaceLayoutRoute,
|
||||
queryParamSignal,
|
||||
} from '../../shared/navigation/portal-route.utils';
|
||||
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
|
||||
import {
|
||||
buildStandardCollectionCategories,
|
||||
filterCollectionBucket,
|
||||
} from '../../shared/utils/portal-collection-items';
|
||||
import { FavoritesContextService } from '../../workspace/favorites-context.service';
|
||||
import { CategoryViewComponent } from '../category-view/category-view.component';
|
||||
|
||||
type PortalSource = 'xtream' | 'stalker';
|
||||
const DOWNLOAD_COLLECTION_LABELS = {
|
||||
all: 'All',
|
||||
movie: 'Movies',
|
||||
live: 'Live TV',
|
||||
series: 'Series',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-downloads',
|
||||
templateUrl: './downloads.component.html',
|
||||
styleUrls: ['./downloads.component.scss', '../sidebar.scss'],
|
||||
styleUrls: [
|
||||
'./downloads.component.scss',
|
||||
'../../shared/styles/portal-sidebar.scss',
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
@@ -41,31 +57,26 @@ type PortalSource = 'xtream' | 'stalker';
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class DownloadsComponent implements OnDestroy {
|
||||
export class DownloadsComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly favoritesCtx = inject(FavoritesContextService);
|
||||
readonly downloadsService = inject(DownloadsService);
|
||||
readonly isWorkspaceLayout =
|
||||
this.route.snapshot.data['layout'] === 'workspace';
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
|
||||
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 selectedCategoryId = this.favoritesCtx.selectedCategoryId;
|
||||
readonly playlistId = toSignal(
|
||||
this.route.paramMap.pipe(map((params) => params.get('id') ?? '')),
|
||||
{ initialValue: this.route.snapshot.params['id'] ?? '' }
|
||||
);
|
||||
readonly searchTerm = toSignal(
|
||||
this.route.queryParamMap.pipe(
|
||||
map((params) => (params.get('q') ?? '').trim().toLowerCase())
|
||||
),
|
||||
{ initialValue: '' }
|
||||
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
|
||||
(value ?? '').trim().toLowerCase()
|
||||
);
|
||||
|
||||
readonly scopedDownloads = computed(() => {
|
||||
@@ -76,7 +87,7 @@ export class DownloadsComponent implements OnDestroy {
|
||||
: downloads;
|
||||
});
|
||||
|
||||
readonly categories = computed<XtreamCategory[]>(() => {
|
||||
readonly categories = computed(() => {
|
||||
const downloads = this.scopedDownloads();
|
||||
const moviesCount = downloads.filter(
|
||||
(item) => item.contentType === 'vod'
|
||||
@@ -85,55 +96,38 @@ export class DownloadsComponent implements OnDestroy {
|
||||
(item) => item.contentType === 'episode'
|
||||
).length;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
category_id: 'all',
|
||||
category_name: 'All',
|
||||
count: downloads.length,
|
||||
parent_id: 0,
|
||||
return buildStandardCollectionCategories({
|
||||
labels: DOWNLOAD_COLLECTION_LABELS,
|
||||
counts: {
|
||||
all: downloads.length,
|
||||
movie: moviesCount,
|
||||
series: seriesCount,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category_id: 'movie',
|
||||
category_name: 'Movies',
|
||||
count: moviesCount,
|
||||
parent_id: 0,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category_id: 'series',
|
||||
category_name: 'Series',
|
||||
count: seriesCount,
|
||||
parent_id: 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
readonly collectionContext = createPortalCollectionContext({
|
||||
ctx: this.favoritesCtx,
|
||||
categories: this.categories,
|
||||
});
|
||||
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
|
||||
|
||||
/** Filter downloads for current playlist and sort by newest first */
|
||||
readonly filteredDownloads = computed(() => {
|
||||
const selectedCategoryId = this.selectedCategoryId();
|
||||
const term = this.searchTerm();
|
||||
const downloads = this.scopedDownloads();
|
||||
|
||||
const filteredByCategory = downloads.filter((item) => {
|
||||
if (selectedCategoryId === 'movie') {
|
||||
return item.contentType === 'vod';
|
||||
}
|
||||
if (selectedCategoryId === 'series') {
|
||||
return item.contentType === 'episode';
|
||||
}
|
||||
return true;
|
||||
const filteredByTerm = filterCollectionBucket({
|
||||
selectedCategoryId: this.selectedCategoryId(),
|
||||
allItems: downloads,
|
||||
buckets: {
|
||||
movie: downloads.filter((item) => item.contentType === 'vod'),
|
||||
series: downloads.filter(
|
||||
(item) => item.contentType === 'episode'
|
||||
),
|
||||
},
|
||||
searchTerm: term,
|
||||
textOf: (item) => `${item.title ?? ''} ${item.errorMessage ?? ''}`,
|
||||
});
|
||||
|
||||
const filteredByTerm = term
|
||||
? filteredByCategory.filter((item) =>
|
||||
`${item.title ?? ''} ${item.errorMessage ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(term)
|
||||
)
|
||||
: filteredByCategory;
|
||||
|
||||
// Sort by createdAt descending (newest first)
|
||||
return [...filteredByTerm].sort((a, b) => {
|
||||
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
@@ -145,21 +139,13 @@ export class DownloadsComponent implements OnDestroy {
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const playlistId = this.playlistId();
|
||||
this.favoritesCtx.setCategoryId('all');
|
||||
this.collectionContext.setCategoryId('all');
|
||||
void this.downloadsService.loadDownloads(playlistId || undefined);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.favoritesCtx.setCategories(this.categories());
|
||||
});
|
||||
}
|
||||
|
||||
setCategoryId(categoryId: string) {
|
||||
this.favoritesCtx.setCategoryId(categoryId);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.favoritesCtx.reset();
|
||||
this.collectionContext.setCategoryId(categoryId);
|
||||
}
|
||||
|
||||
getProgress(item: DownloadItem): number {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<app-favorites-layout
|
||||
<app-portal-collection-shell
|
||||
[categories]="categories()"
|
||||
[favorites]="filteredFavoritesToShow()"
|
||||
[playlistSubtitle]="'Xtream Code'"
|
||||
[playlistTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
|
||||
[items]="filteredFavoritesToShow()"
|
||||
[playlistSubtitle]="playlistSubtitle"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
(categoryClicked)="setCategoryId($event.category_id)"
|
||||
(removeFavorite)="removeFromFavorites($event)"
|
||||
[layout]="layout"
|
||||
(categoryClicked)="setCategoryId($event)"
|
||||
(removeItem)="removeFromFavorites($event)"
|
||||
(openItem)="openItem($event)"
|
||||
/>
|
||||
@@ -1,31 +1,48 @@
|
||||
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, map, switchMap } from 'rxjs';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
|
||||
import { BehaviorSubject, switchMap } from 'rxjs';
|
||||
import { FavoritesContextService } from '../../workspace/favorites-context.service';
|
||||
import {
|
||||
PortalCollectionShellComponent,
|
||||
PortalCollectionShellLayout,
|
||||
} from '../../shared/components/portal-collection-shell/portal-collection-shell.component';
|
||||
import { queryParamSignal } from '../../shared/navigation/portal-route.utils';
|
||||
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
|
||||
import {
|
||||
buildStandardCollectionCategories,
|
||||
filterCollectionBucket,
|
||||
} from '../../shared/utils/portal-collection-items';
|
||||
import { FavoriteItem } from '../services/favorite-item.interface';
|
||||
import { FavoritesService } from '../services/favorites.service';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
|
||||
const XTREAM_FAVORITES_LAYOUT: PortalCollectionShellLayout = {};
|
||||
const XTREAM_COLLECTION_LABELS = {
|
||||
all: 'All',
|
||||
movie: 'Movies',
|
||||
live: 'Live TV',
|
||||
series: 'Series',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-favorites',
|
||||
imports: [FavoritesLayoutComponent, MatCardModule],
|
||||
imports: [PortalCollectionShellComponent, MatCardModule],
|
||||
templateUrl: './favorites.component.html',
|
||||
styleUrls: ['./favorites.component.scss', '../sidebar.scss'],
|
||||
styleUrls: [
|
||||
'./favorites.component.scss',
|
||||
'../../shared/styles/portal-sidebar.scss',
|
||||
],
|
||||
})
|
||||
export class FavoritesComponent implements OnInit, OnDestroy {
|
||||
export class FavoritesComponent implements OnInit {
|
||||
private favoritesService = inject(FavoritesService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
@@ -33,47 +50,56 @@ export class FavoritesComponent implements OnInit, OnDestroy {
|
||||
private xtreamStore = inject(XtreamStore);
|
||||
private readonly favoritesCtx = inject(FavoritesContextService);
|
||||
|
||||
readonly categories = signal<XtreamCategory[]>([]);
|
||||
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
|
||||
readonly layout = XTREAM_FAVORITES_LAYOUT;
|
||||
readonly playlistSubtitle = 'Xtream Code';
|
||||
readonly playlistTitle = computed(
|
||||
() =>
|
||||
this.currentPlaylist()?.name ||
|
||||
this.currentPlaylist()?.title ||
|
||||
'Playlist'
|
||||
);
|
||||
|
||||
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 searchTerm = queryParamSignal(this.route, 'q', (value) =>
|
||||
(value ?? '').trim().toLowerCase()
|
||||
);
|
||||
readonly categories = computed(() =>
|
||||
buildStandardCollectionCategories({
|
||||
labels: XTREAM_COLLECTION_LABELS,
|
||||
counts: {
|
||||
all: this.allItems().length,
|
||||
movie: this.movies().length,
|
||||
live: this.live().length,
|
||||
series: this.series().length,
|
||||
},
|
||||
includeLive: true,
|
||||
})
|
||||
);
|
||||
readonly collectionContext = createPortalCollectionContext({
|
||||
ctx: this.favoritesCtx,
|
||||
categories: this.categories,
|
||||
});
|
||||
|
||||
/** Synced with the workspace context service so panel clicks are reactive */
|
||||
readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId;
|
||||
readonly selectedCategoryId = this.collectionContext.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)
|
||||
);
|
||||
return filterCollectionBucket({
|
||||
selectedCategoryId: this.selectedCategoryId(),
|
||||
allItems: this.allItems(),
|
||||
buckets: {
|
||||
movie: this.movies(),
|
||||
live: this.live(),
|
||||
series: this.series(),
|
||||
},
|
||||
searchTerm: this.searchTerm(),
|
||||
textOf: (item) => `${item?.title ?? ''}`,
|
||||
});
|
||||
});
|
||||
|
||||
private favoritesRefresh$ = new BehaviorSubject<void>(undefined);
|
||||
@@ -92,47 +118,11 @@ export class FavoritesComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
this.live.set(items.filter((item) => item.type === 'live'));
|
||||
this.series.set(items.filter((item) => item.type === 'series'));
|
||||
this.initCategories();
|
||||
});
|
||||
}
|
||||
|
||||
initCategories() {
|
||||
const updated: XtreamCategory[] = [
|
||||
{
|
||||
id: 1,
|
||||
category_id: 'all',
|
||||
category_name: 'All',
|
||||
count: this.allItems().length,
|
||||
parent_id: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category_id: 'movie',
|
||||
category_name: 'Movies',
|
||||
count: this.movies().length,
|
||||
parent_id: 0,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
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.favoritesCtx.setCategoryId(categoryId);
|
||||
this.collectionContext.setCategoryId(categoryId);
|
||||
}
|
||||
|
||||
async removeFromFavorites(item: any) {
|
||||
@@ -180,8 +170,4 @@ export class FavoritesComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.favoritesCtx.reset();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
|
||||
import { SettingsStore } from '../../services/settings-store.service';
|
||||
import { CategoryViewComponent } from '../../shared/components/category-view/category-view.component';
|
||||
import { isWorkspaceLayoutRoute } from '../../shared/navigation/portal-route.utils';
|
||||
import {
|
||||
getAdjacentChannelItem,
|
||||
getChannelItemByNumber,
|
||||
@@ -35,9 +37,6 @@ import {
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
} from '../category-management-dialog/category-management-dialog.component';
|
||||
import {
|
||||
CategoryViewComponent,
|
||||
} from '../category-view/category-view.component';
|
||||
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
|
||||
import { FavoritesService } from '../services/favorites.service';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
@@ -48,7 +47,10 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
|
||||
@Component({
|
||||
selector: 'app-live-stream-layout',
|
||||
templateUrl: './live-stream-layout.component.html',
|
||||
styleUrls: ['./live-stream-layout.component.scss', '../sidebar.scss'],
|
||||
styleUrls: [
|
||||
'./live-stream-layout.component.scss',
|
||||
'../../shared/styles/portal-sidebar.scss',
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
EpgViewComponent,
|
||||
@@ -81,8 +83,7 @@ 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 isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
readonly liveChannelSortMode = signal<LiveChannelSortMode>('server');
|
||||
private readonly pendingAutoOpenLiveItemId = signal<number | null>(null);
|
||||
readonly liveChannelSortLabel = computed(() => {
|
||||
|
||||
@@ -119,20 +119,15 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<app-favorites-layout
|
||||
<app-portal-collection-shell
|
||||
[categories]="categories()"
|
||||
[favorites]="nonGlobalItemsToShow()"
|
||||
[playlistSubtitle]="'Xtream Code'"
|
||||
[playlistTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
|
||||
[items]="nonGlobalItemsToShow()"
|
||||
[playlistSubtitle]="playlistSubtitle"
|
||||
[playlistTitle]="playlistTitle()"
|
||||
[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)"
|
||||
[layout]="nonGlobalLayout"
|
||||
(categoryClicked)="setCategoryId($event)"
|
||||
(removeItem)="onRemoveItem($event)"
|
||||
(headerActionClicked)="clearHistory()"
|
||||
(openItem)="openItem($event)"
|
||||
/>
|
||||
|
||||
@@ -2,14 +2,11 @@ import { KeyValuePipe } from '@angular/common';
|
||||
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';
|
||||
@@ -17,33 +14,60 @@ 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, map } from 'rxjs';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
|
||||
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
|
||||
import {
|
||||
PortalCollectionShellComponent,
|
||||
PortalCollectionShellLayout,
|
||||
} from '../../shared/components/portal-collection-shell/portal-collection-shell.component';
|
||||
import {
|
||||
isWorkspaceLayoutRoute,
|
||||
queryParamSignal,
|
||||
} from '../../shared/navigation/portal-route.utils';
|
||||
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
|
||||
import {
|
||||
buildStandardCollectionCategories,
|
||||
filterCollectionBucket,
|
||||
} from '../../shared/utils/portal-collection-items';
|
||||
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';
|
||||
|
||||
const XTREAM_RECENT_LAYOUT: Omit<
|
||||
PortalCollectionShellLayout,
|
||||
'showHeaderAction'
|
||||
> = {
|
||||
titleTranslationKey: 'PORTALS.SIDEBAR.RECENT',
|
||||
removeTooltip: 'Remove from history',
|
||||
emptyIcon: 'history',
|
||||
headerActionIcon: 'delete_sweep',
|
||||
headerActionTooltip: 'Clear history',
|
||||
};
|
||||
const XTREAM_COLLECTION_LABELS = {
|
||||
all: 'All',
|
||||
movie: 'Movies',
|
||||
live: 'Live TV',
|
||||
series: 'Series',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-recently-viewed',
|
||||
imports: [
|
||||
ContentCardComponent,
|
||||
FavoritesLayoutComponent,
|
||||
FormsModule,
|
||||
KeyValuePipe,
|
||||
MatButton,
|
||||
MatCheckboxModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
PortalCollectionShellComponent,
|
||||
],
|
||||
providers: [],
|
||||
templateUrl: './recently-viewed.component.html',
|
||||
styleUrl: './recently-viewed.component.scss',
|
||||
})
|
||||
export class RecentlyViewedComponent implements OnDestroy {
|
||||
export class RecentlyViewedComponent {
|
||||
private static readonly GROUP_BY_STORAGE_KEY =
|
||||
'global-recent-group-by-playlist';
|
||||
private static readonly TYPE_FILTERS_STORAGE_KEY =
|
||||
@@ -55,14 +79,17 @@ export class RecentlyViewedComponent implements OnDestroy {
|
||||
private router = inject(Router);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
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';
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
|
||||
readonly nonGlobalLayout: PortalCollectionShellLayout = {
|
||||
...XTREAM_RECENT_LAYOUT,
|
||||
showHeaderAction: !this.isWorkspaceLayout,
|
||||
};
|
||||
readonly playlistSubtitle = 'Xtream Code';
|
||||
private readonly _groupByPlaylist = (() => {
|
||||
const saved = localStorage.getItem(
|
||||
RecentlyViewedComponent.GROUP_BY_STORAGE_KEY
|
||||
@@ -98,8 +125,7 @@ export class RecentlyViewedComponent implements OnDestroy {
|
||||
? this.xtreamStore.globalRecentItems()
|
||||
: this.xtreamStore.recentItems()
|
||||
);
|
||||
readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId;
|
||||
readonly categories = computed<XtreamCategory[]>(() => {
|
||||
readonly categories = computed(() => {
|
||||
const items = this.recentItems();
|
||||
const movies = items.filter(
|
||||
(item: any) => item?.type === 'movie'
|
||||
@@ -109,44 +135,28 @@ export class RecentlyViewedComponent implements OnDestroy {
|
||||
(item: any) => item?.type === 'series'
|
||||
).length;
|
||||
|
||||
const cats: XtreamCategory[] = [
|
||||
{
|
||||
id: 1,
|
||||
category_id: 'all',
|
||||
category_name: 'All',
|
||||
count: items.length,
|
||||
parent_id: 0,
|
||||
return buildStandardCollectionCategories({
|
||||
labels: XTREAM_COLLECTION_LABELS,
|
||||
counts: {
|
||||
all: items.length,
|
||||
movie: movies,
|
||||
live,
|
||||
series,
|
||||
},
|
||||
{
|
||||
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;
|
||||
includeLive: true,
|
||||
});
|
||||
});
|
||||
readonly collectionContext = createPortalCollectionContext({
|
||||
ctx: this.favoritesCtx,
|
||||
categories: this.categories,
|
||||
enabled: () => !this.isGlobal,
|
||||
});
|
||||
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
|
||||
readonly recentSearchTerm = signal('');
|
||||
readonly workspaceSearchTerm = toSignal(
|
||||
this.activatedRoute.queryParamMap.pipe(
|
||||
map((params) => (params.get('q') ?? '').trim().toLowerCase())
|
||||
),
|
||||
{ initialValue: '' }
|
||||
readonly workspaceSearchTerm = queryParamSignal(
|
||||
this.activatedRoute,
|
||||
'q',
|
||||
(value) => (value ?? '').trim().toLowerCase()
|
||||
);
|
||||
readonly filteredRecentItems = computed(() => {
|
||||
const term =
|
||||
@@ -180,20 +190,25 @@ export class RecentlyViewedComponent implements OnDestroy {
|
||||
});
|
||||
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;
|
||||
}
|
||||
return filterCollectionBucket({
|
||||
selectedCategoryId: this.selectedCategoryId(),
|
||||
allItems: items,
|
||||
buckets: {
|
||||
movie: items.filter((item: any) => item?.type === 'movie'),
|
||||
live: items.filter((item: any) => item?.type === 'live'),
|
||||
series: items.filter((item: any) => item?.type === 'series'),
|
||||
},
|
||||
textOf: (item: any) =>
|
||||
`${item?.title ?? ''} ${item?.playlist_name ?? ''}`,
|
||||
});
|
||||
});
|
||||
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
|
||||
readonly playlistTitle = computed(
|
||||
() =>
|
||||
this.currentPlaylist()?.name ||
|
||||
this.currentPlaylist()?.title ||
|
||||
'Playlist'
|
||||
);
|
||||
|
||||
constructor(
|
||||
@Optional() public dialogRef?: MatDialogRef<RecentlyViewedComponent>
|
||||
@@ -205,13 +220,6 @@ export class RecentlyViewedComponent implements OnDestroy {
|
||||
} 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() {
|
||||
@@ -342,10 +350,6 @@ export class RecentlyViewedComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
setCategoryId(categoryId: string): void {
|
||||
this.favoritesCtx.setCategoryId(categoryId);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.favoritesCtx.reset();
|
||||
this.collectionContext.setCategoryId(categoryId);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -23,10 +22,13 @@ 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';
|
||||
import {
|
||||
isWorkspaceLayoutRoute,
|
||||
queryParamSignal,
|
||||
} from '../../shared/navigation/portal-route.utils';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
import { XtreamContentItem } from '../data-sources/xtream-data-source.interface';
|
||||
import { SearchFilters } from '../stores/features/with-search.feature';
|
||||
@@ -62,13 +64,11 @@ 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: '' }
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
|
||||
readonly routeSearchTerm = queryParamSignal(
|
||||
this.activatedRoute,
|
||||
'q',
|
||||
(value) => (value ?? '').trim()
|
||||
);
|
||||
|
||||
/** Search term from store */
|
||||
|
||||
@@ -11,11 +11,12 @@ import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import { CategoryViewComponent } from '../shared/components/category-view/category-view.component';
|
||||
import { isWorkspaceLayoutRoute } from '../shared/navigation/portal-route.utils';
|
||||
import {
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
} from './category-management-dialog/category-management-dialog.component';
|
||||
import { CategoryViewComponent } from './category-view/category-view.component';
|
||||
import { XtreamStore } from './stores/xtream.store';
|
||||
import { XtreamCategorySortMode } from './stores/features/with-selection.feature';
|
||||
|
||||
@@ -24,7 +25,10 @@ const XTREAM_CATEGORY_SORT_STORAGE_KEY = 'xtream-category-sort-mode';
|
||||
@Component({
|
||||
selector: 'app-xtream-main-container',
|
||||
templateUrl: './xtream-main-container.component.html',
|
||||
styleUrls: ['./xtream-main-container.component.scss', './sidebar.scss'],
|
||||
styleUrls: [
|
||||
'./xtream-main-container.component.scss',
|
||||
'../shared/styles/portal-sidebar.scss',
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
FormsModule,
|
||||
@@ -58,8 +62,7 @@ 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 isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
readonly contentSortLabel = computed(() => {
|
||||
const mode = this.contentSortMode();
|
||||
if (mode === 'date-asc') return 'Date Added (Oldest First)';
|
||||
|
||||
@@ -10,6 +10,11 @@ import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { filter } from 'rxjs';
|
||||
import {
|
||||
isWorkspaceLayoutRoute,
|
||||
resolveCurrentPortalSection,
|
||||
} from '../../shared/navigation/portal-route.utils';
|
||||
import { PortalRailSection } from '../../shared/navigation/portal-rail-links';
|
||||
import { LoadingOverlayComponent } from '../loading-overlay/loading-overlay.component';
|
||||
import { NavigationComponent } from '../navigation/navigation.component';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
@@ -34,15 +39,14 @@ export class XtreamShellComponent {
|
||||
readonly isImporting = this.xtreamStore.isImporting;
|
||||
readonly itemsToImport = this.xtreamStore.itemsToImport;
|
||||
readonly portalStatus = this.xtreamStore.portalStatus;
|
||||
readonly isWorkspaceLayout =
|
||||
this.route.snapshot.data['layout'] === 'workspace';
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
readonly showImportOverlay = computed(() => {
|
||||
const section = this.currentSection();
|
||||
return this.isImporting() && this.isContentSection(section);
|
||||
});
|
||||
|
||||
private currentPlaylistId: string | null = null;
|
||||
private readonly currentSection = signal<string | null>(null);
|
||||
private readonly currentSection = signal<PortalRailSection | null>(null);
|
||||
|
||||
constructor() {
|
||||
// Subscribe to route params to handle switching between playlists
|
||||
@@ -108,10 +112,11 @@ export class XtreamShellComponent {
|
||||
}
|
||||
|
||||
private syncSectionFromRoute(): void {
|
||||
const sectionFromSnapshot =
|
||||
this.route.firstChild?.snapshot?.url?.[0]?.path ?? null;
|
||||
const sectionFromUrl = this.getSectionFromUrl(this.router.url);
|
||||
const section = sectionFromSnapshot ?? sectionFromUrl;
|
||||
const section = resolveCurrentPortalSection(
|
||||
this.route,
|
||||
this.router.url,
|
||||
'xtreams'
|
||||
);
|
||||
|
||||
if (!section || section === this.currentSection()) {
|
||||
return;
|
||||
@@ -127,14 +132,7 @@ export class XtreamShellComponent {
|
||||
this.xtreamStore.setSelectedContentType(undefined);
|
||||
}
|
||||
|
||||
private getSectionFromUrl(url: string): string | null {
|
||||
const match = url.match(
|
||||
/^\/(?:workspace\/)?xtreams\/[^\/\?]+\/([^\/\?]+)/
|
||||
);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
private isContentSection(section: string | null): boolean {
|
||||
private isContentSection(section: PortalRailSection | null): boolean {
|
||||
return (
|
||||
section === 'vod' ||
|
||||
section === 'live' ||
|
||||
|
||||
Reference in new issue
Block a user