Kavita/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts
Fesaa 193e9b1da9
A collection of bug fixes (#3820)
Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
2025-06-04 00:45:10 -07:00

1774 lines
64 KiB
TypeScript

import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
ElementRef,
EventEmitter,
HostListener,
inject,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import {AsyncPipe, NgClass, NgStyle, PercentPipe} from '@angular/common';
import {ActivatedRoute, Router} from '@angular/router';
import {
BehaviorSubject,
debounceTime,
distinctUntilChanged,
filter,
forkJoin,
fromEvent,
map,
merge,
Observable,
ReplaySubject,
Subject,
take,
tap
} from 'rxjs';
import {ChangeContext, LabelType, NgxSliderModule, Options} from '@angular-slider/ngx-slider';
import {animate, state, style, transition, trigger} from '@angular/animations';
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {ShortcutsModalComponent} from 'src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component';
import {Stack} from 'src/app/shared/data-structures/stack';
import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
import {LibraryType} from 'src/app/_models/library/library';
import {MangaFormat} from 'src/app/_models/manga-format';
import {PageSplitOption} from 'src/app/_models/preferences/page-split-option';
import {layoutModes, pageSplitOptions} from 'src/app/_models/preferences/preferences';
import {ReaderMode} from 'src/app/_models/preferences/reader-mode';
import {ReadingDirection} from 'src/app/_models/preferences/reading-direction';
import {ScalingOption} from 'src/app/_models/preferences/scaling-option';
import {User} from 'src/app/_models/user';
import {AccountService} from 'src/app/_services/account.service';
import {MemberService} from 'src/app/_services/member.service';
import {NavService} from 'src/app/_services/nav.service';
import {ReaderService} from 'src/app/_services/reader.service';
import {LayoutMode} from '../../_models/layout-mode';
import {FITTING_OPTION, PAGING_DIRECTION} from '../../_models/reader-enums';
import {ReaderSetting} from '../../_models/reader-setting';
import {MangaReaderService} from '../../_service/manga-reader.service';
import {CanvasRendererComponent} from '../canvas-renderer/canvas-renderer.component';
import {DoubleRendererComponent} from '../double-renderer/double-renderer.component';
import {DoubleReverseRendererComponent} from '../double-reverse-renderer/double-reverse-renderer.component';
import {SingleRendererComponent} from '../single-renderer/single-renderer.component';
import {ChapterInfo} from '../../_models/chapter-info';
import {DoubleNoCoverRendererComponent} from '../double-renderer-no-cover/double-no-cover-renderer.component';
import {SwipeEvent} from 'src/app/ng-swipe/ag-swipe.core';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {FullscreenIconPipe} from '../../../_pipes/fullscreen-icon.pipe';
import {ReaderModeIconPipe} from '../../../_pipes/reader-mode-icon.pipe';
import {FittingIconPipe} from '../../../_pipes/fitting-icon.pipe';
import {InfiniteScrollerComponent} from '../infinite-scroller/infinite-scroller.component';
import {SwipeDirective} from '../../../ng-swipe/ng-swipe.directive';
import {LoadingComponent} from '../../../shared/loading/loading.component';
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {shareReplay} from "rxjs/operators";
import {DblClickDirective} from "../../../_directives/dbl-click.directive";
import {ConfirmService} from "../../../shared/confirm.service";
const PREFETCH_PAGES = 10;
const CHAPTER_ID_NOT_FETCHED = -2;
const CHAPTER_ID_DOESNT_EXIST = -1;
const ANIMATION_SPEED = 200;
const OVERLAY_AUTO_CLOSE_TIME = 3000;
const CLICK_OVERLAY_TIMEOUT = 3000;
enum ChapterInfoPosition {
Previous = 0,
Current = 1,
Next = 2
}
enum KeyDirection {
Right = 0,
Left = 1,
Up = 2,
Down = 3
}
@Component({
selector: 'app-manga-reader',
templateUrl: './manga-reader.component.html',
styleUrls: ['./manga-reader.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [MangaReaderService],
animations: [
trigger('slideFromTop', [
state('in', style({ transform: 'translateY(0)' })),
transition('void => *', [
style({ transform: 'translateY(-100%)' }),
animate(ANIMATION_SPEED)
]),
transition('* => void', [
animate(ANIMATION_SPEED, style({ transform: 'translateY(-100%)' })),
])
]),
trigger('slideFromBottom', [
state('in', style({ transform: 'translateY(0)' })),
transition('void => *', [
style({ transform: 'translateY(100%)' }),
animate(ANIMATION_SPEED)
]),
transition('* => void', [
animate(ANIMATION_SPEED, style({ transform: 'translateY(100%)' })),
])
])
],
imports: [NgStyle, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent,
DoubleRendererComponent, DoubleReverseRendererComponent, DoubleNoCoverRendererComponent, InfiniteScrollerComponent,
NgxSliderModule, ReactiveFormsModule, FittingIconPipe, ReaderModeIconPipe,
FullscreenIconPipe, TranslocoDirective, PercentPipe, NgClass, AsyncPipe, DblClickDirective]
})
export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('reader') reader!: ElementRef;
@ViewChild('readingArea') readingArea!: ElementRef;
@ViewChild('content') canvas: ElementRef | undefined;
@ViewChild(CanvasRendererComponent, { static: false }) canvasRenderer!: CanvasRendererComponent;
@ViewChild(SingleRendererComponent, { static: false }) singleRenderer!: SingleRendererComponent;
@ViewChild(DoubleRendererComponent, { static: false }) doubleRenderer!: DoubleRendererComponent;
@ViewChild(DoubleReverseRendererComponent, { static: false }) doubleReverseRenderer!: DoubleReverseRendererComponent;
@ViewChild(DoubleNoCoverRendererComponent, { static: false }) doubleNoCoverRenderer!: DoubleNoCoverRendererComponent;
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly accountService = inject(AccountService);
private readonly formBuilder = inject(FormBuilder);
private readonly navService = inject(NavService);
private readonly memberService = inject(MemberService);
private readonly modalService = inject(NgbModal);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService);
private readonly confirmService = inject(ConfirmService);
protected readonly readerService = inject(ReaderService);
protected readonly utilityService = inject(UtilityService);
protected readonly mangaReaderService = inject(MangaReaderService);
protected readonly KeyDirection = KeyDirection;
protected readonly ReaderMode = ReaderMode;
protected readonly LayoutMode = LayoutMode;
protected readonly ReadingDirection = ReadingDirection;
protected readonly Breakpoint = Breakpoint;
protected readonly Math = Math;
libraryId!: number;
seriesId!: number;
volumeId!: number;
chapterId!: number;
/**
* Reading List id. Defaults to -1.
*/
readingListId: number = CHAPTER_ID_DOESNT_EXIST;
/**
* If this is true, no progress will be saved.
*/
incognitoMode: boolean = false;
/**
* If this is true, we are reading a bookmark. ChapterId will be 0. There is no continuous reading. Progress is not saved. Bookmark control is removed.
*/
bookmarkMode: boolean = false;
/**
* If this is true, chapters will be fetched in the order of a reading list, rather than natural series order.
*/
readingListMode: boolean = false;
/**
* The current page. UI will show this number + 1.
*/
pageNum = 0;
/**
* Total pages in the given Chapter
*/
maxPages = 1;
totalSeriesPages = 0;
totalSeriesPagesRead = 0;
user!: User;
generalSettingsForm!: FormGroup;
readingDirection = ReadingDirection.LeftToRight;
scalingOption = ScalingOption.FitToHeight;
pageSplitOption = PageSplitOption.FitSplit;
isFullscreen: boolean = false;
autoCloseMenu: boolean = true;
readerMode: ReaderMode = ReaderMode.LeftRight;
readerModeSubject = new BehaviorSubject(this.readerMode);
readerMode$: Observable<ReaderMode> = this.readerModeSubject.asObservable();
pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD;
pagingDirectionSubject: Subject<PAGING_DIRECTION> = new BehaviorSubject(this.pagingDirection);
pagingDirection$: Observable<PAGING_DIRECTION> = this.pagingDirectionSubject.asObservable();
pageSplitOptionsTranslated = pageSplitOptions.map(this.translatePrefOptions);
layoutModesTranslated = layoutModes.map(this.translatePrefOptions);
isLoading = true;
hasBookmarkRights: boolean = false; // TODO: This can be an observable
getPageFn!: (pageNum: number) => HTMLImageElement;
/**
* Used to render a page on the canvas or in the image tag. This Image element is prefetched by the cachedImages buffer.
* @remarks Used for rendering to screen.
*/
canvasImage = new Image();
/**
* A circular array of size PREFETCH_PAGES. Maintains prefetched Images around the current page to load from to avoid loading animation.
* @see CircularArray
*/
cachedImages: Array<HTMLImageElement> = [];
/**
* A stack of the chapter ids we come across during continuous reading mode. When we traverse a boundary, we use this to avoid extra API calls.
* @see Stack
*/
continuousChaptersStack: Stack<number> = new Stack();
continuousChapterInfos: Array<ChapterInfo | undefined> = [undefined, undefined, undefined];
/**
* An event emitter when a page change occurs. Used solely by the webtoon reader.
*/
goToPageEvent!: BehaviorSubject<number>; // Renderer interaction
/**
* An event emitter when a bookmark on a page change occurs. Used solely by the webtoon reader.
*/
showBookmarkEffectEvent: ReplaySubject<number> = new ReplaySubject<number>();
showBookmarkEffect$: Observable<number> = this.showBookmarkEffectEvent.asObservable();
/**
* An event emitter when fullscreen mode is toggled. Used solely by the webtoon reader.
*/
fullscreenEvent: ReplaySubject<boolean> = new ReplaySubject<boolean>();
/**
* If the menu is open/visible.
*/
menuOpen = false;
/**
* If the prev page allows a page change to occur.
*/
prevPageDisabled = false;
/**
* If the next page allows a page change to occur.
*/
nextPageDisabled = false;
pageOptions: Options = {
floor: 0,
ceil: 0,
step: 1,
boundPointerLabels: true,
showSelectionBar: true,
translate: (_: number, label: LabelType) => {
if (label == LabelType.Floor) {
return 1 + '';
} else if (label === LabelType.Ceil) {
return this.maxPages + '';
}
return (this.pageNum + 1) + '';
},
animate: false
};
refreshSlider: EventEmitter<void> = new EventEmitter<void>();
/**
* Used to store the Series name for UI
*/
title: string = '';
/**
* Used to store the Volume/Chapter information
*/
subtitle: string = '';
/**
* Timeout id for auto-closing menu overlay
*/
menuTimeout: any;
/**
* If the click overlay is rendered on screen
*/
showClickOverlay: boolean = false;
private showClickOverlaySubject: ReplaySubject<boolean> = new ReplaySubject();
showClickOverlay$ = this.showClickOverlaySubject.asObservable();
/**
* Next Chapter Id. This is not guaranteed to be a valid ChapterId. Prefetched on page load (non-blocking).
*/
nextChapterId: number = CHAPTER_ID_NOT_FETCHED;
/**
* Previous Chapter Id. This is not guaranteed to be a valid ChapterId. Prefetched on page load (non-blocking).
*/
prevChapterId: number = CHAPTER_ID_NOT_FETCHED;
/**
* Is there a next chapter. If not, this will disable UI controls.
*/
nextChapterDisabled: boolean = false;
/**
* Is there a previous chapter. If not, this will disable UI controls.
*/
prevChapterDisabled: boolean = false;
/**
* Has the next chapter been prefetched. Prefetched means the backend will cache the files.
*/
nextChapterPrefetched: boolean = false;
/**
* Has the previous chapter been prefetched. Prefetched means the backend will cache the files.
*/
prevChapterPrefetched: boolean = false;
/**
* If extended settings area is visible. Blocks auto-closing of menu.
*/
settingsOpen: boolean = false;
/**
* A map of bookmarked pages to anything. Used for O(1) lookup time if a page is bookmarked or not.
*/
bookmarks: {[key: string]: number} = {};
/**
* Library Type used for rendering chapter or issue
*/
libraryType: LibraryType = LibraryType.Manga;
/**
* Used for webtoon reader. When loading pages or data, this will disable the reader
*/
inSetup: boolean = true;
/**
* If we render 2 pages at once or 1
*/
layoutMode: LayoutMode = LayoutMode.Single;
/**
* Background color for canvas/reader. User configured.
*/
backgroundColor: string = '#FFFFFF';
/**
* This is here as absolute layout requires us to calculate a negative right property for the right pagination when there is overflow. This is calculated on scroll.
*/
rightPaginationOffset = 0;
/**
* Previous amount of scroll left. Used for swipe to paginate functionality.
*/
prevScrollLeft = 0;
/**
* Previous amount of scroll top. Used for swipe to paginate functionality.
*/
prevScrollTop = 0;
prevIsHorizontalScrollLeft = true;
prevIsVerticalScrollLeft = true;
/**
* Has the user scrolled to the far right side. This is used for swipe to next page and must ensure user is at end of scroll then on next swipe, will move pages.
*/
hasHitRightScroll = false;
/**
* Has the user scrolled once for the current page
*/
hasScrolledX: boolean = false;
/**
* Has the user scrolled once in the Y axis for the current page
*/
hasScrolledY: boolean = false;
/**
* Has the user scrolled to far left size. This doesn't include starting from no scroll
*/
hasHitZeroScroll: boolean = false;
/**
* Has the user scrolled to the far top of the screen
*/
hasHitZeroTopScroll: boolean = false;
/**
* Has the user scrolled to the far bottom of the screen
*/
hasHitBottomTopScroll: boolean = false;
/**
* Show and log debug information
*/
debugMode: boolean = false;
/**
* Width override label for manual width control
*/
widthOverrideLabel$ : Observable<string> = new Observable<string>();
// Renderer interaction
readerSettings$!: Observable<ReaderSetting>;
private currentImage: Subject<HTMLImageElement | null> = new ReplaySubject(1);
currentImage$: Observable<HTMLImageElement | null> = this.currentImage.asObservable().pipe(
shareReplay({refCount: true, bufferSize: 2})
);
private pageNumSubject: Subject<{pageNum: number, maxPages: number}> = new ReplaySubject();
pageNum$: Observable<{pageNum: number, maxPages: number}> = this.pageNumSubject.asObservable();
getPageUrl = (pageNum: number, chapterId: number = this.chapterId) => {
if (this.bookmarkMode) return this.readerService.getBookmarkPageUrl(this.seriesId, this.user.apiKey, pageNum);
return this.readerService.getPageUrl(chapterId, pageNum);
}
get CurrentPageBookmarked() {
return this.bookmarks.hasOwnProperty(this.pageNum);
}
get WindowWidth() {
return this.readingArea?.nativeElement.scrollWidth + 'px';
}
get ImageHeight() {
if (this.FittingOption !== FITTING_OPTION.HEIGHT) {
return this.mangaReaderService.getPageDimensions(this.pageNum)?.height + 'px';
}
return this.readingArea?.nativeElement?.clientHeight + 'px';
}
// This is for the pagination area
get MaxHeight() {
return '100dvh';
}
get RightPaginationOffset() {
if (this.readerMode === ReaderMode.LeftRight && this.FittingOption !== FITTING_OPTION.WIDTH) {
return (this.readingArea?.nativeElement?.scrollLeft || 0) * -1;
}
return 0;
}
get SplitIconClass() {
if (this.mangaReaderService.isSplitLeftToRight(this.pageSplitOption)) {
return 'left-side';
} else if (this.mangaReaderService.isNoSplit(this.pageSplitOption)) {
return 'none';
}
return 'right-side';
}
get FittingOption() { return this.generalSettingsForm?.get('fittingOption')?.value || FITTING_OPTION.HEIGHT; }
get ReadingAreaWidth() {
return this.readingArea?.nativeElement.scrollWidth - this.readingArea?.nativeElement.clientWidth;
}
get ReadingAreaHeight() {
return this.readingArea?.nativeElement.scrollHeight - this.readingArea?.nativeElement.clientHeight;
}
constructor() {
this.navService.hideNavBar();
this.navService.hideSideNav();
this.cdRef.markForCheck();
}
ngOnInit(): void {
const libraryId = this.route.snapshot.paramMap.get('libraryId');
const seriesId = this.route.snapshot.paramMap.get('seriesId');
const chapterId = this.route.snapshot.paramMap.get('chapterId');
if (libraryId === null || seriesId === null || chapterId === null) {
this.router.navigateByUrl('/home');
return;
}
this.getPageFn = this.getPage.bind(this);
this.libraryId = parseInt(libraryId, 10);
this.seriesId = parseInt(seriesId, 10);
this.chapterId = parseInt(chapterId, 10);
this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true';
this.bookmarkMode = this.route.snapshot.queryParamMap.get('bookmarkMode') === 'true';
const readingListId = this.route.snapshot.queryParamMap.get('readingListId');
if (readingListId != null) {
this.readingListMode = true;
this.readingListId = parseInt(readingListId, 10);
}
this.continuousChaptersStack.push(this.chapterId);
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (!user) {
this.router.navigateByUrl('/login');
return;
}
this.user = user;
this.hasBookmarkRights = this.accountService.hasBookmarkRole(user) || this.accountService.hasAdminRole(user);
this.readingDirection = this.user.preferences.readingDirection;
this.scalingOption = this.user.preferences.scalingOption;
this.pageSplitOption = this.user.preferences.pageSplitOption;
this.autoCloseMenu = this.user.preferences.autoCloseMenu;
this.readerMode = this.user.preferences.readerMode;
this.layoutMode = this.user.preferences.layoutMode || LayoutMode.Single;
this.backgroundColor = this.user.preferences.backgroundColor || '#000000';
this.readerService.setOverrideStyles(this.backgroundColor);
this.generalSettingsForm = this.formBuilder.nonNullable.group({
autoCloseMenu: new FormControl(this.autoCloseMenu),
pageSplitOption: new FormControl(this.pageSplitOption),
fittingOption: new FormControl(this.mangaReaderService.translateScalingOption(this.scalingOption)),
widthSlider: new FormControl('none'),
layoutMode: new FormControl(this.layoutMode),
darkness: new FormControl(100),
emulateBook: new FormControl(this.user.preferences.emulateBook),
swipeToPaginate: new FormControl(this.user.preferences.swipeToPaginate)
});
this.readerModeSubject.next(this.readerMode);
this.pagingDirectionSubject.next(this.pagingDirection);
// We need a mergeMap when page changes
this.readerSettings$ = merge(this.generalSettingsForm.valueChanges, this.pagingDirection$, this.readerMode$).pipe(
map(_ => this.createReaderSettingsUpdate()),
takeUntilDestroyed(this.destroyRef),
);
this.updateForm();
this.pagingDirection$.pipe(
distinctUntilChanged(),
tap(dir => {
this.pagingDirection = dir;
this.cdRef.markForCheck();
}),
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {});
this.readerMode$.pipe(
distinctUntilChanged(),
tap(mode => {
this.readerMode = mode;
this.disableDoubleRendererIfScreenTooSmall();
this.cdRef.markForCheck();
}),
takeUntilDestroyed(this.destroyRef)
).subscribe(() => {});
this.setupWidthOverrideTriggers();
this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
const changeOccurred = parseInt(val, 10) !== this.layoutMode;
this.layoutMode = parseInt(val, 10);
if (this.layoutMode === LayoutMode.Single) {
this.generalSettingsForm.get('pageSplitOption')?.setValue(this.user.preferences.pageSplitOption);
this.generalSettingsForm.get('pageSplitOption')?.enable();
this.generalSettingsForm.get('widthSlider')?.enable();
this.generalSettingsForm.get('fittingOption')?.enable();
this.generalSettingsForm.get('emulateBook')?.enable();
} else {
this.generalSettingsForm.get('pageSplitOption')?.setValue(PageSplitOption.NoSplit);
this.generalSettingsForm.get('pageSplitOption')?.disable();
this.generalSettingsForm.get('widthSlider')?.disable();
this.generalSettingsForm.get('fittingOption')?.setValue(this.mangaReaderService.translateScalingOption(ScalingOption.FitToHeight));
this.generalSettingsForm.get('fittingOption')?.disable();
this.generalSettingsForm.get('emulateBook')?.enable();
}
this.cdRef.markForCheck();
// Re-render the current page when we switch layouts
if (changeOccurred) {
this.setPageNum(this.adjustPagesForDoubleRenderer(this.pageNum));
this.loadPage();
}
});
this.generalSettingsForm.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value;
this.pageSplitOption = parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10);
const needsSplitting = this.mangaReaderService.isWidePage(this.readerService.imageUrlToPageNum(this.canvasImage.src));
// If we need to split on a menu change, then we need to re-render.
if (needsSplitting) {
// If we need to re-render, to ensure things layout properly, let's update paging direction & reset render
this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
this.canvasRenderer.reset();
this.loadPage();
}
});
this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(progress => {
if (!progress) {
this.toggleMenu();
this.toastr.info(translate('manga-reader.first-time-reading-manga'));
}
});
});
this.init();
}
ngAfterViewInit() {
fromEvent(this.readingArea.nativeElement, 'scroll').pipe(debounceTime(20), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
if (this.readerMode === ReaderMode.Webtoon) return;
if (this.readerMode === ReaderMode.LeftRight && this.FittingOption === FITTING_OPTION.HEIGHT) {
this.rightPaginationOffset = (this.readingArea.nativeElement.scrollLeft) * -1;
this.cdRef.markForCheck();
return;
}
this.rightPaginationOffset = 0;
this.cdRef.markForCheck();
});
fromEvent(this.readingArea.nativeElement, 'scroll').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.prevScrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0;
this.prevScrollTop = this.readingArea?.nativeElement?.scrollTop || 0;
this.hasScrolledX = true;
this.hasScrolledY = true;
});
}
ngOnDestroy() {
this.readerService.resetOverrideStyles();
this.navService.showNavBar();
this.navService.showSideNav();
this.showBookmarkEffectEvent.complete();
if (this.goToPageEvent !== undefined) this.goToPageEvent.complete();
this.readerService.disableWakeLock();
}
@HostListener('window:resize', ['$event'])
@HostListener('window:orientationchange', ['$event'])
onResize() {
this.disableDoubleRendererIfScreenTooSmall();
}
@HostListener('window:keyup', ['$event'])
async handleKeyPress(event: KeyboardEvent) {
switch (this.readerMode) {
case ReaderMode.LeftRight:
if (event.key === KEY_CODES.RIGHT_ARROW) {
if (!this.checkIfPaginationAllowed(KeyDirection.Right)) return;
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
} else if (event.key === KEY_CODES.LEFT_ARROW) {
if (!this.checkIfPaginationAllowed(KeyDirection.Left)) return;
this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage();
}
break;
case ReaderMode.UpDown:
if (event.key === KEY_CODES.UP_ARROW) {
if (!this.checkIfPaginationAllowed(KeyDirection.Up)) return;
this.prevPage();
} else if (event.key === KEY_CODES.DOWN_ARROW) {
if (!this.checkIfPaginationAllowed(KeyDirection.Down)) return;
this.nextPage();
}
break;
case ReaderMode.Webtoon:
break;
}
if (event.key === KEY_CODES.ESC_KEY) {
if (this.menuOpen) {
this.toggleMenu();
event.stopPropagation();
event.preventDefault();
return;
}
this.closeReader();
} else if (event.key === KEY_CODES.SPACE) {
this.toggleMenu();
} else if (event.key === KEY_CODES.G) {
const goToPageNum = await this.promptForPage();
if (goToPageNum === null) { return; }
this.goToPage(parseInt(goToPageNum.trim(), 10));
} else if (event.key === KEY_CODES.B) {
this.bookmarkPage();
} else if (event.key === KEY_CODES.F) {
this.toggleFullscreen();
} else if (event.key === KEY_CODES.H) {
this.openShortcutModal();
}
}
/**
* Width override is only valid under the following conditions:
* Image Scaling is Width
* Reader Mode is Webtoon
*
* In all other cases, the form will be disabled and set to 0 which indicates default/off state.
*/
setupWidthOverrideTriggers() {
const widthOverrideControl = this.generalSettingsForm.get('widthSlider')!;
const enableWidthOverride = () => {
widthOverrideControl.enable();
};
const disableWidthOverride = () => {
widthOverrideControl.setValue(0);
widthOverrideControl.disable();
};
const handleControlChanges = () => {
const fitting = this.generalSettingsForm.get('fittingOption')?.value;
const splitting = this.generalSettingsForm.get('pageSplitOption')?.value;
if ((PageSplitOption.FitSplit == splitting && FITTING_OPTION.WIDTH == fitting) || this.readerMode === ReaderMode.Webtoon) {
enableWidthOverride();
} else {
disableWidthOverride();
}
};
// Reader mode changes
this.readerModeSubject.asObservable()
.pipe(
filter(v => v === ReaderMode.Webtoon),
tap(enableWidthOverride),
takeUntilDestroyed(this.destroyRef)
)
.subscribe();
// Page split option changes
this.generalSettingsForm.get('pageSplitOption')?.valueChanges.pipe(
distinctUntilChanged(),
tap(handleControlChanges),
takeUntilDestroyed(this.destroyRef)
).subscribe();
// Fitting option changes
this.generalSettingsForm.get('fittingOption')?.valueChanges.pipe(
tap(handleControlChanges),
takeUntilDestroyed(this.destroyRef)
).subscribe();
// Set the default override to 0
widthOverrideControl.setValue(0);
//send the current width override value to the label
this.widthOverrideLabel$ = this.readerSettings$?.pipe(
map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'),
takeUntilDestroyed(this.destroyRef)
);
}
createReaderSettingsUpdate() {
return {
pageSplit: parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10),
fitting: (this.generalSettingsForm.get('fittingOption')?.value as FITTING_OPTION),
widthSlider: this.generalSettingsForm.get('widthSlider')?.value,
layoutMode: this.layoutMode,
darkness: parseInt(this.generalSettingsForm.get('darkness')?.value + '', 10) || 100,
pagingDirection: this.pagingDirection,
readerMode: this.readerMode,
emulateBook: this.generalSettingsForm.get('emulateBook')?.value,
};
}
// If we are in double mode, we need to check if our current page is on a right edge or not, if so adjust by decrementing by 1
adjustPagesForDoubleRenderer(pageNum: number) {
if (pageNum === this.maxPages - 1) return pageNum;
if (this.readerMode !== ReaderMode.Webtoon && this.layoutMode !== LayoutMode.Single) {
return this.mangaReaderService.adjustForDoubleReader(pageNum);
}
return pageNum;
}
switchToWebtoonReaderIfPagesLikelyWebtoon() {
if (this.readerMode === ReaderMode.Webtoon) return;
if (!this.user.preferences.allowAutomaticWebtoonReaderDetection) return;
if (this.mangaReaderService.shouldBeWebtoonMode()) {
this.readerMode = ReaderMode.Webtoon;
this.toastr.info(translate('toasts.webtoon-override'));
this.readerModeSubject.next(this.readerMode);
this.cdRef.markForCheck();
}
}
disableDoubleRendererIfScreenTooSmall() {
if (window.innerWidth > window.innerHeight) {
this.generalSettingsForm.get('layoutMode')?.enable();
this.cdRef.markForCheck();
return;
}
if (this.layoutMode === LayoutMode.Single || this.readerMode === ReaderMode.Webtoon) return;
this.generalSettingsForm.get('layoutMode')?.setValue(LayoutMode.Single);
this.generalSettingsForm.get('layoutMode')?.disable();
this.toastr.info(translate('manga-reader.layout-mode-switched'));
this.cdRef.markForCheck();
}
/**
* Gets a page from cache else gets a brand new Image
* @param pageNum Page Number to load
* @param forceNew Forces to fetch a new image
* @param chapterId ChapterId to fetch page from. Defaults to current chapterId. Not used when in bookmark mode
* @returns HTMLImageElement | undefined
*/
getPage(pageNum: number, chapterId: number = this.chapterId, forceNew: boolean = false) {
let img: HTMLImageElement | undefined;
if (this.bookmarkMode) img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === pageNum);
else img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === pageNum
&& (this.readerService.imageUrlToChapterId(img.src) == chapterId || this.readerService.imageUrlToChapterId(img.src) === -1)
);
//console.log('Requesting page ', pageNum, ' found page: ', img, ' and app is requesting new image? ', forceNew);
if (!img || forceNew) {
img = new Image();
img.src = this.getPageUrl(pageNum, chapterId);
img.onload = (evt) => {
this.currentImage.next(img!);
this.cdRef.markForCheck();
}
// img.onerror = (evt) => {
// const event = evt as Event;
// const page = this.readerService.imageUrlToPageNum((event.target as HTMLImageElement).src);
// console.error('Image failed to load: ', page);
// (event.target as HTMLImageElement).onerror = null;
// const newSrc = this.getPageUrl(pageNum, chapterId) + '#' + new Date().getTime();
// console.log('requesting page ', page, ' with url: ', newSrc);
// (event.target as HTMLImageElement).src = newSrc;
// this.cdRef.markForCheck();
// }
}
return img;
}
isHorizontalScrollLeft() {
const scrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0;
// if scrollLeft is 0 and this.ReadingAreaWidth is 0, then there is no scroll needed
// if they equal each other, it means we are at the end of the scroll area
if (scrollLeft === 0 && this.ReadingAreaWidth === 0) return false;
if (scrollLeft === this.ReadingAreaWidth) return false;
return scrollLeft < this.ReadingAreaWidth;
}
isVerticalScrollLeft() {
const scrollTop = this.readingArea?.nativeElement?.scrollTop || 0;
return scrollTop < this.ReadingAreaHeight;
}
/**
* Is there any room to scroll in the direction we are giving? If so, return false. Otherwise, return true.
* @param direction
* @returns
*/
checkIfPaginationAllowed(direction: KeyDirection) {
if (this.readingArea === undefined || this.readingArea.nativeElement === undefined) return true;
const scrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0;
const scrollTop = this.readingArea?.nativeElement?.scrollTop || 0;
switch (direction) {
case KeyDirection.Right:
if (this.prevIsHorizontalScrollLeft && !this.isHorizontalScrollLeft()) { return true; }
this.prevIsHorizontalScrollLeft = this.isHorizontalScrollLeft();
if (this.isHorizontalScrollLeft()) {
return false;
}
break;
case KeyDirection.Left:
this.prevIsHorizontalScrollLeft = this.isHorizontalScrollLeft();
if (scrollLeft > 0 || this.prevScrollLeft > 0) {
return false;
}
break;
case KeyDirection.Up:
this.prevIsVerticalScrollLeft = this.isVerticalScrollLeft();
if (scrollTop > 0|| this.prevScrollTop > 0) {
return false;
}
break;
case KeyDirection.Down:
if (this.prevIsVerticalScrollLeft && !this.isVerticalScrollLeft()) { return true; }
this.prevIsVerticalScrollLeft = this.isVerticalScrollLeft();
if (this.isVerticalScrollLeft()) {
return false;
}
break;
}
return true;
}
init() {
this.nextChapterId = CHAPTER_ID_NOT_FETCHED;
this.prevChapterId = CHAPTER_ID_NOT_FETCHED;
this.nextChapterDisabled = false;
this.prevChapterDisabled = false;
this.nextChapterPrefetched = false;
this.pageNum = 0;
this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
this.inSetup = true;
this.canvasImage.src = '';
this.cdRef.markForCheck();
this.cachedImages = [];
for (let i = 0; i < PREFETCH_PAGES; i++) {
this.cachedImages.push(new Image());
}
if (this.goToPageEvent) {
// There was a bug where goToPage was emitting old values into infinite scroller between chapter loads. We explicitly clear it out between loads,
// and we use a BehaviourSubject to ensure only latest value is sent
this.goToPageEvent.complete();
}
if (this.bookmarkMode) {
this.readerService.getBookmarkInfo(this.seriesId).subscribe(bookmarkInfo => {
this.setPageNum(0);
this.title = bookmarkInfo.seriesName;
this.subtitle = translate('manga-reader.bookmarks-title');
this.libraryType = bookmarkInfo.libraryType;
this.maxPages = bookmarkInfo.pages;
this.mangaReaderService.load(bookmarkInfo);
// Due to change detection rules in Angular, we need to re-create the options object to apply the change
const newOptions: Options = Object.assign({}, this.pageOptions);
newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything.
this.pageOptions = newOptions;
this.inSetup = false;
this.cdRef.markForCheck();
this.goToPageEvent = new BehaviorSubject<number>(this.pageNum);
this.render();
});
setTimeout(() => {
this.readerService.enableWakeLock(this.reader.nativeElement);
}, 1000);
return;
}
forkJoin({
progress: this.readerService.getProgress(this.chapterId),
chapterInfo: this.readerService.getChapterInfo(this.chapterId, true),
bookmarks: this.readerService.getBookmarks(this.chapterId),
}).pipe(take(1)).subscribe(results => {
if (this.readingListMode && (results.chapterInfo.seriesFormat === MangaFormat.EPUB || results.chapterInfo.seriesFormat === MangaFormat.PDF)) {
// Redirect to the book reader.
const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId);
this.router.navigate(this.readerService.getNavigationArray(results.chapterInfo.libraryId, results.chapterInfo.seriesId, this.chapterId, results.chapterInfo.seriesFormat), {queryParams: params});
return;
}
this.mangaReaderService.load(results.chapterInfo);
this.continuousChapterInfos[ChapterInfoPosition.Current] = results.chapterInfo;
this.volumeId = results.chapterInfo.volumeId;
this.maxPages = results.chapterInfo.pages;
let page = results.progress.pageNum;
if (page > this.maxPages) {
page = this.maxPages - 1;
}
page = this.adjustPagesForDoubleRenderer(page);
this.totalSeriesPages = results.chapterInfo.seriesTotalPages;
this.totalSeriesPagesRead = results.chapterInfo.seriesTotalPagesRead - page;
this.setPageNum(page); // first call
this.goToPageEvent = new BehaviorSubject<number>(this.pageNum);
// Due to change detection rules in Angular, we need to re-create the options object to apply the change
const newOptions: Options = Object.assign({}, this.pageOptions);
newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything.
this.pageOptions = newOptions;
this.libraryType = results.chapterInfo.libraryType;
this.title = results.chapterInfo.title;
this.subtitle = results.chapterInfo.subtitle;
this.inSetup = false;
this.disableDoubleRendererIfScreenTooSmall();
this.switchToWebtoonReaderIfPagesLikelyWebtoon();
// From bookmarks, create map of pages to make lookup time O(1)
this.bookmarks = {};
results.bookmarks.forEach(bookmark => {
this.bookmarks[bookmark.page] = 1;
});
this.cdRef.markForCheck();
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.nextChapterId = chapterId;
if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) {
this.nextChapterDisabled = true;
this.cdRef.markForCheck();
} else {
// Fetch the first page of next chapter
this.getPage(0, this.nextChapterId);
}
});
this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.prevChapterId = chapterId;
if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) {
this.prevChapterDisabled = true;
this.cdRef.markForCheck();
} else {
// Fetch the last page of prev chapter
this.getPage(1000000, this.prevChapterId);
}
});
this.render();
}, () => {
setTimeout(() => {
this.closeReader();
}, 200);
});
}
closeReader() {
this.readerService.closeReader(this.readingListMode, this.readingListId);
}
render() {
if (this.readerMode === ReaderMode.Webtoon) {
this.isLoading = false;
this.cdRef.markForCheck();
} else {
this.loadPage();
}
}
cancelMenuCloseTimer() {
if (this.menuTimeout) {
clearTimeout(this.menuTimeout);
}
}
/**
* Whenever the menu is interacted with, restart the timer. However, if the settings menu is open, don't restart, just cancel the timeout.
*/
resetMenuCloseTimer() {
if (this.menuTimeout) {
clearTimeout(this.menuTimeout);
if (!this.settingsOpen && this.autoCloseMenu) {
this.startMenuCloseTimer();
}
}
}
startMenuCloseTimer() {
if (!this.autoCloseMenu) { return; }
this.menuTimeout = setTimeout(() => {
this.toggleMenu();
}, OVERLAY_AUTO_CLOSE_TIME);
}
toggleMenu() {
this.menuOpen = !this.menuOpen;
this.cdRef.markForCheck();
if (this.menuTimeout) {
clearTimeout(this.menuTimeout);
}
if (this.menuOpen && !this.settingsOpen) {
this.startMenuCloseTimer();
} else {
this.showClickOverlay = false;
this.settingsOpen = false;
this.cdRef.markForCheck();
}
}
resetSwipeModifiers() {
this.prevScrollLeft = 0;
this.prevScrollTop = 0;
this.hasScrolledX = false;
this.hasScrolledY = false;
this.hasHitRightScroll = false;
this.hasHitZeroScroll = false;
this.hasHitBottomTopScroll = false;
this.hasHitZeroTopScroll = false;
}
/**
* This executes BEFORE fromEvent('scroll')
* @returns
* @param _
*/
onSwipeMove(_: SwipeEvent) {
this.prevScrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0;
this.prevScrollTop = this.readingArea?.nativeElement?.scrollTop || 0
}
triggerSwipePagination(direction: KeyDirection) {
if (!this.generalSettingsForm.get('swipeToPaginate')?.value) return;
switch(direction) {
case KeyDirection.Down:
this.nextPage();
break;
case KeyDirection.Right:
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
break;
case KeyDirection.Up:
this.prevPage();
break;
case KeyDirection.Left:
this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage();
break;
}
}
onSwipeEnd(event: SwipeEvent) {
// Positive number means swiping right/down, negative means left
switch (this.readerMode) {
case ReaderMode.Webtoon: break;
case ReaderMode.LeftRight:
{
if (event.direction !== 'x') return;
const scrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0;
const direction = event.distance < 0 ? KeyDirection.Right : KeyDirection.Left;
if (!this.checkIfPaginationAllowed(direction)) {
return;
}
// We just came from a swipe where pagination was required, and we are now at the end of the swipe, so make the user do it once more
if (direction === KeyDirection.Right) {
this.hasHitZeroScroll = false;
if (scrollLeft === 0 && this.ReadingAreaWidth === 0) {
this.triggerSwipePagination(direction);
return;
}
if (!this.hasHitRightScroll && this.checkIfPaginationAllowed(direction)) {
this.hasHitRightScroll = true;
return;
}
} else if (direction === KeyDirection.Left) {
this.hasHitRightScroll = false;
// If we have not scrolled then let the user page back
if (scrollLeft === 0 && this.prevScrollLeft === 0) {
if (!this.hasScrolledX || this.hasHitZeroScroll) {
this.triggerSwipePagination(direction);
return;
}
this.hasHitZeroScroll = true;
return;
}
}
if (!this.hasHitRightScroll) {
return;
}
this.triggerSwipePagination(direction);
break;
}
case ReaderMode.UpDown:
{
if (event.direction !== 'y') return;
const direction = event.distance < 0 ? KeyDirection.Down : KeyDirection.Up;
const scrollTop = this.readingArea?.nativeElement?.scrollTop || 0;
if (!this.checkIfPaginationAllowed(direction)) return;
if (direction === KeyDirection.Down) {
this.hasHitZeroTopScroll = false;
if (!this.hasHitBottomTopScroll && this.checkIfPaginationAllowed(direction)) {
this.hasHitBottomTopScroll = true;
return;
}
} else if (direction === KeyDirection.Up) {
this.hasHitBottomTopScroll = false;
// If we have not scrolled then let the user page back
if (scrollTop === 0 && this.prevScrollTop === 0) {
if (!this.hasScrolledY || this.hasHitZeroTopScroll) {
this.triggerSwipePagination(direction);
return;
}
this.hasHitZeroTopScroll = true;
return;
}
}
if (!this.hasHitBottomTopScroll) {
return;
}
this.triggerSwipePagination(direction);
break;
}
}
}
handlePageChange(event: any, direction: KeyDirection) {
if (this.readerMode === ReaderMode.Webtoon) {
if (direction === KeyDirection.Right) {
this.nextPage(event);
} else {
this.prevPage(event);
}
return;
}
if (direction === KeyDirection.Right) {
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage(event) : this.prevPage(event);
} else if (direction === KeyDirection.Left) {
this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage(event) : this.nextPage(event);
}
}
nextPage(event?: any) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
this.resetSwipeModifiers();
this.isLoading = true;
this.cdRef.markForCheck();
this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
const pageAmount = Math.max(this.canvasRenderer.getPageAmount(PAGING_DIRECTION.FORWARD), this.singleRenderer.getPageAmount(PAGING_DIRECTION.FORWARD),
this.doubleRenderer.getPageAmount(PAGING_DIRECTION.FORWARD),
this.doubleReverseRenderer.getPageAmount(PAGING_DIRECTION.FORWARD),
this.doubleNoCoverRenderer.getPageAmount(PAGING_DIRECTION.FORWARD)
);
// If we are on last page with split mode, we need to be able to progress, hence why we check if we could move backwards or not
const isSplitRendering = [PageSplitOption.SplitRightToLeft, PageSplitOption.SplitRightToLeft].includes(parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10));
const notInSplit = this.canvasRenderer.getPageAmount(PAGING_DIRECTION.BACKWARDS) === 0;
const isASpread = this.mangaReaderService.isWidePage(this.pageNum);
if ((this.pageNum + pageAmount >= this.maxPages && (!isASpread || !isSplitRendering || notInSplit))) {
// Move to next volume/chapter automatically
this.loadNextChapter();
return;
}
this.setPageNum(this.pageNum + pageAmount);
this.loadPage();
}
prevPage(event?: any) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
this.resetSwipeModifiers();
this.isLoading = true;
this.cdRef.markForCheck();
this.pagingDirectionSubject.next(PAGING_DIRECTION.BACKWARDS);
const pageAmount = this.readerMode === ReaderMode.Webtoon ? 1 : Math.max(this.canvasRenderer.getPageAmount(PAGING_DIRECTION.BACKWARDS),
this.singleRenderer.getPageAmount(PAGING_DIRECTION.BACKWARDS),
this.doubleRenderer.getPageAmount(PAGING_DIRECTION.BACKWARDS),
this.doubleNoCoverRenderer.getPageAmount(PAGING_DIRECTION.BACKWARDS),
this.doubleReverseRenderer.getPageAmount(PAGING_DIRECTION.BACKWARDS)
);
const notInSplit = this.readerMode === ReaderMode.Webtoon ? true : this.canvasRenderer.shouldMovePrev();
if ((this.pageNum - 1 < 0 && notInSplit)) {
// Move to next volume/chapter automatically
this.loadPrevChapter();
return;
}
this.setPageNum(this.pageNum - pageAmount);
this.loadPage();
}
/**
* Sets canvasImage's src to current page, but first attempts to use a pre-fetched image
*/
setCanvasImage() {
if (this.cachedImages === undefined) return;
this.canvasImage = this.getPage(this.pageNum, this.chapterId, this.layoutMode !== LayoutMode.Single);
if (!this.canvasImage.complete) {
this.canvasImage.addEventListener('load', () => {
this.currentImage.next(this.canvasImage);
}, false);
} else {
this.currentImage.next(this.canvasImage);
}
this.cdRef.markForCheck();
}
loadNextChapter() {
if (this.nextPageDisabled || this.nextChapterDisabled || this.bookmarkMode) {
this.toastr.info(translate('manga-reader.no-next-chapter'));
this.isLoading = false;
this.cdRef.markForCheck();
return;
}
if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) {
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.nextChapterId = chapterId;
this.loadChapter(chapterId, 'Next');
});
} else {
this.loadChapter(this.nextChapterId, 'Next');
}
}
loadPrevChapter() {
if (this.prevPageDisabled || this.prevChapterDisabled || this.bookmarkMode) {
this.toastr.info(translate('manga-reader.no-prev-chapter'));
this.isLoading = false;
this.cdRef.markForCheck();
return;
}
this.continuousChaptersStack.pop();
const prevChapter = this.continuousChaptersStack.peek();
if (prevChapter != this.chapterId) {
if (prevChapter !== undefined) {
this.chapterId = prevChapter;
this.init();
return;
}
}
if (this.prevChapterId === CHAPTER_ID_NOT_FETCHED || this.prevChapterId === this.chapterId) {
this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.prevChapterId = chapterId;
this.loadChapter(chapterId, 'Prev');
});
} else {
this.loadChapter(this.prevChapterId, 'Prev');
}
}
loadChapter(chapterId: number, direction: 'Next' | 'Prev') {
if (chapterId > 0) {
this.isLoading = true;
this.cdRef.markForCheck();
this.chapterId = chapterId;
this.continuousChaptersStack.push(chapterId);
// Load chapter Id onto route but don't reload
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
window.history.replaceState({}, '', newRoute);
this.init();
const msg = translate(direction === 'Next' ? 'toasts.load-next-chapter' : 'toasts.load-prev-chapter', {entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()});
this.toastr.info(msg, '', {timeOut: 3000});
} else {
// This will only happen if no actual chapter can be found
const msg = translate(direction === 'Next' ? 'toasts.no-next-chapter' : 'toasts.no-prev-chapter',
{entity: this.utilityService.formatChapterName(this.libraryType).toLowerCase()});
this.toastr.warning(msg);
this.isLoading = false;
if (direction === 'Prev') {
this.prevPageDisabled = true;
} else {
this.nextPageDisabled = true;
}
this.cdRef.markForCheck();
}
}
renderPage() {
const page = [this.canvasImage];
this.canvasRenderer?.renderPage(page);
this.singleRenderer?.renderPage(page);
this.doubleRenderer?.renderPage(page);
this.doubleNoCoverRenderer?.renderPage(page);
this.doubleReverseRenderer?.renderPage(page);
// Originally this was only for fit to height, but when swiping was introduced, it made more sense to do it always to reset to the same view
this.readingArea.nativeElement.scroll(0,0);
this.isLoading = false;
this.cdRef.markForCheck();
}
/**
* Maintains an array of images (that are requested from backend) around the user's current page. This allows for quick loading (seamless to user)
* and also maintains page info (wide image, etc.) due to onload event.
*/
prefetch() {
// NOTE: This doesn't allow for any directionality
// NOTE: This doesn't maintain 1 image behind at all times
for(let i = 0; i <= PREFETCH_PAGES - 3; i++) {
let numOffset = this.pageNum + i;
if (numOffset > this.maxPages - 1) {
break;
}
const index = (numOffset % this.cachedImages.length + this.cachedImages.length) % this.cachedImages.length;
const cachedImagePageNum = this.readerService.imageUrlToPageNum(this.cachedImages[index].src);
if (cachedImagePageNum !== numOffset) {
this.cachedImages[index] = this.getPage(numOffset, this.chapterId);
}
}
}
/**
* This is responsible for setting up the image variables. This will be moved out to different renderers
*/
loadPage() {
if (this.readerMode === ReaderMode.Webtoon) return;
this.isLoading = true;
this.setCanvasImage();
this.cdRef.markForCheck();
this.renderPage();
this.isLoading = false;
this.cdRef.markForCheck();
this.prefetch();
}
setReadingDirection() {
if (this.readingDirection === ReadingDirection.LeftToRight) {
this.readingDirection = ReadingDirection.RightToLeft;
} else {
this.readingDirection = ReadingDirection.LeftToRight;
}
if (this.menuOpen && this.user.preferences.showScreenHints) {
this.showClickOverlay = true;
this.showClickOverlaySubject.next(true);
setTimeout(() => {
this.showClickOverlay = false;
this.showClickOverlaySubject.next(false);
}, CLICK_OVERLAY_TIMEOUT);
}
}
sliderDragUpdate(context: ChangeContext) {
// This will update the value for value except when in webtoon due to how the webtoon reader
// responds to page changes
if (this.readerMode !== ReaderMode.Webtoon) {
this.setPageNum(context.value);
}
}
sliderPageUpdate(context: ChangeContext) {
const page = context.value;
if (page > this.pageNum) {
this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
} else {
this.pagingDirectionSubject.next(PAGING_DIRECTION.BACKWARDS);
}
this.setPageNum(this.adjustPagesForDoubleRenderer(page));
this.refreshSlider.emit();
this.goToPageEvent.next(this.pageNum);
this.render();
}
setPageNum(pageNum: number) {
this.pageNum = Math.max(Math.min(pageNum, this.maxPages - 1), 0);
this.pageNumSubject.next({pageNum: this.pageNum, maxPages: this.maxPages});
this.cdRef.markForCheck();
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.continuousChapterInfos[ChapterInfoPosition.Next] = res;
this.nextChapterPrefetched = true;
this.prefetchStartOfChapter(this.nextChapterId, PAGING_DIRECTION.FORWARD);
});
}
} else if (this.pageNum <= 10) {
if (this.prevChapterId > 0 && !this.prevChapterPrefetched) {
this.readerService.getChapterInfo(this.prevChapterId).pipe(take(1)).subscribe(res => {
this.continuousChapterInfos[ChapterInfoPosition.Previous] = res;
this.prevChapterPrefetched = true;
this.prefetchStartOfChapter(this.nextChapterId, PAGING_DIRECTION.BACKWARDS);
});
}
}
// Due to the fact that we start at image 0, but page 1, we need the last page to have progress as page + 1 to be completed
let tempPageNum = this.pageNum;
if (this.pageNum == this.maxPages - 1 && this.pagingDirection === PAGING_DIRECTION.FORWARD) {
tempPageNum = this.pageNum + 1;
}
if (!this.incognitoMode && !this.bookmarkMode) {
this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum).pipe(take(1)).subscribe(() => {/* No operation */});
}
}
/**
* Loads the first 5 images (throwaway cache) from the given chapterId
* @param chapterId
* @param direction Used to indicate if the chapter is behind or ahead of current chapter
*/
prefetchStartOfChapter(chapterId: number, direction: PAGING_DIRECTION) {
let pages = [];
if (direction === PAGING_DIRECTION.BACKWARDS) {
if (this.continuousChapterInfos[ChapterInfoPosition.Previous] === undefined) return;
const n = this.continuousChapterInfos[ChapterInfoPosition.Previous]!.pages;
// Ensure we only load up to 5 pages backward
pages = Array.from({ length: Math.min(n + 1, 5) }, (v, k) => n - k);
} else {
pages = [0, 1, 2, 3, 4];
}
const images = [];
pages.forEach((_, i: number) => {
const img = new Image();
img.src = this.getPageUrl(i, chapterId);
images.push(img)
});
}
goToPage(pageNum: number) {
let page = pageNum;
if (page === undefined || this.pageNum === page) { return; }
if (page > this.maxPages) {
page = this.maxPages;
} else if (page < 0) {
page = 0;
}
if (!(page === 0 || page === this.maxPages - 1)) {
page -= 1;
}
if (page > this.pageNum) {
this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
} else {
this.pagingDirectionSubject.next(PAGING_DIRECTION.BACKWARDS);
}
this.setPageNum(this.adjustPagesForDoubleRenderer(page));
this.goToPageEvent.next(page);
this.render();
}
// This is menu code
clickOverlayClass(side: 'right' | 'left') {
if (!this.showClickOverlay) {
return '';
}
if (this.readingDirection === ReadingDirection.LeftToRight) {
return side === 'right' ? 'highlight' : 'highlight-2';
}
return side === 'right' ? 'highlight-2' : 'highlight';
}
// This is menu only code
async promptForPage() {
// const question = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages});
// const goToPageNum = window.prompt(question, '');
const promptConfig = {...this.confirmService.defaultPrompt};
promptConfig.header = translate('book-reader.go-to-page');
promptConfig.content = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages});
const goToPageNum = await this.confirmService.prompt(undefined, promptConfig);
if (goToPageNum === null || goToPageNum.trim().length === 0) { return null; }
return goToPageNum;
}
// This is menu only code
toggleFullscreen() {
this.readerService.toggleFullscreen(this.reader.nativeElement, () => {
this.isFullscreen = true;
this.fullscreenEvent.next(true);
this.render();
});
}
// This is menu only code
toggleReaderMode() {
switch(this.readerMode) {
case ReaderMode.LeftRight:
this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
this.readerModeSubject.next(ReaderMode.UpDown);
break;
case ReaderMode.UpDown:
this.readerModeSubject.next(ReaderMode.Webtoon);
break;
case ReaderMode.Webtoon:
this.readerModeSubject.next(ReaderMode.LeftRight);
break;
}
// We must set this here because loadPage from render doesn't call if we aren't page splitting
if (this.readerMode !== ReaderMode.Webtoon) {
this.canvasImage = this.getPage(this.pageNum);
this.currentImage.next(this.canvasImage);
this.pageNumSubject.next({pageNum: this.pageNum, maxPages: this.maxPages});
//this.isLoading = true;
this.cdRef.detectChanges(); // Must use detectChanges to ensure ViewChildren get updated again
}
this.updateForm();
this.render();
}
// This is menu only code
updateForm() {
if ( this.readerMode === ReaderMode.Webtoon) {
this.generalSettingsForm.get('pageSplitOption')?.disable()
this.generalSettingsForm.get('fittingOption')?.disable()
this.generalSettingsForm.get('layoutMode')?.disable();
} else {
this.generalSettingsForm.get('fittingOption')?.enable()
this.generalSettingsForm.get('pageSplitOption')?.enable();
this.generalSettingsForm.get('layoutMode')?.enable();
if (this.layoutMode !== LayoutMode.Single) {
this.generalSettingsForm.get('pageSplitOption')?.disable();
this.generalSettingsForm.get('fittingOption')?.disable();
}
}
this.cdRef.markForCheck();
}
handleWebtoonPageChange(updatedPageNum: number) {
this.setPageNum(updatedPageNum);
}
/**
* Bookmarks the current page for the chapter
*/
bookmarkPage(event: Event | undefined = undefined) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
if (this.bookmarkMode) return;
if (!(this.accountService.hasBookmarkRole(this.user) || this.accountService.hasAdminRole(this.user))) return;
const pageNum = this.pageNum;
// if canvasRenderer and doubleRenderer is undefined, then we are in webtoon mode
const isDouble = this.canvasRenderer !== undefined && this.doubleRenderer !== undefined && Math.max(this.canvasRenderer.getBookmarkPageCount(), this.singleRenderer.getBookmarkPageCount(),
this.doubleRenderer.getBookmarkPageCount(), this.doubleReverseRenderer.getBookmarkPageCount(), this.doubleNoCoverRenderer.getBookmarkPageCount()) > 1;
if (this.CurrentPageBookmarked) {
let apis = [this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)];
if (isDouble) apis.push(this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1));
forkJoin(apis).pipe(take(1)).subscribe(() => {
delete this.bookmarks[pageNum];
if (isDouble) delete this.bookmarks[pageNum + 1];
this.cdRef.detectChanges();
});
} else {
let apis = [this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)];
if (isDouble) apis.push(this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1));
forkJoin(apis).pipe(take(1)).subscribe(() => {
this.bookmarks[pageNum] = 1;
if (isDouble) this.bookmarks[pageNum + 1] = 1;
this.cdRef.detectChanges();
});
}
// Show an effect on the image to show that it was bookmarked
this.showBookmarkEffectEvent.next(pageNum);
}
// This is menu only code
/**
* Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state
*/
turnOffIncognito() {
this.incognitoMode = false;
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
window.history.replaceState({}, '', newRoute);
this.toastr.info(translate('toasts.incognito-off'));
if (!this.bookmarkMode) {
this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
}
}
// This is menu only code
openShortcutModal() {
const ref = this.modalService.open(ShortcutsModalComponent, { scrollable: true, size: 'md' });
ref.componentInstance.shortcuts = [
{key: '⇽', description: 'prev-page'},
{key: '⇾', description: 'next-page'},
{key: '↑', description: 'prev-page'},
{key: '↓', description: 'next-page'},
{key: 'G', description: 'go-to'},
{key: 'B', description: 'bookmark'},
{key: translate('shortcuts-modal.double-click'), description: 'bookmark'},
{key: 'ESC', description: 'close-reader'},
{key: 'SPACE', description: 'toggle-menu'},
];
}
// menu only code
savePref() {
const modelSettings = this.generalSettingsForm.getRawValue();
// Get latest preferences from user, overwrite with what we manage in this UI, then save
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (!user) return;
const data = {...user.preferences};
data.layoutMode = parseInt(modelSettings.layoutMode, 10);
data.readerMode = this.readerMode;
data.autoCloseMenu = this.autoCloseMenu;
data.readingDirection = this.readingDirection;
data.emulateBook = modelSettings.emulateBook;
data.swipeToPaginate = modelSettings.swipeToPaginate;
data.pageSplitOption = parseInt(modelSettings.pageSplitOption, 10);
data.locale = data.locale || 'en';
this.accountService.updatePreferences(data).subscribe(updatedPrefs => {
this.toastr.success(translate('manga-reader.user-preferences-updated'));
if (this.user) {
this.user.preferences = updatedPrefs;
this.cdRef.markForCheck();
}
})
});
}
translatePrefOptions(o: {text: string, value: any}) {
const d = {...o};
d.text = translate('preferences.' + o.text);
return d;
}
}