mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
feat: refactor Stalker layout to support workspace mode
- Updated the StalkerLiveStreamLayout component to conditionally render the sidebar based on the workspace layout. - Enhanced the sidebar styling to use theme variables for consistency. - Modified the StalkerMainContainer and StalkerShell components to accommodate workspace layout changes. - Improved the search component to hide the search input in workspace mode. - Adjusted the player feature to handle content types more effectively. - Refactored selection feature to streamline category and item management. - Ensured all components utilize the new layout structure and styling conventions.
This commit is contained in:
1 parent
fd2f5972b0
commit
24581a2131
16 files changed
+624
-364
No files matched your search
@@ -37,12 +37,12 @@
|
||||
[titleTranslationString]="'PORTALS.SIDEBAR.RECENT'"
|
||||
[removeTooltip]="'Remove from recent'"
|
||||
[emptyIcon]="'history'"
|
||||
[showHeaderAction]="true"
|
||||
[showHeaderAction]="!isWorkspaceLayout"
|
||||
[headerActionIcon]="'delete_sweep'"
|
||||
[headerActionTooltip]="'Clear all recently viewed'"
|
||||
[headerActionTooltip]="'Clear recently viewed'"
|
||||
(categoryClicked)="setCategoryId($event.category_id)"
|
||||
(removeFavorite)="removeFromRecentlyViewed($event)"
|
||||
(openItem)="openItem($event)"
|
||||
(headerActionClicked)="clearAllRecentlyViewed()"
|
||||
(openItem)="openItem($event)"
|
||||
/>
|
||||
}
|
||||
@@ -1,29 +1,34 @@
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
Component,
|
||||
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 { VodDetailsItem } from 'shared-interfaces';
|
||||
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
|
||||
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
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 {
|
||||
StalkerSelectedVodItem,
|
||||
StalkerVodSource,
|
||||
} from '../models';
|
||||
import {
|
||||
clearStalkerDetailViewState,
|
||||
createPortalCollectionResource,
|
||||
createPortalFavoritesResource,
|
||||
toggleStalkerVodFavorite,
|
||||
clearStalkerDetailViewState,
|
||||
createStalkerDetailViewState,
|
||||
createRefreshTrigger,
|
||||
normalizeStalkerVodDetailsItem,
|
||||
createStalkerDetailViewState,
|
||||
isSelectedStalkerVodFavorite,
|
||||
normalizeStalkerVodDetailsItem,
|
||||
toggleStalkerVodFavorite,
|
||||
} from '../stalker-vod.utils';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recently-viewed',
|
||||
@@ -48,12 +53,13 @@ import {
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class RecentlyViewedComponent {
|
||||
export class RecentlyViewedComponent implements OnDestroy {
|
||||
private static isCategoryType(
|
||||
value: string
|
||||
): value is 'vod' | 'series' | 'itv' {
|
||||
return value === 'vod' || value === 'series' || value === 'itv';
|
||||
}
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly collectionRefresh = createRefreshTrigger();
|
||||
private readonly favoritesRefresh = createRefreshTrigger();
|
||||
@@ -61,6 +67,7 @@ export class RecentlyViewedComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly logger = createLogger('StalkerRecentlyViewed');
|
||||
private readonly favoritesCtx = inject(FavoritesContextService);
|
||||
|
||||
itemDetails: (StalkerVodSource & { category_id?: string }) | null = null;
|
||||
vodDetailsItem: VodDetailsItem | null = null;
|
||||
@@ -84,50 +91,79 @@ export class RecentlyViewedComponent {
|
||||
readonly categories = computed(() => [
|
||||
{
|
||||
id: 1,
|
||||
category_id: 'movie',
|
||||
category_name:
|
||||
this.translate.instant('PORTALS.SIDEBAR.MOVIES') +
|
||||
' (' +
|
||||
(this.movies()?.length ?? 0) +
|
||||
')',
|
||||
category_id: 'all',
|
||||
category_name: this.translate.instant('PORTALS.ALL_CATEGORIES'),
|
||||
count: this.allFavorites.value()?.length ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category_id: 'itv',
|
||||
category_name:
|
||||
this.translate.instant('PORTALS.SIDEBAR.LIVE_TV') +
|
||||
' (' +
|
||||
(this.live()?.length ?? 0) +
|
||||
')',
|
||||
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') +
|
||||
' (' +
|
||||
(this.series()?.length ?? 0) +
|
||||
')',
|
||||
category_name: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
|
||||
count: this.series()?.length ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
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 this.movies();
|
||||
return filterByTerm(this.movies());
|
||||
case 'itv':
|
||||
return this.live();
|
||||
return filterByTerm(this.live());
|
||||
case 'series':
|
||||
return this.series();
|
||||
return filterByTerm(this.series());
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
readonly selectedCategoryId = signal<string>('movie');
|
||||
/** 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 refreshToken = toSignal(
|
||||
this.route.queryParamMap.pipe(
|
||||
map((params) => params.get('refresh') ?? '')
|
||||
),
|
||||
{ initialValue: '' }
|
||||
);
|
||||
|
||||
readonly series = computed(() =>
|
||||
this.allFavorites
|
||||
@@ -147,14 +183,32 @@ export class RecentlyViewedComponent {
|
||||
this.syncSelectedVodFavorite();
|
||||
});
|
||||
|
||||
// Keep workspace context panel in sync with categories
|
||||
effect(() => {
|
||||
this.favoritesCtx.setCategories(this.categories());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const refresh = this.refreshToken();
|
||||
if (!refresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.collectionRefresh.refresh();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const playlist = this.currentPlaylist();
|
||||
if (!playlist?._id) return;
|
||||
|
||||
const state = this.router.currentNavigation()?.extras?.state
|
||||
?? window.history.state;
|
||||
const state =
|
||||
this.router.currentNavigation()?.extras?.state ??
|
||||
window.history.state;
|
||||
const item = state?.openRecentItem;
|
||||
if (!item || !RecentlyViewedComponent.isCategoryType(item.category_id)) {
|
||||
if (
|
||||
!item ||
|
||||
!RecentlyViewedComponent.isCategoryType(item.category_id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,7 +245,7 @@ export class RecentlyViewedComponent {
|
||||
}
|
||||
|
||||
setCategoryId(categoryId: string) {
|
||||
this.selectedCategoryId.set(categoryId);
|
||||
this.favoritesCtx.setCategoryId(categoryId);
|
||||
}
|
||||
|
||||
openItem(item: StalkerVodSource & { category_id: string }) {
|
||||
@@ -202,9 +256,13 @@ export class RecentlyViewedComponent {
|
||||
this.stalkerStore.setSelectedContentType(item.category_id);
|
||||
switch (item.category_id) {
|
||||
case 'itv':
|
||||
this.createLinkToPlayVodItv(item.cmd, item.o_name || item.name, item.logo);
|
||||
this.createLinkToPlayVodItv(
|
||||
item.cmd,
|
||||
item.o_name || item.name,
|
||||
item.logo
|
||||
);
|
||||
break;
|
||||
case 'vod':
|
||||
case 'vod': {
|
||||
// Normalize the item to ensure is_series flag is properly set
|
||||
const normalizedItem = this.normalizeRecentItem(item);
|
||||
const detailViewState = createStalkerDetailViewState(
|
||||
@@ -219,6 +277,7 @@ export class RecentlyViewedComponent {
|
||||
this.stalkerStore.setSelectedItem(detailViewState.itemDetails);
|
||||
this.syncSelectedVodFavorite();
|
||||
break;
|
||||
}
|
||||
case 'series':
|
||||
this.itemDetails = item;
|
||||
this.stalkerStore.setSelectedItem(item);
|
||||
@@ -240,7 +299,10 @@ export class RecentlyViewedComponent {
|
||||
}
|
||||
|
||||
/** Handle favorite toggle from vod-details component */
|
||||
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
|
||||
onVodFavoriteToggled(event: {
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
}): void {
|
||||
toggleStalkerVodFavorite(event, {
|
||||
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
|
||||
removeFromFavorites: (favoriteId, onDone) =>
|
||||
@@ -285,7 +347,9 @@ export class RecentlyViewedComponent {
|
||||
* Normalize recent item to ensure is_series flag is properly set
|
||||
* and all required fields are present for the series view
|
||||
*/
|
||||
private normalizeRecentItem(item: StalkerVodSource): StalkerSelectedVodItem {
|
||||
private normalizeRecentItem(
|
||||
item: StalkerVodSource
|
||||
): StalkerSelectedVodItem {
|
||||
return normalizeStalkerVodDetailsItem(item);
|
||||
}
|
||||
|
||||
@@ -297,4 +361,8 @@ export class RecentlyViewedComponent {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.favoritesCtx.reset();
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,35 @@
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
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 { VodDetailsItem } from 'shared-interfaces';
|
||||
import { FavoritesLayoutComponent } from '../../shared/components/favorites-layout/favorites-layout.component';
|
||||
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
import {
|
||||
StalkerFavoriteItem,
|
||||
} from '../models';
|
||||
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,
|
||||
createPortalFavoritesResource,
|
||||
createRefreshTrigger,
|
||||
createStalkerDetailViewState,
|
||||
normalizeStalkerEntityId,
|
||||
NormalizedStalkerFavoriteItem,
|
||||
normalizeStalkerFavoriteItem,
|
||||
isSelectedStalkerVodFavorite,
|
||||
NormalizedStalkerFavoriteItem,
|
||||
normalizeStalkerEntityId,
|
||||
normalizeStalkerFavoriteItem,
|
||||
toggleStalkerVodFavorite,
|
||||
} from '../stalker-vod.utils';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-favorites',
|
||||
@@ -47,12 +54,26 @@ import {
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class StalkerFavoritesComponent {
|
||||
export class StalkerFavoritesComponent implements OnDestroy {
|
||||
private static isCategoryType(
|
||||
value: unknown
|
||||
): value is 'vod' | 'series' | 'itv' {
|
||||
const normalized = String(value ?? '').toLowerCase();
|
||||
return (
|
||||
normalized === 'vod' ||
|
||||
normalized === 'series' ||
|
||||
normalized === 'itv'
|
||||
);
|
||||
}
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
private readonly favoritesRefresh = createRefreshTrigger();
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly logger = createLogger('StalkerFavorites');
|
||||
private readonly favoritesCtx = inject(FavoritesContextService);
|
||||
|
||||
itemDetails: NormalizedStalkerFavoriteItem | null = null;
|
||||
vodDetailsItem: VodDetailsItem | null = null;
|
||||
@@ -69,50 +90,69 @@ export class StalkerFavoritesComponent {
|
||||
readonly categories = computed(() => [
|
||||
{
|
||||
id: 1,
|
||||
category_id: 'movie',
|
||||
category_name:
|
||||
this.translate.instant('PORTALS.SIDEBAR.MOVIES') +
|
||||
' (' +
|
||||
(this.movies()?.length ?? 0) +
|
||||
')',
|
||||
category_id: 'all',
|
||||
category_name: this.translate.instant('PORTALS.ALL_CATEGORIES'),
|
||||
count: this.allFavorites.value()?.length ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category_id: 'itv',
|
||||
category_name:
|
||||
this.translate.instant('PORTALS.SIDEBAR.LIVE_TV') +
|
||||
' (' +
|
||||
(this.live()?.length ?? 0) +
|
||||
')',
|
||||
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') +
|
||||
' (' +
|
||||
(this.series()?.length ?? 0) +
|
||||
')',
|
||||
category_name: this.translate.instant('PORTALS.SIDEBAR.SERIES'),
|
||||
count: this.series()?.length ?? 0,
|
||||
parent_id: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
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 this.movies();
|
||||
return filterByTerm(this.movies());
|
||||
case 'itv':
|
||||
return this.live();
|
||||
return filterByTerm(this.live());
|
||||
case 'series':
|
||||
return this.series();
|
||||
return filterByTerm(this.series());
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
readonly selectedCategoryId = signal<string>('movie');
|
||||
/** 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 series = computed(() =>
|
||||
this.allFavorites
|
||||
@@ -131,15 +171,49 @@ export class StalkerFavoritesComponent {
|
||||
this.allFavorites.value();
|
||||
this.syncSelectedVodFavorite();
|
||||
});
|
||||
|
||||
// Keep workspace context panel in sync with categories
|
||||
effect(() => {
|
||||
this.favoritesCtx.setCategories(this.categories());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const playlist = this.currentPlaylist();
|
||||
if (!playlist?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state =
|
||||
this.router.currentNavigation()?.extras?.state ??
|
||||
window.history.state;
|
||||
const item = state?.openFavoriteItem;
|
||||
if (
|
||||
!item ||
|
||||
!StalkerFavoritesComponent.isCategoryType(item.category_id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.openItem(item);
|
||||
|
||||
try {
|
||||
window.history.replaceState({}, document.title);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removeFromFavorites(item: Pick<StalkerFavoriteItem, 'id'>, onDone?: () => void) {
|
||||
removeFromFavorites(
|
||||
item: Pick<StalkerFavoriteItem, 'id'>,
|
||||
onDone?: () => void
|
||||
) {
|
||||
this.stalkerStore.removeFromFavorites(
|
||||
normalizeStalkerEntityId(item.id),
|
||||
() => {
|
||||
this.favoritesRefresh.refresh();
|
||||
this.syncSelectedVodFavorite();
|
||||
onDone?.();
|
||||
this.favoritesRefresh.refresh();
|
||||
this.syncSelectedVodFavorite();
|
||||
onDone?.();
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -153,19 +227,29 @@ export class StalkerFavoritesComponent {
|
||||
}
|
||||
|
||||
setCategoryId(categoryId: string) {
|
||||
this.selectedCategoryId.set(categoryId);
|
||||
this.favoritesCtx.setCategoryId(categoryId);
|
||||
}
|
||||
|
||||
openItem(item: StalkerFavoriteItem) {
|
||||
this.logger.debug('Open item', item);
|
||||
const normalizedItem = normalizeStalkerFavoriteItem(item);
|
||||
const normalizedCategory =
|
||||
item.category_id === 'movie' ? 'vod' : item.category_id;
|
||||
const itemToOpen = {
|
||||
...item,
|
||||
category_id: normalizedCategory,
|
||||
} as StalkerFavoriteItem;
|
||||
const normalizedItem = normalizeStalkerFavoriteItem(itemToOpen);
|
||||
|
||||
switch (item.category_id) {
|
||||
switch (itemToOpen.category_id) {
|
||||
case 'itv':
|
||||
this.stalkerStore.setSelectedContentType('itv');
|
||||
this.createLinkToPlayVodItv(item.cmd, item.o_name || item.name, item.logo);
|
||||
this.createLinkToPlayVodItv(
|
||||
itemToOpen.cmd,
|
||||
itemToOpen.o_name || itemToOpen.name,
|
||||
itemToOpen.logo
|
||||
);
|
||||
break;
|
||||
case 'vod':
|
||||
case 'vod': {
|
||||
this.itemDetails = normalizedItem;
|
||||
this.stalkerStore.setSelectedItem(normalizedItem.details);
|
||||
this.stalkerStore.setSelectedContentType('vod');
|
||||
@@ -181,6 +265,7 @@ export class StalkerFavoritesComponent {
|
||||
this.vodDetailsItem = detailViewState.vodDetailsItem;
|
||||
this.syncSelectedVodFavorite();
|
||||
break;
|
||||
}
|
||||
case 'series':
|
||||
this.itemDetails = normalizedItem;
|
||||
this.stalkerStore.setSelectedItem(normalizedItem.details);
|
||||
@@ -203,7 +288,10 @@ export class StalkerFavoritesComponent {
|
||||
}
|
||||
|
||||
/** Handle favorite toggle from vod-details component */
|
||||
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
|
||||
onVodFavoriteToggled(event: {
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
}): void {
|
||||
toggleStalkerVodFavorite(event, {
|
||||
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
|
||||
removeFromFavorites: (favoriteId, onDone) =>
|
||||
@@ -255,4 +343,8 @@ export class StalkerFavoritesComponent {
|
||||
});
|
||||
await this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.favoritesCtx.reset();
|
||||
}
|
||||
}
|
||||
+131
-127
@@ -1,137 +1,141 @@
|
||||
@let isEmbeddedPlayer =
|
||||
player() === 'videojs' || player() === 'html5' || player() === 'artplayer';
|
||||
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
@if (!isWorkspaceLayout || stalkerStore.selectedCategoryId()) {
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
@if (stalkerStore.selectedCategoryId() || !isWorkspaceLayout) {
|
||||
<div class="sidebar-header">
|
||||
@if (stalkerStore.selectedCategoryId()) {
|
||||
<button mat-icon-button (click)="backToCategories()">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<h2>{{ selectedCategoryTitle() }}</h2>
|
||||
} @else {
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[subtitle]="'Stalker Portal'"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (stalkerStore.selectedCategoryId()) {
|
||||
<button mat-icon-button (click)="backToCategories()">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<h2>{{ selectedCategoryTitle() }}</h2>
|
||||
<!-- <div class="search-panel">
|
||||
<mat-form-field class="search-bar" subscriptSizing="dynamic">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
[placeholder]="'PORTALS.SIDEBAR.SEARCH' | translate"
|
||||
[ngModel]="searchString()"
|
||||
(ngModelChange)="searchString.set($event)"
|
||||
/>
|
||||
@if (searchString()) {
|
||||
<button matSuffix mat-icon-button (click)="searchString.set('')">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div> -->
|
||||
<div #scrollContainer class="channels-list">
|
||||
@for (item of itvChannels(); track item.id ?? $index) {
|
||||
<div
|
||||
class="channel-item"
|
||||
[class.active]="stalkerStore.selectedItem()?.id === item.id"
|
||||
(click)="playChannel(item)"
|
||||
>
|
||||
<img
|
||||
class="stream-icon"
|
||||
[src]="item.logo"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
alt=""
|
||||
/>
|
||||
<div class="channel-info">
|
||||
<span class="title">{{
|
||||
item.o_name || item.name
|
||||
}}</span>
|
||||
@if (currentPrograms.get(item.id)) {
|
||||
<div class="program-info">
|
||||
<div class="current-program">
|
||||
{{ currentPrograms.get(item.id) }}
|
||||
</div>
|
||||
@if (currentProgramsProgress.has(item.id)) {
|
||||
<div class="progress-container">
|
||||
<span>{{
|
||||
programTimings.get(item.id)?.start
|
||||
| date: 'HH:mm'
|
||||
}}</span>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress"
|
||||
[style.width.%]="
|
||||
currentProgramsProgress.get(item.id)
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
<span>{{
|
||||
programTimings.get(item.id)?.end
|
||||
| date: 'HH:mm'
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="favorite-btn"
|
||||
(click)="toggleFavorite(item); $event.stopPropagation()"
|
||||
>
|
||||
<mat-icon>{{
|
||||
favorites.get(item.id) ? 'star' : 'star_outline'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isLoadingMore()) {
|
||||
<div class="loading-indicator">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[subtitle]="'Stalker Portal'"
|
||||
/>
|
||||
@if (isCategoryLoading()) {
|
||||
<ngx-skeleton-loader
|
||||
count="20"
|
||||
[theme]="{
|
||||
width: '95%',
|
||||
height: '48px',
|
||||
borderRadius: '40px',
|
||||
}"
|
||||
/>
|
||||
} @else if (isCategoryFailed()) {
|
||||
<app-playlist-error-view
|
||||
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
|
||||
[description]="
|
||||
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
|
||||
(categoryClicked)="selectCategory($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (stalkerStore.selectedCategoryId()) {
|
||||
<!-- <div class="search-panel">
|
||||
<mat-form-field class="search-bar" subscriptSizing="dynamic">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
[placeholder]="'PORTALS.SIDEBAR.SEARCH' | translate"
|
||||
[ngModel]="searchString()"
|
||||
(ngModelChange)="searchString.set($event)"
|
||||
/>
|
||||
@if (searchString()) {
|
||||
<button matSuffix mat-icon-button (click)="searchString.set('')">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div> -->
|
||||
<div #scrollContainer class="channels-list">
|
||||
@for (item of itvChannels(); track item.id ?? $index) {
|
||||
<div
|
||||
class="channel-item"
|
||||
[class.active]="stalkerStore.selectedItem()?.id === item.id"
|
||||
(click)="playChannel(item)"
|
||||
>
|
||||
<img
|
||||
class="stream-icon"
|
||||
[src]="item.logo"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
alt=""
|
||||
/>
|
||||
<div class="channel-info">
|
||||
<span class="title">{{
|
||||
item.o_name || item.name
|
||||
}}</span>
|
||||
@if (currentPrograms.get(item.id)) {
|
||||
<div class="program-info">
|
||||
<div class="current-program">
|
||||
{{ currentPrograms.get(item.id) }}
|
||||
</div>
|
||||
@if (currentProgramsProgress.has(item.id)) {
|
||||
<div class="progress-container">
|
||||
<span>{{
|
||||
programTimings.get(item.id)?.start
|
||||
| date: 'HH:mm'
|
||||
}}</span>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress"
|
||||
[style.width.%]="
|
||||
currentProgramsProgress.get(item.id)
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
<span>{{
|
||||
programTimings.get(item.id)?.end
|
||||
| date: 'HH:mm'
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="favorite-btn"
|
||||
(click)="toggleFavorite(item); $event.stopPropagation()"
|
||||
>
|
||||
<mat-icon>{{
|
||||
favorites.get(item.id) ? 'star' : 'star_outline'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isLoadingMore()) {
|
||||
<div class="loading-indicator">
|
||||
<mat-spinner diameter="24" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@if (isCategoryLoading()) {
|
||||
<ngx-skeleton-loader
|
||||
count="20"
|
||||
[theme]="{
|
||||
width: '95%',
|
||||
height: '48px',
|
||||
borderRadius: '40px',
|
||||
}"
|
||||
/>
|
||||
} @else if (isCategoryFailed()) {
|
||||
<app-playlist-error-view
|
||||
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
|
||||
[description]="
|
||||
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
|
||||
(categoryClicked)="selectCategory($event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="content-container">
|
||||
@if (streamUrl && isEmbeddedPlayer) {
|
||||
|
||||
+8
-8
@@ -12,7 +12,7 @@
|
||||
width: 400px;
|
||||
min-width: 250px;
|
||||
max-width: 600px;
|
||||
border-right: 1px solid #111;
|
||||
border-right: 1px solid var(--mat-sys-outline-variant);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
@@ -110,18 +110,18 @@
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent);
|
||||
|
||||
.title {
|
||||
color: #1db954;
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(29, 185, 84, 0.2);
|
||||
background-color: color-mix(in srgb, var(--mat-sys-primary) 16%, transparent);
|
||||
|
||||
.title {
|
||||
color: #1db954;
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent);
|
||||
}
|
||||
|
||||
.channel-info {
|
||||
@@ -174,14 +174,14 @@
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
min-width: 20px;
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: #1db954;
|
||||
background: var(--mat-sys-primary);
|
||||
transition: width 1s linear;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -19,6 +19,7 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { PlaylistsService } from 'services';
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
getChannelItemByNumber,
|
||||
} from '../../shared/services/remote-channel-navigation.util';
|
||||
import { CategoryViewComponent } from '../../xtream-electron/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../../xtream/playlist-error-view/playlist-error-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../../xtream-electron/playlist-error-view/playlist-error-view.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
@@ -86,6 +88,8 @@ 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';
|
||||
|
||||
/** Channels */
|
||||
readonly itvChannels = this.stalkerStore.itvChannels;
|
||||
@@ -203,8 +207,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
channelName: selectedItem.o_name || selectedItem.name,
|
||||
channelNumber: currentIndex >= 0 ? currentIndex + 1 : undefined,
|
||||
epgTitle: currentProgram?.title,
|
||||
epgStart: (currentProgram as any)?.start,
|
||||
epgEnd: (currentProgram as any)?.end,
|
||||
epgStart: currentProgram?.start,
|
||||
epgEnd: currentProgram?.end,
|
||||
supportsVolume: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,45 +1,49 @@
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[subtitle]="'Stalker Portal'"
|
||||
/>
|
||||
@if (!isWorkspaceLayout) {
|
||||
<div
|
||||
class="sidebar"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
[defaultWidth]="400"
|
||||
storageKey="sidebar-width"
|
||||
>
|
||||
@if (!isWorkspaceLayout) {
|
||||
<div class="sidebar-header">
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[subtitle]="'Stalker Portal'"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@if (isCategoryLoading()) {
|
||||
<ngx-skeleton-loader
|
||||
count="20"
|
||||
[theme]="{
|
||||
width: '95%',
|
||||
height: '48px',
|
||||
borderRadius: '40px',
|
||||
}"
|
||||
/>
|
||||
} @else if (isCategoryFailed()) {
|
||||
<app-playlist-error-view
|
||||
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
|
||||
[description]="
|
||||
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
|
||||
(categoryClicked)="categoryClicked($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@if (isCategoryLoading()) {
|
||||
<ngx-skeleton-loader
|
||||
count="20"
|
||||
[theme]="{
|
||||
width: '95%',
|
||||
height: '48px',
|
||||
borderRadius: '40px',
|
||||
}"
|
||||
/>
|
||||
} @else if (isCategoryFailed()) {
|
||||
<app-playlist-error-view
|
||||
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
|
||||
[description]="
|
||||
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
|
||||
(categoryClicked)="categoryClicked($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="main-content">
|
||||
@if (!stalkerStore.selectedItem()) {
|
||||
@if (!isWorkspaceLayout && !stalkerStore.selectedItem()) {
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
<button mat-icon-button disabled>
|
||||
@@ -63,7 +67,7 @@
|
||||
|
||||
<div
|
||||
class="scrollable-content"
|
||||
[class.no-header]="stalkerStore.selectedItem()"
|
||||
[class.no-header]="isWorkspaceLayout || stalkerStore.selectedItem()"
|
||||
>
|
||||
<router-outlet />
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../xtream/playlist-error-view/playlist-error-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../xtream-electron/playlist-error-view/playlist-error-view.component';
|
||||
import { StalkerStore } from './stalker.store';
|
||||
|
||||
@Component({
|
||||
@@ -32,8 +32,12 @@ import { StalkerStore } from './stalker.store';
|
||||
],
|
||||
})
|
||||
export class StalkerMainContainerComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
readonly isWorkspaceLayout =
|
||||
this.route.snapshot.data['layout'] === 'workspace';
|
||||
|
||||
currentLayout:
|
||||
| 'category_content'
|
||||
@@ -53,10 +57,7 @@ export class StalkerMainContainerComponent {
|
||||
readonly contentItems = this.stalkerStore.getPaginatedContent;
|
||||
readonly isContentLoading = this.stalkerStore.isPaginatedContentLoading;
|
||||
|
||||
constructor(
|
||||
private readonly activatedRoute: ActivatedRoute,
|
||||
private readonly router: Router
|
||||
) {
|
||||
constructor() {
|
||||
// reset category title after changing content type
|
||||
effect(() => {
|
||||
this.stalkerStore.selectedContentType();
|
||||
@@ -70,7 +71,7 @@ export class StalkerMainContainerComponent {
|
||||
this.stalkerStore.setSelectedItem(undefined);
|
||||
|
||||
this.router.navigate(['.', item.category_id], {
|
||||
relativeTo: this.activatedRoute,
|
||||
relativeTo: this.route,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
[resultsCount]="resultsCount"
|
||||
[isLoading]="searchResultsResource.isLoading()"
|
||||
[showResultsCount]="true"
|
||||
[showSearchInput]="!isWorkspaceLayout"
|
||||
(searchTermChange)="updateSearchTerm($event)"
|
||||
>
|
||||
<!-- Filters (single selection mode for stalker) -->
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -14,7 +15,7 @@ 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/vod-details/vod-details.component';
|
||||
import { VodDetailsComponent } from '../../xtream-electron/vod-details/vod-details.component';
|
||||
import { StalkerContentTypes } from '../stalker-content-types';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
@@ -67,6 +68,8 @@ export class StalkerSearchComponent {
|
||||
series: false,
|
||||
vod: true,
|
||||
});
|
||||
readonly isWorkspaceLayout =
|
||||
this.activatedRoute.snapshot.data['layout'] === 'workspace';
|
||||
|
||||
readonly filterConfig: StalkerFilter[] = [
|
||||
{
|
||||
@@ -159,6 +162,15 @@ 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(() => {
|
||||
// Re-evaluate favorite state whenever favorites resource changes.
|
||||
this.portalFavorites.value();
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<div class="stalker-container">
|
||||
<app-navigation
|
||||
class="navigation-sidebar"
|
||||
[navigationItems]="mainNavigationItems"
|
||||
(categoryClick)="setContentType($any($event))"
|
||||
(pageClicked)="handlePageClick()"
|
||||
[selectedContentType]="stalkerStore.selectedContentType()"
|
||||
/>
|
||||
@if (!isWorkspaceLayout) {
|
||||
<app-navigation
|
||||
class="navigation-sidebar"
|
||||
[selectedContentType]="stalkerStore.selectedContentType()"
|
||||
/>
|
||||
}
|
||||
<router-outlet />
|
||||
</div>
|
||||
@@ -10,8 +10,12 @@
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
|
||||
> router-outlet {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-sidebar {
|
||||
border-right: 1px solid #333;
|
||||
border-right: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Component, effect, inject, OnDestroy } from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
NavigationEnd,
|
||||
Router,
|
||||
RouterOutlet,
|
||||
} from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlaylistActions, selectPlaylistById } from 'm3u-state';
|
||||
import { map, switchMap } from 'rxjs';
|
||||
import { createLogger } from '../../shared/utils/logger';
|
||||
import { NavigationComponent } from '../../xtream-electron/navigation/navigation.component';
|
||||
import { NavigationItem } from '../../xtream-electron/navigation/navigation.interface';
|
||||
import { XtreamStore } from '../../xtream-electron/stores/xtream.store';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
|
||||
@@ -23,24 +27,8 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
private readonly router = inject(Router);
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
readonly mainNavigationItems: NavigationItem[] = [
|
||||
{
|
||||
id: 'vod',
|
||||
icon: 'movie',
|
||||
labelKey: 'PORTALS.SIDEBAR.MOVIES',
|
||||
},
|
||||
{
|
||||
id: 'itv',
|
||||
icon: 'live_tv',
|
||||
labelKey: 'PORTALS.SIDEBAR.LIVE_TV',
|
||||
},
|
||||
{
|
||||
id: 'series',
|
||||
icon: 'tv',
|
||||
labelKey: 'PORTALS.SIDEBAR.SERIES',
|
||||
},
|
||||
];
|
||||
readonly isWorkspaceLayout =
|
||||
this.route.snapshot.data['layout'] === 'workspace';
|
||||
|
||||
/** Current playlist derived from route params */
|
||||
readonly currentPlaylist = toSignal(
|
||||
@@ -49,12 +37,16 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
switchMap((id) => this.store.select(selectPlaylistById(id)))
|
||||
)
|
||||
);
|
||||
private currentSection: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// Subscribe to route params to handle switching between playlists
|
||||
this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||
const playlistId = params['id'];
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.setActivePlaylist({ playlistId })
|
||||
);
|
||||
this.store.dispatch(
|
||||
PlaylistActions.setCurrentPlaylistId({ playlistId })
|
||||
);
|
||||
@@ -64,7 +56,7 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
|
||||
const childRoute = this.route.snapshot.firstChild;
|
||||
const path = childRoute?.url[0]?.path;
|
||||
const path = childRoute?.url?.[0]?.path;
|
||||
const validTypes = ['vod', 'series', 'itv'];
|
||||
const initialType = validTypes.includes(path)
|
||||
? (path as any)
|
||||
@@ -72,25 +64,49 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
this.stalkerStore.setSelectedContentType(initialType);
|
||||
});
|
||||
|
||||
this.router.events
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((event) => {
|
||||
if (event instanceof NavigationEnd) {
|
||||
this.syncSectionFromRoute();
|
||||
}
|
||||
});
|
||||
|
||||
this.syncSectionFromRoute();
|
||||
|
||||
effect(() => {
|
||||
this.stalkerStore.setCurrentPlaylist(this.currentPlaylist());
|
||||
});
|
||||
}
|
||||
|
||||
setContentType(type: 'vod' | 'live' | 'series' | 'itv') {
|
||||
if (type === 'live') type = 'itv';
|
||||
this.stalkerStore.setSelectedContentType(type);
|
||||
this.stalkerStore.setSelectedCategory(type === 'itv' ? null : '*');
|
||||
this.router.navigate([type], {
|
||||
relativeTo: this.route,
|
||||
});
|
||||
}
|
||||
|
||||
handlePageClick() {
|
||||
this.stalkerStore.setSelectedContentType(null);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.stalkerStore.resetCategories();
|
||||
}
|
||||
|
||||
private syncSectionFromRoute(): void {
|
||||
const sectionFromSnapshot =
|
||||
this.route.firstChild?.snapshot?.url?.[0]?.path ?? null;
|
||||
const sectionFromUrl = this.getSectionFromUrl(this.router.url);
|
||||
const section = sectionFromSnapshot ?? sectionFromUrl;
|
||||
|
||||
if (!section || section === this.currentSection) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentSection = section;
|
||||
|
||||
if (section === 'vod' || section === 'series' || section === 'itv') {
|
||||
this.stalkerStore.setSelectedContentType(section as any);
|
||||
return;
|
||||
}
|
||||
|
||||
this.stalkerStore.setSelectedContentType(null as any);
|
||||
}
|
||||
|
||||
private getSectionFromUrl(url: string): string | null {
|
||||
const match = url.match(
|
||||
/^\/(?:workspace\/)?stalker\/[^\/\?]+\/([^\/\?]+)/
|
||||
);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -135,13 +135,25 @@ export function withStalkerContent() {
|
||||
.sort((a, b) =>
|
||||
a.category_name.localeCompare(b.category_name)
|
||||
);
|
||||
if (
|
||||
|
||||
// If the portal provides its own "All" category entry,
|
||||
// promote it to position 0. Otherwise add a synthetic one.
|
||||
const allIdx = categories.findIndex(
|
||||
(c) =>
|
||||
c.category_name.trim().toLowerCase() === 'all'
|
||||
);
|
||||
if (allIdx > 0) {
|
||||
// Move the existing 'All' entry to the front
|
||||
categories.unshift(categories.splice(allIdx, 1)[0]);
|
||||
} else if (
|
||||
allIdx === -1 &&
|
||||
categories.length > 0 &&
|
||||
!categories.some(
|
||||
(category) =>
|
||||
String(category.category_id) === '*'
|
||||
)
|
||||
) {
|
||||
// No 'All' entry found — prepend synthetic one
|
||||
categories.unshift({
|
||||
category_name: translateService.instant(
|
||||
'PORTALS.ALL_CATEGORIES'
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
|
||||
import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
|
||||
import { PlayerService } from '../../../services/player.service';
|
||||
import { ContentType } from '../../../xtream/content-type.enum';
|
||||
import { StalkerContentTypes } from '../../stalker-content-types';
|
||||
import {
|
||||
normalizeStalkerEntityId,
|
||||
@@ -13,6 +12,32 @@ import {
|
||||
} from '../../stalker-vod.utils';
|
||||
import { createLogger } from '../../../shared/utils/logger';
|
||||
|
||||
type StalkerContentType = 'itv' | 'vod' | 'series';
|
||||
|
||||
interface StalkerPlayableItem extends Record<string, unknown> {
|
||||
id?: string | number;
|
||||
cmd?: string;
|
||||
has_files?: unknown;
|
||||
}
|
||||
|
||||
interface StalkerPlayerStoreLike {
|
||||
selectedContentType: () => StalkerContentType;
|
||||
currentPlaylist: () => Playlist | null;
|
||||
selectedItem: () => StalkerPlayableItem | null;
|
||||
addToRecentlyViewed?: (item: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
interface StalkerResponse {
|
||||
js?: {
|
||||
error?: string;
|
||||
cmd?: string;
|
||||
data?: Array<{ id?: string | number }>;
|
||||
account_info?: {
|
||||
expire_date?: string | number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Playback/link/player concern methods.
|
||||
*/
|
||||
@@ -29,24 +54,22 @@ export function withStalkerPlayer() {
|
||||
snackBar = inject(MatSnackBar),
|
||||
translate = inject(TranslateService)
|
||||
) => {
|
||||
const storeAny = store as any;
|
||||
const storeState = store as unknown as StalkerPlayerStoreLike;
|
||||
const fetchLinkToPlayInternal = async (
|
||||
portalUrl: string,
|
||||
macAddress: string,
|
||||
cmd: string,
|
||||
series?: number
|
||||
) => {
|
||||
const type = series
|
||||
? ContentType.VODS
|
||||
: storeAny.selectedContentType();
|
||||
const selectedContentType =
|
||||
storeState.selectedContentType();
|
||||
const type = series ? 'vod' : selectedContentType;
|
||||
|
||||
// Always use create_link to get the tokenized streaming URL
|
||||
// The server adds the required token for playback authorization
|
||||
// Note: cmd is already transformed during item processing (has_files items)
|
||||
const params = {
|
||||
action: StalkerContentTypes[
|
||||
storeAny.selectedContentType()
|
||||
].getLink,
|
||||
action: StalkerContentTypes[selectedContentType].getLink,
|
||||
cmd: cmd,
|
||||
type,
|
||||
disable_ad: '0',
|
||||
@@ -56,8 +79,8 @@ export function withStalkerPlayer() {
|
||||
};
|
||||
|
||||
// Use makeAuthenticatedRequest for automatic retry on auth failure
|
||||
const playlist = storeAny.currentPlaylist() as Playlist;
|
||||
let response: any;
|
||||
const playlist = storeState.currentPlaylist();
|
||||
let response: StalkerResponse;
|
||||
if (playlist?.isFullStalkerPortal) {
|
||||
// Full stalker portal - use authenticated request with retry
|
||||
response = await stalkerSession.makeAuthenticatedRequest(
|
||||
@@ -138,7 +161,7 @@ export function withStalkerPlayer() {
|
||||
const fetchMovieFileIdInternal = async (
|
||||
movieId: string
|
||||
): Promise<string | null> => {
|
||||
const playlist = storeAny.currentPlaylist() as Playlist;
|
||||
const playlist = storeState.currentPlaylist();
|
||||
if (!playlist) return null;
|
||||
|
||||
const queryParams = {
|
||||
@@ -148,7 +171,7 @@ export function withStalkerPlayer() {
|
||||
p: '1',
|
||||
};
|
||||
|
||||
let response: any;
|
||||
let response: StalkerResponse;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
response = await stalkerSession.makeAuthenticatedRequest(
|
||||
playlist,
|
||||
@@ -175,23 +198,25 @@ export function withStalkerPlayer() {
|
||||
};
|
||||
|
||||
const addToRecentlyViewedInternal = (
|
||||
item: any,
|
||||
item: StalkerPlayableItem,
|
||||
cmd?: string,
|
||||
cover?: string,
|
||||
title?: string
|
||||
) => {
|
||||
const playlistId = storeAny.currentPlaylist()?._id;
|
||||
const playlistId = storeState.currentPlaylist()?._id;
|
||||
if (!playlistId) return;
|
||||
const recentlyViewedItem: { id: string; title: string } &
|
||||
Record<string, unknown> = {
|
||||
...item,
|
||||
id: normalizeStalkerEntityId(item?.id),
|
||||
cmd,
|
||||
cover,
|
||||
title,
|
||||
category_id: storeState.selectedContentType(),
|
||||
added_at: Date.now(),
|
||||
};
|
||||
playlistService
|
||||
.addPortalRecentlyViewed(playlistId, {
|
||||
...item,
|
||||
id: item?.id,
|
||||
cmd,
|
||||
cover,
|
||||
title,
|
||||
category_id: storeAny.selectedContentType(),
|
||||
added_at: Date.now(),
|
||||
})
|
||||
.addPortalRecentlyViewed(playlistId, recentlyViewedItem)
|
||||
.subscribe();
|
||||
};
|
||||
|
||||
@@ -218,8 +243,11 @@ export function withStalkerPlayer() {
|
||||
|
||||
try {
|
||||
// Use makeAuthenticatedRequest for automatic retry on auth failure
|
||||
const playlist = storeAny.currentPlaylist() as Playlist;
|
||||
let response: any;
|
||||
const playlist = storeState.currentPlaylist();
|
||||
let response: StalkerResponse;
|
||||
if (!playlist) {
|
||||
return 'Unknown';
|
||||
}
|
||||
if (playlist?.isFullStalkerPortal) {
|
||||
// Full stalker portal - use authenticated request with retry
|
||||
response =
|
||||
@@ -247,10 +275,16 @@ export function withStalkerPlayer() {
|
||||
// Extract the expire date from the response
|
||||
const expireDate =
|
||||
response.js.account_info.expire_date;
|
||||
const numericExpireDate = Number(expireDate);
|
||||
|
||||
// Convert timestamp to readable date if it's a unix timestamp
|
||||
if (expireDate && !isNaN(expireDate)) {
|
||||
const date = new Date(expireDate * 1000); // Convert seconds to milliseconds
|
||||
if (
|
||||
expireDate &&
|
||||
!Number.isNaN(numericExpireDate)
|
||||
) {
|
||||
const date = new Date(
|
||||
numericExpireDate * 1000
|
||||
); // Convert seconds to milliseconds
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
@@ -291,7 +325,7 @@ export function withStalkerPlayer() {
|
||||
startTime?: number
|
||||
) {
|
||||
try {
|
||||
const item = storeAny.selectedItem();
|
||||
const item = storeState.selectedItem();
|
||||
let cmdToUse = cmd ?? item?.cmd;
|
||||
|
||||
if (!cmdToUse) {
|
||||
@@ -314,15 +348,21 @@ export function withStalkerPlayer() {
|
||||
}
|
||||
}
|
||||
|
||||
const playlist = storeAny.currentPlaylist();
|
||||
const playlist = storeState.currentPlaylist();
|
||||
if (!playlist) {
|
||||
throw new Error('nothing_to_play');
|
||||
}
|
||||
const url = await fetchLinkToPlayInternal(
|
||||
playlist.portalUrl,
|
||||
playlist.macAddress,
|
||||
cmdToUse,
|
||||
episodeNum
|
||||
);
|
||||
if (typeof storeAny.addToRecentlyViewed === 'function') {
|
||||
storeAny.addToRecentlyViewed({
|
||||
if (
|
||||
typeof storeState.addToRecentlyViewed ===
|
||||
'function'
|
||||
) {
|
||||
storeState.addToRecentlyViewed({
|
||||
...item,
|
||||
id: item?.id,
|
||||
cmd: cmd,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { computed } from '@angular/core';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { computed } from '@angular/core';
|
||||
import { StalkerVodSource } from '../../models';
|
||||
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
|
||||
|
||||
@@ -45,9 +45,7 @@ export function withStalkerSelection() {
|
||||
setSelectedCategory(id: string | number | null) {
|
||||
patchState(store, {
|
||||
selectedCategoryId:
|
||||
id !== null && id !== undefined
|
||||
? String(id)
|
||||
: null,
|
||||
id !== null && id !== undefined ? String(id) : null,
|
||||
page: 0,
|
||||
});
|
||||
},
|
||||
@@ -71,8 +69,7 @@ export function withStalkerSelection() {
|
||||
},
|
||||
setSelectedItem(selectedItem: StalkerVodSource | null | undefined) {
|
||||
const selectedIdRaw =
|
||||
selectedItem?.id !== undefined &&
|
||||
selectedItem?.id !== null
|
||||
selectedItem?.id !== undefined && selectedItem?.id !== null
|
||||
? selectedItem.id
|
||||
: undefined;
|
||||
const selectedId =
|
||||
@@ -101,7 +98,7 @@ export function withStalkerSelection() {
|
||||
if (!categoryId) {
|
||||
return {
|
||||
id: 0,
|
||||
name: 'All Items',
|
||||
category_name: 'All Items',
|
||||
type: store.selectedContentType(),
|
||||
};
|
||||
}
|
||||
@@ -110,7 +107,10 @@ export function withStalkerSelection() {
|
||||
const contentType = store.selectedContentType();
|
||||
let categories: any[] = [];
|
||||
const readCategories = (
|
||||
getterName: 'vodCategories' | 'seriesCategories' | 'itvCategories'
|
||||
getterName:
|
||||
| 'vodCategories'
|
||||
| 'seriesCategories'
|
||||
| 'itvCategories'
|
||||
) =>
|
||||
typeof storeAny[getterName] === 'function'
|
||||
? storeAny[getterName]()
|
||||
@@ -125,9 +125,12 @@ export function withStalkerSelection() {
|
||||
|
||||
return (
|
||||
categories.find(
|
||||
(c: any) =>
|
||||
String(c.category_id) === String(categoryId)
|
||||
) || { id: categoryId, name: '', type: contentType }
|
||||
(c: any) => String(c.category_id) === String(categoryId)
|
||||
) || {
|
||||
category_id: categoryId,
|
||||
category_name: '',
|
||||
type: contentType,
|
||||
}
|
||||
);
|
||||
}),
|
||||
}))
|
||||
|
||||
Reference in new issue
Block a user