Files
gaming-image-files/usr/local/bin/game_launcher.py
2025-08-23 17:42:44 -04:00

1104 lines
46 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Self-voiced Terminal Menu System for Stormux game image
import os
import platform
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
import simpleaudio as sa
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
# System services dictionary - maps friendly names to service names
self.systemMenuServices = {
'Braille': 'brltty.path',
'D L N A Server': 'minidlna.service',
'Fenrir Screen Reader': 'fenrirscreenreader-tty.service',
'Bluetooth': 'bluetooth.service',
'SSH': 'sshd.service',
'Battery Monitoring': 'battery-monitor.service'
}
# 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)
self.volume = 50 # Default volume level
# Load settings
self.load_settings()
# Initialize speech client
self.speechClient = None
self.init_speech()
# Track playing sound
self.currentSound = None
def init_speech(self):
"""Initialize the speech client"""
try:
self.speechClient = speechd.SSIPClient('stormux_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)
# Load volume settings
if 'Volume' in self.config:
self.volume = self.config.getint('Volume', 'level', fallback=50)
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)
# Save volume settings
if 'Volume' not in self.config:
self.config['Volume'] = {}
self.config['Volume']['level'] = str(self.volume)
# 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 get_current_volume(self):
"""Get the current system volume percentage"""
try:
result = subprocess.run(
['pactl', 'get-sink-volume', '@DEFAULT_SINK@'],
capture_output=True, text=True, check=True
)
output = result.stdout
# Extract percentage from output like "Volume: front-left: 27111 / 41% / -23.00 dB"
match = re.search(r'(\d+)%', output)
if match:
return int(match.group(1))
return self.volume # Return saved volume if parsing fails
except Exception as e:
print(f"Error getting volume: {e}")
return self.volume # Return saved volume if command fails
def set_volume(self, volumePercent):
"""Set the system volume to the specified percentage"""
# Ensure volume is between 0 and 150%
volumePercent = max(0, min(150, volumePercent))
try:
subprocess.run(
['pactl', 'set-sink-volume', '@DEFAULT_SINK@', f'{volumePercent}%'],
check=True
)
self.volume = volumePercent
self.save_settings()
return True
except Exception as e:
print(f"Error setting volume: {e}")
return False
def increase_volume(self):
"""Increase the system volume by 5%"""
currentVolume = self.get_current_volume()
newVolume = min(150, currentVolume + 5) # Max 150%
if self.set_volume(newVolume):
self.speak(f"Volume {newVolume} percent")
self.draw_menu()
def decrease_volume(self):
"""Decrease the system volume by 5%"""
currentVolume = self.get_current_volume()
newVolume = max(0, currentVolume - 5) # Min 0%
if self.set_volume(newVolume):
self.speak(f"Volume {newVolume} percent")
self.draw_menu()
def play_sound(self, soundName):
"""Play a sound effect using simpleaudio with ability to cancel previous sounds"""
try:
# Cancel any currently playing sound
self.stop_sound()
soundsDir = "/usr/share/sounds/stormux"
soundFile = os.path.join(soundsDir, f"{soundName}.wav")
# Check if the file exists
if not os.path.exists(soundFile):
print(f"Sound file not found: {soundFile}")
return False
# Play the sound and store the play_obj
waveObj = sa.WaveObject.from_wave_file(soundFile)
self.currentSound = waveObj.play()
return True
except Exception as e:
print(f"Error playing sound {soundName}: {e}")
return False
def stop_sound(self):
"""Stop any currently playing sound"""
if self.currentSound is not None and self.currentSound.is_playing():
self.currentSound.stop()
self.currentSound = None
def check_service_status(self, serviceName):
"""Check if a system service is active"""
try:
result = subprocess.run(
['systemctl', 'is-active', serviceName],
capture_output=True, text=True
)
return result.stdout.strip() == 'active'
except Exception as e:
print(f"Error checking {serviceName} status: {e}")
return False
def toggle_service(self, friendlyName):
"""Toggle a system service on/off"""
# Get the actual service name from the dictionary
serviceName = self.systemMenuServices.get(friendlyName)
if not serviceName:
print(f"Unknown service: {friendlyName}")
return
isActive = self.check_service_status(serviceName)
# Clean up curses before running the command with sudo
curses.endwin()
# Clean up speech client
if self.speechClient:
self.speechClient.close()
self.speechClient = None
# Execute the appropriate command based on current status
action = ""
if serviceName not in ["fenrirscreenreader-tty.service", "minidlna.service"]:
action = "disable" if isActive else "enable"
else:
action = "stop" if isActive else "start"
command = f"sudo systemctl {action} {serviceName} --now"
try:
os.system(command)
print(f"{friendlyName} {action}d successfully")
except Exception as e:
print(f"Error {action}ing {friendlyName}: {e}")
# Restart speech client
self.init_speech()
# Restart curses
self.stdscr = curses.initscr()
curses.noecho()
curses.cbreak()
self.stdscr.keypad(True)
# Announce the action
self.speak(f"{friendlyName} {action}d")
# Update the menu with the new service status
self.update_service_menu_items()
# Update Bluetooth menu items if Bluetooth service was toggled
if friendlyName == "Bluetooth":
self.update_bluetooth_menu_items()
# Redraw the menu
self.draw_menu()
def toggle_screen(self, screenType):
"""Toggle between screen output and headless mode"""
try:
# Platform-specific handling
is_x86 = platform.machine() == "x86_64"
if screenType == "headless":
if is_x86:
# On x86, remove all 10-* files for best compatibility
os.system("sudo rm -f /etc/X11/xorg.conf.d/10-*.conf")
else:
# On Pi, only remove non-essential files, preserve fbdev
os.system("sudo rm -f /etc/X11/xorg.conf.d/10-screen.conf")
configFile = "/home/stormux/.local/files/10-headless.conf"
message = "Screen disabled."
else:
# For enabling screen, remove any existing configuration first
os.system("sudo rm -f /etc/X11/xorg.conf.d/10-*.conf")
configFile = "/home/stormux/.local/files/10-screen.conf"
message = "Screen enabled."
# Copy the configuration file
os.system(f"sudo cp {configFile} /etc/X11/xorg.conf.d/")
self.speak(message, interrupt=False)
except Exception as e:
message = f"Error changing screen configuration: {e}"
print(message)
self.speak(message)
def report_battery_status(self):
"""Report current battery status"""
try:
import pathlib
power_supply_dir = pathlib.Path('/sys/class/power_supply')
# Check if power supply directory exists
if not power_supply_dir.exists():
self.speak("No battery found.")
return
battery_found = False
battery_level = None
is_charging = False
# Look for battery
for item in power_supply_dir.iterdir():
type_file = item / 'type'
if type_file.exists():
try:
if type_file.read_text().strip() == 'Battery':
battery_found = True
# Get battery level
capacity_file = item / 'capacity'
if capacity_file.exists():
battery_level = int(capacity_file.read_text().strip())
# Check charging status
status_file = item / 'status'
if status_file.exists():
status = status_file.read_text().strip()
is_charging = status in ['Charging', 'Full']
break
except Exception:
continue
# Check for AC power
ac_connected = False
for item in power_supply_dir.iterdir():
type_file = item / 'type'
online_file = item / 'online'
if type_file.exists() and online_file.exists():
try:
device_type = type_file.read_text().strip()
if device_type in ['ADP', 'Mains', 'AC']:
online = int(online_file.read_text().strip())
if online == 1:
ac_connected = True
break
except Exception:
continue
if not battery_found:
self.speak("No battery found.")
return
# Build status message
if battery_level is not None:
message = f"Battery level: {battery_level} percent"
if is_charging:
message += ", charging"
elif ac_connected:
message += ", plugged in"
else:
message += ", on battery power"
# Add warning context for low levels
if battery_level <= 5:
message += ". Critical level!"
elif battery_level <= 10:
message += ". Low battery!"
else:
message = "Battery detected but unable to read level"
self.speak(message)
except Exception as e:
self.speak(f"Error reading battery status: {e}")
def update_service_menu_items(self):
"""Update all service-related menu items based on their current status"""
# Remove any existing service items
if "System" in self.menuSections:
# Find and remove any service-related items
self.menuSections["System"] = [item for item in self.menuSections["System"]
if not any(service in item[0] for service in self.systemMenuServices.keys())]
# Add the appropriate items based on current status for each service
for friendlyName, serviceName in self.systemMenuServices.items():
isActive = self.check_service_status(serviceName)
if isActive:
self.add_item("System", f"Disable {friendlyName}",
lambda fn=friendlyName: self.toggle_service(fn))
else:
self.add_item("System", f"Enable {friendlyName}",
lambda fn=friendlyName: self.toggle_service(fn))
def install_and_launch(self, executable_name, launch_mode="gui"):
"""Launch application via xinitrc (which handles installation if needed)"""
try:
# Launch the application - xinitrc will handle installation
if launch_mode == "gui":
command = f"GAME='{executable_name}' startx"
else: # cli mode
command = f"GAME='{executable_name}' /home/stormux/.clirc"
# Use the existing execute_current_item infrastructure by temporarily setting command
# Save current state
original_sections = self.sectionNames.copy()
original_current_section = self.currentSection
original_items = {}
for section in self.menuSections:
original_items[section] = self.menuSections[section].copy()
# Create temporary item to execute
temp_section = "temp_install_launch"
self.add_section(temp_section)
self.add_item(temp_section, f"Launch {executable_name}", command)
# Set to the temporary section and item
self.currentSection = len(self.sectionNames) - 1
self.currentItemIndices[temp_section] = 0
# Execute the command using existing infrastructure
self.execute_current_item()
# Restore original state
self.sectionNames = original_sections
self.currentSection = original_current_section
self.menuSections = original_items
except Exception as e:
error_msg = f"Error launching {executable_name}: {e}"
self.speak(error_msg, interrupt=False)
def update_bluetooth_menu_items(self):
"""Update Bluetooth-related menu items in Accessories section"""
if "Accessories" in self.menuSections:
# Remove any existing Bluetooth-related items
self.menuSections["Accessories"] = [item for item in self.menuSections["Accessories"]
if "Bluetooth" not in item[0]]
# Add Bluetooth management item only if Bluetooth is enabled
if self.check_service_status('bluetooth.service'):
self.add_item("Accessories", "Manage Bluetooth Devices",
"GAME=blueman-manager startx")
def scan_documentation_files(self):
"""Scan Documents directory for .md files and add them to help menu"""
docs_dir = os.path.expanduser("~/Documents")
if not os.path.exists(docs_dir):
return
try:
# Get all .md files in Documents directory
md_files = []
for file in os.listdir(docs_dir):
if file.endswith('.md'):
file_path = os.path.join(docs_dir, file)
if os.path.isfile(file_path):
# Create a friendly display name from filename
# Remove .md extension and replace underscores with spaces
display_name = file[:-3].replace('_', ' ').title()
md_files.append((display_name, file))
# Sort files alphabetically by display name
md_files.sort(key=lambda x: x[0])
# Add each markdown file to the help section
for display_name, filename in md_files:
file_path = f"~/Documents/{filename}"
self.add_item("Help and Documentation", display_name, f"GAME={file_path} /home/stormux/.clirc")
except Exception as e:
print(f"Error scanning documentation files: {e}")
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))
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"""
currentSectionName = self.sectionNames[self.currentSection]
return self.menuSections[currentSectionName]
def get_current_item_index(self):
"""Get the current item index in the current section"""
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"""
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]
# Announce we're launching the program
self.speak(f"Launching {name}")
# Check if command is a function (for service toggles and other functions)
if callable(command):
command()
return
# Save current terminal state before any changes
savedTerminalState = None
try:
result = subprocess.run(['stty', '-g'], capture_output=True, text=True, check=True)
savedTerminalState = result.stdout.strip()
except Exception as e:
print(f"Warning: Could not save terminal state: {e}")
# Cleanup before running the command
self.stop_speech()
# Close speech client
if self.speechClient:
try:
self.speechClient.close()
except:
pass
self.speechClient = None
# Curses cleanup - be thorough
if self.stdscr:
try:
curses.nocbreak()
self.stdscr.keypad(False)
curses.echo()
curses.endwin()
except:
pass
self.stdscr = None
# Complete terminal reset to ensure clean state for games
try:
subprocess.run(['reset'], check=False, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except:
pass
# Ensure we're in a new process group to avoid signal conflicts
# Execute the command with proper terminal isolation
try:
# For applications that need dialog/curses, we need to ensure they
# become the controlling process with full terminal access
# Use os.system with proper terminal restoration as it gives
# the subprocess complete control over the terminal
# First, save our current process group
original_pgrp = os.getpgrp()
# Execute using os.system which gives the subprocess complete terminal control
exit_code = os.system(command)
# Try to regain terminal control after the subprocess exits
try:
# Get the current terminal file descriptor
tty_fd = os.open('/dev/tty', os.O_RDWR)
# Try to make our process group the foreground group again
os.tcsetpgrp(tty_fd, original_pgrp)
os.close(tty_fd)
except:
# If we can't regain control, that's okay
pass
except Exception as e:
print(f"Error launching {name}: {e}")
# Another terminal reset after game exits to clean up any changes
try:
subprocess.run(['reset'], check=False, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except:
pass
# Restore the saved terminal state if we have it
if savedTerminalState:
try:
subprocess.run(['stty', savedTerminalState], check=False)
except Exception as e:
print(f"Warning: Could not restore terminal state: {e}")
# Brief delay to let terminal stabilize
time.sleep(0.5)
# Reinitialize speech and display
self.init_speech()
# Reinitialize curses with better error handling
try:
self.stdscr = curses.initscr()
curses.noecho()
curses.cbreak()
self.stdscr.keypad(True)
self.stdscr.clear()
self.stdscr.refresh()
except Exception as e:
print(f"Error reinitializing display: {e}")
# Try a more aggressive recovery
try:
curses.endwin()
time.sleep(1)
self.stdscr = curses.initscr()
curses.noecho()
curses.cbreak()
self.stdscr.keypad(True)
self.stdscr.clear()
self.stdscr.refresh()
except:
print("Could not recover display. Please restart the menu.")
sys.exit(1)
# Update service menu items and redraw
self.update_service_menu_items()
self.draw_menu()
# Let the user know the menu is activated
self.speak("Game menu activated")
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.
B key: Report battery status.
Left bracket: Decrease speech rate.
Right bracket: Increase speech rate.
9 key: Decrease volume.
0 key: Increase volume.
Escape: Refresh the menu.
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 = "Ãvigate | Enter: Select | H: Help | [ ] Rate | 9 0 Volume | Esc: Refresh"
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()
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)
# Draw volume indicator
volumeText = f"Volume: {self.get_current_volume()}%"
self.stdscr.addstr(h-2, w-len(volumeText)-2, volumeText)
self.stdscr.refresh()
def cleanup(self):
"""Clean up resources before exiting"""
# Stop any speech
self.stop_speech()
# Close speech client
if self.speechClient:
try:
self.speechClient.close()
except:
pass
self.speechClient = None
# Restore terminal settings
curses.nocbreak()
self.stdscr.keypad(False)
curses.echo()
curses.endwin()
def run(self):
"""Run the menu system"""
if not self.sectionNames:
print("Menu is empty. Exiting.")
return
try:
# Initialize curses
self.stdscr = curses.initscr()
curses.noecho() # Don't echo keypresses
curses.cbreak() # React to keys instantly
self.stdscr.keypad(True) # Enable special keys
# Update all service menu items based on current status
self.update_service_menu_items()
# Update Bluetooth menu items based on current status
self.update_bluetooth_menu_items()
# Initial draw
self.draw_menu()
# Welcome message - don't interrupt this initial speech
self.speak(f"Welcome to {self.title}. Use left and right arrows to navigate sections. Up and down for items. Press H for help.")
# 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:
prevIndex = self.get_current_item_index()
self.set_current_item_index((prevIndex - 1) % len(items))
newIndex = self.get_current_item_index()
self.draw_menu()
# Play sound if selection actually changed
if newIndex != prevIndex:
self.play_sound("menu_move")
self.announce_current_item()
elif key == curses.KEY_DOWN:
# Move to next item in current section
items = self.get_current_items()
if items:
prevIndex = self.get_current_item_index()
self.set_current_item_index((prevIndex + 1) % len(items))
newIndex = self.get_current_item_index()
self.draw_menu()
# Play sound if selection actually changed
if newIndex != prevIndex:
self.play_sound("menu_move")
self.announce_current_item()
elif key == curses.KEY_LEFT:
# Move to previous section
if self.sectionNames:
prevSection = self.currentSection
self.currentSection = (self.currentSection - 1) % len(self.sectionNames)
self.draw_menu()
# Play category change sound if section actually changed
if prevSection != self.currentSection:
self.play_sound("menu_category")
# 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:
prevSection = self.currentSection
self.currentSection = (self.currentSection + 1) % len(self.sectionNames)
self.draw_menu()
# Play category change sound if section actually changed
if prevSection != self.currentSection:
self.play_sound("menu_category")
# 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
self.play_sound("menu_select")
self.execute_current_item()
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 == ord('9'): # Decrease volume
self.decrease_volume()
elif key == ord('0'): # Increase volume
self.increase_volume()
elif key == ord('b') or key == ord('B'): # Battery status
self.report_battery_status()
elif key == 27: # Esc key - restart the application
try:
# Announce restart first while speech still works
self.speak("Restarting menu")
# Wait for speech to complete
time.sleep(1)
# Now clean up resources
self.cleanup()
# Try to kill speech-dispatcher if needed
subprocess.run(["sudo", "killall", "speech-dispatcher"], check=False)
# Restart the application
os.execv(sys.argv[0], sys.argv)
except Exception as e:
print(f"Error during restart: {e}")
# If restart fails, we need to recover the UI
self.stdscr = curses.initscr()
curses.noecho()
curses.cbreak()
self.stdscr.keypad(True)
self.draw_menu()
except Exception as e:
# End curses in case of error
curses.endwin()
print(f"An error occurred: {e}")
finally:
# Clean up
self.cleanup()
# Example usage
if __name__ == "__main__":
# Create the menu with sections
menu = VoicedMenu(title="Stormux Gaming Menu")
# Add arcade section
menu.add_section("Arcade")
menu.add_item("Arcade", "Audio Disc", "GAME='Audio Disc' startx")
menu.add_item("Arcade", "BallBouncer", "GAME=BallBouncer startx")
menu.add_item("Arcade", "Battle of the Hunter", "GAME='Battle of the Hunter' startx")
menu.add_item("Arcade", "Challenge of the Horse", "GAME='Challenge of the Horse' startx")
menu.add_item("Arcade", "Clashes of the Sky", "GAME='Clashes of the Sky' startx")
menu.add_item("Arcade", "Constant Motion", "GAME='Constant Motion' startx")
menu.add_item("Arcade", "Crazy Party", "GAME='Crazy Party' startx")
menu.add_item("Arcade", "Doom", "GAME=Doom startx")
menu.add_item("Arcade", "Haunted House", "GAME='Haunted House' startx")
menu.add_item("Arcade", "Haunted Party", "GAME='Haunted Party' startx")
menu.add_item("Arcade", "kaskade", "GAME=Kaskade startx")
menu.add_item("Arcade", "Oh Shitt", "GAME='Oh Shit' startx")
menu.add_item("Arcade", "Q9 Action Game", "GAME='Q9 Action Game' startx")
menu.add_item("Arcade", "River Raiders", "GAME='River Raiders' startx")
menu.add_item("Arcade", "Scramble", "GAME=Scramble startx")
menu.add_item("Arcade", "Screaming Strike 2", "GAME='Screaming Strike 2' startx")
menu.add_item("Arcade", "Scrolling Battles", "GAME='Scrolling Battles' startx")
menu.add_item("Arcade", "Shooter", "GAME=Shooter startx")
menu.add_item("Arcade", "Side Party", "GAME='Side Party' startx")
menu.add_item("Arcade", "Skateboarder Pro", "GAME='Skateboarder Pro' startx")
menu.add_item("Arcade", "Sketchbook (Your World)", "GAME='Sketchbook (Your World)' startx")
menu.add_item("Arcade", "Super Egg Hunt", "GAME='Super Egg Hunt' startx")
menu.add_item("Arcade", "Super Liam", "GAME='Super Liam' startx")
menu.add_item("Arcade", "The Blind Swordsman", "GAME='The Blind Swordsman' startx")
menu.add_item("Arcade", "The Tornado Chicken", "GAME='The Tornado Chicken' startx")
menu.add_item("Arcade", "Toy Mania", "GAME='Toy Mania' startx")
menu.add_item("Arcade", "Villains From Beyond", "GAME='Villains From Beyond' startx")
menu.add_item("Arcade", "Wicked Quest", "GAME='Wicked Quest' startx")
menu.add_item("Arcade", "Zombowl", "GAME='Zombowl' startx")
# Add Kitchen's Sink and Swamp only on x86_64
if platform.machine() == "x86_64":
menu.add_item("Arcade", "Kitchen's Sink", "GAME=\"Kitchen's Sink\" startx")
menu.add_item("Arcade", "Swamp", "GAME='Swamp' startx")
# Add board and card games section
menu.add_section("Board and Card Games")
menu.add_item("Board and Card Games", "RS Games", "GAME='RS Games' startx")
# Add emulators section
menu.add_section("Emulators")
menu.add_item("Emulators", "Apple 2e", "/usr/local/bin/apple_2e.py")
menu.add_item("Emulators", "Bop It", "GAME='Bop It' startx")
menu.add_item("Emulators", "Dosbox", "GAME=Dosbox startx")
menu.add_item("Emulators", "Game Console Menu", "/usr/local/bin/rom_launcher.py")
menu.add_item("Emulators", "Retro Arch", "GAME='Retro Arch' startx")
# Add Steam only on x86_64
if platform.machine() == "x86_64":
menu.add_item("Emulators", "Steam", "GAME='Steam' startx")
# Add MUD section
menu.add_section("MUDs")
menu.add_item("MUDs", "Alter Aeon", "GAME='Alter Aeon' /home/stormux/.clirc")
menu.add_item("MUDs", "Empire MUD", "GAME='Empire MUD' /home/stormux/.clirc")
menu.add_item("MUDs", "End of Time", "GAME='End of Time' /home/stormux/.clirc")
menu.add_item("MUDs", "Kallisti MUD", "GAME='Kallisti MUD' /home/stormux/.clirc")
# Add racing section
menu.add_section("Racing")
menu.add_item("Racing", "Mach1", "GAME=Mach1 startx")
menu.add_item("Racing", "Mine Racer", "GAME='Mine Racer' startx")
menu.add_item("Racing", "Top Speed 3", "GAME=\"Top Speed 3\" startx")
menu.add_item("Racing", "Wheels of Prio", "GAME=\"Wheels of Prio\" startx")
# Add rpg section
menu.add_section("RPG")
menu.add_item("RPG", "Bokurano Daibouken", "GAME='Bokurano Daibouken' startx")
menu.add_item("RPG", "Bokurano Daibouken 2", "GAME='Bokurano Daibouken 2' startx")
menu.add_item("RPG", "Bokurano Daibouken 3", "GAME='Bokurano Daibouken 3' startx")
menu.add_item("RPG", "Fantasy Story 2", "GAME='Fantasy Story 2' startx")
menu.add_item("RPG", "Manamon 2", "GAME='Manamon 2' startx")
menu.add_item("RPG", "Shadow Line", "GAME='Shadow Line' startx")
# Add sports section
menu.add_section("Sports")
menu.add_item("Sports", "Golf", "GAME=Golf startx")
menu.add_item("Sports", "Horseshoes", "GAME=Horseshoes startx")
menu.add_item("Sports", "Pong", "GAME=Pong startx")
# Add strategy section
menu.add_section("Strategy")
menu.add_item("Strategy", "SoundRTS", "GAME=SoundRTS startx")
menu.add_item("Strategy", "Warsim", "/home/stormux/.Warsim")
# Add text games section
menu.add_section("Text Games")
menu.add_item("Text Games", "BPG", "GAME=BPG /home/stormux/.clirc")
menu.add_item("Text Games", "Colossal Cave Adventure", "GAME=/usr/bin/adventure /home/stormux/.clirc")
menu.add_item("Text Games", "Go Fish", "GAME=/usr/bin/gofish /home/stormux/.clirc")
menu.add_item("Text Games", "RS Games", "GAME=\"RS Games\" /home/stormux/.clirc")
menu.add_item("Text Games", "Slay the Text", "GAME=\"Slay the Text\" /home/stormux/.clirc")
menu.add_item("Text Games", "Stationfall", "GAME=Stationfall /home/stormux/.clirc")
menu.add_item("Text Games", "Planetfall", "GAME=Planetfall /home/stormux/.clirc")
menu.add_item("Text Games", "The Hitchhiker's Guide to the Galaxy", "GAME=\"The Hitchhiker's Guide to the Galaxy\" /home/stormux/.clirc")
menu.add_item("Text Games", "Upheaval", "GAME=Upheaval /home/stormux/.Upheaval")
menu.add_item("Text Games", "Zork 1", "GAME='Zork 1' /home/stormux/.clirc")
menu.add_item("Text Games", "Zork 2", "GAME='Zork 2' /home/stormux/.clirc")
menu.add_item("Text Games", "Zork 3", "GAME='Zork 3' /home/stormux/.clirc")
# Add web section
menu.add_section("Web")
menu.add_item("Web", "Aliens", "GAME=https://files.jantrid.net/aliens// startx")
menu.add_item("Web", "Echo Commander", "GAME=https://echo-commander.vercel.app/ startx")
menu.add_item("Web", "Periphery Synthetic EP", "GAME=https://shiftbacktick.itch.io/periphery-synthetic-ep startx")
menu.add_item("Web", "Pontoon", "GAME='https://oneswitch.org.uk/jsbeeb/?autotype=CHAIN%22PONTOON%22%0A&disc=/Blind_Access/Pontoon_1983_2025.dsd' startx")
menu.add_item("Web", "QuentinC Play Room", "GAME=https://qcsalon.net/ startx")
menu.add_item("Web", "soundStrider", "GAME=https://shiftbacktick.itch.io/soundstrider startx")
# Add help and documentation section
menu.add_section("Help and Documentation")
# Dynamically scan and add all .md files from Documents directory
menu.scan_documentation_files()
# Add the IRC help item
menu.add_item("Help and Documentation", "Get help on IRC", "GAME=IRC /home/stormux/.clirc")
# Add accessories section
menu.add_section("Accessories")
menu.add_item("Accessories", "Music Player", "/usr/local/bin/music_player.py")
menu.add_item("Accessories", "Local IP Address", "/usr/local/bin/ip_info.py local")
menu.add_item("Accessories", "Remote IP Address", "/usr/local/bin/ip_info.py remote")
menu.add_item("Accessories", "Web Browser", "GAME=Brave startx")
menu.add_item("Accessories", "LibreOffice", lambda: menu.install_and_launch("libreoffice", "gui"))
menu.add_item("Accessories", "Thunderbird", lambda: menu.install_and_launch("thunderbird", "gui"))
# Add system section
menu.add_section("System")
# Add installer only on x86_64
if platform.machine() == "x86_64":
menu.add_item("System", "Install System to Hard Drive", "GAME='Install to Disk' /home/stormux/.clirc")
menu.add_item("System", "Internet Configuration", "GAME=\"Network Configuration\" /home/stormux/.clirc")
menu.add_item("System", "Enable Screen", lambda: menu.toggle_screen("screen"))
menu.add_item("System", "Disable Screen", lambda: menu.toggle_screen("headless"))
menu.add_item("System", "Set System Speech Settings", "/usr/local/bin/speechd_rate.py")
menu.add_item("System", "Set Default Voice", "/usr/local/bin/set-voice.py")
menu.add_item("System", "Set Timezone", "GAME='Set Timezone' /home/stormux/.clirc")
menu.add_item("System", "Upload Files", "/home/stormux/.local/upload_server/uploader.py")
menu.add_item("System", "Restart: Can Take Several Minutes", "sudo reboot")
menu.add_item("System", "Power Off: Wait 2 Minutes Before Disconnecting Power", "sudo poweroff")
# Service menu items will be added dynamically in run() method via update_service_menu_items()
menu.add_item("System", "Resize to fill empty space on disk", "sudo growpartfs $(df --output='source' / | tail -1)")
# Run the menu
menu.run()