More functionality added included playlist management.
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user