Compare commits

...

4 Commits

11 changed files with 116 additions and 73 deletions
+2 -2
View File
@@ -48,12 +48,12 @@
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | ✅ |
| **Translate with DeepL** | From a single sentence to the entire book—translate instantly with DeepL. | ✅ |
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
## Planned Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.49",
"version": "0.9.50",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
+8
View File
@@ -1,5 +1,13 @@
{
"releases": {
"0.9.50": {
"date": "2025-05-28",
"notes": [
"Bilingual TTS: You can now choose voices for each language in bilingual text-to-speech mode",
"Settings Layout: Improved header layout in the settings dialog for different screen sizes",
"CSS Fixes: Corrected anchor text color and fixed image dimensions within paragraphs"
]
},
"0.9.49": {
"date": "2025-05-27",
"notes": [
@@ -138,64 +138,73 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
boxClassName={clsx('sm:min-w-[520px]', appService?.isMobile && 'sm:max-w-[90%] sm:w-3/4')}
snapHeight={appService?.isMobile ? 0.7 : undefined}
header={
<div className='flex w-full items-center justify-between'>
<button
tabIndex={-1}
onClick={handleClose}
className={
'btn btn-ghost btn-circle flex h-8 min-h-8 w-8 hover:bg-transparent focus:outline-none sm:hidden'
}
>
{isRtl ? <MdArrowForwardIos /> : <MdArrowBackIosNew />}
</button>
<div
ref={tabsRef}
className={clsx(
'dialog-tabs ms-2 flex h-10 w-full items-center sm:ms-0',
showAllTabLabels ? 'gap-1' : 'gap-2',
)}
>
{tabConfig.map(({ tab, icon: Icon, label }) => (
<button
key={tab}
data-tab={tab}
className={clsx(
'btn btn-ghost text-base-content btn-sm',
showAllTabLabels ? 'gap-0 px-1.5' : 'gap-1 px-2',
activePanel === tab ? 'btn-active' : '',
)}
onClick={() => handleSetActivePanel(tab)}
>
<Icon className='mr-0' />
<span
className={clsx('label', !(showAllTabLabels || activePanel === tab) && 'hidden')}
>
{label}
</span>
</button>
))}
<div className='flex w-full flex-col items-center'>
<div className='tab-title flex pb-2 text-base font-semibold sm:hidden'>
{tabConfig.find((tab) => tab.tab === activePanel)?.label || ''}
</div>
<div className='flex h-full items-center justify-end gap-x-2'>
<Dropdown
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 flex items-center justify-center'
toggleButton={<PiDotsThreeVerticalBold />}
>
<DialogMenu />
</Dropdown>
<div className='flex w-full flex-row items-center justify-between'>
<button
tabIndex={-1}
onClick={handleClose}
className={
'bg-base-300/65 btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 p-0 sm:flex'
'btn btn-ghost btn-circle flex h-8 min-h-8 w-8 hover:bg-transparent focus:outline-none sm:hidden'
}
>
<svg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'>
<path
fill='currentColor'
d='M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z'
/>
</svg>
{isRtl ? <MdArrowForwardIos /> : <MdArrowBackIosNew />}
</button>
<div
ref={tabsRef}
className={clsx('dialog-tabs ms-1 flex h-10 w-full items-center gap-1 sm:ms-0')}
>
{tabConfig.map(({ tab, icon: Icon, label }) => (
<button
key={tab}
data-tab={tab}
className={clsx(
'btn btn-ghost text-base-content btn-sm gap-1 px-2',
activePanel === tab ? 'btn-active' : '',
)}
onClick={() => handleSetActivePanel(tab)}
>
<Icon className='mr-0' />
<span
className={clsx(
window.innerWidth < 640 && 'hidden',
!(showAllTabLabels || activePanel === tab) && 'hidden',
)}
>
{label}
</span>
</button>
))}
</div>
<div className='flex h-full items-center justify-end gap-x-2'>
<Dropdown
className='dropdown-bottom dropdown-end'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0 flex items-center justify-center'
toggleButton={<PiDotsThreeVerticalBold />}
>
<DialogMenu />
</Dropdown>
<button
onClick={handleClose}
className={
'bg-base-300/65 btn btn-ghost btn-circle hidden h-6 min-h-6 w-6 p-0 sm:flex'
}
>
<svg
xmlns='http://www.w3.org/2000/svg'
width='1em'
height='1em'
viewBox='0 0 24 24'
>
<path
fill='currentColor'
d='M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z'
/>
</svg>
</button>
</div>
</div>
</div>
}
@@ -140,7 +140,6 @@ const TTSControl = () => {
ttsController.setLang(lang);
ttsController.setRate(viewSettings.ttsRate);
ttsController.setVoice(viewSettings.ttsVoice);
ttsController.speak(ssml);
ttsControllerRef.current = ttsController;
}
@@ -236,15 +235,15 @@ const TTSControl = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSetVoice = useCallback(
throttle(async (voice: string) => {
throttle(async (voice: string, lang: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
await ttsController.stop();
await ttsController.setVoice(voice);
await ttsController.setVoice(voice, lang);
await ttsController.start();
} else {
await ttsController.setVoice(voice);
await ttsController.setVoice(voice, lang);
}
}
}, 3000),
@@ -311,6 +310,10 @@ const TTSControl = () => {
const togglePopup = () => {
updatePanelPosition();
if (!showPanel && ttsControllerRef.current) {
const speakingLang = ttsControllerRef.current.getSpeakingLang() || ttsLang;
setTtsLang(speakingLang);
}
setShowPanel((prev) => !prev);
};
@@ -21,7 +21,7 @@ type TTSPanelProps = {
onForward: () => void;
onSetRate: (rate: number) => void;
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
onSetVoice: (voice: string) => void;
onSetVoice: (voice: string, lang: string) => void;
onGetVoiceId: () => string;
onSelectTimeout: (value: number) => void;
};
@@ -144,8 +144,8 @@ const TTSPanel = ({
saveSettings(envConfig, settings);
};
const handleSelectVoice = (voice: string) => {
onSetVoice(voice);
const handleSelectVoice = (voice: string, lang: string) => {
onSetVoice(voice, lang);
setSelectedVoice(voice);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsVoice = voice;
@@ -282,7 +282,7 @@ const TTSPanel = ({
{voices.map((voice, index) => (
<li
key={`${index}-${voice.id}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id)}
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
@@ -7,6 +7,7 @@ import { TTSUtils } from './TTSUtils';
export class EdgeTTSClient implements TTSClient {
#primaryLang = 'en';
#speakingLang = '';
#rate = 1.0;
#pitch = 1.0;
#voice: TTSVoice | null = null;
@@ -118,6 +119,7 @@ export class EdgeTTSClient implements TTSClient {
const { language } = mark;
const voiceLang = language || defaultLang;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
this.#speakingLang = voiceLang;
const blob = await this.#edgeTTS.createAudio(
this.getPayload(voiceLang, mark.text, voiceId),
);
@@ -286,4 +288,8 @@ export class EdgeTTSClient implements TTSClient {
getVoiceId(): string {
return this.#voice?.id || '';
}
getSpeakingLang(): string {
return this.#speakingLang;
}
}
@@ -29,4 +29,5 @@ export interface TTSClient {
getVoices(lang: string): Promise<TTSVoice[]>;
getGranularities(): TTSGranularity[];
getVoiceId(): string;
getSpeakingLang(): string;
}
@@ -247,7 +247,7 @@ export class TTSController extends EventTarget {
return [...ttsEdgeVoices, ...ttsWebVoices];
}
async setVoice(voiceId: string) {
async setVoice(voiceId: string, lang: string) {
this.state = 'setvoice-paused';
const useEdgeTTS = !!this.ttsEdgeVoices.find(
(voice) => (voiceId === '' || voice.id === voiceId) && !voice.disabled,
@@ -255,11 +255,11 @@ export class TTSController extends EventTarget {
if (useEdgeTTS) {
this.ttsClient = this.ttsEdgeClient;
await this.ttsClient.setRate(this.ttsRate);
TTSUtils.setPreferredVoice('edge-tts', this.ttsLang, voiceId);
TTSUtils.setPreferredVoice('edge-tts', lang, voiceId);
} else {
this.ttsClient = this.ttsWebClient;
await this.ttsClient.setRate(this.ttsRate);
TTSUtils.setPreferredVoice('web-speech', this.ttsLang, voiceId);
TTSUtils.setPreferredVoice('web-speech', lang, voiceId);
}
await this.ttsClient.setVoice(voiceId);
}
@@ -268,6 +268,10 @@ export class TTSController extends EventTarget {
return this.ttsClient.getVoiceId();
}
getSpeakingLang() {
return this.ttsClient.getSpeakingLang();
}
error(e: unknown) {
console.error(e);
this.state = 'stopped';
@@ -50,6 +50,7 @@ async function* speakWithMarks(
getRate: () => number,
getPitch: () => number,
getVoice: (lang: string) => Promise<SpeechSynthesisVoice | null>,
setSpeakingLang: (lang: string) => void = () => {},
) {
const { marks } = parseSSMLMarks(ssml);
@@ -59,6 +60,7 @@ async function* speakWithMarks(
for (const mark of marks) {
const { language } = mark;
const voiceLang = language || defaultLang;
setSpeakingLang(voiceLang);
utterance.text = mark.text;
utterance.rate = getRate();
utterance.pitch = getPitch();
@@ -98,6 +100,7 @@ async function* speakWithMarks(
export class WebSpeechClient implements TTSClient {
#primaryLang = 'en';
#speakingLang = '';
#rate = 1.0;
#pitch = 1.0;
#voice: SpeechSynthesisVoice | null = null;
@@ -162,6 +165,7 @@ export class WebSpeechClient implements TTSClient {
() => this.#rate,
() => this.#pitch,
this.getVoiceFromLang,
(lang) => (this.#speakingLang = lang),
)) {
if (signal.aborted) {
console.log('TTS aborted');
@@ -275,4 +279,8 @@ export class WebSpeechClient implements TTSClient {
getVoiceId(): string {
return this.#voice?.voiceURI ?? '';
}
getSpeakingLang(): string {
return this.#speakingLang;
}
}
+12 -8
View File
@@ -86,7 +86,7 @@ const getFontStyles = (
body * {
${overrideFont ? 'font-family: revert !important;' : ''}
}
a:any-link, a:any-link * {
a:any-link {
${overrideFont ? `color: ${primary};` : isDarkMode ? `color: lightblue;` : ''}
text-decoration: none;
}
@@ -248,9 +248,9 @@ const getLayoutStyles = (
hanging-punctuation: allow-end last;
widows: 2;
}
p:has(> img:only-child) {
p:has(> img:only-child), p:has(> span:only-child > img:only-child),
div[style*="text-align"] {
text-indent: unset !important;
text-align: unset !important;
}
p, div {
${vertical ? `margin-left: ${paragraphMargin}em ${overrideLayout ? '!important' : ''};` : ''}
@@ -300,6 +300,9 @@ const getLayoutStyles = (
height: 1em;
mix-blend-mode: ${isDarkMode ? 'screen' : 'multiply'};
}
p:has(> img:only-child) img, span:has(> img:only-child) img {
height: auto;
}
p:has(img), span:has(img) {
background-color: ${bg};
}
@@ -485,15 +488,16 @@ export const transformStylesheet = (
const h = height - viewSettings.marginPx * 2;
const ruleRegex = /([^{]+)({[^}]+})/g;
css = css.replace(ruleRegex, (match, selector, block) => {
const hasTextAlignCenter =
/text-align\s*:\s*center\s*;/.test(block) || /text-align\s*:\s*center\s*$/.test(block);
const hasTextIndentZero =
/text-indent\s*:\s*0\s*;/.test(block) || /text-indent\s*:\s*0\s*$/.test(block);
const hasTextAlignCenter = /text-align\s*:\s*center\s*[;$]/.test(block);
const hasTextIndentZero = /text-indent\s*:\s*0(?:\.0+)?(?:px|em|rem|%)?\s*[;$]/.test(block);
if (hasTextAlignCenter) {
block = block.replace(/(text-align\s*:\s*center)(\s*;|\s*$)/g, '$1 !important$2');
if (hasTextIndentZero) {
block = block.replace(/(text-indent\s*:\s*0)(\s*;|\s*$)/g, '$1 !important$2');
block = block.replace(
/(text-indent\s*:\s*0(?:\.0+)?(?:px|em|rem|%)?)(\s*;|\s*$)/g,
'$1 !important$2',
);
}
return selector + block;
}