Files
sublime-music/sublime_music/ui/providers.py
Benjamin Schaaf d35dd2c96d WIP
2022-01-09 00:33:09 +11:00

616 lines
20 KiB
Python

import uuid
import time
from pathlib import Path
from typing import Optional, Callable, Dict, List, Any
import bleach
from gi.repository import Gdk, Gtk, Handy, GLib, Pango
from ..adapters import (
AdapterManager,
api_objects as API,
DownloadProgress,
Result,
ConfigurationStore,
ConfigParamDescriptor,
)
from ..config import AppConfiguration, ProviderConfiguration
from ..players import PlayerManager
from .actions import run_action, variant_type_from_python
from .common import IconButton
class ProvidersWindow(Handy.Window):
is_initial = True
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(*_):
if self.is_initial:
run_action(self.main_window, 'app.quit')
return True
self.hide()
return True
self.connect('delete-event', hide_not_destroy)
self.stack = Gtk.Stack()
self.create_page = CreateProviderPage(self)
self.stack.add(self.create_page)
self.configure_page = ConfigureProviderPage(self)
self.stack.add(self.configure_page)
self.status_page = ProviderStatusPage(self)
self.stack.add(self.status_page)
self.add(self.stack)
def update(self, app_config: AppConfiguration, player_manager: PlayerManager):
self.is_initial = app_config.current_provider_id is None
self.status_page.update(app_config, player_manager)
def _set_transition(self, going_back: bool):
if not self.is_visible():
self.stack.set_transition_type(Gtk.StackTransitionType.NONE)
return
if going_back:
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT)
else:
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT)
def open_create_page(self, go_back: Optional[Callable] = None, going_back=False):
self._set_transition(going_back)
self.create_page.setup(go_back)
self.create_page.show()
self.stack.set_visible_child(self.create_page)
def open_configure_page(self, id, adapter, config, go_back: Callable):
self._set_transition(False)
self.configure_page.setup(id, adapter, config, go_back)
self.configure_page.show()
self.stack.set_visible_child(self.configure_page)
def open_status_page(self):
self._set_transition(True)
self.status_page.show()
self.stack.set_visible_child(self.status_page)
class CreateProviderPage(Gtk.Box):
def __init__(self, window):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self.window = window
header_bar = Handy.HeaderBar(title="Add Music Source")
self.cancel_button = Gtk.Button(label='Quit')
self.cancel_button.connect('clicked', self._on_cancel_clicked)
header_bar.pack_start(self.cancel_button)
self.add(header_bar)
scrolled_window = Gtk.ScrolledWindow()
scrolled_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
list_box = Gtk.ListBox(expand=True, selection_mode=Gtk.SelectionMode.NONE)
adapters = AdapterManager.available_adapters
adapters = sorted(
filter(lambda a: a.can_be_ground_truth, adapters),
key=lambda a: a.get_ui_info().name)
for adapter in adapters:
ui_info: UIInfo = adapter.get_ui_info()
row = Handy.ActionRow(
title=ui_info.name,
icon_name=ui_info.icon_name(),
subtitle=ui_info.description,
subtitle_lines=4,
use_underline=True,
activatable=True)
def activated(*_, adapter=adapter):
self.window.open_configure_page(
None,
adapter,
ConfigurationStore(),
lambda: self.window.open_create_page(self._go_back, going_back=True))
row.connect('activated', activated)
row.add(Gtk.Image(icon_name='go-next-symbolic'))
list_box.add(row)
scrolled_box.add(list_box)
scrolled_window.add(scrolled_box)
self.pack_start(scrolled_window, True, True, 0)
def setup(self, go_back: Optional[Callable]):
self._go_back = go_back
if go_back:
self.cancel_button.set_label("Cancel")
else:
self.cancel_button.set_label("Quit")
def _on_cancel_clicked(self, *_):
if self._go_back:
self._go_back()
else:
self.window.close()
class ConfigureProviderPage(Gtk.Box):
_id = None
_adapter = None
_go_back = None
_config_store = None
_config_widgets = {}
_config_updates = {}
_required_fields: Dict[str, Callable[[Any], Optional[str]]] = {}
_errors = {}
_validation_ratchet = 0
_had_all_required = False
def __init__(self, window):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self.window = window
self.header_bar = Handy.HeaderBar(show_close_button=False, title="Configuration")
back_button = Gtk.Button(label="Back")
back_button.connect('clicked', lambda *_: self._go_back())
self.header_bar.pack_start(back_button)
self.create_button = Gtk.Button(label="Create", sensitive=False)
self.create_button.get_style_context().add_class('suggested-action')
self.create_button.connect('clicked', self._on_create_clicked)
self.header_bar.pack_end(self.create_button)
self.add(self.header_bar)
self.status_revealer = Gtk.Revealer(reveal_child=False)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10, margin=10)
clamp = Handy.Clamp()
self.status_stack = Gtk.Stack()
self.status_spinner = Gtk.Spinner()
self.status_stack.add(self.status_spinner)
self.status_ok_image = Gtk.Image(icon_name="config-ok-symbolic")
self.status_stack.add(self.status_ok_image)
self.status_error_image = Gtk.Image(icon_name="config-error-symbolic")
self.status_stack.add(self.status_error_image)
box.pack_start(self.status_stack, False, True, 0)
self.status_label = Gtk.Label(ellipsize=Pango.EllipsizeMode.END, lines=4, justify=Gtk.Justification.LEFT, wrap=True)
box.pack_start(self.status_label, False, True, 0)
clamp.add(box)
self.status_revealer.add(clamp)
self.pack_start(self.status_revealer, False, True, 0)
scrolled_window = Gtk.ScrolledWindow()
clamp = Handy.Clamp(margin=12)
self.settings_list = Gtk.ListBox()
self.settings_list.get_style_context().add_class('content')
clamp.add(self.settings_list)
scrolled_window.add(clamp)
self.pack_start(scrolled_window, True, True, 0)
def setup(self, id_, adapter, config_store, go_back):
self._id = id_
self._adapter = adapter
self._go_back = go_back
# Reset
self._config_store = config_store
self._config_updates = {}
self._required_fields = {}
self._errors = {}
self._validation_ratchet = 0
self._had_all_required = False
self.create_button.set_sensitive(False)
if self._id is not None:
self.create_button.set_label('Save')
else:
self.create_button.set_label('Create')
for child in self.settings_list.get_children():
self.settings_list.remove(child)
name = adapter.get_ui_info().name
self.header_bar.set_title(f'{name} Configuration')
# Reset status revealer
self.status_revealer.set_reveal_child(False)
# First row is always the name
name_row = self._create_entry_row('name', ConfigParamDescriptor(str, 'Name'))
if 'name' in self._config_store:
self._config_updates['name'](self._config_store['name'])
self.settings_list.add(name_row)
# Collected advanced settings in an expander row
advanced_row = Handy.ExpanderRow(title="Advanced", expanded=False)
self._params, self._validate = adapter.get_configuration_form()
for name, config in self._params.items():
row = {
str: self._create_entry_row,
bool: self._create_switch_row,
int: self._create_spin_button_row,
'password': lambda *a: self._create_entry_row(*a, is_password=True),
'option': self._create_option_row,
Path: self._create_path_row,
}[config.type](name, config)
# Set the initial value from the config store
if name in self._config_store:
self._config_updates[name](self._config_store[name])
elif config.default is not None:
self._config_store[name] = config.default
if config.advanced:
advanced_row.add(row)
else:
self.settings_list.add(row)
self.settings_list.add(advanced_row)
self.show_all()
def _create_entry_row(self, name, config, is_password=False):
row = Handy.ActionRow(title=config.description, subtitle=config.helptext, subtitle_lines=4, can_focus=False, selectable=False)
entry = Gtk.Entry(
valign=Gtk.Align.CENTER,
visibility=not is_password,
input_purpose=Gtk.InputPurpose.PASSWORD if is_password else Gtk.InputPurpose.FREE_FORM)
if config.default is not None:
if is_password:
entry.set_text(config.default[1])
else:
entry.set_text(config.default)
if config.required:
if is_password:
self._required_fields[name] = lambda v: len(v[1]) > 0
else:
self._required_fields[name] = lambda v: len(v) > 0
entry.connect('notify::text', lambda *_: self._update_config(name, entry.get_text(), is_password))
def set_value(value):
if is_password:
if value[0] != 'plaintext':
return
value = value[1]
entry.set_text(value)
self._config_updates[name] = set_value
self._config_widgets[name] = entry
row.add(entry)
return row
def _create_switch_row(self, name, config):
row = Handy.ActionRow(title=config.description, subtitle=config.helptext, subtitle_lines=4, can_focus=False, selectable=False)
switch = Gtk.Switch(valign=Gtk.Align.CENTER)
assert config.default is not None
switch.set_active(config.default)
switch.connect('notify::active', lambda *_: self._update_config(name, switch.get_active()))
self._config_updates[name] = switch.set_active
self._config_widgets[name] = switch
row.add(switch)
return row
def _create_spin_button_row(self, name, config):
pass
def _create_option_row(self, name, config):
pass
def _create_path_row(self, name, config):
pass
def _verify_required(self):
errors = {}
for name, verify in self._required_fields.items():
if name not in self._config_store or not verify(self._config_store[name]):
errors[name] = 'Missing field'
return errors
def _update_config(self, name, value, is_secret=False):
if is_secret:
self._config_store.set_secret(name, value)
else:
self._config_store[name] = value
# Reset errors
for name in self._errors.keys():
widget = self._config_widgets[name]
widget.get_style_context().remove_class('error')
widget.set_tooltip_markup(None)
self._errors = {}
self.create_button.set_sensitive(False)
if not self._had_all_required and self._verify_required():
return
self.status_revealer.set_reveal_child(True)
self._update_status(True, '')
self._had_all_required = True
self._validation_ratchet += 1
ratchet = self._validation_ratchet
def on_verify_result(errors: Optional[Dict[str, str]]):
if errors is None:
return
errors.update(self._verify_required())
# Update controls after validation, as fields may have changed
for name, update in self._config_updates.items():
if name in self._config_store:
update(self._config_store[name])
self._errors = errors
self.create_button.set_sensitive(len(errors) == 0)
if '__ping__' in self._errors:
ping_error = self._errors.pop('__ping__')
else:
ping_error = None
for name, error in self._errors.items():
widget = self._config_widgets[name]
widget.get_style_context().add_class('error')
widget.set_tooltip_markup(error)
self._update_status(False, ping_error)
def validate():
time.sleep(0.75)
if self._validation_ratchet != ratchet:
return None
return self._validate(self._config_store)
result = Result(validate)
result.add_done_callback(
lambda f: GLib.idle_add(on_verify_result, f.result())
)
def _update_status(self, verifying: bool, error: Optional[str]):
if verifying:
self.status_spinner.start()
self.status_stack.set_visible_child(self.status_spinner)
self.status_label.set_markup('<b>Verifying Connection...</b>')
elif error:
self.status_stack.set_visible_child(self.status_error_image)
self.status_label.set_markup(bleach.clean(error))
else:
self.status_stack.set_visible_child(self.status_ok_image)
self.status_label.set_markup('<b>Connected Successfully</b>')
if not verifying:
self.status_spinner.stop()
def _on_create_clicked(self, *_):
id = self._id or str(uuid.uuid4())
name = self._config_store.pop('name')
config = {}
for key, item in self._config_store.items():
type = self._params[key].type
if type == 'password':
type = List[str]
elif type == 'option':
type = str
config[key] = GLib.Variant(variant_type_from_python(type), item)
adapter_name = self._adapter.get_ui_info().name
run_action(
self.window.main_window,
'providers.set-config',
id,
name,
adapter_name,
config)
GLib.idle_add(lambda *_: self.window.close())
class ProviderStatusPage(Gtk.Box):
_current_provider = None
def __init__(self, window):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self.window = window
header_bar = Handy.HeaderBar(show_close_button=True, title="Connection Status")
self.add(header_bar)
scrolled_window = Gtk.ScrolledWindow()
clamp = Handy.Clamp(margin=12)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self.title = Gtk.Label(wrap=True, justify=Gtk.Justification.LEFT)
self.title.get_style_context().add_class('title')
self.title.get_style_context().add_class('large-title')
box.add(self.title)
self.status_stack = Gtk.Stack()
self.status_offline = self._create_status_box('offline', 'Offline')
self.status_stack.add(self.status_offline)
self.status_connected = self._create_status_box('connected', 'Connected')
self.status_stack.add(self.status_connected)
self.status_error = self._create_status_box('error', 'Error Connecting to Server')
self.status_stack.add(self.status_error)
box.add(self.status_stack)
list_box = Gtk.ListBox()
list_box.get_style_context().add_class('content')
row = Handy.ActionRow(title='Offline Mode', can_focus=False, selectable=False)
self.offline_switch = Gtk.Switch(valign=Gtk.Align.CENTER)
row.add(self.offline_switch)
list_box.add(row)
row = Handy.ActionRow(
title='Edit Configuration...',
use_underline=True,
activatable=True)
def activated(*_):
config = self._current_provider.ground_truth_adapter_config.clone()
config['name'] = self._current_provider.name
self.window.open_configure_page(
self._current_provider.id,
self._current_provider.ground_truth_adapter_type,
config,
lambda: self.window.open_status_page())
row.connect('activated', activated)
row.add(Gtk.Image(icon_name='go-next-symbolic'))
list_box.add(row)
self.provider_list = Handy.ExpanderRow(title="Other Providers", expanded=False)
add_button = IconButton(icon_name='list-add-symbolic', valign=Gtk.Align.CENTER, relief=True)
def add_clicked(*_):
self.window.open_create_page(lambda: self.window.open_status_page())
add_button.connect('clicked', add_clicked)
self.provider_list.add_action(add_button)
list_box.add(self.provider_list)
box.pack_start(list_box, False, True, 10)
clamp.add(box)
scrolled_window.add(clamp)
self.pack_start(scrolled_window, True, True, 0)
def _create_status_box(self, icon: str, label: str):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10, halign=Gtk.Align.CENTER)
icon = Gtk.Image(icon_name=f"server-{icon}-symbolic")
box.add(icon)
label = Gtk.Label(label=label)
box.add(label)
return box
_other_providers_cache = None
_other_providers = []
def update(self, app_config: AppConfiguration, player_manager: PlayerManager):
assert AdapterManager.ground_truth_adapter_is_networked
self._current_provider = app_config.provider
self.title.set_label(app_config.provider.name)
if app_config.offline_mode:
self.status_stack.set_visible_child(self.status_offline)
elif AdapterManager.get_ping_status():
self.status_stack.set_visible_child(self.status_connected)
else:
self.status_stack.set_visible_child(self.status_error)
other_providers = [id for id in app_config.providers.keys() if id != app_config.current_provider_id]
if self._other_providers_cache is None or self._other_providers_cache != other_providers:
self._other_providers_cache = other_providers
for child in self._other_providers:
self.provider_list.remove(child)
self._other_providers = []
self.provider_list.set_enable_expansion(len(other_providers) > 0)
for id in other_providers:
provider = app_config.providers[id]
row = Handy.ActionRow(title=provider.name, can_focus=False, selectable=False)
button = Gtk.Button(label="Switch", valign=Gtk.Align.CENTER)
def on_clicked(*_, id=id):
run_action(self.window.main_window, 'providers.switch', id)
button.connect('clicked', on_clicked)
row.add(button)
button = IconButton(icon_name='user-trash-symbolic', valign=Gtk.Align.CENTER, relief=True)
button.get_style_context().add_class('destructive-action')
def on_clicked(*_, id=id):
run_action(self.window.main_window, 'providers.remove', id)
button.connect('clicked', on_clicked)
row.add(button)
self.provider_list.add(row)
self._other_providers.append(row)
self.provider_list.show_all()