More functionality added included playlist management.
This commit is contained in:
+314
-2
@@ -3,11 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
@@ -19,7 +20,7 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
QAccessible,
|
||||
@@ -37,6 +38,7 @@ from src.config.settings import Settings
|
||||
from src.managers.playback_manager import PlaybackManager
|
||||
from src.integrations.mpris import MprisService
|
||||
from src.widgets.accessible_text_dialog import AccessibleTextDialog
|
||||
from src.widgets.add_to_playlist_dialog import AddToPlaylistDialog
|
||||
from src.widgets.search_dialog import SearchDialog
|
||||
from src.widgets.server_dialog import ServerDialog
|
||||
|
||||
@@ -225,6 +227,7 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
@@ -333,6 +336,7 @@ class MainWindow(QMainWindow):
|
||||
"""Connect signals after widgets exist."""
|
||||
self.libraryTree.itemExpanded.connect(self.onLibraryExpanded)
|
||||
self.libraryTree.itemActivated.connect(self.onLibraryActivated)
|
||||
self.libraryTree.customContextMenuRequested.connect(self.onLibraryContextMenu)
|
||||
|
||||
self.playButton.clicked.connect(self.playPauseAction.trigger)
|
||||
self.prevButton.clicked.connect(self.previousAction.trigger)
|
||||
@@ -501,6 +505,18 @@ class MainWindow(QMainWindow):
|
||||
self.artistsRoot.setExpanded(True)
|
||||
self.libraryTree.addTopLevelItem(self.artistsRoot)
|
||||
|
||||
self.favoritesRoot = QTreeWidgetItem(["Favorites"])
|
||||
self.favoritesRoot.setData(0, Qt.UserRole, {"type": "favorites_root", "loaded": True})
|
||||
self.favoritesRoot.setExpanded(True)
|
||||
self.libraryTree.addTopLevelItem(self.favoritesRoot)
|
||||
self.loadFavorites()
|
||||
|
||||
self.discoverRoot = QTreeWidgetItem(["Discover"])
|
||||
self.discoverRoot.setData(0, Qt.UserRole, {"type": "discover_root", "loaded": True})
|
||||
self.discoverRoot.setExpanded(True)
|
||||
self.libraryTree.addTopLevelItem(self.discoverRoot)
|
||||
self.loadDiscoverSections()
|
||||
|
||||
self.playlistsRoot = QTreeWidgetItem(["Playlists"])
|
||||
self.playlistsRoot.setData(0, Qt.UserRole, {"type": "playlists_root", "loaded": True})
|
||||
self.playlistsRoot.setExpanded(True)
|
||||
@@ -557,6 +573,49 @@ class MainWindow(QMainWindow):
|
||||
item.addChild(QTreeWidgetItem(["Loading songs..."]))
|
||||
self.genresRoot.addChild(item)
|
||||
|
||||
def loadFavorites(self):
|
||||
"""Populate the favorites tree with starred songs."""
|
||||
starred = self.client.getStarred()
|
||||
songs: List[Song] = starred.get("songs", [])
|
||||
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 {}
|
||||
@@ -574,6 +633,12 @@ class MainWindow(QMainWindow):
|
||||
self.populatePlaylistSongs(item, data["id"])
|
||||
elif itemType == "genre":
|
||||
self.populateGenreSongs(item, data["name"])
|
||||
elif itemType == "album_list":
|
||||
self.populateAlbumListSection(item, data.get("listType", "newest"))
|
||||
elif itemType == "now_playing":
|
||||
self.populateNowPlayingSection(item)
|
||||
elif itemType == "similar_current":
|
||||
self.populateSimilarToCurrent(item)
|
||||
data["loaded"] = True
|
||||
item.setData(0, Qt.UserRole, data)
|
||||
except Exception as e:
|
||||
@@ -600,12 +665,77 @@ class MainWindow(QMainWindow):
|
||||
self.playArtist(data.get("id"), item)
|
||||
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:
|
||||
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")
|
||||
|
||||
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():
|
||||
return
|
||||
|
||||
globalPos = self.libraryTree.viewport().mapToGlobal(position)
|
||||
menu.exec(globalPos)
|
||||
|
||||
def populateArtistAlbums(self, parentItem: QTreeWidgetItem, artistId: str):
|
||||
"""Add albums for a given artist."""
|
||||
result = self.client.getArtist(artistId)
|
||||
@@ -651,6 +781,50 @@ class MainWindow(QMainWindow):
|
||||
parentItem.addChild(songItem)
|
||||
parentItem.setData(0, Qt.UserRole, {"type": "genre", "name": genreName, "loaded": True})
|
||||
|
||||
def populateAlbumListSection(self, parentItem: QTreeWidgetItem, listType: str):
|
||||
"""Populate a discover album list section."""
|
||||
parentItem.takeChildren()
|
||||
albums = self.client.getAlbumList(listType, size=40)
|
||||
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)
|
||||
|
||||
def populateNowPlayingSection(self, parentItem: QTreeWidgetItem):
|
||||
"""Populate songs currently playing across users."""
|
||||
parentItem.takeChildren()
|
||||
songs = self.client.getNowPlaying()
|
||||
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)
|
||||
|
||||
def populateSimilarToCurrent(self, parentItem: QTreeWidgetItem):
|
||||
"""Populate songs similar to the current track."""
|
||||
parentItem.takeChildren()
|
||||
current = self.playback.currentSong()
|
||||
if not current:
|
||||
parentItem.addChild(QTreeWidgetItem(["Play a track to see similar songs."]))
|
||||
return
|
||||
songs = self.client.getSimilarSongs(current.id, count=40)
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
def formatSongLabel(song: Song, includeAlbum: bool = False) -> str:
|
||||
"""Create a descriptive label for a song."""
|
||||
@@ -687,6 +861,132 @@ class MainWindow(QMainWindow):
|
||||
data["loaded"] = True
|
||||
parentItem.setData(0, Qt.UserRole, data)
|
||||
|
||||
def addItemToQueue(self, item: QTreeWidgetItem):
|
||||
"""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":
|
||||
song: Optional[Song] = data.get("song")
|
||||
if not song:
|
||||
self.announceLive("No song selected.")
|
||||
return
|
||||
self.playback.enqueue(song, playNow=False)
|
||||
self.statusBar.showMessage(f"Added {song.title} to the queue", 3000)
|
||||
self.enablePlaybackActions(True)
|
||||
return
|
||||
|
||||
songs, label = self.collectSongsForItem(item)
|
||||
if not songs:
|
||||
self.announceLive(f"No songs found for {label}")
|
||||
return
|
||||
|
||||
self.playback.enqueueMany(songs, playFirst=False)
|
||||
self.statusBar.showMessage(f"Added {len(songs)} tracks from {label} to the queue", 4000)
|
||||
self.enablePlaybackActions(True)
|
||||
|
||||
def addItemToPlaylist(self, item: QTreeWidgetItem):
|
||||
"""Append the item's songs to an existing or new playlist."""
|
||||
if not self.client:
|
||||
self.announceLive("Not connected to a server.")
|
||||
return
|
||||
|
||||
songs, label = self.collectSongsForItem(item)
|
||||
if not songs:
|
||||
self.announceLive(f"No songs found for {label}")
|
||||
return
|
||||
|
||||
playlists = self.client.getPlaylists()
|
||||
dialog = AddToPlaylistDialog(playlists, parent=self)
|
||||
if not dialog.exec():
|
||||
return
|
||||
|
||||
mode, target = dialog.getResult()
|
||||
songIds = [song.id for song in songs]
|
||||
|
||||
try:
|
||||
if mode == "existing":
|
||||
self.client.updatePlaylist(target, songIdsToAdd=songIds)
|
||||
targetName = next((p.name for p in playlists if p.id == target), "playlist")
|
||||
message = f"Added {len(songs)} tracks to {targetName}"
|
||||
self.statusBar.showMessage(message, 3000)
|
||||
self.announceLive(message)
|
||||
else:
|
||||
playlist = self.client.createPlaylist(target, songIds=songIds)
|
||||
message = f"Created playlist {playlist.name} with {len(songs)} tracks"
|
||||
self.statusBar.showMessage(message, 4000)
|
||||
self.announceLive(message)
|
||||
except Exception as e:
|
||||
AccessibleTextDialog.showError("Playlist Error", str(e), parent=self)
|
||||
|
||||
def collectSongsForItem(self, item: QTreeWidgetItem) -> Tuple[List[Song], str]:
|
||||
"""Resolve a tree item into a list of songs for queueing."""
|
||||
data: Dict = item.data(0, Qt.UserRole) or {}
|
||||
itemType = data.get("type")
|
||||
label = item.text(0)
|
||||
|
||||
if not self.client:
|
||||
return [], label
|
||||
|
||||
try:
|
||||
if itemType == "song":
|
||||
song: Optional[Song] = data.get("song")
|
||||
return [song] if song else [], self.formatSongLabel(song, includeAlbum=True) if song else label
|
||||
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
|
||||
if itemType == "artist":
|
||||
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:
|
||||
albumData = self.client.getAlbum(album.id)
|
||||
songs.extend(albumData.get("songs", []))
|
||||
return songs, artist.name if artist else label
|
||||
if itemType == "playlist":
|
||||
playlist: Playlist = self.client.getPlaylist(data.get("id"))
|
||||
return playlist.songs, playlist.name
|
||||
if itemType == "genre":
|
||||
genreName = data.get("name")
|
||||
songs = self.client.getSongsByGenre(genreName, count=5000)
|
||||
return songs, genreName or label
|
||||
if itemType == "artists_root":
|
||||
artists: List[Artist] = self.client.getArtists()
|
||||
songs: List[Song] = []
|
||||
for artist in artists:
|
||||
artistData = self.client.getArtist(artist.id)
|
||||
for album in artistData.get("albums", []):
|
||||
albumData = self.client.getAlbum(album.id)
|
||||
songs.extend(albumData.get("songs", []))
|
||||
return songs, "all artists"
|
||||
if itemType == "favorites_root":
|
||||
starred = self.client.getStarred()
|
||||
songs = starred.get("songs", [])
|
||||
return songs, "favorites"
|
||||
if itemType == "playlist":
|
||||
playlist: Playlist = self.client.getPlaylist(data.get("id"))
|
||||
return playlist.songs, playlist.name
|
||||
if itemType == "album_list":
|
||||
# These nodes contain album children; we don't aggregate here
|
||||
return [], label
|
||||
if itemType == "now_playing":
|
||||
songs = self.client.getNowPlaying()
|
||||
return songs, "now playing"
|
||||
if itemType == "similar_current":
|
||||
current = self.playback.currentSong()
|
||||
songs = self.client.getSimilarSongs(current.id, count=40) if current else []
|
||||
return songs, "similar tracks"
|
||||
except Exception as e:
|
||||
AccessibleTextDialog.showError("Queue Error", str(e), parent=self)
|
||||
return [], label
|
||||
|
||||
# ============ Search ============
|
||||
|
||||
def openSearchDialog(self):
|
||||
@@ -852,6 +1152,18 @@ class MainWindow(QMainWindow):
|
||||
except Exception as e:
|
||||
AccessibleTextDialog.showError("Playlist Error", str(e), parent=self)
|
||||
|
||||
def playFavorites(self):
|
||||
"""Play all starred songs."""
|
||||
if not self.client:
|
||||
self.announceLive("Not connected to a server.")
|
||||
return
|
||||
try:
|
||||
starred = self.client.getStarred()
|
||||
songs: List[Song] = starred.get("songs", [])
|
||||
self.startPlaybackWithSongs(songs, "favorites")
|
||||
except Exception as e:
|
||||
AccessibleTextDialog.showError("Favorites Error", str(e), parent=self)
|
||||
|
||||
def onTrackChanged(self, song: Song):
|
||||
"""Update UI when track changes."""
|
||||
self.nowPlayingInfo.setText(f"{song.title} — {song.artist} ({song.album})")
|
||||
|
||||
Reference in New Issue
Block a user