Files
sublime-music/sublime_music/ui/main.py
Benjamin Schaaf 82a881ebfd WIP
2022-01-12 16:44:55 +11:00

930 lines
35 KiB
Python

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="<i>No current downloads</i>",
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)} - <b>{bleach.clean(song.title)}</b>"
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)