[ePub only] First working export audiobook
This commit is contained in:
parent
61953a761a
commit
1db906f8bc
3 changed files with 408 additions and 98 deletions
|
|
@ -1,9 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment } from 'react';
|
import { Fragment, useState, useRef } from 'react';
|
||||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react';
|
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button } from '@headlessui/react';
|
||||||
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';
|
||||||
|
import { useEPUB } from '@/contexts/EPUBContext';
|
||||||
|
|
||||||
interface DocViewSettingsProps {
|
interface DocViewSettingsProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -19,8 +20,52 @@ const viewTypes = [
|
||||||
|
|
||||||
export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsProps) {
|
export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsProps) {
|
||||||
const { viewType, skipBlank, epubTheme, updateConfigKey } = useConfig();
|
const { viewType, skipBlank, epubTheme, updateConfigKey } = useConfig();
|
||||||
|
const { createFullAudioBook } = useEPUB();
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0];
|
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0];
|
||||||
|
|
||||||
|
const handleStartGeneration = async () => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
setProgress(0);
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioBuffer = await createFullAudioBook(
|
||||||
|
(progress) => setProgress(progress),
|
||||||
|
abortControllerRef.current.signal
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create and trigger download
|
||||||
|
const blob = new Blob([audioBuffer], { type: 'audio/mp3' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'audiobook.mp3';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating audiobook:', error);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
setProgress(0);
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
|
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
|
||||||
|
|
@ -58,8 +103,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!epub && <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}
|
||||||
onChange={(newView) => updateConfigKey('viewType', newView.id as ViewType)}
|
onChange={(newView) => updateConfigKey('viewType', newView.id as ViewType)}
|
||||||
>
|
>
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
|
|
@ -80,8 +125,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={view.id}
|
key={view.id}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
||||||
active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
value={view}
|
value={view}
|
||||||
|
|
@ -125,20 +169,58 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{epub && (
|
{epub && (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<label className="flex items-center space-x-2">
|
<div className="space-y-2">
|
||||||
<input
|
<label className="flex items-center space-x-2">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={epubTheme}
|
type="checkbox"
|
||||||
onChange={(e) => updateConfigKey('epubTheme', e.target.checked)}
|
checked={epubTheme}
|
||||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
onChange={(e) => updateConfigKey('epubTheme', e.target.checked)}
|
||||||
/>
|
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
||||||
<span className="text-sm font-medium text-foreground">Use theme (experimental)</span>
|
/>
|
||||||
</label>
|
<span className="text-sm font-medium text-foreground">Use theme (experimental)</span>
|
||||||
<p className="text-sm text-muted pl-6">
|
</label>
|
||||||
Apply the current app theme to the EPUB viewer
|
<p className="text-sm text-muted pl-6">
|
||||||
</p>
|
Apply the current app theme to the EPUB viewer
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{!isGenerating ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="w-full inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
||||||
|
font-medium text-background hover:opacity-95 focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
|
onClick={handleStartGeneration}
|
||||||
|
>
|
||||||
|
Export to Audiobook (.mp3)
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="w-full bg-background rounded-lg overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-2 bg-accent transition-all duration-300 ease-in-out"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-sm text-muted">
|
||||||
|
<span>{Math.round(progress)}% complete</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex justify-center rounded-lg px-2.5 py-1 text-sm
|
||||||
|
font-medium text-foreground hover:text-accent focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
Cancel and download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useEPUB } from '@/contexts/EPUBContext';
|
import { useEPUB } from '@/contexts/EPUBContext';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
import { setLastDocumentLocation } from '@/utils/indexedDB';
|
|
||||||
import type { Rendition, Book, NavItem } from 'epubjs';
|
|
||||||
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
||||||
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
||||||
|
|
||||||
|
|
@ -23,88 +20,40 @@ interface EPUBViewerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
const { id } = useParams();
|
const {
|
||||||
const { currDocData, currDocName, currDocPage, extractPageText } = useEPUB();
|
currDocData,
|
||||||
const { skipToLocation, registerLocationChangeHandler, setIsEPUB, pause } = useTTS();
|
currDocName,
|
||||||
|
locationRef,
|
||||||
|
handleLocationChanged,
|
||||||
|
bookRef,
|
||||||
|
renditionRef,
|
||||||
|
tocRef,
|
||||||
|
setRendition,
|
||||||
|
extractPageText
|
||||||
|
} = useEPUB();
|
||||||
|
const { registerLocationChangeHandler, pause } = useTTS();
|
||||||
const { epubTheme } = useConfig();
|
const { epubTheme } = useConfig();
|
||||||
const bookRef = useRef<Book | null>(null);
|
const { updateTheme } = useEPUBTheme(epubTheme, renditionRef.current);
|
||||||
const rendition = useRef<Rendition | undefined>(undefined);
|
|
||||||
const toc = useRef<NavItem[]>([]);
|
|
||||||
const locationRef = useRef<string | number>(currDocPage);
|
|
||||||
const { updateTheme } = useEPUBTheme(epubTheme, rendition.current);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const isEPUBSetOnce = useRef(false);
|
|
||||||
const { isResizing, setIsResizing, dimensions } = useEPUBResize(containerRef);
|
const { isResizing, setIsResizing, dimensions } = useEPUBResize(containerRef);
|
||||||
|
|
||||||
const handleLocationChanged = useCallback((location: string | number) => {
|
|
||||||
// Set the EPUB flag once the location changes
|
|
||||||
if (!isEPUBSetOnce.current) {
|
|
||||||
setIsEPUB(true);
|
|
||||||
isEPUBSetOnce.current = true;
|
|
||||||
|
|
||||||
rendition.current?.display(location.toString());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bookRef.current?.isOpen || !rendition.current) return;
|
|
||||||
|
|
||||||
// Handle special 'next' and 'prev' cases
|
|
||||||
if (location === 'next' && rendition.current) {
|
|
||||||
rendition.current.next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (location === 'prev' && rendition.current) {
|
|
||||||
rendition.current.prev();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the location to IndexedDB if not initial
|
|
||||||
if (id && locationRef.current !== 1) {
|
|
||||||
console.log('Saving location:', location);
|
|
||||||
setLastDocumentLocation(id as string, location.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
skipToLocation(location);
|
|
||||||
|
|
||||||
locationRef.current = location;
|
|
||||||
extractPageText(bookRef.current, rendition.current);
|
|
||||||
|
|
||||||
}, [id, skipToLocation, extractPageText, setIsEPUB]);
|
|
||||||
|
|
||||||
const initialExtract = useCallback(() => {
|
|
||||||
if (!bookRef.current || !rendition.current?.location || isEPUBSetOnce.current) return;
|
|
||||||
extractPageText(bookRef.current, rendition.current, false);
|
|
||||||
}, [extractPageText]);
|
|
||||||
|
|
||||||
const checkResize = useCallback(() => {
|
const checkResize = useCallback(() => {
|
||||||
if (isResizing && dimensions && bookRef.current?.isOpen && rendition.current && isEPUBSetOnce.current) {
|
if (isResizing && dimensions && bookRef.current?.isOpen && renditionRef.current) {
|
||||||
pause();
|
pause();
|
||||||
// Only extract text when we have dimensions, ensuring the resize is complete
|
// Only extract text when we have dimensions, ensuring the resize is complete
|
||||||
extractPageText(bookRef.current, rendition.current, true);
|
extractPageText(bookRef.current, renditionRef.current, true);
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [isResizing, setIsResizing, dimensions, pause, extractPageText]);
|
}, [isResizing, setIsResizing, dimensions, pause, bookRef, renditionRef, extractPageText]);
|
||||||
|
|
||||||
// Check for isResizing to pause TTS and re-extract text
|
// Check for isResizing to pause TTS and re-extract text
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (checkResize()) return;
|
if (checkResize()) return;
|
||||||
|
}, [checkResize]);
|
||||||
// Load initial location when not resizing
|
|
||||||
initialExtract();
|
|
||||||
}, [checkResize, initialExtract]);
|
|
||||||
|
|
||||||
// Load the initial location
|
|
||||||
// useEffect(() => {
|
|
||||||
// if (!bookRef.current || !rendition.current || isEPUBSetOnce.current) return;
|
|
||||||
|
|
||||||
// extractPageText(bookRef.current, rendition.current, false);
|
|
||||||
// }, [extractPageText]);
|
|
||||||
|
|
||||||
// Register the location change handler
|
// Register the location change handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -127,12 +76,11 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
locationChanged={handleLocationChanged}
|
locationChanged={handleLocationChanged}
|
||||||
url={currDocData}
|
url={currDocData}
|
||||||
title={currDocName}
|
title={currDocName}
|
||||||
tocChanged={(_toc) => (toc.current = _toc)}
|
tocChanged={(_toc) => (tocRef.current = _toc)}
|
||||||
showToc={true}
|
showToc={true}
|
||||||
readerStyles={epubTheme && getThemeStyles() || undefined}
|
readerStyles={epubTheme && getThemeStyles() || undefined}
|
||||||
getRendition={(_rendition: Rendition) => {
|
getRendition={(_rendition) => {
|
||||||
bookRef.current = _rendition.book;
|
setRendition(_rendition);
|
||||||
rendition.current = _rendition;
|
|
||||||
updateTheme();
|
updateTheme();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,18 @@ import {
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
RefObject,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { indexedDBService } from '@/utils/indexedDB';
|
import { indexedDBService } from '@/utils/indexedDB';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { Book, Rendition } from 'epubjs';
|
import { Book, Rendition } from 'epubjs';
|
||||||
import { createRangeCfi } from '@/utils/epub';
|
import { createRangeCfi } from '@/utils/epub';
|
||||||
|
import type { NavItem } from 'epubjs';
|
||||||
|
import { setLastDocumentLocation } from '@/utils/indexedDB';
|
||||||
|
import { SpineItem } from 'epubjs/types/section';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useConfig } from './ConfigContext';
|
||||||
|
|
||||||
interface EPUBContextType {
|
interface EPUBContextType {
|
||||||
currDocData: ArrayBuffer | undefined;
|
currDocData: ArrayBuffer | undefined;
|
||||||
|
|
@ -22,6 +29,13 @@ interface EPUBContextType {
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
||||||
|
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal) => Promise<ArrayBuffer>;
|
||||||
|
bookRef: RefObject<Book | null>;
|
||||||
|
renditionRef: RefObject<Rendition | undefined>;
|
||||||
|
tocRef: RefObject<NavItem[]>;
|
||||||
|
locationRef: RefObject<string | number>;
|
||||||
|
handleLocationChanged: (location: string | number) => void;
|
||||||
|
setRendition: (rendition: Rendition) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EPUBContext = createContext<EPUBContextType | undefined>(undefined);
|
const EPUBContext = createContext<EPUBContextType | undefined>(undefined);
|
||||||
|
|
@ -33,13 +47,27 @@ const EPUBContext = createContext<EPUBContextType | undefined>(undefined);
|
||||||
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
||||||
*/
|
*/
|
||||||
export function EPUBProvider({ children }: { children: ReactNode }) {
|
export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop } = useTTS();
|
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS();
|
||||||
|
const { id } = useParams();
|
||||||
|
// Configuration context to get TTS settings
|
||||||
|
const {
|
||||||
|
apiKey,
|
||||||
|
baseUrl,
|
||||||
|
voiceSpeed,
|
||||||
|
voice,
|
||||||
|
} = useConfig();
|
||||||
// Current document state
|
// Current document state
|
||||||
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
|
|
||||||
|
// Add new refs
|
||||||
|
const bookRef = useRef<Book | null>(null);
|
||||||
|
const renditionRef = useRef<Rendition | undefined>(undefined);
|
||||||
|
const tocRef = useRef<NavItem[]>([]);
|
||||||
|
const locationRef = useRef<string | number>(currDocPage);
|
||||||
|
const isEPUBSetOnce = useRef(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears all current document state and stops any active TTS
|
* Clears all current document state and stops any active TTS
|
||||||
*/
|
*/
|
||||||
|
|
@ -62,7 +90,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
if (doc) {
|
if (doc) {
|
||||||
console.log('Retrieved document size:', doc.size);
|
console.log('Retrieved document size:', doc.size);
|
||||||
console.log('Retrieved ArrayBuffer size:', doc.data.byteLength);
|
console.log('Retrieved ArrayBuffer size:', doc.data.byteLength);
|
||||||
|
|
||||||
if (doc.data.byteLength === 0) {
|
if (doc.data.byteLength === 0) {
|
||||||
console.error('Retrieved ArrayBuffer is empty');
|
console.error('Retrieved ArrayBuffer is empty');
|
||||||
throw new Error('Empty document data');
|
throw new Error('Empty document data');
|
||||||
|
|
@ -87,18 +115,18 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
* @returns {Promise<string>} The extracted text content
|
* @returns {Promise<string>} The extracted text content
|
||||||
*/
|
*/
|
||||||
const extractPageText = useCallback(async (book: Book, rendition: Rendition, shouldPause = false): Promise<string> => {
|
const extractPageText = useCallback(async (book: Book, rendition: Rendition, shouldPause = false): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const { start, end } = rendition?.location;
|
const { start, end } = rendition?.location;
|
||||||
if (!start?.cfi || !end?.cfi || !book || !book.isOpen || !rendition) return '';
|
if (!start?.cfi || !end?.cfi || !book || !book.isOpen || !rendition) return '';
|
||||||
|
|
||||||
const rangeCfi = createRangeCfi(start.cfi, end.cfi);
|
const rangeCfi = createRangeCfi(start.cfi, end.cfi);
|
||||||
|
|
||||||
const range = await book.getRange(rangeCfi);
|
const range = await book.getRange(rangeCfi);
|
||||||
const textContent = range.toString().trim();
|
const textContent = range.toString().trim();
|
||||||
|
|
||||||
setTTSText(textContent, shouldPause);
|
setTTSText(textContent, shouldPause);
|
||||||
setCurrDocText(textContent);
|
setCurrDocText(textContent);
|
||||||
|
|
||||||
return textContent;
|
return textContent;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error extracting EPUB text:', error);
|
console.error('Error extracting EPUB text:', error);
|
||||||
|
|
@ -106,6 +134,248 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [setTTSText]);
|
}, [setTTSText]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts text content from the entire EPUB book
|
||||||
|
* @returns {Promise<string[]>} Array of text content from each section
|
||||||
|
*/
|
||||||
|
const extractBookText = useCallback(async (): Promise<string[]> => {
|
||||||
|
try {
|
||||||
|
if (!bookRef.current || !bookRef.current.isOpen) return [''];
|
||||||
|
|
||||||
|
const book = bookRef.current;
|
||||||
|
const spine = book.spine;
|
||||||
|
const promises: Promise<string>[] = [];
|
||||||
|
|
||||||
|
spine.each((item: SpineItem) => {
|
||||||
|
const url = item.href || '';
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
const promise = book.load(url)
|
||||||
|
.then((section) => (section as Document))
|
||||||
|
.then((section) => {
|
||||||
|
const textContent = section.body.textContent || '';
|
||||||
|
return textContent;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(`Error loading section ${url}:`, err);
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
promises.push(promise);
|
||||||
|
});
|
||||||
|
|
||||||
|
const textArray = await Promise.all(promises);
|
||||||
|
const filteredArray = textArray.filter(text => text.trim() !== '');
|
||||||
|
console.log('Extracted entire EPUB text array:', filteredArray);
|
||||||
|
return filteredArray;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error extracting EPUB text:', error);
|
||||||
|
return [''];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a complete audiobook by processing all text through NLP and TTS
|
||||||
|
* @param {string} voice - The voice to use for TTS
|
||||||
|
* @param {number} speed - The speed to use for TTS
|
||||||
|
* @returns {Promise<ArrayBuffer>} The complete audiobook as an ArrayBuffer
|
||||||
|
*/
|
||||||
|
const createFullAudioBook = useCallback(async (
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<ArrayBuffer> => {
|
||||||
|
try {
|
||||||
|
// Get all text content from the book
|
||||||
|
const textArray = await extractBookText();
|
||||||
|
if (!textArray.length) throw new Error('No text content found in book');
|
||||||
|
|
||||||
|
// Create an array to store all audio chunks
|
||||||
|
const audioChunks: ArrayBuffer[] = [];
|
||||||
|
let processedSentences = 0;
|
||||||
|
let totalSentences = 0;
|
||||||
|
|
||||||
|
// First, count total sentences
|
||||||
|
for (const text of textArray) {
|
||||||
|
if (!text.trim()) continue;
|
||||||
|
|
||||||
|
const nlpResponse = await fetch('/api/nlp', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!nlpResponse.ok) {
|
||||||
|
throw new Error(`NLP processing failed with status ${nlpResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nlpData = await nlpResponse.json();
|
||||||
|
const sentences = nlpData?.sentences;
|
||||||
|
if (sentences && Array.isArray(sentences)) {
|
||||||
|
totalSentences += sentences.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each section of text
|
||||||
|
for (const text of textArray) {
|
||||||
|
if (!text.trim()) continue;
|
||||||
|
|
||||||
|
// Check for cancellation
|
||||||
|
if (signal?.aborted) {
|
||||||
|
// If cancelled, return what we have so far
|
||||||
|
const partialBuffer = combineAudioChunks(audioChunks);
|
||||||
|
return partialBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nlpResponse = await fetch('/api/nlp', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!nlpResponse.ok) {
|
||||||
|
throw new Error(`NLP processing failed with status ${nlpResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nlpData = await nlpResponse.json();
|
||||||
|
const sentences = nlpData?.sentences;
|
||||||
|
|
||||||
|
if (!sentences || !Array.isArray(sentences) || sentences.length === 0) {
|
||||||
|
console.warn('No valid sentences returned from NLP, processing text block as single sentence');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each sentence through TTS with retries
|
||||||
|
for (const sentence of sentences) {
|
||||||
|
// Check for cancellation
|
||||||
|
if (signal?.aborted) {
|
||||||
|
const partialBuffer = combineAudioChunks(audioChunks);
|
||||||
|
return partialBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sentence || typeof sentence !== 'string' || !sentence.trim()) {
|
||||||
|
processedSentences++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let retryCount = 0;
|
||||||
|
const maxRetries = 2;
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
while (retryCount <= maxRetries && !success) {
|
||||||
|
try {
|
||||||
|
const ttsResponse = await fetch('/api/tts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-openai-key': apiKey || '',
|
||||||
|
'x-openai-base-url': baseUrl || 'https://api.openai.com/v1',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: sentence.trim(),
|
||||||
|
voice: voice,
|
||||||
|
speed: voiceSpeed,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ttsResponse.ok) {
|
||||||
|
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBuffer = await ttsResponse.arrayBuffer();
|
||||||
|
if (audioBuffer.byteLength === 0) {
|
||||||
|
throw new Error('Received empty audio buffer from TTS');
|
||||||
|
}
|
||||||
|
|
||||||
|
audioChunks.push(audioBuffer);
|
||||||
|
success = true;
|
||||||
|
} catch (error) {
|
||||||
|
retryCount++;
|
||||||
|
if (retryCount <= maxRetries) {
|
||||||
|
console.warn(`TTS generation failed, attempt ${retryCount} of ${maxRetries}:`, error);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to generate audio after ${maxRetries} retries, skipping sentence:`, sentence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Add a small pause between sentences (500ms of silence)
|
||||||
|
const silenceBuffer = new ArrayBuffer(24000);
|
||||||
|
audioChunks.push(silenceBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
processedSentences++;
|
||||||
|
onProgress((processedSentences / totalSentences) * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioChunks.length === 0) {
|
||||||
|
throw new Error('No audio was generated from the book content');
|
||||||
|
}
|
||||||
|
|
||||||
|
return combineAudioChunks(audioChunks);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating audiobook:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [extractBookText, apiKey, baseUrl, voice, voiceSpeed]);
|
||||||
|
|
||||||
|
// Helper function to combine audio chunks
|
||||||
|
const combineAudioChunks = (audioChunks: ArrayBuffer[]): ArrayBuffer => {
|
||||||
|
const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.byteLength, 0);
|
||||||
|
const combinedBuffer = new Uint8Array(totalLength);
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of audioChunks) {
|
||||||
|
combinedBuffer.set(new Uint8Array(chunk), offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
return combinedBuffer.buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRendition = useCallback((rendition: Rendition) => {
|
||||||
|
bookRef.current = rendition.book;
|
||||||
|
renditionRef.current = rendition;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLocationChanged = useCallback((location: string | number) => {
|
||||||
|
// Set the EPUB flag once the location changes
|
||||||
|
if (!isEPUBSetOnce.current) {
|
||||||
|
setIsEPUB(true);
|
||||||
|
isEPUBSetOnce.current = true;
|
||||||
|
|
||||||
|
renditionRef.current?.display(location.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bookRef.current?.isOpen || !renditionRef.current) return;
|
||||||
|
|
||||||
|
// Handle special 'next' and 'prev' cases
|
||||||
|
if (location === 'next' && renditionRef.current) {
|
||||||
|
renditionRef.current.next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (location === 'prev' && renditionRef.current) {
|
||||||
|
renditionRef.current.prev();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the location to IndexedDB if not initial
|
||||||
|
if (id && locationRef.current !== 1) {
|
||||||
|
console.log('Saving location:', location);
|
||||||
|
setLastDocumentLocation(id as string, location.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
skipToLocation(location);
|
||||||
|
|
||||||
|
locationRef.current = location;
|
||||||
|
if (bookRef.current && renditionRef.current) {
|
||||||
|
extractPageText(bookRef.current, renditionRef.current);
|
||||||
|
}
|
||||||
|
}, [id, skipToLocation, extractPageText, setIsEPUB]);
|
||||||
|
|
||||||
// Context value memoization
|
// Context value memoization
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -117,6 +387,13 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
currDocText,
|
currDocText,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
|
createFullAudioBook,
|
||||||
|
bookRef,
|
||||||
|
renditionRef,
|
||||||
|
tocRef,
|
||||||
|
locationRef,
|
||||||
|
handleLocationChanged,
|
||||||
|
setRendition,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
|
|
@ -127,6 +404,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
currDocText,
|
currDocText,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
|
createFullAudioBook,
|
||||||
|
handleLocationChanged,
|
||||||
|
setRendition,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue