wrolpi/app/src/components/CbzViewer.js
lrnselfreliance 3c362799b5
Resume EPUBs and comic books where the reader left off (#798)
* Resume EPUBs and comic books where the reader left off

The EPUB viewer (epub.html) accepts `?cfi=` and posts its location and
progress to the page that embeds it on every page turn.  Progress comes from
the chapter and the page within it, rather than book.locations.generate(),
which stalls a Raspberry Pi on a large book.  A search result's `loc` still
wins over the saved position.

The comic viewer opens at the saved page and saves each page shown.

Both resume on the doc page and in the file preview.  The resume point is
decided once per opened file, so saves made while reading do not change the
EPUB viewer's URL and reload it.  The doc page tracks only the FileGroup's
primary file; another format beside it is not tracked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* EPUB/comic progress: never save a place the server would discard

The server treats a position at the start (the cover, under 1%) as not
started, and replaces the saved place with progress 0.  Both viewers
reported the place they opened on, so opening a finished book un-finished
it, and peeking at the cover erased the reader's page.

- The comic viewer does not save the page it opens on, nor the cover; a
  peek at the cover keeps the last page saved.
- The EPUB viewer posts nothing at the start of the book.
- Progress before the end is kept between 1% and 94% (resumableProgress), so
  early pages of a long book and the last pages before the end keep their
  position.  Only the last page (comics) or the end (EPUB) is finished.
- Non-finite EPUB progress is ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:19:51 -06:00

168 lines
6.4 KiB
JavaScript

import React, {useCallback, useEffect, useRef, useState} from 'react';
import {IconArrowLeft, IconArrowRight} from '@tabler/icons-react';
import {getArchiveContents, getArchiveMemberUrl} from '../api';
import {IconButton, Label, Loading, Message} from './ui';
import {useLocalStorage} from './Common';
import {usePageProgress} from '../hooks/useFileProgress';
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i;
/**
* Recursively flatten archive tree entries into a list of file paths.
*/
function flattenEntries(entries, prefix = '') {
let paths = [];
for (const entry of entries) {
const fullPath = entry.path || (prefix ? `${prefix}/${entry.name}` : entry.name);
if (entry.is_dir && entry.children) {
paths = paths.concat(flattenEntries(entry.children, fullPath));
} else if (!entry.is_dir) {
paths.push(fullPath);
}
}
return paths;
}
/**
* @param path The comic book file.
* @param progressPath The FileGroup's primary path, to resume and save the reader's page; omit to not track it.
* @param fileGroup The FileGroup JSON, which holds the saved page.
*/
export function CbzViewer({path, progressPath = null, fileGroup = null}) {
const [pages, setPages] = useState(null);
const [currentPage, setCurrentPage] = useState(0);
const {initialPage, onPageChange} = usePageProgress(progressPath, fileGroup, {enabled: !!progressPath});
// Read when the pages load; a later save must not move the reader.
const initialPageRef = useRef(initialPage);
initialPageRef.current = initialPage;
// The page shown on opening is where the reader already is (or the cover of a finished comic); it is not saved.
const justOpenedRef = useRef(true);
const [rtl, setRtl] = useLocalStorage('cbzViewerRtl', false);
const [fullscreen, setFullscreen] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const load = async () => {
try {
const contents = await getArchiveContents(path);
if (!contents) {
setError('Could not read archive contents.');
return;
}
const images = flattenEntries(contents.entries)
.filter(name => IMAGE_EXTENSIONS.test(name))
.sort();
const urls = images.map(member => getArchiveMemberUrl(path, member));
justOpenedRef.current = true;
setPages(urls);
setCurrentPage(Math.min(initialPageRef.current, Math.max(urls.length - 1, 0)));
} catch (e) {
setError(e.message || 'Could not read archive contents.');
}
};
load();
}, [path]);
useEffect(() => {
if (!pages || pages.length === 0) {
return;
}
if (justOpenedRef.current) {
justOpenedRef.current = false;
return;
}
onPageChange(currentPage, pages.length);
}, [currentPage, pages, onPageChange]);
const goNext = useCallback(() => {
setCurrentPage(p => Math.min(p + 1, (pages?.length || 1) - 1));
}, [pages]);
const goPrev = useCallback(() => {
setCurrentPage(p => Math.max(p - 1, 0));
}, []);
const onLeftClick = rtl ? goNext : goPrev;
const onRightClick = rtl ? goPrev : goNext;
const imageContainerRef = useRef(null);
const handleFullscreen = useCallback(() => {
setFullscreen(f => !f);
}, []);
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'ArrowLeft') onLeftClick();
else if (e.key === 'ArrowRight') onRightClick();
else if (e.key === 'Escape') setFullscreen(false);
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onLeftClick, onRightClick]);
if (error) {
return <Message kind='error' title='Cannot load comic'>
<p>{error}</p>
</Message>;
}
if (!pages) return <Loading/>;
if (pages.length === 0) {
return <Message kind='warning' title='No images found'>
<p>This archive does not contain any image files.</p>
</Message>;
}
const hasPrev = currentPage > 0;
const hasNext = currentPage < pages.length - 1;
return <div style={{marginBottom: '1em'}}>
<div ref={imageContainerRef} className='media' style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: 'var(--bg)',
borderRadius: 0,
padding: '0.5em',
minHeight: '300px',
position: fullscreen ? 'fixed' : 'relative',
...(fullscreen ? {top: 0, left: 0, right: 0, bottom: 0, zIndex: 9999} : {}),
}}>
<img
src={pages[currentPage]}
alt={`Page ${currentPage + 1}`}
style={{
maxHeight: fullscreen ? '100dvh' : 'calc(100dvh - 300px)',
maxWidth: '100%',
objectFit: 'contain',
}}
/>
<div style={{
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
}}>
<div style={{cursor: 'pointer'}} onClick={onLeftClick}/>
<div style={{cursor: 'pointer'}} onClick={handleFullscreen}/>
<div style={{cursor: 'pointer'}} onClick={onRightClick}/>
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5em',
marginTop: '0.5em',
}}>
<IconButton icon={rtl ? IconArrowLeft : IconArrowRight} label='Reading direction'
variant={rtl ? 'filled' : 'default'} onClick={() => setRtl(r => !r)}/>
<span style={{flex: 1}}/>
<IconButton icon='chevron left' label='Previous page'
disabled={rtl ? !hasNext : !hasPrev} onClick={onLeftClick}/>
<Label>{currentPage + 1} / {pages.length}</Label>
<IconButton icon='chevron right' label='Next page'
disabled={rtl ? !hasPrev : !hasNext} onClick={onRightClick}/>
</div>
</div>;
}