Context menu for current song added to alt+i. Screen reader announcement for menu opening added.

This commit is contained in:
Storm Dragon
2025-12-16 04:28:06 -05:00
parent dd2d9a890a
commit bde9502ae3
2 changed files with 143 additions and 1 deletions

View File

@@ -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`

View File

@@ -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)