forked from akai/readest
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f11f437e1 | |||
| 5773e31990 | |||
| 9dda5d4c88 | |||
| 7f5b6959e1 | |||
| 126981d085 | |||
| 854a578929 | |||
| 6dd0daa597 | |||
| fd24a5e9ac | |||
| 948b35f244 | |||
| 96cc182a8c | |||
| e2291ed5b1 | |||
| 60ddb17547 | |||
| 9a991a31ba |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.85",
|
||||
"version": "0.9.87",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev --turbopack",
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
{
|
||||
"releases": {
|
||||
"0.9.87": {
|
||||
"date": "2025-10-16",
|
||||
"notes": [
|
||||
"Reader: Improved chapter detection with better chapter title recognition",
|
||||
"Reader: Enhanced compatibility for parsing more footnote formats",
|
||||
"Theme: Fixed auto theme switching in new reader windows",
|
||||
"TTS: Improved scrolling accuracy to highlighted text during read-aloud",
|
||||
"TTS: Fixed crash issues on certain Android systems"
|
||||
]
|
||||
},
|
||||
"0.9.86": {
|
||||
"date": "2025-10-14",
|
||||
"notes": [
|
||||
"Reader: Added support for per-book background image customization",
|
||||
"Reader: Added option to override book language metadata for improved hyphenation accuracy",
|
||||
"Sync: Readest sync in KOReader can now be triggered using gesture controls",
|
||||
"Sync: Fixed a crash in KOReader that occurred when syncing progress without an open book",
|
||||
"Sync: Improved upload performance with concurrent upload support"
|
||||
]
|
||||
},
|
||||
"0.9.85": {
|
||||
"date": "2025-10-14",
|
||||
"notes": [
|
||||
|
||||
+38
-1
@@ -161,10 +161,47 @@ class MediaPlaybackService : MediaBrowserServiceCompat() {
|
||||
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
setSmallIcon(R.drawable.notification_icon)
|
||||
|
||||
addAction(
|
||||
android.R.drawable.ic_media_previous,
|
||||
"Previous",
|
||||
MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||
this@MediaPlaybackService,
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||
)
|
||||
)
|
||||
if (playbackState == PlaybackStateCompat.STATE_PLAYING) {
|
||||
addAction(
|
||||
android.R.drawable.ic_media_pause,
|
||||
"Pause",
|
||||
MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||
this@MediaPlaybackService,
|
||||
PlaybackStateCompat.ACTION_PAUSE
|
||||
)
|
||||
)
|
||||
} else {
|
||||
addAction(
|
||||
android.R.drawable.ic_media_play,
|
||||
"Play",
|
||||
MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||
this@MediaPlaybackService,
|
||||
PlaybackStateCompat.ACTION_PLAY
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
addAction(
|
||||
android.R.drawable.ic_media_next,
|
||||
"Next",
|
||||
MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||
this@MediaPlaybackService,
|
||||
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
|
||||
)
|
||||
)
|
||||
|
||||
setStyle(
|
||||
androidx.media.app.NotificationCompat.MediaStyle()
|
||||
.setMediaSession(mediaSession?.sessionToken)
|
||||
.setShowActionsInCompactView(0)
|
||||
.setShowActionsInCompactView(0, 1, 2)
|
||||
)
|
||||
}
|
||||
return builder.build()
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
|
||||
"connect-src": "'self' blob: data: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
|
||||
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.netlify.app",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.netlify.app",
|
||||
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
|
||||
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
|
||||
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
|
||||
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
|
||||
},
|
||||
|
||||
@@ -31,11 +31,12 @@ interface BookshelfProps {
|
||||
isSelectAll: boolean;
|
||||
isSelectNone: boolean;
|
||||
handleImportBooks: () => void;
|
||||
handleBookUpload: (book: Book) => Promise<boolean>;
|
||||
handleBookDownload: (book: Book) => Promise<boolean>;
|
||||
handleBookDelete: (book: Book) => Promise<boolean>;
|
||||
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
handlePushLibrary: () => Promise<void>;
|
||||
booksTransferProgress: { [key: string]: number | null };
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handleBookDelete,
|
||||
handleSetSelectMode,
|
||||
handleShowDetailsBook,
|
||||
handlePushLibrary,
|
||||
booksTransferProgress,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
@@ -217,9 +219,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
for (const book of getBooksToDelete()) {
|
||||
handleBookDelete(book);
|
||||
const books = getBooksToDelete();
|
||||
const concurrency = 4;
|
||||
|
||||
for (let i = 0; i < books.length; i += concurrency) {
|
||||
const batch = books.slice(i, i + concurrency);
|
||||
await Promise.all(batch.map((book) => handleBookDelete(book, false)));
|
||||
}
|
||||
handlePushLibrary();
|
||||
setSelectedBooks([]);
|
||||
setShowDeleteAlert(false);
|
||||
setShowSelectModeActions(true);
|
||||
|
||||
@@ -77,9 +77,9 @@ interface BookshelfItemProps {
|
||||
transferProgress: number | null;
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
toggleSelection: (hash: string) => void;
|
||||
handleBookUpload: (book: Book) => Promise<boolean>;
|
||||
handleBookDownload: (book: Book) => Promise<boolean>;
|
||||
handleBookDelete: (book: Book) => Promise<boolean>;
|
||||
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
|
||||
handleSetSelectMode: (selectMode: boolean) => void;
|
||||
handleShowDetailsBook: (book: Book) => void;
|
||||
}
|
||||
|
||||
@@ -419,41 +419,56 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
|
||||
const importBooks = async (files: SelectedFile[]) => {
|
||||
setLoading(true);
|
||||
const failedFiles = [];
|
||||
const { library } = useLibraryStore.getState();
|
||||
const failedImports: Array<{ filename: string; errorMessage: string }> = [];
|
||||
const errorMap: [string, string][] = [
|
||||
['No chapters detected.', _('No chapters detected.')],
|
||||
['Failed to parse EPUB.', _('Failed to parse the EPUB file.')],
|
||||
['Unsupported format.', _('This book format is not supported.')],
|
||||
];
|
||||
const { library } = useLibraryStore.getState();
|
||||
for (const selectedFile of files) {
|
||||
|
||||
const processFile = async (selectedFile: SelectedFile) => {
|
||||
const file = selectedFile.file || selectedFile.path;
|
||||
if (!file) continue;
|
||||
if (!file) return;
|
||||
try {
|
||||
const book = await appService?.importBook(file, library);
|
||||
setLibrary([...library]);
|
||||
if (user && book && !book.uploadedAt && settings.autoUpload) {
|
||||
console.log('Uploading book:', book.title);
|
||||
handleBookUpload(book);
|
||||
handleBookUpload(book, false);
|
||||
}
|
||||
} catch (error) {
|
||||
const filename = typeof file === 'string' ? file : file.name;
|
||||
const baseFilename = getFilename(filename);
|
||||
failedFiles.push(baseFilename);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? errorMap.find(([substring]) => error.message.includes(substring))?.[1] || ''
|
||||
: '';
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message:
|
||||
_('Failed to import book(s): {{filenames}}', {
|
||||
filenames: listFormater(false).format(failedFiles),
|
||||
}) + (errorMessage ? `\n${errorMessage}` : ''),
|
||||
type: 'error',
|
||||
});
|
||||
failedImports.push({ filename: baseFilename, errorMessage });
|
||||
console.error('Failed to import book:', filename, error);
|
||||
}
|
||||
};
|
||||
|
||||
const concurrency = 4;
|
||||
for (let i = 0; i < files.length; i += concurrency) {
|
||||
const batch = files.slice(i, i + concurrency);
|
||||
await Promise.all(batch.map(processFile));
|
||||
}
|
||||
pushLibrary();
|
||||
|
||||
if (failedImports.length > 0) {
|
||||
const filenames = failedImports.map((f) => f.filename);
|
||||
const errorMessage = failedImports.find((f) => f.errorMessage)?.errorMessage || '';
|
||||
|
||||
eventDispatcher.dispatch('toast', {
|
||||
message:
|
||||
_('Failed to import book(s): {{filenames}}', {
|
||||
filenames: listFormater(false).format(filenames),
|
||||
}) + (errorMessage ? `\n${errorMessage}` : ''),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
setLibrary([...library]);
|
||||
appService?.saveLibraryBooks(library);
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -468,13 +483,18 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
}, 500);
|
||||
|
||||
const handleBookUpload = useCallback(
|
||||
async (book: Book) => {
|
||||
async (book: Book, syncBooks = true) => {
|
||||
try {
|
||||
await appService?.uploadBook(book, (progress) => {
|
||||
updateBookTransferProgress(book.hash, progress);
|
||||
});
|
||||
setBooksTransferProgress((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[book.hash];
|
||||
return updated;
|
||||
});
|
||||
await updateBook(envConfig, book);
|
||||
pushLibrary();
|
||||
if (syncBooks) pushLibrary();
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
timeout: 2000,
|
||||
@@ -484,6 +504,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
setBooksTransferProgress((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[book.hash];
|
||||
return updated;
|
||||
});
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('Not authenticated') && settings.keepLogin) {
|
||||
settings.keepLogin = false;
|
||||
@@ -541,7 +566,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
);
|
||||
|
||||
const handleBookDelete = (deleteAction: DeleteAction) => {
|
||||
return async (book: Book) => {
|
||||
return async (book: Book, syncBooks = true) => {
|
||||
const deletionMessages = {
|
||||
both: _('Book deleted: {{title}}', { title: book.title }),
|
||||
cloud: _('Deleted cloud backup of the book: {{title}}', { title: book.title }),
|
||||
@@ -555,7 +580,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
try {
|
||||
await appService?.deleteBook(book, deleteAction);
|
||||
await updateBook(envConfig, book);
|
||||
pushLibrary();
|
||||
if (syncBooks) pushLibrary();
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
timeout: 2000,
|
||||
@@ -709,6 +734,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
handleSetSelectMode={handleSetSelectMode}
|
||||
handleShowDetailsBook={handleShowDetailsBook}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
handlePushLibrary={pushLibrary}
|
||||
/>
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { usePagination } from '../hooks/usePagination';
|
||||
import { useFoliateEvents } from '../hooks/useFoliateEvents';
|
||||
import { useProgressSync } from '../hooks/useProgressSync';
|
||||
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
|
||||
import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
|
||||
import { useAutoFocus } from '@/hooks/useAutoFocus';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useKOSync } from '../hooks/useKOSync';
|
||||
@@ -42,6 +43,7 @@ import { getMaxInlineSize } from '@/utils/config';
|
||||
import { getDirFromUILanguage } from '@/utils/rtl';
|
||||
import { isCJKLang } from '@/utils/lang';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { TransformContext } from '@/services/transformers/types';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
import { lockScreenOrientation } from '@/utils/bridge';
|
||||
import { useTextTranslation } from '../hooks/useTextTranslation';
|
||||
@@ -73,6 +75,7 @@ const FoliateViewer: React.FC<{
|
||||
const { getViewState, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { getParallels } = useParallelViewStore();
|
||||
const { getBookData } = useBookDataStore();
|
||||
const { applyBackgroundTexture } = useBackgroundTexture();
|
||||
const viewState = getViewState(bookKey);
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
|
||||
@@ -116,15 +119,17 @@ const FoliateViewer: React.FC<{
|
||||
detail.data = Promise.resolve(detail.data)
|
||||
.then((data) => {
|
||||
const viewSettings = getViewSettings(bookKey);
|
||||
const bookData = getBookData(bookKey);
|
||||
if (viewSettings && detail.type === 'text/css')
|
||||
return transformStylesheet(width, height, data);
|
||||
if (viewSettings && detail.type === 'application/xhtml+xml') {
|
||||
if (viewSettings && bookData && detail.type === 'application/xhtml+xml') {
|
||||
const ctx = {
|
||||
bookKey,
|
||||
viewSettings,
|
||||
primaryLanguage: bookData.book?.primaryLanguage,
|
||||
content: data,
|
||||
transformers: ['punctuation', 'footnote', 'language'],
|
||||
};
|
||||
transformers: ['punctuation', 'footnote', 'whitespace', 'language'],
|
||||
} as TransformContext;
|
||||
return Promise.resolve(transformContent(ctx));
|
||||
}
|
||||
return data;
|
||||
@@ -145,14 +150,19 @@ const FoliateViewer: React.FC<{
|
||||
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
const bookData = getBookData(bookKey)!;
|
||||
viewSettings.vertical =
|
||||
|
||||
const newVertical =
|
||||
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
|
||||
viewSettings.rtl =
|
||||
const newRtl =
|
||||
writingDir?.rtl ||
|
||||
getDirFromUILanguage() === 'rtl' ||
|
||||
viewSettings.writingMode.includes('rl') ||
|
||||
false;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
if (viewSettings.vertical !== newVertical || viewSettings.rtl !== newRtl) {
|
||||
viewSettings.vertical = newVertical;
|
||||
viewSettings.rtl = newRtl;
|
||||
setViewSettings(bookKey, { ...viewSettings });
|
||||
}
|
||||
|
||||
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
|
||||
|
||||
@@ -395,6 +405,17 @@ const FoliateViewer: React.FC<{
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings.customFonts, envConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewSettings) return;
|
||||
applyBackgroundTexture(envConfig, viewSettings);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
viewSettings?.backgroundTextureId,
|
||||
viewSettings?.backgroundOpacity,
|
||||
viewSettings?.backgroundSize,
|
||||
applyBackgroundTexture,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewRef.current && viewRef.current.renderer) {
|
||||
doubleClickDisabled.current = !!viewSettings?.disableDoubleClick;
|
||||
|
||||
@@ -68,8 +68,8 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
|
||||
}
|
||||
: {
|
||||
top: `${topInset}px`,
|
||||
insetInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
|
||||
width: `calc(100% - ${contentInsets.left + contentInsets.right}px)`,
|
||||
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
|
||||
width: '100%',
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { BookSearchConfig, BookSearchResult } from '@/types/book';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { isCJKStr } from '@/utils/lang';
|
||||
import { createRejecttFilter } from '@/utils/node';
|
||||
import { createRejectFilter } from '@/utils/node';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import SearchOptions from './SearchOptions';
|
||||
|
||||
@@ -127,7 +127,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
...searchConfig,
|
||||
index,
|
||||
query: term,
|
||||
acceptNode: createRejecttFilter({
|
||||
acceptNode: createRejectFilter({
|
||||
tags: primaryLang.startsWith('ja') ? ['rt'] : [],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -208,6 +208,30 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
|
||||
viewSettings.ttsLocation = cfi || '';
|
||||
setViewSettings(bookKey, viewSettings);
|
||||
}
|
||||
|
||||
if (!view.renderer.scrolled) {
|
||||
view.renderer.scrollToAnchor(range);
|
||||
} else {
|
||||
const rect = range.getBoundingClientRect();
|
||||
const { start, size, viewSize, sideProp } = view.renderer;
|
||||
const positionStart = rect[sideProp === 'height' ? 'y' : 'x'] + viewSettings.marginTopPx;
|
||||
const positionEnd = rect[sideProp === 'height' ? 'height' : 'width'] + positionStart;
|
||||
const offsetStart = view.book.dir === 'rtl' ? viewSize - positionStart : positionStart;
|
||||
const offsetEnd = view.book.dir === 'rtl' ? viewSize - positionEnd : positionEnd;
|
||||
|
||||
const showHeader = viewSettings.showHeader;
|
||||
const showFooter = viewSettings.showFooter;
|
||||
const showBarsOnScroll = viewSettings.showBarsOnScroll;
|
||||
const headerScrollOverlap = showHeader && showBarsOnScroll ? 44 : 0;
|
||||
const footerScrollOverlap = showFooter && showBarsOnScroll ? 44 : 0;
|
||||
const scrollingOverlap = viewSettings.scrollingOverlap;
|
||||
const endInNextView = offsetEnd > start + size - footerScrollOverlap - scrollingOverlap;
|
||||
const startInPrevView = offsetStart < start + headerScrollOverlap + scrollingOverlap;
|
||||
if (endInNextView || startInPrevView) {
|
||||
const scrollTo = offsetStart - headerScrollOverlap - scrollingOverlap;
|
||||
view.renderer.scrollToAnchor(scrollTo / viewSize);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
|
||||
|
||||
@@ -142,7 +142,11 @@ export const useProgressSync = (bookKey: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
Object.entries(syncedConfig).filter(([_, value]) => value !== null && value !== undefined),
|
||||
);
|
||||
setConfig(bookKey, { ...config, ...filteredSyncedConfig });
|
||||
if (syncedConfig.updatedAt >= config.updatedAt) {
|
||||
setConfig(bookKey, { ...config, ...filteredSyncedConfig });
|
||||
} else {
|
||||
setConfig(bookKey, { ...filteredSyncedConfig, ...config });
|
||||
}
|
||||
if (remoteCFILocation && configCFI) {
|
||||
if (CFI.compare(configCFI, remoteCFILocation) < 0) {
|
||||
if (view) {
|
||||
|
||||
@@ -12,14 +12,14 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useDeviceControlStore } from '@/store/deviceStore';
|
||||
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
|
||||
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
|
||||
import { useCustomTextureStore } from '@/store/customTextureStore';
|
||||
import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
|
||||
import { getLocale } from '@/utils/misc';
|
||||
|
||||
const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
const { envConfig, appService } = useEnv();
|
||||
const { applyUILanguage } = useSettingsStore();
|
||||
const { setScreenBrightness } = useDeviceControlStore();
|
||||
const { addTexture, applyTexture } = useCustomTextureStore();
|
||||
const { applyBackgroundTexture } = useBackgroundTexture();
|
||||
const iconSize = useDefaultIconSize();
|
||||
useSafeAreaInsets(); // Initialize safe area insets
|
||||
|
||||
@@ -47,24 +47,10 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
if (appService.hasScreenBrightness && brightness >= 0) {
|
||||
setScreenBrightness(brightness / 100);
|
||||
}
|
||||
const backgroundTextureId = globalViewSettings.backgroundTextureId;
|
||||
const backgroundOpacity = globalViewSettings.backgroundOpacity;
|
||||
const backgroundSize = globalViewSettings.backgroundSize;
|
||||
if (backgroundTextureId && backgroundTextureId !== 'none') {
|
||||
const customTexture = settings.customTextures.find((t) => t.id === backgroundTextureId);
|
||||
if (customTexture) {
|
||||
addTexture(customTexture.path);
|
||||
}
|
||||
applyTexture(envConfig, backgroundTextureId);
|
||||
document.documentElement.style.setProperty(
|
||||
'--bg-texture-opacity',
|
||||
backgroundOpacity.toString(),
|
||||
);
|
||||
document.documentElement.style.setProperty('--bg-texture-size', backgroundSize);
|
||||
}
|
||||
applyBackgroundTexture(envConfig, globalViewSettings);
|
||||
});
|
||||
}
|
||||
}, [envConfig, appService, applyUILanguage, addTexture, applyTexture, setScreenBrightness]);
|
||||
}, [envConfig, appService, applyUILanguage, setScreenBrightness, applyBackgroundTexture]);
|
||||
|
||||
// Make sure appService is available in all children components
|
||||
if (!appService) return;
|
||||
|
||||
@@ -142,10 +142,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
|
||||
|
||||
const applyBackgroundTexture = () => {
|
||||
applyTexture(envConfig, selectedTextureId);
|
||||
document.documentElement.style.setProperty(
|
||||
'--bg-texture-opacity',
|
||||
backgroundOpacity.toString(),
|
||||
);
|
||||
document.documentElement.style.setProperty('--bg-texture-opacity', `${backgroundOpacity}`);
|
||||
document.documentElement.style.setProperty('--bg-texture-size', backgroundSize);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCustomTextureStore } from '@/store/customTextureStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { ViewSettings } from '@/types/book';
|
||||
|
||||
export const useBackgroundTexture = () => {
|
||||
const applyBackgroundTexture = useCallback(
|
||||
(envConfig: EnvConfigType, viewSettings: ViewSettings) => {
|
||||
const textureId = viewSettings.backgroundTextureId;
|
||||
const textureOpacity = viewSettings.backgroundOpacity;
|
||||
const textureSize = viewSettings.backgroundSize;
|
||||
if (!textureId || textureId === 'none') return;
|
||||
|
||||
document.documentElement.style.setProperty('--bg-texture-opacity', `${textureOpacity}`);
|
||||
document.documentElement.style.setProperty('--bg-texture-size', textureSize);
|
||||
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
const customTexture = settings.customTextures?.find((t) => t.id === textureId);
|
||||
|
||||
if (customTexture) {
|
||||
useCustomTextureStore.getState().addTexture(customTexture.path);
|
||||
}
|
||||
|
||||
useCustomTextureStore.getState().applyTexture(envConfig, textureId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { applyBackgroundTexture };
|
||||
};
|
||||
@@ -155,7 +155,7 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
|
||||
overrideColor: false,
|
||||
backgroundTextureId: 'none',
|
||||
backgroundOpacity: 0.6,
|
||||
backgroundSize: 'auto',
|
||||
backgroundSize: 'cover',
|
||||
codeHighlighting: false,
|
||||
codeLanguage: 'auto-detect',
|
||||
userStylesheet: '',
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { Transformer } from './types';
|
||||
import { footnoteTransformer } from './footnote';
|
||||
import { languageTransformer } from './language';
|
||||
import { punctuationTransformer } from './punctuation';
|
||||
import { whitespaceTransformer } from './whitespace';
|
||||
|
||||
export const availableTransformers: Transformer[] = [
|
||||
punctuationTransformer,
|
||||
footnoteTransformer,
|
||||
languageTransformer,
|
||||
whitespaceTransformer,
|
||||
// Add more transformers here
|
||||
];
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { detectLanguage, isValidLang } from '@/utils/lang';
|
||||
import { detectLanguage, isSameLang, isValidLang } from '@/utils/lang';
|
||||
import type { Transformer } from './types';
|
||||
|
||||
export const languageTransformer: Transformer = {
|
||||
name: 'language',
|
||||
|
||||
transform: async (ctx) => {
|
||||
const primaryLanguage = ctx.primaryLanguage;
|
||||
let result = ctx.content;
|
||||
const attrsMatch = result.match(/<html\b([^>]*)>/i);
|
||||
if (attrsMatch) {
|
||||
@@ -13,9 +14,10 @@ export const languageTransformer: Transformer = {
|
||||
const xmlLangRegex = / xml:lang="([^"]*)"/i;
|
||||
const xmlLangMatch = attrs.match(xmlLangRegex);
|
||||
const langMatch = attrs.match(langRegex);
|
||||
if (!isValidLang(langMatch?.[1] || xmlLangMatch?.[1])) {
|
||||
const docLang = langMatch?.[1] || xmlLangMatch?.[1];
|
||||
if (!isValidLang(docLang) || !isSameLang(docLang, primaryLanguage)) {
|
||||
const mainContent = result.replace(/<[^>]+>/g, ' ');
|
||||
const lang = detectLanguage(mainContent);
|
||||
const lang = isValidLang(primaryLanguage) ? primaryLanguage : detectLanguage(mainContent);
|
||||
const newLangAttr = ` lang="${lang}"`;
|
||||
const newXmlLangAttr = ` xml:lang="${lang}"`;
|
||||
attrs = langMatch ? attrs.replace(langRegex, newLangAttr) : attrs + newLangAttr;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ViewSettings } from '@/types/book';
|
||||
export type TransformContext = {
|
||||
bookKey: string;
|
||||
viewSettings: ViewSettings;
|
||||
primaryLanguage?: string;
|
||||
content: string;
|
||||
transformers: string[];
|
||||
reversePunctuationTransform?: boolean;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Transformer } from './types';
|
||||
|
||||
export const whitespaceTransformer: Transformer = {
|
||||
name: 'whitespace',
|
||||
|
||||
transform: async (ctx) => {
|
||||
const viewSettings = ctx.viewSettings;
|
||||
if (viewSettings.overrideLayout) {
|
||||
const cleaned = ctx.content
|
||||
// Replace but skip literal "&nbsp;"
|
||||
.replace(/(?<!&) /g, ' ')
|
||||
// Replace literal non-breaking space characters (U+00A0) with normal spaces
|
||||
.replace(/\u00A0/g, ' ')
|
||||
// Collapse consecutive spaces into one
|
||||
.replace(/ {2,}/g, ' ');
|
||||
return cleaned;
|
||||
} else {
|
||||
return ctx.content;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { AppService } from '@/types/system';
|
||||
import { parseSSMLMarks } from '@/utils/ssml';
|
||||
import { Overlayer } from 'foliate-js/overlayer.js';
|
||||
import { TTSGranularity, TTSHighlightOptions, TTSMark, TTSVoice } from './types';
|
||||
import { createRejecttFilter } from '@/utils/node';
|
||||
import { createRejectFilter } from '@/utils/node';
|
||||
import { WebSpeechClient } from './WebSpeechClient';
|
||||
import { NativeTTSClient } from './NativeTTSClient';
|
||||
import { EdgeTTSClient } from './EdgeTTSClient';
|
||||
@@ -85,13 +85,6 @@ export class TTSController extends EventTarget {
|
||||
const { style, color } = options;
|
||||
overlayer?.remove(HIGHLIGHT_KEY);
|
||||
overlayer?.add(HIGHLIGHT_KEY, range, Overlayer[style], { color });
|
||||
const rect = range.getBoundingClientRect();
|
||||
const { start, size, viewSize, sideProp } = this.view.renderer;
|
||||
const position = rect[sideProp === 'height' ? 'y' : 'x'] + 88;
|
||||
const offset = this.view.book.dir === 'rtl' ? viewSize - position : position;
|
||||
if (!this.view.renderer.scrolled || offset < start || offset > start + size) {
|
||||
this.view.renderer.scrollToAnchor(range);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +102,7 @@ export class TTSController extends EventTarget {
|
||||
const highlightOptions: TTSHighlightOptions = { style: 'highlight', color: 'gray' };
|
||||
await this.view.initTTS(
|
||||
granularity,
|
||||
createRejecttFilter({
|
||||
createRejectFilter({
|
||||
tags: ['rt', 'sup'],
|
||||
contents: [{ tag: 'a', content: /^\d+$/ }],
|
||||
}),
|
||||
|
||||
@@ -288,10 +288,9 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
||||
const oldConfig = bookData.config;
|
||||
const newConfig = {
|
||||
...bookData.config,
|
||||
updatedAt: Date.now(),
|
||||
progress,
|
||||
location,
|
||||
};
|
||||
} as BookConfig;
|
||||
|
||||
useBookDataStore.setState((state) => ({
|
||||
booksData: {
|
||||
|
||||
@@ -141,7 +141,12 @@ export const loadDataTheme = () => {
|
||||
const themeMode = localStorage.getItem('themeMode');
|
||||
const themeColor = localStorage.getItem('themeColor');
|
||||
if (themeMode && themeColor) {
|
||||
document.documentElement.setAttribute('data-theme', `${themeColor}-${themeMode}`);
|
||||
const systemIsDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDarkMode = themeMode === 'dark' || (themeMode === 'auto' && systemIsDarkMode);
|
||||
document.documentElement.setAttribute(
|
||||
'data-theme',
|
||||
`${themeColor}-${isDarkMode ? 'dark' : 'light'}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const getAdditionalBasicFontLinks = () => `
|
||||
|
||||
const getAdditionalCJKFontLinks = () => `
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans-webfont@1.0.4/misans-l3/misans-l3/result.min.css" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cn-fontsource-lxgw-wen-kai-gb-screen@1.0.6/font.min.css" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lxgw-wenkai-screen-web/1.520.0/lxgwwenkaigbscreen/result.css" crossorigin="anonymous" />
|
||||
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/hwmct/dist/%E6%B1%87%E6%96%87%E6%98%8E%E6%9C%9D%E4%BD%93/result.css' crossorigin="anonymous" />
|
||||
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/jhlst/dist/%E4%BA%AC%E8%8F%AF%E8%80%81%E5%AE%8B%E4%BD%93v2_002/result.css' crossorigin="anonymous" />
|
||||
<link rel='stylesheet' href='https://chinese-fonts-cdn.netlify.app/packages/syst/dist/SourceHanSerifCN/result.css' crossorigin="anonymous" />
|
||||
|
||||
@@ -59,7 +59,7 @@ const createTextureCSS = (texture: BackgroundTexture) => {
|
||||
body::before, .sidebar-container::before, .notebook-container::before,
|
||||
.foliate-viewer::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
@@ -68,10 +68,14 @@ const createTextureCSS = (texture: BackgroundTexture) => {
|
||||
z-index: 0;
|
||||
background-image: url("${texture.blobUrl || texture.url}");
|
||||
background-repeat: repeat;
|
||||
background-size: var(--bg-texture-size, auto);
|
||||
mix-blend-mode: var(--bg-texture-blend-mode, normal);
|
||||
background-size: var(--bg-texture-size, cover);
|
||||
mix-blend-mode: var(--bg-texture-blend-mode, multiply);
|
||||
opacity: var(--bg-texture-opacity, 0.6);
|
||||
}`;
|
||||
}
|
||||
body::before {
|
||||
height: 100vh;
|
||||
}
|
||||
`;
|
||||
|
||||
return css;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const createRejecttFilter = ({
|
||||
export const createRejectFilter = ({
|
||||
tags = [],
|
||||
classes = [],
|
||||
attributes = [],
|
||||
|
||||
@@ -115,9 +115,10 @@ export class TxtToEpubConverter {
|
||||
[
|
||||
String.raw`第[零〇一二三四五六七八九十0-9][零〇一二三四五六七八九十百千万0-9]*(?:[章卷节回讲篇封本册部话])(?:[::、 \(\)0-9]*[^\n-]{0,24})(?!\S)`,
|
||||
String.raw`(?:楔子|前言|简介|引言|序言|序章|总论|概论|后记)(?:[:: ][^\n-]{0,24})?(?!\S)`,
|
||||
String.raw`chapter[\s.]*[0-9]+(?:[::. ]+[^\n-]{0,50})?(?!\S)`,
|
||||
].join('|') +
|
||||
')',
|
||||
'gu',
|
||||
'gui',
|
||||
),
|
||||
);
|
||||
chapterRegexps.push(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
local Device = require("device")
|
||||
local Event = require("ui/event")
|
||||
local Dispatcher = require("dispatcher")
|
||||
local InfoMessage = require("ui/widget/infomessage")
|
||||
local WidgetContainer = require("ui/widget/container/widgetcontainer")
|
||||
local MultiInputDialog = require("ui/widget/multiinputdialog")
|
||||
@@ -40,35 +41,25 @@ function ReadestSync:init()
|
||||
self.last_sync_timestamp = 0
|
||||
self.settings = G_reader_settings:readSetting("readest_sync", self.default_settings)
|
||||
|
||||
self:onDispatcherRegisterActions()
|
||||
self.ui.menu:registerToMainMenu(self)
|
||||
end
|
||||
|
||||
function ReadestSync:onDispatcherRegisterActions()
|
||||
--
|
||||
Dispatcher:registerAction("readest_sync_set_autosync",
|
||||
{ category="string", event="ReadestSyncToggleAutoSync", title=_("Set auto progress sync"), reader=true,
|
||||
args={true, false}, toggle={_("on"), _("off")},})
|
||||
Dispatcher:registerAction("readest_sync_toggle_autosync", { category="none", event="ReadestSyncToggleAutoSync", title=_("Toggle auto readest sync"), reader=true,})
|
||||
Dispatcher:registerAction("readest_sync_push_progress", { category="none", event="ReadestSyncPushProgress", title=_("Push readest progress from this device"), reader=true,})
|
||||
Dispatcher:registerAction("readest_sync_pull_progress", { category="none", event="ReadestSyncPullProgress", title=_("Pull readest progress from other devices"), reader=true, separator=true,})
|
||||
end
|
||||
|
||||
function ReadestSync:needsLogin()
|
||||
return not self.settings.access_token or not self.settings.expires_at
|
||||
or self.settings.expires_at < os.time() + 60
|
||||
end
|
||||
|
||||
function ReadestSync:tryRefreshToken()
|
||||
if self.settings.refresh_token and self.settings.expires_at
|
||||
and self.settings.expires_at < os.time() - self.settings.expires_in / 2 then
|
||||
local client = self:getSupabaseAuthClient()
|
||||
client:refresh_token(self.settings.refresh_token, function(success, response)
|
||||
if success then
|
||||
self.settings.access_token = response.access_token
|
||||
self.settings.refresh_token = response.refresh_token
|
||||
self.settings.expires_at = response.expires_at
|
||||
self.settings.expires_in = response.expires_in
|
||||
G_reader_settings:saveSetting("readest_sync", self.settings)
|
||||
else
|
||||
logger.err("ReadestSync: Token refresh failed:", response or "Unknown error")
|
||||
end
|
||||
function ReadestSync:onReaderReady()
|
||||
if self.settings.auto_sync and self.settings.access_token then
|
||||
UIManager:nextTick(function()
|
||||
self:pullBookConfig(false)
|
||||
end)
|
||||
end
|
||||
self:onDispatcherRegisterActions()
|
||||
end
|
||||
|
||||
function ReadestSync:addToMainMenu(menu_items)
|
||||
@@ -98,17 +89,14 @@ function ReadestSync:addToMainMenu(menu_items)
|
||||
text = _("Auto sync book configs"),
|
||||
checked_func = function() return self.settings.auto_sync end,
|
||||
callback = function()
|
||||
self.settings.auto_sync = not self.settings.auto_sync
|
||||
if self.settings.auto_sync then
|
||||
self:pullBookConfig(false)
|
||||
end
|
||||
self:onReadestSyncToggleAutoSync()
|
||||
end,
|
||||
separator = true,
|
||||
},
|
||||
{
|
||||
text = _("Push book config now"),
|
||||
enabled_func = function()
|
||||
return self.settings.access_token ~= nil
|
||||
return self.settings.access_token ~= nil and self.ui.document ~= nil
|
||||
end,
|
||||
callback = function()
|
||||
self:pushBookConfig(true)
|
||||
@@ -117,7 +105,7 @@ function ReadestSync:addToMainMenu(menu_items)
|
||||
{
|
||||
text = _("Pull book config now"),
|
||||
enabled_func = function()
|
||||
return self.settings.access_token ~= nil
|
||||
return self.settings.access_token ~= nil and self.ui.document ~= nil
|
||||
end,
|
||||
callback = function()
|
||||
self:pullBookConfig(true)
|
||||
@@ -127,6 +115,29 @@ function ReadestSync:addToMainMenu(menu_items)
|
||||
}
|
||||
end
|
||||
|
||||
function ReadestSync:needsLogin()
|
||||
return not self.settings.access_token or not self.settings.expires_at
|
||||
or self.settings.expires_at < os.time() + 60
|
||||
end
|
||||
|
||||
function ReadestSync:tryRefreshToken()
|
||||
if self.settings.refresh_token and self.settings.expires_at
|
||||
and self.settings.expires_at < os.time() - self.settings.expires_in / 2 then
|
||||
local client = self:getSupabaseAuthClient()
|
||||
client:refresh_token(self.settings.refresh_token, function(success, response)
|
||||
if success then
|
||||
self.settings.access_token = response.access_token
|
||||
self.settings.refresh_token = response.refresh_token
|
||||
self.settings.expires_at = response.expires_at
|
||||
self.settings.expires_in = response.expires_in
|
||||
G_reader_settings:saveSetting("readest_sync", self.settings)
|
||||
else
|
||||
logger.err("ReadestSync: Token refresh failed:", response or "Unknown error")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
function ReadestSync:getSupabaseAuthClient()
|
||||
if not self.settings.supabase_url or not self.settings.supabase_anon_key then
|
||||
return nil
|
||||
@@ -596,12 +607,23 @@ function ReadestSync:pullBookConfig(interactive)
|
||||
)
|
||||
end
|
||||
|
||||
function ReadestSync:onReaderReady()
|
||||
if self.settings.auto_sync and self.settings.access_token then
|
||||
UIManager:nextTick(function()
|
||||
self:pullBookConfig(false)
|
||||
end)
|
||||
function ReadestSync:onReadestSyncToggleAutoSync(toggle)
|
||||
if toggle == self.settings.auto_sync then
|
||||
return true
|
||||
end
|
||||
self.settings.auto_sync = not self.settings.auto_sync
|
||||
G_reader_settings:saveSetting("readest_sync", self.settings)
|
||||
if self.settings.auto_sync and self.ui.document then
|
||||
self:pullBookConfig(false)
|
||||
end
|
||||
end
|
||||
|
||||
function ReadestSync:onReadestSyncPushProgress()
|
||||
self:pushBookConfig(true)
|
||||
end
|
||||
|
||||
function ReadestSync:onReadestSyncPullProgress()
|
||||
self:pullBookConfig(true)
|
||||
end
|
||||
|
||||
function ReadestSync:onCloseDocument()
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: e900daa533...a00119cbcf
Reference in New Issue
Block a user