diff --git a/README.md b/README.md index 133c058..1cac210 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/distro-packages/ArchLinux/navipy-git/PKGBUILD b/distro-packages/ArchLinux/navipy-git/PKGBUILD index 21e0a55..ea69d01 100644 --- a/distro-packages/ArchLinux/navipy-git/PKGBUILD +++ b/distro-packages/ArchLinux/navipy-git/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Storm Dragon pkgname=navipy-git -pkgver=r12.g33bba72 +pkgver=r13.gbd6cfb8 pkgrel=1 pkgdesc='Accessible Subsonic desktop client tested with Navidrome' arch=('any') diff --git a/src/main_window.py b/src/main_window.py index 228960a..a4b05ec 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -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) diff --git a/src/widgets/library_browser.py b/src/widgets/library_browser.py index 470606e..3069c1c 100644 --- a/src/widgets/library_browser.py +++ b/src/widgets/library_browser.py @@ -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) diff --git a/tests/test_library_ui.py b/tests/test_library_ui.py index 7dd2b02..078e976 100644 --- a/tests/test_library_ui.py +++ b/tests/test_library_ui.py @@ -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