mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat: enhance playlist switcher with dynamic type filters and add playlist functionality
Entire-Checkpoint: 50668c5f4b12
This commit is contained in:
1 parent
bd76d8456d
commit
156c12c51a
4 files changed
+247
-39
No files matched your search
+79
-33
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -5,8 +5,10 @@
|
||||
[subtitle]="playlistSubtitle()"
|
||||
[showPlaylistInfo]="canOpenPlaylistInfo()"
|
||||
[showAccountInfo]="canOpenAccountInfo()"
|
||||
[showAddPlaylist]="true"
|
||||
(playlistInfoRequested)="onPlaylistInfoRequested()"
|
||||
(accountInfoRequested)="onAccountInfoRequested()"
|
||||
(addPlaylistRequested)="onAddPlaylistRequested()"
|
||||
/>
|
||||
|
||||
<label class="search-field">
|
||||
|
||||
Reference in new issue
Block a user