feat(downloads): enhance download item handling and UI interactions

This commit is contained in:
4gray committed 2026-03-14 23:40:29 +01:00
1 parent 137d1f01ad
commit 9f581d0d84
3 files changed
+172 -41

No files matched your search

@@ -54,16 +54,19 @@
<button mat-stroked-button (click)="changeFolder()">
{{ 'DOWNLOADS.CHANGE_FOLDER' | translate }}
</button>
}
@if (hasDownloads() && !isWorkspaceLayout) {
<button
mat-stroked-button
(click)="clearCompleted()"
[matTooltip]="'DOWNLOADS.CLEAR_COMPLETED' | translate"
>
<mat-icon>delete_sweep</mat-icon>
{{ 'DOWNLOADS.CLEAR_COMPLETED' | translate }}
</button>
@if (hasClearableDownloads()) {
<button
mat-stroked-button
(click)="clearCompleted()"
[matTooltip]="'DOWNLOADS.CLEAR_COMPLETED' | translate"
[attr.aria-label]="
'DOWNLOADS.CLEAR_COMPLETED' | translate
"
>
<mat-icon>delete_sweep</mat-icon>
{{ 'DOWNLOADS.CLEAR_COMPLETED' | translate }}
</button>
}
}
</div>
</header>
@@ -96,22 +99,34 @@
[class.downloads__item--clickable]="
isItemNavigable(item)
"
[class.downloads__item--unavailable]="
!hasSourcePlaylist(item)
"
[attr.role]="
isItemNavigable(item) ? 'button' : null
"
[attr.tabindex]="isItemNavigable(item) ? 0 : null"
[matTooltip]="
!hasSourcePlaylist(item)
? ('DOWNLOADS.SOURCE_PLAYLIST_MISSING'
| translate)
: null
"
(click)="openInLibrary(item)"
(keydown)="onItemCardKeydown($event, item)"
>
<div class="downloads__item-poster">
@if (item.posterUrl) {
@if (hasPoster(item)) {
<img
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
(error)="markPosterFailed(item)"
/>
} @else {
<mat-icon>movie</mat-icon>
<mat-icon>{{
getPosterPlaceholderIcon(item)
}}</mat-icon>
}
</div>
@@ -178,21 +178,18 @@
gap: 1rem;
padding: 1rem;
min-width: 0;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 90%,
var(--mat-sys-surface) 10%
background: var(
--app-widget-bg,
var(--mat-sys-surface-container-high)
);
border-radius: 0;
border: 1px solid
color-mix(
in srgb,
var(--mat-sys-outline-variant) 80%,
var(--mat-sys-on-surface) 20%
);
box-shadow: inset 0 1px 0
color-mix(in srgb, var(--mat-sys-on-surface) 7%, transparent);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 14px;
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
box-shadow: var(--app-widget-shadow, none);
transition:
background 0.18s ease,
border-color 0.18s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
&--clickable {
cursor: pointer;
@@ -204,26 +201,33 @@
}
&:hover {
background: color-mix(
background: var(--app-selection-surface);
border-color: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 72%,
var(--mat-sys-surface-container) 28%
var(--app-selection-border) 72%,
transparent
);
border-color: var(--mat-sys-outline);
}
&--unavailable {
cursor: default;
}
&-poster {
grid-area: poster;
width: 64px;
height: 90px;
border-radius: 8px;
border-radius: 10px;
overflow: hidden;
flex-shrink: 0;
background: var(--mat-sys-surface-variant);
background: var(
--app-widget-header-bg,
var(--mat-sys-surface-container-high)
);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
img {
width: 100%;
@@ -277,7 +281,10 @@
font-weight: 500;
padding: 2px 8px;
border-radius: 12px;
background: var(--mat-sys-surface-container-high);
background: var(
--app-widget-header-bg,
var(--mat-sys-surface-container-high)
);
mat-icon {
font-size: 1.1rem;
@@ -313,7 +320,7 @@
font-family:
'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas,
monospace;
background: var(--mat-sys-surface-container-high);
background: transparent;
padding: 2px 8px;
border-radius: 12px;
}
@@ -338,6 +345,20 @@
justify-self: end;
opacity: 1;
transition: all 0.2s ease;
button {
width: 34px;
height: 34px;
border-radius: 10px;
transition:
background-color 0.15s ease,
transform 0.15s ease;
&:hover {
background: var(--app-selection-surface);
transform: translateY(-1px);
}
}
}
mat-progress-bar {
@@ -4,15 +4,17 @@ import {
computed,
effect,
inject,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from 'components';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { DialogService, ResizableDirective } from 'components';
import { firstValueFrom, map } from 'rxjs';
import {
DatabaseService,
@@ -31,6 +33,7 @@ import {
filterCollectionBucket,
} from '@iptvnator/portal/shared/util';
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
import { Playlist } from 'shared-interfaces';
type PortalSource = 'xtream' | 'stalker';
const DOWNLOAD_COLLECTION_LABELS = {
@@ -64,6 +67,9 @@ export class DownloadsComponent {
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly favoritesCtx = inject(FavoritesContextService);
private readonly dialogService = inject(DialogService);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
readonly downloadsService = inject(DownloadsService);
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
@@ -79,6 +85,9 @@ export class DownloadsComponent {
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
);
readonly playlists = toSignal(this.playlistsService.getAllPlaylists(), {
initialValue: [] as Playlist[],
});
readonly scopedDownloads = computed(() => {
const playlistId = this.playlistId();
@@ -111,6 +120,7 @@ export class DownloadsComponent {
categories: this.categories,
});
readonly selectedCategoryId = this.collectionContext.selectedCategoryId;
readonly failedPosterKeys = signal<Record<string, true>>({});
/** Filter downloads for current playlist and sort by newest first */
readonly filteredDownloads = computed(() => {
@@ -136,6 +146,17 @@ export class DownloadsComponent {
return dateB - dateA;
});
});
readonly hasClearableDownloads = computed(() =>
this.scopedDownloads().some(
(item) =>
item.status === 'completed' ||
item.status === 'failed' ||
item.status === 'canceled'
)
);
readonly availablePlaylistIds = computed(
() => new Set(this.playlists().map((playlist) => playlist._id))
);
constructor() {
effect(() => {
@@ -205,13 +226,21 @@ export class DownloadsComponent {
async play(item: DownloadItem) {
if (item.filePath) {
await this.downloadsService.playDownload(item.filePath);
const result = await this.downloadsService.playDownload(
item.filePath
);
if (!result.success) {
this.handleFileActionError(result.error);
}
}
}
async reveal(item: DownloadItem) {
if (item.filePath) {
await this.downloadsService.revealFile(item.filePath);
const result = await this.downloadsService.revealFile(item.filePath);
if (!result.success) {
this.handleFileActionError(result.error);
}
}
}
@@ -220,7 +249,18 @@ export class DownloadsComponent {
}
async clearCompleted() {
await this.downloadsService.clearCompleted(this.playlistId());
this.dialogService.openConfirmDialog({
title: this.translate.instant(
'DOWNLOADS.CLEAR_COMPLETED_DIALOG.TITLE'
),
message: this.translate.instant(
'DOWNLOADS.CLEAR_COMPLETED_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant('DOWNLOADS.CLEAR_COMPLETED'),
onConfirm: async (): Promise<void> => {
await this.downloadsService.clearCompleted(this.playlistId());
},
});
}
formatEpisodeLabel(item: DownloadItem): string {
@@ -230,8 +270,38 @@ export class DownloadsComponent {
return `S${s}E${e}`;
}
hasPoster(item: DownloadItem): boolean {
return !!item.posterUrl && !this.failedPosterKeys()[this.getPosterKey(item)];
}
markPosterFailed(item: DownloadItem): void {
const key = this.getPosterKey(item);
this.failedPosterKeys.update((state) => {
if (state[key]) {
return state;
}
return {
...state,
[key]: true,
};
});
}
getPosterPlaceholderIcon(item: DownloadItem): string {
return item.contentType === 'episode' ? 'video_library' : 'movie';
}
hasSourcePlaylist(item: DownloadItem): boolean {
return this.availablePlaylistIds().has(item.playlistId);
}
isItemNavigable(item: DownloadItem): boolean {
return !!item.playlistId && this.getTargetContentId(item) !== null;
return (
!!item.playlistId &&
this.hasSourcePlaylist(item) &&
this.getTargetContentId(item) !== null
);
}
onItemCardKeydown(event: KeyboardEvent, item: DownloadItem): void {
@@ -248,6 +318,15 @@ export class DownloadsComponent {
}
async openInLibrary(item: DownloadItem): Promise<void> {
if (!this.hasSourcePlaylist(item)) {
this.snackBar.open(
this.translate.instant('DOWNLOADS.SOURCE_PLAYLIST_MISSING'),
undefined,
{ duration: 3000, horizontalPosition: 'start' }
);
return;
}
if (!this.isItemNavigable(item)) {
return;
}
@@ -441,4 +520,20 @@ export class DownloadsComponent {
}
);
}
private getPosterKey(item: DownloadItem): string {
return `${item.id}:${item.posterUrl ?? ''}`;
}
private handleFileActionError(error?: string): void {
const message =
error === 'File not found'
? this.translate.instant('DOWNLOADS.FILE_NOT_FOUND')
: this.translate.instant('DOWNLOADS.FILE_ACTION_ERROR');
this.snackBar.open(message, undefined, {
duration: 3000,
horizontalPosition: 'start',
});
}
}