feat: add playlist switcher component UI and styles

This commit is contained in:
4gray committed 2026-01-10 15:18:57 +01:00
1 parent 2c5fc7e1ab
commit b9b7fc8a63
4 files changed
+687

No files matched your search

+1
View File
@@ -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,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<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))
.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<string, PortalStatus>();
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;
}
}