Files
bookstorm/src/options_menu.py
2025-10-08 19:33:29 -04:00

591 lines
20 KiB
Python

#!/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):
"""
Initialize options menu
Args:
config: ConfigManager instance
speechEngine: SpeechEngine instance
voiceSelector: VoiceSelector instance
audioPlayer: MpvPlayer instance
ttsReloadCallback: Optional callback to reload TTS engine
"""
self.config = config
self.speechEngine = speechEngine
self.voiceSelector = voiceSelector
self.audioPlayer = audioPlayer
self.ttsReloadCallback = ttsReloadCallback
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': 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 == '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 _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