334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import { CommonModule, isPlatformBrowser } from "@angular/common";
|
|
import {
|
|
AfterViewInit,
|
|
Component,
|
|
ElementRef,
|
|
Inject,
|
|
OnDestroy,
|
|
PLATFORM_ID,
|
|
QueryList,
|
|
ViewChildren,
|
|
} from "@angular/core";
|
|
import { ActivatedRoute, Router, RouterLink } from "@angular/router";
|
|
import { Observable, Subject, Subscription, fromEvent, map, takeUntil, throttleTime } from "rxjs";
|
|
import { Chapter, Page } from "../../services/parsers/rulib/rulib.chapter.dto";
|
|
import { IRulibChapter } from "../../services/parsers/rulib/rulib.chapters.dto";
|
|
import { SearchService } from "../../services/search.service";
|
|
import { ScaleImageComponent } from "../scale-image/scale-image.component";
|
|
import { LazyLoadDirective } from "./lazyscroll.directive";
|
|
import { CachedPage, CachedPages } from "./reader.dto";
|
|
|
|
@Component({
|
|
selector: "app-reader",
|
|
templateUrl: "./reader.component.html",
|
|
styleUrls: ["./reader.component.less"],
|
|
standalone: true,
|
|
imports: [CommonModule, ScaleImageComponent, RouterLink, LazyLoadDirective],
|
|
})
|
|
export class ReaderComponent implements AfterViewInit, OnDestroy {
|
|
//FIXME: Scrolling to top when manhwa
|
|
pages: Page[] = [];
|
|
currentPageIndex = 0;
|
|
cachedPages: CachedPages = new Map<number, CachedPage>();
|
|
imageUrl: string = "";
|
|
isManhwa = false;
|
|
currentChapterInfo: Chapter | null = null;
|
|
url = "";
|
|
private chaptersInfo: IRulibChapter[] = [];
|
|
private chapterNum: number = 0;
|
|
private chapterVol: number = 0;
|
|
private fromTowards = false;
|
|
private destroy$ = new Subject<void>();
|
|
private scrollSubscription = new Subscription();
|
|
@ViewChildren("anchor") anchor: QueryList<ElementRef<HTMLDivElement>> = new QueryList();
|
|
private observer!: IntersectionObserver;
|
|
|
|
constructor(
|
|
private route: ActivatedRoute,
|
|
private router: Router,
|
|
private searchService: SearchService,
|
|
private host: ElementRef,
|
|
@Inject(PLATFORM_ID) private platformId: object,
|
|
) {}
|
|
ngOnDestroy(): void {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
if (this.observer) this.observer.disconnect();
|
|
}
|
|
|
|
get manhwaPages() {
|
|
return this.cachedPages.values();
|
|
}
|
|
|
|
ngAfterViewInit(): void {
|
|
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
|
|
const url = params["url"];
|
|
const chapter = params["chapter"];
|
|
const volume = params["volume"];
|
|
const fromTowards = params["from_towards"];
|
|
if (url && chapter && volume) {
|
|
if (fromTowards) this.fromTowards = Boolean(+fromTowards);
|
|
else this.fromTowards = false;
|
|
this.chapterNum = +chapter;
|
|
this.chapterVol = +volume;
|
|
this.url = url;
|
|
this.loadChapter(url, chapter, volume);
|
|
this.searchService
|
|
.getChapters(url)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe((data) => {
|
|
this.chaptersInfo = data.data;
|
|
});
|
|
this.searchService
|
|
.isManhwa(url)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe((data) => {
|
|
this.isManhwa = data;
|
|
if (this.isManhwa) {
|
|
//TODO: scroll offset
|
|
this.observer = new IntersectionObserver(
|
|
([entry]) => entry.isIntersecting && this.testScrollEmitter(),
|
|
{
|
|
root: this.isHostScrollable() ? this.host.nativeElement : null,
|
|
},
|
|
);
|
|
this.anchor.changes
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe((list: QueryList<ElementRef<HTMLDivElement>>) => {
|
|
if (this.observer && list.first) this.observer.observe(list.first.nativeElement);
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
this.router.navigate(["/"]);
|
|
}
|
|
});
|
|
}
|
|
|
|
private isHostScrollable(): boolean {
|
|
if (isPlatformBrowser(this.platformId)) {
|
|
const style = window.getComputedStyle(this.host.nativeElement);
|
|
|
|
return (
|
|
style.getPropertyValue("overflow") === "auto" ||
|
|
style.getPropertyValue("overflow-y") === "scroll"
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private testScrollEmitter() {
|
|
this.nextPage();
|
|
}
|
|
|
|
private handleScrollManhwa(event: Event) {
|
|
if (event.currentTarget) {
|
|
if (
|
|
(event.currentTarget as HTMLElement).scrollHeight -
|
|
(event.currentTarget as HTMLElement).scrollTop <
|
|
3000
|
|
) {
|
|
// this.loadPage(this.currentPageIndex + 1);
|
|
console.log("load");
|
|
}
|
|
}
|
|
}
|
|
|
|
private initManhwaScroll() {
|
|
const component = document.querySelector("app-reader");
|
|
if (component && this.isManhwa) {
|
|
this.scrollSubscription = fromEvent(component, "scroll")
|
|
.pipe(
|
|
throttleTime(300),
|
|
map((data) => {
|
|
console.log("throttle");
|
|
return data;
|
|
}),
|
|
takeUntil(this.destroy$),
|
|
)
|
|
.subscribe((event) => {
|
|
this.handleScrollManhwa(event);
|
|
});
|
|
}
|
|
}
|
|
|
|
private noManhwaScroll() {
|
|
const component = document.querySelector("app-reader");
|
|
if (component && !this.isManhwa) {
|
|
this.scrollSubscription.unsubscribe();
|
|
}
|
|
}
|
|
|
|
backToTitle() {
|
|
this.router.navigate(["/", "detail"], { queryParams: { url: this.url } });
|
|
}
|
|
|
|
loadChapter(url: string, chapter: string, volume: string) {
|
|
this.searchService
|
|
.getChapter(url, chapter, volume)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: (data) => {
|
|
this.currentChapterInfo = data.data;
|
|
this.pages = data.data.pages;
|
|
if (this.fromTowards) {
|
|
this.currentPageIndex = this.pages.length - 1;
|
|
this.loadPage(this.pages.length - 1); // Загрузить последнюю страницу при открытии главы
|
|
} else this.loadPage(0); // Загрузить первую страницу при открытии главы
|
|
},
|
|
error: (error) => {
|
|
console.log(error);
|
|
this.router.navigate(["/", "detail"], { queryParams: { url: this.url } });
|
|
},
|
|
});
|
|
}
|
|
|
|
loadPage(index: number) {
|
|
if (index >= 0 && index < this.pages.length) {
|
|
this.currentPageIndex = index;
|
|
this.cachePage(index); // Кэшируем текущую и соседние страницы
|
|
if (!this.isManhwa) this.unloadCachedPages(index); // Сгружаем ненужные страницы из кэша
|
|
if (!this.isManhwa) {
|
|
const container = document.querySelector("app-reader");
|
|
if (container) {
|
|
container.scrollTo({
|
|
top: 0,
|
|
behavior: "smooth",
|
|
});
|
|
}
|
|
}
|
|
if (!this.cachedPages.get(index)?.imageData) {
|
|
// Если страница не закэширована, загружаем её
|
|
this.fetchAndCachePage(index)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe(() => {
|
|
this.updateImage();
|
|
});
|
|
} else {
|
|
// Если страница уже в кэше, просто обновляем изображение
|
|
this.updateImage();
|
|
}
|
|
} else if (index == this.pages.length && !this.isManhwa) {
|
|
const thisChapterIndex = this.chaptersInfo.findIndex((chapter) => {
|
|
return +chapter.number === this.chapterNum && +chapter.volume === this.chapterVol;
|
|
});
|
|
const nextChapterIndex = Math.min(thisChapterIndex + 1, this.chaptersInfo.length - 1);
|
|
const nextChapter = this.chaptersInfo[nextChapterIndex];
|
|
if (nextChapter !== this.chaptersInfo[thisChapterIndex]) {
|
|
this.cachedPages.clear();
|
|
this.router.navigate(["/", "reader"], {
|
|
queryParams: {
|
|
url: this.url,
|
|
chapter: nextChapter.number,
|
|
volume: nextChapter.volume,
|
|
},
|
|
});
|
|
}
|
|
} else if (index == -1 && !this.isManhwa) {
|
|
const thisChapterIndex = this.chaptersInfo.findIndex((chapter) => {
|
|
return +chapter.number === this.chapterNum && +chapter.volume === this.chapterVol;
|
|
});
|
|
const prevChapterIndex = Math.max(thisChapterIndex - 1, 0);
|
|
const prevChapter = this.chaptersInfo[prevChapterIndex];
|
|
if (prevChapter !== this.chaptersInfo[thisChapterIndex]) {
|
|
this.cachedPages.clear();
|
|
this.router.navigate(["/", "reader"], {
|
|
queryParams: {
|
|
url: this.url,
|
|
chapter: prevChapter.number,
|
|
volume: prevChapter.volume,
|
|
from_towards: 1,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Кэширование текущей страницы и несколько соседних для ускорения процесса навигации по страницам
|
|
private cachePage(index: number) {
|
|
const startIndex = Math.max(0, index - 2);
|
|
const endIndex = Math.min(this.pages.length - 1, index + 2);
|
|
|
|
for (let i = startIndex; i <= endIndex; i++) {
|
|
if (!this.isPageCached(i)) {
|
|
this.fetchAndCachePage(i)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe(() => {
|
|
if (i === this.currentPageIndex) {
|
|
this.updateImage();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private isPageCached(index: number): boolean {
|
|
return !!this.cachedPages.get(index)?.imageData;
|
|
}
|
|
|
|
// Загрузка и сохранение изображения в кэш
|
|
private fetchAndCachePage(index: number): Observable<void> {
|
|
return this.searchService
|
|
.getImageData(this.searchService.getImageServer() + this.pages[index].url)
|
|
.pipe(
|
|
map((imageData) => {
|
|
const url = this.getImageUrl(imageData);
|
|
this.cachedPages.set(index, {
|
|
...this.pages[index],
|
|
imageData,
|
|
imageUrl: url,
|
|
isManhwa: +this.pages[index].ratio < 0.5,
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Выгрузка из кэша старых страниц
|
|
private unloadCachedPages(index: number) {
|
|
for (const key in this.cachedPages) {
|
|
const pageIndex = +key;
|
|
if (index - pageIndex > 2) {
|
|
this.cachedPages.delete(pageIndex);
|
|
}
|
|
}
|
|
}
|
|
|
|
getImageUrl(imageData?: Uint8Array): string {
|
|
if (!imageData) {
|
|
return "";
|
|
}
|
|
const blob = new Blob([imageData], { type: "image/jpeg" });
|
|
const urlCreator = window.URL || window.webkitURL;
|
|
return urlCreator.createObjectURL(blob);
|
|
}
|
|
|
|
// Обновляем изображение на странице
|
|
private updateImage() {
|
|
const currentPage = this.cachedPages.get(this.currentPageIndex);
|
|
if (currentPage && currentPage.imageData && !this.isManhwa) {
|
|
const blob = new Blob([currentPage.imageData], { type: "image/jpeg" });
|
|
const urlCreator = window.URL || window.webkitURL;
|
|
this.imageUrl = urlCreator.createObjectURL(blob);
|
|
const imageUrl = this.imageUrl;
|
|
const image = new Image();
|
|
image.onload = () => {
|
|
URL.revokeObjectURL(imageUrl);
|
|
};
|
|
image.src = imageUrl;
|
|
}
|
|
}
|
|
|
|
nextPage() {
|
|
this.loadPage(this.currentPageIndex + 1);
|
|
}
|
|
|
|
prevPage() {
|
|
this.loadPage(this.currentPageIndex - 1);
|
|
}
|
|
|
|
get imageContainerClass() {
|
|
return `${this.isManhwa ? "h-auto" : "h-[70vh]"} w-[95vw] md:w-[700px] md:h-auto flex flex-col`;
|
|
}
|
|
}
|