from datetime import timedelta from functools import partial from typing import Any, Callable, Dict, Optional, Set, Tuple, List import bleach from gi.repository import Gdk, GLib, GObject, Gtk, Pango, Handy, Gio from ..adapters import ( AdapterManager, api_objects as API, DownloadProgress, Result, ) from ..config import AppConfiguration, ProviderConfiguration from ..players import PlayerManager from . import albums, artists, browse, search, player_controls, playlists, util from .common import IconButton, IconToggleButton, IconMenuButton, SpinnerImage from .actions import run_action from .providers import ProvidersWindow class MainWindow(Handy.ApplicationWindow): """Defines the main window for Sublime Music.""" is_desktop = False is_initialized = False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # self.set_default_size(1342, 756) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) # Create the stack self.albums_panel = albums.AlbumsPanel() self.artists_panel = artists.ArtistsPanel() self.browse_panel = browse.BrowsePanel() self.playlists_panel = playlists.PlaylistsPanel() self.search_panel = search.SearchPanel() self.stack = self._create_stack( Albums=self.albums_panel, Artists=self.artists_panel, # Browse=self.browse_panel, Playlists=self.playlists_panel, ) self.stack.add_named(self.search_panel, "Search") self.stack.set_transition_type(Gtk.StackTransitionType.NONE) self.sidebar_flap = Handy.Flap( 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) if self.stack.get_visible_child() == self.search_panel: self.search_panel.entry.grab_focus() run_action(self, 'app.change-tab', self.stack.get_visible_child_name()) self.stack.connect("notify::visible-child", stack_changed) 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._settings_window = SettingsWindow(self) self._downloads_window = DownloadsWindow(self) self._providers_window = ProvidersWindow(self) current_notification_hash = None current_other_providers: Tuple[ProviderConfiguration, ...] = () def update( self, app_config: AppConfiguration, player_manager: PlayerManager, force: bool = False, ): self.is_initialized = app_config.current_provider_id is not None 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) self.stack.set_visible_child_name(app_config.state.current_tab) if app_config.provider: 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) if self._transient_window: self._transient_window.update(app_config, player_manager) if app_config.provider: ui_info = app_config.provider.ground_truth_adapter_type.get_ui_info() icon_basename = ui_info.icon_basename else: icon_basename = "list-add" if app_config.provider and AdapterManager.ground_truth_adapter_is_networked: if app_config.offline_mode: icon_status = "offline" elif AdapterManager.get_ping_status(): icon_status = "connected" else: icon_status = "error" server_menu_button_icon = f"{icon_basename}-{icon_status}-symbolic" else: server_menu_button_icon = f"{icon_basename}-symbolic" self.sidebar_server_menu_button.set_icon(server_menu_button_icon) self.headerbar_server_menu_button.set_icon(server_menu_button_icon) 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): self._downloads_window.update_song_download_progress(song_id, progress) 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_button = IconButton( icon_name='system-search-symbolic', tooltip_text="Search Everything", relief=True) search_button.connect('clicked', lambda *_: self.stack.set_visible_child(self.search_panel)) desktop_header.pack_start(search_button) # Stack switcher switcher = Gtk.StackSwitcher(stack=stack) desktop_header.set_custom_title(switcher) button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) # Downloads self.downloads_menu_button = IconButton( "folder-download-symbolic", tooltip_text="Show download status", relief=True, ) self.downloads_menu_button.connect("clicked", lambda *_: self._show_transient_window(self._downloads_window)) 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", lambda *_: self._show_transient_window(self._settings_window)) button_box.add(preferences_button) # Server icon and change server dropdown self.headerbar_server_menu_button = IconButton( "list-add-symbolic", tooltip_text="Server connection settings", relief=True, ) self.headerbar_server_menu_button.connect( "clicked", lambda *_: self.show_providers_window() ) button_box.add(self.headerbar_server_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) search_button = IconButton( icon_name='system-search-symbolic', tooltip_text="Search Everything", relief=True) search_button.connect('clicked', lambda *_: self.stack.set_visible_child(self.search_panel)) mobile_header.pack_end(search_button) squeezer.add(mobile_header) def squeezer_changed(squeezer, _): 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) self.sidebar_server_menu_button = IconButton("list-add-symbolic", label="Servers") self.sidebar_server_menu_button.connect("clicked", lambda *_: self.show_providers_window()) box.pack_end(self.sidebar_server_menu_button, False, False, 0) settings = IconButton("emblem-system-symbolic", label="Settings") settings.connect("clicked", lambda *_: self._show_transient_window(self._settings_window)) box.pack_end(settings, False, False, 0) downloads = IconButton("folder-download-symbolic", label="Downloads") downloads.connect('clicked', lambda *_: self._show_transient_window(self._downloads_window)) box.pack_end(downloads, False, False, 0) return box 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 _on_settings_change(self, setting, _, prop): if self._updating_settings: return self._emit_settings_change({setting: self.get_property(prop.name)}) def show_providers_window(self): if self.is_initialized: self._providers_window.open_status_page() else: self._providers_window.open_create_page() self._show_transient_window(self._providers_window) _transient_window = None def _show_transient_window(self, window): assert self._transient_window is None self._transient_window = window window.set_transient_for(self) if not self.is_desktop and self.is_maximized(): window.maximize() window.show_all() def reset(*_): self._transient_window = None window.connect('hide', reset) run_action(self, 'app.refresh') # 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 _remove_all_from_widget(self, widget: Gtk.Widget): for c in widget.get_children(): widget.remove(c) 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 ComboEntry(GObject.GObject): def __init__(self, value): GObject.GObject.__init__(self) self.value = value class SettingsWindow(Handy.PreferencesWindow): def __init__(self, main_window): Handy.PreferencesWindow.__init__(self) self.main_window = main_window # Don't die when closed def hide_not_destroy(*_): self.hide() return True self.connect('delete-event', hide_not_destroy) general = Handy.PreferencesPage(icon_name="emblem-system-symbolic", title="General") general_group = Handy.PreferencesGroup(title="General") self._settings_updates = [] self._player_settings_updates = [] def set_args(setting): name = setting.replace('_', '-') action = f'settings.set-{name}' return (lambda app_config: getattr(app_config, setting), lambda value: run_action(self.main_window, action, value)) # Notifications row = self._create_switch(*set_args("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) self.add(general) # Downloads download = Handy.PreferencesPage(icon_name="folder-download-symbolic", title="Downloads") download_group = Handy.PreferencesGroup(title="Downloads", description="Settings for downloads") # Allow Song Downloads self.download_row = Handy.ExpanderRow(show_enable_switch=True, title="Allow Song Downloads", expanded=True) # Download on Stream row = self._create_switch(*set_args("download_on_stream"), title="When Streaming, Also Download Song") self.download_row.add(row) # Prefetch Songs row = self._create_spin_button(0, 10, 1, *set_args("prefetch_amount"), title="Number of Songs to Prefetch") self.download_row.add(row) # Max Concurrent Downloads row = self._create_spin_button(0, 10, 1, *set_args("concurrent_download_limit"), title="Maximum Concurrent Downloads") self.download_row.add(row) download_group.add(self.download_row) download.add(download_group) self.add(download) # Players players = Handy.PreferencesPage(icon_name="media-playback-start-symbolic", title="Players") self.players_group = Handy.PreferencesGroup(title="Players", description="Settings for music players") players.add(self.players_group) self.add(players) _cached_players = None _updating_settings = False def update(self, app_config: AppConfiguration, player_manager: PlayerManager): self._updating_settings = True for update in self._settings_updates: update(app_config) self.download_row.set_enable_expansion(app_config.allow_song_downloads) # Update players config_options = player_manager.get_configuration_options() new_players = list(config_options.keys()) if not self._cached_players or self._cached_players != new_players: # TODO: Diff instead of re-creating for c in self.players_group.get_children(): self.players_group.remove(c) # Keep the update functions for the player options in a separate # list, as these are dynamic settings_updates = self._settings_updates self._settings_updates = [] def set_args(player, option, type_name): return (lambda app_config: app_config.player_config[player][option], lambda value: run_action(self.main_window, f'players.set-{type_name}-option', player, option, value)) for player_name, options in player_manager.get_configuration_options().items(): player_row = Handy.ExpanderRow(title=player_name) for option_name, descriptor in options.items(): if type(descriptor) == tuple: row = self._create_option( descriptor, *set_args(player_name, option_name, 'str'), title=option_name) elif descriptor == bool: row = self._create_switch( *set_args(player_name, option_name, 'bool'), title=option_name) elif descriptor == int: row = self._create_spin_button( 0, 9999, 1, *set_args(player_name, option_name, 'int'), title=option_name) else: assert False player_row.add(row) self.players_group.add(player_row) self.players_group.show_all() self._player_settings_updates = self._settings_updates self._settings_updates = settings_updates self._cached_players = new_players for update in self._player_settings_updates: update(app_config) self._updating_settings = False def _connect_setting(self, widget, signal, get_value: Callable, set_value: Callable, get, set): def on_signal(*_): if self._updating_settings: return set_value(get(widget)) widget.connect(signal, on_signal) def update(app_config: AppConfiguration): set(widget, get_value(app_config)) self._settings_updates.append(update) def _create_switch(self, get_value: Callable, set_value: Callable, **kwargs): row = Handy.ActionRow(**kwargs) switch = Gtk.Switch(valign=Gtk.Align.CENTER) self._connect_setting(switch, 'notify::active', get_value, set_value, lambda s: s.get_active(), lambda s, v: s.set_active(v)) row.add(switch) return row def _create_spin_button(self, low: int, high: int, step: int, get_value: Callable, set_value: Callable, **kwargs): row = Handy.ActionRow(**kwargs) button = Gtk.SpinButton.new_with_range(low, high, step) self._connect_setting(button, 'notify::value', get_value, set_value, lambda b: b.get_value(), lambda b, v: b.set_value(v)) row.add(button) return row def _create_option(self, values: List[str], get_value: Callable, set_value: Callable, **kwargs): row = Handy.ComboRow(**kwargs) store = Gio.ListStore.new(ComboEntry) for option in values: store.append(ComboEntry(option)) row.bind_name_model(store, lambda r: r.value) row.set_selected_index(0) self._connect_setting(row, 'notify::selected-index', get_value, set_value, lambda c: values[c.get_selected_index()], lambda c, v: c.set_selected_index(values.index(v))) return row class DownloadsWindow(Handy.Window): _failed_store: Gtk.ListStore = None _current_store: Gtk.ListStore = None _pending_store: Gtk.ListStore = None _downloads: Dict[str, Tuple[Gtk.ListStore, Gtk.TreeIter]] = None def __init__(self, main_window): Handy.Window.__init__(self, modal=True, window_position=Gtk.WindowPosition.CENTER_ON_PARENT, destroy_with_parent=True, type_hint=Gdk.WindowTypeHint.DIALOG, default_width=640, default_height=576) self.main_window = main_window # Don't die when closed def hide_not_destroy(*_): self.hide() return True self.connect('delete-event', hide_not_destroy) self._failed_store = Gtk.ListStore(str) self._current_store = Gtk.ListStore( str, # song ID float, # progress ) self._pending_store = Gtk.ListStore(str) self._downloads = {} box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) header_bar = Handy.HeaderBar(show_close_button=True, title="Downloads") box.add(header_bar) clamp = Handy.Clamp(margin=12) inner_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 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", 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) self.retry_all_button = IconButton('view-refresh-symbolic', 'Retry All', sensitive=False) self.retry_all_button.connect("clicked", self._on_retry_all_clicked) current_downloads_header.pack_end(self.retry_all_button, False, False, 0) inner_box.add(current_downloads_header) self.stack = Gtk.Stack() self.scrolled_window = Gtk.ScrolledWindow() scrolled_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) scrolled_box.add(self._create_list(self._failed_store)) scrolled_box.add(self._create_list(self._current_store)) scrolled_box.add(self._create_list(self._pending_store)) self.scrolled_window.add(scrolled_box) self.stack.add(self.scrolled_window) self.placeholder = Gtk.Label( label="No current downloads", use_markup=True, name="current-downloads-list-placeholder", ) self.stack.add(self.placeholder) inner_box.pack_start(self.stack, True, True, 5) button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) button = Gtk.Button(label="Clear Song Cache") button.get_style_context().add_class('destructive-action') button.connect("clicked", self._clear_song_file_cache) button_box.pack_end(button, False, False, 5) button = Gtk.Button(label="Clear Full Cache") button.get_style_context().add_class('destructive-action') button.connect("clicked", self._clear_entire_cache) button_box.pack_end(button, False, False, 5) inner_box.add(button_box) clamp.add(inner_box) box.pack_start(clamp, True, True, 0) self.add(box) 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, text=title, ) confirm_dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) confirm_dialog.add_button(Gtk.STOCK_DELETE, Gtk.ResponseType.YES) confirm_dialog.format_secondary_markup(detail_text) result = confirm_dialog.run() confirm_dialog.destroy() return result def _on_cancel_all_clicked(self): AdapterManager.cancel_download_songs(list(self._downloads.keys())) def _on_retry_all_clicked(self): AdapterManager.batch_download_songs( list(self._downloads.keys()), lambda _: None, lambda _: None, ) 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() run_action(self.main_window, 'app.refresh') 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() run_action(self.main_window, 'app.refresh') def _create_list(self, store: Gtk.ListStore): view = Gtk.TreeView( model=store, headers_visible=False) renderer = Gtk.CellRendererText(markup=True, ellipsize=Pango.EllipsizeMode.END) column = Gtk.TreeViewColumn("", renderer) column.set_expand(True) def song_name(column: Any, cell: Gtk.CellRendererPixbuf, model: Gtk.ListStore, tree_iter: Gtk.TreeIter, flags: Any): song_id = model.get_value(tree_iter, 0) song = AdapterManager.get_song_details(song_id).result() artist = song.artist.name if song.artist else None label = f"{bleach.clean(artist)} - {bleach.clean(song.title)}" cell.set_property("markup", label) column.set_cell_data_func(renderer, song_name) view.append_column(column) if store == self._current_store: renderer = Gtk.CellRendererProgress() renderer.set_fixed_size(60, 35) column = Gtk.TreeViewColumn("", renderer) def progress(column: Any, cell: Gtk.CellRendererPixbuf, model: Gtk.ListStore, tree_iter: Gtk.TreeIter, flags: Any): value = model.get_value(tree_iter, 1) cell.set_property("value", value * 100) column.set_cell_data_func(renderer, progress) view.append_column(column) if store == self._failed_store: icon_name = 'view-refresh-symbolic' else: icon_name = 'window-close-symbolic' renderer = Gtk.CellRendererPixbuf(icon_name=icon_name) renderer.set_fixed_size(35, 35) button_column = Gtk.TreeViewColumn("", renderer) button_column.set_resizable(True) view.append_column(button_column) def on_button_press(tree: Any, event: Gdk.EventButton) -> bool: if event.button == 1: path, column, cell_x, cell_y = tree.get_path_at_pos(event.x, event.y) iter = store.get_iter(path) song_id = store[iter][0] if column == button_column: if store == self._failed_store: AdapterManager.batch_download_songs( [song_id], lambda _: None, lambda _: None) else: AdapterManager.cancel_download_songs([song_id]) return True return False view.connect("button-press-event", on_button_press) return view def update(self, app_config: AppConfiguration, player_manager: PlayerManager): pass def update_song_download_progress(self, song_id: str, progress: DownloadProgress): def remove(): if song_id in self._downloads: store, iter = self._downloads[song_id] store.remove(iter) del self._downloads[song_id] if progress.type == DownloadProgress.Type.QUEUED: remove() iter = self._pending_store.append() self._pending_store[iter] = (song_id,) self._downloads[song_id] = (self._pending_store, iter) elif progress.type in ( DownloadProgress.Type.DONE, DownloadProgress.Type.CANCELLED, ): remove() elif progress.type == DownloadProgress.Type.ERROR: remove() iter = self._failed_store.append() self._failed_store[iter] = (song_id,) self._downloads[song_id] = (self._failed_store, iter) elif progress.type == DownloadProgress.Type.PROGRESS: if song_id not in self._downloads or self._downloads[song_id][0] != self._current_store: remove() if song_id in self._downloads: iter = self._downloads[song_id][1] self._current_store[iter][1] = progress.progress_fraction else: iter = self._current_store.prepend((song_id, progress.progress_fraction)) self._downloads[song_id] = (self._current_store, iter) else: assert False self.cancel_all_button.set_sensitive(len(self._downloads) > 0) self.retry_all_button.set_sensitive(len(self._failed_store) > 0) if len(self._downloads) > 0: self.stack.set_visible_child(self.scrolled_window) else: self.stack.set_visible_child(self.placeholder)