feat: enhance playlist switcher with dynamic type filters and add playlist functionality

Entire-Checkpoint: 50668c5f4b12
This commit is contained in:
4gray committed 2026-04-12 19:35:41 +02:00
1 parent bd76d8456d
commit 156c12c51a
4 files changed
+247 -39

No files matched your search

@@ -56,32 +56,40 @@
}
</div>
<div class="type-filters" (click)="$event.stopPropagation()">
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('m3u')"
(click)="togglePlaylistTypeFilter('m3u', $event)"
>
M3U
</button>
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('stalker')"
(click)="togglePlaylistTypeFilter('stalker', $event)"
>
Stalker
</button>
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('xtream')"
(click)="togglePlaylistTypeFilter('xtream', $event)"
>
Xtream
</button>
</div>
@if (showTypeFilters()) {
<div class="type-filters" (click)="$event.stopPropagation()">
@if (hasType('m3u')) {
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('m3u')"
(click)="togglePlaylistTypeFilter('m3u', $event)"
>
M3U
</button>
}
@if (hasType('stalker')) {
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('stalker')"
(click)="togglePlaylistTypeFilter('stalker', $event)"
>
Stalker
</button>
}
@if (hasType('xtream')) {
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('xtream')"
(click)="togglePlaylistTypeFilter('xtream', $event)"
>
Xtream
</button>
}
</div>
}
<div class="menu-content">
@for (playlist of filteredPlaylists(); track playlist._id) {
@@ -122,9 +130,9 @@
{{ playlist.title || playlist.filename }}
</span>
<span class="playlist-meta">
{{ getPlaylistTypeLabel(playlist) }}
{{ getPlaylistMetaLabel(playlist) }}
@if (playlist.updateDate) {
<span class="separator">|</span>
<span class="separator">·</span>
{{
playlist.updateDate
| date
@@ -135,6 +143,18 @@
}
</span>
</div>
<button
type="button"
class="item-action-trigger"
mat-icon-button
[matMenuTriggerFor]="itemMenu"
[matMenuTriggerData]="{ playlist: playlist }"
(click)="$event.stopPropagation()"
aria-label="Playlist actions"
>
<mat-icon>more_vert</mat-icon>
</button>
</div>
} @empty {
<div class="empty-state">
@@ -144,17 +164,17 @@
}
</div>
@if (showPlaylistInfo() || showAccountInfo()) {
@if (showAddPlaylist() || showAccountInfo()) {
<mat-divider />
<div class="context-actions-section">
@if (showPlaylistInfo()) {
@if (showAddPlaylist()) {
<button
class="context-action-item"
(click)="requestPlaylistInfo()"
(click)="requestAddPlaylist()"
>
<mat-icon>playlist_play</mat-icon>
<mat-icon>add</mat-icon>
<span>{{
'WORKSPACE.SHELL.PLAYLIST_INFO' | translate
'WORKSPACE.SHELL.ADD_PLAYLIST' | translate
}}</span>
</button>
}
@@ -172,3 +192,29 @@
</div>
}
</mat-menu>
<mat-menu #itemMenu="matMenu" class="playlist-item-action-menu">
<ng-template matMenuContent let-playlist="playlist">
<button mat-menu-item (click)="openPlaylistInfoFor(playlist, $event)">
<mat-icon>info</mat-icon>
<span>{{ 'WORKSPACE.SHELL.PLAYLIST_INFO' | translate }}</span>
</button>
@if (canRefreshPlaylist(playlist)) {
<button mat-menu-item (click)="refreshPlaylistFor(playlist, $event)">
<mat-icon>sync</mat-icon>
<span>{{
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
}}</span>
</button>
}
<mat-divider />
<button
mat-menu-item
class="danger"
(click)="removePlaylistFor(playlist, $event)"
>
<mat-icon>delete</mat-icon>
<span>{{ 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE' | translate }}</span>
</button>
</ng-template>
</mat-menu>
@@ -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,
@@ -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<typeof setTimeout> | null = null;
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -67,9 +86,11 @@ export class PlaylistSwitcherComponent {
readonly subtitle = input<string>('');
readonly showPlaylistInfo = input(false);
readonly showAccountInfo = input(false);
readonly showAddPlaylist = input(false);
readonly playlistSelected = output<string>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly menuTrigger = viewChild.required<MatMenuTrigger>('menuTrigger');
readonly playlistMenu = viewChild.required<MatMenu>('playlistMenu');
@@ -123,6 +144,17 @@ export class PlaylistSwitcherComponent {
);
});
readonly availablePlaylistTypes = computed(() => {
const types = new Set<PlaylistFilterType>();
for (const playlist of this.playlists()) {
types.add(this.getPlaylistFilterType(playlist));
}
return types;
});
readonly showTypeFilters = computed(
() => this.availablePlaylistTypes().size > 1
);
readonly portalStatuses = signal<Map<string, PortalStatus>>(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<void> {
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`
);
}
@@ -5,8 +5,10 @@
[subtitle]="playlistSubtitle()"
[showPlaylistInfo]="canOpenPlaylistInfo()"
[showAccountInfo]="canOpenAccountInfo()"
[showAddPlaylist]="true"
(playlistInfoRequested)="onPlaylistInfoRequested()"
(accountInfoRequested)="onAccountInfoRequested()"
(addPlaylistRequested)="onAddPlaylistRequested()"
/>
<label class="search-field">