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

622 lines
23 KiB
Python
Executable File

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import sys
import time
import threading
import signal
import curses
import subprocess
import speechd
import configparser
import pathlib
import re
import random
class VoicedMusicPlayer:
def __init__(self, title="Stormux Music Player"):
self.title = title
self.menuSections = {}
self.sectionNames = []
self.currentSection = 0
self.currentItemIndices = {}
self.stdscr = None
self.cursesInitialized = False
self.hasItems = False
self.navigationStack = []
self.currentView = "main"
self.currentAlbumPath = None
self.currentAlbumName = None
self.configDir = os.path.expanduser("~/.config/stormux")
self.configFile = os.path.join(self.configDir, "music_player.conf")
self.config = configparser.ConfigParser()
self.speechRate = 0
self.randomize = False
self.musicExtensions = ['.mp3', '.flac', '.ogg', '.wav', '.opus']
self.musicDir = os.path.expanduser("~/Music")
self.load_settings()
self.speechClient = None
self.init_speech()
def init_speech(self):
try:
self.speechClient = speechd.SSIPClient("music_player")
self.speechClient.set_priority(speechd.Priority.IMPORTANT)
self.speechClient.set_punctuation(speechd.PunctuationMode.SOME)
self.speechClient.set_rate(self.speechRate)
except Exception as e:
print(f"Could not initialize speech: {e}")
def load_settings(self):
if not os.path.exists(self.configFile):
self.save_settings()
return
try:
self.config.read(self.configFile)
if 'Speech' in self.config:
self.speechRate = self.config.getint('Speech', 'rate', fallback=0)
if 'Player' in self.config:
self.randomize = self.config.getboolean('Player', 'randomize', fallback=False)
except Exception as e:
print(f"Error loading settings: {e}")
def save_settings(self):
os.makedirs(self.configDir, exist_ok=True)
if 'Speech' not in self.config:
self.config['Speech'] = {}
if 'Player' not in self.config:
self.config['Player'] = {}
self.config['Speech']['rate'] = str(self.speechRate)
self.config['Player']['randomize'] = str(self.randomize)
try:
with open(self.configFile, 'w') as f:
self.config.write(f)
except Exception as e:
print(f"Error saving settings: {e}")
def toggle_randomize(self):
self.randomize = not self.randomize
self.save_settings()
if self.randomize:
self.speak("Random playback on")
else:
self.speak("Sequential playback")
def increase_speech_rate(self):
self.speechRate = min(100, self.speechRate + 10)
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}")
self.save_settings()
def decrease_speech_rate(self):
self.speechRate = max(-100, self.speechRate - 10)
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}")
self.save_settings()
def add_section(self, sectionName):
if sectionName not in self.menuSections:
self.menuSections[sectionName] = []
self.sectionNames.append(sectionName)
self.currentItemIndices[sectionName] = 0
def add_item(self, sectionName, name, command, isDirectory=False, directoryPath=None):
if sectionName not in self.menuSections:
self.add_section(sectionName)
itemData = (name, command, isDirectory, directoryPath)
self.menuSections[sectionName].append(itemData)
self.hasItems = True
def speak(self, text, interrupt=True):
if self.speechClient is None:
return
try:
if interrupt:
self.stop_speech()
self.speechClient.speak(text)
except Exception as e:
try:
self.init_speech()
if self.speechClient:
self.speechClient.speak(text)
except:
pass
def stop_speech(self):
if self.speechClient is None:
return
try:
self.speechClient.cancel()
except Exception as e:
self.init_speech()
def get_current_items(self):
if not self.sectionNames:
return []
currentSectionName = self.sectionNames[self.currentSection]
return self.menuSections[currentSectionName]
def get_current_item_index(self):
if not self.sectionNames:
return 0
currentSectionName = self.sectionNames[self.currentSection]
return self.currentItemIndices[currentSectionName]
def set_current_item_index(self, index):
if not self.sectionNames:
return
currentSectionName = self.sectionNames[self.currentSection]
self.currentItemIndices[currentSectionName] = index
def announce_current_section(self, interrupt=True):
if 0 <= self.currentSection < len(self.sectionNames):
sectionName = self.sectionNames[self.currentSection]
self.speak(sectionName, interrupt=interrupt)
def announce_current_item(self, interrupt=True):
if len(self.sectionNames) > 0:
items = self.get_current_items()
currentIndex = self.get_current_item_index()
if items and 0 <= currentIndex < len(items):
name = items[currentIndex][0]
isDirectory = items[currentIndex][2]
if isDirectory:
self.speak(f"{name}, folder", interrupt=interrupt)
else:
self.speak(name, interrupt=interrupt)
else:
self.speak("No items", interrupt=interrupt)
def get_music_files_in_dir(self, directoryPath):
musicFiles = []
try:
files = sorted([f for f in os.listdir(directoryPath) if os.path.isfile(os.path.join(directoryPath, f))])
for file in files:
filePath = os.path.join(directoryPath, file)
fileExt = os.path.splitext(file)[1].lower()
if fileExt in self.musicExtensions:
musicFiles.append(filePath)
except Exception as e:
print(f"Error getting music files: {e}")
return musicFiles
def execute_current_item(self):
if len(self.sectionNames) > 0:
items = self.get_current_items()
index = self.get_current_item_index()
if 0 <= index < len(items):
name, command, isDirectory, directoryPath = items[index]
if isDirectory and directoryPath:
self.open_album(directoryPath, name)
return
# Build the base mpv command
shuffle_flag = "--shuffle" if self.randomize else ""
base_cmd = f"mpv --no-video --really-quiet {shuffle_flag}".strip()
if name == "Play All Music":
command = f'{base_cmd} "{self.musicDir}"'
elif name == "Play All Root Music":
# For root only, we do need the glob to avoid subdirectories
command = f'{base_cmd} "{self.musicDir}"/*'
elif name.startswith("Play All ") and not isDirectory:
# This handles both artist "Play All [Artist Name]" and album "Play All [Album Name]"
if self.currentView == "album":
# We're in album view, use the current album path
album_path = self.currentAlbumPath
if album_path:
command = f'{base_cmd} "{album_path}"'
else:
# We're in main view, this is a "Play All [Artist Name]" command
# The directoryPath should contain the actual artist directory path
if directoryPath and os.path.exists(directoryPath):
command = f'{base_cmd} "{directoryPath}"'
elif name.startswith("Play All ") and self.currentView == "album" and not isDirectory:
# Album playback
album_path = self.currentAlbumPath
if album_path:
command = f'{base_cmd} "{album_path}"'
if command:
self.cleanup(fullCleanup=True)
os.system(command)
os.execv(sys.executable, ['python3'] + sys.argv)
def open_album(self, albumPath, albumName):
oldSections = self.menuSections.copy()
oldSectionNames = self.sectionNames.copy()
oldCurrentSection = self.currentSection
oldCurrentIndices = self.currentItemIndices.copy()
self.navigationStack.append({
'sections': oldSections,
'section_names': oldSectionNames,
'current_section': oldCurrentSection,
'current_indices': oldCurrentIndices,
'view': self.currentView
})
self.menuSections = {}
self.sectionNames = []
self.currentItemIndices = {}
self.currentSection = 0
self.currentView = "album"
self.currentAlbumPath = albumPath
self.currentAlbumName = albumName
albumSection = f"{albumName}"
self.add_section(albumSection)
musicFiles = self.get_music_files_in_dir(albumPath)
if musicFiles:
self.add_item(albumSection, f"Play All {albumName}", "")
self.add_item(albumSection, "Back to Artist", "", isDirectory=False)
for filePath in musicFiles:
fileName = os.path.basename(filePath)
displayName = os.path.splitext(fileName)[0].replace('_', ' ')
command = f'mpv --no-video --really-quiet "{filePath}"'
self.add_item(albumSection, displayName, command)
else:
self.add_item(albumSection, "Back to Artist", "", isDirectory=False)
self.draw_menu()
self.announce_current_section()
time.sleep(0.5)
self.announce_current_item(interrupt=False)
def go_back(self):
if self.navigationStack:
prevState = self.navigationStack.pop()
self.menuSections = prevState['sections']
self.sectionNames = prevState['section_names']
self.currentSection = prevState['current_section']
self.currentItemIndices = prevState['current_indices']
self.currentView = prevState['view']
if self.currentView != "album":
self.currentAlbumPath = None
self.currentAlbumName = None
self.draw_menu()
self.announce_current_section()
time.sleep(0.5)
self.announce_current_item(interrupt=False)
return True
return False
def speak_help(self):
helpText = """
Navigation controls:
Up arrow: Previous menu item.
Down arrow: Next menu item.
Left arrow: Previous artist.
Right arrow: Next artist.
Enter: Play selected item or enter album.
Backspace: Go back to previous view.
R key: Toggle random playback.
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):
self.stdscr.clear()
h, w = self.stdscr.getmaxyx()
title = f" {self.title} "
x = max(0, w // 2 - len(title) // 2)
self.stdscr.addstr(1, x, title, curses.A_BOLD)
helpText = "Navigate | Enter: Select | R: Random | H: Help | [ ] Rate | Q/Esc: Quit"
x = max(0, w // 2 - len(helpText) // 2)
self.stdscr.addstr(3, x, helpText)
randomText = "Mode: Random" if self.randomize else "Mode: Sequential"
self.stdscr.addstr(3, w - len(randomText) - 2, randomText)
if self.currentView == "album" and self.currentAlbumName:
contextText = f"Album: {self.currentAlbumName}"
x = max(0, w // 2 - len(contextText) // 2 - 10)
self.stdscr.addstr(5, x, contextText, curses.A_DIM)
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)
items = self.get_current_items()
currentItemIndex = self.get_current_item_index()
if not items:
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, _, isDirectory, _) in enumerate(items):
y = i + 7
if y < h - 1:
if i == currentItemIndex:
prefix = " > "
attr = curses.A_REVERSE
else:
prefix = " "
attr = curses.A_NORMAL
if isDirectory:
text = f"{prefix}{name} [Album]"
else:
text = f"{prefix}{name}"
x = max(0, w // 2 - len(text) // 2)
self.stdscr.addstr(y, x, text, attr)
rateText = f"Speech Rate: {self.speechRate}"
self.stdscr.addstr(h-2, 2, rateText)
self.stdscr.refresh()
def cleanup(self, fullCleanup=False):
self.stop_speech()
if self.speechClient:
try:
self.speechClient.close()
except:
pass
self.speechClient = None
if fullCleanup and self.cursesInitialized:
try:
curses.nocbreak()
self.stdscr.keypad(False)
curses.echo()
curses.endwin()
except:
try:
curses.endwin()
except:
pass
def load_music_from_directory(self, directoryPath):
directoryPath = os.path.expanduser(directoryPath)
self.musicDir = directoryPath
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
rootMusicFiles = self.get_music_files_in_dir(directoryPath)
musicDirs = [d for d in os.listdir(directoryPath) if os.path.isdir(os.path.join(directoryPath, d))]
for artistDir in sorted(musicDirs):
artistPath = os.path.join(directoryPath, artistDir)
artistName = artistDir.replace('_', ' ')
self.add_section(artistName)
artistAllMusicFiles = []
artistMusicFiles = self.get_music_files_in_dir(artistPath)
artistAllMusicFiles.extend(artistMusicFiles)
albumDirs = [d for d in os.listdir(artistPath) if os.path.isdir(os.path.join(artistPath, d))]
for albumDir in albumDirs:
albumPath = os.path.join(artistPath, albumDir)
albumMusicFiles = self.get_music_files_in_dir(albumPath)
artistAllMusicFiles.extend(albumMusicFiles)
if artistAllMusicFiles:
self.add_item(artistName, f"Play All {artistName}", "", isDirectory=False, directoryPath=artistPath)
for albumDir in sorted(albumDirs):
albumPath = os.path.join(artistPath, albumDir)
albumName = albumDir.replace('_', ' ')
albumMusicFiles = self.get_music_files_in_dir(albumPath)
if albumMusicFiles:
self.add_item(artistName, albumName, "", isDirectory=True, directoryPath=albumPath)
for filePath in sorted(artistMusicFiles):
fileName = os.path.basename(filePath)
displayName = os.path.splitext(fileName)[0].replace('_', ' ')
command = f'mpv --no-video --really-quiet "{filePath}"'
self.add_item(artistName, displayName, command)
hasAnyMusic = bool(rootMusicFiles) or bool(musicDirs)
if hasAnyMusic:
self.add_section("All Music")
self.add_item("All Music", "Play All Music", "")
if rootMusicFiles:
self.add_item("All Music", "Play All Root Music", "")
for filePath in sorted(rootMusicFiles):
fileName = os.path.basename(filePath)
displayName = os.path.splitext(fileName)[0].replace('_', ' ')
command = f'mpv --no-video --really-quiet "{filePath}"'
self.add_item("All Music", displayName, command)
if not self.sectionNames:
self.add_section("Music Library")
self.add_item("Music Library", "No music found", "")
def run(self):
if not self.sectionNames:
message = "Menu is empty. No music folders found. Exiting."
print(message)
self.init_speech()
if self.speechClient:
self.speak(message)
time.sleep(3)
self.cleanup(fullCleanup=True)
sys.exit(0)
if not self.hasItems:
message = "No music files found in any sections. Exiting."
print(message)
self.init_speech()
if self.speechClient:
self.speak(message)
time.sleep(3)
self.cleanup(fullCleanup=True)
sys.exit(0)
try:
self.stdscr = curses.initscr()
self.cursesInitialized = True
curses.noecho()
curses.cbreak()
self.stdscr.keypad(True)
self.draw_menu()
self.speak("Music Player")
time.sleep(1)
self.announce_current_section(interrupt=False)
time.sleep(0.5)
self.announce_current_item(interrupt=False)
while True:
key = self.stdscr.getch()
self.stop_speech()
if key == curses.KEY_UP:
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:
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:
if self.currentView == "main":
if self.sectionNames and len(self.sectionNames) > 1:
try:
self.currentSection = (self.currentSection - 1) % len(self.sectionNames)
self.draw_menu()
self.announce_current_section()
time.sleep(0.5)
self.announce_current_item(interrupt=False)
except Exception as e:
print(f"Error navigating: {e}")
elif key == curses.KEY_RIGHT:
if self.currentView == "main":
if self.sectionNames and len(self.sectionNames) > 1:
try:
self.currentSection = (self.currentSection + 1) % len(self.sectionNames)
self.draw_menu()
self.announce_current_section()
time.sleep(0.5)
self.announce_current_item(interrupt=False)
except Exception as e:
print(f"Error navigating: {e}")
elif key == curses.KEY_ENTER or key == 10 or key == 13:
items = self.get_current_items()
if items:
index = self.get_current_item_index()
if 0 <= index < len(items):
name, command, isDirectory, directoryPath = items[index]
if self.currentView == "album" and name == "Back to Artist":
self.go_back()
else:
self.execute_current_item()
elif key == curses.KEY_BACKSPACE or key == 8 or key == 127:
if self.currentView != "main":
self.go_back()
elif key == ord('r') or key == ord('R'):
self.toggle_randomize()
self.draw_menu()
elif key == ord('h') or key == ord('H'):
self.speak_help()
elif key == ord('['):
self.decrease_speech_rate()
self.draw_menu()
elif key == ord(']'):
self.increase_speech_rate()
self.draw_menu()
elif key == 27 or key == ord('q') or key == ord('Q'):
break
except Exception as e:
if self.cursesInitialized:
try:
curses.endwin()
except:
pass
print(f"An error occurred: {e}")
finally:
self.cleanup(fullCleanup=True)
if __name__ == "__main__":
player = VoicedMusicPlayer(title="Stormux Music Player")
player.load_music_from_directory("~/Music")
player.run()