Context menu for current song added to alt+i. Screen reader announcement for menu opening added.
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user