from functools import partial from typing import Any, Callable, Dict, Optional, Set, Tuple from gi.repository import Gdk, GLib, GObject, Gtk, Pango from sublime.adapters import ( AdapterManager, api_objects as API, DownloadProgress, Result, ) from sublime.config import AppConfiguration, ProviderConfiguration from sublime.players import PlayerManager from sublime.ui import albums, artists, browse, player_controls, playlists, util from sublime.ui.common import IconButton, 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 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.set_default_size(1342, 756) # 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.titlebar = self._create_headerbar(self.stack) self.set_titlebar(self.titlebar) flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 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) flowbox.pack_start(notification_container, True, True, 0) # Player Controls self.player_controls = player_controls.PlayerControls() self.player_controls.connect( "song-clicked", lambda _, *a: self.emit("song-clicked", *a) ) self.player_controls.connect( "songs-removed", lambda _, *a: self.emit("songs-removed", *a) ) self.player_controls.connect( "refresh-window", lambda _, *args: self.emit("refresh-window", *args), ) flowbox.pack_start(self.player_controls, False, True, 0) self.add(flowbox) 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"{app_config.provider.name}") 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("No Music Source Selected") 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() # Main Settings self.notification_switch.set_active(app_config.song_play_notification) # 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() # Download Settings allow_song_downloads = app_config.allow_song_downloads self.allow_song_downloads_switch.set_active(allow_song_downloads) self.download_on_stream_switch.set_active(app_config.download_on_stream) self.prefetch_songs_entry.set_value(app_config.prefetch_amount) self.max_concurrent_downloads_entry.set_value( app_config.concurrent_download_limit ) self.download_on_stream_switch.set_sensitive(allow_song_downloads) self.prefetch_songs_entry.set_sensitive(allow_song_downloads) self.max_concurrent_downloads_entry.set_sensitive(allow_song_downloads) self._updating_settings = False 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_controls.update(app_config, force=force) 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="No current downloads", 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() 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. """ header = Gtk.HeaderBar() header.set_show_close_button(True) 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) header.pack_start(self.search_entry) # Search popup self._create_search_popup() # Stack switcher switcher = Gtk.StackSwitcher(stack=stack) 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) # Menu button self.main_menu_popover = self._create_main_menu() main_menu_button = IconMenuButton( "emblem-system-symbolic", tooltip_text="Open Sublime Music settings", popover=self.main_menu_popover, ) main_menu_button.connect("clicked", self._on_main_menu_clicked) self.main_menu_popover.set_relative_to(main_menu_button) button_box.add(main_menu_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) header.pack_end(button_box) return header 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_spin_button_menu_item( self, label: str, low: int, high: int, step: int, settings_name: str ) -> Tuple[Gtk.Box, Gtk.Entry]: def on_change(entry: Gtk.SpinButton) -> bool: self._emit_settings_change({settings_name: int(entry.get_value())}) return False box = Gtk.Box() box.add(spin_button_label := Gtk.Label(label=label)) spin_button_label.get_style_context().add_class("menu-label") entry = Gtk.SpinButton.new_with_range(low, high, step) entry.connect("value-changed", on_change) box.pack_end(entry, False, False, 0) box.get_style_context().add_class("menu-button") return box, entry 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="No current downloads", 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( "No Music Source Selected", 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_main_menu(self) -> Gtk.PopoverMenu: main_menu = Gtk.PopoverMenu() vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, name="main-menu-box") # Notifications notifications_box, self.notification_switch = self._create_toggle_menu_button( "Enable Song Notifications", "song_play_notification" ) vbox.add(notifications_box) # PLAYER SETTINGS # ============================================================================== self.player_settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) vbox.add(self.player_settings_box) # DOWNLOAD SETTINGS # ============================================================================== vbox.add(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) vbox.add( self._create_label("Download Settings", name="menu-settings-separator") ) # Allow Song Downloads ( allow_song_downloads, self.allow_song_downloads_switch, ) = self._create_toggle_menu_button( "Allow Song Downloads", "allow_song_downloads" ) vbox.add(allow_song_downloads) # Download on Stream ( download_on_stream, self.download_on_stream_switch, ) = self._create_toggle_menu_button( "When Streaming, Also Download Song", "download_on_stream" ) vbox.add(download_on_stream) # Prefetch Songs ( prefetch_songs_box, self.prefetch_songs_entry, ) = self._create_spin_button_menu_item( "Number of Songs to Prefetch", 0, 10, 1, "prefetch_amount" ) vbox.add(prefetch_songs_box) # Max Concurrent Downloads ( max_concurrent_downloads, self.max_concurrent_downloads_entry, ) = self._create_spin_button_menu_item( "Maximum Concurrent Downloads", 0, 10, 1, "concurrent_download_limit" ) vbox.add(max_concurrent_downloads) main_menu.add(vbox) return main_menu 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 _on_main_menu_clicked(self, *args): self.main_menu_popover.popup() self.main_menu_popover.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"{util.esc(song.title)}", util.esc(song.artist.name if song.artist else None), ) assert song.album and song.album.id self.song_results.add( self._create_search_result_row( 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"{util.esc(album.name)}", util.esc(album.artist.name if album.artist else None), ) assert album.id self.album_results.add( self._create_search_result_row( 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: label_text = util.esc(artist.name) assert artist.id self.artist_results.add( self._create_search_result_row( label_text, "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: label_text = util.esc(playlist.name) self.playlist_results.add( self._create_search_result_row( label_text, "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 = util.esc(self.song.artist.name if self.song.artist else None) label_text = util.dot_join(f"{util.esc(self.song.title)}", artist) self.song_label = Gtk.Label( label=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)