diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.html b/libs/portal/downloads/feature/src/lib/downloads.component.html index 7a424505c..6b481bc0d 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.html +++ b/libs/portal/downloads/feature/src/lib/downloads.component.html @@ -54,16 +54,19 @@ - } - @if (hasDownloads() && !isWorkspaceLayout) { - + @if (hasClearableDownloads()) { + + } } @@ -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)" >
- @if (item.posterUrl) { + @if (hasPoster(item)) { } @else { - movie + {{ + getPosterPlaceholderIcon(item) + }} }
diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.scss b/libs/portal/downloads/feature/src/lib/downloads.component.scss index 41116f809..bf994722b 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.scss +++ b/libs/portal/downloads/feature/src/lib/downloads.component.scss @@ -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 { diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index abbeb1da3..53125f350 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -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>({}); /** 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 => { + 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 { + 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', + }); + } }