Rework the ui because the treeviews were not reliable.
This commit is contained in:
1 parent
d9f4ac4135
commit
33bba7282e
6 files changed
+858
-651
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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]
|
||||
Reference in new issue
Block a user