Added ability to select multiple items. Added alt+a to act on all checked items.

This commit is contained in:
Storm Dragon committed 2026-09-16 00:43:20 -04:00
1 parent bd6cfb83eb
commit 059e4e124b
5 files changed
+306 -16

No files matched your search

+3 -1
View File
@@ -9,6 +9,7 @@ This project is vibe coded. If you have problems with fully AI generated code, m
- Keyboard-driven playback controls with live announcements
- Queue management (play now, next/previous, clear/remove)
- Track-change announcements (toggle in View → Announce Track Changes)
- Persistent accessible checkboxes for combining artists, albums, and songs across library views
- Context menus on library tables and search results (right click or Shift+F10/Menu) for play, queue, favorite
- Search dialog uses a results tree (artists/albums/songs/genres) with expand/collapse, Enter to play, and context menus
- Discover section with recently added/played, frequently played, random albums, server “Now Playing”, and “Similar to current track”
@@ -47,7 +48,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, `Alt+I` context menu for current track/selection, `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, `Alt+A` actions for checked library items, `Shift+F10` or Menu key for item context menu
## Browsing the Library
@@ -57,6 +58,7 @@ Logs are written to `~/.local/share/stormux/navipy/navipy.log`.
- Use `Backspace`, `Alt+Left`, or the Back button to return to the previous table and selection.
- Use Play selected to play an entire selected album, artist, playlist, or genre without leaving the table.
- Use `Shift+F10`, the Menu key, or Actions for play, queue, playlist, and favorite actions.
- In library tables, press `Space` to check or uncheck an artist, album, or song. Checks remain selected while you browse other tabs and collections. `Alt+A` opens actions for all checked items.
- Collection actions applies to the collection currently being viewed, including all artists or favorites.
- Albums and genre tracks have a Load more button when more results are available.
- Each tab keeps its own browsing position. Refreshing the library resets the tables.
@@ -1,6 +1,6 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=navipy-git
pkgver=r12.g33bba72
pkgver=r13.gbd6cfb8
pkgrel=1
pkgdesc='Accessible Subsonic desktop client tested with Navidrome'
arch=('any')
+87 -2
View File
@@ -758,7 +758,15 @@ class MainWindow(QMainWindow):
def playLibrarySelection(self, data: Dict):
"""Play a table selection without changing the browser's location."""
itemType = data.get("type")
if itemType == "song":
if itemType == "checked_items":
self.statusBar.showMessage("Collecting checked songs...", 2000)
self._run_background(
"collect checked songs for playback",
lambda: self.collectSongsForItem(data),
on_success=lambda result: self.startPlaybackWithSongs(result[0], result[1]),
on_error=lambda err: self._handleCollectionError(str(err))
)
elif itemType == "song":
song = data["song"]
self.playback.enqueue(song, playNow=True)
self.statusBar.showMessage(f"Playing {song.title} by {song.artist}", 4000)
@@ -783,6 +791,15 @@ class MainWindow(QMainWindow):
menu = QMenu(self)
menu.setAccessibleName("Library Context Menu")
itemType = data.get("type")
if itemType == "checked_items":
menu.addAction("Play Checked Items", lambda: self.playLibrarySelection(data))
menu.addAction("Add Checked Items to Queue", lambda: self.addItemToQueue(data))
menu.addAction("Add Checked Items to Playlist", lambda: self.addItemToPlaylist(data))
menu.addSeparator()
menu.addAction("Favorite Checked Items", lambda: self._favoriteLibraryData(data, True))
menu.addAction("Unfavorite Checked Items", lambda: self._favoriteLibraryData(data, False))
menu.addSeparator()
menu.addAction("Clear Checked Items", self.libraryBrowser.clearChecked)
if itemType in ("album", "artist", "playlist", "genre", "album_list",
"now_playing", "similar_current"):
menu.addAction("Open", lambda: self.libraryBrowser.currentWidget().openData(data))
@@ -802,6 +819,19 @@ class MainWindow(QMainWindow):
"""Apply menu actions to their original target even if selection changes."""
action = "favorite" if favorite else "unfavorite"
label = data.get("label", "selection")
if data.get("type") == "checked_items":
items = data.get("items", [])
if not items or not self._confirmCheckedItemsAction(action, len(items)):
return
self.statusBar.showMessage(f"Updating {label}...", 2000)
self._run_background(
f"{action} checked items",
lambda: self._setCheckedItemsFavorite(items, favorite),
on_success=lambda result: self._afterCheckedItemsFavorite(action, result),
on_error=lambda err: AccessibleTextDialog.showError(
"Favorite Error", str(err), parent=self)
)
return
if data["type"] != "song" and not self._confirmBulkAction(action, label):
return
try:
@@ -811,6 +841,48 @@ class MainWindow(QMainWindow):
except Exception:
AccessibleTextDialog.showError("Favorite Error", "Could not update favorites.", parent=self)
def _confirmCheckedItemsAction(self, action: str, count: int) -> bool:
response = QMessageBox.question(
self,
"Confirm action",
f"This will {action} {count} checked item{'s' if count != 1 else ''}. Continue?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
return response == QMessageBox.Yes
def _setCheckedItemsFavorite(self, items: List[Dict], favorite: bool) -> Tuple[int, List[str]]:
"""Favorite mixed media items, retaining enough detail to report partial failure."""
method = self.client.star if favorite else self.client.unstar
updated = 0
failed = []
for data in items:
itemType = data.get("type")
itemId = data.get("id")
if itemType not in ("song", "album", "artist") or not itemId:
failed.append(data.get("label", "unknown item"))
continue
try:
method(itemId, itemType)
updated += 1
except Exception: # Continue so one server error does not skip later checked items.
failed.append(data.get("label", "unknown item"))
return updated, failed
def _afterCheckedItemsFavorite(self, action: str, result: Tuple[int, List[str]]):
updated, failed = result
pastTense = "favorited" if action == "favorite" else "unfavorited"
message = f"{updated} checked item{'s' if updated != 1 else ''} {pastTense}"
if failed:
message += f"; {len(failed)} failed"
AccessibleTextDialog.showError(
"Favorite Error",
f"{message}. The failed items remain checked.",
parent=self
)
self.statusBar.showMessage(message, 4000)
self.announceLive(message)
def showCurrentMediaContextMenu(self):
"""Open a context menu for the currently playing song (Alt+I)."""
currentSong = self.playback.currentSong()
@@ -997,6 +1069,17 @@ class MainWindow(QMainWindow):
if not self.client:
return [], label, truncated
if itemType == "checked_items":
songs: List[Song] = []
seen = set()
for item in data.get("items", []):
itemSongs, _itemLabel, itemTruncated = self.collectSongsForItem(item)
truncated = truncated or itemTruncated
for song in itemSongs:
if song and song.id not in seen:
seen.add(song.id)
songs.append(song)
return songs, label, truncated
if itemType == "song":
song: Optional[Song] = data.get("song")
return ([song] if song else []), (self.formatSongLabel(song, includeAlbum=True) if song else label), truncated
@@ -1074,7 +1157,7 @@ class MainWindow(QMainWindow):
def _handleCollectionError(self, message: str):
"""Surface collection errors consistently."""
AccessibleTextDialog.showError("Queue Error", message, parent=self)
AccessibleTextDialog.showError("Collection Error", message, parent=self)
def _onSongsCollectedForPlaylist(self, result: Tuple[List[Song], str, bool]):
"""Handle async collection results before showing playlist dialog."""
@@ -1863,11 +1946,13 @@ class MainWindow(QMainWindow):
("Alt+C", "Announce current track"),
("Ctrl+P", "Announce playback position"),
("Alt+I", "Open actions for the current item"),
("Alt+A", "Open actions for checked items or the selected library row"),
("Ctrl+Tab", "Next library tab"),
("Ctrl+Shift+Tab", "Previous library tab"),
("Enter", "Open selected collection or play selected song"),
("Backspace or Alt+Left", "Go back in the library"),
("Shift+F10 or Menu", "Open actions for selected library item"),
("Space in library table", "Check or uncheck an artist, album, or song"),
("Ctrl+C", "Copy selected library row"),
)
dialog = QDialog(self)
+135 -12
View File
@@ -2,7 +2,7 @@
from dataclasses import dataclass, field
from typing import Callable, Optional
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal
from PySide6.QtCore import QAbstractTableModel, QEvent, QModelIndex, Qt, Signal
from PySide6.QtGui import QKeySequence, QGuiApplication, QShortcut
from PySide6.QtWidgets import (
QAbstractItemView, QHBoxLayout, QHeaderView, QLabel, QPushButton, QTableView,
@@ -49,8 +49,9 @@ HEADERS = {
class LibraryTableModel(QAbstractTableModel):
def __init__(self, parent=None):
def __init__(self, browser, parent=None):
super().__init__(parent)
self.browser = browser
self.headers = ()
self.rows = []
@@ -61,10 +62,36 @@ class LibraryTableModel(QAbstractTableModel):
return 0 if parent.isValid() else len(self.headers)
def data(self, index, role=Qt.DisplayRole):
if (index.isValid() and index.column() == 0
and role == Qt.CheckStateRole
and self.isCheckable(index)):
return Qt.Checked if self.browser.isChecked(
self.rows[index.row()].data) else Qt.Unchecked
if index.isValid() and role in (Qt.DisplayRole, Qt.AccessibleTextRole, Qt.ToolTipRole):
return str(self.rows[index.row()].values[index.column()])
return None
def flags(self, index):
flags = super().flags(index)
if self.isCheckable(index):
flags |= Qt.ItemIsUserCheckable
return flags
def setData(self, index, value, role=Qt.EditRole):
if role != Qt.CheckStateRole or not self.isCheckable(index):
return False
self.browser.setChecked(self.rows[index.row()].data, value == Qt.Checked)
return True
def isCheckable(self, index):
return (index.isValid() and index.column() == 0
and self.rows[index.row()].data.get('type') in ('artist', 'album', 'song'))
def refreshChecks(self):
if self.rows and self.headers:
self.dataChanged.emit(self.index(0, 0), self.index(len(self.rows) - 1, 0),
[Qt.CheckStateRole])
def headerData(self, section, orientation, role=Qt.DisplayRole):
if role == Qt.DisplayRole:
return self.headers[section] if orientation == Qt.Horizontal else str(section + 1)
@@ -79,7 +106,27 @@ class LibraryTableModel(QAbstractTableModel):
class LibraryTable(QTableView):
backRequested = Signal()
def event(self, event):
# Keep Space available for native checkbox operation while the library
# table has focus instead of letting the window's play/pause shortcut
# consume it first.
if event.type() == QEvent.ShortcutOverride and event.key() == Qt.Key_Space:
index = self.currentIndex()
if index.isValid() and self.model().isCheckable(self.model().index(index.row(), 0)):
event.accept()
return True
return super().event(event)
def keyPressEvent(self, event):
if event.key() == Qt.Key_Space:
index = self.currentIndex()
checkIndex = self.model().index(index.row(), 0) if index.isValid() else QModelIndex()
if self.model().isCheckable(checkIndex):
checked = self.model().data(checkIndex, Qt.CheckStateRole) == Qt.Checked
self.model().setData(
checkIndex, Qt.Unchecked if checked else Qt.Checked, Qt.CheckStateRole)
event.accept()
return
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
if self.currentIndex().isValid():
self.activated.emit(self.currentIndex())
@@ -140,20 +187,25 @@ class BrowserPage(QWidget):
self.playButton.clicked.connect(self.playSelected)
bar.addWidget(self.playButton)
self.actionsButton = QPushButton('Actions')
self.actionsButton.setAccessibleName('Actions for selected library row')
self.actionsButton.setAccessibleName('Actions for current or checked library items')
self.actionsButton.clicked.connect(self.selectedMenu)
bar.addWidget(self.actionsButton)
self.collectionButton = QPushButton('Collection actions')
self.collectionButton.clicked.connect(self.collectionMenu)
bar.addWidget(self.collectionButton)
self.clearCheckedButton = QPushButton('Clear checked')
self.clearCheckedButton.setAccessibleName('Clear all checked library items')
self.clearCheckedButton.clicked.connect(self.browser.clearChecked)
bar.addWidget(self.clearCheckedButton)
bar.addStretch()
layout.addLayout(bar)
self.table = LibraryTable()
self.table.setAccessibleName(f'{name} table')
self.table.setAccessibleDescription(
'Enter opens a collection or plays a song. Backspace returns to the previous table. '
'Shift+F10 opens actions. Control+Tab changes library tabs.')
self.model = LibraryTableModel(self.table)
'Space checks or unchecks an artist, album, or song. Alt+A or Shift+F10 opens actions. '
'Control+Tab changes library tabs.')
self.model = LibraryTableModel(self.browser, self.table)
self.table.setModel(self.model)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
@@ -181,6 +233,9 @@ class BrowserPage(QWidget):
self.backShortcut = QShortcut(QKeySequence('Alt+Left'), self)
self.backShortcut.setContext(Qt.WidgetWithChildrenShortcut)
self.backShortcut.activated.connect(self.goBack)
self.actionsShortcut = QShortcut(QKeySequence('Alt+A'), self)
self.actionsShortcut.setContext(Qt.WidgetWithChildrenShortcut)
self.actionsShortcut.activated.connect(self.selectedMenu)
@property
def state(self):
@@ -198,9 +253,13 @@ class BrowserPage(QWidget):
def updateButtons(self, *args):
data = self.selectedData()
self.playButton.setEnabled(bool(data and data['type'] in (
'song', 'album', 'artist', 'playlist', 'genre')))
self.actionsButton.setEnabled(data is not None)
checkedCount = self.browser.checkedCount()
self.playButton.setText(f'Play checked ({checkedCount})' if checkedCount else 'Play selected')
self.actionsButton.setText(f'Actions ({checkedCount} checked)' if checkedCount else 'Actions')
self.playButton.setEnabled(bool(checkedCount or (data and data['type'] in (
'song', 'album', 'artist', 'playlist', 'genre'))))
self.actionsButton.setEnabled(bool(checkedCount or data is not None))
self.clearCheckedButton.setEnabled(bool(checkedCount))
def render(self):
state = self.state
@@ -217,9 +276,23 @@ class BrowserPage(QWidget):
self.moreButton.setEnabled(not self.loading)
self.retryButton.hide()
count = len(state.rows)
self.status.setText(f'{count} row{"s" if count != 1 else ""}.' if count else 'No items found.')
rowStatus = f'{count} row{"s" if count != 1 else ""}.' if count else 'No items found.'
checkedCount = self.browser.checkedCount()
checkedStatus = (f' {checkedCount} item{"s" if checkedCount != 1 else ""} checked.'
if checkedCount else '')
self.status.setText(rowStatus + checkedStatus)
self.updateButtons()
def checkedStateChanged(self):
self.model.refreshChecks()
self.updateButtons()
count = len(self.state.rows)
rowStatus = f'{count} row{"s" if count != 1 else ""}.' if count else 'No items found.'
checkedCount = self.browser.checkedCount()
checkedStatus = (f' {checkedCount} item{"s" if checkedCount != 1 else ""} checked.'
if checkedCount else '')
self.status.setText(rowStatus + checkedStatus)
def remember(self):
self.state.selected = max(0, self.table.currentIndex().row())
self.state.scroll = self.table.verticalScrollBar().value()
@@ -289,7 +362,7 @@ class BrowserPage(QWidget):
on_success=apply, on_error=failed)
def playSelected(self):
data = self.selectedData()
data = self.browser.actionData(self.selectedData())
if data:
self.browser.playRequested.emit(data)
@@ -297,11 +370,11 @@ class BrowserPage(QWidget):
index = self.table.indexAt(position)
if index.isValid():
self.table.setCurrentIndex(index)
self.browser.menuRequested.emit(self.selectedData(),
self.browser.menuRequested.emit(self.browser.actionData(self.selectedData()),
self.table.viewport().mapToGlobal(position))
def selectedMenu(self):
data = self.selectedData()
data = self.browser.actionData(self.selectedData())
if data:
self.browser.menuRequested.emit(data, self.actionsButton.mapToGlobal(
self.actionsButton.rect().bottomLeft()))
@@ -320,6 +393,7 @@ class LibraryBrowser(QTabWidget):
super().__init__(parent)
self.runTask, self.currentSong = runTask, currentSong
self.client = None
self._checked = {}
self.setAccessibleName('Library categories')
self.pages = {}
for name in ('Artists', 'Albums', 'Favorites', 'Playlists', 'Genres', 'Discover'):
@@ -331,6 +405,7 @@ class LibraryBrowser(QTabWidget):
def clear(self):
self.client = None
self.clearChecked()
for name, page in self.pages.items():
page.reset(ViewState(name, (name,)))
@@ -363,6 +438,54 @@ class LibraryBrowser(QTabWidget):
def selectedData(self):
return self.currentWidget().selectedData()
@staticmethod
def itemKey(data):
itemType = data.get('type')
itemId = data.get('id')
return (itemType, itemId) if itemType in ('artist', 'album', 'song') and itemId else None
def isChecked(self, data):
key = self.itemKey(data)
return key in self._checked if key else False
def setChecked(self, data, checked):
key = self.itemKey(data)
if not key:
return
if checked:
self._checked[key] = dict(data)
else:
self._checked.pop(key, None)
self._refreshCheckedState()
def clearChecked(self):
if not self._checked:
return
self._checked.clear()
self._refreshCheckedState()
def checkedCount(self):
return len(self._checked)
def checkedData(self):
return [dict(data) for data in self._checked.values()]
def actionData(self, fallback=None):
items = self.checkedData()
if items:
count = len(items)
return {
'type': 'checked_items',
'items': items,
'label': f'{count} checked item{"s" if count != 1 else ""}',
}
return fallback
def _refreshCheckedState(self):
for page in self.pages.values():
if page.history:
page.checkedStateChanged()
def focusTable(self):
self.currentWidget().table.setFocus(Qt.OtherFocusReason)
+80
View File
@@ -235,6 +235,86 @@ def test_collection_actions_and_queue_use_current_payload(window, monkeypatch):
assert targets[-1]['type'] == 'artist'
def test_checked_items_persist_across_tabs_and_alt_a_uses_the_group(window):
w, pending = window
browser = w.libraryBrowser
artistsPage = browser.pages['Artists']
QTest.keyClick(artistsPage.table, Qt.Key_Space)
assert artistsPage.model.data(artistsPage.model.index(0, 0), Qt.CheckStateRole) == Qt.Checked
browser.setCurrentWidget(browser.pages['Albums'])
finish(pending)
albumsPage = browser.currentWidget()
QTest.keyClick(albumsPage.table, Qt.Key_Space)
browser.setCurrentWidget(browser.pages['Favorites'])
favoritesPage = browser.currentWidget()
QTest.keyClick(favoritesPage.table, Qt.Key_Space)
group = browser.actionData(favoritesPage.selectedData())
assert group['type'] == 'checked_items'
assert [item['type'] for item in group['items']] == ['artist', 'album', 'song']
assert favoritesPage.actionsButton.text() == 'Actions (3 checked)'
assert '3 items checked' in favoritesPage.status.text()
targets = []
w.addItemToQueue = targets.append
def choose_queue():
menu = QApplication.activePopupWidget()
action = next(action for action in menu.actions()
if action.text() == 'Add Checked Items to Queue')
menu.close()
action.trigger()
QTimer.singleShot(0, choose_queue)
QTest.keyClick(favoritesPage.table, Qt.Key_A, Qt.AltModifier)
assert targets[-1]['type'] == 'checked_items'
assert len(targets[-1]['items']) == 3
favoritesPage.clearCheckedButton.click()
assert browser.checkedCount() == 0
assert artistsPage.model.data(artistsPage.model.index(0, 0), Qt.CheckStateRole) == Qt.Unchecked
def test_checked_collections_expand_and_deduplicate_songs(window):
w, _ = window
selectedSong = w.client.getAlbum.return_value['songs'][0]
result, label, truncated = w.collectSongsForItem({
'type': 'checked_items',
'label': '3 checked items',
'items': [
{'type': 'artist', 'id': 'artist', 'label': 'Artist'},
{'type': 'album', 'id': 'album', 'label': 'Album'},
{'type': 'song', 'id': selectedSong.id, 'label': selectedSong.title,
'song': selectedSong},
],
})
assert result == [selectedSong]
assert label == '3 checked items'
assert not truncated
def test_checked_favorite_action_attempts_every_item(window):
w, _ = window
w.client.star.side_effect = [None, RuntimeError('synthetic failure'), None]
items = [
{'type': 'artist', 'id': 'artist', 'label': 'Artist'},
{'type': 'album', 'id': 'album', 'label': 'Album'},
{'type': 'song', 'id': 'song', 'label': 'Song'},
]
updated, failed = w._setCheckedItemsFavorite(items, True)
assert updated == 2
assert failed == ['Album']
assert [entry.args for entry in w.client.star.call_args_list] == [
('artist', 'artist'),
('album', 'album'),
('song', 'song'),
]
def test_actual_background_request_does_not_block_back_navigation(window):
import threading
import time