1249 lines
52 KiB
Python
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()
|