From d2f3044ffa7ff69df97e21b933a6a22fc11470be Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 28 Mar 2026 15:39:32 +0100 Subject: [PATCH] feat: enhance playlist item component with busy state management and UI feedback --- .../playlist-item.component.html | 74 +++- .../playlist-item.component.scss | 37 ++ .../playlist-item.component.spec.ts | 56 ++- .../playlist-item/playlist-item.component.ts | 49 ++- .../recent-playlists.component.html | 12 +- .../recent-playlists.component.spec.ts | 394 +++++++++++++----- .../recent-playlists.component.ts | 316 +++++++++++++- 7 files changed, 780 insertions(+), 158 deletions(-) diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html index 1a8324577..d339420b5 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html @@ -1,9 +1,10 @@ @if (isDraggable()) { + @if (isBusy() && busyMessage()) { +
+ {{ busyMessage() }} + @if (busyProgress() !== null) { + {{ busyProgress() }}% + } +
+ } + @if (isBusy() && busyMessage() && busyProgress() !== null) { +
+ +
+ } + @if (isBusy() && busyMessage() && busyProgress() === null) { +
+ +
+ } @if (showActions()) { + @if (canCancelBusyAction()) { + + } @if (item.url || item.filePath) { } @if (item.serverUrl && isElectron) { @@ -130,9 +175,18 @@ (click)=" $event.stopPropagation(); refreshClicked.emit(item) " + [disabled]="isBusy()" [matTooltip]="'HOME.PLAYLISTS.REFRESH_XTREAM' | translate" > - sync + @if (isRefreshing()) { + + } @else { + sync + } } @@ -143,6 +197,7 @@ (click)=" $event.stopPropagation(); editPlaylistClicked.emit(item) " + [disabled]="isBusy()" [matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate" > edit @@ -153,9 +208,18 @@ color="accent" class="delete-btn" (click)="$event.stopPropagation(); removeClicked.emit(item._id)" + [disabled]="isBusy()" [matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate" > - delete + @if (isDeleting()) { + + } @else { + delete + } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.scss b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.scss index 029d83ba3..0ec5672f2 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.scss +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.scss @@ -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 { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts index 053e3d652..e00aa9321 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.spec.ts @@ -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(); + }); }); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts index a1f3706a4..ad1375d0f 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts @@ -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(null); + readonly canCancelBusyAction = input(false); + readonly isBusy = computed(() => this.isRefreshing() || this.isDeleting()); readonly editPlaylistClicked = output(); readonly playlistClicked = output(); readonly refreshClicked = output(); readonly removeClicked = output(); + readonly cancelBusyActionClicked = output(); 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); + } } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html index 58ce18e7b..c06181745 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.html @@ -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) + " /> } diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index c8809110f..360497e24 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -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; - let electronService: DataService; - let dialog: MatDialog; - let dialogService: DialogService; - let mockStore: MockStore; - const actions$ = new Observable(); +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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 { + 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(); - 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 | undefined; + + dialogService.openConfirmDialog.mockImplementation( + ({ onConfirm }: { onConfirm?: () => Promise }) => { + 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, + }); }); }); - */ diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index 6b93357db..52d277ad7 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -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(''); + readonly pendingDeletionIds = signal>(new Set()); + readonly pendingRefreshIds = signal>(new Set()); + readonly busyOperations = signal>( + 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 { + 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' + ); + } + } }