From 156c12c51a01f548833f6d8c26f340cb3e0607f2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 12 Apr 2026 19:35:41 +0200 Subject: [PATCH] feat: enhance playlist switcher with dynamic type filters and add playlist functionality Entire-Checkpoint: 50668c5f4b12 --- .../playlist-switcher.component.html | 112 +++++++++++----- .../playlist-switcher.component.scss | 49 ++++++- .../playlist-switcher.component.ts | 123 +++++++++++++++++- .../workspace-shell-header.component.html | 2 + 4 files changed, 247 insertions(+), 39 deletions(-) diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html index a50c6a083..c1e4f79c9 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.html @@ -56,32 +56,40 @@ } -
- - - -
+ @if (showTypeFilters()) { +
+ @if (hasType('m3u')) { + + } + @if (hasType('stalker')) { + + } + @if (hasType('xtream')) { + + } +
+ } + + } @empty {
@@ -144,17 +164,17 @@ }
- @if (showPlaylistInfo() || showAccountInfo()) { + @if (showAddPlaylist() || showAccountInfo()) {
- @if (showPlaylistInfo()) { + @if (showAddPlaylist()) { } @@ -172,3 +192,29 @@
} + + + + + @if (canRefreshPlaylist(playlist)) { + + } + + + + diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss index a16f2d061..9b9ed1119 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.scss @@ -103,15 +103,15 @@ ::ng-deep .playlist-switcher-menu.mat-mdc-menu-panel { --mat-menu-container-color: var(--mat-sys-surface-container-high); width: min( - var(--playlist-switcher-overlay-width, 320px), + var(--playlist-switcher-overlay-width, 360px), calc(100vw - 24px) ) !important; min-width: min( - var(--playlist-switcher-overlay-width, 320px), + var(--playlist-switcher-overlay-width, 360px), calc(100vw - 24px) ) !important; max-width: min( - var(--playlist-switcher-overlay-width, 320px), + var(--playlist-switcher-overlay-width, 360px), calc(100vw - 24px) ) !important; margin-top: -1px !important; @@ -276,12 +276,55 @@ .playlist-item.selected { background: var(--app-selection-surface, rgba(139, 92, 246, 0.1)); + &::before { + content: ''; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 3px; + border-radius: 0 3px 3px 0; + background: var(--app-selection-color, #a78bfa); + } + .playlist-title, .playlist-meta { color: var(--app-selection-color, #a78bfa); } } +.item-action-trigger { + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + width: 32px; + height: 32px; + line-height: 32px; + flex-shrink: 0; + color: var(--mat-sys-on-surface-variant);padding: 0; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.playlist-item:hover .item-action-trigger, +.playlist-item.selected .item-action-trigger, +.item-action-trigger:focus-visible, +.item-action-trigger[aria-expanded='true'] { + opacity: 1; + pointer-events: auto; +} + +::ng-deep .playlist-item-action-menu { + .danger, + .danger mat-icon { + color: var(--mat-sys-error, #f44336); + } +} + .playlist-item:hover:not(.selected) { background: color-mix( in srgb, diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index bf279a7ee..9d106ceb4 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -14,16 +14,29 @@ import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; import { MatDivider } from '@angular/material/divider'; import { MatIcon } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; import { normalizeDateLocale } from '@iptvnator/pipes'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; -import { PortalStatus, PortalStatusService } from 'services'; +import { DialogService } from 'components'; +import { PlaylistActions } from 'm3u-state'; +import { + PlaylistContextFacade, + PlaylistRefreshActionService, +} from '@iptvnator/playlist/shared/util'; +import { + DatabaseService, + PortalStatus, + PortalStatusService, +} from 'services'; import { PlaylistMeta } from 'shared-interfaces'; import { startWith } from 'rxjs'; +import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component'; type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream'; @@ -57,6 +70,12 @@ export class PlaylistSwitcherComponent { private readonly playlistContext = inject(PlaylistContextFacade); private readonly portalStatusService = inject(PortalStatusService); private readonly translate = inject(TranslateService); + private readonly refreshAction = inject(PlaylistRefreshActionService); + private readonly dialog = inject(MatDialog); + private readonly dialogService = inject(DialogService); + private readonly databaseService = inject(DatabaseService); + private readonly snackBar = inject(MatSnackBar); + private readonly store = inject(Store); private focusSearchTimeoutId: ReturnType | null = null; private readonly languageTick = toSignal( this.translate.onLangChange.pipe(startWith(null)), @@ -67,9 +86,11 @@ export class PlaylistSwitcherComponent { readonly subtitle = input(''); readonly showPlaylistInfo = input(false); readonly showAccountInfo = input(false); + readonly showAddPlaylist = input(false); readonly playlistSelected = output(); readonly playlistInfoRequested = output(); readonly accountInfoRequested = output(); + readonly addPlaylistRequested = output(); readonly menuTrigger = viewChild.required('menuTrigger'); readonly playlistMenu = viewChild.required('playlistMenu'); @@ -123,6 +144,17 @@ export class PlaylistSwitcherComponent { ); }); + readonly availablePlaylistTypes = computed(() => { + const types = new Set(); + for (const playlist of this.playlists()) { + types.add(this.getPlaylistFilterType(playlist)); + } + return types; + }); + readonly showTypeFilters = computed( + () => this.availablePlaylistTypes().size > 1 + ); + readonly portalStatuses = signal>(new Map()); readonly currentLocale = computed(() => { this.languageTick(); @@ -192,6 +224,69 @@ export class PlaylistSwitcherComponent { this.accountInfoRequested.emit(); } + requestAddPlaylist(): void { + this.menuTrigger().closeMenu(); + this.addPlaylistRequested.emit(); + } + + hasType(type: PlaylistFilterType): boolean { + return this.availablePlaylistTypes().has(type); + } + + canRefreshPlaylist(playlist: PlaylistMeta): boolean { + return this.refreshAction.canRefresh(playlist); + } + + openPlaylistInfoFor(playlist: PlaylistMeta, event?: Event): void { + event?.stopPropagation(); + this.menuTrigger().closeMenu(); + this.dialog.open(PlaylistInfoComponent, { data: playlist }); + } + + refreshPlaylistFor(playlist: PlaylistMeta, event?: Event): void { + event?.stopPropagation(); + this.menuTrigger().closeMenu(); + this.refreshAction.refresh(playlist); + } + + removePlaylistFor(playlist: PlaylistMeta, event?: Event): void { + event?.stopPropagation(); + this.menuTrigger().closeMenu(); + this.dialogService.openConfirmDialog({ + title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'), + message: this.translate.instant( + 'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE' + ), + onConfirm: () => this.removePlaylistConfirmed(playlist), + }); + } + + private async removePlaylistConfirmed( + playlist: PlaylistMeta + ): Promise { + const operationId = playlist.serverUrl + ? this.databaseService.createOperationId('playlist-delete') + : undefined; + + const deleted = await this.databaseService.deletePlaylist( + playlist._id, + operationId ? { operationId } : undefined + ); + + if (!deleted) { + return; + } + + this.store.dispatch( + PlaylistActions.removePlaylist({ playlistId: playlist._id }) + ); + this.snackBar.open( + this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'), + undefined, + { duration: 2000 } + ); + } + getPlaylistIcon(playlist: PlaylistMeta): string { if (playlist.macAddress) { return 'dashboard'; @@ -215,6 +310,27 @@ export class PlaylistSwitcherComponent { return `${playlist.count} channels`; } + getPlaylistMetaLabel(playlist: PlaylistMeta): string { + const count = playlist.count ?? 0; + const channelsLabel = this.translate.instant( + 'HOME.PLAYLISTS.CHANNELS_COUNT', + { count } + ); + const fallback = `${count} channels`; + const countLabel = + channelsLabel && channelsLabel !== 'HOME.PLAYLISTS.CHANNELS_COUNT' + ? channelsLabel + : fallback; + + if (playlist.macAddress) { + return `Stalker · ${countLabel}`; + } + if (playlist.serverUrl) { + return `Xtream · ${countLabel}`; + } + return countLabel; + } + getStatusClass(playlistId: string): string { const status = this.portalStatuses().get(playlistId); return this.portalStatusService.getStatusClass(status || 'unavailable'); @@ -268,9 +384,10 @@ export class PlaylistSwitcherComponent { const triggerWidth = Math.round( this.triggerElement().nativeElement.getBoundingClientRect().width ); + const overlayWidth = Math.max(triggerWidth, 360); this.document.documentElement.style.setProperty( '--playlist-switcher-overlay-width', - `${triggerWidth}px` + `${overlayWidth}px` ); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index f0192391c..65ac9f1cf 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -5,8 +5,10 @@ [subtitle]="playlistSubtitle()" [showPlaylistInfo]="canOpenPlaylistInfo()" [showAccountInfo]="canOpenAccountInfo()" + [showAddPlaylist]="true" (playlistInfoRequested)="onPlaylistInfoRequested()" (accountInfoRequested)="onAccountInfoRequested()" + (addPlaylistRequested)="onAddPlaylistRequested()" />