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:
Joseph Milazzo 2021-07-19 18:55:01 -05:00 committed by GitHub
parent 1cd68be4e2
commit eb88967545
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 275 additions and 299 deletions

View File

@ -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);

View File

@ -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");
}
}
}

View 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")]

View File

@ -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");
} }

View File

@ -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;

View File

@ -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();
} }

View File

@ -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)

View File

@ -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();

View File

@ -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.

View File

@ -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;
}) })

View File

@ -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;

View File

@ -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}];

View File

@ -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';

View File

@ -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;
} }

View File

@ -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');

View File

@ -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>

View File

@ -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'))

View File

@ -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>

View File

@ -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() {

View File

@ -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.
* *

View File

@ -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);
}); });