Better Caching & Global Downloads (#1372)
* Fixed a bug where cache TTL was using a field which always was 0. * Updated Scan Series task (from UI) to always re-calculate what's on file and not rely on last update. This leads to more reliable results, despite extra overhead. * Added image range processing on images for the reader, for slower networks or large files * On manga (single) try to use prefetched image, rather than re-requesting an image on pagination * Reduced some more latency when rendering first page of next chapter via continuous reading mode * Fixed a bug where metadata filter, after updating a typeahead, collapsing filter area then re-opening, the filter would still be applied, but the typeahead wouldn't show the modification. * Coded an idea around download reporting, commiting for history, might not go with it. * Refactored the download indicator into it's own component. Cleaning up some code for download within card component * Another throw away commit. Put in some temp code, not working but not sure if I'm ditching entirely. * Updated download service to enable range processing (so downloads can resume) and to reduce re-zipping if we've just downloaded something. * Refactored events widget download indicator to the correct design. I will be moving forward with this new functionality. * Added Required fields to ProgressDTO * Cleaned up the event widget and updated existing download progress to indicate preparing the download, rather than the download itself. * Updated dependencies for security alerts * Refactored all download code to be streamlined and globally handled * Updated ScanSeries to find the highest folder path before library, not just within the files. This could lead to scan series missing files due to nested folders on same parent level. * Updated the caching code to use a builtin annotation. Images are now caching correctly. * Fixed a bad redirect on an auth guard * Tweaked how long we allow cache for, as the cover update now doesn't work well. * Fixed a bug on downloading bookmarks from multiple series, where it would just choose the first series id for the temp file. * Added an extra check for downloading bookmarks * UI Security updates, Fixed a bug on bookmark reader, the reader on last page would throw some errors and not show No Next Chapter toast. * After scan, clear temp * Code smells
This commit is contained in:
parent
45bbf422be
commit
af4f35da5b
58 changed files with 2309 additions and 624 deletions
|
|
@ -22,7 +22,7 @@ export class AuthGuard implements CanActivate {
|
|||
this.toastr.error('You are not authorized to view this page.');
|
||||
}
|
||||
localStorage.setItem(this.urlKey, window.location.pathname);
|
||||
this.router.navigateByUrl('/libraries');
|
||||
this.router.navigateByUrl('/login');
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface Preferences {
|
|||
theme: SiteTheme;
|
||||
globalPageLayoutMode: PageLayoutMode;
|
||||
blurUnreadSummaries: boolean;
|
||||
promptForDownloadSize: boolean;
|
||||
}
|
||||
|
||||
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ export enum EVENTS {
|
|||
*/
|
||||
CleanupProgress = 'CleanupProgress',
|
||||
/**
|
||||
* A subtype of NotificationProgress that represnts a user downloading a file or group of files
|
||||
* A subtype of NotificationProgress that represnts a user downloading a file or group of files.
|
||||
* Note: In v0.5.5, this is being replaced by an inbrowser experience. The message is changed and this will be moved to dashboard view once built
|
||||
*/
|
||||
DownloadProgress = 'DownloadProgress',
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ConfirmService } from 'src/app/shared/confirm.service';
|
|||
import { SettingsService } from '../settings.service';
|
||||
import { ServerSettings } from '../_models/server-settings';
|
||||
import { catchError, finalize, shareReplay, take, takeWhile } from 'rxjs/operators';
|
||||
import { forkJoin, Observable, of } from 'rxjs';
|
||||
import { defer, forkJoin, Observable, of } from 'rxjs';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
import { Job } from 'src/app/_models/job/job';
|
||||
import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component';
|
||||
|
|
@ -55,10 +55,7 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||
{
|
||||
name: 'Download Logs',
|
||||
description: 'Compiles all log files into a zip and downloads it',
|
||||
api: this.downloadService.downloadLogs().pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
})),
|
||||
api: defer(() => of(this.downloadService.download('logs', undefined))),
|
||||
successMessage: ''
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -86,12 +86,11 @@ export class BookmarksComponent implements OnInit, OnDestroy {
|
|||
|
||||
switch (action) {
|
||||
case Action.DownloadBookmark:
|
||||
this.downloadService.downloadBookmarks(this.bookmarks.filter(bmk => seriesIds.includes(bmk.seriesId))).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
})).subscribe(() => {
|
||||
this.downloadService.download('bookmark', this.bookmarks.filter(bmk => seriesIds.includes(bmk.seriesId)), (d) => {
|
||||
if (!d) {
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
case Action.Delete:
|
||||
if (!await this.confirmService.confirm('Are you sure you want to clear all bookmarks for multiple series? This cannot be undone.')) {
|
||||
|
|
@ -158,13 +157,18 @@ export class BookmarksComponent implements OnInit, OnDestroy {
|
|||
|
||||
downloadBookmarks(series: Series) {
|
||||
this.downloadingSeries[series.id] = true;
|
||||
this.downloadService.downloadBookmarks(this.bookmarks.filter(bmk => bmk.seriesId === series.id)).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id), (d) => {
|
||||
if (!d) {
|
||||
this.downloadingSeries[series.id] = false;
|
||||
})).subscribe(() => {/* No Operation */});
|
||||
}
|
||||
});
|
||||
// this.downloadService.downloadBookmarks(this.bookmarks.filter(bmk => bmk.seriesId === series.id)).pipe(
|
||||
// takeWhile(val => {
|
||||
// return val.state != 'DONE';
|
||||
// }),
|
||||
// finalize(() => {
|
||||
// this.downloadingSeries[series.id] = false;
|
||||
// })).subscribe(() => {/* No Operation */});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,22 +236,12 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
|
|||
this.toastr.info('Download is already in progress. Please wait.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'chapter');
|
||||
if (!wantToDownload) { return; }
|
||||
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
this.cdRef.markForCheck();
|
||||
}));
|
||||
this.download$.subscribe(() => {});
|
||||
this.downloadInProgress = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.downloadService.download('chapter', chapter, (d) => {
|
||||
if (d) return;
|
||||
this.downloadInProgress = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,8 @@
|
|||
|
||||
<div class="progress-banner">
|
||||
<p *ngIf="read < total && total > 0 && read !== total"><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
|
||||
|
||||
<span class="download" *ngIf="download$ | async as download">
|
||||
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
{{download.progress}}% downloaded
|
||||
</span>
|
||||
<span class="download">
|
||||
<app-download-indicator [download$]="download$"></app-download-indicator>
|
||||
</span>
|
||||
</div>
|
||||
<div class="error-banner" *ngIf="total === 0 && !suppressArchiveWarning">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { ToastrService } from 'ngx-toastr';
|
|||
import { Observable, Subject } from 'rxjs';
|
||||
import { filter, finalize, map, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { Download } from 'src/app/shared/_models/download';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { DownloadEntityType, DownloadEvent, DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
|
|
@ -101,9 +101,10 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
format: MangaFormat = MangaFormat.UNKNOWN;
|
||||
chapterTitle: string = '';
|
||||
|
||||
|
||||
download$: Observable<Download> | null = null;
|
||||
downloadInProgress: boolean = false;
|
||||
/**
|
||||
* This is the download we get from download service.
|
||||
*/
|
||||
download$: Observable<DownloadEvent | null> | null = null;
|
||||
|
||||
/**
|
||||
* Handles touch events for selection on mobile devices
|
||||
|
|
@ -133,24 +134,24 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
public utilityService: UtilityService, private downloadService: DownloadService,
|
||||
private toastr: ToastrService, public bulkSelectionService: BulkSelectionService,
|
||||
private messageHub: MessageHubService, private accountService: AccountService,
|
||||
private scrollService: ScrollService, private readonly changeDetectionRef: ChangeDetectorRef) {}
|
||||
private scrollService: ScrollService, private readonly cdRef: ChangeDetectorRef) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) {
|
||||
this.suppressArchiveWarning = true;
|
||||
this.changeDetectionRef.markForCheck();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
if (this.suppressLibraryLink === false) {
|
||||
if (this.entity !== undefined && this.entity.hasOwnProperty('libraryId')) {
|
||||
this.libraryId = (this.entity as Series).libraryId;
|
||||
this.changeDetectionRef.markForCheck();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
if (this.libraryId !== undefined && this.libraryId > 0) {
|
||||
this.libraryService.getLibraryName(this.libraryId).pipe(takeUntil(this.onDestroy)).subscribe(name => {
|
||||
this.libraryName = name;
|
||||
this.changeDetectionRef.markForCheck();
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -177,8 +178,18 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
if (this.utilityService.isSeries(this.entity) && updateEvent.seriesId !== this.entity.id) return;
|
||||
|
||||
this.read = updateEvent.pagesRead;
|
||||
this.changeDetectionRef.detectChanges();
|
||||
this.cdRef.detectChanges();
|
||||
});
|
||||
|
||||
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntil(this.onDestroy), map((events) => {
|
||||
if(this.utilityService.isSeries(this.entity)) return events.find(e => e.entityType === 'series' && e.subTitle === this.downloadService.downloadSubtitle('series', (this.entity as Series))) || null;
|
||||
if(this.utilityService.isVolume(this.entity)) return events.find(e => e.entityType === 'volume' && e.subTitle === this.downloadService.downloadSubtitle('volume', (this.entity as Volume))) || null;
|
||||
if(this.utilityService.isChapter(this.entity)) return events.find(e => e.entityType === 'chapter' && e.subTitle === this.downloadService.downloadSubtitle('chapter', (this.entity as Chapter))) || null;
|
||||
// Is PageBookmark[]
|
||||
if(this.entity.hasOwnProperty('length')) return events.find(e => e.entityType === 'bookmark' && e.subTitle === this.downloadService.downloadSubtitle('bookmark', [(this.entity as PageBookmark)])) || null;
|
||||
return null;
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
|
@ -191,7 +202,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
if (!this.allowSelection) return;
|
||||
|
||||
this.selectionInProgress = false;
|
||||
this.changeDetectionRef.markForCheck();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
@HostListener('touchstart', ['$event'])
|
||||
|
|
@ -230,62 +241,21 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (action.action == Action.Download) {
|
||||
if (this.downloadInProgress === true) {
|
||||
this.toastr.info('Download is already in progress. Please wait.');
|
||||
return;
|
||||
}
|
||||
|
||||
// if (this.download$ !== null) {
|
||||
// this.toastr.info('Download is already in progress. Please wait.');
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (this.utilityService.isVolume(this.entity)) {
|
||||
const volume = this.utilityService.asVolume(this.entity);
|
||||
this.downloadService.downloadVolumeSize(volume.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'volume');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.changeDetectionRef.markForCheck();
|
||||
this.download$ = this.downloadService.downloadVolume(volume).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
this.changeDetectionRef.markForCheck();
|
||||
}));
|
||||
});
|
||||
this.downloadService.download('volume', volume);
|
||||
} else if (this.utilityService.isChapter(this.entity)) {
|
||||
const chapter = this.utilityService.asChapter(this.entity);
|
||||
this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'chapter');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.changeDetectionRef.markForCheck();
|
||||
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
this.changeDetectionRef.markForCheck();
|
||||
}));
|
||||
});
|
||||
this.downloadService.download('chapter', chapter);
|
||||
} else if (this.utilityService.isSeries(this.entity)) {
|
||||
const series = this.utilityService.asSeries(this.entity);
|
||||
this.downloadService.downloadSeriesSize(series.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'series');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.changeDetectionRef.markForCheck();
|
||||
this.download$ = this.downloadService.downloadSeries(series).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
this.changeDetectionRef.markForCheck();
|
||||
}));
|
||||
});
|
||||
this.downloadService.download('series', series);
|
||||
}
|
||||
return; // Don't propagate the download from a card
|
||||
}
|
||||
|
|
@ -307,6 +277,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
|||
event.stopPropagation();
|
||||
}
|
||||
this.selection.emit(this.selected);
|
||||
this.changeDetectionRef.detectChanges();
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { EntityInfoCardsComponent } from './entity-info-cards/entity-info-cards.
|
|||
import { ListItemComponent } from './list-item/list-item.component';
|
||||
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
|
||||
import { SeriesInfoCardsComponent } from './series-info-cards/series-info-cards.component';
|
||||
import { DownloadIndicatorComponent } from './download-indicator/download-indicator.component';
|
||||
|
||||
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ import { SeriesInfoCardsComponent } from './series-info-cards/series-info-cards.
|
|||
EntityInfoCardsComponent,
|
||||
ListItemComponent,
|
||||
SeriesInfoCardsComponent,
|
||||
DownloadIndicatorComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
<span class="download" *ngIf="download$ | async as download">
|
||||
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
{{download.progress}}% downloaded
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.download {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Download } from 'src/app/shared/_models/download';
|
||||
import { DownloadEvent } from 'src/app/shared/_services/download.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-indicator',
|
||||
templateUrl: './download-indicator.component.html',
|
||||
styleUrls: ['./download-indicator.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DownloadIndicatorComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* Observable that represents when the download completes
|
||||
*/
|
||||
@Input() download$!: Observable<Download | DownloadEvent | null> | null;
|
||||
|
||||
constructor(private readonly cdRef: ChangeDetectorRef) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -2,11 +2,8 @@
|
|||
<div class="pe-2">
|
||||
<app-image [imageUrl]="imageUrl" [height]="imageHeight" maxHeight="200px" [width]="imageWidth"></app-image>
|
||||
<div class="not-read-badge" *ngIf="pagesRead === 0 && totalPages > 0"></div>
|
||||
<span class="download" *ngIf="download$ | async as download">
|
||||
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
{{download.progress}}% downloaded
|
||||
</span>
|
||||
<span class="download">
|
||||
<app-download-indicator [download$]="download$"></app-download-indicator>
|
||||
</span>
|
||||
<div class="progress-banner" *ngIf="pagesRead < totalPages && totalPages > 0 && pagesRead !== totalPages">
|
||||
<p><ngb-progressbar type="primary" height="5px" [value]="pagesRead" [max]="totalPages"></ngb-progressbar></p>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { finalize, Observable, take, takeWhile } from 'rxjs';
|
||||
import { finalize, map, Observable, Subject, take, takeWhile, takeUntil } from 'rxjs';
|
||||
import { Download } from 'src/app/shared/_models/download';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { DownloadEvent, DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { LibraryType } from 'src/app/_models/library';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { RelationKind } from 'src/app/_models/series-detail/relation-kind';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
|
|
@ -16,7 +17,7 @@ import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
|||
styleUrls: ['./list-item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ListItemComponent implements OnInit {
|
||||
export class ListItemComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* Volume or Chapter to render
|
||||
|
|
@ -74,9 +75,11 @@ export class ListItemComponent implements OnInit {
|
|||
isChapter: boolean = false;
|
||||
|
||||
|
||||
download$: Observable<Download> | null = null;
|
||||
download$: Observable<DownloadEvent | null> | null = null;
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
get Title() {
|
||||
if (this.isChapter) return (this.entity as Chapter).titleName;
|
||||
return '';
|
||||
|
|
@ -93,7 +96,20 @@ export class ListItemComponent implements OnInit {
|
|||
} else {
|
||||
this.summary = this.utilityService.asVolume(this.entity).chapters[0].summary || '';
|
||||
}
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
|
||||
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntil(this.onDestroy), map((events) => {
|
||||
if(this.utilityService.isVolume(this.entity)) return events.find(e => e.entityType === 'volume' && e.subTitle === this.downloadService.downloadSubtitle('volume', (this.entity as Volume))) || null;
|
||||
if(this.utilityService.isChapter(this.entity)) return events.find(e => e.entityType === 'chapter' && e.subTitle === this.downloadService.downloadSubtitle('chapter', (this.entity as Chapter))) || null;
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
|
|
@ -102,43 +118,18 @@ export class ListItemComponent implements OnInit {
|
|||
this.toastr.info('Download is already in progress. Please wait.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const statusUpdate = (d: Download | undefined) => {
|
||||
if (d) return;
|
||||
this.downloadInProgress = false;
|
||||
};
|
||||
|
||||
if (this.utilityService.isVolume(this.entity)) {
|
||||
const volume = this.utilityService.asVolume(this.entity);
|
||||
this.downloadService.downloadVolumeSize(volume.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'volume');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.download$ = this.downloadService.downloadVolume(volume).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
this.cdRef.markForCheck();
|
||||
}));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
this.downloadService.download('volume', volume, statusUpdate);
|
||||
} else if (this.utilityService.isChapter(this.entity)) {
|
||||
const chapter = this.utilityService.asChapter(this.entity);
|
||||
this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'chapter');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
this.cdRef.markForCheck();
|
||||
}));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
this.downloadService.download('chapter', chapter, statusUpdate);
|
||||
}
|
||||
return; // Don't propagate the download from a card
|
||||
}
|
||||
|
|
|
|||
|
|
@ -668,7 +668,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
if (this.bookmarkMode) {
|
||||
this.readerService.getBookmarkInfo(this.seriesId).subscribe(bookmarkInfo => {
|
||||
this.setPageNum(0);
|
||||
this.title = bookmarkInfo.seriesName + ' Bookmarks';
|
||||
this.title = bookmarkInfo.seriesName;
|
||||
this.subtitle = 'Bookmarks';
|
||||
this.libraryType = bookmarkInfo.libraryType;
|
||||
this.maxPages = bookmarkInfo.pages;
|
||||
|
||||
|
|
@ -677,6 +678,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything.
|
||||
this.pageOptions = newOptions;
|
||||
this.inSetup = false;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
const images = [];
|
||||
for (let i = 0; i < PREFETCH_PAGES + 2; i++) {
|
||||
|
|
@ -684,6 +686,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
this.cachedImages = new CircularArray<HTMLImageElement>(images, 0);
|
||||
this.goToPageEvent = new BehaviorSubject<number>(this.pageNum);
|
||||
|
||||
this.render();
|
||||
});
|
||||
|
|
@ -1013,7 +1016,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.pageAmount = pageAmount;
|
||||
|
||||
if (this.readerMode !== ReaderMode.Webtoon) {
|
||||
this.canvasImage.src = this.getPageUrl(this.pageNum);
|
||||
this.setCanvasImage();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1058,7 +1061,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
if (this.isNoSplit() || notInSplit) {
|
||||
this.setPageNum(this.pageNum - pageAmount);
|
||||
if (this.readerMode !== ReaderMode.Webtoon) {
|
||||
this.canvasImage.src = this.getPageUrl(this.pageNum);
|
||||
this.setCanvasImage();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1067,15 +1070,25 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets canvasImage's src to current page, but first attempts to use a pre-fetched image
|
||||
*/
|
||||
setCanvasImage() {
|
||||
const img = this.cachedImages.arr.find(img => this.readerService.imageUrlToPageNum(img.src) === this.pageNum);
|
||||
if (img) {
|
||||
this.canvasImage = img;
|
||||
} else {
|
||||
this.canvasImage.src = this.getPageUrl(this.pageNum);
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
loadNextChapter() {
|
||||
if (this.nextPageDisabled) { return; }
|
||||
if (this.nextChapterDisabled) {
|
||||
if (this.nextPageDisabled || this.nextChapterDisabled || this.bookmarkMode) {
|
||||
this.toastr.info('No Next Chapter');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.cdRef.markForCheck();
|
||||
if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) {
|
||||
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
|
||||
this.nextChapterId = chapterId;
|
||||
|
|
@ -1087,13 +1100,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
loadPrevChapter() {
|
||||
if (this.prevPageDisabled) { return; }
|
||||
if (this.prevChapterDisabled) {
|
||||
if (this.prevPageDisabled || this.prevChapterDisabled || this.bookmarkMode) {
|
||||
this.toastr.info('No Previous Chapter');
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.continuousChaptersStack.pop();
|
||||
const prevChapter = this.continuousChaptersStack.peek();
|
||||
if (prevChapter != this.chapterId) {
|
||||
|
|
@ -1104,6 +1114,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
console.log('prevChapterId', this.prevChapterId);
|
||||
|
||||
if (this.prevChapterId === CHAPTER_ID_NOT_FETCHED || this.prevChapterId === this.chapterId) {
|
||||
this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
|
||||
this.prevChapterId = chapterId;
|
||||
|
|
@ -1115,7 +1127,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
loadChapter(chapterId: number, direction: 'Next' | 'Prev') {
|
||||
if (chapterId >= 0) {
|
||||
console.log('chapterId: ', chapterId);
|
||||
if (chapterId > 0) {
|
||||
this.isLoading = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.chapterId = chapterId;
|
||||
this.continuousChaptersStack.push(chapterId);
|
||||
// Load chapter Id onto route but don't reload
|
||||
|
|
@ -1238,7 +1254,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
this.firstPageRendered = true;
|
||||
this.generalSettingsForm.get('fittingOption')?.setValue(newScale, {emitEvent: false});
|
||||
//this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1315,7 +1330,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.canvasImageAheadBy2.src = '';
|
||||
|
||||
this.isLoose = (this.pageAmount === 1 ? true : false);
|
||||
this.canvasImage.src = this.getPageUrl(this.pageNum);
|
||||
this.setCanvasImage();
|
||||
|
||||
|
||||
if (this.layoutMode !== LayoutMode.Single) {
|
||||
this.canvasImageNext.src = this.getPageUrl(this.pageNum + 1); // This needs to be capped at maxPages !this.isLastImage()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<ng-container *ngIf="toggleService.toggleState$ | async as isOpen">
|
||||
<div class="phone-hidden">
|
||||
<div #collapse="ngbCollapse" [ngbCollapse]="!isOpen" (ngbCollapseChange)="setToggle($event)">
|
||||
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
|
||||
</div>
|
||||
<div #collapse="ngbCollapse" [ngbCollapse]="!isOpen" (ngbCollapseChange)="setToggle($event)">
|
||||
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="not-phone-hidden">
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
|||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
this.filter = this.seriesService.createSeriesFilter();
|
||||
this.readProgressGroup = new FormGroup({
|
||||
read: new FormControl({value: this.filter.readStatus.read, disabled: this.filterSettings.readProgressDisabled}, []),
|
||||
|
|
@ -222,10 +222,6 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
|||
]).subscribe(results => {
|
||||
this.fullyLoaded = true;
|
||||
this.resetTypeaheads.next(false); // Pass false to ensure we reset to the preset and not to an empty typeahead
|
||||
if (this.filterSettings.openByDefault) {
|
||||
this.filteringCollapsed = false;
|
||||
this.toggleService.set(!this.filteringCollapsed);
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
this.apply();
|
||||
});
|
||||
|
|
@ -502,21 +498,26 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
|||
|
||||
updateFormatFilters(formats: FilterItem<MangaFormat>[]) {
|
||||
this.filter.formats = formats.map(item => item.value) || [];
|
||||
this.formatSettings.savedData = formats;
|
||||
}
|
||||
|
||||
updateLibraryFilters(libraries: Library[]) {
|
||||
this.filter.libraries = libraries.map(item => item.id) || [];
|
||||
this.librarySettings.savedData = libraries;
|
||||
}
|
||||
|
||||
updateGenreFilters(genres: Genre[]) {
|
||||
this.filter.genres = genres.map(item => item.id) || [];
|
||||
this.genreSettings.savedData = genres;
|
||||
}
|
||||
|
||||
updateTagFilters(tags: Tag[]) {
|
||||
this.filter.tags = tags.map(item => item.id) || [];
|
||||
this.tagsSettings.savedData = tags;
|
||||
}
|
||||
|
||||
updatePersonFilters(persons: Person[], role: PersonRole) {
|
||||
this.peopleSettings[role].savedData = persons;
|
||||
switch (role) {
|
||||
case PersonRole.CoverArtist:
|
||||
this.filter.coverArtist = persons.map(p => p.id);
|
||||
|
|
@ -553,6 +554,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
|||
|
||||
updateCollectionFilters(tags: CollectionTag[]) {
|
||||
this.filter.collectionTags = tags.map(item => item.id) || [];
|
||||
this.collectionSettings.savedData = tags;
|
||||
}
|
||||
|
||||
updateRating(rating: any) {
|
||||
|
|
@ -562,14 +564,17 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
|||
|
||||
updateAgeRating(ratingDtos: AgeRatingDto[]) {
|
||||
this.filter.ageRating = ratingDtos.map(item => item.value) || [];
|
||||
this.ageRatingSettings.savedData = ratingDtos;
|
||||
}
|
||||
|
||||
updatePublicationStatus(dtos: PublicationStatusDto[]) {
|
||||
this.filter.publicationStatus = dtos.map(item => item.value) || [];
|
||||
this.publicationStatusSettings.savedData = dtos;
|
||||
}
|
||||
|
||||
updateLanguages(languages: Language[]) {
|
||||
this.filter.languages = languages.map(item => item.isoCode) || [];
|
||||
this.languageSettings.savedData = languages;
|
||||
}
|
||||
|
||||
updateReadStatus(status: string) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<ng-container *ngIf="isAdmin$ | async">
|
||||
|
||||
<ng-container *ngIf="errors$ | async as errors">
|
||||
<button type="button" class="btn btn-icon" [ngClass]="{'colored': activeEvents > 0, 'colored-error': errors.length > 0}"
|
||||
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'" [autoClose]="'outside'">
|
||||
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
|
||||
</button>
|
||||
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
|
||||
<ng-container *ngIf="errors$ | async as errors">
|
||||
<button type="button" class="btn btn-icon" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0, 'colored-error': errors.length > 0}"
|
||||
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'" [autoClose]="'outside'">
|
||||
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
|
||||
</button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
|
||||
|
|
@ -45,6 +46,18 @@
|
|||
</div>
|
||||
<button type="button" class="btn-close float-end" aria-label="close" ></button>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="d-inline-flex">
|
||||
<span class="download">
|
||||
<app-circular-loader [currentValue]="25" [maxValue]="100" fontSize="16px" [showIcon]="true" width="25px" height="unset" [center]="false"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
10% downloaded
|
||||
</span>
|
||||
</span>
|
||||
<span class="h6 mb-1">Downloading {{'series' | sentenceCase}}</span>
|
||||
</div>
|
||||
<div class="accent-text">PDFs</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
<!-- Progress Events-->
|
||||
<ng-container *ngIf="progressEvents$ | async as progressUpdates">
|
||||
|
|
@ -86,6 +99,23 @@
|
|||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<!-- Active Downloads by the user-->
|
||||
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
|
||||
<ng-container *ngFor="let download of activeDownloads">
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="h6 mb-1">Downloading {{download.entityType | sentenceCase}}</div>
|
||||
<div class="accent-text mb-1" *ngIf="download.subTitle !== ''">{{download.subTitle}}</div>
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
<div class="col-2">{{download.progress}}%</div>
|
||||
<div class="col-10 progress" style="height: 5px;">
|
||||
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': download.progress + '%'}" [attr.aria-valuenow]="download.progress" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
|
||||
<!-- Errors -->
|
||||
<ng-container *ngIf="errors$ | async as errors">
|
||||
<ng-container *ngFor="let error of errors">
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@
|
|||
}
|
||||
|
||||
|
||||
// .download {
|
||||
// width: 80px;
|
||||
// height: 80px;
|
||||
// }
|
||||
|
||||
|
||||
|
||||
.btn-icon {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { map, shareReplay, takeUntil } from 'rxjs/operators';
|
|||
import { ConfirmConfig } from 'src/app/shared/confirm-dialog/_models/confirm-config';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { ErrorEvent } from 'src/app/_models/events/error-event';
|
||||
import { NotificationProgressEvent } from 'src/app/_models/events/notification-progress-event';
|
||||
import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event';
|
||||
|
|
@ -50,7 +51,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
|
|||
|
||||
constructor(public messageHub: MessageHubService, private modalService: NgbModal,
|
||||
private accountService: AccountService, private confirmService: ConfirmService,
|
||||
private readonly cdRef: ChangeDetectorRef) { }
|
||||
private readonly cdRef: ChangeDetectorRef, public downloadService: DownloadService) { }
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { Title } from '@angular/platform-browser';
|
|||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NgbModal, NgbNavChangeEvent, NgbOffcanvas } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { forkJoin, Subject } from 'rxjs';
|
||||
import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { forkJoin, Subject, tap } from 'rxjs';
|
||||
import { filter, finalize, switchMap, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component';
|
||||
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
|
||||
|
|
@ -39,6 +39,7 @@ import { FormControl, FormGroup } from '@angular/forms';
|
|||
import { PageLayoutMode } from '../_models/page-layout-mode';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { User } from '../_models/user';
|
||||
import { Download } from '../shared/_models/download';
|
||||
|
||||
interface RelatedSeris {
|
||||
series: Series;
|
||||
|
|
@ -725,19 +726,13 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
downloadSeries() {
|
||||
this.downloadService.downloadSeriesSize(this.seriesId).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'series');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.downloadService.download('series', this.series, (d) => {
|
||||
if (d) {
|
||||
this.downloadInProgress = true;
|
||||
} else {
|
||||
this.downloadInProgress = false;
|
||||
}
|
||||
this.changeDetectionRef.markForCheck();
|
||||
this.downloadService.downloadSeries(this.series).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.downloadInProgress = false;
|
||||
this.changeDetectionRef.markForCheck();
|
||||
})).subscribe(() => {/* No Operation */});;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,17 @@ import {
|
|||
event.type === HttpEventType.UploadProgress
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Encapsulates an inprogress download of a Blob with progress reporting activated
|
||||
*/
|
||||
export interface Download {
|
||||
content: Blob | null;
|
||||
progress: number;
|
||||
state: "PENDING" | "IN_PROGRESS" | "DONE";
|
||||
filename?: string;
|
||||
loaded?: number;
|
||||
total?: number
|
||||
}
|
||||
|
||||
export function download(saver?: (b: Blob, filename: string) => void): (source: Observable<HttpEvent<Blob>>) => Observable<Download> {
|
||||
|
|
@ -38,7 +43,9 @@ export function download(saver?: (b: Blob, filename: string) => void): (source:
|
|||
? Math.round((100 * event.loaded) / event.total)
|
||||
: previous.progress,
|
||||
state: 'IN_PROGRESS',
|
||||
content: null
|
||||
content: null,
|
||||
loaded: event.loaded,
|
||||
total: event.total
|
||||
}
|
||||
}
|
||||
if (isHttpResponse(event)) {
|
||||
|
|
@ -49,7 +56,7 @@ export function download(saver?: (b: Blob, filename: string) => void): (source:
|
|||
progress: 100,
|
||||
state: 'DONE',
|
||||
content: event.body,
|
||||
filename: getFilename(event.headers, '')
|
||||
filename: getFilename(event.headers, ''),
|
||||
}
|
||||
}
|
||||
return previous;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { HttpClient, HttpErrorResponse, HttpEventType } from '@angular/common/http';
|
||||
import { Inject, Injectable } from '@angular/core';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { environment } from 'src/environments/environment';
|
||||
|
|
@ -6,14 +6,39 @@ import { ConfirmService } from '../confirm.service';
|
|||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { asyncScheduler, Observable } from 'rxjs';
|
||||
import { asyncScheduler, BehaviorSubject, Observable, tap, finalize, of, filter } from 'rxjs';
|
||||
import { SAVER, Saver } from '../_providers/saver.provider';
|
||||
import { download, Download } from '../_models/download';
|
||||
import { PageBookmark } from 'src/app/_models/page-bookmark';
|
||||
import { catchError, throttleTime } from 'rxjs/operators';
|
||||
import { switchMap, takeWhile, throttleTime } from 'rxjs/operators';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
|
||||
export const DEBOUNCE_TIME = 100;
|
||||
|
||||
export interface DownloadEvent {
|
||||
/**
|
||||
* Type of entity being downloaded
|
||||
*/
|
||||
entityType: DownloadEntityType;
|
||||
/**
|
||||
* What to show user. For example, for Series, we might show series name.
|
||||
*/
|
||||
subTitle: string;
|
||||
/**
|
||||
* Progress of the download itself
|
||||
*/
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid entity types for downloading
|
||||
*/
|
||||
export type DownloadEntityType = 'volume' | 'chapter' | 'series' | 'bookmark' | 'logs';
|
||||
/**
|
||||
* Valid entities for downloading. Undefined exclusively for logs.
|
||||
*/
|
||||
export type DownloadEntity = Series | Volume | Chapter | PageBookmark[] | undefined;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
|
|
@ -25,67 +50,207 @@ export class DownloadService {
|
|||
*/
|
||||
public SIZE_WARNING = 104_857_600;
|
||||
|
||||
constructor(private httpClient: HttpClient, private confirmService: ConfirmService, private toastr: ToastrService, @Inject(SAVER) private save: Saver) { }
|
||||
private downloadsSource: BehaviorSubject<DownloadEvent[]> = new BehaviorSubject<DownloadEvent[]>([]);
|
||||
public activeDownloads$ = this.downloadsSource.asObservable();
|
||||
|
||||
constructor(private httpClient: HttpClient, private confirmService: ConfirmService,
|
||||
private toastr: ToastrService, @Inject(SAVER) private save: Saver,
|
||||
private accountService: AccountService) { }
|
||||
|
||||
/**
|
||||
* Returns the entity subtitle (for the event widget) for a given entity
|
||||
* @param downloadEntityType
|
||||
* @param downloadEntity
|
||||
* @returns
|
||||
*/
|
||||
downloadSubtitle(downloadEntityType: DownloadEntityType, downloadEntity: DownloadEntity | undefined) {
|
||||
switch (downloadEntityType) {
|
||||
case 'series':
|
||||
return (downloadEntity as Series).name;
|
||||
case 'volume':
|
||||
return (downloadEntity as Volume).number + '';
|
||||
case 'chapter':
|
||||
return (downloadEntity as Chapter).number;
|
||||
case 'bookmark':
|
||||
return '';
|
||||
case 'logs':
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the entity to the user's system. This handles everything around downloads. This will prompt the user based on size checks and UserPreferences.PromptForDownload.
|
||||
* This will perform the download at a global level, if you need a handle to the download in question, use downloadService.activeDownloads$ and perform a filter on it.
|
||||
* @param entityType
|
||||
* @param entity
|
||||
* @param callback Optional callback. Returns the download or undefined (if the download is complete).
|
||||
*/
|
||||
download(entityType: DownloadEntityType, entity: DownloadEntity, callback?: (d: Download | undefined) => void) {
|
||||
let sizeCheckCall: Observable<number>;
|
||||
let downloadCall: Observable<Download>;
|
||||
switch (entityType) {
|
||||
case 'series':
|
||||
sizeCheckCall = this.downloadSeriesSize((entity as Series).id);
|
||||
downloadCall = this.downloadSeries(entity as Series);
|
||||
break;
|
||||
case 'volume':
|
||||
sizeCheckCall = this.downloadVolumeSize((entity as Volume).id);
|
||||
downloadCall = this.downloadVolume(entity as Volume);
|
||||
break;
|
||||
case 'chapter':
|
||||
sizeCheckCall = this.downloadChapterSize((entity as Chapter).id);
|
||||
downloadCall = this.downloadChapter(entity as Chapter);
|
||||
break;
|
||||
case 'bookmark':
|
||||
sizeCheckCall = of(0);
|
||||
downloadCall = this.downloadBookmarks(entity as PageBookmark[]);
|
||||
break;
|
||||
case 'logs':
|
||||
sizeCheckCall = of(0);
|
||||
downloadCall = this.downloadLogs();
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
public downloadSeriesSize(seriesId: number) {
|
||||
this.accountService.currentUser$.pipe(switchMap(user => {
|
||||
if (user && user.preferences.promptForDownloadSize) {
|
||||
return sizeCheckCall;
|
||||
}
|
||||
return of(0);
|
||||
}), switchMap(async (size) => {
|
||||
return await this.confirmSize(size, entityType);
|
||||
})
|
||||
).pipe(filter(wantsToDownload => wantsToDownload), switchMap(() => {
|
||||
return downloadCall.pipe(
|
||||
tap((d) => {
|
||||
if (callback) callback(d);
|
||||
}),
|
||||
takeWhile((val: Download) => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
if (callback) callback(undefined);
|
||||
}))
|
||||
})).subscribe(() => {});
|
||||
}
|
||||
|
||||
private downloadSeriesSize(seriesId: number) {
|
||||
return this.httpClient.get<number>(this.baseUrl + 'download/series-size?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
public downloadVolumeSize(volumeId: number) {
|
||||
private downloadVolumeSize(volumeId: number) {
|
||||
return this.httpClient.get<number>(this.baseUrl + 'download/volume-size?volumeId=' + volumeId);
|
||||
}
|
||||
|
||||
public downloadChapterSize(chapterId: number) {
|
||||
private downloadChapterSize(chapterId: number) {
|
||||
return this.httpClient.get<number>(this.baseUrl + 'download/chapter-size?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
downloadLogs() {
|
||||
return this.httpClient.get(this.baseUrl + 'server/logs',
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => {
|
||||
this.save(blob, filename)
|
||||
}));
|
||||
|
||||
private downloadLogs() {
|
||||
const downloadType = 'logs';
|
||||
const subtitle = this.downloadSubtitle(downloadType, undefined);
|
||||
return this.httpClient.get(this.baseUrl + 'server/logs',
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(
|
||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
download((blob, filename) => {
|
||||
this.save(blob, filename);
|
||||
}),
|
||||
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
|
||||
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||
);
|
||||
}
|
||||
|
||||
downloadSeries(series: Series) {
|
||||
private downloadSeries(series: Series) {
|
||||
const downloadType = 'series';
|
||||
const subtitle = this.downloadSubtitle(downloadType, series);
|
||||
return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id,
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => {
|
||||
this.save(blob, filename)
|
||||
}));
|
||||
).pipe(
|
||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
download((blob, filename) => {
|
||||
this.save(blob, filename);
|
||||
}),
|
||||
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
|
||||
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||
);
|
||||
}
|
||||
|
||||
downloadChapter(chapter: Chapter) {
|
||||
private finalizeDownloadState(entityType: DownloadEntityType, entitySubtitle: string) {
|
||||
let values = this.downloadsSource.getValue();
|
||||
values = values.filter(v => !(v.entityType === entityType && v.subTitle === entitySubtitle));
|
||||
this.downloadsSource.next(values);
|
||||
}
|
||||
|
||||
private updateDownloadState(d: Download, entityType: DownloadEntityType, entitySubtitle: string) {
|
||||
let values = this.downloadsSource.getValue();
|
||||
if (d.state === 'PENDING') {
|
||||
const index = values.findIndex(v => v.entityType === entityType && v.subTitle === entitySubtitle);
|
||||
if (index >= 0) return; // Don't let us duplicate add
|
||||
values.push({entityType: entityType, subTitle: entitySubtitle, progress: 0});
|
||||
} else if (d.state === 'IN_PROGRESS') {
|
||||
const index = values.findIndex(v => v.entityType === entityType && v.subTitle === entitySubtitle);
|
||||
if (index >= 0) {
|
||||
values[index].progress = d.progress;
|
||||
}
|
||||
} else if (d.state === 'DONE') {
|
||||
values = values.filter(v => !(v.entityType === entityType && v.subTitle === entitySubtitle));
|
||||
}
|
||||
this.downloadsSource.next(values);
|
||||
|
||||
}
|
||||
|
||||
private downloadChapter(chapter: Chapter) {
|
||||
const downloadType = 'chapter';
|
||||
const subtitle = this.downloadSubtitle(downloadType, chapter);
|
||||
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => {
|
||||
this.save(blob, filename)
|
||||
}));
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(
|
||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
download((blob, filename) => {
|
||||
this.save(blob, filename);
|
||||
}),
|
||||
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
|
||||
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||
);
|
||||
}
|
||||
|
||||
downloadVolume(volume: Volume): Observable<Download> {
|
||||
private downloadVolume(volume: Volume): Observable<Download> {
|
||||
const downloadType = 'volume';
|
||||
const subtitle = this.downloadSubtitle(downloadType, volume);
|
||||
return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => {
|
||||
this.save(blob, filename)
|
||||
}));
|
||||
).pipe(
|
||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
download((blob, filename) => {
|
||||
this.save(blob, filename);
|
||||
}),
|
||||
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
|
||||
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||
);
|
||||
}
|
||||
|
||||
async confirmSize(size: number, entityType: 'volume' | 'chapter' | 'series' | 'reading list') {
|
||||
private async confirmSize(size: number, entityType: DownloadEntityType) {
|
||||
return (size < this.SIZE_WARNING || await this.confirmService.confirm('The ' + entityType + ' is ' + this.humanFileSize(size) + '. Are you sure you want to continue?'));
|
||||
}
|
||||
|
||||
downloadBookmarks(bookmarks: PageBookmark[]) {
|
||||
return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks},
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => {
|
||||
this.save(blob, filename)
|
||||
}));
|
||||
}
|
||||
private downloadBookmarks(bookmarks: PageBookmark[]) {
|
||||
const downloadType = 'bookmark';
|
||||
const subtitle = this.downloadSubtitle(downloadType, bookmarks);
|
||||
|
||||
|
||||
return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks},
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(
|
||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
download((blob, filename) => {
|
||||
this.save(blob, filename);
|
||||
}),
|
||||
tap((d) => this.updateDownloadState(d, downloadType, subtitle)),
|
||||
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes as human-readable text.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<ng-container *ngIf="currentValue > 0">
|
||||
<div class="number">
|
||||
<i class="fa fa-angle-double-down" style="font-size: 36px;" aria-hidden="true"></i>
|
||||
<div [ngClass]="{'number': center}" class="indicator" *ngIf="showIcon">
|
||||
<i class="fa fa-angle-double-down" [ngStyle]="{'font-size': fontSize}" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div style="width: 100px; height: 100px;">
|
||||
<div [ngStyle]="{'width': width, 'height': height}">
|
||||
<circle-progress
|
||||
[percent]="currentValue"
|
||||
[radius]="100"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
position: absolute;
|
||||
top:50%;
|
||||
left:50%;
|
||||
z-index:10;
|
||||
font-size:18px;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
font-weight:500;
|
||||
z-index:10;
|
||||
color: var(--primary-color);
|
||||
animation: MoveUpDown 1s linear infinite;
|
||||
}
|
||||
|
|
@ -12,4 +12,18 @@ export class CircularLoaderComponent {
|
|||
@Input() maxValue: number = 0;
|
||||
@Input() animation: boolean = true;
|
||||
@Input() innerStrokeColor: string = 'transparent';
|
||||
@Input() fontSize: string = '36px';
|
||||
@Input() showIcon: boolean = true;
|
||||
/**
|
||||
* The width in pixels of the loader
|
||||
*/
|
||||
@Input() width: string = '100px';
|
||||
/**
|
||||
* The height in pixels of the loader
|
||||
*/
|
||||
@Input() height: string = '100px';
|
||||
/**
|
||||
* Centers the icon in the middle of the loader. Best for card use.
|
||||
*/
|
||||
@Input() center: boolean = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { DOCUMENT } from '@angular/common';
|
|||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, RendererStyleFlags2, TemplateRef, ViewChild } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { Observable, of, ReplaySubject, Subject } from 'rxjs';
|
||||
import { auditTime, filter, map, shareReplay, switchMap, take, takeUntil, tap } from 'rxjs/operators';
|
||||
import { auditTime, distinctUntilChanged, filter, map, shareReplay, switchMap, take, takeUntil, tap } from 'rxjs/operators';
|
||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { ToggleService } from '../_services/toggle.service';
|
||||
import { SelectionCompareFn, TypeaheadSettings } from './typeahead-settings';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
<div class="col-md-6 col-sm-12 pe-2 mb-2">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="auto-close" role="switch" formControlName="blurUnreadSummaries" class="form-check-input" aria-describedby="blurUnreadSummaries" [value]="true" aria-labelledby="auto-close-label">
|
||||
<input type="checkbox" id="auto-close" role="switch" formControlName="blurUnreadSummaries" class="form-check-input" aria-describedby="settings-global-blurUnreadSummaries-help" [value]="true" aria-labelledby="auto-close-label">
|
||||
<label class="form-check-label" for="auto-close">Blur Unread Summaries</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="blurUnreadSummariesTooltip" role="button" tabindex="0"></i>
|
||||
</div>
|
||||
|
||||
|
|
@ -45,6 +45,17 @@
|
|||
<span class="visually-hidden" id="settings-global-blurUnreadSummaries-help">Blurs summary text on volumes or chapters that have no read progress (to avoid spoilers)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6 col-sm-12 pe-2 mb-2">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="prompt-download" role="switch" formControlName="promptForDownloadSize" class="form-check-input" aria-describedby="settings-global-promptForDownloadSize-help" [value]="true" aria-labelledby="auto-close-label">
|
||||
<label class="form-check-label" for="prompt-download">Prompt on Download</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="promptForDownloadSizeTooltip" role="button" tabindex="0"></i>
|
||||
</div>
|
||||
|
||||
<ng-template #promptForDownloadSizeTooltip>Prompt when a download exceedes 100MB in size</ng-template>
|
||||
<span class="visually-hidden" id="settings-global-promptForDownloadSize-help">Prompt when a download exceedes 100MB in size</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">Reset</button>
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||
this.settingsForm.addControl('theme', new FormControl(this.user.preferences.theme, []));
|
||||
this.settingsForm.addControl('globalPageLayoutMode', new FormControl(this.user.preferences.globalPageLayoutMode, []));
|
||||
this.settingsForm.addControl('blurUnreadSummaries', new FormControl(this.user.preferences.blurUnreadSummaries, []));
|
||||
this.settingsForm.addControl('promptForDownloadSize', new FormControl(this.user.preferences.promptForDownloadSize, []));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
|
|
@ -181,6 +182,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||
this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.user.preferences.bookReaderImmersiveMode);
|
||||
this.settingsForm.get('globalPageLayoutMode')?.setValue(this.user.preferences.globalPageLayoutMode);
|
||||
this.settingsForm.get('blurUnreadSummaries')?.setValue(this.user.preferences.blurUnreadSummaries);
|
||||
this.settingsForm.get('promptForDownloadSize')?.setValue(this.user.preferences.promptForDownloadSize);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
|
@ -215,6 +217,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||
bookReaderImmersiveMode: modelSettings.bookReaderImmersiveMode,
|
||||
globalPageLayoutMode: parseInt(modelSettings.globalPageLayoutMode, 10),
|
||||
blurUnreadSummaries: modelSettings.blurUnreadSummaries,
|
||||
promptForDownloadSize: modelSettings.promptForDownloadSize,
|
||||
};
|
||||
|
||||
this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue