From 437b52fd8b8ede6bb7651e5ff2d51519445f4c79 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Thu, 11 Jun 2026 23:27:01 -0500 Subject: [PATCH] Feat: Longstrip Viewer(s) & Lag Improvements --- src/lib/components/browse/Search.svelte | 4 + src/lib/components/browse/TagTab.svelte | 19 +- .../extensions/ExtensionLibrary.svelte | 5 - src/lib/components/reader/PageView.svelte | 599 ++++-------------- src/lib/components/reader/Reader.svelte | 185 ++---- .../components/reader/ReaderControls.svelte | 4 +- .../components/reader/lib/chapterActions.ts | 19 +- .../components/reader/lib/chapterLoader.ts | 18 +- src/lib/components/reader/lib/navigation.ts | 21 +- src/lib/components/reader/lib/pageLoader.ts | 4 +- .../components/reader/lib/scrollHandler.ts | 140 ---- .../reader/viewer/DoubleViewer.svelte | 89 +++ .../reader/viewer/LongstripViewer.svelte | 409 ++++++++++++ .../reader/viewer/SingleViewer.svelte | 71 +++ src/lib/components/recent/Recent.svelte | 13 +- src/lib/components/series/ChapterList.svelte | 5 +- .../components/series/SeriesActions.svelte | 9 +- src/lib/components/series/SeriesDetail.svelte | 352 ++++------ src/lib/components/series/lib/chapterList.ts | 12 - .../series/panels/MarkersPanel.svelte | 4 +- .../shared/manga/MangaPreview.svelte | 197 +++--- src/lib/core/cache/pageCache.ts | 4 + src/lib/request-manager/chapters.ts | 67 +- src/lib/state/reader.svelte.ts | 24 +- src/lib/state/series.svelte.ts | 337 ++++++---- src/lib/state/tracking.svelte.ts | 12 +- 26 files changed, 1298 insertions(+), 1325 deletions(-) create mode 100644 src/lib/components/reader/viewer/DoubleViewer.svelte create mode 100644 src/lib/components/reader/viewer/LongstripViewer.svelte create mode 100644 src/lib/components/reader/viewer/SingleViewer.svelte diff --git a/src/lib/components/browse/Search.svelte b/src/lib/components/browse/Search.svelte index 370bb62..4788ebc 100644 --- a/src/lib/components/browse/Search.svelte +++ b/src/lib/components/browse/Search.svelte @@ -39,6 +39,8 @@ goto(u.toString(), { replaceState: true, noScroll: true }); } + let pendingPrefill = $state(""); + let tabsEl = $state(undefined); let tabIndicator = $state({ left: 0, width: 0 }); @@ -248,11 +250,13 @@ {availableLangs} {hasMultipleLangs} {loadingSources} + {pendingPrefill} popularResults={popular_results} popularLoading={popular_loading} {sourceCache} query={urlQuery} onQueryChange={setQuery} + onPrefillConsumed={() => { pendingPrefill = ""; }} onPreview={(m) => setPreviewManga(m)} /> {:else if urlTab === "tag"} diff --git a/src/lib/components/browse/TagTab.svelte b/src/lib/components/browse/TagTab.svelte index 39972be..6ef0b1b 100644 --- a/src/lib/components/browse/TagTab.svelte +++ b/src/lib/components/browse/TagTab.svelte @@ -53,7 +53,12 @@ }); $effect(() => { - if (tag_localHasNext && !tag_loadingMoreLocal && !tag_loadingLocal) tagLoadMoreLocal(); + const _hasNext = tag_localHasNext; + const _loadingMore = tag_loadingMoreLocal; + const _loadingLocal = tag_loadingLocal; + untrack(() => { + if (_hasNext && !_loadingMore && !_loadingLocal) tagLoadMoreLocal(); + }); }); async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) { @@ -191,11 +196,15 @@ let tag_autoSearchFired = $state(false); $effect(() => { - tag_activeTags; - tag_activeStatuses; + const _tags = tag_activeTags; + const _statuses = tag_activeStatuses; + const _loadingLocal = tag_loadingLocal; + const _hasFilters = tag_hasActiveFilters; + const _resultLen = tag_localResults.length; + const _cacheReady = sourceCacheReady; untrack(() => { tag_autoSearchFired = false; }); - if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) { - if (tag_localResults.length < 20) { + if (!_loadingLocal && _hasFilters && !tag_autoSearchFired && !tag_searchSources && _cacheReady) { + if (_resultLen < 20) { untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; }); } } diff --git a/src/lib/components/extensions/ExtensionLibrary.svelte b/src/lib/components/extensions/ExtensionLibrary.svelte index c313888..f76ed51 100644 --- a/src/lib/components/extensions/ExtensionLibrary.svelte +++ b/src/lib/components/extensions/ExtensionLibrary.svelte @@ -27,17 +27,14 @@ const isLocal = pkgName === '__local__'; - // ── Library mode state ────────────────────────────────────────────── let groups: SourceLibrary[] = $state([]); let sourceNodes: SourceNode[] = $state([]); - // ── Local/browse mode state ────────────────────────────────────────── let localItems: any[] = $state([]); let localPage: number = $state(1); let localHasNext: boolean = $state(false); let localLoadingMore: boolean = $state(false); - // ── Shared state ───────────────────────────────────────────────────── let loading = $state(true); let search = $state(""); let searchInput = $state(""); @@ -49,8 +46,6 @@ const hasActiveFilters = $derived(Object.values(activeFilters).some(Boolean)); let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null); - - // ── Derived filtered lists ──────────────────────────────────────────── const allManga = $derived(isLocal ? localItems : groups.flatMap(g => g.manga)); const filtered = $derived((() => { diff --git a/src/lib/components/reader/PageView.svelte b/src/lib/components/reader/PageView.svelte index 2b28c57..aef81f2 100644 --- a/src/lib/components/reader/PageView.svelte +++ b/src/lib/components/reader/PageView.svelte @@ -1,10 +1,26 @@ + +
+ {#if pageGroups.length} +
+ {#each currentGroup as pg, i (pg)} + {#if srcs[i]} + Page {pg} + {:else} + + {/if} + {/each} +
+ {:else} +
+ +
+ {/if} +
+ +{#snippet skeleton()} + + + + + + + +{/snippet} + + diff --git a/src/lib/components/reader/viewer/LongstripViewer.svelte b/src/lib/components/reader/viewer/LongstripViewer.svelte new file mode 100644 index 0000000..7ba70f2 --- /dev/null +++ b/src/lib/components/reader/viewer/LongstripViewer.svelte @@ -0,0 +1,409 @@ + + +{#if midScrollActive} +
+
+ {#each [5,4,3,2,1] as n} +
= n}>
+ {/each} +
+ {#each [1,2,3,4,5] as n} +
0 && midScrollDisplayLevel >= n}>
+ {/each} +
+ +
+{/if} + +{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)} + {@const src = (_version, resolvedSrc.get(gi))} + {@const isLoaded = (_version, loadedSet.has(gi))} +
+ {#if isLoaded && src} + {page.chapterName} – Page {page.localIndex + 1} { + const img = e.currentTarget as HTMLImageElement; + const slot = img.closest(".strip-slot"); + if (slot && img.naturalWidth > 0) { + const aspect = img.naturalWidth / img.naturalHeight; + slot.style.setProperty("--aspect", String(aspect)); + aspectMap.set(gi, aspect); + } + }} + /> + {:else} + + {/if} +
+{/each} +
+ +{#snippet skeleton()} + + + + + + + +{/snippet} + + \ No newline at end of file diff --git a/src/lib/components/reader/viewer/SingleViewer.svelte b/src/lib/components/reader/viewer/SingleViewer.svelte new file mode 100644 index 0000000..079bac9 --- /dev/null +++ b/src/lib/components/reader/viewer/SingleViewer.svelte @@ -0,0 +1,71 @@ + + +
+ {#if src} + Page {readerState.pageNumber} + {:else} + + {/if} +
+ +{#snippet skeleton()} + + + + + + + +{/snippet} + + diff --git a/src/lib/components/recent/Recent.svelte b/src/lib/components/recent/Recent.svelte index d76259d..3643bab 100644 --- a/src/lib/components/recent/Recent.svelte +++ b/src/lib/components/recent/Recent.svelte @@ -4,9 +4,8 @@ import { cache, CACHE_KEYS, CACHE_GROUPS } from '$lib/core/cache/queryCache' import { homeState, clearHistory } from '$lib/state/home.svelte' import { historyState } from '$lib/state/history.svelte' - import { setActiveManga, openReader, setPreviewManga } from '$lib/state/series.svelte' + import { setActiveManga, openReaderForChapter, setPreviewManga } from '$lib/state/series.svelte' import { addToast } from '$lib/state/notifications.svelte' - import { buildChapterList } from '$lib/components/series/lib/chapterList' import { groupByDay } from './lib/recentHistory' import { fetchedAtMs, parseServerTimestamp, groupUpdatesByDay } from './lib/recentUpdates' import RecentToolbar from './RecentToolbar.svelte' @@ -168,13 +167,11 @@ const manga = mangaStub(item) try { const chapters = await getAdapter().getChapters(String(item.mangaId)) - const sorted = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder) - const list = buildChapterList(sorted, {}) - const target = list.find(ch => ch.id === item.id) - if (target) { setActiveManga(manga); openReader(target, list) } - else setActiveManga(manga) + const target = chapters.find(ch => ch.id === item.id) + if (target) openReaderForChapter(target, manga) + else setPreviewManga(manga) } catch { - setActiveManga(manga) + setPreviewManga(manga) addToast({ kind: 'error', title: "Couldn't open chapter", body: 'Opened the series instead.' }) } finally { openingId = null diff --git a/src/lib/components/series/ChapterList.svelte b/src/lib/components/series/ChapterList.svelte index 7a2f242..9ae6aef 100644 --- a/src/lib/components/series/ChapterList.svelte +++ b/src/lib/components/series/ChapterList.svelte @@ -65,15 +65,13 @@ {:else if viewMode === 'grid'} {#each sortedChapters as ch, i} - {@const inProgress = !ch.read && (ch.lastPageRead ?? 0) > 0} {@const isGridSelected = selectedIds.has(ch.id)} {/each}
- diff --git a/src/lib/components/series/SeriesDetail.svelte b/src/lib/components/series/SeriesDetail.svelte index d9b02bb..5449c5c 100644 --- a/src/lib/components/series/SeriesDetail.svelte +++ b/src/lib/components/series/SeriesDetail.svelte @@ -10,19 +10,17 @@ } from 'phosphor-svelte' import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte' import { getManga, getMangaList } from '$lib/request-manager/manga' - import { getChapters, fetchChapters, markChapterRead, markChaptersRead, deleteDownloadedChapters } from '$lib/request-manager/chapters' + import { markChapterRead, markChaptersRead, deleteDownloadedChapters, fetchChapters } from '$lib/request-manager/chapters' import { downloadStore } from '$lib/state/downloads.svelte' import { getCategories, updateMangaCategories, createCategory as createCategoryReq, updateManga } from '$lib/request-manager/manga' import { saveScroll, getScroll } from '$lib/state/app.svelte' - import { seriesState, openReader, addBookmark, - acknowledgeUpdate, clearMarkersForManga } from '$lib/state/series.svelte' - import { DEFAULT_MANGA_PREFS } from '$lib/types/settings' + import { seriesState, openReaderForChapter, acknowledgeUpdate, addBookmark, clearMarkersForManga } from '$lib/state/series.svelte' + import { DEFAULT_MANGA_PREFS } from '$lib/state/series.svelte' import type { MangaPrefs } from '$lib/types/settings' import { addToast } from '$lib/state/notifications.svelte' import { trackingState } from '$lib/state/tracking.svelte' import { autoLinkLibrary } from '$lib/core/cover/autoLink' - import { buildChapterList } from '$lib/components/series/lib/chapterList' - import { getPref, setPref } from '$lib/components/series/lib/mangaPrefs' + import { getPref, setPref } from '$lib/state/series.svelte' import { openMangaFolder } from '$lib/core/filesystem' import type { Manga, Chapter, Category } from '$lib/types' import AutomationPanel from '$lib/components/series/panels/AutomationPanel.svelte' @@ -37,15 +35,11 @@ const CHAPTERS_PER_PAGE = 25 const MANGA_TTL_MS = 5 * 60 * 1000 - const CHAPTER_TTL_MS = 2 * 60 * 1000 - const mangaCache: Map = new Map() - const chapterCache: Map = new Map() + const mangaCache: Map = new Map() let manga: Manga | null = $state(null) - let chapters: Chapter[] = $state([]) let loadingManga: boolean = $state(false) - let loadingChapters: boolean = $state(true) let enqueueing: Set = $state(new Set()) let togglingLibrary: boolean = $state(false) let chapterPage: number = $state(1) @@ -66,40 +60,26 @@ let catsLoading: boolean = $state(false) let chapterListEl: HTMLDivElement | null = $state(null) - let mangaAbort: AbortController | null = null - let chapterAbort: AbortController | null = null - let loadingFor: number | null = null - let prevChapterIds = new Set() - let prevMangaId: number | null = null + let mangaAbort: AbortController | null = null + let prevMangaId: number | null = null - const get = (key: K) => - mangaId ? getPref(mangaId, key) : DEFAULT_MANGA_PREFS[key] - const set = (key: K, value: MangaPrefs[K]) => { - if (mangaId) setPref(mangaId, key, value) - } + const get = (key: K) => getPref(mangaId, key) + const set = (key: K, value: MangaPrefs[K]) => setPref(mangaId, key, value) - const hasSelection = $derived(selectedIds.size > 0) - const sortDir = $derived(seriesState.settings.chapterSortDir) - const sortMode = $derived(seriesState.settings.chapterSortMode ?? 'source') - const scanlatorFilter = $derived((get('scanlatorFilter') ?? []) as string[]) - const scanlatorBlacklist = $derived((get('scanlatorBlacklist') ?? []) as string[]) - const scanlatorForce = $derived((get('scanlatorForce') ?? false) as boolean) - - const currentPrefs = $derived({ - sortMode, - sortDir, - preferredScanlator: get('preferredScanlator') as string, - scanlatorFilter: scanlatorFilter as string[], - scanlatorBlacklist: scanlatorBlacklist as string[], - scanlatorForce: scanlatorForce as boolean, - }) + const chapters = $derived(seriesState.chaptersFor(mangaId)) + const loadingChapters = $derived(seriesState.isLoadingChapters(mangaId)) + const sortedChapters = $derived(seriesState.activeChapterList) + const hasSelection = $derived(selectedIds.size > 0) const availableScanlators = $derived( [...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))] .sort((a, b) => a.localeCompare(b)) ) - const sortedChapters = $derived(buildChapterList(chapters, currentPrefs)) + const scanlatorFilter = $derived(get('scanlatorFilter') as string[]) + const scanlatorBlacklist = $derived(get('scanlatorBlacklist') as string[]) + const scanlatorForce = $derived(get('scanlatorForce') as boolean) + const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE)) const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE)) const readCount = $derived(sortedChapters.filter(c => c.read).length) @@ -111,13 +91,10 @@ if (!sortedChapters.length) return null const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder) const anyRead = asc.some(c => c.read) - const bookmark = mangaId - ? seriesState.bookmarks.find(b => b.mangaId === mangaId) - : null + const bookmark = seriesState.bookmarks.find(b => b.mangaId === mangaId) const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null - if (bookmarkedCh && !bookmarkedCh.read) { + if (bookmarkedCh && !bookmarkedCh.read) return { chapter: bookmarkedCh, type: (anyRead ? 'continue' : 'start') as const, resumePage: bookmark!.pageNumber } - } const inProgress = asc.find(c => !c.read && (c.lastPageRead ?? 0) > 0) const firstUnread = asc.find(c => !c.read) const target = inProgress ?? firstUnread @@ -146,17 +123,6 @@ selectedIds = next } - function applyChapters(nodes: Chapter[]) { - if (get('autoDownload') && prevChapterIds.size > 0) { - const filtered = buildChapterList(nodes, currentPrefs) - const newChapters = filtered.filter(c => !prevChapterIds.has(c.id) && !c.downloaded) - if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id)) - } - prevChapterIds = new Set(nodes.map(c => c.id)) - chapters = nodes - if (mangaId && nodes.length > 0) checkAndMarkCompleted(mangaId, nodes) - } - function loadCategories(id: number) { catsLoading = true getCategories() @@ -169,96 +135,59 @@ } async function checkAndMarkCompleted(id: number, chaps: Chapter[]) { - if (chaps.length && manga?.status !== 'ONGOING') { - const allRead = chaps.every(c => c.read) - const completed = allCategories.find(c => c.name === 'Completed') - if (completed) { - const inCompleted = mangaCategories.some(c => c.id === completed.id) - if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed] - else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id) - } - } + if (!chaps.length || manga?.status === 'ONGOING') return + const allRead = chaps.every(c => c.read) + const completed = allCategories.find(c => c.name === 'Completed') + if (!completed) return + const inCompleted = mangaCategories.some(c => c.id === completed.id) + if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed] + else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id) } function loadMangaData(id: number) { mangaAbort?.abort() const ctrl = new AbortController() - mangaAbort = ctrl; loadingFor = id + mangaAbort = ctrl const cached = mangaCache.get(id) if (cached) { - manga = cached.data; loadingManga = false + manga = cached.data + loadingManga = false + seriesState.setActiveManga(cached.data) if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return - getManga(id, ctrl.signal).then(m => { - if (ctrl.signal.aborted || loadingFor !== id) return - mangaCache.set(id, { data: m, fetchedAt: Date.now() }) - manga = m - }).catch(() => {}) + // stale-while-revalidate: update cache + store in background + getManga(id, ctrl.signal) + .then(m => { + if (ctrl.signal.aborted) return + mangaCache.set(id, { data: m, fetchedAt: Date.now() }) + manga = m + seriesState.setActiveManga(m) + }) + .catch(() => {}) return } loadingManga = true - getManga(id, ctrl.signal).then(m => { - if (ctrl.signal.aborted || loadingFor !== id) return - mangaCache.set(id, { data: m, fetchedAt: Date.now() }) - manga = m - }).catch(() => {}).finally(() => { if (!ctrl.signal.aborted && loadingFor === id) loadingManga = false }) - } - - function loadChaptersData(id: number) { - chapterAbort?.abort() - const ctrl = new AbortController() - chapterAbort = ctrl - const cached = chapterCache.get(id) - if (cached) { - applyChapters(cached.data); loadingChapters = false - if (Date.now() - cached.fetchedAt < CHAPTER_TTL_MS) return - fetchChapters(id, ctrl.signal) - .then(() => getChapters(id, ctrl.signal)) - .then(nodes => { - if (ctrl.signal.aborted || loadingFor !== id) return - chapterCache.set(id, { data: nodes, fetchedAt: Date.now() }) - applyChapters(nodes) - }).catch(() => {}) - return - } - chapters = []; loadingChapters = true - getChapters(id, ctrl.signal).then(nodes => { - if (ctrl.signal.aborted || loadingFor !== id) return - applyChapters(nodes); loadingChapters = false - return fetchChapters(id, ctrl.signal) - .then(() => getChapters(id, ctrl.signal)) - .then(fresh => { - if (ctrl.signal.aborted || loadingFor !== id) return - chapterCache.set(id, { data: fresh, fetchedAt: Date.now() }) - applyChapters(fresh) - }) - }).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false }) - } - - async function syncTrackersIntoChapters(id: number, chaps: Chapter[]) { - if (!seriesState.settings.trackerSyncBack) return - const records = trackingState.recordsFor(id) - if (!records.length) return - for (const record of records) { - try { - const { markedIds } = await trackingState.syncFromRemote(id, record, chaps, currentPrefs) - if (markedIds.length > 0) { - const idSet = new Set(markedIds) - chapters = chapters.map(c => idSet.has(c.id) ? { ...c, read: true } : c) - chapterCache.set(id, { data: chapters, fetchedAt: Date.now() }) - } - } catch {} - } + getManga(id, ctrl.signal) + .then(m => { + if (ctrl.signal.aborted) return + mangaCache.set(id, { data: m, fetchedAt: Date.now() }) + manga = m + seriesState.setActiveManga(m) + }) + .catch(() => {}) + .finally(() => { if (!ctrl.signal.aborted) loadingManga = false }) } $effect(() => { const id = mangaId const shouldAutoLink = seriesState.settings.autoLinkOnOpen - if (id) untrack(() => { + untrack(() => { acknowledgeUpdate(id) loadMangaData(id) - loadChaptersData(id) + seriesState.loadChapters(id).then(() => { + checkAndMarkCompleted(id, seriesState.chaptersFor(id)) + trackingState.loadForManga(id).then(() => syncTrackersIntoChapters(id)) + }) loadCategories(id) - trackingState.loadForManga(id).then(() => syncTrackersIntoChapters(id, chapters)) if (shouldAutoLink) { if (allMangaForLink.length) { autoLinkLibrary(manga, allMangaForLink) @@ -266,10 +195,7 @@ } else { loadingLinkList = true getMangaList() - .then(list => { - allMangaForLink = list - return autoLinkLibrary(manga, list) - }) + .then(list => { allMangaForLink = list; return autoLinkLibrary(manga, list) }) .then(n => { if (n > 0) addToast({ kind: 'success', title: 'Series linked', body: `${n} new link${n === 1 ? '' : 's'} found` }) }) .catch(console.error) .finally(() => { loadingLinkList = false }) @@ -278,13 +204,9 @@ }) }) - let prevChapterId: number | null = null $effect(() => { - const wasOpen = prevChapterId !== null - prevChapterId = seriesState.activeChapter?.id ?? null - if (wasOpen && !seriesState.activeChapter) { - untrack(() => { reloadChapters(mangaId) }) - } + const wasOpen = seriesState.activeChapter !== null + if (!wasOpen) untrack(() => seriesState.loadChapters(mangaId, { force: true })) }) $effect(() => { @@ -292,12 +214,33 @@ if (id === prevMangaId) return if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop) prevMangaId = id - if (chapterListEl && id !== null) { - chapterListEl.scrollTo({ top: getScroll(`series:${id}`) }) - } + if (chapterListEl) chapterListEl.scrollTo({ top: getScroll(`series:${id}`) }) }) - $effect(() => () => { mangaAbort?.abort(); chapterAbort?.abort() }) + $effect(() => () => { mangaAbort?.abort() }) + + async function syncTrackersIntoChapters(id: number) { + if (!seriesState.settings.trackerSyncBack) return + const records = trackingState.recordsFor(id) + if (!records.length) return + const prefs = { + sortMode: get('sortMode'), + sortDir: get('sortDir'), + preferredScanlator: get('preferredScanlator') as string, + scanlatorFilter: scanlatorFilter, + scanlatorBlacklist: scanlatorBlacklist, + scanlatorForce: scanlatorForce, + } + for (const record of records) { + try { + const { markedIds } = await trackingState.syncFromRemote(id, record, seriesState.chaptersFor(id), prefs) + if (markedIds.length > 0) { + const idSet = new Set(markedIds) + seriesState.patchChapters(id, chaps => chaps.map(c => idSet.has(c.id) ? { ...c, read: true } : c)) + } + } catch {} + } + } async function toggleLibrary() { if (!manga) return @@ -305,23 +248,18 @@ const next = !manga.inLibrary await updateManga(manga.id, { inLibrary: next }).catch(console.error) manga = { ...manga, inLibrary: next } - if (mangaCache.has(manga.id)) { const e = mangaCache.get(manga.id)!; mangaCache.set(manga.id, { ...e, data: manga }) } + seriesState.setActiveManga(manga) + if (mangaCache.has(manga.id)) mangaCache.set(manga.id, { data: manga, fetchedAt: mangaCache.get(manga.id)!.fetchedAt }) togglingLibrary = false } - async function reloadChapters(id: number) { - const nodes = await getChapters(id) - chapterCache.set(id, { data: nodes, fetchedAt: Date.now() }) - applyChapters(nodes) - } - async function enqueue(ch: Chapter, e: MouseEvent) { e.stopPropagation() enqueueing = new Set(enqueueing).add(ch.id) const allowed = await downloadStore.enqueue(ch.id) if (allowed) addToast({ kind: 'download', title: 'Download queued', body: ch.name }) enqueueing.delete(ch.id); enqueueing = new Set(enqueueing) - reloadChapters(mangaId) + seriesState.loadChapters(mangaId, { force: true }) } async function enqueueMultiple(chapterIds: number[]) { @@ -331,26 +269,28 @@ if (!allowed) return } addToast({ kind: 'download', title: 'Download queued', body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? 's' : ''} added` }) - reloadChapters(mangaId) + seriesState.loadChapters(mangaId, { force: true }) } async function markRead(chapterId: number, isRead: boolean) { await markChapterRead(chapterId, isRead).catch(console.error) - chapters = chapters.map(c => c.id === chapterId ? { ...c, read: isRead } : c) - chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() }) - checkAndMarkCompleted(mangaId, chapters) - const ch = chapters.find(c => c.id === chapterId) + seriesState.patchChapters(mangaId, chaps => chaps.map(c => c.id === chapterId ? { ...c, read: isRead } : c)) + checkAndMarkCompleted(mangaId, seriesState.chaptersFor(mangaId)) + const ch = seriesState.chaptersFor(mangaId).find(c => c.id === chapterId) + const currentPrefs = { + sortMode: get('sortMode'), sortDir: get('sortDir'), + preferredScanlator: get('preferredScanlator') as string, + scanlatorFilter, scanlatorBlacklist, scanlatorForce, + } if (ch) { - if (isRead) await trackingState.updateFromRead(mangaId, ch, chapters, currentPrefs) - else await trackingState.updateFromUnread(mangaId, chapters, currentPrefs) + if (isRead) await trackingState.updateFromRead(mangaId, ch, seriesState.chaptersFor(mangaId), currentPrefs) + else await trackingState.updateFromUnread(mangaId, seriesState.chaptersFor(mangaId), currentPrefs) } if (isRead) { - if (get('deleteOnRead')) { - if (ch?.downloaded) { - const delayMs = (get('deleteDelayHours') as number) * 60 * 60 * 1000 - if (delayMs === 0) deleteDownloaded(chapterId) - else setTimeout(() => deleteDownloaded(chapterId), delayMs) - } + if (get('deleteOnRead') && ch?.downloaded) { + const delayMs = (get('deleteDelayHours') as number) * 3_600_000 + const doDelete = () => deleteDownloaded(chapterId) + if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs) } const ahead = get('downloadAhead') as number if (ahead > 0) { @@ -367,24 +307,27 @@ if (!ids.length) return await markChaptersRead(ids, isRead).catch(console.error) const idSet = new Set(ids) - chapters = chapters.map(c => idSet.has(c.id) ? { ...c, read: isRead } : c) - chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() }) - checkAndMarkCompleted(mangaId, chapters) + seriesState.patchChapters(mangaId, chaps => chaps.map(c => idSet.has(c.id) ? { ...c, read: isRead } : c)) + checkAndMarkCompleted(mangaId, seriesState.chaptersFor(mangaId)) + const currentPrefs = { + sortMode: get('sortMode'), sortDir: get('sortDir'), + preferredScanlator: get('preferredScanlator') as string, + scanlatorFilter, scanlatorBlacklist, scanlatorForce, + } if (isRead) { - const ascending = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder) - const lastInBatch = ascending.filter(c => idSet.has(c.id)).at(-1) - if (lastInBatch) await trackingState.updateFromRead(mangaId, lastInBatch, chapters, currentPrefs) + const chaps = seriesState.chaptersFor(mangaId) + const lastRead = [...chaps].sort((a, b) => a.sourceOrder - b.sourceOrder).filter(c => idSet.has(c.id)).at(-1) + if (lastRead) await trackingState.updateFromRead(mangaId, lastRead, chaps, currentPrefs) } else { - await trackingState.updateFromUnread(mangaId, chapters, currentPrefs) + await trackingState.updateFromUnread(mangaId, seriesState.chaptersFor(mangaId), currentPrefs) } if (isRead && get('deleteOnRead')) { - const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.downloaded) + const toDelete = ids.filter(id => seriesState.chaptersFor(mangaId).find(c => c.id === id)?.downloaded) if (toDelete.length) { - const delayMs = (get('deleteDelayHours') as number) * 60 * 60 * 1000 + const delayMs = (get('deleteDelayHours') as number) * 3_600_000 const doDelete = async () => { await deleteDownloadedChapters(toDelete).catch(console.error) - chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, downloaded: false } : c) - chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() }) + seriesState.patchChapters(mangaId, chaps => chaps.map(c => toDelete.includes(c.id) ? { ...c, downloaded: false } : c)) } if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs) } @@ -392,17 +335,16 @@ } async function deleteSelected() { - const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.downloaded) + const ids = [...selectedIds].filter(id => seriesState.chaptersFor(mangaId).find(c => c.id === id)?.downloaded) if (ids.length) { await deleteDownloadedChapters(ids).catch(console.error) - chapters = chapters.map(c => ids.includes(c.id) ? { ...c, downloaded: false } : c) - chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() }) + seriesState.patchChapters(mangaId, chaps => chaps.map(c => ids.includes(c.id) ? { ...c, downloaded: false } : c)) } clearSelection() } async function downloadSelected() { - await enqueueMultiple([...selectedIds].filter(id => !chapters.find(c => c.id === id)?.downloaded)) + await enqueueMultiple([...selectedIds].filter(id => !seriesState.chaptersFor(mangaId).find(c => c.id === id)?.downloaded)) clearSelection() } @@ -418,29 +360,30 @@ async function deleteDownloaded(chapterId: number) { await deleteDownloadedChapters([chapterId]).catch(console.error) - chapters = chapters.map(c => c.id === chapterId ? { ...c, downloaded: false } : c) - chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() }) + seriesState.patchChapters(mangaId, chaps => chaps.map(c => c.id === chapterId ? { ...c, downloaded: false } : c)) } async function deleteAllDownloads() { - const ids = chapters.filter(c => c.downloaded).map(c => c.id) + const ids = seriesState.chaptersFor(mangaId).filter(c => c.downloaded).map(c => c.id) if (!ids.length) return deletingAll = true await deleteDownloadedChapters(ids).catch(console.error) - chapters = chapters.map(c => ({ ...c, downloaded: false })) - chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() }) + seriesState.patchChapters(mangaId, chaps => chaps.map(c => ({ ...c, downloaded: false }))) deletingAll = false } async function refreshChapters() { if (refreshing) return refreshing = true - chapterCache.delete(mangaId) + seriesState.invalidateChapters(mangaId) fetchChapters(mangaId) - .then(() => reloadChapters(mangaId)) - .then(() => addToast({ kind: 'success', title: 'Chapters refreshed', body: `${chapters.length} chapter${chapters.length !== 1 ? 's' : ''} available` })) + .then(() => seriesState.loadChapters(mangaId, { force: true })) + .then(() => { + const count = seriesState.chaptersFor(mangaId).length + addToast({ kind: 'success', title: 'Chapters refreshed', body: `${count} chapter${count !== 1 ? 's' : ''} available` }) + }) .catch(e => addToast({ kind: 'error', title: 'Refresh failed', body: e?.message })) - .finally(() => refreshing = false) + .finally(() => { refreshing = false }) } function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] { @@ -472,43 +415,24 @@ } function openReaderWithAhead(ch: Chapter, inProgress: boolean) { - const ascList = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder) - const resumePage = inProgress ? ch.lastPageRead ?? null : null - const ahead = get('downloadAhead') as number - if (ahead > 0) { - const idx = ascList.indexOf(ch) - if (idx >= 0) { - const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.downloaded).map(c => c.id) - if (toQueue.length) enqueueMultiple(toQueue) - } - } - if (inProgress && resumePage && resumePage > 1) { + if (inProgress && ch.lastPageRead && ch.lastPageRead > 1) { const existing = seriesState.bookmarks.find(b => b.chapterId === ch.id) - if (!existing || existing.pageNumber < resumePage) { + if (!existing || existing.pageNumber < ch.lastPageRead) { addBookmark({ mangaId, mangaTitle: manga!.title, thumbnailUrl: manga!.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, - pageNumber: resumePage, + pageNumber: ch.lastPageRead, }) } } - openReader(ch, ascList, manga) + openReaderForChapter(ch, manga) } function handleContinue(cc: typeof continueChapter) { if (!cc) return - const ascList = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder) - const ahead = get('downloadAhead') as number - if (ahead > 0) { - const idx = ascList.indexOf(cc.chapter) - if (idx >= 0) { - const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.downloaded).map(c => c.id) - if (toQueue.length) enqueueMultiple(toQueue) - } - } if (cc.type === 'continue' && cc.resumePage && cc.resumePage > 1) { const existing = seriesState.bookmarks.find(b => b.chapterId === cc.chapter.id) if (!existing || existing.pageNumber < cc.resumePage) { @@ -522,7 +446,7 @@ }) } } - openReader(cc.chapter, ascList, manga) + openReaderForChapter(cc.chapter, manga) } async function openLinkPicker() { @@ -551,7 +475,7 @@ await updateMangaCategories(mangaId, inCat ? [] : [cat.id], inCat ? [cat.id] : []) if (!inCat && !manga?.inLibrary) { await updateManga(mangaId, { inLibrary: true }).catch(console.error) - if (manga) manga = { ...manga, inLibrary: true } + if (manga) { manga = { ...manga, inLibrary: true }; seriesState.setActiveManga(manga) } } mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat] } catch (e) { console.error(e) } @@ -564,7 +488,7 @@ await updateMangaCategories(mangaId, [cat.id], []) if (!manga?.inLibrary) { await updateManga(mangaId, { inLibrary: true }).catch(console.error) - if (manga) manga = { ...manga, inLibrary: true } + if (manga) { manga = { ...manga, inLibrary: true }; seriesState.setActiveManga(manga) } } allCategories = [...allCategories, cat] mangaCategories = [...mangaCategories, cat] @@ -606,8 +530,8 @@ set('scanlatorFilter', v)} onSetScanlatorBlacklist={(v) => set('scanlatorBlacklist', v)} onSetScanlatorForce={(v) => set('scanlatorForce', v)} + onSortModeChange={(v) => set('sortMode', v)} + onSortDirChange={(v) => set('sortDir', v)} onOpenFolder={() => manga && openMangaFolder(manga)} /> @@ -666,7 +592,7 @@ {#if markersOpen && manga} {/if} @@ -702,7 +628,7 @@ {#if migrateOpen && manga} migrateOpen = false} onMigrated={(newManga) => { goto(`/series/${newManga.id}`); migrateOpen = false }} /> diff --git a/src/lib/components/series/lib/chapterList.ts b/src/lib/components/series/lib/chapterList.ts index ed8e9a0..740b96f 100644 --- a/src/lib/components/series/lib/chapterList.ts +++ b/src/lib/components/series/lib/chapterList.ts @@ -64,16 +64,4 @@ export function buildChapterList(chapters: Chapter[], prefs: ChapterDisplayPrefs export function chaptersAscending(chapters: Chapter[]): Chapter[] { return [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder) -} - -export function buildReaderChapterList( - chapters: Chapter[], - prefs: Pick | undefined, -): Chapter[] { - return buildChapterList(chapters, { - sortMode: 'source', - sortDir: 'asc', - preferredScanlator: prefs?.preferredScanlator, - scanlatorFilter: prefs?.scanlatorFilter, - }) } \ No newline at end of file diff --git a/src/lib/components/series/panels/MarkersPanel.svelte b/src/lib/components/series/panels/MarkersPanel.svelte index ae603f8..db59960 100644 --- a/src/lib/components/series/panels/MarkersPanel.svelte +++ b/src/lib/components/series/panels/MarkersPanel.svelte @@ -1,6 +1,6 @@ @@ -548,24 +530,7 @@ {/if} {#if continueChapter} - {/if} @@ -676,8 +641,6 @@ /> {/if} - -