EPUB Working Well

This commit is contained in:
Richard Roberson 2025-02-09 11:52:42 -07:00
parent 25f3375b2f
commit 707dd9261a
7 changed files with 163 additions and 69 deletions

View file

@ -6,12 +6,16 @@ import { useCallback, useEffect, useState } from 'react';
import { useEPUB } from '@/contexts/EPUBContext'; import { useEPUB } from '@/contexts/EPUBContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { EPUBViewer } from '@/components/EPUBViewer'; import { EPUBViewer } from '@/components/EPUBViewer';
import { Button } from '@headlessui/react';
import { DocumentSettings } from '@/components/DocumentSettings';
import { SettingsIcon } from '@/components/icons/Icons';
export default function EPUBPage() { export default function EPUBPage() {
const { id } = useParams(); const { id } = useParams();
const { setCurrentDocument, currDocName, clearCurrDoc } = useEPUB(); const { setCurrentDocument, currDocName, clearCurrDoc } = useEPUB();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const loadDocument = useCallback(async () => { const loadDocument = useCallback(async () => {
if (!isLoading) return; if (!isLoading) return;
@ -66,6 +70,13 @@ export default function EPUBPage() {
</svg> </svg>
Documents Documents
</Link> </Link>
<Button
onClick={() => setIsSettingsOpen(true)}
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
aria-label="View Settings"
>
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
</Button>
</div> </div>
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate"> <h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
{isLoading ? 'Loading...' : currDocName} {isLoading ? 'Loading...' : currDocName}
@ -79,6 +90,7 @@ export default function EPUBPage() {
) : ( ) : (
<EPUBViewer className="p-4" /> <EPUBViewer className="p-4" />
)} )}
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</> </>
); );
} }

View file

@ -8,7 +8,7 @@ import { useCallback, useEffect, useState } from 'react';
import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { Button } from '@headlessui/react'; import { Button } from '@headlessui/react';
import { PDFViewSettings } from '@/components/PDFViewSettings'; import { DocumentSettings } from '@/components/DocumentSettings';
import { SettingsIcon } from '@/components/icons/Icons'; import { SettingsIcon } from '@/components/icons/Icons';
// Dynamic import for client-side rendering only // Dynamic import for client-side rendering only
@ -123,7 +123,7 @@ export default function PDFViewerPage() {
) : ( ) : (
<PDFViewer zoomLevel={zoomLevel} /> <PDFViewer zoomLevel={zoomLevel} />
)} )}
<PDFViewSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} /> <DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</> </>
); );
} }

View file

@ -3,6 +3,7 @@ import { Button, Dialog } from '@headlessui/react';
import { Transition, TransitionChild, DialogPanel, DialogTitle } from '@headlessui/react'; import { Transition, TransitionChild, DialogPanel, DialogTitle } from '@headlessui/react';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { useDocuments } from '@/contexts/DocumentContext'; import { useDocuments } from '@/contexts/DocumentContext';
import { PDFIcon, EPUBIcon } from '@/components/icons/Icons';
type DocumentToDelete = { type DocumentToDelete = {
id: string; id: string;
@ -82,13 +83,9 @@ export function DocumentList() {
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{doc.type === 'pdf' ? ( {doc.type === 'pdf' ? (
<svg className="w-6 h-6 sm:w-8 sm:h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <PDFIcon />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
) : ( ) : (
<svg className="w-6 h-6 sm:w-8 sm:h-8 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <EPUBIcon />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
)} )}
</div> </div>
<div className="flex-1 min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"> <div className="flex-1 min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02]">

View file

@ -5,9 +5,10 @@ import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox,
import { useConfig, ViewType } from '@/contexts/ConfigContext'; import { useConfig, ViewType } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
interface PDFViewSettingsProps { interface DocViewSettingsProps {
isOpen: boolean; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void; setIsOpen: (isOpen: boolean) => void;
epub?: boolean;
} }
const viewTypes = [ const viewTypes = [
@ -16,7 +17,7 @@ const viewTypes = [
{ id: 'scroll', name: 'Continuous Scroll' }, { id: 'scroll', name: 'Continuous Scroll' },
]; ];
export function PDFViewSettings({ isOpen, setIsOpen }: PDFViewSettingsProps) { export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsProps) {
const { viewType, skipBlank, updateConfigKey } = useConfig(); const { viewType, skipBlank, updateConfigKey } = useConfig();
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0]; const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0];
@ -55,7 +56,7 @@ export function PDFViewSettings({ isOpen, setIsOpen }: PDFViewSettingsProps) {
</DialogTitle> </DialogTitle>
<div className="mt-4"> <div className="mt-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> {!epub && <div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Mode</label> <label className="block text-sm font-medium text-foreground">Mode</label>
<Listbox <Listbox
value={selectedView} value={selectedView}
@ -108,7 +109,7 @@ export function PDFViewSettings({ isOpen, setIsOpen }: PDFViewSettingsProps) {
Note: Continuous scroll may perform poorly for larger documents. Note: Continuous scroll may perform poorly for larger documents.
</p> </p>
)} )}
</div> </div>}
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center space-x-2"> <label className="flex items-center space-x-2">
<input <input

View file

@ -214,3 +214,36 @@ export function GithubIcon(props: React.SVGProps<SVGSVGElement>) {
</svg> </svg>
); );
} }
export function PDFIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
className={`w-6 h-6 sm:w-8 sm:h-8 text-red-500 ${props.className || ''}`}
fill="currentColor"
stroke="currentColor"
viewBox="-4 0 40 40"
strokeWidth='0.25'
{...props}
>
<path d="M25.6686 26.0962C25.1812 26.2401 24.4656 26.2563 23.6984 26.145C22.875 26.0256 22.0351 25.7739 21.2096 25.403C22.6817 25.1888 23.8237 25.2548 24.8005 25.6009C25.0319 25.6829 25.412 25.9021 25.6686 26.0962ZM17.4552 24.7459C17.3953 24.7622 17.3363 24.7776 17.2776 24.7939C16.8815 24.9017 16.4961 25.0069 16.1247 25.1005L15.6239 25.2275C14.6165 25.4824 13.5865 25.7428 12.5692 26.0529C12.9558 25.1206 13.315 24.178 13.6667 23.2564C13.9271 22.5742 14.193 21.8773 14.468 21.1894C14.6075 21.4198 14.7531 21.6503 14.9046 21.8814C15.5948 22.9326 16.4624 23.9045 17.4552 24.7459ZM14.8927 14.2326C14.958 15.383 14.7098 16.4897 14.3457 17.5514C13.8972 16.2386 13.6882 14.7889 14.2489 13.6185C14.3927 13.3185 14.5105 13.1581 14.5869 13.0744C14.7049 13.2566 14.8601 13.6642 14.8927 14.2326ZM9.63347 28.8054C9.38148 29.2562 9.12426 29.6782 8.86063 30.0767C8.22442 31.0355 7.18393 32.0621 6.64941 32.0621C6.59681 32.0621 6.53316 32.0536 6.44015 31.9554C6.38028 31.8926 6.37069 31.8476 6.37359 31.7862C6.39161 31.4337 6.85867 30.8059 7.53527 30.2238C8.14939 29.6957 8.84352 29.2262 9.63347 28.8054ZM27.3706 26.1461C27.2889 24.9719 25.3123 24.2186 25.2928 24.2116C24.5287 23.9407 23.6986 23.8091 22.7552 23.8091C21.7453 23.8091 20.6565 23.9552 19.2582 24.2819C18.014 23.3999 16.9392 22.2957 16.1362 21.0733C15.7816 20.5332 15.4628 19.9941 15.1849 19.4675C15.8633 17.8454 16.4742 16.1013 16.3632 14.1479C16.2737 12.5816 15.5674 11.5295 14.6069 11.5295C13.948 11.5295 13.3807 12.0175 12.9194 12.9813C12.0965 14.6987 12.3128 16.8962 13.562 19.5184C13.1121 20.5751 12.6941 21.6706 12.2895 22.7311C11.7861 24.0498 11.2674 25.4103 10.6828 26.7045C9.04334 27.3532 7.69648 28.1399 6.57402 29.1057C5.8387 29.7373 4.95223 30.7028 4.90163 31.7107C4.87693 32.1854 5.03969 32.6207 5.37044 32.9695C5.72183 33.3398 6.16329 33.5348 6.6487 33.5354C8.25189 33.5354 9.79489 31.3327 10.0876 30.8909C10.6767 30.0029 11.2281 29.0124 11.7684 27.8699C13.1292 27.3781 14.5794 27.011 15.985 26.6562L16.4884 26.5283C16.8668 26.4321 17.2601 26.3257 17.6635 26.2153C18.0904 26.0999 18.5296 25.9802 18.976 25.8665C20.4193 26.7844 21.9714 27.3831 23.4851 27.6028C24.7601 27.7883 25.8924 27.6807 26.6589 27.2811C27.3486 26.9219 27.3866 26.3676 27.3706 26.1461ZM30.4755 36.2428C30.4755 38.3932 28.5802 38.5258 28.1978 38.5301H3.74486C1.60224 38.5301 1.47322 36.6218 1.46913 36.2428L1.46884 3.75642C1.46884 1.6039 3.36763 1.4734 3.74457 1.46908H20.263L20.2718 1.4778V7.92396C20.2718 9.21763 21.0539 11.6669 24.0158 11.6669H30.4203L30.4753 11.7218L30.4755 36.2428ZM28.9572 10.1976H24.0169C21.8749 10.1976 21.7453 8.29969 21.7424 7.92417V2.95307L28.9572 10.1976ZM31.9447 36.2428V11.1157L21.7424 0.871022V0.823357H21.6936L20.8742 0H3.74491C2.44954 0 0 0.785336 0 3.75711V36.2435C0 37.5427 0.782956 40 3.74491 40H28.2001C29.4952 39.9997 31.9447 39.2143 31.9447 36.2428Z"/>
</svg>
);
}
export function EPUBIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
className={`w-6 h-6 sm:w-8 sm:h-8 text-blue-500 ${props.className || ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
{...props}
>
<path d="M4 8C4 5.17157 4 3.75736 4.87868 2.87868C5.75736 2 7.17157 2 10 2H14C16.8284 2 18.2426 2 19.1213 2.87868C20 3.75736 20 5.17157 20 8V16C20 18.8284 20 20.2426 19.1213 21.1213C18.2426 22 16.8284 22 14 22H10C7.17157 22 5.75736 22 4.87868 21.1213C4 20.2426 4 18.8284 4 16V8Z" strokeWidth="1.5" />
<path d="M19.8978 16H7.89778C6.96781 16 6.50282 16 6.12132 16.1022C5.08604 16.3796 4.2774 17.1883 4 18.2235" strokeWidth="1.5" />
<path d="M8 7H16" strokeWidth="1" strokeLinecap="round" />
<path d="M8 10.5H13" strokeWidth="1" strokeLinecap="round" />
<path d="M19.5 19H8" strokeWidth="1" strokeLinecap="round" />
</svg>
);
}

View file

@ -124,6 +124,9 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
const [currDocPages, setCurrDocPages] = useState<number>(); const [currDocPages, setCurrDocPages] = useState<number>();
const [nextPageLoading, setNextPageLoading] = useState(false); const [nextPageLoading, setNextPageLoading] = useState(false);
// Add this state to track if we're in EPUB mode
const [isEPUB, setIsEPUB] = useState(false);
console.log('page:', currDocPage, 'pages:', currDocPages); console.log('page:', currDocPage, 'pages:', currDocPages);
/** /**
@ -139,16 +142,33 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
/** /**
* Sets the current text and splits it into sentences * Sets the current text and splits it into sentences
*
* @param {string} text - The text to be processed
* @returns {void}
*/ */
const setText = useCallback((text: string) => { const setText = useCallback((text: string) => {
console.log('Setting page text:', text); console.log('Setting page text:', text);
const newSentences = splitIntoSentences(text); const newSentences = splitIntoSentences(text);
// If skipBlank is enabled and there's no text and we are playing audio, automatically move to next page // If skipBlank is enabled and there's no text
if (isPlaying && skipBlank && newSentences.length === 0 && currDocPage < currDocPages!) { if (isPlaying && skipBlank && newSentences.length === 0) {
if (isEPUB && locationChangeHandlerRef.current) {
// For EPUB, use the location handler to move to next section
locationChangeHandlerRef.current('next');
toast.success('Skipping blank section', {
id: `epub-section-skip`,
iconTheme: {
primary: 'var(--accent)',
secondary: 'var(--background)',
},
style: {
background: 'var(--background)',
color: 'var(--accent)',
},
duration: 1000,
position: 'top-center',
});
return;
} else if (currDocPage < currDocPages!) {
// For PDF, increment the page
incrementPage(); incrementPage();
toast.success(`Skipping blank page ${currDocPage}`, { toast.success(`Skipping blank page ${currDocPage}`, {
@ -166,10 +186,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
}); });
return; return;
} }
}
setSentences(newSentences); setSentences(newSentences);
setNextPageLoading(false); setNextPageLoading(false);
}, [isPlaying, skipBlank, currDocPage, currDocPages, incrementPage]); }, [isPlaying, skipBlank, currDocPage, currDocPages, incrementPage, isEPUB]);
/** /**
* Stops the current audio playback and clears the active Howl instance * Stops the current audio playback and clears the active Howl instance
@ -220,22 +241,35 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
const [currChapter, setCurrChapter] = useState<string | number>(''); const [currChapter, setCurrChapter] = useState<string | number>('');
const setEPUBPageInChapter = useCallback((page: string | number, total: number, chapter: string | number) => { const setEPUBPageInChapter = useCallback((page: string | number, total: number, chapter: string | number) => {
const alreadyPlaying = isPlaying; const alreadyPlaying = isPlaying;
if (chapter !== currChapter) { const isNewChapter = chapter !== currChapter;
// Mark that we're in EPUB mode
setIsEPUB(true);
// Update chapter info
if (isNewChapter) {
setCurrDocPages(total); setCurrDocPages(total);
setCurrChapter(chapter); setCurrChapter(chapter);
console.log('Changed to chapter:', chapter);
} }
// Reset state for new content
abortAudio(); abortAudio();
setIsPlaying(false); setIsPlaying(false);
setNextPageLoading(true); setNextPageLoading(true);
setCurrentIndex(0); setCurrentIndex(0);
setSentences([]); setSentences([]);
// Update current page
setCurrDocPage(Number(page)); setCurrDocPage(Number(page));
setIsPlaying(alreadyPlaying); // Resume playback if it was playing before
// Only auto-resume if this was triggered by automatic navigation (not manual page turns)
if (alreadyPlaying) {
setIsPlaying(true);
}
}, [abortAudio, currChapter]); }, [abortAudio, currChapter, isPlaying]);
/** /**
* Moves to the next or previous sentence * Moves to the next or previous sentence
@ -253,40 +287,34 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
return; return;
} }
// Handle next page // For EPUB documents, always try to advance to next/prev section
if (nextIndex >= sentences.length && currDocPage < currDocPages!) { if (isEPUB && locationChangeHandlerRef.current) {
console.log('Advancing to next page:', currDocPage + 1); console.log('EPUB: Advancing to next/prev section');
setCurrentIndex(0); setCurrentIndex(0);
setSentences([]);
// Trigger page turn in React Reader locationChangeHandlerRef.current(nextIndex >= sentences.length ? 'next' : 'prev');
if (locationChangeHandlerRef.current) {
locationChangeHandlerRef.current('next');
} else {
incrementPage();
}
return; return;
} }
// Handle previous page // For PDFs and other documents, check page bounds
if (nextIndex < 0 && currDocPage > 1) { if (!isEPUB) {
console.log('Advancing to previous page:', currDocPage - 1); // Handle next/previous page transitions
if ((nextIndex >= sentences.length && currDocPage < currDocPages!) ||
(nextIndex < 0 && currDocPage > 1)) {
console.log('PDF: Advancing to next/prev page');
setCurrentIndex(0); setCurrentIndex(0);
setSentences([]);
// Trigger page turn in React Reader incrementPage(nextIndex >= sentences.length ? 1 : -1);
if (locationChangeHandlerRef.current) {
locationChangeHandlerRef.current('prev');
} else {
incrementPage(-1);
}
return; return;
} }
// Handle end of document // Handle end of document (PDF only)
if (nextIndex >= sentences.length && currDocPage >= currDocPages!) { if (nextIndex >= sentences.length && currDocPage >= currDocPages!) {
console.log('Reached end of document'); console.log('PDF: Reached end of document');
setIsPlaying(false); setIsPlaying(false);
} }
}, [currentIndex, incrementPage, sentences, currDocPage, currDocPages]); }
}, [currentIndex, incrementPage, sentences, currDocPage, currDocPages, isEPUB]);
/** /**
* Moves forward one sentence in the text * Moves forward one sentence in the text
@ -525,6 +553,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) {
setCurrDocPages(undefined); setCurrDocPages(undefined);
setNextPageLoading(false); setNextPageLoading(false);
setIsProcessing(false); setIsProcessing(false);
setIsEPUB(false);
}, [abortAudio]); }, [abortAudio]);
/** /**

View file

@ -15,9 +15,31 @@ export const preprocessSentenceForAudio = (text: string): string => {
.trim(); .trim();
}; };
const isShortSentence = (sentence: string): boolean => {
const words = sentence.trim().split(/\s+/);
return words.length <= 5;
};
export const splitIntoSentences = (text: string): string[] => { export const splitIntoSentences = (text: string): string[] => {
// Preprocess the text before splitting into sentences // Preprocess the text before splitting into sentences
const cleanedText = preprocessSentenceForAudio(text); const cleanedText = preprocessSentenceForAudio(text);
const doc = nlp(cleanedText); const doc = nlp(cleanedText);
return doc.sentences().out('array') as string[]; const rawSentences = doc.sentences().out('array') as string[];
// Combine short sentences with previous ones
const processedSentences: string[] = [];
for (let i = 0; i < rawSentences.length; i++) {
const currentSentence = rawSentences[i].trim();
if (isShortSentence(currentSentence) && processedSentences.length > 0) {
// Combine with previous sentence
const lastIndex = processedSentences.length - 1;
processedSentences[lastIndex] = `${processedSentences[lastIndex]} ${currentSentence}`;
} else {
processedSentences.push(currentSentence);
}
}
return processedSentences;
}; };