Files
navipy/src/main_window.py
Storm Dragon 555ca0bba9 Initial commit
2025-12-15 04:09:55 -05:00

1249 lines
52 KiB
Python

"""Main application window for NaviPy."""
from __future__ import annotations
from pathlib import Path
from typing import Dict, List, Optional
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QMainWindow,
QPushButton,
QSlider,
QSplitter,
QStatusBar,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from PySide6.QtCore import Qt
from PySide6.QtGui import (
QAction,
QAccessible,
QAccessibleEvent,
QAccessibleAnnouncementEvent,
QGuiApplication,
QKeySequence,
QShortcut,
)
from src.accessibility.accessible_tree import AccessibleTreeWidget
from src.api.client import SubsonicClient, SubsonicError
from src.api.models import Album, Artist, Playlist, Song
from src.config.settings import Settings
from src.managers.playback_manager import PlaybackManager
from src.integrations.mpris import MprisService
from src.widgets.accessible_text_dialog import AccessibleTextDialog
from src.widgets.search_dialog import SearchDialog
from src.widgets.server_dialog import ServerDialog
class MainWindow(QMainWindow):
"""Main NaviPy application window"""
def __init__(self):
super().__init__()
self.setWindowTitle("NaviPy - Navidrome Client")
self.setMinimumSize(980, 640)
self.settings = Settings()
self.client: Optional[SubsonicClient] = None
self.playback = PlaybackManager(self)
self.mpris: Optional[MprisService] = None
self.pageStep = self.settings.get("interface", "pageStep", 5)
self.volume = self.settings.get("playback", "volume", 100)
self.shuffleEnabled = self.settings.get("playback", "shuffle", False)
self.repeatMode = self.settings.get("playback", "repeat", "none")
self.announceTrackChangesEnabled = self.settings.get("interface", "announceTrackChanges", True)
self._announceOnTrackChange = False
self.setupMenus()
self.setupUi()
self.connectSignals()
self.applyPersistedPlaybackSettings()
self.enablePlaybackActions(False)
self.playback.setVolume(self.volume)
self.setupMpris()
# Connect to server if one is configured
if self.settings.hasServers():
self.connectToDefaultServer()
else:
self.showServerSetup()
# ============ UI setup ============
def setupMenus(self):
"""Setup the menu bar"""
menuBar = self.menuBar()
menuBar.setAccessibleName("Menu Bar")
# File menu
fileMenu = menuBar.addMenu("&File")
self.connectAction = QAction("&Connect to Server...", self)
self.connectAction.setShortcut(QKeySequence("Ctrl+O"))
self.connectAction.triggered.connect(self.showServerSetup)
fileMenu.addAction(self.connectAction)
fileMenu.addSeparator()
self.refreshAction = QAction("&Refresh Library", self)
self.refreshAction.setShortcut(QKeySequence("F5"))
self.refreshAction.triggered.connect(self.refreshLibrary)
self.refreshAction.setEnabled(False)
fileMenu.addAction(self.refreshAction)
fileMenu.addSeparator()
self.quitAction = QAction("&Quit", self)
self.quitAction.setShortcut(QKeySequence("Ctrl+Q"))
self.quitAction.triggered.connect(self.close)
fileMenu.addAction(self.quitAction)
# Playback menu
playbackMenu = menuBar.addMenu("&Playback")
self.playPauseAction = QAction("&Play/Pause", self)
self.playPauseAction.setShortcut(QKeySequence("Space"))
self.playPauseAction.setEnabled(False)
playbackMenu.addAction(self.playPauseAction)
self.stopAction = QAction("&Stop", self)
self.stopAction.setEnabled(False)
playbackMenu.addAction(self.stopAction)
playbackMenu.addSeparator()
self.previousAction = QAction("Pre&vious Track", self)
self.previousAction.setShortcut(QKeySequence("Ctrl+Left"))
self.previousAction.setEnabled(False)
playbackMenu.addAction(self.previousAction)
self.nextAction = QAction("&Next Track", self)
self.nextAction.setShortcut(QKeySequence("Ctrl+Right"))
self.nextAction.setEnabled(False)
playbackMenu.addAction(self.nextAction)
playbackMenu.addSeparator()
self.shuffleAction = QAction("&Shuffle", self)
self.shuffleAction.setShortcut(QKeySequence("Alt+S"))
self.shuffleAction.setCheckable(True)
playbackMenu.addAction(self.shuffleAction)
self.repeatAction = QAction("&Repeat Off", self)
self.repeatAction.setShortcut(QKeySequence("Alt+R"))
self.repeatAction.setCheckable(True)
playbackMenu.addAction(self.repeatAction)
playbackMenu.addSeparator()
self.favoriteAction = QAction("&Favorite", self)
self.favoriteAction.setShortcut(QKeySequence("+"))
self.favoriteAction.setEnabled(False)
playbackMenu.addAction(self.favoriteAction)
self.unfavoriteAction = QAction("Un&favorite", self)
self.unfavoriteAction.setShortcut(QKeySequence("_"))
self.unfavoriteAction.setEnabled(False)
playbackMenu.addAction(self.unfavoriteAction)
playbackMenu.addSeparator()
self.volumeUpAction = QAction("Volume &Up", self)
self.volumeUpAction.setShortcut(QKeySequence("Ctrl+Up"))
self.volumeUpAction.setEnabled(False)
playbackMenu.addAction(self.volumeUpAction)
self.volumeDownAction = QAction("Volume &Down", self)
self.volumeDownAction.setShortcut(QKeySequence("Ctrl+Down"))
self.volumeDownAction.setEnabled(False)
playbackMenu.addAction(self.volumeDownAction)
# View menu
viewMenu = menuBar.addMenu("&View")
self.searchAction = QAction("&Search...", self)
self.searchAction.setShortcut(QKeySequence("Ctrl+F"))
self.searchAction.setEnabled(False)
viewMenu.addAction(self.searchAction)
viewMenu.addSeparator()
self.announceTrackAction = QAction("Announce Current &Track", self)
self.announceTrackAction.setShortcut(QKeySequence("Ctrl+T"))
self.announceTrackAction.setEnabled(False)
viewMenu.addAction(self.announceTrackAction)
self.announcePositionAction = QAction("Announce &Position", self)
self.announcePositionAction.setShortcut(QKeySequence("Ctrl+P"))
self.announcePositionAction.setEnabled(False)
viewMenu.addAction(self.announcePositionAction)
self.announceTrackChangesAction = QAction("Announce Track &Changes", self)
self.announceTrackChangesAction.setCheckable(True)
self.announceTrackChangesAction.setChecked(self.announceTrackChangesEnabled)
viewMenu.addAction(self.announceTrackChangesAction)
# Help menu
helpMenu = menuBar.addMenu("&Help")
self.aboutAction = QAction("&About NaviPy", self)
self.aboutAction.triggered.connect(self.showAbout)
helpMenu.addAction(self.aboutAction)
def setupUi(self):
"""Setup the main UI"""
centralWidget = QWidget()
self.setCentralWidget(centralWidget)
mainLayout = QHBoxLayout(centralWidget)
# Create splitter for resizable panels
self.splitter = QSplitter(Qt.Horizontal)
self.splitter.setAccessibleName("Main Content")
# Left panel - Library browser
leftPanel = QWidget()
leftLayout = QVBoxLayout(leftPanel)
libraryLabel = QLabel("Library Browser")
libraryLabel.setAccessibleName("Library Panel")
leftLayout.addWidget(libraryLabel)
self.libraryStatus = QLabel("Connect to a server to browse your library")
self.libraryStatus.setAccessibleName("Library Status")
leftLayout.addWidget(self.libraryStatus)
self.libraryTree = AccessibleTreeWidget()
self.libraryTree.setHeaderHidden(True)
self.libraryTree.setAccessibleName("Library Tree")
self.libraryTree.setRootIsDecorated(True)
self.libraryTree.setItemsExpandable(True)
self.libraryTree.pageStep = self.pageStep
self.libraryTree.setEnabled(False)
leftLayout.addWidget(self.libraryTree)
self.splitter.addWidget(leftPanel)
# Right panel - Now playing and queue
rightPanel = QWidget()
rightLayout = QVBoxLayout(rightPanel)
nowPlayingLabel = QLabel("Now Playing")
nowPlayingLabel.setAccessibleName("Now Playing Section")
rightLayout.addWidget(nowPlayingLabel)
self.nowPlayingInfo = QLabel("No track playing")
self.nowPlayingInfo.setAccessibleName("Current Track")
rightLayout.addWidget(self.nowPlayingInfo)
self.positionLabel = QLabel("00:00 / 00:00")
self.positionLabel.setAccessibleName("Playback Position")
rightLayout.addWidget(self.positionLabel)
self.positionSlider = QSlider(Qt.Horizontal)
self.positionSlider.setAccessibleName("Seek Slider")
self.positionSlider.setRange(0, 1000)
self.positionSlider.setEnabled(False)
rightLayout.addWidget(self.positionSlider)
controlsLayout = QHBoxLayout()
self.prevButton = QPushButton("Previous")
self.prevButton.setAccessibleName("Previous Track")
self.prevButton.setEnabled(False)
controlsLayout.addWidget(self.prevButton)
self.playButton = QPushButton("Play/Pause")
self.playButton.setAccessibleName("Play or Pause")
self.playButton.setEnabled(False)
controlsLayout.addWidget(self.playButton)
self.stopButton = QPushButton("Stop")
self.stopButton.setAccessibleName("Stop Playback")
self.stopButton.setEnabled(False)
controlsLayout.addWidget(self.stopButton)
self.nextButton = QPushButton("Next")
self.nextButton.setAccessibleName("Next Track")
self.nextButton.setEnabled(False)
controlsLayout.addWidget(self.nextButton)
rightLayout.addLayout(controlsLayout)
queueLabel = QLabel("Play Queue")
queueLabel.setAccessibleName("Queue Section")
rightLayout.addWidget(queueLabel)
self.queueList = QListWidget()
self.queueList.setAccessibleName("Queue List")
self.queueList.setSelectionMode(QListWidget.SingleSelection)
self.queueList.setEnabled(False)
rightLayout.addWidget(self.queueList)
queueButtons = QHBoxLayout()
self.removeQueueButton = QPushButton("Remove Selected")
self.removeQueueButton.setAccessibleName("Remove Selected Track from Queue")
self.removeQueueButton.setEnabled(False)
queueButtons.addWidget(self.removeQueueButton)
self.clearQueueButton = QPushButton("Clear Queue")
self.clearQueueButton.setAccessibleName("Clear Queue")
self.clearQueueButton.setEnabled(False)
queueButtons.addWidget(self.clearQueueButton)
rightLayout.addLayout(queueButtons)
rightLayout.addStretch()
self.splitter.addWidget(rightPanel)
self.splitter.setSizes([560, 420])
mainLayout.addWidget(self.splitter)
# Status bar
self.statusBar = QStatusBar()
self.setStatusBar(self.statusBar)
self.connectionStatus = QLabel("Not connected")
self.connectionStatus.setAccessibleName("Connection Status")
self.statusBar.addPermanentWidget(self.connectionStatus)
self.playbackStatus = QLabel("Stopped")
self.playbackStatus.setAccessibleName("Playback Status")
self.statusBar.addPermanentWidget(self.playbackStatus)
self.volumeStatus = QLabel(f"Volume: {self.volume}%")
self.volumeStatus.setAccessibleName("Volume Status")
self.statusBar.addPermanentWidget(self.volumeStatus)
self.liveRegion = QLabel("")
self.liveRegion.setAccessibleName("Live Region")
self.liveRegion.setAccessibleDescription("Announcements")
self.liveRegion.setStyleSheet("color: transparent;")
self.statusBar.addPermanentWidget(self.liveRegion, 1)
def connectSignals(self):
"""Connect signals after widgets exist."""
self.libraryTree.itemExpanded.connect(self.onLibraryExpanded)
self.libraryTree.itemActivated.connect(self.onLibraryActivated)
self.playButton.clicked.connect(self.playPauseAction.trigger)
self.prevButton.clicked.connect(self.previousAction.trigger)
self.nextButton.clicked.connect(self.nextAction.trigger)
self.stopButton.clicked.connect(self.stopAction.trigger)
self.playPauseAction.triggered.connect(self.playback.togglePlayPause)
self.stopAction.triggered.connect(self.playback.stop)
self.previousAction.triggered.connect(self.handlePreviousShortcut)
self.nextAction.triggered.connect(self.handleNextShortcut)
self.shuffleAction.toggled.connect(self.onShuffleToggled)
self.repeatAction.toggled.connect(self.onRepeatToggled)
self.announceTrackChangesAction.toggled.connect(self.onAnnounceTrackChangesToggled)
self.volumeUpAction.triggered.connect(lambda: self.adjustVolume(5))
self.volumeDownAction.triggered.connect(lambda: self.adjustVolume(-5))
self.favoriteAction.triggered.connect(self.favoriteSelection)
self.unfavoriteAction.triggered.connect(self.unfavoriteSelection)
self.searchAction.triggered.connect(self.openSearchDialog)
self.positionSlider.sliderMoved.connect(self.onSeekRequested)
self.queueList.itemActivated.connect(self.onQueueItemActivated)
self.removeQueueButton.clicked.connect(self.removeSelectedFromQueue)
self.clearQueueButton.clicked.connect(self.clearQueue)
self.announceTrackAction.triggered.connect(self.announceTrack)
self.announcePositionAction.triggered.connect(self.announcePosition)
self.announceTrackLiveShortcut = QShortcut(QKeySequence("Alt+C"), self)
self.announceTrackLiveShortcut.activated.connect(self.announceCurrentTrackLive)
self.previousShortcut = QShortcut(QKeySequence("Z"), self)
self.previousShortcut.activated.connect(self.handlePreviousShortcut)
self.playShortcut = QShortcut(QKeySequence("X"), self)
self.playShortcut.activated.connect(self.handlePlayShortcut)
self.pauseToggleShortcut = QShortcut(QKeySequence("C"), self)
self.pauseToggleShortcut.activated.connect(self.handlePauseToggleShortcut)
self.stopShortcut = QShortcut(QKeySequence("V"), self)
self.stopShortcut.activated.connect(self.handleStopShortcut)
self.nextShortcut = QShortcut(QKeySequence("B"), self)
self.nextShortcut.activated.connect(self.handleNextShortcut)
self.playback.trackChanged.connect(self.onTrackChanged)
self.playback.queueChanged.connect(self.onQueueChanged)
self.playback.playbackStateChanged.connect(self.onPlaybackStateChanged)
self.playback.positionChanged.connect(self.onPositionChanged)
self.playback.errorOccurred.connect(self.onPlaybackError)
def setupMpris(self):
"""Initialize the MPRIS service and wire signal updates."""
try:
self.mpris = MprisService(
playback=self.playback,
art_url_resolver=self._resolveCoverArtUrl,
raise_callback=self.bringToFront,
quit_callback=self.close,
on_volume_changed=self.setVolumeFromExternal,
on_shuffle_changed=lambda enabled: self.setShuffleFromExternal(enabled, announce=False),
on_loop_changed=lambda loop: self.setRepeatFromExternal(loop, announce=False),
)
except Exception:
self.mpris = None
return
if not self.mpris or not self.mpris.available:
self.mpris = None
return
self.playback.trackChanged.connect(self.mpris.updateMetadata)
self.playback.playbackStateChanged.connect(self.mpris.updatePlaybackStatus)
self.playback.queueChanged.connect(self._handleQueueChangedForMpris)
self.playback.errorOccurred.connect(lambda _message: self.mpris.updatePlaybackStatus("stopped"))
self.mpris.updateMetadata(self.playback.currentSong())
self.mpris.updatePlaybackStatus("stopped")
self.mpris.notifyCapabilities()
self.mpris.notifyVolumeChanged()
self.mpris.notifyShuffleChanged()
self.mpris.notifyLoopStatus()
# Additional keyboard shortcuts for quick volume changes
self.volumeUpShortcut = QShortcut(QKeySequence("0"), self)
self.volumeUpShortcut.activated.connect(lambda: self.adjustVolume(5))
self.volumeDownShortcut = QShortcut(QKeySequence("9"), self)
self.volumeDownShortcut.activated.connect(lambda: self.adjustVolume(-5))
# ============ Connection handling ============
def showServerSetup(self):
"""Show the server setup dialog"""
dialog = ServerDialog(self.settings, parent=self)
if dialog.exec():
serverName = dialog.getServerName()
self.connectToServer(serverName)
def connectToDefaultServer(self):
"""Connect to the default server"""
defaultServer = self.settings.getDefaultServer()
if defaultServer:
self.connectToServer(defaultServer)
else:
servers = self.settings.getServers()
if servers:
self.connectToServer(list(servers.keys())[0])
def connectToServer(self, serverName: str):
"""Connect to a specific server"""
server = self.settings.getServer(serverName)
if not server:
AccessibleTextDialog.showError(
"Connection Error",
f"Server '{serverName}' not found in configuration",
parent=self
)
return
self.connectionStatus.setText(f"Connecting to {serverName}...")
try:
self.client = SubsonicClient(
server['url'],
server['username'],
server['password']
)
if self.client.ping():
self.connectionStatus.setText(f"Connected to {serverName}")
self.playback.setStreamResolver(lambda song: self.client.getStreamUrl(song.id))
self.onConnected()
else:
self.handleConnectionFailure("Failed to connect to server. Please check your settings.")
except SubsonicError as e:
self.handleConnectionFailure(f"Failed to connect: {e.message}")
except Exception as e:
self.handleConnectionFailure(f"Failed to connect: {str(e)}")
def handleConnectionFailure(self, message: str):
"""Handle errors when connecting to the server."""
self.connectionStatus.setText("Connection failed")
AccessibleTextDialog.showError("Connection Error", message, parent=self)
def onConnected(self):
"""Called when successfully connected to a server"""
self.refreshAction.setEnabled(True)
self.searchAction.setEnabled(True)
self.libraryTree.setEnabled(True)
self.libraryStatus.setText("Loading library...")
self.refreshLibrary()
# ============ Library browsing ============
def refreshLibrary(self):
"""Refresh the library browser"""
if not self.client:
return
self.libraryTree.clear()
self.libraryStatus.setText("Loading artists, playlists, and genres...")
# Root sections
self.artistsRoot = QTreeWidgetItem(["Artists"])
self.artistsRoot.setData(0, Qt.UserRole, {"type": "artists_root", "loaded": True})
self.artistsRoot.setExpanded(True)
self.libraryTree.addTopLevelItem(self.artistsRoot)
self.playlistsRoot = QTreeWidgetItem(["Playlists"])
self.playlistsRoot.setData(0, Qt.UserRole, {"type": "playlists_root", "loaded": True})
self.playlistsRoot.setExpanded(True)
self.libraryTree.addTopLevelItem(self.playlistsRoot)
self.genresRoot = QTreeWidgetItem(["Genres"])
self.genresRoot.setData(0, Qt.UserRole, {"type": "genres_root", "loaded": True})
self.genresRoot.setExpanded(True)
self.libraryTree.addTopLevelItem(self.genresRoot)
try:
self.loadArtists()
self.loadPlaylists()
self.loadGenres()
self.libraryStatus.setText("Library ready. Use the tree to browse and press Enter to play.")
except Exception as e:
AccessibleTextDialog.showError(
"Error",
f"Failed to load library: {str(e)}",
parent=self
)
self.libraryStatus.setText("Library failed to load.")
def loadArtists(self):
"""Populate top-level artists."""
artists = self.client.getArtists()
for artist in artists:
item = QTreeWidgetItem([artist.name])
description = f"{artist.albumCount} albums" if artist.albumCount else "Artist"
item.setData(0, Qt.UserRole, {"type": "artist", "id": artist.id, "artist": artist, "loaded": False})
item.setData(0, Qt.AccessibleTextRole, f"{artist.name}, {description}")
item.addChild(QTreeWidgetItem(["Loading albums..."]))
self.artistsRoot.addChild(item)
def loadPlaylists(self):
"""Populate top-level playlists."""
playlists = self.client.getPlaylists()
for playlist in playlists:
label = f"{playlist.name} ({playlist.songCount} tracks)"
item = QTreeWidgetItem([label])
item.setData(0, Qt.UserRole, {"type": "playlist", "id": playlist.id, "playlist": playlist, "loaded": False})
item.setData(0, Qt.AccessibleTextRole, label)
item.addChild(QTreeWidgetItem(["Loading tracks..."]))
self.playlistsRoot.addChild(item)
def loadGenres(self):
"""Populate top-level genres."""
genres = self.client.getGenres()
for genre in genres:
label = f"{genre.name} ({genre.songCount} songs)"
item = QTreeWidgetItem([label])
item.setData(0, Qt.UserRole, {"type": "genre", "name": genre.name, "loaded": False})
item.setData(0, Qt.AccessibleTextRole, label)
item.addChild(QTreeWidgetItem(["Loading songs..."]))
self.genresRoot.addChild(item)
def onLibraryExpanded(self, item: QTreeWidgetItem):
"""Lazy-load children when an item is expanded."""
data: Dict = item.data(0, Qt.UserRole) or {}
if data.get("loaded"):
return
try:
item.takeChildren()
itemType = data.get("type")
if itemType == "artist":
self.populateArtistAlbums(item, data["id"])
elif itemType == "album":
self.populateAlbumSongs(item, data["id"])
elif itemType == "playlist":
self.populatePlaylistSongs(item, data["id"])
elif itemType == "genre":
self.populateGenreSongs(item, data["name"])
data["loaded"] = True
item.setData(0, Qt.UserRole, data)
except Exception as e:
AccessibleTextDialog.showError(
"Library Error",
f"Could not load content: {str(e)}",
parent=self
)
def onLibraryActivated(self, item: QTreeWidgetItem):
"""Handle activation (Enter/Return) in the library tree."""
data: Dict = item.data(0, Qt.UserRole) or {}
itemType = data.get("type")
if itemType == "song":
song: Song = data["song"]
self.playback.enqueue(song, playNow=True)
self.statusBar.showMessage(f"Playing {song.title} by {song.artist}", 4000)
self.enablePlaybackActions(True)
elif itemType == "playlist":
self.playPlaylist(data.get("id"), item)
elif itemType == "album":
self.playAlbum(data.get("id"), item)
elif itemType == "artist":
self.playArtist(data.get("id"), item)
elif itemType == "artists_root":
self.playAllArtists()
elif itemType == "genre":
self.playGenre(data.get("name"), item)
else:
# Toggle expansion for other nodes
item.setExpanded(not item.isExpanded())
def populateArtistAlbums(self, parentItem: QTreeWidgetItem, artistId: str):
"""Add albums for a given artist."""
result = self.client.getArtist(artistId)
albums: List[Album] = result.get("albums", [])
for album in albums:
label = f"{album.name} ({album.songCount} tracks)"
albumItem = QTreeWidgetItem([label])
albumItem.setData(0, Qt.UserRole, {"type": "album", "id": album.id, "album": album, "loaded": False})
albumItem.setData(0, Qt.AccessibleTextRole, label)
albumItem.addChild(QTreeWidgetItem(["Loading tracks..."]))
parentItem.addChild(albumItem)
def populateAlbumSongs(self, parentItem: QTreeWidgetItem, albumId: str):
"""Add songs for a given album."""
result = self.client.getAlbum(albumId)
songs: List[Song] = result.get("songs", [])
for song in songs:
label = self.formatSongLabel(song)
songItem = QTreeWidgetItem([label])
songItem.setData(0, Qt.UserRole, {"type": "song", "song": song})
songItem.setData(0, Qt.AccessibleTextRole, label)
parentItem.addChild(songItem)
def populatePlaylistSongs(self, parentItem: QTreeWidgetItem, playlistId: str):
"""Add songs for a playlist."""
playlist: Playlist = self.client.getPlaylist(playlistId)
for song in playlist.songs:
label = self.formatSongLabel(song, includeAlbum=True)
songItem = QTreeWidgetItem([label])
songItem.setData(0, Qt.UserRole, {"type": "song", "song": song})
songItem.setData(0, Qt.AccessibleTextRole, label)
parentItem.addChild(songItem)
parentItem.setData(0, Qt.UserRole, {"type": "playlist", "id": playlistId, "playlist": playlist, "loaded": True})
def populateGenreSongs(self, parentItem: QTreeWidgetItem, genreName: str):
"""Add songs for a genre (limited batch)."""
songs = self.client.getSongsByGenre(genreName, count=200)
for song in songs:
label = self.formatSongLabel(song, includeAlbum=True)
songItem = QTreeWidgetItem([label])
songItem.setData(0, Qt.UserRole, {"type": "song", "song": song})
songItem.setData(0, Qt.AccessibleTextRole, label)
parentItem.addChild(songItem)
parentItem.setData(0, Qt.UserRole, {"type": "genre", "name": genreName, "loaded": True})
@staticmethod
def formatSongLabel(song: Song, includeAlbum: bool = False) -> str:
"""Create a descriptive label for a song."""
prefix = f"{song.track}. " if song.track else ""
core = f"{prefix}{song.title}"
details = f"{song.artist}"
if includeAlbum:
details = f"{details}{song.album}"
return f"{core} ({song.durationFormatted}) — {details}"
def _populateAlbumItemWithSongs(self, parentItem: QTreeWidgetItem, songs: List[Song]):
"""Populate an album tree item with song children."""
parentItem.takeChildren()
for song in songs:
label = self.formatSongLabel(song)
child = QTreeWidgetItem([label])
child.setData(0, Qt.UserRole, {"type": "song", "song": song})
child.setData(0, Qt.AccessibleTextRole, label)
parentItem.addChild(child)
data: Dict = parentItem.data(0, Qt.UserRole) or {}
data["loaded"] = True
parentItem.setData(0, Qt.UserRole, data)
def _populateGenreItemWithSongs(self, parentItem: QTreeWidgetItem, songs: List[Song]):
"""Populate a genre item with the provided songs."""
parentItem.takeChildren()
for song in songs:
label = self.formatSongLabel(song, includeAlbum=True)
child = QTreeWidgetItem([label])
child.setData(0, Qt.UserRole, {"type": "song", "song": song})
child.setData(0, Qt.AccessibleTextRole, label)
parentItem.addChild(child)
data: Dict = parentItem.data(0, Qt.UserRole) or {}
data["loaded"] = True
parentItem.setData(0, Qt.UserRole, data)
# ============ Search ============
def openSearchDialog(self):
"""Open the search dialog."""
if not self.client:
return
dialog = SearchDialog(self.client, parent=self)
dialog.songActivated.connect(self.onSearchSongChosen)
dialog.exec()
def onSearchSongChosen(self, song: Song):
"""Handle song selection from search."""
self.playback.enqueue(song, playNow=True)
self.enablePlaybackActions(True)
self.statusBar.showMessage(f"Playing {song.title} by {song.artist}", 4000)
# ============ Queue management ============
def onQueueChanged(self, queue: List[Song]):
"""Refresh the queue list widget."""
self.queueList.blockSignals(True)
self.queueList.clear()
for index, song in enumerate(queue):
text = f"{index + 1}. {song.artist}{song.title} ({song.durationFormatted})"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, index)
item.setData(Qt.AccessibleTextRole, text)
self.queueList.addItem(item)
self.queueList.blockSignals(False)
hasItems = len(queue) > 0
self.queueList.setEnabled(hasItems)
self.removeQueueButton.setEnabled(hasItems)
self.clearQueueButton.setEnabled(hasItems)
self.enablePlaybackActions(hasItems)
self.announceTrackAction.setEnabled(hasItems)
self.announcePositionAction.setEnabled(hasItems)
def onQueueItemActivated(self, item: QListWidgetItem):
"""Play the selected queue item."""
index = item.data(Qt.UserRole)
if index is not None:
self.playback.playIndex(int(index))
def removeSelectedFromQueue(self):
"""Remove the selected item from the queue."""
row = self.queueList.currentRow()
if row < 0:
return
if 0 <= row < len(self.playback.queue):
del self.playback.queue[row]
if row <= self.playback.currentIndex:
self.playback.currentIndex = max(-1, self.playback.currentIndex - 1)
if not self.playback.queue:
self.playback.stop()
self.playback.queueChanged.emit(self.playback.queue.copy())
def clearQueue(self):
"""Clear the entire queue."""
self.playback.clearQueue()
# ============ Playback updates ============
def startPlaybackWithSongs(self, songs: List[Song], label: str):
"""Replace queue with provided songs and start playback."""
if not songs:
AccessibleTextDialog.showWarning("Nothing to Play", f"No songs found for {label}.", parent=self)
return
self.playback.clearQueue()
self.playback.enqueueMany(songs, playFirst=True)
self.statusBar.showMessage(f"Playing {label}", 4000)
self.enablePlaybackActions(True)
def playAlbum(self, albumId: str, item: Optional[QTreeWidgetItem] = None):
"""Play all songs in an album."""
if not self.client or not albumId:
self.announceLive("No album selected.")
return
try:
albumData = self.client.getAlbum(albumId)
album: Optional[Album] = albumData.get("album")
songs: List[Song] = albumData.get("songs", [])
self.startPlaybackWithSongs(songs, f"album {album.name if album else albumId}")
if item and songs:
self._populateAlbumItemWithSongs(item, songs)
item.setExpanded(True)
except Exception as e:
AccessibleTextDialog.showError("Album Error", str(e), parent=self)
def playArtist(self, artistId: str, item: Optional[QTreeWidgetItem] = None):
"""Play all songs by an artist across their albums."""
if not self.client or not artistId:
self.announceLive("No artist selected.")
return
try:
artistData = self.client.getArtist(artistId)
artist: Optional[Artist] = artistData.get("artist")
albums: List[Album] = artistData.get("albums", [])
songs: List[Song] = []
for album in albums:
albumData = self.client.getAlbum(album.id)
songs.extend(albumData.get("songs", []))
self.startPlaybackWithSongs(songs, artist.name if artist else f"artist {artistId}")
if item:
item.setExpanded(True)
except Exception as e:
AccessibleTextDialog.showError("Artist Error", str(e), parent=self)
def playAllArtists(self):
"""Play all songs across all artists."""
if not self.client:
self.announceLive("Not connected to a server.")
return
try:
self.announceLive("Loading all artists...")
artists: List[Artist] = self.client.getArtists()
songs: List[Song] = []
for artist in artists:
artistData = self.client.getArtist(artist.id)
for album in artistData.get("albums", []):
albumData = self.client.getAlbum(album.id)
songs.extend(albumData.get("songs", []))
self.startPlaybackWithSongs(songs, "all artists")
except Exception as e:
AccessibleTextDialog.showError("Library Error", str(e), parent=self)
def playGenre(self, genreName: str, item: Optional[QTreeWidgetItem] = None):
"""Play all songs within a genre."""
if not self.client or not genreName:
self.announceLive("No genre selected.")
return
try:
songs = self.client.getSongsByGenre(genreName, count=5000)
self.startPlaybackWithSongs(songs, f"genre {genreName}")
if item and songs:
self._populateGenreItemWithSongs(item, songs)
item.setExpanded(True)
except Exception as e:
AccessibleTextDialog.showError("Genre Error", str(e), parent=self)
def playPlaylist(self, playlistId: str, item: Optional[QTreeWidgetItem] = None):
"""Load a playlist and start playback."""
try:
playlist: Playlist = self.client.getPlaylist(playlistId)
songs = playlist.songs
if not songs:
AccessibleTextDialog.showWarning("Playlist Empty", "This playlist has no tracks.", parent=self)
return
self.playback.clearQueue()
self.playback.enqueueMany(songs, playFirst=True)
self.statusBar.showMessage(f"Playing playlist {playlist.name}", 4000)
if item:
# Mark as loaded and populate the tree view if needed
item.takeChildren()
for song in songs:
label = self.formatSongLabel(song, includeAlbum=True)
child = QTreeWidgetItem([label])
child.setData(0, Qt.UserRole, {"type": "song", "song": song})
child.setData(0, Qt.AccessibleTextRole, label)
item.addChild(child)
item.setData(0, Qt.UserRole, {"type": "playlist", "id": playlistId, "playlist": playlist, "loaded": True})
self.enablePlaybackActions(True)
except Exception as e:
AccessibleTextDialog.showError("Playlist Error", str(e), parent=self)
def onTrackChanged(self, song: Song):
"""Update UI when track changes."""
self.nowPlayingInfo.setText(f"{song.title}{song.artist} ({song.album})")
self.announceTrackAction.setEnabled(True)
self.announcePositionAction.setEnabled(True)
self.positionSlider.setEnabled(True)
self.favoriteAction.setEnabled(True)
self.unfavoriteAction.setEnabled(True)
if self.announceTrackChangesEnabled and self._announceOnTrackChange:
self.announceCurrentTrackLive()
self._announceOnTrackChange = False
def onPlaybackStateChanged(self, state: str):
"""Update playback state labels and actions."""
self.playbackStatus.setText(state.capitalize())
if state in ("playing", "paused"):
self.playButton.setText("Pause" if state == "playing" else "Play")
self.playPauseAction.setText("Pause" if state == "playing" else "Play")
else:
self.playButton.setText("Play/Pause")
self.playPauseAction.setText("Play/Pause")
def onPositionChanged(self, position: int, duration: int):
"""Update seek slider and position label."""
if duration <= 0:
self.positionLabel.setText("00:00 / 00:00")
return
self.positionLabel.setText(f"{self.formatTime(position)} / {self.formatTime(duration)}")
sliderValue = int((position / duration) * 1000)
self.positionSlider.blockSignals(True)
self.positionSlider.setValue(sliderValue)
self.positionSlider.blockSignals(False)
def onPlaybackError(self, message: str):
"""Show playback errors accessibly."""
AccessibleTextDialog.showError("Playback Error", message, parent=self)
self.playbackStatus.setText("Error")
def onRepeatToggled(self, checked: bool):
"""Cycle repeat mode."""
mode = "all" if checked else "none"
self.applyRepeatMode(mode)
def onShuffleToggled(self, checked: bool):
"""Handle shuffle toggle with announcement."""
self.setShuffleState(checked)
def onAnnounceTrackChangesToggled(self, checked: bool):
"""Persist preference for automatic track change announcements."""
self.announceTrackChangesEnabled = checked
self.settings.set("interface", "announceTrackChanges", checked)
self.announceLive(f"Announce track changes {'enabled' if checked else 'disabled'}")
def onSeekRequested(self, value: int):
"""Seek within the current track using slider position."""
duration = self.playback.player.duration()
if duration > 0:
target = int((value / 1000) * duration)
self.playback.seek(target)
def bringToFront(self):
"""Raise the window in response to MPRIS Raise."""
self.showNormal()
self.raise_()
self.activateWindow()
def setVolumePercent(self, volumePercent: int, announce: bool = False):
"""Set volume from 0-100 and sync UI/settings."""
volumePercent = max(0, min(100, volumePercent))
self.volume = volumePercent
self.playback.setVolume(volumePercent)
self.volumeStatus.setText(f"Volume: {self.volume}%")
self.settings.set("playback", "volume", self.volume)
if announce:
self.announceLive(f"Volume {self.volume} percent")
if self.mpris and self.mpris.available:
self.mpris.notifyVolumeChanged()
def setVolumeFromExternal(self, volumePercent: int):
"""Apply volume set via MPRIS without re-announcing."""
self.setVolumePercent(volumePercent, announce=False)
def adjustVolume(self, delta: int):
"""Adjust volume by a delta and update UI/settings."""
self.setVolumePercent(self.volume + delta)
def setShuffleState(self, enabled: bool, announce: bool = True, persist: bool = True):
"""Apply shuffle setting consistently for UI, playback, and persistence."""
self.shuffleAction.blockSignals(True)
self.shuffleAction.setChecked(enabled)
self.shuffleAction.blockSignals(False)
self.playback.setShuffle(enabled)
self.shuffleEnabled = enabled
if persist:
self.settings.set("playback", "shuffle", enabled)
if announce:
self.announceLive(f"Shuffle {'enabled' if enabled else 'disabled'}")
if self.mpris and self.mpris.available:
self.mpris.notifyShuffleChanged()
def setShuffleFromExternal(self, enabled: bool, announce: bool = False):
"""Handle shuffle updates coming from MPRIS."""
self.setShuffleState(enabled, announce=announce, persist=True)
def applyRepeatMode(self, mode: str, announce: bool = True, persist: bool = True):
"""Apply repeat mode ('none', 'one', 'all') across UI and playback."""
if mode not in ("none", "one", "all"):
mode = "none"
self.repeatAction.blockSignals(True)
self.repeatAction.setChecked(mode != "none")
if mode == "one":
self.repeatAction.setText("Repeat One")
elif mode == "all":
self.repeatAction.setText("Repeat All")
else:
self.repeatAction.setText("Repeat Off")
self.repeatAction.blockSignals(False)
self.repeatMode = mode
self.playback.setRepeatMode(mode)
if persist:
self.settings.set("playback", "repeat", mode)
if announce:
self.announceLive(f"Repeat {'enabled' if mode != 'none' else 'disabled'}")
if self.mpris and self.mpris.available:
self.mpris.notifyLoopStatus()
def setRepeatFromExternal(self, loopStatus: str, announce: bool = False):
"""Map MPRIS loop status to internal repeat modes."""
mode = {"Track": "one", "Playlist": "all", "None": "none"}.get(loopStatus, "none")
self.applyRepeatMode(mode, announce=announce, persist=True)
def announceTrack(self):
"""Speak current track details via an accessible dialog."""
text = self.nowPlayingInfo.text()
AccessibleTextDialog.showInfo("Current Track", text, parent=self)
def announcePosition(self):
"""Speak current playback position."""
AccessibleTextDialog.showInfo("Playback Position", self.positionLabel.text(), parent=self)
def announceCurrentTrackLive(self):
"""Announce the currently playing track via the live region."""
song = self.playback.currentSong()
message = self.buildTrackAnnouncement(song)
self.announceLive(message)
def enablePlaybackActions(self, enabled: bool):
"""Enable or disable playback-related actions."""
self.playPauseAction.setEnabled(enabled)
self.stopAction.setEnabled(enabled)
self.previousAction.setEnabled(enabled)
self.nextAction.setEnabled(enabled)
self.shuffleAction.setEnabled(True)
self.repeatAction.setEnabled(True)
self.volumeUpAction.setEnabled(True)
self.volumeDownAction.setEnabled(True)
self.favoriteAction.setEnabled(enabled)
self.unfavoriteAction.setEnabled(enabled)
self.prevButton.setEnabled(enabled)
self.playButton.setEnabled(enabled)
self.stopButton.setEnabled(enabled)
self.nextButton.setEnabled(enabled)
self.positionSlider.setEnabled(enabled)
# ============ Helpers ============
@staticmethod
def formatTime(ms: int) -> str:
"""Convert milliseconds to mm:ss or hh:mm:ss."""
totalSeconds = ms // 1000
hours = totalSeconds // 3600
minutes = (totalSeconds % 3600) // 60
seconds = totalSeconds % 60
if hours:
return f"{hours}:{minutes:02d}:{seconds:02d}"
return f"{minutes:02d}:{seconds:02d}"
def copyToClipboard(self):
"""Allow AccessibleTreeWidget to copy the current item's text."""
item = self.libraryTree.currentItem()
if item:
QGuiApplication.clipboard().setText(item.text(0))
def buildTrackAnnouncement(self, song: Optional[Song]) -> str:
"""Construct a live announcement for the current track with fallbacks."""
if not song:
return "No track playing."
title = (song.title or "").strip()
artist = (song.artist or "").strip()
album = (song.album or "").strip()
filename = Path(song.path).name if song.path else ""
if title and artist and album:
return f"{title} by {artist} from {album}"
if title and artist:
return f"{title} by {artist}"
if filename:
return filename
if title:
return title
return "Unknown track"
def buildFavoriteAnnouncement(self, action: str, song: Optional[Song], label: str) -> str:
"""Create an announcement for favorite/unfavorite actions with track fallbacks."""
base = self.buildTrackAnnouncement(song) if song else (label or "item")
verb = "added to favorites" if action == "favorite" else "removed from favorites"
return f"{base} {verb}"
def buildFavoriteFailureAnnouncement(self, action: str, song: Optional[Song], label: str) -> str:
"""Create an error announcement for favorite/unfavorite actions."""
base = self.buildTrackAnnouncement(song) if song else (label or "item")
verb = "favorite" if action == "favorite" else "unfavorite"
return f"Could not {verb} {base}"
def _resolveCoverArtUrl(self, coverId: Optional[str]) -> Optional[str]:
"""Provide a cover art URL for MPRIS metadata when possible."""
if not coverId or not self.client:
return None
try:
return self.client.getCoverArtUrl(coverId)
except Exception:
return None
def announceLive(self, text: str):
"""Update a live region and status bar for screen readers."""
self.liveRegion.setText(text)
self.statusBar.showMessage(text, 2000)
try:
# Prefer explicit announcement event where supported
ann = QAccessibleAnnouncementEvent(self.liveRegion, text)
QAccessible.updateAccessibility(ann)
except Exception:
try:
event = QAccessibleEvent(self.liveRegion, QAccessible.Event.Alert)
QAccessible.updateAccessibility(event)
except Exception:
# As a last resort, change accessible name to trigger a change event
self.liveRegion.setAccessibleName(text)
except Exception:
# If accessibility event fails, rely on status bar message
pass
# Shortcut handlers with feedback when no playback is active
def handlePreviousShortcut(self, checked: bool = False):
"""Go to previous track or announce why it cannot."""
if not self.playback.queue:
self.announceLive("No tracks in the queue.")
return
if not self.playback.currentSong():
self.announceLive("No track playing.")
return
self._announceOnTrackChange = True
self.playback.previous()
def handlePlayShortcut(self, checked: bool = False):
"""Start or resume playback, announcing if nothing is queued."""
if not self.playback.queue:
self.announceLive("No tracks in the queue.")
return
self.playback.play()
def handlePauseToggleShortcut(self, checked: bool = False):
"""Toggle pause or announce when nothing is playing."""
if not self.playback.currentSong():
message = "No tracks in the queue." if not self.playback.queue else "No track playing."
self.announceLive(message)
return
self.playPauseAction.trigger()
def handleStopShortcut(self, checked: bool = False):
"""Stop playback or announce when nothing is playing."""
if not self.playback.currentSong():
self.announceLive("Nothing is playing.")
return
self.stopAction.trigger()
def handleNextShortcut(self, checked: bool = False):
"""Advance to next track or announce when it is unavailable."""
if not self.playback.queue:
self.announceLive("No tracks in the queue.")
return
if not self.playback.currentSong():
self.announceLive("No track playing.")
return
self._announceOnTrackChange = True
self.playback.next()
def applyPersistedPlaybackSettings(self):
"""Restore saved shuffle and repeat preferences without announcing."""
shuffleEnabled = bool(self.shuffleEnabled)
repeatMode = self.repeatMode if self.repeatMode in ("none", "one", "all") else "none"
self.setShuffleState(shuffleEnabled, announce=False, persist=False)
self.applyRepeatMode(repeatMode, announce=False, persist=False)
self.announceTrackChangesAction.blockSignals(True)
self.announceTrackChangesAction.setChecked(self.announceTrackChangesEnabled)
self.announceTrackChangesAction.blockSignals(False)
def _handleQueueChangedForMpris(self, queue: List[Song]):
"""Update MPRIS capabilities and metadata when the queue changes."""
if not self.mpris or not self.mpris.available:
return
self.mpris.notifyCapabilities()
if not queue:
self.mpris.updateMetadata(None)
def getCurrentMediaTarget(self):
"""Return (type, id, label, song) for the most relevant selection."""
# Library tree selection
item = self.libraryTree.currentItem()
if item:
data: Dict = item.data(0, Qt.UserRole) or {}
itemType = data.get("type")
if itemType == "song":
song: Song = data["song"]
label = self.buildTrackAnnouncement(song)
return ("song", song.id, label, song)
if itemType in ("album", "artist"):
label = item.text(0)
return (itemType, data.get("id"), label, None)
# Queue selection
row = self.queueList.currentRow()
if 0 <= row < len(self.playback.queue):
song = self.playback.queue[row]
label = self.buildTrackAnnouncement(song)
return ("song", song.id, label, song)
# Now playing
current = self.playback.currentSong()
if current:
label = self.buildTrackAnnouncement(current)
return ("song", current.id, label, current)
return None
def favoriteSelection(self):
"""Favorite the selected or currently playing item."""
target = self.getCurrentMediaTarget()
if not target or not self.client:
AccessibleTextDialog.showWarning("Favorite", "No item selected to favorite.", parent=self)
return
itemType, itemId, label, song = target
try:
self.client.star(itemId, itemType)
message = self.buildFavoriteAnnouncement("favorite", song, label)
self.statusBar.showMessage(message, 3000)
self.announceLive(message)
except Exception as e:
self.announceLive(self.buildFavoriteFailureAnnouncement("favorite", song, label))
AccessibleTextDialog.showError("Favorite Failed", str(e), parent=self)
def unfavoriteSelection(self):
"""Unfavorite the selected or currently playing item."""
target = self.getCurrentMediaTarget()
if not target or not self.client:
AccessibleTextDialog.showWarning("Unfavorite", "No item selected to unfavorite.", parent=self)
return
itemType, itemId, label, song = target
try:
self.client.unstar(itemId, itemType)
message = self.buildFavoriteAnnouncement("unfavorite", song, label)
self.statusBar.showMessage(message, 3000)
self.announceLive(message)
except Exception as e:
self.announceLive(self.buildFavoriteFailureAnnouncement("unfavorite", song, label))
AccessibleTextDialog.showError("Unfavorite Failed", str(e), parent=self)
def showAbout(self):
"""Show the about dialog"""
AccessibleTextDialog.showInfo(
"About NaviPy",
"NaviPy - Accessible Navidrome Client\n\n"
"An accessible music player for Navidrome servers.\n\n"
"Built with PySide6 for full keyboard navigation "
"and screen reader compatibility.\n\n"
"Version: 0.1.0 (Development)",
parent=self
)
def closeEvent(self, event):
"""Handle window close"""
self.settings.set("playback", "volume", self.volume)
self.settings.save()
if self.mpris:
self.mpris.shutdown()
event.accept()