mirror of
https://github.com/5rahim/seanime
synced 2026-04-18 22:24:55 +02:00
feat(manga): resume location
This commit is contained in:
@@ -76,6 +76,11 @@ export function MangaVerticalReader({ pageContainer }: MangaVerticalReaderProps)
|
||||
pageDiv?.scrollIntoView()
|
||||
}, [scrollSignal])
|
||||
|
||||
useUpdateEffect(() => {
|
||||
const pageDiv = containerRef.current?.querySelector(`#page-${currentPageIndex}`)
|
||||
pageDiv?.scrollIntoView()
|
||||
}, [pageContainer?.pages])
|
||||
|
||||
/**
|
||||
* Function to handle scroll event
|
||||
*
|
||||
@@ -197,7 +202,7 @@ export function MangaVerticalReader({ pageContainer }: MangaVerticalReaderProps)
|
||||
onFinishedLoading={() => {
|
||||
// If the first page is loaded, set the current page index to 0
|
||||
// This is to avoid the current page index to remain incorrect when multiple pages are loading
|
||||
if (index === 0) {
|
||||
if (index === 0 && currentPageIndex === 0) {
|
||||
setCurrentPageIndex(0)
|
||||
}
|
||||
handlePageLoad(index)
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
__manga_paginationMapAtom,
|
||||
__manga_readingDirectionAtom,
|
||||
__manga_readingModeAtom,
|
||||
__manga_resumeLocationsAtom,
|
||||
cleanupMangaResumeLocations,
|
||||
MangaReadingDirection,
|
||||
MangaReadingMode,
|
||||
} from "@/app/(main)/manga/_lib/manga-chapter-reader.atoms"
|
||||
@@ -62,6 +64,7 @@ export function ChapterReaderDrawer(props: ChapterDrawerProps) {
|
||||
const currentChapter = useCurrentChapter()
|
||||
const setCurrentChapter = useSetCurrentChapter()
|
||||
|
||||
const currentPageIndex = useAtomValue(__manga_currentPageIndexAtom)
|
||||
const setCurrentPageIndex = useSetAtom(__manga_currentPageIndexAtom)
|
||||
const setCurrentPaginationMapIndex = useSetAtom(__manga_currentPaginationMapIndexAtom)
|
||||
|
||||
@@ -74,8 +77,40 @@ export function ChapterReaderDrawer(props: ChapterDrawerProps) {
|
||||
|
||||
const [hiddenBar, setHideBar] = useAtom(__manga_hiddenBarAtom)
|
||||
|
||||
// resume locations
|
||||
const [resumeLocations, setResumeLocations] = useAtom(__manga_resumeLocationsAtom)
|
||||
|
||||
const currentMediaResumeKey = React.useMemo(() => {
|
||||
if (!currentChapter) return undefined
|
||||
return String(currentChapter.mediaId)
|
||||
}, [currentChapter])
|
||||
|
||||
const currentChapterResumeKey = React.useMemo(() => {
|
||||
if (!currentChapter) return undefined
|
||||
return `${currentChapter.mediaId}:${currentChapter.provider}:${currentChapter.chapterId}`
|
||||
}, [currentChapter])
|
||||
|
||||
const currentResumeLocation = React.useMemo(() => {
|
||||
if (!currentMediaResumeKey) return undefined
|
||||
return resumeLocations[currentMediaResumeKey]
|
||||
}, [currentMediaResumeKey, resumeLocations])
|
||||
|
||||
const shouldRestoreSavedPage = React.useMemo(() => {
|
||||
if (!currentChapter || !currentResumeLocation) return false
|
||||
return (
|
||||
currentResumeLocation.chapterId === currentChapter.chapterId
|
||||
&& currentResumeLocation.provider === currentChapter.provider
|
||||
)
|
||||
}, [currentChapter, currentResumeLocation])
|
||||
|
||||
const restoredChapterResumeKeyRef = React.useRef<string | null>(null)
|
||||
|
||||
useSwitchSettingsWithKeys()
|
||||
|
||||
React.useEffect(() => {
|
||||
setResumeLocations(prev => cleanupMangaResumeLocations(prev))
|
||||
}, [])
|
||||
|
||||
const { inject, remove } = useSeaCommandInject()
|
||||
|
||||
/**
|
||||
@@ -176,18 +211,96 @@ export function ChapterReaderDrawer(props: ChapterDrawerProps) {
|
||||
pageContainerLoading])
|
||||
|
||||
/**
|
||||
* Reset the current page index when the pageContainer or chapterContainer changes
|
||||
* This signals that the user has switched chapters
|
||||
* Restore the saved page when the user opens a chapter
|
||||
* This runs before the new page container settles so horizontal mode can hydrate the right map index
|
||||
*/
|
||||
const previousChapterId = React.useRef(currentChapter?.chapterId)
|
||||
React.useEffect(() => {
|
||||
// Avoid resetting the page index when we're still on the same chapter
|
||||
if (currentChapter?.chapterId !== previousChapterId.current) {
|
||||
if (!currentChapterResumeKey) {
|
||||
restoredChapterResumeKeyRef.current = null
|
||||
setCurrentPageIndex(0)
|
||||
setCurrentPaginationMapIndex(0)
|
||||
previousChapterId.current = currentChapter?.chapterId
|
||||
return
|
||||
}
|
||||
}, [pageContainer?.pages, chapterContainer?.chapters])
|
||||
|
||||
restoredChapterResumeKeyRef.current = null
|
||||
setCurrentPageIndex(shouldRestoreSavedPage ? currentResumeLocation?.pageIndex ?? 0 : 0)
|
||||
setCurrentPaginationMapIndex(0)
|
||||
}, [currentChapterResumeKey])
|
||||
|
||||
/**
|
||||
* Clamp the restored page once the chapter pages are available
|
||||
* Providers can return a different page count between sessions
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!currentChapterResumeKey || pageContainerLoading || pageContainerError || !pageContainer?.pages?.length) return
|
||||
if (restoredChapterResumeKeyRef.current === currentChapterResumeKey) return
|
||||
|
||||
const maxPageIndex = pageContainer.pages.length - 1
|
||||
const savedPageIndex = shouldRestoreSavedPage ? currentResumeLocation?.pageIndex ?? 0 : 0
|
||||
const nextPageIndex = Math.min(savedPageIndex, maxPageIndex)
|
||||
|
||||
setCurrentPageIndex(nextPageIndex)
|
||||
restoredChapterResumeKeyRef.current = currentChapterResumeKey
|
||||
|
||||
if (savedPageIndex !== nextPageIndex && currentMediaResumeKey && currentChapter) {
|
||||
setResumeLocations(prev => cleanupMangaResumeLocations({
|
||||
...prev,
|
||||
[currentMediaResumeKey]: {
|
||||
chapterId: currentChapter.chapterId,
|
||||
provider: currentChapter.provider,
|
||||
pageIndex: nextPageIndex,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
}, [
|
||||
currentChapterResumeKey,
|
||||
currentMediaResumeKey,
|
||||
currentResumeLocation,
|
||||
currentChapter,
|
||||
pageContainer?.pages,
|
||||
pageContainerError,
|
||||
pageContainerLoading,
|
||||
shouldRestoreSavedPage,
|
||||
])
|
||||
|
||||
/**
|
||||
* Autosave the current page after the chapter resume point has been restored
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!currentChapterResumeKey || !currentMediaResumeKey || !currentChapter || pageContainerLoading || pageContainerError || !pageContainer?.pages?.length) return
|
||||
if (restoredChapterResumeKeyRef.current !== currentChapterResumeKey) return
|
||||
if (currentPageIndex < 0 || currentPageIndex > pageContainer.pages.length - 1) return
|
||||
|
||||
setResumeLocations(prev => {
|
||||
const currentLocation = prev[currentMediaResumeKey]
|
||||
if (
|
||||
currentLocation?.chapterId === currentChapter.chapterId
|
||||
&& currentLocation?.provider === currentChapter.provider
|
||||
&& currentLocation?.pageIndex === currentPageIndex
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
|
||||
return cleanupMangaResumeLocations({
|
||||
...prev,
|
||||
[currentMediaResumeKey]: {
|
||||
chapterId: currentChapter.chapterId,
|
||||
provider: currentChapter.provider,
|
||||
pageIndex: currentPageIndex,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
})
|
||||
}, [
|
||||
currentChapterResumeKey,
|
||||
currentMediaResumeKey,
|
||||
currentChapter,
|
||||
currentPageIndex,
|
||||
pageContainer?.pages,
|
||||
pageContainerError,
|
||||
pageContainerLoading,
|
||||
])
|
||||
|
||||
// Progress update keyboard shortcuts
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -5,6 +5,60 @@ export const __manga_currentPageIndexAtom = atom(0)
|
||||
export const __manga_currentPaginationMapIndexAtom = atom(0) // HORIZONTAL MODE
|
||||
export const __manga_paginationMapAtom = atom<Record<number, number[]>>({})
|
||||
|
||||
export type MangaReaderResumeLocation = {
|
||||
chapterId: string
|
||||
provider: string
|
||||
pageIndex: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export const MANGA_READER_RESUME_MAX_ENTRIES = 250
|
||||
export const MANGA_READER_RESUME_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 90
|
||||
|
||||
export function cleanupMangaResumeLocations(locations: Record<string, MangaReaderResumeLocation>) {
|
||||
const now = Date.now()
|
||||
|
||||
const trimmedLocations = Object.entries(locations)
|
||||
.filter(([key, value]) => {
|
||||
return (
|
||||
!!key
|
||||
&& typeof value?.chapterId === "string"
|
||||
&& value.chapterId.length > 0
|
||||
&& typeof value?.provider === "string"
|
||||
&& value.provider.length > 0
|
||||
&& Number.isInteger(value?.pageIndex)
|
||||
&& value.pageIndex >= 0
|
||||
&& Number.isFinite(value?.updatedAt)
|
||||
&& now - value.updatedAt <= MANGA_READER_RESUME_MAX_AGE_MS
|
||||
)
|
||||
})
|
||||
.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||
.slice(0, MANGA_READER_RESUME_MAX_ENTRIES)
|
||||
|
||||
const nextLocations = Object.fromEntries(trimmedLocations)
|
||||
|
||||
const sameSize = Object.keys(nextLocations).length === Object.keys(locations).length
|
||||
if (!sameSize) return nextLocations
|
||||
|
||||
for (const [key, value] of Object.entries(nextLocations)) {
|
||||
if (
|
||||
locations[key]?.pageIndex !== value.pageIndex
|
||||
|| locations[key]?.updatedAt !== value.updatedAt
|
||||
) {
|
||||
return nextLocations
|
||||
}
|
||||
}
|
||||
|
||||
return locations
|
||||
}
|
||||
|
||||
export const __manga_resumeLocationsAtom = atomWithStorage<Record<string, MangaReaderResumeLocation>>(
|
||||
"sea-manga-resume-locations",
|
||||
{},
|
||||
undefined,
|
||||
{ getOnInit: true },
|
||||
)
|
||||
|
||||
export const __manga_hiddenBarAtom = atom(false)
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
Reference in New Issue
Block a user