616 lines
20 KiB
Python
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()
|