mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat(downloads): render offline movie and series details
This commit is contained in:
1 parent
e936748fe3
commit
a741415a6a
7 files changed
+1756
-5
No files matched your search
+423
@@ -0,0 +1,423 @@
|
||||
@if (detail(); as offlineDetail) {
|
||||
<app-portal-detail-shell
|
||||
[title]="title()"
|
||||
[description]="description()"
|
||||
[posterUrl]="posterUrl()"
|
||||
[backdropUrl]="backdropUrl()"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
<ng-template appDetailTags>
|
||||
<span class="details__tag offline-detail__available">
|
||||
<mat-icon aria-hidden="true">download_done</mat-icon>
|
||||
{{ 'DOWNLOADS.OFFLINE_DETAIL.AVAILABLE' | translate }}
|
||||
</span>
|
||||
@if (metadata()?.year; as year) {
|
||||
@if (positiveFinite(year)) {
|
||||
<span class="details__tag">{{ year }}</span>
|
||||
}
|
||||
}
|
||||
@if (durationLabel(); as duration) {
|
||||
<span class="details__tag">{{ duration }}</span>
|
||||
}
|
||||
@for (genre of genres(); track genre) {
|
||||
<span class="details__tag">{{ genre }}</span>
|
||||
}
|
||||
@if (metadata()?.rating; as rating) {
|
||||
@if (positiveFinite(rating)) {
|
||||
<span class="details__tag details__tag--rating">
|
||||
<span aria-hidden="true">★</span> {{ rating }}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
@if (metadata()?.status; as status) {
|
||||
@if (status.trim()) {
|
||||
<span class="details__tag details__tag--status">
|
||||
{{ status }}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template appDetailMeta>
|
||||
@if (cast().length) {
|
||||
<div class="details__meta-item offline-detail__people-group">
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
<div class="details__cast-strip">
|
||||
@for (
|
||||
person of cast();
|
||||
track person.tmdbPersonId ?? person.name
|
||||
) {
|
||||
<span
|
||||
class="details__cast-chip offline-detail__person"
|
||||
[title]="
|
||||
person.role
|
||||
? person.name + ' — ' + person.role
|
||||
: person.name
|
||||
"
|
||||
>
|
||||
@if (person.profileUrl) {
|
||||
<img
|
||||
class="details__cast-avatar"
|
||||
[src]="person.profileUrl"
|
||||
[alt]="person.name"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__cast-avatar details__cast-avatar--fallback"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ person.name.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__cast-name">{{
|
||||
person.name
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (creators().length) {
|
||||
<div class="details__meta-item offline-detail__people-group">
|
||||
<span class="label">{{
|
||||
'XTREAM.DIRECTOR' | translate
|
||||
}}</span>
|
||||
<div class="details__cast-strip">
|
||||
@for (
|
||||
person of creators();
|
||||
track person.tmdbPersonId ?? person.name
|
||||
) {
|
||||
<span
|
||||
class="details__cast-chip offline-detail__person"
|
||||
[title]="person.name"
|
||||
>
|
||||
@if (person.profileUrl) {
|
||||
<img
|
||||
class="details__cast-avatar"
|
||||
[src]="person.profileUrl"
|
||||
[alt]="person.name"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__cast-avatar details__cast-avatar--fallback"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ person.name.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__cast-name">{{
|
||||
person.name
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (movieFileSize(); as size) {
|
||||
<div class="details__meta-item">
|
||||
<span class="value">
|
||||
{{
|
||||
'DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE'
|
||||
| translate: { size }
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template appDetailActions>
|
||||
@if (offlineDetail.kind === 'movie') {
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
class="offline-detail__primary-action"
|
||||
data-testid="offline-play"
|
||||
[disabled]="isPending(offlineDetail.item)"
|
||||
(click)="runFileAction('play', offlineDetail.item)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">play_arrow</mat-icon>
|
||||
{{ 'DOWNLOADS.OFFLINE_DETAIL.PLAY' | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<span
|
||||
class="offline-detail__portal-action"
|
||||
[matTooltip]="
|
||||
canOpenInPortal()
|
||||
? ''
|
||||
: ('DOWNLOADS.OFFLINE_DETAIL.VIEW_IN_PORTAL_UNAVAILABLE'
|
||||
| translate)
|
||||
"
|
||||
>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-testid="view-in-portal"
|
||||
[disabled]="!canOpenInPortal()"
|
||||
[attr.aria-describedby]="
|
||||
canOpenInPortal()
|
||||
? null
|
||||
: 'offline-view-in-portal-unavailable'
|
||||
"
|
||||
(click)="viewInPortal()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||
{{ 'DOWNLOADS.OFFLINE_DETAIL.VIEW_IN_PORTAL' | translate }}
|
||||
</button>
|
||||
</span>
|
||||
@if (!canOpenInPortal()) {
|
||||
<span
|
||||
id="offline-view-in-portal-unavailable"
|
||||
class="offline-detail__visually-hidden"
|
||||
>
|
||||
{{
|
||||
'DOWNLOADS.OFFLINE_DETAIL.VIEW_IN_PORTAL_UNAVAILABLE'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (offlineDetail.kind === 'movie') {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
data-testid="movie-overflow"
|
||||
[matMenuTriggerFor]="movieMenu"
|
||||
[disabled]="isPending(offlineDetail.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.MORE_ACTIONS' | translate) + ': ' + title()
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true">more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #movieMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
data-testid="movie-reveal"
|
||||
[disabled]="isPending(offlineDetail.item)"
|
||||
(click)="runFileAction('reveal', offlineDetail.item)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">folder_open</mat-icon>
|
||||
<span>{{ 'DOWNLOADS.REVEAL' | translate }}</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@if (offlineDetail.kind === 'series') {
|
||||
<section
|
||||
detail-episodes
|
||||
class="offline-detail__episodes"
|
||||
[attr.aria-label]="
|
||||
'DOWNLOADS.OFFLINE_DETAIL.DOWNLOADED_EPISODES'
|
||||
| translate
|
||||
: {
|
||||
count: count(),
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="offline-detail__season-tabs" role="tablist">
|
||||
@for (
|
||||
season of offlineDetail.seasons;
|
||||
track seasonTestId(season)
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="offline-detail__season-tab"
|
||||
[class.offline-detail__season-tab--selected]="
|
||||
isSelectedSeason(season)
|
||||
"
|
||||
[attr.aria-selected]="isSelectedSeason(season)"
|
||||
[attr.aria-controls]="
|
||||
seasonTestId(season) + '-panel'
|
||||
"
|
||||
[attr.data-testid]="seasonTestId(season)"
|
||||
(click)="selectSeason(season)"
|
||||
>
|
||||
<span>
|
||||
@if (season.seasonNumberState === 'known') {
|
||||
{{
|
||||
'DOWNLOADS.SEASON'
|
||||
| translate
|
||||
: {
|
||||
season: season.seasonNumber,
|
||||
}
|
||||
}}
|
||||
} @else {
|
||||
S??
|
||||
}
|
||||
</span>
|
||||
<span class="offline-detail__season-count">
|
||||
{{
|
||||
'DOWNLOADS.OFFLINE_DETAIL.DOWNLOADED_EPISODES'
|
||||
| translate
|
||||
: { count: season.episodes.length }
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedSeason(); as season) {
|
||||
<div
|
||||
role="tabpanel"
|
||||
class="offline-detail__episode-list"
|
||||
[id]="seasonTestId(season) + '-panel'"
|
||||
>
|
||||
@for (
|
||||
episode of season.episodes;
|
||||
track episode.item.id
|
||||
) {
|
||||
<article
|
||||
class="offline-detail__episode"
|
||||
[attr.data-testid]="
|
||||
'offline-episode-' + episode.item.id
|
||||
"
|
||||
>
|
||||
<div class="offline-detail__episode-copy">
|
||||
<span class="offline-detail__coordinate">
|
||||
{{
|
||||
episodeCoordinate(
|
||||
episode.seasonNumber,
|
||||
episode.episodeNumber
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<strong
|
||||
class="offline-detail__episode-title"
|
||||
>
|
||||
{{
|
||||
episodeTitle(
|
||||
episode.item,
|
||||
episode.episodeMetadata?.title
|
||||
)
|
||||
}}
|
||||
</strong>
|
||||
<div class="offline-detail__episode-facts">
|
||||
@if (
|
||||
formatItemBytes(episode.item);
|
||||
as size
|
||||
) {
|
||||
<span>
|
||||
{{
|
||||
'DOWNLOADS.OFFLINE_DETAIL.FILE_SIZE'
|
||||
| translate: { size }
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
<span
|
||||
class="offline-detail__episode-available"
|
||||
>
|
||||
<mat-icon aria-hidden="true"
|
||||
>download_done</mat-icon
|
||||
>
|
||||
{{
|
||||
'DOWNLOADS.OFFLINE_DETAIL.AVAILABLE'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="offline-detail__episode-actions">
|
||||
<button
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[attr.data-testid]="
|
||||
'episode-play-' + episode.item.id
|
||||
"
|
||||
[disabled]="isPending(episode.item)"
|
||||
(click)="
|
||||
runFileAction('play', episode.item)
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true"
|
||||
>play_arrow</mat-icon
|
||||
>
|
||||
{{
|
||||
'DOWNLOADS.OFFLINE_DETAIL.PLAY'
|
||||
| translate
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[matMenuTriggerFor]="episodeMenu"
|
||||
[disabled]="isPending(episode.item)"
|
||||
[attr.data-testid]="
|
||||
'episode-overflow-' +
|
||||
episode.item.id
|
||||
"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.MORE_ACTIONS'
|
||||
| translate) +
|
||||
': ' +
|
||||
episodeTitle(
|
||||
episode.item,
|
||||
episode.episodeMetadata?.title
|
||||
)
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true"
|
||||
>more_vert</mat-icon
|
||||
>
|
||||
</button>
|
||||
<mat-menu #episodeMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="isPending(episode.item)"
|
||||
(click)="
|
||||
runFileAction(
|
||||
'reveal',
|
||||
episode.item
|
||||
)
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true">
|
||||
folder_open
|
||||
</mat-icon>
|
||||
<span>{{
|
||||
'DOWNLOADS.REVEAL' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</app-portal-detail-shell>
|
||||
} @else {
|
||||
<app-portal-detail-shell
|
||||
[isLoading]="showLoading()"
|
||||
[errorMessage]="
|
||||
showNotFound()
|
||||
? ('DOWNLOADS.OFFLINE_DETAIL.NOT_FOUND_TITLE' | translate)
|
||||
: undefined
|
||||
"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
@if (showNotFound()) {
|
||||
<div hero-error-actions class="offline-detail__not-found-actions">
|
||||
<p>
|
||||
{{ 'DOWNLOADS.OFFLINE_DETAIL.NOT_FOUND_BODY' | translate }}
|
||||
</p>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-testid="not-found-back"
|
||||
(click)="goBack()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
||||
{{ 'DOWNLOADS.OFFLINE_DETAIL.BACK' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</app-portal-detail-shell>
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
.offline-detail__portal-action {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.offline-detail__available,
|
||||
.offline-detail__episode-available {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.offline-detail__people-group {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.offline-detail__person {
|
||||
cursor: default;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.offline-detail__portal-action {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.offline-detail__visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.offline-detail__episodes {
|
||||
display: block;
|
||||
padding: 24px 0 40px;
|
||||
}
|
||||
|
||||
.offline-detail__season-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-block: 0 16px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.offline-detail__season-tab {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: 2px;
|
||||
min-width: 132px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--mat-sys-outline-variant, var(--tag-border));
|
||||
border-radius: var(--mat-sys-corner-medium, 10px);
|
||||
background: var(--mat-sys-surface-container, var(--tag-bg));
|
||||
color: var(--mat-sys-on-surface, var(--text-primary));
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--mat-sys-surface-container-high, var(--tag-bg));
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: var(
|
||||
--app-selection-border,
|
||||
var(--mat-sys-primary, var(--text-primary))
|
||||
);
|
||||
background: var(--mat-sys-secondary-container, var(--tag-bg));
|
||||
color: var(--mat-sys-on-secondary-container, var(--text-primary));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid
|
||||
var(--app-selection-border, var(--mat-sys-primary, currentColor));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.offline-detail__season-count {
|
||||
color: var(--mat-sys-on-surface-variant, var(--text-secondary));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.offline-detail__episode-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.offline-detail__episode {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 72px;
|
||||
padding: 12px 12px 12px 16px;
|
||||
border: 1px solid var(--mat-sys-outline-variant, var(--tag-border));
|
||||
border-radius: var(--mat-sys-corner-medium, 10px);
|
||||
background: var(--mat-sys-surface-container, var(--surface-bg));
|
||||
}
|
||||
|
||||
.offline-detail__episode-copy {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
gap: 4px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.offline-detail__coordinate {
|
||||
color: var(--mat-sys-primary, var(--text-primary));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.offline-detail__episode-title {
|
||||
overflow: hidden;
|
||||
color: var(--mat-sys-on-surface, var(--text-primary));
|
||||
font-size: 0.9375rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.offline-detail__episode-facts {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
color: var(--mat-sys-on-surface-variant, var(--text-secondary));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.offline-detail__episode-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.offline-detail__not-found-actions {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
color: var(--mat-sys-on-surface-variant, var(--text-secondary));
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.offline-detail__episode {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.offline-detail__episode-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.offline-detail__season-tab {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
+614
@@ -0,0 +1,614 @@
|
||||
import { Location } from '@angular/common';
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
type DownloadItem,
|
||||
DownloadsService,
|
||||
PlaylistsService,
|
||||
} from '@iptvnator/services';
|
||||
import type {
|
||||
DownloadMetadataSnapshot,
|
||||
Playlist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { BehaviorSubject, type Observable, throwError } from 'rxjs';
|
||||
import { DownloadLibraryNavigationService } from '../download-library-navigation.service';
|
||||
import { DownloadManagerActionsService } from '../download-manager-actions.service';
|
||||
import { DownloadOfflineDetailComponent } from './download-offline-detail.component';
|
||||
import { DownloadOfflineMetadataResolutionService } from './download-offline-metadata-resolution.service';
|
||||
import { DownloadOfflineMetadataService } from './download-offline-metadata.service';
|
||||
import { DownloadOfflineRouteNavigationService } from './download-offline-route-navigation.service';
|
||||
|
||||
const TRANSLATIONS = {
|
||||
DOWNLOADS: {
|
||||
ACTION_FAILED: 'Action failed',
|
||||
MORE_ACTIONS: 'More actions',
|
||||
REVEAL: 'Show in folder',
|
||||
SEASON: 'Season {{season}}',
|
||||
OFFLINE_DETAIL: {
|
||||
AVAILABLE: 'Available offline',
|
||||
PLAY: 'Play offline',
|
||||
VIEW_IN_PORTAL: 'View in portal',
|
||||
VIEW_IN_PORTAL_UNAVAILABLE: 'The source is unavailable',
|
||||
DOWNLOADED_EPISODES: '{{count}} downloaded episodes',
|
||||
FILE_SIZE: '{{size}}',
|
||||
NOT_FOUND_TITLE: 'Download not found',
|
||||
NOT_FOUND_BODY: 'This download is no longer in the manager.',
|
||||
BACK: 'Back to Downloads',
|
||||
},
|
||||
},
|
||||
XTREAM: {
|
||||
ACTORS: 'Cast',
|
||||
DIRECTOR: 'Creators',
|
||||
},
|
||||
SHOW_LESS: 'Show less',
|
||||
SHOW_MORE: 'Show more',
|
||||
} as const;
|
||||
|
||||
interface Deferred<T> {
|
||||
readonly promise: Promise<T>;
|
||||
readonly resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
interface DownloadsFake {
|
||||
readonly downloads: ReturnType<typeof signal<DownloadItem[]>>;
|
||||
readonly isLoadingDownloads: ReturnType<typeof signal<boolean>>;
|
||||
readonly hasLoadedDownloads: ReturnType<typeof signal<boolean>>;
|
||||
readonly loadDownloads: jest.Mock<Promise<void>, []>;
|
||||
readonly formatBytes: jest.Mock<string, [number]>;
|
||||
}
|
||||
|
||||
interface ActionsFake {
|
||||
readonly pendingIds: ReturnType<typeof signal<ReadonlySet<number>>>;
|
||||
readonly run: jest.Mock<Promise<void>, [unknown]>;
|
||||
readonly showActionError: jest.Mock<void, []>;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
mediaKind: DownloadMetadataSnapshot['mediaKind'],
|
||||
title: string,
|
||||
overrides: Partial<DownloadMetadataSnapshot> = {}
|
||||
): DownloadMetadataSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
language: 'en',
|
||||
mediaKind,
|
||||
title,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function download(
|
||||
id: number,
|
||||
overrides: Partial<DownloadItem> = {}
|
||||
): DownloadItem {
|
||||
return {
|
||||
id,
|
||||
playlistId: 'playlist-a',
|
||||
xtreamId: 1_000 + id,
|
||||
contentType: 'vod',
|
||||
title: `Download ${id}`,
|
||||
url: `https://media.example.test/${id}`,
|
||||
filePath: `/downloads/${id}.mp4`,
|
||||
fileAvailability: 'available',
|
||||
status: 'completed',
|
||||
totalBytes: 1_024,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function playlist(id: string): Playlist {
|
||||
return { _id: id, title: id } as Playlist;
|
||||
}
|
||||
|
||||
describe('DownloadOfflineDetailComponent', () => {
|
||||
let fixture: ComponentFixture<DownloadOfflineDetailComponent>;
|
||||
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let playlistItems: BehaviorSubject<Playlist[]>;
|
||||
let playlistsObservable: Observable<Playlist[]>;
|
||||
let downloads: DownloadsFake;
|
||||
let actions: ActionsFake;
|
||||
let navigation: {
|
||||
canOpen: jest.Mock<boolean, [DownloadItem, ReadonlySet<string>]>;
|
||||
open: jest.Mock<Promise<boolean>, [DownloadItem]>;
|
||||
};
|
||||
let metadata: {
|
||||
resolve: jest.Mock<Promise<DownloadMetadataSnapshot>, [unknown]>;
|
||||
};
|
||||
let router: {
|
||||
url: string;
|
||||
navigate: jest.Mock;
|
||||
navigateByUrl: jest.Mock;
|
||||
};
|
||||
let historyState: Record<string, unknown>;
|
||||
let location: { back: jest.Mock; getState: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
routeParams = new BehaviorSubject(
|
||||
convertToParamMap({ downloadId: '17' })
|
||||
);
|
||||
playlistItems = new BehaviorSubject([playlist('playlist-a')]);
|
||||
playlistsObservable = playlistItems.asObservable();
|
||||
downloads = {
|
||||
downloads: signal<DownloadItem[]>([]),
|
||||
isLoadingDownloads: signal(false),
|
||||
hasLoadedDownloads: signal(true),
|
||||
loadDownloads: jest.fn().mockResolvedValue(undefined),
|
||||
formatBytes: jest.fn((bytes: number) => `${bytes / 1_024} KB`),
|
||||
};
|
||||
actions = {
|
||||
pendingIds: signal<ReadonlySet<number>>(new Set()),
|
||||
run: jest.fn().mockResolvedValue(undefined),
|
||||
showActionError: jest.fn(),
|
||||
};
|
||||
navigation = {
|
||||
canOpen: jest.fn((item, availableIds) =>
|
||||
availableIds.has(item.playlistId)
|
||||
),
|
||||
open: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
metadata = {
|
||||
resolve: jest.fn(async (detail: unknown) => {
|
||||
const value = detail as {
|
||||
kind: 'movie' | 'series';
|
||||
item?: DownloadItem;
|
||||
representative?: DownloadItem;
|
||||
snapshot?: DownloadMetadataSnapshot;
|
||||
};
|
||||
const item = value.item ?? value.representative;
|
||||
return (
|
||||
value.snapshot ??
|
||||
snapshot(value.kind, item?.title ?? 'Offline download')
|
||||
);
|
||||
}),
|
||||
};
|
||||
router = {
|
||||
url: '/workspace/downloads/17',
|
||||
navigate: jest.fn().mockResolvedValue(true),
|
||||
navigateByUrl: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
historyState = {};
|
||||
location = {
|
||||
back: jest.fn(),
|
||||
getState: jest.fn(() => historyState),
|
||||
};
|
||||
|
||||
const activatedRoute = {
|
||||
paramMap: routeParams.asObservable(),
|
||||
snapshot: {
|
||||
paramMap: routeParams.value,
|
||||
params: { downloadId: '17' },
|
||||
},
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
DownloadOfflineDetailComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: Location, useValue: location },
|
||||
{
|
||||
provide: DownloadsService,
|
||||
useValue: downloads,
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
getAllPlaylists: () => playlistsObservable,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(DownloadOfflineDetailComponent, {
|
||||
set: {
|
||||
providers: [
|
||||
{
|
||||
provide: DownloadManagerActionsService,
|
||||
useValue: actions,
|
||||
},
|
||||
{
|
||||
provide: DownloadLibraryNavigationService,
|
||||
useValue: navigation,
|
||||
},
|
||||
{
|
||||
provide: DownloadOfflineMetadataService,
|
||||
useValue: metadata,
|
||||
},
|
||||
DownloadOfflineMetadataResolutionService,
|
||||
DownloadOfflineRouteNavigationService,
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', TRANSLATIONS);
|
||||
translate.use('en');
|
||||
});
|
||||
|
||||
afterEach(() => fixture?.destroy());
|
||||
|
||||
async function render(items: DownloadItem[] = []): Promise<void> {
|
||||
downloads.downloads.set(items);
|
||||
fixture = TestBed.createComponent(DownloadOfflineDetailComponent);
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
function text(): string {
|
||||
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
}
|
||||
|
||||
function button(testId: string): HTMLButtonElement {
|
||||
const selector = `[data-testid="${testId}"]`;
|
||||
const element =
|
||||
(fixture.nativeElement as HTMLElement).querySelector(selector) ??
|
||||
document.body.querySelector(selector);
|
||||
expect(element).toBeTruthy();
|
||||
return element as HTMLButtonElement;
|
||||
}
|
||||
|
||||
it('loads downloads on entry and renders the focused loading state', async () => {
|
||||
downloads.hasLoadedDownloads.set(false);
|
||||
downloads.isLoadingDownloads.set(true);
|
||||
|
||||
await render();
|
||||
|
||||
expect(downloads.loadDownloads).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'ngx-skeleton-loader'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(text()).not.toContain('Download not found');
|
||||
});
|
||||
|
||||
it('renders persisted movie metadata immediately with row artwork fallback and local-only actions', async () => {
|
||||
const movie = download(17, {
|
||||
posterUrl: 'https://images.example.test/fallback.jpg',
|
||||
totalBytes: 2_048,
|
||||
metadataSnapshot: snapshot('movie', 'Signal', {
|
||||
plot: 'A local-first mystery.',
|
||||
year: 2026,
|
||||
durationMinutes: 112,
|
||||
genres: ['Drama', 'Mystery'],
|
||||
rating: 8.4,
|
||||
status: 'Released',
|
||||
cast: [{ name: 'Ada Actor', role: 'Mara' }],
|
||||
creators: [{ name: 'Cora Creator' }],
|
||||
}),
|
||||
});
|
||||
|
||||
await render([movie]);
|
||||
|
||||
expect(text()).toContain('Signal');
|
||||
expect(text()).toContain('A local-first mystery.');
|
||||
expect(text()).toContain('Available offline');
|
||||
expect(text()).toContain('2026');
|
||||
expect(text()).toContain('112 min');
|
||||
expect(text()).toContain('Drama');
|
||||
expect(text()).toContain('8.4');
|
||||
expect(text()).toContain('Released');
|
||||
expect(text()).toContain('Ada Actor');
|
||||
expect(text()).toContain('Cora Creator');
|
||||
expect(text()).toContain('2 KB');
|
||||
expect(text()).not.toContain('{{size}}');
|
||||
expect(downloads.formatBytes).toHaveBeenCalledWith(2_048);
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'img[src="https://images.example.test/fallback.jpg"]'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(button('offline-play').textContent).toContain('Play offline');
|
||||
expect(button('view-in-portal').textContent).toContain(
|
||||
'View in portal'
|
||||
);
|
||||
expect(button('movie-overflow')).toBeTruthy();
|
||||
expect(text()).not.toContain('Play from source');
|
||||
});
|
||||
|
||||
it('omits sparse facts and does not format non-positive or non-finite file sizes', async () => {
|
||||
await render([
|
||||
download(17, {
|
||||
title: 'Sparse movie',
|
||||
totalBytes: Number.NaN,
|
||||
bytesDownloaded: 0,
|
||||
metadataSnapshot: snapshot('movie', 'Sparse movie', {
|
||||
durationMinutes: -1,
|
||||
genres: [' '],
|
||||
rating: Number.POSITIVE_INFINITY,
|
||||
status: ' ',
|
||||
year: Number.NaN,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(text()).toContain('Sparse movie');
|
||||
expect(text()).not.toContain('File size');
|
||||
expect(text()).not.toContain('Infinity');
|
||||
expect(downloads.formatBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders only available series seasons and episodes in natural order with explicit local actions', async () => {
|
||||
const seriesMetadata = snapshot('series', 'Northwind', {
|
||||
plot: 'Signals travel north.',
|
||||
});
|
||||
const episode = (
|
||||
id: number,
|
||||
seasonNumber: number,
|
||||
episodeNumber: number,
|
||||
title: string,
|
||||
overrides: Partial<DownloadItem> = {}
|
||||
) =>
|
||||
download(id, {
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 77,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
title,
|
||||
metadataSnapshot: {
|
||||
...seriesMetadata,
|
||||
episode: { seasonNumber, episodeNumber, title },
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
await render([
|
||||
episode(17, 2, 1, 'Second season'),
|
||||
episode(18, 1, 2, 'The Signal'),
|
||||
episode(19, 1, 1, 'Arrival'),
|
||||
episode(20, 1, 3, 'Missing', {
|
||||
fileAvailability: 'missing',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(text()).toContain('Northwind');
|
||||
expect(button('view-in-portal')).toBeTruthy();
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-testid="offline-play"]'
|
||||
)
|
||||
).toBeNull();
|
||||
const seasons = Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll(
|
||||
'[data-testid^="offline-season-"]'
|
||||
)
|
||||
) as HTMLButtonElement[];
|
||||
expect(seasons).toHaveLength(2);
|
||||
expect(seasons[0].getAttribute('aria-selected')).toBe('true');
|
||||
expect(seasons[0].textContent).toContain('2 downloaded episodes');
|
||||
const firstSeasonEpisodes = Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll(
|
||||
'[data-testid^="offline-episode-"]'
|
||||
)
|
||||
) as HTMLElement[];
|
||||
expect(
|
||||
firstSeasonEpisodes.map(({ dataset }) => dataset['testid'])
|
||||
).toEqual(['offline-episode-19', 'offline-episode-18']);
|
||||
expect(firstSeasonEpisodes[0].textContent).toContain('S01E01');
|
||||
expect(firstSeasonEpisodes[0].textContent).toContain('Arrival');
|
||||
expect(firstSeasonEpisodes[0].textContent).toContain(
|
||||
'Available offline'
|
||||
);
|
||||
expect(button('episode-play-19')).toBeTruthy();
|
||||
expect(button('episode-overflow-19')).toBeTruthy();
|
||||
|
||||
seasons[1].click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(seasons[1].getAttribute('aria-selected')).toBe('true');
|
||||
expect(text()).toContain('S02E01');
|
||||
expect(text()).not.toContain('Missing');
|
||||
});
|
||||
|
||||
it('shows a focused not-found state when the row is absent after the initial load', async () => {
|
||||
await render();
|
||||
|
||||
expect(text()).toContain('Download not found');
|
||||
expect(text()).toContain('This download is no longer in the manager.');
|
||||
expect(button('not-found-back').textContent).toContain(
|
||||
'Back to Downloads'
|
||||
);
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
expect(router.navigateByUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps View in portal visible but disabled and described when its source playlist is missing', async () => {
|
||||
playlistItems.next([]);
|
||||
navigation.canOpen.mockReturnValue(false);
|
||||
|
||||
await render([download(17, { title: 'Orphaned movie' })]);
|
||||
|
||||
const viewButton = button('view-in-portal');
|
||||
expect(viewButton.disabled).toBe(true);
|
||||
expect(viewButton.getAttribute('aria-describedby')).toBe(
|
||||
'offline-view-in-portal-unavailable'
|
||||
);
|
||||
expect(text()).toContain('The source is unavailable');
|
||||
expect(navigation.canOpen).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 17 }),
|
||||
new Set()
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps local playback available when loading source playlists fails', async () => {
|
||||
playlistsObservable = throwError(
|
||||
() => new Error('playlist storage unavailable')
|
||||
);
|
||||
|
||||
await render([download(17, { title: 'Offline survivor' })]);
|
||||
|
||||
expect(text()).toContain('Offline survivor');
|
||||
expect(button('offline-play').disabled).toBe(false);
|
||||
expect(button('view-in-portal').disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('replace-navigates to the validated manager return URL when a completed file is initially missing', async () => {
|
||||
historyState = {
|
||||
navigationId: 4,
|
||||
returnUrl: '/workspace/downloads?q=signal&filter=movie',
|
||||
};
|
||||
|
||||
await render([
|
||||
download(17, {
|
||||
fileAvailability: 'missing',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith(
|
||||
'/workspace/downloads?q=signal&filter=movie',
|
||||
{ replaceUrl: true }
|
||||
);
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['play', 'reveal'] as const)(
|
||||
'returns to the manager when a local %s race reloads the current file as missing',
|
||||
async (actionType) => {
|
||||
const operation = deferred<void>();
|
||||
actions.run.mockReturnValueOnce(operation.promise);
|
||||
await render([download(17), download(18, { title: 'Other' })]);
|
||||
|
||||
button(
|
||||
actionType === 'play' ? 'offline-play' : 'movie-overflow'
|
||||
).click();
|
||||
if (actionType === 'reveal') {
|
||||
await fixture.whenStable();
|
||||
button('movie-reveal').click();
|
||||
}
|
||||
actions.pendingIds.set(new Set([17]));
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(button('offline-play').disabled).toBe(true);
|
||||
expect(button('view-in-portal').disabled).toBe(false);
|
||||
downloads.downloads.set([
|
||||
download(17, { fileAvailability: 'missing' }),
|
||||
download(18, { title: 'Other' }),
|
||||
]);
|
||||
operation.resolve(undefined);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(actions.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: actionType,
|
||||
item: expect.objectContaining({ id: 17 }),
|
||||
})
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith(['..'], {
|
||||
relativeTo: expect.anything(),
|
||||
queryParamsHandling: 'preserve',
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('reacts to route reuse and displays the new row before metadata resolution completes', async () => {
|
||||
const secondResolution = deferred<DownloadMetadataSnapshot>();
|
||||
metadata.resolve
|
||||
.mockResolvedValueOnce(snapshot('movie', 'Resolved first'))
|
||||
.mockReturnValueOnce(secondResolution.promise);
|
||||
await render([
|
||||
download(17, { title: 'Local first' }),
|
||||
download(18, { title: 'Local second' }),
|
||||
]);
|
||||
|
||||
routeParams.next(convertToParamMap({ downloadId: '18' }));
|
||||
router.url = '/workspace/downloads/18';
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(text()).toContain('Local second');
|
||||
expect(text()).not.toContain('Resolved first');
|
||||
expect(metadata.resolve).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('discards an older asynchronous metadata result after a route-id change', async () => {
|
||||
const firstResolution = deferred<DownloadMetadataSnapshot>();
|
||||
const secondResolution = deferred<DownloadMetadataSnapshot>();
|
||||
metadata.resolve
|
||||
.mockReturnValueOnce(firstResolution.promise)
|
||||
.mockReturnValueOnce(secondResolution.promise);
|
||||
await render([
|
||||
download(17, { title: 'Local first' }),
|
||||
download(18, { title: 'Local second' }),
|
||||
]);
|
||||
|
||||
routeParams.next(convertToParamMap({ downloadId: '18' }));
|
||||
router.url = '/workspace/downloads/18';
|
||||
await fixture.whenStable();
|
||||
secondResolution.resolve(snapshot('movie', 'Resolved second'));
|
||||
await fixture.whenStable();
|
||||
firstResolution.resolve(snapshot('movie', 'Stale first'));
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(text()).toContain('Resolved second');
|
||||
expect(text()).not.toContain('Stale first');
|
||||
});
|
||||
|
||||
it('opens the current provider target and reports a navigation failure without blocking local playback', async () => {
|
||||
navigation.open.mockResolvedValueOnce(false);
|
||||
await render([download(17)]);
|
||||
|
||||
button('view-in-portal').click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(navigation.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 17 })
|
||||
);
|
||||
expect(actions.showActionError).toHaveBeenCalledTimes(1);
|
||||
expect(button('offline-play').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('uses browser Back only for a validated manager return URL', async () => {
|
||||
historyState = {
|
||||
navigationId: 9,
|
||||
returnUrl: '/workspace/downloads?q=signal',
|
||||
};
|
||||
await render([download(17)]);
|
||||
|
||||
(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.hero__back-button'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(location.back).toHaveBeenCalledTimes(1);
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an external return URL and falls back to the scoped parent safely', async () => {
|
||||
historyState = {
|
||||
navigationId: 9,
|
||||
returnUrl: 'https://evil.example.test/workspace/downloads',
|
||||
};
|
||||
router.url = '/workspace/stalker/playlist-a/downloads/17';
|
||||
await render([download(17)]);
|
||||
|
||||
(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.hero__back-button'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(location.back).not.toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['..'], {
|
||||
relativeTo: expect.anything(),
|
||||
queryParamsHandling: 'preserve',
|
||||
});
|
||||
expect(router.navigateByUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+294
-5
@@ -1,10 +1,299 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { PortalDetailShellComponent } from '@iptvnator/ui/components';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
type DownloadItem,
|
||||
DownloadsService,
|
||||
PlaylistsService,
|
||||
} from '@iptvnator/services';
|
||||
import type { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
PortalDetailShellComponent,
|
||||
} from '@iptvnator/ui/components';
|
||||
import { catchError, map, of, startWith } from 'rxjs';
|
||||
import { DownloadLibraryNavigationService } from '../download-library-navigation.service';
|
||||
import { DownloadManagerActionsService } from '../download-manager-actions.service';
|
||||
import {
|
||||
buildDownloadOfflineDetail,
|
||||
type DownloadOfflineSeason,
|
||||
} from './download-offline-detail.viewmodel';
|
||||
import {
|
||||
boundedOfflinePeople,
|
||||
offlineDetailIdentity,
|
||||
offlineDetailRepresentative,
|
||||
offlineDurationLabel,
|
||||
offlineEpisodeCoordinate,
|
||||
offlineEpisodeCount,
|
||||
offlineEpisodeTitle,
|
||||
offlineFileByteCount,
|
||||
offlineHasLocalFile,
|
||||
offlinePositiveFinite,
|
||||
offlineSeasonKey,
|
||||
offlineSeasonTestId,
|
||||
parseOfflineDownloadId,
|
||||
type OfflineDetailItem,
|
||||
type OfflineSelectedSeason,
|
||||
} from './download-offline-detail.presentation';
|
||||
import { DownloadOfflineMetadataResolutionService } from './download-offline-metadata-resolution.service';
|
||||
import { DownloadOfflineRouteNavigationService } from './download-offline-route-navigation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-offline-detail',
|
||||
imports: [PortalDetailShellComponent],
|
||||
template: '<app-portal-detail-shell [isLoading]="true" />',
|
||||
imports: [
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
MatTooltipModule,
|
||||
PortalDetailShellComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './download-offline-detail.component.html',
|
||||
styleUrls: [
|
||||
'../../../../../../ui/components/src/lib/styles/detail-view.scss',
|
||||
'./download-offline-detail.component.scss',
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
DownloadLibraryNavigationService,
|
||||
DownloadManagerActionsService,
|
||||
DownloadOfflineMetadataResolutionService,
|
||||
DownloadOfflineRouteNavigationService,
|
||||
],
|
||||
})
|
||||
export class DownloadOfflineDetailComponent {}
|
||||
export class DownloadOfflineDetailComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly actions = inject(DownloadManagerActionsService);
|
||||
private readonly navigation = inject(DownloadLibraryNavigationService);
|
||||
private readonly metadataResolution = inject(
|
||||
DownloadOfflineMetadataResolutionService
|
||||
);
|
||||
private readonly routeNavigation = inject(
|
||||
DownloadOfflineRouteNavigationService
|
||||
);
|
||||
private readonly selectedSeasonState = signal<
|
||||
OfflineSelectedSeason | undefined
|
||||
>(undefined);
|
||||
private redirectedUnavailableId?: number;
|
||||
|
||||
readonly pendingIds = this.actions.pendingIds;
|
||||
readonly downloadId = toSignal(
|
||||
this.route.paramMap.pipe(
|
||||
map((params) => parseOfflineDownloadId(params.get('downloadId')))
|
||||
),
|
||||
{
|
||||
initialValue: parseOfflineDownloadId(
|
||||
this.route.snapshot.paramMap.get('downloadId')
|
||||
),
|
||||
}
|
||||
);
|
||||
readonly detail = computed(() => {
|
||||
const downloadId = this.downloadId();
|
||||
return downloadId === undefined
|
||||
? undefined
|
||||
: buildDownloadOfflineDetail({
|
||||
downloadId,
|
||||
downloads: this.downloadsService.downloads(),
|
||||
});
|
||||
});
|
||||
readonly currentIdentity = computed(() =>
|
||||
offlineDetailIdentity(this.detail())
|
||||
);
|
||||
readonly currentItem = computed(() =>
|
||||
offlineDetailRepresentative(this.detail())
|
||||
);
|
||||
readonly selectedRow = computed(() =>
|
||||
this.downloadsService
|
||||
.downloads()
|
||||
.find(({ id }) => id === this.downloadId())
|
||||
);
|
||||
readonly metadata = computed(() => {
|
||||
const detail = this.detail();
|
||||
if (!detail) return undefined;
|
||||
const resolved = this.metadataResolution.resolution();
|
||||
return resolved.identity === this.currentIdentity()
|
||||
? (resolved.snapshot ?? detail.snapshot)
|
||||
: detail.snapshot;
|
||||
});
|
||||
readonly title = computed(
|
||||
() => this.metadata()?.title?.trim() || this.currentItem()?.title || ''
|
||||
);
|
||||
readonly description = computed(() => this.metadata()?.plot?.trim());
|
||||
readonly posterUrl = computed(
|
||||
() =>
|
||||
this.metadata()?.posterUrl?.trim() ||
|
||||
this.currentItem()?.posterUrl?.trim()
|
||||
);
|
||||
readonly backdropUrl = computed(() => this.metadata()?.backdropUrl?.trim());
|
||||
readonly genres = computed(() =>
|
||||
(this.metadata()?.genres ?? [])
|
||||
.map((genre) => genre.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 5)
|
||||
);
|
||||
readonly cast = computed(() => boundedOfflinePeople(this.metadata()?.cast));
|
||||
readonly creators = computed(() =>
|
||||
boundedOfflinePeople(this.metadata()?.creators)
|
||||
);
|
||||
readonly playlists = toSignal(
|
||||
this.playlistsService.getAllPlaylists().pipe(
|
||||
startWith(null),
|
||||
catchError(() => of([]))
|
||||
),
|
||||
{ initialValue: null as Playlist[] | null }
|
||||
);
|
||||
readonly availablePlaylistIds = computed(
|
||||
() => new Set((this.playlists() ?? []).map(({ _id }) => _id))
|
||||
);
|
||||
readonly canOpenInPortal = computed(() => {
|
||||
const item = this.currentItem();
|
||||
return (
|
||||
item !== undefined &&
|
||||
this.playlists() !== null &&
|
||||
this.navigation.canOpen(
|
||||
item as DownloadItem,
|
||||
this.availablePlaylistIds()
|
||||
)
|
||||
);
|
||||
});
|
||||
readonly showLoading = computed(
|
||||
() =>
|
||||
!this.detail() &&
|
||||
(!this.downloadsService.hasLoadedDownloads() ||
|
||||
this.downloadsService.isLoadingDownloads())
|
||||
);
|
||||
readonly showNotFound = computed(
|
||||
() =>
|
||||
!this.detail() &&
|
||||
!this.showLoading() &&
|
||||
(!this.selectedRow() || this.selectedRow()?.status !== 'completed')
|
||||
);
|
||||
readonly seasons = computed(() => {
|
||||
const detail = this.detail();
|
||||
return detail?.kind === 'series' ? detail.seasons : [];
|
||||
});
|
||||
readonly selectedSeason = computed(() => {
|
||||
const seasons = this.seasons();
|
||||
const selection = this.selectedSeasonState();
|
||||
const identity = this.currentIdentity();
|
||||
if (!selection || selection.identity !== identity) return seasons[0];
|
||||
return (
|
||||
seasons.find(
|
||||
(season) => offlineSeasonKey(season) === selection.key
|
||||
) ?? seasons[0]
|
||||
);
|
||||
});
|
||||
readonly count = computed(() => offlineEpisodeCount(this.selectedSeason()));
|
||||
readonly movieFileSize = computed(() => {
|
||||
const detail = this.detail();
|
||||
return detail?.kind === 'movie'
|
||||
? this.formatItemBytes(detail.item)
|
||||
: undefined;
|
||||
});
|
||||
readonly durationLabel = computed(() =>
|
||||
offlineDurationLabel(this.metadata()?.durationMinutes)
|
||||
);
|
||||
readonly seasonTestId = offlineSeasonTestId;
|
||||
readonly episodeCoordinate = offlineEpisodeCoordinate;
|
||||
readonly episodeTitle = offlineEpisodeTitle;
|
||||
readonly positiveFinite = offlinePositiveFinite;
|
||||
|
||||
constructor() {
|
||||
void this.downloadsService.loadDownloads();
|
||||
this.metadataResolution.connect(this.detail, this.currentIdentity);
|
||||
this.redirectUnavailableRowsReactively();
|
||||
}
|
||||
|
||||
selectSeason(season: DownloadOfflineSeason): void {
|
||||
const identity = this.currentIdentity();
|
||||
if (identity) {
|
||||
this.selectedSeasonState.set({
|
||||
identity,
|
||||
key: offlineSeasonKey(season),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isSelectedSeason(season: DownloadOfflineSeason): boolean {
|
||||
return this.selectedSeason() === season;
|
||||
}
|
||||
|
||||
isPending(item: OfflineDetailItem): boolean {
|
||||
return this.pendingIds().has(item.id);
|
||||
}
|
||||
|
||||
formatItemBytes(item: OfflineDetailItem): string | undefined {
|
||||
const bytes = offlineFileByteCount(item);
|
||||
return bytes === undefined
|
||||
? undefined
|
||||
: this.downloadsService.formatBytes(bytes);
|
||||
}
|
||||
|
||||
async runFileAction(
|
||||
type: 'play' | 'reveal',
|
||||
item: OfflineDetailItem
|
||||
): Promise<void> {
|
||||
const identity = this.currentIdentity();
|
||||
await this.actions.run({ type, item: item as DownloadItem });
|
||||
if (identity !== this.currentIdentity()) return;
|
||||
const live = this.downloadsService
|
||||
.downloads()
|
||||
.find(({ id }) => id === item.id);
|
||||
if (!offlineHasLocalFile(live)) {
|
||||
await this.routeNavigation.toManager(true);
|
||||
}
|
||||
}
|
||||
|
||||
async viewInPortal(): Promise<void> {
|
||||
const item = this.currentItem();
|
||||
if (!item || !this.canOpenInPortal()) return;
|
||||
if (!(await this.navigation.open(item as DownloadItem))) {
|
||||
this.actions.showActionError();
|
||||
}
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.routeNavigation.back();
|
||||
}
|
||||
|
||||
private redirectUnavailableRowsReactively(): void {
|
||||
effect(() => {
|
||||
const id = this.downloadId();
|
||||
const row = this.selectedRow();
|
||||
const loaded = this.downloadsService.hasLoadedDownloads();
|
||||
const loading = this.downloadsService.isLoadingDownloads();
|
||||
if (
|
||||
id === undefined ||
|
||||
!loaded ||
|
||||
loading ||
|
||||
!row ||
|
||||
row.status !== 'completed' ||
|
||||
this.detail() ||
|
||||
this.redirectedUnavailableId === id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.redirectedUnavailableId = id;
|
||||
void this.routeNavigation.toManager(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import type { DownloadItem } from '@iptvnator/services';
|
||||
import type { DownloadMetadataPerson } from '@iptvnator/shared/interfaces';
|
||||
import type {
|
||||
DeepReadonly,
|
||||
DownloadOfflineDetail,
|
||||
DownloadOfflineSeason,
|
||||
} from './download-offline-detail.viewmodel';
|
||||
|
||||
export type OfflineDetailItem = DeepReadonly<DownloadItem>;
|
||||
|
||||
export interface OfflineSelectedSeason {
|
||||
readonly identity: string;
|
||||
readonly key: string;
|
||||
}
|
||||
|
||||
export function parseOfflineDownloadId(
|
||||
value: string | null
|
||||
): number | undefined {
|
||||
const normalized = value?.trim() ?? '';
|
||||
if (!/^\d+$/.test(normalized)) return undefined;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function offlineDetailRepresentative(
|
||||
detail: DownloadOfflineDetail | undefined
|
||||
): OfflineDetailItem | undefined {
|
||||
if (!detail) return undefined;
|
||||
return detail.kind === 'movie' ? detail.item : detail.representative;
|
||||
}
|
||||
|
||||
export function offlineDetailIdentity(
|
||||
detail: DownloadOfflineDetail | undefined
|
||||
): string | undefined {
|
||||
if (!detail) return undefined;
|
||||
const item = offlineDetailRepresentative(detail);
|
||||
if (!item) return undefined;
|
||||
return detail.kind === 'series'
|
||||
? `series:${item.playlistId}:${item.seriesXtreamId ?? item.id}`
|
||||
: `movie:${item.playlistId}:${item.id}`;
|
||||
}
|
||||
|
||||
export function offlineSeasonKey(season: DownloadOfflineSeason): string {
|
||||
return season.seasonNumberState === 'known'
|
||||
? `season-${season.seasonNumber}`
|
||||
: 'season-unknown';
|
||||
}
|
||||
|
||||
export function offlineSeasonTestId(season: DownloadOfflineSeason): string {
|
||||
return `offline-${offlineSeasonKey(season)}`;
|
||||
}
|
||||
|
||||
export function offlineEpisodeCount(
|
||||
season: DownloadOfflineSeason | undefined
|
||||
): number {
|
||||
return season?.episodes.length ?? 0;
|
||||
}
|
||||
|
||||
export function offlineEpisodeCoordinate(
|
||||
seasonNumber: number | null,
|
||||
episodeNumber: number | null
|
||||
): string {
|
||||
const coordinate = (prefix: string, value: number | null) =>
|
||||
`${prefix}${value === null ? '??' : String(value).padStart(2, '0')}`;
|
||||
return `${coordinate('S', seasonNumber)}${coordinate('E', episodeNumber)}`;
|
||||
}
|
||||
|
||||
export function offlineEpisodeTitle(
|
||||
item: OfflineDetailItem,
|
||||
metadataTitle?: string
|
||||
): string {
|
||||
return metadataTitle?.trim() || item.title;
|
||||
}
|
||||
|
||||
export function offlineFileByteCount(
|
||||
item: OfflineDetailItem
|
||||
): number | undefined {
|
||||
return [item.totalBytes, item.bytesDownloaded].find(
|
||||
(value): value is number =>
|
||||
value !== undefined && Number.isFinite(value) && value > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function offlineDurationLabel(
|
||||
duration: number | undefined
|
||||
): string | undefined {
|
||||
return duration !== undefined && Number.isFinite(duration) && duration > 0
|
||||
? `${duration} min`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function offlinePositiveFinite(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
export function boundedOfflinePeople(
|
||||
values: readonly DownloadMetadataPerson[] | undefined
|
||||
): readonly DownloadMetadataPerson[] {
|
||||
return (values ?? [])
|
||||
.filter(({ name }) => name.trim().length > 0)
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
export function offlineHasLocalFile(
|
||||
item: OfflineDetailItem | undefined
|
||||
): boolean {
|
||||
return (
|
||||
item?.status === 'completed' &&
|
||||
!!item.filePath?.trim() &&
|
||||
item.fileAvailability !== 'missing'
|
||||
);
|
||||
}
|
||||
|
||||
export function safeOfflineManagerReturnUrl(
|
||||
state: unknown,
|
||||
currentUrl: string
|
||||
): string | undefined {
|
||||
if (!state || typeof state !== 'object') return undefined;
|
||||
const record = state as Record<string, unknown>;
|
||||
const returnUrl = record['returnUrl'];
|
||||
const navigationId = record['navigationId'];
|
||||
if (
|
||||
typeof returnUrl !== 'string' ||
|
||||
typeof navigationId !== 'number' ||
|
||||
navigationId <= 1 ||
|
||||
!returnUrl.startsWith('/') ||
|
||||
returnUrl.startsWith('//')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const currentPath = currentUrl.split(/[?#]/, 1)[0].replace(/\/$/, '');
|
||||
const managerPath = currentPath.replace(/\/[^/]+$/, '');
|
||||
const returnPath = returnUrl.split(/[?#]/, 1)[0].replace(/\/$/, '');
|
||||
const managerPattern =
|
||||
/^\/workspace\/(?:downloads|(?:xtreams|stalker)\/[^/?#]+\/downloads)$/;
|
||||
return managerPattern.test(managerPath) && returnPath === managerPath
|
||||
? returnUrl
|
||||
: undefined;
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
effect,
|
||||
inject,
|
||||
Injectable,
|
||||
Injector,
|
||||
signal,
|
||||
type Signal,
|
||||
} from '@angular/core';
|
||||
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
|
||||
import type { DownloadOfflineDetail } from './download-offline-detail.viewmodel';
|
||||
import { DownloadOfflineMetadataService } from './download-offline-metadata.service';
|
||||
|
||||
export interface OfflineMetadataResolution {
|
||||
readonly generation: number;
|
||||
readonly identity?: string;
|
||||
readonly snapshot?: DownloadMetadataSnapshot;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DownloadOfflineMetadataResolutionService {
|
||||
private readonly metadata = inject(DownloadOfflineMetadataService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly state = signal<OfflineMetadataResolution>({
|
||||
generation: 0,
|
||||
});
|
||||
private generation = 0;
|
||||
|
||||
readonly resolution = this.state.asReadonly();
|
||||
|
||||
connect(
|
||||
detail: Signal<DownloadOfflineDetail | undefined>,
|
||||
identity: Signal<string | undefined>
|
||||
): void {
|
||||
effect(
|
||||
() => {
|
||||
const currentDetail = detail();
|
||||
const currentIdentity = identity();
|
||||
const generation = ++this.generation;
|
||||
this.state.set({ generation, identity: currentIdentity });
|
||||
if (!currentDetail || !currentIdentity) return;
|
||||
void this.metadata
|
||||
.resolve(currentDetail)
|
||||
.then((snapshot) => {
|
||||
if (
|
||||
generation === this.generation &&
|
||||
currentIdentity === identity()
|
||||
) {
|
||||
this.state.set({
|
||||
generation,
|
||||
identity: currentIdentity,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Location } from '@angular/common';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { safeOfflineManagerReturnUrl } from './download-offline-detail.presentation';
|
||||
|
||||
@Injectable()
|
||||
export class DownloadOfflineRouteNavigationService {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly location = inject(Location);
|
||||
|
||||
back(): void {
|
||||
if (this.returnUrl()) {
|
||||
this.location.back();
|
||||
return;
|
||||
}
|
||||
void this.toManager(false);
|
||||
}
|
||||
|
||||
async toManager(replaceUrl: boolean): Promise<void> {
|
||||
const returnUrl = this.returnUrl();
|
||||
if (returnUrl) {
|
||||
await this.router.navigateByUrl(returnUrl, { replaceUrl });
|
||||
return;
|
||||
}
|
||||
await this.router.navigate(['..'], {
|
||||
relativeTo: this.route,
|
||||
queryParamsHandling: 'preserve',
|
||||
...(replaceUrl ? { replaceUrl: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private returnUrl(): string | undefined {
|
||||
return safeOfflineManagerReturnUrl(
|
||||
this.location.getState(),
|
||||
this.router.url
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user