More functionality added included playlist management.

This commit is contained in:
Storm Dragon
2025-12-15 22:25:07 -05:00
parent 555ca0bba9
commit 5507aa8faa
6 changed files with 800 additions and 41 deletions
+314 -2
View File
@@ -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})")