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
+106
View File
@@ -0,0 +1,106 @@
"""Dialog to select an existing playlist or create a new one."""
from __future__ import annotations
from typing import List, Optional, Tuple
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QComboBox,
)
from src.api.models import Playlist
class AddToPlaylistDialog(QDialog):
"""Accessible dialog for choosing or creating a playlist."""
def __init__(self, playlists: List[Playlist], parent=None):
super().__init__(parent)
self.playlists = playlists
self.setWindowTitle("Add to Playlist")
self.setMinimumWidth(360)
self.selectedPlaylistId: Optional[str] = None
self.newPlaylistName: Optional[str] = None
self.setupUi()
def setupUi(self):
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Choose an existing playlist or create a new one."))
form = QFormLayout()
self.playlistCombo = QComboBox()
self.playlistCombo.setAccessibleName("Existing Playlists")
self.playlistCombo.addItem("Create new playlist...", userData=None)
for pl in self.playlists:
self.playlistCombo.addItem(pl.name, userData=pl.id)
self.playlistCombo.currentIndexChanged.connect(self.onPlaylistChanged)
form.addRow("Playlist:", self.playlistCombo)
nameRow = QHBoxLayout()
self.nameEdit = QLineEdit()
self.nameEdit.setAccessibleName("New Playlist Name")
self.nameEdit.setPlaceholderText("Enter a new playlist name")
self.nameEdit.textChanged.connect(self.validate)
nameRow.addWidget(self.nameEdit)
form.addRow("Name:", nameRow)
layout.addLayout(form)
self.statusLabel = QLabel("")
self.statusLabel.setAccessibleName("Playlist Status")
layout.addWidget(self.statusLabel)
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
layout.addWidget(self.buttons)
self.onPlaylistChanged(0)
self.validate()
def onPlaylistChanged(self, index: int):
"""Toggle name input based on selection."""
playlistId = self.playlistCombo.itemData(index)
if playlistId:
self.nameEdit.setEnabled(False)
self.nameEdit.clear()
else:
self.nameEdit.setEnabled(True)
self.validate()
def validate(self):
"""Enable OK only when selection is valid."""
playlistId = self.playlistCombo.currentData()
if playlistId:
self.statusLabel.setText("")
self.buttons.button(QDialogButtonBox.Ok).setEnabled(True)
return
name = self.nameEdit.text().strip()
if not name:
self.statusLabel.setText("Enter a playlist name.")
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
else:
self.statusLabel.setText("")
self.buttons.button(QDialogButtonBox.Ok).setEnabled(True)
def getResult(self) -> Tuple[str, Optional[str]]:
"""
Returns:
('existing', playlistId) or ('new', name)
"""
playlistId = self.playlistCombo.currentData()
if playlistId:
return ("existing", playlistId)
return ("new", self.nameEdit.text().strip())
+257 -33
View File
@@ -4,28 +4,30 @@ Accessible search dialog for finding tracks quickly.
from __future__ import annotations
from typing import List
from typing import List, Dict, Optional
from PySide6.QtCore import Signal, Qt
from PySide6.QtCore import QPoint, Signal, Qt
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QComboBox,
QLineEdit,
QListWidget,
QListWidgetItem,
QMenu,
QPushButton,
QTreeWidgetItem,
QVBoxLayout,
)
from src.api.client import SubsonicClient
from src.api.models import Song
from src.api.models import Album, Artist, Song
from src.accessibility.accessible_tree import AccessibleTreeWidget
from src.widgets.accessible_text_dialog import AccessibleTextDialog
class SearchDialog(QDialog):
"""Simple dialog for searching the Navidrome library."""
"""Search dialog that mirrors the library tree interactions."""
songActivated = Signal(Song)
@@ -35,16 +37,27 @@ class SearchDialog(QDialog):
self.setWindowTitle("Search Library")
self.setMinimumSize(500, 420)
self.setupUi()
self._resultsLoaded = False
self.setModal(True)
def setupUi(self):
layout = QVBoxLayout(self)
self.instructions = QLabel("Enter a song, artist, or album title. Press Enter to play results.")
self.instructions = QLabel(
"Search songs, artists, albums, or genres. Results behave like the main library tree: "
"expand with arrow keys, press Enter to play, or open the context menu with right click or Shift+F10."
)
self.instructions.setAccessibleName("Search Instructions")
self.instructions.setWordWrap(True)
layout.addWidget(self.instructions)
inputLayout = QHBoxLayout()
self.filterSelect = QComboBox()
self.filterSelect.setAccessibleName("Search Type")
self.filterSelect.addItems(["All", "Songs", "Artists", "Albums", "Genres"])
inputLayout.addWidget(self.filterSelect)
self.queryEdit = QLineEdit()
self.queryEdit.setAccessibleName("Search Query")
self.queryEdit.returnPressed.connect(self.runSearch)
@@ -57,10 +70,15 @@ class SearchDialog(QDialog):
layout.addLayout(inputLayout)
self.resultsList = QListWidget()
self.resultsList.setAccessibleName("Search Results")
self.resultsList.itemActivated.connect(self.activateSelection)
layout.addWidget(self.resultsList)
self.resultsTree = AccessibleTreeWidget()
self.resultsTree.setAccessibleName("Search Results Tree")
self.resultsTree.setHeaderHidden(True)
self.resultsTree.setRootIsDecorated(True)
self.resultsTree.setContextMenuPolicy(Qt.CustomContextMenu)
self.resultsTree.itemActivated.connect(self.handleActivate)
self.resultsTree.itemExpanded.connect(self.onItemExpanded)
self.resultsTree.customContextMenuRequested.connect(self.showContextMenu)
layout.addWidget(self.resultsTree)
self.statusLabel = QLabel("")
self.statusLabel.setAccessibleName("Search Status")
@@ -78,31 +96,237 @@ class SearchDialog(QDialog):
return
self.statusLabel.setText("Searching...")
self.resultsList.clear()
self.resultsTree.clear()
self._resultsLoaded = False
try:
results = self.client.search(query, artistCount=0, albumCount=0, songCount=50)
songs: List[Song] = results.get("songs", [])
if not songs:
self.statusLabel.setText("No songs found.")
return
mode = self.filterSelect.currentText()
if mode == "Genres":
songs = self.client.getSongsByGenre(query, count=200)
self.populateSongResults(songs, f"Songs in genre '{query}'")
else:
artistCount = 40 if mode in ("All", "Artists") else 0
albumCount = 40 if mode in ("All", "Albums") else 0
songCount = 80 if mode in ("All", "Songs") else 40
results = self.client.search(query, artistCount=artistCount, albumCount=albumCount, songCount=songCount)
self.populateResults(results)
for song in songs:
text = f"{song.title}{song.artist} ({song.album}, {song.durationFormatted})"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, song)
item.setData(Qt.AccessibleTextRole, text)
self.resultsList.addItem(item)
self.statusLabel.setText(f"Found {len(songs)} songs. Activate a result to play.")
self.resultsList.setCurrentRow(0)
self.resultsList.setFocus()
if self._resultsLoaded:
self.resultsTree.setFocus()
first = self.resultsTree.topLevelItem(0)
if first:
self.resultsTree.setCurrentItem(first)
self.statusLabel.setText("Search complete. Use the tree to play or queue items.")
else:
self.statusLabel.setText("No results found.")
except Exception as e:
AccessibleTextDialog.showError("Search Error", str(e), parent=self)
self.statusLabel.setText("Search failed.")
def activateSelection(self, item: QListWidgetItem):
"""Emit the chosen song and close the dialog."""
song = item.data(Qt.UserRole)
if isinstance(song, Song):
self.songActivated.emit(song)
self.accept()
def populateResults(self, results: Dict[str, List]):
"""Populate the results tree from search3 data."""
artists: List[Artist] = results.get("artists", [])
albums: List[Album] = results.get("albums", [])
songs: List[Song] = results.get("songs", [])
if artists:
root = QTreeWidgetItem([f"Artists ({len(artists)})"])
root.setData(0, Qt.UserRole, {"type": "artists_root"})
root.setExpanded(True)
for artist in artists:
item = QTreeWidgetItem([artist.name])
item.setData(0, Qt.UserRole, {"type": "artist", "id": artist.id, "artist": artist, "loaded": False})
item.setData(0, Qt.AccessibleTextRole, f"{artist.name} ({artist.albumCount} albums)")
item.addChild(QTreeWidgetItem(["Loading..."]))
root.addChild(item)
self.resultsTree.addTopLevelItem(root)
self._resultsLoaded = True
if albums:
root = QTreeWidgetItem([f"Albums ({len(albums)})"])
root.setData(0, Qt.UserRole, {"type": "albums_root"})
root.setExpanded(True)
for album in albums:
label = f"{album.name}{album.artist} ({album.songCount} tracks)"
item = QTreeWidgetItem([label])
item.setData(0, Qt.UserRole, {"type": "album", "id": album.id, "album": album, "loaded": False})
item.setData(0, Qt.AccessibleTextRole, label)
item.addChild(QTreeWidgetItem(["Loading..."]))
root.addChild(item)
self.resultsTree.addTopLevelItem(root)
self._resultsLoaded = True
if songs:
self.populateSongResults(songs, f"Songs ({len(songs)})")
def populateSongResults(self, songs: List[Song], rootLabel: str):
"""Add song results under a root heading."""
if not songs:
return
root = QTreeWidgetItem([rootLabel])
root.setData(0, Qt.UserRole, {"type": "songs_root"})
root.setExpanded(True)
for song in songs:
text = f"{song.title}{song.artist} ({song.album}, {song.durationFormatted})"
item = QTreeWidgetItem([text])
item.setData(0, Qt.UserRole, {"type": "song", "song": song})
item.setData(0, Qt.AccessibleTextRole, text)
root.addChild(item)
self.resultsTree.addTopLevelItem(root)
self._resultsLoaded = True
def handleActivate(self, item: QTreeWidgetItem):
"""Activate the selected item similar to the main tree."""
data: Dict = item.data(0, Qt.UserRole) or {}
itemType = data.get("type")
parentWindow = self.parent()
if itemType == "song":
song: Song = data.get("song")
if song:
if hasattr(parentWindow, "playback"):
parentWindow.playback.enqueue(song, playNow=True)
if hasattr(parentWindow, "enablePlaybackActions"):
parentWindow.enablePlaybackActions(True)
self.songActivated.emit(song)
self.accept()
elif itemType == "album":
if hasattr(parentWindow, "playAlbum"):
parentWindow.playAlbum(data.get("id"))
self.accept()
elif itemType == "artist":
if hasattr(parentWindow, "playArtist"):
parentWindow.playArtist(data.get("id"))
self.accept()
elif itemType == "artists_root":
if hasattr(parentWindow, "playAllArtists"):
parentWindow.playAllArtists()
self.accept()
def onItemExpanded(self, item: QTreeWidgetItem):
"""Lazy-load children for artist or album results."""
data: Dict = item.data(0, Qt.UserRole) or {}
if data.get("loaded") or not self.client:
return
try:
item.takeChildren()
itemType = data.get("type")
if itemType == "artist":
artistData = self.client.getArtist(data.get("id"))
for album in artistData.get("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..."]))
item.addChild(albumItem)
elif itemType == "album":
albumData = self.client.getAlbum(data.get("id"))
for song in albumData.get("songs", []):
text = f"{song.title}{song.artist} ({song.durationFormatted})"
child = QTreeWidgetItem([text])
child.setData(0, Qt.UserRole, {"type": "song", "song": song})
child.setData(0, Qt.AccessibleTextRole, text)
item.addChild(child)
data["loaded"] = True
item.setData(0, Qt.UserRole, data)
except Exception as e:
AccessibleTextDialog.showError("Search Error", str(e), parent=self)
def showContextMenu(self, position: QPoint):
"""Show a context menu with play/queue/favorite actions for search results."""
item = self.resultsTree.itemAt(position)
if not item:
return
self.resultsTree.setCurrentItem(item)
parentWindow = self.parent()
hasClient = isinstance(parentWindow, object) and hasattr(parentWindow, "client") and parentWindow.client
hasPlayback = hasattr(parentWindow, "playback")
menu = QMenu(self)
menu.setAccessibleName("Search Context Menu")
data: Dict = item.data(0, Qt.UserRole) or {}
itemType = data.get("type")
if itemType == "song":
song: Optional[Song] = data.get("song")
if song and hasClient and hasPlayback:
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.addSeparator()
menu.addAction("Favorite", lambda: self.favoriteItem(item, "favorite"))
menu.addAction("Unfavorite", lambda: self.favoriteItem(item, "unfavorite"))
elif itemType == "album" and hasClient:
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.addSeparator()
menu.addAction("Favorite Album", lambda: self.favoriteItem(item, "favorite"))
menu.addAction("Unfavorite Album", lambda: self.favoriteItem(item, "unfavorite"))
elif itemType == "artist" and hasClient:
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.addSeparator()
menu.addAction("Favorite Artist", lambda: self.favoriteItem(item, "favorite"))
menu.addAction("Unfavorite Artist", lambda: self.favoriteItem(item, "unfavorite"))
if not menu.actions():
return
globalPos = self.resultsTree.viewport().mapToGlobal(position)
menu.exec(globalPos)
def enqueueCollection(self, parentWindow, item: QTreeWidgetItem):
"""Queue albums/artists from search results via parent helpers when possible."""
if hasattr(parentWindow, "addItemToQueue"):
parentWindow.addItemToQueue(item)
def favoriteItem(self, item: QTreeWidgetItem, action: str):
"""Favorite or unfavorite an item directly via the client."""
data: Dict = item.data(0, Qt.UserRole) or {}
itemType = data.get("type")
parentWindow = self.parent()
targetType = None
targetId = None
label = item.text(0)
if itemType == "song":
song: Optional[Song] = data.get("song")
if song:
targetType = "song"
targetId = song.id
label = f"{song.title} by {song.artist}"
elif itemType == "album":
album: Optional[Album] = data.get("album")
targetType = "album"
targetId = data.get("id")
if album:
label = album.name
elif itemType == "artist":
artist: Optional[Artist] = data.get("artist")
targetType = "artist"
targetId = data.get("id")
if artist:
label = artist.name
if not targetType or not targetId or not self.client:
return
try:
if action == "favorite":
self.client.star(targetId, targetType)
message = f"{label} added to favorites"
else:
self.client.unstar(targetId, targetType)
message = f"{label} removed from favorites"
if hasattr(parentWindow, "announceLive"):
parentWindow.announceLive(message)
elif self.statusLabel:
self.statusLabel.setText(message)
except Exception as e:
AccessibleTextDialog.showError("Favorite Error", str(e), parent=self)