From 971ad708939b957f7c4f794108a7052dcd699a74 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 10 Jan 2026 16:53:05 +0100 Subject: [PATCH] refactor: add lifecycle-safe router unsubscribe and optimize portal checks --- .../stalker-shell/stalker-shell.component.ts | 41 +++-- .../xtream-shell/xtream-shell.component.ts | 36 +++-- apps/web/src/app/xtream-tauri/xtream-state.ts | 1 + apps/web/src/app/xtream-tauri/xtream.store.ts | 141 ++++++++++-------- .../playlist-switcher.component.ts | 24 +-- 5 files changed, 151 insertions(+), 92 deletions(-) diff --git a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts index ca990df87..8a186bc58 100644 --- a/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts +++ b/apps/web/src/app/stalker/stalker-shell/stalker-shell.component.ts @@ -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()); }); diff --git a/apps/web/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts b/apps/web/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts index dd0448586..4a6f5f309 100644 --- a/apps/web/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts +++ b/apps/web/src/app/xtream-tauri/xtream-shell/xtream-shell.component.ts @@ -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], { diff --git a/apps/web/src/app/xtream-tauri/xtream-state.ts b/apps/web/src/app/xtream-tauri/xtream-state.ts index d180e6305..4d380d680 100644 --- a/apps/web/src/app/xtream-tauri/xtream-state.ts +++ b/apps/web/src/app/xtream-tauri/xtream-state.ts @@ -44,4 +44,5 @@ export interface XtreamState { portalStatus: PortalStatusType; globalSearchResults: GlobalSearchResult[]; streamUrl: string; + playlistId: string | null; } diff --git a/apps/web/src/app/xtream-tauri/xtream.store.ts b/apps/web/src/app/xtream-tauri/xtream.store.ts index eb7970754..327eaf98c 100644 --- a/apps/web/src/app/xtream-tauri/xtream.store.ts +++ b/apps/web/src/app/xtream-tauri/xtream.store.ts @@ -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( - 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( 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 { diff --git a/libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.ts index b1a523f55..25c080b2f 100644 --- a/libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -9,6 +9,7 @@ import { 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'; @@ -90,7 +91,10 @@ export class PlaylistSwitcherComponent { // Listen for route changes this.router.events - .pipe(filter((event) => event instanceof NavigationEnd)) + .pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed() + ) .subscribe((event: NavigationEnd) => { this.updateActivePlaylistFromRoute(event.urlAfterRedirects); }); @@ -113,21 +117,23 @@ export class PlaylistSwitcherComponent { } private async checkPortalStatuses(playlists: PlaylistMeta[]) { - const statusMap = new Map(); - for (const playlist of playlists) { - if (playlist.serverUrl && playlist.username && playlist.password) { + 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 ); - statusMap.set(playlist._id, status); + return { id: playlist._id, status }; } catch { - statusMap.set(playlist._id, 'unavailable'); + 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); } @@ -147,8 +153,8 @@ export class PlaylistSwitcherComponent { this.router.navigate(['stalker', playlist._id]); } else { this.router.navigate(['playlists', playlist._id]); - this.playlistSelected.emit(playlist._id); } + this.playlistSelected.emit(playlist._id); } getPlaylistIcon(playlist: PlaylistMeta): string {