tts: translation also when TTS is played in the background, closes #1383 (#1399)

This commit is contained in:
Huang Xin
2025-06-15 15:07:22 +08:00
committed by GitHub
parent be9802a936
commit f7f8872a13
7 changed files with 96 additions and 36 deletions
@@ -7,20 +7,14 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useTranslator } from '@/hooks/useTranslator';
import { TRANSLATED_LANGS } from '@/services/constants';
import { UseTranslatorOptions, getTranslators } from '@/services/translators';
import { localeToLang } from '@/utils/lang';
import Select from '@/components/Select';
const notSupportedLangs = ['hi', 'vi'];
const notSupportedLangs = [''];
const generateTranslatorLangs = () => {
const langs = { ...TRANSLATED_LANGS };
const result: Record<string, string> = {};
for (const [code, name] of Object.entries(langs)) {
if (notSupportedLangs.includes(code)) continue;
const newCode = localeToLang(code).toUpperCase();
result[newCode] = name;
}
return result;
return Object.fromEntries(
Object.entries(TRANSLATED_LANGS).filter(([code]) => !notSupportedLangs.includes(code)),
);
};
const TRANSLATOR_LANGS = generateTranslatorLangs();
@@ -205,7 +199,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
</div>
<div className='absolute bottom-0 flex h-8 w-full items-center justify-between bg-gray-600 px-4'>
{provider && !loading && (
<div className='text-xs opacity-60'>
<div className='line-clamp-1 text-xs opacity-60'>
{error
? ''
: _('Translated by {{provider}}.', {
@@ -1,15 +1,17 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FoliateView } from '@/types/view';
import { UseTranslatorOptions } from '@/services/translators';
import { useReaderStore } from '@/store/readerStore';
import { useTranslator } from '@/hooks/useTranslator';
import { walkTextNodes } from '@/utils/walk';
import { localeToLang } from '@/utils/lang';
import { debounce } from '@/utils/debounce';
import { getLocale } from '@/utils/misc';
export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElement | null) {
const { getViewSettings } = useReaderStore();
const { getViewSettings, getViewState, getProgress } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const viewState = getViewState(bookKey);
const progress = getProgress(bookKey);
const enabled = useRef(viewSettings?.translationEnabled);
const [provider, setProvider] = useState(viewSettings?.translationProvider);
@@ -17,7 +19,7 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
const { translate } = useTranslator({
provider,
targetLang: localeToLang(targetLang || getLocale()),
targetLang: targetLang || getLocale(),
} as UseTranslatorOptions);
const translateRef = useRef(translate);
@@ -118,7 +120,7 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
const translateElement = async (el: HTMLElement) => {
if (!enabled.current) return;
const text = el.textContent?.trim();
const text = el.textContent?.replaceAll('\n', '').trim();
if (!text) return;
if (el.querySelector('.translation-target')) {
@@ -156,6 +158,61 @@ export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElem
}
};
const findNodeIndicesInRange = (range: Range, nodes: HTMLElement[]) => {
const startContainer = range.startContainer;
const endContainer = range.endContainer;
let startIndex = -1;
let endIndex = -1;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]!;
if (node === startContainer || node.contains(startContainer)) {
if (startIndex === -1) startIndex = i;
}
if (node === endContainer || node.contains(endContainer)) {
endIndex = i;
}
}
if (startIndex !== -1 && endIndex === -1) {
endIndex = startIndex;
}
return { startIndex, endIndex };
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const translateInRange = useCallback(
debounce((range: Range) => {
const nodes = allTextNodes.current;
if (nodes.length === 0) {
console.warn('No text nodes available for translation.');
return;
}
const { startIndex, endIndex } = findNodeIndicesInRange(range, nodes);
if (startIndex === -1) {
console.log('Range not found in text nodes');
return;
}
const beforeStart = Math.max(0, startIndex - 2);
const afterEnd = Math.min(nodes.length - 1, endIndex + 2);
for (let i = beforeStart; i <= afterEnd; i++) {
const node = nodes[i];
if (node) {
translateElement(node);
}
}
}, 500),
[],
);
useEffect(() => {
if (viewState?.ttsEnabled && progress && document.hidden) {
const { range } = progress;
translateInRange(range);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewState?.ttsEnabled, progress]);
useEffect(() => {
if (!viewSettings) return;
@@ -2,7 +2,7 @@ import { stubTranslation as _ } from '@/utils/misc';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { TranslationProvider } from '../types';
import { langToDefaultLocale } from '@/utils/lang';
import { normalizeToFullLang } from '@/utils/lang';
interface TokenCache {
token: string;
@@ -53,8 +53,8 @@ export const azureProvider: TranslationProvider = {
if (!text.length) return [];
const results: string[] = [];
const msSourceLang = sourceLang ? langToDefaultLocale(sourceLang.toLowerCase()) : '';
const msTargetLang = langToDefaultLocale(targetLang.toLowerCase());
const msSourceLang = sourceLang ? normalizeToFullLang(sourceLang) : '';
const msTargetLang = normalizeToFullLang(targetLang);
const translationPromises = text.map(async (line, index) => {
if (!line?.trim().length) {
@@ -67,7 +67,7 @@ export const azureProvider: TranslationProvider = {
to: msTargetLang,
'api-version': '3.0',
});
if (msSourceLang && msSourceLang !== 'auto') {
if (msSourceLang && msSourceLang.toLowerCase() !== 'auto') {
params.append('from', msSourceLang);
}
@@ -3,6 +3,7 @@ import { stubTranslation as _ } from '@/utils/misc';
import { ErrorCodes, TranslationProvider } from '../types';
import { UserPlan } from '@/types/user';
import { getUserPlan } from '@/utils/access';
import { normalizeToShortLang } from '@/utils/lang';
import { DEFAULT_DAILY_TRANSLATION_QUOTA } from '@/services/constants';
import { saveDailyUsage } from '../utils';
@@ -38,8 +39,8 @@ export const deeplProvider: TranslationProvider = {
const body = JSON.stringify({
text: text,
source_lang: sourceLang.toUpperCase(),
target_lang: targetLang.toUpperCase(),
source_lang: normalizeToShortLang(sourceLang).toUpperCase(),
target_lang: normalizeToShortLang(targetLang).toUpperCase(),
use_cache: useCache,
});
@@ -1,6 +1,7 @@
import { stubTranslation as _ } from '@/utils/misc';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { normalizeToShortLang } from '@/utils/lang';
import { TranslationProvider } from '../types';
export const googleProvider: TranslationProvider = {
@@ -20,8 +21,8 @@ export const googleProvider: TranslationProvider = {
const url = new URL('https://translate.googleapis.com/translate_a/single');
url.searchParams.append('client', 'gtx');
url.searchParams.append('dt', 't');
url.searchParams.append('sl', sourceLang.toLowerCase() || 'auto');
url.searchParams.append('tl', targetLang.toLowerCase());
url.searchParams.append('sl', normalizeToShortLang(sourceLang).toLowerCase() || 'auto');
url.searchParams.append('tl', normalizeToShortLang(targetLang).toLowerCase());
url.searchParams.append('q', line);
const fetch = isTauriAppPlatform() ? tauriFetch : window.fetch;
@@ -51,13 +51,13 @@ export class TTSController extends EventTarget {
async init() {
const availableClients = [];
if (await this.ttsEdgeClient.init()) {
availableClients.push(this.ttsEdgeClient);
}
if (this.ttsNativeClient && (await this.ttsNativeClient.init())) {
availableClients.push(this.ttsNativeClient);
this.ttsNativeVoices = await this.ttsNativeClient.getAllVoices();
}
if (await this.ttsEdgeClient.init()) {
availableClients.push(this.ttsEdgeClient);
}
if (await this.ttsWebClient.init()) {
availableClients.push(this.ttsWebClient);
}
+16 -9
View File
@@ -8,7 +8,7 @@ export const isCJKLang = (lang: string | null | undefined): boolean => {
return ['zh', 'ja', 'ko'].includes(normalizedLang);
};
export const langToDefaultLocale = (langCode: string): string => {
export const normalizeToFullLang = (langCode: string): string => {
const mapping: Record<string, string> = {
en: 'en-US',
fr: 'fr-FR',
@@ -26,24 +26,31 @@ export const langToDefaultLocale = (langCode: string): string => {
ru: 'ru-RU',
uk: 'uk-UA',
zh: 'zh-Hans',
'zh-cn': 'zh-Hans',
'zh-tw': 'zh-Hant',
'zh-mo': 'zh-Hant',
'zh-hans': 'zh-Hans',
'zh-hant': 'zh-Hant',
};
return mapping[langCode] || langCode;
return mapping[langCode.toLowerCase()] || langCode;
};
export const localeToLang = (locale: string): string => {
export const normalizeToShortLang = (langCode: string): string => {
const lang = langCode.toLowerCase();
const mapping: Record<string, string> = {
'zh-CN': 'zh-Hans',
'zh-TW': 'zh-Hant',
'zh-HK': 'zh-Hant',
'zh-cn': 'zh-Hans',
'zh-tw': 'zh-Hant',
'zh-hk': 'zh-Hant',
'zh-mo': 'zh-Hant',
'zh-hans': 'zh-Hans',
'zh-hant': 'zh-Hant',
};
if (locale.startsWith('zh')) {
return mapping[locale] || 'zh-Hans';
if (lang.startsWith('zh')) {
return mapping[lang] || 'zh-Hans';
}
return mapping[locale] || locale.split('-')[0]!.toLowerCase();
return lang.split('-')[0]!;
};
export const normalizedLangCode = (lang: string | null | undefined): string => {