From bde9502ae37d56c7e0c2b806778bd2d4ef00154f Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 16 Dec 2025 04:28:06 -0500 Subject: [PATCH] Context menu for current song added to alt+i. Screen reader announcement for menu opening added. --- README.md | 2 +- src/main_window.py | 142 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 81ff33b..85d96e6 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Logs are written to `~/.local/share/stormux/navipy/navipy.log`. - Navigation: `Z` previous, `B` next, `Ctrl+Left/Right` previous/next, `Alt+S` shuffle, `Alt+R` repeat - Announcements: `Alt+C` announce current track (live region), `Ctrl+T` announce track (dialog), `Ctrl+P` announce position - Volume: `Ctrl+Up/Down` or `0/9` to adjust -- Library/Search: `Ctrl+O` connect, `F5` refresh, `Ctrl+F` search, `Shift+F10` or Menu key for item context menu +- Library/Search: `Ctrl+O` connect, `F5` refresh, `Ctrl+F` search, `Alt+I` context menu for current track/selection, `Shift+F10` or Menu key for item context menu ## Playback Persistence - Volume, shuffle, and repeat preferences persist in `settings.json` diff --git a/src/main_window.py b/src/main_window.py index 378d459..1e1d5b8 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -14,6 +14,7 @@ from PySide6.QtWidgets import ( QListWidget, QListWidgetItem, QMainWindow, + QMessageBox, QPushButton, QSlider, QSplitter, @@ -383,6 +384,9 @@ class MainWindow(QMainWindow): self.searchShortcut = QShortcut(QKeySequence("Ctrl+F"), self) self.searchShortcut.setContext(Qt.ApplicationShortcut) self.searchShortcut.activated.connect(self.openSearchDialog) + self.interactCurrentShortcut = QShortcut(QKeySequence("Alt+I"), self) + self.interactCurrentShortcut.setContext(Qt.ApplicationShortcut) + self.interactCurrentShortcut.activated.connect(self.showCurrentMediaContextMenu) self.previousShortcut = QShortcut(QKeySequence("Z"), self) self.previousShortcut.activated.connect(self.handlePreviousShortcut) @@ -850,9 +854,136 @@ class MainWindow(QMainWindow): if not menu.actions(): return + label = (item.text(0) or "").strip() + if label: + self.announceLive(f"Context menu for {label} opened.") + else: + self.announceLive("Context menu opened.") + globalPos = self.libraryTree.viewport().mapToGlobal(position) menu.exec(globalPos) + def showCurrentMediaContextMenu(self): + """Open a context menu for the currently playing song (Alt+I).""" + currentSong = self.playback.currentSong() + if currentSong: + row = self.playback.currentIndex + if row is not None and 0 <= row < len(self.playback.queue): + self.queueList.setCurrentRow(row) + self._showQueueSongContextMenu(row, announce=True) + return + self._showSongContextMenu(currentSong, self.queueList.viewport(), announce=True) + return + + item = self.libraryTree.currentItem() + if item: + rect = self.libraryTree.visualItemRect(item) + if rect.isValid(): + self.announceLive(f"Context menu for {item.text(0)} opened.") + self.onLibraryContextMenu(rect.center()) + return + + row = self.queueList.currentRow() + if 0 <= row < len(self.playback.queue): + self._showQueueSongContextMenu(row, announce=True) + return + + self.announceLive("No track to interact with.") + + def _showQueueSongContextMenu(self, row: int, announce: bool = False): + """Show a song context menu for a queue row.""" + if not (0 <= row < len(self.playback.queue)): + return + song = self.playback.queue[row] + item = self.queueList.item(row) + rect = self.queueList.visualItemRect(item) if item else None + anchor = rect.center() if rect and rect.isValid() else self.queueList.viewport().rect().center() + self._showSongContextMenu(song, self.queueList.viewport(), anchor, queueRow=row, announce=announce) + + def _showSongContextMenu(self, song: Song, anchorWidget: QWidget, anchor: Optional[QPoint] = None, queueRow: Optional[int] = None, announce: bool = False): + """Show a context menu with song actions at the provided anchor.""" + menu = QMenu(self) + menu.setAccessibleName("Song Context Menu") + hasClient = self.client is not None + + menu.addAction("Play", lambda _=False, row=queueRow, s=song: self._playSongFromMenu(s, row)).setEnabled(hasClient) + menu.addAction("Add to Queue", lambda _=False, s=song: self.playback.enqueue(s, playNow=False)).setEnabled(hasClient) + menu.addAction("Add to Playlist", lambda _=False, s=song: self._addSongToPlaylistDirect(s)).setEnabled(hasClient) + menu.addSeparator() + menu.addAction("Favorite", lambda _=False, s=song: self._favoriteSongDirect(s)).setEnabled(hasClient) + menu.addAction("Unfavorite", lambda _=False, s=song: self._unfavoriteSongDirect(s)).setEnabled(hasClient) + + if not menu.actions(): + return + + anchor = anchor or anchorWidget.rect().center() + globalPos = anchorWidget.mapToGlobal(anchor) + if announce: + label = self.buildTrackAnnouncement(song) + self.announceLive(f"Context menu for {label} opened.") + menu.exec(globalPos) + + def _playSongFromMenu(self, song: Song, queueRow: Optional[int]): + """Play a song, reusing queue position when available.""" + if queueRow is not None and 0 <= queueRow < len(self.playback.queue): + self.playback.playIndex(queueRow) + return + self.playback.enqueue(song, playNow=True) + + def _addSongToPlaylistDirect(self, song: Song): + """Open the playlist dialog for a single song.""" + if not self.client: + self.announceLive("Not connected to a server.") + return + label = self.formatSongLabel(song, includeAlbum=True) + self._collectPlaylistsAndShowDialog([song], label, truncated=False) + + def _favoriteSongDirect(self, song: Song): + """Star a song without relying on tree selection.""" + if not self.client: + self.announceLive("Not connected to a server.") + return + label = self.buildTrackAnnouncement(song) + try: + self.client.star(song.id, "song") + message = self.buildFavoriteAnnouncement("favorite", song, label) + self.statusBar.showMessage(message, 3000) + self.announceLive(message) + except Exception as exc: # noqa: BLE001 + self.announceLive(self.buildFavoriteFailureAnnouncement("favorite", song, label)) + AccessibleTextDialog.showError("Favorite Failed", str(exc), parent=self) + + def _unfavoriteSongDirect(self, song: Song): + """Unstar a song without relying on tree selection.""" + if not self.client: + self.announceLive("Not connected to a server.") + return + label = self.buildTrackAnnouncement(song) + try: + self.client.unstar(song.id, "song") + message = self.buildFavoriteAnnouncement("unfavorite", song, label) + self.statusBar.showMessage(message, 3000) + self.announceLive(message) + except Exception as exc: # noqa: BLE001 + self.announceLive(self.buildFavoriteFailureAnnouncement("unfavorite", song, label)) + AccessibleTextDialog.showError("Unfavorite Failed", str(exc), parent=self) + + def _confirmBulkAction(self, action: str, label: str, count: Optional[int] = None, truncated: bool = False) -> bool: + """Confirm bulk-affecting actions like favoriting many tracks.""" + target = label or "selection" + countText = f"{count} " if count else "" + plural = "s" if count and count != 1 else "" + truncatedText = " (first batch shown)" if truncated else "" + message = f"This will {action} {countText}track{plural} from {target}{truncatedText}. Continue?" + response = QMessageBox.question( + self, + "Confirm action", + message, + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + return response == QMessageBox.Yes + def _fetchArtistAlbums(self, artistId: str) -> List[Album]: """Fetch albums for a given artist off the UI thread.""" result = self.client.getArtist(artistId) @@ -1165,6 +1296,9 @@ class MainWindow(QMainWindow): if not songs: self.announceLive(f"No songs found for {label}") return + if len(songs) > 1 and not self._confirmBulkAction("add to playlist", label, count=len(songs), truncated=truncated): + self.statusBar.showMessage("Add to playlist canceled", 2000) + return self._run_background( "load playlists", self.client.getPlaylists, @@ -1896,6 +2030,10 @@ class MainWindow(QMainWindow): AccessibleTextDialog.showWarning("Favorite", "No item selected to favorite.", parent=self) return itemType, itemId, label, song = target + if itemType != "song": + if not self._confirmBulkAction("favorite", label): + self.statusBar.showMessage("Favorite canceled", 2000) + return try: self.client.star(itemId, itemType) message = self.buildFavoriteAnnouncement("favorite", song, label) @@ -1912,6 +2050,10 @@ class MainWindow(QMainWindow): AccessibleTextDialog.showWarning("Unfavorite", "No item selected to unfavorite.", parent=self) return itemType, itemId, label, song = target + if itemType != "song": + if not self._confirmBulkAction("unfavorite", label): + self.statusBar.showMessage("Unfavorite canceled", 2000) + return try: self.client.unstar(itemId, itemType) message = self.buildFavoriteAnnouncement("unfavorite", song, label)