feat: enhance playlist item component with busy state management and UI feedback

This commit is contained in:
4gray committed 2026-03-28 15:39:32 +01:00
1 parent 5f6ae5c743
commit d2f3044ffa
7 files changed
+780 -158

No files matched your search

@@ -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>
}
@@ -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 {
@@ -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();
});
});
@@ -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>
@@ -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'
);
}
}
}