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
|
- 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
|
- 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
|
- 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
|
## Playback Persistence
|
||||||
- Volume, shuffle, and repeat preferences persist in `settings.json`
|
- Volume, shuffle, and repeat preferences persist in `settings.json`
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from PySide6.QtWidgets import (
|
|||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
|
QMessageBox,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSlider,
|
QSlider,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
@@ -383,6 +384,9 @@ class MainWindow(QMainWindow):
|
|||||||
self.searchShortcut = QShortcut(QKeySequence("Ctrl+F"), self)
|
self.searchShortcut = QShortcut(QKeySequence("Ctrl+F"), self)
|
||||||
self.searchShortcut.setContext(Qt.ApplicationShortcut)
|
self.searchShortcut.setContext(Qt.ApplicationShortcut)
|
||||||
self.searchShortcut.activated.connect(self.openSearchDialog)
|
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 = QShortcut(QKeySequence("Z"), self)
|
||||||
self.previousShortcut.activated.connect(self.handlePreviousShortcut)
|
self.previousShortcut.activated.connect(self.handlePreviousShortcut)
|
||||||
@@ -850,9 +854,136 @@ class MainWindow(QMainWindow):
|
|||||||
if not menu.actions():
|
if not menu.actions():
|
||||||
return
|
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)
|
globalPos = self.libraryTree.viewport().mapToGlobal(position)
|
||||||
menu.exec(globalPos)
|
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]:
|
def _fetchArtistAlbums(self, artistId: str) -> List[Album]:
|
||||||
"""Fetch albums for a given artist off the UI thread."""
|
"""Fetch albums for a given artist off the UI thread."""
|
||||||
result = self.client.getArtist(artistId)
|
result = self.client.getArtist(artistId)
|
||||||
@@ -1165,6 +1296,9 @@ class MainWindow(QMainWindow):
|
|||||||
if not songs:
|
if not songs:
|
||||||
self.announceLive(f"No songs found for {label}")
|
self.announceLive(f"No songs found for {label}")
|
||||||
return
|
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(
|
self._run_background(
|
||||||
"load playlists",
|
"load playlists",
|
||||||
self.client.getPlaylists,
|
self.client.getPlaylists,
|
||||||
@@ -1896,6 +2030,10 @@ class MainWindow(QMainWindow):
|
|||||||
AccessibleTextDialog.showWarning("Favorite", "No item selected to favorite.", parent=self)
|
AccessibleTextDialog.showWarning("Favorite", "No item selected to favorite.", parent=self)
|
||||||
return
|
return
|
||||||
itemType, itemId, label, song = target
|
itemType, itemId, label, song = target
|
||||||
|
if itemType != "song":
|
||||||
|
if not self._confirmBulkAction("favorite", label):
|
||||||
|
self.statusBar.showMessage("Favorite canceled", 2000)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self.client.star(itemId, itemType)
|
self.client.star(itemId, itemType)
|
||||||
message = self.buildFavoriteAnnouncement("favorite", song, label)
|
message = self.buildFavoriteAnnouncement("favorite", song, label)
|
||||||
@@ -1912,6 +2050,10 @@ class MainWindow(QMainWindow):
|
|||||||
AccessibleTextDialog.showWarning("Unfavorite", "No item selected to unfavorite.", parent=self)
|
AccessibleTextDialog.showWarning("Unfavorite", "No item selected to unfavorite.", parent=self)
|
||||||
return
|
return
|
||||||
itemType, itemId, label, song = target
|
itemType, itemId, label, song = target
|
||||||
|
if itemType != "song":
|
||||||
|
if not self._confirmBulkAction("unfavorite", label):
|
||||||
|
self.statusBar.showMessage("Unfavorite canceled", 2000)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self.client.unstar(itemId, itemType)
|
self.client.unstar(itemId, itemType)
|
||||||
message = self.buildFavoriteAnnouncement("unfavorite", song, label)
|
message = self.buildFavoriteAnnouncement("unfavorite", song, label)
|
||||||
|
|||||||
Reference in New Issue
Block a user