diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 2c749d153..3084ab352 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -227,7 +227,7 @@ namespace API.Controllers [HttpGet("search")] public async Task> Search(string queryString) { - queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty); + queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); // Get libraries user has access to diff --git a/API/DTOs/Stats/ServerInfoDto.cs b/API/DTOs/Stats/ServerInfoDto.cs index 46a8c9ae1..9176a81ff 100644 --- a/API/DTOs/Stats/ServerInfoDto.cs +++ b/API/DTOs/Stats/ServerInfoDto.cs @@ -8,5 +8,7 @@ public string DotnetVersion { get; set; } public string KavitaVersion { get; set; } public int NumOfCores { get; set; } + public int NumberOfLibraries { get; set; } + public bool HasBookmarks { get; set; } } } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 29bdd7ca3..fa650b7fc 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -241,11 +241,7 @@ public class MetadataService : IMetadataService } await _unitOfWork.CommitAsync(); - // foreach (var series in nonLibrarySeries) - // { - // // TODO: This can be removed, we use CoverUpdate elsewhere - // await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(library.Id, series.Id)); - // } + _logger.LogInformation( "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index a9d233e2a..298b8f2b7 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; using System.Threading.Tasks; @@ -105,7 +106,9 @@ public class StatsService : IStatsService KavitaVersion = BuildInfo.Version.ToString(), DotnetVersion = Environment.Version.ToString(), IsDocker = new OsInfo(Array.Empty()).IsDocker, - NumOfCores = Math.Max(Environment.ProcessorCount, 1) + NumOfCores = Math.Max(Environment.ProcessorCount, 1), + HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(), + NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count() }; return serverInfo; diff --git a/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts index 030a6e720..47c79e49f 100644 --- a/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts +++ b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts @@ -1,7 +1,7 @@ import { DOCUMENT } from '@angular/common'; import { Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewChild } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; -import { Subject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; import { debounceTime, takeUntil } from 'rxjs/operators'; import { KEY_CODES } from '../shared/_services/utility.service'; import { SearchResultGroup } from '../_models/search/search-result-group'; @@ -77,7 +77,7 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy { } - constructor(private renderer2: Renderer2, @Inject(DOCUMENT) private document: Document) { } + constructor() { } @HostListener('window:click', ['$event']) handleDocumentClick(event: any) { @@ -122,13 +122,6 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy { event.stopPropagation(); event.preventDefault(); } - - if (this.inputElem) { - // hack: To prevent multiple typeaheads from being open at once, click document then trigger the focus - this.document.querySelector('body')?.click(); - this.inputElem.nativeElement.focus(); - this.open(); - } this.openDropdown(); return this.hasFocus; diff --git a/UI/Web/src/app/library/library.component.html b/UI/Web/src/app/library/library.component.html index ac79f074c..3ac780ef5 100644 --- a/UI/Web/src/app/library/library.component.html +++ b/UI/Web/src/app/library/library.component.html @@ -18,6 +18,12 @@ + + + + + + (); @@ -44,11 +45,16 @@ export class LibraryComponent implements OnInit, OnDestroy { this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(res => { if (res.event === EVENTS.SeriesAdded) { const seriesAddedEvent = res.payload as SeriesAddedEvent; + + this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => { + this.recentlyAddedSeries.unshift(series); + }); this.loadRecentlyAdded(); } else if (res.event === EVENTS.SeriesRemoved) { const seriesRemovedEvent = res.payload as SeriesRemovedEvent; - this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId); + this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId); + this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId); this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId); this.recentlyAddedChapters = this.recentlyAddedChapters.filter(item => item.seriesId != seriesRemovedEvent.seriesId); } else if (res.event === EVENTS.ScanSeries) { @@ -81,6 +87,7 @@ export class LibraryComponent implements OnInit, OnDestroy { reloadSeries() { this.loadOnDeck(); this.loadRecentlyAdded(); + this.loadRecentlyAddedSeries(); } reloadInProgress(series: Series | boolean) { @@ -102,6 +109,12 @@ export class LibraryComponent implements OnInit, OnDestroy { }); } + loadRecentlyAddedSeries() { + this.seriesService.getRecentlyAdded().pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => { + this.recentlyAddedSeries = updatedSeries.result; + }); + } + loadRecentlyAdded() { this.seriesService.getRecentlyUpdatedSeries().pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => { diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html index a2c1f61e1..1f8477c62 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html @@ -4,7 +4,7 @@ Is Scrolling: {{isScrollingForwards() ? 'Forwards' : 'Backwards'}} {{this.isScrolling}} All Images Loaded: {{this.allImagesLoaded}} Prefetched {{minPageLoaded}}-{{maxPageLoaded}} - Pages: {{pageNum}} / {{totalPages}} + Pages: {{pageNum}} / {{totalPages - 1}} At Top: {{atTop}} At Bottom: {{atBottom}} Total Height: {{getTotalHeight()}} @@ -27,7 +27,7 @@ image diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss index c723bbcb2..bc7e74ef1 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss @@ -6,6 +6,10 @@ border: 2px solid red; } +.full-opacity { + opacity: 0; +} + .spacer { width: 100%; height: 300px; @@ -25,10 +29,6 @@ width: 100% !important; } -// .img-container { -// overflow: auto; -// } - @keyframes move-up-down { 0%, 100% { diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts index fd9d01106..7c041623f 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts @@ -61,7 +61,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { @Output() loadNextChapter: EventEmitter = new EventEmitter(); @Output() loadPrevChapter: EventEmitter = new EventEmitter(); - @Input() goToPage: ReplaySubject = new ReplaySubject(); + @Input() goToPage: BehaviorSubject | undefined; @Input() bookmarkPage: ReplaySubject = new ReplaySubject(); @Input() fullscreenToggled: ReplaySubject = new ReplaySubject(); @@ -121,10 +121,18 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { * Keeps track of the previous scrolling height for restoring scroll position after we inject spacer block */ previousScrollHeightMinusTop: number = 0; + /** + * Tracks the first load, until all the initial prefetched images are loaded. We use this to reduce opacity so images can load without jerk. + */ + initFinished: boolean = false; /** * Debug mode. Will show extra information. Use bitwise (|) operators between different modes to enable different output */ debugMode: DEBUG_MODES = DEBUG_MODES.None; + /** + * Debug mode. Will filter out any messages in here so they don't hit the log + */ + debugLogFilter: Array = ['[PREFETCH]', '[Intersection]', '[Visibility]', '[Image Load]']; get minPageLoaded() { return Math.min(...Object.values(this.imagesLoaded)); @@ -173,8 +181,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { fromEvent(this.isFullscreenMode ? this.readerElemRef.nativeElement : window, 'scroll') .pipe(debounceTime(20), takeUntil(this.onDestroy)) .subscribe((event) => this.handleScrollEvent(event)); - - } ngOnInit(): void { @@ -182,9 +188,9 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { 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; } + this.debugLog('[GoToPage] jump has occured from ' + this.pageNum + ' to ' + page); if (this.pageNum < page) { this.scrollingDirection = PAGING_DIRECTION.FORWARD; @@ -252,10 +258,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { } this.prevScrollPosition = verticalOffset; - console.log('CurrentPageElem: ', this.currentPageElem); - if (this.currentPageElem != null) { - console.log('Element Visible: ', this.isElementVisible(this.currentPageElem)); - } if (this.isScrolling && this.currentPageElem != null && this.isElementVisible(this.currentPageElem)) { this.debugLog('[Scroll] Image is visible from scroll, isScrolling is now false'); this.isScrolling = false; @@ -356,15 +358,12 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { isElementVisible(elem: Element) { if (elem === null || elem === undefined) { return false; } + this.debugLog('[Visibility] Checking if Page ' + elem.getAttribute('id') + ' is visible'); // NOTE: This will say an element is visible if it is 1 px offscreen on top var rect = elem.getBoundingClientRect(); let [innerHeight, innerWidth] = this.getInnerDimensions(); - - console.log('innerHeight: ', innerHeight); - console.log('innerWidth: ', innerWidth); - return (rect.bottom >= 0 && rect.right >= 0 && rect.top <= (innerHeight || document.documentElement.clientHeight) && @@ -399,6 +398,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { initWebtoonReader() { + this.initFinished = false; const [innerWidth, _] = this.getInnerDimensions(); this.webtoonImageWidth = innerWidth || document.documentElement.clientWidth || document.body.clientWidth; this.imagesLoaded = {}; @@ -437,11 +437,14 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { .filter((img: any) => !img.complete) .map((img: any) => new Promise(resolve => { img.onload = img.onerror = resolve; }))) .then(() => { + this.debugLog('[Initialization] All images have loaded from initial prefetch, initFinished = true'); this.debugLog('[Image Load] ! Loaded current page !', this.pageNum); this.currentPageElem = document.querySelector('img#page-' + this.pageNum); - + // There needs to be a bit of time before we scroll if (this.currentPageElem && !this.isElementVisible(this.currentPageElem)) { this.scrollToCurrentPage(); + } else { + this.initFinished = true; } this.allImagesLoaded = true; @@ -471,8 +474,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { * @param scrollToPage Optional (default false) parameter to trigger scrolling to the newly set page */ setPageNum(pageNum: number, scrollToPage: boolean = false) { - if (pageNum > this.totalPages) { - pageNum = this.totalPages; + if (pageNum >= this.totalPages) { + pageNum = this.totalPages - 1; } else if (pageNum < 0) { pageNum = 0; } @@ -482,9 +485,6 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { this.prefetchWebtoonImages(); if (scrollToPage) { - const currentImage = document.querySelector('img#page-' + this.pageNum); - if (currentImage === null) return; - this.debugLog('[GoToPage] Scrolling to page', this.pageNum); this.scrollToCurrentPage(); } } @@ -499,6 +499,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { scrollToCurrentPage() { this.currentPageElem = document.querySelector('img#page-' + this.pageNum); if (!this.currentPageElem) { return; } + this.debugLog('[GoToPage] Scrolling to page', this.pageNum); // Update prevScrollPosition, so the next scroll event properly calculates direction this.prevScrollPosition = this.currentPageElem.getBoundingClientRect().top; @@ -508,6 +509,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { if (this.currentPageElem) { this.debugLog('[Scroll] Scrolling to page ', this.pageNum); this.currentPageElem.scrollIntoView({behavior: 'smooth'}); + this.initFinished = true; } }, 600); } @@ -540,7 +542,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { attachIntersectionObserverElem(elem: HTMLImageElement) { if (elem !== null) { this.intersectionObserver.observe(elem); - this.debugLog('Attached Intersection Observer to page', this.readerService.imageUrlToPageNum(elem.src)); + this.debugLog('[Intersection] Attached Intersection Observer to page', this.readerService.imageUrlToPageNum(elem.src)); } else { console.error('Could not attach observer on elem'); // This never happens } @@ -610,6 +612,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { debugLog(message: string, extraData?: any) { if (!(this.debugMode & DEBUG_MODES.Logs)) return; + if (this.debugLogFilter.filter(str => message.replace('\t', '').startsWith(str)).length > 0) return; if (extraData !== undefined) { console.log(message, extraData); } else { diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/manga-reader.component.html index ca95853bc..4adc3ec04 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/manga-reader.component.html @@ -27,7 +27,7 @@ -
+
= new ReplaySubject(); + goToPageEvent!: BehaviorSubject; + /** * An event emiter when a bookmark on a page change occurs. Used soley by the webtoon reader. */ @@ -221,6 +222,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { * 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; private readonly onDestroy = new Subject(); @@ -424,6 +429,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.nextChapterPrefetched = false; this.pageNum = 0; this.pagingDirection = PAGING_DIRECTION.FORWARD; + this.inSetup = true; + + if (this.goToPageEvent) { + // There was a bug where goToPage was emitting old values into infinite scroller between chapter loads. We explicity clear it out between loads + // and we use a BehaviourSubject to ensure only latest value is sent + this.goToPageEvent.complete(); + } forkJoin({ progress: this.readerService.getProgress(this.chapterId), @@ -445,6 +457,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { page = this.maxPages - 1; } this.setPageNum(page); + this.goToPageEvent = new BehaviorSubject(this.pageNum); + @@ -453,11 +467,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything. this.pageOptions = newOptions; + // TODO: Move this into ChapterInfo this.libraryService.getLibraryType(results.chapterInfo.libraryId).pipe(take(1)).subscribe(type => { this.libraryType = type; this.updateTitle(results.chapterInfo, type); }); + this.inSetup = false; + // From bookmarks, create map of pages to make lookup time O(1) @@ -1019,7 +1036,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.setPageNum(page); this.refreshSlider.emit(); - this.goToPageEvent.next(page); + this.goToPageEvent.next(page); this.render(); } diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index d2462ffc2..d9020995b 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -128,7 +128,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { this.actionInProgress = false; this.bulkSelectionService.deselectAll(); }); - + break; case Action.MarkAsUnread: this.actionService.markMultipleAsUnread(seriesId, selectedVolumeIds, chapters, () => { @@ -167,7 +167,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { private actionFactoryService: ActionFactoryService, private libraryService: LibraryService, private confirmService: ConfirmService, private titleService: Title, private downloadService: DownloadService, private actionService: ActionService, - public imageSerivce: ImageService, private messageHub: MessageHubService, + public imageSerivce: ImageService, private messageHub: MessageHubService, ) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.accountService.currentUser$.pipe(take(1)).subscribe(user => { @@ -203,7 +203,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { } else if (event.event === EVENTS.ScanSeries) { const seriesCoverUpdatedEvent = event.payload as ScanSeriesEvent; if (seriesCoverUpdatedEvent.seriesId === this.series.id) { - console.log('ScanSeries called') this.seriesService.getMetadata(this.series.id).pipe(take(1)).subscribe(metadata => { this.seriesMetadata = metadata; this.createHTML(); @@ -361,13 +360,13 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { this.seriesService.getVolumes(this.series.id).subscribe(volumes => { this.volumes = volumes; // volumes are already be sorted in the backend const vol0 = this.volumes.filter(v => v.number === 0); - this.storyChapters = vol0.map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters); - this.chapters = volumes.map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters).filter(c => !c.isSpecial || isNaN(parseInt(c.range, 10))); - - + this.storyChapters = vol0.map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters); + this.chapters = volumes.map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters).filter(c => !c.isSpecial || isNaN(parseInt(c.range, 10))); + + this.setContinuePoint(); - + const specials = this.storyChapters.filter(c => c.isSpecial || isNaN(parseInt(c.range, 10))); this.hasSpecials = specials.length > 0 if (this.hasSpecials) { @@ -390,7 +389,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { /** * This will update the selected tab - * + * * This assumes loadPage() has already primed all the calculations and state variables. Do not call directly. */ updateSelectedTab() { @@ -402,11 +401,11 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { } // This shows Volumes tab - if (this.volumes.filter(v => v.number !== 0).length !== 0) { + if (this.volumes.filter(v => v.number !== 0).length !== 0) { this.hasNonSpecialVolumeChapters = true; } - // If an update occured and we were on specials, re-activate Volumes/Chapters + // If an update occured and we were on specials, re-activate Volumes/Chapters if (!this.hasSpecials && !this.hasNonSpecialVolumeChapters && this.activeTabId != TabID.Storyline) { this.activeTabId = TabID.Storyline; } @@ -455,7 +454,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { if (this.series === undefined) { return; } - + this.actionService.markChapterAsRead(this.series.id, chapter, () => { this.setContinuePoint(); this.actionInProgress = false; @@ -505,9 +504,9 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { this.toastr.error('There are no chapters to this volume. Cannot read.'); return; } - // NOTE: When selecting a volume, we might want to ask the user which chapter they want or an "Automatic" option. For Volumes - // made up of lots of chapter files, it makes it more versitile. The modal can have pages read / pages with colored background - // to help the user make a good choice. + // NOTE: When selecting a volume, we might want to ask the user which chapter they want or an "Automatic" option. For Volumes + // made up of lots of chapter files, it makes it more versitile. The modal can have pages read / pages with colored background + // to help the user make a good choice. // If user has progress on the volume, load them where they left off if (volume.pagesRead < volume.pages && volume.pagesRead > 0) {