mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #721 from 4gray/feat/introduce-playlist-switcher
feat/introduce-playlist-switcher
This commit is contained in:
26 files changed
+915
-208
No files matched your search
@@ -1,9 +1,10 @@
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>list</mat-icon>
|
||||
</button>
|
||||
<h2>{{ 'PORTALS.ALL_CATEGORIES' | translate }}</h2>
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="playlistTitle()"
|
||||
[subtitle]="playlistSubtitle()"
|
||||
/>
|
||||
</div>
|
||||
<app-category-view
|
||||
[items]="categories()"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent } from 'components';
|
||||
import { CategoryViewComponent } from '../../../xtream-tauri/category-view/category-view.component';
|
||||
|
||||
@Component({
|
||||
@@ -20,14 +21,16 @@ import { CategoryViewComponent } from '../../../xtream-tauri/category-view/categ
|
||||
MatCardModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
/* MpvPlayerBarComponent, */
|
||||
MatTooltip,
|
||||
PlaylistSwitcherComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class FavoritesLayoutComponent {
|
||||
readonly categories = input<any[]>([]);
|
||||
readonly favorites = input<any[]>([]);
|
||||
readonly playlistSubtitle = input<string>('');
|
||||
readonly playlistTitle = input<string>('Playlist');
|
||||
readonly selectedCategoryId = input<string>('movie');
|
||||
readonly titleTranslationString = input<string>('CHANNELS.FAVORITES');
|
||||
|
||||
|
||||
@@ -31,10 +31,12 @@
|
||||
<app-favorites-layout
|
||||
[categories]="categories() ?? []"
|
||||
[favorites]="itemsToShow() ?? []"
|
||||
[playlistSubtitle]="'Stalker Portal'"
|
||||
[playlistTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
[titleTranslationString]="'PORTALS.SIDEBAR.RECENT'"
|
||||
(categoryClicked)="setCategoryId($event.category_id)"
|
||||
(removeFavorite)="removeFromRecentlyViewed($event)"
|
||||
[titleTranslationString]="'PORTALS.SIDEBAR.RECENT'"
|
||||
(openItem)="openItem($event)"
|
||||
/>
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export class RecentlyViewedComponent {
|
||||
|
||||
itemDetails: any = null;
|
||||
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
readonly allFavorites = rxResource({
|
||||
params: () => ({
|
||||
refreshTimestamp: this.refreshTimestamp(),
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
<app-favorites-layout
|
||||
[categories]="categories() ?? []"
|
||||
[favorites]="favoritesToShow() ?? []"
|
||||
[playlistSubtitle]="'Stalker Portal'"
|
||||
[playlistTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
(categoryClicked)="setCategoryId($event.category_id)"
|
||||
(removeFavorite)="removeFromFavorites($event)"
|
||||
|
||||
@@ -43,6 +43,8 @@ export class StalkerFavoritesComponent {
|
||||
|
||||
itemDetails: any = null;
|
||||
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
readonly allFavorites = rxResource({
|
||||
params: () => ({
|
||||
refreshTimestamp: this.refreshTimestamp(),
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
</button>
|
||||
<h2>{{ selectedCategoryTitle() }}</h2>
|
||||
} @else {
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>list</mat-icon>
|
||||
</button>
|
||||
<h2>{{ 'PORTALS.ALL_CATEGORIES' | translate }}</h2>
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="currentPlaylist()?.title || 'Portal'"
|
||||
[subtitle]="'Stalker Portal'"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@if (isItvCategorySelected) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { PlaylistSwitcherComponent } from 'components';
|
||||
import { CategoryViewComponent } from '../xtream-tauri/category-view/category-view.component';
|
||||
import { PlaylistErrorViewComponent } from '../xtream/playlist-error-view/playlist-error-view.component';
|
||||
import { StalkerStore } from './stalker.store';
|
||||
@@ -27,11 +28,11 @@ import { StalkerStore } from './stalker.store';
|
||||
MatIconButton,
|
||||
MatListModule,
|
||||
MatPaginatorModule,
|
||||
/* MpvPlayerBarComponent, */
|
||||
NgxSkeletonLoaderModule,
|
||||
PlaylistErrorViewComponent,
|
||||
TranslatePipe,
|
||||
PlaylistSwitcherComponent,
|
||||
RouterOutlet,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class StalkerMainContainerComponent {
|
||||
@@ -54,6 +55,7 @@ export class StalkerMainContainerComponent {
|
||||
readonly itvChannels = this.stalkerStore.itvChannels;
|
||||
|
||||
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
/** categories */
|
||||
readonly categories = this.stalkerStore.getCategoryResource;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, effect, inject, OnDestroy } from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { selectPlaylistById } from 'm3u-state';
|
||||
import { PlaylistActions, selectPlaylistById } from 'm3u-state';
|
||||
import { map, switchMap } from 'rxjs';
|
||||
import { NavigationComponent } from '../../xtream-tauri/navigation/navigation.component';
|
||||
import { NavigationItem } from '../../xtream-tauri/navigation/navigation.interface';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
@@ -38,24 +39,34 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
},
|
||||
];
|
||||
|
||||
readonly currentPlaylist = this.store.selectSignal(
|
||||
selectPlaylistById(this.route.snapshot.params.id)
|
||||
/** Current playlist derived from route params */
|
||||
readonly currentPlaylist = toSignal(
|
||||
this.route.params.pipe(
|
||||
map((params) => params['id']),
|
||||
switchMap((id) => this.store.select(selectPlaylistById(id)))
|
||||
)
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.store.dispatch(
|
||||
PlaylistActions.setCurrentPlaylistId({
|
||||
playlistId: this.route.snapshot.params['id'],
|
||||
})
|
||||
);
|
||||
// Subscribe to route params to handle switching between playlists
|
||||
this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||
const playlistId = params['id'];
|
||||
|
||||
const childRoute = this.route.snapshot.firstChild;
|
||||
const path = childRoute?.url[0]?.path;
|
||||
const validTypes = ['vod', 'series', 'itv'];
|
||||
const initialType = validTypes.includes(path) ? (path as any) : 'vod';
|
||||
this.store.dispatch(
|
||||
PlaylistActions.setCurrentPlaylistId({ playlistId })
|
||||
);
|
||||
|
||||
// Reset store state when switching playlists
|
||||
this.stalkerStore.resetCategories();
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
|
||||
const childRoute = this.route.snapshot.firstChild;
|
||||
const path = childRoute?.url[0]?.path;
|
||||
const validTypes = ['vod', 'series', 'itv'];
|
||||
const initialType = validTypes.includes(path) ? (path as any) : 'vod';
|
||||
this.stalkerStore.setSelectedContentType(initialType);
|
||||
});
|
||||
|
||||
this.stalkerStore.setSelectedContentType(initialType);
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
effect(() => {
|
||||
this.stalkerStore.setCurrentPlaylist(this.currentPlaylist());
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<app-favorites-layout
|
||||
[categories]="categories()"
|
||||
[favorites]="favoritesToShow()"
|
||||
[playlistSubtitle]="'Xtream Code'"
|
||||
[playlistTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
|
||||
[selectedCategoryId]="selectedCategoryId()"
|
||||
(categoryClicked)="setCategoryId($event.category_id)"
|
||||
(removeFavorite)="removeFromFavorites($event)"
|
||||
|
||||
@@ -24,6 +24,7 @@ export class FavoritesComponent implements OnInit {
|
||||
private xtreamStore = inject(XtreamStore);
|
||||
|
||||
readonly categories = signal<XtreamCategory[]>([]);
|
||||
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
|
||||
|
||||
readonly series = signal<FavoriteItem[]>([]);
|
||||
readonly movies = signal<FavoriteItem[]>([]);
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
</button>
|
||||
<h2>{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}</h2>
|
||||
} @else {
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>list</mat-icon>
|
||||
</button>
|
||||
<h2>{{ 'PORTALS.ALL_CATEGORIES' | translate }}</h2>
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
|
||||
[subtitle]="'Xtream Code'"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="manage-categories-btn"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent } from 'components';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
|
||||
import { SettingsStore } from '../../services/settings-store.service';
|
||||
@@ -41,6 +42,7 @@ import { XtreamStore } from '../xtream.store';
|
||||
MatListModule,
|
||||
MatTooltipModule,
|
||||
/* MpvPlayerBarComponent, */
|
||||
PlaylistSwitcherComponent,
|
||||
PortalChannelsListComponent,
|
||||
TranslatePipe,
|
||||
WebPlayerViewComponent,
|
||||
@@ -56,6 +58,7 @@ export class LiveStreamLayoutComponent implements OnInit {
|
||||
|
||||
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
|
||||
readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts;
|
||||
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
|
||||
readonly epgItems = this.xtreamStore.epgItems;
|
||||
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.switcher {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.manage-categories-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>list</mat-icon>
|
||||
</button>
|
||||
<h2>{{ 'PORTALS.ALL_CATEGORIES' | translate }}</h2>
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
|
||||
[subtitle]="'Xtream Code'"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
class="manage-categories-btn"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent } from 'components';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import {
|
||||
CategoryManagementDialogComponent,
|
||||
@@ -19,12 +20,12 @@ import { XtreamStore } from './xtream.store';
|
||||
styleUrls: ['./xtream-main-container.component.scss', './sidebar.scss'],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
TranslateModule,
|
||||
RouterOutlet,
|
||||
/* MpvPlayerBarComponent, */
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltipModule,
|
||||
PlaylistSwitcherComponent,
|
||||
RouterOutlet,
|
||||
TranslateModule,
|
||||
],
|
||||
})
|
||||
export class XtreamMainContainerComponent implements OnInit {
|
||||
@@ -36,7 +37,7 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
|
||||
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
|
||||
readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts;
|
||||
|
||||
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
|
||||
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, effect, inject } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -48,7 +49,33 @@ export class XtreamShellComponent {
|
||||
},
|
||||
];
|
||||
|
||||
private currentPlaylistId: string | null = null;
|
||||
private isFirstLoad = true;
|
||||
|
||||
constructor() {
|
||||
// Subscribe to route params to handle switching between playlists
|
||||
this.route.params.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||
const newPlaylistId = params['id'];
|
||||
|
||||
if (this.isFirstLoad) {
|
||||
// First load - just set the playlist ID without resetting
|
||||
this.xtreamStore.setPlaylistId(newPlaylistId);
|
||||
this.isFirstLoad = false;
|
||||
} else if (this.currentPlaylistId !== newPlaylistId) {
|
||||
// Switching to a different playlist - reset and set new ID
|
||||
this.xtreamStore.resetStore(newPlaylistId);
|
||||
}
|
||||
this.currentPlaylistId = newPlaylistId;
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.setActivePlaylist({
|
||||
playlistId: newPlaylistId,
|
||||
})
|
||||
);
|
||||
this.xtreamStore.fetchXtreamPlaylist();
|
||||
this.xtreamStore.checkPortalStatus();
|
||||
});
|
||||
|
||||
effect(
|
||||
() => {
|
||||
if (this.xtreamStore.currentPlaylist() !== null) {
|
||||
@@ -59,15 +86,6 @@ export class XtreamShellComponent {
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.xtreamStore.checkPortalStatus();
|
||||
this.store.dispatch(
|
||||
PlaylistActions.setActivePlaylist({
|
||||
playlistId: this.route.snapshot.params.id,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
handleCategoryClick(category: 'vod' | 'live' | 'series') {
|
||||
this.xtreamStore.setSelectedContentType(category);
|
||||
this.router.navigate([category], {
|
||||
|
||||
@@ -44,4 +44,5 @@ export interface XtreamState {
|
||||
portalStatus: PortalStatusType;
|
||||
globalSearchResults: GlobalSearchResult[];
|
||||
streamUrl: string;
|
||||
playlistId: string | null;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { computed, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
patchState,
|
||||
signalStore,
|
||||
@@ -60,6 +59,7 @@ const initialState: XtreamState = {
|
||||
portalStatus: 'unavailable',
|
||||
globalSearchResults: [],
|
||||
streamUrl: null,
|
||||
playlistId: null,
|
||||
};
|
||||
|
||||
/** to decode epg */
|
||||
@@ -200,7 +200,6 @@ export const XtreamStore = signalStore(
|
||||
withMethods(
|
||||
(
|
||||
store,
|
||||
route = inject(ActivatedRoute),
|
||||
dataService = inject(DataService),
|
||||
dbService = inject(DatabaseService),
|
||||
oldStore = inject(Store),
|
||||
@@ -215,7 +214,8 @@ export const XtreamStore = signalStore(
|
||||
>,
|
||||
type: 'live' | 'movies' | 'series'
|
||||
) => {
|
||||
console.log(`Fetching ${type} categories...`);
|
||||
const playlistId = store.playlistId();
|
||||
console.log(`Fetching ${type} categories for playlist ${playlistId}...`);
|
||||
patchState(store, { isLoadingCategories: true });
|
||||
const queryParams = {
|
||||
action,
|
||||
@@ -224,17 +224,14 @@ export const XtreamStore = signalStore(
|
||||
};
|
||||
|
||||
return from(
|
||||
dbService.hasXtreamCategories(
|
||||
route.snapshot.params.id,
|
||||
type
|
||||
)
|
||||
dbService.hasXtreamCategories(playlistId, type)
|
||||
).pipe(
|
||||
switchMap(async (exists) => {
|
||||
try {
|
||||
if (exists) {
|
||||
const localData =
|
||||
await dbService.getXtreamCategories(
|
||||
route.snapshot.params.id,
|
||||
playlistId,
|
||||
type
|
||||
);
|
||||
patchState(store, {
|
||||
@@ -253,7 +250,7 @@ export const XtreamStore = signalStore(
|
||||
|
||||
if (remoteData && Array.isArray(remoteData)) {
|
||||
await dbService.saveXtreamCategories(
|
||||
route.snapshot.params.id,
|
||||
playlistId,
|
||||
remoteData,
|
||||
type
|
||||
);
|
||||
@@ -261,7 +258,7 @@ export const XtreamStore = signalStore(
|
||||
|
||||
const localData =
|
||||
await dbService.getXtreamCategories(
|
||||
route.snapshot.params.id,
|
||||
playlistId,
|
||||
type
|
||||
);
|
||||
patchState(store, {
|
||||
@@ -290,7 +287,8 @@ export const XtreamStore = signalStore(
|
||||
>,
|
||||
type: 'live' | 'movie' | 'series'
|
||||
) => {
|
||||
console.log(`Fetching ${type} streams...`);
|
||||
const playlistId = store.playlistId();
|
||||
console.log(`Fetching ${type} streams for playlist ${playlistId}...`);
|
||||
patchState(store, { isLoadingContent: true });
|
||||
const queryParams = {
|
||||
action,
|
||||
@@ -299,14 +297,14 @@ export const XtreamStore = signalStore(
|
||||
};
|
||||
|
||||
return from(
|
||||
dbService.hasXtreamContent(route.snapshot.params.id, type)
|
||||
dbService.hasXtreamContent(playlistId, type)
|
||||
).pipe(
|
||||
switchMap(async (exists) => {
|
||||
try {
|
||||
if (exists) {
|
||||
const localData =
|
||||
await dbService.getXtreamContent(
|
||||
route.snapshot.params.id,
|
||||
playlistId,
|
||||
type
|
||||
);
|
||||
patchState(store, {
|
||||
@@ -331,7 +329,7 @@ export const XtreamStore = signalStore(
|
||||
});
|
||||
const insertedCount =
|
||||
await dbService.saveXtreamContent(
|
||||
route.snapshot.params.id,
|
||||
playlistId,
|
||||
remoteData,
|
||||
type,
|
||||
(count) =>
|
||||
@@ -343,7 +341,7 @@ export const XtreamStore = signalStore(
|
||||
}
|
||||
|
||||
const localData = await dbService.getXtreamContent(
|
||||
route.snapshot.params.id,
|
||||
playlistId,
|
||||
type
|
||||
);
|
||||
patchState(store, {
|
||||
@@ -363,53 +361,57 @@ export const XtreamStore = signalStore(
|
||||
|
||||
const searchContent = async (
|
||||
searchTerm: string,
|
||||
types: string[],
|
||||
route = inject(ActivatedRoute),
|
||||
dbService = inject(DatabaseService)
|
||||
types: string[]
|
||||
) => {
|
||||
if (!route.snapshot.params.id) return;
|
||||
const playlistId = store.playlistId();
|
||||
if (!playlistId) return;
|
||||
|
||||
const results = await dbService.searchXtreamContent(
|
||||
route.snapshot.params.id,
|
||||
playlistId,
|
||||
searchTerm,
|
||||
types
|
||||
);
|
||||
return results;
|
||||
};
|
||||
|
||||
const fetchXtreamPlaylist = rxMethod<void>(
|
||||
pipe(() => {
|
||||
const playlistId = route.snapshot.params.id;
|
||||
if (!playlistId) return EMPTY;
|
||||
return from(dbService.getPlaylistById(playlistId)).pipe(
|
||||
switchMap(async (playlist) => {
|
||||
if (playlist) {
|
||||
patchState(store, {
|
||||
currentPlaylist: playlist,
|
||||
});
|
||||
} else {
|
||||
const playlist = oldStore.selectSignal(
|
||||
selectActivePlaylist
|
||||
)() as Playlist;
|
||||
if (playlist) {
|
||||
await dbService.createPlaylist(playlist);
|
||||
patchState(store, {
|
||||
currentPlaylist: {
|
||||
...playlist,
|
||||
serverUrl: playlist.serverUrl,
|
||||
id: playlist._id,
|
||||
name: playlist.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
tap((res) => {
|
||||
console.log('Fetched playlist:', res);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
const fetchXtreamPlaylist = async () => {
|
||||
const playlistId = store.playlistId();
|
||||
console.log('fetchXtreamPlaylist called with playlistId:', playlistId);
|
||||
if (!playlistId) {
|
||||
console.log('No playlistId, skipping fetch');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const playlist = await dbService.getPlaylistById(playlistId);
|
||||
console.log('Fetched playlist from DB:', playlist);
|
||||
|
||||
if (playlist) {
|
||||
patchState(store, {
|
||||
currentPlaylist: playlist,
|
||||
});
|
||||
} else {
|
||||
const activePlaylist = oldStore.selectSignal(
|
||||
selectActivePlaylist
|
||||
)() as Playlist;
|
||||
console.log('No playlist in DB, using active playlist:', activePlaylist);
|
||||
|
||||
if (activePlaylist) {
|
||||
await dbService.createPlaylist(activePlaylist);
|
||||
patchState(store, {
|
||||
currentPlaylist: {
|
||||
...activePlaylist,
|
||||
serverUrl: activePlaylist.serverUrl,
|
||||
id: activePlaylist._id,
|
||||
name: activePlaylist.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching playlist:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLiveCategories = rxMethod<void>(
|
||||
pipe(
|
||||
@@ -504,7 +506,7 @@ export const XtreamStore = signalStore(
|
||||
}>(
|
||||
pipe(
|
||||
switchMap(({ term, types }) =>
|
||||
from(searchContent(term, types, route, dbService)).pipe(
|
||||
from(searchContent(term, types)).pipe(
|
||||
tap((results) => {
|
||||
patchState(store, {
|
||||
searchResults: results || [],
|
||||
@@ -645,9 +647,9 @@ export const XtreamStore = signalStore(
|
||||
console.log('Content initialization completed');
|
||||
|
||||
// Check if we need to restore favorites and recently viewed after refresh
|
||||
const playlistId = route.snapshot.params.id;
|
||||
if (playlistId) {
|
||||
const restoreKey = `xtream-restore-${playlistId}`;
|
||||
const currentPlaylistId = store.playlistId();
|
||||
if (currentPlaylistId) {
|
||||
const restoreKey = `xtream-restore-${currentPlaylistId}`;
|
||||
const restoreData = localStorage.getItem(restoreKey);
|
||||
if (restoreData) {
|
||||
try {
|
||||
@@ -656,7 +658,7 @@ export const XtreamStore = signalStore(
|
||||
recentlyViewedXtreamIds,
|
||||
} = JSON.parse(restoreData);
|
||||
await dbService.restoreXtreamUserData(
|
||||
playlistId,
|
||||
currentPlaylistId,
|
||||
favoritedXtreamIds,
|
||||
recentlyViewedXtreamIds
|
||||
);
|
||||
@@ -761,6 +763,27 @@ export const XtreamStore = signalStore(
|
||||
fetchVodDetailsWithMetadata,
|
||||
fetchSerialDetailsWithMetadata,
|
||||
initializeContent,
|
||||
/**
|
||||
* Set the current playlist ID
|
||||
*/
|
||||
setPlaylistId(playlistId: string) {
|
||||
patchState(store, { playlistId });
|
||||
},
|
||||
/**
|
||||
* Reset store to initial state for switching between playlists
|
||||
*/
|
||||
resetStore(newPlaylistId?: string) {
|
||||
patchState(store, {
|
||||
...initialState,
|
||||
playlistId: newPlaylistId ?? null,
|
||||
limit: Number(
|
||||
localStorage.getItem('xtream-page-size') ?? 25
|
||||
),
|
||||
hideExternalInfoDialog:
|
||||
localStorage.getItem('hideExternalInfoDialog') ===
|
||||
'true',
|
||||
});
|
||||
},
|
||||
updatePlaylist(playlist: any) {
|
||||
patchState(store, {
|
||||
currentPlaylist: {
|
||||
@@ -916,7 +939,7 @@ export const XtreamStore = signalStore(
|
||||
* Reload categories from database after visibility changes
|
||||
*/
|
||||
async reloadCategories() {
|
||||
const playlistId = route.snapshot.params.id;
|
||||
const playlistId = store.playlistId();
|
||||
if (!playlistId) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ export * from './lib/html-video-player/html-video-player.component';
|
||||
export * from './lib/info-overlay/info-overlay.component';
|
||||
export * from './lib/multi-epg/multi-epg-container.component';
|
||||
export * from './lib/multi-epg/overlay-ref.token';
|
||||
export * from './lib/playlist-switcher/playlist-switcher.component';
|
||||
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
|
||||
export * from './lib/recent-playlists/playlist-item/playlist-item.component';
|
||||
export * from './lib/recent-playlists/recent-playlists.component';
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<div
|
||||
class="playlist-switcher-trigger"
|
||||
matRipple
|
||||
[matRippleDisabled]="false"
|
||||
[matMenuTriggerFor]="playlistMenu"
|
||||
#menuTrigger="matMenuTrigger"
|
||||
(menuOpened)="onMenuOpened()"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
>
|
||||
<div class="playlist-info">
|
||||
<div class="name">{{ currentTitle() }}</div>
|
||||
<div class="channels-count">{{ subtitle() }}</div>
|
||||
</div>
|
||||
<mat-icon class="dropdown-icon" [class.rotated]="isMenuOpen()">
|
||||
expand_more
|
||||
</mat-icon>
|
||||
</div>
|
||||
|
||||
<mat-menu
|
||||
#playlistMenu="matMenu"
|
||||
class="playlist-switcher-menu"
|
||||
xPosition="after"
|
||||
yPosition="below"
|
||||
>
|
||||
<div class="menu-header">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
<span>{{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="search-container" (click)="$event.stopPropagation()">
|
||||
<mat-icon class="search-icon">search</mat-icon>
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="searchQuery.set($event)"
|
||||
/>
|
||||
@if (searchQuery()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="clear-btn"
|
||||
(click)="searchQuery.set('')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="menu-content">
|
||||
@for (playlist of filteredPlaylists(); track playlist._id) {
|
||||
<div
|
||||
class="playlist-item"
|
||||
[class.selected]="isSelected(playlist)"
|
||||
(click)="selectPlaylist(playlist)"
|
||||
>
|
||||
@if (isSelected(playlist)) {
|
||||
<mat-icon class="selected-indicator">check</mat-icon>
|
||||
} @else {
|
||||
<div class="selected-indicator-placeholder"></div>
|
||||
}
|
||||
|
||||
<div class="playlist-icon-container">
|
||||
<mat-icon class="playlist-type-icon">
|
||||
{{ getPlaylistIcon(playlist) }}
|
||||
</mat-icon>
|
||||
@if (playlist.serverUrl) {
|
||||
<span
|
||||
class="status-dot"
|
||||
[class]="getStatusClass(playlist._id)"
|
||||
></span>
|
||||
}
|
||||
@if (playlist.autoRefresh && !playlist.serverUrl && !playlist.macAddress) {
|
||||
<mat-icon class="auto-refresh-indicator">autorenew</mat-icon>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="playlist-details">
|
||||
<span class="playlist-title">
|
||||
{{ playlist.title || playlist.filename }}
|
||||
</span>
|
||||
<span class="playlist-meta">
|
||||
{{ getPlaylistTypeLabel(playlist) }}
|
||||
@if (playlist.updateDate) {
|
||||
<span class="separator">|</span>
|
||||
{{ playlist.updateDate | date: 'MMM d, HH:mm' }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="empty-state">
|
||||
<mat-icon>playlist_add</mat-icon>
|
||||
<span>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-menu>
|
||||
@@ -0,0 +1,403 @@
|
||||
.playlist-switcher-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
app-region: no-drag;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 240px;
|
||||
|
||||
.name {
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.channels-count {
|
||||
font-size: 0.75em;
|
||||
opacity: 0.6;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0.7;
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-left: auto;
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Light theme
|
||||
@media (prefers-color-scheme: light) {
|
||||
.playlist-switcher-trigger:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
// Menu styles (global)
|
||||
::ng-deep .playlist-switcher-menu {
|
||||
min-width: 320px !important;
|
||||
max-width: 400px !important;
|
||||
border-radius: 12px !important;
|
||||
overflow: hidden !important;
|
||||
|
||||
.mat-mdc-menu-content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.7;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.menu-header {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 0 8px 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 0.85em;
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
padding: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.search-container {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
|
||||
&:focus-within {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
|
||||
// Custom scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.menu-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
min-height: 52px;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
|
||||
.playlist-title {
|
||||
color: #a78bfa;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.selected) {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.selected-indicator {
|
||||
color: #8b5cf6;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selected-indicator-placeholder {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.playlist-item {
|
||||
&.selected {
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
|
||||
.playlist-title {
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.selected) {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.playlist-icon-container {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-type-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
border: 2px solid var(--mat-menu-container-color, #2a2a2e);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background-color: #4caf50;
|
||||
box-shadow: 0 0 6px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background-color: #f44336;
|
||||
box-shadow: 0 0 6px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
background-color: #ff9800;
|
||||
box-shadow: 0 0 6px rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
|
||||
.status-unavailable {
|
||||
background-color: #666666;
|
||||
}
|
||||
|
||||
.auto-refresh-indicator {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
font-size: 12px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
color: #4caf50;
|
||||
background-color: var(--mat-menu-container-color, #2a2a2e);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.auto-refresh-indicator,
|
||||
.status-dot {
|
||||
border-color: var(--mat-menu-container-color, #fff);
|
||||
background-color: var(--mat-menu-container-color, #fff);
|
||||
}
|
||||
|
||||
.auto-refresh-indicator {
|
||||
background-color: var(--mat-menu-container-color, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
font-size: 0.75em;
|
||||
opacity: 0.55;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
.separator {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
gap: 12px;
|
||||
opacity: 0.5;
|
||||
|
||||
mat-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media only screen and (max-width: 480px) {
|
||||
.playlist-info {
|
||||
max-width: 140px;
|
||||
|
||||
.name {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .playlist-switcher-menu {
|
||||
min-width: 280px !important;
|
||||
}
|
||||
|
||||
.playlist-icon-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { MatRippleModule } from '@angular/material/core';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { filter } from 'rxjs';
|
||||
import { PortalStatus, PortalStatusService } from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-switcher',
|
||||
templateUrl: './playlist-switcher.component.html',
|
||||
styleUrls: ['./playlist-switcher.component.scss'],
|
||||
imports: [
|
||||
DatePipe,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
MatRippleModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class PlaylistSwitcherComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly portalStatusService = inject(PortalStatusService);
|
||||
|
||||
/** Current playlist title to display */
|
||||
readonly currentTitle = input.required<string>();
|
||||
/** Subtitle to display (e.g., "123 Channels" or "Xtream Code") */
|
||||
readonly subtitle = input<string>('');
|
||||
/** Emitted when a different playlist is selected */
|
||||
readonly playlistSelected = output<string>();
|
||||
|
||||
readonly menuTrigger = viewChild.required<MatMenuTrigger>('menuTrigger');
|
||||
readonly playlistMenu = viewChild.required<MatMenu>('playlistMenu');
|
||||
|
||||
/** Signal for tracking menu open state */
|
||||
readonly isMenuOpen = signal(false);
|
||||
|
||||
/** Search query for filtering playlists */
|
||||
readonly searchQuery = signal('');
|
||||
|
||||
/** All playlists from store */
|
||||
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
|
||||
|
||||
/** Filtered playlists based on search query */
|
||||
readonly filteredPlaylists = computed(() => {
|
||||
const query = this.searchQuery().toLowerCase().trim();
|
||||
const allPlaylists = this.playlists();
|
||||
if (!query) {
|
||||
return allPlaylists;
|
||||
}
|
||||
return allPlaylists.filter(
|
||||
(p) =>
|
||||
p.title?.toLowerCase().includes(query) ||
|
||||
p.filename?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
/** Current active playlist ID from route */
|
||||
readonly activePlaylistId = signal<string | null>(null);
|
||||
|
||||
/** Portal statuses for Xtream playlists */
|
||||
readonly portalStatuses = signal<Map<string, PortalStatus>>(new Map());
|
||||
|
||||
constructor() {
|
||||
// Extract playlist ID from current route
|
||||
this.updateActivePlaylistFromRoute(this.router.url);
|
||||
|
||||
// Listen for route changes
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
takeUntilDestroyed()
|
||||
)
|
||||
.subscribe((event: NavigationEnd) => {
|
||||
this.updateActivePlaylistFromRoute(event.urlAfterRedirects);
|
||||
});
|
||||
|
||||
// Check portal status for Xtream playlists when playlists load
|
||||
effect(() => {
|
||||
const allPlaylists = this.playlists();
|
||||
this.checkPortalStatuses(allPlaylists);
|
||||
});
|
||||
}
|
||||
|
||||
private updateActivePlaylistFromRoute(url: string) {
|
||||
// Match routes like /playlists/:id, /xtreams/:id, /stalker/:id
|
||||
const match = url.match(/\/(playlists|xtreams|stalker)\/([^\/\?]+)/);
|
||||
if (match) {
|
||||
this.activePlaylistId.set(match[2]);
|
||||
} else {
|
||||
this.activePlaylistId.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkPortalStatuses(playlists: PlaylistMeta[]) {
|
||||
const statusPromises = playlists
|
||||
.filter(playlist => playlist.serverUrl && playlist.username && playlist.password)
|
||||
.map(async (playlist) => {
|
||||
try {
|
||||
const status = await this.portalStatusService.checkPortalStatus(
|
||||
playlist.serverUrl,
|
||||
playlist.username,
|
||||
playlist.password
|
||||
);
|
||||
return { id: playlist._id, status };
|
||||
} catch {
|
||||
return { id: playlist._id, status: 'unavailable' as PortalStatus };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(statusPromises);
|
||||
const statusMap = new Map(results.map(r => [r.id, r.status]));
|
||||
this.portalStatuses.set(statusMap);
|
||||
}
|
||||
|
||||
onMenuOpened() {
|
||||
this.isMenuOpen.set(true);
|
||||
}
|
||||
|
||||
onMenuClosed() {
|
||||
this.isMenuOpen.set(false);
|
||||
this.searchQuery.set('');
|
||||
}
|
||||
|
||||
selectPlaylist(playlist: PlaylistMeta) {
|
||||
if (playlist.serverUrl) {
|
||||
this.router.navigate(['xtreams', playlist._id]);
|
||||
} else if (playlist.macAddress) {
|
||||
this.router.navigate(['stalker', playlist._id]);
|
||||
} else {
|
||||
this.router.navigate(['playlists', playlist._id]);
|
||||
}
|
||||
this.playlistSelected.emit(playlist._id);
|
||||
}
|
||||
|
||||
getPlaylistIcon(playlist: PlaylistMeta): string {
|
||||
if (playlist.macAddress) {
|
||||
return 'dashboard';
|
||||
}
|
||||
if (playlist.serverUrl) {
|
||||
return 'public';
|
||||
}
|
||||
if (playlist.url) {
|
||||
return 'cloud';
|
||||
}
|
||||
return 'folder';
|
||||
}
|
||||
|
||||
getPlaylistTypeLabel(playlist: PlaylistMeta): string {
|
||||
if (playlist.macAddress) {
|
||||
return 'Stalker Portal';
|
||||
}
|
||||
if (playlist.serverUrl) {
|
||||
return 'Xtream Code';
|
||||
}
|
||||
return `${playlist.count} channels`;
|
||||
}
|
||||
|
||||
getStatusClass(playlistId: string): string {
|
||||
const status = this.portalStatuses().get(playlistId);
|
||||
return this.portalStatusService.getStatusClass(status || 'unavailable');
|
||||
}
|
||||
|
||||
isSelected(playlist: PlaylistMeta): boolean {
|
||||
return this.activePlaylistId() === playlist._id;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,18 @@
|
||||
<div class="current-playlist">
|
||||
<div class="left">
|
||||
<button
|
||||
mat-icon-button
|
||||
[matTooltip]="'SETTINGS.BACK_TO_HOME' | translate"
|
||||
(click)="goBack()"
|
||||
>
|
||||
<mat-icon>arrow_back_ios</mat-icon>
|
||||
</button>
|
||||
<div class="playlist-info">
|
||||
<div class="name">
|
||||
@if (sidebarView === 'CHANNELS') {
|
||||
{{ playlistTitle$ | async }}
|
||||
} @else {
|
||||
{{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }}
|
||||
}
|
||||
</div>
|
||||
<div class="channels-count">
|
||||
{{
|
||||
sidebarView === 'CHANNELS'
|
||||
? channels()?.length +
|
||||
' ' +
|
||||
('HOME.PLAYLISTS.CHANNELS' | translate)
|
||||
: ('HOME.PLAYLISTS.MY_PLAYLISTS_SUBTITLE' | translate)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
mat-icon-button
|
||||
routerLink="/"
|
||||
[matTooltip]="'SETTINGS.BACK_TO_HOME' | translate"
|
||||
class="home-btn"
|
||||
>
|
||||
<mat-icon>home</mat-icon>
|
||||
</button>
|
||||
|
||||
@if (sidebarView === 'CHANNELS') {
|
||||
<button
|
||||
mat-icon-button
|
||||
routerLink="/"
|
||||
[matTooltip]="
|
||||
'CHANNELS.UPLOAD_OR_SELECT_OTHER_PLAYLIST' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>playlist_add</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (sidebarView === 'CHANNELS') {
|
||||
<app-channel-list-container [channelList]="channels()" />
|
||||
} @else {
|
||||
<app-recent-playlists
|
||||
class="recent-playlists"
|
||||
[sidebarMode]="true"
|
||||
(playlistClicked)="selectPlaylist()"
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="playlistTitle()"
|
||||
[subtitle]="subtitle()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<app-channel-list-container [channelList]="channels()" />
|
||||
@@ -2,45 +2,21 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
app-region: drag;
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
button {
|
||||
app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
|
||||
.name {
|
||||
font-size: 1.1em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.switcher {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channels-count {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.recent-playlists {
|
||||
height: calc(100vh - 60px);
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.playlist-info {
|
||||
.name {
|
||||
max-width: 105px;
|
||||
}
|
||||
.current-playlist {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ChannelActions } from 'm3u-state';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { selectPlaylistTitle } from 'm3u-state';
|
||||
import { Channel, SidebarView } from 'shared-interfaces';
|
||||
import { RecentPlaylistsComponent } from '../../recent-playlists/recent-playlists.component';
|
||||
import { Channel } from 'shared-interfaces';
|
||||
import { PlaylistSwitcherComponent } from '../../playlist-switcher/playlist-switcher.component';
|
||||
import { ChannelListContainerComponent } from './../../channel-list-container/channel-list-container.component';
|
||||
|
||||
@Component({
|
||||
@@ -17,12 +15,11 @@ import { ChannelListContainerComponent } from './../../channel-list-container/ch
|
||||
templateUrl: './sidebar.component.html',
|
||||
styleUrls: ['./sidebar.component.scss'],
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ChannelListContainerComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
RecentPlaylistsComponent,
|
||||
PlaylistSwitcherComponent,
|
||||
RouterLink,
|
||||
TranslatePipe,
|
||||
],
|
||||
@@ -30,22 +27,13 @@ import { ChannelListContainerComponent } from './../../channel-list-container/ch
|
||||
export class SidebarComponent {
|
||||
readonly channels = input<Channel[]>([]);
|
||||
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly playlistTitle$ = this.store.select(selectPlaylistTitle);
|
||||
sidebarView: SidebarView = 'CHANNELS';
|
||||
readonly playlistTitle = this.store.selectSignal(selectPlaylistTitle);
|
||||
|
||||
goBack() {
|
||||
if (this.sidebarView === 'PLAYLISTS') {
|
||||
this.store.dispatch(ChannelActions.resetActiveChannel());
|
||||
this.router.navigate(['/']);
|
||||
} else {
|
||||
this.sidebarView = 'PLAYLISTS';
|
||||
}
|
||||
}
|
||||
|
||||
selectPlaylist() {
|
||||
this.sidebarView = 'CHANNELS';
|
||||
}
|
||||
readonly subtitle = computed(() => {
|
||||
const count = this.channels()?.length ?? 0;
|
||||
return `${count} ${this.translate.instant('HOME.PLAYLISTS.CHANNELS')}`;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user