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 -4
View File
@@ -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)}
<button
class="grid-cell"
class:read={ch.read}
class:in-progress={inProgress}
class:grid-selected={isGridSelected}
use:chapterLongPress={[ch, i]}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, inProgress)}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, !ch.read && (ch.lastPageRead ?? 0) > 0)}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i } }}
title={ch.name}
>
@@ -185,7 +183,6 @@
.grid-cell { display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-raised); border: 1px solid var(--border-dim); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); cursor: pointer; position: relative; transition: background var(--t-fast), border-color var(--t-fast); }
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
.grid-cell.in-progress { border-color: var(--accent-dim); color: var(--accent-fg); }
.grid-cell-num { font-size: 10px; }
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
@@ -6,7 +6,6 @@
} from 'phosphor-svelte'
import type { Chapter, Category } from '$lib/types'
import type { ChapterSortMode, ChapterSortDir } from './lib/chapterList'
import { updateSettings } from '$lib/state/settings.svelte'
interface ContinueChapter {
chapter: Chapter
@@ -52,6 +51,8 @@
onSetScanlatorBlacklist: (v: string[]) => void
onSetScanlatorForce: (v: boolean) => void
onOpenFolder: () => void
onSortModeChange: (v: ChapterSortMode) => void
onSortDirChange: (v: ChapterSortDir) => void
}
let {
@@ -64,7 +65,7 @@
onMarkSelectedRead, onClearSelection, onEnqueueNext, onEnqueueMultiple,
onDeleteAll, onRefresh, onToggleCategory, onCreateCategory,
onSetScanlatorFilter, onSetScanlatorBlacklist, onSetScanlatorForce,
onOpenFolder,
onOpenFolder, onSortModeChange, onSortDirChange,
}: Props = $props()
let sortMenuOpen: boolean = $state(false)
@@ -166,11 +167,11 @@
<button
class="sort-option"
class:active={sortMode === val}
onclick={() => { updateSettings({ chapterSortMode: val as ChapterSortMode }); onPageChange(1); sortMenuOpen = false }}
onclick={() => { onSortModeChange(val as ChapterSortMode); onPageChange(1); sortMenuOpen = false }}
>{label}</button>
{/each}
<div class="sort-divider"></div>
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === 'desc' ? 'asc' : 'desc' }); onPageChange(1); sortMenuOpen = false }}>
<button class="sort-option" onclick={() => { onSortDirChange(sortDir === 'desc' ? 'asc' : 'desc'); onPageChange(1); sortMenuOpen = false }}>
{sortDir === 'desc' ? '↑ Ascending' : '↓ Descending'}
</button>
</div>
+139 -213
View File
@@ -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<number, { data: Manga; fetchedAt: number }> = new Map()
const chapterCache: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map()
const mangaCache: Map<number, { data: Manga; fetchedAt: number }> = 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<number> = $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<number>()
let prevMangaId: number | null = null
let mangaAbort: AbortController | null = null
let prevMangaId: number | null = null
const get = <K extends keyof MangaPrefs>(key: K) =>
mangaId ? getPref(mangaId, key) : DEFAULT_MANGA_PREFS[key]
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => {
if (mangaId) setPref(mangaId, key, value)
}
const get = <K extends keyof MangaPrefs>(key: K) => getPref(mangaId, key)
const set = <K extends keyof MangaPrefs>(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 @@
<SeriesActions
{chapters}
{sortedChapters}
{sortMode}
{sortDir}
sortMode={get('sortMode')}
sortDir={get('sortDir')}
{viewMode}
{chapterPage}
{totalPages}
@@ -640,6 +564,8 @@
onSetScanlatorFilter={(v) => 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}
<div class="panel-overlay" role="presentation" onclick={() => markersOpen = false}>
<div class="panel-drawer" role="presentation" onclick={(e) => e.stopPropagation()}>
<MarkersPanel mangaId={manga.id} {chapters} onClose={() => markersOpen = false} />
<MarkersPanel mangaId={manga.id} chapters={seriesState.chaptersFor(manga.id)} onClose={() => markersOpen = false} />
</div>
</div>
{/if}
@@ -702,7 +628,7 @@
{#if migrateOpen && manga}
<MigrateModal
{manga}
currentChapters={chapters}
currentChapters={seriesState.chaptersFor(manga.id)}
onClose={() => migrateOpen = false}
onMigrated={(newManga) => { goto(`/series/${newManga.id}`); migrateOpen = false }}
/>
@@ -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<ChapterDisplayPrefs, 'preferredScanlator' | 'scanlatorFilter'> | undefined,
): Chapter[] {
return buildChapterList(chapters, {
sortMode: 'source',
sortDir: 'asc',
preferredScanlator: prefs?.preferredScanlator,
scanlatorFilter: prefs?.scanlatorFilter,
})
}
@@ -1,6 +1,6 @@
<script lang="ts">
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
import { seriesState, updateMarker, removeMarker, openReader } from "$lib/state/series.svelte";
import { seriesState, updateMarker, removeMarker, openReaderForChapter } from "$lib/state/series.svelte";
import type { MarkerEntry, MarkerColor } from "$lib/types/history";
import type { Chapter } from "$lib/types";
@@ -58,7 +58,7 @@
const chapter = chapters.find(c => c.id === m.chapterId);
if (!chapter) return;
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
openReader(chapter, chaptersAsc);
openReaderForChapter(chapter);
}
function formatDate(ts: number): string {