Initial commit
This commit is contained in:
Executable
+621
@@ -0,0 +1,621 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user