From ad3f6fc206c62a2fe4e9a2dc4d29f478f89a421b Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 25 Feb 2026 07:45:29 +0100 Subject: [PATCH] feat(downloads): enhance downloads UI with new layout, category filtering, and improved functionality --- .../downloads/downloads.component.html | 537 ++++++++++-------- .../downloads/downloads.component.scss | 202 +++++-- .../downloads/downloads.component.ts | 327 ++++++++++- 3 files changed, 766 insertions(+), 300 deletions(-) diff --git a/apps/web/src/app/xtream-electron/downloads/downloads.component.html b/apps/web/src/app/xtream-electron/downloads/downloads.component.html index 578a48ead..7a424505c 100644 --- a/apps/web/src/app/xtream-electron/downloads/downloads.component.html +++ b/apps/web/src/app/xtream-electron/downloads/downloads.component.html @@ -1,234 +1,311 @@ -
-
-
-
- downloading -
-
-

{{ 'DOWNLOADS.TITLE' | translate }}

- @if (activeCount() > 0) { - {{ activeCount() }} - } -
-
-
- @if (hasDownloads() && !isWorkspaceLayout) { - - } -
-
- -
- folder_open - {{ - downloadFolder() || ('DOWNLOADS.NO_FOLDER' | translate) - }} - -
- - @if (!isAvailable()) { -
- desktop_access_disabled -

{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}

- {{ 'DOWNLOADS.DESKTOP_ONLY' | translate }} -
- } @else if (!hasDownloads()) { -
- download -

{{ 'DOWNLOADS.EMPTY' | translate }}

- {{ 'DOWNLOADS.EMPTY_HINT' | translate }} -
- } @else if (filteredDownloads().length === 0) { -
- search_off -

No downloads match the current filter

- Try a different search term -
- } @else { -
-
- @for (item of filteredDownloads(); track item.id) { -
-
- @if (item.posterUrl) { - - } @else { - movie - } -
- -
-
- {{ item.title }} - @if (item.contentType === 'episode') { - - {{ formatEpisodeLabel(item) }} - - } -
- -
- - {{ - getStatusIcon(item.status) - }} - {{ - 'DOWNLOADS.STATUS.' + - item.status.toUpperCase() - | translate - }} - - - @if ( - item.status === 'downloading' || - item.status === 'completed' - ) { - - {{ formatBytes(item.bytesDownloaded) }} - @if (item.totalBytes) { - / {{ formatBytes(item.totalBytes) }} - } - - } - - @if (item.errorMessage) { - - {{ item.errorMessage }} - - } -
- - @if (item.status === 'downloading') { - - } -
- -
- @switch (item.status) { - @case ('queued') { - - } - @case ('downloading') { - - } - @case ('completed') { - - - - } - @case ('failed') { - - - } - @case ('canceled') { - - - } - } -
-
- } +
+ @if (!isWorkspaceLayout) { + } + +
+
+
+
+ downloading +
+
+

{{ 'DOWNLOADS.TITLE' | translate }}

+ @if (activeCount() > 0) { + {{ + activeCount() + }} + } +
+
+
+ @if (isAvailable()) { +
+ folder_open + + {{ + downloadFolder() || + ('DOWNLOADS.NO_FOLDER' | translate) + }} + +
+ + } + @if (hasDownloads() && !isWorkspaceLayout) { + + } +
+
+ + @if (!isAvailable()) { +
+ desktop_access_disabled +

{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}

+ {{ 'DOWNLOADS.DESKTOP_ONLY' | translate }} +
+ } @else if (!hasDownloads()) { +
+ download +

{{ 'DOWNLOADS.EMPTY' | translate }}

+ {{ 'DOWNLOADS.EMPTY_HINT' | translate }} +
+ } @else if (filteredDownloads().length === 0) { +
+ search_off +

No downloads match the current filter

+ Try a different search term +
+ } @else { +
+
+ @for (item of filteredDownloads(); track item.id) { +
+
+ @if (item.posterUrl) { + + } @else { + movie + } +
+ +
+
+ {{ item.title }} + @if (item.contentType === 'episode') { + + {{ formatEpisodeLabel(item) }} + + } +
+ +
+ + {{ + getStatusIcon(item.status) + }} + {{ + 'DOWNLOADS.STATUS.' + + item.status.toUpperCase() + | translate + }} + + + @if ( + item.status === 'downloading' || + item.status === 'completed' + ) { + + {{ + formatBytes( + item.bytesDownloaded + ) + }} + @if (item.totalBytes) { + / + {{ + formatBytes(item.totalBytes) + }} + } + + } + + @if (item.errorMessage) { + + {{ item.errorMessage }} + + } +
+ + @if (item.status === 'downloading') { + + } +
+ +
+ @switch (item.status) { + @case ('queued') { + + } + @case ('downloading') { + + } + @case ('completed') { + + + + } + @case ('failed') { + + + } + @case ('canceled') { + + + } + } +
+
+ } +
+
+ } +
diff --git a/apps/web/src/app/xtream-electron/downloads/downloads.component.scss b/apps/web/src/app/xtream-electron/downloads/downloads.component.scss index 9d7567b68..41116f809 100644 --- a/apps/web/src/app/xtream-electron/downloads/downloads.component.scss +++ b/apps/web/src/app/xtream-electron/downloads/downloads.component.scss @@ -1,11 +1,25 @@ :host { width: 100%; + display: flex; + min-width: 0; + min-height: 0; +} + +.downloads-layout { + display: flex; + flex: 1; + width: 100%; + min-width: 0; + min-height: 0; } .downloads { + flex: 1; display: flex; flex-direction: column; - height: calc(100vh - 20px); + height: 100%; + min-width: 0; + min-height: 0; overflow: hidden; &__header { @@ -59,6 +73,9 @@ &-actions { display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; gap: 0.5rem; } } @@ -72,29 +89,40 @@ font-weight: 600; } - &__folder { - display: flex; + &__folder-inline { + display: inline-flex; align-items: center; - gap: 0.75rem; - padding: 12px 14px; - margin: 0 16px 1rem; - background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent); - border: 1px solid var(--mat-sys-primary); - border-radius: 12px; + gap: 8px; + max-width: min(42vw, 460px); + padding: 7px 10px; + background: color-mix( + in srgb, + var(--mat-sys-surface-container-high) 86%, + transparent + ); + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 8px; + box-shadow: inset 0 1px 0 + color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent); mat-icon { + width: 16px; + height: 16px; + font-size: 16px; color: var(--mat-sys-primary); + flex-shrink: 0; } + } - &-path { - flex: 1; - font-family: 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, monospace; - font-size: 0.875rem; - color: var(--mat-sys-on-surface); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } + &__folder-inline-path { + font-family: + 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, + monospace; + font-size: 0.78rem; + color: var(--mat-sys-on-surface); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } &__empty { @@ -129,58 +157,63 @@ &__list-wrapper { flex: 1; overflow-y: auto; + overflow-x: hidden; padding: 0 16px; + container-type: inline-size; } &__list { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap: 12px; padding-bottom: 24px; + align-content: start; } &__item { - display: flex; + display: grid; + grid-template-columns: 64px minmax(0, 1fr) auto; + grid-template-areas: 'poster info actions'; align-items: center; gap: 1rem; padding: 1rem; - background: var(--mat-sys-surface-container-low); - border-radius: 16px; - border: 1px solid var(--mat-sys-outline-variant); + min-width: 0; + background: color-mix( + in srgb, + var(--mat-sys-surface-container-high) 90%, + var(--mat-sys-surface) 10% + ); + 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); - &:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - transform: translateY(-2px); - background: var(--mat-sys-surface-container); - border-color: var(--mat-sys-outline); + &--clickable { + cursor: pointer; - .downloads__item-actions { - opacity: 1; - transform: translateX(0); + &:focus-visible { + outline: 2px solid var(--mat-sys-primary); + outline-offset: 2px; } } - // Status colors using Material semantics where possible, falling back to clean hexes - &.status-downloading { - border-left: 4px solid #2196f3; - background: color-mix(in srgb, #2196f3 4%, var(--mat-sys-surface-container-low)); - } - - &.status-completed { - border-left: 4px solid #4caf50; - } - - &.status-failed { - border-left: 4px solid var(--mat-sys-error); - background: color-mix(in srgb, var(--mat-sys-error) 4%, var(--mat-sys-surface-container-low)); - } - - &.status-queued { - border-left: 4px solid var(--mat-sys-outline); + &:hover { + background: color-mix( + in srgb, + var(--mat-sys-surface-container-high) 72%, + var(--mat-sys-surface-container) 28% + ); + border-color: var(--mat-sys-outline); } &-poster { + grid-area: poster; width: 64px; height: 90px; border-radius: 8px; @@ -208,6 +241,7 @@ } &-info { + grid-area: info; flex: 1; min-width: 0; display: flex; @@ -230,6 +264,7 @@ &-meta { display: flex; align-items: center; + flex-wrap: wrap; gap: 1rem; font-size: 0.85rem; color: var(--mat-sys-on-surface-variant); @@ -262,7 +297,11 @@ &.status-failed { color: var(--mat-sys-error); - background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent); + background: color-mix( + in srgb, + var(--mat-sys-error) 12%, + transparent + ); } &.status-queued { @@ -271,7 +310,9 @@ } &-size { - font-family: 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, monospace; + font-family: + 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, + monospace; background: var(--mat-sys-surface-container-high); padding: 2px 8px; border-radius: 12px; @@ -280,17 +321,22 @@ &-error { color: var(--mat-sys-error); font-size: 0.8rem; - background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent); + background: color-mix( + in srgb, + var(--mat-sys-error) 12%, + transparent + ); padding: 2px 8px; border-radius: 12px; } &-actions { + grid-area: actions; display: flex; gap: 0.25rem; flex-shrink: 0; - opacity: 0.7; - transform: translateX(4px); + justify-self: end; + opacity: 1; transition: all 0.2s ease; } @@ -312,3 +358,51 @@ } } +.sidebar-header h2 { + margin: 0; +} + +@media (max-width: 980px) { + .downloads { + &__header { + align-items: flex-start; + gap: 12px; + } + + &__header-actions { + width: 100%; + justify-content: flex-start; + } + + &__folder-inline { + max-width: 100%; + } + + &__list { + grid-template-columns: 1fr; + } + } +} + +@container (max-width: 760px) { + .downloads__item { + grid-template-columns: 64px minmax(0, 1fr); + grid-template-areas: + 'poster info' + 'actions actions'; + align-items: start; + row-gap: 10px; + } + + .downloads__item-actions { + width: 100%; + justify-content: flex-end; + } + + .downloads__item-title { + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } +} diff --git a/apps/web/src/app/xtream-electron/downloads/downloads.component.ts b/apps/web/src/app/xtream-electron/downloads/downloads.component.ts index ddbf7735e..7b6fab643 100644 --- a/apps/web/src/app/xtream-electron/downloads/downloads.component.ts +++ b/apps/web/src/app/xtream-electron/downloads/downloads.component.ts @@ -2,37 +2,51 @@ import { ChangeDetectionStrategy, Component, computed, + effect, inject, - OnInit, + OnDestroy, } 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 { MatTooltip } from '@angular/material/tooltip'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; -import { map } from 'rxjs'; +import { ResizableDirective } from 'components'; +import { firstValueFrom, map } from 'rxjs'; +import { DatabaseService, PlaylistsService } from 'services'; +import { XtreamCategory } from 'shared-interfaces'; import { DownloadItem, DownloadsService, } from '../../services/downloads.service'; +import { FavoritesContextService } from '../../workspace/favorites-context.service'; +import { CategoryViewComponent } from '../category-view/category-view.component'; + +type PortalSource = 'xtream' | 'stalker'; @Component({ selector: 'app-downloads', templateUrl: './downloads.component.html', - styleUrls: ['./downloads.component.scss'], + styleUrls: ['./downloads.component.scss', '../sidebar.scss'], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + CategoryViewComponent, MatButtonModule, MatIcon, MatProgressBarModule, MatTooltip, + ResizableDirective, TranslatePipe, ], }) -export class DownloadsComponent implements OnInit { +export class DownloadsComponent implements OnDestroy { private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly dbService = inject(DatabaseService); + private readonly playlistsService = inject(PlaylistsService); + private readonly favoritesCtx = inject(FavoritesContextService); readonly downloadsService = inject(DownloadsService); readonly isWorkspaceLayout = this.route.snapshot.data['layout'] === 'workspace'; @@ -42,6 +56,11 @@ export class DownloadsComponent implements OnInit { readonly isAvailable = this.downloadsService.isAvailable; readonly hasDownloads = this.downloadsService.hasDownloads; readonly activeCount = this.downloadsService.activeCount; + readonly selectedCategoryId = this.favoritesCtx.selectedCategoryId; + readonly playlistId = toSignal( + this.route.paramMap.pipe(map((params) => params.get('id') ?? '')), + { initialValue: this.route.snapshot.params['id'] ?? '' } + ); readonly searchTerm = toSignal( this.route.queryParamMap.pipe( map((params) => (params.get('q') ?? '').trim().toLowerCase()) @@ -49,21 +68,71 @@ export class DownloadsComponent implements OnInit { { initialValue: '' } ); + readonly scopedDownloads = computed(() => { + const playlistId = this.playlistId(); + const downloads = this.downloads(); + return playlistId + ? downloads.filter((item) => item.playlistId === playlistId) + : downloads; + }); + + readonly categories = computed(() => { + const downloads = this.scopedDownloads(); + const moviesCount = downloads.filter( + (item) => item.contentType === 'vod' + ).length; + const seriesCount = downloads.filter( + (item) => item.contentType === 'episode' + ).length; + + return [ + { + id: 1, + category_id: 'all', + category_name: 'All', + count: downloads.length, + parent_id: 0, + }, + { + id: 2, + category_id: 'movie', + category_name: 'Movies', + count: moviesCount, + parent_id: 0, + }, + { + id: 3, + category_id: 'series', + category_name: 'Series', + count: seriesCount, + parent_id: 0, + }, + ]; + }); + /** Filter downloads for current playlist and sort by newest first */ readonly filteredDownloads = computed(() => { - const playlistId = this.route.snapshot.params['id']; + const selectedCategoryId = this.selectedCategoryId(); const term = this.searchTerm(); - const downloads = playlistId - ? this.downloads().filter((d) => d.playlistId === playlistId) - : this.downloads(); + const downloads = this.scopedDownloads(); + + const filteredByCategory = downloads.filter((item) => { + if (selectedCategoryId === 'movie') { + return item.contentType === 'vod'; + } + if (selectedCategoryId === 'series') { + return item.contentType === 'episode'; + } + return true; + }); const filteredByTerm = term - ? downloads.filter((item) => + ? filteredByCategory.filter((item) => `${item.title ?? ''} ${item.errorMessage ?? ''}` .toLowerCase() .includes(term) ) - : downloads; + : filteredByCategory; // Sort by createdAt descending (newest first) return [...filteredByTerm].sort((a, b) => { @@ -73,9 +142,24 @@ export class DownloadsComponent implements OnInit { }); }); - ngOnInit() { - const playlistId = this.route.snapshot.params['id']; - this.downloadsService.loadDownloads(playlistId); + constructor() { + effect(() => { + const playlistId = this.playlistId(); + this.favoritesCtx.setCategoryId('all'); + void this.downloadsService.loadDownloads(playlistId || undefined); + }); + + effect(() => { + this.favoritesCtx.setCategories(this.categories()); + }); + } + + setCategoryId(categoryId: string) { + this.favoritesCtx.setCategoryId(categoryId); + } + + ngOnDestroy(): void { + this.favoritesCtx.reset(); } getProgress(item: DownloadItem): number { @@ -149,8 +233,7 @@ export class DownloadsComponent implements OnInit { } async clearCompleted() { - const playlistId = this.route.snapshot.params['id']; - await this.downloadsService.clearCompleted(playlistId); + await this.downloadsService.clearCompleted(this.playlistId()); } formatEpisodeLabel(item: DownloadItem): string { @@ -159,4 +242,216 @@ export class DownloadsComponent implements OnInit { const e = item.episodeNumber?.toString().padStart(2, '0') || '00'; return `S${s}E${e}`; } + + isItemNavigable(item: DownloadItem): boolean { + return !!item.playlistId && this.getTargetContentId(item) !== null; + } + + onItemCardKeydown(event: KeyboardEvent, item: DownloadItem): void { + if (event.target !== event.currentTarget) { + return; + } + + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + void this.openInLibrary(item); + } + + async openInLibrary(item: DownloadItem): Promise { + if (!this.isItemNavigable(item)) { + return; + } + + const source = await this.resolveSourceType(item.playlistId); + if (!source) { + return; + } + + if (source === 'xtream') { + await this.openXtreamItem(item); + return; + } + + await this.openStalkerItem(item); + } + + private getTargetContentId(item: DownloadItem): number | null { + const id = + item.contentType === 'episode' + ? (item.seriesXtreamId ?? item.xtreamId) + : item.xtreamId; + const numeric = Number(id); + return Number.isFinite(numeric) ? numeric : null; + } + + private buildPlaylistRoute( + source: PortalSource, + playlistId: string, + segments: Array + ): Array { + const sourceSegment = source === 'stalker' ? 'stalker' : 'xtreams'; + if (this.isWorkspaceLayout) { + return ['/workspace', sourceSegment, playlistId, ...segments]; + } + return [`/${sourceSegment}`, playlistId, ...segments]; + } + + private async resolveSourceType( + playlistId: string + ): Promise { + try { + const playlist = await firstValueFrom( + this.playlistsService.getPlaylistById(playlistId) + ); + + if (!playlist) { + return null; + } + + if (playlist.portalUrl && playlist.macAddress) { + return 'stalker'; + } + + return 'xtream'; + } catch { + return null; + } + } + + private async openXtreamItem(item: DownloadItem): Promise { + const targetId = this.getTargetContentId(item); + if (targetId === null) return; + + const contentType = item.contentType === 'episode' ? 'series' : 'vod'; + const content = await this.dbService.getContentByXtreamId( + targetId, + item.playlistId + ); + const categoryId = content?.category_id; + + if (categoryId === null || categoryId === undefined) { + await this.router.navigate( + this.buildPlaylistRoute('xtream', item.playlistId, [ + contentType, + ]) + ); + return; + } + + await this.router.navigate( + this.buildPlaylistRoute('xtream', item.playlistId, [ + contentType, + String(categoryId), + String(targetId), + ]) + ); + } + + private normalizePortalItemId(value: unknown): string { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + return raw.includes(':') ? raw.split(':')[0] : raw; + } + + private normalizeStalkerCategoryId( + value: unknown, + fallback: 'vod' | 'series' + ): 'vod' | 'series' | 'itv' { + const normalized = String(value ?? '').toLowerCase(); + if (normalized === 'movie') return 'vod'; + if ( + normalized === 'vod' || + normalized === 'series' || + normalized === 'itv' + ) { + return normalized; + } + return fallback; + } + + private findMatchingStalkerRecentItem( + items: Array>, + targetId: number + ): Record | undefined { + const expectedId = String(targetId); + return items.find((recentItem) => { + const candidates = [ + recentItem['id'], + recentItem['movie_id'], + recentItem['series_id'], + recentItem['stream_id'], + ]; + + return candidates.some( + (candidate) => + this.normalizePortalItemId(candidate) === expectedId + ); + }); + } + + private async buildStalkerOpenState( + item: DownloadItem, + targetId: number, + fallbackCategory: 'vod' | 'series' + ): Promise> { + try { + const items = (await firstValueFrom( + this.playlistsService.getPortalRecentlyViewed(item.playlistId) + )) as Array>; + + const matched = this.findMatchingStalkerRecentItem(items, targetId); + + if (matched) { + return { + ...matched, + id: + matched['id'] ?? + matched['series_id'] ?? + matched['movie_id'] ?? + String(targetId), + category_id: this.normalizeStalkerCategoryId( + matched['category_id'], + fallbackCategory + ), + title: matched['title'] ?? item.title, + name: matched['name'] ?? matched['o_name'] ?? item.title, + }; + } + } catch { + // Ignore and use fallback state below. + } + + return { + id: String(targetId), + category_id: fallbackCategory, + title: item.title, + name: item.title, + o_name: item.title, + cover: item.posterUrl, + logo: item.posterUrl, + }; + } + + private async openStalkerItem(item: DownloadItem): Promise { + const targetId = this.getTargetContentId(item); + if (targetId === null) return; + + const fallbackCategory = + item.contentType === 'episode' ? 'series' : 'vod'; + const openRecentItem = await this.buildStalkerOpenState( + item, + targetId, + fallbackCategory + ); + + await this.router.navigate( + this.buildPlaylistRoute('stalker', item.playlistId, ['recent']), + { + state: { openRecentItem }, + } + ); + } }