feat(downloads): enhance downloads UI with new layout, category filtering, and improved functionality

This commit is contained in:
4gray committed 2026-02-25 07:45:29 +01:00
1 parent e1b41832a9
commit ad3f6fc206
3 files changed
+766 -300

No files matched your search

@@ -1,234 +1,311 @@
<div class="downloads">
<header class="downloads__header">
<div class="downloads__header-left">
<div class="settings-group__header-icon">
<mat-icon>downloading</mat-icon>
</div>
<div class="settings-group__header-text">
<h1>{{ 'DOWNLOADS.TITLE' | translate }}</h1>
@if (activeCount() > 0) {
<span class="downloads__badge">{{ activeCount() }}</span>
}
</div>
</div>
<div class="downloads__header-actions">
@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>
}
</div>
</header>
<div class="downloads__folder">
<mat-icon>folder_open</mat-icon>
<span class="downloads__folder-path">{{
downloadFolder() || ('DOWNLOADS.NO_FOLDER' | translate)
}}</span>
<button mat-flat-button color="accent" (click)="changeFolder()">
{{ 'DOWNLOADS.CHANGE_FOLDER' | translate }}
</button>
</div>
@if (!isAvailable()) {
<div class="downloads__empty">
<mat-icon>desktop_access_disabled</mat-icon>
<p>{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}</p>
<span>{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}</span>
</div>
} @else if (!hasDownloads()) {
<div class="downloads__empty">
<mat-icon>download</mat-icon>
<p>{{ 'DOWNLOADS.EMPTY' | translate }}</p>
<span>{{ 'DOWNLOADS.EMPTY_HINT' | translate }}</span>
</div>
} @else if (filteredDownloads().length === 0) {
<div class="downloads__empty">
<mat-icon>search_off</mat-icon>
<p>No downloads match the current filter</p>
<span>Try a different search term</span>
</div>
} @else {
<div class="downloads__list-wrapper">
<div class="downloads__list">
@for (item of filteredDownloads(); track item.id) {
<div
class="downloads__item"
[class]="getStatusColor(item.status)"
>
<div class="downloads__item-poster">
@if (item.posterUrl) {
<img
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<mat-icon>movie</mat-icon>
}
</div>
<div class="downloads__item-info">
<div class="downloads__item-title">
{{ item.title }}
@if (item.contentType === 'episode') {
<span class="downloads__episode-badge">
{{ formatEpisodeLabel(item) }}
</span>
}
</div>
<div class="downloads__item-meta">
<span
class="downloads__item-status"
[class]="getStatusColor(item.status)"
>
<mat-icon>{{
getStatusIcon(item.status)
}}</mat-icon>
{{
'DOWNLOADS.STATUS.' +
item.status.toUpperCase()
| translate
}}
</span>
@if (
item.status === 'downloading' ||
item.status === 'completed'
) {
<span class="downloads__item-size">
{{ formatBytes(item.bytesDownloaded) }}
@if (item.totalBytes) {
/ {{ formatBytes(item.totalBytes) }}
}
</span>
}
@if (item.errorMessage) {
<span class="downloads__item-error">
{{ item.errorMessage }}
</span>
}
</div>
@if (item.status === 'downloading') {
<mat-progress-bar
mode="determinate"
[value]="getProgress(item)"
></mat-progress-bar>
}
</div>
<div class="downloads__item-actions">
@switch (item.status) {
@case ('queued') {
<button
mat-icon-button
(click)="cancel(item)"
[matTooltip]="
'DOWNLOADS.CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
}
@case ('downloading') {
<button
mat-icon-button
(click)="cancel(item)"
[matTooltip]="
'DOWNLOADS.CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
}
@case ('completed') {
<button
mat-icon-button
color="primary"
(click)="play(item)"
[matTooltip]="
'DOWNLOADS.PLAY' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
<button
mat-icon-button
(click)="reveal(item)"
[matTooltip]="
'DOWNLOADS.REVEAL' | translate
"
>
<mat-icon>folder_open</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="remove(item)"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
@case ('failed') {
<button
mat-icon-button
color="accent"
(click)="retry(item)"
[matTooltip]="
'DOWNLOADS.RETRY' | translate
"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="remove(item)"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
@case ('canceled') {
<button
mat-icon-button
color="accent"
(click)="retry(item)"
[matTooltip]="
'DOWNLOADS.RETRY' | translate
"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="remove(item)"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
}
</div>
</div>
}
<div class="downloads-layout">
@if (!isWorkspaceLayout) {
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="320"
storageKey="sidebar-width"
>
<div class="sidebar-header">
<h2>{{ 'DOWNLOADS.TITLE' | translate }}</h2>
</div>
<app-category-view
[items]="categories()"
[selectedCategoryId]="selectedCategoryId()"
(categoryClicked)="setCategoryId($any($event).category_id)"
/>
</div>
}
<div class="downloads">
<header class="downloads__header">
<div class="downloads__header-left">
<div class="settings-group__header-icon">
<mat-icon>downloading</mat-icon>
</div>
<div class="settings-group__header-text">
<h1>{{ 'DOWNLOADS.TITLE' | translate }}</h1>
@if (activeCount() > 0) {
<span class="downloads__badge">{{
activeCount()
}}</span>
}
</div>
</div>
<div class="downloads__header-actions">
@if (isAvailable()) {
<div
class="downloads__folder-inline"
[attr.title]="
downloadFolder() ||
('DOWNLOADS.NO_FOLDER' | translate)
"
>
<mat-icon>folder_open</mat-icon>
<span class="downloads__folder-inline-path">
{{
downloadFolder() ||
('DOWNLOADS.NO_FOLDER' | translate)
}}
</span>
</div>
<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>
}
</div>
</header>
@if (!isAvailable()) {
<div class="downloads__empty">
<mat-icon>desktop_access_disabled</mat-icon>
<p>{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}</p>
<span>{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}</span>
</div>
} @else if (!hasDownloads()) {
<div class="downloads__empty">
<mat-icon>download</mat-icon>
<p>{{ 'DOWNLOADS.EMPTY' | translate }}</p>
<span>{{ 'DOWNLOADS.EMPTY_HINT' | translate }}</span>
</div>
} @else if (filteredDownloads().length === 0) {
<div class="downloads__empty">
<mat-icon>search_off</mat-icon>
<p>No downloads match the current filter</p>
<span>Try a different search term</span>
</div>
} @else {
<div class="downloads__list-wrapper">
<div class="downloads__list">
@for (item of filteredDownloads(); track item.id) {
<div
class="downloads__item"
[class]="getStatusColor(item.status)"
[class.downloads__item--clickable]="
isItemNavigable(item)
"
[attr.role]="
isItemNavigable(item) ? 'button' : null
"
[attr.tabindex]="isItemNavigable(item) ? 0 : null"
(click)="openInLibrary(item)"
(keydown)="onItemCardKeydown($event, item)"
>
<div class="downloads__item-poster">
@if (item.posterUrl) {
<img
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<mat-icon>movie</mat-icon>
}
</div>
<div class="downloads__item-info">
<div class="downloads__item-title">
{{ item.title }}
@if (item.contentType === 'episode') {
<span class="downloads__episode-badge">
{{ formatEpisodeLabel(item) }}
</span>
}
</div>
<div class="downloads__item-meta">
<span
class="downloads__item-status"
[class]="getStatusColor(item.status)"
>
<mat-icon>{{
getStatusIcon(item.status)
}}</mat-icon>
{{
'DOWNLOADS.STATUS.' +
item.status.toUpperCase()
| translate
}}
</span>
@if (
item.status === 'downloading' ||
item.status === 'completed'
) {
<span class="downloads__item-size">
{{
formatBytes(
item.bytesDownloaded
)
}}
@if (item.totalBytes) {
/
{{
formatBytes(item.totalBytes)
}}
}
</span>
}
@if (item.errorMessage) {
<span class="downloads__item-error">
{{ item.errorMessage }}
</span>
}
</div>
@if (item.status === 'downloading') {
<mat-progress-bar
mode="determinate"
[value]="getProgress(item)"
></mat-progress-bar>
}
</div>
<div class="downloads__item-actions">
@switch (item.status) {
@case ('queued') {
<button
mat-icon-button
(click)="
$event.stopPropagation();
cancel(item)
"
[matTooltip]="
'DOWNLOADS.CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
}
@case ('downloading') {
<button
mat-icon-button
(click)="
$event.stopPropagation();
cancel(item)
"
[matTooltip]="
'DOWNLOADS.CANCEL' | translate
"
>
<mat-icon>close</mat-icon>
</button>
}
@case ('completed') {
<button
mat-icon-button
color="primary"
(click)="
$event.stopPropagation();
play(item)
"
[matTooltip]="
'DOWNLOADS.PLAY' | translate
"
>
<mat-icon>play_arrow</mat-icon>
</button>
<button
mat-icon-button
(click)="
$event.stopPropagation();
reveal(item)
"
[matTooltip]="
'DOWNLOADS.REVEAL' | translate
"
>
<mat-icon>folder_open</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="
$event.stopPropagation();
remove(item)
"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
@case ('failed') {
<button
mat-icon-button
color="accent"
(click)="
$event.stopPropagation();
retry(item)
"
[matTooltip]="
'DOWNLOADS.RETRY' | translate
"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="
$event.stopPropagation();
remove(item)
"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
@case ('canceled') {
<button
mat-icon-button
color="accent"
(click)="
$event.stopPropagation();
retry(item)
"
[matTooltip]="
'DOWNLOADS.RETRY' | translate
"
>
<mat-icon>refresh</mat-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="
$event.stopPropagation();
remove(item)
"
[matTooltip]="
'DOWNLOADS.REMOVE' | translate
"
>
<mat-icon>delete</mat-icon>
</button>
}
}
</div>
</div>
}
</div>
</div>
}
</div>
</div>
@@ -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;
}
}
@@ -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<XtreamCategory[]>(() => {
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<void> {
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<string | number>
): Array<string | number> {
const sourceSegment = source === 'stalker' ? 'stalker' : 'xtreams';
if (this.isWorkspaceLayout) {
return ['/workspace', sourceSegment, playlistId, ...segments];
}
return [`/${sourceSegment}`, playlistId, ...segments];
}
private async resolveSourceType(
playlistId: string
): Promise<PortalSource | null> {
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<void> {
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<Record<string, unknown>>,
targetId: number
): Record<string, unknown> | 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<Record<string, unknown>> {
try {
const items = (await firstValueFrom(
this.playlistsService.getPortalRecentlyViewed(item.playlistId)
)) as Array<Record<string, unknown>>;
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<void> {
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 },
}
);
}
}