1329 lines
51 KiB
Python
1329 lines
51 KiB
Python
from datetime import timedelta
|
|
from functools import partial
|
|
from typing import Any, Callable, Dict, Optional, Set, Tuple
|
|
|
|
import bleach
|
|
|
|
from gi.repository import Gdk, GLib, GObject, Gtk, Pango, Handy
|
|
|
|
from ..adapters import (
|
|
AdapterManager,
|
|
api_objects as API,
|
|
DownloadProgress,
|
|
Result,
|
|
)
|
|
from ..config import AppConfiguration, ProviderConfiguration
|
|
from ..players import PlayerManager
|
|
from ..ui import albums, artists, browse, player_controls, playlists, util
|
|
from ..ui.common import IconButton, IconToggleButton, IconMenuButton, SpinnerImage
|
|
from .actions import run_action
|
|
|
|
class MainWindow(Handy.ApplicationWindow):
|
|
"""Defines the main window for Sublime Music."""
|
|
|
|
_updating_settings: bool = False
|
|
_pending_downloads: Set[str] = set()
|
|
_failed_downloads: Set[str] = set()
|
|
_current_download_boxes: Dict[str, Gtk.Box] = {}
|
|
_failed_downloads_box: Optional[Gtk.Label] = None
|
|
_pending_downloads_label: Optional[Gtk.Label] = None
|
|
_current_downloads_placeholder: Optional[Gtk.Label] = None
|
|
|
|
# Settings
|
|
setting_song_play_notification = GObject.Property(type=bool, default=False)
|
|
setting_allow_song_downloads = GObject.Property(type=bool, default=False)
|
|
setting_download_on_stream = GObject.Property(type=bool, default=False)
|
|
setting_prefetch_amount = GObject.Property(type=int, default=0)
|
|
setting_concurrent_download_limit = GObject.Property(type=int, default=0)
|
|
|
|
is_desktop = False
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
# self.set_default_size(1342, 756)
|
|
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
|
def connect_setting(setting):
|
|
prop = setting.replace("_", "-")
|
|
self.connect(f"notify::setting-{prop}",
|
|
partial(self._on_settings_change, setting))
|
|
connect_setting("song_play_notification")
|
|
connect_setting("allow_song_downloads")
|
|
connect_setting("download_on_stream")
|
|
connect_setting("prefetch_amount")
|
|
connect_setting("concurrent_download_limit")
|
|
|
|
# Create the stack
|
|
self.albums_panel = albums.AlbumsPanel()
|
|
self.artists_panel = artists.ArtistsPanel()
|
|
self.browse_panel = browse.BrowsePanel()
|
|
self.playlists_panel = playlists.PlaylistsPanel()
|
|
self.stack = self._create_stack(
|
|
Albums=self.albums_panel,
|
|
Artists=self.artists_panel,
|
|
# Browse=self.browse_panel,
|
|
Playlists=self.playlists_panel,
|
|
)
|
|
self.stack.set_transition_type(Gtk.StackTransitionType.NONE)
|
|
|
|
self.sidebar_flap = Handy.Flap(
|
|
orientation=Gtk.Orientation.HORIZONTAL,
|
|
fold_policy=Handy.FlapFoldPolicy.ALWAYS,
|
|
transition_type=Handy.FlapTransitionType.OVER,
|
|
modal=True)
|
|
|
|
def stack_changed(*_):
|
|
self.sidebar_flap.set_reveal_flap(False)
|
|
|
|
run_action(self, 'app.change-tab', self.stack.get_visible_child_name())
|
|
self.stack.connect("notify::visible-child", stack_changed)
|
|
|
|
self.titlebar = self._create_headerbar(self.stack)
|
|
box.add(self.titlebar)
|
|
|
|
drawer = Handy.Flap(
|
|
orientation=Gtk.Orientation.VERTICAL,
|
|
fold_policy=Handy.FlapFoldPolicy.ALWAYS,
|
|
flap_position=Gtk.PackType.END,
|
|
transition_type=Handy.FlapTransitionType.SLIDE,
|
|
modal=False)
|
|
notification_container = Gtk.Overlay()
|
|
|
|
notification_container.add(self.stack)
|
|
|
|
self.notification_revealer = Gtk.Revealer(
|
|
valign=Gtk.Align.END, halign=Gtk.Align.CENTER
|
|
)
|
|
|
|
notification_box = Gtk.Box(can_focus=False, valign="start", spacing=10)
|
|
notification_box.get_style_context().add_class("app-notification")
|
|
|
|
self.notification_icon = Gtk.Image()
|
|
notification_box.pack_start(self.notification_icon, True, False, 5)
|
|
|
|
self.notification_text = Gtk.Label(use_markup=True)
|
|
notification_box.pack_start(self.notification_text, True, False, 5)
|
|
|
|
self.notification_actions = Gtk.Box()
|
|
notification_box.pack_start(self.notification_actions, True, False, 0)
|
|
|
|
notification_box.add(close_button := IconButton("window-close-symbolic"))
|
|
close_button.connect("clicked", lambda _: self.emit("notification-closed"))
|
|
|
|
self.notification_revealer.add(notification_box)
|
|
|
|
notification_container.add_overlay(self.notification_revealer)
|
|
drawer.set_content(notification_container)
|
|
|
|
# Player state
|
|
self.player_manager = player_controls.Manager(self)
|
|
|
|
# Player Controls
|
|
drawer.set_separator(Gtk.Separator())
|
|
|
|
squeezer = Handy.Squeezer(vexpand=True, homogeneous=False)
|
|
|
|
desktop_controls = player_controls.Desktop(self.player_manager)
|
|
squeezer.add(desktop_controls)
|
|
|
|
mobile_handle = player_controls.MobileHandle(self.player_manager)
|
|
|
|
# Toggle drawer when handle is pressed
|
|
mobile_handle_events = Gtk.EventBox()
|
|
mobile_handle_events.set_events(Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK)
|
|
|
|
click_gesture = Gtk.GestureMultiPress.new(mobile_handle_events)
|
|
click_gesture.set_propagation_phase(Gtk.PropagationPhase.BUBBLE)
|
|
def toggle_drawer(gesture, num, x, y):
|
|
if (num != 1 or not drawer.get_swipe_to_open() or
|
|
x < 0 or x > mobile_handle_events.get_allocated_width() or
|
|
y < 0 or y > mobile_handle_events.get_allocated_height()):
|
|
return
|
|
|
|
drawer.set_reveal_flap(not drawer.get_reveal_flap())
|
|
click_gesture.connect('released', toggle_drawer)
|
|
|
|
# Need to keep the gesture object alive
|
|
mobile_handle_events._gesture = click_gesture
|
|
|
|
mobile_handle_events.add(mobile_handle)
|
|
squeezer.add(mobile_handle_events)
|
|
|
|
# Only show the handle on desktop
|
|
def squeezer_changed(squeezer, _):
|
|
is_desktop = squeezer.get_visible_child() == desktop_controls
|
|
|
|
drawer.set_swipe_to_open(not is_desktop)
|
|
|
|
# When transitioning, don't play the reveal animation
|
|
dur = drawer.get_reveal_duration()
|
|
drawer.set_reveal_duration(0)
|
|
|
|
drawer.set_reveal_flap(False)
|
|
|
|
drawer.set_reveal_duration(dur)
|
|
squeezer.connect("notify::visible-child", squeezer_changed)
|
|
|
|
drawer.set_handle(squeezer)
|
|
|
|
mobile_flap = player_controls.MobileFlap(self.player_manager)
|
|
drawer.set_flap(mobile_flap)
|
|
|
|
def drawer_reveal_progress(drawer, _):
|
|
self.player_manager.flap_reveal_progress = drawer.get_reveal_progress()
|
|
drawer.connect("notify::reveal-progress", drawer_reveal_progress)
|
|
|
|
self.sidebar_flap.set_content(drawer)
|
|
|
|
self.sidebar_flap.set_flap(self._create_sidebar())
|
|
|
|
box.pack_start(self.sidebar_flap, True, True, 0)
|
|
|
|
self.add(box)
|
|
|
|
self.connect("button-release-event", self._on_button_release)
|
|
|
|
current_notification_hash = None
|
|
current_other_providers: Tuple[ProviderConfiguration, ...] = ()
|
|
|
|
def update(
|
|
self,
|
|
app_config: AppConfiguration,
|
|
player_manager: PlayerManager,
|
|
force: bool = False,
|
|
):
|
|
notification = app_config.state.current_notification
|
|
if notification and (h := hash(notification)) != self.current_notification_hash:
|
|
self.current_notification_hash = h
|
|
|
|
if notification.icon:
|
|
self.notification_icon.set_from_icon_name(
|
|
notification.icon, Gtk.IconSize.DND
|
|
)
|
|
else:
|
|
self.notification_icon.set_from_icon_name(None, Gtk.IconSize.DND)
|
|
|
|
self.notification_text.set_markup(notification.markup)
|
|
|
|
for c in self.notification_actions.get_children():
|
|
self.notification_actions.remove(c)
|
|
|
|
for label, fn in notification.actions:
|
|
self.notification_actions.add(action_button := Gtk.Button(label=label))
|
|
action_button.connect("clicked", lambda _: fn())
|
|
|
|
self.notification_revealer.show_all()
|
|
self.notification_revealer.set_reveal_child(True)
|
|
|
|
if notification is None:
|
|
self.notification_revealer.set_reveal_child(False)
|
|
|
|
# Update the Connected to label on the popup menu.
|
|
if app_config.provider:
|
|
self.connected_to_label.set_markup(f"<b>{app_config.provider.name}</b>")
|
|
ui_info = app_config.provider.ground_truth_adapter_type.get_ui_info()
|
|
icon_basename = ui_info.icon_basename
|
|
else:
|
|
self.connected_to_label.set_markup("<i>No Music Source Selected</i>")
|
|
icon_basename = "list-add"
|
|
|
|
if AdapterManager.ground_truth_adapter_is_networked:
|
|
status_label = ""
|
|
if app_config.offline_mode:
|
|
status_label = "Offline"
|
|
elif AdapterManager.get_ping_status():
|
|
status_label = "Connected"
|
|
else:
|
|
status_label = "Error Connecting to Server"
|
|
|
|
icon_status = status_label.split()[0].lower()
|
|
|
|
self.server_connection_menu_button.set_icon(
|
|
f"{icon_basename}-{icon_status}-symbolic"
|
|
)
|
|
self.connection_status_icon.set_from_icon_name(
|
|
f"server-{icon_status}-symbolic",
|
|
Gtk.IconSize.BUTTON,
|
|
)
|
|
self.connection_status_label.set_text(status_label)
|
|
self.connected_status_box.show_all()
|
|
else:
|
|
self.server_connection_menu_button.set_icon(f"{icon_basename}-symbolic")
|
|
self.connected_status_box.hide()
|
|
|
|
self._updating_settings = True
|
|
|
|
# Offline Mode Settings
|
|
offline_mode = app_config.offline_mode
|
|
self.offline_mode_switch.set_active(offline_mode)
|
|
|
|
# Switch Provider options
|
|
other_providers = tuple(
|
|
v
|
|
for k, v in app_config.providers.items()
|
|
if k != app_config.current_provider_id
|
|
)
|
|
if self.current_other_providers != other_providers:
|
|
self.current_other_providers = other_providers
|
|
for c in self.provider_options_box.get_children():
|
|
self.provider_options_box.remove(c)
|
|
|
|
for provider in sorted(other_providers, key=lambda p: p.name.lower()):
|
|
self.provider_options_box.pack_start(
|
|
self._create_switch_provider_button(provider),
|
|
False,
|
|
True,
|
|
0,
|
|
)
|
|
|
|
self.provider_options_box.show_all()
|
|
|
|
self.setting_song_play_notification = app_config.song_play_notification
|
|
self.setting_allow_song_downloads = app_config.allow_song_downloads
|
|
self.setting_download_on_stream = app_config.download_on_stream
|
|
self.setting_prefetch_amount = app_config.prefetch_amount
|
|
self.setting_concurrent_download_limit = app_config.concurrent_download_limit
|
|
|
|
self._updating_settings = False
|
|
|
|
# Player settings
|
|
# for c in self.player_settings_box.get_children():
|
|
# self.player_settings_box.remove(c)
|
|
|
|
# def emit_player_settings_change(
|
|
# player_name: str, option_name: str, value_extraction_fn: Callable, *args
|
|
# ):
|
|
# if self._updating_settings:
|
|
# return
|
|
# self.emit(
|
|
# "refresh-window",
|
|
# {
|
|
# "__player_setting__": (
|
|
# player_name,
|
|
# option_name,
|
|
# value_extraction_fn(*args),
|
|
# )
|
|
# },
|
|
# False,
|
|
# )
|
|
|
|
# for player_name, options in player_manager.get_configuration_options().items():
|
|
# self.player_settings_box.add(Gtk.Separator())
|
|
# self.player_settings_box.add(
|
|
# self._create_label(
|
|
# f"{player_name} Settings", name="menu-settings-separator"
|
|
# )
|
|
# )
|
|
|
|
# for option_name, descriptor in options.items():
|
|
# setting_box = Gtk.Box()
|
|
# setting_box.add(option_name_label := Gtk.Label(label=option_name))
|
|
# option_name_label.get_style_context().add_class("menu-label")
|
|
|
|
# option_value = app_config.player_config.get(player_name, {}).get(
|
|
# option_name
|
|
# )
|
|
|
|
# if type(descriptor) == tuple:
|
|
# option_store = Gtk.ListStore(str)
|
|
# for option in descriptor:
|
|
# option_store.append([option])
|
|
# combo = Gtk.ComboBox.new_with_model(option_store)
|
|
# combo.set_id_column(0)
|
|
# renderer_text = Gtk.CellRendererText()
|
|
# combo.pack_start(renderer_text, True)
|
|
# combo.add_attribute(renderer_text, "text", 0)
|
|
# combo.set_active_id(option_value)
|
|
# combo.connect(
|
|
# "changed",
|
|
# partial(
|
|
# emit_player_settings_change,
|
|
# player_name,
|
|
# option_name,
|
|
# lambda c: c.get_active_id(),
|
|
# ),
|
|
# )
|
|
|
|
# setting_box.pack_end(combo, False, False, 0)
|
|
|
|
# elif descriptor == bool:
|
|
# switch = Gtk.Switch(active=option_value)
|
|
# switch.connect(
|
|
# "notify::active",
|
|
# partial(
|
|
# emit_player_settings_change,
|
|
# player_name,
|
|
# option_name,
|
|
# lambda s, _: s.get_active(),
|
|
# ),
|
|
# )
|
|
# setting_box.pack_end(switch, False, False, 0)
|
|
|
|
# elif descriptor == int:
|
|
# int_editor_box = Gtk.Box()
|
|
|
|
# def restrict_to_ints(
|
|
# entry: Gtk.Entry, text: str, length: int, position: int
|
|
# ) -> bool:
|
|
# if self._updating_settings:
|
|
# return False
|
|
# if not text.isdigit():
|
|
# entry.emit_stop_by_name("insert-text")
|
|
# return True
|
|
# return False
|
|
|
|
# entry = Gtk.Entry(width_chars=8, text=option_value, sensitive=False)
|
|
# entry.connect("insert-text", restrict_to_ints)
|
|
# int_editor_box.add(entry)
|
|
|
|
# buttons_box = Gtk.Box()
|
|
|
|
# edit_button = IconButton("document-edit-symbolic", relief=True)
|
|
# confirm_button = IconButton("object-select-symbolic", relief=True)
|
|
# cancel_button = IconButton("process-stop-symbolic", relief=True)
|
|
|
|
# def on_edit_button_click(*a):
|
|
# entry.set_sensitive(True)
|
|
# buttons_box.remove(edit_button)
|
|
# buttons_box.add(cancel_button)
|
|
# buttons_box.add(confirm_button)
|
|
# buttons_box.show_all()
|
|
|
|
# def on_cancel_button_click(*a):
|
|
# entry.set_text(str(option_value))
|
|
# entry.set_sensitive(False)
|
|
# buttons_box.remove(cancel_button)
|
|
# buttons_box.remove(confirm_button)
|
|
# buttons_box.add(edit_button)
|
|
# buttons_box.show_all()
|
|
|
|
# edit_button.connect("clicked", on_edit_button_click)
|
|
# confirm_button.connect(
|
|
# "clicked",
|
|
# partial(
|
|
# emit_player_settings_change,
|
|
# player_name,
|
|
# option_name,
|
|
# lambda b: int(entry.get_text()),
|
|
# ),
|
|
# )
|
|
# cancel_button.connect("clicked", on_cancel_button_click)
|
|
# buttons_box.add(edit_button)
|
|
|
|
# int_editor_box.add(buttons_box)
|
|
|
|
# setting_box.pack_end(int_editor_box, False, False, 0)
|
|
|
|
# setting_box.get_style_context().add_class("menu-button")
|
|
# self.player_settings_box.add(setting_box)
|
|
|
|
# self.player_settings_box.show_all()
|
|
|
|
self.stack.set_visible_child_name(app_config.state.current_tab)
|
|
|
|
active_panel = self.stack.get_visible_child()
|
|
if hasattr(active_panel, "update"):
|
|
active_panel.update(app_config, force=force)
|
|
|
|
self.player_manager.update(app_config, force=force)
|
|
|
|
def update_song_progress(self,
|
|
progress: Optional[timedelta],
|
|
duration: Optional[timedelta],
|
|
cache_progess: Optional[timedelta]):
|
|
self.player_manager.update_song_progress(progress, duration, cache_progess)
|
|
|
|
def update_song_download_progress(self, song_id: str, progress: DownloadProgress):
|
|
if progress.type == DownloadProgress.Type.QUEUED:
|
|
if (
|
|
song_id not in self._failed_downloads
|
|
and song_id not in self._current_download_boxes.keys()
|
|
):
|
|
self._pending_downloads.add(song_id)
|
|
elif progress.type in (
|
|
DownloadProgress.Type.DONE,
|
|
DownloadProgress.Type.CANCELLED,
|
|
):
|
|
# Remove and delete the box for the download if it exists.
|
|
if song_id in self._current_download_boxes:
|
|
self.current_downloads_box.remove(self._current_download_boxes[song_id])
|
|
del self._current_download_boxes[song_id]
|
|
|
|
# The download is no longer pending.
|
|
if song_id in self._pending_downloads:
|
|
self._pending_downloads.remove(song_id)
|
|
if song_id in self._failed_downloads:
|
|
self._failed_downloads.remove(song_id)
|
|
elif progress.type == DownloadProgress.Type.ERROR:
|
|
self._failed_downloads.add(song_id)
|
|
self.current_downloads_box.remove(self._current_download_boxes[song_id])
|
|
del self._current_download_boxes[song_id]
|
|
elif progress.type == DownloadProgress.Type.PROGRESS:
|
|
if song_id not in self._current_download_boxes:
|
|
# Create and add the box to show the progress.
|
|
self._current_download_boxes[song_id] = DownloadStatusBox(song_id)
|
|
self._current_download_boxes[song_id].connect(
|
|
"cancel-clicked", self._on_download_box_cancel_click
|
|
)
|
|
self.current_downloads_box.add(self._current_download_boxes[song_id])
|
|
|
|
if song_id in self._pending_downloads:
|
|
self._pending_downloads.remove(song_id)
|
|
if song_id in self._failed_downloads:
|
|
self._failed_downloads.remove(song_id)
|
|
|
|
self._current_download_boxes[song_id].update_progress(
|
|
progress.progress_fraction
|
|
)
|
|
|
|
# Show or hide the "failed count" indicator.
|
|
failed_download_count = len(self._failed_downloads)
|
|
if failed_download_count > 0:
|
|
if not self._failed_downloads_box:
|
|
self._failed_downloads_box = Gtk.Box(
|
|
orientation=Gtk.Orientation.HORIZONTAL
|
|
)
|
|
|
|
self._failed_downloads_label = Gtk.Label(
|
|
label="",
|
|
halign=Gtk.Align.START,
|
|
name="current-downloads-list-failed-count",
|
|
)
|
|
self._failed_downloads_box.add(self._failed_downloads_label)
|
|
|
|
retry_all_button = IconButton(
|
|
"view-refresh-symbolic", tooltip_text="Retry all failed downloads."
|
|
)
|
|
retry_all_button.connect("clicked", self._on_retry_all_clicked)
|
|
self._failed_downloads_box.pack_end(retry_all_button, False, False, 0)
|
|
|
|
self.current_downloads_box.pack_start(
|
|
self._failed_downloads_box, False, False, 5
|
|
)
|
|
songs = util.pluralize("song", failed_download_count)
|
|
self._failed_downloads_label.set_text(
|
|
f"{failed_download_count} {songs} failed to download"
|
|
)
|
|
else:
|
|
if self._failed_downloads_box:
|
|
self.current_downloads_box.remove(self._failed_downloads_box)
|
|
self._failed_downloads_box = None
|
|
|
|
# Show or hide the "pending count" indicator.
|
|
pending_download_count = len(self._pending_downloads)
|
|
if pending_download_count > 0:
|
|
if not self._pending_downloads_label:
|
|
self._pending_downloads_label = Gtk.Label(
|
|
label="",
|
|
halign=Gtk.Align.START,
|
|
name="current-downloads-list-pending-count",
|
|
)
|
|
self.current_downloads_box.pack_end(
|
|
self._pending_downloads_label, False, False, 5
|
|
)
|
|
songs = util.pluralize("song", pending_download_count)
|
|
self._pending_downloads_label.set_text(
|
|
f"+{pending_download_count} pending {songs}"
|
|
)
|
|
else:
|
|
if self._pending_downloads_label:
|
|
self.current_downloads_box.remove(self._pending_downloads_label)
|
|
self._pending_downloads_label = None
|
|
|
|
# Show or hide the placeholder depending on whether or not there's anything to
|
|
# show.
|
|
current_downloads = (
|
|
len(self._current_download_boxes)
|
|
+ pending_download_count
|
|
+ failed_download_count
|
|
)
|
|
if current_downloads == 0:
|
|
if not self._current_downloads_placeholder:
|
|
self._current_downloads_placeholder = Gtk.Label(
|
|
label="<i>No current downloads</i>",
|
|
use_markup=True,
|
|
name="current-downloads-list-placeholder",
|
|
)
|
|
self.current_downloads_box.add(self._current_downloads_placeholder)
|
|
else:
|
|
if self._current_downloads_placeholder:
|
|
self.current_downloads_box.remove(self._current_downloads_placeholder)
|
|
self._current_downloads_placeholder = None
|
|
|
|
self.current_downloads_box.show_all()
|
|
|
|
self.cancel_all_button.set_sensitive(current_downloads > 0)
|
|
|
|
def _on_cancel_all_clicked(self, _):
|
|
AdapterManager.cancel_download_songs(
|
|
{*self._pending_downloads, *self._current_download_boxes.keys()}
|
|
)
|
|
self.emit("refresh-window", {}, False)
|
|
|
|
def _on_download_box_cancel_click(self, _, song_id: str):
|
|
AdapterManager.cancel_download_songs([song_id])
|
|
|
|
def _on_retry_all_clicked(self, _):
|
|
AdapterManager.batch_download_songs(
|
|
self._failed_downloads,
|
|
lambda _: None,
|
|
lambda _: None,
|
|
)
|
|
|
|
def _create_stack(self, **kwargs: Gtk.Widget) -> Gtk.Stack:
|
|
stack = Gtk.Stack(homogeneous=True, transition_type=Gtk.StackTransitionType.NONE)
|
|
for name, child in kwargs.items():
|
|
stack.add_titled(child, name.lower(), name)
|
|
return stack
|
|
|
|
def _create_headerbar(self, stack: Gtk.Stack) -> Gtk.HeaderBar:
|
|
"""
|
|
Configure the header bar for the window.
|
|
"""
|
|
squeezer = Handy.Squeezer()
|
|
|
|
# Desktop header
|
|
desktop_header = Handy.HeaderBar()
|
|
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()
|
|
|
|
# Stack switcher
|
|
switcher = Gtk.StackSwitcher(stack=stack)
|
|
desktop_header.set_custom_title(switcher)
|
|
|
|
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
|
|
# Downloads
|
|
self.downloads_popover = self._create_downloads_popover()
|
|
self.downloads_menu_button = IconMenuButton(
|
|
"folder-download-symbolic",
|
|
tooltip_text="Show download status",
|
|
popover=self.downloads_popover,
|
|
)
|
|
self.downloads_menu_button.connect("clicked", self._on_downloads_menu_clicked)
|
|
self.downloads_popover.set_relative_to(self.downloads_menu_button)
|
|
button_box.add(self.downloads_menu_button)
|
|
|
|
# Preferences
|
|
preferences_button = IconButton(
|
|
"emblem-system-symbolic",
|
|
tooltip_text="Open Sublime Music settings",
|
|
relief=True,
|
|
)
|
|
preferences_button.connect("clicked", self._show_settings)
|
|
button_box.add(preferences_button)
|
|
|
|
# Server icon and change server dropdown
|
|
self.server_connection_popover = self._create_server_connection_popover()
|
|
self.server_connection_menu_button = IconMenuButton(
|
|
"list-add-symbolic",
|
|
tooltip_text="Server connection settings",
|
|
popover=self.server_connection_popover,
|
|
)
|
|
self.server_connection_menu_button.connect(
|
|
"clicked", self._on_server_connection_menu_clicked
|
|
)
|
|
self.server_connection_popover.set_relative_to(
|
|
self.server_connection_menu_button
|
|
)
|
|
button_box.add(self.server_connection_menu_button)
|
|
|
|
desktop_header.pack_end(button_box)
|
|
|
|
squeezer.add(desktop_header)
|
|
|
|
# Mobile header
|
|
mobile_header = Handy.HeaderBar()
|
|
mobile_header.set_show_close_button(True)
|
|
|
|
button = IconToggleButton("open-menu-symbolic")
|
|
self.sidebar_flap.bind_property("reveal-flap", button, "active", GObject.BindingFlags.BIDIRECTIONAL)
|
|
mobile_header.pack_start(button)
|
|
|
|
squeezer.add(mobile_header)
|
|
|
|
def squeezer_changed(squeezer, _):
|
|
self.is_desktop = squeezer.get_visible_child() == desktop_header
|
|
|
|
self.sidebar_flap.set_swipe_to_open(not self.is_desktop)
|
|
|
|
# When transitioning, don't play the reveal animation
|
|
dur = self.sidebar_flap.get_reveal_duration()
|
|
self.sidebar_flap.set_reveal_duration(0)
|
|
|
|
self.sidebar_flap.set_reveal_flap(False)
|
|
|
|
self.sidebar_flap.set_reveal_duration(dur)
|
|
squeezer.connect("notify::visible-child", squeezer_changed)
|
|
|
|
return squeezer
|
|
|
|
def _create_sidebar(self):
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, width_request=150)
|
|
box.get_style_context().add_class("background")
|
|
|
|
stack = Gtk.StackSidebar(stack=self.stack, vexpand=True)
|
|
box.pack_start(stack, True, True, 0)
|
|
|
|
box.pack_end(Gtk.Separator(), False, False, 0)
|
|
|
|
servers = IconButton("list-add-symbolic", label="Servers")
|
|
box.pack_end(servers, False, False, 0)
|
|
|
|
settings = IconButton("emblem-system-symbolic", label="Settings")
|
|
settings.connect("clicked", self._show_settings)
|
|
box.pack_end(settings, False, False, 0)
|
|
|
|
downloads = IconButton("folder-download-symbolic", label="Downloads")
|
|
box.pack_end(downloads, False, False, 0)
|
|
|
|
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]:
|
|
def on_active_change(toggle: Gtk.Switch, _):
|
|
self._emit_settings_change({settings_name: toggle.get_active()})
|
|
|
|
box = Gtk.Box()
|
|
box.add(gtk_label := Gtk.Label(label=label))
|
|
gtk_label.get_style_context().add_class("menu-label")
|
|
switch = Gtk.Switch(active=True)
|
|
switch.connect("notify::active", on_active_change)
|
|
box.pack_end(switch, False, False, 0)
|
|
box.get_style_context().add_class("menu-button")
|
|
return box, switch
|
|
|
|
def _create_model_button(
|
|
self,
|
|
text: str,
|
|
clicked_fn: Callable = None,
|
|
action_name: str = None,
|
|
action_value: GLib.Variant = None,
|
|
**kwargs,
|
|
) -> Gtk.ModelButton:
|
|
model_button = Gtk.ModelButton(text=text, **kwargs)
|
|
model_button.get_style_context().add_class("menu-button")
|
|
if clicked_fn:
|
|
model_button.connect("clicked", clicked_fn)
|
|
if action_name:
|
|
model_button.set_action_name(f"app.{action_name}")
|
|
if action_value is not None:
|
|
model_button.set_action_target_value(action_value)
|
|
return model_button
|
|
|
|
def _create_switch_provider_button(
|
|
self, provider: ProviderConfiguration
|
|
) -> Gtk.Box:
|
|
box = Gtk.Box()
|
|
provider_name_button = self._create_model_button(
|
|
provider.name,
|
|
action_name="switch-music-provider",
|
|
action_value=GLib.Variant("s", provider.id),
|
|
)
|
|
provider_name_button.connect(
|
|
"clicked", lambda *a: self.server_connection_popover.popdown()
|
|
)
|
|
box.pack_start(provider_name_button, True, True, 0)
|
|
|
|
provider_delete_button = IconButton(
|
|
icon_name="user-trash-symbolic",
|
|
tooltip_text=f"Remove the {provider.name} music provider",
|
|
)
|
|
provider_delete_button.connect(
|
|
"clicked", lambda *a: self.server_connection_popover.popdown()
|
|
)
|
|
provider_delete_button.set_action_name("app.remove-music-provider")
|
|
provider_delete_button.set_action_target_value(GLib.Variant("s", provider.id))
|
|
box.pack_end(provider_delete_button, False, False, 0)
|
|
|
|
return box
|
|
|
|
def _create_downloads_popover(self) -> Gtk.PopoverMenu:
|
|
menu = Gtk.PopoverMenu()
|
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="downloads-menu")
|
|
|
|
current_downloads_header = Gtk.Box()
|
|
current_downloads_header.add(
|
|
current_downloads_label := Gtk.Label(
|
|
label="Current Downloads",
|
|
name="menu-header",
|
|
)
|
|
)
|
|
current_downloads_label.get_style_context().add_class("menu-label")
|
|
self.cancel_all_button = IconButton(
|
|
"process-stop-symbolic", "Cancel all downloads", sensitive=False
|
|
)
|
|
self.cancel_all_button.connect("clicked", self._on_cancel_all_clicked)
|
|
current_downloads_header.pack_end(self.cancel_all_button, False, False, 0)
|
|
vbox.add(current_downloads_header)
|
|
|
|
self.current_downloads_box = Gtk.Box(
|
|
orientation=Gtk.Orientation.VERTICAL, name="current-downloads-list"
|
|
)
|
|
self._current_downloads_placeholder = Gtk.Label(
|
|
label="<i>No current downloads</i>",
|
|
use_markup=True,
|
|
name="current-downloads-list-placeholder",
|
|
)
|
|
self.current_downloads_box.add(self._current_downloads_placeholder)
|
|
vbox.add(self.current_downloads_box)
|
|
|
|
vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
|
|
|
|
clear_cache = self._create_model_button("Clear Cache", menu_name="clear-cache")
|
|
vbox.add(clear_cache)
|
|
menu.add(vbox)
|
|
|
|
# Create the "Add song(s) to playlist" sub-menu.
|
|
clear_cache_options = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
|
# Back button
|
|
clear_cache_options.add(
|
|
Gtk.ModelButton(inverted=True, centered=True, menu_name="main")
|
|
)
|
|
|
|
# Clear Song File Cache
|
|
menu_items = [
|
|
("Delete Cached Song Files", self._clear_song_file_cache),
|
|
("Delete Cached Song Files and Metadata", self._clear_entire_cache),
|
|
]
|
|
for text, clicked_fn in menu_items:
|
|
clear_song_cache = self._create_model_button(text, clicked_fn=clicked_fn)
|
|
clear_cache_options.pack_start(clear_song_cache, False, True, 0)
|
|
|
|
menu.add(clear_cache_options)
|
|
menu.child_set_property(clear_cache_options, "submenu", "clear-cache")
|
|
|
|
return menu
|
|
|
|
def _create_server_connection_popover(self) -> Gtk.PopoverMenu:
|
|
menu = Gtk.PopoverMenu()
|
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
|
# Current Server
|
|
|
|
self.connected_to_label = self._create_label(
|
|
"<i>No Music Source Selected</i>",
|
|
name="connected-to-label",
|
|
halign=Gtk.Align.CENTER,
|
|
)
|
|
vbox.add(self.connected_to_label)
|
|
|
|
self.connected_status_box = Gtk.Box(
|
|
orientation=Gtk.Orientation.HORIZONTAL, name="connected-status-row"
|
|
)
|
|
self.connected_status_box.pack_start(Gtk.Box(), True, True, 0)
|
|
|
|
self.connection_status_icon = Gtk.Image.new_from_icon_name(
|
|
"server-online", Gtk.IconSize.BUTTON
|
|
)
|
|
self.connection_status_icon.set_name("online-status-icon")
|
|
self.connected_status_box.add(self.connection_status_icon)
|
|
|
|
self.connection_status_label = Gtk.Label(
|
|
label="Connected", name="connection-status-label"
|
|
)
|
|
self.connected_status_box.add(self.connection_status_label)
|
|
|
|
self.connected_status_box.pack_start(Gtk.Box(), True, True, 0)
|
|
vbox.add(self.connected_status_box)
|
|
|
|
# Offline Mode
|
|
offline_box, self.offline_mode_switch = self._create_toggle_menu_button(
|
|
"Offline Mode", "offline_mode"
|
|
)
|
|
vbox.add(offline_box)
|
|
|
|
edit_button = self._create_model_button(
|
|
"Edit Configuration...", action_name="edit-current-music-provider"
|
|
)
|
|
vbox.add(edit_button)
|
|
|
|
vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
|
|
|
|
music_provider_button = self._create_model_button(
|
|
"Switch Music Provider",
|
|
menu_name="switch-provider",
|
|
)
|
|
vbox.add(music_provider_button)
|
|
|
|
add_new_music_provider_button = self._create_model_button(
|
|
"Add New Music Provider...", action_name="add-new-music-provider"
|
|
)
|
|
vbox.add(add_new_music_provider_button)
|
|
menu.add(vbox)
|
|
|
|
# Create the "Switch Provider" sub-menu.
|
|
switch_provider_options = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
|
|
# Back button
|
|
switch_provider_options.add(
|
|
Gtk.ModelButton(inverted=True, centered=True, menu_name="main")
|
|
)
|
|
|
|
# Provider Options box
|
|
self.provider_options_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
switch_provider_options.add(self.provider_options_box)
|
|
|
|
menu.add(switch_provider_options)
|
|
menu.child_set_property(switch_provider_options, "submenu", "switch-provider")
|
|
return menu
|
|
|
|
def _create_settings_window(self) -> Gtk.PopoverMenu:
|
|
window = Handy.PreferencesWindow(can_swipe_back=True)
|
|
|
|
general = Handy.PreferencesPage(icon_name="emblem-system-symbolic", title="General")
|
|
general_group = Handy.PreferencesGroup(title="General")
|
|
|
|
def create_switch(setting, **kwargs):
|
|
row = Handy.ActionRow(**kwargs)
|
|
switch = Gtk.Switch(valign=Gtk.Align.CENTER)
|
|
prop = setting.replace("_", "-")
|
|
self.bind_property(f"setting-{prop}", switch, "active", GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE)
|
|
|
|
row.add(switch)
|
|
return row
|
|
|
|
def create_spin_button(low: int, high: int, step: int, setting: str, **kwargs):
|
|
row = Handy.ActionRow(**kwargs)
|
|
button = Gtk.SpinButton.new_with_range(low, high, step)
|
|
prop = setting.replace("_", "-")
|
|
self.bind_property(f"setting-{prop}", button, "value", GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE)
|
|
|
|
row.add(button)
|
|
return row
|
|
|
|
# Notifications
|
|
row = create_switch("song_play_notification", title="Enable Song Notifications")
|
|
general_group.add(row)
|
|
|
|
# Player settings
|
|
self.player_settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
# vbox.add(self.player_settings_box)
|
|
|
|
general.add(general_group)
|
|
window.add(general)
|
|
|
|
# DOWNLOAD SETTINGS
|
|
# ==============================================================================
|
|
download = Handy.PreferencesPage(icon_name="folder-download-symbolic", title="Downloads")
|
|
download_group = Handy.PreferencesGroup(title="Downloads", description="Settings for downloads")
|
|
|
|
# Allow Song Downloads
|
|
download_row = Handy.ExpanderRow(show_enable_switch=True, title="Allow Song Downloads", expanded=True)
|
|
self.bind_property("setting-allow-song-downloads", download_row, "enable-expansion", GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE)
|
|
|
|
# Download on Stream
|
|
row = create_switch("download_on_stream", title="When Streaming, Also Download Song")
|
|
download_row.add(row)
|
|
|
|
# Prefetch Songs
|
|
row = create_spin_button(0, 10, 1, "prefetch_amount", title="Number of Songs to Prefetch")
|
|
download_row.add(row)
|
|
|
|
# Max Concurrent Downloads
|
|
row = create_spin_button(0, 10, 1, "concurrent_download_limit", title="Maximum Concurrent Downloads")
|
|
download_row.add(row)
|
|
|
|
download_group.add(download_row)
|
|
download.add(download_group)
|
|
|
|
window.add(download)
|
|
|
|
return window
|
|
|
|
def _on_settings_change(self, setting, _, prop):
|
|
if self._updating_settings:
|
|
return
|
|
|
|
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 _prompt_confirm_clear_cache(
|
|
self, title: str, detail_text: str
|
|
) -> Gtk.ResponseType:
|
|
confirm_dialog = Gtk.MessageDialog(
|
|
transient_for=self.get_toplevel(),
|
|
message_type=Gtk.MessageType.WARNING,
|
|
buttons=(
|
|
Gtk.STOCK_CANCEL,
|
|
Gtk.ResponseType.CANCEL,
|
|
Gtk.STOCK_DELETE,
|
|
Gtk.ResponseType.YES,
|
|
),
|
|
text=title,
|
|
)
|
|
confirm_dialog.format_secondary_markup(detail_text)
|
|
result = confirm_dialog.run()
|
|
confirm_dialog.destroy()
|
|
return result
|
|
|
|
def _clear_song_file_cache(self, _):
|
|
title = "Confirm Delete Song Files"
|
|
detail_text = "Are you sure you want to delete all cached song files? Your song metadata will be preserved." # noqa: 512
|
|
if self._prompt_confirm_clear_cache(title, detail_text) == Gtk.ResponseType.YES:
|
|
AdapterManager.clear_song_cache()
|
|
self.emit("refresh-window", {}, True)
|
|
|
|
def _clear_entire_cache(self, _):
|
|
title = "Confirm Delete Song Files and Metadata"
|
|
detail_text = "Are you sure you want to delete all cached song files and corresponding metadata?" # noqa: 512
|
|
if self._prompt_confirm_clear_cache(title, detail_text) == Gtk.ResponseType.YES:
|
|
AdapterManager.clear_entire_cache()
|
|
self.emit("refresh-window", {}, True)
|
|
|
|
def _on_downloads_menu_clicked(self, *args):
|
|
self.downloads_popover.popup()
|
|
self.downloads_popover.show_all()
|
|
|
|
def _on_server_connection_menu_clicked(self, *args):
|
|
self.server_connection_popover.popup()
|
|
self.server_connection_popover.show_all()
|
|
|
|
def _show_settings(self, *args):
|
|
window = self._create_settings_window()
|
|
window.set_transient_for(self)
|
|
|
|
if not self.is_desktop and self.is_maximized():
|
|
window.maximize()
|
|
|
|
window.show_all()
|
|
|
|
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]):
|
|
if self._updating_settings:
|
|
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():
|
|
continue
|
|
|
|
_, win_x, win_y = Gdk.Window.get_origin(self.get_window())
|
|
widget_x, widget_y = widget.translate_coordinates(self, 0, 0)
|
|
allocation = widget.get_allocation()
|
|
|
|
bound_x = (win_x + widget_x, win_x + widget_x + allocation.width)
|
|
bound_y = (win_y + widget_y, win_y + widget_y + allocation.height)
|
|
|
|
# If the event is in this widget, return True immediately.
|
|
if (bound_x[0] <= event.x_root <= bound_x[1]) and (
|
|
bound_y[0] <= event.y_root <= bound_y[1]
|
|
):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
class DownloadStatusBox(Gtk.Box):
|
|
__gsignals__ = {
|
|
"cancel-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,)),
|
|
"retry-clicked": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str,)),
|
|
}
|
|
|
|
def __init__(self, song_id: str):
|
|
Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
|
|
|
|
self.song = AdapterManager.get_song_details(song_id).result()
|
|
|
|
image = SpinnerImage(
|
|
image_size=30, image_name="current-downloads-cover-art-image"
|
|
)
|
|
self.add(image)
|
|
|
|
artist = self.song.artist.name if self.song.artist else None
|
|
label_text = util.dot_join(f"<b>{self.song.title}</b>", artist)
|
|
self.song_label = Gtk.Label(
|
|
label=bleach.clean(label_text),
|
|
ellipsize=Pango.EllipsizeMode.END,
|
|
max_width_chars=30,
|
|
name="currently-downloading-song-title",
|
|
use_markup=True,
|
|
halign=Gtk.Align.START,
|
|
)
|
|
self.pack_start(self.song_label, True, True, 5)
|
|
|
|
self.download_progress = Gtk.ProgressBar(show_text=True)
|
|
self.add(self.download_progress)
|
|
|
|
self.cancel_button = IconButton(
|
|
"process-stop-symbolic", tooltip_text="Cancel download"
|
|
)
|
|
self.cancel_button.connect(
|
|
"clicked", lambda *a: self.emit("cancel-clicked", self.song.id)
|
|
)
|
|
self.add(self.cancel_button)
|
|
|
|
def image_callback(f: Result):
|
|
image.set_loading(False)
|
|
image.set_from_file(f.result())
|
|
|
|
artwork_future = AdapterManager.get_cover_art_uri(self.song.cover_art, "file")
|
|
artwork_future.add_done_callback(lambda f: GLib.idle_add(image_callback, f))
|
|
|
|
def update_progress(self, progress_fraction: float):
|
|
self.download_progress.set_fraction(progress_fraction)
|