Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "portal-downloads-feature",
|
||||
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/portal/downloads/feature/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:portal", "domain:portal-shared", "type:feature"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/downloads.component';
|
||||
@@ -0,0 +1,331 @@
|
||||
<div class="downloads-layout">
|
||||
<div class="downloads">
|
||||
<header class="downloads__header">
|
||||
<div class="downloads__header-left">
|
||||
<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 (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>
|
||||
|
||||
@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 (hasNoPlaylists()) {
|
||||
<app-empty-state
|
||||
type="no-data"
|
||||
icon="download_for_offline"
|
||||
titleKey="DOWNLOADS.NO_PLAYLISTS_TITLE"
|
||||
descriptionKey="DOWNLOADS.NO_PLAYLISTS_BODY"
|
||||
primaryActionLabelKey="HOME.PLAYLISTS.ADD_YOUR_FIRST"
|
||||
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_SOURCES"
|
||||
(primaryActionClicked)="addPlaylist()"
|
||||
(secondaryActionClicked)="goToSources()"
|
||||
/>
|
||||
} @else if (!hasDownloads()) {
|
||||
<app-empty-state
|
||||
type="no-data"
|
||||
icon="download_for_offline"
|
||||
titleKey="DOWNLOADS.EMPTY"
|
||||
descriptionKey="DOWNLOADS.NO_ITEMS_BODY"
|
||||
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_DASHBOARD"
|
||||
(secondaryActionClicked)="goToDashboard()"
|
||||
/>
|
||||
} @else if (filteredDownloads().length === 0) {
|
||||
<div class="downloads__empty">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<p>{{ 'DOWNLOADS.NO_FILTER_MATCH' | translate }}</p>
|
||||
<span>{{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }}</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)
|
||||
"
|
||||
[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 (hasPoster(item)) {
|
||||
<img
|
||||
[src]="item.posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="lazy"
|
||||
(error)="markPosterFailed(item)"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
getPosterPlaceholderIcon(item)
|
||||
}}</mat-icon>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="downloads__item-info">
|
||||
<div class="downloads__item-title">
|
||||
<span class="downloads__item-title-text">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
@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 class="downloads__item-actions">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
copyUrl(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.COPY_URL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
@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>
|
||||
</div>
|
||||
@@ -0,0 +1,418 @@
|
||||
: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: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding: 24px 16px 12px;
|
||||
border-bottom: 1px solid var(--mat-sys-outline-variant);
|
||||
|
||||
&-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.settings-group__header-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
background: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-on-primary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__folder-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
&__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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 4rem;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 520;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__list-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 16px;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr));
|
||||
gap: 12px;
|
||||
padding-bottom: 24px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
min-width: 0;
|
||||
background: var(
|
||||
--app-widget-bg,
|
||||
var(--mat-sys-surface-container-high)
|
||||
);
|
||||
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;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--app-selection-surface);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-border) 72%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&--unavailable {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&-poster {
|
||||
width: 64px;
|
||||
height: 90px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(
|
||||
--app-widget-header-bg,
|
||||
var(--mat-sys-surface-container-high)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--app-separator, var(--mat-sys-outline-variant));
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 2rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 90px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--mat-sys-on-surface);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-title-text {
|
||||
min-width: 0;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
&-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: var(
|
||||
--app-widget-header-bg,
|
||||
var(--mat-sys-surface-container-high)
|
||||
);
|
||||
|
||||
mat-icon {
|
||||
font-size: 1.1rem;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
&.status-downloading {
|
||||
color: #2196f3;
|
||||
background: color-mix(in srgb, #2196f3 12%, transparent);
|
||||
}
|
||||
|
||||
&.status-completed {
|
||||
color: #4caf50;
|
||||
background: color-mix(in srgb, #4caf50 12%, transparent);
|
||||
}
|
||||
|
||||
&.status-failed {
|
||||
color: var(--mat-sys-error);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error) 12%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&.status-queued {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
&-size {
|
||||
font-family:
|
||||
'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas,
|
||||
monospace;
|
||||
background: transparent;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
&-error {
|
||||
color: var(--mat-sys-error);
|
||||
font-size: 0.8rem;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-error) 12%,
|
||||
transparent
|
||||
);
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.2rem;
|
||||
margin-top: auto;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-separator, var(--mat-sys-outline-variant)) 72%,
|
||||
transparent
|
||||
);
|
||||
|
||||
button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 9px;
|
||||
--mdc-icon-button-state-layer-size: 32px;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
opacity: 0.86;
|
||||
|
||||
&:hover {
|
||||
background: var(--app-selection-surface);
|
||||
transform: translateY(-1px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
mat-progress-bar {
|
||||
margin-top: 0.15rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__episode-badge {
|
||||
background: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-on-primary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
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, TranslateService } from '@ngx-translate/core';
|
||||
import { DialogService } from 'components';
|
||||
import { firstValueFrom, map } from 'rxjs';
|
||||
import {
|
||||
DatabaseService,
|
||||
type DownloadItem,
|
||||
DownloadsService,
|
||||
PlaylistsService,
|
||||
} from 'services';
|
||||
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
|
||||
import { queryParamSignal } from '@iptvnator/portal/shared/util';
|
||||
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
buildStandardCollectionCategories,
|
||||
filterCollectionBucket,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PortalCollectionContextService } from '@iptvnator/portal/shared/util';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
|
||||
type PortalSource = 'xtream' | 'stalker';
|
||||
const DOWNLOAD_COLLECTION_LABELS = {
|
||||
all: 'All',
|
||||
movie: 'Movies',
|
||||
live: 'Live TV',
|
||||
series: 'Series',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-downloads',
|
||||
templateUrl: './downloads.component.html',
|
||||
styleUrls: [
|
||||
'./downloads.component.scss',
|
||||
'../../../../shared/ui/src/lib/styles/portal-sidebar.scss',
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
EmptyStateComponent,
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
MatProgressBarModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class DownloadsComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly collectionCtx = inject(PortalCollectionContextService);
|
||||
private readonly dialogService = inject(DialogService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
readonly downloadsService = inject(DownloadsService);
|
||||
|
||||
readonly downloads = this.downloadsService.downloads;
|
||||
readonly downloadFolder = this.downloadsService.downloadFolder;
|
||||
readonly isAvailable = this.downloadsService.isAvailable;
|
||||
readonly hasDownloads = this.downloadsService.hasDownloads;
|
||||
readonly activeCount = this.downloadsService.activeCount;
|
||||
readonly playlistId = toSignal(
|
||||
this.route.paramMap.pipe(map((params) => params.get('id') ?? '')),
|
||||
{ initialValue: this.route.snapshot.params['id'] ?? '' }
|
||||
);
|
||||
readonly searchTerm = queryParamSignal(this.route, 'q', (value) =>
|
||||
(value ?? '').trim().toLowerCase()
|
||||
);
|
||||
readonly playlists = toSignal(this.playlistsService.getAllPlaylists(), {
|
||||
initialValue: [] as Playlist[],
|
||||
});
|
||||
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
|
||||
|
||||
addPlaylist(): void {
|
||||
this.shellActions.openAddPlaylistDialog();
|
||||
}
|
||||
|
||||
goToSources(): void {
|
||||
void this.router.navigate(['/workspace', 'sources']);
|
||||
}
|
||||
|
||||
goToDashboard(): void {
|
||||
void this.router.navigate(['/workspace', 'dashboard']);
|
||||
}
|
||||
|
||||
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 buildStandardCollectionCategories({
|
||||
labels: DOWNLOAD_COLLECTION_LABELS,
|
||||
counts: {
|
||||
all: downloads.length,
|
||||
movie: moviesCount,
|
||||
series: seriesCount,
|
||||
},
|
||||
});
|
||||
});
|
||||
readonly collectionContext = createPortalCollectionContext({
|
||||
ctx: this.collectionCtx,
|
||||
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(() => {
|
||||
const term = this.searchTerm();
|
||||
const downloads = this.scopedDownloads();
|
||||
const filteredByTerm = filterCollectionBucket({
|
||||
selectedCategoryId: this.selectedCategoryId(),
|
||||
allItems: downloads,
|
||||
buckets: {
|
||||
movie: downloads.filter((item) => item.contentType === 'vod'),
|
||||
series: downloads.filter(
|
||||
(item) => item.contentType === 'episode'
|
||||
),
|
||||
},
|
||||
searchTerm: term,
|
||||
textOf: (item) => `${item.title ?? ''} ${item.errorMessage ?? ''}`,
|
||||
});
|
||||
|
||||
// Sort by createdAt descending (newest first)
|
||||
return [...filteredByTerm].sort((a, b) => {
|
||||
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
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(() => {
|
||||
const playlistId = this.playlistId();
|
||||
this.collectionContext.setCategoryId('all');
|
||||
void this.downloadsService.loadDownloads(playlistId || undefined);
|
||||
});
|
||||
}
|
||||
|
||||
getProgress(item: DownloadItem): number {
|
||||
return this.downloadsService.getProgressPercent(item);
|
||||
}
|
||||
|
||||
formatBytes(bytes: number | undefined): string {
|
||||
if (!bytes) return '0 B';
|
||||
return this.downloadsService.formatBytes(bytes);
|
||||
}
|
||||
|
||||
getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'schedule';
|
||||
case 'downloading':
|
||||
return 'downloading';
|
||||
case 'completed':
|
||||
return 'check_circle';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
case 'canceled':
|
||||
return 'cancel';
|
||||
default:
|
||||
return 'help';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return 'status-queued';
|
||||
case 'downloading':
|
||||
return 'status-downloading';
|
||||
case 'completed':
|
||||
return 'status-completed';
|
||||
case 'failed':
|
||||
case 'canceled':
|
||||
return 'status-failed';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async copyUrl(item: DownloadItem): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(item.url);
|
||||
this.snackBar.open(
|
||||
this.translate.instant('DOWNLOADS.URL_COPIED'),
|
||||
undefined,
|
||||
{ duration: 2000, horizontalPosition: 'start' }
|
||||
);
|
||||
} catch {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('DOWNLOADS.URL_COPY_FAILED'),
|
||||
undefined,
|
||||
{ duration: 3000, horizontalPosition: 'start' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(item: DownloadItem) {
|
||||
await this.downloadsService.cancelDownload(item.id);
|
||||
}
|
||||
|
||||
async retry(item: DownloadItem) {
|
||||
await this.downloadsService.retryDownload(item.id);
|
||||
}
|
||||
|
||||
async remove(item: DownloadItem) {
|
||||
await this.downloadsService.removeDownload(item.id);
|
||||
}
|
||||
|
||||
async play(item: DownloadItem) {
|
||||
if (item.filePath) {
|
||||
const result = await this.downloadsService.playDownload(
|
||||
item.filePath
|
||||
);
|
||||
if (!result.success) {
|
||||
this.handleFileActionError(result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reveal(item: DownloadItem) {
|
||||
if (item.filePath) {
|
||||
const result = await this.downloadsService.revealFile(item.filePath);
|
||||
if (!result.success) {
|
||||
this.handleFileActionError(result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async changeFolder() {
|
||||
await this.downloadsService.selectFolder();
|
||||
}
|
||||
|
||||
async clearCompleted() {
|
||||
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 {
|
||||
if (item.contentType !== 'episode') return '';
|
||||
const s = item.seasonNumber?.toString().padStart(2, '0') || '00';
|
||||
const e = item.episodeNumber?.toString().padStart(2, '0') || '00';
|
||||
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.hasSourcePlaylist(item) &&
|
||||
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.hasSourcePlaylist(item)) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant('DOWNLOADS.SOURCE_PLAYLIST_MISSING'),
|
||||
undefined,
|
||||
{ duration: 3000, horizontalPosition: 'start' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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';
|
||||
return ['/workspace', 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 },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user