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; + } +}