mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(downloads): enhance download item handling and UI interactions
This commit is contained in:
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user