diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 9425b267..e6ee0d21 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -3,6 +3,7 @@ "About Readest": "Über Readest", "Add your notes here...": "Fügen Sie hier Ihre Notizen hinzu...", "Animation": "Animation", + "Annotate": "Annotieren", "Apply": "Anwenden", "Are you sure to delete the selected books?": "Möchten Sie die ausgewählten Bücher wirklich löschen?", "Auto Mode": "Automatischer Modus", @@ -16,12 +17,16 @@ "Color": "Farbe", "Confirm": "Bestätigen", "Confirm Deletion": "Löschen bestätigen", + "Copied to notebook": "In Notizbuch kopiert", + "Copy": "Kopieren", "Custom CSS": "Benutzerdefiniertes CSS", "Dark Mode": "Dunkelmodus", "Default": "Standard", "Default Font": "Standardschriftart", "Default Font Size": "Standard-Schriftgröße", "Delete": "Löschen", + "Delete Highlight": "Hervorhebung löschen", + "Dictionary": "Wörterbuch", "Disable Click-to-Flip": "Klick zum Blättern deaktivieren", "Download Readest": "Readest herunterladen", "Edit": "Bearbeiten", @@ -44,6 +49,7 @@ "Grass": "Grasgrün", "Gray": "Grau", "Gruvbox": "Gruvbox", + "Highlight": "Hervorheben", "Horizontal Direction": "Horizontale Richtung", "Hyphenation": "Silbentrennung", "Identifier:": "Kennung:", @@ -89,6 +95,7 @@ "Sans-Serif Font": "Serifenlose Schriftart", "Save": "Speichern", "Scrolled Mode": "Scroll-Modus", + "Search": "Suchen", "Search books...": "Bücher suchen...", "Search...": "Suchen...", "Select Book": "Buch auswählen", @@ -102,10 +109,12 @@ "Sign Out": "Abmelden", "Sky": "Himmelblau", "Solarized": "Solarisiert", + "Speak": "Sprechen", "Subjects:": "Themen:", "System Fonts": "System-Schriftarten", "Theme Color": "Themenfarbe", "Theme Mode": "Themenmodus", + "Translate": "Übersetzen", "Translated Text": "Übersetzter Text", "Unknown": "Unbekannt", "Untitled": "Ohne Titel", @@ -114,6 +123,7 @@ "Version {{version}}": "Version {{version}}", "Vertical Direction": "Vertikale Richtung", "Welcome to your library. You can import your books here and read them anytime.": "Willkommen in Ihrer Bibliothek. Sie können hier Ihre Bücher importieren und jederzeit lesen.", + "Wikipedia": "Wikipedia", "Writing Mode": "Schreibmodus", "Your Library": "Ihre Bibliothek" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 1696f904..b2d76dd7 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -3,6 +3,7 @@ "About Readest": "Acerca de Readest", "Add your notes here...": "Añade tus notas aquí...", "Animation": "Animación", + "Annotate": "Anotar", "Apply": "Aplicar", "Are you sure to delete the selected books?": "¿Estás seguro de eliminar los libros seleccionados?", "Auto Mode": "Modo automático", @@ -16,12 +17,16 @@ "Color": "Color", "Confirm": "Confirmar", "Confirm Deletion": "Confirmar eliminación", + "Copied to notebook": "Copiado al cuaderno", + "Copy": "Copiar", "Custom CSS": "CSS personalizado", "Dark Mode": "Modo oscuro", "Default": "Predeterminado", "Default Font": "Fuente predeterminada", "Default Font Size": "Tamaño de fuente predeterminado", "Delete": "Eliminar", + "Delete Highlight": "Eliminar resaltado", + "Dictionary": "Diccionario", "Disable Click-to-Flip": "Desactivar clic para voltear", "Download Readest": "Descargar Readest", "Edit": "Editar", @@ -44,6 +49,7 @@ "Grass": "Hierba", "Gray": "Gris", "Gruvbox": "Gruvbox", + "Highlight": "Resaltar", "Horizontal Direction": "Dirección horizontal", "Hyphenation": "Guionización", "Identifier:": "Identificador:", @@ -89,6 +95,7 @@ "Sans-Serif Font": "Fuente sans-serif", "Save": "Guardar", "Scrolled Mode": "Modo desplazamiento", + "Search": "Buscar", "Search books...": "Buscar libros...", "Search...": "Buscar...", "Select Book": "Seleccionar libro", @@ -102,10 +109,12 @@ "Sign Out": "Cerrar sesión", "Sky": "Cielo", "Solarized": "Solarizado", + "Speak": "Leer", "Subjects:": "Temas:", "System Fonts": "Fuentes del sistema", "Theme Color": "Color del tema", "Theme Mode": "Modo del tema", + "Translate": "Traducir", "Translated Text": "Texto traducido", "Unknown": "Desconocido", "Untitled": "Sin título", @@ -114,6 +123,7 @@ "Version {{version}}": "Versión {{version}}", "Vertical Direction": "Dirección vertical", "Welcome to your library. You can import your books here and read them anytime.": "Bienvenido a tu biblioteca. Puedes importar tus libros aquí y leerlos en cualquier momento.", + "Wikipedia": "Wikipedia", "Writing Mode": "Modo de escritura", "Your Library": "Tu biblioteca" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 006374e3..04e110c6 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -3,6 +3,7 @@ "About Readest": "À propos de Readest", "Add your notes here...": "Ajoutez vos notes ici...", "Animation": "Animation", + "Annotate": "Annoter", "Apply": "Appliquer", "Are you sure to delete the selected books?": "Êtes-vous sûr de vouloir supprimer les livres sélectionnés ?", "Auto Mode": "Mode automatique", @@ -16,12 +17,16 @@ "Color": "Couleur", "Confirm": "Confirmer", "Confirm Deletion": "Confirmer la suppression", + "Copied to notebook": "Copié dans le carnet de notes", + "Copy": "Copier", "Custom CSS": "CSS personnalisé", "Dark Mode": "Mode sombre", "Default": "Par défaut", "Default Font": "Police par défaut", "Default Font Size": "Taille de police par défaut", "Delete": "Supprimer", + "Delete Highlight": "Supprimer le surlignage", + "Dictionary": "Dictionnaire", "Disable Click-to-Flip": "Désactiver le clic pour tourner", "Download Readest": "Télécharger Readest", "Edit": "Modifier", @@ -44,6 +49,7 @@ "Grass": "Herbe", "Gray": "Gris", "Gruvbox": "Gruvbox", + "Highlight": "Surligner", "Horizontal Direction": "Direction horizontale", "Hyphenation": "Césure", "Identifier:": "Identifiant :", @@ -89,6 +95,7 @@ "Sans-Serif Font": "Police sans empattement", "Save": "Enregistrer", "Scrolled Mode": "Mode défilement", + "Search": "Rechercher", "Search books...": "Rechercher des livres...", "Search...": "Rechercher...", "Select Book": "Sélectionner un livre", @@ -102,10 +109,12 @@ "Sign Out": "Se déconnecter", "Sky": "Ciel", "Solarized": "Solarisé", + "Speak": "Lire", "Subjects:": "Sujets :", "System Fonts": "Polices système", "Theme Color": "Couleur du thème", "Theme Mode": "Mode du thème", + "Translate": "Traduire", "Translated Text": "Texte traduit", "Unknown": "Inconnu", "Untitled": "Sans titre", @@ -114,6 +123,7 @@ "Version {{version}}": "Version {{version}}", "Vertical Direction": "Direction verticale", "Welcome to your library. You can import your books here and read them anytime.": "Bienvenue dans votre bibliothèque. Vous pouvez importer vos livres ici et les lire à tout moment.", + "Wikipedia": "Wikipédia", "Writing Mode": "Mode écriture", "Your Library": "Votre bibliothèque" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index e14b79ae..1cb23862 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -3,6 +3,7 @@ "About Readest": "Tentang Readest", "Add your notes here...": "Tambahkan catatan Anda di sini...", "Animation": "Animasi", + "Annotate": "Anotasi", "Apply": "Terapkan", "Are you sure to delete the selected books?": "Apakah Anda yakin ingin menghapus buku yang dipilih?", "Auto Mode": "Mode Otomatis", @@ -16,12 +17,16 @@ "Color": "Warna", "Confirm": "Konfirmasi", "Confirm Deletion": "Konfirmasi Penghapusan", + "Copied to notebook": "Disalin ke buku catatan", + "Copy": "Salin", "Custom CSS": "CSS Kustom", "Dark Mode": "Mode Gelap", "Default": "Default", "Default Font": "Font Default", "Default Font Size": "Ukuran Font Default", "Delete": "Hapus", + "Delete Highlight": "Hapus Sorotan", + "Dictionary": "Kamus", "Disable Click-to-Flip": "Nonaktifkan Klik untuk Membalik", "Download Readest": "Unduh Readest", "Edit": "Edit", @@ -44,6 +49,7 @@ "Grass": "Rumput", "Gray": "Abu-abu", "Gruvbox": "Gruvbox", + "Highlight": "Sorot", "Horizontal Direction": "Arah Horizontal", "Hyphenation": "Pemenggalan Kata", "Identifier:": "Pengenal:", @@ -89,6 +95,7 @@ "Sans-Serif Font": "Font Sans-Serif", "Save": "Simpan", "Scrolled Mode": "Mode Gulir", + "Search": "Cari", "Search books...": "Cari buku...", "Search...": "Cari...", "Select Book": "Pilih Buku", @@ -102,10 +109,12 @@ "Sign Out": "Keluar", "Sky": "Langit", "Solarized": "Solarized", + "Speak": "Bicara", "Subjects:": "Subjek:", "System Fonts": "Font Sistem", "Theme Color": "Warna Tema", "Theme Mode": "Mode Tema", + "Translate": "Terjemahkan", "Translated Text": "Teks Terjemahan", "Unknown": "Tidak Diketahui", "Untitled": "Tanpa Judul", @@ -114,6 +123,7 @@ "Version {{version}}": "Versi {{version}}", "Vertical Direction": "Arah Vertikal", "Welcome to your library. You can import your books here and read them anytime.": "Selamat datang di perpustakaan Anda. Anda dapat mengimpor buku-buku Anda di sini dan membacanya kapan saja.", + "Wikipedia": "Wikipedia", "Writing Mode": "Mode Menulis", "Your Library": "Perpustakaan Anda" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 0392004a..8c44356b 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -3,6 +3,7 @@ "About Readest": "Informazioni su Readest", "Add your notes here...": "Aggiungi qui le tue note...", "Animation": "Animazione", + "Annotate": "Annota", "Apply": "Applica", "Are you sure to delete the selected books?": "Sei sicuro di voler eliminare i libri selezionati?", "Auto Mode": "Modalità automatica", @@ -16,12 +17,16 @@ "Color": "Colore", "Confirm": "Conferma", "Confirm Deletion": "Conferma eliminazione", + "Copied to notebook": "Copiato nel quaderno", + "Copy": "Copia", "Custom CSS": "CSS personalizzato", "Dark Mode": "Modalità scura", "Default": "Predefinito", "Default Font": "Font predefinito", "Default Font Size": "Dimensione font predefinita", "Delete": "Elimina", + "Delete Highlight": "Elimina evidenziazione", + "Dictionary": "Dizionario", "Disable Click-to-Flip": "Disattiva click per voltare pagina", "Download Readest": "Scarica Readest", "Edit": "Modifica", @@ -44,6 +49,7 @@ "Grass": "Erba", "Gray": "Grigio", "Gruvbox": "Gruvbox", + "Highlight": "Evidenzia", "Horizontal Direction": "Direzione orizzontale", "Hyphenation": "Sillabazione", "Identifier:": "Identificatore:", @@ -89,6 +95,7 @@ "Sans-Serif Font": "Font sans-serif", "Save": "Salva", "Scrolled Mode": "Modalità scorrimento", + "Search": "Cerca", "Search books...": "Cerca libri...", "Search...": "Cerca...", "Select Book": "Seleziona libro", @@ -102,10 +109,12 @@ "Sign Out": "Esci", "Sky": "Cielo", "Solarized": "Solarizzato", + "Speak": "Leggi", "Subjects:": "Argomenti:", "System Fonts": "Font di sistema", "Theme Color": "Colore tema", "Theme Mode": "Modalità tema", + "Translate": "Traduci", "Translated Text": "Testo tradotto", "Unknown": "Sconosciuto", "Untitled": "Senza titolo", @@ -114,6 +123,7 @@ "Version {{version}}": "Versione {{version}}", "Vertical Direction": "Direzione verticale", "Welcome to your library. You can import your books here and read them anytime.": "Benvenuto nella tua biblioteca. Puoi importare i tuoi libri qui e leggerli in qualsiasi momento.", + "Wikipedia": "Wikipedia", "Writing Mode": "Modalità scrittura", "Your Library": "La tua biblioteca" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 5dccd12b..56c66e60 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -3,6 +3,7 @@ "About Readest": "Readestについて", "Add your notes here...": "ここにメモを追加...", "Animation": "アニメーション", + "Annotate": "注釈", "Apply": "適用", "Are you sure to delete the selected books?": "選択した書籍を削除してもよろしいですか?", "Auto Mode": "自動モード", @@ -16,12 +17,16 @@ "Color": "色", "Confirm": "確認", "Confirm Deletion": "削除の確認", + "Copied to notebook": "ノートブックにコピー", + "Copy": "コピー", "Custom CSS": "カスタムCSS", "Dark Mode": "ダークモード", "Default": "デフォルト", "Default Font": "デフォルトフォント", "Default Font Size": "デフォルトフォントサイズ", "Delete": "削除", + "Delete Highlight": "ハイライトを削除", + "Dictionary": "辞書", "Disable Click-to-Flip": "クリックめくりを無効化", "Download Readest": "Readestをダウンロード", "Edit": "編集", @@ -44,6 +49,7 @@ "Grass": "グリーン", "Gray": "グレー", "Gruvbox": "グルーブボックス", + "Highlight": "ハイライト", "Horizontal Direction": "横書", "Hyphenation": "ハイフネーション", "Identifier:": "識別子:", @@ -89,6 +95,7 @@ "Sans-Serif Font": "ゴシック体", "Save": "保存", "Scrolled Mode": "スクロールモード", + "Search": "検索", "Search books...": "書籍を検索...", "Search...": "検索...", "Select Book": "書籍を選択", @@ -102,10 +109,12 @@ "Sign Out": "サインアウト", "Sky": "スカイ", "Solarized": "ソーラライズド", + "Speak": "読み上げ", "Subjects:": "主題:", "System Fonts": "システムフォント", "Theme Color": "テーマカラー", "Theme Mode": "テーマモード", + "Translate": "翻訳", "Translated Text": "翻訳されたテキスト", "Unknown": "不明", "Untitled": "無題", @@ -114,6 +123,7 @@ "Version {{version}}": "バージョン {{version}}", "Vertical Direction": "縦書", "Welcome to your library. You can import your books here and read them anytime.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。", + "Wikipedia": "ウィキペディア", "Writing Mode": "書き込みモード", "Your Library": "ライブラリー" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 0365af4f..e3684fe0 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -3,6 +3,7 @@ "About Readest": "Readest 정보", "Add your notes here...": "여기에 메모를 추가하세요...", "Animation": "애니메이션", + "Annotate": "주석", "Apply": "적용", "Are you sure to delete the selected books?": "선택한 책을 삭제하시겠습니까?", "Auto Mode": "자동 모드", @@ -16,12 +17,16 @@ "Color": "색상", "Confirm": "확인", "Confirm Deletion": "삭제 확인", + "Copied to notebook": "노트북에 복사됨", + "Copy": "복사", "Custom CSS": "사용자 정의 CSS", "Dark Mode": "다크 모드", "Default": "기본값", "Default Font": "기본 글꼴", "Default Font Size": "기본 글꼴 크기", "Delete": "삭제", + "Delete Highlight": "하이라이트 삭제", + "Dictionary": "사전", "Disable Click-to-Flip": "클릭 넘기기 비활성화", "Download Readest": "Readest 다운로드", "Edit": "편집", @@ -44,6 +49,7 @@ "Grass": "잔디색", "Gray": "회색", "Gruvbox": "그러브박스", + "Highlight": "하이라이트", "Horizontal Direction": "수평 방향", "Hyphenation": "하이픈 넣기", "Identifier:": "식별자:", @@ -89,6 +95,7 @@ "Sans-Serif Font": "산세리프체", "Save": "저장", "Scrolled Mode": "스크롤 모드", + "Search": "검색", "Search books...": "책 검색...", "Search...": "검색...", "Select Book": "책 선택", @@ -102,10 +109,12 @@ "Sign Out": "로그아웃", "Sky": "하늘색", "Solarized": "솔라라이즈드", + "Speak": "말하기", "Subjects:": "주제:", "System Fonts": "시스템 글꼴", "Theme Color": "테마 색상", "Theme Mode": "테마 모드", + "Translate": "번역", "Translated Text": "번역된 텍스트", "Unknown": "알 수 없음", "Untitled": "제목 없음", @@ -114,6 +123,7 @@ "Version {{version}}": "버전 {{version}}", "Vertical Direction": "수직 방향", "Welcome to your library. You can import your books here and read them anytime.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.", + "Wikipedia": "위키피디아", "Writing Mode": "글쓰기 모드", "Your Library": "내 서재" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 18650eac..43d7d14e 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -3,6 +3,7 @@ "About Readest": "Sobre o Readest", "Add your notes here...": "Adicione suas notas aqui...", "Animation": "Animação", + "Annotate": "Anotar", "Apply": "Aplicar", "Are you sure to delete the selected books?": "Tem certeza de que deseja excluir os livros selecionados?", "Auto Mode": "Modo Automático", @@ -16,12 +17,16 @@ "Color": "Cor", "Confirm": "Confirmar", "Confirm Deletion": "Confirmar Exclusão", + "Copied to notebook": "Copiado para o caderno", + "Copy": "Copiar", "Custom CSS": "CSS Personalizado", "Dark Mode": "Modo Escuro", "Default": "Padrão", "Default Font": "Fonte Padrão", "Default Font Size": "Tamanho da Fonte Padrão", "Delete": "Excluir", + "Delete Highlight": "Excluir Destaque", + "Dictionary": "Dicionário", "Disable Click-to-Flip": "Desativar Clique para Virar", "Download Readest": "Baixar Readest", "Edit": "Editar", @@ -44,6 +49,7 @@ "Grass": "Grama", "Gray": "Cinza", "Gruvbox": "Gruvbox", + "Highlight": "Destaque", "Horizontal Direction": "Direção Horizontal", "Hyphenation": "Hifenização", "Identifier:": "Identificador:", @@ -89,6 +95,7 @@ "Sans-Serif Font": "Fonte Sans-Serif", "Save": "Salvar", "Scrolled Mode": "Modo de Rolagem", + "Search": "Buscar", "Search books...": "Procurar livros...", "Search...": "Pesquisar...", "Select Book": "Selecionar Livro", @@ -102,10 +109,12 @@ "Sign Out": "Sair", "Sky": "Céu", "Solarized": "Solarizado", + "Speak": "Falar", "Subjects:": "Assuntos:", "System Fonts": "Fontes do Sistema", "Theme Color": "Cor do Tema", "Theme Mode": "Modo do Tema", + "Translate": "Traduzir", "Translated Text": "Texto Traduzido", "Unknown": "Desconhecido", "Untitled": "Sem Título", @@ -114,6 +123,7 @@ "Version {{version}}": "Versão {{version}}", "Vertical Direction": "Direção Vertical", "Welcome to your library. You can import your books here and read them anytime.": "Bem-vindo à sua biblioteca. Você pode importar seus livros aqui e lê-los a qualquer momento.", + "Wikipedia": "Wikipédia", "Writing Mode": "Modo de Escrita", "Your Library": "Sua Biblioteca" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index a8a2f865..2ef83557 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -3,6 +3,7 @@ "About Readest": "О Readest", "Add your notes here...": "Добавьте свои заметки здесь...", "Animation": "Анимация", + "Annotate": "Аннотация", "Apply": "Применить", "Are you sure to delete the selected books?": "Вы уверены, что хотите удалить выбранные книги?", "Auto Mode": "Автоматический режим", @@ -16,12 +17,16 @@ "Color": "Цвет", "Confirm": "Подтвердить", "Confirm Deletion": "Подтвердить удаление", + "Copied to notebook": "Скопировано в блокнот", + "Copy": "Копировать", "Custom CSS": "Пользовательский CSS", "Dark Mode": "Тёмная тема", "Default": "По умолчанию", "Default Font": "Шрифт по умолчанию", "Default Font Size": "Размер шрифта по умолчанию", "Delete": "Удалить", + "Delete Highlight": "Удалить выделение", + "Dictionary": "Словарь", "Disable Click-to-Flip": "Отключить переворот по клику", "Download Readest": "Скачать Readest", "Edit": "Редактировать", @@ -44,6 +49,7 @@ "Grass": "Трава", "Gray": "Серый", "Gruvbox": "Gruvbox", + "Highlight": "Выделить", "Horizontal Direction": "Горизонтальное направление", "Hyphenation": "Перенос слов", "Identifier:": "Идентификатор:", @@ -89,6 +95,7 @@ "Sans-Serif Font": "Шрифт без засечек", "Save": "Сохранить", "Scrolled Mode": "Режим прокрутки", + "Search": "Поиск", "Search books...": "Поиск книг...", "Search...": "Поиск...", "Select Book": "Выбрать книгу", @@ -102,10 +109,12 @@ "Sign Out": "Выйти", "Sky": "Небесный", "Solarized": "Солнечный", + "Speak": "Произнести", "Subjects:": "Темы:", "System Fonts": "Системные шрифты", "Theme Color": "Цвет темы", "Theme Mode": "Режим темы", + "Translate": "Перевести", "Translated Text": "Переведённый текст", "Unknown": "Неизвестно", "Untitled": "Без названия", @@ -114,6 +123,7 @@ "Version {{version}}": "Версия {{version}}", "Vertical Direction": "Вертикальное направление", "Welcome to your library. You can import your books here and read them anytime.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.", + "Wikipedia": "Википедия", "Writing Mode": "Режим записи", "Your Library": "Ваша библиотека" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index a5e1ced3..095e8fc0 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -3,6 +3,7 @@ "About Readest": "Readest Hakkında", "Add your notes here...": "Notlarınızı buraya ekleyin...", "Animation": "Animasyon", + "Annotate": "Not Ekle", "Apply": "Uygula", "Are you sure to delete the selected books?": "Seçili kitapları silmek istediğinizden emin misiniz?", "Auto Mode": "Otomatik Mod", @@ -16,12 +17,16 @@ "Color": "Renk", "Confirm": "Onayla", "Confirm Deletion": "Silmeyi Onayla", + "Copied to notebook": "Not defterine kopyalandı", + "Copy": "Kopyala", "Custom CSS": "Özel CSS", "Dark Mode": "Karanlık Mod", "Default": "Varsayılan", "Default Font": "Varsayılan Yazı Tipi", "Default Font Size": "Varsayılan Yazı Boyutu", "Delete": "Sil", + "Delete Highlight": "Vurguyu Sil", + "Dictionary": "Sözlük", "Disable Click-to-Flip": "Tıkla-Çevir'i Devre Dışı Bırak", "Download Readest": "Readest'i İndir", "Edit": "Düzenle", @@ -44,6 +49,7 @@ "Grass": "Çimen", "Gray": "Gri", "Gruvbox": "Gruvbox", + "Highlight": "Vurgula", "Horizontal Direction": "Yatay Yön", "Hyphenation": "Heceleme", "Identifier:": "Tanımlayıcı:", @@ -89,6 +95,7 @@ "Sans-Serif Font": "Sans-Serif Yazı Tipi", "Save": "Kaydet", "Scrolled Mode": "Kaydırma Modu", + "Search": "Ara", "Search books...": "Kitap ara...", "Search...": "Ara...", "Select Book": "Kitap Seç", @@ -102,10 +109,12 @@ "Sign Out": "Çıkış Yap", "Sky": "Gökyüzü", "Solarized": "Solarized", + "Speak": "Konuş", "Subjects:": "Konular:", "System Fonts": "Sistem Yazı Tipleri", "Theme Color": "Tema Rengi", "Theme Mode": "Tema Modu", + "Translate": "Çevir", "Translated Text": "Çevrilen Metin", "Unknown": "Bilinmiyor", "Untitled": "Başlıksız", @@ -114,6 +123,7 @@ "Version {{version}}": "Sürüm {{version}}", "Vertical Direction": "Dikey Yön", "Welcome to your library. You can import your books here and read them anytime.": "Kütüphanenize hoş geldiniz. Buradan kitaplarınızı içe aktarabilir ve istediğiniz zaman okuyabilirsiniz.", + "Wikipedia": "Vikipedi", "Writing Mode": "Yazma Modu", "Your Library": "Kütüphaneniz" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 6fea5c84..7e38cb5e 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -3,6 +3,7 @@ "About Readest": "Về Readest", "Add your notes here...": "Thêm ghi chú của bạn vào đây...", "Animation": "Hiệu ứng động", + "Annotate": "Chú thích", "Apply": "Áp dụng", "Are you sure to delete the selected books?": "Bạn có chắc chắn muốn xóa các sách đã chọn?", "Auto Mode": "Chế độ tự động", @@ -16,12 +17,16 @@ "Color": "Màu sắc", "Confirm": "Xác nhận", "Confirm Deletion": "Xác nhận xóa", + "Copied to notebook": "Đã sao chép vào sổ ghi chú", + "Copy": "Sao chép", "Custom CSS": "CSS tùy chỉnh", "Dark Mode": "Chế độ tối", "Default": "Mặc định", "Default Font": "Phông chữ mặc định", "Default Font Size": "Cỡ chữ mặc định", "Delete": "Xóa", + "Delete Highlight": "Xóa văn bản được đánh dấu", + "Dictionary": "Từ điển", "Disable Click-to-Flip": "Tắt tính năng nhấp để lật trang", "Download Readest": "Tải Readest", "Edit": "Chỉnh sửa", @@ -44,6 +49,7 @@ "Grass": "Xanh cỏ", "Gray": "Xám", "Gruvbox": "Gruvbox", + "Highlight": "Đánh dấu", "Horizontal Direction": "Hướng ngang", "Hyphenation": "Gạch nối từ", "Identifier:": "Định danh:", @@ -89,6 +95,7 @@ "Sans-Serif Font": "Phông chữ không chân", "Save": "Lưu", "Scrolled Mode": "Chế độ cuộn", + "Search": "Tìm kiếm", "Search books...": "Tìm kiếm sách...", "Search...": "Tìm kiếm...", "Select Book": "Chọn sách", @@ -102,10 +109,12 @@ "Sign Out": "Đăng xuất", "Sky": "Xanh trời", "Solarized": "Solarized", + "Speak": "Đọc", "Subjects:": "Chủ đề:", "System Fonts": "Phông chữ hệ thống", "Theme Color": "Màu chủ đề", "Theme Mode": "Chế độ chủ đề", + "Translate": "Dịch", "Translated Text": "Văn bản dịch", "Unknown": "Không xác định", "Untitled": "Không có tiêu đề", @@ -114,6 +123,7 @@ "Version {{version}}": "Phiên bản {{version}}", "Vertical Direction": "Hướng dọc", "Welcome to your library. You can import your books here and read them anytime.": "Chào mừng đến với thư viện của bạn. Bạn có thể nhập sách vào đây và đọc bất cứ lúc nào.", + "Wikipedia": "Wikipedia", "Writing Mode": "Chế độ viết", "Your Library": "Thư viện của bạn" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 94ebc565..f595e18c 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -3,6 +3,7 @@ "About Readest": "关于 Readest", "Add your notes here...": "在这里添加您的笔记...", "Animation": "动画", + "Annotate": "笔记", "Apply": "应用", "Are you sure to delete the selected books?": "您确定要删除所选书籍吗?", "Auto Mode": "自动主题", @@ -16,12 +17,16 @@ "Color": "颜色", "Confirm": "确认", "Confirm Deletion": "确认删除", + "Copied to notebook": "已复制到笔记本", + "Copy": "复制", "Custom CSS": "自定义 CSS", "Dark Mode": "深色主题", "Default": "默认", "Default Font": "默认字体", "Default Font Size": "默认字号", "Delete": "删除", + "Delete Highlight": "删除划线", + "Dictionary": "词典", "Disable Click-to-Flip": "禁用点击翻页", "Download Readest": "下载 Readest", "Edit": "编辑", @@ -44,6 +49,7 @@ "Grass": "青草", "Gray": "素雅", "Gruvbox": "暖橘", + "Highlight": "划线", "Horizontal Direction": "横排", "Hyphenation": "断字", "Identifier:": "识别码", @@ -89,6 +95,7 @@ "Sans-Serif Font": "无衬线字体", "Save": "保存", "Scrolled Mode": "滚动模式", + "Search": "搜索", "Search books...": "搜索书籍...", "Search...": "搜索...", "Select Book": "选择书籍", @@ -102,10 +109,12 @@ "Sign Out": "登出", "Sky": "天青", "Solarized": "日晖", + "Speak": "朗读", "Subjects:": "主题", "System Fonts": "系统字体", "Theme Color": "主题颜色", "Theme Mode": "主题模式", + "Translate": "翻译", "Translated Text": "译文", "Unknown": "未知", "Untitled": "无标题", @@ -114,6 +123,7 @@ "Version {{version}}": "版本 {{version}}", "Vertical Direction": "竖排", "Welcome to your library. You can import your books here and read them anytime.": "书库空空如也,您可以导入您的书籍并随时阅读。", + "Wikipedia": "维基百科", "Writing Mode": "排版模式", "Your Library": "书库" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 3df29e45..b6311645 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -3,6 +3,7 @@ "About Readest": "關於 Readest", "Add your notes here...": "在這裡添加您的筆記...", "Animation": "動畫", + "Annotate": "筆記", "Apply": "應用", "Are you sure to delete the selected books?": "您確定要刪除所選書籍嗎?", "Auto Mode": "自動主題", @@ -16,12 +17,16 @@ "Color": "顏色", "Confirm": "確認", "Confirm Deletion": "確認刪除", + "Copied to notebook": "已複製到筆記本", + "Copy": "複製", "Custom CSS": "自定義 CSS", "Dark Mode": "深色主題", "Default": "預設", "Default Font": "預設字體", "Default Font Size": "預設字號", "Delete": "刪除", + "Delete Highlight": "刪除劃線", + "Dictionary": "詞典", "Disable Click-to-Flip": "禁用點擊翻頁", "Download Readest": "下載 Readest", "Edit": "編輯", @@ -44,6 +49,7 @@ "Grass": "青草", "Gray": "素雅", "Gruvbox": "暖橘", + "Highlight": "劃線", "Horizontal Direction": "橫排", "Hyphenation": "斷字", "Identifier:": "識別碼", @@ -89,6 +95,7 @@ "Sans-Serif Font": "無襯線字體", "Save": "保存", "Scrolled Mode": "滾動模式", + "Search": "搜索", "Search books...": "搜索書籍...", "Search...": "搜索...", "Select Book": "選擇書籍", @@ -102,10 +109,12 @@ "Sign Out": "登出", "Sky": "天青", "Solarized": "日暉", + "Speak": "朗讀", "Subjects:": "主題", "System Fonts": "系統字體", "Theme Color": "主題顏色", "Theme Mode": "主題模式", + "Translate": "翻譯", "Translated Text": "譯文", "Unknown": "未知", "Untitled": "無標題", @@ -114,6 +123,7 @@ "Version {{version}}": "版本 {{version}}", "Vertical Direction": "豎排", "Welcome to your library. You can import your books here and read them anytime.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。", + "Wikipedia": "維基百科", "Writing Mode": "排版模式", "Your Library": "書庫" } diff --git a/apps/readest-app/src/app/reader/components/BooksGrid.tsx b/apps/readest-app/src/app/reader/components/BooksGrid.tsx index e460ac8c..2e1c59de 100644 --- a/apps/readest-app/src/app/reader/components/BooksGrid.tsx +++ b/apps/readest-app/src/app/reader/components/BooksGrid.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useSettingsStore } from '@/store/settingsStore'; import { useReaderStore } from '@/store/readerStore'; +import { useBookDataStore } from '@/store/bookDataStore'; import FoliateViewer from './FoliateViewer'; import getGridTemplate from '@/utils/grid'; import SectionInfo from './SectionInfo'; @@ -11,7 +12,6 @@ import PageInfoView from './PageInfo'; import Ribbon from './Ribbon'; import SettingsDialog from './settings/SettingsDialog'; import Annotator from './annotator/Annotator'; -import { useBookDataStore } from '@/store/bookDataStore'; import FootnotePopup from './FootnotePopup'; interface BooksGridProps { @@ -49,7 +49,7 @@ const BooksGrid: React.FC = ({ bookKeys, onCloseBook }) => {
{isBookmarked && } = ({ ids, settings }) => { const router = useRouter(); @@ -154,6 +155,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
+ {showDetailsBook && ( = ({ bookKey }) => { + const _ = useTranslation(); const { envConfig } = useEnv(); const { settings } = useSettingsStore(); const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore(); @@ -222,7 +225,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const handleCopy = () => { if (!selection || !selection.text) return; - setToastMessage('Copied to notebook'); + setToastMessage(_('Copied to notebook')); const { booknotes: annotations = [] } = config; if (selection) navigator.clipboard.writeText(selection.text); @@ -334,19 +337,29 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { setShowDeepLPopup(true); }; + const handleSpeakText = async () => { + if (!selection || !selection.text) return; + setShowAnnotPopup(false); + + await view!.initTTS(); + const ssml = view!.tts.from(selection.range)!; + eventDispatcher.dispatch('speak', { bookKey, ssml }); + }; + const selectionAnnotated = selection?.annotated; const buttons = [ - { tooltipText: 'Copy', Icon: FiCopy, onClick: handleCopy }, + { tooltipText: _('Copy'), Icon: FiCopy, onClick: handleCopy }, { - tooltipText: selectionAnnotated ? 'Delete Highlight' : 'Highlight', + tooltipText: selectionAnnotated ? _('Delete Highlight') : _('Highlight'), Icon: selectionAnnotated ? RiDeleteBinLine : PiHighlighterFill, onClick: handleHighlight, }, - { tooltipText: 'Annotate', Icon: BsPencilSquare, onClick: handleAnnotate }, - { tooltipText: 'Search', Icon: FiSearch, onClick: handleSearch }, - { tooltipText: 'Dictionary', Icon: BsTranslate, onClick: handleDictionary }, - { tooltipText: 'Translate', Icon: SiDeepl, onClick: handleTranslation }, - { tooltipText: 'Wikipedia', Icon: FaWikipediaW, onClick: handleWikipedia }, + { tooltipText: _('Annotate'), Icon: BsPencilSquare, onClick: handleAnnotate }, + { tooltipText: _('Search'), Icon: FiSearch, onClick: handleSearch }, + { tooltipText: _('Dictionary'), Icon: TbHexagonLetterD, onClick: handleDictionary }, + { tooltipText: _('Translate'), Icon: BsTranslate, onClick: handleTranslation }, + { tooltipText: _('Wikipedia'), Icon: FaWikipediaW, onClick: handleWikipedia }, + { tooltipText: _('Speak'), Icon: FaHeadphones, onClick: handleSpeakText }, ]; return ( diff --git a/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx b/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx index f6063e74..bbf6d1f2 100644 --- a/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx @@ -51,7 +51,8 @@ const HighlightOptions: React.FC = ({ >
{ + const { getView } = useReaderStore(); + const [isPlaying, setIsPlaying] = useState(false); + const [showPanel, setShowPanel] = useState(false); + const [panelPosition, setPanelPosition] = useState(); + const [trianglePosition, setTrianglePosition] = useState(); + + const iconRef = useRef(null); + const ttsControllerRef = useRef(null); + + useEffect(() => { + return () => { + if (ttsControllerRef.current) { + ttsControllerRef.current.kill(); + ttsControllerRef.current = null; + } + }; + }, []); + + useEffect(() => { + eventDispatcher.on('speak', handleSpeak); + return () => { + eventDispatcher.off('speak', handleSpeak); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSpeak = async (event: CustomEvent) => { + const { bookKey, ssml } = event.detail; + const view = getView(bookKey); + if (!view) return; + + const ttsClient = new WebSpeechClient(); + const ttsController = new TTSController(ttsClient, view); + ttsControllerRef.current = ttsController; + ttsControllerRef.current.speak(ssml); + setIsPlaying(true); + }; + + const handleTogglePlay = async () => { + const ttsController = ttsControllerRef.current; + if (!ttsController) return; + + if (isPlaying) { + ttsController.pause(); + setIsPlaying(false); + } else { + await ttsController.start(); + setIsPlaying(true); + } + }; + + const handleBackward = async () => { + const ttsController = ttsControllerRef.current; + if (ttsController) { + await ttsController.backward(); + } + }; + + const handleForward = async () => { + const ttsController = ttsControllerRef.current; + if (ttsController) { + await ttsController.forward(); + } + }; + + const handleStop = async () => { + const ttsController = ttsControllerRef.current; + if (ttsController) { + ttsController.stop(); + ttsControllerRef.current = null; + setIsPlaying(false); + setShowPanel(false); + } + }; + + const updatePanelPosition = () => { + if (iconRef.current) { + const rect = iconRef.current.getBoundingClientRect(); + const windowRect = document.documentElement.getBoundingClientRect(); + + const trianglePos = { + dir: 'up', + point: { x: rect.left + rect.width / 2, y: rect.top - 12 }, + } as Position; + + const popupPos = getPopupPosition( + trianglePos, + windowRect, + POPUP_WIDTH, + POPUP_HEIGHT, + POPUP_PADDING, + ); + + setPanelPosition(popupPos); + setTrianglePosition(trianglePos); + } + }; + + const togglePopup = () => { + updatePanelPosition(); + setShowPanel((prev) => !prev); + }; + + return ( +
+ {ttsControllerRef.current && ( +
+ +
+ )} + {showPanel && panelPosition && trianglePosition && ( + + + + )} +
+ ); +}; + +export default TTSControl; diff --git a/apps/readest-app/src/app/reader/components/tts/TTSIcon.tsx b/apps/readest-app/src/app/reader/components/tts/TTSIcon.tsx new file mode 100644 index 00000000..d5b87883 --- /dev/null +++ b/apps/readest-app/src/app/reader/components/tts/TTSIcon.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +type TTSIconProps = { + isPlaying: boolean; + onClick: () => void; +}; + +const TTSIcon: React.FC = ({ isPlaying, onClick }) => { + const bars = [1, 2, 3, 4]; + + return ( +
+
+
+
+ +
+ +
+ {bars.map((bar) => ( +
+ ))} +
+
+
+ ); +}; + +export default TTSIcon; diff --git a/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx b/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx new file mode 100644 index 00000000..3a7e071c --- /dev/null +++ b/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdStop } from 'react-icons/md'; + +type TTSPanelProps = { + isPlaying: boolean; + onTogglePlay: () => void; + onBackward: () => void; + onForward: () => void; + onStop: () => void; +}; + +const TTSPanel = ({ isPlaying, onTogglePlay, onBackward, onForward, onStop }: TTSPanelProps) => { + return ( +
+
+ + + + +
+
+ ); +}; + +export default TTSPanel; diff --git a/apps/readest-app/src/services/tts/TTSClient.ts b/apps/readest-app/src/services/tts/TTSClient.ts new file mode 100644 index 00000000..64e1e085 --- /dev/null +++ b/apps/readest-app/src/services/tts/TTSClient.ts @@ -0,0 +1,17 @@ +export type TTSMessageCode = 'boundary' | 'error' | 'end'; + +export interface TTSMessageEvent { + code: TTSMessageCode; + message?: string; + mark?: string; +} + +export interface TTSClient { + init(): Promise; + speak(ssml: string): Promise>; + pause(): Promise; + resume(): Promise; + stop(): Promise; + setRate(rate: number): Promise; + setPitch(pitch: number): Promise; +} diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts new file mode 100644 index 00000000..854fb1a3 --- /dev/null +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -0,0 +1,115 @@ +import { FoliateView } from '@/types/view'; +import { TTSClient, TTSMessageCode } from './TTSClient'; + +type TTSState = 'stopped' | 'playing' | 'paused'; + +export class TTSController extends EventTarget { + state: TTSState = 'stopped'; + ttsClient: TTSClient; + view: FoliateView; + #nossmlCnt: number = 0; + + constructor(ttsClient: TTSClient, view: FoliateView) { + super(); + this.ttsClient = ttsClient; + this.view = view; + } + + async #init() { + await this.ttsClient.stop(); + await this.view.initTTS(); + } + + async #speak(ssml: string | undefined | Promise) { + console.log('TTS speak'); + this.state = 'playing'; + ssml = await ssml; + if (!ssml) { + this.#nossmlCnt++; + // FIXME: in case we are at the end of the book, need a better way to handle this + if (this.#nossmlCnt < 100) { + await this.view.next(1); + this.forward(); + } + return; + } else { + this.#nossmlCnt = 0; + } + + const iter = await this.ttsClient.speak(ssml); + let lastCode: TTSMessageCode = 'boundary'; + for await (const { code, mark } of iter) { + if (mark) { + this.view.tts.setMark(mark); + } + lastCode = code; + } + + if (lastCode === 'end') { + this.forward(); + } else { + this.state = 'paused'; + } + } + + async speak(ssml: string | Promise) { + await this.#init(); + this.#speak(ssml).catch((e) => this.error(e)); + } + + play() { + if (this.state !== 'playing') { + this.start(); + } else { + this.pause(); + } + } + + async start() { + await this.#init(); + const resumeOrStart = this.state === 'paused' ? this.view.tts.resume() : this.view.tts.start(); + return this.#speak(resumeOrStart); + } + + pause() { + this.state = 'paused'; + this.ttsClient.stop().catch((e) => this.error(e)); + } + + stop() { + this.state = 'stopped'; + this.ttsClient.stop().catch((e) => this.error(e)); + } + + // goto previous sentence + async backward() { + this.#init(); + if (this.state === 'playing') { + this.#speak(this.view.tts.prev()); + } else { + this.state = 'paused'; + this.view.tts.prev(true); + } + } + + // goto next sentence + async forward() { + await this.#init(); + if (this.state === 'playing') { + this.#speak(this.view.tts.next()); + } else { + this.state = 'paused'; + this.view.tts.next(true); + } + } + + error(e: unknown) { + console.error(e); + this.state = 'stopped'; + } + + kill() { + this.state = 'stopped'; + this.ttsClient.stop(); + } +} diff --git a/apps/readest-app/src/services/tts/WebSpeechClient.ts b/apps/readest-app/src/services/tts/WebSpeechClient.ts new file mode 100644 index 00000000..c3f88e64 --- /dev/null +++ b/apps/readest-app/src/services/tts/WebSpeechClient.ts @@ -0,0 +1,174 @@ +import { TTSClient, TTSMessageEvent } from './TTSClient'; +import { AsyncQueue } from '@/utils/queue'; + +interface TTSBoundaryEvent { + type: 'boundary' | 'end' | 'error'; + speaking: boolean; + name?: string; + mark?: string; + charIndex?: number; + charLength?: number; + error?: string; +} + +type TTSMark = { + offset: number; + name: string; + text: string; +}; + +function getXmlLang(ssml: string): string | null { + const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/); + return match ? match[1]! : null; +} + +function parseSSMLMarks(ssml: string) { + ssml = ssml.replace(/]*>/i, ''); + ssml = ssml.replace(/<\/speak>/i, ''); + + const markRegex = //g; + let plainText = ''; + const marks: TTSMark[] = []; + + let match; + while ((match = markRegex.exec(ssml)) !== null) { + const markTagEndIndex = markRegex.lastIndex; + const nextMarkIndex = ssml.indexOf(']+>/g, '').trimStart(); + plainText += cleanedChunk; + + const offset = plainText.length - cleanedChunk.length; + const markName = match[1]!; + marks.push({ offset, name: markName, text: cleanedChunk }); + } + + return { plainText, marks }; +} + +function findMark(charIndex: number, marks: TTSMark[]) { + let left = 0; + let right = marks.length - 1; + let result: TTSMark | null = null; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const mark = marks[mid]!; + + if (mark.offset <= charIndex) { + result = mark; + left = mid + 1; + } else { + right = mid - 1; + } + } + + return result; +} + +async function* speakWithBoundary(ssml: string) { + const lang = getXmlLang(ssml); + const { plainText, marks } = parseSSMLMarks(ssml); + // console.log('text', plainText, marks); + + const synth = window.speechSynthesis; + const utterance = new SpeechSynthesisUtterance(plainText); + + if (lang) { + utterance.lang = lang; + } + + const queue = new AsyncQueue(); + + utterance.onboundary = (event: SpeechSynthesisEvent) => { + const mark = findMark(event.charIndex, marks); + // console.log('boundary', mark); + queue.enqueue({ + type: 'boundary', + speaking: true, + name: event.name, + mark: mark?.name ?? '', + charIndex: event.charIndex, + charLength: event.charLength, + }); + }; + + utterance.onend = () => { + queue.enqueue({ type: 'end', speaking: false }); + queue.finish(); + }; + + utterance.onerror = (event) => { + queue.enqueue({ type: 'error', speaking: false, error: event.error }); + queue.finish(); + }; + + synth.speak(utterance); + + while (true) { + const ev = await queue.dequeue(); + if (ev === null) { + break; + } + yield ev; + } +} + +export class WebSpeechClient implements TTSClient { + #utterance: SpeechSynthesisUtterance | null = null; + #synth = window.speechSynthesis; + + async init() { + if (!this.#synth) { + throw new Error('Web Speech API not supported in this browser'); + } + } + + async speak(ssml: string) { + await this.stop(); + + const generator = (async function* () { + for await (const ev of speakWithBoundary(ssml)) { + if (ev.type === 'boundary') { + yield { + code: 'boundary', + mark: ev.mark ?? '', + message: `${ev.name} ${ev.charIndex}/${ev.charLength}`, + } as TTSMessageEvent; + } else if (ev.type === 'error') { + yield { code: 'error', message: ev.error } as TTSMessageEvent; + } else if (ev.type === 'end') { + yield { code: 'end', message: 'Speech finished' } as TTSMessageEvent; + } + } + })(); + + return generator; + } + + async pause() { + this.#synth.pause(); + } + + async resume() { + this.#synth.resume(); + } + + async stop() { + this.#synth.cancel(); + } + + async setRate(rate: number) { + // The Web Speech API uses utterance.rate in [0.1 .. 10], + // you might need to scale your “rate” to match. + if (this.#utterance) this.#utterance.rate = rate / 10; + } + + async setPitch(pitch: number) { + // The Web Speech API uses pitch in [0 .. 2]. + if (this.#utterance) this.#utterance.pitch = pitch / 50; + } +} diff --git a/apps/readest-app/src/services/tts/index.ts b/apps/readest-app/src/services/tts/index.ts new file mode 100644 index 00000000..aed8c0ed --- /dev/null +++ b/apps/readest-app/src/services/tts/index.ts @@ -0,0 +1,3 @@ +export * from './TTSClient'; +export * from './WebSpeechClient'; +export * from './TTSController'; diff --git a/apps/readest-app/src/types/view.ts b/apps/readest-app/src/types/view.ts index c4fd0fdd..135b2698 100644 --- a/apps/readest-app/src/types/view.ts +++ b/apps/readest-app/src/types/view.ts @@ -1,5 +1,6 @@ import { BookDoc } from '@/libs/document'; import { BookNote, BookSearchConfig, BookSearchResult } from '@/types/book'; +import { TTS } from 'foliate-js/tts.js'; export interface FoliateView extends HTMLElement { open: (book: BookDoc) => Promise; @@ -17,6 +18,8 @@ export interface FoliateView extends HTMLElement { clearSearch: () => void; select: (target: string | number | { fraction: number }) => void; deselect: () => void; + initTTS: () => Promise; + tts: TTS; history: { canGoBack: boolean; canGoForward: boolean; diff --git a/apps/readest-app/src/utils/queue.ts b/apps/readest-app/src/utils/queue.ts new file mode 100644 index 00000000..e540df77 --- /dev/null +++ b/apps/readest-app/src/utils/queue.ts @@ -0,0 +1,39 @@ +export class AsyncQueue { + private queue: T[] = []; + private resolves: ((value: void) => void)[] = []; + private done = false; + + enqueue(item: T) { + this.queue.push(item); + if (this.resolves.length > 0) { + const resolve = this.resolves.shift(); + resolve?.(); + } + } + + finish() { + this.done = true; + while (this.resolves.length > 0) { + const resolve = this.resolves.shift(); + resolve?.(); + } + } + + async dequeue(): Promise { + if (this.queue.length > 0) { + return this.queue.shift()!; + } + if (this.done) { + return null; + } + return new Promise((resolve) => { + this.resolves.push(() => { + if (this.queue.length > 0) { + resolve(this.queue.shift()!); + } else { + resolve(null); + } + }); + }); + } +} diff --git a/packages/foliate-js b/packages/foliate-js index fcc449c0..aa5f950b 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit fcc449c08909bdaa008cd03e114a846d49e7c118 +Subproject commit aa5f950b1f839f025a2f09432e5c3b62f8565234