930 lines
35 KiB
Python
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)
|