#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Options Menu Interactive menu system for BookStorm settings. Inspired by soundstorm's hierarchical menu system. """ from pathlib import Path from src.tts_engine import TtsEngine class OptionsMenu: """Options menu for configuring BookStorm settings""" def __init__(self, config, speechEngine, voiceSelector, audioPlayer, ttsReloadCallback=None, brailleOutput=None): """ Initialize options menu Args: config: ConfigManager instance speechEngine: SpeechEngine instance voiceSelector: VoiceSelector instance audioPlayer: MpvPlayer instance ttsReloadCallback: Optional callback to reload TTS engine brailleOutput: Optional BrailleOutput instance """ self.config = config self.speechEngine = speechEngine self.voiceSelector = voiceSelector self.audioPlayer = audioPlayer self.ttsReloadCallback = ttsReloadCallback self.brailleOutput = brailleOutput self.currentSelection = 0 self.inMenu = False def show_main_menu(self): """ Show main options menu Returns: Menu items as list of dicts """ readerEngine = self.config.get_reader_engine() readerEngineText = "Piper-TTS" if readerEngine == "piper" else "Speech-Dispatcher" menuItems = [ { 'label': f"Reader Engine: {readerEngineText}", 'action': 'toggle_reader_engine' }, { 'label': "Select Reader Voice", 'action': 'select_voice' } ] # Add output module selection only when using speech-dispatcher if readerEngine == 'speechd': menuItems.append({ 'label': "Select Speech Engine", 'action': 'select_output_module' }) # Add text display toggle showText = self.config.get_show_text() showTextLabel = "Show Text Display: On" if showText else "Show Text Display: Off" # Add Audiobookshelf setup status absConfigured = self.config.is_abs_configured() absLabel = "Audiobookshelf: Configured" if absConfigured else "Audiobookshelf: Not Configured" menuItems.extend([ { 'label': showTextLabel, 'action': 'toggle_show_text' }, { 'label': "Speech Rate Settings", 'action': 'speech_rate' }, { 'label': "Braille Settings", 'action': 'braille_settings' }, { 'label': absLabel, 'action': 'audiobookshelf_setup' }, { 'label': "Back", 'action': 'back' } ]) return menuItems def navigate_menu(self, direction): """ Navigate menu up or down Args: direction: 'up' or 'down' Returns: Current menu item """ menuItems = self.show_main_menu() if direction == 'up': self.currentSelection = (self.currentSelection - 1) % len(menuItems) elif direction == 'down': self.currentSelection = (self.currentSelection + 1) % len(menuItems) currentItem = menuItems[self.currentSelection] self.speechEngine.speak(currentItem['label']) return currentItem def activate_current_item(self): """ Activate currently selected menu item Returns: True to stay in menu, False to exit """ menuItems = self.show_main_menu() currentItem = menuItems[self.currentSelection] action = currentItem['action'] if action == 'toggle_reader_engine': return self._toggle_reader_engine() elif action == 'select_voice': return self._select_voice() elif action == 'select_output_module': return self._select_output_module() elif action == 'toggle_show_text': return self._toggle_show_text() elif action == 'speech_rate': return self._speech_rate_info() elif action == 'braille_settings': return self._braille_settings() elif action == 'audiobookshelf_setup': return self._audiobookshelf_setup() elif action == 'back': self.speechEngine.speak("Closing options menu") return False return True def _toggle_reader_engine(self): """Toggle between piper-tts and speech-dispatcher""" currentEngine = self.config.get_reader_engine() if currentEngine == 'piper': newEngine = 'speechd' oldEngine = 'piper' self.config.set_reader_engine('speechd') message = "Reader engine: Speech-Dispatcher." else: newEngine = 'piper' oldEngine = 'speechd' self.config.set_reader_engine('piper') message = "Reader engine: Piper-TTS." # Reload TTS engine if callback available needsRestart = False if self.ttsReloadCallback: try: self.ttsReloadCallback() except Exception as e: print(f"Error reloading TTS engine: {e}") needsRestart = True else: needsRestart = True if needsRestart: # Show restart confirmation dialog self.previousEngine = oldEngine self.inRestartMenu = True self.restartSelection = 0 message = "Restart required. Restart now or cancel?" self.speechEngine.speak(message) print(message) # Speak first option self.speechEngine.speak("Restart now") else: self.speechEngine.speak(message) print(message) return True def _select_voice(self): """Select voice based on current reader engine""" readerEngine = self.config.get_reader_engine() if readerEngine == 'piper': return self._select_piper_voice() else: return self._select_speechd_voice() def _select_piper_voice(self): """Select piper-tts voice""" self.speechEngine.speak("Selecting piper voice. Use arrow keys to browse, enter to select, escape to cancel.") voices = self.voiceSelector.get_voices() if not voices: self.speechEngine.speak("No piper voices found.") return True # Store current selection for voice browsing self.voiceSelection = 0 self.voiceList = voices self.inVoiceMenu = True # Speak first voice using piper try: voice = voices[0] tts = TtsEngine(voice['path']) voiceName = voice['name'].split('(')[0].strip() text = f"Hi, my name is {voiceName}" wavData = tts.text_to_wav_data(text) if wavData: self.audioPlayer.play_wav_data(wavData) except Exception as e: # Fallback to speech-dispatcher print(f"Error playing voice sample: {e}") self.speechEngine.speak(voices[0]['name']) return True def _select_speechd_voice(self): """Select speech-dispatcher voice""" voices = self.speechEngine.list_voices() if not voices: self.speechEngine.speak("No speech dispatcher voices available.") return True self.speechEngine.speak("Selecting speech dispatcher voice. Use arrow keys to browse, enter to select, escape to cancel.") # Store current selection for voice browsing self.voiceSelection = 0 self.voiceList = voices self.inVoiceMenu = True # Speak first voice if len(voices) > 0: voice = voices[0] if isinstance(voice, tuple): voiceName = f"{voice[0]} ({voice[1]})" else: voiceName = str(voice) self.speechEngine.speak(voiceName) return True def navigate_voice_menu(self, direction): """Navigate voice selection menu""" if not hasattr(self, 'voiceList') or not self.voiceList: return if direction == 'up': self.voiceSelection = (self.voiceSelection - 1) % len(self.voiceList) elif direction == 'down': self.voiceSelection = (self.voiceSelection + 1) % len(self.voiceList) # Speak current voice voice = self.voiceList[self.voiceSelection] readerEngine = self.config.get_reader_engine() if readerEngine == 'piper': # Use piper to speak the voice name with that voice try: tts = TtsEngine(voice['path']) # Generate voice speaking its own name voiceName = voice['name'].split('(')[0].strip() text = f"Hi, my name is {voiceName}" wavData = tts.text_to_wav_data(text) if wavData: self.audioPlayer.play_wav_data(wavData) except Exception as e: # Fallback to speech-dispatcher if error print(f"Error playing voice sample: {e}") self.speechEngine.speak(voice['name']) else: # Format speech-dispatcher voice tuple if isinstance(voice, tuple): voiceName = f"{voice[0]} ({voice[1]})" else: voiceName = str(voice) self.speechEngine.speak(voiceName) def select_current_voice(self): """Select the currently highlighted voice""" if not hasattr(self, 'voiceList') or not self.voiceList: return True voice = self.voiceList[self.voiceSelection] readerEngine = self.config.get_reader_engine() if readerEngine == 'piper': # Save piper voice self.config.set_voice_model(voice['path']) message = f"Voice selected: {voice['name']}." # Reload TTS engine if callback available if self.ttsReloadCallback: try: self.ttsReloadCallback() except Exception as e: print(f"Error reloading TTS engine: {e}") message += " Restart required." else: message += " Restart required." self.speechEngine.speak(message) print(message) else: # Save speechd voice - extract name from tuple if isinstance(voice, tuple): voiceName = voice[0] # First element is the voice name displayName = f"{voice[0]} ({voice[1]})" else: voiceName = str(voice) displayName = voiceName self.config.set_speechd_voice(voiceName) self.speechEngine.set_voice(voiceName) message = f"Voice selected: {displayName}" self.speechEngine.speak(message) print(message) # Exit voice menu self.inVoiceMenu = False return True def _select_output_module(self): """Select speech-dispatcher output module""" modules = self.speechEngine.list_output_modules() if not modules: self.speechEngine.speak("No output modules available.") return True self.speechEngine.speak("Selecting speech engine. Use arrow keys to browse, enter to select, escape to cancel.") # Store current selection for module browsing self.moduleSelection = 0 self.moduleList = modules self.inModuleMenu = True # Speak first module if len(modules) > 0: self.speechEngine.speak(modules[0]) return True def navigate_module_menu(self, direction): """Navigate output module selection menu""" if not hasattr(self, 'moduleList') or not self.moduleList: return if direction == 'up': self.moduleSelection = (self.moduleSelection - 1) % len(self.moduleList) elif direction == 'down': self.moduleSelection = (self.moduleSelection + 1) % len(self.moduleList) # Speak current module module = self.moduleList[self.moduleSelection] self.speechEngine.speak(module) def select_current_module(self): """Select the currently highlighted output module""" if not hasattr(self, 'moduleList') or not self.moduleList: return True module = self.moduleList[self.moduleSelection] # Save and set output module self.config.set_speechd_output_module(module) self.speechEngine.set_output_module(module) message = f"Speech engine selected: {module}" self.speechEngine.speak(message) print(message) # Exit module menu self.inModuleMenu = False return True def exit_module_menu(self): """Exit output module selection menu""" self.inModuleMenu = False self.speechEngine.speak("Cancelled. Back to options menu.") # Speak current main menu item menuItems = self.show_main_menu() if menuItems and self.currentSelection < len(menuItems): self.speechEngine.speak(menuItems[self.currentSelection]['label']) def is_in_module_menu(self): """Check if currently in output module selection submenu""" return hasattr(self, 'inModuleMenu') and self.inModuleMenu def is_in_restart_menu(self): """Check if currently in restart confirmation dialog""" return hasattr(self, 'inRestartMenu') and self.inRestartMenu def navigate_restart_menu(self, direction): """Navigate restart confirmation menu""" # Toggle between "Restart now" (0) and "Cancel" (1) if direction == 'up' or direction == 'down': self.restartSelection = 1 - self.restartSelection # Speak current option if self.restartSelection == 0: self.speechEngine.speak("Restart now") else: self.speechEngine.speak("Cancel") def select_restart_option(self): """Handle restart menu selection""" if self.restartSelection == 0: # Restart now - exit cleanly self.speechEngine.speak("Restarting. Please run bookstorm again.") import sys sys.exit(0) else: # Cancel - revert engine change self.config.set_reader_engine(self.previousEngine) self.inRestartMenu = False self.speechEngine.speak("Cancelled. Engine change reverted.") # Speak current main menu item menuItems = self.show_main_menu() if menuItems and self.currentSelection < len(menuItems): self.speechEngine.speak(menuItems[self.currentSelection]['label']) return True def exit_restart_menu(self): """Exit restart confirmation menu (same as cancel)""" self.config.set_reader_engine(self.previousEngine) self.inRestartMenu = False self.speechEngine.speak("Cancelled. Engine change reverted.") # Speak current main menu item menuItems = self.show_main_menu() if menuItems and self.currentSelection < len(menuItems): self.speechEngine.speak(menuItems[self.currentSelection]['label']) def _toggle_show_text(self): """Toggle text display on/off""" currentSetting = self.config.get_show_text() newSetting = not currentSetting self.config.set_show_text(newSetting) if newSetting: message = "Text display: On" else: message = "Text display: Off" self.speechEngine.speak(message) print(message) return True def _speech_rate_info(self): """Show speech rate information""" currentRate = self.config.get_speech_rate() message = f"Current speech rate: {currentRate}. Use Page Up and Page Down to adjust during reading." self.speechEngine.speak(message) print(message) return True def enter_menu(self): """Enter the options menu""" self.inMenu = True self.currentSelection = 0 self.inVoiceMenu = False self.speechEngine.speak("Options menu. Use arrow keys to navigate, Enter to select, Escape to close.") # Speak first item menuItems = self.show_main_menu() if menuItems: self.speechEngine.speak(menuItems[0]['label']) def is_in_menu(self): """Check if currently in menu""" return self.inMenu def is_in_voice_menu(self): """Check if currently in voice selection submenu""" return hasattr(self, 'inVoiceMenu') and self.inVoiceMenu def exit_voice_menu(self): """Exit voice selection menu""" self.inVoiceMenu = False self.speechEngine.speak("Cancelled. Back to options menu.") # Speak current main menu item menuItems = self.show_main_menu() if menuItems and self.currentSelection < len(menuItems): self.speechEngine.speak(menuItems[self.currentSelection]['label']) def _braille_settings(self): """Open Braille settings submenu""" if not self.brailleOutput: self.speechEngine.speak("Braille output not initialized") return True # Import here to avoid circular dependency from src.braille_menu import BrailleMenu import pygame # Create Braille menu # Get pygame screen if available screen = pygame.display.get_surface() if not screen: # Create minimal display for menu screen = pygame.display.set_mode((1600, 900)) brailleMenu = BrailleMenu(screen, self.speechEngine, self.brailleOutput, self.config) # Show Braille menu (blocks until user exits) continueRunning = brailleMenu.show() # Return to options menu if continueRunning: self.speechEngine.speak("Back to options menu") # Speak current menu item menuItems = self.show_main_menu() if menuItems and self.currentSelection < len(menuItems): self.speechEngine.speak(menuItems[self.currentSelection]['label']) return True else: # User closed the application return False def _audiobookshelf_setup(self): """Setup Audiobookshelf server connection""" from src.ui import get_input self.speechEngine.speak("Audiobookshelf setup starting.") # Show current settings if configured currentUrl = "" currentUser = "" if self.config.is_abs_configured(): currentUrl = self.config.get_abs_server_url() currentUser = self.config.get_abs_username() self.speechEngine.speak(f"Current server: {currentUrl}. Current username: {currentUser}. Leave fields blank to keep current values.") # Get server URL serverUrlPrompt = "Enter Audiobookshelf server URL. Example: https colon slash slash abs dot example dot com" serverUrl = get_input(self.speechEngine, serverUrlPrompt, currentUrl) if serverUrl is None: self.speechEngine.speak("Setup cancelled.") return True serverUrl = serverUrl.strip() if not serverUrl and self.config.is_abs_configured(): serverUrl = currentUrl self.speechEngine.speak(f"Using current URL: {serverUrl}") elif not serverUrl: self.speechEngine.speak("Server URL required. Setup cancelled.") return True # Validate URL format if not serverUrl.startswith(('http://', 'https://')): self.speechEngine.speak("Invalid URL. Must start with http or https. Setup cancelled.") return True # Get username usernamePrompt = "Enter Audiobookshelf username" username = get_input(self.speechEngine, usernamePrompt, currentUser) if username is None: self.speechEngine.speak("Setup cancelled.") return True username = username.strip() if not username and self.config.is_abs_configured(): username = currentUser self.speechEngine.speak(f"Using current username: {username}") elif not username: self.speechEngine.speak("Username required. Setup cancelled.") return True # Get password passwordPrompt = "Enter password for testing connection. Note: Password is NOT saved, only the authentication token." password = get_input(self.speechEngine, passwordPrompt, "") if password is None: self.speechEngine.speak("Setup cancelled.") return True if not password: self.speechEngine.speak("Password required. Setup cancelled.") return True # Test connection self.speechEngine.speak("Testing connection. Please wait.") # Import here to avoid circular dependency from src.audiobookshelf_client import AudiobookshelfClient # Create temporary client to test testClient = AudiobookshelfClient(serverUrl, None) if not testClient.login(username, password): self.speechEngine.speak("Login failed. Check server URL, username, and password. Make sure the server is reachable and credentials are correct.") return True # Login successful - get token authToken = testClient.authToken # Save settings self.config.set_abs_server_url(serverUrl) self.config.set_abs_username(username) self.config.set_abs_auth_token(authToken) self.speechEngine.speak("Setup successful. Audiobookshelf configured. You can now press a to browse your Audiobookshelf library.") return True def exit_menu(self): """Exit the menu""" self.inMenu = False self.inVoiceMenu = False self.currentSelection = 0