WIP
This commit is contained in:
@@ -5,6 +5,7 @@ import abc
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from functools import lru_cache, partial
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
@@ -145,18 +146,23 @@ class SearchResult:
|
||||
both server and local results.
|
||||
"""
|
||||
|
||||
class Kind(Enum):
|
||||
ARTIST = 0
|
||||
ALBUM = 1
|
||||
SONG = 2
|
||||
PLAYLIST = 2
|
||||
|
||||
ValueType = Union[Artist, Album, Song, Playlist]
|
||||
|
||||
def __init__(self, query: str = None):
|
||||
self.query = query
|
||||
self.similiarity_partial = partial(
|
||||
similarity_ratio, self.query.lower() if self.query else ""
|
||||
)
|
||||
self._artists: Dict[str, Artist] = {}
|
||||
self._albums: Dict[str, Album] = {}
|
||||
self._songs: Dict[str, Song] = {}
|
||||
self._playlists: Dict[str, Playlist] = {}
|
||||
self._results: Dict[Tuple[Kind, str], ValueType] = {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
fields = ("query", "_artists", "_albums", "_songs", "_playlists")
|
||||
fields = ("query", "_results")
|
||||
formatted_fields = map(lambda f: f"{f}={getattr(self, f)}", fields)
|
||||
return f"<SearchResult {' '.join(formatted_fields)}>"
|
||||
|
||||
@@ -165,76 +171,57 @@ class SearchResult:
|
||||
if results is None:
|
||||
return
|
||||
|
||||
member = f"_{result_type}"
|
||||
cast(Dict[str, Any], getattr(self, member)).update({r.id: r for r in results})
|
||||
for result in results:
|
||||
if result_type == 'artists':
|
||||
kind = self.Kind.ARTIST
|
||||
elif result_type == 'albums':
|
||||
kind = self.Kind.ALBUM
|
||||
elif result_type == 'songs':
|
||||
kind = self.Kind.SONG
|
||||
elif result_type == 'playlists':
|
||||
kind = self.Kind.PLAYLIST
|
||||
else:
|
||||
assert False
|
||||
|
||||
self._results[(kind, result.id)] = result
|
||||
|
||||
def update(self, other: "SearchResult"):
|
||||
assert self.query == other.query
|
||||
self._artists.update(other._artists)
|
||||
self._albums.update(other._albums)
|
||||
self._songs.update(other._songs)
|
||||
self._playlists.update(other._playlists)
|
||||
self._results.update(other._results)
|
||||
|
||||
_S = TypeVar("_S")
|
||||
def _transform(self, kind: Kind, value: ValueType) -> Tuple[str, ...]:
|
||||
if kind is self.Kind.ARTIST:
|
||||
return (value.name,)
|
||||
elif kind is self.Kind.ALBUM:
|
||||
return (value.name, value.artist and value.artist.name)
|
||||
elif kind is self.Kind.SONG:
|
||||
return (value.title, value.artist and value.artist.name)
|
||||
elif kind is self.Kind.PLAYLIST:
|
||||
return (value.name,)
|
||||
else:
|
||||
assert False
|
||||
|
||||
def _to_result(
|
||||
self,
|
||||
it: Dict[str, _S],
|
||||
transform: Callable[[_S], Tuple[Optional[str], ...]],
|
||||
) -> List[_S]:
|
||||
def get_results(self) -> List[Tuple[Kind, ValueType]]:
|
||||
assert self.query
|
||||
|
||||
all_results = []
|
||||
for value in it.values():
|
||||
transformed = transform(value)
|
||||
if any(t is None for t in transformed):
|
||||
for (kind, _), value in self._results.items():
|
||||
try:
|
||||
transformed = self._transform(kind, value)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
max_similarity = max(
|
||||
self.similiarity_partial(t.lower())
|
||||
for t in transformed
|
||||
if t is not None
|
||||
)
|
||||
(self.similiarity_partial(t.lower()) for t in transformed
|
||||
if t is not None),
|
||||
default=0)
|
||||
|
||||
if max_similarity < 60:
|
||||
continue
|
||||
|
||||
all_results.append((max_similarity, value))
|
||||
all_results.append((max_similarity, (kind, value)))
|
||||
|
||||
all_results.sort(key=lambda rx: rx[0], reverse=True)
|
||||
|
||||
result: List[SearchResult._S] = []
|
||||
for ratio, x in all_results:
|
||||
if ratio >= 60 and len(result) < 20:
|
||||
result.append(x)
|
||||
else:
|
||||
# No use going on, all the rest are less.
|
||||
break
|
||||
|
||||
logging.debug(similarity_ratio.cache_info())
|
||||
return result
|
||||
|
||||
@property
|
||||
def artists(self) -> List[Artist]:
|
||||
return self._to_result(self._artists, lambda a: (a.name,))
|
||||
|
||||
def _try_get_artist_name(self, obj: Union[Album, Song]) -> Optional[str]:
|
||||
try:
|
||||
assert obj.artist
|
||||
return obj.artist.name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@property
|
||||
def albums(self) -> List[Album]:
|
||||
return self._to_result(
|
||||
self._albums, lambda a: (a.name, self._try_get_artist_name(a))
|
||||
)
|
||||
|
||||
@property
|
||||
def songs(self) -> List[Song]:
|
||||
return self._to_result(
|
||||
self._songs, lambda s: (s.title, self._try_get_artist_name(s))
|
||||
)
|
||||
|
||||
@property
|
||||
def playlists(self) -> List[Playlist]:
|
||||
return self._to_result(self._playlists, lambda p: (p.name,))
|
||||
return [r for _, r in all_results]
|
||||
|
@@ -775,17 +775,15 @@ class FilesystemAdapter(CachingAdapter):
|
||||
|
||||
elif data_key == KEYS.SEARCH_RESULTS:
|
||||
data = cast(API.SearchResult, data)
|
||||
for a in data._artists.values():
|
||||
self._do_ingest_new_data(KEYS.ARTIST, a.id, a, partial=True)
|
||||
|
||||
for a in data._albums.values():
|
||||
self._do_ingest_new_data(KEYS.ALBUM, a.id, a, partial=True)
|
||||
|
||||
for s in data._songs.values():
|
||||
self._do_ingest_new_data(KEYS.SONG, s.id, s, partial=True)
|
||||
|
||||
for p in data._playlists.values():
|
||||
self._do_ingest_new_data(KEYS.PLAYLIST_DETAILS, p.id, p, partial=True)
|
||||
for (kind, id), v in data._results.items():
|
||||
if kind is API.SearchResult.Kind.ARTIST:
|
||||
self._do_ingest_new_data(KEYS.ARTIST, id, v, partial=True)
|
||||
elif kind is API.SearchResult.Kind.ALBUM:
|
||||
self._do_ingest_new_data(KEYS.ALBUM, id, v, partial=True)
|
||||
elif kind is API.SearchResult.Kind.SONG:
|
||||
self._do_ingest_new_data(KEYS.SONG, id, v, partial=True)
|
||||
elif kind is API.SearchResult.Kind.PLAYLIST:
|
||||
self._do_ingest_new_data(KEYS.PLAYLIST_DETAILS, id, v, partial=True)
|
||||
|
||||
elif data_key == KEYS.SONG:
|
||||
api_song = cast(API.Song, data)
|
||||
|
@@ -140,11 +140,11 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.tap.start()
|
||||
|
||||
# self.set_accels_for_action('app.play-pause', ["<Ctrl>F"])
|
||||
self.set_accels_for_action('app.play-pause', ["space"])
|
||||
self.set_accels_for_action('app.prev-track', ["Home"])
|
||||
self.set_accels_for_action('app.next-track', ["End"])
|
||||
self.set_accels_for_action('app.quit', ["<Ctrl>q"])
|
||||
self.set_accels_for_action('app.quit', ["<Ctrl>w"])
|
||||
# self.set_accels_for_action('app.play-pause', ["space"])
|
||||
# self.set_accels_for_action('app.prev-track', ["Home"])
|
||||
# self.set_accels_for_action('app.next-track', ["End"])
|
||||
# self.set_accels_for_action('app.quit', ["<Ctrl>q"])
|
||||
# self.set_accels_for_action('app.quit', ["<Ctrl>w"])
|
||||
|
||||
|
||||
def do_activate(self):
|
||||
@@ -177,6 +177,10 @@ class SublimeMusicApp(Gtk.Application):
|
||||
register_action(playlists, self.playlists_set_details_expanded, 'set-details-expanded')
|
||||
self.window.insert_action_group('playlists', playlists)
|
||||
|
||||
search = Gio.SimpleActionGroup()
|
||||
register_action(search, self.search_set_query, 'set-query')
|
||||
self.window.insert_action_group('search', search)
|
||||
|
||||
settings = Gio.SimpleActionGroup()
|
||||
register_dataclass_actions(settings, self.app_config, after=self._save_and_refresh)
|
||||
self.window.insert_action_group('settings', settings)
|
||||
@@ -1000,6 +1004,10 @@ class SublimeMusicApp(Gtk.Application):
|
||||
self.app_config.state.playlist_details_expanded = expanded
|
||||
self.update_window()
|
||||
|
||||
def search_set_query(self, query: str):
|
||||
self.app_config.state.search_query = query
|
||||
self.update_window()
|
||||
|
||||
def players_set_option(self, player: str, option: str, value: Any):
|
||||
self.app_config.player_config[player][option] = value
|
||||
|
||||
|
@@ -22,8 +22,11 @@ from .actions import run_action
|
||||
class ArtistsPanel(Handy.Leaflet):
|
||||
"""Defines the arist panel."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(transition_type=Handy.LeafletTransitionType.SLIDE, can_swipe_forward=False, interpolate_size=False)
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
transition_type=Handy.LeafletTransitionType.SLIDE,
|
||||
can_swipe_forward=False,
|
||||
interpolate_size=False)
|
||||
|
||||
list_sizer = Sizer(natural_width=400)
|
||||
self.artist_list = ArtistList()
|
||||
@@ -185,6 +188,10 @@ class ArtistList(Gtk.Box):
|
||||
self.loading_indicator.hide()
|
||||
|
||||
|
||||
ARTIST_ARTWORK_SIZE_DESKTOP=200
|
||||
ARTIST_ARTWORK_SIZE_MOBILE=80
|
||||
|
||||
|
||||
class ArtistDetailPanel(Gtk.Box):
|
||||
"""Defines the artists list."""
|
||||
|
||||
@@ -251,7 +258,7 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
|
||||
self.artist_artwork = SpinnerImage(
|
||||
loading=False,
|
||||
image_size=200,
|
||||
image_size=ARTIST_ARTWORK_SIZE_DESKTOP,
|
||||
valign=Gtk.Align.START,
|
||||
)
|
||||
info_panel.pack_start(self.artist_artwork, False, False, 10)
|
||||
@@ -260,7 +267,7 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.artist_name = self.make_label(
|
||||
name="artist-name", ellipsize=Pango.EllipsizeMode.END
|
||||
name="artist-name", wrap=True,
|
||||
)
|
||||
details_box.add(self.artist_name)
|
||||
|
||||
@@ -316,7 +323,7 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
self.expand_button.set_active(not self.show_mobile)
|
||||
self.artist_bio_revealer.set_reveal_child(not self.show_mobile)
|
||||
self.expand_button_revealer.set_reveal_child(self.show_mobile)
|
||||
self.artist_artwork.set_image_size( 120 if self.show_mobile else 200)
|
||||
self.artist_artwork.set_image_size(ARTIST_ARTWORK_SIZE_MOBILE if self.show_mobile else ARTIST_ARTWORK_SIZE_DESKTOP)
|
||||
|
||||
def on_expand_button_clicked(self, *_):
|
||||
up_down = "up" if self.expand_button.get_active() else "down"
|
||||
|
@@ -106,11 +106,10 @@ class AlbumWithSongs(Gtk.Box):
|
||||
|
||||
album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
# TODO (#43): deal with super long-ass titles
|
||||
self.title = Gtk.Label(
|
||||
name="artist-album-list-album-name",
|
||||
halign=Gtk.Align.START,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
wrap=True,
|
||||
)
|
||||
album_details.pack_start(self.title, False, False, 0)
|
||||
|
||||
|
@@ -14,7 +14,7 @@ from ..adapters import (
|
||||
)
|
||||
from ..config import AppConfiguration, ProviderConfiguration
|
||||
from ..players import PlayerManager
|
||||
from . import albums, artists, browse, player_controls, playlists, util
|
||||
from . import albums, artists, browse, search, player_controls, playlists, util
|
||||
from .common import IconButton, IconToggleButton, IconMenuButton, SpinnerImage
|
||||
from .actions import run_action
|
||||
from .providers import ProvidersWindow
|
||||
@@ -36,12 +36,14 @@ class MainWindow(Handy.ApplicationWindow):
|
||||
self.artists_panel = artists.ArtistsPanel()
|
||||
self.browse_panel = browse.BrowsePanel()
|
||||
self.playlists_panel = playlists.PlaylistsPanel()
|
||||
self.search_panel = search.SearchPanel()
|
||||
self.stack = self._create_stack(
|
||||
Albums=self.albums_panel,
|
||||
Artists=self.artists_panel,
|
||||
# Browse=self.browse_panel,
|
||||
Playlists=self.playlists_panel,
|
||||
)
|
||||
self.stack.add_named(self.search_panel, "Search")
|
||||
self.stack.set_transition_type(Gtk.StackTransitionType.NONE)
|
||||
|
||||
self.sidebar_flap = Handy.Flap(
|
||||
@@ -53,6 +55,9 @@ class MainWindow(Handy.ApplicationWindow):
|
||||
def stack_changed(*_):
|
||||
self.sidebar_flap.set_reveal_flap(False)
|
||||
|
||||
if self.stack.get_visible_child() == self.search_panel:
|
||||
self.search_panel.entry.grab_focus()
|
||||
|
||||
run_action(self, 'app.change-tab', self.stack.get_visible_child_name())
|
||||
self.stack.connect("notify::visible-child", stack_changed)
|
||||
|
||||
@@ -159,8 +164,6 @@ class MainWindow(Handy.ApplicationWindow):
|
||||
|
||||
self.add(box)
|
||||
|
||||
self.connect("button-release-event", self._on_button_release)
|
||||
|
||||
self._settings_window = SettingsWindow(self)
|
||||
self._downloads_window = DownloadsWindow(self)
|
||||
self._providers_window = ProvidersWindow(self)
|
||||
@@ -261,19 +264,12 @@ class MainWindow(Handy.ApplicationWindow):
|
||||
desktop_header.set_show_close_button(True)
|
||||
desktop_header.props.title = "Sublime Music"
|
||||
|
||||
# Search
|
||||
self.search_entry = Gtk.SearchEntry(placeholder_text="Search everything...")
|
||||
self.search_entry.connect("focus-in-event", self._on_search_entry_focus)
|
||||
self.search_entry.connect(
|
||||
"button-press-event", self._on_search_entry_button_press
|
||||
)
|
||||
self.search_entry.connect("focus-out-event", self._on_search_entry_loose_focus)
|
||||
self.search_entry.connect("changed", self._on_search_entry_changed)
|
||||
self.search_entry.connect("stop-search", self._on_search_entry_stop_search)
|
||||
# desktop_header.pack_start(self.search_entry)
|
||||
|
||||
# Search popup
|
||||
self._create_search_popup()
|
||||
search_button = IconButton(
|
||||
icon_name='system-search-symbolic',
|
||||
tooltip_text="Search Everything",
|
||||
relief=True)
|
||||
search_button.connect('clicked', lambda *_: self.stack.set_visible_child(self.search_panel))
|
||||
desktop_header.pack_start(search_button)
|
||||
|
||||
# Stack switcher
|
||||
switcher = Gtk.StackSwitcher(stack=stack)
|
||||
@@ -322,6 +318,13 @@ class MainWindow(Handy.ApplicationWindow):
|
||||
self.sidebar_flap.bind_property("reveal-flap", button, "active", GObject.BindingFlags.BIDIRECTIONAL)
|
||||
mobile_header.pack_start(button)
|
||||
|
||||
search_button = IconButton(
|
||||
icon_name='system-search-symbolic',
|
||||
tooltip_text="Search Everything",
|
||||
relief=True)
|
||||
search_button.connect('clicked', lambda *_: self.stack.set_visible_child(self.search_panel))
|
||||
mobile_header.pack_end(search_button)
|
||||
|
||||
squeezer.add(mobile_header)
|
||||
|
||||
def squeezer_changed(squeezer, _):
|
||||
@@ -363,20 +366,6 @@ class MainWindow(Handy.ApplicationWindow):
|
||||
|
||||
return box
|
||||
|
||||
def _create_label(
|
||||
self, text: str, *args, halign: Gtk.Align = Gtk.Align.START, **kwargs
|
||||
) -> Gtk.Label:
|
||||
label = Gtk.Label(
|
||||
use_markup=True,
|
||||
halign=halign,
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
label.set_markup(text)
|
||||
label.get_style_context().add_class("search-result-row")
|
||||
return label
|
||||
|
||||
def _create_toggle_menu_button(
|
||||
self, label: str, settings_name: str
|
||||
) -> Tuple[Gtk.Box, Gtk.Switch]:
|
||||
@@ -443,76 +432,6 @@ class MainWindow(Handy.ApplicationWindow):
|
||||
|
||||
self._emit_settings_change({setting: self.get_property(prop.name)})
|
||||
|
||||
def _create_search_popup(self) -> Gtk.PopoverMenu:
|
||||
self.search_popup = Gtk.PopoverMenu(modal=False)
|
||||
|
||||
results_scrollbox = Gtk.ScrolledWindow(
|
||||
min_content_width=500,
|
||||
min_content_height=700,
|
||||
)
|
||||
|
||||
def make_search_result_header(text: str) -> Gtk.Label:
|
||||
label = self._create_label(text)
|
||||
label.get_style_context().add_class("search-result-header")
|
||||
return label
|
||||
|
||||
search_results_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
name="search-results",
|
||||
)
|
||||
self.search_results_loading = Gtk.Spinner(active=False, name="search-spinner")
|
||||
search_results_box.add(self.search_results_loading)
|
||||
|
||||
search_results_box.add(make_search_result_header("Songs"))
|
||||
self.song_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
search_results_box.add(self.song_results)
|
||||
|
||||
search_results_box.add(make_search_result_header("Albums"))
|
||||
self.album_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
search_results_box.add(self.album_results)
|
||||
|
||||
search_results_box.add(make_search_result_header("Artists"))
|
||||
self.artist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
search_results_box.add(self.artist_results)
|
||||
|
||||
search_results_box.add(make_search_result_header("Playlists"))
|
||||
self.playlist_results = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
search_results_box.add(self.playlist_results)
|
||||
|
||||
results_scrollbox.add(search_results_box)
|
||||
self.search_popup.add(results_scrollbox)
|
||||
|
||||
self.search_popup.set_relative_to(self.search_entry)
|
||||
rect = Gdk.Rectangle()
|
||||
rect.x = 22
|
||||
rect.y = 28
|
||||
rect.width = 1
|
||||
rect.height = 1
|
||||
self.search_popup.set_pointing_to(rect)
|
||||
self.search_popup.set_position(Gtk.PositionType.BOTTOM)
|
||||
|
||||
# Event Listeners
|
||||
# =========================================================================
|
||||
def _on_button_release(self, win: Any, event: Gdk.EventButton) -> bool:
|
||||
if not self._event_in_widgets(event, self.search_entry, self.search_popup):
|
||||
self._hide_search()
|
||||
|
||||
# if not self._event_in_widgets(
|
||||
# event,
|
||||
# self.player_controls.device_button,
|
||||
# self.player_controls.device_popover,
|
||||
# ):
|
||||
# self.player_controls.device_popover.popdown()
|
||||
|
||||
# if not self._event_in_widgets(
|
||||
# event,
|
||||
# self.player_controls.play_queue_button,
|
||||
# self.player_controls.play_queue_popover,
|
||||
# ):
|
||||
# self.player_controls.play_queue_popover.popdown()
|
||||
|
||||
return False
|
||||
|
||||
def show_providers_window(self):
|
||||
if self.is_initialized:
|
||||
self._providers_window.open_status_page()
|
||||
@@ -521,8 +440,6 @@ class MainWindow(Handy.ApplicationWindow):
|
||||
|
||||
self._show_transient_window(self._providers_window)
|
||||
|
||||
|
||||
|
||||
_transient_window = None
|
||||
|
||||
def _show_transient_window(self, window):
|
||||
@@ -542,55 +459,6 @@ class MainWindow(Handy.ApplicationWindow):
|
||||
|
||||
run_action(self, 'app.refresh')
|
||||
|
||||
def _on_search_entry_focus(self, *args):
|
||||
self._show_search()
|
||||
|
||||
def _on_search_entry_button_press(self, *args):
|
||||
self._show_search()
|
||||
|
||||
def _on_search_entry_loose_focus(self, *args):
|
||||
self._hide_search()
|
||||
|
||||
search_idx = 0
|
||||
searches: Set[Result] = set()
|
||||
|
||||
def _on_search_entry_changed(self, entry: Gtk.Entry):
|
||||
while len(self.searches) > 0:
|
||||
search = self.searches.pop()
|
||||
if search:
|
||||
search.cancel()
|
||||
|
||||
if not self.search_popup.is_visible():
|
||||
self.search_popup.show_all()
|
||||
self.search_popup.popup()
|
||||
|
||||
def search_result_calback(idx: int, result: API.SearchResult):
|
||||
# Ignore slow returned searches.
|
||||
if idx < self.search_idx:
|
||||
return
|
||||
|
||||
GLib.idle_add(self._update_search_results, result)
|
||||
|
||||
def search_result_done(r: Result):
|
||||
if r.result() is True:
|
||||
# The search was cancelled
|
||||
return
|
||||
|
||||
# If all results are back, the stop the loading indicator.
|
||||
GLib.idle_add(self._set_search_loading, False)
|
||||
|
||||
self.search_idx += 1
|
||||
search_result = AdapterManager.search(
|
||||
entry.get_text(),
|
||||
search_callback=partial(search_result_calback, self.search_idx),
|
||||
before_download=lambda: self._set_search_loading(True),
|
||||
)
|
||||
search_result.add_done_callback(search_result_done)
|
||||
self.searches.add(search_result)
|
||||
|
||||
def _on_search_entry_stop_search(self, entry: Any):
|
||||
self.search_popup.popdown()
|
||||
|
||||
# Helper Functions
|
||||
# =========================================================================
|
||||
def _emit_settings_change(self, changed_settings: Dict[str, Any]):
|
||||
@@ -598,119 +466,10 @@ class MainWindow(Handy.ApplicationWindow):
|
||||
return
|
||||
self.emit("refresh-window", {"__settings__": changed_settings}, False)
|
||||
|
||||
def _show_search(self):
|
||||
self.search_entry.set_size_request(300, -1)
|
||||
self.search_popup.show_all()
|
||||
self.search_results_loading.hide()
|
||||
self.search_popup.popup()
|
||||
|
||||
def _hide_search(self):
|
||||
self.search_popup.popdown()
|
||||
self.search_entry.set_size_request(-1, -1)
|
||||
|
||||
def _set_search_loading(self, loading_state: bool):
|
||||
if loading_state:
|
||||
self.search_results_loading.start()
|
||||
self.search_results_loading.show_all()
|
||||
else:
|
||||
self.search_results_loading.stop()
|
||||
self.search_results_loading.hide()
|
||||
|
||||
def _remove_all_from_widget(self, widget: Gtk.Widget):
|
||||
for c in widget.get_children():
|
||||
widget.remove(c)
|
||||
|
||||
def _create_search_result_row(
|
||||
self, text: str, action_name: str, id: str, cover_art_id: Optional[str]
|
||||
) -> Gtk.Button:
|
||||
def on_search_row_button_press(*args):
|
||||
self.emit("go-to", action_name, id)
|
||||
self._hide_search()
|
||||
|
||||
row = Gtk.Button(relief=Gtk.ReliefStyle.NONE)
|
||||
row.connect("button-press-event", on_search_row_button_press)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
image = SpinnerImage(image_name="search-artwork", image_size=30)
|
||||
box.add(image)
|
||||
box.add(self._create_label(text))
|
||||
row.add(box)
|
||||
|
||||
def image_callback(f: Result):
|
||||
image.set_loading(False)
|
||||
image.set_from_file(f.result())
|
||||
|
||||
artwork_future = AdapterManager.get_cover_art_uri(cover_art_id, "file")
|
||||
artwork_future.add_done_callback(lambda f: GLib.idle_add(image_callback, f))
|
||||
|
||||
return row
|
||||
|
||||
def _update_search_results(self, search_results: API.SearchResult):
|
||||
# Songs
|
||||
if search_results.songs is not None:
|
||||
self._remove_all_from_widget(self.song_results)
|
||||
for song in search_results.songs:
|
||||
label_text = util.dot_join(
|
||||
f"<b>{song.title}</b>",
|
||||
song.artist.name if song.artist else None,
|
||||
)
|
||||
assert song.album and song.album.id
|
||||
self.song_results.add(
|
||||
self._create_search_result_row(
|
||||
bleach.clean(label_text), "album", song.album.id, song.cover_art
|
||||
)
|
||||
)
|
||||
|
||||
self.song_results.show_all()
|
||||
|
||||
# Albums
|
||||
if search_results.albums is not None:
|
||||
self._remove_all_from_widget(self.album_results)
|
||||
for album in search_results.albums:
|
||||
label_text = util.dot_join(
|
||||
f"<b>{album.name}</b>",
|
||||
album.artist.name if album.artist else None,
|
||||
)
|
||||
assert album.id
|
||||
self.album_results.add(
|
||||
self._create_search_result_row(
|
||||
bleach.clean(label_text), "album", album.id, album.cover_art
|
||||
)
|
||||
)
|
||||
|
||||
self.album_results.show_all()
|
||||
|
||||
# Artists
|
||||
if search_results.artists is not None:
|
||||
self._remove_all_from_widget(self.artist_results)
|
||||
for artist in search_results.artists:
|
||||
assert artist.id
|
||||
self.artist_results.add(
|
||||
self._create_search_result_row(
|
||||
bleach.clean(artist.name),
|
||||
"artist",
|
||||
artist.id,
|
||||
artist.artist_image_url,
|
||||
)
|
||||
)
|
||||
|
||||
self.artist_results.show_all()
|
||||
|
||||
# Playlists
|
||||
if search_results.playlists:
|
||||
self._remove_all_from_widget(self.playlist_results)
|
||||
for playlist in search_results.playlists:
|
||||
self.playlist_results.add(
|
||||
self._create_search_result_row(
|
||||
bleach.clean(playlist.name),
|
||||
"playlist",
|
||||
playlist.id,
|
||||
playlist.cover_art,
|
||||
)
|
||||
)
|
||||
|
||||
self.playlist_results.show_all()
|
||||
|
||||
def _event_in_widgets(self, event: Gdk.EventButton, *widgets) -> bool:
|
||||
for widget in widgets:
|
||||
if not widget.is_visible():
|
||||
|
262
sublime_music/ui/search.py
Normal file
262
sublime_music/ui/search.py
Normal file
@@ -0,0 +1,262 @@
|
||||
from functools import partial
|
||||
from typing import Optional, List, Any, Union, Tuple
|
||||
|
||||
import bleach
|
||||
|
||||
from gi.repository import Gio, GLib, GObject, Gtk, Pango, Handy, Gdk, GdkPixbuf
|
||||
|
||||
from ..adapters import (
|
||||
AdapterManager,
|
||||
api_objects as API,
|
||||
Result,
|
||||
)
|
||||
from . import util
|
||||
from ..config import AppConfiguration, ProviderConfiguration
|
||||
from .actions import run_action
|
||||
|
||||
class SearchPanel(Gtk.ScrolledWindow):
|
||||
_ratchet = 0
|
||||
_query: str = ''
|
||||
_search: Optional[Result] = None
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._art_cache = {}
|
||||
|
||||
clamp = Handy.Clamp(margin=12)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.entry = Gtk.Entry(hexpand=True, placeholder_text="Search")
|
||||
self.entry.connect("notify::text", lambda *_: run_action(self, 'search.set-query', self.entry.get_text()))
|
||||
box.pack_start(self.entry, False, False, 10)
|
||||
|
||||
scrolled_window = ()
|
||||
|
||||
self.stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.NONE, homogeneous=True)
|
||||
|
||||
self.spinner = Gtk.Spinner(active=False, hexpand=True, vexpand=True)
|
||||
self.stack.add(self.spinner)
|
||||
|
||||
self.store = Gtk.ListStore(
|
||||
int, # type
|
||||
str, # art
|
||||
str, # title, subtitle
|
||||
str, # id
|
||||
)
|
||||
self.art_cache = {}
|
||||
|
||||
self.list = Gtk.TreeView(
|
||||
model=self.store,
|
||||
reorderable=False,
|
||||
headers_visible=False)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf(stock_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
renderer.set_fixed_size(40, 60)
|
||||
column = Gtk.TreeViewColumn("", renderer)
|
||||
column.set_cell_data_func(renderer, self._get_type_pixbuf)
|
||||
column.set_resizable(True)
|
||||
self.list.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf()
|
||||
renderer.set_fixed_size(45, 60)
|
||||
column = Gtk.TreeViewColumn("", renderer)
|
||||
column.set_cell_data_func(renderer, self._get_result_pixbuf)
|
||||
column.set_resizable(True)
|
||||
self.list.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END)
|
||||
column = Gtk.TreeViewColumn("", renderer, markup=2)
|
||||
column.set_expand(True)
|
||||
self.list.append_column(column)
|
||||
|
||||
renderer = Gtk.CellRendererPixbuf(icon_name="view-more-symbolic")
|
||||
renderer.set_fixed_size(45, 60)
|
||||
self.options_column = Gtk.TreeViewColumn("", renderer)
|
||||
self.options_column.set_resizable(True)
|
||||
self.list.append_column(self.options_column)
|
||||
|
||||
self.list.connect("button-press-event", self._on_list_button_press)
|
||||
|
||||
self.stack.add(self.list)
|
||||
|
||||
box.pack_start(self.stack, True, True, 10)
|
||||
|
||||
clamp.add(box)
|
||||
|
||||
self.add(clamp)
|
||||
|
||||
def _get_type_pixbuf(self,
|
||||
column: Any,
|
||||
cell: Gtk.CellRendererPixbuf,
|
||||
model: Gtk.ListStore,
|
||||
tree_iter: Gtk.TreeIter,
|
||||
flags: Any):
|
||||
kind = model.get_value(tree_iter, 0)
|
||||
|
||||
if kind == API.SearchResult.Kind.ARTIST.value:
|
||||
cell.set_property("icon-name", "avatar-default-symbolic")
|
||||
elif kind == API.SearchResult.Kind.ALBUM.value:
|
||||
cell.set_property("icon-name", "media-optical-symbolic")
|
||||
elif kind == API.SearchResult.Kind.SONG.value:
|
||||
cell.set_property("icon-name", "folder-music-symbolic")
|
||||
elif kind == API.SearchResult.Kind.PLAYLIST.value:
|
||||
cell.set_property("icon-name", "open-menu-symbolic")
|
||||
else:
|
||||
assert False
|
||||
|
||||
def _get_pixbuf_from_path(self, path: Optional[str]):
|
||||
if not path:
|
||||
return None
|
||||
|
||||
if path in self._art_cache:
|
||||
return self._art_cache[path]
|
||||
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 50, 50, True)
|
||||
|
||||
self._art_cache[path] = pixbuf
|
||||
return pixbuf
|
||||
|
||||
def _get_result_pixbuf(self,
|
||||
column: Any,
|
||||
cell: Gtk.CellRendererPixbuf,
|
||||
model: Gtk.ListStore,
|
||||
tree_iter: Gtk.TreeIter,
|
||||
flags: Any):
|
||||
filename = model.get_value(tree_iter, 1)
|
||||
pixbuf = self._get_pixbuf_from_path(filename)
|
||||
if not pixbuf:
|
||||
cell.set_property("icon-name", "")
|
||||
else:
|
||||
cell.set_property("pixbuf", pixbuf)
|
||||
|
||||
def _on_list_button_press(self, tree: Gtk.ListStore, event: Gdk.EventButton) -> bool:
|
||||
if event.button != 1:
|
||||
return False
|
||||
|
||||
path, column, cell_x, cell_y = tree.get_path_at_pos(event.x, event.y)
|
||||
index = path.get_indices()[0]
|
||||
row = self.store[index]
|
||||
|
||||
if column == self.options_column:
|
||||
area = tree.get_cell_area(path, self.options_column)
|
||||
x = area.x + area.width / 2
|
||||
y = area.y + area.height / 2
|
||||
|
||||
# TODO: Show popup
|
||||
return True
|
||||
else:
|
||||
if row[0] == API.SearchResult.Kind.ARTIST.value:
|
||||
run_action(self, 'app.go-to-artist', row[3])
|
||||
elif row[0] == API.SearchResult.Kind.ALBUM.value:
|
||||
run_action(self, 'app.go-to-album', row[3])
|
||||
elif row[0] == API.SearchResult.Kind.SONG.value:
|
||||
run_action(self, 'app.play-song', 0, [row[3]], {"force_shuffle_state": GLib.Variant('b', False)})
|
||||
elif row[0] == API.SearchResult.Kind.PLAYLIST.value:
|
||||
run_action(self, 'app.go-to-playlist', row[3])
|
||||
else:
|
||||
assert False
|
||||
|
||||
return True
|
||||
|
||||
def update(self, app_config: AppConfiguration, force: bool = False):
|
||||
query = app_config.state.search_query
|
||||
|
||||
if query != self._query:
|
||||
self._query = query
|
||||
|
||||
self.entry.set_text(self._query)
|
||||
|
||||
if self._search:
|
||||
self._search.cancel()
|
||||
|
||||
self._ratchet += 1
|
||||
|
||||
if self._query:
|
||||
def search_callback(ratchet, result: API.SearchResult):
|
||||
if ratchet != self._ratchet:
|
||||
return
|
||||
|
||||
GLib.idle_add(self._update_search_results, result.get_results(), ratchet)
|
||||
|
||||
self._search = AdapterManager.search(
|
||||
self._query,
|
||||
search_callback=partial(search_callback, self._ratchet))
|
||||
self._set_loading(True)
|
||||
else:
|
||||
self._update_search_results([], self._ratchet)
|
||||
|
||||
def _set_loading(self, loading: bool):
|
||||
if loading:
|
||||
self.spinner.start()
|
||||
self.stack.set_visible_child(self.spinner)
|
||||
else:
|
||||
self.spinner.stop()
|
||||
self.stack.set_visible_child(self.list)
|
||||
|
||||
def _set_art(self, index: int, path: str, ratchet: int):
|
||||
if ratchet != self._ratchet:
|
||||
return
|
||||
|
||||
self.store[index][1] = path
|
||||
|
||||
def _get_art_path(self, index: int, art_id: Optional[str], ratchet: int):
|
||||
cover_art_result = AdapterManager.get_cover_art_uri(art_id, "file")
|
||||
if not cover_art_result.data_is_available:
|
||||
def on_done(result: Result):
|
||||
if ratchet != self._ratchet:
|
||||
return
|
||||
|
||||
GLib.idle_add(self._set_art, index, result.result(), ratchet)
|
||||
|
||||
cover_art_result.add_done_callback(on_done)
|
||||
return None
|
||||
|
||||
# The cover art is already cached.
|
||||
return cover_art_result.result()
|
||||
|
||||
def _update_search_results(
|
||||
self,
|
||||
results: List[Tuple[API.SearchResult.Kind, API.SearchResult.ValueType]],
|
||||
ratchet: int,
|
||||
):
|
||||
if ratchet != self._ratchet:
|
||||
return
|
||||
|
||||
self._set_loading(False)
|
||||
|
||||
self.store.clear()
|
||||
self._art_cache = {}
|
||||
|
||||
for index, (kind, result) in enumerate(results):
|
||||
id = result.id
|
||||
|
||||
if kind is API.SearchResult.Kind.ARTIST:
|
||||
art_path = None
|
||||
title = f"<b>{bleach.clean(result.name)}</b>"
|
||||
|
||||
elif kind is API.SearchResult.Kind.ALBUM:
|
||||
art_path = self._get_art_path(index, result.cover_art, ratchet)
|
||||
|
||||
artist = bleach.clean(result.artist.name if result.artist else None)
|
||||
song_count = f"{result.song_count} {util.pluralize('song', result.song_count)}"
|
||||
title = f"<b>{bleach.clean(result.name)}</b>\n{util.dot_join(artist, song_count)}"
|
||||
|
||||
elif kind is API.SearchResult.Kind.SONG:
|
||||
art_path = self._get_art_path(index, result.cover_art, ratchet)
|
||||
|
||||
name = bleach.clean(result.title)
|
||||
album = bleach.clean(result.album.name if result.album else None)
|
||||
artist = bleach.clean(result.artist.name if result.artist else None)
|
||||
title = f"<b>{name}</b>\n{util.dot_join(album, artist)}"
|
||||
|
||||
elif kind is API.SearchResult.Kind.PLAYLIST:
|
||||
art_path = None
|
||||
|
||||
title = f"<b>{bleach.clean(result.name)}</b>\n{result.song_count} {util.pluralize('song', result.song_count)}"
|
||||
|
||||
else:
|
||||
assert False
|
||||
|
||||
self.store.append((kind.value, art_path, title, id))
|
@@ -81,6 +81,7 @@ class UIState:
|
||||
playlist_details_expanded: bool = True
|
||||
artist_details_expanded: bool = True
|
||||
loading_play_queue: bool = False
|
||||
search_query: str = ''
|
||||
|
||||
current_album_search_query: AlbumSearchQuery = AlbumSearchQuery(
|
||||
AlbumSearchQuery.Type.RANDOM,
|
||||
|
Reference in New Issue
Block a user