forked from akai/readest
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebdfc39ef6 | |||
| 9f0d8b5c12 | |||
| a267671671 | |||
| aa62ebd3f0 |
@@ -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,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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user