Rework the ui because the treeviews were not reliable.

This commit is contained in:
Storm Dragon committed 2026-09-15 15:10:27 -04:00
1 parent d9f4ac4135
commit 33bba7282e
6 files changed
+858 -651

No files matched your search

+17 -4
View File
@@ -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
@@ -1,6 +1,6 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=navipy-git
pkgver=r10.gc4ccc39
pkgver=r11.gd9f4ac4
pkgrel=1
pkgdesc='Accessible Subsonic desktop client tested with Navidrome'
arch=('any')
+160 -558
View File
@@ -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:
+409
View File
@@ -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)
+11 -6
View File
@@ -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."""
+260 -82
View File
@@ -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]