mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06: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>
|
||||||
|
|
||||||
<div class="type-filters" (click)="$event.stopPropagation()">
|
@if (showTypeFilters()) {
|
||||||
<button
|
<div class="type-filters" (click)="$event.stopPropagation()">
|
||||||
type="button"
|
@if (hasType('m3u')) {
|
||||||
class="type-filter-chip"
|
<button
|
||||||
[class.selected]="isTypeFilterSelected('m3u')"
|
type="button"
|
||||||
(click)="togglePlaylistTypeFilter('m3u', $event)"
|
class="type-filter-chip"
|
||||||
>
|
[class.selected]="isTypeFilterSelected('m3u')"
|
||||||
M3U
|
(click)="togglePlaylistTypeFilter('m3u', $event)"
|
||||||
</button>
|
>
|
||||||
<button
|
M3U
|
||||||
type="button"
|
</button>
|
||||||
class="type-filter-chip"
|
}
|
||||||
[class.selected]="isTypeFilterSelected('stalker')"
|
@if (hasType('stalker')) {
|
||||||
(click)="togglePlaylistTypeFilter('stalker', $event)"
|
<button
|
||||||
>
|
type="button"
|
||||||
Stalker
|
class="type-filter-chip"
|
||||||
</button>
|
[class.selected]="isTypeFilterSelected('stalker')"
|
||||||
<button
|
(click)="togglePlaylistTypeFilter('stalker', $event)"
|
||||||
type="button"
|
>
|
||||||
class="type-filter-chip"
|
Stalker
|
||||||
[class.selected]="isTypeFilterSelected('xtream')"
|
</button>
|
||||||
(click)="togglePlaylistTypeFilter('xtream', $event)"
|
}
|
||||||
>
|
@if (hasType('xtream')) {
|
||||||
Xtream
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</div>
|
class="type-filter-chip"
|
||||||
|
[class.selected]="isTypeFilterSelected('xtream')"
|
||||||
|
(click)="togglePlaylistTypeFilter('xtream', $event)"
|
||||||
|
>
|
||||||
|
Xtream
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="menu-content">
|
<div class="menu-content">
|
||||||
@for (playlist of filteredPlaylists(); track playlist._id) {
|
@for (playlist of filteredPlaylists(); track playlist._id) {
|
||||||
@@ -122,9 +130,9 @@
|
|||||||
{{ playlist.title || playlist.filename }}
|
{{ playlist.title || playlist.filename }}
|
||||||
</span>
|
</span>
|
||||||
<span class="playlist-meta">
|
<span class="playlist-meta">
|
||||||
{{ getPlaylistTypeLabel(playlist) }}
|
{{ getPlaylistMetaLabel(playlist) }}
|
||||||
@if (playlist.updateDate) {
|
@if (playlist.updateDate) {
|
||||||
<span class="separator">|</span>
|
<span class="separator">·</span>
|
||||||
{{
|
{{
|
||||||
playlist.updateDate
|
playlist.updateDate
|
||||||
| date
|
| date
|
||||||
@@ -135,6 +143,18 @@
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
} @empty {
|
} @empty {
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
@@ -144,17 +164,17 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showPlaylistInfo() || showAccountInfo()) {
|
@if (showAddPlaylist() || showAccountInfo()) {
|
||||||
<mat-divider />
|
<mat-divider />
|
||||||
<div class="context-actions-section">
|
<div class="context-actions-section">
|
||||||
@if (showPlaylistInfo()) {
|
@if (showAddPlaylist()) {
|
||||||
<button
|
<button
|
||||||
class="context-action-item"
|
class="context-action-item"
|
||||||
(click)="requestPlaylistInfo()"
|
(click)="requestAddPlaylist()"
|
||||||
>
|
>
|
||||||
<mat-icon>playlist_play</mat-icon>
|
<mat-icon>add</mat-icon>
|
||||||
<span>{{
|
<span>{{
|
||||||
'WORKSPACE.SHELL.PLAYLIST_INFO' | translate
|
'WORKSPACE.SHELL.ADD_PLAYLIST' | translate
|
||||||
}}</span>
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -172,3 +192,29 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</mat-menu>
|
</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 {
|
::ng-deep .playlist-switcher-menu.mat-mdc-menu-panel {
|
||||||
--mat-menu-container-color: var(--mat-sys-surface-container-high);
|
--mat-menu-container-color: var(--mat-sys-surface-container-high);
|
||||||
width: min(
|
width: min(
|
||||||
var(--playlist-switcher-overlay-width, 320px),
|
var(--playlist-switcher-overlay-width, 360px),
|
||||||
calc(100vw - 24px)
|
calc(100vw - 24px)
|
||||||
) !important;
|
) !important;
|
||||||
min-width: min(
|
min-width: min(
|
||||||
var(--playlist-switcher-overlay-width, 320px),
|
var(--playlist-switcher-overlay-width, 360px),
|
||||||
calc(100vw - 24px)
|
calc(100vw - 24px)
|
||||||
) !important;
|
) !important;
|
||||||
max-width: min(
|
max-width: min(
|
||||||
var(--playlist-switcher-overlay-width, 320px),
|
var(--playlist-switcher-overlay-width, 360px),
|
||||||
calc(100vw - 24px)
|
calc(100vw - 24px)
|
||||||
) !important;
|
) !important;
|
||||||
margin-top: -1px !important;
|
margin-top: -1px !important;
|
||||||
@@ -276,12 +276,55 @@
|
|||||||
.playlist-item.selected {
|
.playlist-item.selected {
|
||||||
background: var(--app-selection-surface, rgba(139, 92, 246, 0.1));
|
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-title,
|
||||||
.playlist-meta {
|
.playlist-meta {
|
||||||
color: var(--app-selection-color, #a78bfa);
|
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) {
|
.playlist-item:hover:not(.selected) {
|
||||||
background: color-mix(
|
background: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
|
|||||||
@@ -14,16 +14,29 @@ import { toSignal } from '@angular/core/rxjs-interop';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { MatIconButton } from '@angular/material/button';
|
import { MatIconButton } from '@angular/material/button';
|
||||||
import { MatRippleModule } from '@angular/material/core';
|
import { MatRippleModule } from '@angular/material/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { MatDivider } from '@angular/material/divider';
|
import { MatDivider } from '@angular/material/divider';
|
||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
|
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 { normalizeDateLocale } from '@iptvnator/pipes';
|
||||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
import { DialogService } from 'components';
|
||||||
import { PortalStatus, PortalStatusService } from 'services';
|
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 { PlaylistMeta } from 'shared-interfaces';
|
||||||
import { startWith } from 'rxjs';
|
import { startWith } from 'rxjs';
|
||||||
|
import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component';
|
||||||
|
|
||||||
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';
|
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';
|
||||||
|
|
||||||
@@ -57,6 +70,12 @@ export class PlaylistSwitcherComponent {
|
|||||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||||
private readonly portalStatusService = inject(PortalStatusService);
|
private readonly portalStatusService = inject(PortalStatusService);
|
||||||
private readonly translate = inject(TranslateService);
|
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 focusSearchTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
private readonly languageTick = toSignal(
|
private readonly languageTick = toSignal(
|
||||||
this.translate.onLangChange.pipe(startWith(null)),
|
this.translate.onLangChange.pipe(startWith(null)),
|
||||||
@@ -67,9 +86,11 @@ export class PlaylistSwitcherComponent {
|
|||||||
readonly subtitle = input<string>('');
|
readonly subtitle = input<string>('');
|
||||||
readonly showPlaylistInfo = input(false);
|
readonly showPlaylistInfo = input(false);
|
||||||
readonly showAccountInfo = input(false);
|
readonly showAccountInfo = input(false);
|
||||||
|
readonly showAddPlaylist = input(false);
|
||||||
readonly playlistSelected = output<string>();
|
readonly playlistSelected = output<string>();
|
||||||
readonly playlistInfoRequested = output<void>();
|
readonly playlistInfoRequested = output<void>();
|
||||||
readonly accountInfoRequested = output<void>();
|
readonly accountInfoRequested = output<void>();
|
||||||
|
readonly addPlaylistRequested = output<void>();
|
||||||
|
|
||||||
readonly menuTrigger = viewChild.required<MatMenuTrigger>('menuTrigger');
|
readonly menuTrigger = viewChild.required<MatMenuTrigger>('menuTrigger');
|
||||||
readonly playlistMenu = viewChild.required<MatMenu>('playlistMenu');
|
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 portalStatuses = signal<Map<string, PortalStatus>>(new Map());
|
||||||
readonly currentLocale = computed(() => {
|
readonly currentLocale = computed(() => {
|
||||||
this.languageTick();
|
this.languageTick();
|
||||||
@@ -192,6 +224,69 @@ export class PlaylistSwitcherComponent {
|
|||||||
this.accountInfoRequested.emit();
|
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 {
|
getPlaylistIcon(playlist: PlaylistMeta): string {
|
||||||
if (playlist.macAddress) {
|
if (playlist.macAddress) {
|
||||||
return 'dashboard';
|
return 'dashboard';
|
||||||
@@ -215,6 +310,27 @@ export class PlaylistSwitcherComponent {
|
|||||||
return `${playlist.count} channels`;
|
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 {
|
getStatusClass(playlistId: string): string {
|
||||||
const status = this.portalStatuses().get(playlistId);
|
const status = this.portalStatuses().get(playlistId);
|
||||||
return this.portalStatusService.getStatusClass(status || 'unavailable');
|
return this.portalStatusService.getStatusClass(status || 'unavailable');
|
||||||
@@ -268,9 +384,10 @@ export class PlaylistSwitcherComponent {
|
|||||||
const triggerWidth = Math.round(
|
const triggerWidth = Math.round(
|
||||||
this.triggerElement().nativeElement.getBoundingClientRect().width
|
this.triggerElement().nativeElement.getBoundingClientRect().width
|
||||||
);
|
);
|
||||||
|
const overlayWidth = Math.max(triggerWidth, 360);
|
||||||
this.document.documentElement.style.setProperty(
|
this.document.documentElement.style.setProperty(
|
||||||
'--playlist-switcher-overlay-width',
|
'--playlist-switcher-overlay-width',
|
||||||
`${triggerWidth}px`
|
`${overlayWidth}px`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -5,8 +5,10 @@
|
|||||||
[subtitle]="playlistSubtitle()"
|
[subtitle]="playlistSubtitle()"
|
||||||
[showPlaylistInfo]="canOpenPlaylistInfo()"
|
[showPlaylistInfo]="canOpenPlaylistInfo()"
|
||||||
[showAccountInfo]="canOpenAccountInfo()"
|
[showAccountInfo]="canOpenAccountInfo()"
|
||||||
|
[showAddPlaylist]="true"
|
||||||
(playlistInfoRequested)="onPlaylistInfoRequested()"
|
(playlistInfoRequested)="onPlaylistInfoRequested()"
|
||||||
(accountInfoRequested)="onAccountInfoRequested()"
|
(accountInfoRequested)="onAccountInfoRequested()"
|
||||||
|
(addPlaylistRequested)="onAddPlaylistRequested()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label class="search-field">
|
<label class="search-field">
|
||||||
|
|||||||
Reference in new issue
Block a user