mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
feat: enhance playlist item component with busy state management and UI feedback
This commit is contained in:
1 parent
5f6ae5c743
commit
d2f3044ffa
7 files changed
+780
-158
No files matched your search
+69
-5
@@ -1,9 +1,10 @@
|
||||
<mat-list-item
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="!isDraggable()"
|
||||
[cdkDragDisabled]="!isDraggable() || isBusy()"
|
||||
cdkDragPreviewContainer="parent"
|
||||
[class.selected]="isSelected()"
|
||||
(click)="playlistClicked.emit(item._id)"
|
||||
[class.is-busy]="isBusy()"
|
||||
(click)="onPlaylistClick()"
|
||||
>
|
||||
@if (isDraggable()) {
|
||||
<mat-icon cdkDragHandle class="drag-icon" matListItemIcon
|
||||
@@ -106,9 +107,44 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (isBusy() && busyMessage()) {
|
||||
<div matListItemLine class="busy-state">
|
||||
<span class="busy-state__message">{{ busyMessage() }}</span>
|
||||
@if (busyProgress() !== null) {
|
||||
<span class="busy-state__value">{{ busyProgress() }}%</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (isBusy() && busyMessage() && busyProgress() !== null) {
|
||||
<div matListItemLine class="busy-state__bar">
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="busyProgress() ?? 0"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@if (isBusy() && busyMessage() && busyProgress() === null) {
|
||||
<div matListItemLine class="busy-state__bar">
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showActions()) {
|
||||
<span matListItemMeta class="action-buttons">
|
||||
@if (canCancelBusyAction()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
class="cancel-btn"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
cancelBusyActionClicked.emit()
|
||||
"
|
||||
[matTooltip]="'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (item.url || item.filePath) {
|
||||
<button
|
||||
mat-icon-button
|
||||
@@ -117,9 +153,18 @@
|
||||
(click)="
|
||||
$event.stopPropagation(); refreshClicked.emit(item)
|
||||
"
|
||||
[disabled]="isBusy()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
@if (isRefreshing()) {
|
||||
<mat-spinner
|
||||
class="action-spinner"
|
||||
diameter="18"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>sync</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (item.serverUrl && isElectron) {
|
||||
@@ -130,9 +175,18 @@
|
||||
(click)="
|
||||
$event.stopPropagation(); refreshClicked.emit(item)
|
||||
"
|
||||
[disabled]="isBusy()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REFRESH_XTREAM' | translate"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
@if (isRefreshing()) {
|
||||
<mat-spinner
|
||||
class="action-spinner"
|
||||
diameter="18"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>sync</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -143,6 +197,7 @@
|
||||
(click)="
|
||||
$event.stopPropagation(); editPlaylistClicked.emit(item)
|
||||
"
|
||||
[disabled]="isBusy()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
|
||||
>
|
||||
<mat-icon>edit</mat-icon>
|
||||
@@ -153,9 +208,18 @@
|
||||
color="accent"
|
||||
class="delete-btn"
|
||||
(click)="$event.stopPropagation(); removeClicked.emit(item._id)"
|
||||
[disabled]="isBusy()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
@if (isDeleting()) {
|
||||
<mat-spinner
|
||||
class="action-spinner"
|
||||
diameter="18"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>delete</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
|
||||
+37
@@ -38,6 +38,10 @@ mat-list-item {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-busy {
|
||||
cursor: progress;
|
||||
}
|
||||
}
|
||||
|
||||
// Light theme hover
|
||||
@@ -57,6 +61,30 @@ mat-list-item {
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
|
||||
.busy-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 11px !important;
|
||||
color: var(--app-selection-border, #8aa4c8);
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.busy-state__message {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.busy-state__value {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.busy-state__bar {
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.cdk-drag-preview {
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
@@ -123,9 +151,18 @@ mat-list-item {
|
||||
opacity: 0.85;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-spinner {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// Light theme action buttons
|
||||
@media (prefers-color-scheme: light) {
|
||||
.action-buttons button:hover {
|
||||
|
||||
+54
-2
@@ -4,7 +4,7 @@ import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { MockModule } from 'ng-mocks';
|
||||
import { DataService } from 'services';
|
||||
import { PortalStatusService } from 'services';
|
||||
import { PlaylistItemComponent } from './playlist-item.component';
|
||||
|
||||
describe('PlaylistItemComponent', () => {
|
||||
@@ -20,7 +20,18 @@ describe('PlaylistItemComponent', () => {
|
||||
MockModule(MatTooltipModule),
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [DataService],
|
||||
providers: [
|
||||
{
|
||||
provide: PortalStatusService,
|
||||
useValue: {
|
||||
checkPortalStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue('active'),
|
||||
getStatusClass: jest.fn(() => 'status-active'),
|
||||
getStatusIcon: jest.fn(() => 'check_circle'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
@@ -40,4 +51,45 @@ describe('PlaylistItemComponent', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows busy UI and suppresses playlist clicks while deleting', () => {
|
||||
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
|
||||
|
||||
fixture.componentRef.setInput('isDeleting', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
const deleteButton = nativeElement.querySelector(
|
||||
'.delete-btn'
|
||||
) as HTMLButtonElement;
|
||||
const editButton = nativeElement.querySelector(
|
||||
'.edit-btn'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(component.isBusy()).toBe(true);
|
||||
expect(deleteButton.disabled).toBe(true);
|
||||
expect(editButton.disabled).toBe(true);
|
||||
expect(nativeElement.querySelector('.action-spinner')).not.toBeNull();
|
||||
|
||||
component.onPlaylistClick();
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders cancel and progress UI for long-running playlist actions', () => {
|
||||
fixture.componentRef.setInput('isDeleting', true);
|
||||
fixture.componentRef.setInput('busyMessage', 'Removing cached content...');
|
||||
fixture.componentRef.setInput('busyProgress', 42);
|
||||
fixture.componentRef.setInput('canCancelBusyAction', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(nativeElement.querySelector('.busy-state__message')?.textContent).toContain(
|
||||
'Removing cached content...'
|
||||
);
|
||||
expect(nativeElement.querySelector('.busy-state__value')?.textContent).toContain(
|
||||
'42%'
|
||||
);
|
||||
expect(nativeElement.querySelector('.cancel-btn')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+33
-16
@@ -1,9 +1,19 @@
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, Input, OnInit, inject, input, output } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PortalStatus, PortalStatusService } from 'services';
|
||||
@@ -19,6 +29,8 @@ import { PlaylistMeta } from 'shared-interfaces';
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatListModule,
|
||||
MatProgressBarModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
@@ -28,11 +40,18 @@ export class PlaylistItemComponent implements OnInit {
|
||||
readonly showActions = input(true);
|
||||
readonly isDraggable = input(false);
|
||||
readonly isSelected = input(false);
|
||||
readonly isRefreshing = input(false);
|
||||
readonly isDeleting = input(false);
|
||||
readonly busyMessage = input('');
|
||||
readonly busyProgress = input<number | null>(null);
|
||||
readonly canCancelBusyAction = input(false);
|
||||
readonly isBusy = computed(() => this.isRefreshing() || this.isDeleting());
|
||||
|
||||
readonly editPlaylistClicked = output<PlaylistMeta>();
|
||||
readonly playlistClicked = output<string>();
|
||||
readonly refreshClicked = output<PlaylistMeta>();
|
||||
readonly removeClicked = output<string>();
|
||||
readonly cancelBusyActionClicked = output<void>();
|
||||
|
||||
portalStatus: PortalStatus = 'unavailable';
|
||||
private readonly portalStatusService = inject(PortalStatusService);
|
||||
@@ -44,22 +63,12 @@ export class PlaylistItemComponent implements OnInit {
|
||||
}
|
||||
|
||||
private async checkPortalStatus() {
|
||||
try {
|
||||
if (
|
||||
this.item.serverUrl &&
|
||||
this.item.username &&
|
||||
if (this.item.serverUrl && this.item.username && this.item.password) {
|
||||
this.portalStatus = await this.portalStatusService.checkPortalStatus(
|
||||
this.item.serverUrl,
|
||||
this.item.username,
|
||||
this.item.password
|
||||
) {
|
||||
this.portalStatus =
|
||||
await this.portalStatusService.checkPortalStatus(
|
||||
this.item.serverUrl,
|
||||
this.item.username,
|
||||
this.item.password
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking portal status:', error);
|
||||
this.portalStatus = 'unavailable';
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,4 +79,12 @@ export class PlaylistItemComponent implements OnInit {
|
||||
getStatusIcon(): string {
|
||||
return this.portalStatusService.getStatusIcon(this.portalStatus);
|
||||
}
|
||||
|
||||
onPlaylistClick(): void {
|
||||
if (this.isBusy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playlistClicked.emit(this.item._id);
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,20 @@
|
||||
[isSelected]="activePlaylistId() === item._id"
|
||||
[showActions]="!sidebarMode()"
|
||||
[isDraggable]="isCustomSortActive()"
|
||||
[isRefreshing]="isRefreshPending(item._id)"
|
||||
[isDeleting]="isDeletePending(item._id)"
|
||||
[busyMessage]="getBusyMessage(item)"
|
||||
[busyProgress]="getBusyProgress(item._id)"
|
||||
[canCancelBusyAction]="
|
||||
canCancelBusyOperation(item)
|
||||
"
|
||||
(editPlaylistClicked)="openInfoDialog($event)"
|
||||
(playlistClicked)="getPlaylist(item)"
|
||||
(refreshClicked)="refreshPlaylist($event)"
|
||||
(removeClicked)="removeClicked($event)"
|
||||
(removeClicked)="removeClicked(item)"
|
||||
(cancelBusyActionClicked)="
|
||||
cancelBusyOperation(item)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</mat-nav-list>
|
||||
|
||||
+283
-111
@@ -1,130 +1,302 @@
|
||||
/* import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { Actions } from '@ngrx/effects';
|
||||
import { provideMockActions } from '@ngrx/effects/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { initialPlaylistMetaState } from 'm3u-state';
|
||||
import { MockComponent, MockModule, MockProvider } from 'ng-mocks';
|
||||
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
|
||||
import { Observable } from 'rxjs';
|
||||
import { DataService } from 'services';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectActivePlaylistId,
|
||||
selectActiveTypeFilters,
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from 'm3u-state';
|
||||
import { of } from 'rxjs';
|
||||
import { DialogService } from 'components';
|
||||
import {
|
||||
DatabaseService,
|
||||
DataService,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
SortService,
|
||||
} from 'services';
|
||||
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
|
||||
import { ElectronServiceStub } from '../../../../../../apps/web/src/app/services/electron.service.stub';
|
||||
import { DialogService } from '../confirm-dialog/dialog.service';
|
||||
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
|
||||
import { RecentPlaylistsComponent } from './recent-playlists.component';
|
||||
|
||||
describe('RecentPlaylistsComponent', () => {
|
||||
let component: RecentPlaylistsComponent;
|
||||
let fixture: ComponentFixture<RecentPlaylistsComponent>;
|
||||
let electronService: DataService;
|
||||
let dialog: MatDialog;
|
||||
let dialogService: DialogService;
|
||||
let mockStore: MockStore;
|
||||
const actions$ = new Observable<Actions>();
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RecentPlaylistsComponent,
|
||||
MockComponent(PlaylistItemComponent),
|
||||
MockModule(MatDialogModule),
|
||||
MockModule(MatDividerModule),
|
||||
MockModule(MatListModule),
|
||||
MockModule(MatIconModule),
|
||||
MockModule(MatInputModule),
|
||||
MockModule(MatTooltipModule),
|
||||
MockComponent(NgxSkeletonLoaderComponent),
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error('Cancelled');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
function createPlaylistMeta(
|
||||
overrides: Partial<PlaylistMeta> = {}
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: 'playlist-1',
|
||||
title: 'Xtream Playlist',
|
||||
count: 0,
|
||||
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
||||
autoRefresh: false,
|
||||
serverUrl: 'http://localhost:8080',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
...overrides,
|
||||
} as PlaylistMeta;
|
||||
}
|
||||
|
||||
describe('RecentPlaylistsComponent busy state', () => {
|
||||
let component: RecentPlaylistsComponent;
|
||||
let databaseService: {
|
||||
cancelOperation: jest.Mock;
|
||||
createOperationId: jest.Mock;
|
||||
deletePlaylist: jest.Mock;
|
||||
deleteXtreamPlaylistContent: jest.Mock;
|
||||
updateXtreamPlaylistDetails: jest.Mock;
|
||||
};
|
||||
let dialogService: {
|
||||
openConfirmDialog: jest.Mock;
|
||||
};
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock;
|
||||
};
|
||||
let router: {
|
||||
navigate: jest.Mock;
|
||||
};
|
||||
let snackBar: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let store: MockStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
databaseService = {
|
||||
cancelOperation: jest.fn().mockResolvedValue(true),
|
||||
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
|
||||
deletePlaylist: jest.fn(),
|
||||
deleteXtreamPlaylistContent: jest.fn(),
|
||||
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
dialogService = {
|
||||
openConfirmDialog: jest.fn(),
|
||||
};
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
router = {
|
||||
navigate: jest.fn(),
|
||||
};
|
||||
snackBar = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RecentPlaylistsComponent],
|
||||
providers: [
|
||||
{ provide: DataService, useClass: ElectronServiceStub },
|
||||
MockProvider(DialogService),
|
||||
MatSnackBar,
|
||||
provideMockStore(),
|
||||
provideMockActions(actions$),
|
||||
MockProvider(TranslateService),
|
||||
provideMockStore({
|
||||
selectors: [
|
||||
{ selector: selectPlaylistsLoadingFlag, value: true },
|
||||
{ selector: selectActivePlaylistId, value: null },
|
||||
{
|
||||
selector: selectAllPlaylistsMeta,
|
||||
value: [],
|
||||
},
|
||||
{
|
||||
selector: selectActiveTypeFilters,
|
||||
value: ['xtream', 'm3u', 'stalker'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: databaseService,
|
||||
},
|
||||
{
|
||||
provide: DialogService,
|
||||
useValue: dialogService,
|
||||
},
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: dataService,
|
||||
},
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: SortService,
|
||||
useValue: {
|
||||
getSortOptions: jest.fn(() =>
|
||||
of({
|
||||
by: SortBy.DATE_ADDED,
|
||||
order: SortOrder.DESC,
|
||||
})
|
||||
),
|
||||
sortPlaylists: jest.fn((playlists: PlaylistMeta[]) => playlists),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(RecentPlaylistsComponent);
|
||||
component = fixture.componentInstance;
|
||||
dialog = TestBed.inject(MatDialog);
|
||||
electronService = TestBed.inject(DataService);
|
||||
dialogService = TestBed.inject(DialogService);
|
||||
mockStore = TestBed.inject(MockStore);
|
||||
mockStore.setState({
|
||||
playlistState: { playlists: initialPlaylistMetaState },
|
||||
});
|
||||
console.error = jest.fn();
|
||||
fixture.detectChanges();
|
||||
component = TestBed.createComponent(
|
||||
RecentPlaylistsComponent
|
||||
).componentInstance;
|
||||
store = TestBed.inject(MockStore);
|
||||
jest.spyOn(store, 'dispatch');
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
it('tracks delete progress and clears the busy row after completion', async () => {
|
||||
const item = createPlaylistMeta({ _id: 'playlist-delete-1' });
|
||||
const deletion = createDeferred<boolean>();
|
||||
|
||||
it('should open the info dialog', () => {
|
||||
jest.spyOn(dialog, 'open');
|
||||
component.openInfoDialog({} as PlaylistMeta);
|
||||
expect(dialog.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should send an ipc event after drop event', () => {
|
||||
const event = {
|
||||
previousIndex: 0,
|
||||
currentIndex: 1,
|
||||
item: undefined,
|
||||
container: undefined,
|
||||
previousContainer: undefined,
|
||||
isPointerOverContainer: true,
|
||||
distance: { x: 0, y: 0 },
|
||||
dropPoint: { x: 0, y: 0 },
|
||||
} as any;
|
||||
jest.spyOn(mockStore, 'dispatch');
|
||||
component.drop(event, []);
|
||||
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should open the confirmation dialog on remove icon click', () => {
|
||||
const playlistId = '12345';
|
||||
jest.spyOn(dialogService, 'openConfirmDialog');
|
||||
component.removeClicked(playlistId);
|
||||
expect(dialogService.openConfirmDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should send an event to the main process to refresh a playlist', () => {
|
||||
const playlistMeta: PlaylistMeta = {
|
||||
id: 'iptv1',
|
||||
title: 'iptv',
|
||||
filePath: '/home/user/lists/iptv.m3u',
|
||||
} as unknown as PlaylistMeta;
|
||||
jest.spyOn(electronService, 'sendIpcEvent');
|
||||
component.refreshPlaylist(playlistMeta);
|
||||
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
|
||||
PLAYLIST_UPDATE,
|
||||
{
|
||||
id: playlistMeta._id,
|
||||
filePath: playlistMeta.filePath,
|
||||
title: playlistMeta.title,
|
||||
databaseService.deletePlaylist.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
options?: {
|
||||
onEvent?: (event: any) => void;
|
||||
operationId?: string;
|
||||
}
|
||||
) => {
|
||||
options?.onEvent?.({
|
||||
operation: 'delete-playlist',
|
||||
operationId: 'playlist-delete-op',
|
||||
status: 'progress',
|
||||
phase: 'deleting-content',
|
||||
current: 25,
|
||||
total: 100,
|
||||
});
|
||||
return deletion.promise;
|
||||
}
|
||||
);
|
||||
|
||||
const removalPromise = component.removePlaylist(item);
|
||||
|
||||
expect(component.isDeletePending(item._id)).toBe(true);
|
||||
expect(component.getBusyMessage(item)).toBe(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT'
|
||||
);
|
||||
expect(component.getBusyProgress(item._id)).toBe(25);
|
||||
expect(component.canCancelBusyOperation(item)).toBe(true);
|
||||
|
||||
await component.cancelBusyOperation(item);
|
||||
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
|
||||
'playlist-delete-op'
|
||||
);
|
||||
|
||||
deletion.resolve(true);
|
||||
await removalPromise;
|
||||
|
||||
expect(component.isDeletePending(item._id)).toBe(false);
|
||||
expect(component.getBusyProgress(item._id)).toBeNull();
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.removePlaylist({ playlistId: item._id })
|
||||
);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS',
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('should send an event to the main process to get a playlist', () => {
|
||||
const playlistId = '6789';
|
||||
jest.spyOn(component.playlistClicked, 'emit');
|
||||
component.getPlaylist({ _id: playlistId } as unknown as PlaylistMeta);
|
||||
expect(component.playlistClicked.emit).toHaveBeenCalledTimes(1);
|
||||
it('tracks Xtream refresh progress and clears the busy row after abort', async () => {
|
||||
const item = createPlaylistMeta({ _id: 'playlist-refresh-1' });
|
||||
const refresh = createDeferred<{
|
||||
success: boolean;
|
||||
favoritedXtreamIds: number[];
|
||||
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[];
|
||||
hiddenCategories: { xtreamId: number; type: string }[];
|
||||
}>();
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
|
||||
dialogService.openConfirmDialog.mockImplementation(
|
||||
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
|
||||
confirmPromise = onConfirm?.();
|
||||
}
|
||||
);
|
||||
|
||||
databaseService.deleteXtreamPlaylistContent.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
options?: {
|
||||
onEvent?: (event: any) => void;
|
||||
operationId?: string;
|
||||
}
|
||||
) => {
|
||||
options?.onEvent?.({
|
||||
operation: 'delete-xtream-content',
|
||||
operationId: 'xtream-refresh-op',
|
||||
status: 'progress',
|
||||
phase: 'collecting-user-data',
|
||||
current: 1,
|
||||
total: 4,
|
||||
});
|
||||
return refresh.promise;
|
||||
}
|
||||
);
|
||||
|
||||
component.refreshXtreamPlaylist(item);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(component.isRefreshPending(item._id)).toBe(true);
|
||||
expect(component.getBusyMessage(item)).toBe(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
|
||||
);
|
||||
expect(component.getBusyProgress(item._id)).toBe(25);
|
||||
expect(component.canCancelBusyOperation(item)).toBe(true);
|
||||
|
||||
await component.cancelBusyOperation(item);
|
||||
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
|
||||
'xtream-refresh-op'
|
||||
);
|
||||
|
||||
refresh.reject(createAbortError());
|
||||
await confirmPromise;
|
||||
|
||||
expect(component.isRefreshPending(item._id)).toBe(false);
|
||||
expect(component.getBusyProgress(item._id)).toBeNull();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the legacy IPC refresh flow for non-Xtream playlists', () => {
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'playlist-m3u-1',
|
||||
serverUrl: undefined,
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
filePath: '/tmp/test.m3u',
|
||||
});
|
||||
|
||||
component.refreshPlaylist(item);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, {
|
||||
id: item._id,
|
||||
title: item.title,
|
||||
filePath: item.filePath,
|
||||
});
|
||||
});
|
||||
});
|
||||
*/
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Component, effect, inject, input, output } from '@angular/core';
|
||||
import { Component, effect, inject, input, output, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
@@ -23,13 +23,29 @@ import {
|
||||
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
|
||||
import { BehaviorSubject, combineLatest, map } from 'rxjs';
|
||||
import { DialogService } from 'components';
|
||||
import { DatabaseService, DataService, SortBy, SortService } from 'services';
|
||||
import {
|
||||
DatabaseService,
|
||||
DataService,
|
||||
DbOperationEvent,
|
||||
isDbAbortError,
|
||||
SortBy,
|
||||
SortService,
|
||||
} from 'services';
|
||||
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
|
||||
import { PlaylistType } from '../add-playlist-menu/add-playlist-menu.component';
|
||||
import { EmptyStateComponent } from './empty-state/empty-state.component';
|
||||
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
|
||||
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
|
||||
|
||||
type PlaylistBusyOperation = {
|
||||
current?: number;
|
||||
operation: string;
|
||||
operationId?: string;
|
||||
phase?: string;
|
||||
status: DbOperationEvent['status'];
|
||||
total?: number;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-recent-playlists',
|
||||
templateUrl: './recent-playlists.component.html',
|
||||
@@ -74,6 +90,11 @@ export class RecentPlaylistsComponent {
|
||||
this.currentSortOptions().by === SortBy.CUSTOM;
|
||||
|
||||
readonly searchQuery = new BehaviorSubject<string>('');
|
||||
readonly pendingDeletionIds = signal<Set<string>>(new Set());
|
||||
readonly pendingRefreshIds = signal<Set<string>>(new Set());
|
||||
readonly busyOperations = signal<Map<string, PlaylistBusyOperation>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
readonly ghostElements = new Array(10);
|
||||
|
||||
@@ -177,14 +198,21 @@ export class RecentPlaylistsComponent {
|
||||
* Triggers on remove click
|
||||
* @param playlistId playlist id to remove
|
||||
*/
|
||||
removeClicked(playlistId: string): void {
|
||||
removeClicked(item: PlaylistMeta): void {
|
||||
if (
|
||||
this.isDeletePending(item._id) ||
|
||||
this.isRefreshPending(item._id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
|
||||
message: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
|
||||
),
|
||||
onConfirm: () => {
|
||||
this.removePlaylist(playlistId);
|
||||
this.removePlaylist(item);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -193,17 +221,47 @@ export class RecentPlaylistsComponent {
|
||||
* Removes the provided playlist from the database
|
||||
* @param playlistId playlist id to remove
|
||||
*/
|
||||
async removePlaylist(playlistId: string) {
|
||||
const deleted = await this.databaseService.deletePlaylist(playlistId);
|
||||
if (deleted) {
|
||||
this.store.dispatch(PlaylistActions.removePlaylist({ playlistId }));
|
||||
this.snackBar.open(
|
||||
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
|
||||
undefined,
|
||||
{
|
||||
duration: 2000,
|
||||
}
|
||||
async removePlaylist(item: PlaylistMeta) {
|
||||
if (
|
||||
this.isDeletePending(item._id) ||
|
||||
this.isRefreshPending(item._id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setPendingDeletion(item._id, true);
|
||||
const operationId = item.serverUrl
|
||||
? this.databaseService.createOperationId('playlist-delete')
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const deleted = await this.databaseService.deletePlaylist(
|
||||
item._id,
|
||||
operationId
|
||||
? {
|
||||
operationId,
|
||||
onEvent: (event) =>
|
||||
this.updateBusyOperation(item._id, event),
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
if (deleted) {
|
||||
this.store.dispatch(
|
||||
PlaylistActions.removePlaylist({ playlistId: item._id })
|
||||
);
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'
|
||||
),
|
||||
undefined,
|
||||
{
|
||||
duration: 2000,
|
||||
}
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.clearBusyOperation(item._id);
|
||||
this.setPendingDeletion(item._id, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +270,13 @@ export class RecentPlaylistsComponent {
|
||||
* @param item playlist to update
|
||||
*/
|
||||
refreshPlaylist(item: PlaylistMeta) {
|
||||
if (
|
||||
this.isDeletePending(item._id) ||
|
||||
this.isRefreshPending(item._id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.serverUrl) {
|
||||
// For Xtream playlists, delete and re-import
|
||||
this.refreshXtreamPlaylist(item);
|
||||
@@ -230,6 +295,13 @@ export class RecentPlaylistsComponent {
|
||||
* @param item Xtream playlist to refresh
|
||||
*/
|
||||
async refreshXtreamPlaylist(item: PlaylistMeta) {
|
||||
if (
|
||||
this.isDeletePending(item._id) ||
|
||||
this.isRefreshPending(item._id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.TITLE'
|
||||
@@ -238,6 +310,18 @@ export class RecentPlaylistsComponent {
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.MESSAGE'
|
||||
),
|
||||
onConfirm: async () => {
|
||||
if (
|
||||
this.isDeletePending(item._id) ||
|
||||
this.isRefreshPending(item._id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setPendingRefresh(item._id, true);
|
||||
const operationId = this.databaseService.createOperationId(
|
||||
'xtream-refresh'
|
||||
);
|
||||
|
||||
try {
|
||||
// Show immediate feedback — deletion can take several seconds
|
||||
// for large playlists.
|
||||
@@ -260,7 +344,15 @@ export class RecentPlaylistsComponent {
|
||||
},
|
||||
] = await Promise.all([
|
||||
this.databaseService.deleteXtreamPlaylistContent(
|
||||
item._id
|
||||
item._id,
|
||||
{
|
||||
operationId,
|
||||
onEvent: (workerEvent) =>
|
||||
this.updateBusyOperation(
|
||||
item._id,
|
||||
workerEvent
|
||||
),
|
||||
}
|
||||
),
|
||||
this.databaseService.updateXtreamPlaylistDetails({
|
||||
id: item._id,
|
||||
@@ -289,16 +381,194 @@ export class RecentPlaylistsComponent {
|
||||
// Navigate to the playlist to trigger re-import
|
||||
this.router.navigate(['/workspace', 'xtreams', item._id]);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing Xtream playlist:', error);
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.ERROR'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 3000 }
|
||||
);
|
||||
if (!isDbAbortError(error)) {
|
||||
console.error('Error refreshing Xtream playlist:', error);
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.ERROR'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.clearBusyOperation(item._id);
|
||||
this.setPendingRefresh(item._id, false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
isDeletePending(playlistId: string): boolean {
|
||||
return this.pendingDeletionIds().has(playlistId);
|
||||
}
|
||||
|
||||
isRefreshPending(playlistId: string): boolean {
|
||||
return this.pendingRefreshIds().has(playlistId);
|
||||
}
|
||||
|
||||
getBusyMessage(item: PlaylistMeta): string {
|
||||
const operation = this.busyOperations().get(item._id);
|
||||
if (!operation) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (operation.operation) {
|
||||
case 'delete-playlist':
|
||||
return this.translateDeletePhase(operation.phase);
|
||||
case 'delete-xtream-content':
|
||||
return this.translateRefreshPhase(operation.phase);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getBusyProgress(playlistId: string): number | null {
|
||||
const operation = this.busyOperations().get(playlistId);
|
||||
if (
|
||||
!operation ||
|
||||
operation.current == null ||
|
||||
operation.total == null ||
|
||||
operation.total <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
100,
|
||||
Math.round((operation.current / operation.total) * 100)
|
||||
);
|
||||
}
|
||||
|
||||
canCancelBusyOperation(item: PlaylistMeta): boolean {
|
||||
const operation = this.busyOperations().get(item._id);
|
||||
return Boolean(item.serverUrl && operation?.operationId);
|
||||
}
|
||||
|
||||
async cancelBusyOperation(item: PlaylistMeta): Promise<void> {
|
||||
const operation = this.busyOperations().get(item._id);
|
||||
if (!operation?.operationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.databaseService.cancelOperation(operation.operationId);
|
||||
}
|
||||
|
||||
private setPendingDeletion(playlistId: string, isPending: boolean): void {
|
||||
this.pendingDeletionIds.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (isPending) {
|
||||
next.add(playlistId);
|
||||
} else {
|
||||
next.delete(playlistId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private setPendingRefresh(playlistId: string, isPending: boolean): void {
|
||||
this.pendingRefreshIds.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (isPending) {
|
||||
next.add(playlistId);
|
||||
} else {
|
||||
next.delete(playlistId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private updateBusyOperation(
|
||||
playlistId: string,
|
||||
event: DbOperationEvent
|
||||
): void {
|
||||
this.busyOperations.update((current) => {
|
||||
const next = new Map(current);
|
||||
|
||||
if (
|
||||
event.status === 'completed' ||
|
||||
event.status === 'cancelled' ||
|
||||
event.status === 'error'
|
||||
) {
|
||||
next.delete(playlistId);
|
||||
return next;
|
||||
}
|
||||
|
||||
next.set(playlistId, {
|
||||
operation: event.operation,
|
||||
operationId: event.operationId,
|
||||
phase: event.phase,
|
||||
current: event.current,
|
||||
total: event.total,
|
||||
status: event.status,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private clearBusyOperation(playlistId: string): void {
|
||||
this.busyOperations.update((current) => {
|
||||
const next = new Map(current);
|
||||
next.delete(playlistId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private translateDeletePhase(phase?: string): string {
|
||||
switch (phase) {
|
||||
case 'deleting-favorites':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES'
|
||||
);
|
||||
case 'deleting-recently-viewed':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT'
|
||||
);
|
||||
case 'deleting-playback-positions':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS'
|
||||
);
|
||||
case 'deleting-downloads':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS'
|
||||
);
|
||||
case 'deleting-content':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT'
|
||||
);
|
||||
case 'deleting-categories':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES'
|
||||
);
|
||||
case 'deleting-playlist':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST'
|
||||
);
|
||||
default:
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private translateRefreshPhase(phase?: string): string {
|
||||
switch (phase) {
|
||||
case 'collecting-user-data':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
|
||||
);
|
||||
case 'deleting-content':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT'
|
||||
);
|
||||
case 'deleting-categories':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES'
|
||||
);
|
||||
default:
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user