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/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/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 }}

+ + } + + + + 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..25c080b2f --- /dev/null +++ b/libs/ui/components/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -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(); + /** 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), + 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; + } +} diff --git a/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.html b/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.html index ee13d5ec0..e5126a32e 100644 --- a/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.html +++ b/libs/ui/components/src/lib/video-player/sidebar/sidebar.component.html @@ -1,50 +1,18 @@
-
- -
-
- @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')}`; + }); }