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