Feat: Longstrip Viewer(s) & Lag Improvements

This commit is contained in:
Youwes09
2026-06-11 23:27:01 -05:00
parent 1e159bbd73
commit 437b52fd8b
26 changed files with 1298 additions and 1325 deletions
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { onMount, onDestroy, untrack } from "svelte";
import {
X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch,
Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, Image,
@@ -16,17 +16,13 @@
import { addToast } from "$lib/state/notifications.svelte";
import {
seriesState,
setPreviewManga, setActiveManga, openReader, addBookmark,
setPreviewManga, addBookmark, openReaderForChapter,
} from "$lib/state/series.svelte";
import { app } from "$lib/state/app.svelte";
import type { Manga, Chapter, Category } from "$lib/types";
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte'
import ModalBlur from '$lib/components/shared/ui/ModalBlur.svelte';
let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]);
let loadingDetail = $state(false);
let loadingChapters = $state(false);
let togglingLib = $state(false);
let descExpanded = $state(false);
let folderOpen = $state(false);
@@ -44,8 +40,6 @@
let loadingLinkList = $state(false);
let coverPickerOpen = $state(false);
let originNavPage = app.navPage;
const linkedIds = $derived(
seriesState.previewManga
? (settingsState.settings.mangaLinks?.[seriesState.previewManga.id] ?? [])
@@ -57,6 +51,9 @@
);
const displayManga = $derived(manga ?? seriesState.previewManga);
const mangaId = $derived(seriesState.previewManga?.id ?? null);
const chapters = $derived(mangaId != null ? seriesState.chaptersFor(mangaId) : []);
const loadingChapters = $derived(mangaId != null ? seriesState.isLoadingChapters(mangaId) : false);
const totalCount = $derived(chapters.length);
const readCount = $derived(chapters.filter((c) => c.read).length);
const unreadCount = $derived(totalCount - readCount);
@@ -113,16 +110,12 @@
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
});
let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
let detailAbort: AbortController | null = null;
function close() {
detailAbort?.abort();
chapterAbort?.abort();
setPreviewManga(null);
manga = null; chapters = []; descExpanded = false;
manga = null; descExpanded = false;
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
}
@@ -153,12 +146,15 @@
}
$effect(() => {
const shouldAutoLink = settingsState.settings.autoLinkOnOpen;
const focal = seriesState.previewManga;
if (focal) {
originNavPage = app.navPage;
load(focal.id);
if (!focal) return;
untrack(() => {
loadDetail(focal.id);
seriesState.loadChapters(focal.id);
loadCategories(focal.id);
const shouldAutoLink = settingsState.settings.autoLinkOnOpen;
if (shouldAutoLink) {
if (allMangaForLink.length) {
autoLinkLibrary(focal, allMangaForLink)
@@ -166,71 +162,48 @@
} else {
loadingLinkList = true;
getAdapter().getMangaList({})
.then((d) => {
allMangaForLink = d.items;
return autoLinkLibrary(focal, d.items);
})
.then((d) => { allMangaForLink = d.items; return autoLinkLibrary(focal, d.items); })
.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; });
}
}
}
});
});
async function load(id: number) {
detailAbort?.abort(); chapterAbort?.abort();
const dCtrl = new AbortController(), cCtrl = new AbortController();
detailAbort = dCtrl; chapterAbort = cCtrl;
async function loadDetail(id: number) {
detailAbort?.abort();
const ctrl = new AbortController();
detailAbort = ctrl;
manga = seriesState.previewManga as Manga;
chapters = []; descExpanded = false; fetchError = null;
loadingDetail = true; loadingChapters = true;
descExpanded = false; fetchError = null;
loadingDetail = true;
(async (): Promise<Manga> => {
const key = CACHE_KEYS.MANGA(id);
if (cache.has(key)) return cache.get(key, () => Promise.resolve(seriesState.previewManga as Manga)) as Promise<Manga>;
try {
return await getAdapter().fetchManga(String(id), dCtrl.signal);
} catch (e: any) {
if (e?.name === "AbortError") throw e;
const local = await getAdapter().getManga(String(id), dCtrl.signal);
if (local) return local;
throw new Error("Could not load manga details");
const key = CACHE_KEYS.MANGA(id);
try {
let fullManga: Manga;
if (cache.has(key)) {
fullManga = await (cache.get(key, () => Promise.resolve(seriesState.previewManga as Manga)) as Promise<Manga>);
} else {
try {
fullManga = await getAdapter().fetchManga(String(id), ctrl.signal);
} catch (e: any) {
if (e?.name === "AbortError") return;
const local = await getAdapter().getManga(String(id), ctrl.signal);
if (local) fullManga = local;
else throw new Error("Could not load manga details");
}
if (!cache.has(key)) cache.get(key, () => Promise.resolve(fullManga));
}
})()
.then((fullManga) => {
if (dCtrl.signal.aborted) return;
if (!cache.has(CACHE_KEYS.MANGA(id)))
cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
manga = fullManga; loadingDetail = false;
})
.catch((e) => {
if (e?.name === "AbortError") return;
manga = seriesState.previewManga as Manga;
fetchError = "Could not load full details — showing cached data";
loadingDetail = false;
});
getAdapter().getChapters(String(id), cCtrl.signal)
.then(async (nodes) => {
if (cCtrl.signal.aborted) return;
let sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
if (sorted.length === 0) {
try {
const fetched = await getAdapter().fetchChapters(String(id), cCtrl.signal);
if (!cCtrl.signal.aborted)
sorted = [...fetched].sort((a, b) => a.sourceOrder - b.sourceOrder);
} catch (e: any) {
if (e?.name === "AbortError") return;
}
}
if (!cCtrl.signal.aborted) {
chapters = sorted;
if (sorted.length > 0) checkAndMarkCompleted(id, sorted);
}
})
.catch(() => {})
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
if (ctrl.signal.aborted) return;
manga = fullManga;
} catch (e: any) {
if (e?.name === "AbortError") return;
manga = seriesState.previewManga as Manga;
fetchError = "Could not load full details — showing cached data";
} finally {
if (!ctrl.signal.aborted) loadingDetail = false;
}
}
async function toggleLibrary() {
@@ -258,58 +231,68 @@
function openSeriesDetail() {
if (!displayManga) return;
setActiveManga(displayManga);
app.setNavPage(originNavPage);
goto(`/series/${displayManga.id}`);
close();
}
function loadCategories(mangaId: number) {
function handleRead() {
if (!continueChapter || !displayManga) return;
const { ch, type, resumePage } = continueChapter;
if (type === "continue" && resumePage && resumePage > 1) {
const existing = seriesState.bookmarks.find((b) => b.chapterId === ch.id);
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: displayManga.id,
mangaTitle: displayManga.title,
thumbnailUrl: displayManga.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
});
}
}
openReaderForChapter(ch, displayManga);
close();
}
function loadCategories(id: number) {
catsLoading = true;
getAdapter().getCategories()
.then((cats) => {
allCategories = cats.filter((c) => c.id !== 0);
mangaCategories = allCategories.filter((c) => c.mangas?.nodes?.some((m) => m.id === mangaId));
mangaCategories = allCategories.filter((c) => c.mangas?.nodes?.some((m) => m.id === id));
})
.catch(console.error)
.finally(() => { catsLoading = false; });
}
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
const mangaStatus = (manga ?? displayManga)?.status;
const isOngoing = mangaStatus === "ONGOING";
async function checkAndMarkCompleted(id: number, chaps: Chapter[]) {
const isOngoing = (manga ?? displayManga)?.status === "ONGOING";
if (!chaps.length || isOngoing) 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) {
await getAdapter().updateMangaCategories(String(mangaId), [completed.id], []).catch(console.error);
await getAdapter().updateMangaCategories(String(id), [completed.id], []).catch(console.error);
mangaCategories = [...mangaCategories, completed];
} else if (!allRead && inCompleted) {
await getAdapter().updateMangaCategories(String(mangaId), [], [completed.id]).catch(console.error);
await getAdapter().updateMangaCategories(String(id), [], [completed.id]).catch(console.error);
mangaCategories = mangaCategories.filter((c) => c.id !== completed.id);
}
}
async function toggleCategory(cat: Category) {
if (!seriesState.previewManga) return;
const mangaId = seriesState.previewManga.id;
const inCat = mangaCategories.some((c) => c.id === cat.id);
await getAdapter().updateMangaCategories(
String(mangaId),
inCat ? [] : [cat.id],
inCat ? [cat.id] : [],
).catch(console.error);
const id = seriesState.previewManga.id;
const inCat = mangaCategories.some((c) => c.id === cat.id);
await getAdapter().updateMangaCategories(String(id), inCat ? [] : [cat.id], inCat ? [cat.id] : []).catch(console.error);
if (!inCat && !inLibrary) {
await getAdapter().addToLibrary(String(mangaId)).catch(console.error);
await getAdapter().addToLibrary(String(id)).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
cache.clear(CACHE_KEYS.LIBRARY);
}
mangaCategories = inCat
? mangaCategories.filter((c) => c.id !== cat.id)
: [...mangaCategories, cat];
mangaCategories = inCat ? mangaCategories.filter((c) => c.id !== cat.id) : [...mangaCategories, cat];
}
async function handleFolderCreate() {
@@ -349,7 +332,6 @@
onDestroy(() => {
window.removeEventListener("keydown", onKey);
detailAbort?.abort();
chapterAbort?.abort();
});
</script>
@@ -548,24 +530,7 @@
</div>
{/if}
{#if continueChapter}
<button class="read-btn" onclick={() => {
const { ch, type, resumePage } = continueChapter!;
if (type === "continue" && resumePage && resumePage > 1) {
const existing = seriesState.bookmarks.find((b) => b.chapterId === ch.id);
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: displayManga!.id,
mangaTitle: displayManga!.title,
thumbnailUrl: displayManga!.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
});
}
}
openReader(ch, chapters, displayManga);
close();
}}>
<button class="read-btn" onclick={handleRead}>
<Play size={12} weight="fill" />{continueLabel}
</button>
{/if}
@@ -676,8 +641,6 @@
/>
{/if}
<style>
.backdrop {
position: fixed; inset: 0;