Files
gaming-image-files/usr/local/bin/rom_launcher.py
Storm Dragon 52e1656e42 Initial commit
2025-07-12 13:48:20 -04:00

508 lines
19 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Self-voiced Terminal Menu ROM launcher
import os
import sys
import time
import threading
import signal
import curses
import subprocess
import speechd # Python bindings for Speech Dispatcher
import configparser
import pathlib
import re
class VoicedMenu:
def __init__(self, title="Stormux Game Menu"):
self.title = title
self.menuSections = {} # Dictionary to hold sections and their items
self.sectionNames = [] # List to maintain section order
self.currentSection = 0 # Index of current section
self.currentItemIndices = {} # Current item index for each section
self.stdscr = None
self.curses_initialized = False # Flag to track if curses has been initialized
self.has_items = False # Flag to track if any section has items
# Config settings
self.configDir = os.path.expanduser("~/.config/stormux")
self.configFile = os.path.join(self.configDir, "game_launcher.conf")
self.config = configparser.ConfigParser()
# Default settings
self.speechRate = 0 # Normal speech rate (0 is default in speechd)
# Load settings
self.load_settings()
# Initialize speech client
self.speechClient = None
self.init_speech()
def init_speech(self):
"""Initialize the speech client"""
try:
# Use a fixed client ID
self.speechClient = speechd.SSIPClient("rom_menu")
self.speechClient.set_priority(speechd.Priority.IMPORTANT)
self.speechClient.set_punctuation(speechd.PunctuationMode.SOME)
# Apply speech rate from settings
self.speechClient.set_rate(self.speechRate)
except Exception as e:
print(f"Could not initialize speech: {e}")
# Fallback to None - the speak method will handle this
def load_settings(self):
"""Load settings from config file"""
# Create default settings if they don't exist
if not os.path.exists(self.configFile):
self.save_settings()
return
try:
self.config.read(self.configFile)
# Load speech settings
if 'Speech' in self.config:
self.speechRate = self.config.getint('Speech', 'rate', fallback=0)
except Exception as e:
print(f"Error loading settings: {e}")
# If loading fails, we'll use default values
def save_settings(self):
"""Save settings to config file"""
# Ensure config directory exists
os.makedirs(self.configDir, exist_ok=True)
# Update config object
if 'Speech' not in self.config:
self.config['Speech'] = {}
self.config['Speech']['rate'] = str(self.speechRate)
# Write to file
try:
with open(self.configFile, 'w') as f:
self.config.write(f)
except Exception as e:
print(f"Error saving settings: {e}")
def increase_speech_rate(self):
"""Increase speech rate"""
self.speechRate = min(100, self.speechRate + 10) # Max is 100
if self.speechClient:
try:
self.speechClient.set_rate(self.speechRate)
self.speak(f"Speech rate: {self.speechRate}")
except Exception as e:
print(f"Error adjusting speech rate: {e}")
# Save the new setting
self.save_settings()
def decrease_speech_rate(self):
"""Decrease speech rate"""
self.speechRate = max(-100, self.speechRate - 10) # Min is -100
if self.speechClient:
try:
self.speechClient.set_rate(self.speechRate)
self.speak(f"Speech rate: {self.speechRate}")
except Exception as e:
print(f"Error adjusting speech rate: {e}")
# Save the new setting
self.save_settings()
def add_section(self, sectionName):
"""Add a new section to the menu"""
if sectionName not in self.menuSections:
self.menuSections[sectionName] = []
self.sectionNames.append(sectionName)
self.currentItemIndices[sectionName] = 0
def add_item(self, sectionName, name, command):
"""Add a menu item to a specific section"""
# Create section if it doesn't exist
if sectionName not in self.menuSections:
self.add_section(sectionName)
self.menuSections[sectionName].append((name, command))
self.has_items = True # Mark that we have at least one item
def speak(self, text, interrupt=True):
"""Speak the given text with option to interrupt existing speech"""
if self.speechClient is None:
return
try:
if interrupt:
self.stop_speech()
self.speechClient.speak(text)
except Exception as e:
# If speech fails, try to reinitialize and try once more
try:
self.init_speech()
if self.speechClient:
self.speechClient.speak(text)
except:
# If reinitializing fails, just give up silently
pass
def stop_speech(self):
"""Stop any ongoing speech"""
if self.speechClient is None:
return
try:
self.speechClient.cancel()
except Exception as e:
# If cancel fails, try to reinitialize
self.init_speech()
def get_current_items(self):
"""Get items from the current section"""
if not self.sectionNames:
return []
currentSectionName = self.sectionNames[self.currentSection]
return self.menuSections[currentSectionName]
def get_current_item_index(self):
"""Get the current item index in the current section"""
if not self.sectionNames:
return 0
currentSectionName = self.sectionNames[self.currentSection]
return self.currentItemIndices[currentSectionName]
def set_current_item_index(self, index):
"""Set the current item index for the current section"""
if not self.sectionNames:
return
currentSectionName = self.sectionNames[self.currentSection]
self.currentItemIndices[currentSectionName] = index
def announce_current_section(self, interrupt=True):
"""Announce the currently selected section"""
if 0 <= self.currentSection < len(self.sectionNames):
sectionName = self.sectionNames[self.currentSection]
self.speak(sectionName, interrupt=interrupt)
def announce_current_item(self, interrupt=True):
"""Announce the currently selected menu item"""
if len(self.sectionNames) > 0:
items = self.get_current_items()
index = self.get_current_item_index()
if 0 <= index < len(items):
name = items[index][0]
self.speak(name, interrupt=interrupt)
def execute_current_item(self):
"""Execute the currently selected menu item"""
if len(self.sectionNames) > 0:
items = self.get_current_items()
index = self.get_current_item_index()
if 0 <= index < len(items):
name, command = items[index]
# Clean up resources before executing the command
self.cleanup(full_cleanup=True) # This handles curses properly
# Execute the command and exit
os.system(command)
sys.exit(0) # Now safe to exit
def speak_help(self):
"""Speak help information"""
helpText = """
Navigation controls:
Up arrow: Previous menu item.
Down arrow: Next menu item.
Left arrow: Previous section.
Right arrow: Next section.
Enter: Launch selected item.
H key: Hear these instructions again.
Left bracket: Decrease speech rate.
Right bracket: Increase speech rate.
Escape or Q: Exit the menu.
Any key will interrupt speech.
"""
self.speak(helpText)
def draw_menu(self):
"""Draw the menu on the screen"""
self.stdscr.clear()
h, w = self.stdscr.getmaxyx()
# Draw title
title = f" {self.title} "
x = max(0, w // 2 - len(title) // 2)
self.stdscr.addstr(1, x, title, curses.A_BOLD)
# Draw help line
helpText = "Ãavigate | Enter: Select | H: Help | [ ] Rate | Q/Esc: Quit"
x = max(0, w // 2 - len(helpText) // 2)
self.stdscr.addstr(3, x, helpText)
# Draw current section
if len(self.sectionNames) > 0:
currentSectionName = self.sectionNames[self.currentSection]
sectionText = f"== {currentSectionName} =="
x = max(0, w // 2 - len(sectionText) // 2)
self.stdscr.addstr(5, x, sectionText, curses.A_BOLD)
# Draw menu items for current section
items = self.get_current_items()
currentItemIndex = self.get_current_item_index()
if not items:
# Display a message if the section is empty
emptyMsg = "No items in this section"
x = max(0, w // 2 - len(emptyMsg) // 2)
self.stdscr.addstr(7, x, emptyMsg, curses.A_DIM)
else:
for i, (name, _) in enumerate(items):
y = i + 7 # Start items 2 lines below section header
if y < h - 1: # Ensure we don't draw outside the window
# Highlight the selected item
if i == currentItemIndex:
text = f" > {name} "
attr = curses.A_REVERSE
else:
text = f" {name} "
attr = curses.A_NORMAL
x = max(0, w // 2 - len(text) // 2)
self.stdscr.addstr(y, x, text, attr)
# Draw speech rate indicator
rateText = f"Speech Rate: {self.speechRate}"
self.stdscr.addstr(h-2, 2, rateText)
self.stdscr.refresh()
def cleanup(self, full_cleanup=False):
"""Clean up resources before exiting or executing a command
Args:
full_cleanup: If True, also close curses. Used when exiting or running a command.
"""
# Stop any speech
self.stop_speech()
# Close speech client
if self.speechClient:
try:
self.speechClient.close()
except:
pass
self.speechClient = None
# Restore terminal settings if curses was initialized
if full_cleanup and self.curses_initialized:
try:
curses.nocbreak()
self.stdscr.keypad(False)
curses.echo()
curses.endwin()
except:
# If there's an error, just try a simple endwin
try:
curses.endwin()
except:
pass # Last resort, just continue
def load_roms_from_directory(self, directoryPath):
"""Load ROMs from the specified directory and add them to the menu"""
# Expand the path (in case it contains ~)
directoryPath = os.path.expanduser(directoryPath)
# Check if directory exists
if not os.path.exists(directoryPath) or not os.path.isdir(directoryPath):
print(f"Directory {directoryPath} does not exist or is not a directory")
return
# Get all subdirectories in the roms directory
try:
subdirs = [d for d in os.listdir(directoryPath) if os.path.isdir(os.path.join(directoryPath, d))]
# For each subdirectory, create a section
for subdir in subdirs:
sectionPath = os.path.join(directoryPath, subdir)
# Get all files in the subdirectory
files = [f for f in os.listdir(sectionPath) if os.path.isfile(os.path.join(sectionPath, f))]
# If the directory has files, add it as a section
if files:
# Add the section
self.add_section(subdir)
# Add files as menu items
for file in files:
# Get full path to the file
filePath = os.path.join(sectionPath, file)
# Create display name - remove extension
displayName = os.path.splitext(file)[0]
# Replace underscores with spaces for better readability
displayName = displayName.replace('_', ' ')
# Properly escape special characters in file path
escapedPath = filePath.replace('"', '\\"')
# Add the item to the section - use double quotes for the GAME variable
self.add_item(subdir, displayName, f'export GAME="{escapedPath}" && startx')
except Exception as e:
print(f"Error loading ROMs directory: {e}")
def run(self):
"""Run the menu system"""
# Check if menu is completely empty
if not self.sectionNames:
message = "No games found."
print(message)
# Speak the message
self.init_speech() # Make sure speech is initialized
if self.speechClient:
self.speak(message)
# Wait for speech to finish (rough estimate)
time.sleep(3)
# Clean up and exit properly
self.cleanup(full_cleanup=True)
sys.exit(0)
# Check if any sections have items
if not self.has_items:
message = "No ROMs found in any sections. Exiting."
print(message)
# Speak the message
self.init_speech() # Make sure speech is initialized
if self.speechClient:
self.speak(message)
# Wait for speech to finish (rough estimate)
time.sleep(3)
# Clean up and exit properly
self.cleanup(full_cleanup=True)
sys.exit(0)
try:
# Initialize curses
self.stdscr = curses.initscr()
self.curses_initialized = True # Mark curses as initialized
curses.noecho() # Don't echo keypresses
curses.cbreak() # React to keys instantly
self.stdscr.keypad(True) # Enable special keys
# Initial draw
self.draw_menu()
# Welcome message - don't interrupt this initial speech
self.speak("Roms menu")
# Wait for initial speech to finish before announcing section
time.sleep(1)
# Announce initial section and item without interrupting welcome speech
self.announce_current_section(interrupt=False)
time.sleep(0.5) # Wait before announcing first item
self.announce_current_item(interrupt=False)
# Main loop
while True:
key = self.stdscr.getch()
# Stop any speech when a key is pressed
self.stop_speech()
# Handle navigation
if key == curses.KEY_UP:
# Move to previous item in current section
items = self.get_current_items()
if items:
index = self.get_current_item_index()
self.set_current_item_index((index - 1) % len(items))
self.draw_menu()
self.announce_current_item()
elif key == curses.KEY_DOWN:
# Move to next item in current section
items = self.get_current_items()
if items:
index = self.get_current_item_index()
self.set_current_item_index((index + 1) % len(items))
self.draw_menu()
self.announce_current_item()
elif key == curses.KEY_LEFT:
# Move to previous section
if self.sectionNames:
self.currentSection = (self.currentSection - 1) % len(self.sectionNames)
self.draw_menu()
# Announce section and current item without interruption between them
self.announce_current_section()
time.sleep(0.5) # Brief pause between section and item announcement
self.announce_current_item(interrupt=False) # Don't interrupt the section announcement
elif key == curses.KEY_RIGHT:
# Move to next section
if self.sectionNames:
self.currentSection = (self.currentSection + 1) % len(self.sectionNames)
self.draw_menu()
# Announce section and current item without interruption between them
self.announce_current_section()
time.sleep(0.5) # Brief pause between section and item announcement
self.announce_current_item(interrupt=False) # Don't interrupt the section announcement
elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key
items = self.get_current_items()
if items: # Only execute if there are items
self.execute_current_item() # This now handles cleanup and exit
elif key == ord('h') or key == ord('H'): # Help
self.speak_help()
elif key == ord('['): # Decrease speech rate
self.decrease_speech_rate()
self.draw_menu()
elif key == ord(']'): # Increase speech rate
self.increase_speech_rate()
self.draw_menu()
elif key == 27 or key == ord('q') or key == ord('Q'): # Esc or Q
break
except Exception as e:
# End curses in case of error
if self.curses_initialized:
try:
curses.endwin()
except:
pass
print(f"An error occurred: {e}")
finally:
# Clean up - safe to call even if curses wasn't initialized
self.cleanup(full_cleanup=True)
# Example usage
if __name__ == "__main__":
# Create the menu with sections
menu = VoicedMenu(title="")
# Load ROMs from the ~/Roms directory
menu.load_roms_from_directory("~/Roms")
# Run the menu
menu.run()