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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user