From b9b7fc8a637e519c77509f4c5d8f4b181c077893 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 10 Jan 2026 15:18:57 +0100 Subject: [PATCH 1/3] feat: add playlist switcher component UI and styles --- libs/ui/components/src/index.ts | 1 + .../playlist-switcher.component.html | 98 +++++ .../playlist-switcher.component.scss | 403 ++++++++++++++++++ .../playlist-switcher.component.ts | 185 ++++++++ 4 files changed, 687 insertions(+) create mode 100644 libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.html create mode 100644 libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.scss create mode 100644 libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.ts diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 1ba4a1f71..6426219e9 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -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'; diff --git a/libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.html b/libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.html new file mode 100644 index 000000000..faa72cfc3 --- /dev/null +++ b/libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.html @@ -0,0 +1,98 @@ +
+
+
{{ currentTitle() }}
+
{{ subtitle() }}
+
+ + expand_more + +
+ + + + +
+ search + + @if (searchQuery()) { + + } +
+ + +
diff --git a/libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.scss b/libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.scss new file mode 100644 index 000000000..78529df81 --- /dev/null +++ b/libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.scss @@ -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; + } +} 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 new file mode 100644 index 000000000..b1a523f55 --- /dev/null +++ b/libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -0,0 +1,185 @@ +import { DatePipe } from '@angular/common'; +import { + Component, + computed, + effect, + inject, + input, + output, + signal, + viewChild, +} from '@angular/core'; +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(); + /** Subtitle to display (e.g., "123 Channels" or "Xtream Code") */ + readonly subtitle = input(''); + /** Emitted when a different playlist is selected */ + readonly playlistSelected = output(); + + readonly menuTrigger = viewChild.required('menuTrigger'); + readonly playlistMenu = viewChild.required('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(null); + + /** Portal statuses for Xtream playlists */ + readonly portalStatuses = signal>(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)) + .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 statusMap = new Map(); + for (const playlist of playlists) { + if (playlist.serverUrl && playlist.username && playlist.password) { + try { + const status = await this.portalStatusService.checkPortalStatus( + playlist.serverUrl, + playlist.username, + playlist.password + ); + statusMap.set(playlist._id, status); + } catch { + statusMap.set(playlist._id, 'unavailable'); + } + } + } + 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; + } +} From 6b94fcbdd6c9707b807c7c056fe27d40bb2f0a3d Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 10 Jan 2026 15:19:37 +0100 Subject: [PATCH 2/3] feat(ui): use playlist-switcher in all modules --- .../favorites-layout.component.html | 9 +-- .../favorites-layout.component.ts | 5 +- .../recently-viewed.component.html | 4 +- .../recently-viewed.component.ts | 2 + .../stalker-favorites.component.html | 2 + .../stalker-favorites.component.ts | 2 + .../stalker-main-container.component.html | 9 +-- .../stalker-main-container.component.ts | 6 +- .../favorites/favorites.component.html | 2 + .../favorites/favorites.component.ts | 1 + .../live-stream-layout.component.html | 9 +-- .../live-stream-layout.component.ts | 3 + apps/web/src/app/xtream-tauri/sidebar.scss | 5 ++ .../xtream-main-container.component.html | 9 +-- .../xtream-main-container.component.ts | 9 +-- .../sidebar/sidebar.component.html | 62 +++++-------------- .../sidebar/sidebar.component.scss | 36 ++--------- .../video-player/sidebar/sidebar.component.ts | 36 ++++------- 18 files changed, 86 insertions(+), 125 deletions(-) diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html index a12776367..53630e466 100644 --- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html +++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html @@ -1,9 +1,10 @@ @if (isItvCategorySelected) { diff --git a/apps/web/src/app/stalker/stalker-main-container.component.ts b/apps/web/src/app/stalker/stalker-main-container.component.ts index 5396e8223..bfd934a9d 100644 --- a/apps/web/src/app/stalker/stalker-main-container.component.ts +++ b/apps/web/src/app/stalker/stalker-main-container.component.ts @@ -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; diff --git a/apps/web/src/app/xtream-tauri/favorites/favorites.component.html b/apps/web/src/app/xtream-tauri/favorites/favorites.component.html index 2a130d92b..384cedcfa 100644 --- a/apps/web/src/app/xtream-tauri/favorites/favorites.component.html +++ b/apps/web/src/app/xtream-tauri/favorites/favorites.component.html @@ -1,6 +1,8 @@ ([]); + readonly currentPlaylist = this.xtreamStore.currentPlaylist; readonly series = signal([]); readonly movies = signal([]); diff --git a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html index cb34a6c50..58873ecc3 100644 --- a/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html +++ b/apps/web/src/app/xtream-tauri/live-stream-layout/live-stream-layout.component.html @@ -9,10 +9,11 @@

{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}

} @else { - -

{{ 'PORTALS.ALL_CATEGORIES' | translate }}

+ -

{{ 'PORTALS.ALL_CATEGORIES' | translate }}

+ -
-
- @if (sidebarView === 'CHANNELS') { - {{ playlistTitle$ | async }} - } @else { - {{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }} - } -
-
- {{ - sidebarView === 'CHANNELS' - ? channels()?.length + - ' ' + - ('HOME.PLAYLISTS.CHANNELS' | translate) - : ('HOME.PLAYLISTS.MY_PLAYLISTS_SUBTITLE' | translate) - }} -
-
- + - @if (sidebarView === 'CHANNELS') { - - } - -@if (sidebarView === 'CHANNELS') { - -} @else { - -} + + + diff --git a/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.scss b/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.scss index 106079930..976c66665 100644 --- a/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.scss +++ b/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.scss @@ -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; } } diff --git a/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.ts index 6fca98c18..973e08aba 100644 --- a/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.ts @@ -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([]); - 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')}`; + }); } From 971ad708939b957f7c4f794108a7052dcd699a74 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 10 Jan 2026 16:53:05 +0100 Subject: [PATCH 3/3] 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 {