Setting for themed epubs
This commit is contained in:
parent
09188ecab2
commit
1dcf1b09e3
6 changed files with 157 additions and 14 deletions
|
|
@ -20,7 +20,6 @@ export default function EPUBPage() {
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
|
||||||
const loadDocument = useCallback(async () => {
|
const loadDocument = useCallback(async () => {
|
||||||
if (!isLoading) return;
|
|
||||||
console.log('Loading new epub (from page.tsx)');
|
console.log('Loading new epub (from page.tsx)');
|
||||||
stop(); // Reset TTS when loading new document
|
stop(); // Reset TTS when loading new document
|
||||||
|
|
||||||
|
|
@ -36,11 +35,13 @@ export default function EPUBPage() {
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [isLoading, id, setCurrentDocument, stop]);
|
}, [id, setCurrentDocument, stop]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isLoading) return;
|
||||||
|
|
||||||
loadDocument();
|
loadDocument();
|
||||||
}, [loadDocument]);
|
}, [loadDocument, isLoading]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const viewTypes = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsProps) {
|
export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsProps) {
|
||||||
const { viewType, skipBlank, updateConfigKey } = useConfig();
|
const { viewType, skipBlank, epubTheme, updateConfigKey } = useConfig();
|
||||||
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0];
|
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -124,6 +124,22 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
|
||||||
Automatically skip pages with no text content
|
Automatically skip pages with no text content
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{epub && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={epubTheme}
|
||||||
|
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>
|
||||||
|
<p className="text-sm text-muted pl-6">
|
||||||
|
Apply the current app theme to the EPUB viewer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
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';
|
||||||
|
|
@ -9,12 +9,80 @@ import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
import { setLastDocumentLocation } from '@/utils/indexedDB';
|
import { setLastDocumentLocation } from '@/utils/indexedDB';
|
||||||
import type { Rendition, Book, NavItem } from 'epubjs';
|
import type { Rendition, Book, NavItem } from 'epubjs';
|
||||||
|
import { ReactReaderStyle, type IReactReaderStyle } from 'react-reader';
|
||||||
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
|
||||||
const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), {
|
const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => <DocumentSkeleton />
|
loading: () => <DocumentSkeleton />
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
background: getComputedStyle(document.documentElement).getPropertyValue('--background'),
|
||||||
|
foreground: getComputedStyle(document.documentElement).getPropertyValue('--foreground'),
|
||||||
|
base: getComputedStyle(document.documentElement).getPropertyValue('--base'),
|
||||||
|
offbase: getComputedStyle(document.documentElement).getPropertyValue('--offbase'),
|
||||||
|
muted: getComputedStyle(document.documentElement).getPropertyValue('--muted'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const getThemeStyles = (): IReactReaderStyle => {
|
||||||
|
const baseStyle = {
|
||||||
|
...ReactReaderStyle,
|
||||||
|
readerArea: {
|
||||||
|
...ReactReaderStyle.readerArea,
|
||||||
|
transition: undefined,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseStyle,
|
||||||
|
arrow: {
|
||||||
|
...baseStyle.arrow,
|
||||||
|
color: colors.foreground,
|
||||||
|
},
|
||||||
|
arrowHover: {
|
||||||
|
...baseStyle.arrowHover,
|
||||||
|
color: colors.muted,
|
||||||
|
},
|
||||||
|
readerArea: {
|
||||||
|
...baseStyle.readerArea,
|
||||||
|
backgroundColor: colors.base,
|
||||||
|
},
|
||||||
|
titleArea: {
|
||||||
|
...baseStyle.titleArea,
|
||||||
|
color: colors.foreground,
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
tocArea: {
|
||||||
|
...baseStyle.tocArea,
|
||||||
|
background: colors.base,
|
||||||
|
},
|
||||||
|
tocButtonExpanded: {
|
||||||
|
...baseStyle.tocButtonExpanded,
|
||||||
|
background: colors.offbase,
|
||||||
|
},
|
||||||
|
tocButtonBar: {
|
||||||
|
...baseStyle.tocButtonBar,
|
||||||
|
background: colors.muted,
|
||||||
|
},
|
||||||
|
tocButton: {
|
||||||
|
...baseStyle.tocButton,
|
||||||
|
color: colors.muted,
|
||||||
|
},
|
||||||
|
tocAreaButton: {
|
||||||
|
...baseStyle.tocAreaButton,
|
||||||
|
color: colors.muted,
|
||||||
|
backgroundColor: colors.offbase,
|
||||||
|
padding: '0.25rem',
|
||||||
|
paddingLeft: '0.5rem',
|
||||||
|
paddingRight: '0.5rem',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface EPUBViewerProps {
|
interface EPUBViewerProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -23,13 +91,16 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { currDocData, currDocName, currDocPage, extractPageText } = useEPUB();
|
const { currDocData, currDocName, currDocPage, extractPageText } = useEPUB();
|
||||||
const { setEPUBPageInChapter, registerLocationChangeHandler } = useTTS();
|
const { setEPUBPageInChapter, registerLocationChangeHandler } = useTTS();
|
||||||
|
const { epubTheme } = useConfig();
|
||||||
const bookRef = useRef<Book | null>(null);
|
const bookRef = useRef<Book | null>(null);
|
||||||
const rendition = useRef<Rendition | undefined>(undefined);
|
const rendition = useRef<Rendition | undefined>(undefined);
|
||||||
const toc = useRef<NavItem[]>([]);
|
const toc = useRef<NavItem[]>([]);
|
||||||
const locationRef = useRef<string | number>(currDocPage);
|
const locationRef = useRef<string | number>(currDocPage);
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
const [initialPrevLocLoad, setInitialPrevLocLoad] = useState(false);
|
||||||
|
|
||||||
const handleLocationChanged = useCallback((location: string | number, initial = false) => {
|
const handleLocationChanged = useCallback((location: string | number, initial = false) => {
|
||||||
|
if (!bookRef.current?.isOpen) return;
|
||||||
// Handle special 'next' and 'prev' cases, which
|
// Handle special 'next' and 'prev' cases, which
|
||||||
if (location === 'next' && rendition.current) {
|
if (location === 'next' && rendition.current) {
|
||||||
rendition.current.next();
|
rendition.current.next();
|
||||||
|
|
@ -60,17 +131,55 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
|
|
||||||
// Add a small delay for initial load to ensure rendition is ready
|
// Add a small delay for initial load to ensure rendition is ready
|
||||||
if (initial) {
|
if (initial) {
|
||||||
setTimeout(() => {
|
setInitialPrevLocLoad(true);
|
||||||
if (bookRef.current && rendition.current) {
|
|
||||||
extractPageText(bookRef.current, rendition.current);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
} else {
|
} else {
|
||||||
extractPageText(bookRef.current, rendition.current);
|
extractPageText(bookRef.current, rendition.current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [id, setEPUBPageInChapter, extractPageText]);
|
}, [id, setEPUBPageInChapter, extractPageText]);
|
||||||
|
|
||||||
|
// Load the initial location
|
||||||
|
useEffect(() => {
|
||||||
|
if (bookRef.current && rendition.current) {
|
||||||
|
extractPageText(bookRef.current, rendition.current);
|
||||||
|
}
|
||||||
|
}, [extractPageText, initialPrevLocLoad]);
|
||||||
|
|
||||||
|
const updateTheme = useCallback((rendition: Rendition) => {
|
||||||
|
if (!epubTheme) return; // Only apply theme if enabled
|
||||||
|
|
||||||
|
rendition.themes.override('color', colors.foreground);
|
||||||
|
rendition.themes.override('background', colors.base);
|
||||||
|
}, [epubTheme]);
|
||||||
|
|
||||||
|
// Watch for theme changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!epubTheme || !bookRef.current?.isOpen || !rendition.current) return;
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.attributeName === 'class') {
|
||||||
|
if (epubTheme) {
|
||||||
|
setReloadKey(prev => prev + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class']
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [epubTheme]);
|
||||||
|
|
||||||
|
// Watch for epubTheme changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!epubTheme || !bookRef.current?.isOpen || !rendition.current) return;
|
||||||
|
setReloadKey(prev => prev + 1);
|
||||||
|
}, [epubTheme]);
|
||||||
|
|
||||||
// Register the location change handler
|
// Register the location change handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerLocationChangeHandler(handleLocationChanged);
|
registerLocationChangeHandler(handleLocationChanged);
|
||||||
|
|
@ -87,13 +196,17 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 -mt-16 pt-16">
|
<div className="flex-1 -mt-16 pt-16">
|
||||||
<ReactReader
|
<ReactReader
|
||||||
|
key={reloadKey} // Add this line to force remount
|
||||||
location={locationRef.current}
|
location={locationRef.current}
|
||||||
locationChanged={handleLocationChanged}
|
locationChanged={handleLocationChanged}
|
||||||
url={currDocData}
|
url={currDocData}
|
||||||
title={currDocName}
|
title={currDocName}
|
||||||
tocChanged={(_toc) => (toc.current = _toc)}
|
tocChanged={(_toc) => (toc.current = _toc)}
|
||||||
showToc={true}
|
showToc={true}
|
||||||
|
readerStyles={epubTheme && getThemeStyles() || undefined}
|
||||||
getRendition={(_rendition: Rendition) => {
|
getRendition={(_rendition: Rendition) => {
|
||||||
|
updateTheme(_rendition);
|
||||||
|
|
||||||
bookRef.current = _rendition.book;
|
bookRef.current = _rendition.book;
|
||||||
rendition.current = _rendition;
|
rendition.current = _rendition;
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-49 transition-opacity duration-300`}>
|
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-49 transition-opacity duration-300`}>
|
||||||
<div className="bg-base dark:bg-base rounded-full shadow-lg px-3 sm:px-4 py-0.5 sm:py-1 flex items-center space-x-0.5 sm:space-x-1 relative scale-90 sm:scale-100">
|
<div className="bg-base dark:bg-base rounded-full shadow-lg px-3 sm:px-4 py-0.5 sm:py-1 flex items-center space-x-0.5 sm:space-x-1 relative scale-90 sm:scale-100 border border-offbase">
|
||||||
{/* Speed control */}
|
{/* Speed control */}
|
||||||
<SpeedControl setSpeedAndRestart={setSpeedAndRestart} />
|
<SpeedControl setSpeedAndRestart={setSpeedAndRestart} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ interface ConfigContextType {
|
||||||
voiceSpeed: number;
|
voiceSpeed: number;
|
||||||
voice: string;
|
voice: string;
|
||||||
skipBlank: boolean;
|
skipBlank: boolean;
|
||||||
|
epubTheme: boolean; // Add this line
|
||||||
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
|
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
|
||||||
updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>;
|
updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|
@ -25,6 +26,7 @@ type ConfigValues = {
|
||||||
voiceSpeed: number;
|
voiceSpeed: number;
|
||||||
voice: string;
|
voice: string;
|
||||||
skipBlank: boolean;
|
skipBlank: boolean;
|
||||||
|
epubTheme: boolean; // Add this line
|
||||||
};
|
};
|
||||||
|
|
||||||
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
|
|
@ -37,6 +39,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
const [voiceSpeed, setVoiceSpeed] = useState<number>(1);
|
const [voiceSpeed, setVoiceSpeed] = useState<number>(1);
|
||||||
const [voice, setVoice] = useState<string>('af_sarah');
|
const [voice, setVoice] = useState<string>('af_sarah');
|
||||||
const [skipBlank, setSkipBlank] = useState<boolean>(true);
|
const [skipBlank, setSkipBlank] = useState<boolean>(true);
|
||||||
|
const [epubTheme, setEpubTheme] = useState<boolean>(false);
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isDBReady, setIsDBReady] = useState(false);
|
const [isDBReady, setIsDBReady] = useState(false);
|
||||||
|
|
@ -55,6 +58,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
const cachedVoiceSpeed = await getItem('voiceSpeed');
|
const cachedVoiceSpeed = await getItem('voiceSpeed');
|
||||||
const cachedVoice = await getItem('voice');
|
const cachedVoice = await getItem('voice');
|
||||||
const cachedSkipBlank = await getItem('skipBlank');
|
const cachedSkipBlank = await getItem('skipBlank');
|
||||||
|
const cachedEpubTheme = await getItem('epubTheme');
|
||||||
|
|
||||||
if (cachedApiKey) console.log('Cached API key found:', cachedApiKey);
|
if (cachedApiKey) console.log('Cached API key found:', cachedApiKey);
|
||||||
if (cachedBaseUrl) console.log('Cached base URL found:', cachedBaseUrl);
|
if (cachedBaseUrl) console.log('Cached base URL found:', cachedBaseUrl);
|
||||||
|
|
@ -62,6 +66,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
if (cachedVoiceSpeed) console.log('Cached voice speed found:', cachedVoiceSpeed);
|
if (cachedVoiceSpeed) console.log('Cached voice speed found:', cachedVoiceSpeed);
|
||||||
if (cachedVoice) console.log('Cached voice found:', cachedVoice);
|
if (cachedVoice) console.log('Cached voice found:', cachedVoice);
|
||||||
if (cachedSkipBlank) console.log('Cached skip blank found:', cachedSkipBlank);
|
if (cachedSkipBlank) console.log('Cached skip blank found:', cachedSkipBlank);
|
||||||
|
if (cachedEpubTheme) console.log('Cached EPUB theme found:', cachedEpubTheme);
|
||||||
|
|
||||||
// If not in cache, use env variables
|
// If not in cache, use env variables
|
||||||
const defaultApiKey = process.env.NEXT_PUBLIC_OPENAI_API_KEY || '1234567890';
|
const defaultApiKey = process.env.NEXT_PUBLIC_OPENAI_API_KEY || '1234567890';
|
||||||
|
|
@ -74,6 +79,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
setVoiceSpeed(parseFloat(cachedVoiceSpeed || '1'));
|
setVoiceSpeed(parseFloat(cachedVoiceSpeed || '1'));
|
||||||
setVoice(cachedVoice || 'af_sarah');
|
setVoice(cachedVoice || 'af_sarah');
|
||||||
setSkipBlank(cachedSkipBlank === 'false' ? false : true);
|
setSkipBlank(cachedSkipBlank === 'false' ? false : true);
|
||||||
|
setEpubTheme(cachedEpubTheme === 'true');
|
||||||
|
|
||||||
// If not in cache, save to cache
|
// If not in cache, save to cache
|
||||||
if (!cachedApiKey) {
|
if (!cachedApiKey) {
|
||||||
|
|
@ -88,6 +94,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
if (cachedSkipBlank === null) {
|
if (cachedSkipBlank === null) {
|
||||||
await setItem('skipBlank', 'true');
|
await setItem('skipBlank', 'true');
|
||||||
}
|
}
|
||||||
|
if (cachedEpubTheme === null) {
|
||||||
|
await setItem('epubTheme', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing:', error);
|
console.error('Error initializing:', error);
|
||||||
|
|
@ -137,6 +146,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
case 'skipBlank':
|
case 'skipBlank':
|
||||||
setSkipBlank(value as boolean);
|
setSkipBlank(value as boolean);
|
||||||
break;
|
break;
|
||||||
|
case 'epubTheme':
|
||||||
|
setEpubTheme(value as boolean);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error updating config key ${key}:`, error);
|
console.error(`Error updating config key ${key}:`, error);
|
||||||
|
|
@ -152,6 +164,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
voiceSpeed,
|
voiceSpeed,
|
||||||
voice,
|
voice,
|
||||||
skipBlank,
|
skipBlank,
|
||||||
|
epubTheme,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
updateConfigKey,
|
updateConfigKey,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
*/
|
*/
|
||||||
const extractPageText = useCallback(async (book: Book, rendition: Rendition): Promise<string> => {
|
const extractPageText = useCallback(async (book: Book, rendition: Rendition): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const { start, end } = rendition.location;
|
const { start, end } = rendition?.location;
|
||||||
if (!start?.cfi || !end?.cfi) 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);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue