From 33bba7282e73fabb4417926f8233f7c2da3ccd2b Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 15 Sep 2026 15:10:27 -0400 Subject: [PATCH] Rework the ui because the treeviews were not reliable. --- README.md | 21 +- distro-packages/ArchLinux/navipy-git/PKGBUILD | 2 +- src/main_window.py | 718 ++++-------------- src/widgets/library_browser.py | 409 ++++++++++ src/widgets/search_dialog.py | 17 +- tests/test_library_ui.py | 342 +++++++-- 6 files changed, 858 insertions(+), 651 deletions(-) create mode 100644 src/widgets/library_browser.py diff --git a/README.md b/README.md index 85d96e6..133c058 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@ Accessible Navidrome desktop client built with PySide6. Keyboard-first controls, This project is vibe coded. If you have problems with fully AI generated code, move along, nothing to see here. For people with a brain, keep reading, you will be impressed. ## Features -- Browse artists, albums, playlists, and genres from your Navidrome server +- Browse Artists, Albums, Favorites, Playlists, Genres, and Discover in separate tabs with native tables - Keyboard-driven playback controls with live announcements - Queue management (play now, next/previous, clear/remove) - Track-change announcements (toggle in View → Announce Track Changes) -- Context menus on library/search trees (right click or Shift+F10/Menu) for play, queue, favorite -- Search dialog mirrors the tree (artists/albums/songs/genres) with expand/collapse, Enter to play, and context menus +- 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” -- Favorites section listing all starred songs; press Enter to play all or open individual tracks +- Favorites tab listing starred songs; Enter plays a song, and Collection actions can play or queue all favorites - Add-to-playlist from songs/albums/artists/genres/favorites via context menu (choose existing or create new) - Desktop integrations via MPRIS (playback control, metadata, notifications) - Config, data, and cache saved under XDG paths (`~/.config/stormux/navipy`, etc.) @@ -49,6 +49,19 @@ Logs are written to `~/.local/share/stormux/navipy/navipy.log`. - 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 +## Browsing the Library + +- Switch categories with `Ctrl+Tab` / `Ctrl+Shift+Tab`, or focus the tab bar and use arrow keys. +- Use arrow keys to navigate table rows and columns. `Tab` leaves the table for the next control. +- Press `Enter` to open an artist's albums, an album's tracks, or a playlist or genre. On a song, `Enter` starts playback. +- 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. +- 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. +- `Ctrl+C` copies the current row. Search remains available with `Ctrl+F`. + ## Playback Persistence - Volume, shuffle, and repeat preferences persist in `settings.json` - Repeat defaults to off; shuffle retains your last choice across launches diff --git a/distro-packages/ArchLinux/navipy-git/PKGBUILD b/distro-packages/ArchLinux/navipy-git/PKGBUILD index 1c1cca6..df5ba9d 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=r10.gc4ccc39 +pkgver=r11.gd9f4ac4 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 32f4238..909d007 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -20,7 +20,6 @@ from PySide6.QtWidgets import ( QSlider, QSplitter, QStatusBar, - QTreeWidgetItem, QVBoxLayout, QWidget, ) @@ -35,7 +34,7 @@ from PySide6.QtGui import ( QShortcut, ) -from src.accessibility.accessible_tree import AccessibleTreeWidget +from src.widgets.library_browser import LibraryBrowser from src.api.client import SubsonicClient, SubsonicError from src.api.models import Album, Artist, Playlist, Song, Genre from src.cache import CachedClient @@ -72,7 +71,6 @@ class MainWindow(QMainWindow): self._startupSyncInterval = timedelta(minutes=60) self.logger.info("MainWindow initialized") - self.pageStep = self.settings.get("interface", "pageStep", 5) self.volume = self.settings.get("playback", "volume", 100) self.shuffleEnabled = self.settings.get("playback", "shuffle", False) self.repeatMode = self.settings.get("playback", "repeat", "none") @@ -248,17 +246,14 @@ class MainWindow(QMainWindow): self.libraryStatus = QLabel("Connect to a server to browse your library") self.libraryStatus.setAccessibleName("Library Status") + self.libraryStatus.setWordWrap(True) leftLayout.addWidget(self.libraryStatus) - self.libraryTree = AccessibleTreeWidget() - self.libraryTree.setHeaderHidden(True) - self.libraryTree.setAccessibleName("Library Tree") - self.libraryTree.setRootIsDecorated(True) - self.libraryTree.setItemsExpandable(True) - self.libraryTree.setContextMenuPolicy(Qt.CustomContextMenu) - self.libraryTree.pageStep = self.pageStep - self.libraryTree.setEnabled(False) - leftLayout.addWidget(self.libraryTree) + self.libraryBrowser = LibraryBrowser( + lambda *args, **kwargs: self._run_background(*args, **kwargs), + self.playback.currentSong, self) + self.libraryBrowser.setEnabled(False) + leftLayout.addWidget(self.libraryBrowser) self.splitter.addWidget(leftPanel) @@ -362,9 +357,8 @@ class MainWindow(QMainWindow): def connectSignals(self): """Connect signals after widgets exist.""" - self.libraryTree.itemExpanded.connect(self.onLibraryExpanded) - self.libraryTree.itemActivated.connect(self.onLibraryActivated) - self.libraryTree.customContextMenuRequested.connect(self.onLibraryContextMenu) + self.libraryBrowser.playRequested.connect(self.playLibrarySelection) + self.libraryBrowser.menuRequested.connect(self.onLibraryContextMenu) self.playButton.clicked.connect(self.playPauseAction.trigger) self.prevButton.clicked.connect(self.previousAction.trigger) @@ -524,6 +518,8 @@ class MainWindow(QMainWindow): def _on_connection_success(self, serverName: str, rawClient: SubsonicClient): """Handle successful connection on the UI thread.""" self.logger.info("Connected to %s, starting connected flow", serverName) + self.libraryBrowser.clear() + self.libraryBrowser.setEnabled(False) self._rawClient = rawClient # Wrap with caching layer @@ -550,7 +546,7 @@ class MainWindow(QMainWindow): self.refreshAction.setEnabled(True) self.fullSyncAction.setEnabled(True) self.searchAction.setEnabled(True) - self.libraryTree.setEnabled(True) + self.libraryBrowser.setEnabled(True) # Load from cache first if available, then sync in background if self.client and self.client.hasCache(): @@ -598,8 +594,8 @@ class MainWindow(QMainWindow): self.refreshAction.setEnabled(False) self.fullSyncAction.setEnabled(False) self.searchAction.setEnabled(False) - self.libraryTree.clear() - self.libraryTree.setEnabled(False) + self.libraryBrowser.clear() + self.libraryBrowser.setEnabled(False) self.libraryStatus.setText("Full sync in progress...") self._startStatusAnnouncements("Full library sync in progress") self.logger.info("Refreshing library (full sync)...") @@ -725,62 +721,19 @@ class MainWindow(QMainWindow): } def _applyLibraryData(self, data: Dict[str, Any]): - """Populate the library tree with fetched data on the UI thread.""" + """Replace the library tables with a new snapshot on the UI thread.""" if not self.client: return - self.logger.info("Library data fetched; building tree") - - self.libraryTree.clear() - - # Root sections - self.artistsRoot = QTreeWidgetItem(["Artists"]) - self.artistsRoot.setData(0, Qt.UserRole, {"type": "artists_root", "loaded": True}) - self.libraryTree.addTopLevelItem(self.artistsRoot) - self.artistsRoot.setExpanded(True) - - self.favoritesRoot = QTreeWidgetItem(["Favorites"]) - self.favoritesRoot.setData(0, Qt.UserRole, {"type": "favorites_root", "loaded": True}) - self.libraryTree.addTopLevelItem(self.favoritesRoot) - self.favoritesRoot.setExpanded(True) - self._populateFavorites(data.get("favorites", {})) - - self.discoverRoot = QTreeWidgetItem(["Discover"]) - self.discoverRoot.setData(0, Qt.UserRole, {"type": "discover_root", "loaded": True}) - self.libraryTree.addTopLevelItem(self.discoverRoot) - self.discoverRoot.setExpanded(True) - self.loadDiscoverSections() - - self.playlistsRoot = QTreeWidgetItem(["Playlists"]) - self.playlistsRoot.setData(0, Qt.UserRole, {"type": "playlists_root", "loaded": True}) - self.libraryTree.addTopLevelItem(self.playlistsRoot) - self.playlistsRoot.setExpanded(True) - - self.genresRoot = QTreeWidgetItem(["Genres"]) - self.genresRoot.setData(0, Qt.UserRole, {"type": "genres_root", "loaded": True}) - self.libraryTree.addTopLevelItem(self.genresRoot) - self.genresRoot.setExpanded(True) - - self._populateArtists(data.get("artists", [])) - self._populatePlaylists(data.get("playlists", [])) - self._populateGenres(data.get("genres", [])) - - self.libraryStatus.setText("Library ready. Use the tree to browse and press Enter to play.") - self.libraryTree.setEnabled(True) - if self.artistsRoot and self.artistsRoot.childCount() > 0: - self.libraryTree.setCurrentItem(self.artistsRoot) - self.libraryTree.setFocus(Qt.OtherFocusReason) + self.libraryBrowser.setLibrary(self.client, data) + self.libraryBrowser.setEnabled(True) + self.libraryStatus.setText( + "Choose a tab. Enter opens collections or plays songs; Backspace goes back. " + "Use Play selected or Shift+F10 for collection playback.") self.refreshAction.setEnabled(True) self.fullSyncAction.setEnabled(True) self.searchAction.setEnabled(True) self._stopStatusAnnouncements() self.announceLive("Library ready") - self.logger.info("Library ready with sections: artists=%d playlists=%d genres=%d", - self.artistsRoot.childCount(), - self.playlistsRoot.childCount(), - self.genresRoot.childCount()) - - if all(root.childCount() == 0 for root in (self.artistsRoot, self.playlistsRoot, self.genresRoot)): - self.libraryStatus.setText("No library data returned from server.") def _handleLibraryLoadError(self, message: str): """Show error when library fails to load.""" @@ -790,232 +743,64 @@ class MainWindow(QMainWindow): self.refreshAction.setEnabled(True) self.fullSyncAction.setEnabled(True) self.searchAction.setEnabled(bool(self.client)) - self.libraryTree.setEnabled(True) + self.libraryBrowser.setEnabled(True) self.logger.error("Library load failed: %s", message) - def _populateArtists(self, artists: List[Artist]): - """Populate top-level artists from fetched data.""" - for artist in artists: - item = QTreeWidgetItem([artist.name]) - description = f"{artist.albumCount} albums" if artist.albumCount else "Artist" - item.setData(0, Qt.UserRole, {"type": "artist", "id": artist.id, "artist": artist, "loaded": False}) - item.setData(0, Qt.AccessibleTextRole, f"{artist.name}, {description}") - item.addChild(QTreeWidgetItem(["Loading albums..."])) - self.artistsRoot.addChild(item) - - def _populatePlaylists(self, playlists: List[Playlist]): - """Populate top-level playlists from fetched data.""" - for playlist in playlists: - label = f"{playlist.name} ({playlist.songCount} tracks)" - item = QTreeWidgetItem([label]) - item.setData(0, Qt.UserRole, {"type": "playlist", "id": playlist.id, "playlist": playlist, "loaded": False}) - item.setData(0, Qt.AccessibleTextRole, label) - item.addChild(QTreeWidgetItem(["Loading tracks..."])) - self.playlistsRoot.addChild(item) - - def _populateGenres(self, genres: List[Genre]): - """Populate top-level genres from fetched data.""" - for genre in genres: - label = f"{genre.name} ({genre.songCount} songs)" - item = QTreeWidgetItem([label]) - item.setData(0, Qt.UserRole, {"type": "genre", "name": genre.name, "loaded": False}) - item.setData(0, Qt.AccessibleTextRole, label) - item.addChild(QTreeWidgetItem(["Loading songs..."])) - self.genresRoot.addChild(item) - - def _populateFavorites(self, starred: Dict[str, List[Song]]): - """Populate favorites with pre-fetched starred songs.""" - songs: List[Song] = starred.get("songs", []) if starred else [] - self.favoritesRoot.takeChildren() - if not songs: - self.favoritesRoot.addChild(QTreeWidgetItem(["No favorites yet."])) - return - for song in songs: - label = self.formatSongLabel(song, includeAlbum=True) - item = QTreeWidgetItem([label]) - item.setData(0, Qt.UserRole, {"type": "song", "song": song}) - item.setData(0, Qt.AccessibleTextRole, label) - self.favoritesRoot.addChild(item) - - def loadDiscoverSections(self): - """Create discover sections for album lists and recommendations.""" - sections = [ - ("Recently Added", "newest"), - ("Frequently Played", "frequent"), - ("Recently Played", "recent"), - ("Random Albums", "random"), - ] - - for label, listType in sections: - item = QTreeWidgetItem([label]) - item.setData(0, Qt.UserRole, {"type": "album_list", "listType": listType, "loaded": False}) - item.setData(0, Qt.AccessibleTextRole, label) - item.addChild(QTreeWidgetItem(["Loading albums..."])) - self.discoverRoot.addChild(item) - - nowPlaying = QTreeWidgetItem(["Now Playing (server)"]) - nowPlaying.setData(0, Qt.UserRole, {"type": "now_playing", "loaded": False}) - nowPlaying.setData(0, Qt.AccessibleTextRole, "Now Playing across users") - nowPlaying.addChild(QTreeWidgetItem(["Loading songs..."])) - self.discoverRoot.addChild(nowPlaying) - - similarCurrent = QTreeWidgetItem(["Similar to Current Track"]) - similarCurrent.setData(0, Qt.UserRole, {"type": "similar_current", "loaded": False}) - similarCurrent.setData(0, Qt.AccessibleTextRole, "Songs similar to the current track") - similarCurrent.addChild(QTreeWidgetItem(["Load to view similar tracks..."])) - self.discoverRoot.addChild(similarCurrent) - - def onLibraryExpanded(self, item: QTreeWidgetItem): - """Lazy-load children when an item is expanded.""" - data: Dict = item.data(0, Qt.UserRole) or {} - if data.get("loaded"): - return - itemType = data.get("type") - self._setLoadingPlaceholder(item, "Loading...") - - if itemType == "artist": - self._run_background( - "load artist albums", - lambda: self._fetchArtistAlbums(data.get("id")), - on_success=lambda albums: self._applyArtistAlbums(item, albums), - on_error=lambda err: self._handleItemLoadError(item, str(err)) - ) - elif itemType == "album": - self._run_background( - "load album songs", - lambda: self._fetchAlbumSongs(data.get("id")), - on_success=lambda songs: self._applyAlbumSongs(item, songs), - on_error=lambda err: self._handleItemLoadError(item, str(err)) - ) - elif itemType == "playlist": - self._run_background( - "load playlist songs", - lambda: self._fetchPlaylistSongs(data.get("id")), - on_success=lambda playlist: self._applyPlaylistSongs(item, playlist), - on_error=lambda err: self._handleItemLoadError(item, str(err)) - ) - elif itemType == "genre": - self._run_background( - "load genre songs", - lambda: self._fetchGenreSongs(data.get("name")), - on_success=lambda songs: self._applyGenreSongs(item, songs), - on_error=lambda err: self._handleItemLoadError(item, str(err)) - ) - elif itemType == "album_list": - self._run_background( - "load discover section", - lambda: self._fetchAlbumList(data.get("listType", "newest")), - on_success=lambda albums: self._applyAlbumListSection(item, albums), - on_error=lambda err: self._handleItemLoadError(item, str(err)) - ) - elif itemType == "now_playing": - self._run_background( - "load now playing", - self._fetchNowPlaying, - on_success=lambda songs: self._applyNowPlaying(item, songs), - on_error=lambda err: self._handleItemLoadError(item, str(err)) - ) - elif itemType == "similar_current": - self._run_background( - "load similar songs", - self._fetchSimilarToCurrent, - on_success=lambda songs: self._applySimilarToCurrent(item, songs), - on_error=lambda err: self._handleItemLoadError(item, str(err)) - ) - - def onLibraryActivated(self, item: QTreeWidgetItem): - """Handle activation (Enter/Return) in the library tree.""" - data: Dict = item.data(0, Qt.UserRole) or {} + def playLibrarySelection(self, data: Dict): + """Play a table selection without changing the browser's location.""" itemType = data.get("type") if itemType == "song": - song: Song = data["song"] + song = data["song"] self.playback.enqueue(song, playNow=True) self.statusBar.showMessage(f"Playing {song.title} by {song.artist}", 4000) self.enablePlaybackActions(True) - elif itemType == "playlist": - self.playPlaylist(data.get("id"), item) elif itemType == "album": - self.playAlbum(data.get("id"), item) + self.playAlbum(data.get("id")) elif itemType == "artist": - self.playArtist(data.get("id"), item) + self.playArtist(data.get("id")) + elif itemType == "playlist": + self.playPlaylist(data.get("id")) + elif itemType == "genre": + self.playGenre(data.get("name")) elif itemType == "artists_root": self.playAllArtists() elif itemType == "favorites_root": self.playFavorites() - elif itemType == "genre": - self.playGenre(data.get("name"), item) - elif itemType in ("album_list", "now_playing", "similar_current"): - item.setExpanded(not item.isExpanded()) - else: - # Toggle expansion for other nodes - item.setExpanded(not item.isExpanded()) - def onLibraryContextMenu(self, position: QPoint): - """Show a context menu for the library tree (mouse or keyboard).""" - item = self.libraryTree.itemAt(position) - if not item: + def onLibraryContextMenu(self, data: Dict, globalPos: QPoint): + """Offer actions for a snapshot of the selected row or collection.""" + if not data or not self.client: return - - # Ensure actions target the item the user invoked the menu on - self.libraryTree.setCurrentItem(item) - - data: Dict = item.data(0, Qt.UserRole) or {} - itemType = data.get("type") - hasClient = self.client is not None - menu = QMenu(self) menu.setAccessibleName("Library Context Menu") + itemType = data.get("type") + if itemType in ("album", "artist", "playlist", "genre", "album_list", + "now_playing", "similar_current"): + menu.addAction("Open", lambda: self.libraryBrowser.currentWidget().openData(data)) + if itemType in ("song", "album", "artist", "playlist", "genre", + "artists_root", "favorites_root"): + menu.addAction("Play", lambda: self.playLibrarySelection(data)) + menu.addAction("Add to Queue", lambda: self.addItemToQueue(data)) + menu.addAction("Add to Playlist", lambda: self.addItemToPlaylist(data)) + if itemType in ("song", "album", "artist"): + menu.addSeparator() + menu.addAction("Favorite", lambda: self._favoriteLibraryData(data, True)) + menu.addAction("Unfavorite", lambda: self._favoriteLibraryData(data, False)) + if menu.actions(): + menu.exec(globalPos) - if itemType == "song": - song: Optional[Song] = data.get("song") - menu.addAction("Play", lambda _=False, song=song: self.playback.enqueue(song, playNow=True) if song else None).setEnabled(hasClient and song is not None) - menu.addAction("Add to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient and song is not None) - menu.addAction("Add to Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient and song is not None) - menu.addSeparator() - menu.addAction("Favorite", self.favoriteSelection).setEnabled(hasClient) - menu.addAction("Unfavorite", self.unfavoriteSelection).setEnabled(hasClient) - elif itemType == "album": - menu.addAction("Play Album", lambda _=False, item=item, data=data: self.playAlbum(data.get("id"), item)).setEnabled(hasClient) - menu.addAction("Add Album to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient) - menu.addAction("Add Album to Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient) - menu.addSeparator() - menu.addAction("Favorite Album", self.favoriteSelection).setEnabled(hasClient) - menu.addAction("Unfavorite Album", self.unfavoriteSelection).setEnabled(hasClient) - elif itemType == "artist": - menu.addAction("Play Artist", lambda _=False, item=item, data=data: self.playArtist(data.get("id"), item)).setEnabled(hasClient) - menu.addAction("Add Artist to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient) - menu.addAction("Add Artist to Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient) - menu.addSeparator() - menu.addAction("Favorite Artist", self.favoriteSelection).setEnabled(hasClient) - menu.addAction("Unfavorite Artist", self.unfavoriteSelection).setEnabled(hasClient) - elif itemType == "playlist": - menu.addAction("Play Playlist", lambda _=False, item=item, data=data: self.playPlaylist(data.get("id"), item)).setEnabled(hasClient) - menu.addAction("Add Playlist to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient) - menu.addAction("Add Playlist to Another Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient) - elif itemType == "genre": - menu.addAction("Play Genre", lambda _=False, item=item, data=data: self.playGenre(data.get("name"), item)).setEnabled(hasClient) - menu.addAction("Add Genre to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient) - menu.addAction("Add Genre to Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient) - elif itemType == "artists_root": - menu.addAction("Play All Artists", self.playAllArtists).setEnabled(hasClient) - menu.addAction("Add All Artists to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient) - menu.addAction("Add All Artists to Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient) - elif itemType == "favorites_root": - menu.addAction("Play Favorites", self.playFavorites).setEnabled(hasClient) - menu.addAction("Add Favorites to Queue", lambda _=False, item=item: self.addItemToQueue(item)).setEnabled(hasClient) - menu.addAction("Add Favorites to Playlist", lambda _=False, item=item: self.addItemToPlaylist(item)).setEnabled(hasClient) - - if not menu.actions(): + def _favoriteLibraryData(self, data: Dict, favorite: bool): + """Apply menu actions to their original target even if selection changes.""" + action = "favorite" if favorite else "unfavorite" + label = data.get("label", "selection") + if data["type"] != "song" and not self._confirmBulkAction(action, label): 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) + try: + method = self.client.star if favorite else self.client.unstar + method(data["id"], data["type"]) + self.announceLive(self.buildFavoriteAnnouncement(action, data.get("song"), label)) + except Exception: + AccessibleTextDialog.showError("Favorite Error", "Could not update favorites.", parent=self) def showCurrentMediaContextMenu(self): """Open a context menu for the currently playing song (Alt+I).""" @@ -1029,13 +814,9 @@ class MainWindow(QMainWindow): 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 + if self.libraryBrowser.selectedData(): + self.libraryBrowser.currentWidget().selectedMenu() + return row = self.queueList.currentRow() if 0 <= row < len(self.playback.queue): @@ -1093,7 +874,7 @@ class MainWindow(QMainWindow): self._collectPlaylistsAndShowDialog([song], label, truncated=False) def _favoriteSongDirect(self, song: Song): - """Star a song without relying on tree selection.""" + """Star a song without relying on browser selection.""" if not self.client: self.announceLive("Not connected to a server.") return @@ -1108,7 +889,7 @@ class MainWindow(QMainWindow): AccessibleTextDialog.showError("Favorite Failed", str(exc), parent=self) def _unfavoriteSongDirect(self, song: Song): - """Unstar a song without relying on tree selection.""" + """Unstar a song without relying on browser selection.""" if not self.client: self.announceLive("Not connected to a server.") return @@ -1138,128 +919,6 @@ class MainWindow(QMainWindow): ) 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) - return result.get("albums", []) - - def _applyArtistAlbums(self, parentItem: QTreeWidgetItem, albums: List[Album]): - """Add albums for a given artist on the UI thread.""" - parentItem.takeChildren() - for album in albums: - label = f"{album.name} ({album.songCount} tracks)" - albumItem = QTreeWidgetItem([label]) - albumItem.setData(0, Qt.UserRole, {"type": "album", "id": album.id, "album": album, "loaded": False}) - albumItem.setData(0, Qt.AccessibleTextRole, label) - albumItem.addChild(QTreeWidgetItem(["Loading tracks..."])) - parentItem.addChild(albumItem) - self._markItemLoaded(parentItem) - - def _fetchAlbumSongs(self, albumId: str) -> List[Song]: - """Fetch songs for a given album off the UI thread.""" - result = self.client.getAlbum(albumId) - return result.get("songs", []) - - def _applyAlbumSongs(self, parentItem: QTreeWidgetItem, songs: List[Song]): - """Add songs for a given album on the UI thread.""" - parentItem.takeChildren() - for song in songs: - label = self.formatSongLabel(song) - songItem = QTreeWidgetItem([label]) - songItem.setData(0, Qt.UserRole, {"type": "song", "song": song}) - songItem.setData(0, Qt.AccessibleTextRole, label) - parentItem.addChild(songItem) - self._markItemLoaded(parentItem) - - def _fetchPlaylistSongs(self, playlistId: str) -> Playlist: - """Fetch playlist with songs off the UI thread.""" - return self.client.getPlaylist(playlistId) - - def _applyPlaylistSongs(self, parentItem: QTreeWidgetItem, playlist: Playlist): - """Add songs for a playlist on the UI thread.""" - parentItem.takeChildren() - for song in playlist.songs: - label = self.formatSongLabel(song, includeAlbum=True) - songItem = QTreeWidgetItem([label]) - songItem.setData(0, Qt.UserRole, {"type": "song", "song": song}) - songItem.setData(0, Qt.AccessibleTextRole, label) - parentItem.addChild(songItem) - parentItem.setData(0, Qt.UserRole, {"type": "playlist", "id": playlist.id, "playlist": playlist, "loaded": True}) - - def _fetchGenreSongs(self, genreName: str) -> List[Song]: - """Fetch songs for a genre off the UI thread.""" - return self.client.getSongsByGenre(genreName, count=200) - - def _applyGenreSongs(self, parentItem: QTreeWidgetItem, songs: List[Song]): - """Add songs for a genre on the UI thread.""" - parentItem.takeChildren() - for song in songs: - label = self.formatSongLabel(song, includeAlbum=True) - songItem = QTreeWidgetItem([label]) - songItem.setData(0, Qt.UserRole, {"type": "song", "song": song}) - songItem.setData(0, Qt.AccessibleTextRole, label) - parentItem.addChild(songItem) - self._markItemLoaded(parentItem) - - def _fetchAlbumList(self, listType: str) -> List[Album]: - """Fetch album list for discover section off the UI thread.""" - return self.client.getAlbumList(listType, size=40) - - def _applyAlbumListSection(self, parentItem: QTreeWidgetItem, albums: List[Album]): - """Populate a discover album list section on the UI thread.""" - parentItem.takeChildren() - for album in albums: - label = f"{album.name} — {album.artist} ({album.songCount} tracks)" - albumItem = QTreeWidgetItem([label]) - albumItem.setData(0, Qt.UserRole, {"type": "album", "id": album.id, "album": album, "loaded": False}) - albumItem.setData(0, Qt.AccessibleTextRole, label) - albumItem.addChild(QTreeWidgetItem(["Loading tracks..."])) - parentItem.addChild(albumItem) - self._markItemLoaded(parentItem) - - def _fetchNowPlaying(self) -> List[Song]: - """Fetch songs currently playing across users off the UI thread.""" - return self.client.getNowPlaying() - - def _applyNowPlaying(self, parentItem: QTreeWidgetItem, songs: List[Song]): - """Populate songs currently playing across users on the UI thread.""" - parentItem.takeChildren() - if not songs: - parentItem.addChild(QTreeWidgetItem(["Nothing is currently playing."])) - return - for song in songs: - label = self.formatSongLabel(song, includeAlbum=True) - songItem = QTreeWidgetItem([label]) - songItem.setData(0, Qt.UserRole, {"type": "song", "song": song}) - songItem.setData(0, Qt.AccessibleTextRole, label) - parentItem.addChild(songItem) - self._markItemLoaded(parentItem) - - def _fetchSimilarToCurrent(self) -> List[Song]: - """Fetch songs similar to the current track off the UI thread.""" - current = self.playback.currentSong() - if not current: - return [] - return self.client.getSimilarSongs(current.id, count=40) - - def _applySimilarToCurrent(self, parentItem: QTreeWidgetItem, songs: List[Song]): - """Populate songs similar to the current track on the UI thread.""" - parentItem.takeChildren() - current = self.playback.currentSong() - if not current: - parentItem.addChild(QTreeWidgetItem(["Play a track to see similar songs."])) - return - if not songs: - parentItem.addChild(QTreeWidgetItem([f"No similar tracks found for {current.title}."])) - return - for song in songs: - label = self.formatSongLabel(song, includeAlbum=True) - songItem = QTreeWidgetItem([label]) - songItem.setData(0, Qt.UserRole, {"type": "song", "song": song}) - songItem.setData(0, Qt.AccessibleTextRole, label) - parentItem.addChild(songItem) - self._markItemLoaded(parentItem) - @staticmethod def formatSongLabel(song: Song, includeAlbum: bool = False) -> str: """Create a descriptive label for a song.""" @@ -1270,39 +929,12 @@ class MainWindow(QMainWindow): details = f"{details} — {song.album}" return f"{core} ({song.durationFormatted}) — {details}" - def _populateAlbumItemWithSongs(self, parentItem: QTreeWidgetItem, songs: List[Song]): - """Populate an album tree item with song children.""" - parentItem.takeChildren() - for song in songs: - label = self.formatSongLabel(song) - child = QTreeWidgetItem([label]) - child.setData(0, Qt.UserRole, {"type": "song", "song": song}) - child.setData(0, Qt.AccessibleTextRole, label) - parentItem.addChild(child) - data: Dict = parentItem.data(0, Qt.UserRole) or {} - data["loaded"] = True - parentItem.setData(0, Qt.UserRole, data) - - def _populateGenreItemWithSongs(self, parentItem: QTreeWidgetItem, songs: List[Song]): - """Populate a genre item with the provided songs.""" - parentItem.takeChildren() - for song in songs: - label = self.formatSongLabel(song, includeAlbum=True) - child = QTreeWidgetItem([label]) - child.setData(0, Qt.UserRole, {"type": "song", "song": song}) - child.setData(0, Qt.AccessibleTextRole, label) - parentItem.addChild(child) - data: Dict = parentItem.data(0, Qt.UserRole) or {} - data["loaded"] = True - parentItem.setData(0, Qt.UserRole, data) - - def addItemToQueue(self, item: QTreeWidgetItem): + def addItemToQueue(self, data: Dict): """Append the item's songs to the queue without interrupting playback.""" if not self.client: self.announceLive("Not connected to a server.") return - data: Dict = item.data(0, Qt.UserRole) or {} itemType = data.get("type") if itemType == "song": @@ -1318,110 +950,105 @@ class MainWindow(QMainWindow): self.statusBar.showMessage("Collecting songs...", 2000) self._run_background( "collect songs for queue", - lambda: self.collectSongsForItem(item), + lambda: self.collectSongsForItem(data), on_success=self._onSongsCollectedForQueue, on_error=lambda err: self._handleCollectionError(str(err)) ) - def addItemToPlaylist(self, item: QTreeWidgetItem): + def addItemToPlaylist(self, data: Dict): """Append the item's songs to an existing or new playlist.""" if not self.client: self.announceLive("Not connected to a server.") return - data: Dict = item.data(0, Qt.UserRole) or {} itemType = data.get("type") if itemType == "song": song: Optional[Song] = data.get("song") songs = [song] if song else [] - label = self.formatSongLabel(song, includeAlbum=True) if song else item.text(0) + label = self.formatSongLabel(song, includeAlbum=True) if song else data.get("label", "selection") self._collectPlaylistsAndShowDialog(songs, label, truncated=False) return self.statusBar.showMessage("Collecting songs...", 2000) self._run_background( "collect songs for playlist", - lambda: self.collectSongsForItem(item), + lambda: self.collectSongsForItem(data), on_success=self._onSongsCollectedForPlaylist, on_error=lambda err: self._handleCollectionError(str(err)) ) - def collectSongsForItem(self, item: QTreeWidgetItem) -> Tuple[List[Song], str, bool]: - """Resolve a tree item into a list of songs for queueing or playlist actions.""" + def collectSongsForItem(self, data: Dict) -> Tuple[List[Song], str, bool]: + """Resolve a media payload into a list of songs for queueing or playlist actions.""" if self._shutting_down: return [], "", False - data: Dict = item.data(0, Qt.UserRole) or {} itemType = data.get("type") - label = item.text(0) + label = data.get("label", "selection") truncated = False request_count = 0 if not self.client: return [], label, truncated - try: - if itemType == "song": - song: Optional[Song] = data.get("song") - return ([song] if song else []), (self.formatSongLabel(song, includeAlbum=True) if song else label), truncated - if itemType == "album": - albumData = self.client.getAlbum(data.get("id")) - album: Optional[Album] = albumData.get("album") - songs: List[Song] = albumData.get("songs", []) - return songs, (album.name if album else label), truncated - if itemType == "artist": + if itemType == "song": + song: Optional[Song] = data.get("song") + return ([song] if song else []), (self.formatSongLabel(song, includeAlbum=True) if song else label), truncated + if itemType == "album": + albumData = self.client.getAlbum(data.get("id")) + album: Optional[Album] = albumData.get("album") + songs: List[Song] = albumData.get("songs", []) + return songs, (album.name if album else label), truncated + if itemType == "artist": + if self._shutting_down: + return [], label, truncated + artistData = self.client.getArtist(data.get("id")) + artist: Optional[Artist] = artistData.get("artist") + albums: List[Album] = artistData.get("albums", []) + songs: List[Song] = [] + for album in albums: if self._shutting_down: - return [], label, truncated - artistData = self.client.getArtist(data.get("id")) - artist: Optional[Artist] = artistData.get("artist") - albums: List[Album] = artistData.get("albums", []) - songs: List[Song] = [] - for album in albums: + break + albumData = self.client.getAlbum(album.id) + songs.extend(albumData.get("songs", [])) + request_count += 1 + self._throttle_requests(request_count) + return songs, (artist.name if artist else label), truncated + if itemType == "playlist": + playlist: Playlist = self.client.getPlaylist(data.get("id")) + return playlist.songs, playlist.name, truncated + if itemType == "genre": + genreName = data.get("name") + songs, truncated = self._fetchGenreSongsForPlay(genreName) + return songs, (genreName or label), truncated + if itemType == "artists_root": + if self._shutting_down: + return [], "all artists", truncated + artists: List[Artist] = self.client.getArtists() + songs: List[Song] = [] + for artist in artists: + if self._shutting_down: + break + artistData = self.client.getArtist(artist.id) + for album in artistData.get("albums", []): if self._shutting_down: break albumData = self.client.getAlbum(album.id) songs.extend(albumData.get("songs", [])) request_count += 1 self._throttle_requests(request_count) - return songs, (artist.name if artist else label), truncated - if itemType == "playlist": - playlist: Playlist = self.client.getPlaylist(data.get("id")) - return playlist.songs, playlist.name, truncated - if itemType == "genre": - genreName = data.get("name") - songs = self.client.getSongsByGenre(genreName) - return songs, (genreName or label), truncated - if itemType == "artists_root": - if self._shutting_down: - return [], "all artists", truncated - artists: List[Artist] = self.client.getArtists() - songs: List[Song] = [] - for artist in artists: - if self._shutting_down: - break - artistData = self.client.getArtist(artist.id) - for album in artistData.get("albums", []): - if self._shutting_down: - break - albumData = self.client.getAlbum(album.id) - songs.extend(albumData.get("songs", [])) - request_count += 1 - self._throttle_requests(request_count) - return songs, "all artists", truncated - if itemType == "favorites_root": - starred = self.client.getStarred() - songs = starred.get("songs", []) - return songs, "favorites", truncated - if itemType == "album_list": - # These nodes contain album children; we don't aggregate here - return [], label, truncated - if itemType == "now_playing": - songs = self.client.getNowPlaying() - return songs, "now playing", truncated - if itemType == "similar_current": - current = self.playback.currentSong() - songs = self.client.getSimilarSongs(current.id, count=40) if current else [] - return songs, "similar tracks", truncated - except Exception as e: - AccessibleTextDialog.showError("Queue Error", str(e), parent=self) + return songs, "all artists", truncated + if itemType == "favorites_root": + starred = self.client.getStarred() + songs = starred.get("songs", []) + return songs, "favorites", truncated + if itemType == "album_list": + # These nodes contain album children; we don't aggregate here + return [], label, truncated + if itemType == "now_playing": + songs = self.client.getNowPlaying() + return songs, "now playing", truncated + if itemType == "similar_current": + current = self.playback.currentSong() + songs = self.client.getSimilarSongs(current.id, count=40) if current else [] + return songs, "similar tracks", truncated return [], label, truncated def _onSongsCollectedForQueue(self, result: Tuple[List[Song], str, bool]): @@ -1569,7 +1196,7 @@ class MainWindow(QMainWindow): self.statusBar.showMessage(f"Playing {label}", 4000) self.enablePlaybackActions(True) - def playAlbum(self, albumId: str, item: Optional[QTreeWidgetItem] = None): + def playAlbum(self, albumId: str): """Play all songs in an album.""" if not self.client or not albumId: self.announceLive("No album selected.") @@ -1578,11 +1205,11 @@ class MainWindow(QMainWindow): self._run_background( "play album", lambda: self._fetchAlbumWithSongs(albumId), - on_success=lambda data: self._onAlbumLoadedForPlay(albumId, item, data), + on_success=lambda data: self._onAlbumLoadedForPlay(albumId, data), on_error=lambda err: AccessibleTextDialog.showError("Album Error", str(err), parent=self) ) - def playArtist(self, artistId: str, item: Optional[QTreeWidgetItem] = None): + def playArtist(self, artistId: str): """Play all songs by an artist across their albums.""" if not self.client or not artistId: self.announceLive("No artist selected.") @@ -1591,7 +1218,7 @@ class MainWindow(QMainWindow): self._run_background( "play artist", lambda: self._fetchArtistSongs(artistId), - on_success=lambda payload: self._onArtistLoadedForPlay(item, payload), + on_success=lambda payload: self._onArtistLoadedForPlay(payload), on_error=lambda err: AccessibleTextDialog.showError("Artist Error", str(err), parent=self) ) @@ -1608,7 +1235,7 @@ class MainWindow(QMainWindow): on_error=lambda err: AccessibleTextDialog.showError("Library Error", str(err), parent=self) ) - def playGenre(self, genreName: str, item: Optional[QTreeWidgetItem] = None): + def playGenre(self, genreName: str): """Play all songs within a genre.""" if not self.client or not genreName: self.announceLive("No genre selected.") @@ -1617,17 +1244,17 @@ class MainWindow(QMainWindow): self._run_background( "play genre", lambda: self._fetchGenreSongsForPlay(genreName), - on_success=lambda result: self._onGenreLoadedForPlay(genreName, item, result), + on_success=lambda result: self._onGenreLoadedForPlay(genreName, result), on_error=lambda err: AccessibleTextDialog.showError("Genre Error", str(err), parent=self) ) - def playPlaylist(self, playlistId: str, item: Optional[QTreeWidgetItem] = None): + def playPlaylist(self, playlistId: str): """Load a playlist and start playback.""" self.statusBar.showMessage("Loading playlist...", 2000) self._run_background( "play playlist", lambda: self.client.getPlaylist(playlistId), - on_success=lambda playlist: self._onPlaylistLoadedForPlay(item, playlist), + on_success=lambda playlist: self._onPlaylistLoadedForPlay(playlist), on_error=lambda err: AccessibleTextDialog.showError("Playlist Error", str(err), parent=self) ) @@ -1651,12 +1278,10 @@ class MainWindow(QMainWindow): albumData = self.client.getAlbum(albumId) return albumData.get("album"), albumData.get("songs", []) - def _onAlbumLoadedForPlay(self, albumId: str, item: Optional[QTreeWidgetItem], data: Tuple[Optional[Album], List[Song]]): - """Start playback for a loaded album and populate UI if needed.""" + def _onAlbumLoadedForPlay(self, albumId: str, data: Tuple[Optional[Album], List[Song]]): + """Start playback for a loaded album without changing library navigation.""" album, songs = data self.startPlaybackWithSongs(songs, f"album {album.name if album else albumId}") - if item and songs: - self._populateAlbumItemWithSongs(item, songs) def _fetchArtistSongs(self, artistId: str) -> Tuple[Optional[Artist], List[Song]]: """Fetch all songs for an artist across albums with throttling.""" @@ -1676,8 +1301,8 @@ class MainWindow(QMainWindow): self._throttle_requests(request_count) return artist, songs - def _onArtistLoadedForPlay(self, item: Optional[QTreeWidgetItem], payload: Tuple[Optional[Artist], List[Song]]): - """Start playback without changing the user's tree expansion state.""" + def _onArtistLoadedForPlay(self, payload: Tuple[Optional[Artist], List[Song]]): + """Start playback without changing library navigation.""" artist, songs = payload label = artist.name if artist else "artist" self.startPlaybackWithSongs(songs, label) @@ -1735,18 +1360,23 @@ class MainWindow(QMainWindow): def _fetchGenreSongsForPlay(self, genreName: str) -> Tuple[List[Song], bool]: """Fetch songs for a genre with throttling for playback.""" - songs = self.client.getSongsByGenre(genreName) + songs = [] + size = 200 + while not self._shutting_down: + batch = self.client.getSongsByGenre(genreName, count=size, offset=len(songs)) + songs.extend(batch) + if len(batch) < size: + break + self._throttle_requests(len(songs) // size) return songs, False - def _onGenreLoadedForPlay(self, genreName: str, item: Optional[QTreeWidgetItem], result: Tuple[List[Song], bool]): - """Start playback for a genre and populate UI if present.""" + def _onGenreLoadedForPlay(self, genreName: str, result: Tuple[List[Song], bool]): + """Start playback for a genre without changing library navigation.""" songs, truncated = result self.startPlaybackWithSongs(songs, f"genre {genreName}") - if item and songs: - self._populateGenreItemWithSongs(item, songs) - def _onPlaylistLoadedForPlay(self, item: Optional[QTreeWidgetItem], playlist: Playlist): - """Start playback for a playlist and populate UI if present.""" + def _onPlaylistLoadedForPlay(self, playlist: Playlist): + """Start playback for a playlist without changing library navigation.""" songs = playlist.songs if not songs: AccessibleTextDialog.showWarning("Playlist Empty", "This playlist has no tracks.", parent=self) @@ -1754,15 +1384,6 @@ class MainWindow(QMainWindow): self.playback.clearQueue() self.playback.enqueueMany(songs, playFirst=True) self.statusBar.showMessage(f"Playing playlist {playlist.name}", 4000) - if item: - item.takeChildren() - for song in songs: - label = self.formatSongLabel(song, includeAlbum=True) - child = QTreeWidgetItem([label]) - child.setData(0, Qt.UserRole, {"type": "song", "song": song}) - child.setData(0, Qt.AccessibleTextRole, label) - item.addChild(child) - item.setData(0, Qt.UserRole, {"type": "playlist", "id": playlist.id, "playlist": playlist, "loaded": True}) self.enablePlaybackActions(True) def onTrackChanged(self, song: Song): @@ -1936,22 +1557,6 @@ class MainWindow(QMainWindow): # ============ Helpers ============ - def _setLoadingPlaceholder(self, item: QTreeWidgetItem, text: str): - """Replace children with a single loading placeholder.""" - item.takeChildren() - item.addChild(QTreeWidgetItem([text])) - - def _markItemLoaded(self, item: QTreeWidgetItem): - """Mark a tree item as loaded in its user data.""" - data: Dict = item.data(0, Qt.UserRole) or {} - data["loaded"] = True - item.setData(0, Qt.UserRole, data) - - def _handleItemLoadError(self, item: QTreeWidgetItem, message: str): - """Show a load error and restore a placeholder child.""" - AccessibleTextDialog.showError("Library Error", f"Could not load content: {message}", parent=self) - self._setLoadingPlaceholder(item, "Failed to load.") - def _run_background( self, label: str, @@ -2020,10 +1625,10 @@ class MainWindow(QMainWindow): return f"{minutes:02d}:{seconds:02d}" def copyToClipboard(self): - """Allow AccessibleTreeWidget to copy the current item's text.""" - item = self.libraryTree.currentItem() - if item: - QGuiApplication.clipboard().setText(item.text(0)) + """Copy the selected library row's label.""" + data = self.libraryBrowser.selectedData() + if data: + QGuiApplication.clipboard().setText(data["label"]) def buildTrackAnnouncement(self, song: Optional[Song]) -> str: """Construct a live announcement for the current track with fallbacks.""" @@ -2150,18 +1755,14 @@ class MainWindow(QMainWindow): def getCurrentMediaTarget(self): """Return (type, id, label, song) for the most relevant selection.""" - # Library tree selection - item = self.libraryTree.currentItem() - if item: - data: Dict = item.data(0, Qt.UserRole) or {} + data = self.libraryBrowser.selectedData() + if data: itemType = data.get("type") if itemType == "song": - song: Song = data["song"] - label = self.buildTrackAnnouncement(song) - return ("song", song.id, label, song) + song = data["song"] + return ("song", song.id, self.buildTrackAnnouncement(song), song) if itemType in ("album", "artist"): - label = item.text(0) - return (itemType, data.get("id"), label, None) + return (itemType, data.get("id"), data["label"], None) # Queue selection row = self.queueList.currentRow() @@ -2233,6 +1834,7 @@ class MainWindow(QMainWindow): """Handle window close""" # Signal background tasks to stop self._shutting_down = True + self.libraryBrowser.clear() # Signal cached client to stop any running sync if self.client: diff --git a/src/widgets/library_browser.py b/src/widgets/library_browser.py new file mode 100644 index 0000000..470606e --- /dev/null +++ b/src/widgets/library_browser.py @@ -0,0 +1,409 @@ +"""Flat, native Qt tables for browsing a music library without tree state.""" +from dataclasses import dataclass, field +from typing import Callable, Optional + +from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal +from PySide6.QtGui import QKeySequence, QGuiApplication, QShortcut +from PySide6.QtWidgets import ( + QAbstractItemView, QHBoxLayout, QHeaderView, QLabel, QPushButton, QTableView, + QTabWidget, QVBoxLayout, QWidget, +) + + +@dataclass +class LibraryRow: + values: tuple + data: dict + + +def media_rows(kind, items): + """Build display columns and independent action payloads from API objects.""" + rows = [] + for item in items: + if kind == 'artist': + values = (item.name, item.albumCount) + elif kind == 'album': + values = (item.name, item.artist, item.songCount, item.year or '') + elif kind == 'playlist': + values = (item.name, item.songCount) + elif kind == 'genre': + values = (item.name, item.songCount, item.albumCount) + else: + values = (item.title, item.artist, item.album, item.durationFormatted) + data = {'type': kind, 'label': values[0], kind: item} + if kind == 'genre': + data['name'] = item.name + else: + data['id'] = item.id + rows.append(LibraryRow(values, data)) + return rows + + +HEADERS = { + 'artist': ('Artist', 'Albums'), + 'album': ('Album', 'Artist', 'Tracks', 'Year'), + 'playlist': ('Playlist', 'Tracks'), + 'genre': ('Genre', 'Songs', 'Albums'), + 'song': ('Title', 'Artist', 'Album', 'Duration'), +} + + +class LibraryTableModel(QAbstractTableModel): + def __init__(self, parent=None): + super().__init__(parent) + self.headers = () + self.rows = [] + + def rowCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(self.rows) + + def columnCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(self.headers) + + def data(self, index, role=Qt.DisplayRole): + if index.isValid() and role in (Qt.DisplayRole, Qt.AccessibleTextRole, Qt.ToolTipRole): + return str(self.rows[index.row()].values[index.column()]) + return None + + def headerData(self, section, orientation, role=Qt.DisplayRole): + if role == Qt.DisplayRole: + return self.headers[section] if orientation == Qt.Horizontal else str(section + 1) + return None + + def replace(self, headers, rows): + self.beginResetModel() + self.headers, self.rows = headers, rows + self.endResetModel() + + +class LibraryTable(QTableView): + backRequested = Signal() + + def keyPressEvent(self, event): + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + if self.currentIndex().isValid(): + self.activated.emit(self.currentIndex()) + event.accept() + return + if event.key() == Qt.Key_Backspace: + self.backRequested.emit() + event.accept() + return + if event.key() == Qt.Key_Menu or ( + event.key() == Qt.Key_F10 and event.modifiers() & Qt.ShiftModifier): + if self.currentIndex().isValid(): + self.customContextMenuRequested.emit(self.visualRect(self.currentIndex()).center()) + event.accept() + return + if event.matches(QKeySequence.Copy): + row = self.currentIndex().row() + if row >= 0: + QGuiApplication.clipboard().setText( + '\t'.join(map(str, self.model().rows[row].values))) + event.accept() + return + super().keyPressEvent(event) + + +@dataclass +class ViewState: + title: str + headers: tuple + rows: list = field(default_factory=list) + context: dict = field(default_factory=dict) + loader: Optional[Callable] = None + loaded: bool = True + more: bool = False + selected: int = 0 + scroll: int = 0 + + +class BrowserPage(QWidget): + """A table and its navigation history. Requests never hold Qt row objects.""" + def __init__(self, browser, name): + super().__init__(browser) + self.browser = browser + self.name = name + self.history = [] + self.revision = 0 + self.loading = False + layout = QVBoxLayout(self) + self.heading = QLabel(name) + self.heading.setWordWrap(True) + layout.addWidget(self.heading) + bar = QHBoxLayout() + self.backButton = QPushButton('Back') + self.backButton.setAccessibleName('Back to previous library table') + self.backButton.clicked.connect(self.goBack) + bar.addWidget(self.backButton) + self.playButton = QPushButton('Play selected') + self.playButton.clicked.connect(self.playSelected) + bar.addWidget(self.playButton) + self.actionsButton = QPushButton('Actions') + self.actionsButton.setAccessibleName('Actions for selected library row') + 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) + 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) + self.table.setModel(self.model) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setTabKeyNavigation(False) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.table.setContextMenuPolicy(Qt.CustomContextMenu) + self.table.customContextMenuRequested.connect(self.contextMenu) + self.table.activated.connect(lambda index: self.openSelected()) + self.table.backRequested.connect(self.goBack) + self.table.selectionModel().selectionChanged.connect(self.updateButtons) + layout.addWidget(self.table) + self.status = QLabel() + self.status.setWordWrap(True) + layout.addWidget(self.status) + footer = QHBoxLayout() + self.moreButton = QPushButton('Load more') + self.moreButton.clicked.connect(lambda: self.load(append=True)) + footer.addWidget(self.moreButton) + self.retryButton = QPushButton('Retry') + self.retryButton.clicked.connect(lambda: self.load(append=bool(self.state.rows))) + footer.addWidget(self.retryButton) + footer.addStretch() + layout.addLayout(footer) + self.backShortcut = QShortcut(QKeySequence('Alt+Left'), self) + self.backShortcut.setContext(Qt.WidgetWithChildrenShortcut) + self.backShortcut.activated.connect(self.goBack) + + @property + def state(self): + return self.history[-1] + + def reset(self, state): + self.revision += 1 + self.loading = False + self.history = [state] + self.render() + + def selectedData(self): + index = self.table.currentIndex() + return dict(self.model.rows[index.row()].data) if index.isValid() else None + + 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) + + def render(self): + state = self.state + self.heading.setText(state.title) + self.table.setAccessibleName(f'{state.title} table') + self.model.replace(state.headers, state.rows) + if state.rows: + self.table.setCurrentIndex(self.model.index(min(state.selected, len(state.rows) - 1), 0)) + self.table.verticalScrollBar().setValue(state.scroll) + self.backButton.setEnabled(len(self.history) > 1) + self.collectionButton.setEnabled(state.context.get('type') in ( + 'artists_root', 'favorites_root', 'artist', 'album', 'playlist', 'genre')) + self.moreButton.setVisible(state.more) + 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.') + self.updateButtons() + + def remember(self): + self.state.selected = max(0, self.table.currentIndex().row()) + self.state.scroll = self.table.verticalScrollBar().value() + + def openSelected(self): + self.openData(self.selectedData()) + + def openData(self, data): + if not data: + return + if data['type'] == 'song': + self.browser.playRequested.emit(data) + return + state = self.browser.viewFor(data) + if state is None: + return + self.remember() + self.revision += 1 + self.loading = False + state.title = f'{self.state.title} / {state.title}' + self.history.append(state) + self.render() + self.load() + self.table.setFocus() + + def goBack(self): + if len(self.history) < 2: + return + self.revision += 1 + self.loading = False + self.history.pop() + self.render() + self.table.setFocus() + + def load(self, append=False): + if self.loading or not self.state.loader: + return + self.remember() + self.loading = True + self.revision += 1 + revision, state = self.revision, self.state + offset = len(state.rows) if append else 0 + self.moreButton.setEnabled(False) + self.retryButton.hide() + self.status.setText('Loading…') + + def apply(result): + if self.revision != revision: + return + rows, more = result + self.loading = False + state.rows = state.rows + rows if append else rows + state.loaded, state.more = True, more + self.render() + + def failed(error): + if self.revision != revision: + return + self.loading = False + self.status.setText('Could not load this table. Use Retry or Back.') + self.moreButton.setEnabled(True) + self.retryButton.show() + + # Capture the loader now: changing history must not change the request. + loader = state.loader + self.browser.runTask('browse library', lambda: loader(offset), + on_success=apply, on_error=failed) + + def playSelected(self): + data = self.selectedData() + if data: + self.browser.playRequested.emit(data) + + def contextMenu(self, position): + index = self.table.indexAt(position) + if index.isValid(): + self.table.setCurrentIndex(index) + self.browser.menuRequested.emit(self.selectedData(), + self.table.viewport().mapToGlobal(position)) + + def selectedMenu(self): + data = self.selectedData() + if data: + self.browser.menuRequested.emit(data, self.actionsButton.mapToGlobal( + self.actionsButton.rect().bottomLeft())) + + def collectionMenu(self): + self.browser.menuRequested.emit(dict(self.state.context), self.collectionButton.mapToGlobal( + self.collectionButton.rect().bottomLeft())) + + +class LibraryBrowser(QTabWidget): + playRequested = Signal(dict) + menuRequested = Signal(dict, object) + PAGE_SIZE = 200 + + def __init__(self, runTask, currentSong, parent=None): + super().__init__(parent) + self.runTask, self.currentSong = runTask, currentSong + self.client = None + self.setAccessibleName('Library categories') + self.pages = {} + for name in ('Artists', 'Albums', 'Favorites', 'Playlists', 'Genres', 'Discover'): + page = BrowserPage(self, name) + self.pages[name] = page + page.reset(ViewState(name, (name,))) + self.addTab(page, name) + self.currentChanged.connect(self.ensureLoaded) + + def clear(self): + self.client = None + for name, page in self.pages.items(): + page.reset(ViewState(name, (name,))) + + def setLibrary(self, client, data): + self.client = client + roots = { + 'Artists': ('artist', data.get('artists', []), {'type': 'artists_root', 'label': 'All artists'}), + 'Favorites': ('song', data.get('favorites', {}).get('songs', []), + {'type': 'favorites_root', 'label': 'Favorites'}), + 'Playlists': ('playlist', data.get('playlists', []), {}), + 'Genres': ('genre', data.get('genres', []), {}), + } + for name, (kind, items, context) in roots.items(): + self.pages[name].reset(ViewState(name, HEADERS[kind], media_rows(kind, items), context)) + albums = self.viewFor({'type': 'album_list', 'listType': 'alphabeticalByName', 'label': 'Albums'}) + self.pages['Albums'].reset(albums) + sections = [('Recently Added', 'newest'), ('Frequently Played', 'frequent'), + ('Recently Played', 'recent'), ('Random Albums', 'random')] + rows = [LibraryRow((label,), {'type': 'album_list', 'listType': kind, 'label': label}) + for label, kind in sections] + rows += [LibraryRow((label,), {'type': kind, 'label': label}) for label, kind in ( + ('Now Playing (server)', 'now_playing'), ('Similar to Current Track', 'similar_current'))] + self.pages['Discover'].reset(ViewState('Discover', ('Collection',), rows)) + self.ensureLoaded() + + def ensureLoaded(self, *args): + if self.client and not self.currentWidget().state.loaded: + self.currentWidget().load() + + def selectedData(self): + return self.currentWidget().selectedData() + + def focusTable(self): + self.currentWidget().table.setFocus(Qt.OtherFocusReason) + + def viewFor(self, data): + client = self.client + if not client: + return None + kind = data['type'] + size = self.PAGE_SIZE + if kind == 'artist': + result_kind = 'album' + fetch = lambda offset: (client.getArtist(data['id']).get('albums', []), False) + elif kind == 'album': + result_kind = 'song' + fetch = lambda offset: (client.getAlbum(data['id']).get('songs', []), False) + elif kind == 'playlist': + result_kind = 'song' + fetch = lambda offset: (client.getPlaylist(data['id']).songs, False) + elif kind == 'genre': + result_kind = 'song' + def fetch(offset): + items = client.getSongsByGenre(data['name'], count=size, offset=offset) + return items, len(items) == size + elif kind == 'album_list': + result_kind = 'album' + def fetch(offset): + items = client.getAlbumList(data['listType'], size=size, offset=offset) + return items, len(items) == size and data['listType'] != 'random' + elif kind == 'now_playing': + result_kind = 'song' + fetch = lambda offset: (client.getNowPlaying(), False) + elif kind == 'similar_current': + result_kind = 'song' + song = self.currentSong() + fetch = lambda offset: (client.getSimilarSongs(song.id, count=40) if song else [], False) + else: + return None + + def loader(offset): + items, more = fetch(offset) + return media_rows(result_kind, items), more + + return ViewState(data['label'], HEADERS[result_kind], context=dict(data), + loader=loader, loaded=False) diff --git a/src/widgets/search_dialog.py b/src/widgets/search_dialog.py index 5bffedc..d1eccd1 100644 --- a/src/widgets/search_dialog.py +++ b/src/widgets/search_dialog.py @@ -29,7 +29,7 @@ from src.widgets.accessible_text_dialog import AccessibleTextDialog class SearchDialog(QDialog): - """Search dialog that mirrors the library tree interactions.""" + """Search dialog with hierarchical search results.""" songActivated = Signal(Song) _taskFinished = Signal(object) @@ -52,7 +52,7 @@ class SearchDialog(QDialog): layout = QVBoxLayout(self) self.instructions = QLabel( - "Search songs, artists, albums, or genres. Results behave like the main library tree: " + "Search songs, artists, albums, or genres. Browse search results: " "expand with arrow keys, press Enter to play, or open the context menu with right click or Shift+F10." ) self.instructions.setAccessibleName("Search Instructions") @@ -329,7 +329,7 @@ class SearchDialog(QDialog): menu.addAction("Play", lambda: parentWindow.playback.enqueue(song, playNow=True)) menu.addAction("Add to Queue", lambda: parentWindow.playback.enqueue(song, playNow=False)) if hasattr(parentWindow, "addItemToPlaylist"): - menu.addAction("Add to Playlist", lambda: parentWindow.addItemToPlaylist(item)) + menu.addAction("Add to Playlist", lambda: parentWindow.addItemToPlaylist(self.actionData(item))) menu.addSeparator() menu.addAction("Favorite", lambda: self.favoriteItem(item, "favorite")) menu.addAction("Unfavorite", lambda: self.favoriteItem(item, "unfavorite")) @@ -337,7 +337,7 @@ class SearchDialog(QDialog): menu.addAction("Play Album", lambda: parentWindow.playAlbum(data.get("id"))) menu.addAction("Add Album to Queue", lambda: self.enqueueCollection(parentWindow, item)) if hasattr(parentWindow, "addItemToPlaylist"): - menu.addAction("Add Album to Playlist", lambda: parentWindow.addItemToPlaylist(item)) + menu.addAction("Add Album to Playlist", lambda: parentWindow.addItemToPlaylist(self.actionData(item))) menu.addSeparator() menu.addAction("Favorite Album", lambda: self.favoriteItem(item, "favorite")) menu.addAction("Unfavorite Album", lambda: self.favoriteItem(item, "unfavorite")) @@ -345,7 +345,7 @@ class SearchDialog(QDialog): menu.addAction("Play Artist", lambda: parentWindow.playArtist(data.get("id"))) menu.addAction("Add Artist to Queue", lambda: self.enqueueCollection(parentWindow, item)) if hasattr(parentWindow, "addItemToPlaylist"): - menu.addAction("Add Artist to Playlist", lambda: parentWindow.addItemToPlaylist(item)) + menu.addAction("Add Artist to Playlist", lambda: parentWindow.addItemToPlaylist(self.actionData(item))) menu.addSeparator() menu.addAction("Favorite Artist", lambda: self.favoriteItem(item, "favorite")) menu.addAction("Unfavorite Artist", lambda: self.favoriteItem(item, "unfavorite")) @@ -356,10 +356,15 @@ class SearchDialog(QDialog): globalPos = self.resultsTree.viewport().mapToGlobal(position) menu.exec(globalPos) + @staticmethod + def actionData(item: QTreeWidgetItem) -> Dict: + """Snapshot a search result before dispatching collection actions.""" + return dict(item.data(0, Qt.UserRole) or {}, label=item.text(0)) + def enqueueCollection(self, parentWindow, item: QTreeWidgetItem): """Queue albums/artists from search results via parent helpers when possible.""" if hasattr(parentWindow, "addItemToQueue"): - parentWindow.addItemToQueue(item) + parentWindow.addItemToQueue(self.actionData(item)) def favoriteItem(self, item: QTreeWidgetItem, action: str): """Favorite or unfavorite an item directly via the client.""" diff --git a/tests/test_library_ui.py b/tests/test_library_ui.py index 0ebd8d0..7dd2b02 100644 --- a/tests/test_library_ui.py +++ b/tests/test_library_ui.py @@ -1,12 +1,13 @@ -"""Exercise the assembled library UI with fake data and controlled requests.""" +"""Exercise the assembled tabbed library with fake data and controlled requests.""" from unittest.mock import Mock import pytest -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QAccessible from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication -from src.api.models import Album, Artist, Song +from src.api.models import Album, Artist, Genre, Playlist, Song from src.main_window import MainWindow @@ -17,113 +18,290 @@ def window(monkeypatch, tmp_path): monkeypatch.setenv(name, str(tmp_path / name)) monkeypatch.setattr(MainWindow, 'setupMpris', lambda self: None) monkeypatch.setattr(MainWindow, 'showServerSetup', lambda self: None) - # Avoid the production close handler's forced process exit. monkeypatch.setattr(MainWindow, 'closeEvent', lambda self, event: event.accept()) - monkeypatch.setattr("src.managers.playback_manager.QMediaPlayer", Mock()) - monkeypatch.setattr("src.managers.playback_manager.QAudioOutput", Mock()) + monkeypatch.setattr('src.managers.playback_manager.QMediaPlayer', Mock()) + monkeypatch.setattr('src.managers.playback_manager.QAudioOutput', Mock()) w = MainWindow() w.client = Mock() w.startPlaybackWithSongs = Mock() + w.playback.enqueue = Mock() + w.client.getArtist.return_value = {'albums': [Album('album', 'Album', 'Artist', songCount=1)]} + song = Song('song', 'Song', 'Album', 'album', 'Artist') + w.client.getAlbum.return_value = {'songs': [song]} + w.client.getAlbumList.return_value = [Album('album', 'Album', 'Artist', songCount=1)] + w.client.getPlaylist.return_value = Playlist('playlist', 'Playlist', songs=[song]) + w.client.getSongsByGenre.return_value = [song] + w.client.getNowPlaying.return_value = [song] + w.client.getSimilarSongs.return_value = [song] + w.playback.currentSong = Mock(return_value=song) pending = [] - w._run_background = lambda label, work, on_success=None, **kw: pending.append(on_success) - w._applyLibraryData({'artists': [Artist('artist', 'Artist', 1)]}) + w._run_background = lambda label, work, on_success=None, on_error=None: pending.append( + (work, on_success, on_error)) + w._applyLibraryData({'artists': [Artist('artist', 'Artist', 1)], + 'favorites': {'songs': [song]}, + 'playlists': [Playlist('playlist', 'Playlist', songCount=1)], + 'genres': [Genre('Rock', songCount=1, albumCount=1)]}) w.show() - w.libraryTree.setFocus() + w.activateWindow() + w.libraryBrowser.focusTable() app.processEvents() yield w, pending + w.libraryBrowser.clear() w._executor.shutdown(wait=True) w.close() w.deleteLater() app.processEvents() +def finish(pending, index=0): + work, apply, failed = pending.pop(index) + apply(work()) + QApplication.processEvents() + + def open_album(window): w, pending = window - tree = w.libraryTree - artist = w.artistsRoot.child(0) - tree.setCurrentItem(artist) - QTest.keyClick(tree, Qt.Key_Right) - pending.pop()([Album('album', 'Album', 'Artist', songCount=1)]) - QTest.keyClick(tree, Qt.Key_Right) - album = tree.currentItem() - assert album.parent() is artist - QTest.keyClick(tree, Qt.Key_Right) - songs = [Song('song', 'Song', 'Album', 'album', 'Artist')] - pending.pop()(songs) - QApplication.processEvents() - return album, songs + page = w.libraryBrowser.currentWidget() + QTest.keyClick(page.table, Qt.Key_Return) + finish(pending) + assert page.selectedData()['type'] == 'album' + QTest.keyClick(page.table, Qt.Key_Return) + finish(pending) + assert page.selectedData()['type'] == 'song' + return page -def test_album_can_collapse_and_reopen_after_loading(window): +def test_browse_and_back_replace_rows_without_hidden_descendants(window): w, pending = window - album, songs = open_album(window) - tree = w.libraryTree - assert album.isExpanded() - QTest.keyClick(tree, Qt.Key_Right) - assert tree.currentItem() is album.child(0) - QTest.keyClick(tree, Qt.Key_Left) - assert tree.currentItem() is album - QTest.keyClick(tree, Qt.Key_Left) - assert not album.isExpanded() - assert tree.visualItemRect(album.child(0)).isEmpty() - QTest.keyClick(tree, Qt.Key_Right) - assert album.isExpanded() + page = open_album(window) + assert page.model.headers == ('Title', 'Artist', 'Album', 'Duration') + QTest.keyClick(page.table, Qt.Key_Backspace) + assert page.selectedData()['type'] == 'album' + QTest.keyClick(page.table, Qt.Key_Backspace) + assert page.selectedData()['type'] == 'artist' + assert page.model.rowCount() == 1 + assert page.model.columnCount() == 2 + assert not page.backButton.isEnabled() assert not pending + assert page.table.hasFocus() + w.playback.enqueue.assert_not_called() -def test_playback_completion_respects_album_collapse(window): +def test_play_song_once_and_playback_completion_does_not_navigate(window): w, pending = window - album, songs = open_album(window) - QTest.keyClick(w.libraryTree, Qt.Key_Return) - assert len(pending) == 1 - QTest.keyClick(w.libraryTree, Qt.Key_Left) - assert not album.isExpanded() - pending.pop()((None, songs)) - QApplication.processEvents() - assert not album.isExpanded() - assert w.libraryTree.visualItemRect(album.child(0)).isEmpty() + page = open_album(window) + QTest.keyClick(page.table, Qt.Key_Return) + w.playback.enqueue.assert_called_once_with(w.client.getAlbum.return_value['songs'][0], playNow=True) + page.goBack() + page.playButton.click() + page.goBack() + finish(pending) + assert page.selectedData()['type'] == 'artist' w.startPlaybackWithSongs.assert_called_once() -def test_album_can_collapse_with_mouse(window): - w, _ = window - album, _ = open_album(window) - tree = w.libraryTree - tree.setCurrentItem(album.child(0)) - rect = tree.visualItemRect(album) - # Click the branch indicator just before the album's text rectangle. - point = rect.center() - point.setX(rect.left() - tree.indentation() // 2) - QTest.mouseClick(tree.viewport(), Qt.LeftButton, pos=point) - QApplication.processEvents() - assert not album.isExpanded() - assert tree.currentItem() is album - - -def test_album_browse_response_does_not_reopen_collapsed_branch(window): +def test_back_discards_late_browse_response(window): w, pending = window - album, songs = open_album(window) - data = album.data(0, Qt.UserRole) - data['loaded'] = False - album.setData(0, Qt.UserRole, data) - QTest.keyClick(w.libraryTree, Qt.Key_Left) - QTest.keyClick(w.libraryTree, Qt.Key_Right) - assert len(pending) == 1 - QTest.keyClick(w.libraryTree, Qt.Key_Left) - pending.pop()(songs) + page = w.libraryBrowser.currentWidget() + QTest.keyClick(page.table, Qt.Key_Return) + assert page.model.rowCount() == 0 + QTest.keyClick(page.table, Qt.Key_Backspace) + finish(pending) + assert page.selectedData()['type'] == 'artist' + assert len(page.history) == 1 + + +def test_tab_switch_during_load_keeps_active_table_and_focus(window): + w, pending = window + browser = w.libraryBrowser + page = browser.currentWidget() + QTest.keyClick(page.table, Qt.Key_Return) + browser.setCurrentWidget(browser.pages['Favorites']) + browser.focusTable() + finish(pending) + assert browser.selectedData()['type'] == 'song' + assert browser.pages['Favorites'].table.hasFocus() + browser.setCurrentWidget(page) + assert page.selectedData()['type'] == 'album' + + +def test_refresh_invalidates_pending_browse(window): + w, pending = window + page = w.libraryBrowser.currentWidget() + page.openSelected() + w._applyLibraryData({'artists': [Artist('new', 'New artist')]}) + finish(pending) + assert page.selectedData()['id'] == 'new' + assert len(page.history) == 1 + + +def test_failure_can_retry_without_fake_rows(window): + w, pending = window + page = w.libraryBrowser.currentWidget() + page.openSelected() + _, _, fail = pending.pop() + fail(RuntimeError('synthetic failure')) + assert page.model.rowCount() == 0 + assert page.retryButton.isVisible() + page.retryButton.click() + finish(pending) + assert page.selectedData()['type'] == 'album' + assert not page.retryButton.isVisible() + + +def test_albums_pagination_does_not_drop_or_repeat_rows(window): + w, pending = window + browser = w.libraryBrowser + browser.PAGE_SIZE = 2 + # Rebuild loaders with the small test page size. + w._applyLibraryData({}) + w.client.getAlbumList.side_effect = [ + [Album('a', 'A', 'Artist'), Album('b', 'B', 'Artist')], + [Album('c', 'C', 'Artist')], + ] + browser.setCurrentWidget(browser.pages['Albums']) + finish(pending) + page = browser.currentWidget() + page.table.setCurrentIndex(page.model.index(1, 0)) + page.moreButton.click() + finish(pending) + assert [row.data['id'] for row in page.model.rows] == ['a', 'b', 'c'] + assert page.selectedData()['id'] == 'b' + assert not page.moreButton.isVisible() + assert [call.kwargs['offset'] for call in w.client.getAlbumList.call_args_list] == [0, 2] + + +@pytest.mark.parametrize('name', ['Playlists', 'Genres', 'Discover']) +def test_other_tabs_open_collections(window, name): + w, pending = window + browser = w.libraryBrowser + browser.setCurrentWidget(browser.pages[name]) + page = browser.currentWidget() + page.openSelected() + finish(pending) + assert page.selectedData()['type'] == ('album' if name == 'Discover' else 'song') + page.backButton.click() + assert len(page.history) == 1 + + +def test_keyboard_tab_switch_and_context_menu(window, monkeypatch): + w, pending = window + browser = w.libraryBrowser + page = browser.currentWidget() + menus = [] + def inspect_menu(): + menu = QApplication.activePopupWidget() + menus.append([action.text() for action in menu.actions()]) + menu.close() + QTimer.singleShot(0, inspect_menu) + QTest.keyClick(page.table, Qt.Key_F10, Qt.ShiftModifier) + assert 'Open' in menus[-1] + assert 'Play' in menus[-1] + assert 'Add to Queue' in menus[-1] + QTest.keyClick(page.table, Qt.Key_Tab, Qt.ControlModifier) QApplication.processEvents() - assert not album.isExpanded() - assert not w.libraryTree.isItemNavigable(album.child(0)) + assert browser.currentWidget() is browser.pages['Albums'] + finish(pending) -@pytest.mark.parametrize('kind', ['artist', 'genre']) -def test_other_playback_responses_preserve_collapsed_branches(window, kind): +def test_native_accessibility_reports_table_headers_and_current_cells(window): w, _ = window - album, songs = open_album(window) - album.setExpanded(False) - if kind == 'artist': - w._onArtistLoadedForPlay(album, (None, songs)) - else: - w._onGenreLoadedForPlay('Genre', album, (songs, False)) - assert not album.isExpanded() - w.startPlaybackWithSongs.assert_called_once() + page = open_album(window) + page.goBack() + page.goBack() + QApplication.processEvents() + interface = QAccessible.queryAccessibleInterface(page.table) + assert interface.role() == QAccessible.Table + names = [interface.child(i).text(QAccessible.Name) for i in range(interface.childCount())] + assert 'Artist' in names and 'Albums' in names + assert 'Song' not in names and 'Album' not in names + + +def test_collection_actions_and_queue_use_current_payload(window, monkeypatch): + w, _ = window + page = w.libraryBrowser.currentWidget() + targets = [] + def choose_queue(): + menu = QApplication.activePopupWidget() + action = next(action for action in menu.actions() if action.text() == 'Add to Queue') + menu.close() + action.trigger() + w.addItemToQueue = targets.append + QTimer.singleShot(0, choose_queue) + page.collectionButton.click() + assert targets[-1]['type'] == 'artists_root' + QTimer.singleShot(0, choose_queue) + page.actionsButton.click() + assert targets[-1]['type'] == 'artist' + + +def test_actual_background_request_does_not_block_back_navigation(window): + import threading + import time + w, _ = window + started, release, done = threading.Event(), threading.Event(), threading.Event() + threads = [] + def fetch(identifier): + threads.append(threading.get_ident()) + started.set() + assert release.wait(3) + done.set() + return {'albums': [Album('late', 'Late album', 'Artist')]} + w.client.getArtist.side_effect = fetch + w._run_background = MainWindow._run_background.__get__(w) + page = w.libraryBrowser.currentWidget() + try: + page.openSelected() + assert started.wait(1) + page.goBack() + release.set() + assert done.wait(1) + w._executor.shutdown(wait=True) + deadline = time.monotonic() + 0.1 + while time.monotonic() < deadline: + QApplication.processEvents() + time.sleep(0.005) + assert page.selectedData()['type'] == 'artist' + assert len(threads) == 1 + assert threads[0] != threading.get_ident() + finally: + release.set() + + +def test_back_restores_selection_and_empty_tables_have_no_actions(window): + w, pending = window + w._applyLibraryData({'artists': [Artist('first', 'First'), Artist('second', 'Second')]}) + page = w.libraryBrowser.currentWidget() + page.table.setCurrentIndex(page.model.index(1, 0)) + w.client.getArtist.return_value = {'albums': []} + page.openSelected() + finish(pending) + assert page.model.rowCount() == 0 + assert not page.playButton.isEnabled() + assert not page.actionsButton.isEnabled() + page.goBack() + assert page.selectedData()['id'] == 'second' + + +def test_search_queue_uses_payload_independent_of_removed_search_item(window): + from PySide6.QtWidgets import QTreeWidgetItem + from src.widgets.search_dialog import SearchDialog + w, pending = window + item = QTreeWidgetItem(['Album from search']) + item.setData(0, Qt.UserRole, {'type': 'album', 'id': 'album'}) + w.playback.enqueueMany = Mock() + w.addItemToQueue(SearchDialog.actionData(item)) + del item + finish(pending) + w.playback.enqueueMany.assert_called_once_with(w.client.getAlbum.return_value['songs'], playFirst=False) + + +def test_genre_playback_and_queue_collect_all_pages(window): + w, _ = window + songs = [Song(str(i), f'Song {i}', 'Album', 'album', 'Artist') for i in range(201)] + w.client.getSongsByGenre.side_effect = [songs[:200], songs[200:]] + collected, label, truncated = w.collectSongsForItem( + {'type': 'genre', 'name': 'Rock', 'label': 'Rock'}) + assert collected == songs + assert not truncated + assert [call.kwargs['offset'] for call in w.client.getSongsByGenre.call_args_list] == [0, 200]