diff --git a/app/src/main/java/com/bibliadiaria/app/MainActivity.java b/app/src/main/java/com/bibliadiaria/app/MainActivity.java index 87b3d40..cce9119 100644 --- a/app/src/main/java/com/bibliadiaria/app/MainActivity.java +++ b/app/src/main/java/com/bibliadiaria/app/MainActivity.java @@ -6,8 +6,11 @@ import android.content.SharedPreferences; import android.graphics.Color; import android.graphics.Typeface; import android.graphics.drawable.GradientDrawable; +import android.media.MediaPlayer; import android.os.Build; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.text.Html; import android.text.Spanned; import android.view.Gravity; @@ -16,6 +19,7 @@ import android.view.ViewGroup; import android.widget.Button; import android.widget.FrameLayout; import android.widget.GridLayout; +import android.widget.ImageButton; import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.ScrollView; @@ -23,9 +27,12 @@ import android.widget.SeekBar; import android.widget.TextView; import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; @@ -39,6 +46,8 @@ import java.util.Locale; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import org.json.JSONObject; + public class MainActivity extends Activity { private static final int COLOR_BACKGROUND = Color.rgb(247, 248, 246); private static final int COLOR_CARD = Color.WHITE; @@ -57,8 +66,21 @@ public class MainActivity extends Activity { private static final int RANK_FEAST = 2; private static final int RANK_SOLEMNITY = 3; + private static final String TTS_SPEECH_URL = "https://ttsapi.host2.gleeze.com/v1/audio/speech"; + private static final String TTS_MODEL = "kokoro"; + private final Locale spanishLocale = new Locale("es", "ES"); private final List resizableTextViews = new ArrayList<>(); + private final Handler ttsProgressHandler = new Handler(Looper.getMainLooper()); + private final Runnable ttsProgressTick = new Runnable() { + @Override + public void run() { + updateTtsProgress(); + if (ttsPlayer != null && ttsPlayer.isPlaying()) { + ttsProgressHandler.postDelayed(this, 500); + } + } + }; private ExecutorService executor; private SharedPreferences preferences; @@ -78,9 +100,18 @@ public class MainActivity extends Activity { private Button retryButton; private LinearLayout fontPanel; private TextView fontValueText; + private LinearLayout ttsPlayerPanel; + private TextView ttsStatusText; + private Button ttsPlayButton; + private SeekBar ttsSeekBar; + private TextView ttsTimeText; + private MediaPlayer ttsPlayer; + private File ttsAudioFile; + private String currentTtsText = ""; private Calendar selectedDateCalendar; private Calendar visibleMonthCalendar; private int loadGeneration; + private int ttsGeneration; private float readingFontSp; private boolean isEnglish; @@ -108,9 +139,14 @@ public class MainActivity extends Activity { @Override protected void onDestroy() { super.onDestroy(); + loadGeneration++; + ttsGeneration++; if (executor != null) { executor.shutdownNow(); } + ttsProgressHandler.removeCallbacks(ttsProgressTick); + releaseTtsPlayer(); + deleteTtsAudioFile(); } @Override @@ -1095,6 +1131,7 @@ public class MainActivity extends Activity { } private void showLoading(Date date, String url) { + resetTtsForNewReading(); resizableTextViews.clear(); sectionsLayout.removeAllViews(); statusBlock.setVisibility(View.VISIBLE); @@ -1113,6 +1150,7 @@ public class MainActivity extends Activity { } private void renderReading(DailyReading reading) { + resetTtsForNewReading(); statusBlock.setVisibility(View.GONE); progressBar.setVisibility(View.GONE); retryButton.setVisibility(View.GONE); @@ -1145,6 +1183,7 @@ public class MainActivity extends Activity { } private void showError(Exception error, String url) { + resetTtsForNewReading(); statusBlock.setVisibility(View.VISIBLE); progressBar.setVisibility(View.GONE); retryButton.setVisibility(View.VISIBLE); @@ -1155,6 +1194,401 @@ public class MainActivity extends Activity { sourceText.setText((isEnglish ? "Intended source: " : "Fuente prevista: ") + "Vatican News\n" + url); } + private Button createOutlinedButton(String text) { + Button button = new Button(this); + button.setText(text); + button.setTextColor(COLOR_ACCENT); + button.setTextSize(14); + button.setAllCaps(false); + button.setBackground(roundedRect(Color.WHITE, dp(8), Color.rgb(219, 226, 222), dp(1))); + return button; + } + + private void startTtsConversionForText(String text, TtsControls controls) { + activateTtsControls(controls); + currentTtsText = text == null ? "" : text; + startTtsConversion(); + } + + private void activateTtsControls(TtsControls controls) { + if (controls == null) { + return; + } + + if (ttsPlayerPanel != null && ttsPlayerPanel != controls.playerPanel) { + ttsPlayerPanel.setVisibility(View.GONE); + } + if (ttsStatusText != null && ttsStatusText != controls.statusText) { + ttsStatusText.setText(""); + ttsStatusText.setVisibility(View.GONE); + } + if (ttsPlayButton != null && ttsPlayButton != controls.playButton) { + ttsPlayButton.setText(isEnglish ? "Play" : "Reproducir"); + } + if (ttsSeekBar != null && ttsSeekBar != controls.seekBar) { + ttsSeekBar.setProgress(0); + ttsSeekBar.setMax(0); + } + if (ttsTimeText != null && ttsTimeText != controls.timeText) { + ttsTimeText.setText("0:00"); + } + + ttsStatusText = controls.statusText; + ttsPlayerPanel = controls.playerPanel; + ttsPlayButton = controls.playButton; + ttsSeekBar = controls.seekBar; + ttsTimeText = controls.timeText; + } + + private void startTtsConversion() { + String text = currentTtsText == null ? "" : currentTtsText.trim(); + if (text.isEmpty()) { + return; + } + + int generation = ++ttsGeneration; + stopTtsProgressUpdates(); + releaseTtsPlayer(); + deleteTtsAudioFile(); + setTtsStatus(isEnglish ? "Creating audio..." : "Generando audio..."); + if (ttsPlayerPanel != null) { + ttsPlayerPanel.setVisibility(View.GONE); + } + + executor.submit(() -> { + try { + File audioFile = downloadTtsAudio(text); + runOnUiThread(() -> { + if (generation == ttsGeneration) { + showTtsAudioReady(audioFile); + } else if (audioFile.exists()) { + audioFile.delete(); + } + }); + } catch (Exception error) { + runOnUiThread(() -> { + if (generation == ttsGeneration) { + showTtsError(error); + } + }); + } + }); + } + + private File downloadTtsAudio(String text) throws Exception { + HttpURLConnection connection = (HttpURLConnection) new URL(TTS_SPEECH_URL).openConnection(); + connection.setConnectTimeout(15000); + connection.setReadTimeout(120000); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setRequestProperty("User-Agent", "BibliaDiaria/1.0 Android"); + connection.setRequestProperty("Accept", "audio/mpeg"); + connection.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + + JSONObject body = new JSONObject(); + body.put("model", TTS_MODEL); + body.put("voice", UpdateManager.getTtsVoice(this, isEnglish)); + body.put("input", text); + body.put("response_format", "mp3"); + body.put("speed", 1.0); + + byte[] payload = body.toString().getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(payload.length); + try (OutputStream stream = connection.getOutputStream()) { + stream.write(payload); + } + + int responseCode = connection.getResponseCode(); + if (responseCode < 200 || responseCode >= 300) { + String details = ""; + InputStream errorStream = connection.getErrorStream(); + if (errorStream != null) { + details = readStream(errorStream); + } + connection.disconnect(); + throw new IOException((isEnglish ? "TTS API responded with code " : "La API TTS respondio con codigo ") + + responseCode + + (details.isEmpty() ? "." : ". " + details)); + } + + File outputFile = File.createTempFile("daily-reading-tts-", ".mp3", getCacheDir()); + try (InputStream input = connection.getInputStream(); + FileOutputStream output = new FileOutputStream(outputFile)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + } finally { + connection.disconnect(); + } + return outputFile; + } + + private void showTtsAudioReady(File audioFile) { + try { + releaseTtsPlayer(); + deleteTtsAudioFile(); + ttsAudioFile = audioFile; + ttsPlayer = new MediaPlayer(); + ttsPlayer.setDataSource(audioFile.getAbsolutePath()); + ttsPlayer.setOnCompletionListener(player -> { + stopTtsProgressUpdates(); + if (ttsPlayButton != null) { + ttsPlayButton.setText(isEnglish ? "Play" : "Reproducir"); + } + updateTtsProgress(); + }); + ttsPlayer.prepare(); + + if (ttsSeekBar != null) { + ttsSeekBar.setMax(ttsPlayer.getDuration()); + ttsSeekBar.setProgress(0); + } + updateTtsTimeText(0, ttsPlayer.getDuration()); + if (ttsPlayerPanel != null) { + ttsPlayerPanel.setVisibility(View.VISIBLE); + } + setTtsStatus(isEnglish ? "Audio ready." : "Audio listo."); + } catch (Exception error) { + if (audioFile.exists()) { + audioFile.delete(); + } + showTtsError(error); + } + } + + private void showTtsError(Exception error) { + releaseTtsPlayer(); + deleteTtsAudioFile(); + if (ttsPlayerPanel != null) { + ttsPlayerPanel.setVisibility(View.GONE); + } + setTtsStatus((isEnglish ? "Could not create audio. " : "No se pudo crear el audio. ") + + cleanErrorMessage(error)); + } + + private void toggleTtsPlayback() { + if (ttsPlayer == null) { + return; + } + + if (ttsPlayer.isPlaying()) { + ttsPlayer.pause(); + stopTtsProgressUpdates(); + if (ttsPlayButton != null) { + ttsPlayButton.setText(isEnglish ? "Play" : "Reproducir"); + } + updateTtsProgress(); + } else { + ttsPlayer.start(); + if (ttsPlayButton != null) { + ttsPlayButton.setText(isEnglish ? "Pause" : "Pausar"); + } + ttsProgressHandler.post(ttsProgressTick); + } + } + + private void updateTtsProgress() { + if (ttsPlayer == null || ttsSeekBar == null) { + return; + } + + int position = ttsPlayer.getCurrentPosition(); + int duration = ttsPlayer.getDuration(); + ttsSeekBar.setProgress(position); + updateTtsTimeText(position, duration); + } + + private void updateTtsTimeText(int positionMillis, int durationMillis) { + if (ttsTimeText == null) { + return; + } + ttsTimeText.setText(formatAudioTime(positionMillis) + " / " + formatAudioTime(durationMillis)); + } + + private String formatAudioTime(int millis) { + int totalSeconds = Math.max(0, millis / 1000); + int minutes = totalSeconds / 60; + int seconds = totalSeconds % 60; + return String.format(Locale.US, "%d:%02d", minutes, seconds); + } + + private void setTtsStatus(String text) { + if (ttsStatusText != null) { + ttsStatusText.setText(text); + ttsStatusText.setVisibility(text == null || text.isEmpty() ? View.GONE : View.VISIBLE); + } + } + + private void resetTtsForNewReading() { + ttsGeneration++; + currentTtsText = ""; + stopTtsProgressUpdates(); + releaseTtsPlayer(); + deleteTtsAudioFile(); + if (ttsStatusText != null) { + ttsStatusText.setText(""); + ttsStatusText.setVisibility(View.GONE); + } + if (ttsPlayerPanel != null) { + ttsPlayerPanel.setVisibility(View.GONE); + } + if (ttsPlayButton != null) { + ttsPlayButton.setText(isEnglish ? "Play" : "Reproducir"); + } + if (ttsSeekBar != null) { + ttsSeekBar.setProgress(0); + ttsSeekBar.setMax(0); + } + if (ttsTimeText != null) { + ttsTimeText.setText("0:00"); + } + } + + private void stopTtsProgressUpdates() { + ttsProgressHandler.removeCallbacks(ttsProgressTick); + } + + private void releaseTtsPlayer() { + if (ttsPlayer != null) { + ttsPlayer.release(); + ttsPlayer = null; + } + } + + private void deleteTtsAudioFile() { + if (ttsAudioFile != null && ttsAudioFile.exists()) { + ttsAudioFile.delete(); + } + ttsAudioFile = null; + } + + private void addSpeechSection(List parts, String title, DailySection section) { + if (section == null || section.body.isEmpty()) { + return; + } + + StringBuilder builder = new StringBuilder(title); + if (!section.introduction.isEmpty()) { + builder.append("\n").append(section.introduction); + } + if (!section.reference.isEmpty()) { + builder.append("\n").append(section.reference); + } + builder.append("\n").append(section.body); + parts.add(builder.toString()); + } + + private String buildSectionSpeechText(String title, DailySection section) { + List parts = new ArrayList<>(); + addSpeechSection(parts, title, section); + if (parts.isEmpty()) { + return ""; + } + return normalizeWhitespace(parts.get(0)); + } + + private View createSpeakerTitleRow(String title, int titleSizeSp, String speechText, TtsControls controls) { + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + + TextView titleText = textView(title, titleSizeSp, COLOR_INK, Typeface.BOLD); + titleText.setIncludeFontPadding(false); + row.addView(titleText, new LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f + )); + + ImageButton speakerButton = new ImageButton(this); + speakerButton.setImageResource(R.drawable.ic_speaker_24); + speakerButton.setColorFilter(COLOR_ACCENT); + speakerButton.setPadding(dp(8), dp(8), dp(8), dp(8)); + speakerButton.setBackground(roundedRect(Color.rgb(229, 244, 240), dp(8), Color.TRANSPARENT, 0)); + speakerButton.setContentDescription((isEnglish ? "Listen to " : "Escuchar ") + title); + speakerButton.setOnClickListener(view -> startTtsConversionForText(speechText, controls)); + + LinearLayout.LayoutParams speakerParams = new LinearLayout.LayoutParams(dp(40), dp(40)); + speakerParams.setMargins(dp(8), 0, 0, 0); + row.addView(speakerButton, speakerParams); + return row; + } + + private TtsControls createTtsControls() { + TextView status = textView("", 14, COLOR_MUTED, Typeface.BOLD); + status.setVisibility(View.GONE); + + LinearLayout playerPanel = new LinearLayout(this); + playerPanel.setOrientation(LinearLayout.HORIZONTAL); + playerPanel.setGravity(Gravity.CENTER_VERTICAL); + playerPanel.setVisibility(View.GONE); + + Button playButton = createOutlinedButton(isEnglish ? "Play" : "Reproducir"); + playerPanel.addView(playButton, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + dp(42) + )); + + SeekBar seekBar = new SeekBar(this); + seekBar.setMax(0); + seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser && seekBar == ttsSeekBar && ttsPlayer != null) { + ttsPlayer.seekTo(progress); + updateTtsTimeText(progress, ttsPlayer.getDuration()); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + }); + LinearLayout.LayoutParams seekParams = new LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f + ); + seekParams.setMargins(dp(8), 0, dp(8), 0); + playerPanel.addView(seekBar, seekParams); + + TextView timeText = textView("0:00", 13, COLOR_MUTED, Typeface.BOLD); + timeText.setGravity(Gravity.CENTER_VERTICAL); + playerPanel.addView(timeText, new LinearLayout.LayoutParams( + dp(86), + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + TtsControls controls = new TtsControls(status, playerPanel, playButton, seekBar, timeText); + playButton.setOnClickListener(view -> { + activateTtsControls(controls); + toggleTtsPlayback(); + }); + return controls; + } + + private void addTtsControlsToCard(LinearLayout card, TtsControls controls) { + LinearLayout.LayoutParams statusParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + statusParams.setMargins(0, 0, 0, dp(10)); + card.addView(controls.statusText, statusParams); + + LinearLayout.LayoutParams playerParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + playerParams.setMargins(0, 0, 0, dp(14)); + card.addView(controls.playerPanel, playerParams); + } + private void addScriptureCard(String title, String overline, DailySection section, boolean featured) { if (section == null || section.body.isEmpty()) { return; @@ -1167,14 +1601,20 @@ public class MainActivity extends Activity { card.addView(overlineText); } - TextView titleText = textView(title, featured ? 28 : 22, COLOR_INK, Typeface.BOLD); - titleText.setIncludeFontPadding(false); + TtsControls ttsControls = createTtsControls(); + View titleRow = createSpeakerTitleRow( + title, + featured ? 28 : 22, + buildSectionSpeechText(title, section), + ttsControls + ); LinearLayout.LayoutParams titleParams = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ); titleParams.setMargins(0, overline == null ? 0 : dp(8), 0, dp(12)); - card.addView(titleText, titleParams); + card.addView(titleRow, titleParams); + addTtsControlsToCard(card, ttsControls); if (!section.introduction.isEmpty()) { TextView introText = textView(section.introduction, 16, COLOR_MUTED, Typeface.BOLD); @@ -1211,9 +1651,15 @@ public class MainActivity extends Activity { LinearLayout card = createCard(dp(18)); - TextView title = textView(isEnglish ? "The Pope's words" : "Las palabras de los Papas", 22, COLOR_INK, Typeface.BOLD); - title.setIncludeFontPadding(false); - card.addView(title); + String title = isEnglish ? "The Pope's words" : "Las palabras de los Papas"; + TtsControls ttsControls = createTtsControls(); + card.addView(createSpeakerTitleRow( + title, + 22, + buildSectionSpeechText(title, section), + ttsControls + )); + addTtsControlsToCard(card, ttsControls); TextView body = textView(section.body, Math.round(readingFontSp), COLOR_INK, Typeface.NORMAL); body.setLineSpacing(dp(4), 1.16f); @@ -1670,6 +2116,28 @@ public class MainActivity extends Activity { return Math.round(value * getResources().getDisplayMetrics().density); } + private static class TtsControls { + final TextView statusText; + final LinearLayout playerPanel; + final Button playButton; + final SeekBar seekBar; + final TextView timeText; + + TtsControls( + TextView statusText, + LinearLayout playerPanel, + Button playButton, + SeekBar seekBar, + TextView timeText + ) { + this.statusText = statusText; + this.playerPanel = playerPanel; + this.playButton = playButton; + this.seekBar = seekBar; + this.timeText = timeText; + } + } + private static class LiturgicalDay { final String englishName; final String spanishName; diff --git a/app/src/main/java/com/bibliadiaria/app/SettingsActivity.java b/app/src/main/java/com/bibliadiaria/app/SettingsActivity.java index 4193753..fd191f9 100644 --- a/app/src/main/java/com/bibliadiaria/app/SettingsActivity.java +++ b/app/src/main/java/com/bibliadiaria/app/SettingsActivity.java @@ -1,6 +1,7 @@ package com.bibliadiaria.app; import android.app.Activity; +import android.app.AlertDialog; import android.graphics.Color; import android.graphics.Typeface; import android.graphics.drawable.GradientDrawable; @@ -31,6 +32,8 @@ public class SettingsActivity extends Activity { private TextView installedVersionText; private TextView checkButton; private TextView downloadButton; + private TextView voiceSubtitleText; + private TextView voiceButton; private UpdateManager.UpdateInfo latestInfo; @Override @@ -83,6 +86,7 @@ public class SettingsActivity extends Activity { content.addView(title, titleParams); content.addView(createLanguageCard()); + content.addView(createVoiceCard()); content.addView(createVersionCard()); checkButton = actionButton("Check now", COLOR_ACCENT, Color.WHITE); @@ -182,12 +186,50 @@ public class SettingsActivity extends Activity { boolean current = UpdateManager.isEnglish(this); UpdateManager.setEnglish(this, !current); langToggle.setText(!current ? "English" : "Spanish"); + updateVoiceCard(); }); card.addView(langToggle, new LinearLayout.LayoutParams(dp(100), dp(40))); return card; } + private View createVoiceCard() { + LinearLayout card = createCard(); + card.setOrientation(LinearLayout.VERTICAL); + + LinearLayout copy = new LinearLayout(this); + copy.setOrientation(LinearLayout.VERTICAL); + + TextView title = textView("Voice", 20, COLOR_INK, Typeface.BOLD); + title.setIncludeFontPadding(false); + copy.addView(title); + + voiceSubtitleText = textView("", 14, COLOR_MUTED, Typeface.NORMAL); + LinearLayout.LayoutParams subtitleParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + subtitleParams.setMargins(0, dp(6), 0, 0); + copy.addView(voiceSubtitleText, subtitleParams); + + card.addView(copy, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + voiceButton = actionButton("", COLOR_ACCENT, Color.WHITE); + voiceButton.setOnClickListener(view -> showVoicePicker()); + LinearLayout.LayoutParams buttonParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(42) + ); + buttonParams.setMargins(0, dp(12), 0, 0); + card.addView(voiceButton, buttonParams); + + updateVoiceCard(); + return card; + } + private View createVersionCard() { LinearLayout card = createCard(); card.setOrientation(LinearLayout.VERTICAL); @@ -261,6 +303,43 @@ public class SettingsActivity extends Activity { statusText.setText("Could not check for updates. " + cleanMessage(error)); } + private void showVoicePicker() { + boolean english = UpdateManager.isEnglish(this); + UpdateManager.VoiceOption[] options = UpdateManager.getTtsVoiceOptions(english); + String[] labels = new String[options.length]; + for (int index = 0; index < options.length; index++) { + labels[index] = options[index].label; + } + + int selectedIndex = UpdateManager.getTtsVoiceIndex( + english, + UpdateManager.getTtsVoice(this, english) + ); + + new AlertDialog.Builder(this) + .setTitle(english ? "English voice" : "Spanish voice") + .setSingleChoiceItems(labels, selectedIndex, (dialog, which) -> { + UpdateManager.setTtsVoice(this, english, options[which].id); + updateVoiceCard(); + dialog.dismiss(); + }) + .setNegativeButton("Cancel", null) + .show(); + } + + private void updateVoiceCard() { + if (voiceSubtitleText == null || voiceButton == null) { + return; + } + + boolean english = UpdateManager.isEnglish(this); + String voiceId = UpdateManager.getTtsVoice(this, english); + voiceSubtitleText.setText(english + ? "English voices are shown while English is selected" + : "Spanish voices are shown while Spanish is selected"); + voiceButton.setText(UpdateManager.getTtsVoiceLabel(english, voiceId)); + } + private void updateInstalledVersionText() { try { installedVersionText.setText("Installed: " diff --git a/app/src/main/java/com/bibliadiaria/app/UpdateManager.java b/app/src/main/java/com/bibliadiaria/app/UpdateManager.java index 80ed107..a1850bb 100644 --- a/app/src/main/java/com/bibliadiaria/app/UpdateManager.java +++ b/app/src/main/java/com/bibliadiaria/app/UpdateManager.java @@ -24,11 +24,52 @@ final class UpdateManager { static final String PREFS_NAME = "preferencias_lectura"; static final String KEY_AUTO_UPDATES = "auto_updates_enabled"; static final String KEY_IS_ENGLISH = "is_english"; + static final String KEY_TTS_VOICE_ENGLISH = "tts_voice_english"; + static final String KEY_TTS_VOICE_SPANISH = "tts_voice_spanish"; + static final String DEFAULT_TTS_VOICE_ENGLISH = "af_heart"; + static final String DEFAULT_TTS_VOICE_SPANISH = "ef_dora"; static final String LATEST_JSON_URL = "https://github.com/jahruz67/Bible-Daily/releases/download/latest/latest.json"; static final String APK_URL = "https://github.com/jahruz67/Bible-Daily/releases/download/latest/app-debug.apk"; + private static final VoiceOption[] ENGLISH_VOICES = { + new VoiceOption("af_heart", "Heart (US female)"), + new VoiceOption("af_alloy", "Alloy (US female)"), + new VoiceOption("af_aoede", "Aoede (US female)"), + new VoiceOption("af_bella", "Bella (US female)"), + new VoiceOption("af_jadzia", "Jadzia (US female)"), + new VoiceOption("af_jessica", "Jessica (US female)"), + new VoiceOption("af_kore", "Kore (US female)"), + new VoiceOption("af_nicole", "Nicole (US female)"), + new VoiceOption("af_nova", "Nova (US female)"), + new VoiceOption("af_river", "River (US female)"), + new VoiceOption("af_sarah", "Sarah (US female)"), + new VoiceOption("af_sky", "Sky (US female)"), + new VoiceOption("am_adam", "Adam (US male)"), + new VoiceOption("am_echo", "Echo (US male)"), + new VoiceOption("am_eric", "Eric (US male)"), + new VoiceOption("am_fenrir", "Fenrir (US male)"), + new VoiceOption("am_liam", "Liam (US male)"), + new VoiceOption("am_michael", "Michael (US male)"), + new VoiceOption("am_onyx", "Onyx (US male)"), + new VoiceOption("am_puck", "Puck (US male)"), + new VoiceOption("am_santa", "Santa (US male)"), + new VoiceOption("bf_alice", "Alice (UK female)"), + new VoiceOption("bf_emma", "Emma (UK female)"), + new VoiceOption("bf_lily", "Lily (UK female)"), + new VoiceOption("bm_daniel", "Daniel (UK male)"), + new VoiceOption("bm_fable", "Fable (UK male)"), + new VoiceOption("bm_george", "George (UK male)"), + new VoiceOption("bm_lewis", "Lewis (UK male)") + }; + + private static final VoiceOption[] SPANISH_VOICES = { + new VoiceOption("ef_dora", "Dora (Spanish female)"), + new VoiceOption("em_alex", "Alex (Spanish male)"), + new VoiceOption("em_santa", "Santa (Spanish male)") + }; + private UpdateManager() { } @@ -48,6 +89,63 @@ final class UpdateManager { preferences(context).edit().putBoolean(KEY_IS_ENGLISH, enabled).apply(); } + static VoiceOption[] getTtsVoiceOptions(boolean isEnglish) { + return isEnglish ? ENGLISH_VOICES : SPANISH_VOICES; + } + + static String getTtsVoice(Context context, boolean isEnglish) { + String defaultVoice = getDefaultTtsVoice(isEnglish); + String voice = preferences(context).getString(getTtsVoiceKey(isEnglish), defaultVoice); + if (isValidTtsVoice(isEnglish, voice)) { + return voice; + } + return defaultVoice; + } + + static void setTtsVoice(Context context, boolean isEnglish, String voiceId) { + String voice = isValidTtsVoice(isEnglish, voiceId) ? voiceId : getDefaultTtsVoice(isEnglish); + preferences(context).edit().putString(getTtsVoiceKey(isEnglish), voice).apply(); + } + + static String getTtsVoiceLabel(boolean isEnglish, String voiceId) { + for (VoiceOption option : getTtsVoiceOptions(isEnglish)) { + if (option.id.equals(voiceId)) { + return option.label; + } + } + return getTtsVoiceLabel(isEnglish, getDefaultTtsVoice(isEnglish)); + } + + static int getTtsVoiceIndex(boolean isEnglish, String voiceId) { + VoiceOption[] options = getTtsVoiceOptions(isEnglish); + for (int index = 0; index < options.length; index++) { + if (options[index].id.equals(voiceId)) { + return index; + } + } + return 0; + } + + private static String getDefaultTtsVoice(boolean isEnglish) { + return isEnglish ? DEFAULT_TTS_VOICE_ENGLISH : DEFAULT_TTS_VOICE_SPANISH; + } + + private static String getTtsVoiceKey(boolean isEnglish) { + return isEnglish ? KEY_TTS_VOICE_ENGLISH : KEY_TTS_VOICE_SPANISH; + } + + private static boolean isValidTtsVoice(boolean isEnglish, String voiceId) { + if (voiceId == null) { + return false; + } + for (VoiceOption option : getTtsVoiceOptions(isEnglish)) { + if (option.id.equals(voiceId)) { + return true; + } + } + return false; + } + static UpdateInfo fetchLatestUpdate(Context context) throws Exception { String jsonText = download(LATEST_JSON_URL, "application/json"); JSONObject json = new JSONObject(jsonText); @@ -187,4 +285,14 @@ final class UpdateManager { this.updateAvailable = updateAvailable; } } + + static final class VoiceOption { + final String id; + final String label; + + VoiceOption(String id, String label) { + this.id = id; + this.label = label; + } + } } diff --git a/app/src/main/res/drawable/ic_speaker_24.xml b/app/src/main/res/drawable/ic_speaker_24.xml new file mode 100644 index 0000000..9e4535b --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/build/reports/problems/problems-report.html b/build/reports/problems/problems-report.html index ae3aa1c..22b0632 100644 --- a/build/reports/problems/problems-report.html +++ b/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button {