feat: enable rich text links for metadata fields and add search functionality

- Introduced clickable metadata links (tracks, albums, artists, playlists, videos).
- Left-click triggers in-app search; Ctrl+click or right-click opens browser search.
- Enhanced label interactions and added tooltips for actionable fields.
- Simplified artist, album, and playlist rendering with rich links.
- Extended search functionality with URL-safe encoding and type mapping.
This commit is contained in:
Warry
2025-11-29 14:15:51 +01:00
parent c4d999e8dc
commit 8905ac7e75
2 changed files with 175 additions and 78 deletions
+47
View File
@@ -48,6 +48,7 @@ import contextlib
import math import math
import sys import sys
import time import time
import urllib.parse
from collections.abc import Callable, Iterable, Sequence from collections.abc import Callable, Iterable, Sequence
from typing import Any from typing import Any
@@ -863,6 +864,52 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
# Managers # Managers
self.queue_manager.connect_signals() self.queue_manager.connect_signals()
self.playlist_manager.connect_signals() self.playlist_manager.connect_signals()
self.info_tab_widget.s_search_in_app.connect(self.on_search_in_app)
self.info_tab_widget.s_search_in_browser.connect(self.on_search_in_browser)
def on_search_in_app(self, search_term: str, search_type: str):
"""Perform a search within the application, selecting the correct category."""
self.l_search.setText(search_term)
# Map the search type string to the corresponding class
search_type_map = {
"artist": Artist,
"album": Album,
"track": Track,
"video": Video,
"playlist": Playlist,
}
# Default to the current selection if the type is unknown
search_category = search_type_map.get(search_type.lower(), self.cb_search_type.currentData())
# Find the index of the category in the combobox and set it
for i in range(self.cb_search_type.count()):
if self.cb_search_type.itemData(i) == search_category:
self.cb_search_type.setCurrentIndex(i)
break
self.search_manager.search_populate_results(search_term, search_category)
def on_search_in_browser(self, search_term: str, search_type: str):
"""Open a search in the default web browser."""
safe_term = urllib.parse.quote(search_term)
# Map the search type to the URL path component
search_path_map = {
"artist": "artists",
"album": "albums",
"track": "tracks",
"video": "videos",
"playlist": "playlists",
}
# Use a generic search if the type is not in the map
search_path = search_path_map.get(search_type.lower())
if search_path:
url = QtCore.QUrl(f"https://listen.tidal.com/search/{search_path}?q={safe_term}")
else:
url = QtCore.QUrl(f"https://listen.tidal.com/search?q={safe_term}")
QtGui.QDesktopServices.openUrl(url)
def on_logout(self) -> None: def on_logout(self) -> None:
"""Log out from TIDAL and close the application.""" """Log out from TIDAL and close the application."""
+128 -78
View File
@@ -80,8 +80,8 @@ class TrackInfoFormatter:
artists = getattr(media, "artists", []) artists = getattr(media, "artists", [])
for artist in artists: for artist in artists:
name = InfoTabWidget._get_artist_name(artist) name = TrackInfoFormatter._get_artist_name(artist)
roles = InfoTabWidget._format_artist_roles(artist) roles = TrackInfoFormatter._format_artist_roles(artist)
if roles and roles[0] != "main": if roles and roles[0] != "main":
name = f"{name} ({', '.join(roles)})" name = f"{name} ({', '.join(roles)})"
@@ -207,6 +207,8 @@ class InfoTabWidget(QtCore.QObject):
s_update_cover: QtCore.Signal = QtCore.Signal(str) s_update_cover: QtCore.Signal = QtCore.Signal(str)
s_spinner_start: QtCore.Signal = QtCore.Signal() s_spinner_start: QtCore.Signal = QtCore.Signal()
s_spinner_stop: QtCore.Signal = QtCore.Signal() s_spinner_stop: QtCore.Signal = QtCore.Signal()
s_search_in_app: QtCore.Signal = QtCore.Signal(str, str)
s_search_in_browser: QtCore.Signal = QtCore.Signal(str, str)
def _setup_search_roots(self, parent_widget: QtWidgets.QTabWidget | None) -> list: def _setup_search_roots(self, parent_widget: QtWidgets.QTabWidget | None) -> list:
"""Setup list of widget roots to search for UI elements.""" """Setup list of widget roots to search for UI elements."""
@@ -307,6 +309,60 @@ class InfoTabWidget(QtCore.QObject):
# connect signals # connect signals
self._connect_signals() self._connect_signals()
# Make labels clickable using linkActivated signal
clickable_labels = [self.lbl_title, self.lbl_artists, self.lbl_album]
for label in clickable_labels:
label.setTextFormat(QtCore.Qt.RichText)
label.setOpenExternalLinks(False) # We handle links manually
label.linkActivated.connect(self._on_link_activated)
label.installEventFilter(self) # For right-click handling
label.setToolTip("Left-click to search in app, Ctrl+click or right-click to search in browser.")
def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool:
"""Filter events for clickable labels to handle right-clicks."""
if event.type() == QtCore.QEvent.Type.MouseButtonPress and isinstance(watched, QtWidgets.QLabel):
event: QtGui.QMouseEvent
if event.button() == QtCore.Qt.MouseButton.RightButton:
# On right-click, find the link under the cursor
anchor = watched.anchorAt(event.pos())
if anchor:
self._on_link_activated(anchor, right_click=True)
return True # Event handled
return super().eventFilter(watched, event)
def _create_link(self, search_type: str, text: str) -> str:
"""Create an HTML link for search, using a specific color."""
if not text or text == "—":
return "—"
# The link format is "search:type:text"
link_target = f"search:{search_type}:{text}"
# Use a standard link color; Qt will handle styling.
return f"<a href='{link_target}' style='color:#367af6;'>{text}</a>"
@QtCore.Slot(str)
def _on_link_activated(self, link: str, right_click: bool = False) -> None:
"""Handle clicks on rich text links in labels."""
if not link:
return
parts = link.split(":", 2)
if len(parts) < 3 or parts[0] != "search":
return
search_type = parts[1]
search_term = parts[2]
modifiers = QtWidgets.QApplication.keyboardModifiers()
is_browser_action = right_click or (modifiers & QtCore.Qt.KeyboardModifier.ControlModifier)
if is_browser_action:
# Ctrl+Click or Right-Click: Open in browser
self.s_search_in_browser.emit(search_term, search_type)
else:
# Left-Click: Search in app
self.s_search_in_app.emit(search_term, search_type)
def _connect_signals(self) -> None: def _connect_signals(self) -> None:
"""Connect internal signals to slots.""" """Connect internal signals to slots."""
self.s_update_details.connect(self._on_update_details) self.s_update_details.connect(self._on_update_details)
@@ -520,21 +576,28 @@ class InfoTabWidget(QtCore.QObject):
""" """
# Title and version # Title and version
title = name_builder_title(track) title = name_builder_title(track)
self.lbl_title.setText(self._create_link("track", title))
version = getattr(track, "version", None) version = getattr(track, "version", None)
# Use _safe_str for all displayed values to avoid passing non-str types to QLabel.setText
self.lbl_title.setText(safe_str(title))
self.lbl_version.setText(safe_str(version)) self.lbl_version.setText(safe_str(version))
# Artists # Artists
artists = TrackInfoFormatter.format_artists(track) artist_parts = []
self.lbl_artists.setText(safe_str(artists)) if hasattr(track, "artists") and track.artists:
for artist in track.artists:
name = TrackInfoFormatter._get_artist_name(artist)
roles = TrackInfoFormatter._format_artist_roles(artist)
link = self._create_link("artist", name)
if roles and roles[0] != "main":
artist_parts.append(f"{link} ({', '.join(roles)})")
else:
artist_parts.append(link)
self.lbl_artists.setText(", ".join(artist_parts) if artist_parts else "—")
# Album name (prefer album object's name) # Album name (prefer album object's name)
album_name = None album_name = None
if hasattr(track, "album") and track.album: if hasattr(track, "album") and track.album:
album_name = getattr(track.album, "name", None) or None album_name = getattr(track.album, "name", None) or None
self.lbl_album.setText(safe_str(album_name)) self.lbl_album.setText(self._create_link("album", album_name))
# Codec & Bitrate # Codec & Bitrate
codec = TrackInfoFormatter.format_codec(track) codec = TrackInfoFormatter.format_codec(track)
@@ -557,22 +620,18 @@ class InfoTabWidget(QtCore.QObject):
self.lbl_popularity.setText(safe_str(popularity)) self.lbl_popularity.setText(safe_str(popularity))
# BPM: Show loading indicator, will be updated when extras are loaded # BPM: Show loading indicator, will be updated when extras are loaded
# The BPM comes from async extras, not from the track object itself
self.lbl_bpm.setText("⏳ Loading...") self.lbl_bpm.setText("⏳ Loading...")
# ISRC # ISRC
isrc = getattr(track, "isrc", None) isrc = getattr(track, "isrc", None)
self.lbl_isrc.setText(safe_str(isrc)) self.lbl_isrc.setText(safe_str(isrc))
# Track Number: try multiple possible attribute names and album track info # Track Number
track_number = find_attr(track, "track_number", "tracknumber", "number", "position", "track") or None track_number = find_attr(track, "track_number", "tracknumber", "number", "position", "track") or None
if track_number is None and hasattr(track, "album") and track.album: if track_number is None and hasattr(track, "album") and track.album:
# Some album objects contain track indexing information
track_number = find_attr(track.album, "track_number", "tracknumber", "number") or None track_number = find_attr(track.album, "track_number", "tracknumber", "number") or None
self.lbl_track_number.setText(safe_str(track_number)) self.lbl_track_number.setText(safe_str(track_number))
# Note: Label, Genres, Producers and Composers fields are not available in TIDAL API - removed from display
def _populate_video_details(self, video: Video) -> None: def _populate_video_details(self, video: Video) -> None:
"""Populate details for a Video. """Populate details for a Video.
@@ -581,27 +640,31 @@ class InfoTabWidget(QtCore.QObject):
""" """
# Title and version # Title and version
title = name_builder_title(video) title = name_builder_title(video)
self.lbl_title.setText(self._create_link("video", title))
version = getattr(video, "version", None) version = getattr(video, "version", None)
self.lbl_title.setText(safe_str(title))
self.lbl_version.setText(safe_str(version)) self.lbl_version.setText(safe_str(version))
# Artists (from credits if available) # Artists
artists = extract_names_from_mixed( artists_str = extract_names_from_mixed(
getattr(video, "credits", None), match_types=("performer", "artist", "actor") getattr(video, "credits", None), match_types=("performer", "artist", "actor")
) )
if not artists: if not artists_str:
# Fallback to regular artist field artist_parts = []
artists = TrackInfoFormatter.format_artists(video) if hasattr(video, "artists") and video.artists:
self.lbl_artists.setText(safe_str(artists)) for artist in video.artists:
name = TrackInfoFormatter._get_artist_name(artist)
artist_parts.append(self._create_link("artist", name))
self.lbl_artists.setText(", ".join(artist_parts) if artist_parts else "—")
else:
self.lbl_artists.setText(self._create_link("artist", artists_str))
# Album name (from album object if available) # Album name
album_name = None album_name = None
if hasattr(video, "album") and video.album: if hasattr(video, "album") and video.album:
album_name = getattr(video.album, "name", None) or None album_name = getattr(video.album, "name", None) or None
self.lbl_album.setText(safe_str(album_name)) self.lbl_album.setText(self._create_link("album", album_name))
# Codec & Bitrate (inherited from Track) # Codec & Bitrate
codec = TrackInfoFormatter.format_codec(video) codec = TrackInfoFormatter.format_codec(video)
self.lbl_codec.setText(safe_str(codec)) self.lbl_codec.setText(safe_str(codec))
bitrate = TrackInfoFormatter.format_bitrate(video) bitrate = TrackInfoFormatter.format_bitrate(video)
@@ -611,7 +674,7 @@ class InfoTabWidget(QtCore.QObject):
duration = TrackInfoFormatter.format_duration(getattr(video, "duration", None)) duration = TrackInfoFormatter.format_duration(getattr(video, "duration", None))
self.lbl_duration.setText(safe_str(duration)) self.lbl_duration.setText(safe_str(duration))
# Release date (from album if exists) # Release date
release_date = None release_date = None
if hasattr(video, "album") and video.album and hasattr(video.album, "release_date"): if hasattr(video, "album") and video.album and hasattr(video.album, "release_date"):
release_date = TrackInfoFormatter.format_date(video.album.release_date) release_date = TrackInfoFormatter.format_date(video.album.release_date)
@@ -621,15 +684,12 @@ class InfoTabWidget(QtCore.QObject):
popularity = getattr(video, "popularity", None) popularity = getattr(video, "popularity", None)
self.lbl_popularity.setText(safe_str(popularity)) self.lbl_popularity.setText(safe_str(popularity))
# ISRC (from audio track if available) # ISRC
isrc = getattr(video, "isrc", None) isrc = getattr(video, "isrc", None)
if not isrc and hasattr(video, "track"): if not isrc and hasattr(video, "track"):
isrc = getattr(video.track, "isrc", None) isrc = getattr(video.track, "isrc", None)
self.lbl_isrc.setText(safe_str(isrc)) self.lbl_isrc.setText(safe_str(isrc))
# Note: Video-specific fields (resolution, frame rate) and genres are not displayed
# as the required labels are not available in the UI
def _populate_album_details(self, album: Album) -> None: def _populate_album_details(self, album: Album) -> None:
"""Populate details for an Album. """Populate details for an Album.
@@ -638,26 +698,28 @@ class InfoTabWidget(QtCore.QObject):
""" """
# Title and version # Title and version
title = name_builder_title(album) title = name_builder_title(album)
self.lbl_title.setText(self._create_link("album", title))
version = getattr(album, "version", None) version = getattr(album, "version", None)
self.lbl_title.setText(safe_str(title))
self.lbl_version.setText(safe_str(version)) self.lbl_version.setText(safe_str(version))
# Artists (from album object) # Artists
artists = TrackInfoFormatter.format_artists(album) artist_parts = []
self.lbl_artists.setText(safe_str(artists)) if hasattr(album, "artists") and album.artists:
for artist in album.artists:
name = TrackInfoFormatter._get_artist_name(artist)
artist_parts.append(self._create_link("artist", name))
self.lbl_artists.setText(", ".join(artist_parts) if artist_parts else "—")
# Album name # Album name (same as title for an album)
album_name = getattr(album, "name", None) self.lbl_album.setText(self._create_link("album", title))
self.lbl_album.setText(safe_str(album_name))
# Codec & Bitrate: not typically relevant for albums, but can inherit from first track # Codec & Bitrate
codec = TrackInfoFormatter.format_codec(album) codec = TrackInfoFormatter.format_codec(album)
self.lbl_codec.setText(safe_str(codec)) self.lbl_codec.setText(safe_str(codec))
bitrate = TrackInfoFormatter.format_bitrate(album) bitrate = TrackInfoFormatter.format_bitrate(album)
self.lbl_bitrate.setText(safe_str(bitrate)) self.lbl_bitrate.setText(safe_str(bitrate))
# Duration: total duration of the album # Duration
duration = TrackInfoFormatter.format_duration(getattr(album, "duration", None)) duration = TrackInfoFormatter.format_duration(getattr(album, "duration", None))
self.lbl_duration.setText(safe_str(duration)) self.lbl_duration.setText(safe_str(duration))
@@ -665,15 +727,12 @@ class InfoTabWidget(QtCore.QObject):
release_date = TrackInfoFormatter.format_date(getattr(album, "release_date", None)) release_date = TrackInfoFormatter.format_date(getattr(album, "release_date", None))
self.lbl_release_date.setText(safe_str(release_date)) self.lbl_release_date.setText(safe_str(release_date))
# Popularity: album popularity if available # Popularity
popularity = getattr(album, "popularity", None) popularity = getattr(album, "popularity", None)
self.lbl_popularity.setText(safe_str(popularity)) self.lbl_popularity.setText(safe_str(popularity))
# ISRC: not typically available at album level, clear field
self.lbl_isrc.setText("—") self.lbl_isrc.setText("—")
# Note: Genres, Label, Producers and Composers fields are not available in TIDAL API - removed from display
def _extract_playlist_artists(self, playlist: Playlist | Mix) -> str: def _extract_playlist_artists(self, playlist: Playlist | Mix) -> str:
"""Extract and format artists from playlist tracks.""" """Extract and format artists from playlist tracks."""
if not hasattr(playlist, "tracks") or not isinstance(playlist.tracks, list | Iterable): if not hasattr(playlist, "tracks") or not isinstance(playlist.tracks, list | Iterable):
@@ -681,13 +740,15 @@ class InfoTabWidget(QtCore.QObject):
all_artists = set() all_artists = set()
for track in playlist.tracks: for track in playlist.tracks:
if isinstance(track, Track | Video | Album): if isinstance(track, Track | Video | Album) and hasattr(track, "artists"):
artists = TrackInfoFormatter.format_artists(track) for artist in track.artists:
if artists and artists != "Unknown Artist": name = TrackInfoFormatter._get_artist_name(artist)
all_artists.update(artists.split(", ")) if name and name != "Unknown Artist":
all_artists.add(name)
if all_artists: if all_artists:
return ", ".join(list(all_artists)[:3]) artist_links = [self._create_link("artist", name) for name in list(all_artists)[:5]]
return ", ".join(artist_links)
return "—" return "—"
def _extract_playlist_release_date(self, playlist: Playlist | Mix) -> str: def _extract_playlist_release_date(self, playlist: Playlist | Mix) -> str:
@@ -712,26 +773,23 @@ class InfoTabWidget(QtCore.QObject):
""" """
# Title and version # Title and version
title = name_builder_title(playlist) title = name_builder_title(playlist)
self.lbl_title.setText(self._create_link("playlist", title))
version = getattr(playlist, "version", None) version = getattr(playlist, "version", None)
self.lbl_title.setText(safe_str(title))
self.lbl_version.setText(safe_str(version)) self.lbl_version.setText(safe_str(version))
# Artists: try to combine artists from all tracks # Artists
artists = self._extract_playlist_artists(playlist) artists_html = self._extract_playlist_artists(playlist)
self.lbl_artists.setText(safe_str(artists)) self.lbl_artists.setText(artists_html)
# Album name: prefer playlist name, then mix/album name # Album name (playlist title)
album_name = getattr(playlist, "name", None) album_name = getattr(playlist, "name", None)
if not album_name and hasattr(playlist, "album"): self.lbl_album.setText(self._create_link("playlist", album_name))
album_name = getattr(playlist.album, "name", None)
self.lbl_album.setText(safe_str(album_name))
# Codec & Bitrate: not typically relevant for playlists, clear fields # Codec & Bitrate
self.lbl_codec.setText("—") self.lbl_codec.setText("—")
self.lbl_bitrate.setText("—") self.lbl_bitrate.setText("—")
# Duration: total duration of the playlist/mix # Duration
duration = TrackInfoFormatter.format_duration(getattr(playlist, "duration", None)) duration = TrackInfoFormatter.format_duration(getattr(playlist, "duration", None))
self.lbl_duration.setText(safe_str(duration)) self.lbl_duration.setText(safe_str(duration))
@@ -739,15 +797,12 @@ class InfoTabWidget(QtCore.QObject):
release_date = self._extract_playlist_release_date(playlist) release_date = self._extract_playlist_release_date(playlist)
self.lbl_release_date.setText(safe_str(release_date)) self.lbl_release_date.setText(safe_str(release_date))
# Popularity: playlist popularity if available # Popularity
popularity = getattr(playlist, "popularity", None) popularity = getattr(playlist, "popularity", None)
self.lbl_popularity.setText(safe_str(popularity)) self.lbl_popularity.setText(safe_str(popularity))
# ISRC: not typically available at playlist level, clear field
self.lbl_isrc.setText("—") self.lbl_isrc.setText("—")
# Note: Genres, Label, Producers and Composers fields are not available in TIDAL API - removed from display
def _extract_artist_album_name(self, artist: Artist) -> str | None: def _extract_artist_album_name(self, artist: Artist) -> str | None:
"""Extract first album name from artist's albums.""" """Extract first album name from artist's albums."""
if not hasattr(artist, "albums") or not isinstance(artist.albums, list | Iterable): if not hasattr(artist, "albums") or not isinstance(artist.albums, list | Iterable):
@@ -789,13 +844,11 @@ class InfoTabWidget(QtCore.QObject):
Args: Args:
artist (Artist): The artist object. artist (Artist): The artist object.
""" """
# Name: prefer artist's own name, fallback to common name # Name
name = getattr(artist, "name", None) name = getattr(artist, "name", None)
if not name and hasattr(artist, "common_name"): self.lbl_title.setText(self._create_link("artist", name))
name = getattr(artist.common_name, "name", None)
self.lbl_title.setText(safe_str(name))
# Version: use artist type or role if available # Version
version = None version = None
if hasattr(artist, "type"): if hasattr(artist, "type"):
version = getattr(artist.type, "name", None) version = getattr(artist.type, "name", None)
@@ -803,31 +856,28 @@ class InfoTabWidget(QtCore.QObject):
version = getattr(artist.role, "name", None) version = getattr(artist.role, "name", None)
self.lbl_version.setText(safe_str(version)) self.lbl_version.setText(safe_str(version))
# Artists: typically not relevant for single artist, clear field # Artists
self.lbl_artists.setText("—") self.lbl_artists.setText("—")
# Album name: try to use first album name if available # Album name
album_name = self._extract_artist_album_name(artist) album_name = self._extract_artist_album_name(artist)
self.lbl_album.setText(safe_str(album_name)) self.lbl_album.setText(self._create_link("album", album_name))
# Codec & Bitrate: not relevant for artists, clear fields # Codec & Bitrate
self.lbl_codec.setText("—") self.lbl_codec.setText("—")
self.lbl_bitrate.setText("—") self.lbl_bitrate.setText("—")
# Duration: total duration of all albums/tracks by the artist # Duration
total_duration = self._calculate_artist_total_duration(artist) total_duration = self._calculate_artist_total_duration(artist)
duration = TrackInfoFormatter.format_duration(total_duration) duration = TrackInfoFormatter.format_duration(total_duration)
self.lbl_duration.setText(safe_str(duration)) self.lbl_duration.setText(safe_str(duration))
# Release date: try to use release date of the first album # Release date
release_date = self._extract_artist_release_date(artist) release_date = self._extract_artist_release_date(artist)
self.lbl_release_date.setText(safe_str(release_date)) self.lbl_release_date.setText(safe_str(release_date))
# Popularity: artist popularity if available # Popularity
popularity = getattr(artist, "popularity", None) popularity = getattr(artist, "popularity", None)
self.lbl_popularity.setText(safe_str(popularity)) self.lbl_popularity.setText(safe_str(popularity))
# ISRC: not typically available at artist level, clear field
self.lbl_isrc.setText("—") self.lbl_isrc.setText("—")
# Note: Genres, Label, Producers and Composers fields are not available in TIDAL API - removed from display