More functionality added included playlist management.
This commit is contained in:
@@ -2,11 +2,18 @@
|
|||||||
|
|
||||||
Accessible Navidrome desktop client built with PySide6. Keyboard-first controls, live region announcements, and XDG-compliant configuration keep the app friendly for screen readers and shared systems.
|
Accessible Navidrome desktop client built with PySide6. Keyboard-first controls, live region announcements, and XDG-compliant configuration keep the app friendly for screen readers and shared systems.
|
||||||
|
|
||||||
|
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
|
## Features
|
||||||
- Browse artists, albums, playlists, and genres from your Navidrome server
|
- Browse artists, albums, playlists, and genres from your Navidrome server
|
||||||
- Keyboard-driven playback controls with live announcements
|
- Keyboard-driven playback controls with live announcements
|
||||||
- Queue management (play now, next/previous, clear/remove)
|
- Queue management (play now, next/previous, clear/remove)
|
||||||
- Track-change announcements (toggle in View → Announce Track Changes)
|
- 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
|
||||||
|
- 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
|
||||||
|
- 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)
|
- Desktop integrations via MPRIS (playback control, metadata, notifications)
|
||||||
- Config, data, and cache saved under XDG paths (`~/.config/stormux/navipy`, etc.)
|
- Config, data, and cache saved under XDG paths (`~/.config/stormux/navipy`, etc.)
|
||||||
- Does not log credentials; server secrets live in `servers.json`
|
- Does not log credentials; server secrets live in `servers.json`
|
||||||
@@ -31,7 +38,7 @@ Logs are written to `~/.local/share/stormux/navipy/navipy.log`.
|
|||||||
|
|
||||||
## Configuration and Data Paths
|
## Configuration and Data Paths
|
||||||
- Config: `~/.config/stormux/navipy/settings.json` and `servers.json`
|
- Config: `~/.config/stormux/navipy/settings.json` and `servers.json`
|
||||||
Honors `$XDG_CONFIG_HOME`. Reads legacy `~/.config/navipy` if present.
|
Honors `$XDG_CONFIG_HOME`. Default path is `~/.config/navipy`.
|
||||||
- Data (logs, runtime files): `~/.local/share/stormux/navipy/` (`$XDG_DATA_HOME` respected)
|
- Data (logs, runtime files): `~/.local/share/stormux/navipy/` (`$XDG_DATA_HOME` respected)
|
||||||
- Cache: `~/.cache/stormux/navipy/` (`$XDG_CACHE_HOME` respected)
|
- Cache: `~/.cache/stormux/navipy/` (`$XDG_CACHE_HOME` respected)
|
||||||
|
|
||||||
@@ -40,7 +47,7 @@ Logs are written to `~/.local/share/stormux/navipy/navipy.log`.
|
|||||||
- Navigation: `Z` previous, `B` next, `Ctrl+Left/Right` previous/next, `Alt+S` shuffle, `Alt+R` repeat
|
- Navigation: `Z` previous, `B` next, `Ctrl+Left/Right` previous/next, `Alt+S` shuffle, `Alt+R` repeat
|
||||||
- Announcements: `Alt+C` announce current track (live region), `Ctrl+T` announce track (dialog), `Ctrl+P` announce position
|
- Announcements: `Alt+C` announce current track (live region), `Ctrl+T` announce track (dialog), `Ctrl+P` announce position
|
||||||
- Volume: `Ctrl+Up/Down` or `0/9` to adjust
|
- Volume: `Ctrl+Up/Down` or `0/9` to adjust
|
||||||
- Library/Search: `Ctrl+O` connect, `F5` refresh, `Ctrl+F` search
|
- Library/Search: `Ctrl+O` connect, `F5` refresh, `Ctrl+F` search, `Shift+F10` or Menu key for item context menu
|
||||||
|
|
||||||
## Playback Persistence
|
## Playback Persistence
|
||||||
- Volume, shuffle, and repeat preferences persist in `settings.json`
|
- Volume, shuffle, and repeat preferences persist in `settings.json`
|
||||||
|
|||||||
@@ -257,6 +257,42 @@ class SubsonicClient:
|
|||||||
genresData = response.get('genres', {})
|
genresData = response.get('genres', {})
|
||||||
return [Genre.fromDict(g) for g in genresData.get('genre', [])]
|
return [Genre.fromDict(g) for g in genresData.get('genre', [])]
|
||||||
|
|
||||||
|
def getAlbumList(self, listType: str, size: int = 20, offset: int = 0,
|
||||||
|
musicFolderId: str = None, genre: str = None,
|
||||||
|
fromYear: int = None, toYear: int = None) -> List[Album]:
|
||||||
|
"""
|
||||||
|
Get a list of albums by criteria (newest, frequent, recent, random, highest, alphabetical).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
listType: Album list type (e.g., 'newest', 'frequent', 'recent', 'random', 'highest')
|
||||||
|
size: Number of albums to return
|
||||||
|
offset: Offset for pagination
|
||||||
|
musicFolderId: Optional folder filter
|
||||||
|
genre: Optional genre filter
|
||||||
|
fromYear: Optional starting year
|
||||||
|
toYear: Optional ending year
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Album objects
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
'type': listType,
|
||||||
|
'size': size,
|
||||||
|
'offset': offset
|
||||||
|
}
|
||||||
|
if musicFolderId:
|
||||||
|
params['musicFolderId'] = musicFolderId
|
||||||
|
if genre:
|
||||||
|
params['genre'] = genre
|
||||||
|
if fromYear:
|
||||||
|
params['fromYear'] = fromYear
|
||||||
|
if toYear:
|
||||||
|
params['toYear'] = toYear
|
||||||
|
|
||||||
|
response = self._makeRequest('getAlbumList2', params)
|
||||||
|
albumsData = response.get('albumList2', {})
|
||||||
|
return [Album.fromDict(a) for a in albumsData.get('album', [])]
|
||||||
|
|
||||||
def getSongsByGenre(self, genre: str, count: int = 100, offset: int = 0) -> List[Song]:
|
def getSongsByGenre(self, genre: str, count: int = 100, offset: int = 0) -> List[Song]:
|
||||||
"""
|
"""
|
||||||
Get songs by genre
|
Get songs by genre
|
||||||
@@ -518,6 +554,52 @@ class SubsonicClient:
|
|||||||
'songs': [Song.fromDict(s) for s in starredData.get('song', [])]
|
'songs': [Song.fromDict(s) for s in starredData.get('song', [])]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ==================== Recommendations ====================
|
||||||
|
|
||||||
|
def getTopSongs(self, artist: str, count: int = 50) -> List[Song]:
|
||||||
|
"""
|
||||||
|
Get top songs for an artist
|
||||||
|
|
||||||
|
Args:
|
||||||
|
artist: Artist name
|
||||||
|
count: Number of songs to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Song objects
|
||||||
|
"""
|
||||||
|
if not artist:
|
||||||
|
return []
|
||||||
|
params = {'artist': artist, 'count': count}
|
||||||
|
response = self._makeRequest('getTopSongs', params)
|
||||||
|
topData = response.get('topSongs', {})
|
||||||
|
return [Song.fromDict(s) for s in topData.get('song', [])]
|
||||||
|
|
||||||
|
def getSimilarSongs(self, songId: str, count: int = 50) -> List[Song]:
|
||||||
|
"""
|
||||||
|
Get songs similar to a given song
|
||||||
|
|
||||||
|
Args:
|
||||||
|
songId: ID of the seed song
|
||||||
|
count: Number of songs to return
|
||||||
|
"""
|
||||||
|
if not songId:
|
||||||
|
return []
|
||||||
|
params = {'id': songId, 'count': count}
|
||||||
|
response = self._makeRequest('getSimilarSongs2', params)
|
||||||
|
data = response.get('similarSongs2', {})
|
||||||
|
return [Song.fromDict(s) for s in data.get('song', [])]
|
||||||
|
|
||||||
|
def getNowPlaying(self) -> List[Song]:
|
||||||
|
"""
|
||||||
|
Get songs currently being played by all users
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Song objects
|
||||||
|
"""
|
||||||
|
response = self._makeRequest('getNowPlaying')
|
||||||
|
nowPlayingData = response.get('nowPlaying', {})
|
||||||
|
return [Song.fromDict(s) for s in nowPlayingData.get('entry', [])]
|
||||||
|
|
||||||
# ==================== Lyrics ====================
|
# ==================== Lyrics ====================
|
||||||
|
|
||||||
def getLyrics(self, artist: str = None, title: str = None) -> Optional[str]:
|
def getLyrics(self, artist: str = None, title: str = None) -> Optional[str]:
|
||||||
|
|||||||
+32
-4
@@ -4,6 +4,7 @@ Configuration management with XDG compliance
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
@@ -106,16 +107,14 @@ class Settings:
|
|||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
"""Save settings to file"""
|
"""Save settings to file"""
|
||||||
try:
|
try:
|
||||||
with open(self.configFile, 'w', encoding='utf-8') as f:
|
self._write_secure_json(self.configFile, self._settings)
|
||||||
json.dump(self._settings, f, indent=2)
|
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
print(f"Failed to save settings: {e}")
|
print(f"Failed to save settings: {e}")
|
||||||
|
|
||||||
def saveServers(self) -> None:
|
def saveServers(self) -> None:
|
||||||
"""Save server configurations to file"""
|
"""Save server configurations to file"""
|
||||||
try:
|
try:
|
||||||
with open(self.serversFile, 'w', encoding='utf-8') as f:
|
self._write_secure_json(self.serversFile, self._servers)
|
||||||
json.dump(self._servers, f, indent=2)
|
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
print(f"Failed to save servers: {e}")
|
print(f"Failed to save servers: {e}")
|
||||||
|
|
||||||
@@ -166,3 +165,32 @@ class Settings:
|
|||||||
def hasServers(self) -> bool:
|
def hasServers(self) -> bool:
|
||||||
"""Check if any servers are configured"""
|
"""Check if any servers are configured"""
|
||||||
return len(self._servers) > 0
|
return len(self._servers) > 0
|
||||||
|
|
||||||
|
def _write_secure_json(self, path: Path, payload: Any) -> None:
|
||||||
|
"""
|
||||||
|
Write JSON atomically with user-only permissions to avoid leaking credentials.
|
||||||
|
"""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tempFile: Optional[Path] = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
'w',
|
||||||
|
encoding='utf-8',
|
||||||
|
dir=path.parent,
|
||||||
|
prefix=path.name,
|
||||||
|
suffix='.tmp',
|
||||||
|
delete=False
|
||||||
|
) as tmp:
|
||||||
|
tempFile = Path(tmp.name)
|
||||||
|
os.chmod(tempFile, 0o600)
|
||||||
|
json.dump(payload, tmp, indent=2)
|
||||||
|
tmp.flush()
|
||||||
|
os.fsync(tmp.fileno())
|
||||||
|
os.replace(tempFile, path)
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
finally:
|
||||||
|
if tempFile and tempFile.exists():
|
||||||
|
try:
|
||||||
|
tempFile.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
+314
-2
@@ -3,11 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QMenu,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
@@ -19,7 +20,7 @@ from PySide6.QtWidgets import (
|
|||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import QPoint, Qt
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QAction,
|
QAction,
|
||||||
QAccessible,
|
QAccessible,
|
||||||
@@ -37,6 +38,7 @@ from src.config.settings import Settings
|
|||||||
from src.managers.playback_manager import PlaybackManager
|
from src.managers.playback_manager import PlaybackManager
|
||||||
from src.integrations.mpris import MprisService
|
from src.integrations.mpris import MprisService
|
||||||
from src.widgets.accessible_text_dialog import AccessibleTextDialog
|
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.search_dialog import SearchDialog
|
||||||
from src.widgets.server_dialog import ServerDialog
|
from src.widgets.server_dialog import ServerDialog
|
||||||
|
|
||||||
@@ -225,6 +227,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.libraryTree.setAccessibleName("Library Tree")
|
self.libraryTree.setAccessibleName("Library Tree")
|
||||||
self.libraryTree.setRootIsDecorated(True)
|
self.libraryTree.setRootIsDecorated(True)
|
||||||
self.libraryTree.setItemsExpandable(True)
|
self.libraryTree.setItemsExpandable(True)
|
||||||
|
self.libraryTree.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
self.libraryTree.pageStep = self.pageStep
|
self.libraryTree.pageStep = self.pageStep
|
||||||
self.libraryTree.setEnabled(False)
|
self.libraryTree.setEnabled(False)
|
||||||
leftLayout.addWidget(self.libraryTree)
|
leftLayout.addWidget(self.libraryTree)
|
||||||
@@ -333,6 +336,7 @@ class MainWindow(QMainWindow):
|
|||||||
"""Connect signals after widgets exist."""
|
"""Connect signals after widgets exist."""
|
||||||
self.libraryTree.itemExpanded.connect(self.onLibraryExpanded)
|
self.libraryTree.itemExpanded.connect(self.onLibraryExpanded)
|
||||||
self.libraryTree.itemActivated.connect(self.onLibraryActivated)
|
self.libraryTree.itemActivated.connect(self.onLibraryActivated)
|
||||||
|
self.libraryTree.customContextMenuRequested.connect(self.onLibraryContextMenu)
|
||||||
|
|
||||||
self.playButton.clicked.connect(self.playPauseAction.trigger)
|
self.playButton.clicked.connect(self.playPauseAction.trigger)
|
||||||
self.prevButton.clicked.connect(self.previousAction.trigger)
|
self.prevButton.clicked.connect(self.previousAction.trigger)
|
||||||
@@ -501,6 +505,18 @@ class MainWindow(QMainWindow):
|
|||||||
self.artistsRoot.setExpanded(True)
|
self.artistsRoot.setExpanded(True)
|
||||||
self.libraryTree.addTopLevelItem(self.artistsRoot)
|
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 = QTreeWidgetItem(["Playlists"])
|
||||||
self.playlistsRoot.setData(0, Qt.UserRole, {"type": "playlists_root", "loaded": True})
|
self.playlistsRoot.setData(0, Qt.UserRole, {"type": "playlists_root", "loaded": True})
|
||||||
self.playlistsRoot.setExpanded(True)
|
self.playlistsRoot.setExpanded(True)
|
||||||
@@ -557,6 +573,49 @@ class MainWindow(QMainWindow):
|
|||||||
item.addChild(QTreeWidgetItem(["Loading songs..."]))
|
item.addChild(QTreeWidgetItem(["Loading songs..."]))
|
||||||
self.genresRoot.addChild(item)
|
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):
|
def onLibraryExpanded(self, item: QTreeWidgetItem):
|
||||||
"""Lazy-load children when an item is expanded."""
|
"""Lazy-load children when an item is expanded."""
|
||||||
data: Dict = item.data(0, Qt.UserRole) or {}
|
data: Dict = item.data(0, Qt.UserRole) or {}
|
||||||
@@ -574,6 +633,12 @@ class MainWindow(QMainWindow):
|
|||||||
self.populatePlaylistSongs(item, data["id"])
|
self.populatePlaylistSongs(item, data["id"])
|
||||||
elif itemType == "genre":
|
elif itemType == "genre":
|
||||||
self.populateGenreSongs(item, data["name"])
|
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
|
data["loaded"] = True
|
||||||
item.setData(0, Qt.UserRole, data)
|
item.setData(0, Qt.UserRole, data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -600,12 +665,77 @@ class MainWindow(QMainWindow):
|
|||||||
self.playArtist(data.get("id"), item)
|
self.playArtist(data.get("id"), item)
|
||||||
elif itemType == "artists_root":
|
elif itemType == "artists_root":
|
||||||
self.playAllArtists()
|
self.playAllArtists()
|
||||||
|
elif itemType == "favorites_root":
|
||||||
|
self.playFavorites()
|
||||||
elif itemType == "genre":
|
elif itemType == "genre":
|
||||||
self.playGenre(data.get("name"), item)
|
self.playGenre(data.get("name"), item)
|
||||||
|
elif itemType in ("album_list", "now_playing", "similar_current"):
|
||||||
|
item.setExpanded(not item.isExpanded())
|
||||||
else:
|
else:
|
||||||
# Toggle expansion for other nodes
|
# Toggle expansion for other nodes
|
||||||
item.setExpanded(not item.isExpanded())
|
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):
|
def populateArtistAlbums(self, parentItem: QTreeWidgetItem, artistId: str):
|
||||||
"""Add albums for a given artist."""
|
"""Add albums for a given artist."""
|
||||||
result = self.client.getArtist(artistId)
|
result = self.client.getArtist(artistId)
|
||||||
@@ -651,6 +781,50 @@ class MainWindow(QMainWindow):
|
|||||||
parentItem.addChild(songItem)
|
parentItem.addChild(songItem)
|
||||||
parentItem.setData(0, Qt.UserRole, {"type": "genre", "name": genreName, "loaded": True})
|
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
|
@staticmethod
|
||||||
def formatSongLabel(song: Song, includeAlbum: bool = False) -> str:
|
def formatSongLabel(song: Song, includeAlbum: bool = False) -> str:
|
||||||
"""Create a descriptive label for a song."""
|
"""Create a descriptive label for a song."""
|
||||||
@@ -687,6 +861,132 @@ class MainWindow(QMainWindow):
|
|||||||
data["loaded"] = True
|
data["loaded"] = True
|
||||||
parentItem.setData(0, Qt.UserRole, data)
|
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 ============
|
# ============ Search ============
|
||||||
|
|
||||||
def openSearchDialog(self):
|
def openSearchDialog(self):
|
||||||
@@ -852,6 +1152,18 @@ class MainWindow(QMainWindow):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
AccessibleTextDialog.showError("Playlist Error", str(e), parent=self)
|
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):
|
def onTrackChanged(self, song: Song):
|
||||||
"""Update UI when track changes."""
|
"""Update UI when track changes."""
|
||||||
self.nowPlayingInfo.setText(f"{song.title} — {song.artist} ({song.album})")
|
self.nowPlayingInfo.setText(f"{song.title} — {song.artist} ({song.album})")
|
||||||
|
|||||||
@@ -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 __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 (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QComboBox,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QListWidget,
|
QMenu,
|
||||||
QListWidgetItem,
|
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QTreeWidgetItem,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
)
|
)
|
||||||
|
|
||||||
from src.api.client import SubsonicClient
|
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
|
from src.widgets.accessible_text_dialog import AccessibleTextDialog
|
||||||
|
|
||||||
|
|
||||||
class SearchDialog(QDialog):
|
class SearchDialog(QDialog):
|
||||||
"""Simple dialog for searching the Navidrome library."""
|
"""Search dialog that mirrors the library tree interactions."""
|
||||||
|
|
||||||
songActivated = Signal(Song)
|
songActivated = Signal(Song)
|
||||||
|
|
||||||
@@ -35,16 +37,27 @@ class SearchDialog(QDialog):
|
|||||||
self.setWindowTitle("Search Library")
|
self.setWindowTitle("Search Library")
|
||||||
self.setMinimumSize(500, 420)
|
self.setMinimumSize(500, 420)
|
||||||
self.setupUi()
|
self.setupUi()
|
||||||
|
self._resultsLoaded = False
|
||||||
|
self.setModal(True)
|
||||||
|
|
||||||
def setupUi(self):
|
def setupUi(self):
|
||||||
layout = QVBoxLayout(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.setAccessibleName("Search Instructions")
|
||||||
self.instructions.setWordWrap(True)
|
self.instructions.setWordWrap(True)
|
||||||
layout.addWidget(self.instructions)
|
layout.addWidget(self.instructions)
|
||||||
|
|
||||||
inputLayout = QHBoxLayout()
|
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 = QLineEdit()
|
||||||
self.queryEdit.setAccessibleName("Search Query")
|
self.queryEdit.setAccessibleName("Search Query")
|
||||||
self.queryEdit.returnPressed.connect(self.runSearch)
|
self.queryEdit.returnPressed.connect(self.runSearch)
|
||||||
@@ -57,10 +70,15 @@ class SearchDialog(QDialog):
|
|||||||
|
|
||||||
layout.addLayout(inputLayout)
|
layout.addLayout(inputLayout)
|
||||||
|
|
||||||
self.resultsList = QListWidget()
|
self.resultsTree = AccessibleTreeWidget()
|
||||||
self.resultsList.setAccessibleName("Search Results")
|
self.resultsTree.setAccessibleName("Search Results Tree")
|
||||||
self.resultsList.itemActivated.connect(self.activateSelection)
|
self.resultsTree.setHeaderHidden(True)
|
||||||
layout.addWidget(self.resultsList)
|
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 = QLabel("")
|
||||||
self.statusLabel.setAccessibleName("Search Status")
|
self.statusLabel.setAccessibleName("Search Status")
|
||||||
@@ -78,31 +96,237 @@ class SearchDialog(QDialog):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.statusLabel.setText("Searching...")
|
self.statusLabel.setText("Searching...")
|
||||||
self.resultsList.clear()
|
self.resultsTree.clear()
|
||||||
|
self._resultsLoaded = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = self.client.search(query, artistCount=0, albumCount=0, songCount=50)
|
mode = self.filterSelect.currentText()
|
||||||
songs: List[Song] = results.get("songs", [])
|
if mode == "Genres":
|
||||||
if not songs:
|
songs = self.client.getSongsByGenre(query, count=200)
|
||||||
self.statusLabel.setText("No songs found.")
|
self.populateSongResults(songs, f"Songs in genre '{query}'")
|
||||||
return
|
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:
|
if self._resultsLoaded:
|
||||||
text = f"{song.title} — {song.artist} ({song.album}, {song.durationFormatted})"
|
self.resultsTree.setFocus()
|
||||||
item = QListWidgetItem(text)
|
first = self.resultsTree.topLevelItem(0)
|
||||||
item.setData(Qt.UserRole, song)
|
if first:
|
||||||
item.setData(Qt.AccessibleTextRole, text)
|
self.resultsTree.setCurrentItem(first)
|
||||||
self.resultsList.addItem(item)
|
self.statusLabel.setText("Search complete. Use the tree to play or queue items.")
|
||||||
|
else:
|
||||||
self.statusLabel.setText(f"Found {len(songs)} songs. Activate a result to play.")
|
self.statusLabel.setText("No results found.")
|
||||||
self.resultsList.setCurrentRow(0)
|
|
||||||
self.resultsList.setFocus()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
AccessibleTextDialog.showError("Search Error", str(e), parent=self)
|
AccessibleTextDialog.showError("Search Error", str(e), parent=self)
|
||||||
self.statusLabel.setText("Search failed.")
|
self.statusLabel.setText("Search failed.")
|
||||||
|
|
||||||
def activateSelection(self, item: QListWidgetItem):
|
def populateResults(self, results: Dict[str, List]):
|
||||||
"""Emit the chosen song and close the dialog."""
|
"""Populate the results tree from search3 data."""
|
||||||
song = item.data(Qt.UserRole)
|
artists: List[Artist] = results.get("artists", [])
|
||||||
if isinstance(song, Song):
|
albums: List[Album] = results.get("albums", [])
|
||||||
self.songActivated.emit(song)
|
songs: List[Song] = results.get("songs", [])
|
||||||
self.accept()
|
|
||||||
|
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