mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
* 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>
168 lines
6.4 KiB
JavaScript
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>;
|
|
}
|