feat(settings): add TTS settings tab and highlight opacity, closes #3661 (#3712)

Add a new TTS tab in settings with media metadata update frequency control
(sentence/paragraph/chapter) to reduce Bluetooth notification spam, and move
TTS highlight settings from Color tab to the new TTS tab. Also add a highlight
opacity setting with live preview in the Color tab.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-04-01 12:35:26 +08:00
committed by GitHub
parent 94843902ac
commit b71b246601
42 changed files with 729 additions and 167 deletions
@@ -1196,5 +1196,12 @@
"Text to Speech": "تحويل النص إلى كلام",
"Zoom": "تكبير",
"Window": "نافذة",
"Your Bookshelf": "رف الكتب الخاص بك"
"Your Bookshelf": "رف الكتب الخاص بك",
"TTS": "تحويل النص إلى كلام",
"Media Info": "معلومات الوسائط",
"Update Frequency": "تردد التحديث",
"Every Sentence": "كل جملة",
"Every Paragraph": "كل فقرة",
"Every Chapter": "كل فصل",
"TTS Media Info Update Frequency": "تردد تحديث معلومات وسائط TTS"
}
@@ -1144,5 +1144,12 @@
"Text to Speech": "পাঠ থেকে বাক্য",
"Zoom": "জুম",
"Window": "উইন্ডো",
"Your Bookshelf": "আপনার বইয়ের তাক"
"Your Bookshelf": "আপনার বইয়ের তাক",
"TTS": "টেক্সট টু স্পিচ",
"Media Info": "মিডিয়া তথ্য",
"Update Frequency": "আপডেটের ফ্রিকোয়েন্সি",
"Every Sentence": "প্রতিটি বাক্য",
"Every Paragraph": "প্রতিটি অনুচ্ছেদ",
"Every Chapter": "প্রতিটি অধ্যায়",
"TTS Media Info Update Frequency": "TTS মিডিয়া তথ্য আপডেট ফ্রিকোয়েন্সি"
}
@@ -1131,5 +1131,12 @@
"Text to Speech": "ཡི་གེ་ནས་སྒྲ།",
"Zoom": "ཆེ་རུ་གཏོང་།",
"Window": "སྒེའུ་ཁུང་།",
"Your Bookshelf": "ཁྱོད་ཀྱི་དཔེ་མཛོད།"
"Your Bookshelf": "ཁྱོད་ཀྱི་དཔེ་མཛོད།",
"TTS": "ཡི་གེ་ནས་སྒྲ",
"Media Info": "སྨྱན་བྱད་ཆ་འཕྲིན",
"Update Frequency": "གསར་སྒྱུར་ཐེངས་གྲངས",
"Every Sentence": "ཚིག་གྲུབ་རེ་རེ",
"Every Paragraph": "དུམ་མཚམས་རེ་རེ",
"Every Chapter": "ལེའུ་རེ་རེ",
"TTS Media Info Update Frequency": "TTS སྨྱན་བྱད་ཆ་འཕྲིན་གསར་སྒྱུར་ཐེངས་གྲངས"
}
@@ -1144,5 +1144,12 @@
"Text to Speech": "Sprachausgabe",
"Zoom": "Zoom",
"Window": "Fenster",
"Your Bookshelf": "Dein Bücherregal"
"Your Bookshelf": "Dein Bücherregal",
"TTS": "Sprachausgabe",
"Media Info": "Medieninfo",
"Update Frequency": "Aktualisierungshäufigkeit",
"Every Sentence": "Jeden Satz",
"Every Paragraph": "Jeden Absatz",
"Every Chapter": "Jedes Kapitel",
"TTS Media Info Update Frequency": "TTS-Medieninfo-Aktualisierungshäufigkeit"
}
@@ -1144,5 +1144,12 @@
"Text to Speech": "Κείμενο σε ομιλία",
"Zoom": "Ζουμ",
"Window": "Παράθυρο",
"Your Bookshelf": "Η βιβλιοθήκη σου"
"Your Bookshelf": "Η βιβλιοθήκη σου",
"TTS": "Εκφώνηση",
"Media Info": "Πληροφορίες μέσων",
"Update Frequency": "Συχνότητα ενημέρωσης",
"Every Sentence": "Κάθε πρόταση",
"Every Paragraph": "Κάθε παράγραφο",
"Every Chapter": "Κάθε κεφάλαιο",
"TTS Media Info Update Frequency": "Συχνότητα ενημέρωσης πληροφοριών μέσων TTS"
}
@@ -1157,5 +1157,12 @@
"Text to Speech": "Texto a voz",
"Zoom": "Zoom",
"Window": "Ventana",
"Your Bookshelf": "Tu estantería"
"Your Bookshelf": "Tu estantería",
"TTS": "Lectura en voz alta",
"Media Info": "Info multimedia",
"Update Frequency": "Frecuencia de actualización",
"Every Sentence": "Cada oración",
"Every Paragraph": "Cada párrafo",
"Every Chapter": "Cada capítulo",
"TTS Media Info Update Frequency": "Frecuencia de actualización de info multimedia TTS"
}
@@ -1144,5 +1144,12 @@
"Text to Speech": "تبدیل متن به گفتار",
"Zoom": "بزرگ‌نمایی",
"Window": "پنجره",
"Your Bookshelf": "قفسه کتاب شما"
"Your Bookshelf": "قفسه کتاب شما",
"TTS": "تبدیل متن به گفتار",
"Media Info": "اطلاعات رسانه",
"Update Frequency": "تناوب به‌روزرسانی",
"Every Sentence": "هر جمله",
"Every Paragraph": "هر پاراگراف",
"Every Chapter": "هر فصل",
"TTS Media Info Update Frequency": "تناوب به‌روزرسانی اطلاعات رسانه TTS"
}
@@ -1157,5 +1157,12 @@
"Text to Speech": "Synthèse vocale",
"Zoom": "Zoom",
"Window": "Fenêtre",
"Your Bookshelf": "Votre bibliothèque"
"Your Bookshelf": "Votre bibliothèque",
"TTS": "Synthèse vocale",
"Media Info": "Infos média",
"Update Frequency": "Fréquence de mise à jour",
"Every Sentence": "Chaque phrase",
"Every Paragraph": "Chaque paragraphe",
"Every Chapter": "Chaque chapitre",
"TTS Media Info Update Frequency": "Fréquence de mise à jour des infos média TTS"
}
@@ -1157,5 +1157,12 @@
"Text to Speech": "טקסט לדיבור",
"Zoom": "זום",
"Window": "חלון",
"Your Bookshelf": "מדף הספרים שלך"
"Your Bookshelf": "מדף הספרים שלך",
"TTS": "הקראה",
"Media Info": "פרטי מדיה",
"Update Frequency": "תדירות עדכון",
"Every Sentence": "כל משפט",
"Every Paragraph": "כל פסקה",
"Every Chapter": "כל פרק",
"TTS Media Info Update Frequency": "תדירות עדכון פרטי מדיה TTS"
}
@@ -1144,5 +1144,12 @@
"Text to Speech": "पाठ से वाक्",
"Zoom": "ज़ूम",
"Window": "विंडो",
"Your Bookshelf": "आपकी किताबों की अलमारी"
"Your Bookshelf": "आपकी किताबों की अलमारी",
"TTS": "पाठ से वाक्",
"Media Info": "मीडिया जानकारी",
"Update Frequency": "अपडेट आवृत्ति",
"Every Sentence": "हर वाक्य",
"Every Paragraph": "हर पैराग्राफ",
"Every Chapter": "हर अध्याय",
"TTS Media Info Update Frequency": "TTS मीडिया जानकारी अपडेट आवृत्ति"
}
@@ -1131,5 +1131,12 @@
"Text to Speech": "Teks ke Suara",
"Zoom": "Zoom",
"Window": "Jendela",
"Your Bookshelf": "Rak Buku Anda"
"Your Bookshelf": "Rak Buku Anda",
"TTS": "Teks ke Suara",
"Media Info": "Info Media",
"Update Frequency": "Frekuensi Pembaruan",
"Every Sentence": "Setiap Kalimat",
"Every Paragraph": "Setiap Paragraf",
"Every Chapter": "Setiap Bab",
"TTS Media Info Update Frequency": "Frekuensi Pembaruan Info Media TTS"
}
@@ -1157,5 +1157,12 @@
"Text to Speech": "Sintesi vocale",
"Zoom": "Zoom",
"Window": "Finestra",
"Your Bookshelf": "La tua libreria"
"Your Bookshelf": "La tua libreria",
"TTS": "Sintesi vocale",
"Media Info": "Info multimediali",
"Update Frequency": "Frequenza aggiornamento",
"Every Sentence": "Ogni frase",
"Every Paragraph": "Ogni paragrafo",
"Every Chapter": "Ogni capitolo",
"TTS Media Info Update Frequency": "Frequenza aggiornamento info multimediali TTS"
}
@@ -1131,5 +1131,12 @@
"Text to Speech": "テキスト読み上げ",
"Zoom": "ズーム",
"Window": "ウィンドウ",
"Your Bookshelf": "あなたの本棚"
"Your Bookshelf": "あなたの本棚",
"TTS": "読み上げ",
"Media Info": "メディア情報",
"Update Frequency": "更新頻度",
"Every Sentence": "文ごと",
"Every Paragraph": "段落ごと",
"Every Chapter": "章ごと",
"TTS Media Info Update Frequency": "TTSメディア情報の更新頻度"
}
@@ -1131,5 +1131,12 @@
"Text to Speech": "텍스트 음성 변환",
"Zoom": "확대/축소",
"Window": "창",
"Your Bookshelf": "내 책장"
"Your Bookshelf": "내 책장",
"TTS": "음성 읽기",
"Media Info": "미디어 정보",
"Update Frequency": "업데이트 빈도",
"Every Sentence": "문장마다",
"Every Paragraph": "단락마다",
"Every Chapter": "챕터마다",
"TTS Media Info Update Frequency": "TTS 미디어 정보 업데이트 빈도"
}
@@ -1131,5 +1131,12 @@
"Text to Speech": "Teks ke Pertuturan",
"Zoom": "Zum",
"Window": "Tetingkap",
"Your Bookshelf": "Rak Buku Anda"
"Your Bookshelf": "Rak Buku Anda",
"TTS": "Teks ke Suara",
"Media Info": "Info Media",
"Update Frequency": "Kekerapan Kemas Kini",
"Every Sentence": "Setiap Ayat",
"Every Paragraph": "Setiap Perenggan",
"Every Chapter": "Setiap Bab",
"TTS Media Info Update Frequency": "Kekerapan Kemas Kini Info Media TTS"
}
@@ -1144,5 +1144,12 @@
"Text to Speech": "Tekst naar spraak",
"Zoom": "Zoom",
"Window": "Venster",
"Your Bookshelf": "Jouw boekenkast"
"Your Bookshelf": "Jouw boekenkast",
"TTS": "Spraak",
"Media Info": "Media-info",
"Update Frequency": "Updatefrequentie",
"Every Sentence": "Elke zin",
"Every Paragraph": "Elke alinea",
"Every Chapter": "Elk hoofdstuk",
"TTS Media Info Update Frequency": "TTS media-info updatefrequentie"
}
@@ -1170,5 +1170,12 @@
"Text to Speech": "Tekst na mowę",
"Zoom": "Powiększenie",
"Window": "Okno",
"Your Bookshelf": "Twoja półka z książkami"
"Your Bookshelf": "Twoja półka z książkami",
"TTS": "Czytanie na głos",
"Media Info": "Informacje o mediach",
"Update Frequency": "Częstotliwość aktualizacji",
"Every Sentence": "Co zdanie",
"Every Paragraph": "Co akapit",
"Every Chapter": "Co rozdział",
"TTS Media Info Update Frequency": "Częstotliwość aktualizacji informacji o mediach TTS"
}
@@ -1157,5 +1157,12 @@
"Text to Speech": "Texto para fala",
"Zoom": "Zoom",
"Window": "Janela",
"Your Bookshelf": "Sua estante"
"Your Bookshelf": "Sua estante",
"TTS": "Leitura em voz alta",
"Media Info": "Info de mídia",
"Update Frequency": "Frequência de atualização",
"Every Sentence": "A cada frase",
"Every Paragraph": "A cada parágrafo",
"Every Chapter": "A cada capítulo",
"TTS Media Info Update Frequency": "Frequência de atualização de info de mídia TTS"
}
@@ -1170,5 +1170,12 @@
"Text to Speech": "Озвучивание",
"Zoom": "Масштаб",
"Window": "Окно",
"Your Bookshelf": "Ваша книжная полка"
"Your Bookshelf": "Ваша книжная полка",
"TTS": "Озвучивание",
"Media Info": "Медиа-информация",
"Update Frequency": "Частота обновления",
"Every Sentence": "Каждое предложение",
"Every Paragraph": "Каждый абзац",
"Every Chapter": "Каждая глава",
"TTS Media Info Update Frequency": "Частота обновления медиа-информации TTS"
}
@@ -1144,5 +1144,12 @@
"Text to Speech": "පෙළ කථනය",
"Zoom": "විශාලනය",
"Window": "කවුළුව",
"Your Bookshelf": "ඔබේ පොත් රාක්කය"
"Your Bookshelf": "ඔබේ පොත් රාක්කය",
"TTS": "කියවීම",
"Media Info": "මාධ්‍ය තොරතුරු",
"Update Frequency": "යාවත්කාලීන සංඛ්‍යාතය",
"Every Sentence": "සෑම වාක්‍යයක්ම",
"Every Paragraph": "සෑම ඡේදයක්ම",
"Every Chapter": "සෑම පරිච්ඡේදයක්ම",
"TTS Media Info Update Frequency": "TTS මාධ්‍ය තොරතුරු යාවත්කාලීන සංඛ්‍යාතය"
}
@@ -1170,5 +1170,12 @@
"Text to Speech": "Besedilo v govor",
"Zoom": "Povečava",
"Window": "Okno",
"Your Bookshelf": "Vaša knjižna polica"
"Your Bookshelf": "Vaša knjižna polica",
"TTS": "Branje na glas",
"Media Info": "Podatki o medijih",
"Update Frequency": "Pogostost posodabljanja",
"Every Sentence": "Vsak stavek",
"Every Paragraph": "Vsak odstavek",
"Every Chapter": "Vsako poglavje",
"TTS Media Info Update Frequency": "Pogostost posodabljanja podatkov o medijih TTS"
}
@@ -1144,5 +1144,12 @@
"Text to Speech": "Text till tal",
"Zoom": "Zoom",
"Window": "Fönster",
"Your Bookshelf": "Din bokhylla"
"Your Bookshelf": "Din bokhylla",
"TTS": "Uppläsning",
"Media Info": "Medieinformation",
"Update Frequency": "Uppdateringsfrekvens",
"Every Sentence": "Varje mening",
"Every Paragraph": "Varje stycke",
"Every Chapter": "Varje kapitel",
"TTS Media Info Update Frequency": "Uppdateringsfrekvens för TTS-medieinformation"
}
@@ -1144,5 +1144,12 @@
"Text to Speech": "உரையிலிருந்து பேச்சு",
"Zoom": "பெரிதாக்கு",
"Window": "சாளரம்",
"Your Bookshelf": "உங்கள் புத்தக அலமாரி"
"Your Bookshelf": "உங்கள் புத்தக அலமாரி",
"TTS": "உரை வாசிப்பு",
"Media Info": "ஊடக தகவல்",
"Update Frequency": "புதுப்பிப்பு அதிர்வெண்",
"Every Sentence": "ஒவ்வொரு வாக்கியமும்",
"Every Paragraph": "ஒவ்வொரு பத்தியும்",
"Every Chapter": "ஒவ்வொரு அத்தியாயமும்",
"TTS Media Info Update Frequency": "TTS ஊடக தகவல் புதுப்பிப்பு அதிர்வெண்"
}
@@ -1131,5 +1131,12 @@
"Text to Speech": "อ่านออกเสียง",
"Zoom": "ซูม",
"Window": "หน้าต่าง",
"Your Bookshelf": "ชั้นหนังสือของคุณ"
"Your Bookshelf": "ชั้นหนังสือของคุณ",
"TTS": "อ่านออกเสียง",
"Media Info": "ข้อมูลสื่อ",
"Update Frequency": "ความถี่ในการอัปเดต",
"Every Sentence": "ทุกประโยค",
"Every Paragraph": "ทุกย่อหน้า",
"Every Chapter": "ทุกบท",
"TTS Media Info Update Frequency": "ความถี่ในการอัปเดตข้อมูลสื่อ TTS"
}
@@ -1144,5 +1144,12 @@
"Text to Speech": "Metinden konuşmaya",
"Zoom": "Yakınlaştırma",
"Window": "Pencere",
"Your Bookshelf": "Kitaplığınız"
"Your Bookshelf": "Kitaplığınız",
"TTS": "Sesli Okuma",
"Media Info": "Medya Bilgisi",
"Update Frequency": "Güncelleme Sıklığı",
"Every Sentence": "Her Cümle",
"Every Paragraph": "Her Paragraf",
"Every Chapter": "Her Bölüm",
"TTS Media Info Update Frequency": "TTS Medya Bilgisi Güncelleme Sıklığı"
}
@@ -1170,5 +1170,12 @@
"Text to Speech": "Озвучення",
"Zoom": "Масштаб",
"Window": "Вікно",
"Your Bookshelf": "Ваша книжкова полиця"
"Your Bookshelf": "Ваша книжкова полиця",
"TTS": "Озвучування",
"Media Info": "Медіа-інформація",
"Update Frequency": "Частота оновлення",
"Every Sentence": "Кожне речення",
"Every Paragraph": "Кожен абзац",
"Every Chapter": "Кожен розділ",
"TTS Media Info Update Frequency": "Частота оновлення медіа-інформації TTS"
}
@@ -1131,5 +1131,12 @@
"Text to Speech": "Chuyển văn bản thành giọng nói",
"Zoom": "Thu phóng",
"Window": "Cửa sổ",
"Your Bookshelf": "Tủ sách của bạn"
"Your Bookshelf": "Tủ sách của bạn",
"TTS": "Đọc văn bản",
"Media Info": "Thông tin phương tiện",
"Update Frequency": "Tần suất cập nhật",
"Every Sentence": "Mỗi câu",
"Every Paragraph": "Mỗi đoạn văn",
"Every Chapter": "Mỗi chương",
"TTS Media Info Update Frequency": "Tần suất cập nhật thông tin phương tiện TTS"
}
@@ -1131,5 +1131,12 @@
"Text to Speech": "文字转语音",
"Zoom": "缩放",
"Window": "窗口",
"Your Bookshelf": "你的书架"
"Your Bookshelf": "你的书架",
"TTS": "语音朗读",
"Media Info": "媒体信息",
"Update Frequency": "更新频率",
"Every Sentence": "每句",
"Every Paragraph": "每段",
"Every Chapter": "每章",
"TTS Media Info Update Frequency": "TTS 媒体信息更新频率"
}
@@ -1131,5 +1131,12 @@
"Text to Speech": "文字轉語音",
"Zoom": "縮放",
"Window": "視窗",
"Your Bookshelf": "你的書架"
"Your Bookshelf": "你的書架",
"TTS": "語音朗讀",
"Media Info": "媒體資訊",
"Update Frequency": "更新頻率",
"Every Sentence": "每句",
"Every Paragraph": "每段",
"Every Chapter": "每章",
"TTS Media Info Update Frequency": "TTS 媒體資訊更新頻率"
}
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import { buildTTSMediaMetadata } from '@/utils/ttsMetadata';
describe('buildTTSMediaMetadata', () => {
const baseOptions = {
markText: 'The quick brown fox jumps over the lazy dog.',
markName: '0',
sectionLabel: 'Chapter 1: Introduction',
title: 'Alice in Wonderland',
author: 'Lewis Carroll',
};
describe('sentence mode (default)', () => {
it('uses mark text as title', () => {
const result = buildTTSMediaMetadata({ ...baseOptions, ttsMediaMetadata: 'sentence' });
expect(result.title).toBe(baseOptions.markText);
});
it('uses section label as artist', () => {
const result = buildTTSMediaMetadata({ ...baseOptions, ttsMediaMetadata: 'sentence' });
expect(result.artist).toBe(baseOptions.sectionLabel);
});
it('uses author as album', () => {
const result = buildTTSMediaMetadata({ ...baseOptions, ttsMediaMetadata: 'sentence' });
expect(result.album).toBe(baseOptions.author);
});
it('falls back to book title when section label is empty', () => {
const result = buildTTSMediaMetadata({
...baseOptions,
sectionLabel: '',
ttsMediaMetadata: 'sentence',
});
expect(result.artist).toBe(baseOptions.title);
});
it('always updates regardless of mark name', () => {
expect(
buildTTSMediaMetadata({ ...baseOptions, markName: '0', ttsMediaMetadata: 'sentence' })
.shouldUpdate,
).toBe(true);
expect(
buildTTSMediaMetadata({ ...baseOptions, markName: '3', ttsMediaMetadata: 'sentence' })
.shouldUpdate,
).toBe(true);
});
});
describe('paragraph mode', () => {
it('uses same mapping as sentence mode', () => {
const sentence = buildTTSMediaMetadata({ ...baseOptions, ttsMediaMetadata: 'sentence' });
const paragraph = buildTTSMediaMetadata({
...baseOptions,
markName: '0',
ttsMediaMetadata: 'paragraph',
});
expect(paragraph.title).toBe(sentence.title);
expect(paragraph.artist).toBe(sentence.artist);
expect(paragraph.album).toBe(sentence.album);
});
it('updates on first sentence of a block (markName "0")', () => {
const result = buildTTSMediaMetadata({
...baseOptions,
markName: '0',
ttsMediaMetadata: 'paragraph',
});
expect(result.shouldUpdate).toBe(true);
});
it('skips update for subsequent sentences in the same block', () => {
expect(
buildTTSMediaMetadata({ ...baseOptions, markName: '1', ttsMediaMetadata: 'paragraph' })
.shouldUpdate,
).toBe(false);
expect(
buildTTSMediaMetadata({ ...baseOptions, markName: '2', ttsMediaMetadata: 'paragraph' })
.shouldUpdate,
).toBe(false);
});
});
describe('chapter mode', () => {
it('uses section label as title', () => {
const result = buildTTSMediaMetadata({ ...baseOptions, ttsMediaMetadata: 'chapter' });
expect(result.title).toBe(baseOptions.sectionLabel);
});
it('uses author as artist', () => {
const result = buildTTSMediaMetadata({ ...baseOptions, ttsMediaMetadata: 'chapter' });
expect(result.artist).toBe(baseOptions.author);
});
it('uses book title as album', () => {
const result = buildTTSMediaMetadata({ ...baseOptions, ttsMediaMetadata: 'chapter' });
expect(result.album).toBe(baseOptions.title);
});
it('falls back to book title when section label is empty', () => {
const result = buildTTSMediaMetadata({
...baseOptions,
sectionLabel: '',
ttsMediaMetadata: 'chapter',
});
expect(result.title).toBe(baseOptions.title);
});
it('updates when section label differs from previous', () => {
const result = buildTTSMediaMetadata({
...baseOptions,
ttsMediaMetadata: 'chapter',
previousSectionLabel: 'Prologue',
});
expect(result.shouldUpdate).toBe(true);
});
it('skips update when section label matches previous', () => {
const result = buildTTSMediaMetadata({
...baseOptions,
ttsMediaMetadata: 'chapter',
previousSectionLabel: 'Chapter 1: Introduction',
});
expect(result.shouldUpdate).toBe(false);
});
it('updates when no previous section label', () => {
const result = buildTTSMediaMetadata({
...baseOptions,
ttsMediaMetadata: 'chapter',
});
expect(result.shouldUpdate).toBe(true);
});
});
});
@@ -15,6 +15,7 @@ import { genSSMLRaw, parseSSMLLang } from '@/utils/ssml';
import { throttle } from '@/utils/throttle';
import { isCfiInLocation } from '@/utils/cfi';
import { getLocale } from '@/utils/misc';
import { buildTTSMediaMetadata } from '@/utils/ttsMetadata';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { estimateTTSTime } from '@/utils/ttsTime';
import { useTTSMediaSession } from './useTTSMediaSession';
@@ -47,6 +48,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const followingTTSLocationRef = useRef(true);
const sectionChangingTimestampRef = useRef(0);
const previousSectionLabelRef = useRef<string | undefined>(undefined);
const ttsControllerRef = useRef<TTSController | null>(null);
const [ttsController, setTtsController] = useState<TTSController | null>(null);
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
@@ -134,23 +136,39 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const handleSpeakMark = (e: Event) => {
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const { sectionLabel } = progress || {};
const mark = (e as CustomEvent<TTSMark>).detail;
const ttsMediaMetadata = viewSettings?.ttsMediaMetadata ?? 'sentence';
if (mediaSessionRef.current) {
const metadata = buildTTSMediaMetadata({
markText: mark?.text || '',
markName: mark?.name || '',
sectionLabel: sectionLabel || '',
title,
author,
ttsMediaMetadata,
previousSectionLabel: previousSectionLabelRef.current,
});
if (ttsMediaMetadata === 'chapter') {
previousSectionLabelRef.current = sectionLabel;
}
if (metadata.shouldUpdate && mediaSessionRef.current) {
const mediaSession = mediaSessionRef.current;
if (mediaSession instanceof TauriMediaSession) {
mediaSession.updateMetadata({
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
title: metadata.title,
artist: metadata.artist,
album: metadata.album,
artwork: '',
});
} else {
mediaSession.metadata = new MediaMetadata({
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
title: metadata.title,
artist: metadata.artist,
album: metadata.album,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
});
}
@@ -386,6 +404,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
setShowIndicator(false);
setShowBackToCurrentTTSLocation(false);
}
previousSectionLabelRef.current = undefined;
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: false });
}
@@ -2,6 +2,7 @@ import { useRef } from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { buildTTSMediaMetadata } from '@/utils/ttsMetadata';
import { useTranslation } from '@/hooks/useTranslation';
import { SILENCE_DATA } from '@/services/tts';
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
@@ -15,7 +16,7 @@ export const useTTSMediaSession = ({ bookKey }: UseTTSMediaSessionProps) => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { getProgress } = useReaderStore();
const { getProgress, getViewSettings } = useReaderStore();
const mediaSessionRef = useRef<TauriMediaSession | MediaSession | null>(null);
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
@@ -60,9 +61,11 @@ export const useTTSMediaSession = ({ bookKey }: UseTTSMediaSessionProps) => {
if (mediaSession instanceof TauriMediaSession) {
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
if (!bookData || !bookData.book) return;
const { title, author, coverImageUrl } = bookData.book;
const { sectionLabel } = progress || {};
const ttsMediaMetadataMode = viewSettings?.ttsMediaMetadata ?? 'sentence';
let artworkImage = '/icon.png';
try {
@@ -79,10 +82,18 @@ export const useTTSMediaSession = ({ bookKey }: UseTTSMediaSessionProps) => {
foregroundServiceTitle: _('Read Aloud'),
foregroundServiceText: _('Ready to read aloud'),
});
const metadata = buildTTSMediaMetadata({
markText: title,
markName: '0',
sectionLabel: sectionLabel || '',
title,
author,
ttsMediaMetadata: ttsMediaMetadataMode,
});
mediaSession.updateMetadata({
title: title,
artist: sectionLabel || title,
album: author,
title: metadata.title,
artist: metadata.artist,
album: metadata.album,
artwork: artworkImage,
});
}
@@ -25,7 +25,6 @@ import ThemeModeSelector from './color/ThemeModeSelector';
import ThemeColorSelector from './color/ThemeColorSelector';
import BackgroundTextureSelector from './color/BackgroundTextureSelector';
import HighlightColorsEditor from './color/HighlightColorsEditor';
import TTSHighlightStyleEditor, { TTSHighlightStyle } from './color/TTSHighlightStyleEditor';
import CodeHighlightingSettings from './color/CodeHighlightingSettings';
import ReadingRulerSettings from './color/ReadingRulerSettings';
@@ -50,18 +49,10 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const [selectedTextureId, setSelectedTextureId] = useState(viewSettings.backgroundTextureId);
const [backgroundOpacity, setBackgroundOpacity] = useState(viewSettings.backgroundOpacity);
const [backgroundSize, setBackgroundSize] = useState(viewSettings.backgroundSize);
const [ttsHighlightStyle, setTtsHighlightStyle] = useState(
viewSettings.ttsHighlightOptions.style,
);
const [ttsHighlightColor, setTtsHighlightColor] = useState(
viewSettings.ttsHighlightOptions.color,
);
const [highlightOpacity, setHighlightOpacity] = useState(viewSettings.highlightOpacity ?? 0.3);
const [customHighlightColors, setCustomHighlightColors] = useState(
settings.globalReadSettings.customHighlightColors,
);
const [customTtsHighlightColors, setCustomTtsHighlightColors] = useState(
settings.globalReadSettings.customTtsHighlightColors || [],
);
const [userHighlightColors, setUserHighlightColors] = useState(
settings.globalReadSettings.userHighlightColors || [],
);
@@ -87,6 +78,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
resetToDefaults({
overrideColor: setOverrideColor,
invertImgColorInDark: setInvertImgColorInDark,
highlightOpacity: setHighlightOpacity,
codeHighlighting: setcodeHighlighting,
codeLanguage: setCodeLanguage,
readingRulerEnabled: setReadingRulerEnabled,
@@ -123,6 +115,12 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideColor]);
useEffect(() => {
if (highlightOpacity === viewSettings.highlightOpacity) return;
saveViewSettings(envConfig, bookKey, 'highlightOpacity', highlightOpacity);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [highlightOpacity]);
useEffect(() => {
let update = false;
if (codeHighlighting !== viewSettings.codeHighlighting) {
@@ -263,29 +261,6 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
saveSettings(envConfig, settings);
};
const handleTTSStyleChange = (style: TTSHighlightStyle) => {
setTtsHighlightStyle(style);
saveViewSettings(envConfig, bookKey, 'ttsHighlightOptions', {
style,
color: ttsHighlightColor,
});
};
const handleTTSColorChange = (color: string) => {
setTtsHighlightColor(color);
saveViewSettings(envConfig, bookKey, 'ttsHighlightOptions', {
style: ttsHighlightStyle,
color,
});
};
const handleCustomTtsColorsChange = (colors: string[]) => {
setCustomTtsHighlightColors(colors);
settings.globalReadSettings.customTtsHighlightColors = colors;
setSettings(settings);
saveSettings(envConfig, settings);
};
const handleUserHighlightColorsChange = (colors: string[]) => {
setUserHighlightColors(colors);
settings.globalReadSettings.userHighlightColors = colors;
@@ -364,21 +339,14 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
<HighlightColorsEditor
customHighlightColors={customHighlightColors}
userHighlightColors={userHighlightColors}
highlightOpacity={highlightOpacity}
isEink={viewSettings.isEink}
onChange={handleHighlightColorsChange}
onUserColorsChange={handleUserHighlightColorsChange}
onOpacityChange={setHighlightOpacity}
data-setting-id='settings.color.highlightColors'
/>
<TTSHighlightStyleEditor
style={ttsHighlightStyle}
color={ttsHighlightColor}
customColors={customTtsHighlightColors}
onStyleChange={handleTTSStyleChange}
onColorChange={handleTTSColorChange}
onCustomColorsChange={handleCustomTtsColorsChange}
data-setting-id='settings.color.ttsHighlightStyle'
/>
<ReadingRulerSettings
enabled={readingRulerEnabled}
lines={readingRulerLines}
@@ -4,10 +4,11 @@ import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTranslation } from '@/hooks/useTranslation';
import { useCommandPalette } from '@/components/command-palette';
import { RiFontSize } from 'react-icons/ri';
import { RiDashboardLine, RiTranslate } from 'react-icons/ri';
import { VscSymbolColor } from 'react-icons/vsc';
import { PiDotsThreeVerticalBold, PiRobot } from 'react-icons/pi';
import { PiDotsThreeVerticalBold, PiRobot, PiSpeakerHigh } from 'react-icons/pi';
import { LiaHandPointerSolid } from 'react-icons/lia';
import { IoAccessibilityOutline } from 'react-icons/io5';
import { MdArrowBackIosNew, MdArrowForwardIos, MdClose } from 'react-icons/md';
@@ -24,13 +25,14 @@ import ControlPanel from './ControlPanel';
import LangPanel from './LangPanel';
import MiscPanel from './MiscPanel';
import AIPanel from './AIPanel';
import { useCommandPalette } from '@/components/command-palette';
import TTSPanel from './TTSPanel';
export type SettingsPanelType =
| 'Font'
| 'Layout'
| 'Color'
| 'Control'
| 'TTS'
| 'Language'
| 'AI'
| 'Custom';
@@ -89,6 +91,11 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
icon: RiTranslate,
label: _('Language'),
},
{
tab: 'TTS',
icon: PiSpeakerHigh,
label: _('TTS'),
},
{
tab: 'AI',
icon: PiRobot,
@@ -133,6 +140,7 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
Layout: null,
Color: null,
Control: null,
TTS: null,
Language: null,
AI: null,
Custom: null,
@@ -165,6 +173,7 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
layout: 'Layout',
color: 'Color',
control: 'Control',
tts: 'TTS',
language: 'Language',
ai: 'AI',
custom: 'Custom',
@@ -200,7 +209,7 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
if (!container) return;
const checkButtonWidths = () => {
const threshold = (container.clientWidth - 64) * 0.22;
const threshold = (container.clientWidth - 64) / tabConfig.filter((t) => !t.disabled).length;
const hideLabel = Array.from(container.querySelectorAll('button')).some((button) => {
const labelSpan = button.querySelector('span');
const labelText = labelSpan?.textContent || '';
@@ -373,6 +382,9 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onRegisterReset={(fn) => registerResetFunction('Control', fn)}
/>
)}
{activePanel === 'TTS' && (
<TTSPanel bookKey={bookKey} onRegisterReset={(fn) => registerResetFunction('TTS', fn)} />
)}
{activePanel === 'Language' && (
<LangPanel
bookKey={bookKey}
@@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { useTranslation } from '@/hooks/useTranslation';
import { saveViewSettings } from '@/helpers/settings';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { TTSMediaMetadataMode } from '@/services/tts/types';
import TTSHighlightStyleEditor, { TTSHighlightStyle } from './color/TTSHighlightStyleEditor';
import Select from '../Select';
const TTSPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getViewSettings } = useReaderStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
const [ttsMediaMetadata, setTtsMediaMetadata] = useState<TTSMediaMetadataMode>(
viewSettings.ttsMediaMetadata ?? 'sentence',
);
const [ttsHighlightStyle, setTtsHighlightStyle] = useState(
viewSettings.ttsHighlightOptions.style,
);
const [ttsHighlightColor, setTtsHighlightColor] = useState(
viewSettings.ttsHighlightOptions.color,
);
const [customTtsHighlightColors, setCustomTtsHighlightColors] = useState(
settings.globalReadSettings.customTtsHighlightColors || [],
);
const resetToDefaults = useResetViewSettings();
const handleReset = () => {
resetToDefaults({
ttsMediaMetadata: setTtsMediaMetadata as React.Dispatch<React.SetStateAction<string>>,
});
};
useEffect(() => {
onRegisterReset(handleReset);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (ttsMediaMetadata === viewSettings.ttsMediaMetadata) return;
saveViewSettings(envConfig, bookKey, 'ttsMediaMetadata', ttsMediaMetadata, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsMediaMetadata]);
const handleTTSStyleChange = (style: TTSHighlightStyle) => {
setTtsHighlightStyle(style);
saveViewSettings(envConfig, bookKey, 'ttsHighlightOptions', {
style,
color: ttsHighlightColor,
});
};
const handleTTSColorChange = (color: string) => {
setTtsHighlightColor(color);
saveViewSettings(envConfig, bookKey, 'ttsHighlightOptions', {
style: ttsHighlightStyle,
color,
});
};
const handleCustomTtsColorsChange = (colors: string[]) => {
setCustomTtsHighlightColors(colors);
settings.globalReadSettings.customTtsHighlightColors = colors;
setSettings(settings);
saveSettings(envConfig, settings);
};
const handleMediaMetadataChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setTtsMediaMetadata(event.target.value as TTSMediaMetadataMode);
};
return (
<div className='my-4 w-full space-y-6'>
<TTSHighlightStyleEditor
style={ttsHighlightStyle}
color={ttsHighlightColor}
customColors={customTtsHighlightColors}
onStyleChange={handleTTSStyleChange}
onColorChange={handleTTSColorChange}
onCustomColorsChange={handleCustomTtsColorsChange}
data-setting-id='settings.tts.ttsHighlightStyle'
/>
<div className='w-full' data-setting-id='settings.tts.mediaMetadata'>
<h2 className='mb-2 font-medium'>{_('Media Info')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item !h-16'>
<div className='flex flex-col gap-1'>
<span>{_('Update Frequency')}</span>
</div>
<Select
value={ttsMediaMetadata}
onChange={handleMediaMetadataChange}
options={[
{ value: 'sentence', label: _('Every Sentence') },
{ value: 'paragraph', label: _('Every Paragraph') },
{ value: 'chapter', label: _('Every Chapter') },
]}
/>
</div>
</div>
</div>
</div>
</div>
);
};
export default TTSPanel;
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { MdClose } from 'react-icons/md';
import { HighlightColor } from '@/types/book';
import { useTranslation } from '@/hooks/useTranslation';
import NumberInput from '../NumberInput';
import ColorInput from './ColorInput';
const MAX_USER_HIGHLIGHT_COLORS = 10;
@@ -9,19 +10,31 @@ const MAX_USER_HIGHLIGHT_COLORS = 10;
interface HighlightColorsEditorProps {
customHighlightColors: Record<HighlightColor, string>;
userHighlightColors: string[];
highlightOpacity: number;
isEink: boolean;
onChange: (colors: Record<HighlightColor, string>) => void;
onUserColorsChange: (colors: string[]) => void;
onOpacityChange: (opacity: number) => void;
}
const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
customHighlightColors,
userHighlightColors,
highlightOpacity,
isEink,
onChange,
onUserColorsChange,
onOpacityChange,
}) => {
const _ = useTranslation();
const [newColor, setNewColor] = useState('#808080');
const highlightPreviewStyle: React.CSSProperties = {
opacity: highlightOpacity,
mixBlendMode:
'var(--overlayer-highlight-blend-mode, normal)' as React.CSSProperties['mixBlendMode'],
};
const handleColorChange = (color: HighlightColor, value: string) => {
const updated = { ...customHighlightColors, [color]: value };
onChange(updated);
@@ -48,92 +61,109 @@ const HighlightColorsEditor: React.FC<HighlightColorsEditorProps> = ({
return (
<div>
<h2 className='mb-2 font-medium'>{_('Highlight Colors')}</h2>
<div className='card border-base-200 bg-base-100 overflow-visible border p-4 shadow'>
<div className='space-y-4'>
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
{(['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[]).map(
(color, index, array) => {
const position =
index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
return (
<div key={color} className='flex flex-col items-center gap-2'>
<div className='card border-base-200 bg-base-100 overflow-visible border shadow'>
<div className='grid grid-cols-3 gap-3 p-4 sm:grid-cols-5'>
{(['red', 'violet', 'blue', 'green', 'yellow'] as HighlightColor[]).map(
(color, index, array) => {
const position =
index === 0 ? 'left' : index === array.length - 1 ? 'right' : 'center';
return (
<div key={color} className='flex flex-col items-center gap-2'>
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
<div
className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'
style={{ backgroundColor: customHighlightColors[color] }}
/>
<ColorInput
label=''
value={customHighlightColors[color]!}
compact={true}
pickerPosition={position}
onChange={(value: string) => handleColorChange(color, value)}
className='h-full w-full rounded-full'
style={{
backgroundColor: customHighlightColors[color],
...highlightPreviewStyle,
}}
/>
</div>
);
},
)}
</div>
{(userHighlightColors.length > 0 || true) && (
<div className='border-base-200 border-t pt-4'>
<div className='mb-3 flex items-center justify-between'>
<span className='text-sm font-medium'>
{_('Custom Colors')} ({userHighlightColors.length}/{MAX_USER_HIGHLIGHT_COLORS})
</span>
<div className='flex items-center gap-2'>
<div
className='border-base-300 h-6 w-6 rounded-full border-2 shadow-sm'
style={{ backgroundColor: newColor }}
/>
<ColorInput
label=''
value={newColor}
value={customHighlightColors[color]!}
compact={true}
pickerPosition='right'
onChange={setNewColor}
pickerPosition={position}
onChange={(value: string) => handleColorChange(color, value)}
/>
<button
onClick={handleAddUserColor}
disabled={
userHighlightColors.includes(newColor) ||
userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS
}
className='btn btn-ghost btn-sm gap-1 bg-transparent disabled:bg-transparent disabled:opacity-40'
>
<span className='text-xs'>{_('Add')}</span>
</button>
</div>
</div>
{userHighlightColors.length > 0 && (
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
{userHighlightColors.map((hex, index) => (
<div key={hex} className='group relative flex flex-col items-center gap-2'>
<div
className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'
style={{ backgroundColor: hex }}
/>
<ColorInput
label=''
value={hex}
compact={true}
pickerPosition={index === 0 ? 'left' : 'center'}
onChange={(value: string) => handleUserColorChange(hex, value)}
/>
<button
onClick={() => handleDeleteUserColor(hex)}
className='absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
title={_('Delete')}
>
<MdClose size={12} />
</button>
</div>
))}
</div>
)}
</div>
);
},
)}
</div>
{(userHighlightColors.length > 0 || true) && (
<div className='border-base-200 border-t p-4'>
<div className='mb-2 flex items-center justify-between'>
<span className='font-normal'>
{_('Custom Colors')} ({userHighlightColors.length}/{MAX_USER_HIGHLIGHT_COLORS})
</span>
<div className='flex items-center gap-2'>
<div className='border-base-300 h-6 w-6 rounded-full border-2 shadow-sm'>
<div
className='h-full w-full rounded-full'
style={{ backgroundColor: newColor, ...highlightPreviewStyle }}
/>
</div>
<ColorInput
label=''
value={newColor}
compact={true}
pickerPosition='right'
onChange={setNewColor}
/>
<button
onClick={handleAddUserColor}
disabled={
userHighlightColors.includes(newColor) ||
userHighlightColors.length >= MAX_USER_HIGHLIGHT_COLORS
}
className='btn btn-ghost btn-sm gap-1 bg-transparent disabled:bg-transparent disabled:opacity-40'
>
<span className='text-xs'>{_('Add')}</span>
</button>
</div>
</div>
{userHighlightColors.length > 0 && (
<div className='grid grid-cols-3 gap-3 sm:grid-cols-5'>
{userHighlightColors.map((hex, index) => (
<div key={hex} className='group relative flex flex-col items-center gap-2'>
<div className='border-base-300 h-8 w-8 rounded-full border-2 shadow-sm'>
<div
className='h-full w-full rounded-full'
style={{ backgroundColor: hex, ...highlightPreviewStyle }}
/>
</div>
<ColorInput
label=''
value={hex}
compact={true}
pickerPosition={index === 0 ? 'left' : 'center'}
onChange={(value: string) => handleUserColorChange(hex, value)}
/>
<button
onClick={() => handleDeleteUserColor(hex)}
className='absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white opacity-0 transition-opacity hover:opacity-100 group-hover:opacity-100'
title={_('Delete')}
>
<MdClose size={12} />
</button>
</div>
))}
</div>
)}
</div>
)}
<NumberInput
label={_('Opacity')}
value={highlightOpacity}
onChange={onOpacityChange}
disabled={isEink}
min={0}
max={1}
step={0.1}
/>
</div>
</div>
);
+4 -3
View File
@@ -22,6 +22,7 @@ export const useTheme = ({
const isEink = settings?.globalViewSettings?.isEink;
const isColorEink = settings?.globalViewSettings?.isColorEink;
const isBwEink = isEink && !isColorEink;
const highlightOpacity = settings?.globalViewSettings?.highlightOpacity ?? 0.4;
const {
themeColor,
isDarkMode,
@@ -133,15 +134,15 @@ export const useTheme = ({
document.documentElement.style.setProperty('--scroll-bg-opacity', isBwEink ? '1.0' : '0.5');
document.documentElement.style.setProperty(
'--overlayer-highlight-opacity',
isBwEink ? '1.0' : '0.3',
isBwEink ? '1.0' : String(highlightOpacity),
);
document.documentElement.style.setProperty(
'--overlayer-highlight-blend-mode',
isBwEink ? 'difference' : isDarkMode ? 'lighten' : 'normal',
isBwEink ? 'difference' : isDarkMode ? 'screen' : 'multiply',
);
document.documentElement.style.setProperty(
'--bg-texture-blend-mode',
isDarkMode ? 'lighten' : 'multiply',
);
}, [themeColor, isDarkMode, isBwEink]);
}, [themeColor, isDarkMode, isBwEink, highlightOpacity]);
};
@@ -4,7 +4,7 @@ import { RiFontSize, RiDashboardLine, RiTranslate } from 'react-icons/ri';
import { VscSymbolColor } from 'react-icons/vsc';
import { LiaHandPointerSolid } from 'react-icons/lia';
import { IoAccessibilityOutline } from 'react-icons/io5';
import { PiRobot, PiSun, PiMoon } from 'react-icons/pi';
import { PiRobot, PiSpeakerHigh, PiSun, PiMoon } from 'react-icons/pi';
import { TbSunMoon } from 'react-icons/tb';
import { MdRefresh } from 'react-icons/md';
import { IconType } from 'react-icons';
@@ -151,6 +151,7 @@ const panelIcons: Record<SettingsPanelType, IconType> = {
Layout: RiDashboardLine,
Color: VscSymbolColor,
Control: LiaHandPointerSolid,
TTS: PiSpeakerHigh,
Language: RiTranslate,
AI: PiRobot,
Custom: IoAccessibilityOutline,
@@ -365,11 +366,17 @@ const colorPanelItems = [
section: 'Highlight',
},
{
id: 'settings.color.ttsHighlightStyle',
id: 'settings.tts.ttsHighlightStyle',
labelKey: _('TTS Highlighting'),
keywords: ['tts', 'highlight', 'style', 'speech', 'read', 'aloud'],
section: 'Highlight',
},
{
id: 'settings.tts.mediaMetadata',
labelKey: _('TTS Media Info Update Frequency'),
keywords: ['tts', 'media', 'metadata', 'bluetooth', 'notification', 'chapter', 'paragraph'],
section: 'TTS',
},
{
id: 'settings.color.readingRuler',
labelKey: _('Reading Ruler'),
@@ -218,6 +218,7 @@ export const DEFAULT_BOOK_STYLE: BookStyle = {
backgroundTextureId: 'none',
backgroundOpacity: 0.6,
backgroundSize: 'cover',
highlightOpacity: 0.4,
codeHighlighting: false,
codeLanguage: 'auto-detect',
userStylesheet: '',
@@ -295,6 +296,7 @@ export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsLocation: '',
showTTSBar: false,
ttsHighlightOptions: { style: 'highlight', color: '#808080' },
ttsMediaMetadata: 'sentence',
};
export const DEFAULT_TRANSLATOR_CONFIG: TranslatorConfig = {
@@ -1,5 +1,7 @@
export type TTSGranularity = 'sentence' | 'word';
export type TTSMediaMetadataMode = 'sentence' | 'paragraph' | 'chapter';
export type TTSHighlightOptions = {
style: 'highlight' | 'underline' | 'strikethrough' | 'squiggly' | 'outline';
color: string;
+3
View File
@@ -1,5 +1,6 @@
import { BookMetadata } from '@/libs/document';
import { TTSHighlightOptions } from '@/services/tts/types';
import { TTSMediaMetadataMode } from '@/services/tts/types';
import { AnnotationToolType } from './annotator';
export type BookFormat =
@@ -154,6 +155,7 @@ export interface BookStyle {
backgroundTextureId: string;
backgroundOpacity: number;
backgroundSize: string;
highlightOpacity: number;
codeHighlighting: boolean;
codeLanguage: string;
userStylesheet: string;
@@ -234,6 +236,7 @@ export interface TTSConfig {
ttsLocation: string;
showTTSBar: boolean;
ttsHighlightOptions: TTSHighlightOptions;
ttsMediaMetadata: TTSMediaMetadataMode;
}
export interface TranslatorConfig {
+53
View File
@@ -0,0 +1,53 @@
import { TTSMediaMetadataMode } from '@/services/tts/types';
interface BuildTTSMediaMetadataOptions {
markText: string;
markName: string;
sectionLabel: string;
title: string;
author: string;
ttsMediaMetadata: TTSMediaMetadataMode;
previousSectionLabel?: string;
}
interface TTSMediaMetadataResult {
title: string;
artist: string;
album: string;
shouldUpdate: boolean;
}
export function buildTTSMediaMetadata(
options: BuildTTSMediaMetadataOptions,
): TTSMediaMetadataResult {
const {
markText,
markName,
sectionLabel,
title,
author,
ttsMediaMetadata,
previousSectionLabel,
} = options;
if (ttsMediaMetadata === 'chapter') {
const shouldUpdate =
previousSectionLabel === undefined || previousSectionLabel !== sectionLabel;
return {
title: sectionLabel || title,
artist: author,
album: title,
shouldUpdate,
};
}
// sentence and paragraph share the same metadata mapping;
// paragraph only updates on the first sentence of each block (markName "0")
const shouldUpdate = ttsMediaMetadata === 'sentence' || markName === '0';
return {
title: markText,
artist: sectionLabel || title,
album: author,
shouldUpdate,
};
}