mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
[V1] Flatpak Release + Buffering Fix & Storage Management
This commit is contained in:
+175
-40
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
|
||||
import React, { useEffect, useLayoutEffect, useRef, useCallback, useState, useMemo } from "react";
|
||||
import {
|
||||
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
|
||||
Square, Rows, Download, ArrowsLeftRight,
|
||||
@@ -13,8 +13,53 @@ import { useStore, type FitMode } from "../../store";
|
||||
import { matchesKeybind, toggleFullscreen } from "../../lib/keybinds";
|
||||
import s from "./Reader.module.css";
|
||||
|
||||
// ── LRU image cache ───────────────────────────────────────────────────────────
|
||||
// Keeps browser memory in check by revoking object-URLs for chapters that
|
||||
// have scrolled far away. We cache by chapterId (not URL) so that we can
|
||||
// drop a whole chapter at once.
|
||||
const MAX_CACHED_CHAPTERS = 6;
|
||||
|
||||
// Track insertion order so we can evict the oldest chapter.
|
||||
const chapterCacheOrder: number[] = [];
|
||||
|
||||
function touchChapterOrder(chapterId: number) {
|
||||
const idx = chapterCacheOrder.indexOf(chapterId);
|
||||
if (idx !== -1) chapterCacheOrder.splice(idx, 1);
|
||||
chapterCacheOrder.push(chapterId);
|
||||
}
|
||||
|
||||
function evictOldestChapter(
|
||||
pageCache: React.MutableRefObject<Map<number, string[]>>,
|
||||
keepIds: Set<number>,
|
||||
): number | null {
|
||||
for (let i = 0; i < chapterCacheOrder.length; i++) {
|
||||
const id = chapterCacheOrder[i];
|
||||
if (!keepIds.has(id)) {
|
||||
chapterCacheOrder.splice(i, 1);
|
||||
pageCache.current.delete(id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Fire-and-forget: create an Image and let the browser cache it. */
|
||||
function preloadImage(url: string) {
|
||||
const img = new Image(); img.src = url;
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a single image fully before resolving.
|
||||
* Used to avoid showing a half-painted page.
|
||||
*/
|
||||
function decodeImage(url: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => { img.decode ? img.decode().then(resolve, resolve) : resolve(); };
|
||||
img.onerror = () => resolve(); // don't block on error
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function measureAspect(url: string): Promise<number> {
|
||||
@@ -146,9 +191,15 @@ export default function Reader() {
|
||||
const uiRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track which chapters are being fetched so we don't double-fire
|
||||
const fetchingRef = useRef<Set<number>>(new Set());
|
||||
const fetchingRef = useRef<Set<number>>(new Set());
|
||||
// Whether we've already appended the next chapter into the strip
|
||||
const appendedRef = useRef<Set<number>>(new Set());
|
||||
const appendedRef = useRef<Set<number>>(new Set());
|
||||
// The chapter id whose pages are currently being loaded (prevents stale sets)
|
||||
const loadingChapterRef = useRef<number | null>(null);
|
||||
// Mirror of stripChapters in a ref so the scroll handler never closes over stale state
|
||||
const stripChaptersRef = useRef<StripChapter[]>([]);
|
||||
// Scroll anchor: captured just before a head-trim so useLayoutEffect can restore position
|
||||
const scrollAnchorRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -157,6 +208,9 @@ export default function Reader() {
|
||||
const [uiVisible, setUiVisible] = useState(true);
|
||||
const [markedRead, setMarkedRead] = useState<Set<number>>(new Set());
|
||||
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
||||
// True only after the first page of the new chapter has been decoded,
|
||||
// preventing any flash of the previous chapter's image.
|
||||
const [pageReady, setPageReady] = useState(false);
|
||||
|
||||
/**
|
||||
* The infinite strip: an ordered list of chapter chunks.
|
||||
@@ -170,6 +224,24 @@ export default function Reader() {
|
||||
*/
|
||||
const [visibleChapterId, setVisibleChapterId] = useState<number | null>(null);
|
||||
|
||||
// Keep the ref mirror in sync so the scroll handler always sees current strip state
|
||||
useEffect(() => { stripChaptersRef.current = stripChapters; }, [stripChapters]);
|
||||
|
||||
// Restore scroll position synchronously after a head-trim, before the browser paints
|
||||
useLayoutEffect(() => {
|
||||
const anchor = scrollAnchorRef.current;
|
||||
if (!anchor || !containerRef.current) return;
|
||||
scrollAnchorRef.current = null;
|
||||
const gained = containerRef.current.scrollHeight - anchor.scrollHeight;
|
||||
// gained is negative when we removed nodes (scrollHeight shrank)
|
||||
// We want scrollTop to decrease by the same amount so the visible content stays put.
|
||||
// But since we removed nodes from the top, scrollHeight already shrank —
|
||||
// we just need to subtract the removed pixel height from scrollTop.
|
||||
if (gained < 0) {
|
||||
containerRef.current.scrollTop = Math.max(0, anchor.scrollTop + gained);
|
||||
}
|
||||
}, [stripChapters]);
|
||||
|
||||
const {
|
||||
activeManga, activeChapter, activeChapterList,
|
||||
pageUrls, pageNumber, settings,
|
||||
@@ -212,7 +284,10 @@ export default function Reader() {
|
||||
// ── Fetch helpers ────────────────────────────────────────────────────────────
|
||||
const fetchPages = useCallback(async (chapterId: number): Promise<string[]> => {
|
||||
const cached = pageCache.current.get(chapterId);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
touchChapterOrder(chapterId);
|
||||
return cached;
|
||||
}
|
||||
if (fetchingRef.current.has(chapterId)) {
|
||||
// Poll until another in-flight fetch resolves
|
||||
return new Promise((resolve) => {
|
||||
@@ -228,6 +303,12 @@ export default function Reader() {
|
||||
);
|
||||
const urls = d.fetchChapterPages.pages.map(thumbUrl);
|
||||
pageCache.current.set(chapterId, urls);
|
||||
touchChapterOrder(chapterId);
|
||||
// Evict oldest chapters if we're over the limit, but always keep the
|
||||
// immediately adjacent chapters so navigation is instant.
|
||||
while (pageCache.current.size > MAX_CACHED_CHAPTERS) {
|
||||
evictOldestChapter(pageCache, new Set([chapterId]));
|
||||
}
|
||||
fetchingRef.current.delete(chapterId);
|
||||
return urls;
|
||||
}, []);
|
||||
@@ -235,13 +316,25 @@ export default function Reader() {
|
||||
// ── Load pages ──────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!activeChapter) return;
|
||||
setLoading(true); setError(null); setPageGroups([]);
|
||||
setLoading(true); setError(null); setPageGroups([]); setPageReady(false);
|
||||
// Reset strip state for new chapter navigation (non-scroll transitions)
|
||||
appendedRef.current = new Set();
|
||||
|
||||
fetchPages(activeChapter.id)
|
||||
.then((urls) => {
|
||||
const targetId = activeChapter.id;
|
||||
loadingChapterRef.current = targetId;
|
||||
|
||||
fetchPages(targetId)
|
||||
.then(async (urls) => {
|
||||
// Discard result if the user has already navigated to a different chapter
|
||||
if (loadingChapterRef.current !== targetId) return;
|
||||
|
||||
// Decode the first page before committing so no previous chapter flashes
|
||||
await decodeImage(urls[0]);
|
||||
|
||||
if (loadingChapterRef.current !== targetId) return;
|
||||
|
||||
setPageUrls(urls);
|
||||
setPageReady(true);
|
||||
if (style === "longstrip" && autoNext) {
|
||||
setStripChapters([{
|
||||
chapterId: activeChapter.id,
|
||||
@@ -256,7 +349,9 @@ export default function Reader() {
|
||||
}
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
.finally(() => {
|
||||
if (loadingChapterRef.current === targetId) setLoading(false);
|
||||
});
|
||||
}, [activeChapter?.id]);
|
||||
|
||||
// ── Double-page grouping ─────────────────────────────────────────────────────
|
||||
@@ -303,11 +398,16 @@ export default function Reader() {
|
||||
}, [pageUrls, style, settings.offsetDoubleSpreads, rtl]);
|
||||
|
||||
// ── Preload ─────────────────────────────────────────────────────────────────
|
||||
// Eagerly decode pages ahead; fire-and-forget preload for pages behind.
|
||||
useEffect(() => {
|
||||
for (let i = 1; i <= (settings.preloadPages ?? 3); i++) {
|
||||
const ahead = settings.preloadPages ?? 3;
|
||||
for (let i = 1; i <= ahead; i++) {
|
||||
const url = pageUrls[pageNumber - 1 + i];
|
||||
if (url) preloadImage(url);
|
||||
if (url) decodeImage(url); // uses browser cache — no duplicate network request
|
||||
}
|
||||
// Also keep one page behind warm
|
||||
const behindUrl = pageUrls[pageNumber - 2];
|
||||
if (behindUrl) preloadImage(behindUrl);
|
||||
}, [pageNumber, pageUrls, settings.preloadPages]);
|
||||
|
||||
// ── Adjacent chapters ────────────────────────────────────────────────────────
|
||||
@@ -323,6 +423,11 @@ export default function Reader() {
|
||||
}, [activeChapter, activeChapterList]);
|
||||
|
||||
useEffect(() => {
|
||||
const pinned = new Set<number>();
|
||||
if (activeChapter) pinned.add(activeChapter.id);
|
||||
if (adjacent.next) pinned.add(adjacent.next.id);
|
||||
if (adjacent.prev) pinned.add(adjacent.prev.id);
|
||||
|
||||
const preload = (id: number) => {
|
||||
fetchPages(id)
|
||||
.then((urls) => urls.slice(0, 3).forEach(preloadImage))
|
||||
@@ -330,6 +435,13 @@ export default function Reader() {
|
||||
};
|
||||
if (adjacent.next) preload(adjacent.next.id);
|
||||
if (adjacent.prev) preload(adjacent.prev.id);
|
||||
|
||||
// After preloads are kicked off, evict anything beyond MAX_CACHED_CHAPTERS
|
||||
// that isn't pinned as adjacent or current.
|
||||
while (pageCache.current.size > MAX_CACHED_CHAPTERS) {
|
||||
const evicted = evictOldestChapter(pageCache, pinned);
|
||||
if (evicted === null) break; // nothing left to evict
|
||||
}
|
||||
}, [adjacent.next?.id, adjacent.prev?.id]);
|
||||
|
||||
const lastPage = pageUrls.length;
|
||||
@@ -394,20 +506,33 @@ export default function Reader() {
|
||||
const goForward = useCallback(() => {
|
||||
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
|
||||
if (pageNumber < lastPage) {
|
||||
setPageNumber(pageNumber + 1);
|
||||
const nextUrl = pageUrls[pageNumber]; // pageNumber is 1-based, so index is pageNumber
|
||||
if (nextUrl) {
|
||||
decodeImage(nextUrl).then(() => setPageNumber(pageNumber + 1));
|
||||
} else {
|
||||
setPageNumber(pageNumber + 1);
|
||||
}
|
||||
} else if (adjacent.next) {
|
||||
setPageNumber(1);
|
||||
openReader(adjacent.next, activeChapterList);
|
||||
} else {
|
||||
closeReader();
|
||||
}
|
||||
}, [pageNumber, lastPage, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||
}, [pageNumber, lastPage, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
|
||||
if (pageNumber > 1) setPageNumber(pageNumber - 1);
|
||||
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
|
||||
}, [pageNumber, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||
if (pageNumber > 1) {
|
||||
const prevUrl = pageUrls[pageNumber - 2]; // 0-based index of previous page
|
||||
if (prevUrl) {
|
||||
decodeImage(prevUrl).then(() => setPageNumber(pageNumber - 1));
|
||||
} else {
|
||||
setPageNumber(pageNumber - 1);
|
||||
}
|
||||
} else if (adjacent.prev) {
|
||||
openReader(adjacent.prev, activeChapterList);
|
||||
}
|
||||
}, [pageNumber, pageUrls, adjacent, activeChapterList, style, pageGroups, advanceGroup]);
|
||||
|
||||
const goNext = rtl ? goBack : goForward;
|
||||
const goPrev = rtl ? goForward : goBack;
|
||||
@@ -494,24 +619,27 @@ export default function Reader() {
|
||||
|
||||
// ── Infinite append ──────────────────────────────────────────────────
|
||||
if (!autoNext) {
|
||||
// Classic behavior: jump to next chapter at the very end of scroll
|
||||
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
|
||||
if (atBottom && adjacent.next) openReader(adjacent.next, activeChapterList);
|
||||
return;
|
||||
}
|
||||
|
||||
const strip = stripChaptersRef.current;
|
||||
|
||||
// Silently update visibleChapterId as we scroll into each chunk
|
||||
for (const chunk of stripChapters) {
|
||||
for (const chunk of strip) {
|
||||
const chunkEnd = chunk.startGlobalIdx + chunk.urls.length;
|
||||
if (n - 1 >= chunk.startGlobalIdx && n - 1 < chunkEnd) {
|
||||
if (chunk.chapterId !== visibleChapterId) {
|
||||
setVisibleChapterId(chunk.chapterId);
|
||||
// Mark as read when we scroll into a new chapter
|
||||
if (!markedRead.has(chunk.chapterId) && settings.autoMarkRead) {
|
||||
const prevChunk = stripChapters[stripChapters.indexOf(chunk) - 1];
|
||||
if (settings.autoMarkRead) {
|
||||
const prevChunk = strip[strip.indexOf(chunk) - 1];
|
||||
if (prevChunk) {
|
||||
setMarkedRead((r) => new Set(r).add(prevChunk.chapterId));
|
||||
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
|
||||
setMarkedRead((r) => {
|
||||
if (r.has(prevChunk.chapterId)) return r;
|
||||
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
|
||||
return new Set(r).add(prevChunk.chapterId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,12 +647,11 @@ export default function Reader() {
|
||||
}
|
||||
}
|
||||
|
||||
// Append next chapter 300px before we hit the bottom of the last chunk
|
||||
// Append next chapter when within 300px of the bottom
|
||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 300;
|
||||
if (!nearBottom) return;
|
||||
|
||||
// What's the last chapter currently in the strip?
|
||||
const lastChunk = stripChapters[stripChapters.length - 1];
|
||||
const lastChunk = strip[strip.length - 1];
|
||||
if (!lastChunk) return;
|
||||
|
||||
const lastChunkIdx = activeChapterList.findIndex((c) => c.id === lastChunk.chapterId);
|
||||
@@ -533,20 +660,26 @@ export default function Reader() {
|
||||
const nextChEntry = activeChapterList[lastChunkIdx + 1];
|
||||
if (!nextChEntry || appendedRef.current.has(nextChEntry.id)) return;
|
||||
|
||||
// Mark immediately so concurrent scroll events don't double-append
|
||||
appendedRef.current.add(nextChEntry.id);
|
||||
|
||||
// Fetch (likely already cached from preload) then append to strip
|
||||
fetchPages(nextChEntry.id).then((urls) => {
|
||||
setStripChapters((prev) => {
|
||||
const lastInPrev = prev[prev.length - 1];
|
||||
const newStart = lastInPrev
|
||||
? lastInPrev.startGlobalIdx + lastInPrev.urls.length
|
||||
: 0;
|
||||
return [
|
||||
const newStart = lastInPrev ? lastInPrev.startGlobalIdx + lastInPrev.urls.length : 0;
|
||||
const next = [
|
||||
...prev,
|
||||
{ chapterId: nextChEntry.id, chapterName: nextChEntry.name, urls, startGlobalIdx: newStart },
|
||||
];
|
||||
|
||||
const MAX_STRIP_CHAPTERS = 3;
|
||||
if (next.length > MAX_STRIP_CHAPTERS) {
|
||||
const toRemove = next.length - MAX_STRIP_CHAPTERS;
|
||||
// Snapshot scroll position now, inside the state updater, before React
|
||||
// removes the nodes. useLayoutEffect will restore it after the DOM mutation.
|
||||
scrollAnchorRef.current = { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight };
|
||||
return next.slice(toRemove);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}).catch(console.error);
|
||||
});
|
||||
@@ -557,7 +690,7 @@ export default function Reader() {
|
||||
el.removeEventListener("scroll", onScroll);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [style, autoNext, stripChapters, activeChapterList, activeChapter?.id, adjacent.next, fetchPages]);
|
||||
}, [style, autoNext, activeChapterList, activeChapter?.id, adjacent.next, fetchPages, visibleChapterId]);
|
||||
|
||||
// Reset scroll position when switching chapters in non-longstrip modes
|
||||
useEffect(() => {
|
||||
@@ -781,13 +914,15 @@ export default function Reader() {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
key={pageNumber}
|
||||
src={pageUrls[pageNumber - 1]}
|
||||
alt={`Page ${pageNumber}`}
|
||||
className={imgCls}
|
||||
decoding="async"
|
||||
/>
|
||||
pageReady && (
|
||||
<img
|
||||
key={pageNumber}
|
||||
src={pageUrls[pageNumber - 1]}
|
||||
alt={`Page ${pageNumber}`}
|
||||
className={imgCls}
|
||||
decoding="async"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -279,6 +279,84 @@
|
||||
.kbReset:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-dim); }
|
||||
.kbReset:disabled { opacity: 0.2; cursor: default; }
|
||||
|
||||
/* ─── Storage ── */
|
||||
.storageLoading {
|
||||
font-size: var(--text-sm); color: var(--text-faint);
|
||||
padding: var(--sp-3) var(--sp-3);
|
||||
}
|
||||
|
||||
.storageBarWrap { padding: var(--sp-2) var(--sp-3) var(--sp-1); }
|
||||
|
||||
.storageBar {
|
||||
width: 100%; height: 7px;
|
||||
background: var(--bg-overlay); border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.storageBarFill {
|
||||
height: 100%; border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.storageBarWarn { background: #d97706; }
|
||||
.storageBarCritical { background: var(--color-error); }
|
||||
|
||||
.storageBarLabels {
|
||||
display: flex; justify-content: space-between;
|
||||
margin-top: var(--sp-2);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.storageBarUsed { color: var(--text-secondary); }
|
||||
.storageBarFree { color: var(--text-faint); }
|
||||
|
||||
.storageBarNote {
|
||||
font-size: var(--text-xs); color: var(--text-faint);
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
|
||||
.storageLegend {
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
}
|
||||
|
||||
.storageLegendRow {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 6px 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.storageDot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.storageDotManga { background: var(--accent); }
|
||||
.storageDotApp { background: var(--border-strong); }
|
||||
.storageDotFree { background: var(--bg-overlay); border: 1px solid var(--border-strong); }
|
||||
|
||||
.storageLegendLabel { flex: 1; color: var(--text-muted); }
|
||||
.storageLegendVal { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.storageLimitHint {
|
||||
font-size: var(--text-xs); color: #d97706;
|
||||
padding: 0 var(--sp-3) var(--sp-2);
|
||||
}
|
||||
|
||||
.setLimitBtn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 12px; border-radius: var(--radius-md);
|
||||
background: none; border: 1px solid var(--border-strong);
|
||||
color: var(--text-muted); cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.setLimitBtn:hover { color: var(--text-primary); border-color: var(--border-focus); }
|
||||
|
||||
.storagePathNote {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
padding: var(--sp-1) var(--sp-3) var(--sp-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ─── About ── */
|
||||
.aboutBlock {
|
||||
padding: var(--sp-3); background: var(--bg-raised); border-radius: var(--radius-md);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear } from "@phosphor-icons/react";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives } from "@phosphor-icons/react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { gql } from "../../lib/client";
|
||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind, type Keybinds } from "../../lib/keybinds";
|
||||
import type { Settings, FitMode } from "../../store";
|
||||
import s from "./Settings.module.css";
|
||||
|
||||
type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "about";
|
||||
type Tab = "general" | "reader" | "library" | "performance" | "keybinds" | "storage" | "about";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "general", label: "General", icon: <Gear size={14} weight="light" /> },
|
||||
@@ -13,6 +16,7 @@ const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "library", label: "Library", icon: <Image size={14} weight="light" /> },
|
||||
{ id: "performance", label: "Performance", icon: <Sliders size={14} weight="light" /> },
|
||||
{ id: "keybinds", label: "Keybinds", icon: <Keyboard size={14} weight="light" /> },
|
||||
{ id: "storage", label: "Storage", icon: <HardDrives size={14} weight="light" /> },
|
||||
{ id: "about", label: "About", icon: <Info size={14} weight="light" /> },
|
||||
];
|
||||
|
||||
@@ -397,6 +401,172 @@ function KeybindsTab({ settings, update, reset }: {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Storage helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
interface StorageInfo {
|
||||
manga_bytes: number;
|
||||
total_bytes: number;
|
||||
free_bytes: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function StorageBar({ used, limit, total }: { used: number; limit: number | null; total: number }) {
|
||||
const cap = limit ?? total;
|
||||
const pctUsed = cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
|
||||
const critical = pctUsed > 90;
|
||||
const warning = pctUsed > 75;
|
||||
|
||||
return (
|
||||
<div className={s.storageBarWrap}>
|
||||
<div className={s.storageBar}>
|
||||
<div
|
||||
className={[s.storageBarFill, critical ? s.storageBarCritical : warning ? s.storageBarWarn : ""].join(" ")}
|
||||
style={{ width: `${pctUsed}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.storageBarLabels}>
|
||||
<span className={s.storageBarUsed}>{fmtBytes(used)} used</span>
|
||||
<span className={s.storageBarFree}>{fmtBytes(Math.max(0, cap - used))} free</span>
|
||||
</div>
|
||||
{limit !== null && total > 0 && (
|
||||
<p className={s.storageBarNote}>Limit {fmtBytes(limit)} of {fmtBytes(total)} total</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageTab({ settings, update }: { settings: Settings; update: (p: Partial<Settings>) => void }) {
|
||||
const [info, setInfo] = useState<StorageInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [cleared, setCleared] = useState(false);
|
||||
|
||||
const limitGb = settings.storageLimitGb ?? null;
|
||||
const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null;
|
||||
|
||||
async function fetchInfo() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const pathData = await gql<{ settings: { downloadsPath: string } }>(GET_DOWNLOADS_PATH);
|
||||
const result = await invoke<StorageInfo>("get_storage_info", {
|
||||
downloadsPath: pathData.settings.downloadsPath,
|
||||
});
|
||||
setInfo(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchInfo(); }, []);
|
||||
|
||||
function handleClearCache() {
|
||||
setClearing(true);
|
||||
caches.keys()
|
||||
.then((names) => Promise.all(names.map((n) => caches.delete(n))))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setClearing(false);
|
||||
setCleared(true);
|
||||
setTimeout(() => setCleared(false), 2500);
|
||||
fetchInfo();
|
||||
});
|
||||
}
|
||||
|
||||
const mangaBytes = info?.manga_bytes ?? 0;
|
||||
const totalBytes = info?.total_bytes ?? 0;
|
||||
const freeBytes = info?.free_bytes ?? 0;
|
||||
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Disk Usage</p>
|
||||
{loading && <p className={s.storageLoading}>Reading filesystem…</p>}
|
||||
{error && <p className={s.storageLoading} style={{ color: "var(--color-error)" }}>{error}</p>}
|
||||
{!loading && !error && info && (
|
||||
<>
|
||||
<StorageBar used={mangaBytes} limit={limitBytes} total={totalBytes} />
|
||||
<div className={s.storageLegend}>
|
||||
<div className={s.storageLegendRow}>
|
||||
<span className={[s.storageDot, s.storageDotManga].join(" ")} />
|
||||
<span className={s.storageLegendLabel}>Downloaded manga</span>
|
||||
<span className={s.storageLegendVal}>{fmtBytes(mangaBytes)}</span>
|
||||
</div>
|
||||
<div className={s.storageLegendRow}>
|
||||
<span className={[s.storageDot, s.storageDotFree].join(" ")} />
|
||||
<span className={s.storageLegendLabel}>Drive free</span>
|
||||
<span className={s.storageLegendVal}>{fmtBytes(freeBytes)}</span>
|
||||
</div>
|
||||
<div className={s.storageLegendRow}>
|
||||
<span className={[s.storageDot, s.storageDotApp].join(" ")} />
|
||||
<span className={s.storageLegendLabel}>Drive total</span>
|
||||
<span className={s.storageLegendVal}>{fmtBytes(totalBytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className={s.storagePathNote}>{info.path}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Storage Limit</p>
|
||||
<div className={s.stepRow}>
|
||||
<div className={s.toggleInfo}>
|
||||
<span className={s.toggleLabel}>Limit download storage</span>
|
||||
<span className={s.toggleDesc}>
|
||||
{limitGb === null
|
||||
? "No limit — uses full drive capacity"
|
||||
: `Warn when downloads exceed ${limitGb} GB`}
|
||||
</span>
|
||||
</div>
|
||||
{limitGb === null ? (
|
||||
<button className={s.setLimitBtn} onClick={() => update({ storageLimitGb: 10 })}>
|
||||
Set limit
|
||||
</button>
|
||||
) : (
|
||||
<div className={s.stepControls}>
|
||||
<button className={s.stepBtn}
|
||||
onClick={() => update({ storageLimitGb: Math.max(1, limitGb - 1) })}
|
||||
disabled={limitGb <= 1}>−</button>
|
||||
<span className={s.stepVal}>{limitGb} GB</span>
|
||||
<button className={s.stepBtn}
|
||||
onClick={() => update({ storageLimitGb: limitGb + 1 })}>+</button>
|
||||
<button className={s.kbReset} onClick={() => update({ storageLimitGb: null })} title="Remove limit">↺</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalBytes > 0 && limitGb !== null && limitBytes !== null && limitBytes > freeBytes && (
|
||||
<p className={s.storageLimitHint}>Limit exceeds available free space ({fmtBytes(freeBytes)})</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.section}>
|
||||
<p className={s.sectionTitle}>Cache</p>
|
||||
<div className={s.stepRow}>
|
||||
<div className={s.toggleInfo}>
|
||||
<span className={s.toggleLabel}>Image cache</span>
|
||||
<span className={s.toggleDesc}>Cached page images stored by the webview</span>
|
||||
</div>
|
||||
<button className={s.dangerBtn} onClick={handleClearCache} disabled={clearing}>
|
||||
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function AboutTab() {
|
||||
return (
|
||||
<div className={s.panel}>
|
||||
@@ -467,6 +637,7 @@ export default function SettingsModal() {
|
||||
{tab === "library" && <LibraryTab settings={settings} update={updateSettings} />}
|
||||
{tab === "performance" && <PerformanceTab settings={settings} update={updateSettings} />}
|
||||
{tab === "keybinds" && <KeybindsTab settings={settings} update={updateSettings} reset={resetKeybinds} />}
|
||||
{tab === "storage" && <StorageTab settings={settings} update={updateSettings} />}
|
||||
{tab === "about" && <AboutTab />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -163,6 +163,24 @@ export const DELETE_DOWNLOADED_CHAPTERS = `
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_DOWNLOADED_CHAPTERS_PAGES = `
|
||||
query GetDownloadedChaptersPages {
|
||||
chapters(condition: { isDownloaded: true }) {
|
||||
nodes {
|
||||
pageCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_DOWNLOADS_PATH = `
|
||||
query GetDownloadsPath {
|
||||
settings {
|
||||
downloadsPath
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Downloads ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GET_DOWNLOAD_STATUS = `
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface Settings {
|
||||
autoStartServer: boolean;
|
||||
preferredExtensionLang: string;
|
||||
keybinds: Keybinds;
|
||||
storageLimitGb: number | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
@@ -76,6 +77,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
autoStartServer: true,
|
||||
preferredExtensionLang: "en",
|
||||
keybinds: DEFAULT_KEYBINDS,
|
||||
storageLimitGb: null,
|
||||
};
|
||||
|
||||
interface Store {
|
||||
|
||||
Reference in New Issue
Block a user