Files
sublime-music/sublime_music/ui/main.py
Benjamin Schaaf 4ba2e09cf1 WIP
2021-12-20 22:09:08 +11:00

1332 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
class MainWindow(Gtk.ApplicationWindow):
"""Defines the main window for Sublime Music."""
__gsignals__ = {
"song-clicked": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(int, object, object),
),
"songs-removed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (object,)),
"refresh-window": (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, bool),
),
"notification-closed": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, ()),
"go-to": (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str, str)),
}
_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)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# self.set_default_size(1342, 756)
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.SLIDE_LEFT_RIGHT)
self.sidebar_flap = Handy.Flap(
orientation=Gtk.Orientation.HORIZONTAL,
fold_policy=Handy.FlapFoldPolicy.ALWAYS,
transition_type=Handy.FlapTransitionType.OVER,
modal=True)
self.stack.connect("notify::visible-child", lambda *_: self.sidebar_flap.set_reveal_flap(False))
self.titlebar = self._create_headerbar(self.stack)
self.set_titlebar(self.titlebar)
drawer = Handy.Flap(orientation=Gtk.Orientation.VERTICAL, fold_policy=Handy.FlapFoldPolicy.ALWAYS, flap_position=Gtk.PackType.END)
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_manager.connect(
"song-clicked", lambda _, *a: self.emit("song-clicked", *a)
)
self.player_manager.connect(
"songs-removed", lambda _, *a: self.emit("songs-removed", *a)
)
self.player_manager.connect(
"refresh-window",
lambda _, *args: self.emit("refresh-window", *args),
)
# 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
def toggle_drawer(handle, event):
if event.get_click_count() != (True, 1) or not drawer.get_swipe_to_open():
return
drawer.set_reveal_flap(not drawer.get_reveal_flap())
mobile_handle.connect("button-press-event", toggle_drawer)
squeezer.add(mobile_handle)
# 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_open = drawer.get_reveal_progress() > 0.5
drawer.connect("notify::reveal-progress", drawer_reveal_progress)
self.sidebar_flap.set_content(drawer)
self.sidebar_flap.set_flap(self._create_sidebar())
self.add(self.sidebar_flap)
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)
for name, child in kwargs.items():
child.connect(
"song-clicked",
lambda _, *args: self.emit("song-clicked", *args),
)
child.connect(
"refresh-window",
lambda _, *args: self.emit("refresh-window", *args),
)
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, _):
is_desktop = squeezer.get_visible_child() == desktop_header
self.sidebar_flap.set_swipe_to_open(not 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()
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)
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)