622 lines
23 KiB
Python
Executable File
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()
|