mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Webtoon Reader Fixup (#405)
* Navigate users to library page instead of home to prevent history block. * Cleaned up the Contributing to describe new code structure * Fixed a critical bug for how we find files for a chapter download (use ChapterId for lookup, not MangaFile.Id). Refactored how downloading works on the UI side to use the backend's filename whenever possible, else provide a custom name (and use backend's extension) for bundled downloads. * Fixed a bug where scroll intersection wasn't working on books without a table of content, even though it should have. * If user is using a direct url and hits an authentication guard, cache the url, allow authentication, then redirect them to said url * Added a transaction for bookmarking due to a rare case (in dev machines) where bookmark progress can duplicate * Re-enabled webtoon preference in reader settings. Refactored gotopage into it's own, dedicated handler to simplify logic. * Moved the prefetching code to occur whenever the page number within infinite scroller changes. This results in an easier to understand functioning. * Fixed isElementVisible() which was not properly calculating element visibility * GoToPage going forwards is working as expected, going backwards is completly broken * After performing a gotopage, make sure we update the scrolling direction based on the delta. * Removed some stuff thats not used, split the prefetching code up into separate functions to prepare for a rewrite. * Reworked prefetching to ensure we have a buffer of pages around ourselves. It is not fully tested, but working much better than previous implementation. Will be enhanced with DOM Pruning. * Cleaned up some old cruft from the backend code * Cleaned up the webtoon page change handler to use setPageNum, which will handle the correct prefetching of next/prev chapter * More cleanup around the codebase * Refactored the code to use a map to keep track of what is loaded or not, which works better than max/min in cases where you jump to a page that doesn't have anything preloaded and loads images out of order * Fixed a bad placement of code for when you are unauthenticated, the code will now redirect to the original location you requested before you had to login. * Some cleanup. Fixed the scrolling issue with prev page, spec seems to not work on intersection observer. using 0.01 instead of 0.0.
This commit is contained in:
parent
1cd68be4e2
commit
eb88967545
@ -24,6 +24,7 @@ namespace API.Tests.Services
|
|||||||
public void GetFilesTest_Should_Be28()
|
public void GetFilesTest_Should_Be28()
|
||||||
{
|
{
|
||||||
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Manga");
|
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Manga");
|
||||||
|
// ReSharper disable once CollectionNeverQueried.Local
|
||||||
var files = new List<string>();
|
var files = new List<string>();
|
||||||
var fileCount = DirectoryService.TraverseTreeParallelForEach(testDirectory, s => files.Add(s),
|
var fileCount = DirectoryService.TraverseTreeParallelForEach(testDirectory, s => files.Add(s),
|
||||||
API.Parser.Parser.ArchiveFileExtensions, _logger);
|
API.Parser.Parser.ArchiveFileExtensions, _logger);
|
||||||
|
@ -1,30 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace API.Configurations.CustomOptions
|
|
||||||
{
|
|
||||||
public class StatsOptions
|
|
||||||
{
|
|
||||||
public string ServerUrl { get; set; }
|
|
||||||
public string ServerSecret { get; set; }
|
|
||||||
public string SendDataAt { get; set; }
|
|
||||||
|
|
||||||
private const char Separator = ':';
|
|
||||||
|
|
||||||
public short SendDataHour => GetValueFromSendAt(0);
|
|
||||||
public short SendDataMinute => GetValueFromSendAt(1);
|
|
||||||
|
|
||||||
// The expected SendDataAt format is: Hour:Minute. Ex: 19:45
|
|
||||||
private short GetValueFromSendAt(int index)
|
|
||||||
{
|
|
||||||
var key = $"{nameof(StatsOptions)}:{nameof(SendDataAt)}";
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(SendDataAt))
|
|
||||||
throw new InvalidOperationException($"{key} is invalid. Check the app settings file");
|
|
||||||
|
|
||||||
if (short.TryParse(SendDataAt.Split(Separator)[index], out var parsedValue))
|
|
||||||
return parsedValue;
|
|
||||||
|
|
||||||
throw new InvalidOperationException($"Could not parse {key}. Check the app settings file");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -89,7 +89,7 @@ namespace API.Controllers
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return File(await _directoryService.ReadFileAsync(firstFile), contentType, Path.GetFileNameWithoutExtension(firstFile));
|
return File(await _directoryService.ReadFileAsync(firstFile), contentType, Path.GetFileName(firstFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("chapter")]
|
[HttpGet("chapter")]
|
||||||
|
@ -11,7 +11,6 @@ using API.Extensions;
|
|||||||
using API.Interfaces;
|
using API.Interfaces;
|
||||||
using API.Interfaces.Services;
|
using API.Interfaces.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace API.Controllers
|
namespace API.Controllers
|
||||||
{
|
{
|
||||||
@ -19,17 +18,14 @@ namespace API.Controllers
|
|||||||
{
|
{
|
||||||
private readonly IDirectoryService _directoryService;
|
private readonly IDirectoryService _directoryService;
|
||||||
private readonly ICacheService _cacheService;
|
private readonly ICacheService _cacheService;
|
||||||
private readonly ILogger<ReaderController> _logger;
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
|
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
|
||||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
||||||
|
|
||||||
public ReaderController(IDirectoryService directoryService, ICacheService cacheService,
|
public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork)
|
||||||
ILogger<ReaderController> logger, IUnitOfWork unitOfWork)
|
|
||||||
{
|
{
|
||||||
_directoryService = directoryService;
|
_directoryService = directoryService;
|
||||||
_cacheService = cacheService;
|
_cacheService = cacheService;
|
||||||
_logger = logger;
|
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,8 +234,11 @@ namespace API.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
user.Progresses ??= new List<AppUserProgress>();
|
user.Progresses ??= new List<AppUserProgress>();
|
||||||
var userProgress = user.Progresses.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id);
|
var userProgress =
|
||||||
|
user.Progresses.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id);
|
||||||
|
|
||||||
if (userProgress == null)
|
if (userProgress == null)
|
||||||
{
|
{
|
||||||
@ -268,6 +267,11 @@ namespace API.Controllers
|
|||||||
{
|
{
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
await _unitOfWork.RollbackAsync();
|
||||||
|
}
|
||||||
|
|
||||||
return BadRequest("Could not save progress");
|
return BadRequest("Could not save progress");
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,6 @@ using API.Extensions;
|
|||||||
using API.Interfaces.Services;
|
using API.Interfaces.Services;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Kavita.Common.EnvironmentInfo;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
@ -82,7 +82,7 @@ namespace API.Data
|
|||||||
public async Task<IList<MangaFile>> GetFilesForChapter(int chapterId)
|
public async Task<IList<MangaFile>> GetFilesForChapter(int chapterId)
|
||||||
{
|
{
|
||||||
return await _context.MangaFile
|
return await _context.MangaFile
|
||||||
.Where(c => chapterId == c.Id)
|
.Where(c => chapterId == c.ChapterId)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
@ -2,26 +2,21 @@
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
using API.Configurations.CustomOptions;
|
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace API.Services.Clients
|
namespace API.Services.Clients
|
||||||
{
|
{
|
||||||
public class StatsApiClient
|
public class StatsApiClient
|
||||||
{
|
{
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client;
|
||||||
private readonly StatsOptions _options;
|
|
||||||
private readonly ILogger<StatsApiClient> _logger;
|
private readonly ILogger<StatsApiClient> _logger;
|
||||||
private const string ApiUrl = "http://stats.kavitareader.com";
|
private const string ApiUrl = "http://stats.kavitareader.com";
|
||||||
|
|
||||||
public StatsApiClient(HttpClient client, IOptions<StatsOptions> options, ILogger<StatsApiClient> logger)
|
public StatsApiClient(HttpClient client, ILogger<StatsApiClient> logger)
|
||||||
{
|
{
|
||||||
_client = client;
|
_client = client;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendDataToStatsServer(UsageStatisticsDto data)
|
public async Task SendDataToStatsServer(UsageStatisticsDto data)
|
||||||
|
@ -101,7 +101,7 @@ namespace API
|
|||||||
// Ordering is important. Cors, authentication, authorization
|
// Ordering is important. Cors, authentication, authorization
|
||||||
if (env.IsDevelopment())
|
if (env.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseCors(policy => policy.AllowAnyHeader().AllowAnyMethod().WithOrigins("http://localhost:4200"));
|
app.UseCors(policy => policy.AllowAnyHeader().AllowAnyMethod().WithOrigins("http://localhost:4200").WithExposedHeaders("Content-Disposition"));
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseResponseCaching();
|
app.UseResponseCaching();
|
||||||
|
@ -19,12 +19,11 @@ Setup guides, FAQ, the more information we have on the [wiki](https://github.com
|
|||||||
|
|
||||||
1. Fork Kavita
|
1. Fork Kavita
|
||||||
2. Clone the repository into your development machine. [*info*](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository-from-github)
|
2. Clone the repository into your development machine. [*info*](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository-from-github)
|
||||||
- Kavita as of v0.4.2 requires Kavita-webui to be cloned next to the Kavita. Fork and clone this as well.
|
|
||||||
3. Install the required Node Packages
|
3. Install the required Node Packages
|
||||||
- cd kavita-webui
|
- cd Kavita/UI/Web
|
||||||
- `npm install`
|
- `npm install`
|
||||||
- `npm install -g @angular/cli`
|
- `npm install -g @angular/cli`
|
||||||
4. Start webui server `ng serve`
|
4. Start angular server `ng serve`
|
||||||
5. Build the project in Visual Studio/Rider, Setting startup project to `API`
|
5. Build the project in Visual Studio/Rider, Setting startup project to `API`
|
||||||
6. Debug the project in Visual Studio/Rider
|
6. Debug the project in Visual Studio/Rider
|
||||||
7. Open http://localhost:4200
|
7. Open http://localhost:4200
|
||||||
@ -41,10 +40,10 @@ Setup guides, FAQ, the more information we have on the [wiki](https://github.com
|
|||||||
- Commit with *nix line endings for consistency (We checkout Windows and commit *nix)
|
- Commit with *nix line endings for consistency (We checkout Windows and commit *nix)
|
||||||
- One feature/bug fix per pull request to keep things clean and easy to understand
|
- One feature/bug fix per pull request to keep things clean and easy to understand
|
||||||
- Use 4 spaces instead of tabs, this is the default for VS 2019 and WebStorm (to my knowledge)
|
- Use 4 spaces instead of tabs, this is the default for VS 2019 and WebStorm (to my knowledge)
|
||||||
- Use 2 spaces for Kavita-webui files
|
- Use 2 spaces for UI files
|
||||||
|
|
||||||
### Pull Requesting ###
|
### Pull Requesting ###
|
||||||
- Only make pull requests to develop, never master, if you make a PR to master we'll comment on it and close it
|
- Only make pull requests to develop, never main, if you make a PR to main we'll comment on it and close it
|
||||||
- You're probably going to get some comments or questions from us, they will be to ensure consistency and maintainability
|
- You're probably going to get some comments or questions from us, they will be to ensure consistency and maintainability
|
||||||
- We'll try to respond to pull requests as soon as possible, if its been a day or two, please reach out to us, we may have missed it
|
- We'll try to respond to pull requests as soon as possible, if its been a day or two, please reach out to us, we may have missed it
|
||||||
- Each PR should come from its own [feature branch](http://martinfowler.com/bliki/FeatureBranch.html) not develop in your fork, it should have a meaningful branch name (what is being added/fixed)
|
- Each PR should come from its own [feature branch](http://martinfowler.com/bliki/FeatureBranch.html) not develop in your fork, it should have a meaningful branch name (what is being added/fixed)
|
||||||
@ -52,5 +51,7 @@ Setup guides, FAQ, the more information we have on the [wiki](https://github.com
|
|||||||
- fix-bug (Good)
|
- fix-bug (Good)
|
||||||
- patch (Bad)
|
- patch (Bad)
|
||||||
- develop (Bad)
|
- develop (Bad)
|
||||||
|
- feature/parser-enhancements (Great)
|
||||||
|
- bugfix/book-issues (Great)
|
||||||
|
|
||||||
If you have any questions about any of this, please let us know.
|
If you have any questions about any of this, please let us know.
|
||||||
|
@ -10,6 +10,7 @@ import { AccountService } from '../_services/account.service';
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
|
public urlKey: string = 'kavita--auth-intersection-url';
|
||||||
constructor(private accountService: AccountService, private router: Router, private toastr: ToastrService) {}
|
constructor(private accountService: AccountService, private router: Router, private toastr: ToastrService) {}
|
||||||
|
|
||||||
canActivate(): Observable<boolean> {
|
canActivate(): Observable<boolean> {
|
||||||
@ -19,6 +20,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
this.toastr.error('You are not authorized to view this page.');
|
this.toastr.error('You are not authorized to view this page.');
|
||||||
|
localStorage.setItem(this.urlKey, window.location.pathname);
|
||||||
this.router.navigateByUrl('/home');
|
this.router.navigateByUrl('/home');
|
||||||
return false;
|
return false;
|
||||||
})
|
})
|
||||||
|
@ -15,6 +15,7 @@ import { environment } from 'src/environments/environment';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ErrorInterceptor implements HttpInterceptor {
|
export class ErrorInterceptor implements HttpInterceptor {
|
||||||
|
|
||||||
|
public urlKey: string = 'kavita--no-connection-url';
|
||||||
constructor(private router: Router, private toastr: ToastrService, private accountService: AccountService) {}
|
constructor(private router: Router, private toastr: ToastrService, private accountService: AccountService) {}
|
||||||
|
|
||||||
|
|
||||||
@ -50,7 +51,7 @@ export class ErrorInterceptor implements HttpInterceptor {
|
|||||||
|
|
||||||
// If we are not on no-connection, redirect there and save current url so when we refersh, we redirect back there
|
// If we are not on no-connection, redirect there and save current url so when we refersh, we redirect back there
|
||||||
if (this.router.url !== '/no-connection') {
|
if (this.router.url !== '/no-connection') {
|
||||||
localStorage.setItem('kavita--no-connection-url', this.router.url);
|
localStorage.setItem(this.urlKey, this.router.url);
|
||||||
this.router.navigateByUrl('/no-connection');
|
this.router.navigateByUrl('/no-connection');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -27,4 +27,4 @@ export interface Preferences {
|
|||||||
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
|
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
|
||||||
export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automatic}, {text: 'Fit to Height', value: ScalingOption.FitToHeight}, {text: 'Fit to Width', value: ScalingOption.FitToWidth}, {text: 'Original', value: ScalingOption.Original}];
|
export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automatic}, {text: 'Fit to Height', value: ScalingOption.FitToHeight}, {text: 'Fit to Width', value: ScalingOption.FitToWidth}, {text: 'Original', value: ScalingOption.Original}];
|
||||||
export const pageSplitOptions = [{text: 'Right to Left', value: PageSplitOption.SplitRightToLeft}, {text: 'Left to Right', value: PageSplitOption.SplitLeftToRight}, {text: 'No Split', value: PageSplitOption.NoSplit}];
|
export const pageSplitOptions = [{text: 'Right to Left', value: PageSplitOption.SplitRightToLeft}, {text: 'Left to Right', value: PageSplitOption.SplitLeftToRight}, {text: 'No Split', value: PageSplitOption.NoSplit}];
|
||||||
export const readingModes = [{text: 'Left to Right', value: READER_MODE.MANGA_LR}, {text: 'Up to Down', value: READER_MODE.MANGA_UD}/*, {text: 'Webtoon', value: READER_MODE.WEBTOON}*/];
|
export const readingModes = [{text: 'Left to Right', value: READER_MODE.MANGA_LR}, {text: 'Up to Down', value: READER_MODE.MANGA_UD}, {text: 'Webtoon', value: READER_MODE.WEBTOON}];
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
//import * as console.log from 'console.log';
|
|
||||||
import { environment } from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
import { ChapterInfo } from '../manga-reader/_models/chapter-info';
|
import { ChapterInfo } from '../manga-reader/_models/chapter-info';
|
||||||
import { UtilityService } from '../shared/_services/utility.service';
|
import { UtilityService } from '../shared/_services/utility.service';
|
||||||
|
@ -196,12 +196,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After the page has loaded, setup the scroll handler. The scroll handler has 2 parts. One is if there are page anchors setup (aka page anchor elements linked with the
|
||||||
|
* table of content) then we calculate what has already been reached and grab the last reached one to bookmark. If page anchors aren't setup (toc missing), then try to bookmark
|
||||||
|
* based on the last seen scroll part (xpath).
|
||||||
|
*/
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
// check scroll offset and if offset is after any of the "id" markers, bookmark it
|
// check scroll offset and if offset is after any of the "id" markers, bookmark it
|
||||||
fromEvent(window, 'scroll')
|
fromEvent(window, 'scroll')
|
||||||
.pipe(debounceTime(200), takeUntil(this.onDestroy)).subscribe((event) => {
|
.pipe(debounceTime(200), takeUntil(this.onDestroy)).subscribe((event) => {
|
||||||
if (this.isLoading) return;
|
if (this.isLoading) return;
|
||||||
if (Object.keys(this.pageAnchors).length === 0) return;
|
if (Object.keys(this.pageAnchors).length !== 0) {
|
||||||
// get the height of the document so we can capture markers that are halfway on the document viewport
|
// get the height of the document so we can capture markers that are halfway on the document viewport
|
||||||
const verticalOffset = (window.pageYOffset
|
const verticalOffset = (window.pageYOffset
|
||||||
|| document.documentElement.scrollTop
|
|| document.documentElement.scrollTop
|
||||||
@ -210,9 +215,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset);
|
const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset);
|
||||||
if (alreadyReached.length > 0) {
|
if (alreadyReached.length > 0) {
|
||||||
this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1];
|
this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1];
|
||||||
|
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
this.currentPageAnchor = '';
|
this.currentPageAnchor = '';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.lastSeenScrollPartPath !== '') {
|
if (this.lastSeenScrollPartPath !== '') {
|
||||||
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
|
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||||
@ -253,7 +261,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
const chapterId = this.route.snapshot.paramMap.get('chapterId');
|
const chapterId = this.route.snapshot.paramMap.get('chapterId');
|
||||||
|
|
||||||
if (libraryId === null || seriesId === null || chapterId === null) {
|
if (libraryId === null || seriesId === null || chapterId === null) {
|
||||||
this.router.navigateByUrl('/home');
|
this.router.navigateByUrl('/library');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,7 +40,6 @@ export class HomeComponent implements OnInit {
|
|||||||
.subscribe(resp => {/* No Operation */});
|
.subscribe(resp => {/* No Operation */});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
// User is logged in, redirect to libraries
|
|
||||||
this.router.navigateByUrl('/library');
|
this.router.navigateByUrl('/library');
|
||||||
} else {
|
} else {
|
||||||
this.router.navigateByUrl('/login');
|
this.router.navigateByUrl('/login');
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<strong>Captures Scroll Events:</strong> {{!this.isScrolling && this.allImagesLoaded}}
|
<strong>Captures Scroll Events:</strong> {{!this.isScrolling && this.allImagesLoaded}}
|
||||||
<strong>Is Scrolling:</strong> {{isScrollingForwards() ? 'Forwards' : 'Backwards'}} {{this.isScrolling}}
|
<strong>Is Scrolling:</strong> {{isScrollingForwards() ? 'Forwards' : 'Backwards'}} {{this.isScrolling}}
|
||||||
<strong>All Images Loaded:</strong> {{this.allImagesLoaded}}
|
<strong>All Images Loaded:</strong> {{this.allImagesLoaded}}
|
||||||
<strong>Prefetched</strong> {{minPrefetchedWebtoonImage}}-{{maxPrefetchedWebtoonImage}}
|
<strong>Prefetched</strong> {{minPageLoaded}}-{{maxPageLoaded}}
|
||||||
<strong>Current Page:</strong>{{pageNum}}
|
<strong>Current Page:</strong>{{pageNum}}
|
||||||
<strong>Width:</strong> {{webtoonImageWidth}}
|
<strong>Width:</strong> {{webtoonImageWidth}}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
|
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core';
|
||||||
import { BehaviorSubject, fromEvent, Subject } from 'rxjs';
|
import { BehaviorSubject, fromEvent, ReplaySubject, Subject } from 'rxjs';
|
||||||
import { debounceTime, takeUntil } from 'rxjs/operators';
|
import { debounceTime, takeUntil } from 'rxjs/operators';
|
||||||
import { CircularArray } from 'src/app/shared/data-structures/circular-array';
|
import { ReaderService } from '../../_services/reader.service';
|
||||||
import { ReaderService } from 'src/app/_services/reader.service';
|
|
||||||
import { PAGING_DIRECTION } from '../_models/reader-enums';
|
import { PAGING_DIRECTION } from '../_models/reader-enums';
|
||||||
import { WebtoonImage } from '../_models/webtoon-image';
|
import { WebtoonImage } from '../_models/webtoon-image';
|
||||||
|
|
||||||
@ -20,7 +19,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* Number of pages to prefetch ahead of position
|
* Number of pages to prefetch ahead of position
|
||||||
*/
|
*/
|
||||||
@Input() buffferPages: number = 5;
|
@Input() bufferPages: number = 5;
|
||||||
/**
|
/**
|
||||||
* Total number of pages
|
* Total number of pages
|
||||||
*/
|
*/
|
||||||
@ -31,6 +30,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
@Input() urlProvider!: (page: number) => string;
|
@Input() urlProvider!: (page: number) => string;
|
||||||
@Output() pageNumberChange: EventEmitter<number> = new EventEmitter<number>();
|
@Output() pageNumberChange: EventEmitter<number> = new EventEmitter<number>();
|
||||||
|
|
||||||
|
@Input() goToPage: ReplaySubject<number> = new ReplaySubject<number>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores and emits all the src urls
|
* Stores and emits all the src urls
|
||||||
*/
|
*/
|
||||||
@ -38,9 +39,9 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Responsible for calculating current page on screen and uses hooks to trigger prefetching.
|
* Responsible for calculating current page on screen and uses hooks to trigger prefetching.
|
||||||
* Note: threshold will fire differently due to size of images. 1 requires full image on screen. 0 means 1px on screen.
|
* Note: threshold will fire differently due to size of images. 1 requires full image on screen. 0 means 1px on screen. We use 0.01 as 0 does not work currently.
|
||||||
*/
|
*/
|
||||||
intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: [0] });
|
intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: 0.01 });
|
||||||
/**
|
/**
|
||||||
* Direction we are scrolling. Controls calculations for prefetching
|
* Direction we are scrolling. Controls calculations for prefetching
|
||||||
*/
|
*/
|
||||||
@ -53,19 +54,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
* Temp variable to keep track of when the scrollTo() finishes, so we can start capturing scroll events again
|
* Temp variable to keep track of when the scrollTo() finishes, so we can start capturing scroll events again
|
||||||
*/
|
*/
|
||||||
currentPageElem: Element | null = null;
|
currentPageElem: Element | null = null;
|
||||||
/**
|
|
||||||
* The min page number that has been prefetched
|
|
||||||
*/
|
|
||||||
minPrefetchedWebtoonImage: number = Number.MAX_SAFE_INTEGER;
|
|
||||||
/**
|
|
||||||
* The max page number that has been prefetched
|
|
||||||
*/
|
|
||||||
maxPrefetchedWebtoonImage: number = Number.MIN_SAFE_INTEGER;
|
|
||||||
/**
|
/**
|
||||||
* The minimum width of images in webtoon. On image loading, this is checked and updated. All images will get this assigned to them for rendering.
|
* The minimum width of images in webtoon. On image loading, this is checked and updated. All images will get this assigned to them for rendering.
|
||||||
*/
|
*/
|
||||||
webtoonImageWidth: number = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
webtoonImageWidth: number = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to tell if a scrollTo() operation is in progress
|
* Used to tell if a scrollTo() operation is in progress
|
||||||
*/
|
*/
|
||||||
@ -74,24 +66,22 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
* Whether all prefetched images have loaded on the screen (not neccesarily in viewport)
|
* Whether all prefetched images have loaded on the screen (not neccesarily in viewport)
|
||||||
*/
|
*/
|
||||||
allImagesLoaded: boolean = false;
|
allImagesLoaded: boolean = false;
|
||||||
|
/**
|
||||||
|
* Denotes each page that has been loaded or not. If pruning is implemented, the key will be deleted.
|
||||||
|
*/
|
||||||
|
imagesLoaded: {[key: number]: number} = {};
|
||||||
/**
|
/**
|
||||||
* Debug mode. Will show extra information
|
* Debug mode. Will show extra information
|
||||||
*/
|
*/
|
||||||
debug: boolean = true;
|
debug: boolean = false;
|
||||||
|
|
||||||
/**
|
get minPageLoaded() {
|
||||||
* Timer to help detect when a scroll end event has occured (not used)
|
return Math.min(...Object.keys(this.imagesLoaded).map(key => parseInt(key, 10)));
|
||||||
*/
|
}
|
||||||
scrollEndTimer: any;
|
|
||||||
|
|
||||||
|
get maxPageLoaded() {
|
||||||
/**
|
return Math.max(...Object.keys(this.imagesLoaded).map(key => parseInt(key, 10)));
|
||||||
* Each pages height mapped to page number as key (not used)
|
}
|
||||||
*/
|
|
||||||
pageHeights:{[key: number]: number} = {};
|
|
||||||
|
|
||||||
buffer: CircularArray<HTMLImageElement> = new CircularArray<HTMLImageElement>([], 0);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -100,40 +90,10 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
constructor(private readerService: ReaderService, private renderer: Renderer2) { }
|
constructor(private readerService: ReaderService, private renderer: Renderer2) { }
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
let shouldInit = false;
|
|
||||||
console.log('Changes: ', changes);
|
|
||||||
if (changes.hasOwnProperty('totalPages') && changes['totalPages'].currentValue === 0) {
|
|
||||||
this.debugLog('Swallowing variable change due to totalPages being 0');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changes.hasOwnProperty('totalPages') && changes['totalPages'].previousValue != changes['totalPages'].currentValue) {
|
if (changes.hasOwnProperty('totalPages') && changes['totalPages'].previousValue != changes['totalPages'].currentValue) {
|
||||||
this.totalPages = changes['totalPages'].currentValue;
|
this.totalPages = changes['totalPages'].currentValue;
|
||||||
shouldInit = true;
|
|
||||||
}
|
|
||||||
if (changes.hasOwnProperty('pageNum') && changes['pageNum'].previousValue != changes['pageNum'].currentValue) {
|
|
||||||
// Manually update pageNum as we are getting notified from a parent component, hence we shouldn't invoke update
|
|
||||||
this.setPageNum(changes['pageNum'].currentValue);
|
|
||||||
if (Math.abs(changes['pageNum'].currentValue - changes['pageNum'].previousValue) > 2) {
|
|
||||||
// Go to page has occured
|
|
||||||
shouldInit = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldInit) {
|
|
||||||
this.initWebtoonReader();
|
this.initWebtoonReader();
|
||||||
}
|
}
|
||||||
|
|
||||||
// This should only execute on initial load or from a gotopage update
|
|
||||||
const currentImage = document.querySelector('img#page-' + this.pageNum);
|
|
||||||
if (currentImage !== null && this.isElementVisible(currentImage)) {
|
|
||||||
|
|
||||||
if ((changes.hasOwnProperty('pageNum') && Math.abs(changes['pageNum'].previousValue - changes['pageNum'].currentValue) <= 0) || !shouldInit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.debugLog('Scrolling to page', this.pageNum);
|
|
||||||
this.scrollToCurrentPage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@ -146,24 +106,36 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
fromEvent(window, 'scroll')
|
fromEvent(window, 'scroll')
|
||||||
.pipe(debounceTime(20), takeUntil(this.onDestroy))
|
.pipe(debounceTime(20), takeUntil(this.onDestroy))
|
||||||
.subscribe((event) => this.handleScrollEvent(event));
|
.subscribe((event) => this.handleScrollEvent(event));
|
||||||
|
|
||||||
|
if (this.goToPage) {
|
||||||
|
this.goToPage.pipe(takeUntil(this.onDestroy)).subscribe(page => {
|
||||||
|
this.debugLog('[GoToPage] jump has occured from ' + this.pageNum + ' to ' + page);
|
||||||
|
const isSamePage = this.pageNum === page;
|
||||||
|
if (isSamePage) { return; }
|
||||||
|
|
||||||
|
if (this.pageNum < page) {
|
||||||
|
this.scrollingDirection = PAGING_DIRECTION.FORWARD;
|
||||||
|
} else {
|
||||||
|
this.scrollingDirection = PAGING_DIRECTION.BACKWARDS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setPageNum(page, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On scroll in document, calculate if the user/javascript has scrolled to the current image element (and it's visible), update that scrolling has ended completely,
|
||||||
|
* and calculate the direction the scrolling is occuring. This is used for prefetching.
|
||||||
|
* @param event Scroll Event
|
||||||
|
*/
|
||||||
handleScrollEvent(event?: any) {
|
handleScrollEvent(event?: any) {
|
||||||
const verticalOffset = (window.pageYOffset
|
const verticalOffset = (window.pageYOffset
|
||||||
|| document.documentElement.scrollTop
|
|| document.documentElement.scrollTop
|
||||||
|| document.body.scrollTop || 0);
|
|| document.body.scrollTop || 0);
|
||||||
|
|
||||||
|
if (this.isScrolling && this.currentPageElem != null && this.isElementVisible(this.currentPageElem)) {
|
||||||
clearTimeout(this.scrollEndTimer);
|
this.debugLog('[Scroll] Image is visible from scroll, isScrolling is now false');
|
||||||
this.scrollEndTimer = setTimeout(() => this.handleScrollEnd(), 150);
|
|
||||||
|
|
||||||
if (this.debug && this.isScrolling) {
|
|
||||||
this.debugLog('verticalOffset: ', verticalOffset);
|
|
||||||
this.debugLog('scroll to element offset: ', this.currentPageElem?.getBoundingClientRect().top);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.currentPageElem != null && this.isElementVisible(this.currentPageElem)) {
|
|
||||||
this.debugLog('Image is visible from scroll, isScrolling is now false');
|
|
||||||
this.isScrolling = false;
|
this.isScrolling = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,58 +147,36 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.prevScrollPosition = verticalOffset;
|
this.prevScrollPosition = verticalOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ! This will fire twice from an automatic scroll
|
|
||||||
handleScrollEnd() {
|
|
||||||
//console.log('!!! Scroll End Event !!!');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is any part of the element visible in the scrollport. Does not take into account
|
* Is any part of the element visible in the scrollport. Does not take into account
|
||||||
* style properites, just scroll port visibility.
|
* style properites, just scroll port visibility.
|
||||||
* Note: use && to ensure the whole images is visible
|
|
||||||
* @param elem
|
* @param elem
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
isElementVisible(elem: Element) {
|
isElementVisible(elem: Element) {
|
||||||
if (elem === null || elem === undefined) { return false; }
|
if (elem === null || elem === undefined) { return false; }
|
||||||
|
|
||||||
const docViewTop = window.pageYOffset;
|
// NOTE: This will say an element is visible if it is 1 px offscreen on top
|
||||||
const docViewBottom = docViewTop + window.innerHeight;
|
var rect = elem.getBoundingClientRect();
|
||||||
|
|
||||||
const elemTop = elem.getBoundingClientRect().top;
|
return (rect.bottom >= 0 &&
|
||||||
const elemBottom = elemTop + elem.getBoundingClientRect().height;
|
rect.right >= 0 &&
|
||||||
|
rect.top <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||||
return ((elemBottom <= docViewBottom) || (elemTop >= docViewTop));
|
rect.left <= (window.innerWidth || document.documentElement.clientWidth)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
initWebtoonReader() {
|
initWebtoonReader() {
|
||||||
|
this.imagesLoaded = {};
|
||||||
this.minPrefetchedWebtoonImage = this.pageNum;
|
|
||||||
this.maxPrefetchedWebtoonImage = Number.MIN_SAFE_INTEGER;
|
|
||||||
this.webtoonImages.next([]);
|
this.webtoonImages.next([]);
|
||||||
|
|
||||||
|
const prefetchStart = Math.max(this.pageNum - this.bufferPages, 0);
|
||||||
|
const prefetchMax = Math.min(this.pageNum + this.bufferPages, this.totalPages);
|
||||||
|
|
||||||
const prefetchStart = Math.max(this.pageNum - this.buffferPages, 0);
|
|
||||||
const prefetchMax = Math.min(this.pageNum + this.buffferPages, this.totalPages);
|
|
||||||
this.debugLog('[INIT] Prefetching pages ' + prefetchStart + ' to ' + prefetchMax + '. Current page: ', this.pageNum);
|
this.debugLog('[INIT] Prefetching pages ' + prefetchStart + ' to ' + prefetchMax + '. Current page: ', this.pageNum);
|
||||||
for(let i = prefetchStart; i < prefetchMax; i++) {
|
for(let i = prefetchStart; i < prefetchMax; i++) {
|
||||||
this.prefetchWebtoonImage(i);
|
this.loadWebtoonImage(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
const images = [];
|
|
||||||
for (let i = prefetchStart; i < prefetchMax; i++) {
|
|
||||||
images.push(new Image());
|
|
||||||
}
|
|
||||||
this.buffer = new CircularArray<HTMLImageElement>(images, this.buffferPages);
|
|
||||||
|
|
||||||
this.minPrefetchedWebtoonImage = prefetchStart;
|
|
||||||
this.maxPrefetchedWebtoonImage = prefetchMax;
|
|
||||||
|
|
||||||
this.debugLog('Buffer: ', this.buffer.arr.map(img => this.readerService.imageUrlToPageNum(img.src)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -236,15 +186,17 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
onImageLoad(event: any) {
|
onImageLoad(event: any) {
|
||||||
const imagePage = this.readerService.imageUrlToPageNum(event.target.src);
|
const imagePage = this.readerService.imageUrlToPageNum(event.target.src);
|
||||||
this.debugLog('Image loaded: ', imagePage);
|
this.debugLog('[Image Load] Image loaded: ', imagePage);
|
||||||
|
|
||||||
|
if (!this.imagesLoaded.hasOwnProperty(imagePage)) {
|
||||||
|
this.imagesLoaded[imagePage] = imagePage;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (event.target.width < this.webtoonImageWidth) {
|
if (event.target.width < this.webtoonImageWidth) {
|
||||||
this.webtoonImageWidth = event.target.width;
|
this.webtoonImageWidth = event.target.width;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pageHeights[imagePage] = event.target.getBoundingClientRect().height;
|
|
||||||
|
|
||||||
this.renderer.setAttribute(event.target, 'width', this.webtoonImageWidth + '');
|
this.renderer.setAttribute(event.target, 'width', this.webtoonImageWidth + '');
|
||||||
this.renderer.setAttribute(event.target, 'height', event.target.height + '');
|
this.renderer.setAttribute(event.target, 'height', event.target.height + '');
|
||||||
|
|
||||||
@ -255,59 +207,99 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
.filter((img: any) => !img.complete)
|
.filter((img: any) => !img.complete)
|
||||||
.map((img: any) => new Promise(resolve => { img.onload = img.onerror = resolve; })))
|
.map((img: any) => new Promise(resolve => { img.onload = img.onerror = resolve; })))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.debugLog('! Loaded current page !', this.pageNum);
|
this.debugLog('[Image Load] ! Loaded current page !', this.pageNum);
|
||||||
|
this.currentPageElem = document.querySelector('img#page-' + this.pageNum);
|
||||||
|
|
||||||
|
if (this.currentPageElem && !this.isElementVisible(this.currentPageElem)) {
|
||||||
|
this.scrollToCurrentPage();
|
||||||
|
}
|
||||||
|
|
||||||
this.allImagesLoaded = true;
|
this.allImagesLoaded = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleIntersection(entries: IntersectionObserverEntry[]) {
|
handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||||
|
|
||||||
if (!this.allImagesLoaded || this.isScrolling) {
|
if (!this.allImagesLoaded || this.isScrolling) {
|
||||||
this.debugLog('Images are not loaded (or performing scrolling action), skipping any scroll calculations');
|
this.debugLog('[Intersection] Images are not loaded (or performing scrolling action), skipping any scroll calculations');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
const imagePage = parseInt(entry.target.attributes.getNamedItem('page')?.value + '', 10);
|
const imagePage = parseInt(entry.target.attributes.getNamedItem('page')?.value + '', 10);
|
||||||
this.debugLog('[Intersection] Page ' + imagePage + ' is visible: ', entry.isIntersecting);
|
this.debugLog('[Intersection] Page ' + imagePage + ' is visible: ', entry.isIntersecting);
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
this.debugLog('[Intersection] ! Page ' + imagePage + ' just entered screen');
|
this.debugLog('[Intersection] ! Page ' + imagePage + ' just entered screen');
|
||||||
this.setPageNum(imagePage);
|
this.setPageNum(imagePage);
|
||||||
this.prefetchWebtoonImages();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setPageNum(pageNum: number) {
|
/**
|
||||||
|
* Set the page number, invoke prefetching and optionally scroll to the new page.
|
||||||
|
* @param pageNum Page number to set to. Will trigger the pageNumberChange event emitter.
|
||||||
|
* @param scrollToPage Optional (default false) parameter to trigger scrolling to the newly set page
|
||||||
|
*/
|
||||||
|
setPageNum(pageNum: number, scrollToPage: boolean = false) {
|
||||||
this.pageNum = pageNum;
|
this.pageNum = pageNum;
|
||||||
this.pageNumberChange.emit(this.pageNum);
|
this.pageNumberChange.emit(this.pageNum);
|
||||||
|
|
||||||
//TODO: Perform check here to see if prefetching or DOM removal is needed
|
this.prefetchWebtoonImages();
|
||||||
|
// TODO: We can prune DOM based on our buffer
|
||||||
|
// Note: Can i test if we can put this dom pruning async, so user doesn't feel it? (don't forget to unobserve image when purging)
|
||||||
|
// I can feel a noticable scroll spike from this code (commenting out pruning until rest of the bugs are sorted)
|
||||||
|
// const images = document.querySelectorAll('img').forEach(img => {
|
||||||
|
// const imagePageNum = this.readerService.imageUrlToPageNum(img.src);
|
||||||
|
// if (imagePageNum < this.pageNum - this.bufferPages) { // this.minPrefetchedWebtoonImage
|
||||||
|
// console.log('Image ' + imagePageNum + ' is outside minimum range, pruning from DOM');
|
||||||
|
// } else if (imagePageNum > this.pageNum + 1 + this.bufferPages) { // this.maxPrefetchedWebtoonImage
|
||||||
|
// console.log('Image ' + imagePageNum + ' is outside maximum range, pruning from DOM');
|
||||||
|
// }
|
||||||
|
// // NOTE: Max and Mins don't update as we scroll!
|
||||||
|
// });
|
||||||
|
|
||||||
|
|
||||||
|
if (scrollToPage) {
|
||||||
|
const currentImage = document.querySelector('img#page-' + this.pageNum);
|
||||||
|
if (currentImage !== null && !this.isElementVisible(currentImage)) {
|
||||||
|
this.debugLog('[GoToPage] Scrolling to page', this.pageNum);
|
||||||
|
this.scrollToCurrentPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isScrollingForwards() {
|
isScrollingForwards() {
|
||||||
return this.scrollingDirection === PAGING_DIRECTION.FORWARD;
|
return this.scrollingDirection === PAGING_DIRECTION.FORWARD;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the scroll for the current page element. Updates any state variables needed.
|
||||||
|
*/
|
||||||
scrollToCurrentPage() {
|
scrollToCurrentPage() {
|
||||||
this.currentPageElem = document.querySelector('img#page-' + this.pageNum);
|
this.currentPageElem = document.querySelector('img#page-' + this.pageNum);
|
||||||
if (!this.currentPageElem) { return; }
|
if (!this.currentPageElem) { return; }
|
||||||
|
//if (this.isElementVisible(this.currentPageElem)) { return; }
|
||||||
// Update prevScrollPosition, so the next scroll event properly calculates direction
|
// Update prevScrollPosition, so the next scroll event properly calculates direction
|
||||||
this.prevScrollPosition = this.currentPageElem.getBoundingClientRect().top;
|
this.prevScrollPosition = this.currentPageElem.getBoundingClientRect().top;
|
||||||
this.isScrolling = true;
|
this.isScrolling = true;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.currentPageElem) {
|
if (this.currentPageElem) {
|
||||||
|
this.debugLog('[Scroll] Scrolling to page ', this.pageNum);
|
||||||
this.currentPageElem.scrollIntoView({behavior: 'smooth'});
|
this.currentPageElem.scrollIntoView({behavior: 'smooth'});
|
||||||
}
|
}
|
||||||
}, 600);
|
}, 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
prefetchWebtoonImage(page: number) {
|
loadWebtoonImage(page: number) {
|
||||||
let data = this.webtoonImages.value;
|
let data = this.webtoonImages.value;
|
||||||
|
|
||||||
|
if (this.imagesLoaded.hasOwnProperty(page)) {
|
||||||
|
this.debugLog('\t[PREFETCH] Skipping prefetch of ', page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.debugLog('\t[PREFETCH] Prefetching ', page);
|
||||||
|
|
||||||
data = data.concat({src: this.urlProvider(page), page});
|
data = data.concat({src: this.urlProvider(page), page});
|
||||||
|
|
||||||
data.sort((a: WebtoonImage, b: WebtoonImage) => {
|
data.sort((a: WebtoonImage, b: WebtoonImage) => {
|
||||||
@ -316,32 +308,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
else return 0;
|
else return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (page < this.minPrefetchedWebtoonImage) {
|
|
||||||
this.minPrefetchedWebtoonImage = page;
|
|
||||||
}
|
|
||||||
if (page > this.maxPrefetchedWebtoonImage) {
|
|
||||||
this.maxPrefetchedWebtoonImage = page;
|
|
||||||
}
|
|
||||||
this.allImagesLoaded = false;
|
this.allImagesLoaded = false;
|
||||||
|
|
||||||
this.webtoonImages.next(data);
|
this.webtoonImages.next(data);
|
||||||
|
|
||||||
let index = 1;
|
|
||||||
|
|
||||||
this.buffer.applyFor((item, i) => {
|
|
||||||
const offsetIndex = this.pageNum + index;
|
|
||||||
const urlPageNum = this.readerService.imageUrlToPageNum(item.src);
|
|
||||||
if (urlPageNum === offsetIndex) {
|
|
||||||
index += 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (offsetIndex < this.totalPages - 1) {
|
|
||||||
item.src = this.urlProvider(offsetIndex);
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
}, this.buffer.size() - 3);
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
attachIntersectionObserverElem(elem: HTMLImageElement) {
|
attachIntersectionObserverElem(elem: HTMLImageElement) {
|
||||||
@ -349,27 +317,23 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
this.intersectionObserver.observe(elem);
|
this.intersectionObserver.observe(elem);
|
||||||
this.debugLog('Attached Intersection Observer to page', this.readerService.imageUrlToPageNum(elem.src));
|
this.debugLog('Attached Intersection Observer to page', this.readerService.imageUrlToPageNum(elem.src));
|
||||||
} else {
|
} else {
|
||||||
console.error('Could not attach observer on elem');
|
console.error('Could not attach observer on elem'); // This never happens
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prefetchWebtoonImages() {
|
calculatePrefetchIndecies() {
|
||||||
let startingIndex = 0;
|
let startingIndex = 0;
|
||||||
let endingIndex = 0;
|
let endingIndex = 0;
|
||||||
if (this.isScrollingForwards()) {
|
if (this.isScrollingForwards()) {
|
||||||
startingIndex = Math.min(this.maxPrefetchedWebtoonImage + 1, this.totalPages);
|
startingIndex = Math.min(Math.max(this.pageNum - this.bufferPages, 0), this.totalPages);
|
||||||
endingIndex = Math.min(this.maxPrefetchedWebtoonImage + 1 + this.buffferPages, this.totalPages);
|
endingIndex = Math.min(Math.max(this.pageNum + this.bufferPages, 0), this.totalPages);
|
||||||
|
|
||||||
if (startingIndex === this.totalPages) {
|
if (startingIndex === this.totalPages) {
|
||||||
return;
|
return [0, 0];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
startingIndex = Math.max(this.minPrefetchedWebtoonImage - 1, 0) ;
|
startingIndex = Math.max(this.pageNum - this.bufferPages, 0);
|
||||||
endingIndex = Math.max(this.minPrefetchedWebtoonImage - 1 - this.buffferPages, 0);
|
endingIndex = Math.max(this.pageNum + this.bufferPages, 0);
|
||||||
|
|
||||||
if (startingIndex <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -378,20 +342,33 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
startingIndex = endingIndex;
|
startingIndex = endingIndex;
|
||||||
endingIndex = temp;
|
endingIndex = temp;
|
||||||
}
|
}
|
||||||
this.debugLog('[Prefetch] prefetching pages: ' + startingIndex + ' to ' + endingIndex);
|
|
||||||
this.debugLog(' [Prefetch] page num: ', this.pageNum);
|
return [startingIndex, endingIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
range(size: number, startAt: number = 0): ReadonlyArray<number> {
|
||||||
|
return [...Array(size).keys()].map(i => i + startAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
prefetchWebtoonImages() {
|
||||||
|
let [startingIndex, endingIndex] = this.calculatePrefetchIndecies();
|
||||||
|
if (startingIndex === 0 && endingIndex === 0) { return; }
|
||||||
|
|
||||||
|
// NOTE: This code isn't required now that we buffer around our current page. There will never be a request that is outside our bounds
|
||||||
// If a request comes in to prefetch over current page +/- bufferPages (+ 1 due to requesting from next/prev page), then deny it
|
// If a request comes in to prefetch over current page +/- bufferPages (+ 1 due to requesting from next/prev page), then deny it
|
||||||
this.debugLog(' [Prefetch] Caps: ' + (this.pageNum - (this.buffferPages + 1)) + ' - ' + (this.pageNum + (this.buffferPages + 1)));
|
// if (this.isScrollingForwards() && startingIndex > this.pageNum + (this.bufferPages + 1)) {
|
||||||
if (this.isScrollingForwards() && startingIndex > this.pageNum + (this.buffferPages + 1)) {
|
// this.debugLog('\t[PREFETCH] A request that is too far outside buffer range has been declined', this.pageNum);
|
||||||
this.debugLog('[Prefetch] A request that is too far outside buffer range has been declined', this.pageNum);
|
// return;
|
||||||
return;
|
// }
|
||||||
}
|
// if (!this.isScrollingForwards() && endingIndex < (this.pageNum - (this.bufferPages + 1))) {
|
||||||
if (!this.isScrollingForwards() && endingIndex < (this.pageNum - (this.buffferPages + 1))) {
|
// this.debugLog('\t[PREFETCH] A request that is too far outside buffer range has been declined', this.pageNum);
|
||||||
this.debugLog('[Prefetch] A request that is too far outside buffer range has been declined', this.pageNum);
|
// return;
|
||||||
return;
|
// }
|
||||||
}
|
|
||||||
|
this.debugLog('\t[PREFETCH] prefetching pages: ' + startingIndex + ' to ' + endingIndex);
|
||||||
|
|
||||||
for(let i = startingIndex; i < endingIndex; i++) {
|
for(let i = startingIndex; i < endingIndex; i++) {
|
||||||
this.prefetchWebtoonImage(i);
|
this.loadWebtoonImage(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.all(Array.from(document.querySelectorAll('img'))
|
Promise.all(Array.from(document.querySelectorAll('img'))
|
||||||
|
@ -25,7 +25,7 @@
|
|||||||
ondragstart="return false;" onselectstart="return false;">
|
ondragstart="return false;" onselectstart="return false;">
|
||||||
</canvas>
|
</canvas>
|
||||||
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading">
|
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading">
|
||||||
<app-infinite-scroller [pageNum]="pageNum" [buffferPages]="5" (pageNumberChange)="handleWebtoonPageChange($event)" [totalPages]="maxPages" [urlProvider]="getPageUrl"></app-infinite-scroller>
|
<app-infinite-scroller [pageNum]="pageNum" [bufferPages]="5" [goToPage]="goToPageEvent" (pageNumberChange)="handleWebtoonPageChange($event)" [totalPages]="maxPages" [urlProvider]="getPageUrl"></app-infinite-scroller>
|
||||||
</div>
|
</div>
|
||||||
<ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD"> <!--; else webtoonClickArea; TODO: See if people want this mode WEBTOON_WITH_CLICKS-->
|
<ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD"> <!--; else webtoonClickArea; TODO: See if people want this mode WEBTOON_WITH_CLICKS-->
|
||||||
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'top'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div>
|
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'top'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div>
|
||||||
|
@ -10,7 +10,7 @@ import { NavService } from '../_services/nav.service';
|
|||||||
import { ReadingDirection } from '../_models/preferences/reading-direction';
|
import { ReadingDirection } from '../_models/preferences/reading-direction';
|
||||||
import { ScalingOption } from '../_models/preferences/scaling-option';
|
import { ScalingOption } from '../_models/preferences/scaling-option';
|
||||||
import { PageSplitOption } from '../_models/preferences/page-split-option';
|
import { PageSplitOption } from '../_models/preferences/page-split-option';
|
||||||
import { forkJoin, Subject } from 'rxjs';
|
import { forkJoin, ReplaySubject, Subject } from 'rxjs';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||||
import { CircularArray } from '../shared/data-structures/circular-array';
|
import { CircularArray } from '../shared/data-structures/circular-array';
|
||||||
@ -104,6 +104,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
continuousChaptersStack: Stack<number> = new Stack();
|
continuousChaptersStack: Stack<number> = new Stack();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event emiter when a page change occurs. Used soley by the webtoon reader.
|
||||||
|
*/
|
||||||
|
goToPageEvent: ReplaySubject<number> = new ReplaySubject<number>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the menu is open/visible.
|
* If the menu is open/visible.
|
||||||
@ -308,6 +312,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.readerService.resetOverrideStyles();
|
this.readerService.resetOverrideStyles();
|
||||||
this.navService.showNavBar();
|
this.navService.showNavBar();
|
||||||
this.onDestroy.next();
|
this.onDestroy.next();
|
||||||
|
this.onDestroy.complete();
|
||||||
|
this.goToPageEvent.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:keyup', ['$event'])
|
@HostListener('window:keyup', ['$event'])
|
||||||
@ -798,6 +804,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
this.setPageNum(page);
|
this.setPageNum(page);
|
||||||
this.refreshSlider.emit();
|
this.refreshSlider.emit();
|
||||||
|
this.goToPageEvent.next(page);
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -896,19 +903,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleWebtoonPageChange(updatedPageNum: number) {
|
handleWebtoonPageChange(updatedPageNum: number) {
|
||||||
console.log('[MangaReader] Handling Page Change');
|
this.setPageNum(updatedPageNum);
|
||||||
|
|
||||||
this.pageNum = updatedPageNum;
|
|
||||||
|
|
||||||
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||||
if (this.pageNum >= this.maxPages - 10) {
|
|
||||||
// Tell server to cache the next chapter
|
|
||||||
if (this.nextChapterId > 0 && !this.nextChapterPrefetched) {
|
|
||||||
this.readerService.getChapterInfo(this.nextChapterId).pipe(take(1)).subscribe(res => {
|
|
||||||
this.nextChapterPrefetched = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
saveSettings() {
|
saveSettings() {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Series } from 'src/app/_models/series';
|
import { Series } from 'src/app/_models/series';
|
||||||
import { environment } from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
@ -35,15 +35,15 @@ export class DownloadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private downloadSeriesAPI(seriesId: number) {
|
private downloadSeriesAPI(seriesId: number) {
|
||||||
return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + seriesId, {responseType: 'blob' as 'text'});
|
return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + seriesId, {observe: 'response', responseType: 'blob' as 'text'});
|
||||||
}
|
}
|
||||||
|
|
||||||
private downloadVolumeAPI(volumeId: number) {
|
private downloadVolumeAPI(volumeId: number) {
|
||||||
return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volumeId, {responseType: 'blob' as 'text'});
|
return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volumeId, {observe: 'response', responseType: 'blob' as 'text'});
|
||||||
}
|
}
|
||||||
|
|
||||||
private downloadChapterAPI(chapterId: number) {
|
private downloadChapterAPI(chapterId: number) {
|
||||||
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapterId, {responseType: 'blob' as 'text'});
|
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapterId, {observe: 'response', responseType: 'blob' as 'text'});
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadSeries(series: Series) {
|
downloadSeries(series: Series) {
|
||||||
@ -51,9 +51,10 @@ export class DownloadService {
|
|||||||
if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The series is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) {
|
if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The series is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.downloadSeriesAPI(series.id).subscribe(res => {
|
this.downloadSeriesAPI(series.id).subscribe(resp => {
|
||||||
const filename = series.name + '.zip';
|
//const filename = series.name + '.zip';
|
||||||
this.preformSave(res, filename);
|
//this.preformSave(res, filename);
|
||||||
|
this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, series.name));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -63,9 +64,8 @@ export class DownloadService {
|
|||||||
if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The chapter is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) {
|
if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The chapter is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.downloadChapterAPI(chapter.id).subscribe(res => {
|
this.downloadChapterAPI(chapter.id).subscribe((resp: HttpResponse<string>) => {
|
||||||
const filename = seriesName + ' - Chapter ' + chapter.number + '.zip';
|
this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, seriesName + ' - Chapter ' + chapter.number));
|
||||||
this.preformSave(res, filename);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -75,9 +75,8 @@ export class DownloadService {
|
|||||||
if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The chapter is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) {
|
if (size >= this.SIZE_WARNING && !await this.confirmService.confirm('The chapter is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.downloadVolumeAPI(volume.id).subscribe(res => {
|
this.downloadVolumeAPI(volume.id).subscribe(resp => {
|
||||||
const filename = seriesName + ' - Volume ' + volume.name + '.zip';
|
this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, seriesName + ' - Volume ' + volume.name));
|
||||||
this.preformSave(res, filename);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -88,6 +87,23 @@ export class DownloadService {
|
|||||||
this.toastr.success('File downloaded successfully: ' + filename);
|
this.toastr.success('File downloaded successfully: ' + filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to parse out the filename from Content-Disposition header.
|
||||||
|
* If it fails, will default to defaultName and add the correct extension. If no extension is found in header, will use zip.
|
||||||
|
* @param headers
|
||||||
|
* @param defaultName
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
private getFilenameFromHeader(headers: HttpHeaders, defaultName: string) {
|
||||||
|
const tokens = (headers.get('content-disposition') || '').split(';');
|
||||||
|
let filename = tokens[1].replace('filename=', '').replace('"', '').trim();
|
||||||
|
if (filename.startsWith('download_') || filename.startsWith('kavita_download_')) {
|
||||||
|
const ext = filename.substring(filename.lastIndexOf('.'), filename.length);
|
||||||
|
return defaultName + ext;
|
||||||
|
}
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format bytes as human-readable text.
|
* Format bytes as human-readable text.
|
||||||
*
|
*
|
||||||
|
@ -42,7 +42,15 @@ export class UserLoginComponent implements OnInit {
|
|||||||
this.accountService.login(this.model).subscribe(() => {
|
this.accountService.login(this.model).subscribe(() => {
|
||||||
this.loginForm.reset();
|
this.loginForm.reset();
|
||||||
this.navService.showNavBar();
|
this.navService.showNavBar();
|
||||||
|
|
||||||
|
// Check if user came here from another url, else send to library route
|
||||||
|
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
|
||||||
|
if (pageResume && pageResume !== '/no-connection') {
|
||||||
|
localStorage.setItem('kavita--auth-intersection-url', '');
|
||||||
|
this.router.navigateByUrl(pageResume);
|
||||||
|
} else {
|
||||||
this.router.navigateByUrl('/library');
|
this.router.navigateByUrl('/library');
|
||||||
|
}
|
||||||
}, err => {
|
}, err => {
|
||||||
this.toastr.error(err.error);
|
this.toastr.error(err.error);
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user